Files
trading/news-collector/main.py
T
kyu 6d3b0bacc0 Initial commit: Korean stock value-investing AI pipeline
- 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector,
  aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등)
- 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/
  모멘텀/Beneish/GP-A/G-Score/Amihud/BAB)
- 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인
- 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 21:33:56 +09:00

887 lines
43 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
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
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")
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
last_run = ""; running = False
stats = S()
def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16]
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.stockdaily.kr/rss/rss.xml"),
("세계일보", "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):
try:
# 0. 비주식 카테고리 prefix 차단 (바른API 호출 전)
if _is_noise_title(item.get("title", "")):
return "noise"
# 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 any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단
except: hits = []
# 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)
# 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"
"[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\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는 영향받는 다른 종목코드 배열, reason은 한 문장 핵심 근거.\n\n"
"JSON 스키마:\n"
'{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",'
'"affected_stocks":["000660"],"reason":"핵심 근거 한 문장",'
'"investment_action":"매수관심|매도관심|관망",'
'"catalyst":"실적|수주|배당|리스크|모멘텀|기타"}\n'
"예시: "
'{"sentiment":"호재","intensity":4,"primary_stock":"005930",'
'"affected_stocks":["000660"],"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",'
'"investment_action":"매수관심","catalyst":"수주"}'
)
# EXAONE 호출 + JSON 파싱 (실패 시 1회 재시도 → ~15% 유실 감소)
a = None
raw_prev = ""
for attempt in range(2):
msgs = [{"role":"system","content": system_prompt},
{"role":"user","content": user_prompt}]
if attempt == 1:
msgs += [{"role":"assistant","content": raw_prev[:600]},
{"role":"user","content":
"위 응답이 유효한 JSON이 아닙니다. 설명·마크다운 없이 "
"스키마에 맞는 JSON 객체 하나만 출력하세요."}]
try:
vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={
"model":"exaone3.5:7.8b","messages": msgs,
"max_tokens":400,"temperature":0.0 if attempt else 0.05},
timeout=120)
raw_prev = vr.json()["choices"][0]["message"]["content"]
c = raw_prev.replace("```json","").replace("```","").strip()
if not c.startswith("{"): # 앞뒤 설명 제거
s, e = c.find("{"), c.rfind("}")
if s != -1 and e > s: c = c[s:e+1]
a = json.loads(c)
break
except Exception:
continue
if a is None:
a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],"reason":"파싱실패","investment_action":"관망"}
# catalyst 6개 enum 강제 (score-engine CATALYST_WEIGHTS 정합 — 일탈값은 '기타')
if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"):
a["catalyst"] = "기타"
# 5. Qdrant 저장
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","")}}]}, 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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW())
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])
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 == "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):
"""news_raw의 미처리 행을 batch_size만큼 EXAONE 분석 → news_analysis로 이동"""
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
async with httpx.AsyncClient() as c:
for r in rows:
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 await is_dup(item["title"], item["url"]):
stats.duplicates += 1
else:
stats.collected += 1
res = await pipeline(item, c)
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))
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 == "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 == "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시 5분마다
scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/5",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 뉴스 분석: 24시간 30분마다 batch 200건 처리 (백로그 소화용)
scheduler.add_job(job_process_raw,"cron",hour="*",minute="*/30",
id="process_raw",replace_existing=True,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})
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):
"""news_raw 미처리분 batch 분석 → news_analysis로 이동"""
n = await job_process_raw(batch_size)
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]