refactor: 전체 코드베이스 감사 기반 리팩토링 — 버그 수정, 데드코드 제거, 보안 강화

P0 버그 수정:
- stock-lab: trade 엔드포인트 NameError 수정 (resp 미정의)
- deployer: 동시 배포 시 HTTP 200 → 503 반환

P1 데드코드 제거:
- stock-lab: fetch_overseas_news(), get_broker_cash() 제거
- blog-lab: 미사용 urlparse import 제거
- lotto-lab: 중복 inline import json 7곳 제거

P2 성능/효율 개선:
- lotto-lab: 가중 샘플링 3중 복사 → utils.weighted_sample_6() 통합
- lotto-lab: DB 인덱스 3개 추가 (recommendations, purchase_history)
- stock-lab: Pydantic .dict() → .model_dump() 호환
- blog-lab: 페이지네이션 상한(le=100) 추가

P3 보안/인프라:
- nginx: X-Frame-Options, X-Content-Type-Options, Referrer-Policy 헤더 추가
- docker-compose: travel-proxy CORS 와일드카드 → localhost 전용
- Dockerfile: music-lab, blog-lab, realestate-lab에 PYTHONUNBUFFERED 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-07 04:10:14 +09:00
parent 9d5583935d
commit 535ffea45a
15 changed files with 84 additions and 150 deletions

View File

@@ -272,6 +272,11 @@ def init_db() -> None:
""" """
) )
# ── 추가 인덱스 ───────────────────────────────────────────────────────
conn.execute("CREATE INDEX IF NOT EXISTS idx_reco_based_checked ON recommendations(based_on_draw, checked)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_strategy ON purchase_history(source_strategy)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_purchase_checked ON purchase_history(draw_no, checked)")
# ── todos CRUD ─────────────────────────────────────────────────────────────── # ── todos CRUD ───────────────────────────────────────────────────────────────
@@ -512,8 +517,6 @@ def list_recommendations_ex(
q: Optional[str] = None, q: Optional[str] = None,
sort: str = "id_desc", # id_desc|created_desc|favorite_desc sort: str = "id_desc", # id_desc|created_desc|favorite_desc
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
import json
where = [] where = []
args: list[Any] = [] args: list[Any] = []
@@ -810,7 +813,7 @@ def get_simulation_candidates(run_id: int, limit: int = 100) -> List[Dict[str, A
# ── purchase_history CRUD ───────────────────────────────────────────────────── # ── purchase_history CRUD ─────────────────────────────────────────────────────
def _purchase_row_to_dict(r) -> Dict[str, Any]: def _purchase_row_to_dict(r) -> Dict[str, Any]:
import json as _json
keys = r.keys() keys = r.keys()
numbers_raw = r["numbers"] if "numbers" in keys else "[]" numbers_raw = r["numbers"] if "numbers" in keys else "[]"
detail_raw = r["source_detail"] if "source_detail" in keys else "{}" detail_raw = r["source_detail"] if "source_detail" in keys else "{}"
@@ -823,12 +826,12 @@ def _purchase_row_to_dict(r) -> Dict[str, Any]:
"prize": r["prize"], "prize": r["prize"],
"note": r["note"], "note": r["note"],
"created_at": r["created_at"], "created_at": r["created_at"],
"numbers": _json.loads(numbers_raw) if numbers_raw else [], "numbers": json.loads(numbers_raw) if numbers_raw else [],
"is_real": r["is_real"] if "is_real" in keys else 1, "is_real": r["is_real"] if "is_real" in keys else 1,
"source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual", "source_strategy": r["source_strategy"] if "source_strategy" in keys else "manual",
"source_detail": _json.loads(detail_raw) if detail_raw else {}, "source_detail": json.loads(detail_raw) if detail_raw else {},
"checked": r["checked"] if "checked" in keys else 0, "checked": r["checked"] if "checked" in keys else 0,
"results": _json.loads(results_raw) if results_raw else [], "results": json.loads(results_raw) if results_raw else [],
"total_prize": r["total_prize"] if "total_prize" in keys else 0, "total_prize": r["total_prize"] if "total_prize" in keys else 0,
} }
@@ -836,9 +839,9 @@ def _purchase_row_to_dict(r) -> Dict[str, Any]:
def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "", def add_purchase(draw_no: int, amount: int, sets: int, prize: int = 0, note: str = "",
numbers: list = None, is_real: bool = True, numbers: list = None, is_real: bool = True,
source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]: source_strategy: str = "manual", source_detail: dict = None) -> Dict[str, Any]:
import json as _json
numbers_json = _json.dumps(numbers or [], ensure_ascii=False) numbers_json = json.dumps(numbers or [], ensure_ascii=False)
detail_json = _json.dumps(source_detail or {}, ensure_ascii=False) detail_json = json.dumps(source_detail or {}, ensure_ascii=False)
is_real_int = 1 if is_real else 0 is_real_int = 1 if is_real else 0
with _conn() as conn: with _conn() as conn:
conn.execute( conn.execute(
@@ -880,7 +883,7 @@ def get_purchases(draw_no: int = None, days: int = None,
def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
import json as _json
allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"} allowed = {"draw_no", "amount", "sets", "prize", "note", "numbers", "is_real", "source_strategy"}
updates = {k: v for k, v in data.items() if k in allowed} updates = {k: v for k, v in data.items() if k in allowed}
if not updates: if not updates:
@@ -889,7 +892,7 @@ def update_purchase(purchase_id: int, data: Dict[str, Any]) -> Optional[Dict[str
return _purchase_row_to_dict(row) if row else None return _purchase_row_to_dict(row) if row else None
# SQLite에 전달 전 타입 변환 # SQLite에 전달 전 타입 변환
if "numbers" in updates: if "numbers" in updates:
updates["numbers"] = _json.dumps(updates["numbers"], ensure_ascii=False) updates["numbers"] = json.dumps(updates["numbers"], ensure_ascii=False)
if "is_real" in updates: if "is_real" in updates:
updates["is_real"] = 1 if updates["is_real"] else 0 updates["is_real"] = 1 if updates["is_real"] else 0
set_clause = ", ".join(f"{k} = ?" for k in updates) set_clause = ", ".join(f"{k} = ?" for k in updates)
@@ -911,7 +914,7 @@ def delete_purchase(purchase_id: int) -> bool:
def get_purchase_stats() -> Dict[str, Any]: def get_purchase_stats() -> Dict[str, Any]:
import json as _json
def _calc_group(rows): def _calc_group(rows):
if not rows: if not rows:
@@ -951,7 +954,7 @@ def get_purchase_stats() -> Dict[str, Any]:
for r in srows: for r in srows:
results_raw = r.get("results", "[]") results_raw = r.get("results", "[]")
try: try:
results = _json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or []) results = json.loads(results_raw) if isinstance(results_raw, str) else (results_raw or [])
except Exception: except Exception:
results = [] results = []
for res in results: for res in results:
@@ -1015,8 +1018,8 @@ def get_weekly_report(drw_no: int) -> Optional[Dict[str, Any]]:
).fetchone() ).fetchone()
if not row: if not row:
return None return None
import json as _json
return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **_json.loads(row["report"])} return {"drw_no": row["drw_no"], "generated_at": row["generated_at"], **json.loads(row["report"])}
def get_all_recommendation_numbers() -> List[List[int]]: def get_all_recommendation_numbers() -> List[List[int]]:
@@ -1086,10 +1089,10 @@ def update_strategy_weight(strategy: str, weight: float, ema_score: float,
def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None: def update_purchase_results(purchase_id: int, results: list, total_prize: int) -> None:
"""구매 건의 결과를 갱신 (체커 호출 후)""" """구매 건의 결과를 갱신 (체커 호출 후)"""
import json as _json
with _conn() as conn: with _conn() as conn:
conn.execute( conn.execute(
"UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?", "UPDATE purchase_history SET results=?, total_prize=?, checked=1 WHERE id=?",
(_json.dumps(results, ensure_ascii=False), total_prize, purchase_id), (json.dumps(results, ensure_ascii=False), total_prize, purchase_id),
) )

View File

@@ -24,26 +24,7 @@ from .db import (
replace_best_picks, replace_best_picks,
) )
from .analyzer import build_analysis_cache, build_number_weights, score_combination from .analyzer import build_analysis_cache, build_number_weights, score_combination
from .utils import weighted_sample_6
def _weighted_sample_6(weights: Dict[int, float]) -> List[int]:
"""
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
weights: {1: w1, 2: w2, ..., 45: w45}
"""
pool = list(range(1, 46))
chosen: List[int] = []
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
return chosen
def run_simulation( def run_simulation(
@@ -82,7 +63,7 @@ def run_simulation(
attempts = 0 attempts = 0
while len(candidates) < n_candidates and attempts < max_attempts: while len(candidates) < n_candidates and attempts < max_attempts:
attempts += 1 attempts += 1
nums = _weighted_sample_6(weights) nums = weighted_sample_6(weights)
key = tuple(sorted(nums)) key = tuple(sorted(nums))
if key in seen_keys: if key in seen_keys:
continue continue

View File

@@ -2,6 +2,8 @@ import random
from collections import Counter from collections import Counter
from typing import Dict, Any, List, Tuple from typing import Dict, Any, List, Tuple
from .utils import weighted_sample_6
def recommend_numbers( def recommend_numbers(
draws: List[Tuple[int, List[int]]], draws: List[Tuple[int, List[int]]],
*, *,
@@ -40,20 +42,7 @@ def recommend_numbers(
weights[n] = max(w, 0.1) weights[n] = max(w, 0.1)
# 중복 없이 6개 뽑기(가중 샘플링) # 중복 없이 6개 뽑기(가중 샘플링)
chosen = [] chosen_sorted = sorted(weighted_sample_6(weights))
pool = list(range(1, 46))
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
chosen_sorted = sorted(chosen)
explain = { explain = {
"recent_window": recent_window, "recent_window": recent_window,
@@ -130,20 +119,7 @@ def recommend_with_heatmap(
weights[n] = max(w, 0.1) weights[n] = max(w, 0.1)
# 4. 가중 샘플링으로 6개 선택 # 4. 가중 샘플링으로 6개 선택
chosen = [] chosen_sorted = sorted(weighted_sample_6(weights))
pool = list(range(1, 46))
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
chosen_sorted = sorted(chosen)
# 5. 설명 데이터 # 5. 설명 데이터
explain = { explain = {

View File

@@ -1,5 +1,26 @@
import random
from typing import List, Dict, Any, Tuple from typing import List, Dict, Any, Tuple
def weighted_sample_6(weights: Dict[int, float]) -> List[int]:
"""
가중 확률 샘플링으로 중복 없이 6개 번호 추출.
weights: {1: w1, 2: w2, ..., 45: w45}
"""
pool = list(range(1, 46))
chosen: List[int] = []
for _ in range(6):
total = sum(weights[n] for n in pool)
r = random.random() * total
acc = 0.0
for n in pool:
acc += weights[n]
if acc >= r:
chosen.append(n)
pool.remove(n)
break
return chosen
def calc_metrics(numbers: List[int]) -> Dict[str, Any]: def calc_metrics(numbers: List[int]) -> Dict[str, Any]:
nums = sorted(numbers) nums = sorted(numbers)
s = sum(nums) s = sum(nums)

View File

@@ -1,4 +1,5 @@
FROM python:3.12-alpine FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app

View File

@@ -1,7 +1,7 @@
import os import os
import uuid import uuid
import logging import logging
from fastapi import FastAPI, HTTPException, BackgroundTasks from fastapi import FastAPI, HTTPException, BackgroundTasks, Query
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Optional from typing import List, Optional
@@ -94,7 +94,7 @@ def start_research(req: ResearchRequest, background_tasks: BackgroundTasks):
@app.get("/api/blog-marketing/research/history") @app.get("/api/blog-marketing/research/history")
def list_research(limit: int = 30): def list_research(limit: int = Query(30, ge=1, le=100)):
return {"analyses": get_keyword_analyses(limit)} return {"analyses": get_keyword_analyses(limit)}
@@ -285,7 +285,7 @@ def start_regenerate(post_id: int, background_tasks: BackgroundTasks):
# ── 포스트 CRUD API ────────────────────────────────────────────────────────── # ── 포스트 CRUD API ──────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/posts") @app.get("/api/blog-marketing/posts")
def list_posts(status: str = None, limit: int = 50): def list_posts(status: str = None, limit: int = Query(50, ge=1, le=100)):
return {"posts": get_posts(status=status, limit=limit)} return {"posts": get_posts(status=status, limit=limit)}
@@ -409,7 +409,7 @@ def start_market(post_id: int, background_tasks: BackgroundTasks):
# ── 수익 추적 API ──────────────────────────────────────────────────────────── # ── 수익 추적 API ────────────────────────────────────────────────────────────
@app.get("/api/blog-marketing/commissions") @app.get("/api/blog-marketing/commissions")
def list_commissions(post_id: int = None, limit: int = 100): def list_commissions(post_id: int = None, limit: int = Query(100, ge=1, le=100)):
return {"commissions": get_commissions(post_id=post_id, limit=limit)} return {"commissions": get_commissions(post_id=post_id, limit=limit)}

View File

@@ -4,8 +4,6 @@ import asyncio
import logging import logging
import re import re
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
import httpx import httpx
from bs4 import BeautifulSoup from bs4 import BeautifulSoup

View File

@@ -1,5 +1,6 @@
import os, hmac, hashlib, subprocess, threading import os, hmac, hashlib, subprocess, threading
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
import logging import logging
logging.basicConfig( logging.basicConfig(
@@ -64,6 +65,13 @@ async def webhook(req: Request, background_tasks: BackgroundTasks):
if not verify(sig, body): if not verify(sig, body):
raise HTTPException(401, "bad signature") raise HTTPException(401, "bad signature")
# 동시 배포 방지: 이미 진행 중이면 503 반환
if _deploy_lock.locked():
return JSONResponse(
status_code=503,
content={"ok": False, "message": "Deploy already in progress"},
)
# ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행 # ✅ 비동기 실행: Gitea에게는 즉시 OK 응답을 주고, 배포는 뒤에서 실행
background_tasks.add_task(run_deploy_script) background_tasks.add_task(run_deploy_script)

View File

@@ -121,7 +121,7 @@ services:
- TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs} - TRAVEL_THUMB_ROOT=${TRAVEL_THUMB_ROOT:-/data/thumbs}
- TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel} - TRAVEL_MEDIA_BASE=${TRAVEL_MEDIA_BASE:-/media/travel}
- TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300} - TRAVEL_CACHE_TTL=${TRAVEL_CACHE_TTL:-300}
- CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-*} - CORS_ALLOW_ORIGINS=${CORS_ALLOW_ORIGINS:-http://localhost:3007,http://localhost:8080}
volumes: volumes:
- ${PHOTO_PATH}:/data/travel:ro - ${PHOTO_PATH}:/data/travel:ro
- ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw - ${RUNTIME_PATH}/travel-thumbs:/data/thumbs:rw

View File

@@ -1,4 +1,5 @@
FROM python:3.12-alpine FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .

View File

@@ -2,6 +2,11 @@ server {
listen 80; listen 80;
server_name _; server_name _;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;

View File

@@ -1,4 +1,5 @@
FROM python:3.12-alpine FROM python:3.12-alpine
ENV PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY requirements.txt . COPY requirements.txt .

View File

@@ -183,12 +183,6 @@ def get_all_broker_cash() -> List[Dict[str, Any]]:
return [dict(r) for r in rows] return [dict(r) for r in rows]
def get_broker_cash(broker: str) -> Dict[str, Any] | None:
with _conn() as conn:
row = conn.execute("SELECT * FROM broker_cash WHERE broker = ?", (broker,)).fetchone()
return dict(row) if row else None
def delete_broker_cash(broker: str) -> bool: def delete_broker_cash(broker: str) -> bool:
with _conn() as conn: with _conn() as conn:
cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,)) cur = conn.execute("DELETE FROM broker_cash WHERE broker = ?", (broker,))

View File

@@ -17,11 +17,11 @@ from .db import (
init_db, save_articles, get_latest_articles, init_db, save_articles, get_latest_articles,
add_portfolio_item, get_all_portfolio, get_portfolio_item, add_portfolio_item, get_all_portfolio, get_portfolio_item,
update_portfolio_item, delete_portfolio_item, update_portfolio_item, delete_portfolio_item,
upsert_broker_cash, get_all_broker_cash, get_broker_cash, delete_broker_cash, upsert_broker_cash, get_all_broker_cash, delete_broker_cash,
upsert_asset_snapshot, get_asset_snapshots, upsert_asset_snapshot, get_asset_snapshots,
add_sell_history, get_sell_history, update_sell_history, delete_sell_history, add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
) )
from .scraper import fetch_market_news, fetch_major_indices, fetch_overseas_news from .scraper import fetch_market_news, fetch_major_indices
from .price_fetcher import get_current_prices from .price_fetcher import get_current_prices
app = FastAPI() app = FastAPI()
@@ -119,16 +119,10 @@ def on_startup():
def run_scraping_job(): def run_scraping_job():
logger.info("뉴스 스크래핑 시작") logger.info("뉴스 스크래핑 시작")
# 1. 국내
articles_kr = fetch_market_news() articles_kr = fetch_market_news()
count_kr = save_articles(articles_kr) count_kr = save_articles(articles_kr)
# 2. 해외 (임시 차단) logger.info(f"스크래핑 완료: 국내 {count_kr}")
# articles_world = fetch_overseas_news()
# count_world = save_articles(articles_world)
count_world = 0
logger.info(f"스크래핑 완료: 국내 {count_kr}건, 해외 {count_world}")
@app.get("/health") @app.get("/health")
def health(): def health():
@@ -156,14 +150,16 @@ def trigger_scrap():
def get_balance(): def get_balance():
"""계좌 잔고 조회 (Windows AI Server Proxy)""" """계좌 잔고 조회 (Windows AI Server Proxy)"""
logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}") logger.info(f"Requesting Balance from {WINDOWS_AI_SERVER_URL}")
resp = None
try: try:
resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5) resp = requests.get(f"{WINDOWS_AI_SERVER_URL}/trade/balance", timeout=5)
if resp.status_code != 200: if resp.status_code != 200:
logger.error(f"Balance Error: {resp.status_code}") logger.error(f"Balance Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json()) return JSONResponse(status_code=resp.status_code, content=resp.json())
return resp.json() return resp.json()
except requests.JSONDecodeError: except ValueError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"}) status = resp.status_code if resp is not None else 502
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
except Exception as e: except Exception as e:
logger.error(f"Balance Connection Failed: {e}") logger.error(f"Balance Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"}) return JSONResponse(status_code=500, content={"error": "Connection Failed"})
@@ -179,14 +175,16 @@ class OrderRequest(BaseModel):
def order_stock(req: OrderRequest): def order_stock(req: OrderRequest):
"""주식 매수/매도 주문 (Windows AI Server Proxy)""" """주식 매수/매도 주문 (Windows AI Server Proxy)"""
logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}") logger.info(f"Order Request: {req.action} {req.ticker} x{req.quantity}")
resp = None
try: try:
resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.dict(), timeout=10) resp = requests.post(f"{WINDOWS_AI_SERVER_URL}/trade/order", json=req.model_dump(), timeout=10)
if resp.status_code != 200: if resp.status_code != 200:
logger.error(f"Order Error: {resp.status_code}") logger.error(f"Order Error: {resp.status_code}")
return JSONResponse(status_code=resp.status_code, content=resp.json()) return JSONResponse(status_code=resp.status_code, content=resp.json())
return resp.json() return resp.json()
except requests.JSONDecodeError: except ValueError:
return JSONResponse(status_code=resp.status_code, content={"error": f"Upstream error {resp.status_code}"}) status = resp.status_code if resp is not None else 502
return JSONResponse(status_code=status, content={"error": f"Upstream error {status}"})
except Exception as e: except Exception as e:
logger.error(f"Order Connection Failed: {e}") logger.error(f"Order Connection Failed: {e}")
return JSONResponse(status_code=500, content={"error": "Connection Failed"}) return JSONResponse(status_code=500, content={"error": "Connection Failed"})
@@ -368,7 +366,7 @@ def update_portfolio(item_id: int, req: PortfolioUpdateRequest):
"""포트폴리오 종목 수정""" """포트폴리오 종목 수정"""
if get_portfolio_item(item_id) is None: if get_portfolio_item(item_id) is None:
return JSONResponse(status_code=404, content={"error": "Item not found"}) return JSONResponse(status_code=404, content={"error": "Item not found"})
update_portfolio_item(item_id, **req.dict()) update_portfolio_item(item_id, **req.model_dump())
return {"ok": True} return {"ok": True}

View File

@@ -79,59 +79,6 @@ def fetch_market_news() -> List[Dict[str, str]]:
logger.error(f"국내 뉴스 스크래핑 실패: {e}") logger.error(f"국내 뉴스 스크래핑 실패: {e}")
return [] return []
def fetch_overseas_news() -> List[Dict[str, str]]:
"""
네이버 금융 해외증시 뉴스 크롤링 (모바일 API 사용)
"""
api_url = "https://api.stock.naver.com/news/overseas/mainnews"
try:
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)"
}
resp = requests.get(api_url, headers=headers, timeout=10)
resp.raise_for_status()
data = resp.json()
if isinstance(data, list):
items = data
else:
items = data.get("result", [])
articles = []
for item in items:
# API 키 매핑 (subject/title/tit, summary/subContent/sub_tit 등)
title = item.get("subject") or item.get("title") or item.get("tit") or ""
summary = item.get("summary") or item.get("subContent") or item.get("sub_tit") or ""
press = item.get("officeName") or item.get("office_name") or item.get("cp_name") or ""
# 날짜 포맷팅 (20260126123000 -> 2026-01-26 12:30:00)
raw_dt = str(item.get("dt", ""))
if len(raw_dt) == 14:
date = f"{raw_dt[:4]}-{raw_dt[4:6]}-{raw_dt[6:8]} {raw_dt[8:10]}:{raw_dt[10:12]}:{raw_dt[12:]}"
else:
date = raw_dt
# 링크 생성
aid = item.get("articleId")
oid = item.get("officeId")
link = f"https://m.stock.naver.com/worldstock/news/read/{oid}/{aid}"
articles.append({
"title": title,
"link": link,
"summary": summary,
"press": press,
"date": date,
"crawled_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"category": "overseas"
})
return articles
except Exception as e:
logger.error(f"해외 뉴스 스크래핑 실패: {e}")
return []
def fetch_major_indices() -> Dict[str, Any]: def fetch_major_indices() -> Dict[str, Any]:
""" """
KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈) KOSPI, KOSDAQ, KOSPI200 등 주요 지표 (네이버 금융 홈)