feat(seo-data): CrUX field-data fetch with mock mode and graceful degrade

This commit is contained in:
Bastien Chanot
2026-07-10 01:16:11 +02:00
parent 0f7fd5b678
commit e214da036d
3 changed files with 92 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""CrUX + GSC fetch → normalized JSON. Third-party imports are LAZY so mock and
degraded paths run stdlib-only (no venv, no network)."""
import argparse, json, os, sys
def _mock(name):
d = os.environ.get("SEO_DATA_MOCK_DIR")
if not d:
return None
path = os.path.join(d, name)
if not os.path.exists(path):
return None
with open(path, encoding="utf-8") as f:
return json.load(f)
def _norm_crux(raw):
m = raw["record"]["metrics"]
def p75(metric):
return m.get(metric, {}).get("percentiles", {}).get("p75")
out = {"status": "ok", "source": "crux"}
lcp = p75("largest_contentful_paint")
inp = p75("interaction_to_next_paint")
cls = p75("cumulative_layout_shift")
# Low-traffic origins often miss a metric (INP notably) — omit, don't crash.
if lcp is not None:
out["lcp_p75_ms"] = int(lcp)
if inp is not None:
out["inp_p75_ms"] = int(inp)
if cls is not None:
out["cls_p75"] = float(cls)
if len(out) == 2: # no metric at all
return {"status": "degraded", "reason": "no_field_data"}
return out
def _crux_query(key, body):
import requests # lazy
return requests.post(
"https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=" + key,
json=body, timeout=20)
def crux(url, strategy="mobile"):
raw = _mock("crux_%s.json" % strategy)
if raw is None:
key = os.environ.get("CRUX_API_KEY")
if not key:
return {"status": "degraded", "reason": "no_crux_key"}
ff = "PHONE" if strategy == "mobile" else "DESKTOP"
r = _crux_query(key, {"url": url, "formFactor": ff})
if r.status_code == 404: # no page-level data → try origin-level
r = _crux_query(key, {"origin": url.rstrip("/"), "formFactor": ff})
if r.status_code == 404:
return {"status": "degraded", "reason": "no_field_data"}
if r.status_code == 429:
return {"status": "degraded", "reason": "rate_limited"}
r.raise_for_status()
raw = r.json()
return _norm_crux(raw)
def _cli():
p = argparse.ArgumentParser()
sub = p.add_subparsers(dest="cmd", required=True)
pc = sub.add_parser("crux")
pc.add_argument("--url", required=True)
pc.add_argument("--strategy", default="mobile", choices=["mobile", "desktop"])
pc.add_argument("--store", default=None) # accepted+ignored: uniform fetch.sh dispatch
args = p.parse_args()
try:
if args.cmd == "crux":
print(json.dumps(crux(args.url, args.strategy), indent=2))
except Exception:
# Fail-open data contract: ANY unexpected error (HTTP 403/5xx, DNS,
# timeout) degrades with exit 0 — never a traceback, never empty stdout.
print(json.dumps({"status": "degraded", "reason": "unexpected_error"}))
if __name__ == "__main__":
_cli()