#!/usr/bin/env python3 """Build the SEO page batch from findings. Emits, from analysis/out/findings/*.json: - frontend/public//index.html: standalone, crawlable, on-brand landing page per finding - frontend/public/cheaper-twins/index.html: a hub page linking every twin (internal-link mesh) - server-rs/src/generated_data_pages.rs: registry the og_middleware consults (path/title/desc + the screenshot query so the OG card shows the finding, not a blank map) - frontend/public/sitemap.xml: data-page entries inserted between markers (idempotent) These are static files: webpack copies public/ -> dist/, and the server serves dist//index.html exactly like the existing prerendered pages. og_middleware must register each path or it 404s. That registration is generated_data_pages.rs. English-only by design (no 6-locale i18n), per the growth strategy. Run: source .venv/bin/activate && python analysis/build_pages.py (after cheaper_twins.py + generate_findings.py) """ from __future__ import annotations import html import json import urllib.parse from pathlib import Path ROOT = Path(".") FIND = Path("analysis/out/findings") PUBLIC = ROOT / "frontend/public" RUST = ROOT / "server-rs/src/generated_data_pages.rs" SITEMAP = PUBLIC / "sitemap.xml" SITE = "https://perfect-postcode.co.uk" CSS = """ :root{color-scheme:light dark} *{box-sizing:border-box} body{margin:0;font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; color:#0b1220;background:#f7f5f0;line-height:1.6} a{color:#0d9488} .topbar{background:#0b1220;color:#e7ecf3;padding:.7rem 1.25rem;display:flex;justify-content:space-between;align-items:center} .topbar a{color:#2dd4bf;text-decoration:none;font-weight:700} .wrap{max-width:54rem;margin:0 auto;padding:0 1.25rem} .hero{background:linear-gradient(#0b1220,#111a2e);color:#fff;padding:3rem 0 2.5rem} .eyebrow{color:#2dd4bf;font-weight:700;text-transform:uppercase;letter-spacing:.05em;font-size:.8rem;margin:0 0 .5rem} h1{font-size:2rem;line-height:1.15;margin:.2rem 0 .6rem} .hook{color:#cbd5e1;font-size:1.15rem;margin:.5rem 0 1.4rem;max-width:42rem} .big{font-size:3rem;font-weight:800;color:#2dd4bf;margin:.3rem 0} .cta{display:inline-block;margin-top:.4rem;padding:.8rem 1.4rem;border-radius:.6rem;background:#f09a22;color:#0b1220; font-weight:700;text-decoration:none;box-shadow:0 6px 20px rgba(122,57,5,.35)} .cta:hover{background:#df8614} table{width:100%;border-collapse:collapse;margin:1.5rem 0;background:#fff;border-radius:.6rem;overflow:hidden; box-shadow:0 1px 3px rgba(0,0,0,.08)} th,td{padding:.7rem .9rem;text-align:left;border-bottom:1px solid #ece8e0;font-size:.95rem} thead th{background:#0b1220;color:#fff} tbody tr:last-child td{border-bottom:0} .val{font-variant-numeric:tabular-nums;font-weight:600} .cheaper{color:#0d9488} section{margin:2rem 0} h2{font-size:1.3rem;margin:0 0 .6rem} .note{font-size:.82rem;color:#6b7280;border-top:1px solid #e5e1d8;padding-top:1rem;margin-top:2rem} .links{display:grid;gap:.6rem;grid-template-columns:repeat(auto-fit,minmax(15rem,1fr));margin:1rem 0} .links a{display:block;background:#fff;border:1px solid #ece8e0;border-radius:.5rem;padding:.8rem 1rem;text-decoration:none;color:#0b1220} .links a:hover{border-color:#5eead4} .links b{color:#0d9488} .cards{display:grid;gap:1rem;grid-template-columns:repeat(auto-fit,minmax(18rem,1fr))} .card{background:#fff;border:1px solid #ece8e0;border-radius:.6rem;padding:1.1rem;text-decoration:none;color:#0b1220} .card:hover{border-color:#5eead4} .card .n{color:#0d9488;font-weight:800;font-size:1.4rem} footer{color:#6b7280;font-size:.8rem;padding:2rem 0 3rem} @media(prefers-color-scheme:dark){body{background:#0b1220;color:#e7ecf3}table{background:#13203a} th,td{border-color:#223153}.card,.links a{background:#13203a;border-color:#223153;color:#e7ecf3} .note{border-color:#223153;color:#9fb0c3}} """ def esc(s) -> str: return html.escape(str(s), quote=True) def gbp(n) -> str: return f"£{int(n):,}" def rust_str(s: str) -> str: return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"' def page_shell(title: str, desc: str, path: str, jsonld: dict, body: str) -> str: return f""" {esc(title)} | Perfect Postcode {body}
Sources: {esc(SOURCES)}. {esc(ATTRIB)}
""" SOURCES = "HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk" ATTRIB = "Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0." def breadcrumb(path: str, name: str) -> dict: return { "@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [ {"@type": "ListItem", "position": 1, "name": "Home", "item": SITE + "/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": SITE + "/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": name, "item": SITE + path}, ], } def twin_html(f: dict, siblings: list[dict]) -> str: p, w, s = f["pricey"], f["twin"], f["stats"] typ = s["dominant_type"].lower() map_url = f["map_url"] rows = [ ("Estimated £/m²", gbp(p["est_psqm"]), gbp(w["est_psqm"])), ("On a 90 m² home", gbp(p["est_psqm"] * 90), f'{gbp(w["est_psqm"]*90)}'), ("Dominant property type", esc(s["dominant_type"]), esc(s["dominant_type"])), ("Typical build era", f"~{s['build_year']}", f"~{s['build_year']}"), ("Good+ secondary catchments", f"{s['good_secondary_catchments']:.1f}", f"{s['good_secondary_catchments']:.1f}"), ("Nearest station", f"~{s['station_km']} km", f"~{s['station_km']} km"), ("Sales in sample (N)", f"{p['n']:,}", f"{w['n']:,}"), ] table_rows = "\n".join( f"{esc(k)}{a}{b}" for k, a, b in rows ) prose = ( f"{esc(p['label'])} and {esc(w['label'])} sit about {s['distance_km']} km apart, share the same " f"dominant housing ({typ}, typically built around {s['build_year']}), comparable good-school catchments " f"and the same level of station access. Yet an equivalent home works out roughly " f"{s['gap_pct']:.0f}% (about {gbp(s['gap_on_90sqm'])} on a 90 m² property) cheaper in " f"{esc(w['name'] or w['sector'])}. On the measures that move price they are near-identical; the gap " f"is mostly the premium attached to the better-known name." ) sib_links = "\n".join( f'{esc(sf["title"].split(":")[0])}
{esc(sf["hook"])}
' for sf in siblings ) body = f"""

Cheaper twin · England

{esc(f['title'])}

{esc(f['shocking_number'])} cheaper / m²

{esc(f['hook'])}

See both areas on the live map →
{table_rows}
{esc(p['label'])}{esc(w['label'])}

The same life, one postcode cheaper

{prose}

How we worked this out

{esc(f['methodology'])}

Compare more areas

{esc(ATTRIB)} Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.

""" return page_shell(f["title"], meta_desc(f), f["page_path"], breadcrumb(f["page_path"], f["title"].split(":")[0]), body) def national_html(f: dict) -> str: b, d = f["stats"]["best"], f["stats"]["dearest"] body = f"""

England · value index

{esc(f['title'])}

{esc(f['shocking_number'])}

{esc(f['hook'])}

Explore the value map →
SectorEst. £/m²m² for £100kN
{esc(b['sector'])} (best value){gbp(b['est_psqm'])}{b['sqm_per_100k']:.0f} m²{b['n']:,}
{esc(d['sector'])} (dearest){gbp(d['est_psqm'])}{d['sqm_per_100k']:.0f} m²{d['n']:,}

How we worked this out

{esc(f['methodology'])}

More

{esc(ATTRIB)}

""" return page_shell(f["title"], meta_desc(f), f["page_path"], breadcrumb(f["page_path"], f["title"]), body) def hub_html(twins: list[dict]) -> str: cards = "\n".join( f'
{esc(f["shocking_number"])}
' f'
{esc(f["title"].split(":")[0])}
' for f in twins ) body = f"""

England

Cheaper twins: pay for the home, not the name

Pairs of neighbouring England postcodes that share a station, school catchment and build era, but sell thousands apart because one name got bid up. Built from {len(twins)} verified pairs.

Find your cheaper twin on the map →
{cards}

{esc(ATTRIB)}

""" jsonld = {"@context": "https://schema.org", "@type": "CollectionPage", "name": "Cheaper twins", "url": SITE + "/cheaper-twins"} return page_shell("Cheaper twin postcodes in England", "Neighbouring England postcodes priced apart for the name, not the home. Find the cheaper twin of a pricier area.", "/cheaper-twins", jsonld, body) def meta_desc(f: dict) -> str: return (f.get("hook") or f.get("title"))[:155] def write_page(path: str, content: str): out = PUBLIC / path.strip("/") / "index.html" out.parent.mkdir(parents=True, exist_ok=True) out.write_text(content) def main(): findings = [json.loads(p.read_text()) for p in sorted(FIND.glob("*.json"))] twins = [f for f in findings if f["type"] == "cheaper_twin"] nationals = [f for f in findings if f["type"] == "national_table"] pages = [] # (path, title, description, screenshot_query) for i, f in enumerate(twins): siblings = [twins[(i + k) % len(twins)] for k in (1, 2, 3)][: max(0, len(twins) - 1)] write_page(f["page_path"], twin_html(f, siblings)) pages.append((f["page_path"], f["title"], meta_desc(f), f["map_query"])) for f in nationals: write_page(f["page_path"], national_html(f)) pages.append((f["page_path"], f["title"], meta_desc(f), f["map_query"])) write_page("/cheaper-twins", hub_html(twins)) pages.append(("/cheaper-twins", "Cheaper twin postcodes in England", "Neighbouring England postcodes priced apart for the name, not the home.", "")) # Rust registry entries = "\n".join( f" DataPage {{ path: {rust_str(p)}, title: {rust_str(t)}, description: {rust_str(d)}, screenshot_query: {rust_str(q)} }}," for p, t, d, q in pages ) RUST.write_text( "// @generated by analysis/build_pages.py. Do not edit by hand.\n" "// Registers the data-driven growth pages so og_middleware serves them (not 404) with the\n" "// right title/description and an OG card pointed at the finding's map view.\n\n" "pub struct DataPage {\n" " pub path: &'static str,\n" " pub title: &'static str,\n" " pub description: &'static str,\n" " /// Map query string the OG screenshot should frame (empty = default map).\n" " pub screenshot_query: &'static str,\n" "}\n\n" f"pub static DATA_PAGES: &[DataPage] = &[\n{entries}\n];\n\n" "/// Look up a generated data page by request path (already trailing-slash-trimmed).\n" "pub fn data_page(path: &str) -> Option<&'static DataPage> {\n" " DATA_PAGES.iter().find(|p| p.path == path)\n" "}\n" ) # Sitemap: replace the block between markers (idempotent), else insert before . start, end = "", "" block = [start] for p, *_ in pages: block.append(f" \n {SITE}{p}\n monthly\n 0.7\n ") block.append(end) block_str = "\n".join(block) sm = SITEMAP.read_text() if start in sm and end in sm: pre, rest = sm.split(start, 1) _, post = rest.split(end, 1) sm = pre + block_str + post else: sm = sm.replace("", block_str + "\n") SITEMAP.write_text(sm) print(f"Wrote {len(pages)} pages to {PUBLIC}/ (+ hub), {RUST}, and {len(pages)} sitemap entries.") print("Pages:") for p, t, *_ in pages: print(f" {p}") if __name__ == "__main__": main()