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:
@@ -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]] = []
|
||||
|
||||
@@ -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 = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Daily Search Trends</title>
|
||||
<item><title>기준금리</title></item>
|
||||
<item><title>BTS 컴백</title></item>
|
||||
<item><title>스트레스 관리</title></item>
|
||||
</channel>
|
||||
</rss>"""
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user