..
This commit is contained in:
parent
ab688243d7
commit
463bd4c647
54 changed files with 13239 additions and 625 deletions
175
analysis/og_preflight.py
Normal file
175
analysis/og_preflight.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
#!/usr/bin/env python3
|
||||
"""OG / share-card preflight for Perfect Postcode growth findings.
|
||||
|
||||
Before publishing growth findings we must confirm that each finding's Open
|
||||
Graph share card actually renders, and that the filter name baked into its
|
||||
deep-link map query still matches a live feature. The OG render has no guard
|
||||
today, so a wrong/renamed filter silently ships a card whose map contradicts
|
||||
the headline.
|
||||
|
||||
For every analysis/out/findings/*.json this script:
|
||||
1. Fetches the OG render at {BASE}/api/screenshot?og=1&{map_query} and checks
|
||||
it is a non-trivial image (HTTP 200, image/*, > MIN_IMAGE_BYTES, and
|
||||
~1200x630 when Pillow is installed).
|
||||
2. Cross-checks every `filter=<NAME>:...` in the map query against the live
|
||||
feature list from {BASE}/api/features, failing on any drifted name.
|
||||
|
||||
Exits non-zero if any finding fails, so it can gate a publish step.
|
||||
|
||||
Usage:
|
||||
source .venv/bin/activate && python analysis/og_preflight.py --base https://perfect-postcode.co.uk
|
||||
|
||||
Needs the server (local dev or prod) reachable at --base / $OG_BASE
|
||||
(default http://localhost:8001); it makes live HTTP requests.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
# Pillow is optional: when present we additionally decode the image and verify
|
||||
# its dimensions; otherwise we fall back to status/content-type/size checks.
|
||||
try:
|
||||
from PIL import Image # type: ignore
|
||||
|
||||
_HAVE_PIL = True
|
||||
except Exception:
|
||||
_HAVE_PIL = False
|
||||
|
||||
FINDINGS_DIR = Path(__file__).resolve().parent / "out" / "findings"
|
||||
DEFAULT_BASE = os.environ.get("OG_BASE", "http://localhost:8001")
|
||||
MIN_IMAGE_BYTES = 5 * 1024 # a blank/error render is far smaller than this
|
||||
OG_SIZE = (1200, 630) # screenshot service VIEWPORT, device scale factor 1
|
||||
SIZE_TOLERANCE = 4 # px slack when checking decoded dimensions
|
||||
|
||||
|
||||
def http_get(url: str, timeout: float):
|
||||
"""GET a URL, returning (status, content_type, body_bytes). Raises on error."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "og-preflight"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return resp.status, resp.headers.get("Content-Type", ""), resp.read()
|
||||
|
||||
|
||||
def filter_names(map_query: str) -> list[str]:
|
||||
"""Extract the feature name from each `filter=<name>:...` query param.
|
||||
|
||||
Mirrors the frontend parser (url-state.ts): the name is everything before
|
||||
the first colon of a URL-decoded filter value, e.g. "Est. price per sqm".
|
||||
Non-filter params (school=, crime=, ...) carry no feature name and are
|
||||
ignored here.
|
||||
"""
|
||||
names = []
|
||||
for key, value in urllib.parse.parse_qsl(map_query, keep_blank_values=True):
|
||||
if key == "filter" and ":" in value:
|
||||
names.append(value.split(":", 1)[0])
|
||||
return names
|
||||
|
||||
|
||||
def live_feature_names(base: str, timeout: float) -> set[str]:
|
||||
"""Collect every feature `name` from the grouped {base}/api/features response."""
|
||||
status, _ctype, body = http_get(f"{base}/api/features", timeout)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"/api/features returned HTTP {status}")
|
||||
payload = json.loads(body)
|
||||
names: set[str] = set()
|
||||
for group in payload.get("groups", []):
|
||||
for feature in group.get("features", []):
|
||||
name = feature.get("name")
|
||||
if name:
|
||||
names.add(name)
|
||||
return names
|
||||
|
||||
|
||||
def check_og(map_query: str, base: str, timeout: float) -> tuple[bool, str]:
|
||||
"""Render the OG card and validate it looks like a real image."""
|
||||
url = f"{base}/api/screenshot?og=1&{map_query}"
|
||||
try:
|
||||
status, ctype, body = http_get(url, timeout)
|
||||
except Exception as exc: # network / HTTP error
|
||||
return False, f"request failed: {exc}"
|
||||
if status != 200:
|
||||
return False, f"HTTP {status}"
|
||||
if not ctype.lower().startswith("image/"):
|
||||
return False, f"content-type {ctype!r} is not an image"
|
||||
if len(body) < MIN_IMAGE_BYTES:
|
||||
return False, f"image too small ({len(body)} B) - likely blank/error render"
|
||||
if _HAVE_PIL:
|
||||
try:
|
||||
img = Image.open(BytesIO(body))
|
||||
img.load() # force a full decode; raises on a corrupt image
|
||||
w, h = img.size
|
||||
except Exception as exc:
|
||||
return False, f"undecodable image: {exc}"
|
||||
if abs(w - OG_SIZE[0]) > SIZE_TOLERANCE or abs(h - OG_SIZE[1]) > SIZE_TOLERANCE:
|
||||
return False, f"unexpected size {w}x{h} (want ~{OG_SIZE[0]}x{OG_SIZE[1]})"
|
||||
return True, f"{len(body)} B, {w}x{h}"
|
||||
return True, f"{len(body)} B"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Preflight OG cards for growth findings.")
|
||||
parser.add_argument(
|
||||
"--base", default=DEFAULT_BASE, help=f"server base URL (default {DEFAULT_BASE}; or $OG_BASE)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--findings-dir", default=str(FINDINGS_DIR), help="directory of finding *.json files"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout", type=float, default=30.0, help="per-request timeout in seconds (default 30)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
base = args.base.rstrip("/")
|
||||
|
||||
finding_paths = sorted(Path(args.findings_dir).glob("*.json"))
|
||||
if not finding_paths:
|
||||
print(f"No findings found in {args.findings_dir}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
# The drift guard needs the live feature list; if we cannot fetch it the
|
||||
# whole gate is inconclusive, so bail out non-zero before checking cards.
|
||||
try:
|
||||
live_names = live_feature_names(base, args.timeout)
|
||||
except Exception as exc:
|
||||
print(f"FATAL: could not load {base}/api/features: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Loaded {len(live_names)} live feature names from {base}/api/features")
|
||||
if not _HAVE_PIL:
|
||||
print("(Pillow not installed - skipping image dimension check)")
|
||||
|
||||
passed = failed = 0
|
||||
for path in finding_paths:
|
||||
finding = json.loads(path.read_text())
|
||||
slug = finding.get("slug", path.stem)
|
||||
map_query = finding.get("map_query")
|
||||
if not map_query:
|
||||
print(f"FAIL {slug}: no map_query field")
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
# Drift guard first: a renamed filter is the silent-ship bug we most
|
||||
# want to catch, and it makes the rendered card meaningless anyway.
|
||||
drifted = [n for n in filter_names(map_query) if n not in live_names]
|
||||
og_ok, og_reason = check_og(map_query, base, args.timeout)
|
||||
|
||||
if drifted:
|
||||
print(f"FAIL {slug}: unknown filter name(s): {', '.join(drifted)}")
|
||||
failed += 1
|
||||
elif not og_ok:
|
||||
print(f"FAIL {slug}: OG render - {og_reason}")
|
||||
failed += 1
|
||||
else:
|
||||
print(f"PASS {slug}: OG {og_reason}")
|
||||
passed += 1
|
||||
|
||||
print(f"\n{passed} passed / {failed} failed (of {passed + failed})")
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue