feat(market-research): add best-effort Google Trends fetcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
36f64a4419
commit
f705033061
|
|
@ -0,0 +1,79 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Best-effort Google Trends interest signal. NEVER hard-fails.
|
||||||
|
|
||||||
|
Google Trends has no API key but the unofficial endpoint needs a token dance
|
||||||
|
and is fragile under stdlib-only. So: try the live token+timeline flow; on ANY
|
||||||
|
failure print {"ok": false, "fallback": "websearch"} and exit 0, signalling the
|
||||||
|
skill to fall back to WebSearch for the same momentum signal. The JSON parser is
|
||||||
|
unit-tested offline via --json-file. Stdlib-only, no pip.
|
||||||
|
"""
|
||||||
|
import sys, json, argparse, urllib.request, urllib.parse, ssl, subprocess, shutil
|
||||||
|
|
||||||
|
UA = "Mozilla/5.0"
|
||||||
|
|
||||||
|
def _http_get(url, headers=None):
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": UA, **(headers or {})})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.read().decode("utf-8", "replace")
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
if not isinstance(e.reason, ssl.SSLError):
|
||||||
|
raise
|
||||||
|
if not shutil.which("curl"):
|
||||||
|
raise
|
||||||
|
out = subprocess.run(["curl", "-sL", "--fail", "-A", UA, url],
|
||||||
|
capture_output=True, timeout=60)
|
||||||
|
if out.returncode != 0:
|
||||||
|
raise
|
||||||
|
return out.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def parse_timeline(raw):
|
||||||
|
"""raw = the timeline JSON (Google prefixes live responses with )]}', —
|
||||||
|
callers strip it first). Returns the computed interest summary dict."""
|
||||||
|
data = json.loads(raw)
|
||||||
|
points = data["default"]["timelineData"]
|
||||||
|
values = [int(p["value"][0]) for p in points if p.get("value")]
|
||||||
|
if not values:
|
||||||
|
raise ValueError("no timeline values")
|
||||||
|
avg = round(sum(values) / len(values), 2)
|
||||||
|
first, latest = values[0], values[-1]
|
||||||
|
pct = round((latest - first) / first * 100, 2) if first else None
|
||||||
|
return {"ok": True, "avg_interest": avg, "latest_interest": latest,
|
||||||
|
"trend_pct": pct, "points": len(values)}
|
||||||
|
|
||||||
|
def fetch_live(term):
|
||||||
|
"""Token dance: explore -> widget token -> multiline timeline."""
|
||||||
|
base = "https://trends.google.com/trends/api"
|
||||||
|
comp = urllib.parse.quote(json.dumps(
|
||||||
|
{"comparisonItem": [{"keyword": term, "geo": "", "time": "today 3-m"}],
|
||||||
|
"category": 0, "property": ""}))
|
||||||
|
explore = _http_get(f"{base}/explore?hl=en-US&tz=0&req={comp}")
|
||||||
|
widgets = json.loads(explore.lstrip(")]}',\n"))
|
||||||
|
w = next(x for x in widgets["widgets"] if x.get("id") == "TIMESERIES")
|
||||||
|
token = w["token"]
|
||||||
|
reqobj = urllib.parse.quote(json.dumps(w["request"]))
|
||||||
|
tl = _http_get(f"{base}/widgetdata/multiline?hl=en-US&tz=0&req={reqobj}&token={token}")
|
||||||
|
return tl.lstrip(")]}',\n")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("term")
|
||||||
|
ap.add_argument("--json-file")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if args.json_file:
|
||||||
|
with open(args.json_file, encoding="utf-8") as f:
|
||||||
|
raw = f.read()
|
||||||
|
else:
|
||||||
|
raw = fetch_live(args.term)
|
||||||
|
res = parse_timeline(raw)
|
||||||
|
res["term"] = args.term
|
||||||
|
print(json.dumps(res, indent=2))
|
||||||
|
except Exception:
|
||||||
|
print(json.dumps({"ok": False, "term": args.term,
|
||||||
|
"fallback": "websearch"}, indent=2))
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
{"default":{"timelineData":[
|
||||||
|
{"time":"1","value":[50]},
|
||||||
|
{"time":"2","value":[60]},
|
||||||
|
{"time":"3","value":[100]}
|
||||||
|
]}}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
import json, subprocess, sys, os
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SCRIPT = os.path.join(HERE, "..", "skills", "market-research", "scripts", "trends.py")
|
||||||
|
FIXTURE = os.path.join(HERE, "fixtures", "trends-sample.json")
|
||||||
|
|
||||||
|
def run(*args):
|
||||||
|
# exit 0 always (never hard-fails); capture stdout
|
||||||
|
out = subprocess.run([sys.executable, SCRIPT, *args],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
return out.returncode, json.loads(out.stdout)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
fails = []
|
||||||
|
def check(name, cond):
|
||||||
|
print(f"{'PASS' if cond else 'FAIL'}: {name}")
|
||||||
|
if not cond: fails.append(name)
|
||||||
|
|
||||||
|
rc, d = run("habit tracker", "--json-file", FIXTURE)
|
||||||
|
check("exit 0", rc == 0)
|
||||||
|
check("ok", d["ok"] is True)
|
||||||
|
check("points", d["points"] == 3)
|
||||||
|
check("latest_interest", d["latest_interest"] == 100)
|
||||||
|
check("avg_interest", d["avg_interest"] == 70.0) # mean(50,60,100)=70
|
||||||
|
check("trend_pct", d["trend_pct"] == 100.0) # (100-50)/50*100
|
||||||
|
|
||||||
|
# bad fixture -> graceful fallback, still exit 0
|
||||||
|
rc2, d2 = run("x", "--json-file", os.path.join(HERE, "fixtures", "play-chart.html"))
|
||||||
|
check("fallback exit 0", rc2 == 0)
|
||||||
|
check("fallback ok false", d2["ok"] is False)
|
||||||
|
check("fallback flag", d2["fallback"] == "websearch")
|
||||||
|
|
||||||
|
sys.exit(1 if fails else 0)
|
||||||
|
|
||||||
|
main()
|
||||||
Loading…
Reference in New Issue