perfect-postcode/analysis/build_pages.py
2026-07-03 18:47:28 +01:00

299 lines
14 KiB
Python

#!/usr/bin/env python3
"""Build the SEO page batch from findings.
Emits, from analysis/out/findings/*.json:
- frontend/public/<slug>/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 <url> entries inserted between markers (idempotent)
These are static files: webpack copies public/ -> dist/, and the server serves dist/<path>/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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{esc(title)} | Perfect Postcode</title>
<meta name="description" content="{esc(desc)}" />
<link rel="canonical" href="{SITE}{esc(path)}" />
<style>{CSS}</style>
<script type="application/ld+json">{json.dumps(jsonld)}</script>
</head>
<body>
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
{body}
<footer><div class="wrap">Sources: {esc(SOURCES)}. {esc(ATTRIB)}</div></footer>
</body>
</html>
"""
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'<span class="cheaper">{gbp(w["est_psqm"]*90)}</span>'),
("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"<tr><td>{esc(k)}</td><td class='val'>{a}</td><td class='val'>{b}</td></tr>" 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"<b>{s['gap_pct']:.0f}% (about {gbp(s['gap_on_90sqm'])} on a 90 m² property) cheaper in "
f"{esc(w['name'] or w['sector'])}</b>. 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'<a href="{esc(sf["page_path"])}"><b>{esc(sf["title"].split(":")[0])}</b><br>{esc(sf["hook"])}</a>'
for sf in siblings
)
body = f"""
<div class="hero"><div class="wrap">
<p class="eyebrow">Cheaper twin · England</p>
<h1>{esc(f['title'])}</h1>
<div class="big">{esc(f['shocking_number'])} cheaper / m²</div>
<p class="hook">{esc(f['hook'])}</p>
<a class="cta" href="{esc(map_url)}">See both areas on the live map →</a>
</div></div>
<div class="wrap">
<table><thead><tr><th></th><th>{esc(p['label'])}</th><th>{esc(w['label'])}</th></tr></thead>
<tbody>{table_rows}</tbody></table>
<section><h2>The same life, one postcode cheaper</h2><p>{prose}</p></section>
<section><h2>How we worked this out</h2><p>{esc(f['methodology'])}</p></section>
<section><h2>Compare more areas</h2>
<div class="links">
<a href="/cheaper-twins"><b>All cheaper twins →</b><br>Browse every England name-premium pair we found.</a>
<a href="/postcode-checker"><b>Postcode checker →</b><br>Prices, crime, schools, broadband for any postcode.</a>
<a href="/property-price-map"><b>Property price map →</b><br>Rank England by what each £ buys.</a>
</div>
<div class="links">{sib_links}</div>
</section>
<p class="note">{esc(ATTRIB)} Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
</div>
"""
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"""
<div class="hero"><div class="wrap">
<p class="eyebrow">England · value index</p>
<h1>{esc(f['title'])}</h1>
<div class="big">{esc(f['shocking_number'])}</div>
<p class="hook">{esc(f['hook'])}</p>
<a class="cta" href="{SITE}/?{esc(f['map_query'])}">Explore the value map →</a>
</div></div>
<div class="wrap">
<table><thead><tr><th>Sector</th><th>Est. £/m²</th><th>m² for £100k</th><th>N</th></tr></thead><tbody>
<tr><td>{esc(b['sector'])} (best value)</td><td class='val'>{gbp(b['est_psqm'])}</td><td class='val cheaper'>{b['sqm_per_100k']:.0f} m²</td><td class='val'>{b['n']:,}</td></tr>
<tr><td>{esc(d['sector'])} (dearest)</td><td class='val'>{gbp(d['est_psqm'])}</td><td class='val'>{d['sqm_per_100k']:.0f} m²</td><td class='val'>{d['n']:,}</td></tr>
</tbody></table>
<section><h2>How we worked this out</h2><p>{esc(f['methodology'])}</p></section>
<section><h2>More</h2><div class="links">
<a href="/cheaper-twins"><b>Cheaper twins →</b><br>Pairs of areas priced apart for the name, not the home.</a>
<a href="/postcode-checker"><b>Postcode checker →</b><br>Everything known about any postcode.</a>
</div></section>
<p class="note">{esc(ATTRIB)}</p>
</div>
"""
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'<a class="card" href="{esc(f["page_path"])}"><div class="n">{esc(f["shocking_number"])}</div>'
f'<div>{esc(f["title"].split(":")[0])}</div></a>'
for f in twins
)
body = f"""
<div class="hero"><div class="wrap">
<p class="eyebrow">England</p>
<h1>Cheaper twins: pay for the home, not the name</h1>
<p class="hook">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.</p>
<a class="cta" href="/?ref=twins-hub">Find your cheaper twin on the map →</a>
</div></div>
<div class="wrap">
<section><div class="cards">{cards}</div></section>
<p class="note">{esc(ATTRIB)}</p>
</div>
"""
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 </urlset>.
start, end = "<!-- DATA_PAGES_START -->", "<!-- DATA_PAGES_END -->"
block = [start]
for p, *_ in pages:
block.append(f" <url>\n <loc>{SITE}{p}</loc>\n <changefreq>monthly</changefreq>\n <priority>0.7</priority>\n </url>")
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("</urlset>", block_str + "\n</urlset>")
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()