2026-05-15 13:54:01 +09:00
|
|
|
"""
|
|
|
|
|
app/ai/morning.py
|
2026-05-15 13:58:16 +09:00
|
|
|
장 전 데이터 수집 스크립트 (claude_morning 헬퍼)
|
2026-05-15 13:54:01 +09:00
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
Claude Code headless가 이 스크립트를 실행해 데이터를 수집한 뒤,
|
|
|
|
|
수집 결과를 직접 분석해 daily_context.json을 작성한다.
|
2026-05-15 13:54:01 +09:00
|
|
|
|
|
|
|
|
실행:
|
2026-05-15 13:58:16 +09:00
|
|
|
python app/ai/morning.py # 수집 + 파일 저장
|
|
|
|
|
python app/ai/morning.py --print # 수집 결과를 stdout으로 출력 (Claude 분석용)
|
2026-05-15 13:54:01 +09:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import re
|
|
|
|
|
import sys
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_env():
|
|
|
|
|
env_path = Path(".env")
|
|
|
|
|
if not env_path.exists():
|
|
|
|
|
return
|
|
|
|
|
with open(env_path, encoding="utf-8") as f:
|
|
|
|
|
for line in f:
|
|
|
|
|
line = line.strip()
|
|
|
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
|
|
|
continue
|
|
|
|
|
k, _, v = line.partition("=")
|
|
|
|
|
k = k.strip()
|
|
|
|
|
v = v.strip()
|
|
|
|
|
if " #" in v:
|
|
|
|
|
v = v[: v.index(" #")]
|
|
|
|
|
v = v.strip().strip('"').strip("'")
|
|
|
|
|
if k and v and k not in os.environ:
|
|
|
|
|
os.environ[k] = v
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_load_env()
|
|
|
|
|
|
|
|
|
|
ROOT = Path(__file__).parent.parent.parent
|
|
|
|
|
if str(ROOT) not in sys.path:
|
|
|
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
|
|
|
|
|
|
os.makedirs("logs", exist_ok=True)
|
|
|
|
|
logging.basicConfig(
|
|
|
|
|
level=logging.INFO,
|
|
|
|
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
|
|
|
|
handlers=[
|
2026-05-15 13:58:16 +09:00
|
|
|
logging.StreamHandler(sys.stderr), # stdout은 Claude 분석용으로 비워둠
|
2026-05-15 13:54:01 +09:00
|
|
|
logging.FileHandler("logs/stockbot.log", encoding="utf-8"),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
import aiohttp
|
|
|
|
|
from bs4 import BeautifulSoup
|
|
|
|
|
from app.execution.kis_client import KISClient
|
|
|
|
|
|
|
|
|
|
TODAY = datetime.now().strftime("%Y-%m-%d")
|
|
|
|
|
NEWS_PATH = f"data/news/{TODAY}.json"
|
|
|
|
|
MARKET_PATH = f"data/market/{TODAY}.json"
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
_HEADERS = {
|
2026-05-15 13:54:01 +09:00
|
|
|
"User-Agent": (
|
|
|
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
|
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
|
|
|
"Chrome/120.0.0.0 Safari/537.36"
|
|
|
|
|
),
|
|
|
|
|
"Accept-Language": "ko-KR,ko;q=0.9",
|
|
|
|
|
"Referer": "https://finance.naver.com/",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 뉴스 크롤링 ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
async def fetch_news() -> list[str]:
|
2026-05-15 13:54:01 +09:00
|
|
|
url = "https://finance.naver.com/news/mainnews.naver"
|
|
|
|
|
try:
|
2026-05-15 13:58:16 +09:00
|
|
|
async with aiohttp.ClientSession() as session:
|
|
|
|
|
async with session.get(
|
|
|
|
|
url, headers=_HEADERS, timeout=aiohttp.ClientTimeout(total=15)
|
|
|
|
|
) as resp:
|
|
|
|
|
raw = await resp.read()
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
html = raw.decode("euc-kr")
|
|
|
|
|
except Exception:
|
|
|
|
|
html = raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
|
|
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
|
|
|
headlines: list[str] = []
|
|
|
|
|
|
|
|
|
|
for tag in soup.select(
|
|
|
|
|
"ul.simpleNewsList li a, "
|
|
|
|
|
".newsListBody .articleSubject a, "
|
|
|
|
|
".mainNewsList .articleSubject a"
|
|
|
|
|
):
|
2026-05-15 13:54:01 +09:00
|
|
|
text = tag.get_text(strip=True)
|
2026-05-15 13:58:16 +09:00
|
|
|
if len(text) >= 10:
|
2026-05-15 13:54:01 +09:00
|
|
|
headlines.append(text)
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
if not headlines:
|
|
|
|
|
for tag in soup.find_all("a", href=re.compile(r"news")):
|
|
|
|
|
text = tag.get_text(strip=True)
|
|
|
|
|
if 10 <= len(text) <= 120:
|
|
|
|
|
headlines.append(text)
|
|
|
|
|
|
|
|
|
|
seen: set[str] = set()
|
|
|
|
|
result: list[str] = []
|
|
|
|
|
for h in headlines:
|
|
|
|
|
if h not in seen:
|
|
|
|
|
seen.add(h)
|
|
|
|
|
result.append(h)
|
|
|
|
|
if len(result) >= 15:
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
logger.info(f"뉴스 {len(result)}건 수집")
|
|
|
|
|
return result
|
2026-05-15 13:54:01 +09:00
|
|
|
except Exception as e:
|
2026-05-15 13:58:16 +09:00
|
|
|
logger.warning(f"뉴스 크롤링 실패: {e}")
|
2026-05-15 13:54:01 +09:00
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── KIS 시장 데이터 ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
async def fetch_market_data(kis: KISClient) -> dict:
|
|
|
|
|
data: dict = {
|
|
|
|
|
"volume_rank": [],
|
|
|
|
|
"foreign_buy": [],
|
|
|
|
|
"institution_buy": [],
|
|
|
|
|
"sectors": [],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
data["volume_rank"] = await kis.get_volume_rank(top_n=20)
|
2026-05-15 13:58:16 +09:00
|
|
|
logger.info(f"거래량 순위 {len(data['volume_rank'])}종목")
|
2026-05-15 13:54:01 +09:00
|
|
|
await asyncio.sleep(1.1)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"거래량 순위 실패: {e}")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
fi = await kis.get_foreign_institution_rank(top_n=20)
|
|
|
|
|
data["foreign_buy"] = fi["foreign"][:10]
|
|
|
|
|
data["institution_buy"] = fi["institution"][:10]
|
2026-05-15 13:58:16 +09:00
|
|
|
logger.info("외국인/기관 수급 수집")
|
2026-05-15 13:54:01 +09:00
|
|
|
await asyncio.sleep(1.1)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"수급 데이터 실패: {e}")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
data["sectors"] = await kis.get_sector_trend()
|
2026-05-15 13:58:16 +09:00
|
|
|
logger.info(f"업종 동향 {len(data['sectors'])}개")
|
2026-05-15 13:54:01 +09:00
|
|
|
await asyncio.sleep(1.1)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning(f"업종 동향 실패: {e}")
|
|
|
|
|
|
|
|
|
|
return data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 메인 ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
async def main(print_mode: bool = False):
|
|
|
|
|
logger.info(f"데이터 수집 시작 [{TODAY}]")
|
2026-05-15 13:54:01 +09:00
|
|
|
|
|
|
|
|
for d in ["data/news", "data/market"]:
|
|
|
|
|
os.makedirs(d, exist_ok=True)
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
# 뉴스 수집
|
2026-05-15 13:54:01 +09:00
|
|
|
news = await fetch_news()
|
|
|
|
|
Path(NEWS_PATH).write_text(
|
|
|
|
|
json.dumps({"date": TODAY, "headlines": news}, ensure_ascii=False, indent=2),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
# KIS 시장 데이터
|
2026-05-15 13:54:01 +09:00
|
|
|
kis = KISClient()
|
2026-05-15 13:58:16 +09:00
|
|
|
market: dict = {"volume_rank": [], "foreign_buy": [], "institution_buy": [], "sectors": []}
|
2026-05-15 13:54:01 +09:00
|
|
|
try:
|
|
|
|
|
await kis.get_access_token()
|
|
|
|
|
market = await fetch_market_data(kis)
|
|
|
|
|
except Exception as e:
|
2026-05-15 13:58:16 +09:00
|
|
|
logger.warning(f"KIS 수집 실패: {e}")
|
2026-05-15 13:54:01 +09:00
|
|
|
|
|
|
|
|
Path(MARKET_PATH).write_text(
|
|
|
|
|
json.dumps({"date": TODAY, **market}, ensure_ascii=False, indent=2),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
logger.info(f"수집 완료 → {NEWS_PATH}, {MARKET_PATH}")
|
2026-05-15 13:54:01 +09:00
|
|
|
|
2026-05-15 13:58:16 +09:00
|
|
|
if print_mode:
|
|
|
|
|
# Claude Code가 읽을 수 있도록 stdout으로 출력
|
|
|
|
|
print(json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"date": TODAY,
|
|
|
|
|
"news_headlines": news,
|
|
|
|
|
"volume_rank": market["volume_rank"][:10],
|
|
|
|
|
"foreign_buy_top5": market["foreign_buy"][:5],
|
|
|
|
|
"institution_buy_top5": market["institution_buy"][:5],
|
|
|
|
|
"sectors": market["sectors"][:15],
|
|
|
|
|
},
|
|
|
|
|
ensure_ascii=False,
|
|
|
|
|
indent=2,
|
|
|
|
|
))
|
2026-05-15 13:54:01 +09:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2026-05-15 13:58:16 +09:00
|
|
|
print_mode = "--print" in sys.argv
|
|
|
|
|
asyncio.run(main(print_mode=print_mode))
|