299 lines
14 KiB
Python
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()
|