fix(insta-lab): Google Trends — RSS endpoint도 404 폐기, dailytrends JSON API로 교체

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 파싱.
중복 키워드 제거 + 등장 순서 보존.
This commit is contained in:
2026-05-17 09:30:40 +09:00
parent bf5897fc85
commit cfbb72051f
2 changed files with 57 additions and 28 deletions

View File

@@ -1,7 +1,8 @@
"""외부 트렌드 수집 — NAVER 인기 + Google Trends RSS + LLM 카테고리 분류. """외부 트렌드 수집 — NAVER 인기 + Google Trends + LLM 카테고리 분류.
NAVER: 카테고리별 시드 키워드로 인기 검색 → 빈도 상위 추출. 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 캐시. LLM 분류 결과는 24h in-memory 캐시.
""" """
@@ -9,7 +10,6 @@ import json
import logging import logging
import re import re
import time import time
import xml.etree.ElementTree as ET
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import requests import requests
@@ -31,7 +31,10 @@ _NAVER_HEADERS = {
"X-Naver-Client-Secret": NAVER_CLIENT_SECRET, "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"} _PLACEHOLDER_SEEDS = {"...", "", "tbd", "todo", "placeholder", "example"}
@@ -165,27 +168,43 @@ def classify_keyword(keyword: str) -> str:
# ── Google Trends ───────────────────────────────────────────────────────────── # ── Google Trends ─────────────────────────────────────────────────────────────
# pytrends 4.x가 Google API 변경(404)으로 자주 깨지므로 daily trending searches # pytrends 4.x + daily RSS endpoint(`/trends/trendingsearches/daily/rss`) 모두
# RSS endpoint를 직접 호출. RSS는 공식 Google Trends 서비스가 제공하며 pn=geo # 폐기/404 상태라 Google Trends 비공식 JSON API `/trends/api/dailytrends`를 직접
# 파라미터로 region 지정 가능. # 호출. 응답 앞에 `)]}'` XSSI 보호 prefix가 붙어있어 잘라낸 후 JSON 파싱.
# 응답 구조: default.trendingSearchesDays[].trendingSearches[].title.query
_XSSI_PREFIX_RE = re.compile(r"^[\s\)\]\}',\n]+")
def fetch_google_trends() -> List[Dict[str, Any]]: def fetch_google_trends() -> List[Dict[str, Any]]:
"""Google Trends Daily RSS (한국) 직접 호출. 실패 시 빈 리스트로 graceful degrade.""" """Google Trends Daily JSON API (한국) 직접 호출. 실패 시 빈 리스트."""
try: try:
resp = requests.get( resp = requests.get(
GOOGLE_TRENDS_RSS_URL, GOOGLE_TRENDS_DAILY_URL,
timeout=15, timeout=15,
headers={"User-Agent": "Mozilla/5.0 (insta-lab trend collector)"}, headers={"User-Agent": "Mozilla/5.0 (insta-lab trend collector)"},
) )
resp.raise_for_status() resp.raise_for_status()
root = ET.fromstring(resp.text) body = _XSSI_PREFIX_RE.sub("", resp.text, count=1)
titles = [ data = json.loads(body)
(item.findtext("title") or "").strip() days = data.get("default", {}).get("trendingSearchesDays", []) or []
for item in root.iter("item") raw_titles: List[str] = []
] for day in days:
titles = [t for t in titles if t] 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: except Exception as e:
logger.warning("Google Trends RSS fetch failed: %s", e) logger.warning("Google Trends daily fetch failed: %s", e)
return [] return []
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []

View File

@@ -77,18 +77,27 @@ def test_classify_keyword_with_cache(monkeypatch):
assert calls["n"] == 1 assert calls["n"] == 1
def test_fetch_google_trends_parses_rss_and_classifies(tmp_db, monkeypatch): def test_fetch_google_trends_parses_json_and_classifies(tmp_db, monkeypatch):
fake_rss = """<?xml version="1.0" encoding="UTF-8"?> import json as _json
<rss version="2.0"> payload = {
<channel> "default": {
<title>Daily Search Trends</title> "trendingSearchesDays": [
<item><title>기준금리</title></item> {
<item><title>BTS 컴백</title></item> "date": "20260517",
<item><title>스트레스 관리</title></item> "trendingSearches": [
</channel> {"title": {"query": "기준금리"}},
</rss>""" {"title": {"query": "BTS 컴백"}},
{"title": {"query": "스트레스 관리"}},
# 다음 날 데이터에 중복 키워드 — 중복 제거 확인
{"title": {"query": "기준금리"}},
],
}
]
}
}
fake_resp = MagicMock() 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 fake_resp.raise_for_status.return_value = None
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp) monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)
monkeypatch.setattr(trend_collector, "classify_keyword", 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() trends = trend_collector.fetch_google_trends()
by_kw = {t["keyword"]: t for t in trends} by_kw = {t["keyword"]: t for t in trends}
assert set(by_kw.keys()) == {"기준금리", "BTS 컴백", "스트레스 관리"} # 중복 제거됨
assert by_kw["기준금리"]["category"] == "economy" assert by_kw["기준금리"]["category"] == "economy"
assert by_kw["BTS 컴백"]["category"] == "celebrity" assert by_kw["BTS 컴백"]["category"] == "celebrity"
assert by_kw["스트레스 관리"]["category"] == "psychology" 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} 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 = MagicMock()
fake_resp.raise_for_status.side_effect = RuntimeError("Google returned 404") fake_resp.raise_for_status.side_effect = RuntimeError("Google returned 404")
monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp) monkeypatch.setattr(trend_collector.requests, "get", lambda *a, **kw: fake_resp)