..
This commit is contained in:
parent
ab688243d7
commit
463bd4c647
54 changed files with 13239 additions and 625 deletions
299
analysis/build_pages.py
Normal file
299
analysis/build_pages.py
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
#!/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()
|
||||
221
analysis/build_video_scripts.py
Normal file
221
analysis/build_video_scripts.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Turn each finding into a ready-to-film VIDEO KIT.
|
||||
|
||||
The founder films manually (screen-recording the live map), so this emits everything needed to shoot a
|
||||
payoff-first "cheaper twin" video without writing anything: the hook, a beat-by-beat shot list tied to
|
||||
applying each filter, a human-VO narration script, ≤6-word captions, the exact map URL to record, and the
|
||||
YouTube title/description/chapters/tags/thumbnail. It also prints a storyboard spec (filters + suggested
|
||||
city) for the optional automated render path (video/src/storyboard.ts AD_CONFIGS + render.sh, which needs the
|
||||
running stack + login creds, so that part is yours to run).
|
||||
|
||||
Outputs:
|
||||
analysis/out/video_scripts/<slug>.md: one filming kit per finding
|
||||
analysis/out/video_scripts/INDEX.md: overview + how to film
|
||||
|
||||
Run: source .venv/bin/activate && python analysis/build_video_scripts.py (after generate_findings.py)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FIND = Path("analysis/out/findings")
|
||||
OUT = Path("analysis/out/video_scripts")
|
||||
SITE = "https://perfect-postcode.co.uk"
|
||||
SOURCES = "Land Registry, EPC, Ofsted, DfT, Police.uk"
|
||||
|
||||
# Suggested CityKey for the optional auto-render path (storyboard.ts CityKey union).
|
||||
CITY_OUTCODES = {
|
||||
"manchester": {"M", "SK", "OL", "BL", "WN"},
|
||||
"birmingham": {"B"},
|
||||
"bristol": {"BS"},
|
||||
"leeds": {"LS", "WF", "BD"},
|
||||
}
|
||||
LONDON = {"E", "EC", "WC", "W", "SW", "SE", "N", "NW", "BR", "IG", "RM", "TW", "KT", "HA", "SM", "CR", "UB", "EN", "DA"}
|
||||
|
||||
|
||||
def gbp(n) -> str:
|
||||
return f"£{int(n):,}"
|
||||
|
||||
|
||||
def outward_area(sector: str) -> str:
|
||||
return re.match(r"^[A-Z]+", sector).group(0)
|
||||
|
||||
|
||||
def city_key(pricey_sector: str) -> str:
|
||||
area = outward_area(pricey_sector)
|
||||
if area in LONDON:
|
||||
return "london"
|
||||
for city, codes in CITY_OUTCODES.items():
|
||||
if area in codes:
|
||||
return city
|
||||
return "london" # fallback; only matters for the auto-render variant
|
||||
|
||||
|
||||
def twin_kit(f: dict) -> str:
|
||||
p, w, s = f["pricey"], f["twin"], f["stats"]
|
||||
pn, wn = p["name"] or p["sector"], w["name"] or w["sector"]
|
||||
typ = s["dominant_type"].lower()
|
||||
plural = {
|
||||
"Flats/Maisonettes": "flats",
|
||||
"Terraced": "terraced houses",
|
||||
"Semi-Detached": "semi-detached houses",
|
||||
"Detached": "detached houses",
|
||||
}.get(s["dominant_type"], typ + "s")
|
||||
gap = f"{s['gap_pct']:.0f}%"
|
||||
money = gbp(s["gap_on_90sqm"])
|
||||
url = f["map_url"]
|
||||
|
||||
titles = [
|
||||
f["title"],
|
||||
f"{pn} vs {wn}: same station, same schools, {money} cheaper",
|
||||
f"Why {wn} is the smart-money version of {pn} ({gap} less per m²)",
|
||||
]
|
||||
captions = [
|
||||
f"{pn} vs {wn}",
|
||||
f"Same station. Same schools.",
|
||||
f"{money} cheaper",
|
||||
f"Same {typ}, ~{s['build_year']}",
|
||||
f"{gap} less per m²",
|
||||
"Find your cheaper twin, free",
|
||||
]
|
||||
narration = (
|
||||
f"This is {pn}. And this is {wn}, right next door. Same station. "
|
||||
f"Same {'secondary school catchment' if s['good_secondary_catchments'] else 'schools'}. "
|
||||
f"The same kind of home: {plural} built around {s['build_year']}. "
|
||||
f"On every measure that moves price, they're twins. "
|
||||
f"But watch the price per square metre. {pn}: {gbp(p['est_psqm'])}. {wn}: {gbp(w['est_psqm'])}. "
|
||||
f"That's {gap} cheaper, about {money} on a typical 90-square-metre home, "
|
||||
f"for the same life, one postcode over. You're not paying for the house. You're paying for the name. "
|
||||
f"You can find the cheaper twin of any postcode in England on the map for free, no signup."
|
||||
)
|
||||
chapters = [
|
||||
("0:00", f"The two postcodes ({pn} & {wn})"),
|
||||
("0:08", "Same station"),
|
||||
("0:18", "Same school catchment"),
|
||||
("0:28", "Same kind of home"),
|
||||
("0:38", "The price-per-m² reveal"),
|
||||
("0:52", "Find your own cheaper twin (free map)"),
|
||||
]
|
||||
description = (
|
||||
f"{url}\n\n"
|
||||
f"{pn} and {wn} share a station, a school catchment and the same era of housing, but {wn} costs about "
|
||||
f"{gap} less per square metre ({money} on a 90 m² home). I built a map that ranks every postcode in "
|
||||
f"England by what each pound actually buys, from official open data ({SOURCES}). Find the cheaper twin "
|
||||
f"of any area, free and with no signup, at {SITE}.\n\n"
|
||||
+ "\n".join(f"{ts} {label}" for ts, label in chapters)
|
||||
+ "\n\nData: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. "
|
||||
"Figures are estimates aggregated to postcode sector, not valuations."
|
||||
)
|
||||
shotlist = [
|
||||
("0:00–0:06", "COLD OPEN: payoff first", f"Open on the map already showing both areas with the £/m² gap visible. Caption: '{money} cheaper'. Say the hook.", "Land on the map URL below (filters pre-applied)."),
|
||||
("0:06–0:18", "Same station", "Pan/zoom to show both areas sit by the same line/station. Toggle the commute context if you want.", "Caption: 'Same station.'"),
|
||||
("0:18–0:28", "Same schools", "Show the Good+ secondary catchment covering both.", "Caption: 'Same school catchment.'"),
|
||||
("0:28–0:38", "Same homes", f"Note the dominant type ({plural}) and build era (~{s['build_year']}).", "Caption: 'Same homes.'"),
|
||||
("0:38–0:52", "THE REVEAL", f"Show the £/m² side by side: {pn} {gbp(p['est_psqm'])} vs {wn} {gbp(w['est_psqm'])}.", f"Caption: '{gap} less per m²'."),
|
||||
("0:52–1:00", "CTA", "End on the map; invite them to find their own cheaper twin.", "Caption: 'Free. No signup.'"),
|
||||
]
|
||||
|
||||
ck = city_key(p["sector"])
|
||||
psqm_cap = int(round(w["est_psqm"] * 1.05, -2))
|
||||
spec = {
|
||||
"name": f"twin-{f['slug'].split('/')[-1]}",
|
||||
"city": ck,
|
||||
"promptText": f"Best value {typ}s near {pn}: same schools and station, lower price",
|
||||
"initialFilters": {
|
||||
"Est. price per sqm": [0, psqm_cap],
|
||||
"Good+ secondary school catchments": [1, 11],
|
||||
},
|
||||
"outroLine": f"{wn}: same life, {gap} cheaper.",
|
||||
}
|
||||
|
||||
lines = [
|
||||
f"# Video kit: {pn} vs {wn}",
|
||||
"",
|
||||
f"**Page:** {SITE}{f['page_path']} · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut",
|
||||
"",
|
||||
f"## 🎬 Map URL to record (open this, hit record)",
|
||||
f"`{url}`",
|
||||
"*(filters are pre-applied so the value is on screen immediately)*",
|
||||
"",
|
||||
"## Hook (first 2 seconds, on screen + said)",
|
||||
f"**\"{money} cheaper. Same station. Same schools.\"**",
|
||||
"",
|
||||
"## Shot list",
|
||||
"| Time | Beat | What to show | On-screen |",
|
||||
"|------|------|--------------|-----------|",
|
||||
]
|
||||
for t, beat, show, cap in shotlist:
|
||||
lines.append(f"| {t} | {beat} | {show} | {cap} |")
|
||||
lines += [
|
||||
"",
|
||||
"## Narration (human voiceover, never raw TTS for a property audience)",
|
||||
f"> {narration}",
|
||||
"",
|
||||
"## Captions (≤6 words, sound-off)",
|
||||
*[f"- {c}" for c in captions],
|
||||
"",
|
||||
"## YouTube",
|
||||
"**Title options:**",
|
||||
*[f"{i+1}. {t}" for i, t in enumerate(titles)],
|
||||
"",
|
||||
"**Thumbnail text:** big number `" + money + " cheaper` + the two names `" + f"{pn} → {wn}`",
|
||||
"",
|
||||
"**Description (paste as-is):**",
|
||||
"```",
|
||||
description,
|
||||
"```",
|
||||
"",
|
||||
"## 9:16 Short (cut from the same recording)",
|
||||
f"First 3 seconds: the £/m² reveal ({pn} {gbp(p['est_psqm'])} → {wn} {gbp(w['est_psqm'])}) + caption '{gap} less'. "
|
||||
"End card: 'Find your cheaper twin, free, no signup.'",
|
||||
"",
|
||||
"## Optional auto-render spec (video/src/storyboard.ts AD_CONFIGS)",
|
||||
"Add this as a `DemoAdStoryboardConfig` and run `video/render.sh --prod` (needs login creds + the live stack). "
|
||||
"Filter names must match live `/api/features` or preflight fails.",
|
||||
"```json",
|
||||
json.dumps(spec, indent=2),
|
||||
"```",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
findings = [json.loads(p.read_text()) for p in sorted(FIND.glob("*.json"))]
|
||||
twins = [f for f in findings if f["type"] == "cheaper_twin"]
|
||||
|
||||
made = []
|
||||
for f in twins:
|
||||
slug = f["slug"].split("/")[-1]
|
||||
(OUT / f"{slug}.md").write_text(twin_kit(f))
|
||||
made.append((slug, f))
|
||||
|
||||
index = [
|
||||
"# Video kits: film one per 1–2 weeks",
|
||||
"",
|
||||
"Each kit is a complete, payoff-first faceless video you can screen-record off the live map. "
|
||||
"Pick one, open its Map URL, record, read the narration (human voice), export one clean cut + a 9:16 Short.",
|
||||
"",
|
||||
"**Priority order (relatable family-home twins first, since they convert better than prime London):**",
|
||||
"",
|
||||
"| Kit | Hook | File |",
|
||||
"|-----|------|------|",
|
||||
]
|
||||
# Family homes first, then the rest, by £ gap.
|
||||
fam = [m for m in made if m[1]["stats"]["dominant_type"] in ("Terraced", "Semi-Detached", "Detached")]
|
||||
rest = [m for m in made if m not in fam]
|
||||
for slug, f in sorted(fam, key=lambda m: m[1]["stats"]["gap_on_90sqm"], reverse=True) + sorted(
|
||||
rest, key=lambda m: m[1]["stats"]["gap_on_90sqm"], reverse=True
|
||||
):
|
||||
p, w = f["pricey"], f["twin"]
|
||||
index.append(f"| {p['name'] or p['sector']} → {w['name'] or w['sector']} | {f['shocking_number']} / {gbp(f['stats']['gap_on_90sqm'])} | `{slug}.md` |")
|
||||
(OUT / "INDEX.md").write_text("\n".join(index))
|
||||
print(f"Wrote {len(made)} video kits + INDEX.md to {OUT}/")
|
||||
for slug, f in made[:6]:
|
||||
print(f" {slug}.md: {f['title']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
341
analysis/cheaper_twins.py
Normal file
341
analysis/cheaper_twins.py
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Cheaper-twin / name-premium index over all-England property data.
|
||||
|
||||
This is the ROOT growth artifact: every page, OG card, video and outreach number derives from its
|
||||
output. It reads the local property data, aggregates to POSTCODE SECTOR grain (e.g. "N1 1", the same
|
||||
grain the homepage TwinProof block uses, which is load-bearing), and finds "cheaper twins": nearby
|
||||
sectors that match on property type, build era, school provision and station access but differ
|
||||
materially in estimated price per square metre.
|
||||
|
||||
Defensibility rules baked in (see growth/README.md):
|
||||
- Sector aggregation only, never address-level output (Royal Mail / OS rights).
|
||||
- Minimum sample sizes per sector (--min-props, --min-recorded).
|
||||
- Robust statistics: median £/sqm; a sector needs real recorded sales, not just modelled estimates.
|
||||
- England only (ctry25cd starts with "E").
|
||||
- Every row stamps its N.
|
||||
- "Twin" requires genuine like-for-like matching before any price claim is made.
|
||||
|
||||
Outputs (analysis/out/):
|
||||
- sector_index.parquet / .csv: per-sector value table (powers "best value", "£100k buys X m²", etc.)
|
||||
- cheaper_twins.parquet / .csv: ranked twin pairs (pricey name -> cheaper twin)
|
||||
- national_facts.json: headline stats for collateral/finding placeholders
|
||||
|
||||
Run: source .venv/bin/activate && python analysis/cheaper_twins.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import polars as pl
|
||||
from scipy.spatial import cKDTree
|
||||
|
||||
DATA = Path("property-data")
|
||||
OUT = Path("analysis/out")
|
||||
# Postcode sector = outward code + first inward digit, e.g. "N1 1", "M20 2".
|
||||
SECTOR_RE = r"^([A-Z]{1,2}[0-9][A-Z0-9]? [0-9])"
|
||||
|
||||
STATION_DIST_COLS = [
|
||||
"Distance to nearest amenity (Rail station) (km)",
|
||||
"Distance to nearest amenity (Tube station) (km)",
|
||||
"Distance to nearest amenity (DLR station) (km)",
|
||||
"Distance to nearest amenity (Tram & Metro stop) (km)",
|
||||
]
|
||||
AREA_COLS = [
|
||||
"lat",
|
||||
"lon",
|
||||
"Good+ primary school catchments",
|
||||
"Good+ secondary school catchments",
|
||||
"Outstanding primary school catchments",
|
||||
"Outstanding secondary school catchments",
|
||||
"Serious crime (/yr, 7y)",
|
||||
"Minor crime (/yr, 7y)",
|
||||
"Noise (dB)",
|
||||
"Max available download speed (Mbps)",
|
||||
"Median age",
|
||||
"% Owner occupied",
|
||||
"% Degree or higher",
|
||||
*STATION_DIST_COLS,
|
||||
]
|
||||
|
||||
|
||||
def _collect(lf: pl.LazyFrame) -> pl.DataFrame:
|
||||
"""Collect with streaming if the installed polars supports it, else fall back."""
|
||||
try:
|
||||
return lf.collect(streaming=True)
|
||||
except Exception:
|
||||
return lf.collect()
|
||||
|
||||
|
||||
def property_aggregates() -> pl.DataFrame:
|
||||
props = (
|
||||
pl.scan_parquet(DATA / "properties.parquet")
|
||||
.with_columns(pl.col("Postcode").str.extract(SECTOR_RE, 1).alias("Sector"))
|
||||
.filter(pl.col("Sector").is_not_null())
|
||||
)
|
||||
agg = props.group_by("Sector").agg(
|
||||
pl.len().alias("n_props"),
|
||||
pl.col("Price per sqm").drop_nulls().len().alias("n_recorded"),
|
||||
pl.col("Est. price per sqm").median().alias("est_psqm"),
|
||||
pl.col("Price per sqm").median().alias("recorded_psqm"),
|
||||
pl.col("Last known price").median().alias("median_price"),
|
||||
pl.col("Estimated current price").median().alias("est_price"),
|
||||
pl.col("Total floor area (sqm)").median().alias("median_floor"),
|
||||
pl.col("Number of bedrooms & living rooms").median().alias("median_rooms"),
|
||||
pl.col("Construction year")
|
||||
.filter(pl.col("Construction year") > 1800)
|
||||
.median()
|
||||
.alias("median_build_year"),
|
||||
)
|
||||
|
||||
# Dominant property type + its share, per sector (robust mode without ordering assumptions).
|
||||
tc = props.group_by(["Sector", "Property type"]).agg(pl.len().alias("c"))
|
||||
tc = tc.with_columns(
|
||||
(pl.col("c") / pl.col("c").sum().over("Sector")).alias("share"),
|
||||
pl.col("c").max().over("Sector").alias("maxc"),
|
||||
)
|
||||
dom = (
|
||||
tc.filter(pl.col("c") == pl.col("maxc"))
|
||||
.unique(subset="Sector", keep="first")
|
||||
.select(
|
||||
pl.col("Sector"),
|
||||
pl.col("Property type").alias("dominant_type"),
|
||||
pl.col("share").alias("dominant_share"),
|
||||
)
|
||||
)
|
||||
return _collect(agg).join(_collect(dom), on="Sector", how="left")
|
||||
|
||||
|
||||
def area_aggregates() -> pl.DataFrame:
|
||||
# Property counts per postcode unit -> weights, so area features are weighted by housing stock.
|
||||
unit_counts = _collect(
|
||||
pl.scan_parquet(DATA / "properties.parquet")
|
||||
.group_by("Postcode")
|
||||
.agg(pl.len().alias("n"))
|
||||
)
|
||||
|
||||
pc = pl.read_parquet(DATA / "postcode.parquet", columns=["Postcode", "ctry25cd", *AREA_COLS])
|
||||
pc = pc.filter(pl.col("ctry25cd").str.starts_with("E")) # England only
|
||||
pc = pc.join(unit_counts, on="Postcode", how="inner") # only units that contain homes
|
||||
pc = pc.with_columns(
|
||||
pl.col("Postcode").str.extract(SECTOR_RE, 1).alias("Sector"),
|
||||
pl.min_horizontal(STATION_DIST_COLS).alias("dist_station_km"),
|
||||
).filter(pl.col("Sector").is_not_null())
|
||||
|
||||
def wmean(col: str, alias: str) -> pl.Expr:
|
||||
# weighted mean ignoring nulls: sum(col*n) / sum(n where col not null)
|
||||
num = (pl.col(col) * pl.col("n")).sum()
|
||||
den = pl.col("n").filter(pl.col(col).is_not_null()).sum()
|
||||
return (num / den).alias(alias)
|
||||
|
||||
return pc.group_by("Sector").agg(
|
||||
pl.col("n").sum().alias("area_n"),
|
||||
wmean("lat", "lat"),
|
||||
wmean("lon", "lon"),
|
||||
wmean("Good+ primary school catchments", "good_primary"),
|
||||
wmean("Good+ secondary school catchments", "good_secondary"),
|
||||
wmean("Outstanding primary school catchments", "outstanding_primary"),
|
||||
wmean("Outstanding secondary school catchments", "outstanding_secondary"),
|
||||
wmean("Serious crime (/yr, 7y)", "serious_crime"),
|
||||
wmean("Noise (dB)", "noise_db"),
|
||||
wmean("Max available download speed (Mbps)", "broadband_mbps"),
|
||||
wmean("Median age", "median_age"),
|
||||
wmean("% Owner occupied", "pct_owner"),
|
||||
wmean("% Degree or higher", "pct_degree"),
|
||||
wmean("dist_station_km", "dist_station_km"),
|
||||
)
|
||||
|
||||
|
||||
def build_index(args) -> pl.DataFrame:
|
||||
idx = property_aggregates().join(area_aggregates(), on="Sector", how="inner")
|
||||
idx = idx.filter(
|
||||
(pl.col("n_props") >= args.min_props)
|
||||
& (pl.col("n_recorded") >= args.min_recorded)
|
||||
& pl.col("est_psqm").is_not_null()
|
||||
& (pl.col("est_psqm") > 0)
|
||||
& pl.col("lat").is_not_null()
|
||||
& pl.col("lon").is_not_null()
|
||||
& pl.col("dist_station_km").is_not_null()
|
||||
)
|
||||
idx = idx.with_columns(
|
||||
(100_000 / pl.col("est_psqm")).round(1).alias("sqm_per_100k"),
|
||||
pl.col("est_psqm").round().cast(pl.Int64),
|
||||
pl.col("recorded_psqm").round().cast(pl.Int64),
|
||||
)
|
||||
return idx.sort("est_psqm", descending=True)
|
||||
|
||||
|
||||
def find_twins(idx: pl.DataFrame, args) -> pl.DataFrame:
|
||||
d = idx.to_dict(as_series=False)
|
||||
n = len(d["Sector"])
|
||||
lat = np.array(d["lat"], float)
|
||||
lon = np.array(d["lon"], float)
|
||||
psqm = np.array(d["est_psqm"], float)
|
||||
floor = np.array([f if f is not None else np.nan for f in d["median_floor"]], float)
|
||||
build = np.array([b if b is not None else np.nan for b in d["median_build_year"]], float)
|
||||
dom = d["dominant_type"]
|
||||
good_sec = np.array(d["good_secondary"], float)
|
||||
good_pri = np.array(d["good_primary"], float)
|
||||
crime = np.array(d["serious_crime"], float)
|
||||
station = np.array(d["dist_station_km"], float)
|
||||
owner = np.array(d["pct_owner"], float)
|
||||
degree = np.array(d["pct_degree"], float)
|
||||
age = np.array(d["median_age"], float)
|
||||
|
||||
# Planar projection (km) for a local KD-tree radius search.
|
||||
lat0 = math.radians(float(np.nanmean(lat)))
|
||||
x = lon * 111.320 * math.cos(lat0)
|
||||
y = lat * 110.574
|
||||
tree = cKDTree(np.column_stack([x, y]))
|
||||
|
||||
rows = []
|
||||
for i in range(n):
|
||||
neigh = tree.query_ball_point([x[i], y[i]], args.max_km)
|
||||
best_j, best_gap = -1, -1.0
|
||||
for j in neigh:
|
||||
if j == i or psqm[i] <= psqm[j]:
|
||||
continue # i must be the pricier side
|
||||
gap = 1.0 - psqm[j] / psqm[i]
|
||||
# A genuine "twin" sits in a believable band: below min it's not a story,
|
||||
# above max it's a different market tier (city-centre premium / prime), not a twin.
|
||||
if gap < args.min_gap or gap > args.max_gap:
|
||||
continue
|
||||
if dom[i] != dom[j]:
|
||||
continue
|
||||
if not (np.isfinite(build[i]) and np.isfinite(build[j])) or abs(build[i] - build[j]) > args.build_band:
|
||||
continue
|
||||
if abs(good_sec[i] - good_sec[j]) > args.school_tol or abs(good_pri[i] - good_pri[j]) > args.school_tol:
|
||||
continue
|
||||
if station[i] > args.station_max or station[j] > args.station_max or abs(station[i] - station[j]) > args.station_tol:
|
||||
continue
|
||||
# Similarity gates: the two must be the SAME KIND of neighbourhood, so the gap
|
||||
# reads as a name premium, not a tier jump (deprivation/tenure/education/age/safety/size).
|
||||
if crime[j] > crime[i] * args.crime_ratio or crime[i] > crime[j] * args.crime_ratio:
|
||||
continue
|
||||
if abs(owner[i] - owner[j]) > args.owner_tol:
|
||||
continue
|
||||
if abs(degree[i] - degree[j]) > args.degree_tol:
|
||||
continue
|
||||
if np.isfinite(age[i]) and np.isfinite(age[j]) and abs(age[i] - age[j]) > args.age_tol:
|
||||
continue
|
||||
if np.isfinite(floor[i]) and np.isfinite(floor[j]) and floor[j] > 0:
|
||||
fr = floor[i] / floor[j]
|
||||
if fr < args.floor_ratio or fr > 1.0 / args.floor_ratio:
|
||||
continue
|
||||
if gap > best_gap:
|
||||
best_gap, best_j = gap, j
|
||||
if best_j < 0:
|
||||
continue
|
||||
j = best_j
|
||||
avg_floor = np.nanmean([floor[i], floor[j]])
|
||||
if not np.isfinite(avg_floor):
|
||||
avg_floor = 90.0
|
||||
rows.append(
|
||||
{
|
||||
"pricey_sector": d["Sector"][i],
|
||||
"twin_sector": d["Sector"][j],
|
||||
"pricey_psqm": int(psqm[i]),
|
||||
"twin_psqm": int(psqm[j]),
|
||||
"gap_pct": round(best_gap * 100, 1),
|
||||
"gap_per_sqm": int(psqm[i] - psqm[j]),
|
||||
"gap_on_avg_home": int((psqm[i] - psqm[j]) * avg_floor),
|
||||
"gap_on_90sqm": int((psqm[i] - psqm[j]) * 90),
|
||||
"dist_km": round(math.hypot(x[i] - x[j], y[i] - y[j]), 2),
|
||||
"dominant_type": dom[i],
|
||||
"build_year": None if not np.isfinite(build[i]) else int(build[i]),
|
||||
"good_secondary": round(float(good_sec[i]), 1),
|
||||
"station_km": round(float(station[i]), 2),
|
||||
"pricey_lat": round(float(lat[i]), 5),
|
||||
"pricey_lon": round(float(lon[i]), 5),
|
||||
"twin_lat": round(float(lat[j]), 5),
|
||||
"twin_lon": round(float(lon[j]), 5),
|
||||
"pricey_n": int(d["n_props"][i]),
|
||||
"twin_n": int(d["n_props"][j]),
|
||||
}
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return pl.DataFrame()
|
||||
tw = pl.DataFrame(rows)
|
||||
# Dedup unordered pairs, keep the biggest gap.
|
||||
tw = tw.with_columns(
|
||||
pl.concat_list(
|
||||
[
|
||||
pl.min_horizontal("pricey_sector", "twin_sector"),
|
||||
pl.max_horizontal("pricey_sector", "twin_sector"),
|
||||
]
|
||||
)
|
||||
.list.join("|")
|
||||
.alias("_pair")
|
||||
)
|
||||
tw = tw.sort("gap_pct", descending=True).unique(subset="_pair", keep="first").drop("_pair")
|
||||
return tw.filter(pl.col("gap_on_90sqm") >= args.min_abs_gap).sort("gap_pct", descending=True)
|
||||
|
||||
|
||||
def national_facts(idx: pl.DataFrame, twins: pl.DataFrame, args) -> dict:
|
||||
valid = idx.filter(pl.col("n_props") >= args.min_props)
|
||||
cheapest = valid.sort("est_psqm").head(1).to_dicts()[0]
|
||||
dearest = valid.sort("est_psqm", descending=True).head(1).to_dicts()[0]
|
||||
facts = {
|
||||
"generated_with": "analysis/cheaper_twins.py",
|
||||
"params": vars(args),
|
||||
"n_sectors": idx.height,
|
||||
"n_twin_pairs": twins.height,
|
||||
"attribution": "Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"best_value_sector": {"sector": cheapest["Sector"], "est_psqm": cheapest["est_psqm"], "sqm_per_100k": cheapest["sqm_per_100k"], "n": cheapest["n_props"]},
|
||||
"dearest_sector": {"sector": dearest["Sector"], "est_psqm": dearest["est_psqm"], "sqm_per_100k": dearest["sqm_per_100k"], "n": dearest["n_props"]},
|
||||
}
|
||||
if twins.height:
|
||||
top = twins.head(10).to_dicts()
|
||||
facts["biggest_twin_gap"] = top[0]
|
||||
facts["top_twins"] = top
|
||||
return facts
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--min-props", type=int, default=150, help="min properties per sector")
|
||||
p.add_argument("--min-recorded", type=int, default=40, help="min recorded sales (with floor area) per sector")
|
||||
p.add_argument("--max-km", type=float, default=3.0, help="max centroid distance for a twin (km)")
|
||||
p.add_argument("--min-gap", type=float, default=0.15, help="min fractional £/sqm gap")
|
||||
p.add_argument("--max-gap", type=float, default=0.45, help="max fractional gap (above this it's a tier jump, not a twin)")
|
||||
p.add_argument("--build-band", type=float, default=30, help="max build-year difference")
|
||||
p.add_argument("--school-tol", type=float, default=1.5, help="max difference in good-catchment counts")
|
||||
p.add_argument("--station-max", type=float, default=1.5, help="both sectors must be within this many km of a station")
|
||||
p.add_argument("--station-tol", type=float, default=0.9, help="max difference in station distance (km)")
|
||||
p.add_argument("--crime-ratio", type=float, default=1.5, help="serious crime must be within this ratio either way")
|
||||
p.add_argument("--owner-tol", type=float, default=22, help="max difference in %% owner-occupied")
|
||||
p.add_argument("--degree-tol", type=float, default=22, help="max difference in %% degree-or-higher")
|
||||
p.add_argument("--age-tol", type=float, default=12, help="max difference in median age (years)")
|
||||
p.add_argument("--floor-ratio", type=float, default=0.72, help="median floor area must be within this ratio either way")
|
||||
p.add_argument("--min-abs-gap", type=int, default=20000, help="min £ gap on a 90 sqm home for the twin list")
|
||||
args = p.parse_args()
|
||||
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
print("Aggregating 22.4M properties to sector grain ...")
|
||||
idx = build_index(args)
|
||||
print(f" {idx.height} valid England sectors (>= {args.min_props} props, >= {args.min_recorded} recorded sales)")
|
||||
idx.write_parquet(OUT / "sector_index.parquet")
|
||||
idx.write_csv(OUT / "sector_index.csv")
|
||||
|
||||
print("Matching cheaper twins ...")
|
||||
twins = find_twins(idx, args)
|
||||
print(f" {twins.height} twin pairs")
|
||||
if twins.height:
|
||||
twins.write_parquet(OUT / "cheaper_twins.parquet")
|
||||
twins.write_csv(OUT / "cheaper_twins.csv")
|
||||
|
||||
facts = national_facts(idx, twins, args)
|
||||
(OUT / "national_facts.json").write_text(json.dumps(facts, indent=2, default=str))
|
||||
print(f"Wrote outputs to {OUT}/")
|
||||
if twins.height:
|
||||
print("\nTop 10 cheaper twins (pricey -> twin, gap%, £ on 90sqm):")
|
||||
for r in twins.head(10).to_dicts():
|
||||
print(f" {r['pricey_sector']:>7} -> {r['twin_sector']:<7} {r['gap_pct']:>5}% £{r['gap_on_90sqm']:,} ({r['dominant_type']}, ~{r['build_year']})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
220
analysis/generate_findings.py
Normal file
220
analysis/generate_findings.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Turn the cheaper-twin index into publishable FINDINGS.
|
||||
|
||||
Each finding is the single artifact the whole engine renders four ways (growth/README.md):
|
||||
an SEO page, an OG/unfurl card, a video storyboard, and a ≤3-filter deep-link CTA into the live map.
|
||||
This script curates the raw 415 twin pairs into a reviewed set of findings and emits:
|
||||
|
||||
analysis/out/findings/<slug>.json: one machine-readable finding per page/video
|
||||
analysis/out/findings_review.md: human-readable sheet for the founder to eyeball + fix names
|
||||
|
||||
Place labels come from analysis/place_names.json (APPROXIMATE, verify before publishing).
|
||||
|
||||
Run: source .venv/bin/activate && python analysis/generate_findings.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
|
||||
import polars as pl
|
||||
|
||||
OUT = Path("analysis/out")
|
||||
FIND = OUT / "findings"
|
||||
SITE = "https://perfect-postcode.co.uk"
|
||||
ATTRIB = "Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0."
|
||||
SOURCES = "HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk"
|
||||
TYPE_SINGULAR = {
|
||||
"Flats/Maisonettes": "flat",
|
||||
"Terraced": "terraced house",
|
||||
"Semi-Detached": "semi-detached house",
|
||||
"Detached": "detached house",
|
||||
}
|
||||
|
||||
NAMES = json.loads(Path("analysis/place_names.json").read_text())
|
||||
|
||||
|
||||
def label(sector: str) -> dict:
|
||||
outward = sector.split(" ")[0]
|
||||
name = NAMES.get(outward)
|
||||
return {"sector": sector, "name": name, "label": f"{name} ({sector})" if name else sector, "named": bool(name)}
|
||||
|
||||
|
||||
def sector_slug(sector: str) -> str:
|
||||
return sector.lower().replace(" ", "-")
|
||||
|
||||
|
||||
def map_query(t: dict) -> str:
|
||||
"""A ≤3-filter deep link that frames the value: centre between the pair, cap £/sqm near the
|
||||
cheaper twin, require a good secondary catchment. Reproducible by non-payers (DEMO_MAX_FILTERS=3)."""
|
||||
mid_lat = round((t["pricey_lat"] + t["twin_lat"]) / 2, 5)
|
||||
mid_lon = round((t["pricey_lon"] + t["twin_lon"]) / 2, 5)
|
||||
psqm_cap = int(round(t["twin_psqm"] * 1.05, -2))
|
||||
enc_psqm = urllib.parse.quote("Est. price per sqm", safe="")
|
||||
enc_school = urllib.parse.quote("Good+ secondary school catchments", safe="")
|
||||
# Two plain numeric filters (within the 3-filter demo cap): frames value + good schools and
|
||||
# uses the well-understood filter=NAME:MIN:MAX form so the OG-card filter-name guard passes.
|
||||
parts = [
|
||||
f"lat={mid_lat}",
|
||||
f"lon={mid_lon}",
|
||||
"zoom=12.5",
|
||||
f"filter={enc_psqm}:0:{psqm_cap}",
|
||||
f"filter={enc_school}:1:11",
|
||||
]
|
||||
return "&".join(parts)
|
||||
|
||||
|
||||
def twin_finding(t: dict) -> dict:
|
||||
p, w = label(t["pricey_sector"]), label(t["twin_sector"])
|
||||
typ = TYPE_SINGULAR.get(t["dominant_type"], "home")
|
||||
slug = f"cheaper-twin/{sector_slug(t['pricey_sector'])}-vs-{sector_slug(t['twin_sector'])}"
|
||||
q = map_query(t)
|
||||
headline_name = f"{p['name']} vs {w['name']}" if p["named"] and w["named"] else f"{t['pricey_sector']} vs {t['twin_sector']}"
|
||||
return {
|
||||
"slug": slug,
|
||||
"type": "cheaper_twin",
|
||||
"page_path": f"/{slug}",
|
||||
"title": f"{headline_name}: the same {typ}, about {t['gap_pct']:.0f}% cheaper per m²",
|
||||
"hook": f"£{t['gap_on_90sqm']:,} less for an equivalent {typ}: same station, similar schools, ~{t['dist_km']}km apart",
|
||||
"shocking_number": f"{t['gap_pct']:.0f}%",
|
||||
"pricey": {**p, "est_psqm": t["pricey_psqm"], "n": t["pricey_n"]},
|
||||
"twin": {**w, "est_psqm": t["twin_psqm"], "n": t["twin_n"]},
|
||||
"stats": {
|
||||
"gap_pct": t["gap_pct"],
|
||||
"gap_per_sqm": t["gap_per_sqm"],
|
||||
"gap_on_90sqm": t["gap_on_90sqm"],
|
||||
"gap_on_avg_home": t["gap_on_avg_home"],
|
||||
"dominant_type": t["dominant_type"],
|
||||
"build_year": t["build_year"],
|
||||
"good_secondary_catchments": t["good_secondary"],
|
||||
"station_km": t["station_km"],
|
||||
"distance_km": t["dist_km"],
|
||||
},
|
||||
"map_query": q,
|
||||
"map_url": f"{SITE}/?{q}",
|
||||
"og_image": f"{SITE}/api/screenshot?og=1&{q}",
|
||||
"methodology": (
|
||||
"Postcode sectors (e.g. N10 3) compared on estimated £/m² of floor space. A pair is only "
|
||||
"called a 'twin' when the two sectors share the dominant property type, build era (±30y), "
|
||||
"good-school catchment provision, station access, deprivation/tenure, education, age and "
|
||||
"home size, so the price gap reflects a name premium, not a different kind of area. "
|
||||
"Estimates, not valuations; aggregated to sector, never address-level."
|
||||
),
|
||||
"needs_name_check": not (p["named"] and w["named"]),
|
||||
"attribution": ATTRIB,
|
||||
"sources": SOURCES,
|
||||
}
|
||||
|
||||
|
||||
def curate(tw: pl.DataFrame) -> list[dict]:
|
||||
rows = tw.to_dicts()
|
||||
london = ("E", "EC", "WC", "W", "SW", "SE", "N", "NW")
|
||||
|
||||
def area(s):
|
||||
import re
|
||||
|
||||
return re.match(r"^[A-Z]+", s).group(0)
|
||||
|
||||
family = [r for r in rows if r["dominant_type"] in ("Terraced", "Semi-Detached", "Detached")]
|
||||
prime = sorted(rows, key=lambda r: r["gap_on_90sqm"], reverse=True)
|
||||
regional_family = sorted(
|
||||
[r for r in family if area(r["pricey_sector"]) not in london],
|
||||
key=lambda r: r["gap_on_90sqm"],
|
||||
reverse=True,
|
||||
)
|
||||
london_family = sorted(
|
||||
[r for r in family if area(r["pricey_sector"]) in london],
|
||||
key=lambda r: r["gap_on_90sqm"],
|
||||
reverse=True,
|
||||
)
|
||||
# A spread: biggest national name premiums (PR) + relatable family-home twins (buyer video).
|
||||
picks, seen = [], set()
|
||||
for r in prime[:6] + regional_family[:8] + london_family[:6]:
|
||||
key = r["pricey_sector"] + "|" + r["twin_sector"]
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
picks.append(r)
|
||||
return picks
|
||||
|
||||
|
||||
def national_findings(idx: pl.DataFrame, facts: dict) -> list[dict]:
|
||||
named = idx.with_columns(
|
||||
pl.col("Sector").str.extract(r"^([A-Z]+[0-9][A-Z0-9]?)", 1).alias("outward")
|
||||
)
|
||||
best = facts["best_value_sector"]
|
||||
dear = facts["dearest_sector"]
|
||||
return [
|
||||
{
|
||||
"slug": "square-metres-per-100k",
|
||||
"type": "national_table",
|
||||
"page_path": "/square-metres-per-100k",
|
||||
"title": "How many square metres £100,000 buys across England",
|
||||
"shocking_number": f"{best['sqm_per_100k']:.0f} m² vs {dear['sqm_per_100k']:.0f} m²",
|
||||
"hook": (
|
||||
f"£100k buys ~{best['sqm_per_100k']:.0f} m² of floor space in {label(best['sector'])['label']} "
|
||||
f"but only ~{dear['sqm_per_100k']:.0f} m² in {label(dear['sector'])['label']}"
|
||||
),
|
||||
"stats": {"best": best, "dearest": dear, "n_sectors": facts["n_sectors"]},
|
||||
"map_query": "zoom=6&filter=" + urllib.parse.quote("Est. price per sqm", safe="") + ":0:4000",
|
||||
"methodology": "100000 ÷ median estimated £/m², per England postcode sector with sufficient sales.",
|
||||
"needs_name_check": not (label(best["sector"])["named"] and label(dear["sector"])["named"]),
|
||||
"attribution": ATTRIB,
|
||||
"sources": SOURCES,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
FIND.mkdir(parents=True, exist_ok=True)
|
||||
tw = pl.read_parquet(OUT / "cheaper_twins.parquet")
|
||||
idx = pl.read_parquet(OUT / "sector_index.parquet")
|
||||
facts = json.loads((OUT / "national_facts.json").read_text())
|
||||
|
||||
findings = [twin_finding(r) for r in curate(tw)] + national_findings(idx, facts)
|
||||
|
||||
for f in findings:
|
||||
(FIND / (f["slug"].replace("/", "__") + ".json")).write_text(json.dumps(f, indent=2, default=str))
|
||||
|
||||
# Human review sheet
|
||||
lines = [
|
||||
"# Findings: review before publishing",
|
||||
"",
|
||||
f"{len(findings)} findings generated from analysis/out/cheaper_twins.parquet.",
|
||||
"**Check the place names** (⚠ = unnamed sector, needs a label in analysis/place_names.json) "
|
||||
"and spot-check a couple of numbers. Then these feed the page batch + video factory.",
|
||||
"",
|
||||
"| ⚠ | Title | Hook number | Page path | Deep link |",
|
||||
"|---|-------|-------------|-----------|-----------|",
|
||||
]
|
||||
for f in findings:
|
||||
warn = "⚠" if f.get("needs_name_check") else ""
|
||||
lines.append(
|
||||
f"| {warn} | {f['title']} | {f.get('shocking_number','')} | `{f['page_path']}` | "
|
||||
f"[map]({f.get('map_url', SITE + '/?' + f['map_query'])}) |"
|
||||
)
|
||||
lines += ["", "## Per-finding detail", ""]
|
||||
for f in findings:
|
||||
lines.append(f"### {f['title']}")
|
||||
lines.append(f"- **Type:** {f['type']} · **Page:** `{f['page_path']}`")
|
||||
lines.append(f"- **Hook:** {f['hook']}")
|
||||
if f["type"] == "cheaper_twin":
|
||||
lines.append(
|
||||
f"- **{f['pricey']['label']}** £{f['pricey']['est_psqm']:,}/m² (n={f['pricey']['n']:,}) → "
|
||||
f"**{f['twin']['label']}** £{f['twin']['est_psqm']:,}/m² (n={f['twin']['n']:,}) · "
|
||||
f"gap {f['stats']['gap_pct']}% · {f['stats']['dominant_type']}, ~{f['stats']['build_year']}"
|
||||
)
|
||||
lines.append(f"- **OG card / deep link:** `{f['map_query']}`")
|
||||
lines.append("")
|
||||
(OUT / "findings_review.md").write_text("\n".join(lines))
|
||||
|
||||
n_named = sum(1 for f in findings if not f.get("needs_name_check"))
|
||||
print(f"Wrote {len(findings)} findings to {FIND}/ ({n_named} fully named, {len(findings)-n_named} need a name check)")
|
||||
print(f"Review sheet: {OUT / 'findings_review.md'}")
|
||||
for f in findings[:14]:
|
||||
flag = " ⚠needs-name" if f.get("needs_name_check") else ""
|
||||
print(f" [{f['shocking_number']:>14}] {f['title']}{flag}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
777
analysis/newbuild_vs_existing_london.ipynb
Normal file
777
analysis/newbuild_vs_existing_london.ipynb
Normal file
File diff suppressed because one or more lines are too long
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())
|
||||
416
analysis/out/cheaper_twins.csv
Normal file
416
analysis/out/cheaper_twins.csv
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
pricey_sector,twin_sector,pricey_psqm,twin_psqm,gap_pct,gap_per_sqm,gap_on_avg_home,gap_on_90sqm,dist_km,dominant_type,build_year,good_secondary,station_km,pricey_lat,pricey_lon,twin_lat,twin_lon,pricey_n,twin_n
|
||||
W1U 3,EC1N 7,16948,9362,44.8,7586,504469,682740,2.89,Flats/Maisonettes,1940,1.3,0.41,51.51712,-0.15271,51.52057,-0.11049,302,747
|
||||
WC2R 1,SW1V 1,19997,11119,44.4,8878,665850,799020,2.82,Flats/Maisonettes,2017,2.0,0.21,51.51228,-0.11525,51.49297,-0.14234,316,1408
|
||||
L8 7,L7 0,2757,1541,44.1,1216,78432,109440,2.36,Flats/Maisonettes,1914,2.3,1.04,53.39791,-2.96459,53.41174,-2.93813,2054,2208
|
||||
L3 2,L5 5,1642,920,44.0,722,47291,64980,1.61,Flats/Maisonettes,2004,1.5,0.47,53.41155,-2.98583,53.42544,-2.97852,1341,706
|
||||
S2 4,S3 9,2468,1402,43.2,1066,73554,95940,2.82,Flats/Maisonettes,1986,2.6,0.72,53.36993,-1.46978,53.39521,-1.46478,2423,1778
|
||||
W1U 4,NW1 4,24238,13766,43.2,10472,759220,942480,0.97,Flats/Maisonettes,1940,1.0,0.44,51.51958,-0.15295,51.52803,-0.14886,984,1340
|
||||
WC2A 2,EC2A 2,25482,14576,42.8,10906,834309,981540,2.3,Flats/Maisonettes,2019,2.0,0.42,51.51496,-0.11456,51.52119,-0.08217,254,772
|
||||
M40 5,M9 4,2812,1626,42.2,1186,91915,106740,1.18,Terraced,1958,2.6,0.72,53.51372,-2.18713,53.51214,-2.20436,1632,3530
|
||||
W11 2,NW1 6,19262,11154,42.1,8108,482426,729720,2.94,Flats/Maisonettes,1890,4.0,0.53,51.51407,-0.20373,51.5237,-0.16342,4082,3312
|
||||
N10 3,N12 0,9590,5554,42.1,4036,296646,363240,2.97,Flats/Maisonettes,1914,3.9,1.16,51.58839,-0.14404,51.60872,-0.17254,3984,3282
|
||||
W1U 1,SW1V 1,19096,11119,41.8,7977,506539,717930,2.54,Flats/Maisonettes,1986,2.0,0.17,51.51535,-0.15064,51.49297,-0.14234,416,1408
|
||||
SW1X 8,SW7 2,26735,15611,41.6,11124,1301508,1001160,1.31,Flats/Maisonettes,1890,3.7,0.43,51.49787,-0.15472,51.49729,-0.17406,1410,1126
|
||||
W1U 5,WC1E 6,20074,11756,41.4,8318,486603,748620,1.34,Flats/Maisonettes,1940,1.0,0.26,51.5214,-0.15392,51.52242,-0.13419,796,424
|
||||
SW3 1,SW5 0,22813,13361,41.4,9452,652188,850680,1.82,Flats/Maisonettes,1914,3.0,0.39,51.49847,-0.16393,51.4925,-0.18898,1104,4266
|
||||
HD4 5,HD1 3,2219,1301,41.4,918,65637,82620,1.79,Terraced,1940,5.0,1.12,53.63444,-1.81642,53.63808,-1.79069,4888,3052
|
||||
W11 3,W12 9,15942,9343,41.4,6599,458630,593910,2.87,Flats/Maisonettes,1914,3.5,0.26,51.50961,-0.20173,51.50318,-0.24269,2643,5847
|
||||
W1J 7,SW7 3,32986,19392,41.2,13594,1077324,1223460,2.6,Flats/Maisonettes,1914,2.0,0.3,51.5059,-0.14756,51.49122,-0.1775,724,2581
|
||||
EC3N 1,EC1V 8,15538,9196,40.8,6342,383691,570780,2.25,Flats/Maisonettes,2021,0.0,0.15,51.51271,-0.07495,51.52808,-0.09657,162,1192
|
||||
SE1 8,SE1 5,11517,6831,40.7,4686,285846,421740,2.8,Flats/Maisonettes,1998,4.8,0.26,51.50309,-0.10794,51.48984,-0.07272,2354,4478
|
||||
EC1A 7,E1W 1,16062,9651,39.9,6411,413509,576990,2.52,Flats/Maisonettes,1999,0.0,0.2,51.51834,-0.09869,51.50581,-0.06777,574,1353
|
||||
WC1B 5,EC1N 7,15504,9362,39.6,6142,411514,552780,0.98,Flats/Maisonettes,1914,1.4,0.26,51.52107,-0.12492,51.52057,-0.11049,168,747
|
||||
EC3R 6,EC1A 9,19039,11553,39.3,7486,426702,673740,1.82,Flats/Maisonettes,2018,1.0,0.31,51.50832,-0.08023,51.5181,-0.10183,325,367
|
||||
S2 5,S3 9,2308,1402,39.3,906,63873,81540,2.0,Flats/Maisonettes,1958,1.2,0.51,53.37989,-1.44909,53.39521,-1.46478,3807,1778
|
||||
W1K 5,W2 3,19600,11893,39.3,7707,454713,693630,2.17,Flats/Maisonettes,1890,2.0,0.11,51.51346,-0.14947,51.513,-0.18143,233,5113
|
||||
W1S 1,WC2E 7,24324,14910,38.7,9414,663687,847260,1.53,Flats/Maisonettes,2012,2.0,0.17,51.51339,-0.14384,51.51193,-0.1214,274,337
|
||||
W13 0,UB1 3,8242,5063,38.6,3179,220940,286110,2.55,Flats/Maisonettes,1971,3.0,0.41,51.51603,-0.3258,51.51377,-0.36322,4716,2761
|
||||
L9 9,L4 6,2410,1482,38.5,928,76096,83520,2.76,Terraced,1940,1.2,0.52,53.46789,-2.94392,53.44478,-2.95911,1898,1185
|
||||
W1T 6,EC1N 7,15122,9362,38.1,5760,322560,518400,2.07,Flats/Maisonettes,1914,1.6,0.25,51.52266,-0.14074,51.52057,-0.11049,562,747
|
||||
SW3 2,SW10 9,21360,13240,38.0,8120,596820,730800,1.66,Flats/Maisonettes,1890,3.5,0.44,51.49428,-0.16481,51.48648,-0.18567,2546,5825
|
||||
WC2E 9,SW1V 2,16982,10522,38.0,6460,381140,581400,2.55,Flats/Maisonettes,1940,2.0,0.14,51.51211,-0.1249,51.49031,-0.13734,406,3528
|
||||
SW10 0,SW11 3,12094,7514,37.9,4580,329760,412200,1.08,Flats/Maisonettes,1971,6.1,0.65,51.48064,-0.18125,51.47181,-0.17455,5124,7592
|
||||
N2 9,N12 0,8942,5554,37.9,3388,255794,304920,1.92,Flats/Maisonettes,1940,3.5,0.73,51.59254,-0.16238,51.60872,-0.17254,2927,3282
|
||||
B11 3,B11 1,2787,1741,37.5,1046,82111,94140,1.99,Terraced,1914,2.8,0.86,52.44872,-1.84954,52.46166,-1.86989,4748,3202
|
||||
W1J 8,SW1A 2,27270,17088,37.3,10182,1089474,916380,1.31,Flats/Maisonettes,2000,2.0,0.18,51.50769,-0.1444,51.50557,-0.12549,295,261
|
||||
W1H 6,NW1 4,21897,13766,37.1,8131,719593,731790,1.38,Flats/Maisonettes,1940,1.3,0.45,51.5162,-0.15546,51.52803,-0.14886,233,1340
|
||||
B5 6,B16 8,4080,2588,36.6,1492,90266,134280,1.78,Flats/Maisonettes,2022,3.0,0.58,52.47243,-1.89503,52.47603,-1.92066,2784,5647
|
||||
W1K 3,SW7 2,24608,15611,36.6,8997,1025658,809730,2.35,Flats/Maisonettes,1914,2.0,0.34,51.5108,-0.14739,51.49729,-0.17406,263,1126
|
||||
W11 1,W12 8,13227,8399,36.5,4828,272782,434520,2.07,Flats/Maisonettes,1940,4.3,0.27,51.51697,-0.20643,51.5037,-0.22797,4436,4802
|
||||
W1J 5,W8 5,26103,16629,36.3,9474,916609,852660,2.89,Flats/Maisonettes,1914,2.0,0.4,51.50796,-0.14833,51.49913,-0.1884,503,2829
|
||||
E7 0,IG1 3,7737,4926,36.3,2811,195364,252990,2.75,Flats/Maisonettes,1914,2.1,0.38,51.552,0.02729,51.56639,0.06025,4114,5219
|
||||
BB1 8,BB1 6,2318,1498,35.4,820,75030,73800,0.97,Terraced,1958,3.0,1.48,53.76182,-2.48651,53.75666,-2.47488,3550,1175
|
||||
M4 6,M3 1,4332,2797,35.4,1535,103612,138150,1.56,Flats/Maisonettes,2016,3.2,0.46,53.48377,-2.2243,53.48836,-2.24596,3944,1313
|
||||
N10 2,N12 0,8571,5554,35.2,3017,209681,271530,2.29,Flats/Maisonettes,1940,3.7,1.29,51.59958,-0.14231,51.60872,-0.17254,3792,3282
|
||||
E2 7,E1 4,10016,6518,34.9,3498,227370,314820,1.87,Flats/Maisonettes,1958,4.9,0.41,51.52816,-0.07104,51.52181,-0.04552,3508,4193
|
||||
W11 4,W6 0,15604,10152,34.9,5452,346202,490680,1.98,Flats/Maisonettes,1958,3.6,0.35,51.50884,-0.21361,51.49621,-0.23419,4044,6681
|
||||
NW10 1,NW10 0,7284,4776,34.4,2508,169290,225720,1.21,Flats/Maisonettes,1940,2.1,0.37,51.55484,-0.2425,51.55677,-0.25998,3163,3308
|
||||
NW1 8,N7 9,12470,8179,34.4,4291,276769,386190,2.09,Flats/Maisonettes,1958,2.8,0.32,51.54247,-0.15026,51.54901,-0.12137,4113,4630
|
||||
M19 3,M18 8,2911,1916,34.2,995,74127,89550,2.51,Terraced,1914,3.3,0.61,53.44574,-2.18452,53.46589,-2.16737,4046,4415
|
||||
SW1X 0,SW7 5,23489,15480,34.1,8009,784882,720810,1.31,Flats/Maisonettes,1890,3.1,0.45,51.49639,-0.16144,51.49667,-0.18078,1606,3238
|
||||
W1K 6,W8 4,23400,15425,34.1,7975,626037,717750,2.83,Flats/Maisonettes,1914,2.0,0.29,51.5128,-0.15349,51.50498,-0.19312,562,2309
|
||||
NW3 1,NW6 3,16761,11048,34.1,5713,437044,514170,1.65,Flats/Maisonettes,1914,1.2,0.33,51.5575,-0.17458,51.54407,-0.18523,2098,4073
|
||||
WC1A 2,EC1N 7,14183,9362,34.0,4821,310954,433890,0.94,Flats/Maisonettes,1914,1.7,0.28,51.51831,-0.12387,51.52057,-0.11049,234,747
|
||||
E9 7,E3 4,9732,6430,33.9,3302,224536,297180,2.4,Flats/Maisonettes,1958,5.6,0.6,51.53924,-0.04918,51.52221,-0.02733,4570,6823
|
||||
BD21 1,BD21 2,1419,938,33.9,481,37999,43290,1.01,Terraced,1914,1.7,0.7,53.85976,-1.91701,53.86885,-1.9178,2292,2303
|
||||
SW10 9,W14 9,13240,8770,33.8,4470,270435,402300,1.55,Flats/Maisonettes,1914,4.5,0.63,51.48648,-0.18567,51.48883,-0.20815,5825,7258
|
||||
L22 5,L21 3,2224,1472,33.8,752,48880,67680,1.65,Flats/Maisonettes,1958,0.8,0.28,53.47329,-3.02717,53.46477,-3.00727,821,259
|
||||
EC2A 2,E1W 1,14576,9651,33.8,4925,354600,443250,1.96,Flats/Maisonettes,2021,0.8,0.33,51.52119,-0.08217,51.50581,-0.06777,772,1353
|
||||
L20 7,L5 7,1728,1146,33.7,582,42195,52380,1.59,Terraced,1979,2.0,0.39,53.44391,-2.99118,53.42998,-2.98541,1243,848
|
||||
B6 6,B21 9,2148,1428,33.5,720,63000,64800,2.92,Terraced,1914,3.9,0.74,52.50775,-1.89127,52.50746,-1.93433,2834,3409
|
||||
B15 1,B16 8,3888,2588,33.4,1300,74100,117000,0.49,Flats/Maisonettes,2018,2.2,0.17,52.47397,-1.9142,52.47603,-1.92066,2987,5647
|
||||
W1F 9,SW1V 2,15806,10522,33.4,5284,272126,475560,2.44,Flats/Maisonettes,1940,2.0,0.32,51.51241,-0.13704,51.49031,-0.13734,340,3528
|
||||
E17 6,N15 6,8263,5515,33.3,2748,186864,247320,2.95,Flats/Maisonettes,1958,2.9,0.49,51.58862,-0.03531,51.57766,-0.07499,8292,4196
|
||||
N5 1,N7 7,10770,7185,33.3,3585,227647,322650,0.69,Flats/Maisonettes,1958,3.5,0.31,51.55439,-0.10301,51.55818,-0.11109,5686,3539
|
||||
W1W 5,EC1R 4,14857,9931,33.2,4926,310338,443340,2.41,Flats/Maisonettes,1971,1.0,0.19,51.52234,-0.14327,51.52666,-0.10846,790,634
|
||||
W1H 7,NW1 5,15131,10126,33.1,5005,402902,450450,0.69,Flats/Maisonettes,1914,1.4,0.33,51.51532,-0.15973,51.52135,-0.16228,1094,1593
|
||||
SW11 4,SW11 3,11216,7514,33.0,3702,264693,333180,1.24,Flats/Maisonettes,1971,5.4,0.72,51.47665,-0.15803,51.47181,-0.17455,5268,7592
|
||||
W7 3,UB1 3,7546,5063,32.9,2483,175051,223470,1.66,Flats/Maisonettes,1958,3.8,0.56,51.51276,-0.33883,51.51377,-0.36322,4515,2761
|
||||
B68 8,B67 7,3056,2053,32.8,1003,79237,90270,2.09,Terraced,1958,3.1,0.84,52.48621,-2.00783,52.49476,-1.98036,3700,2852
|
||||
W1F 7,SW1V 1,16512,11119,32.7,5393,320883,485370,2.28,Flats/Maisonettes,1971,2.0,0.27,51.51336,-0.13775,51.49297,-0.14234,382,1408
|
||||
L32 0,L33 9,2106,1422,32.5,684,53010,61560,2.16,Terraced,1958,1.9,0.42,53.48356,-2.90176,53.49051,-2.87196,1371,821
|
||||
L7 7,L3 2,2432,1642,32.5,790,46215,71100,1.87,Flats/Maisonettes,2004,1.2,1.01,53.40015,-2.96557,53.41155,-2.98583,910,1341
|
||||
L13 1,L7 4,2015,1363,32.4,652,42054,58680,2.09,Terraced,1940,4.3,0.4,53.40739,-2.91819,53.39725,-2.94425,610,401
|
||||
W1G 9,WC1E 7,18377,12426,32.4,5951,499884,535590,1.07,Flats/Maisonettes,1940,1.5,0.42,51.51814,-0.14732,51.52141,-0.13245,426,386
|
||||
L8 3,L8 0,2096,1420,32.3,676,42926,60840,0.81,Flats/Maisonettes,1914,5.0,1.28,53.38744,-2.95519,53.39354,-2.94861,2200,3137
|
||||
E2 8,E1 4,9634,6518,32.3,3116,200982,280440,2.17,Flats/Maisonettes,1995,6.8,0.36,51.53258,-0.07231,51.52181,-0.04552,3891,4193
|
||||
N1 1,NW1 9,13560,9213,32.1,4347,269514,391230,1.64,Flats/Maisonettes,1914,2.3,0.41,51.54168,-0.10954,51.544,-0.13333,5369,4459
|
||||
W3 6,NW10 8,7977,5414,32.1,2563,144809,230670,2.88,Flats/Maisonettes,1999,2.8,0.28,51.51541,-0.26423,51.54116,-0.25786,8573,4510
|
||||
NW1 4,EC1N 7,13766,9362,32.0,4404,295068,396360,2.73,Flats/Maisonettes,1940,1.9,0.42,51.52803,-0.14886,51.52057,-0.11049,1340,747
|
||||
L1 5,L3 2,2416,1642,32.0,774,47988,69660,1.28,Flats/Maisonettes,2004,2.8,0.51,53.40039,-2.98092,53.41155,-2.98583,1724,1341
|
||||
N1 4,E2 0,10333,7036,31.9,3297,212656,296730,2.99,Flats/Maisonettes,1940,7.1,0.41,51.54667,-0.0819,51.52807,-0.04998,3830,4330
|
||||
SE1 9,SE1 3,13534,9218,31.9,4316,308594,388440,1.69,Flats/Maisonettes,2009,3.4,0.4,51.50702,-0.10046,51.49842,-0.07986,2358,4683
|
||||
SW7 3,SW10 9,19392,13240,31.7,6152,453710,553680,0.76,Flats/Maisonettes,1890,3.3,0.41,51.49122,-0.1775,51.48648,-0.18567,2581,5825
|
||||
W1K 2,SW1X 0,34362,23489,31.6,10873,1293887,978570,1.62,Flats/Maisonettes,1914,2.0,0.5,51.50983,-0.15202,51.49639,-0.16144,591,1606
|
||||
E8 4,E2 0,10272,7036,31.5,3236,207104,291240,1.79,Flats/Maisonettes,1979,7.2,0.34,51.53852,-0.07013,51.52807,-0.04998,4554,4330
|
||||
SW12 9,SW16 2,9262,6351,31.4,2911,190670,261990,2.33,Flats/Maisonettes,1914,5.7,0.38,51.44479,-0.1475,51.43068,-0.12196,5519,7620
|
||||
BR3 3,CR0 7,7153,4910,31.4,2243,214206,201870,2.02,Terraced,1940,7.8,0.73,51.3949,-0.03019,51.38447,-0.05469,4514,5143
|
||||
NE8 3,NE6 2,1812,1248,31.1,564,35814,50760,2.38,Flats/Maisonettes,1986,1.5,0.5,54.95623,-1.58964,54.97313,-1.568,3727,4445
|
||||
KT13 8,KT15 2,6738,4663,30.8,2075,150437,186750,1.98,Flats/Maisonettes,1971,3.7,1.25,51.37281,-0.45766,51.3727,-0.48684,3317,4145
|
||||
W2 5,W10 4,13145,9102,30.8,4043,254709,363870,1.73,Flats/Maisonettes,1914,3.6,0.38,51.51783,-0.19337,51.52944,-0.21048,5062,3629
|
||||
WC1A 1,W1T 1,23988,16609,30.8,7379,472256,664110,0.48,Flats/Maisonettes,2016,2.0,0.21,51.517,-0.1274,51.51779,-0.1344,241,584
|
||||
EC1V 2,EC1V 8,13244,9196,30.6,4048,267168,364320,0.11,Flats/Maisonettes,2020,0.1,0.55,51.52885,-0.09564,51.52808,-0.09657,1056,1192
|
||||
W2 4,W1H 5,15000,10428,30.5,4572,290322,411480,2.0,Flats/Maisonettes,1914,2.1,0.28,51.51248,-0.19214,51.5169,-0.16353,5441,1194
|
||||
N15 5,N17 0,7488,5207,30.5,2281,141422,205290,2.58,Flats/Maisonettes,1940,2.6,0.47,51.58315,-0.08094,51.60306,-0.06115,3757,4447
|
||||
W1K 4,W1G 6,22808,15856,30.5,6952,590920,625680,1.05,Flats/Maisonettes,1940,2.0,0.23,51.51197,-0.14832,51.52144,-0.15008,229,588
|
||||
NW2 1,NW2 7,7494,5224,30.3,2270,164575,204300,1.95,Flats/Maisonettes,1958,1.4,0.52,51.56613,-0.21393,51.56404,-0.24245,3610,3368
|
||||
SE4 2,SE6 2,7845,5474,30.2,2371,156486,213390,2.85,Flats/Maisonettes,1914,1.0,0.39,51.46145,-0.04025,51.44106,-0.01466,5138,5519
|
||||
B18 4,B21 9,2042,1428,30.1,614,48506,55260,1.65,Terraced,1914,2.7,0.69,52.49286,-1.93966,52.50746,-1.93433,1927,3409
|
||||
L8 8,L7 4,1950,1363,30.1,587,40796,52830,1.55,Terraced,1940,3.8,1.13,53.39004,-2.96384,53.39725,-2.94425,1868,401
|
||||
NW10 5,NW10 2,10106,7078,30.0,3028,199848,272520,1.75,Flats/Maisonettes,1914,3.0,0.4,51.5329,-0.2278,51.54767,-0.23691,5023,3505
|
||||
B19 3,B7 4,3417,2397,29.9,1020,69360,91800,1.88,Flats/Maisonettes,1958,2.0,0.47,52.49095,-1.90381,52.48861,-1.87637,1584,1469
|
||||
W1U 7,WC1N 2,18854,13224,29.9,5630,375802,506700,2.67,Flats/Maisonettes,1914,1.0,0.44,51.51885,-0.15472,51.52321,-0.11611,216,514
|
||||
L15 4,L7 4,1942,1363,29.8,579,39661,52110,1.33,Terraced,1914,3.2,0.52,53.40136,-2.9259,53.39725,-2.94425,2027,401
|
||||
CH62 8,CH62 9,3363,2362,29.8,1001,81831,90090,0.74,Semi-Detached,1958,4.0,0.91,53.31456,-2.97287,53.30801,-2.97057,1775,1274
|
||||
SE1 0,SE1 5,9712,6831,29.7,2881,167098,259290,2.32,Flats/Maisonettes,2005,5.6,0.41,51.5023,-0.10018,51.48984,-0.07272,2736,4478
|
||||
SE28 8,DA18 4,4850,3409,29.7,1441,104112,129690,1.72,Terraced,1993,2.8,1.39,51.5066,0.11805,51.49419,0.1333,5033,1063
|
||||
BD21 2,BD21 3,938,660,29.6,278,24186,25020,0.88,Terraced,1914,2.0,1.07,53.86885,-1.9178,53.87113,-1.90541,2303,1377
|
||||
W7 1,UB1 3,7184,5063,29.5,2121,148470,190890,2.06,Flats/Maisonettes,1958,3.8,0.36,51.51927,-0.3343,51.51377,-0.36322,3765,2761
|
||||
L16 7,L14 6,4026,2840,29.5,1186,117414,106740,1.88,Semi-Detached,1940,5.1,1.22,53.39586,-2.89157,53.41102,-2.87901,500,809
|
||||
W1G 8,WC1N 2,18737,13224,29.4,5513,395557,496170,2.31,Flats/Maisonettes,1914,1.0,0.49,51.51913,-0.14944,51.52321,-0.11611,707,514
|
||||
L6 1,L5 8,2573,1818,29.3,755,47376,67950,1.72,Flats/Maisonettes,2009,2.0,1.1,53.41331,-2.96373,53.42093,-2.98578,1468,800
|
||||
W12 7,W12 0,11848,8378,29.3,3470,225550,312300,0.73,Flats/Maisonettes,1958,2.7,0.37,51.51063,-0.22867,51.51319,-0.23865,7409,5147
|
||||
NW8 0,W2 2,13358,9441,29.3,3917,272231,352530,2.73,Flats/Maisonettes,1958,3.0,0.38,51.53784,-0.18192,51.51475,-0.1679,2625,4150
|
||||
W1K 1,W1G 7,28754,20358,29.2,8396,812313,755640,1.52,Flats/Maisonettes,1940,2.0,0.49,51.50754,-0.15204,51.52096,-0.14696,288,280
|
||||
WC1N 2,EC1N 7,13224,9362,29.2,3862,230754,347580,0.48,Flats/Maisonettes,1914,0.9,0.53,51.52321,-0.11611,51.52057,-0.11049,514,747
|
||||
BB11 2,BB11 3,1455,1032,29.1,423,32042,38070,0.74,Terraced,1914,0.1,0.71,53.78136,-2.24549,53.78377,-2.23529,1925,2642
|
||||
E11 3,E12 5,7858,5570,29.1,2288,156728,205920,2.78,Flats/Maisonettes,1940,2.9,0.65,51.56154,0.01383,51.55444,0.05315,5821,5288
|
||||
L3 5,L3 2,2315,1642,29.1,673,38697,60570,0.98,Flats/Maisonettes,2004,1.5,0.34,53.40732,-2.97319,53.41155,-2.98583,1300,1341
|
||||
IG1 2,IG11 8,5300,3768,28.9,1532,98814,137880,1.16,Flats/Maisonettes,1986,2.6,0.81,51.5513,0.07504,51.5409,0.07766,7114,4525
|
||||
E8 3,E1 4,9141,6518,28.7,2623,174429,236070,2.67,Flats/Maisonettes,1999,6.7,0.32,51.54269,-0.0654,51.52181,-0.04552,4980,4193
|
||||
E17 9,E10 7,9498,6778,28.6,2720,175440,244800,1.56,Flats/Maisonettes,1914,2.3,0.52,51.5812,-0.01156,51.56938,-0.02405,5616,4807
|
||||
B20 3,B21 9,2000,1428,28.6,572,49764,51480,2.0,Terraced,1914,2.4,0.76,52.51116,-1.90551,52.50746,-1.93433,4683,3409
|
||||
SW1Y 6,WC2H 9,19050,13619,28.5,5431,353015,488790,1.02,Flats/Maisonettes,1914,2.0,0.26,51.50775,-0.13677,51.51405,-0.12585,343,726
|
||||
EC2Y 8,E1W 1,13481,9651,28.4,3830,268100,344700,2.4,Flats/Maisonettes,1971,0.0,0.2,51.52001,-0.09446,51.50581,-0.06777,2082,1353
|
||||
L15 1,L7 4,1902,1363,28.3,539,35843,48510,0.76,Terraced,1914,4.0,0.84,53.39986,-2.93392,53.39725,-2.94425,1301,401
|
||||
NW6 2,NW2 5,9924,7120,28.3,2804,165436,252360,2.23,Flats/Maisonettes,1940,1.5,0.27,51.54603,-0.19676,51.54856,-0.22936,3822,4370
|
||||
NW3 6,NW11 8,10350,7423,28.3,2927,191718,263430,2.42,Flats/Maisonettes,1914,1.8,0.22,51.55188,-0.1812,51.57032,-0.2005,3182,2951
|
||||
NE1 2,NE8 3,2524,1812,28.2,712,41296,64080,1.88,Flats/Maisonettes,2003,2.6,0.41,54.97209,-1.59959,54.95623,-1.58964,1713,3727
|
||||
WC1R 4,EC1Y 8,12388,8894,28.2,3494,204399,314460,1.82,Flats/Maisonettes,1958,1.0,0.28,51.51938,-0.11755,51.5234,-0.09159,362,1020
|
||||
B8 3,B8 1,2451,1761,28.2,690,55200,62100,0.95,Terraced,1914,3.9,1.07,52.48814,-1.84204,52.49094,-1.85524,4026,2834
|
||||
TW12 2,TW16 5,8001,5764,28.0,2237,189585,201330,2.42,Flats/Maisonettes,1958,1.5,0.51,51.41708,-0.37008,51.41307,-0.4051,3364,2205
|
||||
W4 1,W3 0,10328,7440,28.0,2888,254144,259920,2.74,Flats/Maisonettes,1914,2.3,0.41,51.49721,-0.25518,51.5193,-0.2734,3571,2316
|
||||
B1 3,B16 8,3590,2588,27.9,1002,60120,90180,1.04,Flats/Maisonettes,2017,2.0,0.52,52.48455,-1.91433,52.47603,-1.92066,2657,5647
|
||||
W1W 7,SW1P 4,15530,11191,27.9,4339,269018,390510,2.94,Flats/Maisonettes,1979,1.9,0.35,51.51856,-0.14057,51.49278,-0.1299,602,3443
|
||||
WC1N 1,WC1H 8,12880,9294,27.8,3586,188265,322740,0.42,Flats/Maisonettes,1940,2.3,0.21,51.52432,-0.12359,51.5279,-0.12161,1191,815
|
||||
SW8 1,SE5 0,9368,6774,27.7,2594,164719,233460,1.7,Flats/Maisonettes,1960,4.3,0.37,51.48059,-0.12115,51.47961,-0.09617,6064,3441
|
||||
E20 1,E15 2,8586,6208,27.7,2378,160515,214020,0.88,Flats/Maisonettes,2017,3.9,0.28,51.54601,-0.00917,51.53827,-0.00621,6585,6157
|
||||
EC1Y 1,EC1V 9,11663,8443,27.6,3220,214130,289800,0.26,Flats/Maisonettes,2010,1.0,0.1,51.52492,-0.08698,51.52596,-0.09038,155,960
|
||||
N15 3,N17 0,7178,5207,27.5,1971,116289,177390,3.0,Flats/Maisonettes,1914,3.4,0.67,51.58598,-0.09545,51.60306,-0.06115,4481,4447
|
||||
W1T 1,SW1Y 4,16609,12054,27.4,4555,247108,409950,1.01,Flats/Maisonettes,1993,2.0,0.2,51.51779,-0.1344,51.50869,-0.1327,584,176
|
||||
N7 9,NW1 3,8179,5948,27.3,2231,142784,200790,2.66,Flats/Maisonettes,1971,3.7,0.39,51.54901,-0.12137,51.5281,-0.14076,4630,1949
|
||||
L22 9,L22 4,2838,2067,27.2,771,74208,69390,0.51,Terraced,1914,1.4,0.47,53.47895,-3.02768,53.47998,-3.02039,567,716
|
||||
HU1 2,HU2 8,2320,1690,27.2,630,34335,56700,0.77,Flats/Maisonettes,1979,1.1,0.49,53.74094,-0.34259,53.74792,-0.34183,1177,1146
|
||||
SE1 3,SE16 3,9218,6721,27.1,2497,157311,224730,1.33,Flats/Maisonettes,1999,4.8,0.77,51.49842,-0.07986,51.49091,-0.06454,4683,4240
|
||||
LE1 6,LE1 3,2159,1576,27.0,583,26526,52470,0.95,Flats/Maisonettes,2004,5.4,0.42,52.63073,-1.13046,52.63932,-1.13017,2093,1426
|
||||
BB2 3,BB1 1,1608,1174,27.0,434,35154,39060,1.5,Terraced,1971,3.5,1.42,53.73342,-2.47718,53.74438,-2.4641,4798,3580
|
||||
N1 2,N7 6,11472,8394,26.8,3078,192375,277020,2.21,Flats/Maisonettes,1940,3.2,0.32,51.54325,-0.09732,51.55896,-0.11742,4823,3705
|
||||
L35 3,L34 6,2438,1786,26.7,652,48574,58680,2.38,Terraced,1971,0.8,0.61,53.41043,-2.7958,53.43186,-2.79945,3080,858
|
||||
B33 0,B37 5,2704,1984,26.6,720,53640,64800,1.46,Terraced,1958,4.1,1.06,52.47541,-1.77066,52.47855,-1.74975,4058,3580
|
||||
W1W 6,EC1N 7,12734,9362,26.5,3372,180402,303480,2.11,Flats/Maisonettes,1914,1.0,0.41,51.52013,-0.14163,51.52057,-0.11049,946,747
|
||||
E2 6,E1 4,8850,6518,26.4,2332,148082,209880,1.38,Flats/Maisonettes,1986,5.7,0.41,51.52646,-0.06445,51.52181,-0.04552,3414,4193
|
||||
SW17 8,SW16 2,8612,6351,26.3,2261,151487,203490,2.36,Flats/Maisonettes,1914,5.9,0.49,51.43266,-0.1565,51.43068,-0.12196,7040,7620
|
||||
LE1 4,LE1 3,2136,1576,26.2,560,26600,50400,0.58,Flats/Maisonettes,2018,4.5,1.17,52.63787,-1.13834,52.63932,-1.13017,1436,1426
|
||||
B15 2,B16 8,3508,2588,26.2,920,57960,82800,1.21,Flats/Maisonettes,2004,2.5,0.59,52.46686,-1.91097,52.47603,-1.92066,4678,5647
|
||||
NE31 2,NE32 3,2197,1629,25.9,568,43452,51120,2.31,Semi-Detached,1958,1.7,1.14,54.96595,-1.50844,54.97725,-1.47983,4787,2350
|
||||
W1T 2,EC4V 5,15137,11220,25.9,3917,230123,352530,2.37,Flats/Maisonettes,1914,1.5,0.15,51.51933,-0.13475,51.51284,-0.10141,334,208
|
||||
SK6 6,SK6 3,4485,3329,25.8,1156,99416,104040,2.56,Semi-Detached,1958,1.0,0.5,53.3973,-2.07179,53.41157,-2.1014,2365,1915
|
||||
SE1 1,SE16 3,9062,6721,25.8,2341,145142,210690,2.34,Flats/Maisonettes,1999,4.3,0.23,51.50157,-0.09427,51.49091,-0.06454,1565,4240
|
||||
SW3 3,SW10 9,17851,13240,25.8,4611,262827,414990,1.42,Flats/Maisonettes,1940,4.1,0.55,51.49144,-0.1664,51.48648,-0.18567,3413,5825
|
||||
E12 5,IG1 1,5570,4137,25.7,1433,99593,128970,1.98,Flats/Maisonettes,1940,1.6,0.73,51.55444,0.05315,51.55757,0.0819,5288,4839
|
||||
WC1X 0,EC1A 4,13653,10144,25.7,3509,236857,315810,1.29,Flats/Maisonettes,2012,0.2,0.62,51.52581,-0.11375,51.51977,-0.09756,1688,188
|
||||
B33 9,B37 5,2667,1984,25.6,683,49176,61470,2.98,Terraced,1958,3.0,0.74,52.48615,-1.79185,52.47855,-1.74975,3962,3580
|
||||
W1B 1,W1G 9,24699,18377,25.6,6322,678034,568980,0.43,Flats/Maisonettes,1958,1.1,0.17,51.52197,-0.14618,51.51814,-0.14732,466,426
|
||||
SW17 9,CR4 2,8157,6067,25.6,2090,145777,188100,1.15,Flats/Maisonettes,1914,5.0,0.44,51.42261,-0.16219,51.41288,-0.15632,6187,4538
|
||||
EC1Y 0,EC1Y 8,11961,8894,25.6,3067,181719,276030,0.3,Flats/Maisonettes,1971,0.0,0.29,51.52254,-0.09582,51.5234,-0.09159,686,1020
|
||||
IG8 7,IG6 2,7148,5325,25.5,1823,143105,164070,2.98,Terraced,1958,2.8,0.58,51.60562,0.03933,51.59914,0.08194,2965,4423
|
||||
LS28 5,LS28 6,3598,2682,25.5,916,68242,82440,1.38,Terraced,1958,1.6,1.24,53.81629,-1.67775,53.80707,-1.66403,4799,1534
|
||||
WC2E 7,SW1V 1,14910,11119,25.4,3791,242624,341190,2.53,Flats/Maisonettes,1986,2.0,0.26,51.51193,-0.1214,51.49297,-0.14234,337,1408
|
||||
DN35 7,DN32 9,1278,954,25.4,324,27864,29160,1.8,Terraced,1914,1.2,0.9,53.56767,-0.04891,53.56012,-0.07242,4576,3714
|
||||
SE22 9,SE23 1,10024,7474,25.4,2550,178500,229500,2.36,Flats/Maisonettes,1914,4.6,0.82,51.4571,-0.0718,51.446,-0.04209,3946,3974
|
||||
N16 0,E5 9,10653,7944,25.4,2709,174730,243810,1.72,Flats/Maisonettes,1940,5.9,0.64,51.56195,-0.07997,51.56528,-0.0553,3007,5763
|
||||
CH43 7,CH49 8,2680,2001,25.3,679,53301,61110,2.7,Terraced,1979,4.2,0.84,53.39925,-3.07298,53.37667,-3.08801,2619,1101
|
||||
CH1 2,CH1 3,3642,2720,25.3,922,59008,82980,0.87,Flats/Maisonettes,1994,2.7,1.23,53.19006,-2.89451,53.19535,-2.88502,842,2725
|
||||
N1C 4,N1 7,14255,10642,25.3,3613,251103,325170,2.36,Flats/Maisonettes,2018,2.5,0.6,51.53756,-0.12621,51.5324,-0.09254,1949,5352
|
||||
NW2 2,NW2 7,6991,5224,25.3,1767,122806,159030,2.74,Flats/Maisonettes,1958,0.9,0.82,51.56242,-0.20213,51.56404,-0.24245,3603,3368
|
||||
SW15 6,SW19 6,8335,6233,25.2,2102,145038,189180,1.89,Flats/Maisonettes,1958,4.0,0.64,51.4602,-0.22549,51.44413,-0.21583,4327,4199
|
||||
HA0 4,NW10 0,6382,4776,25.2,1606,114026,144540,2.84,Flats/Maisonettes,1940,3.7,0.41,51.54606,-0.29796,51.55677,-0.25998,3934,3308
|
||||
NE22 7,NE22 5,2220,1662,25.1,558,41292,50220,1.25,Semi-Detached,1958,2.8,0.9,55.1417,-1.56867,55.13372,-1.58167,1369,3178
|
||||
B13 8,B13 9,3557,2669,25.0,888,59940,79920,1.35,Flats/Maisonettes,1958,3.3,0.65,52.44751,-1.89353,52.44314,-1.87493,3567,6454
|
||||
L1 2,L3 8,2481,1863,24.9,618,35226,55620,0.97,Flats/Maisonettes,2006,1.0,0.33,53.40313,-2.97495,53.41171,-2.97208,531,1603
|
||||
SE21 8,SW16 3,7738,5811,24.9,1927,150306,173430,2.71,Flats/Maisonettes,1940,5.2,0.47,51.4369,-0.09319,51.4182,-0.11905,4505,3133
|
||||
SW7 1,SW1A 1,22459,16895,24.8,5564,730275,500760,1.99,Flats/Maisonettes,1940,2.9,0.46,51.50025,-0.16756,51.50602,-0.13973,1864,229
|
||||
N3 2,N12 0,7373,5554,24.7,1819,123692,163710,1.22,Flats/Maisonettes,1940,3.2,0.52,51.60127,-0.18578,51.60872,-0.17254,3926,3282
|
||||
W1H 1,W1H 4,18118,13634,24.7,4484,242136,403560,0.25,Flats/Maisonettes,1914,2.5,0.28,51.52019,-0.16119,51.51941,-0.16464,626,484
|
||||
SW5 9,W6 8,13297,10011,24.7,3286,190588,295740,1.49,Flats/Maisonettes,1914,3.4,0.24,51.49114,-0.19565,51.48654,-0.21631,5808,3253
|
||||
SE15 4,SE23 1,9910,7474,24.6,2436,171738,219240,2.95,Flats/Maisonettes,1914,3.4,0.46,51.46584,-0.07115,51.446,-0.04209,3545,3974
|
||||
E8 2,E2 0,9330,7036,24.6,2294,144522,206460,2.8,Flats/Maisonettes,1940,6.8,0.39,51.55034,-0.0696,51.52807,-0.04998,4333,4330
|
||||
W1U 6,W2 6,14569,10990,24.6,3579,238003,322110,1.81,Flats/Maisonettes,1914,1.0,0.23,51.52059,-0.15781,51.51704,-0.18386,1075,4210
|
||||
SE16 6,E14 3,8241,6225,24.5,2016,139104,181440,2.21,Flats/Maisonettes,1993,1.7,0.54,51.50061,-0.04314,51.49193,-0.01389,1940,8179
|
||||
SW13 9,W4 5,12220,9220,24.5,3000,231750,270000,2.61,Flats/Maisonettes,1940,2.5,0.93,51.48023,-0.24118,51.49745,-0.26758,2631,4935
|
||||
L22 6,L22 7,3710,2800,24.5,910,73710,81900,0.4,Semi-Detached,1940,0.0,0.7,53.48144,-3.04085,53.47948,-3.03589,464,450
|
||||
L3 0,L5 9,2906,2198,24.4,708,47790,63720,1.03,Flats/Maisonettes,2001,1.0,1.03,53.41642,-3.00063,53.42433,-2.99258,1085,525
|
||||
WC2B 5,SW1V 1,14709,11119,24.4,3590,211810,323100,2.86,Flats/Maisonettes,1971,2.0,0.21,51.5155,-0.1218,51.49297,-0.14234,891,1408
|
||||
W10 5,NW6 5,10968,8303,24.3,2665,163897,239850,1.66,Flats/Maisonettes,1971,4.0,0.55,51.5225,-0.21145,51.53337,-0.19455,5247,4901
|
||||
L16 5,L14 6,3753,2840,24.3,913,79431,82170,1.4,Semi-Detached,1940,4.7,0.97,53.3991,-2.88607,53.41102,-2.87901,453,809
|
||||
NE1 3,NE1 6,2754,2090,24.1,664,36520,59760,0.55,Flats/Maisonettes,2008,2.3,0.35,54.96753,-1.61131,54.972,-1.60784,903,712
|
||||
W1T 3,SW1P 2,15879,12102,23.8,3777,247393,339930,2.58,Flats/Maisonettes,2015,1.9,0.3,51.51842,-0.13787,51.49529,-0.13253,827,2039
|
||||
N1 8,NW1 9,12078,9213,23.7,2865,181927,257850,2.53,Flats/Maisonettes,1940,1.4,0.41,51.53533,-0.09877,51.544,-0.13333,3569,4459
|
||||
SW1W 0,SW1E 5,16694,12733,23.7,3961,354509,356490,0.38,Flats/Maisonettes,1979,3.3,0.21,51.49667,-0.146,51.49843,-0.14124,595,272
|
||||
L1 3,L2 0,2929,2234,23.7,695,33360,62550,0.5,Flats/Maisonettes,2009,2.0,0.39,53.40327,-2.98663,53.40563,-2.99288,365,609
|
||||
KT2 5,KT2 7,8290,6333,23.6,1957,148732,176130,1.59,Flats/Maisonettes,1995,2.4,0.94,51.42071,-0.30084,51.41822,-0.27782,6209,3298
|
||||
E1 2,E1 4,8529,6518,23.6,2011,120660,180990,1.1,Flats/Maisonettes,1999,6.2,0.3,51.51519,-0.0577,51.52181,-0.04552,3511,4193
|
||||
SW1H 9,EC4V 3,14058,10757,23.5,3301,209613,297090,2.75,Flats/Maisonettes,1999,3.0,0.2,51.50018,-0.13284,51.51065,-0.09613,289,315
|
||||
WV2 3,WV3 0,2407,1842,23.5,565,42375,50850,1.33,Terraced,1914,2.5,1.27,52.57102,-2.12564,52.57828,-2.14126,1725,3074
|
||||
W1H 4,W1H 5,13634,10428,23.5,3206,201978,288540,0.29,Flats/Maisonettes,1940,3.3,0.27,51.51941,-0.16464,51.5169,-0.16353,484,1194
|
||||
W4 5,TW8 9,9220,7062,23.4,2158,132717,194220,2.91,Flats/Maisonettes,1958,2.8,0.32,51.49745,-0.26758,51.49138,-0.30937,4935,2835
|
||||
N15 4,N17 0,6797,5207,23.4,1590,94605,143100,2.03,Flats/Maisonettes,1958,2.4,0.48,51.58626,-0.07309,51.60306,-0.06115,5055,4447
|
||||
B3 1,B16 8,3374,2588,23.3,786,44802,70740,1.35,Flats/Maisonettes,2009,2.0,0.31,52.48427,-1.90599,52.47603,-1.92066,3141,5647
|
||||
BB9 8,BB9 7,1452,1115,23.2,337,27465,30330,1.05,Terraced,1940,1.4,1.36,53.84457,-2.20627,53.838,-2.21735,3407,2693
|
||||
B4 6,B5 5,4045,3105,23.2,940,45590,84600,0.55,Flats/Maisonettes,2017,2.0,0.23,52.4845,-1.89523,52.48086,-1.88965,2116,434
|
||||
EC1V 1,EC1V 8,11949,9196,23.0,2753,173439,247770,0.23,Flats/Maisonettes,2016,0.5,0.44,51.52897,-0.09357,51.52808,-0.09657,1360,1192
|
||||
NW6 1,NW2 4,10139,7802,23.0,2337,157747,210330,1.63,Flats/Maisonettes,1914,1.4,0.42,51.55137,-0.19415,51.55025,-0.21803,5669,3614
|
||||
NW5 1,N19 3,11360,8742,23.0,2618,175406,235620,1.65,Flats/Maisonettes,1914,4.4,0.39,51.55724,-0.14407,51.56879,-0.12869,3114,5518
|
||||
L9 8,L20 9,1951,1502,23.0,449,40410,40410,1.84,Terraced,1940,2.4,0.44,53.46493,-2.9656,53.45061,-2.97938,1769,2306
|
||||
SW17 7,SW16 6,8557,6603,22.8,1954,132872,175860,2.25,Flats/Maisonettes,1940,4.1,0.41,51.43851,-0.16223,51.42335,-0.14006,6006,6534
|
||||
WC2H 9,SW1V 2,13619,10522,22.7,3097,170335,278730,2.74,Flats/Maisonettes,1940,2.0,0.18,51.51405,-0.12585,51.49031,-0.13734,726,3528
|
||||
NE8 2,NE8 3,2343,1812,22.7,531,34780,47790,2.06,Flats/Maisonettes,2004,2.0,0.87,54.95767,-1.61988,54.95623,-1.58964,3437,3727
|
||||
NE6 1,NE8 3,2345,1812,22.7,533,33845,47970,1.96,Flats/Maisonettes,1986,1.8,0.6,54.97331,-1.58183,54.95623,-1.58964,1688,3727
|
||||
LE1 5,LE1 3,2034,1576,22.5,458,20381,41220,0.93,Flats/Maisonettes,1999,5.0,0.73,52.6316,-1.13556,52.63932,-1.13017,1626,1426
|
||||
WC1N 3,WC1H 8,11955,9294,22.3,2661,154338,239490,0.73,Flats/Maisonettes,1940,1.0,0.38,51.52146,-0.11947,51.5279,-0.12161,917,815
|
||||
E1 0,E1 4,8390,6518,22.3,1872,123552,168480,0.99,Flats/Maisonettes,1971,5.1,0.35,51.51313,-0.04908,51.52181,-0.04552,3486,4193
|
||||
BB11 4,BB11 3,1328,1032,22.3,296,21090,26640,1.59,Terraced,1914,0.0,0.63,53.78411,-2.25864,53.78377,-2.23529,3293,2642
|
||||
SE3 7,SE12 8,7893,6137,22.2,1756,126432,158040,2.72,Flats/Maisonettes,1940,3.6,0.65,51.47871,0.0152,51.45411,0.01431,3853,3365
|
||||
B42 2,B20 1,3168,2464,22.2,704,54560,63360,2.14,Semi-Detached,1958,2.6,1.27,52.53357,-1.90811,52.52345,-1.93504,5931,2309
|
||||
E17 7,E10 7,8712,6778,22.2,1934,126677,174060,1.45,Flats/Maisonettes,1940,2.0,0.28,51.58188,-0.03031,51.56938,-0.02405,6041,4807
|
||||
W1G 7,W1G 6,20358,15856,22.1,4502,387172,405180,0.22,Flats/Maisonettes,1940,1.0,0.29,51.52096,-0.14696,51.52144,-0.15008,280,588
|
||||
FY1 5,FY1 3,1190,927,22.1,263,20251,23670,1.43,Terraced,1940,0.0,0.71,53.80886,-3.04494,53.8218,-3.0435,3510,2642
|
||||
L17 8,L8 3,2687,2096,22.0,591,37233,53190,0.78,Flats/Maisonettes,1914,5.7,0.79,53.3819,-2.94808,53.38744,-2.95519,2017,2200
|
||||
SE10 0,E14 2,7544,5898,21.8,1646,116866,148140,1.89,Flats/Maisonettes,2016,2.0,0.58,51.49297,0.01055,51.50866,-0.00067,10015,844
|
||||
E3 5,E3 4,8220,6430,21.8,1790,126195,161100,1.18,Flats/Maisonettes,1971,5.5,0.74,51.53199,-0.03426,51.52221,-0.02733,4925,6823
|
||||
DL1 1,DL1 4,2015,1576,21.8,439,32486,39510,1.07,Semi-Detached,1986,2.2,0.97,54.52695,-1.53562,54.51731,-1.53379,3425,5446
|
||||
NW5 3,NW1 1,11150,8731,21.7,2419,151187,217710,1.95,Flats/Maisonettes,1958,2.7,0.23,51.54742,-0.14738,51.53218,-0.13296,1597,2651
|
||||
AL8 6,AL7 1,6072,4755,21.7,1317,90873,118530,1.96,Flats/Maisonettes,1958,2.0,0.84,51.79764,-0.21351,51.80767,-0.18975,2609,3209
|
||||
N8 0,N22 6,8463,6637,21.6,1826,115038,164340,0.83,Flats/Maisonettes,1940,3.5,0.44,51.58777,-0.10664,51.59482,-0.10227,6469,4971
|
||||
E14 5,E14 2,7510,5898,21.5,1612,114452,145080,0.85,Flats/Maisonettes,2000,0.8,0.37,51.50474,-0.0115,51.50866,-0.00067,712,844
|
||||
N8 7,N22 8,7980,6261,21.5,1719,102280,154710,1.96,Flats/Maisonettes,1958,2.9,0.6,51.58797,-0.11977,51.6055,-0.11564,4703,4514
|
||||
GU16 7,GU15 3,4558,3581,21.4,977,70832,87930,2.46,Flats/Maisonettes,1971,2.1,0.46,51.31468,-0.74357,51.33663,-0.7494,953,3911
|
||||
E9 5,E15 4,7728,6072,21.4,1656,106812,149040,2.92,Flats/Maisonettes,1979,4.7,0.44,51.54638,-0.03302,51.54158,0.00934,4708,4460
|
||||
B3 2,B16 8,3290,2588,21.3,702,38610,63180,1.43,Flats/Maisonettes,2014,2.0,0.23,52.48237,-1.90234,52.47603,-1.92066,178,5647
|
||||
E1W 1,SE16 5,9651,7596,21.3,2055,146932,184950,1.93,Flats/Maisonettes,1998,1.4,0.57,51.50581,-0.06777,51.50438,-0.03943,1353,2901
|
||||
L16 1,L14 6,3607,2840,21.3,767,61360,69030,1.18,Semi-Detached,1940,5.2,0.51,53.40267,-2.88984,53.41102,-2.87901,434,809
|
||||
LA14 2,LA14 1,1252,985,21.3,267,18022,24030,0.81,Terraced,1914,1.1,1.37,54.10749,-3.22482,54.11479,-3.22584,3502,2195
|
||||
B11 2,B11 1,2208,1741,21.2,467,36192,42030,1.57,Terraced,1914,2.2,0.64,52.4551,-1.84936,52.46166,-1.86989,1291,3202
|
||||
L13 3,L13 2,2078,1637,21.2,441,34618,39690,0.48,Terraced,1914,4.1,1.34,53.41643,-2.91645,53.41304,-2.92082,1350,1340
|
||||
E14 9,E14 3,7903,6225,21.2,1678,111587,151020,0.99,Flats/Maisonettes,2013,0.8,0.26,51.50073,-0.01626,51.49193,-0.01389,18659,8179
|
||||
B25 8,B10 0,2476,1952,21.2,524,38514,47160,2.64,Terraced,1940,3.8,1.45,52.4666,-1.81995,52.46765,-1.85885,5605,2837
|
||||
SW3 6,SW10 9,16803,13240,21.2,3563,260099,320670,0.84,Flats/Maisonettes,1914,4.3,0.67,51.48869,-0.17386,51.48648,-0.18567,1594,5825
|
||||
WC1B 3,EC4V 5,14214,11220,21.1,2994,210328,269460,1.92,Flats/Maisonettes,1914,1.9,0.23,51.51799,-0.12848,51.51284,-0.10141,322,208
|
||||
NW1 6,NW6 4,11154,8814,21.0,2340,136890,210600,2.62,Flats/Maisonettes,1914,3.1,0.23,51.5237,-0.16342,51.54102,-0.18969,3312,3641
|
||||
EC1V 4,EC1Y 8,11255,8894,21.0,2361,151104,212490,0.83,Flats/Maisonettes,1999,0.0,0.43,51.52548,-0.10341,51.5234,-0.09159,897,1020
|
||||
NW5 2,N19 3,11064,8742,21.0,2322,145125,208980,2.09,Flats/Maisonettes,1914,3.9,0.35,51.5506,-0.13698,51.56879,-0.12869,4088,5518
|
||||
BB12 0,BB12 6,2222,1756,21.0,466,35882,41940,1.82,Terraced,1971,0.3,0.76,53.79775,-2.257,53.79231,-2.28235,3008,3957
|
||||
L8 4,L7 6,2126,1682,20.9,444,30636,39960,2.4,Terraced,1958,3.5,0.69,53.3828,-2.9662,53.4,-2.94456,1582,885
|
||||
N22 7,N11 2,8317,6576,20.9,1741,127093,156690,1.09,Flats/Maisonettes,1914,3.9,0.49,51.59943,-0.12437,51.60923,-0.12642,2841,3946
|
||||
SW17 0,CR4 2,7672,6067,20.9,1605,111948,144450,2.42,Flats/Maisonettes,1958,5.0,0.6,51.43102,-0.17616,51.41288,-0.15632,7498,4538
|
||||
TW7 7,TW7 4,6663,5276,20.8,1387,101944,124830,1.84,Flats/Maisonettes,1971,3.4,1.05,51.46225,-0.3339,51.47701,-0.34628,3353,3229
|
||||
L16 2,L36 4,3306,2618,20.8,688,65016,61920,1.21,Semi-Detached,1958,3.5,1.21,53.40324,-2.87211,53.41162,-2.86068,807,2242
|
||||
CH42 9,CH42 0,1726,1367,20.8,359,30694,32310,0.8,Terraced,1914,4.9,1.49,53.37733,-3.03646,53.38266,-3.02855,1821,1277
|
||||
ST4 2,ST1 4,1611,1276,20.8,335,23785,30150,1.7,Terraced,1914,1.0,0.79,53.00742,-2.17135,53.01975,-2.18626,2587,1893
|
||||
L36 9,L36 5,3227,2558,20.7,669,63555,60210,0.94,Semi-Detached,1986,2.4,0.35,53.41196,-2.84932,53.40572,-2.83991,824,1467
|
||||
E2 9,E1 4,8218,6518,20.7,1700,105400,153000,1.34,Flats/Maisonettes,1971,6.3,0.31,51.53204,-0.05617,51.52181,-0.04552,3336,4193
|
||||
N1 0,NW5 4,11018,8753,20.6,2265,144960,203850,2.96,Flats/Maisonettes,1958,1.6,0.54,51.53742,-0.11419,51.55049,-0.15229,4577,2697
|
||||
NG7 7,NG7 6,2175,1730,20.5,445,31595,40050,0.71,Terraced,1914,4.1,0.56,52.97526,-1.16804,52.96909,-1.16531,2339,3559
|
||||
NG7 2,NG2 1,2801,2228,20.5,573,42688,51570,1.76,Terraced,1958,4.4,0.58,52.94656,-1.17989,52.94167,-1.15523,3214,1056
|
||||
NG1 7,NG1 1,2786,2216,20.5,570,30210,51300,0.49,Flats/Maisonettes,2010,3.4,0.2,52.94995,-1.14699,52.95301,-1.14178,945,2895
|
||||
NR32 1,NR32 2,2311,1840,20.4,471,32734,42390,0.81,Terraced,1914,2.0,0.86,52.48169,1.75296,52.47962,1.74151,2532,4026
|
||||
TW4 5,TW3 3,5776,4597,20.4,1179,76045,106110,1.08,Flats/Maisonettes,1979,5.5,1.24,51.45786,-0.3795,51.46635,-0.37157,3500,4518
|
||||
L15 0,L7 4,1712,1363,20.4,349,23906,31410,0.75,Terraced,1914,4.8,1.06,53.39699,-2.93315,53.39725,-2.94425,1329,401
|
||||
WF8 2,WF11 8,2705,2152,20.4,553,42304,49770,2.99,Semi-Detached,1958,1.8,0.8,53.6932,-1.29594,53.70935,-1.26056,7519,2858
|
||||
CH42 2,CH42 0,1715,1367,20.3,348,28536,31320,1.85,Terraced,1940,2.9,0.32,53.37035,-3.01,53.38266,-3.02855,1267,1277
|
||||
SW9 0,SE5 0,8502,6774,20.3,1728,112320,155520,1.46,Flats/Maisonettes,1958,3.7,0.46,51.47388,-0.11558,51.47961,-0.09617,4980,3441
|
||||
LS6 3,LS6 2,3230,2577,20.2,653,51587,58770,1.69,Flats/Maisonettes,1940,1.6,0.68,53.82002,-1.58519,53.81733,-1.56075,4033,3873
|
||||
SW20 0,KT3 4,8740,6972,20.2,1768,144092,159120,1.41,Flats/Maisonettes,1958,4.8,0.75,51.41269,-0.23796,51.40313,-0.25172,3673,2531
|
||||
RM14 2,RM12 5,6360,5079,20.1,1281,111447,115290,2.99,Semi-Detached,1940,3.7,0.77,51.55277,0.24307,51.54507,0.20079,3026,3133
|
||||
N7 8,N7 7,8998,7185,20.1,1813,110593,163170,1.11,Flats/Maisonettes,1979,3.2,0.28,51.54816,-0.11264,51.55818,-0.11109,4607,3539
|
||||
B19 1,B21 9,1788,1428,20.1,360,30060,32400,1.69,Terraced,1914,3.6,1.2,52.50297,-1.91051,52.50746,-1.93433,3162,3409
|
||||
L18 6,L15 6,3877,3098,20.1,779,88806,70110,1.54,Semi-Detached,1940,4.8,0.87,53.38188,-2.90414,53.39565,-2.90712,564,901
|
||||
WV14 6,WV14 7,2774,2218,20.0,556,41700,50040,1.05,Semi-Detached,1958,3.5,0.74,52.57348,-2.0784,52.56866,-2.06512,2963,1517
|
||||
DN35 8,DN35 7,1598,1278,20.0,320,26400,28800,1.7,Terraced,1914,0.7,0.65,53.55706,-0.03085,53.56767,-0.04891,3803,4576
|
||||
NE4 5,NE8 1,1503,1202,20.0,301,21070,27090,2.84,Flats/Maisonettes,1914,2.0,0.96,54.97595,-1.63497,54.95588,-1.60896,3018,2310
|
||||
B9 5,B10 9,2597,2079,19.9,518,44289,46620,0.95,Terraced,1940,5.5,1.05,52.47881,-1.84057,52.47059,-1.84439,5554,4180
|
||||
SE11 4,SE5 0,8462,6774,19.9,1688,111408,151920,1.4,Flats/Maisonettes,1971,5.3,0.32,51.49044,-0.10679,51.47961,-0.09617,4439,3441
|
||||
SE10 9,E14 3,7769,6225,19.9,1544,107308,138960,1.14,Flats/Maisonettes,2012,2.0,0.31,51.48279,-0.00612,51.49193,-0.01389,6113,8179
|
||||
W2 6,NW6 4,10990,8814,19.8,2176,129472,195840,2.68,Flats/Maisonettes,1914,2.5,0.3,51.51704,-0.18386,51.54102,-0.18969,4210,3641
|
||||
SE11 6,SE1 5,8493,6831,19.6,1662,103044,149580,2.81,Flats/Maisonettes,1971,4.7,0.58,51.49243,-0.11392,51.48984,-0.07272,2536,4478
|
||||
TW10 6,SW14 7,11640,9375,19.5,2265,175537,203850,1.91,Flats/Maisonettes,1914,1.8,0.79,51.45695,-0.29727,51.46517,-0.27252,4157,2831
|
||||
N2 0,NW11 0,8195,6594,19.5,1601,123277,144090,2.02,Flats/Maisonettes,1940,1.8,0.83,51.5874,-0.17339,51.58225,-0.2019,3685,2109
|
||||
S1 2,S1 4,2568,2069,19.4,499,21207,44910,0.47,Flats/Maisonettes,2012,3.2,0.24,53.38187,-1.46947,53.37885,-1.47441,2182,5583
|
||||
TW2 7,TW3 2,6971,5620,19.4,1351,113821,121590,1.0,Semi-Detached,1940,5.4,0.59,51.45266,-0.35393,51.4609,-0.36011,3377,2964
|
||||
L15 7,L14 6,3518,2840,19.3,678,56274,61020,2.0,Semi-Detached,1940,5.1,0.83,53.40286,-2.90524,53.41102,-2.87901,1032,809
|
||||
CH45 1,CH45 4,2213,1785,19.3,428,38948,38520,1.26,Semi-Detached,1914,1.7,0.83,53.43265,-3.03879,53.42321,-3.04925,1283,1465
|
||||
N1 9,N1 6,10402,8392,19.3,2010,115575,180900,2.16,Flats/Maisonettes,1971,1.1,0.31,51.53273,-0.1152,51.52938,-0.08379,2833,3384
|
||||
EC3R 8,E1 7,10309,8330,19.2,1979,101918,178110,0.99,Flats/Maisonettes,1986,0.0,0.19,51.50999,-0.08419,51.51629,-0.07389,221,2146
|
||||
L18 9,L19 4,3921,3169,19.2,752,69184,67680,0.66,Semi-Detached,1958,4.2,0.67,53.36947,-2.89798,53.3647,-2.89212,1296,1311
|
||||
BB9 0,BB9 7,1378,1115,19.1,263,22092,23670,1.05,Terraced,1914,1.0,0.84,53.82989,-2.20929,53.838,-2.21735,4211,2693
|
||||
M25 9,M27 4,3607,2917,19.1,690,55890,62100,2.99,Semi-Detached,1958,1.5,1.33,53.52156,-2.2855,53.5097,-2.32514,2959,2295
|
||||
E16 2,SE18 5,6146,4972,19.1,1174,78658,105660,1.26,Flats/Maisonettes,2017,3.1,0.33,51.502,0.04566,51.49158,0.05314,11558,3903
|
||||
SK15 2,OL6 6,3024,2450,19.0,574,40754,51660,2.77,Terraced,1958,2.2,1.36,53.47937,-2.04467,53.48856,-2.08261,3742,2416
|
||||
WV14 8,WS10 7,3186,2581,19.0,605,42652,54450,2.72,Semi-Detached,1958,1.7,0.62,52.54996,-2.06972,52.55734,-2.03155,5736,2725
|
||||
TW12 3,KT8 1,7042,5708,18.9,1334,107053,120060,2.23,Terraced,1979,3.2,1.27,51.42588,-0.37796,51.40643,-0.36933,2527,1567
|
||||
LS1 4,LS11 9,3513,2848,18.9,665,40897,59850,0.82,Flats/Maisonettes,2006,3.0,0.46,53.79487,-1.55334,53.78743,-1.55422,2171,2841
|
||||
NE3 4,NE3 2,3298,2676,18.9,622,53803,55980,1.92,Semi-Detached,1958,4.9,1.25,55.00102,-1.63652,55.01655,-1.64912,3991,4588
|
||||
CT19 5,CT19 4,3399,2761,18.8,638,53592,57420,2.06,Terraced,1940,3.0,0.77,51.08812,1.17314,51.08929,1.14286,4481,3250
|
||||
L31 3,L10 2,3176,2578,18.8,598,56212,53820,2.77,Semi-Detached,1971,2.0,0.35,53.50739,-2.93196,53.48371,-2.94544,661,347
|
||||
L9 0,L20 9,1849,1502,18.8,347,30189,31230,2.77,Terraced,1914,1.9,0.6,53.46914,-2.95196,53.45061,-2.97938,1477,2306
|
||||
L30 7,L30 8,2650,2154,18.7,496,40176,44640,0.79,Semi-Detached,1979,3.0,1.02,53.49196,-2.96368,53.48815,-2.95383,790,425
|
||||
BN2 0,BN2 4,5633,4582,18.7,1051,69366,94590,2.32,Flats/Maisonettes,1958,3.0,1.35,50.82353,-0.12438,50.84309,-0.11204,3130,5322
|
||||
SW3 5,SW10 9,16271,13240,18.6,3031,207623,272790,1.09,Flats/Maisonettes,1914,4.8,1.05,51.48503,-0.16986,51.48648,-0.18567,3183,5825
|
||||
IP4 1,IP1 1,2606,2120,18.6,486,28674,43740,0.79,Flats/Maisonettes,2004,2.1,0.95,52.05439,1.16289,52.05524,1.15128,2603,1100
|
||||
NR33 0,NR32 2,2261,1840,18.6,421,30943,37890,1.62,Terraced,1914,2.0,1.29,52.46508,1.739,52.47962,1.74151,3979,4026
|
||||
L9 2,L9 4,1807,1472,18.5,335,27637,30150,1.02,Terraced,1940,1.8,0.37,53.45663,-2.95873,53.46584,-2.95873,1133,551
|
||||
B12 8,B11 1,2135,1741,18.5,394,30929,35460,0.88,Terraced,1914,1.8,1.16,52.45575,-1.87847,52.46166,-1.86989,2418,3202
|
||||
NW3 5,NW3 6,12706,10350,18.5,2356,174344,212040,0.5,Flats/Maisonettes,1914,2.6,0.36,51.54962,-0.17479,51.55188,-0.1812,3173,3182
|
||||
SW4 6,SE5 0,8312,6774,18.5,1538,100739,138420,2.69,Flats/Maisonettes,1958,3.4,0.27,51.46863,-0.13154,51.47961,-0.09617,4563,3441
|
||||
L23 0,L21 9,3007,2450,18.5,557,49016,50130,1.48,Semi-Detached,1940,2.0,1.2,53.48494,-3.01832,53.47603,-3.00214,1547,1758
|
||||
SW1W 8,SW1P 4,13714,11191,18.4,2523,166518,227070,1.55,Flats/Maisonettes,1993,3.8,0.47,51.48954,-0.15204,51.49278,-0.1299,3312,3443
|
||||
TS4 3,TS4 2,1675,1367,18.4,308,23100,27720,1.7,Terraced,1958,1.6,1.03,54.548,-1.22043,54.56321,-1.22413,3973,4228
|
||||
CR4 2,CR4 4,6067,4969,18.1,1098,76036,98820,2.29,Flats/Maisonettes,1940,4.4,0.48,51.41288,-0.15632,51.39323,-0.16681,4538,3840
|
||||
PL1 3,PL4 0,2904,2378,18.1,526,35242,47340,1.79,Flats/Maisonettes,1986,2.3,1.41,50.36686,-4.15566,50.36895,-4.12957,4256,1753
|
||||
E9 6,E1 4,7954,6518,18.1,1436,92622,129240,2.76,Flats/Maisonettes,1971,6.8,0.38,51.54675,-0.04816,51.52181,-0.04552,3947,4193
|
||||
LS18 5,LS16 6,4110,3367,18.1,743,59068,66870,1.91,Semi-Detached,1958,2.8,1.03,53.84265,-1.63652,53.84651,-1.60911,3580,4155
|
||||
E1 1,E14 7,7686,6306,18.0,1380,84180,124200,2.27,Flats/Maisonettes,2000,3.4,0.38,51.51507,-0.06529,51.51406,-0.03192,3545,5687
|
||||
SW7 4,W14 8,14200,11646,18.0,2554,176226,229860,1.54,Flats/Maisonettes,1914,2.6,0.31,51.49492,-0.18518,51.49743,-0.20756,3430,7221
|
||||
SW20 8,KT3 4,8500,6972,18.0,1528,116892,137520,2.23,Flats/Maisonettes,1940,4.6,0.46,51.41147,-0.22174,51.40313,-0.25172,5155,2531
|
||||
L4 5,L20 2,1410,1158,17.9,252,19908,22680,1.18,Terraced,1914,2.0,0.99,53.44347,-2.96715,53.44207,-2.98445,2817,1300
|
||||
SM6 7,SM6 0,5628,4620,17.9,1008,67032,90720,1.97,Flats/Maisonettes,1996,4.0,0.55,51.37513,-0.15286,51.35737,-0.15137,3418,3099
|
||||
NW5 4,N7 7,8753,7185,17.9,1568,94864,141120,2.92,Flats/Maisonettes,1971,3.0,0.42,51.55049,-0.15229,51.55818,-0.11109,2697,3539
|
||||
M3 7,M3 6,3816,3134,17.9,682,40579,61380,0.7,Flats/Maisonettes,2018,3.8,0.46,53.48713,-2.25085,53.4858,-2.26093,5941,3553
|
||||
NE2 2,NE2 1,3146,2584,17.9,562,44960,50580,1.01,Flats/Maisonettes,1914,3.3,0.63,54.99126,-1.60136,54.98237,-1.5983,2656,3970
|
||||
W4 2,W4 4,10174,8372,17.7,1802,128843,162180,1.1,Flats/Maisonettes,1914,1.8,0.69,51.48794,-0.25401,51.49057,-0.26957,4027,2732
|
||||
SW4 0,SW2 1,9983,8214,17.7,1769,113216,159210,2.14,Flats/Maisonettes,1940,4.2,0.52,51.4646,-0.14415,51.45683,-0.11529,3931,3735
|
||||
EC1M 5,EC1Y 8,10803,8894,17.7,1909,128857,171810,0.78,Flats/Maisonettes,1999,0.0,0.18,51.52179,-0.10282,51.5234,-0.09159,521,1020
|
||||
L4 1,L20 7,2098,1728,17.6,370,27565,33300,1.2,Terraced,1979,2.3,0.49,53.43506,-2.98101,53.44391,-2.99118,1667,1243
|
||||
EC3N 2,EC1V 9,10244,8443,17.6,1801,112562,162090,1.86,Flats/Maisonettes,1999,0.0,0.08,51.5113,-0.07707,51.52596,-0.09038,166,960
|
||||
SE27 0,SW16 3,7049,5811,17.6,1238,95326,111420,1.5,Flats/Maisonettes,1940,5.6,0.53,51.42953,-0.10688,51.4182,-0.11905,5574,3133
|
||||
B43 5,B20 1,2989,2464,17.6,525,43312,47250,1.8,Semi-Detached,1958,2.2,1.28,52.5392,-1.94182,52.52345,-1.93504,3455,2309
|
||||
W6 0,W12 0,10152,8378,17.5,1774,116197,159660,1.9,Flats/Maisonettes,1940,3.2,0.33,51.49621,-0.23419,51.51319,-0.23865,6681,5147
|
||||
UB3 4,UB7 9,6151,5076,17.5,1075,76325,96750,2.94,Flats/Maisonettes,2004,1.9,0.55,51.49951,-0.41996,51.50394,-0.46273,4607,4365
|
||||
M7 1,M5 3,3274,2701,17.5,573,39823,51570,2.67,Flats/Maisonettes,2007,3.1,1.23,53.49476,-2.26295,53.47213,-2.27652,2375,5106
|
||||
SW1V 4,SW1V 2,12739,10522,17.4,2217,116392,199530,0.47,Flats/Maisonettes,1890,3.0,0.61,51.48917,-0.14394,51.49031,-0.13734,3227,3528
|
||||
HA7 2,HA3 0,6834,5646,17.4,1188,108108,106920,2.57,Semi-Detached,1940,2.9,1.31,51.60329,-0.31221,51.58069,-0.30359,2775,3122
|
||||
EC1R 0,EC1R 4,12020,9931,17.4,2089,133696,188010,0.25,Flats/Maisonettes,1971,0.0,0.51,51.52501,-0.10599,51.52666,-0.10846,532,634
|
||||
L19 7,L19 9,4230,3496,17.4,734,72666,66060,0.48,Semi-Detached,1940,4.0,0.53,53.36525,-2.90341,53.36383,-2.91015,446,1110
|
||||
B90 2,B28 0,4200,3470,17.4,730,63145,65700,1.76,Semi-Detached,1958,1.9,0.99,52.40657,-1.83487,52.42046,-1.84765,4449,4603
|
||||
SE8 5,E14 3,7524,6225,17.3,1299,88981,116910,1.7,Flats/Maisonettes,1986,1.6,0.67,51.48642,-0.03731,51.49193,-0.01389,5494,8179
|
||||
SE10 8,E14 3,7531,6225,17.3,1306,90767,117540,1.98,Flats/Maisonettes,2001,1.0,0.36,51.47401,-0.01359,51.49193,-0.01389,5455,8179
|
||||
BN7 1,BN7 2,6210,5138,17.3,1072,82544,96480,1.0,Terraced,1958,1.8,1.01,50.87346,-0.00155,50.87753,0.01166,3196,3571
|
||||
NW10 4,NW10 9,7114,5893,17.2,1221,73260,109890,0.84,Flats/Maisonettes,1914,2.8,0.51,51.53703,-0.24549,51.5439,-0.25092,4233,3624
|
||||
E14 0,E15 2,7497,6208,17.2,1289,83140,116010,2.9,Flats/Maisonettes,2016,2.5,0.36,51.51208,-0.0033,51.53827,-0.00621,8378,6157
|
||||
UB5 4,UB5 5,5964,4938,17.2,1026,72333,92340,1.65,Flats/Maisonettes,1958,2.9,0.58,51.5526,-0.36148,51.54333,-0.38054,5251,4277
|
||||
WC1X 9,WC1H 8,11211,9294,17.1,1917,106393,172530,0.45,Flats/Maisonettes,1940,0.5,0.41,51.52919,-0.11524,51.5279,-0.12161,1540,815
|
||||
S6 4,S6 1,2988,2476,17.1,512,40192,46080,1.64,Terraced,1940,1.9,0.49,53.40639,-1.51241,53.42047,-1.50486,4963,4420
|
||||
SO23 9,SO23 0,6311,5240,17.0,1071,79789,96390,1.31,Flats/Maisonettes,1971,3.2,1.28,51.05626,-1.31826,51.06197,-1.30127,2026,2127
|
||||
L39 5,L39 6,3842,3187,17.0,655,74997,58950,1.36,Detached,1971,0.0,0.59,53.55169,-2.90318,53.53975,-2.90771,1515,821
|
||||
SW11 8,SW11 7,13444,11155,17.0,2289,168241,206010,0.78,Flats/Maisonettes,2016,3.2,0.38,51.48159,-0.14506,51.48145,-0.13354,5298,4866
|
||||
TS19 0,TS18 4,1750,1453,17.0,297,25096,26730,1.3,Semi-Detached,1958,2.3,1.23,54.57472,-1.33294,54.56297,-1.33273,4499,2209
|
||||
CH63 3,CH63 2,3484,2896,16.9,588,51450,52920,1.01,Semi-Detached,1940,2.7,0.83,53.34542,-3.0082,53.3507,-3.02031,1436,1530
|
||||
CH41 8,CH41 7,1806,1503,16.8,303,22270,27270,1.1,Terraced,1958,4.5,0.41,53.40007,-3.04816,53.40356,-3.06336,1683,1078
|
||||
WS12 4,WS11 6,3130,2604,16.8,526,39450,47340,2.04,Semi-Detached,1986,2.9,1.48,52.71611,-2.0179,52.69769,-2.01587,6766,1607
|
||||
SE24 0,SE5 9,9093,7561,16.8,1532,101112,137880,1.33,Flats/Maisonettes,1914,3.1,0.44,51.45863,-0.10307,51.47044,-0.09938,4116,4741
|
||||
WF10 1,WF10 4,2506,2084,16.8,422,31861,37980,1.76,Terraced,1958,0.9,0.94,53.72625,-1.36697,53.72026,-1.34292,2055,4531
|
||||
S20 7,S20 1,2922,2430,16.8,492,35424,44280,1.31,Semi-Detached,1979,0.2,0.39,53.33769,-1.35432,53.34676,-1.342,1346,2233
|
||||
NW10 2,NW10 9,7078,5893,16.7,1185,71100,106650,1.04,Flats/Maisonettes,1940,2.2,0.56,51.54767,-0.23691,51.5439,-0.25092,3505,3624
|
||||
L3 6,L5 3,2356,1962,16.7,394,24034,35460,1.21,Flats/Maisonettes,2003,1.5,0.64,53.41455,-2.99003,53.42081,-2.97545,1725,1454
|
||||
L22 0,L22 1,2609,2175,16.6,434,28861,39060,0.66,Flats/Maisonettes,1914,1.3,0.24,53.47671,-3.02473,53.4711,-3.02158,710,617
|
||||
SW4 9,SW2 5,10076,8400,16.6,1676,118158,150840,1.23,Flats/Maisonettes,1914,5.1,0.49,51.45624,-0.14204,51.45674,-0.1239,4010,5125
|
||||
L9 3,L20 9,1802,1502,16.6,300,27450,27000,1.34,Terraced,1914,3.0,0.29,53.46011,-2.96714,53.45061,-2.97938,864,2306
|
||||
BB2 2,BB2 1,1405,1172,16.6,233,16892,20970,0.83,Terraced,1940,4.7,0.56,53.73966,-2.50046,53.74659,-2.49571,2449,2156
|
||||
NG8 5,NG8 6,2654,2216,16.5,438,29346,39420,1.54,Terraced,1940,3.4,1.11,52.97537,-1.19714,52.97907,-1.21893,5034,4450
|
||||
KT1 4,KT2 7,7580,6333,16.5,1247,88537,112230,2.4,Flats/Maisonettes,1971,2.4,0.23,51.41458,-0.31269,51.41822,-0.27782,1490,3298
|
||||
EC1N 8,E1 7,9979,8330,16.5,1649,96466,148410,2.35,Flats/Maisonettes,2002,0.8,0.21,51.52047,-0.10778,51.51629,-0.07389,573,2146
|
||||
SE19 1,SW16 3,6947,5811,16.4,1136,87472,102240,2.43,Flats/Maisonettes,1958,4.9,0.37,51.42311,-0.08413,51.4182,-0.11905,4346,3133
|
||||
TW1 2,TW1 1,9945,8322,16.3,1623,120913,146070,1.04,Flats/Maisonettes,1914,3.0,0.6,51.45459,-0.31202,51.45653,-0.32698,2753,3637
|
||||
SW9 9,SW9 7,8416,7041,16.3,1375,89375,123750,1.0,Flats/Maisonettes,1940,2.8,0.36,51.46731,-0.123,51.46801,-0.10832,6031,3760
|
||||
BN3 5,BN3 3,6741,5645,16.3,1096,74528,98640,1.24,Flats/Maisonettes,1914,5.6,0.44,50.83387,-0.18759,50.83132,-0.16973,5310,8935
|
||||
L2 5,L1 6,1878,1574,16.2,304,14592,27360,0.16,Flats/Maisonettes,2006,1.0,0.19,53.40738,-2.98838,53.40797,-2.98616,369,881
|
||||
L4 3,L20 2,1382,1158,16.2,224,18144,20160,0.68,Terraced,1914,2.2,0.47,53.44124,-2.97449,53.44207,-2.98445,1796,1300
|
||||
L31 7,L10 3,3036,2545,16.2,491,44926,44190,2.43,Semi-Detached,1958,2.0,1.21,53.50819,-2.94793,53.48632,-2.94406,886,418
|
||||
SM2 7,KT17 3,6904,5790,16.1,1114,173227,100260,2.55,Detached,1940,2.8,0.72,51.34792,-0.21697,51.32954,-0.23953,1934,1519
|
||||
NW4 2,NW9 0,6465,5422,16.1,1043,75617,93870,2.92,Flats/Maisonettes,1940,5.2,0.69,51.58597,-0.21789,51.58914,-0.26066,3448,3514
|
||||
BN1 3,BN2 3,6411,5387,16.0,1024,61952,92160,1.62,Flats/Maisonettes,1890,4.0,0.51,50.8283,-0.1474,50.83424,-0.12553,7052,5567
|
||||
NW11 6,N2 0,9752,8195,16.0,1557,130788,140130,1.13,Flats/Maisonettes,1914,1.9,1.34,51.58492,-0.1896,51.5874,-0.17339,2269,3685
|
||||
SN2 1,SN2 8,3293,2767,16.0,526,38924,47340,0.91,Terraced,1958,2.5,1.12,51.57453,-1.78706,51.57392,-1.77363,4720,1056
|
||||
WA10 2,WA9 1,1701,1431,15.9,270,18225,24300,2.12,Terraced,1979,1.5,1.14,53.45722,-2.74475,53.4528,-2.71443,2814,3230
|
||||
WD24 4,WD18 7,5679,4774,15.9,905,62445,81450,2.17,Flats/Maisonettes,1993,4.0,0.51,51.6669,-0.39275,51.65289,-0.41509,2014,4190
|
||||
RM20 3,RM20 4,4648,3908,15.9,740,49580,66600,1.37,Terraced,2012,3.0,1.06,51.47874,0.27854,51.47707,0.29861,1330,1542
|
||||
CH46 2,CH44 5,2334,1962,15.9,372,30504,33480,3.0,Terraced,1958,1.8,1.0,53.41644,-3.09066,53.41539,-3.04651,966,1407
|
||||
SE15 3,SE15 1,8041,6766,15.9,1275,87337,114750,2.04,Flats/Maisonettes,1958,2.1,0.65,51.46242,-0.05615,51.48086,-0.05779,4646,3041
|
||||
NW7 2,NW4 1,6545,5506,15.9,1039,81042,93510,1.94,Flats/Maisonettes,1958,5.9,0.88,51.60921,-0.23469,51.59454,-0.219,2863,3256
|
||||
CH66 2,CH66 1,3004,2530,15.8,474,39342,42660,2.84,Semi-Detached,1971,1.3,1.4,53.26372,-2.92341,53.28903,-2.93023,4041,2959
|
||||
PR8 3,PR8 4,2962,2498,15.7,464,43152,41760,2.97,Semi-Detached,1958,1.9,0.94,53.60196,-3.03175,53.6256,-3.01102,4601,3996
|
||||
SM6 8,SM6 0,5483,4620,15.7,863,59547,77670,0.94,Flats/Maisonettes,1958,4.7,0.68,51.3632,-0.14127,51.35737,-0.15137,4470,3099
|
||||
L32 5,L33 0,1870,1576,15.7,294,20874,26460,1.24,Terraced,1993,2.0,0.9,53.47912,-2.89684,53.4823,-2.8793,431,591
|
||||
SW1P 1,SW1V 2,12483,10522,15.7,1961,126484,176490,0.55,Flats/Maisonettes,1914,3.0,0.35,51.49525,-0.13811,51.49031,-0.13734,1402,3528
|
||||
M3 5,M5 4,4152,3503,15.6,649,39264,58410,1.18,Flats/Maisonettes,2020,2.9,0.3,53.48267,-2.25473,53.47828,-2.27064,4203,7855
|
||||
SW1W 9,SW3 6,19900,16803,15.6,3097,277181,278730,1.68,Flats/Maisonettes,1914,4.0,0.38,51.49389,-0.15063,51.48869,-0.17386,1512,1594
|
||||
L21 8,L20 5,1536,1297,15.6,239,19239,21510,0.75,Terraced,1940,2.0,0.76,53.46498,-2.99614,53.45858,-2.99252,1753,1206
|
||||
NG1 5,NG7 3,2298,1941,15.5,357,21063,32130,1.12,Flats/Maisonettes,2004,4.1,0.35,52.95503,-1.15757,52.95802,-1.17335,1227,3268
|
||||
PL31 1,PL31 2,2902,2451,15.5,451,35403,40590,0.68,Terraced,1979,1.0,0.91,50.46537,-4.72475,50.47146,-4.72295,3265,3650
|
||||
N21 3,EN1 2,6539,5529,15.4,1010,85850,90900,2.0,Flats/Maisonettes,1940,2.4,0.67,51.62986,-0.09786,51.64171,-0.07559,2506,2804
|
||||
NW3 2,NW3 6,12230,10350,15.4,1880,123140,169200,1.42,Flats/Maisonettes,1940,2.7,0.31,51.55255,-0.16025,51.55188,-0.1812,5663,3182
|
||||
L16 3,L14 6,3353,2840,15.3,513,43348,46170,0.83,Semi-Detached,1958,5.4,0.79,53.4038,-2.8825,53.41102,-2.87901,646,809
|
||||
ME7 2,ME7 5,3614,3061,15.3,553,42304,49770,1.62,Terraced,1940,3.0,1.29,51.38449,0.56629,51.38258,0.54265,4375,3928
|
||||
B10 9,B8 1,2079,1761,15.3,318,26871,28620,2.37,Terraced,1914,4.9,1.28,52.47059,-1.84439,52.49094,-1.85524,4180,2834
|
||||
SM5 2,SM6 0,5455,4620,15.3,835,55945,75150,2.06,Flats/Maisonettes,1971,4.6,0.63,51.37365,-0.16616,51.35737,-0.15137,4877,3099
|
||||
S12 3,S12 4,2902,2461,15.2,441,33957,39690,1.88,Semi-Detached,1958,0.0,0.45,53.34167,-1.41358,53.34743,-1.38757,3092,4427
|
||||
EC1V 7,EC1Y 8,10494,8894,15.2,1600,100800,144000,0.9,Flats/Maisonettes,1999,0.0,0.47,51.52856,-0.10175,51.5234,-0.09159,1368,1020
|
||||
SW9 8,SW9 7,8306,7041,15.2,1265,81592,113850,0.69,Flats/Maisonettes,1979,3.1,0.37,51.46184,-0.1102,51.46801,-0.10832,3059,3760
|
||||
N16 6,N15 6,6500,5515,15.2,985,65010,88650,1.01,Flats/Maisonettes,1914,3.7,0.51,51.5696,-0.06789,51.57766,-0.07499,4716,4196
|
||||
EN2 6,EN1 1,6398,5424,15.2,974,67693,87660,1.32,Flats/Maisonettes,1958,4.5,0.42,51.65156,-0.08438,51.64635,-0.06695,1875,5034
|
||||
SW11 6,SW18 3,10999,9326,15.2,1673,157262,150570,1.75,Flats/Maisonettes,1914,5.2,0.84,51.4556,-0.16199,51.44534,-0.18171,4237,5500
|
||||
BB1 7,BB1 6,1764,1498,15.1,266,25935,23940,0.62,Terraced,1914,4.1,0.91,53.75417,-2.48301,53.75666,-2.47488,850,1175
|
||||
WF6 2,WF6 1,2630,2234,15.1,396,30492,35640,1.36,Semi-Detached,1971,1.2,0.94,53.70552,-1.42098,53.69415,-1.41339,4466,4718
|
||||
W9 2,W10 4,10718,9102,15.1,1616,103424,145440,1.32,Flats/Maisonettes,1914,4.1,0.48,51.52498,-0.19242,51.52944,-0.21048,5679,3629
|
||||
NW11 7,NW2 2,8238,6991,15.1,1247,86978,112230,1.51,Flats/Maisonettes,1940,0.5,0.56,51.57475,-0.19246,51.56242,-0.20213,2668,3603
|
||||
WR1 3,WR1 1,3131,2660,15.0,471,28731,42390,0.39,Flats/Maisonettes,1958,3.5,0.69,52.1988,-2.22707,52.19991,-2.22163,1651,2759
|
||||
|
BIN
analysis/out/cheaper_twins.parquet
Normal file
BIN
analysis/out/cheaper_twins.parquet
Normal file
Binary file not shown.
120
analysis/out/findings_review.md
Normal file
120
analysis/out/findings_review.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Findings: review before publishing
|
||||
|
||||
16 findings generated from analysis/out/cheaper_twins.parquet.
|
||||
**Check the place names** (⚠ = unnamed sector, needs a label in analysis/place_names.json) and spot-check a couple of numbers. Then these feed the page batch + video factory.
|
||||
|
||||
| ⚠ | Title | Hook number | Page path | Deep link |
|
||||
|---|-------|-------------|-----------|-----------|
|
||||
| ⚠ | W1J 7 vs SW7 3: the same flat, about 41% cheaper per m² | 41% | `/cheaper-twin/w1j-7-vs-sw7-3` | [map](https://perfect-postcode.co.uk/?lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| ⚠ | SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m² | 42% | `/cheaper-twin/sw1x-8-vs-sw7-2` | [map](https://perfect-postcode.co.uk/?lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| ⚠ | WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m² | 43% | `/cheaper-twin/wc2a-2-vs-ec2a-2` | [map](https://perfect-postcode.co.uk/?lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| ⚠ | W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m² | 32% | `/cheaper-twin/w1k-2-vs-sw1x-0` | [map](https://perfect-postcode.co.uk/?lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Marylebone vs Camden: the same flat, about 43% cheaper per m² | 43% | `/cheaper-twin/w1u-4-vs-nw1-4` | [map](https://perfect-postcode.co.uk/?lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| ⚠ | W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m² | 37% | `/cheaper-twin/w1j-8-vs-sw1a-2` | [map](https://perfect-postcode.co.uk/?lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Beckenham vs Croydon: the same terraced house, about 31% cheaper per m² | 31% | `/cheaper-twin/br3-3-vs-cr0-7` | [map](https://perfect-postcode.co.uk/?lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m² | 26% | `/cheaper-twin/ig8-7-vs-ig6-2` | [map](https://perfect-postcode.co.uk/?lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m² | 19% | `/cheaper-twin/tw2-7-vs-tw3-2` | [map](https://perfect-postcode.co.uk/?lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Hampton vs East Molesey: the same terraced house, about 19% cheaper per m² | 19% | `/cheaper-twin/tw12-3-vs-kt8-1` | [map](https://perfect-postcode.co.uk/?lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m² | 20% | `/cheaper-twin/rm14-2-vs-rm12-5` | [map](https://perfect-postcode.co.uk/?lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m² | 17% | `/cheaper-twin/ha7-2-vs-ha3-0` | [map](https://perfect-postcode.co.uk/?lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m² | 42% | `/cheaper-twin/m40-5-vs-m9-4` | [map](https://perfect-postcode.co.uk/?lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| | Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m² | 30% | `/cheaper-twin/l16-7-vs-l14-6` | [map](https://perfect-postcode.co.uk/?lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| ⚠ | SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m² | 30% | `/cheaper-twin/se28-8-vs-da18-4` | [map](https://perfect-postcode.co.uk/?lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11) |
|
||||
| ⚠ | How many square metres £100,000 buys across England | 152 m² vs 3 m² | `/square-metres-per-100k` | [map](https://perfect-postcode.co.uk/?zoom=6&filter=Est.%20price%20per%20sqm:0:4000) |
|
||||
|
||||
## Per-finding detail
|
||||
|
||||
### W1J 7 vs SW7 3: the same flat, about 41% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/w1j-7-vs-sw7-3`
|
||||
- **Hook:** £1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart
|
||||
- **Mayfair (W1J 7)** £32,986/m² (n=724) → **SW7 3** £19,392/m² (n=2,581) · gap 41.2% · Flats/Maisonettes, ~1914
|
||||
- **OG card / deep link:** `lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/sw1x-8-vs-sw7-2`
|
||||
- **Hook:** £1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart
|
||||
- **SW1X 8** £26,735/m² (n=1,410) → **SW7 2** £15,611/m² (n=1,126) · gap 41.6% · Flats/Maisonettes, ~1890
|
||||
- **OG card / deep link:** `lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/wc2a-2-vs-ec2a-2`
|
||||
- **Hook:** £981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart
|
||||
- **WC2A 2** £25,482/m² (n=254) → **EC2A 2** £14,576/m² (n=772) · gap 42.8% · Flats/Maisonettes, ~2019
|
||||
- **OG card / deep link:** `lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/w1k-2-vs-sw1x-0`
|
||||
- **Hook:** £978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart
|
||||
- **Mayfair (W1K 2)** £34,362/m² (n=591) → **SW1X 0** £23,489/m² (n=1,606) · gap 31.6% · Flats/Maisonettes, ~1914
|
||||
- **OG card / deep link:** `lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Marylebone vs Camden: the same flat, about 43% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/w1u-4-vs-nw1-4`
|
||||
- **Hook:** £942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart
|
||||
- **Marylebone (W1U 4)** £24,238/m² (n=984) → **Camden (NW1 4)** £13,766/m² (n=1,340) · gap 43.2% · Flats/Maisonettes, ~1940
|
||||
- **OG card / deep link:** `lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/w1j-8-vs-sw1a-2`
|
||||
- **Hook:** £916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart
|
||||
- **Mayfair (W1J 8)** £27,270/m² (n=295) → **SW1A 2** £17,088/m² (n=261) · gap 37.3% · Flats/Maisonettes, ~2000
|
||||
- **OG card / deep link:** `lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Beckenham vs Croydon: the same terraced house, about 31% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/br3-3-vs-cr0-7`
|
||||
- **Hook:** £201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart
|
||||
- **Beckenham (BR3 3)** £7,153/m² (n=4,514) → **Croydon (CR0 7)** £4,910/m² (n=5,143) · gap 31.4% · Terraced, ~1940
|
||||
- **OG card / deep link:** `lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/ig8-7-vs-ig6-2`
|
||||
- **Hook:** £164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart
|
||||
- **Woodford Green (IG8 7)** £7,148/m² (n=2,965) → **Barkingside (IG6 2)** £5,325/m² (n=4,423) · gap 25.5% · Terraced, ~1958
|
||||
- **OG card / deep link:** `lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/tw2-7-vs-tw3-2`
|
||||
- **Hook:** £121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart
|
||||
- **Twickenham (TW2 7)** £6,971/m² (n=3,377) → **Hounslow (TW3 2)** £5,620/m² (n=2,964) · gap 19.4% · Semi-Detached, ~1940
|
||||
- **OG card / deep link:** `lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Hampton vs East Molesey: the same terraced house, about 19% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/tw12-3-vs-kt8-1`
|
||||
- **Hook:** £120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart
|
||||
- **Hampton (TW12 3)** £7,042/m² (n=2,527) → **East Molesey (KT8 1)** £5,708/m² (n=1,567) · gap 18.9% · Terraced, ~1979
|
||||
- **OG card / deep link:** `lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/rm14-2-vs-rm12-5`
|
||||
- **Hook:** £115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart
|
||||
- **Upminster (RM14 2)** £6,360/m² (n=3,026) → **Hornchurch (RM12 5)** £5,079/m² (n=3,133) · gap 20.1% · Semi-Detached, ~1940
|
||||
- **OG card / deep link:** `lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/ha7-2-vs-ha3-0`
|
||||
- **Hook:** £106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart
|
||||
- **Stanmore (HA7 2)** £6,834/m² (n=2,775) → **Kenton (HA3 0)** £5,646/m² (n=3,122) · gap 17.4% · Semi-Detached, ~1940
|
||||
- **OG card / deep link:** `lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/m40-5-vs-m9-4`
|
||||
- **Hook:** £106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart
|
||||
- **Newton Heath (M40 5)** £2,812/m² (n=1,632) → **Harpurhey (M9 4)** £1,626/m² (n=3,530) · gap 42.2% · Terraced, ~1958
|
||||
- **OG card / deep link:** `lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/l16-7-vs-l14-6`
|
||||
- **Hook:** £106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart
|
||||
- **Childwall (L16 7)** £4,026/m² (n=500) → **Broadgreen (L14 6)** £2,840/m² (n=809) · gap 29.5% · Semi-Detached, ~1940
|
||||
- **OG card / deep link:** `lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m²
|
||||
- **Type:** cheaper_twin · **Page:** `/cheaper-twin/se28-8-vs-da18-4`
|
||||
- **Hook:** £129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart
|
||||
- **SE28 8** £4,850/m² (n=5,033) → **DA18 4** £3,409/m² (n=1,063) · gap 29.7% · Terraced, ~1993
|
||||
- **OG card / deep link:** `lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11`
|
||||
|
||||
### How many square metres £100,000 buys across England
|
||||
- **Type:** national_table · **Page:** `/square-metres-per-100k`
|
||||
- **Hook:** £100k buys ~152 m² of floor space in BD21 3 but only ~3 m² in Mayfair (W1K 2)
|
||||
- **OG card / deep link:** `zoom=6&filter=Est.%20price%20per%20sqm:0:4000`
|
||||
268
analysis/out/national_facts.json
Normal file
268
analysis/out/national_facts.json
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
{
|
||||
"generated_with": "analysis/cheaper_twins.py",
|
||||
"params": {
|
||||
"min_props": 150,
|
||||
"min_recorded": 40,
|
||||
"max_km": 3.0,
|
||||
"min_gap": 0.15,
|
||||
"max_gap": 0.45,
|
||||
"build_band": 30,
|
||||
"school_tol": 1.5,
|
||||
"station_max": 1.5,
|
||||
"station_tol": 0.9,
|
||||
"crime_ratio": 1.5,
|
||||
"owner_tol": 22,
|
||||
"degree_tol": 22,
|
||||
"age_tol": 12,
|
||||
"floor_ratio": 0.72,
|
||||
"min_abs_gap": 20000
|
||||
},
|
||||
"n_sectors": 7560,
|
||||
"n_twin_pairs": 415,
|
||||
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||
"best_value_sector": {
|
||||
"sector": "BD21 3",
|
||||
"est_psqm": 660,
|
||||
"sqm_per_100k": 151.6,
|
||||
"n": 1377
|
||||
},
|
||||
"dearest_sector": {
|
||||
"sector": "W1K 2",
|
||||
"est_psqm": 34362,
|
||||
"sqm_per_100k": 2.9,
|
||||
"n": 591
|
||||
},
|
||||
"biggest_twin_gap": {
|
||||
"pricey_sector": "W1U 3",
|
||||
"twin_sector": "EC1N 7",
|
||||
"pricey_psqm": 16948,
|
||||
"twin_psqm": 9362,
|
||||
"gap_pct": 44.8,
|
||||
"gap_per_sqm": 7586,
|
||||
"gap_on_avg_home": 504469,
|
||||
"gap_on_90sqm": 682740,
|
||||
"dist_km": 2.89,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1940,
|
||||
"good_secondary": 1.3,
|
||||
"station_km": 0.41,
|
||||
"pricey_lat": 51.51712,
|
||||
"pricey_lon": -0.15271,
|
||||
"twin_lat": 51.52057,
|
||||
"twin_lon": -0.11049,
|
||||
"pricey_n": 302,
|
||||
"twin_n": 747
|
||||
},
|
||||
"top_twins": [
|
||||
{
|
||||
"pricey_sector": "W1U 3",
|
||||
"twin_sector": "EC1N 7",
|
||||
"pricey_psqm": 16948,
|
||||
"twin_psqm": 9362,
|
||||
"gap_pct": 44.8,
|
||||
"gap_per_sqm": 7586,
|
||||
"gap_on_avg_home": 504469,
|
||||
"gap_on_90sqm": 682740,
|
||||
"dist_km": 2.89,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1940,
|
||||
"good_secondary": 1.3,
|
||||
"station_km": 0.41,
|
||||
"pricey_lat": 51.51712,
|
||||
"pricey_lon": -0.15271,
|
||||
"twin_lat": 51.52057,
|
||||
"twin_lon": -0.11049,
|
||||
"pricey_n": 302,
|
||||
"twin_n": 747
|
||||
},
|
||||
{
|
||||
"pricey_sector": "WC2R 1",
|
||||
"twin_sector": "SW1V 1",
|
||||
"pricey_psqm": 19997,
|
||||
"twin_psqm": 11119,
|
||||
"gap_pct": 44.4,
|
||||
"gap_per_sqm": 8878,
|
||||
"gap_on_avg_home": 665850,
|
||||
"gap_on_90sqm": 799020,
|
||||
"dist_km": 2.82,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 2017,
|
||||
"good_secondary": 2.0,
|
||||
"station_km": 0.21,
|
||||
"pricey_lat": 51.51228,
|
||||
"pricey_lon": -0.11525,
|
||||
"twin_lat": 51.49297,
|
||||
"twin_lon": -0.14234,
|
||||
"pricey_n": 316,
|
||||
"twin_n": 1408
|
||||
},
|
||||
{
|
||||
"pricey_sector": "L8 7",
|
||||
"twin_sector": "L7 0",
|
||||
"pricey_psqm": 2757,
|
||||
"twin_psqm": 1541,
|
||||
"gap_pct": 44.1,
|
||||
"gap_per_sqm": 1216,
|
||||
"gap_on_avg_home": 78432,
|
||||
"gap_on_90sqm": 109440,
|
||||
"dist_km": 2.36,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1914,
|
||||
"good_secondary": 2.3,
|
||||
"station_km": 1.04,
|
||||
"pricey_lat": 53.39791,
|
||||
"pricey_lon": -2.96459,
|
||||
"twin_lat": 53.41174,
|
||||
"twin_lon": -2.93813,
|
||||
"pricey_n": 2054,
|
||||
"twin_n": 2208
|
||||
},
|
||||
{
|
||||
"pricey_sector": "L3 2",
|
||||
"twin_sector": "L5 5",
|
||||
"pricey_psqm": 1642,
|
||||
"twin_psqm": 920,
|
||||
"gap_pct": 44.0,
|
||||
"gap_per_sqm": 722,
|
||||
"gap_on_avg_home": 47291,
|
||||
"gap_on_90sqm": 64980,
|
||||
"dist_km": 1.61,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 2004,
|
||||
"good_secondary": 1.5,
|
||||
"station_km": 0.47,
|
||||
"pricey_lat": 53.41155,
|
||||
"pricey_lon": -2.98583,
|
||||
"twin_lat": 53.42544,
|
||||
"twin_lon": -2.97852,
|
||||
"pricey_n": 1341,
|
||||
"twin_n": 706
|
||||
},
|
||||
{
|
||||
"pricey_sector": "S2 4",
|
||||
"twin_sector": "S3 9",
|
||||
"pricey_psqm": 2468,
|
||||
"twin_psqm": 1402,
|
||||
"gap_pct": 43.2,
|
||||
"gap_per_sqm": 1066,
|
||||
"gap_on_avg_home": 73554,
|
||||
"gap_on_90sqm": 95940,
|
||||
"dist_km": 2.82,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1986,
|
||||
"good_secondary": 2.6,
|
||||
"station_km": 0.72,
|
||||
"pricey_lat": 53.36993,
|
||||
"pricey_lon": -1.46978,
|
||||
"twin_lat": 53.39521,
|
||||
"twin_lon": -1.46478,
|
||||
"pricey_n": 2423,
|
||||
"twin_n": 1778
|
||||
},
|
||||
{
|
||||
"pricey_sector": "W1U 4",
|
||||
"twin_sector": "NW1 4",
|
||||
"pricey_psqm": 24238,
|
||||
"twin_psqm": 13766,
|
||||
"gap_pct": 43.2,
|
||||
"gap_per_sqm": 10472,
|
||||
"gap_on_avg_home": 759220,
|
||||
"gap_on_90sqm": 942480,
|
||||
"dist_km": 0.97,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1940,
|
||||
"good_secondary": 1.0,
|
||||
"station_km": 0.44,
|
||||
"pricey_lat": 51.51958,
|
||||
"pricey_lon": -0.15295,
|
||||
"twin_lat": 51.52803,
|
||||
"twin_lon": -0.14886,
|
||||
"pricey_n": 984,
|
||||
"twin_n": 1340
|
||||
},
|
||||
{
|
||||
"pricey_sector": "WC2A 2",
|
||||
"twin_sector": "EC2A 2",
|
||||
"pricey_psqm": 25482,
|
||||
"twin_psqm": 14576,
|
||||
"gap_pct": 42.8,
|
||||
"gap_per_sqm": 10906,
|
||||
"gap_on_avg_home": 834309,
|
||||
"gap_on_90sqm": 981540,
|
||||
"dist_km": 2.3,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 2019,
|
||||
"good_secondary": 2.0,
|
||||
"station_km": 0.42,
|
||||
"pricey_lat": 51.51496,
|
||||
"pricey_lon": -0.11456,
|
||||
"twin_lat": 51.52119,
|
||||
"twin_lon": -0.08217,
|
||||
"pricey_n": 254,
|
||||
"twin_n": 772
|
||||
},
|
||||
{
|
||||
"pricey_sector": "M40 5",
|
||||
"twin_sector": "M9 4",
|
||||
"pricey_psqm": 2812,
|
||||
"twin_psqm": 1626,
|
||||
"gap_pct": 42.2,
|
||||
"gap_per_sqm": 1186,
|
||||
"gap_on_avg_home": 91915,
|
||||
"gap_on_90sqm": 106740,
|
||||
"dist_km": 1.18,
|
||||
"dominant_type": "Terraced",
|
||||
"build_year": 1958,
|
||||
"good_secondary": 2.6,
|
||||
"station_km": 0.72,
|
||||
"pricey_lat": 53.51372,
|
||||
"pricey_lon": -2.18713,
|
||||
"twin_lat": 53.51214,
|
||||
"twin_lon": -2.20436,
|
||||
"pricey_n": 1632,
|
||||
"twin_n": 3530
|
||||
},
|
||||
{
|
||||
"pricey_sector": "W11 2",
|
||||
"twin_sector": "NW1 6",
|
||||
"pricey_psqm": 19262,
|
||||
"twin_psqm": 11154,
|
||||
"gap_pct": 42.1,
|
||||
"gap_per_sqm": 8108,
|
||||
"gap_on_avg_home": 482426,
|
||||
"gap_on_90sqm": 729720,
|
||||
"dist_km": 2.94,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1890,
|
||||
"good_secondary": 4.0,
|
||||
"station_km": 0.53,
|
||||
"pricey_lat": 51.51407,
|
||||
"pricey_lon": -0.20373,
|
||||
"twin_lat": 51.5237,
|
||||
"twin_lon": -0.16342,
|
||||
"pricey_n": 4082,
|
||||
"twin_n": 3312
|
||||
},
|
||||
{
|
||||
"pricey_sector": "N10 3",
|
||||
"twin_sector": "N12 0",
|
||||
"pricey_psqm": 9590,
|
||||
"twin_psqm": 5554,
|
||||
"gap_pct": 42.1,
|
||||
"gap_per_sqm": 4036,
|
||||
"gap_on_avg_home": 296646,
|
||||
"gap_on_90sqm": 363240,
|
||||
"dist_km": 2.97,
|
||||
"dominant_type": "Flats/Maisonettes",
|
||||
"build_year": 1914,
|
||||
"good_secondary": 3.9,
|
||||
"station_km": 1.16,
|
||||
"pricey_lat": 51.58839,
|
||||
"pricey_lon": -0.14404,
|
||||
"twin_lat": 51.60872,
|
||||
"twin_lon": -0.17254,
|
||||
"pricey_n": 3984,
|
||||
"twin_n": 3282
|
||||
}
|
||||
]
|
||||
}
|
||||
7561
analysis/out/sector_index.csv
Normal file
7561
analysis/out/sector_index.csv
Normal file
File diff suppressed because it is too large
Load diff
BIN
analysis/out/sector_index.parquet
Normal file
BIN
analysis/out/sector_index.parquet
Normal file
Binary file not shown.
23
analysis/place_names.json
Normal file
23
analysis/place_names.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"_comment": "Outward-code -> approximate neighbourhood label, used by generate_findings.py. APPROXIMATE (an outward code can span more than one area). VERIFY before publishing a page/video. Add entries to name more sectors; unknown codes fall back to the bare sector code and are flagged NEEDS-NAME in findings_review.md.",
|
||||
"W11": "Notting Hill", "W12": "Shepherd's Bush", "W4": "Chiswick", "W3": "Acton",
|
||||
"W1H": "Marylebone", "W1U": "Marylebone", "W1K": "Mayfair", "W1J": "Mayfair",
|
||||
"SW3": "Chelsea", "SW5": "Earl's Court", "SW1V": "Pimlico", "SW13": "Barnes", "SW1H": "Westminster",
|
||||
"N1": "Islington", "N7": "Holloway", "N10": "Muswell Hill", "N12": "North Finchley", "N16": "Stoke Newington",
|
||||
"NW1": "Camden", "NW6": "West Hampstead", "NW2": "Cricklewood", "NW10": "Willesden",
|
||||
"E2": "Bethnal Green", "E5": "Clapton", "E8": "Hackney",
|
||||
"EC1V": "Clerkenwell", "EC1Y": "Old Street", "EC1N": "Farringdon", "EC4V": "Blackfriars",
|
||||
"WC1N": "Bloomsbury", "WC1B": "Bloomsbury", "WC2R": "Covent Garden", "WC1E": "Bloomsbury",
|
||||
"SE1": "Bermondsey",
|
||||
"BR3": "Beckenham", "CR0": "Croydon",
|
||||
"HA7": "Stanmore", "HA3": "Kenton",
|
||||
"IG8": "Woodford Green", "IG6": "Barkingside",
|
||||
"TW2": "Twickenham", "TW3": "Hounslow", "TW12": "Hampton", "KT8": "East Molesey",
|
||||
"RM14": "Upminster", "RM12": "Hornchurch",
|
||||
"SM2": "Sutton", "KT17": "Ewell",
|
||||
"B11": "Sparkhill",
|
||||
"M40": "Newton Heath", "M9": "Harpurhey",
|
||||
"L16": "Childwall", "L14": "Broadgreen", "L8": "Toxteth", "L7": "Kensington (L'pool)",
|
||||
"SK6": "Romiley",
|
||||
"BN7": "Lewes"
|
||||
}
|
||||
354
analysis/weekly_readout.py
Normal file
354
analysis/weekly_readout.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Perfect Postcode: weekly growth-metrics readout.
|
||||
|
||||
Assembles a one-screen, dated markdown block (SEO + YouTube + product funnel)
|
||||
suitable for pasting into a weekly note. Every section degrades gracefully: if a
|
||||
credential is missing the section prints "⚠ set X to enable" instead of crashing.
|
||||
|
||||
USAGE
|
||||
python3 analysis/weekly_readout.py # prints the readout to stdout
|
||||
|
||||
DATA SOURCES & CREDENTIALS (all read from the environment)
|
||||
|
||||
SEO: Google Search Console (Search Analytics API)
|
||||
GSC_SITE_URL Property as registered in GSC, e.g.
|
||||
"sc-domain:perfect-postcode.co.uk" or
|
||||
"https://perfect-postcode.co.uk/".
|
||||
GOOGLE_APPLICATION_CREDENTIALS (or GSC_CREDENTIALS)
|
||||
Path to a service-account JSON key. Create it in
|
||||
Google Cloud → IAM → Service Accounts, enable the
|
||||
"Google Search Console API", then in Search Console
|
||||
→ Settings → Users add the service-account email as a
|
||||
(restricted) user on the property.
|
||||
|
||||
YouTube: view counts (Data API v3, API key) + impressions/CTR/retention
|
||||
(Analytics API, founder OAuth)
|
||||
YT_API_KEY Public Data API v3 key (Cloud console → Credentials).
|
||||
Gives per-video view/like/comment counts.
|
||||
YT_CHANNEL_ID Channel id (starts "UC..."). See
|
||||
youtube.com/account_advanced.
|
||||
YT_OAUTH_TOKEN (optional) Path to an authorized_user OAuth token
|
||||
JSON for the channel owner. ONLY this unlocks
|
||||
impressions, CTR and average view duration, which are
|
||||
private and need the founder's Google login + scope
|
||||
yt-analytics.readonly. Without it we show public views
|
||||
only.
|
||||
|
||||
Funnel: Plausible (self-hosted) Stats API v2
|
||||
PLAUSIBLE_API_KEY Stats API key (Plausible → Settings → API Keys).
|
||||
PLAUSIBLE_SITE_ID (default perfect-postcode.co.uk)
|
||||
PLAUSIBLE_HOST (default https://stats.schmelczer.dev)
|
||||
|
||||
KILL / KEEP rule (printed at the bottom): keep going if BOTH search impressions
|
||||
AND map opens grew month-over-month; otherwise reconsider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import date, timedelta
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2 import service_account
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
TODAY = date.today()
|
||||
WARN = "⚠"
|
||||
|
||||
|
||||
# Comparison windows (yesterday-anchored). GSC data lags ~2-3 days, so the most
|
||||
# recent days of the SEO section may read low, which is expected.
|
||||
def window(end_offset: int, length: int) -> tuple[str, str]:
|
||||
end = TODAY - timedelta(days=end_offset)
|
||||
return (end - timedelta(days=length - 1)).isoformat(), end.isoformat()
|
||||
|
||||
|
||||
WK_CUR, WK_PREV = window(1, 7), window(8, 7) # weekly readout
|
||||
MO_CUR, MO_PREV = window(1, 28), window(29, 28) # monthly kill/keep gate
|
||||
|
||||
|
||||
def delta(cur: float, prev: float) -> str:
|
||||
if prev == 0:
|
||||
return " (new)" if cur else ""
|
||||
pct = (cur - prev) / prev * 100
|
||||
return f" {'▲' if pct >= 0 else '▼'}{pct:+.0f}%"
|
||||
|
||||
|
||||
def http_json(url, *, method="GET", headers=None, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
|
||||
if data is not None:
|
||||
req.add_header("Content-Type", "application/json")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def google_token(creds_path, scopes, *, authorized_user):
|
||||
"""Mint an OAuth bearer token from a Google credential file."""
|
||||
if authorized_user:
|
||||
creds = Credentials.from_authorized_user_file(creds_path, scopes)
|
||||
else:
|
||||
creds = service_account.Credentials.from_service_account_file(
|
||||
creds_path, scopes=scopes
|
||||
)
|
||||
creds.refresh(Request())
|
||||
return creds.token
|
||||
|
||||
|
||||
# --- shared GSC + Plausible query helpers (reused by sections and kill/keep) ---
|
||||
def gsc_creds():
|
||||
return os.environ.get("GSC_SITE_URL"), (
|
||||
os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
or os.environ.get("GSC_CREDENTIALS")
|
||||
)
|
||||
|
||||
|
||||
_GSC_TOKEN = {} # cache: creds_path -> token
|
||||
|
||||
|
||||
def gsc_query(start, end, dimensions=None, limit=1):
|
||||
"""Search Analytics query. Caller must have verified gsc_creds() first."""
|
||||
site, creds = gsc_creds()
|
||||
if creds not in _GSC_TOKEN:
|
||||
_GSC_TOKEN[creds] = google_token(
|
||||
creds,
|
||||
["https://www.googleapis.com/auth/webmasters.readonly"],
|
||||
authorized_user=False,
|
||||
)
|
||||
body = {"startDate": start, "endDate": end, "rowLimit": limit}
|
||||
if dimensions:
|
||||
body["dimensions"] = dimensions
|
||||
url = (
|
||||
"https://searchconsole.googleapis.com/webmasters/v3/sites/"
|
||||
+ urllib.parse.quote(site, safe="")
|
||||
+ "/searchAnalytics/query"
|
||||
)
|
||||
headers = {"Authorization": f"Bearer {_GSC_TOKEN[creds]}"}
|
||||
return http_json(url, method="POST", headers=headers, body=body).get("rows", [])
|
||||
|
||||
|
||||
def plausible_query(metrics, date_range, filters=None):
|
||||
"""Stats API v2 aggregate query -> list of metric values (aligned to
|
||||
`metrics`). Caller must have verified PLAUSIBLE_API_KEY first."""
|
||||
key = os.environ["PLAUSIBLE_API_KEY"]
|
||||
site = os.environ.get("PLAUSIBLE_SITE_ID", "perfect-postcode.co.uk")
|
||||
host = os.environ.get("PLAUSIBLE_HOST", "https://stats.schmelczer.dev").rstrip("/")
|
||||
body = {"site_id": site, "metrics": metrics, "date_range": list(date_range)}
|
||||
if filters:
|
||||
body["filters"] = filters
|
||||
res = http_json(
|
||||
host + "/api/v2/query",
|
||||
method="POST",
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
body=body,
|
||||
).get("results", [])
|
||||
return res[0]["metrics"] if res else [0] * len(metrics)
|
||||
|
||||
|
||||
# Real Plausible events (frontend/src/lib/analytics.ts + MapPage.tsx):
|
||||
# pageview /dashboard -> map opens (no dedicated "Map Open" event exists)
|
||||
# "Filter Add" -> a filter was applied (props.feature = feature name)
|
||||
# "Upgrade Modal Shown" -> the 3-filter demo cap (DEMO_MAX_FILTERS) was hit
|
||||
MAP_FILTER = [["is", "event:page", ["/dashboard"]]]
|
||||
ADD_FILTER = [["is", "event:name", ["Filter Add"]]]
|
||||
CAP_FILTER = [["is", "event:name", ["Upgrade Modal Shown"]]]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. SEO: Google Search Console
|
||||
# ---------------------------------------------------------------------------
|
||||
def section_seo() -> None:
|
||||
print("## SEO: Google Search Console")
|
||||
site, creds = gsc_creds()
|
||||
if not site or not creds:
|
||||
print(f"{WARN} set GSC_SITE_URL + GOOGLE_APPLICATION_CREDENTIALS to enable\n")
|
||||
return
|
||||
try:
|
||||
cur = gsc_query(*WK_CUR)
|
||||
prev = gsc_query(*WK_PREV)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{WARN} GSC failed: {exc}\n")
|
||||
return
|
||||
|
||||
c, p = (cur[0] if cur else {}), (prev[0] if prev else {})
|
||||
ci, cc = c.get("impressions", 0), c.get("clicks", 0)
|
||||
pi, pc = p.get("impressions", 0), p.get("clicks", 0)
|
||||
print(f" impressions {ci:>8,.0f}{delta(ci, pi)} (prior {pi:,.0f})")
|
||||
print(f" clicks {cc:>8,.0f}{delta(cc, pc)} (prior {pc:,.0f})")
|
||||
print(f" CTR {(cc / ci * 100 if ci else 0):>7.2f}%")
|
||||
|
||||
rows = sorted(
|
||||
gsc_query(*WK_CUR, dimensions=["page"], limit=1000),
|
||||
key=lambda r: r.get("impressions", 0),
|
||||
reverse=True,
|
||||
)
|
||||
watch = ("twin", "postcode", "dashboard", "property-search", "cheaper")
|
||||
print(" top pages (impressions / clicks):")
|
||||
for r in rows[:8]:
|
||||
page = r["keys"][0]
|
||||
star = " ★" if any(w in page.lower() for w in watch) else ""
|
||||
path = page.replace("https://perfect-postcode.co.uk", "") or "/"
|
||||
print(
|
||||
f" {r.get('impressions', 0):>6,.0f} / {r.get('clicks', 0):>4,.0f}"
|
||||
f" {path}{star}"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. YouTube
|
||||
# ---------------------------------------------------------------------------
|
||||
def section_youtube() -> None:
|
||||
print("## YouTube")
|
||||
api_key, channel = os.environ.get("YT_API_KEY"), os.environ.get("YT_CHANNEL_ID")
|
||||
if not api_key or not channel:
|
||||
print(f"{WARN} set YT_API_KEY + YT_CHANNEL_ID to enable\n")
|
||||
return
|
||||
try:
|
||||
search = http_json(
|
||||
"https://www.googleapis.com/youtube/v3/search?"
|
||||
f"key={api_key}&channelId={channel}&part=id&type=video"
|
||||
"&order=date&maxResults=15"
|
||||
)
|
||||
ids = [
|
||||
it["id"]["videoId"]
|
||||
for it in search.get("items", [])
|
||||
if it.get("id", {}).get("videoId")
|
||||
]
|
||||
if not ids:
|
||||
print(" (no public videos found)\n")
|
||||
return
|
||||
stats = http_json(
|
||||
"https://www.googleapis.com/youtube/v3/videos?"
|
||||
f"key={api_key}&part=snippet,statistics&id={','.join(ids)}"
|
||||
)
|
||||
print(" public views (Data API v3):")
|
||||
for it in stats.get("items", []):
|
||||
views = int(it.get("statistics", {}).get("viewCount", 0))
|
||||
print(f" {views:>8,} {it['snippet']['title'][:48]}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{WARN} YouTube Data API failed: {exc}")
|
||||
|
||||
oauth = os.environ.get("YT_OAUTH_TOKEN") # private metrics need founder OAuth
|
||||
if not oauth:
|
||||
print(
|
||||
f" {WARN} set YT_OAUTH_TOKEN (founder OAuth) to add impressions, CTR "
|
||||
"and avg view duration\n"
|
||||
)
|
||||
return
|
||||
try:
|
||||
token = google_token(
|
||||
oauth,
|
||||
["https://www.googleapis.com/auth/yt-analytics.readonly"],
|
||||
authorized_user=True,
|
||||
)
|
||||
rep = http_json(
|
||||
"https://youtubeanalytics.googleapis.com/v2/reports?ids=channel==MINE"
|
||||
f"&startDate={WK_CUR[0]}&endDate={WK_CUR[1]}"
|
||||
"&metrics=impressions,impressionsClickThroughRate,averageViewDuration,views"
|
||||
"&dimensions=video&sort=-impressions&maxResults=15",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
print(" impressions / CTR% / avg-dur(s) (Analytics API):")
|
||||
for vid, impr, ctr, avgdur, _views in rep.get("rows", []):
|
||||
print(f" {impr:>7,} CTR {ctr:>5.1f}% {avgdur:>4.0f}s {vid}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" {WARN} YouTube Analytics failed: {exc}")
|
||||
print()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3. Funnel: Plausible (self-hosted)
|
||||
# ---------------------------------------------------------------------------
|
||||
def section_funnel():
|
||||
print("## Funnel: Plausible")
|
||||
if not os.environ.get("PLAUSIBLE_API_KEY"):
|
||||
site = os.environ.get("PLAUSIBLE_SITE_ID", "perfect-postcode.co.uk")
|
||||
print(f"{WARN} set PLAUSIBLE_API_KEY to enable (site={site})\n")
|
||||
return None
|
||||
|
||||
def snapshot(rng):
|
||||
return (
|
||||
plausible_query(["visitors"], rng)[0],
|
||||
plausible_query(["pageviews"], rng, MAP_FILTER)[0],
|
||||
plausible_query(["visitors"], rng, ADD_FILTER)[0],
|
||||
plausible_query(["visitors"], rng, CAP_FILTER)[0],
|
||||
)
|
||||
|
||||
try:
|
||||
cv, cm, cf, cc = snapshot(WK_CUR)
|
||||
pv, pm, _pf, _pc = snapshot(WK_PREV)
|
||||
except urllib.error.HTTPError as exc:
|
||||
print(f"{WARN} Plausible query failed ({exc.code}); check key/site_id\n")
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"{WARN} Plausible query failed: {exc}\n")
|
||||
return None
|
||||
|
||||
print(f" visitors {cv:>7,}{delta(cv, pv)} (prior {pv:,})")
|
||||
print(f" map opens {cm:>7,}{delta(cm, pm)} (prior {pm:,})")
|
||||
print(
|
||||
f" ≥1 filter applied {cf:>7,} = {(cf / cv * 100 if cv else 0):.0f}% of visitors"
|
||||
)
|
||||
print(
|
||||
f" 3-filter cap hit {cc:>7,} = {(cc / cm * 100 if cm else 0):.0f}% of map opens"
|
||||
)
|
||||
print()
|
||||
return cm # weekly map opens (unused below, but handy for callers)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Kill / keep: month-over-month gate
|
||||
# ---------------------------------------------------------------------------
|
||||
def kill_keep(_map_opens_weekly) -> None:
|
||||
print("## Kill / Keep")
|
||||
impr_up = map_up = None
|
||||
|
||||
site, creds = gsc_creds()
|
||||
if site and creds:
|
||||
try:
|
||||
cur = gsc_query(*MO_CUR)
|
||||
prev = gsc_query(*MO_PREV)
|
||||
impr_up = (cur[0]["impressions"] if cur else 0) > (
|
||||
prev[0]["impressions"] if prev else 0
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
impr_up = None
|
||||
|
||||
if os.environ.get("PLAUSIBLE_API_KEY"):
|
||||
try:
|
||||
map_up = (
|
||||
plausible_query(["pageviews"], MO_CUR, MAP_FILTER)[0]
|
||||
> plausible_query(["pageviews"], MO_PREV, MAP_FILTER)[0]
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
map_up = None
|
||||
|
||||
fmt = lambda v: "?" if v is None else ("up" if v else "down") # noqa: E731
|
||||
if impr_up and map_up:
|
||||
verdict = "KEEP: impressions ↑ and map opens ↑ MoM"
|
||||
elif impr_up is None or map_up is None:
|
||||
verdict = "INCONCLUSIVE: enable GSC + Plausible to decide"
|
||||
else:
|
||||
verdict = "REVIEW: impressions and/or map opens did not grow MoM"
|
||||
print(f" impressions MoM: {fmt(impr_up)} | map opens MoM: {fmt(map_up)}")
|
||||
print(f" → {verdict}\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(f"# Perfect Postcode: weekly readout ({TODAY.isoformat()})")
|
||||
print(f"week {WK_CUR[0]}…{WK_CUR[1]} vs prior {WK_PREV[0]}…{WK_PREV[1]}\n")
|
||||
section_seo()
|
||||
section_youtube()
|
||||
map_opens_weekly = section_funnel()
|
||||
kill_keep(map_opens_weekly)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue