Files
trading/news-collector/main.py
T
kyu 8b200e6cc6 feat: 핫종목 렌즈 + 뉴스 파서 견고화 + 텔레그램 알림 2회 축소
- news-collector: EXAONE JSON 파서 견고화(max_tokens 800 + 관대 파싱 → 파싱실패율 9.3%→3.3%),
  평일 18:30 종목뉴스 스위프(중형주 신선도 사각지대 해소), /process/raw reprocess 플래그,
  파싱실패→Gemini 폴백 라우팅(일일캡 1→50 상수화)
- score-engine: /hot 모멘텀 렌즈(뉴스+거래량, 백테스트로 가격 제외) + /hot/backtest,
  텔레그램 정기 브리핑 2회로 축소(중복 send_briefing 자동스케줄 제거)
- dashboard-api: /api/hot 프록시 + 🔥 지금뜨는 탭
- telegram-bot: /hot 명령 + 한글(뜨는/핫/급등) 라우팅

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 01:22:23 +09:00

1258 lines
64 KiB
Python

"""
멀티소스 금융 뉴스 수집기 + AI 분석 파이프라인 (버핏 스타일 강화)
- RSS 28개 소스 (네이버 크롤링 제거, 주식 전문 사이트 중심)
- 매 5분마다 전체 소스 수집
- 2단계 중복제거: URL해시(Redis) + 제목정규화해시(Redis) + 벡터유사도(Qdrant)
- 수집 즉시 바른API → Ollama → Qdrant → Ollama(EXAONE) → PostgreSQL
"""
import asyncio, hashlib, json, os, re, random, time
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta
from typing import Optional, Literal
import asyncpg, httpx, redis.asyncio as aioredis, structlog
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from bs4 import BeautifulSoup
from fastapi import FastAPI, Query
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from sentiment_rules import keyword_rule, hallucination_match_ratio
structlog.configure(processors=[
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.add_log_level,
structlog.processors.JSONRenderer(),
])
logger = structlog.get_logger()
REDIS_HOST = os.getenv("REDIS_HOST", "redis")
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "")
PG_HOST = os.getenv("POSTGRES_HOST", "postgres")
PG_PORT = int(os.getenv("POSTGRES_PORT", "5432"))
PG_DB = os.getenv("POSTGRES_DB", "trading_ai")
PG_USER = os.getenv("POSTGRES_USER", "kyu")
PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123")
BAREUN_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434")
QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333")
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
GEMINI_DAILY_LIMIT = int(os.getenv("GEMINI_DAILY_LIMIT", "50")) # 파싱실패 폴백 포함 일일 한도(비용통제)
class _GeminiNewsClf(BaseModel):
sentiment: Literal["호재", "악재", "중립"]
intensity: int = Field(ge=1, le=5)
catalyst: Literal["실적", "수주", "배당", "리스크", "모멘텀", "기타"]
reason: str
confidence: float = Field(ge=0.0, le=1.0)
async def _gemini_news_classify(title: str, content: str, exaone: dict,
rule: dict) -> tuple[dict, int]:
"""Gemini 짧은 프롬프트로 뉴스 sentiment 재판정 (애매한 건만).
response_schema로 JSON 강제, max_output_tokens=1500.
returns: (parsed_dict_or_empty, cost_krw)
"""
if not GEMINI_API_KEY:
return {}, 0
# === 글로벌 일일 1회 한도 (score-engine과 공유) ===
try:
async with pg_pool.acquire() as conn:
cnt = await conn.fetchval(
"SELECT COUNT(*) FROM gemini_call_log WHERE called_at::date = CURRENT_DATE")
if cnt and int(cnt) >= GEMINI_DAILY_LIMIT:
logger.warning("gemini.daily_limit", source="news_collector", today_count=int(cnt))
return {}, 0
except Exception as e:
logger.warning("gemini.limit_check_err", error=str(e))
try:
from google import genai
from google.genai import types
gclient = genai.Client(api_key=GEMINI_API_KEY)
system = (
"한국 주식 뉴스 한 건의 호재/악재/중립 여부를 판단합니다. "
"본문에 명시된 사실만 근거로 하고, 본문에 없는 정보는 절대 지어내지 마세요. "
"키워드 카운트와 EXAONE 1차 판단이 충돌하거나 신뢰도가 낮은 경우 호출됩니다."
)
prompt = (
f"[제목]\n{title}\n\n"
f"[본문(요약)]\n{(content or '')[:600]}\n\n"
f"[EXAONE 1차] {exaone.get('sentiment')}/"
f"강도{exaone.get('intensity',0)}/conf{float(exaone.get('confidence',0) or 0):.2f}\n"
f"[키워드 카운트] 호재 {rule['pos']} / 악재 {rule['neg']}"
)
config = types.GenerateContentConfig(
system_instruction=system,
temperature=0.1,
response_mime_type="application/json",
response_schema=_GeminiNewsClf,
max_output_tokens=1500,
)
resp = await asyncio.to_thread(
gclient.models.generate_content,
model=GEMINI_MODEL,
contents=prompt,
config=config,
)
text = getattr(resp, "text", "") or ""
parsed = {}
try:
parsed = json.loads(text)
except Exception:
pass
usage = getattr(resp, "usage_metadata", None)
cost_krw = 0
if usage:
in_tok = getattr(usage, "prompt_token_count", 0) or 0
total_tok = getattr(usage, "total_token_count", 0) or 0
cand_tok = getattr(usage, "candidates_token_count", 0) or 0
# 출력 전체(candidates + thinking) = total - prompt
out_tok = max(total_tok - in_tok, cand_tok)
cost_usd = (in_tok / 1_000_000 * 1.25) + (out_tok / 1_000_000 * 10.0)
cost_krw = int(cost_usd * 1400)
# 일일 한도 카운터 기록
try:
async with pg_pool.acquire() as conn:
await conn.execute(
"INSERT INTO gemini_call_log (source, cost_krw) VALUES ($1, $2)",
"news_collector", cost_krw)
except Exception:
pass
return parsed, cost_krw
except Exception as e:
logger.warning("news.gemini_err", error=str(e))
return {}, 0
HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
pg_pool: Optional[asyncpg.Pool] = None
redis_cl: Optional[aioredis.Redis] = None
scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
class S:
collected = 0; processed = 0; duplicates = 0; errors = 0
noise = 0; off_topic = 0; stale = 0
last_run = ""; running = False
stats = S()
def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16]
def _loads_lenient(s: str):
"""EXAONE 출력에서 JSON 객체를 관대하게 파싱. 실패 시 None.
마크다운 펜스 제거 + {..} 추출 + 트레일링 콤마 보정."""
if not s:
return None
s = s.replace("```json", "").replace("```", "").strip()
if not s.startswith("{"):
sx, ex = s.find("{"), s.rfind("}")
if sx != -1 and ex > sx:
s = s[sx:ex + 1]
try:
return json.loads(s)
except Exception:
pass
s2 = re.sub(r",\s*([}\]])", r"\1", s) # 트레일링 콤마 제거
try:
return json.loads(s2)
except Exception:
return None
# ── 출처 신뢰도 (정적 기본값 — score-engine 사후검증이 news_source_credibility 자동 갱신) ──
# 0.0~1.0. 메이저 통신사·전문지일수록 높음. score-engine이 가격반응 기반으로 자동 보정.
SOURCE_CREDIBILITY = {
"DART공시": 1.00,
"연합뉴스": 0.95, "한국경제": 0.92, "매일경제": 0.90, "조선비즈": 0.90,
"이데일리": 0.88, "머니투데이": 0.85, "파이낸셜뉴스": 0.85,
"서울경제": 0.85, "SBS Biz": 0.85, "더벨": 0.82,
"비즈니스포스트": 0.80, "전자신문": 0.80, "인포스탁": 0.78,
"헤럴드경제": 0.78, "디지털타임스": 0.75, "뉴스1": 0.78,
"뉴시스": 0.75, "이투데이": 0.72, "아시아경제": 0.72,
"네이버금융": 0.75, "뉴스핌": 0.70, "글로벌이코노믹": 0.65,
"스탁데일리": 0.60, "세계일보": 0.65,
}
async def get_source_credibility(source: str) -> float:
"""static dict + DB 사후 학습 결과 융합. DB에 sample≥20이면 그 값으로 덮어씀."""
base = SOURCE_CREDIBILITY.get(source, 0.50)
if not pg_pool: return base
try:
row = await pg_pool.fetchrow(
"SELECT credibility, sample_size FROM news_source_credibility WHERE source=$1",
source)
if row and (row["sample_size"] or 0) >= 20:
return float(row["credibility"])
except Exception:
pass
return base
# ── 제목 강도 (제목이 본문 대비 얼마나 강한 신호인지) ──
_STRONG_TITLE_WORDS = (
"폭락","폭등","급락","급등","충격","역대급","쇼크","서프라이즈","어닝",
"사상최대","사상최저","신고가","신저가","상한가","하한가","적자전환","흑자전환",
"어닝쇼크","어닝서프라이즈","대규모","돌파","무너","급증","급감",
)
def measure_title_strength(title: str) -> float:
"""0.3~1.5. 제목 강조어 ↑ 가중, 추측성("?"·"")은 감점."""
if not title: return 0.5
s = 1.0
s += 0.10 * sum(1 for w in _STRONG_TITLE_WORDS if w in title)
if "!" in title: s += 0.05
if title.endswith("?") or "?" in title[-3:]: s -= 0.15
if "" in title or "..." in title: s -= 0.05
return max(0.3, min(1.5, s))
def normalize_title(title: str) -> str:
"""[속보][단독](종합) 등 제거 후 특수문자·공백 제거 → 유사 제목 중복 감지용"""
t = re.sub(r'[\[\(【〔][^\]\)】〕]{0,10}[\]\)】〕]', '', title)
t = re.sub(r'[^\w가-힣a-zA-Z0-9]', '', t)
return t.lower().strip()
def is_korean(text: str) -> bool:
if not text: return True
hangul = sum(1 for c in text if '' <= c <= '')
cjk = sum(1 for c in text if '' <= c <= '鿿' or '' <= c <= '䶿')
if hangul == 0 and cjk > 2: return False
return True
async def is_dup(title: str, url: str = "") -> bool:
if not redis_cl: return False
try:
# 1차: URL+제목 해시 (완전 동일 기사)
h_url = nhash(title, url)
if await redis_cl.set(f"news:u:{h_url}", "1", ex=172800, nx=True) is None:
return True
# 2차: 정규화된 제목 해시 (같은 뉴스 다른 소스)
h_norm = nhash(normalize_title(title))
if await redis_cl.set(f"news:t:{h_norm}", "1", ex=21600, nx=True) is None:
return True
return False
except: return False
# ── RSS 멀티소스 수집 (28개 소스, 네이버 크롤링 제거) ──────
RSS_SOURCES = [
# ── 주요 경제/금융 신문 ──
("한국경제", "https://www.hankyung.com/feed/all-news"),
("한국경제", "https://www.hankyung.com/feed/finance"),
("매일경제", "https://www.mk.co.kr/rss/30100041/"),
("매일경제", "https://www.mk.co.kr/rss/30200030/"),
("머니투데이", "https://news.mt.co.kr/mtview.php?type=rss&MTPub=E&section=E"),
("머니투데이", "https://news.mt.co.kr/mtview.php?type=rss&MTPub=S"),
("이데일리", "https://www.edaily.co.kr/rss/news/finance.xml"),
("이데일리", "https://www.edaily.co.kr/rss/news/markets.xml"),
("연합뉴스", "https://www.yna.co.kr/rss/economy.xml"),
("연합뉴스", "https://www.yna.co.kr/rss/market.xml"),
("조선비즈", "https://biz.chosun.com/rss/rss.htm?site=biz.chosun.com"),
("헤럴드경제", "https://biz.heraldcorp.com/rss/index.xml"),
("파이낸셜뉴스", "https://www.fnnews.com/rss/fn_economy.xml"),
("서울경제", "https://www.sedaily.com/RSS/Economy"),
("아시아경제", "https://www.asiae.co.kr/rss/economy.htm"),
("뉴스1", "https://www.news1.kr/rss/industry.xml"),
("비즈니스포스트", "https://www.businesspost.co.kr/BP?command=rss&rssFeedType=0000"),
("인포스탁", "https://www.infostock.co.kr/rss/rss_news.xml"),
# ── 추가 소스 ──
("뉴스핌", "https://www.newspim.com/rss/view?outtype=1"),
("뉴시스", "https://www.newsis.com/rss/finance.xml"),
("이투데이", "https://www.etoday.co.kr/news/newsfeed.php?CateId=0102"),
("글로벌이코노믹", "https://www.g-enews.com/rss/rss_economy.xml"),
("전자신문", "https://www.etnews.com/news/latest_news.xml"),
("디지털타임스", "https://www.dt.co.kr/rss/rss_economy.html"),
("더벨", "https://www.thebell.co.kr/free/content/xmlService.asp"),
("세계일보", "https://www.segye.com/RSS/economyRss.xml"),
("SBS Biz", "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=EC"),
]
def parse_rss_date(date_str: str) -> str:
if not date_str:
return datetime.now().isoformat()
for fmt in ("%a, %d %b %Y %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S +0900",
"%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(date_str.strip(), fmt).isoformat()
except: pass
return datetime.now().isoformat()
async def crawl_rss_sources(client) -> list:
news = []
for source_name, rss_url in RSS_SOURCES:
try:
r = await client.get(rss_url, timeout=15,
headers={"User-Agent": "Mozilla/5.0", "Accept": "application/rss+xml, application/xml"})
if r.status_code != 200:
continue
try:
root = ET.fromstring(r.content)
except ET.ParseError:
# XML 파싱 실패 시 BeautifulSoup 폴백
soup = BeautifulSoup(r.content, "lxml-xml")
items = soup.find_all("item")
for item in items[:20]:
title = item.find("title")
link = item.find("link")
pub = item.find("pubDate") or item.find("dc:date")
if title and title.get_text(strip=True):
t = title.get_text(strip=True)
if len(t) > 5 and is_korean(t):
news.append({
"title": t,
"url": link.get_text(strip=True) if link else "",
"source": source_name,
"content": "",
"published_at": parse_rss_date(pub.get_text(strip=True) if pub else ""),
})
continue
ns = {"dc": "http://purl.org/dc/elements/1.1/"}
items = root.findall(".//item")
for item in items[:20]:
title = item.findtext("title", "").strip()
link = item.findtext("link", "").strip()
pubdate = item.findtext("pubDate", "") or item.findtext("dc:date", "", ns)
desc = item.findtext("description", "")
if title and len(title) > 5 and is_korean(title):
news.append({
"title": title,
"url": link,
"source": source_name,
"content": re.sub(r"<[^>]+>", "", desc)[:300] if desc else "",
"published_at": parse_rss_date(pubdate),
})
await asyncio.sleep(0.2)
except Exception as e:
logger.debug("rss.fetch.err", source=source_name, error=str(e))
logger.info("rss.crawled", sources=len(RSS_SOURCES), items=len(news))
return news
# ── 비주식 뉴스 필터 ───────────────────────────────────────
# 명백한 비주식 카테고리 prefix → 수집 차단
NOISE_PREFIXES = (
# 도서/문화
"[책마을]", "[책꽂이]", "[책&생각]", "[책]", "[리뷰]", "[영화]",
"[공연]", "[전시]", "[음악]", "[연예]",
# 의견/사설
"[사설]", "[칼럼]", "[기고]", "[오피니언]", "[기자수첩]", "[데스크칼럼]",
"[Why]", "[취재일기]", "[현장에서]", "[광화문]", "[로터리]",
# 인사/사회
"[부고]", "[인사]", "[동정]", "[포토]", "[화보]", "[지면]", "[그래픽]",
# 라이프
"[건강]", "[푸드]", "[여행]", "[패션]", "[뷰티]", "[자동차]", "[취미]",
"[운세]", "[날씨]",
# 스포츠
"[스포츠]", "[야구]", "[축구]", "[농구]", "[골프]",
# 광고성/이벤트
"[보도자료]", "[알림]", "[이벤트]", "[알립니다]",
)
# 종목/시장과 무관한 라이프스타일·클릭베이트 키워드 (제목에 등장하면 차단)
NOISE_KEYWORDS = (
# 라이프/소비
"이직", "퇴사", "신의 직장", "마통", "돈복사", "전기료 폭탄",
"세탁기", "건조기", "에어컨", "다이어트", "맛집", "할인",
"결혼", "이혼", "연애", "임신", "출산", "육아",
# 사회/사건사고 (기업이 부수적으로 언급되는 경우 多)
"성폭행", "성추행", "음주운전", "마약", "도박", "사기",
"교통사고", "화재", "살인", "강도",
# 정치 (종목 영향 적은 일반 정치)
"선거", "투표", "여론조사", "지지율", "총선", "대선",
# 라이프스타일 클릭베이트
"충격", "발칵", "경악", "", "화들짝", "깜짝", "대체",
)
# 클릭베이트: 제목이 큰따옴표·작은따옴표로 시작하면 의심
CLICKBAIT_QUOTE_RE = re.compile(r'^[\s]*[\"“‘’\']')
# 종목 매칭 0건일 때 시장 관련성 판정용 키워드
MARKET_KEYWORDS = (
# 지수/시장
"코스피", "코스닥", "KOSPI", "KOSDAQ", "나스닥", "다우", "S&P", "증시",
"지수", "시장", "장세", "장중", "거래소",
# 거시지표
"환율", "금리", "원달러", "원화", "달러", "엔화", "위안", "유가",
"금값", "은값", "원유", "WTI", "브렌트", "채권", "국채", "회사채",
"인플레이션", "물가", "CPI", "PPI", "성장률", "GDP", "경기", "경제",
# 수급
"외국인", "외인", "기관", "개인투자자", "프로그램매매", "공매도",
"신용잔고", "거래대금", "거래량",
# 종목/거래
"주식", "주가", "종목", "매수", "매도", "매매", "투자", "테마주", "관련주",
"급등", "급락", "상승", "하락", "신고가", "신저가", "호재", "악재",
# 펀드/상품
"펀드", "ETF", "ETN", "선물", "옵션", "파생", "리츠",
# 기업이벤트
"상장", "상폐", "공모", "IPO", "유상증자", "무상증자", "배당", "자사주",
"M&A", "인수", "합병", "분할", "지분", "스톡옵션",
# 실적/재무
"실적", "영업이익", "매출", "순이익", "EBITDA", "ROE", "PER", "PBR",
"수주", "계약", "수출", "공시",
# 산업/섹터
"산업", "섹터", "반도체", "바이오", "2차전지", "배터리", "전기차",
"AI", "로봇", "방산", "조선", "건설", "유통", "금융", "은행", "보험",
"증권", "통신", "에너지", "철강", "화학", "정유"
)
def _is_noise_title(title: str) -> bool:
if not title:
return True
if any(title.startswith(p) for p in NOISE_PREFIXES):
return True
# 클릭베이트: 따옴표 시작 + 시장 키워드 부재 시 차단
if CLICKBAIT_QUOTE_RE.match(title) and not any(kw in title for kw in MARKET_KEYWORDS):
return True
# 라이프스타일/사건사고 키워드 (제목에서 검사)
if any(nk in title for nk in NOISE_KEYWORDS):
return True
return False
def _has_market_relevance(text: str) -> bool:
"""종목 매칭이 없을 때만 호출. 시장 키워드 ≥2개 매칭 시 통과 (broad 1개로는 부족)"""
if not text:
return False
matches = sum(1 for kw in MARKET_KEYWORDS if kw in text)
return matches >= 2
# ── 파이프라인 ─────────────────────────────────────────────
_corp_cache: dict = {}
async def _corp_name(code: str) -> str:
if code in _corp_cache:
return _corp_cache[code]
nm = code
try:
row = await pg_pool.fetchrow(
"SELECT corp_name FROM dart_corps WHERE stock_code=$1", code)
if row and row["corp_name"]:
nm = row["corp_name"]
except Exception:
pass
_corp_cache[code] = nm
return nm
async def pipeline(item, client, reprocess: bool = False):
try:
# 0. 비주식 카테고리 prefix 차단 (바른API 호출 전)
if _is_noise_title(item.get("title", "")):
return "noise"
# 0.5. published_at이 30일 이상 과거면 차단.
# 일부 RSS 사이트가 오래된 기사를 재노출해서 점수 왜곡 유발 → LLM 호출 전에 컷.
# 명시적 백필 엔드포인트(/collect/historical-raw)는 별도 경로라 영향 없음.
pub_str = item.get("published_at") or ""
if pub_str and not item.get("allow_stale"):
try:
pub_dt = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
# tz-aware 비교를 위해 둘 다 aware로 맞춤
now_dt = datetime.now(pub_dt.tzinfo) if pub_dt.tzinfo else datetime.now()
if (now_dt - pub_dt).days > 30:
return "stale"
except Exception:
pass # 파싱 실패는 정상 진행 (parse_rss_date가 fallback으로 NOW 채움)
# 1. 바른API
br = await client.post(f"{BAREUN_URL}/analyze", json={
"title": item["title"], "content": item.get("content",""),
"url": item.get("url",""), "source": item.get("source",""),
"published_at": item.get("published_at","")}, timeout=30)
bd = br.json()
if bd.get("is_duplicate"): return "dup"
data = {**item, "hash": bd.get("hash",""), "stocks": bd.get("stocks",[]),
"keywords": bd.get("keywords",[]), "filtered_text": bd.get("filtered_text","")}
# raw 백필: 종목코드가 확정된 뉴스는 종목명 인식 실패해도 보존 (관련성 과필터 완화)
kc = item.get("stock_code")
if kc and not any(st.get("code") == kc for st in data["stocks"]):
data["stocks"] = [{"name": await _corp_name(kc), "code": kc}] + data["stocks"]
# 1.5. 종목 매칭 + 시장 키워드 둘 다 없으면 스킵 (LLM 비용 절감)
if not data["stocks"]:
full_text = (item.get("title","") or "") + " " + (item.get("content","") or "")
if not _has_market_relevance(full_text):
return "off_topic"
# 2. Ollama 임베딩
er = await client.post(f"{OLLAMA_URL}/api/embeddings",
json={"model":"bge-m3","prompt": data["filtered_text"] or data["title"]}, timeout=60)
emb = er.json().get("embedding")
if not emb: return "no_embed"
# 3. Qdrant 유사도
try:
sr = await client.post(f"{QDRANT_URL}/collections/news_vectors/points/search",
json={"vector":emb,"limit":6,"score_threshold":0.80,"with_payload":True}, timeout=15)
hits = sr.json().get("result",[])
if not reprocess and any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
except: hits = []
# 3.1. 사건 클러스터링: 유사도 0.85~0.92 안에 있는 hit이 있으면 그 클러스터로 합류.
# 첫 뉴스만 풀 가중, 후속은 score-engine에서 감쇠 (is_event_seed=True 인 것만 큰 신호로).
event_cluster_id = ""
is_event_seed = True
try:
for h in hits:
if 0.85 <= h.get("score", 0) < 0.92:
pl = h.get("payload", {}) or {}
cid = pl.get("event_cluster_id") or pl.get("hash") or ""
if cid:
event_cluster_id = cid
is_event_seed = False
try:
await pg_pool.execute("""
UPDATE news_event_cluster
SET last_seen_at=NOW(), member_count=member_count+1
WHERE cluster_id=$1
""", cid)
except Exception: pass
break
if not event_cluster_id:
event_cluster_id = hashlib.sha256(
normalize_title(data["title"]).encode()).hexdigest()[:32]
except Exception:
event_cluster_id = ""
is_event_seed = True
# 3.5. RAG 컨텍스트: 유사 과거뉴스 + 종목 재무·추세·점수 (버핏 판단 근거 주입)
ctx = []
rel = [h for h in hits if 0.80 <= h.get("score", 0) < 0.92][:4]
if rel:
ctx.append("· 유사 과거뉴스:")
for h in rel:
p = h.get("payload", {})
ctx.append(f" - {(p.get('title') or '')[:55]}"
f"{p.get('sentiment','?')}/강도{p.get('intensity','?')}")
focus = data["stocks"][0] if data["stocks"] else None
if focus:
g = lambda v: "-" if v is None else v
try:
fin = await pg_pool.fetchrow("""
SELECT ROUND(roe::numeric,1) roe, ROUND(operating_margin::numeric,1) om,
ROUND(debt_ratio::numeric,1) dr, ROUND(fcf_ratio::numeric,2) fcf,
ROUND(revenue_growth::numeric,1) rg
FROM dart_financials WHERE stock_code=$1 AND reprt_code='11011'
ORDER BY bsns_year DESC LIMIT 1""", focus["code"])
if fin:
ctx.append(f"· {focus['name']} 재무: ROE {g(fin['roe'])}% "
f"영업이익률 {g(fin['om'])}% 부채비율 {g(fin['dr'])}% "
f"FCF {g(fin['fcf'])} 매출성장 {g(fin['rg'])}%")
his = await pg_pool.fetchrow("""
SELECT COUNT(*) FILTER (WHERE sentiment='호재') pos,
COUNT(*) FILTER (WHERE sentiment='악재') neg,
ROUND(AVG(intensity)::numeric,1) ai,
MODE() WITHIN GROUP (ORDER BY catalyst) cat
FROM news_analysis
WHERE primary_stock=$1 AND analyzed_at >= NOW()-INTERVAL '14 days'
""", focus["code"])
if his and (his["pos"] or his["neg"]):
ctx.append(f"· 최근14일 {focus['name']}: 호재 {his['pos']} / "
f"악재 {his['neg']}, 평균강도 {g(his['ai'])}, "
f"주catalyst {his['cat'] or '-'}")
recent = await pg_pool.fetch("""
SELECT sentiment, intensity, catalyst, left(reason,50) reason
FROM news_analysis
WHERE primary_stock=$1 AND reason != '파싱실패'
AND analyzed_at >= NOW()-INTERVAL '30 days'
ORDER BY analyzed_at DESC LIMIT 2""", focus["code"])
if recent:
ctx.append(f"· {focus['name']} 최근 분석:")
for r in recent:
ctx.append(f" - {r['sentiment']}/강도{r['intensity']} "
f"[{r['catalyst'] or '-'}] {r['reason']}")
sc = await pg_pool.fetchrow("""
SELECT ROUND(total_score::numeric,1) total_score, recommendation
FROM stock_scores
WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1""",
focus["code"])
if sc:
ctx.append(f"· 현재 퀀트: 종합점수 {g(sc['total_score'])} "
f"({sc['recommendation'] or '-'})")
except Exception as e:
logger.debug("rag.ctx_err", code=focus["code"], error=str(e))
context_block = "\n".join(ctx)
# 3.9. 키워드 룰 1차 판정 — 명확한 호재/악재면 LLM 스킵 (비용 0)
rule_text = (data.get("title", "") or "") + " " + (data.get("filtered_text", "") or "")
rule = keyword_rule(rule_text)
if rule["sentiment"] is not None:
# 키워드 룰로 sentiment 확정 — LLM 스킵 (비용 0)
primary = (data["stocks"][0]["code"] if data["stocks"] else "")
affected = [s["code"] for s in data["stocks"][1:5]]
investment = ("매수관심" if rule["sentiment"] == "호재" and rule["intensity"] >= 3
else "매도관심" if rule["sentiment"] == "악재" and rule["intensity"] >= 3
else "관망")
hits = rule["pos_hits"] if rule["sentiment"] == "호재" else rule["neg_hits"]
stock_impacts = {primary: 1.0} if primary else {}
for c in affected:
stock_impacts[c] = 0.5
a = {
"sentiment": rule["sentiment"], "intensity": rule["intensity"],
"primary_stock": primary, "affected_stocks": affected,
"reason": f"키워드 룰 매칭: {'/'.join(hits)}"[:500],
"investment_action": investment,
"catalyst": rule["catalyst"],
"time_horizon": "단기", "impact_scope": "종목",
"confidence": 1.0, "stock_impacts": stock_impacts,
"sector_hint": "",
}
llm_conf = 1.0
logger.info("news.path", path="rule", sentiment=rule["sentiment"],
pos=rule["pos"], neg=rule["neg"], hits=hits[:3])
else:
# 4. Ollama EXAONE 분석 (버핏 관점 + 라벨 세분화) — 키워드 룰 애매 케이스
stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]])
source = item.get("source", "")
content_preview = (data.get("filtered_text") or item.get("content") or "")[:400]
system_prompt = (
"당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n"
"뉴스 한 건을 읽고 기업 본질가치 관점에서 영향을 평가합니다.\n"
"판단 우선순위: 기업 본질가치(실적·FCF·경쟁우위) > 재무 리스크 > 단기 수급·테마\n\n"
"[sentiment] 호재 / 악재 / 중립 — 본질가치 또는 주가에 미치는 방향\n\n"
"[intensity] 1~5 정수\n"
" 5=상장폐지·대규모 횡령 등 기업 존폐\n"
" 4=연간실적 20% 이상 영향(대형 수주·어닝쇼크 등)\n"
" 3=분기실적에 유의미한 영향\n"
" 2=영업환경·업황 변화(직접 실적 수치 영향은 제한적)\n"
" 1=단순 정보성·정기공시·소폭 코멘트\n\n"
"[catalyst] 반드시 아래 6개 중 정확히 하나만 출력 (다른 단어·복합어·영문·한자 절대 금지):\n"
' "실적" = 영업이익·매출·순이익 증감, 흑자/적자 전환, 어닝 서프라이즈/쇼크, 실적 가이던스\n'
' "수주" = 신규 수주·공급계약·납품, M&A, 신사업·증설·신제품 등 성장 동인\n'
' "배당" = 배당 확대/축소, 자사주 매입·소각, 주주환원 정책\n'
' "리스크" = 횡령·배임·소송·감사의견거절·부채급증·유상증자(희석)·경영진 돌발사임·규제/제재·상폐위험\n'
' "모멘텀" = 본질가치 변화 없는 가격 동인(외국인/기관 수급, 목표주가 조정, 테마·정책 기대)\n'
' "기타" = 위 어디에도 명확히 속하지 않는 정보성 뉴스\n'
" 호재/악재면 catalyst를 '기타'로 두지 말고 실적·수주·배당·리스크·모멘텀 중 가장 가까운 것을 고르세요.\n\n"
"[time_horizon] 영향이 발현되는 시간프레임\n"
' "즉시" = 당일~3일 내 주가 반응 (수주공시·실적 발표·대형 사건)\n'
' "단기" = 1~4주 (분기실적·수급 변화·일시적 모멘텀)\n'
' "중기" = 1~6개월 (업황·정책 변화·신제품 출시 효과)\n'
' "장기" = 6개월 이상 (구조적 변화·신사업 본격화·체질개선)\n\n'
"[impact_scope] 영향 받는 대상 범위\n"
' "종목" = 특정 종목(들)에만 영향\n'
' "섹터" = 동일 업종/테마 전체 영향 (예: 반도체 업황·2차전지 정책)\n'
' "시장" = 코스피/코스닥 전체 또는 매크로 (금리·환율·지정학)\n\n'
"[stock_impacts] {종목코드: 영향가중치 0~1} 매핑. primary_stock=1.0, 보조 종목은 0.3~0.8.\n"
" affected_stocks와 일치하는 모든 종목에 가중치를 매김. 단일 종목이면 {primary:1.0}.\n\n"
"[sector_hint] 관련 업종 한 단어 (예: 반도체, 2차전지, 바이오, 조선, 방산, 자동차, 금융,\n"
" 통신, 화학, 철강, 건설, 유통, AI, 로봇, 게임, 미디어, 에너지, 정유, 기타).\n\n"
"[confidence] 0.0~1.0 — 본인 판단의 신뢰도. 사실/숫자가 명확하면 0.8+, 추측·소문이면 0.3-0.5,\n"
" 맥락 부족·해석 모호하면 0.5 이하. 정직하게 평가하세요.\n\n"
"[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\n"
"동일 내용이 [참고]의 유사 과거뉴스에 반복되면 신규성이 낮으니 intensity를 보수적으로.\n\n"
"반드시 스키마에 맞는 유효한 JSON 객체 하나만 출력. 마크다운·설명문 금지."
)
user_prompt = (
f"[출처] {source}\n"
f"[제목] {data['title'][:200]}\n"
f"[내용] {content_preview}\n"
f"[관련종목] {stocks_str or '없음'}\n"
+ (f"\n[참고]\n{context_block}\n" if context_block else "")
+ "\ninvestment_action 규칙: 호재+intensity≥3→매수관심, 악재+intensity≥3→매도관심, 그 외→관망\n"
"primary_stock은 6자리 숫자코드(시장 전체 뉴스면 빈 문자열), "
"affected_stocks는 영향받는 다른 종목코드 배열.\n\n"
"JSON 스키마:\n"
'{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",'
'"affected_stocks":["000660"],"stock_impacts":{"005930":1.0,"000660":0.5},'
'"reason":"핵심 근거 한 문장",'
'"investment_action":"매수관심|매도관심|관망",'
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타",'
'"time_horizon":"즉시|단기|중기|장기","impact_scope":"종목|섹터|시장",'
'"sector_hint":"반도체|2차전지|바이오|조선|방산|자동차|금융|통신|화학|철강|건설|유통|AI|로봇|게임|미디어|에너지|정유|기타",'
'"confidence":0.85}\n'
"예시: "
'{"sentiment":"호재","intensity":4,"primary_stock":"005930",'
'"affected_stocks":["000660"],"stock_impacts":{"005930":1.0,"000660":0.6},'
'"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
'"investment_action":"매수관심","catalyst":"수주",'
'"time_horizon":"중기","impact_scope":"섹터",'
'"sector_hint":"반도체","confidence":0.85}'
)
# EXAONE 호출 + JSON 파싱 (1차 정상, 2차 파싱실패/저신뢰 재시도)
a = None
raw_prev = ""
for attempt in range(2):
msgs = [{"role":"system","content": system_prompt},
{"role":"user","content": user_prompt}]
if attempt == 1:
hint = ("위 응답이 유효한 JSON이 아니거나 신뢰도가 낮습니다. "
"설명·마크다운 없이 스키마에 맞는 JSON 객체 하나만 출력하고, "
"확실하지 않으면 sentiment='중립', intensity=1로 보수적으로 표기하세요.")
msgs += [{"role":"assistant","content": raw_prev[:600]},
{"role":"user","content": hint}]
try:
vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
"model":"exaone3.5:7.8b","messages": msgs,
"max_tokens":800,"temperature":0.0 if attempt else 0.05},
timeout=120)
raw_prev = vr.json()["choices"][0]["message"]["content"]
cand = _loads_lenient(raw_prev)
if cand is None:
continue
conf = float(cand.get("confidence", 0.5) or 0.5)
if attempt == 0 and conf < 0.40 and cand.get("sentiment") in ("호재","악재") \
and int(cand.get("intensity", 0) or 0) >= 3:
a = cand
continue
a = cand
break
except Exception:
continue
if a is None:
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],
"reason":"파싱실패","investment_action":"관망","confidence":0.0}
# enum 강제 / confidence clamp / stock_impacts 정합
if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"):
a["catalyst"] = "기타"
if a.get("time_horizon") not in ("즉시","단기","중기","장기"):
a["time_horizon"] = "단기"
if a.get("impact_scope") not in ("종목","섹터","시장"):
a["impact_scope"] = "종목"
try:
llm_conf = max(0.0, min(1.0, float(a.get("confidence", 0.5) or 0.5)))
except Exception:
llm_conf = 0.5
if a.get("reason") == "파싱실패":
llm_conf = 0.0
si = a.get("stock_impacts")
if not isinstance(si, dict): si = {}
ps = a.get("primary_stock") or ""
if ps and ps not in si: si[ps] = 1.0
for c in (a.get("affected_stocks") or []):
if c and c not in si: si[c] = 0.5
si = {k: max(0.0, min(1.0, float(v or 0)))
for k, v in si.items() if k and isinstance(k, str) and len(k) <= 10}
a["stock_impacts"] = si
# === 환각 / 키워드 충돌 검증 → 필요 시 Gemini fallback ===
# 임계값을 보수적으로 (Gemini 과호출 방지 — Pro thinking 비용 큼)
halluc = hallucination_match_ratio(a.get("reason", ""), rule_text)
conflict = ((rule["pos"] >= 2 and a.get("sentiment") == "악재") or
(rule["neg"] >= 2 and a.get("sentiment") == "호재"))
sent_strong = (a.get("sentiment") in ("호재", "악재")
and int(a.get("intensity") or 0) >= 4) # 4→매우 강한 시그널만
parse_failed = (a.get("reason") == "파싱실패")
need_gem = (parse_failed # EXAONE 파싱실패 → Gemini 재판정(중립 방치 대신)
or (sent_strong and llm_conf < 0.4) # 0.6→0.4 (정말 저신뢰만)
or halluc < 0.25 # 0.5→0.25 (명백한 환각만)
or conflict)
if need_gem and GEMINI_API_KEY:
gem, gem_cost = await _gemini_news_classify(
data.get("title", ""), content_preview,
{"sentiment": a.get("sentiment"),
"intensity": a.get("intensity"),
"confidence": llm_conf}, rule)
if gem:
a["sentiment"] = gem.get("sentiment", a["sentiment"])
try:
a["intensity"] = int(gem.get("intensity", a["intensity"]) or a["intensity"])
except Exception:
pass
if gem.get("catalyst") in ("실적","수주","배당","리스크","모멘텀","기타"):
a["catalyst"] = gem["catalyst"]
a["reason"] = f"[Gemini검증] {gem.get('reason','')}"[:500]
try:
llm_conf = float(gem.get("confidence", llm_conf) or llm_conf)
except Exception:
pass
a["investment_action"] = (
"매수관심" if a["sentiment"] == "호재" and (a.get("intensity") or 0) >= 3
else "매도관심" if a["sentiment"] == "악재" and (a.get("intensity") or 0) >= 3
else "관망"
)
logger.info("news.path", path="gemini", final=a["sentiment"],
halluc=round(halluc, 2), conflict=conflict,
cost_krw=gem_cost)
else:
logger.info("news.path", path="exaone_lowconf",
halluc=round(halluc, 2), conflict=conflict,
conf=round(llm_conf, 2))
else:
logger.info("news.path", path="exaone",
sentiment=a.get("sentiment"),
conf=round(llm_conf, 2), halluc=round(halluc, 2))
# 파싱실패 복구분: EXAONE/Gemini는 종목을 안 주므로(스키마 없음), 바른 감지 종목으로
# primary_stock·stock_impacts 채움 — 안 그러면 score-engine 뉴스점수에 안 잡힘.
if parse_failed and not a.get("primary_stock") and not a.get("stock_impacts"):
_codes = [st["code"] for st in data.get("stocks", []) if st.get("code")]
if _codes:
_w = 1.0 if len(_codes) == 1 else 0.6
a["primary_stock"] = _codes[0]
a["stock_impacts"] = {c: _w for c in _codes[:5]}
# 출처 신뢰도 + 제목 강도 (분석 후 DB INSERT 직전에 계산)
src_cred = await get_source_credibility(item.get("source", ""))
title_str = measure_title_strength(data["title"])
# 5. Qdrant 저장 (event_cluster_id 포함 — 다음 뉴스의 클러스터 매칭에 사용)
try:
await client.put(f"{QDRANT_URL}/collections/news_vectors/points", json={
"points":[{"id":random.randint(1,999999999),"vector":emb,
"payload":{"title":data["title"],"hash":data["hash"],
"sentiment":a.get("sentiment",""),"intensity":a.get("intensity",0),
"primary_stock":a.get("primary_stock",""),
"event_cluster_id": event_cluster_id,
"catalyst": a.get("catalyst", "기타"),
"time_horizon": a.get("time_horizon","단기")}}]}, timeout=15)
except: pass
# 6. PostgreSQL 저장
pub = data.get("published_at") or ""
try:
pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00")) if pub else datetime.now()
except Exception:
pub_dt = datetime.now()
try:
iv = int(a.get("intensity", 0) or 0)
except Exception:
iv = 0
s = lambda v: str(v if v is not None else "")
await pg_pool.execute("""
INSERT INTO news_analysis (title,url,source,published_at,hash,sentiment,intensity,
primary_stock,affected_stocks,reason,investment_action,keywords,stock_names,stock_codes,
catalyst,similar_count,analyzed_at,
time_horizon,impact_scope,llm_confidence,source_credibility,title_strength,
stock_impacts,event_cluster_id,is_event_seed,sector_hint)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW(),
$16,$17,$18,$19,$20,$21::jsonb,$22,$23,$24)
ON CONFLICT (hash) DO NOTHING
""",
s(data.get("title"))[:500], s(data.get("url"))[:500], s(data.get("source"))[:100],
pub_dt, data["hash"], s(a.get("sentiment", "중립"))[:10], iv,
s(a.get("primary_stock"))[:10], json.dumps(a.get("affected_stocks", []), ensure_ascii=False),
s(a.get("reason"))[:500], s(a.get("investment_action", "관망"))[:10],
json.dumps(data.get("keywords", [])[:20], ensure_ascii=False),
json.dumps([s_["name"] for s_ in data.get("stocks", [])], ensure_ascii=False),
json.dumps([s_["code"] for s_ in data.get("stocks", [])], ensure_ascii=False),
s(a.get("catalyst", "기타"))[:20],
s(a.get("time_horizon","단기"))[:10],
s(a.get("impact_scope","종목"))[:10],
llm_conf, src_cred, title_str,
json.dumps(a.get("stock_impacts", {}), ensure_ascii=False),
event_cluster_id[:32], is_event_seed,
s(a.get("sector_hint",""))[:40])
# 이벤트 클러스터 시드 등록 (첫 뉴스만)
if is_event_seed and event_cluster_id:
try:
await pg_pool.execute("""
INSERT INTO news_event_cluster (cluster_id, member_count,
seed_sentiment, seed_intensity, seed_catalyst)
VALUES ($1, 1, $2, $3, $4)
ON CONFLICT (cluster_id) DO NOTHING
""", event_cluster_id[:32], s(a.get("sentiment","중립"))[:10], iv,
s(a.get("catalyst","기타"))[:20])
except Exception: pass
return "ok"
except Exception as e:
logger.warning("pipeline.err", title=item.get("title","")[:50], error=str(e))
return "error"
# ── 수집 작업 ──────────────────────────────────────────────
async def job_rss():
"""RSS 28개 소스 수집 (5분마다, 2단계 중복제거)"""
if stats.running: return
stats.running = True
try:
async with httpx.AsyncClient() as c:
news = await crawl_rss_sources(c)
ok = 0
for item in news:
if await is_dup(item["title"], item.get("url", "")):
stats.duplicates += 1
continue
stats.collected += 1
r = await pipeline(item, c)
if r == "ok":
ok += 1
stats.processed += 1
elif r == "noise":
stats.noise += 1
elif r == "stale":
stats.stale += 1
elif r == "off_topic":
stats.off_topic += 1
stats.last_run = datetime.now().isoformat()
logger.info("job.rss", sources=len(RSS_SOURCES), total=len(news), processed=ok,
duplicates=stats.duplicates)
except Exception as e:
stats.errors += 1
logger.error("job.rss.err", error=str(e))
finally:
stats.running = False
NAVER_FINANCE_URLS = [
("https://finance.naver.com/news/mainnews.naver", "네이버금융"),
("https://finance.naver.com/news/news_list.nhn?mode=RANK&type=now", "네이버금융"),
]
async def crawl_naver_market(client) -> list:
news = []
for url, source in NAVER_FINANCE_URLS:
try:
r = await client.get(url, timeout=15, headers=HEADERS)
if r.status_code != 200:
continue
soup = BeautifulSoup(r.text, "html.parser")
anchors = (
soup.select("ul.newsList li a") or
soup.select(".headline_list li a") or
soup.select("a[href*='news_read']")
)
for a in anchors[:30]:
title = a.get_text(strip=True)
href = a.get("href", "")
if not href or not title or len(title) < 5:
continue
if href.startswith("/"):
href = f"https://finance.naver.com{href}"
if not is_korean(title):
continue
news.append({"title": title, "url": href, "source": source,
"content": "", "published_at": datetime.now().isoformat()})
except Exception as e:
logger.debug("naver.market.err", url=url, error=str(e))
logger.info("naver.market.crawled", items=len(news))
return news
async def crawl_naver_stock_news(client, code: str, max_pages: int = 20, sleep_s: float = 0.3) -> list:
"""네이버 모바일 종목 뉴스 API (JSON) — 백워드 페이징"""
items = []
for page in range(1, max_pages + 1):
url = f"https://m.stock.naver.com/api/news/stock/{code}?pageSize=20&page={page}"
try:
r = await client.get(url, headers=HEADERS, timeout=15)
if r.status_code != 200:
break
data = r.json()
if not isinstance(data, list) or not data:
break
page_items = data[0].get("items", []) if isinstance(data[0], dict) else []
if not page_items:
break
for it in page_items:
title = (it.get("title") or "").strip()
article_id = it.get("articleId") or ""
office_id = it.get("officeId") or ""
if not title or not article_id or not is_korean(title):
continue
url_news = f"https://n.news.naver.com/article/{office_id}/{article_id}"
dt = it.get("datetime") or ""
pub = (f"{dt[:4]}-{dt[4:6]}-{dt[6:8]} {dt[8:10]}:{dt[10:12]}"
if len(dt) >= 12 else "")
items.append({
"title": title, "url": url_news,
"source": it.get("officeName") or "네이버",
"published_at": pub,
"content": (it.get("body") or "")[:500],
})
await asyncio.sleep(sleep_s)
except Exception as e:
logger.debug("naver_mobile.err", code=code, page=page, error=str(e))
break
return items
async def save_raw_news(items: list, stock_code: str) -> int:
"""raw 뉴스 일괄 저장 (분석 스킵, 빠른 누적용)"""
if not items: return 0
saved = 0
async with pg_pool.acquire() as conn:
for it in items:
try:
url = it.get("url", "")
title = it.get("title", "")
if not url or not title: continue
url_hash = hashlib.sha256(url.encode()).hexdigest()
res = await conn.execute("""
INSERT INTO news_raw (stock_code, title, url, url_hash, source, published_at_text)
VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (url_hash) DO NOTHING
""", stock_code, title, url, url_hash,
it.get("source", ""), it.get("published_at", ""))
if "INSERT 0 1" in res:
saved += 1
except Exception as e:
logger.debug("raw.save_err", error=str(e))
return saved
async def job_historical_raw(count: int = 0, max_pages: int = 100):
"""전체 활성종목 백워드 크롤링 → news_raw에 raw 저장 (LLM 분석 X, 빠름)
count=0 → 활성 종목 전체"""
if stats.running:
logger.warning("historical_raw.skip_running"); return
stats.running = True
try:
async with pg_pool.acquire() as conn:
if count > 0:
rows = await conn.fetch("""
SELECT stock_code FROM dart_corps WHERE is_active=true
ORDER BY stock_code LIMIT $1
""", count)
else:
rows = await conn.fetch(
"SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code")
codes = [r["stock_code"] for r in rows]
logger.info("historical_raw.start", n_stocks=len(codes), max_pages=max_pages)
total_saved = 0
async with httpx.AsyncClient() as c:
for i, code in enumerate(codes):
try:
items = await crawl_naver_stock_news(c, code, max_pages=max_pages)
saved = await save_raw_news(items, code)
total_saved += saved
if (i + 1) % 100 == 0:
logger.info("historical_raw.progress",
done=i+1, of=len(codes), total_saved=total_saved)
await asyncio.sleep(0.4)
except Exception as e:
stats.errors += 1
logger.warning("historical_raw.stock_err", code=code, error=str(e))
stats.last_run = datetime.now().isoformat()
logger.info("historical_raw.done", n_stocks=len(codes), total_saved=total_saved)
finally:
stats.running = False
async def job_process_raw(batch_size: int = 200, reprocess: bool = False):
"""news_raw의 미처리 행을 batch_size만큼 EXAONE 분석 → news_analysis로 이동.
OLLAMA_NUM_PARALLEL=4와 매칭되는 4-동시 병렬 처리."""
async with pg_pool.acquire() as conn:
rows = await conn.fetch("""
SELECT id, stock_code, title, url, source, published_at_text
FROM news_raw WHERE processed=false
ORDER BY id DESC LIMIT $1
""", batch_size)
if not rows:
logger.info("process_raw.empty"); return 0
ok = 0
sem = asyncio.Semaphore(4)
async with httpx.AsyncClient() as c:
async def _one(r):
nonlocal ok
async with sem:
item = {
"title": r["title"], "url": r["url"],
"source": r["source"] or "네이버금융",
"published_at": r["published_at_text"] or "",
"content": "",
"stock_code": r["stock_code"],
}
try:
if not reprocess and await is_dup(item["title"], item["url"]):
stats.duplicates += 1
else:
stats.collected += 1
res = await pipeline(item, c, reprocess=reprocess)
if res == "ok": ok += 1; stats.processed += 1
elif res == "noise": stats.noise += 1
elif res == "off_topic": stats.off_topic += 1
async with pg_pool.acquire() as conn:
await conn.execute(
"UPDATE news_raw SET processed=true, processed_at=NOW() WHERE id=$1",
r["id"])
except Exception as e:
stats.errors += 1
logger.warning("process_raw.err", id=r["id"], error=str(e))
await asyncio.gather(*(_one(r) for r in rows))
logger.info("process_raw.batch_done", batch=len(rows), processed_ok=ok)
return ok
async def job_historical(count: int = 500, max_pages: int = 20):
"""과거 뉴스 백필: stock_scores 점수 매겨진 활성 종목 우선 + 종목당 max_pages 페이지"""
if stats.running:
logger.warning("historical.skip_running")
return
stats.running = True
try:
async with pg_pool.acquire() as conn:
# 우선순위: 최근 점수 매겨진 종목 → 그 외 활성 종목 alphabetical
rows = await conn.fetch("""
WITH scored AS (
SELECT DISTINCT stock_code, MAX(score_date) AS last_d
FROM stock_scores GROUP BY stock_code
), priority AS (
SELECT d.stock_code,
CASE WHEN s.stock_code IS NOT NULL THEN 0 ELSE 1 END AS p,
COALESCE(s.last_d, '1900-01-01'::date) AS d
FROM dart_corps d LEFT JOIN scored s ON s.stock_code=d.stock_code
WHERE d.is_active=true
)
SELECT stock_code FROM priority
ORDER BY p ASC, d DESC, stock_code ASC LIMIT $1
""", count)
codes = [r["stock_code"] for r in rows]
logger.info("historical.start", n_stocks=len(codes), max_pages=max_pages)
ok = 0; total_items = 0
async with httpx.AsyncClient() as c:
for i, code in enumerate(codes):
try:
items = await crawl_naver_stock_news(c, code, max_pages=max_pages)
total_items += len(items)
for item in items:
if await is_dup(item["title"], item.get("url", "")):
stats.duplicates += 1
continue
stats.collected += 1
item["stock_code"] = code
r = await pipeline(item, c)
if r == "ok":
ok += 1; stats.processed += 1
elif r == "noise": stats.noise += 1
elif r == "stale": stats.stale += 1
elif r == "off_topic": stats.off_topic += 1
if (i + 1) % 50 == 0:
logger.info("historical.progress", done=i+1, of=len(codes),
items=total_items, processed=ok)
await asyncio.sleep(0.5) # 종목 간격 (차단 회피)
except Exception as e:
stats.errors += 1
logger.warning("historical.stock_err", code=code, error=str(e))
stats.last_run = datetime.now().isoformat()
logger.info("historical.done", n_stocks=len(codes), items=total_items, processed=ok)
finally:
stats.running = False
async def job_market():
if stats.running:
return
stats.running = True
try:
async with httpx.AsyncClient() as c:
news = await crawl_naver_market(c)
ok = 0
for item in news:
if await is_dup(item["title"], item.get("url", "")):
stats.duplicates += 1
continue
stats.collected += 1
r = await pipeline(item, c)
if r == "ok":
ok += 1
stats.processed += 1
elif r == "noise":
stats.noise += 1
elif r == "stale":
stats.stale += 1
elif r == "off_topic":
stats.off_topic += 1
stats.last_run = datetime.now().isoformat()
logger.info("job.market", total=len(news), processed=ok, duplicates=stats.duplicates)
except Exception as e:
stats.errors += 1
logger.error("job.market.err", error=str(e))
finally:
stats.running = False
# ── FastAPI ────────────────────────────────────────────────
app = FastAPI(title="뉴스 수집기 (멀티소스)")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
async def init_news_tables():
"""news_raw 테이블 (수집/분석 분리용 임시 저장소)"""
async with pg_pool.acquire() as conn:
await conn.execute("""
CREATE TABLE IF NOT EXISTS news_raw (
id SERIAL PRIMARY KEY,
stock_code VARCHAR(10),
title TEXT NOT NULL,
url TEXT NOT NULL,
url_hash VARCHAR(64) UNIQUE,
source VARCHAR(100),
published_at_text VARCHAR(50),
collected_at TIMESTAMP DEFAULT NOW(),
processed BOOLEAN DEFAULT FALSE,
processed_at TIMESTAMP
)
""")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_news_raw_unprocessed ON news_raw(processed) WHERE processed=false")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_news_raw_stock ON news_raw(stock_code)")
@app.on_event("startup")
async def startup():
global pg_pool, redis_cl
pg_pool = await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5)
redis_cl = aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=4,decode_responses=True)
await init_news_tables()
# 평일 RSS: 8-18시 2분마다 (자동매매 latency 단축)
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/2",id="rss_weekday",replace_existing=True)
# 주말 RSS: 8-22시 15분마다 (누적 학습용)
scheduler.add_job(job_rss,"cron",day_of_week="sat,sun",hour="8-22",minute="*/15",id="rss_weekend",replace_existing=True)
# 평일 마켓 (네이버 금융): 9-17시 10분마다
scheduler.add_job(job_market,"cron",day_of_week="mon-fri",hour="9-17",minute="*/10",id="market",replace_existing=True)
# raw 뉴스 분석: 30분마다 batch 200건 처리 (병렬화 4-동시로 30분 내 완주)
scheduler.add_job(job_process_raw,"cron",minute="*/30",
id="process_raw",replace_existing=True,
max_instances=1, kwargs={"batch_size":200})
# 매주 일요일 새벽 2시 raw 백필 (전체 활성종목)
scheduler.add_job(job_historical_raw,"cron",day_of_week="sun",hour="2",minute="0",
id="historical_raw_weekly",replace_existing=True,
kwargs={"count":0,"max_pages":50})
# 평일 18:30 전 종목 "최신 1페이지" 스위프 — RSS/마켓메인에 안 잡히는 중형주
# 신선뉴스가 주1회 백필까지 누락되던 사각지대 해소 (전 종목×1페이지 ≈ 25분)
scheduler.add_job(job_historical_raw,"cron",day_of_week="mon-fri",hour="18",minute="30",
id="fresh_stock_sweep",replace_existing=True,
kwargs={"count":0,"max_pages":1})
scheduler.start()
logger.info("news-collector.started", sources=len(RSS_SOURCES))
@app.on_event("shutdown")
async def shutdown():
scheduler.shutdown()
if pg_pool: await pg_pool.close()
if redis_cl: await redis_cl.aclose()
@app.get("/health")
async def health():
return JSONResponse(content={"status":"ok","collected":stats.collected,"processed":stats.processed,
"duplicates":stats.duplicates,"noise":stats.noise,"off_topic":stats.off_topic,
"errors":stats.errors,"last_run":stats.last_run,"running":stats.running})
@app.post("/collect/rss")
async def m_rss():
asyncio.create_task(job_rss()); return {"status":"started","type":"rss","sources":len(RSS_SOURCES)}
@app.post("/collect/market")
async def m_market():
asyncio.create_task(job_market()); return {"status":"started","type":"market"}
@app.post("/collect/historical")
async def m_historical(count: int = 500, max_pages: int = 20):
asyncio.create_task(job_historical(count, max_pages))
return {"status":"started","type":"historical","count":count,"max_pages":max_pages}
@app.post("/collect/historical-raw")
async def m_historical_raw(count: int = 0, max_pages: int = 100):
"""전체 활성종목 raw 백필 (LLM 분석 X). count=0 → 전체"""
asyncio.create_task(job_historical_raw(count, max_pages))
return {"status":"started","type":"historical_raw","count":count or "전체","max_pages":max_pages}
@app.post("/process/raw")
async def m_process_raw(batch_size: int = 200, reprocess: bool = False):
"""news_raw 미처리분 batch 분석 → news_analysis로 이동.
reprocess=true: is_dup·Qdrant sim_dup 우회 (파싱실패 복구 재분석용)."""
n = await job_process_raw(batch_size, reprocess=reprocess)
return {"status":"done","processed":n}
@app.get("/raw/stats")
async def raw_stats():
"""raw 백필 진행 상태"""
async with pg_pool.acquire() as conn:
s = await conn.fetchrow("""
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE processed=false) AS unprocessed,
COUNT(*) FILTER (WHERE processed=true) AS processed,
COUNT(DISTINCT stock_code) AS unique_stocks,
MIN(collected_at) AS first_collected,
MAX(collected_at) AS last_collected
FROM news_raw
""")
return dict(s) if s else {}
@app.get("/sources")
async def list_sources():
return [{"name": s, "url": u} for s, u in RSS_SOURCES]