POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""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 fetch_related(keywords, geo="US", timeframe="today 3-m"):
|
||||
"""逐关键词串行请求 related_queries(单关键词接口,避免 429)。"""
|
||||
merged = {}
|
||||
for kw in keywords:
|
||||
time.sleep(3) # 节流
|
||||
|
||||
def _call(kw=kw):
|
||||
# timeout=(connect, read):pytrends 默认 connect=2s 太短,网络波动即全挂,放宽到 10/30s
|
||||
pytrends = TrendReq(hl="en-US", tz=360, retries=2, backoff_factor=0.5, timeout=(10, 30))
|
||||
pytrends.build_payload(kw_list=[kw], timeframe=timeframe, geo=geo)
|
||||
return pytrends.related_queries()
|
||||
|
||||
try:
|
||||
data = _retry(_call, max_attempts=2, 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):
|
||||
key = f"trending|{geo}|{limit}"
|
||||
cached = _cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
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"):
|
||||
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)
|
||||
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]] = []
|
||||
|
||||
# 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"))
|
||||
|
||||
# 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"))
|
||||
|
||||
return rows
|
||||
Reference in New Issue
Block a user