"""외부 트렌드 수집 — NAVER 인기 + YouTube 인기 영상 + LLM 카테고리 분류. NAVER: 카테고리별 시드 키워드로 인기 검색 → 빈도 상위 추출. YouTube: Google Trends 비공식 endpoint(RSS / dailytrends JSON)가 모두 404 폐기되어 대체로 YouTube Data API v3 (`videos.list?chart=mostPopular®ionCode=KR`) 사용. 무료 일일 quota 10000, 한국 region 지원, 인기 영상 50개 제목에서 트렌드 추출. LLM 분류 결과는 24h in-memory 캐시. """ import json import logging import re import time from typing import Any, Dict, List, Optional import requests from anthropic import Anthropic from .config import ( NAVER_CLIENT_ID, NAVER_CLIENT_SECRET, DEFAULT_CATEGORY_SEEDS, ANTHROPIC_API_KEY, ANTHROPIC_MODEL_HAIKU, YOUTUBE_DATA_API_KEY, ) from . import db from .news_collector import _clean from .keyword_extractor import _count_nouns, _top_candidates logger = logging.getLogger(__name__) NEWS_URL = "https://openapi.naver.com/v1/search/news.json" _NAVER_HEADERS = { "X-Naver-Client-Id": NAVER_CLIENT_ID, "X-Naver-Client-Secret": NAVER_CLIENT_SECRET, } YOUTUBE_TRENDING_URL = "https://www.googleapis.com/youtube/v3/videos" # YouTube 제목 정제: 대괄호·이모지·과도한 길이 제거 후 카드 주제로 적합한 키워드 형태 _TITLE_BRACKET_RE = re.compile(r"[\[【「『\(][^\]】」』\)]{0,30}[\]】」』\)]") _EMOJI_RE = re.compile( r"[" r"\U0001F300-\U0001FAFF" # symbols & pictographs, etc. r"\U00002600-\U000027BF" # misc symbols, dingbats r"\U0001F1E6-\U0001F1FF" # regional indicator r"]" ) _TITLE_MAX_LEN = 60 _PLACEHOLDER_SEEDS = {"...", "…", "tbd", "todo", "placeholder", "example"} def _is_valid_seed(s: str) -> bool: """프롬프트 템플릿에 placeholder/빈 값이 들어가 NAVER에 400을 유발하는 일을 막는 가드.""" if not s: return False s = s.strip() if len(s) < 2: return False if s.lower() in _PLACEHOLDER_SEEDS: return False return True def _seeds_for(category: str) -> List[str]: """category_seeds 프롬프트 템플릿이 있으면 사용, 없거나 모두 invalid면 config DEFAULT 폴백.""" pt = db.get_prompt_template("category_seeds") if pt and pt.get("template"): try: data = json.loads(pt["template"]) if category in data: filtered = [s for s in (data[category] or []) if _is_valid_seed(s)] if filtered: return filtered logger.warning("category_seeds[%s]에 유효한 시드 없음 → DEFAULT 폴백", category) except Exception as e: logger.warning("category_seeds JSON 파싱 실패 → DEFAULT 폴백: %s", e) return list(DEFAULT_CATEGORY_SEEDS.get(category, [])) def fetch_naver_popular(category: str, per_seed: int = 30, top_n: int = 10) -> List[Dict[str, Any]]: """카테고리 시드 키워드들로 NAVER news.json `sort=sim` 호출, 응답 기사 묶음에서 빈도어 추출 후 상위 N개 반환.""" seeds = _seeds_for(category) if not seeds: return [] blob_parts: List[str] = [] for seed in seeds: try: resp = requests.get( NEWS_URL, headers=_NAVER_HEADERS, params={"query": seed, "display": per_seed, "sort": "sim"}, timeout=10, ) resp.raise_for_status() for item in resp.json().get("items", []): blob_parts.append(_clean(item.get("title", ""))) blob_parts.append(_clean(item.get("description", ""))) except Exception as e: logger.warning("fetch_naver_popular seed=%s err=%s", seed, e) continue text = "\n".join(blob_parts) counts = _count_nouns(text) candidates = _top_candidates(counts, n=top_n) if not candidates: return [] max_count = candidates[0][1] or 1 return [ { "keyword": k, "category": category, "source": "naver_popular", "score": round(min(1.0, c / max_count), 4), "articles_count": c, } for k, c in candidates ] def collect_naver_popular_for(categories: List[str]) -> int: total = 0 for cat in categories: trends = fetch_naver_popular(cat) for t in trends: db.add_external_trend(t) total += 1 return total # ── LLM 분류 캐시 ──────────────────────────────────────────────────────────── _CACHE_TTL_SEC = 24 * 3600 _category_cache: Dict[str, tuple] = {} # keyword -> (category, expires_ts) def _llm_classify_one(keyword: str) -> str: """Claude Haiku 1회 호출로 단일 키워드 분류.""" if not ANTHROPIC_API_KEY: return "uncategorized" seeds_template = db.get_prompt_template("category_seeds") if seeds_template and seeds_template.get("template"): try: allowed = sorted(json.loads(seeds_template["template"]).keys()) except Exception: allowed = sorted(DEFAULT_CATEGORY_SEEDS.keys()) else: allowed = sorted(DEFAULT_CATEGORY_SEEDS.keys()) allowed.append("uncategorized") client = Anthropic(api_key=ANTHROPIC_API_KEY) msg = client.messages.create( model=ANTHROPIC_MODEL_HAIKU, max_tokens=20, messages=[{ "role": "user", "content": ( f"다음 한국어 트렌딩 키워드를 카테고리 중 하나로 분류해라. " f"카테고리: {allowed}. 키워드: '{keyword}'. " f"카테고리명 한 단어만 출력. 다른 텍스트 금지." ), }], ) raw = msg.content[0].text.strip().lower() for cat in allowed: if cat.lower() in raw: return cat return "uncategorized" def classify_keyword(keyword: str) -> str: now = time.time() cached = _category_cache.get(keyword) if cached and cached[1] > now: return cached[0] cat = _llm_classify_one(keyword) _category_cache[keyword] = (cat, now + _CACHE_TTL_SEC) return cat # ── YouTube Trending ────────────────────────────────────────────────────────── # YouTube Data API v3 videos.list?chart=mostPopular®ionCode=KR # 한국 인기 영상 50개 제목에서 카드 주제로 적합한 키워드 추출. def _clean_yt_title(title: str) -> str: """[공식]·【속보】·🔥 등 제거 후 60자 이내로 자른다.""" if not title: return "" cleaned = _TITLE_BRACKET_RE.sub("", title) cleaned = _EMOJI_RE.sub("", cleaned) cleaned = re.sub(r"\s+", " ", cleaned).strip() return cleaned[:_TITLE_MAX_LEN] def fetch_youtube_trending() -> List[Dict[str, Any]]: """YouTube Data API v3 mostPopular (한국, 50개). API 키 없거나 호출 실패 시 빈 리스트.""" if not YOUTUBE_DATA_API_KEY: logger.info("YOUTUBE_DATA_API_KEY 미설정 — youtube_trending skip") return [] try: resp = requests.get( YOUTUBE_TRENDING_URL, params={ "part": "snippet", "chart": "mostPopular", "regionCode": "KR", "maxResults": 50, "key": YOUTUBE_DATA_API_KEY, }, timeout=15, ) resp.raise_for_status() videos = resp.json().get("items", []) or [] except Exception as e: logger.warning("YouTube trending fetch failed: %s", e) return [] items: List[Dict[str, Any]] = [] seen = set() total = max(1, len(videos)) for idx, v in enumerate(videos): title = (v.get("snippet") or {}).get("title", "") kw = _clean_yt_title(title) if not kw or kw in seen: continue seen.add(kw) try: cat = classify_keyword(kw) except Exception as e: logger.warning("classify_keyword(%s) 실패: %s", kw, e) cat = "uncategorized" rank_score = round(max(0.0, 1.0 - (idx / total)), 4) items.append({ "keyword": kw, "category": cat, "source": "youtube_trending", "score": rank_score, "articles_count": 0, }) return items def collect_youtube_trending() -> int: items = fetch_youtube_trending() for it in items: db.add_external_trend(it) return len(items) def collect_all(categories: List[str]) -> Dict[str, int]: naver_n = collect_naver_popular_for(categories) yt_n = collect_youtube_trending() return {"naver_popular": naver_n, "youtube_trending": yt_n}