From cfbb72051ffd2d120d9eca32d0b94c4a39704477 Mon Sep 17 00:00:00 2001 From: gahusb Date: Sun, 17 May 2026 09:30:40 +0900 Subject: [PATCH] =?UTF-8?q?fix(insta-lab):=20Google=20Trends=20=E2=80=94?= =?UTF-8?q?=20RSS=20endpoint=EB=8F=84=20404=20=ED=8F=90=EA=B8=B0,=20dailyt?= =?UTF-8?q?rends=20JSON=20API=EB=A1=9C=20=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Google이 /trends/trendingsearches/daily/rss?geo=KR도 404로 폐기 (직전 fix에서 RSS로 교체했으나 NAS에서 실제 호출 시 404 확인). 대안으로 비공식 /trends/api/dailytrends?hl=ko&tz=-540&geo=KR&ns=15 JSON API로 교체. 응답 앞 `)]}'` XSSI 보호 prefix는 정규식으로 자르고 JSON 파싱. 중복 키워드 제거 + 등장 순서 보존. --- insta-lab/app/trend_collector.py | 51 +++++++++++++++++-------- insta-lab/tests/test_trend_collector.py | 34 +++++++++++------ 2 files changed, 57 insertions(+), 28 deletions(-) diff --git a/insta-lab/app/trend_collector.py b/insta-lab/app/trend_collector.py index 7dc132b..caace23 100644 --- a/insta-lab/app/trend_collector.py +++ b/insta-lab/app/trend_collector.py @@ -1,7 +1,8 @@ -"""외부 트렌드 수집 — NAVER 인기 + Google Trends RSS + LLM 카테고리 분류. +"""외부 트렌드 수집 — NAVER 인기 + Google Trends + LLM 카테고리 분류. NAVER: 카테고리별 시드 키워드로 인기 검색 → 빈도 상위 추출. -Google Trends: pytrends 4.x가 Google API 변경으로 깨진 상태라 daily RSS endpoint 직접 호출. +Google Trends: pytrends 4.x + daily RSS endpoint 모두 폐기/404로 깨진 상태라 +'/trends/api/dailytrends' JSON API를 직접 호출 (응답 앞 `)]}'` XSSI 접두사 자름). LLM 분류 결과는 24h in-memory 캐시. """ @@ -9,7 +10,6 @@ import json import logging import re import time -import xml.etree.ElementTree as ET from typing import Any, Dict, List, Optional import requests @@ -31,7 +31,10 @@ _NAVER_HEADERS = { "X-Naver-Client-Secret": NAVER_CLIENT_SECRET, } -GOOGLE_TRENDS_RSS_URL = "https://trends.google.com/trends/trendingsearches/daily/rss?geo=KR" +GOOGLE_TRENDS_DAILY_URL = ( + "https://trends.google.com/trends/api/dailytrends" + "?hl=ko&tz=-540&geo=KR&ns=15" +) _PLACEHOLDER_SEEDS = {"...", "…", "tbd", "todo", "placeholder", "example"} @@ -165,27 +168,43 @@ def classify_keyword(keyword: str) -> str: # ── Google Trends ───────────────────────────────────────────────────────────── -# pytrends 4.x가 Google API 변경(404)으로 자주 깨지므로 daily trending searches -# RSS endpoint를 직접 호출. RSS는 공식 Google Trends 서비스가 제공하며 pn=geo -# 파라미터로 region 지정 가능. +# pytrends 4.x + daily RSS endpoint(`/trends/trendingsearches/daily/rss`) 모두 +# 폐기/404 상태라 Google Trends 비공식 JSON API `/trends/api/dailytrends`를 직접 +# 호출. 응답 앞에 `)]}'` XSSI 보호 prefix가 붙어있어 잘라낸 후 JSON 파싱. +# 응답 구조: default.trendingSearchesDays[].trendingSearches[].title.query + +_XSSI_PREFIX_RE = re.compile(r"^[\s\)\]\}',\n]+") + def fetch_google_trends() -> List[Dict[str, Any]]: - """Google Trends Daily RSS (한국) 직접 호출. 실패 시 빈 리스트로 graceful degrade.""" + """Google Trends Daily JSON API (한국) 직접 호출. 실패 시 빈 리스트.""" try: resp = requests.get( - GOOGLE_TRENDS_RSS_URL, + GOOGLE_TRENDS_DAILY_URL, timeout=15, headers={"User-Agent": "Mozilla/5.0 (insta-lab trend collector)"}, ) resp.raise_for_status() - root = ET.fromstring(resp.text) - titles = [ - (item.findtext("title") or "").strip() - for item in root.iter("item") - ] - titles = [t for t in titles if t] + body = _XSSI_PREFIX_RE.sub("", resp.text, count=1) + data = json.loads(body) + days = data.get("default", {}).get("trendingSearchesDays", []) or [] + raw_titles: List[str] = [] + for day in days: + for ts in day.get("trendingSearches", []) or []: + q = (ts.get("title") or {}).get("query", "") + if isinstance(q, str): + q = q.strip() + if q: + raw_titles.append(q) + # 중복 제거 (등장 순서 유지) + seen = set() + titles: List[str] = [] + for t in raw_titles: + if t not in seen: + seen.add(t) + titles.append(t) except Exception as e: - logger.warning("Google Trends RSS fetch failed: %s", e) + logger.warning("Google Trends daily fetch failed: %s", e) return [] items: List[Dict[str, Any]] = [] diff --git a/insta-lab/tests/test_trend_collector.py b/insta-lab/tests/test_trend_collector.py index 22fab8f..eae7964 100644 --- a/insta-lab/tests/test_trend_collector.py +++ b/insta-lab/tests/test_trend_collector.py @@ -77,18 +77,27 @@ def test_classify_keyword_with_cache(monkeypatch): assert calls["n"] == 1 -def test_fetch_google_trends_parses_rss_and_classifies(tmp_db, monkeypatch): - fake_rss = """ - - - Daily Search Trends - 기준금리 - BTS 컴백 - 스트레스 관리 - -""" +def test_fetch_google_trends_parses_json_and_classifies(tmp_db, monkeypatch): + import json as _json + payload = { + "default": { + "trendingSearchesDays": [ + { + "date": "20260517", + "trendingSearches": [ + {"title": {"query": "기준금리"}}, + {"title": {"query": "BTS 컴백"}}, + {"title": {"query": "스트레스 관리"}}, + # 다음 날 데이터에 중복 키워드 — 중복 제거 확인 + {"title": {"query": "기준금리"}}, + ], + } + ] + } + } fake_resp = MagicMock() - fake_resp.text = fake_rss + # 실제 Google 응답 형태: `)]}',\n` XSSI prefix가 앞에 붙음 + fake_resp.text = ")]}',\n" + _json.dumps(payload, ensure_ascii=False) fake_resp.raise_for_status.return_value = None monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp) monkeypatch.setattr(trend_collector, "classify_keyword", @@ -97,6 +106,7 @@ def test_fetch_google_trends_parses_rss_and_classifies(tmp_db, monkeypatch): trends = trend_collector.fetch_google_trends() by_kw = {t["keyword"]: t for t in trends} + assert set(by_kw.keys()) == {"기준금리", "BTS 컴백", "스트레스 관리"} # 중복 제거됨 assert by_kw["기준금리"]["category"] == "economy" assert by_kw["BTS 컴백"]["category"] == "celebrity" assert by_kw["스트레스 관리"]["category"] == "psychology" @@ -112,7 +122,7 @@ def test_collect_all_invokes_both_sources(tmp_db, monkeypatch): assert out == {"naver_popular": 5, "google_trends": 3} -def test_fetch_google_trends_graceful_on_rss_failure(monkeypatch): +def test_fetch_google_trends_graceful_on_api_failure(monkeypatch): fake_resp = MagicMock() fake_resp.raise_for_status.side_effect = RuntimeError("Google returned 404") monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)