diff --git a/lib/seo-data/fixtures/crux_mobile.json b/lib/seo-data/fixtures/crux_mobile.json new file mode 100644 index 0000000..9c30f30 --- /dev/null +++ b/lib/seo-data/fixtures/crux_mobile.json @@ -0,0 +1,4 @@ +{"record":{"key":{"formFactor":"PHONE"},"metrics":{ + "largest_contentful_paint":{"percentiles":{"p75":2100}}, + "interaction_to_next_paint":{"percentiles":{"p75":180}}, + "cumulative_layout_shift":{"percentiles":{"p75":"0.08"}}}}} diff --git a/lib/seo-data/google_seo.py b/lib/seo-data/google_seo.py new file mode 100644 index 0000000..7d6d74d --- /dev/null +++ b/lib/seo-data/google_seo.py @@ -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() diff --git a/lib/seo-data/seo-data.test.sh b/lib/seo-data/seo-data.test.sh index 81470d0..2324313 100644 --- a/lib/seo-data/seo-data.test.sh +++ b/lib/seo-data/seo-data.test.sh @@ -29,6 +29,18 @@ DPERM="$(stat -c '%a' "$(dirname "$STORE")")" [ "$DPERM" = "700" ] && ok "store dir is 0700" || no "store dir 0700" "got $DPERM" rm -rf "$TMP" +echo "── crux (mock) ──" +CRUX_OK="$(SEO_DATA_MOCK_DIR="$REPO/lib/seo-data/fixtures" \ + python3 "$SD/google_seo.py" crux --url https://ex.com --strategy mobile)" +has "crux status ok" "$CRUX_OK" '"status": "ok"' +has "crux lcp p75 mapped" "$CRUX_OK" '"lcp_p75_ms": 2100' +has "crux inp p75 mapped" "$CRUX_OK" '"inp_p75_ms": 180' +has "crux cls p75 mapped" "$CRUX_OK" '"cls_p75": 0.08' +CRUX_DEG="$(env -u CRUX_API_KEY -u SEO_DATA_MOCK_DIR \ + python3 "$SD/google_seo.py" crux --url https://ex.com)" +has "crux degrades w/o key" "$CRUX_DEG" '"status": "degraded"' +has "crux degrade reason" "$CRUX_DEG" 'no_crux_key' + echo "" echo "seo-data engine: $PASS pass, $FAIL fail" [ "$FAIL" -eq 0 ]