305 lines
11 KiB
Python
305 lines
11 KiB
Python
"""Google Trends 数据源(可插拔实现)。
|
||
|
||
封装 pytrends + 官方 RSS,带本地缓存、指数退避重试、urllib3 兼容补丁。
|
||
- gt_trending:国家实时趋势榜(RSS,稳定)
|
||
- gt_style:按国家风格种子词抓 related_queries(设计灵感)
|
||
- gt_related:按 POD 行业种子词抓 related_queries(行业交叉验证)
|
||
|
||
注意:related_queries 是「单关键词」接口,一次传多个词会触发 Google /sorry(429),
|
||
因此逐词串行 + 节流 + 快速失败。缓存按 (key, 日期) 分文件:不删除历史文件,
|
||
24h 内读最新;超过 24h 重新抓取写当日新文件;抓取失败回退最新历史缓存兜底。
|
||
"""
|
||
import datetime
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import time
|
||
import xml.etree.ElementTree as ET
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List
|
||
|
||
import requests
|
||
import urllib3
|
||
from urllib3.util.retry import Retry as _Retry
|
||
|
||
# pytrends 4.x 仍用 method_whitelist;urllib3>=2 已改名 allowed_methods。做兼容补丁。
|
||
if "method_whitelist" not in _Retry.__init__.__code__.co_varnames:
|
||
_orig_retry_init = _Retry.__init__
|
||
|
||
def _patched_retry_init(self, *args, **kwargs):
|
||
if "method_whitelist" in kwargs:
|
||
kwargs["allowed_methods"] = kwargs.pop("method_whitelist")
|
||
_orig_retry_init(self, *args, **kwargs)
|
||
|
||
_Retry.__init__ = _patched_retry_init
|
||
|
||
from pytrends.request import TrendReq
|
||
|
||
from graph.paths import runtime_root
|
||
from .base import DataSource
|
||
|
||
CACHE_DIR = runtime_root() / ".cache" / "google_trends"
|
||
CACHE_TTL = 24 * 3600
|
||
CACHE_VERSION = "v3"
|
||
|
||
|
||
def _cache_fname(key: str, date_suffix: str = "") -> str:
|
||
digest = hashlib.md5((CACHE_VERSION + "|" + key).encode("utf-8")).hexdigest()
|
||
return f"{digest}.{date_suffix}.json" if date_suffix else f"{digest}.json"
|
||
|
||
|
||
def _cache_date(p: Path) -> datetime.date:
|
||
"""解析文件名里的 YYYYMMDD;无日期后缀则用 mtime。"""
|
||
for token in p.name.split("."):
|
||
if len(token) == 8 and token.isdigit():
|
||
try:
|
||
return datetime.datetime.strptime(token, "%Y%m%d").date()
|
||
except ValueError:
|
||
pass
|
||
try:
|
||
return datetime.date.fromtimestamp(p.stat().st_mtime)
|
||
except Exception:
|
||
return datetime.date.min
|
||
|
||
|
||
def _cache_paths(key: str) -> List[Path]:
|
||
"""该 key 的所有缓存文件(含旧版无日期后缀),按日期新旧降序。"""
|
||
digest = hashlib.md5((CACHE_VERSION + "|" + key).encode("utf-8")).hexdigest()
|
||
files = list(CACHE_DIR.glob(f"{digest}.*.json"))
|
||
legacy = CACHE_DIR / f"{digest}.json"
|
||
if legacy.exists():
|
||
files.append(legacy)
|
||
files.sort(key=_cache_date, reverse=True)
|
||
return files
|
||
|
||
|
||
def _cache_get(key: str):
|
||
"""返回 24h 内有效的最新缓存;无则 None。"""
|
||
for p in _cache_paths(key):
|
||
try:
|
||
fresh = (time.time() - p.stat().st_mtime) < CACHE_TTL
|
||
except Exception:
|
||
fresh = False
|
||
if fresh:
|
||
try:
|
||
return json.loads(p.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _cache_latest(key: str):
|
||
"""取最新缓存文件内容(不限时效),用于抓取失败时的兜底(不删除缓存,取最新)。"""
|
||
for p in _cache_paths(key):
|
||
try:
|
||
return json.loads(p.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
return None
|
||
|
||
|
||
def _cache_set(key: str, data) -> None:
|
||
"""写当日新文件(保留历史,不覆盖)。"""
|
||
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||
today = datetime.datetime.now().strftime("%Y%m%d")
|
||
path = CACHE_DIR / _cache_fname(key, today)
|
||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||
|
||
|
||
def _retry(func, max_attempts=3, base_delay=3):
|
||
last = None
|
||
for attempt in range(max_attempts):
|
||
try:
|
||
return func()
|
||
except Exception as e: # noqa: BLE001
|
||
last = e
|
||
if attempt == max_attempts - 1:
|
||
break
|
||
time.sleep(base_delay * (2 ** attempt))
|
||
raise last if last else RuntimeError("retry failed")
|
||
|
||
|
||
def _probe_google(timeout: float = 3.0) -> bool:
|
||
"""快速探测 trends.google.com 是否可达(连接+首字节)。
|
||
|
||
不可达时逐词 related_queries 会每个词超时 ~24s(10s 连接 ×2 重试 + 3s 节流),
|
||
24 个种子词要干等 10 分钟;探测失败直接跳过逐词抓取,回退缓存快速返回。
|
||
"""
|
||
try:
|
||
resp = requests.get("https://trends.google.com/trending/rss?geo=US",
|
||
timeout=timeout, headers={"User-Agent": "Mozilla/5.0"})
|
||
return resp.status_code < 500
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def fetch_related(keywords, geo="US", timeframe="today 3-m", time_budget=40):
|
||
"""逐关键词串行请求 related_queries(单关键词接口,避免 429)。
|
||
|
||
time_budget:整批逐词抓取的总时间预算(秒)。网络不稳/被限流时,每个词都可能
|
||
超时 ~10s+,24 个种子词会干等 10 分钟;超预算提前结束,回退缓存快速返回。
|
||
"""
|
||
merged = {}
|
||
t0 = time.time()
|
||
for kw in keywords:
|
||
if time.time() - t0 > time_budget:
|
||
print(f"[GoogleTrends] {geo} 逐词抓取超过 {time_budget}s 预算,提前结束,回退缓存")
|
||
break
|
||
time.sleep(3) # 节流
|
||
|
||
def _call(kw=kw):
|
||
# timeout=(connect, read):pytrends 默认 connect=2s 太短,网络波动即全挂,放宽到 5/15s
|
||
pytrends = TrendReq(hl="en-US", tz=360, retries=1, backoff_factor=0.5, timeout=(5, 15))
|
||
pytrends.build_payload(kw_list=[kw], timeframe=timeframe, geo=geo)
|
||
return pytrends.related_queries()
|
||
|
||
try:
|
||
data = _retry(_call, max_attempts=1, base_delay=1)
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[GoogleTrends] {geo} 种子「{kw}」抓取失败(跳过): {e}")
|
||
continue
|
||
if isinstance(data, dict):
|
||
merged.update(data)
|
||
return merged
|
||
|
||
|
||
def parse_related(raw, geo, source="gt_related"):
|
||
rows = []
|
||
for kw, payload in raw.items():
|
||
if not isinstance(payload, dict):
|
||
continue
|
||
for kind in ("rising", "top"):
|
||
df = payload.get(kind)
|
||
if df is None or getattr(df, "empty", True):
|
||
continue
|
||
for _, r in df.iterrows():
|
||
val = r["value"]
|
||
if isinstance(val, str) and val.strip().lower() == "breakout":
|
||
num = 100.0
|
||
else:
|
||
try:
|
||
num = float(val)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
rows.append({
|
||
"country": geo,
|
||
"topic": str(r["query"]).strip(),
|
||
"seed": kw,
|
||
"source": source,
|
||
"kind": kind,
|
||
"raw_score": num,
|
||
})
|
||
return rows
|
||
|
||
|
||
def _parse_traffic(desc):
|
||
m = re.search(r"([\d,]+)\+?\s*searches", desc or "", re.I)
|
||
if m:
|
||
try:
|
||
return float(m.group(1).replace(",", ""))
|
||
except ValueError:
|
||
return None
|
||
return None
|
||
|
||
|
||
def fetch_trending(geo="US", limit=40, skip_network=False):
|
||
key = f"trending|{geo}|{limit}"
|
||
cached = _cache_get(key)
|
||
if cached is not None:
|
||
return cached
|
||
if skip_network:
|
||
latest = _cache_latest(key)
|
||
if latest is not None:
|
||
print(f"[GoogleTrends 趋势] {geo} 网络不可达,回退最新缓存({len(latest)}条)")
|
||
return latest
|
||
return []
|
||
url = f"https://trends.google.com/trending/rss?geo={geo}"
|
||
try:
|
||
resp = requests.get(url, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
|
||
resp.raise_for_status()
|
||
root = ET.fromstring(resp.content)
|
||
rows = []
|
||
for idx, it in enumerate(root.findall(".//item")[:limit]):
|
||
title = (it.findtext("title") or "").strip()
|
||
if not title:
|
||
continue
|
||
score = _parse_traffic(it.findtext("description"))
|
||
if score is None:
|
||
score = float(limit - idx)
|
||
rows.append({
|
||
"country": geo, "topic": title, "seed": "",
|
||
"source": "gt_trending", "kind": "trending", "raw_score": score,
|
||
})
|
||
_cache_set(key, rows)
|
||
return rows
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[GoogleTrends 趋势] {geo} 抓取失败: {e}")
|
||
latest = _cache_latest(key)
|
||
if latest is not None:
|
||
print(f"[GoogleTrends 趋势] {geo} 回退最新缓存({len(latest)}条)")
|
||
return latest
|
||
return []
|
||
|
||
|
||
def get_rows(keywords, geo="US", timeframe="today 3-m", source="gt_related", time_budget=40):
|
||
key = f"{','.join(keywords)}|{geo}|{timeframe}|{source}|rows"
|
||
cached = _cache_get(key)
|
||
if cached is not None:
|
||
return cached
|
||
raw = fetch_related(keywords, geo=geo, timeframe=timeframe, time_budget=time_budget)
|
||
rows = parse_related(raw, geo, source=source)
|
||
if raw: # 有结果才写当日新缓存
|
||
_cache_set(key, rows)
|
||
return rows
|
||
# 抓取无果(429/超时):回退最新历史缓存,保证流水线不中断
|
||
latest = _cache_latest(key)
|
||
if latest is not None:
|
||
print(f"[GoogleTrends] {geo} 种子「{','.join(keywords)}」抓取无结果,回退最新缓存({len(latest)}行)")
|
||
return latest
|
||
return rows
|
||
|
||
|
||
class GoogleTrendsSource(DataSource):
|
||
name = "google_trends"
|
||
|
||
def fetch(self, country, country_config, global_config):
|
||
cc = country_config or {}
|
||
trending_cfg = cc.get("trending", {})
|
||
style_cfg = cc.get("style", {})
|
||
related_cfg = cc.get("related", {})
|
||
tf = cc.get("timeframe", "today 3-m")
|
||
|
||
rows: List[Dict[str, Any]] = []
|
||
|
||
# 0) 快速连通性探测:trends.google.com 不可达 → 跳过逐词抓取(每个词~24s 超时,
|
||
# 24 个种子词要干等 10 分钟),只回退 trending 缓存;无缓存则返回空,
|
||
# 由 fetch_node 回退 collected_keywords.json,让「采集热点」快速返回。
|
||
if not _probe_google():
|
||
print(f"[GoogleTrends] {country} trends.google.com 不可达,跳过逐词抓取,回退缓存")
|
||
if trending_cfg.get("enabled", True):
|
||
rows.extend(fetch_trending(geo=country,
|
||
limit=int(trending_cfg.get("limit", 40)),
|
||
skip_network=True))
|
||
return rows
|
||
|
||
# 1) 国家实时趋势榜(主源)
|
||
if trending_cfg.get("enabled", True):
|
||
limit = int(trending_cfg.get("limit", 40))
|
||
rows.extend(fetch_trending(geo=country, limit=limit))
|
||
|
||
# 2) 风格种子词
|
||
if style_cfg.get("enabled", True):
|
||
seeds = style_cfg.get("seeds", []) or []
|
||
if seeds:
|
||
rows.extend(get_rows(seeds, geo=country, timeframe=tf, source="gt_style",
|
||
time_budget=40))
|
||
|
||
# 3) 行业种子词
|
||
if related_cfg.get("enabled", True):
|
||
seeds = related_cfg.get("seed_keywords", []) or []
|
||
if seeds:
|
||
rows.extend(get_rows(seeds, geo=country, timeframe=tf, source="gt_related",
|
||
time_budget=40))
|
||
|
||
return rows
|