Compare commits
8 commits
f1601257c7
...
920119ff48
| Author | SHA1 | Date | |
|---|---|---|---|
| 920119ff48 | |||
| cfaf58dfba | |||
| 982e0cc89c | |||
| 463bd4c647 | |||
| ab688243d7 | |||
| 1ee796b282 | |||
| 909e241907 | |||
| c2070693fb |
323 changed files with 18761 additions and 1556 deletions
|
|
@ -30,7 +30,7 @@ jobs:
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
# Chrome isn't needed for these checks (lint/typecheck/vitest-jsdom), so
|
# Chrome isn't needed for these checks (lint/typecheck/vitest-jsdom), so
|
||||||
# skip puppeteer's postinstall browser download — it's slow and a flaky
|
# skip puppeteer's postinstall browser download: it's slow and a flaky
|
||||||
# point of failure. The prerender build installs Chrome explicitly.
|
# point of failure. The prerender build installs Chrome explicitly.
|
||||||
env:
|
env:
|
||||||
PUPPETEER_SKIP_DOWNLOAD: "true"
|
PUPPETEER_SKIP_DOWNLOAD: "true"
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ jobs:
|
||||||
# The product-demo videos and their poster JPGs live in Git LFS (see
|
# The product-demo videos and their poster JPGs live in Git LFS (see
|
||||||
# .gitattributes). The checkout below needs `lfs: true` to smudge the real
|
# .gitattributes). The checkout below needs `lfs: true` to smudge the real
|
||||||
# binaries, but the runner image ships without the git-lfs executable, so
|
# binaries, but the runner image ships without the git-lfs executable, so
|
||||||
# install it first — otherwise checkout fails with "Unable to locate
|
# install it first. Otherwise checkout fails with "Unable to locate
|
||||||
# executable file: git-lfs".
|
# executable file: git-lfs".
|
||||||
- name: Install Git LFS
|
- name: Install Git LFS
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -25,7 +25,7 @@ jobs:
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
# Without lfs, checkout writes ~130-byte LFS pointer text files, the
|
# Without lfs, checkout writes ~130-byte LFS pointer text files, the
|
||||||
# Docker build copies those stubs into frontend/dist/video/*, and the
|
# Docker build copies those stubs into frontend/dist/video/*, and the
|
||||||
# server serves text as video/mp4 — so the videos never load in
|
# server serves text as video/mp4, so the videos never load in
|
||||||
# production. Smudge the real binaries instead.
|
# production. Smudge the real binaries instead.
|
||||||
with:
|
with:
|
||||||
lfs: true
|
lfs: true
|
||||||
|
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -28,3 +28,4 @@ r5-java/tmp
|
||||||
property-data
|
property-data
|
||||||
property-data-snapshot
|
property-data-snapshot
|
||||||
property-data-snapshot2
|
property-data-snapshot2
|
||||||
|
video/.audit*
|
||||||
|
|
|
||||||
|
|
@ -229,7 +229,7 @@ $(SATELLITE_HIGHRES_TILES): $(PMTILES_BIN) pipeline/download/satellite_highres.p
|
||||||
docker build -t $(GDAL_ECW_IMAGE) docker/gdal-ecw
|
docker build -t $(GDAL_ECW_IMAGE) docker/gdal-ecw
|
||||||
uv run python -m pipeline.download.satellite_highres --output $@ --pmtiles-bin $(PMTILES_BIN) --pmtiles-version $(PMTILES_VERSION) --gdal-image $(GDAL_ECW_IMAGE) $(SATELLITE_HIGHRES_ARGS)
|
uv run python -m pipeline.download.satellite_highres --output $@ --pmtiles-bin $(PMTILES_BIN) --pmtiles-version $(PMTILES_VERSION) --gdal-image $(GDAL_ECW_IMAGE) $(SATELLITE_HIGHRES_ARGS)
|
||||||
|
|
||||||
# EPC requires manual registration — fail with instructions
|
# EPC requires manual registration. Fail with instructions
|
||||||
$(EPC):
|
$(EPC):
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== EPC dataset not found ==="
|
@echo "=== EPC dataset not found ==="
|
||||||
|
|
@ -409,7 +409,7 @@ $(TREE_DENSITY_PC): $(FR_TOW) $(NFI) $(ARCGIS) $(TREE_DENSITY_DEPS)
|
||||||
--arcgis $(ARCGIS) \
|
--arcgis $(ARCGIS) \
|
||||||
--output-postcodes $(TREE_DENSITY_PC)
|
--output-postcodes $(TREE_DENSITY_PC)
|
||||||
|
|
||||||
# Postcode boundaries require manual generation — fail with instructions
|
# Postcode boundaries require manual generation. Fail with instructions
|
||||||
$(PC_BOUNDARIES):
|
$(PC_BOUNDARIES):
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "=== Postcode boundaries not found ==="
|
@echo "=== Postcode boundaries not found ==="
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
"cell_type": "markdown",
|
"cell_type": "markdown",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"# Postcode Boundary Quality — Bank Station (1km radius)\n",
|
"# Postcode Boundary Quality: Bank Station (1km radius)\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Compares postcode boundaries **before** and **after** greenspace/water subtraction."
|
"Compares postcode boundaries **before** and **after** greenspace/water subtraction."
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -390,14 +390,14 @@
|
||||||
"name": "stdout",
|
"name": "stdout",
|
||||||
"output_type": "stream",
|
"output_type": "stream",
|
||||||
"text": [
|
"text": [
|
||||||
"PROPERTY_TYPE — 5 distinct values:\n",
|
"PROPERTY_TYPE: 5 distinct values:\n",
|
||||||
" House 17,437,884\n",
|
" House 17,437,884\n",
|
||||||
" Flat 8,236,696\n",
|
" Flat 8,236,696\n",
|
||||||
" Bungalow 2,448,109\n",
|
" Bungalow 2,448,109\n",
|
||||||
" Maisonette 710,695\n",
|
" Maisonette 710,695\n",
|
||||||
" Park home 14,577\n",
|
" Park home 14,577\n",
|
||||||
"\n",
|
"\n",
|
||||||
"BUILT_FORM — 9 distinct values:\n",
|
"BUILT_FORM: 9 distinct values:\n",
|
||||||
" Semi-Detached 8,777,318\n",
|
" Semi-Detached 8,777,318\n",
|
||||||
" Mid-Terrace 7,972,697\n",
|
" Mid-Terrace 7,972,697\n",
|
||||||
" Detached 6,428,144\n",
|
" Detached 6,428,144\n",
|
||||||
|
|
@ -414,7 +414,7 @@
|
||||||
"source": [
|
"source": [
|
||||||
"for col_name in [\"PROPERTY_TYPE\", \"BUILT_FORM\"]:\n",
|
"for col_name in [\"PROPERTY_TYPE\", \"BUILT_FORM\"]:\n",
|
||||||
" counts = scan().group_by(col_name).len().sort(\"len\", descending=True).collect()\n",
|
" counts = scan().group_by(col_name).len().sort(\"len\", descending=True).collect()\n",
|
||||||
" print(f\"{col_name} — {len(counts)} distinct values:\")\n",
|
" print(f\"{col_name}: {len(counts)} distinct values:\")\n",
|
||||||
" for row in counts.iter_rows(named=True):\n",
|
" for row in counts.iter_rows(named=True):\n",
|
||||||
" print(f\" {row[col_name]!s:30s} {row['len']:>12,}\")\n",
|
" print(f\" {row[col_name]!s:30s} {row['len']:>12,}\")\n",
|
||||||
" print()"
|
" print()"
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
"id": "21f27a93",
|
"id": "21f27a93",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"# Online Buy Listings — data quality & cleanup\n",
|
"# Online Buy Listings: data quality & cleanup\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Source: `finder/data/online_listings_buy.parquet` — ~112k UK *for-sale* property listings\n",
|
"Source: `finder/data/online_listings_buy.parquet`, ~112k UK *for-sale* property listings\n",
|
||||||
"(Greater London) scraped from **Rightmove**, **OnTheMarket** and **Zoopla** and merged by the\n",
|
"(Greater London) scraped from **Rightmove**, **OnTheMarket** and **Zoopla** and merged by the\n",
|
||||||
"`finder` pipeline (`transform.py` / `onthemarket.py` / `zoopla.py` → `storage.py`).\n",
|
"`finder` pipeline (`transform.py` / `onthemarket.py` / `zoopla.py` → `storage.py`).\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
|
@ -453,7 +453,7 @@
|
||||||
"id": "7ea9293c",
|
"id": "7ea9293c",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"### 3.1 · 🔴 Mislabeled column — `Number of bedrooms & living rooms` is actually `Bedrooms + Bathrooms`\n",
|
"### 3.1 · 🔴 Mislabeled column: `Number of bedrooms & living rooms` is actually `Bedrooms + Bathrooms`\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The column name promises *living rooms / receptions*, but `storage.py` recomputes it as\n",
|
"The column name promises *living rooms / receptions*, but `storage.py` recomputes it as\n",
|
||||||
"`Bedrooms + Bathrooms` for **every** row of **every** portal (Zoopla's `+ receptions` line is\n",
|
"`Bedrooms + Bathrooms` for **every** row of **every** portal (Zoopla's `+ receptions` line is\n",
|
||||||
|
|
@ -529,7 +529,7 @@
|
||||||
"### 3.2 · 🔴 Price integrity\n",
|
"### 3.2 · 🔴 Price integrity\n",
|
||||||
"\n",
|
"\n",
|
||||||
"- **Shared-ownership / part-buy** listings store the *share* price as `Asking price`, not full value\n",
|
"- **Shared-ownership / part-buy** listings store the *share* price as `Asking price`, not full value\n",
|
||||||
" (median ~£146k vs ~£550k) — corrupts any price / £-per-sqm aggregate.\n",
|
" (median ~£146k vs ~£550k). Corrupts any price / £-per-sqm aggregate.\n",
|
||||||
"- **38% of rows carry a non-firm qualifier** (Guide / Offers Over / OIEO / From / Shared); `Asking price`\n",
|
"- **38% of rows carry a non-firm qualifier** (Guide / Offers Over / OIEO / From / Shared); `Asking price`\n",
|
||||||
" is stored identically regardless.\n",
|
" is stored identically regardless.\n",
|
||||||
"- A `<£10k` tail of auction land/garage lots + two `£1` placeholders; 364 nulls (price ≤ 0 → null)."
|
"- A `<£10k` tail of auction land/garage lots + two `£1` placeholders; 364 nulls (price ≤ 0 → null)."
|
||||||
|
|
@ -657,7 +657,7 @@
|
||||||
"\n",
|
"\n",
|
||||||
"- ~49% of `Total floor area (sqm)` is null (OnTheMarket ~87%).\n",
|
"- ~49% of `Total floor area (sqm)` is null (OnTheMarket ~87%).\n",
|
||||||
"- **Impossible tiny areas**: rows under 20 m² with ≥2 bedrooms (single-room dimensions parsed as total).\n",
|
"- **Impossible tiny areas**: rows under 20 m² with ≥2 bedrooms (single-room dimensions parsed as total).\n",
|
||||||
"- **Suspiciously large areas** (>400 m²) — likely sq ft never converted to m².\n",
|
"- **Suspiciously large areas** (>400 m²): likely sq ft never converted to m².\n",
|
||||||
"- `Asking price per sqm` is mechanically correct but faithfully amplifies bad areas (max £410,959/m²)."
|
"- `Asking price per sqm` is mechanically correct but faithfully amplifies bad areas (max £410,959/m²)."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -755,7 +755,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"# Floor area vs bedrooms — red points are physically impossible (area < beds * 8 m²)\n",
|
"# Floor area vs bedrooms: red points are physically impossible (area < beds * 8 m²)\n",
|
||||||
"s = raw.filter(fa.is_not_null() & (fa < 160)).select(\"Bedrooms\", \"Total floor area (sqm)\")\n",
|
"s = raw.filter(fa.is_not_null() & (fa < 160)).select(\"Bedrooms\", \"Total floor area (sqm)\")\n",
|
||||||
"rng = np.random.default_rng(0)\n",
|
"rng = np.random.default_rng(0)\n",
|
||||||
"xb = s[\"Bedrooms\"].to_numpy().astype(float)\n",
|
"xb = s[\"Bedrooms\"].to_numpy().astype(float)\n",
|
||||||
|
|
@ -946,7 +946,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"# Listings by provider (downsampled) — shows London footprint + centroid clusters\n",
|
"# Listings by provider (downsampled): shows London footprint + centroid clusters\n",
|
||||||
"g = raw.sample(min(25_000, raw.height), seed=0).select(\"lat\", \"lon\", \"provider\")\n",
|
"g = raw.sample(min(25_000, raw.height), seed=0).select(\"lat\", \"lon\", \"provider\")\n",
|
||||||
"colors = {\"Rightmove\": \"#2563eb\", \"OnTheMarket\": \"#16a34a\", \"Zoopla\": \"#dc2626\"}\n",
|
"colors = {\"Rightmove\": \"#2563eb\", \"OnTheMarket\": \"#16a34a\", \"Zoopla\": \"#dc2626\"}\n",
|
||||||
"fig, ax = plt.subplots(figsize=(8, 7))\n",
|
"fig, ax = plt.subplots(figsize=(8, 7))\n",
|
||||||
|
|
@ -971,7 +971,7 @@
|
||||||
"\n",
|
"\n",
|
||||||
"No cross-source identity resolution: the same physical property appears across portals, and is\n",
|
"No cross-source identity resolution: the same physical property appears across portals, and is\n",
|
||||||
"re-listed multiple times within a portal (especially Zoopla). `UPRN` (Zoopla-only, 1.8% coverage)\n",
|
"re-listed multiple times within a portal (especially Zoopla). `UPRN` (Zoopla-only, 1.8% coverage)\n",
|
||||||
"is not 1:1 — 405 UPRNs repeat up to 7×, breaking the intended exact EPC join."
|
"is not 1:1. 405 UPRNs repeat up to 7×, breaking the intended exact EPC join."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1024,7 +1024,7 @@
|
||||||
"- `Listing status` is a constant `\"For sale\"` (dead column).\n",
|
"- `Listing status` is a constant `\"For sale\"` (dead column).\n",
|
||||||
"- `Property sub-type` has 79 values with portal-spelling variants (`Apartment`↔`Flat`, `X House`↔`X`, …).\n",
|
"- `Property sub-type` has 79 values with portal-spelling variants (`Apartment`↔`Flat`, `X House`↔`X`, …).\n",
|
||||||
"- `Price qualifier` has case-only duplicate pairs (Rightmove TitleCase vs OTM sentence-case).\n",
|
"- `Price qualifier` has case-only duplicate pairs (Rightmove TitleCase vs OTM sentence-case).\n",
|
||||||
"- Missing values are encoded inconsistently — empty-string in some columns, `null` in others."
|
"- Missing values are encoded inconsistently: empty-string in some columns, `null` in others."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1155,7 +1155,7 @@
|
||||||
"### 3.8 · 🔴/🟡 Listing date\n",
|
"### 3.8 · 🔴/🟡 Listing date\n",
|
||||||
"\n",
|
"\n",
|
||||||
"`Listing date` is **null for 100% of OnTheMarket & Zoopla** (Rightmove-only `firstVisibleDate`), so any\n",
|
"`Listing date` is **null for 100% of OnTheMarket & Zoopla** (Rightmove-only `firstVisibleDate`), so any\n",
|
||||||
"recency analysis is biased to 82% of the data — and it reaches back to **2011** in a live for-sale set."
|
"recency analysis is biased to 82% of the data, and it reaches back to **2011** in a live for-sale set."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1293,7 +1293,7 @@
|
||||||
"id": "2491e545",
|
"id": "2491e545",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"**Step A** — drop dead/redundant columns, normalise empty-string → null, canonicalise `Price qualifier`, derive `price_basis` + `is_shared_ownership`."
|
"**Step A**: drop dead/redundant columns, normalise empty-string → null, canonicalise `Price qualifier`, derive `price_basis` + `is_shared_ownership`."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1392,7 +1392,7 @@
|
||||||
"id": "58767c84",
|
"id": "58767c84",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"**Step B** — floor-area sanity (null impossibly-small, flag suspiciously-large) + recompute `Asking price per sqm` (excluding shared-ownership); null sentinel-`0` beds/baths for dwellings; canonicalise sub-types; add `is_residential`, `location_precision`; strip fake unit postcodes from outcode-only rows."
|
"**Step B**: floor-area sanity (null impossibly-small, flag suspiciously-large) + recompute `Asking price per sqm` (excluding shared-ownership); null sentinel-`0` beds/baths for dwellings; canonicalise sub-types; add `is_residential`, `location_precision`; strip fake unit postcodes from outcode-only rows."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1481,7 +1481,7 @@
|
||||||
"id": "33e81cf5",
|
"id": "33e81cf5",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"**Step C** — de-duplicate to one row per physical property. Heuristic key: `(lat, lon, Asking price, Bedrooms)`, keeping the most-recently-listed row. This collapses both cross-portal and intra-portal duplicates; `raw` is retained for provenance."
|
"**Step C**: de-duplicate to one row per physical property. Heuristic key: `(lat, lon, Asking price, Bedrooms)`, keeping the most-recently-listed row. This collapses both cross-portal and intra-portal duplicates; `raw` is retained for provenance."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1554,7 +1554,7 @@
|
||||||
"id": "7d17dd05",
|
"id": "7d17dd05",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## 5 · After cleanup — re-show the stats"
|
"## 5 · After cleanup: re-show the stats"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1737,7 +1737,7 @@
|
||||||
"id": "0a16d168",
|
"id": "0a16d168",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"**Missingness after cleanup** — beds/baths now legitimately null where unknown; empty-strings gone; per-sqm follows cleaned area:"
|
"**Missingness after cleanup**: beds/baths now legitimately null where unknown; empty-strings gone; per-sqm follows cleaned area:"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1924,7 +1924,7 @@
|
||||||
"id": "e833c578",
|
"id": "e833c578",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"**Categoricals normalised** — sub-type variants collapsed, qualifier casing merged:"
|
"**Categoricals normalised**: sub-type variants collapsed, qualifier casing merged:"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -2007,7 +2007,7 @@
|
||||||
"id": "94ac0585",
|
"id": "94ac0585",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"**Before/after — key metrics chart:**"
|
"**Before/after key metrics chart:**"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -2076,7 +2076,7 @@
|
||||||
"\n",
|
"\n",
|
||||||
"- **`clean` keeps every row** (issues are flagged, not dropped) so it stays comparable to `raw`; use the\n",
|
"- **`clean` keeps every row** (issues are flagged, not dropped) so it stays comparable to `raw`; use the\n",
|
||||||
" `is_residential` / `is_shared_ownership` / `floor_area_suspect_*` / `location_precision` flags to filter.\n",
|
" `is_residential` / `is_shared_ownership` / `floor_area_suspect_*` / `location_precision` flags to filter.\n",
|
||||||
"- **Shared-ownership full prices cannot be recovered** here — those rows are flagged and excluded from\n",
|
"- **Shared-ownership full prices cannot be recovered** here: those rows are flagged and excluded from\n",
|
||||||
" `£/sqm`, but their `Asking price` is still a share. Likewise **suspiciously-large areas are flagged,\n",
|
" `£/sqm`, but their `Asking price` is still a share. Likewise **suspiciously-large areas are flagged,\n",
|
||||||
" not auto-converted** from sq ft (the conversion factor isn't certain per-row).\n",
|
" not auto-converted** from sq ft (the conversion factor isn't certain per-row).\n",
|
||||||
"- **De-dup is heuristic** (`lat,lon,price,beds`); it can over-collapse distinct units sharing a\n",
|
"- **De-dup is heuristic** (`lat,lon,price,beds`); it can over-collapse distinct units sharing a\n",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"id": "46a28f40",
|
"id": "46a28f40",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"# School catchment model — the working\n",
|
"# School catchment model: the working\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The postcode features **\"Good+/Outstanding primary/secondary school catchments\"** count the\n",
|
"The postcode features **\"Good+/Outstanding primary/secondary school catchments\"** count the\n",
|
||||||
"rated state schools whose modelled *admission cutoff radius* covers a postcode. This notebook\n",
|
"rated state schools whose modelled *admission cutoff radius* covers a postcode. This notebook\n",
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
"Pupil Database. What *is* public: where every school is and how many pupils it has (GIAS), how\n",
|
"Pupil Database. What *is* public: where every school is and how many pupils it has (GIAS), how\n",
|
||||||
"many children live where (Census 2021), and the fact that most English admissions are run as\n",
|
"many children live where (Census 2021), and the fact that most English admissions are run as\n",
|
||||||
"**deferred acceptance with distance tie-breaks**. That is enough to *solve for* each school's\n",
|
"**deferred acceptance with distance tie-breaks**. That is enough to *solve for* each school's\n",
|
||||||
"cutoff distance — the \"last distance offered\" that councils publish each offer day — and those\n",
|
"cutoff distance (the \"last distance offered\" that councils publish each offer day) and those\n",
|
||||||
"published figures give us ground truth to calibrate against.\n",
|
"published figures give us ground truth to calibrate against.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The production code is `pipeline/transform/school_catchments.py`; the calibration harness is\n",
|
"The production code is `pipeline/transform/school_catchments.py`; the calibration harness is\n",
|
||||||
|
|
@ -81,14 +81,14 @@
|
||||||
"id": "e13f2bc4",
|
"id": "e13f2bc4",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## 1. Supply — schools and their phase fill targets\n",
|
"## 1. Supply: schools and their phase fill targets\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Every open, **non-selective** state school (academies, LA-maintained, free schools) takes part.\n",
|
"Every open, **non-selective** state school (academies, LA-maintained, free schools) takes part.\n",
|
||||||
"Grammar schools are excluded outright: their intakes are test-based and region-wide, so any\n",
|
"Grammar schools are excluded outright: their intakes are test-based and region-wide, so any\n",
|
||||||
"distance-based catchment would be fabricated. Independent, special and Welsh schools don't\n",
|
"distance-based catchment would be fabricated. Independent, special and Welsh schools don't\n",
|
||||||
"admit by distance either.\n",
|
"admit by distance either.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"A school's *fill target* is `max(capacity, headcount)` — an over-full school keeps its\n",
|
"A school's *fill target* is `max(capacity, headcount)`: an over-full school keeps its\n",
|
||||||
"demonstrated size, an under-full one can admit up to capacity (the feature asks \"would you get\n",
|
"demonstrated size, an under-full one can admit up to capacity (the feature asks \"would you get\n",
|
||||||
"a place?\", not \"does a pupil already live there?\"). The target is prorated over the cohort ages\n",
|
"a place?\", not \"does a pupil already live there?\"). The target is prorated over the cohort ages\n",
|
||||||
"the school teaches, parsed from its age range: nursery years weigh 0.5 and sixth-form years 0.6,\n",
|
"the school teaches, parsed from its age range: nursery years weigh 0.5 and sixth-form years 0.6,\n",
|
||||||
|
|
@ -182,16 +182,16 @@
|
||||||
"id": "2905514f",
|
"id": "2905514f",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## 2. Demand — children per postcode\n",
|
"## 2. Demand: children per postcode\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Census 2021 (TS007A) gives children by five-year band per LSOA. Bands don't align with school\n",
|
"Census 2021 (TS007A) gives children by five-year band per LSOA. Bands don't align with school\n",
|
||||||
"phases, so phases take fractional shares — primary (ages 4–10) = ⅕·(0–4) + (5–9) + ⅕·(10–14);\n",
|
"phases, so phases take fractional shares: primary (ages 4–10) = ⅕·(0–4) + (5–9) + ⅕·(10–14);\n",
|
||||||
"secondary (11–15) = ⅘·(10–14) + ⅕·(15–19) — and each LSOA's total is split evenly across its\n",
|
"secondary (11–15) = ⅘·(10–14) + ⅕·(15–19), and each LSOA's total is split evenly across its\n",
|
||||||
"live postcodes (LSOAs hold ~40 postcodes, small enough at catchment scale).\n",
|
"live postcodes (LSOAs hold ~40 postcodes, small enough at catchment scale).\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Not all of those children compete for state places: births fell ~10% between 2016 and 2021\n",
|
"Not all of those children compete for state places: births fell ~10% between 2016 and 2021\n",
|
||||||
"(exactly the gap between the census stock and the cohorts reaching Reception by mid-decade) and\n",
|
"(exactly the gap between the census stock and the cohorts reaching Reception by mid-decade) and\n",
|
||||||
"~7% attend independent schools or are home-educated. `DEMAND_SCALE = 0.8` absorbs both — without\n",
|
"~7% attend independent schools or are home-educated. `DEMAND_SCALE = 0.8` absorbs both, without\n",
|
||||||
"it, modelled cutoffs run systematically tight and half the genuinely undersubscribed schools\n",
|
"it, modelled cutoffs run systematically tight and half the genuinely undersubscribed schools\n",
|
||||||
"look full (this was the single biggest correction the ground truth forced; see §7).\n"
|
"look full (this was the single biggest correction the ground truth forced; see §7).\n"
|
||||||
]
|
]
|
||||||
|
|
@ -241,18 +241,18 @@
|
||||||
"id": "20d44b21",
|
"id": "20d44b21",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## 3. Preferences — grade bonuses and logit choice\n",
|
"## 3. Preferences: grade bonuses and logit choice\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Families don't just pick the nearest school. Two ingredients:\n",
|
"Families don't just pick the nearest school. Two ingredients:\n",
|
||||||
"\n",
|
"\n",
|
||||||
"- **Grade bonus** — a school's *effective distance* is its real distance minus an Ofsted-grade\n",
|
"- **Grade bonus**: a school's *effective distance* is its real distance minus an Ofsted-grade\n",
|
||||||
" bonus (+0.6 km Outstanding, +0.3 km Good, −0.3/−0.6 km for grade 3/4). A family accepts that\n",
|
" bonus (+0.6 km Outstanding, +0.3 km Good, −0.3/−0.6 km for grade 3/4). A family accepts that\n",
|
||||||
" much extra travel for a better school.\n",
|
" much extra travel for a better school.\n",
|
||||||
"- **Logit smearing** — even so, not everyone at a postcode ranks the same school first. Each\n",
|
"- **Logit smearing**: even so, not everyone at a postcode ranks the same school first. Each\n",
|
||||||
" postcode's children split across the nearby feasible schools with weights\n",
|
" postcode's children split across the nearby feasible schools with weights\n",
|
||||||
" `softmax(−effective_distance / τ)`, τ = 0.3 km. This matters more than it looks: with\n",
|
" `softmax(−effective_distance / τ)`, τ = 0.3 km. This matters more than it looks: with\n",
|
||||||
" deterministic choice a popular school fills entirely from its nearest band, putting its\n",
|
" deterministic choice a popular school fills entirely from its nearest band, putting its\n",
|
||||||
" marginal admitted child — and therefore its cutoff — unrealistically close (about 2× too\n",
|
" marginal admitted child (and therefore its cutoff) unrealistically close (about 2× too\n",
|
||||||
" tight against published cutoffs).\n",
|
" tight against published cutoffs).\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Below: the share of applications a Good school captures against an unrated neighbour 1 km away.\n"
|
"Below: the share of applications a Good school captures against an unrated neighbour 1 km away.\n"
|
||||||
|
|
@ -306,7 +306,7 @@
|
||||||
"id": "04bcfbcf",
|
"id": "04bcfbcf",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## 4. The equilibrium — cutoff dynamics\n",
|
"## 4. The equilibrium: cutoff dynamics\n",
|
||||||
"\n",
|
"\n",
|
||||||
"English admissions run deferred acceptance with distance priority; in a continuum economy that\n",
|
"English admissions run deferred acceptance with distance priority; in a continuum economy that\n",
|
||||||
"is equivalent to finding **market-clearing cutoff distances** (Azevedo & Leshno 2016). The solver:\n",
|
"is equivalent to finding **market-clearing cutoff distances** (Azevedo & Leshno 2016). The solver:\n",
|
||||||
|
|
@ -314,7 +314,7 @@
|
||||||
"1. start every school's cutoff at ∞;\n",
|
"1. start every school's cutoff at ∞;\n",
|
||||||
"2. every child unit applies to its preferred school(s) among those whose cutoff still covers it;\n",
|
"2. every child unit applies to its preferred school(s) among those whose cutoff still covers it;\n",
|
||||||
"3. every oversubscribed school tightens its cutoff to the distance of its **marginal admitted\n",
|
"3. every oversubscribed school tightens its cutoff to the distance of its **marginal admitted\n",
|
||||||
" child** — exactly the published \"last distance offered\";\n",
|
" child**, exactly the published \"last distance offered\";\n",
|
||||||
"4. repeat. Cutoffs only ever tighten, so the iteration converges to the deferred-acceptance\n",
|
"4. repeat. Cutoffs only ever tighten, so the iteration converges to the deferred-acceptance\n",
|
||||||
" outcome. Schools that never fill keep no binding cutoff; their radius falls back to the\n",
|
" outcome. Schools that never fill keep no binding cutoff; their radius falls back to the\n",
|
||||||
" distance within which the local child population would cover their fill target.\n",
|
" distance within which the local child population would cover their fill target.\n",
|
||||||
|
|
@ -533,7 +533,7 @@
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"The bimodal logic is visible: oversubscribed urban schools cluster well under 1 km while schools\n",
|
"The bimodal logic is visible: oversubscribed urban schools cluster well under 1 km while schools\n",
|
||||||
"with spare places reach further. A concrete slice — Cambridge and its villages. Circles are the\n",
|
"with spare places reach further. A concrete slice: Cambridge and its villages. Circles are the\n",
|
||||||
"calibrated catchment radii of Good+ primary schools: tight in town, wide in the villages.\n"
|
"calibrated catchment radii of Good+ primary schools: tight in town, wide in the villages.\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -600,11 +600,11 @@
|
||||||
"id": "25770af8",
|
"id": "25770af8",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"## 6. Calibration — modelled vs published cutoffs\n",
|
"## 6. Calibration: modelled vs published cutoffs\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Councils publish each school's **last distance offered** in their allocation reports. We scraped\n",
|
"Councils publish each school's **last distance offered** in their allocation reports. We scraped\n",
|
||||||
"783 rows from nine authorities (Hertfordshire, Surrey, Stockport, Manchester, Bristol, Barnet,\n",
|
"783 rows from nine authorities (Hertfordshire, Surrey, Stockport, Manchester, Bristol, Barnet,\n",
|
||||||
"Redbridge, Ealing, Lambeth — `property-data/ground_truth/`), matched them to GIAS URNs, and\n",
|
"Redbridge, Ealing, Lambeth: `property-data/ground_truth/`), matched them to GIAS URNs, and\n",
|
||||||
"compare against the modelled radii. Faith schools are reported separately: their published\n",
|
"compare against the modelled radii. Faith schools are reported separately: their published\n",
|
||||||
"cutoff applies *within* faith priority, which a postcode model cannot see. \"All applicants\n",
|
"cutoff applies *within* faith priority, which a postcode model cannot see. \"All applicants\n",
|
||||||
"offered\" schools test whether the model agrees there was no binding cutoff at all.\n"
|
"offered\" schools test whether the model agrees there was no binding cutoff at all.\n"
|
||||||
|
|
@ -823,11 +823,11 @@
|
||||||
"source": [
|
"source": [
|
||||||
"## 8. Limitations\n",
|
"## 8. Limitations\n",
|
||||||
"\n",
|
"\n",
|
||||||
"- **Faith admissions are not modelled** — whether a faith school's catchment is open to a given\n",
|
"- **Faith admissions are not modelled**: whether a faith school's catchment is open to a given\n",
|
||||||
" family depends on the family. Their fit is accordingly worse (the orange triangles above).\n",
|
" family depends on the family. Their fit is accordingly worse (the orange triangles above).\n",
|
||||||
"- **Cutoffs are single-year snapshots**; real ones move with each cohort. The model is a\n",
|
"- **Cutoffs are single-year snapshots**; real ones move with each cohort. The model is a\n",
|
||||||
" steady-state estimate, not this September's number.\n",
|
" steady-state estimate, not this September's number.\n",
|
||||||
"- **Straight-line distance** is used throughout — it is the modal LA tie-break, but some\n",
|
"- **Straight-line distance** is used throughout. It is the modal LA tie-break, but some\n",
|
||||||
" authorities measure walking routes, and none of sibling priority, feeder schools or\n",
|
" authorities measure walking routes, and none of sibling priority, feeder schools or\n",
|
||||||
" designated catchment polygons are visible to the model.\n",
|
" designated catchment polygons are visible to the model.\n",
|
||||||
"- Census 2021 child counts age; `DEMAND_SCALE` should drift upward as the birth-rate dip works\n",
|
"- Census 2021 child counts age; `DEMAND_SCALE` should drift upward as the birth-rate dip works\n",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
"id": "db1n423kpm8",
|
"id": "db1n423kpm8",
|
||||||
"metadata": {},
|
"metadata": {},
|
||||||
"source": [
|
"source": [
|
||||||
"# Rightmove vs Home.co.uk — Source Overlap Analysis\n",
|
"# Rightmove vs Home.co.uk: Source Overlap Analysis\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The property scraper collects listings from two sources: **Rightmove** and **home.co.uk**. During merging, cross-source deduplication removes home.co.uk listings that match a Rightmove listing by `(postcode, bedrooms, price)`.\n",
|
"The property scraper collects listings from two sources: **Rightmove** and **home.co.uk**. During merging, cross-source deduplication removes home.co.uk listings that match a Rightmove listing by `(postcode, bedrooms, price)`.\n",
|
||||||
"\n",
|
"\n",
|
||||||
|
|
@ -81,7 +81,7 @@
|
||||||
"\n",
|
"\n",
|
||||||
"The merged parquet contains already-deduplicated data. Home.co.uk listings that matched a Rightmove listing by `(postcode, bedrooms, price)` were removed during scraping. The log reported **2,220 cross-source dedupes** for BUY.\n",
|
"The merged parquet contains already-deduplicated data. Home.co.uk listings that matched a Rightmove listing by `(postcode, bedrooms, price)` were removed during scraping. The log reported **2,220 cross-source dedupes** for BUY.\n",
|
||||||
"\n",
|
"\n",
|
||||||
"So the true home.co.uk total was 20,650 (unique) + 2,220 (deduped) = **22,870** — giving a **9.7% overlap rate** on the outcodes that were scraped."
|
"So the true home.co.uk total was 20,650 (unique) + 2,220 (deduped) = **22,870**, giving a **9.7% overlap rate** on the outcodes that were scraped."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -3164,7 +3164,7 @@
|
||||||
"---\n",
|
"---\n",
|
||||||
"## 3. Approximate Overlap via Fuzzy Matching\n",
|
"## 3. Approximate Overlap via Fuzzy Matching\n",
|
||||||
"\n",
|
"\n",
|
||||||
"The scraper deduped by exact `(postcode, bedrooms, price)`. We can also check for near-matches — properties at the same postcode with similar price that might be the same listing with slightly different data."
|
"The scraper deduped by exact `(postcode, bedrooms, price)`. We can also check for near-matches: properties at the same postcode with similar price that might be the same listing with slightly different data."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -5246,7 +5246,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"# Property sub-type comparison — top home.co.uk sub-types\n",
|
"# Property sub-type comparison: top home.co.uk sub-types\n",
|
||||||
"hk_subtypes = (\n",
|
"hk_subtypes = (\n",
|
||||||
" buy.filter(pl.col(\"source\") == \"Home.co.uk\")[\"Property sub-type\"]\n",
|
" buy.filter(pl.col(\"source\") == \"Home.co.uk\")[\"Property sub-type\"]\n",
|
||||||
" .value_counts()\n",
|
" .value_counts()\n",
|
||||||
|
|
@ -6304,7 +6304,7 @@
|
||||||
"---\n",
|
"---\n",
|
||||||
"## 7. What Does Home.co.uk Add?\n",
|
"## 7. What Does Home.co.uk Add?\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Home.co.uk listings that passed the dedup filter are genuinely unique — not on Rightmove at all (or listed with different price/beds). What do they look like?"
|
"Home.co.uk listings that passed the dedup filter are genuinely unique: not on Rightmove at all (or listed with different price/beds). What do they look like?"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -6409,13 +6409,13 @@
|
||||||
"output_type": "stream",
|
"output_type": "stream",
|
||||||
"text": [
|
"text": [
|
||||||
"\n",
|
"\n",
|
||||||
"Rightmove — days on market:\n",
|
"Rightmove: days on market:\n",
|
||||||
" Median: 74\n",
|
" Median: 74\n",
|
||||||
" Mean: 140\n",
|
" Mean: 140\n",
|
||||||
" P25: 26\n",
|
" P25: 26\n",
|
||||||
" P75: 189\n",
|
" P75: 189\n",
|
||||||
"\n",
|
"\n",
|
||||||
"Home.co.uk — days on market:\n",
|
"Home.co.uk: days on market:\n",
|
||||||
" Median: 156\n",
|
" Median: 156\n",
|
||||||
" Mean: 164\n",
|
" Mean: 164\n",
|
||||||
" P25: 50\n",
|
" P25: 50\n",
|
||||||
|
|
@ -6424,7 +6424,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"# Listing age comparison — are home.co.uk listings older/newer?\n",
|
"# Listing age comparison: are home.co.uk listings older/newer?\n",
|
||||||
"import datetime\n",
|
"import datetime\n",
|
||||||
"\n",
|
"\n",
|
||||||
"now = datetime.datetime(2026, 3, 11)\n",
|
"now = datetime.datetime(2026, 3, 11)\n",
|
||||||
|
|
@ -6435,7 +6435,7 @@
|
||||||
"for src in [\"Rightmove\", \"Home.co.uk\"]:\n",
|
"for src in [\"Rightmove\", \"Home.co.uk\"]:\n",
|
||||||
" age = with_age.filter(pl.col(\"source\") == src)[\"days_on_market\"].drop_nulls()\n",
|
" age = with_age.filter(pl.col(\"source\") == src)[\"days_on_market\"].drop_nulls()\n",
|
||||||
" if len(age) > 0:\n",
|
" if len(age) > 0:\n",
|
||||||
" print(f\"\\n{src} — days on market:\")\n",
|
" print(f\"\\n{src}: days on market:\")\n",
|
||||||
" print(f\" Median: {age.median():.0f}\")\n",
|
" print(f\" Median: {age.median():.0f}\")\n",
|
||||||
" print(f\" Mean: {age.mean():.0f}\")\n",
|
" print(f\" Mean: {age.mean():.0f}\")\n",
|
||||||
" print(f\" P25: {age.quantile(0.25):.0f}\")\n",
|
" print(f\" P25: {age.quantile(0.25):.0f}\")\n",
|
||||||
|
|
@ -7388,7 +7388,7 @@
|
||||||
" Projected unique additions: ~274,584\n",
|
" Projected unique additions: ~274,584\n",
|
||||||
" Projected merged dataset: ~728,899 (60.4% increase)\n",
|
" Projected merged dataset: ~728,899 (60.4% increase)\n",
|
||||||
"\n",
|
"\n",
|
||||||
"⚠️ These are rough estimates — the covered outcodes may not be representative\n"
|
"⚠️ These are rough estimates: the covered outcodes may not be representative\n"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
@ -7425,7 +7425,7 @@
|
||||||
" f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique / rm_buy * 100:.1f}% increase)\"\n",
|
" f\" Projected merged dataset: ~{rm_buy + projected_unique:,} ({projected_unique / rm_buy * 100:.1f}% increase)\"\n",
|
||||||
")\n",
|
")\n",
|
||||||
"print()\n",
|
"print()\n",
|
||||||
"print(\"⚠️ These are rough estimates — the covered outcodes may not be representative\")"
|
"print(\"⚠️ These are rough estimates: the covered outcodes may not be representative\")"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -403120,7 +403120,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Bank — Median transit error (R5 − TfL easy), minutes"
|
"text": "Bank: Median transit error (R5 − TfL easy), minutes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -403143,7 +403143,7 @@
|
||||||
" zoom=6,\n",
|
" zoom=6,\n",
|
||||||
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
||||||
" opacity=0.5,\n",
|
" opacity=0.5,\n",
|
||||||
" title=\"Bank — Median transit error (R5 − TfL easy), minutes\",\n",
|
" title=\"Bank: Median transit error (R5 − TfL easy), minutes\",\n",
|
||||||
" hover_data={\n",
|
" hover_data={\n",
|
||||||
" \"pcds\": True,\n",
|
" \"pcds\": True,\n",
|
||||||
" \"travel_minutes\": True,\n",
|
" \"travel_minutes\": True,\n",
|
||||||
|
|
@ -804061,7 +804061,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Bank — Best transit error (R5 − TfL quick), minutes"
|
"text": "Bank: Best transit error (R5 − TfL quick), minutes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -804081,7 +804081,7 @@
|
||||||
" zoom=6,\n",
|
" zoom=6,\n",
|
||||||
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
||||||
" opacity=0.5,\n",
|
" opacity=0.5,\n",
|
||||||
" title=\"Bank — Best transit error (R5 − TfL quick), minutes\",\n",
|
" title=\"Bank: Best transit error (R5 − TfL quick), minutes\",\n",
|
||||||
" hover_data={\n",
|
" hover_data={\n",
|
||||||
" \"pcds\": True,\n",
|
" \"pcds\": True,\n",
|
||||||
" \"best_minutes\": True,\n",
|
" \"best_minutes\": True,\n",
|
||||||
|
|
@ -1204999,7 +1204999,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"text": "Bank — Absolute median transit error |R5 − TfL easy|, minutes"
|
"text": "Bank: Absolute median transit error |R5 − TfL easy|, minutes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1205019,7 +1205019,7 @@
|
||||||
" zoom=6,\n",
|
" zoom=6,\n",
|
||||||
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
" center={\"lat\": 51.5, \"lon\": -0.1},\n",
|
||||||
" opacity=0.5,\n",
|
" opacity=0.5,\n",
|
||||||
" title=\"Bank — Absolute median transit error |R5 − TfL easy|, minutes\",\n",
|
" title=\"Bank: Absolute median transit error |R5 − TfL easy|, minutes\",\n",
|
||||||
" hover_data={\n",
|
" hover_data={\n",
|
||||||
" \"pcds\": True,\n",
|
" \"pcds\": True,\n",
|
||||||
" \"travel_minutes\": True,\n",
|
" \"travel_minutes\": True,\n",
|
||||||
|
|
|
||||||
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.
42
analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json
Normal file
42
analysis/out/findings/cheaper-twin__br3-3-vs-cr0-7.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/br3-3-vs-cr0-7",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/br3-3-vs-cr0-7",
|
||||||
|
"title": "Beckenham vs Croydon: the same terraced house, about 31% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart",
|
||||||
|
"shocking_number": "31%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "BR3 3",
|
||||||
|
"name": "Beckenham",
|
||||||
|
"label": "Beckenham (BR3 3)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 7153,
|
||||||
|
"n": 4514
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "CR0 7",
|
||||||
|
"name": "Croydon",
|
||||||
|
"label": "Croydon (CR0 7)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 4910,
|
||||||
|
"n": 5143
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 31.4,
|
||||||
|
"gap_per_sqm": 2243,
|
||||||
|
"gap_on_90sqm": 201870,
|
||||||
|
"gap_on_avg_home": 214206,
|
||||||
|
"dominant_type": "Terraced",
|
||||||
|
"build_year": 1940,
|
||||||
|
"good_secondary_catchments": 7.8,
|
||||||
|
"station_km": 0.73,
|
||||||
|
"distance_km": 2.02
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.38969&lon=-0.04244&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5200&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json
Normal file
42
analysis/out/findings/cheaper-twin__ha7-2-vs-ha3-0.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/ha7-2-vs-ha3-0",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/ha7-2-vs-ha3-0",
|
||||||
|
"title": "Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart",
|
||||||
|
"shocking_number": "17%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "HA7 2",
|
||||||
|
"name": "Stanmore",
|
||||||
|
"label": "Stanmore (HA7 2)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 6834,
|
||||||
|
"n": 2775
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "HA3 0",
|
||||||
|
"name": "Kenton",
|
||||||
|
"label": "Kenton (HA3 0)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 5646,
|
||||||
|
"n": 3122
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 17.4,
|
||||||
|
"gap_per_sqm": 1188,
|
||||||
|
"gap_on_90sqm": 106920,
|
||||||
|
"gap_on_avg_home": 108108,
|
||||||
|
"dominant_type": "Semi-Detached",
|
||||||
|
"build_year": 1940,
|
||||||
|
"good_secondary_catchments": 2.9,
|
||||||
|
"station_km": 1.31,
|
||||||
|
"distance_km": 2.57
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.59199&lon=-0.3079&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json
Normal file
42
analysis/out/findings/cheaper-twin__ig8-7-vs-ig6-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/ig8-7-vs-ig6-2",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/ig8-7-vs-ig6-2",
|
||||||
|
"title": "Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart",
|
||||||
|
"shocking_number": "26%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "IG8 7",
|
||||||
|
"name": "Woodford Green",
|
||||||
|
"label": "Woodford Green (IG8 7)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 7148,
|
||||||
|
"n": 2965
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "IG6 2",
|
||||||
|
"name": "Barkingside",
|
||||||
|
"label": "Barkingside (IG6 2)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 5325,
|
||||||
|
"n": 4423
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 25.5,
|
||||||
|
"gap_per_sqm": 1823,
|
||||||
|
"gap_on_90sqm": 164070,
|
||||||
|
"gap_on_avg_home": 143105,
|
||||||
|
"dominant_type": "Terraced",
|
||||||
|
"build_year": 1958,
|
||||||
|
"good_secondary_catchments": 2.8,
|
||||||
|
"station_km": 0.58,
|
||||||
|
"distance_km": 2.98
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.60238&lon=0.06063&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json
Normal file
42
analysis/out/findings/cheaper-twin__l16-7-vs-l14-6.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/l16-7-vs-l14-6",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/l16-7-vs-l14-6",
|
||||||
|
"title": "Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart",
|
||||||
|
"shocking_number": "30%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "L16 7",
|
||||||
|
"name": "Childwall",
|
||||||
|
"label": "Childwall (L16 7)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 4026,
|
||||||
|
"n": 500
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "L14 6",
|
||||||
|
"name": "Broadgreen",
|
||||||
|
"label": "Broadgreen (L14 6)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 2840,
|
||||||
|
"n": 809
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 29.5,
|
||||||
|
"gap_per_sqm": 1186,
|
||||||
|
"gap_on_90sqm": 106740,
|
||||||
|
"gap_on_avg_home": 117414,
|
||||||
|
"dominant_type": "Semi-Detached",
|
||||||
|
"build_year": 1940,
|
||||||
|
"good_secondary_catchments": 5.1,
|
||||||
|
"station_km": 1.22,
|
||||||
|
"distance_km": 1.88
|
||||||
|
},
|
||||||
|
"map_query": "lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=53.40344&lon=-2.88529&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json
Normal file
42
analysis/out/findings/cheaper-twin__m40-5-vs-m9-4.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/m40-5-vs-m9-4",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/m40-5-vs-m9-4",
|
||||||
|
"title": "Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart",
|
||||||
|
"shocking_number": "42%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "M40 5",
|
||||||
|
"name": "Newton Heath",
|
||||||
|
"label": "Newton Heath (M40 5)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 2812,
|
||||||
|
"n": 1632
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "M9 4",
|
||||||
|
"name": "Harpurhey",
|
||||||
|
"label": "Harpurhey (M9 4)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 1626,
|
||||||
|
"n": 3530
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 42.2,
|
||||||
|
"gap_per_sqm": 1186,
|
||||||
|
"gap_on_90sqm": 106740,
|
||||||
|
"gap_on_avg_home": 91915,
|
||||||
|
"dominant_type": "Terraced",
|
||||||
|
"build_year": 1958,
|
||||||
|
"good_secondary_catchments": 2.6,
|
||||||
|
"station_km": 0.72,
|
||||||
|
"distance_km": 1.18
|
||||||
|
},
|
||||||
|
"map_query": "lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=53.51293&lon=-2.19574&zoom=12.5&filter=Est.%20price%20per%20sqm:0:1700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json
Normal file
42
analysis/out/findings/cheaper-twin__rm14-2-vs-rm12-5.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/rm14-2-vs-rm12-5",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/rm14-2-vs-rm12-5",
|
||||||
|
"title": "Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart",
|
||||||
|
"shocking_number": "20%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "RM14 2",
|
||||||
|
"name": "Upminster",
|
||||||
|
"label": "Upminster (RM14 2)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 6360,
|
||||||
|
"n": 3026
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "RM12 5",
|
||||||
|
"name": "Hornchurch",
|
||||||
|
"label": "Hornchurch (RM12 5)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 5079,
|
||||||
|
"n": 3133
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 20.1,
|
||||||
|
"gap_per_sqm": 1281,
|
||||||
|
"gap_on_90sqm": 115290,
|
||||||
|
"gap_on_avg_home": 111447,
|
||||||
|
"dominant_type": "Semi-Detached",
|
||||||
|
"build_year": 1940,
|
||||||
|
"good_secondary_catchments": 3.7,
|
||||||
|
"station_km": 0.77,
|
||||||
|
"distance_km": 2.99
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.54892&lon=0.22193&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json
Normal file
42
analysis/out/findings/cheaper-twin__se28-8-vs-da18-4.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/se28-8-vs-da18-4",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/se28-8-vs-da18-4",
|
||||||
|
"title": "SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart",
|
||||||
|
"shocking_number": "30%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "SE28 8",
|
||||||
|
"name": null,
|
||||||
|
"label": "SE28 8",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 4850,
|
||||||
|
"n": 5033
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "DA18 4",
|
||||||
|
"name": null,
|
||||||
|
"label": "DA18 4",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 3409,
|
||||||
|
"n": 1063
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 29.7,
|
||||||
|
"gap_per_sqm": 1441,
|
||||||
|
"gap_on_90sqm": 129690,
|
||||||
|
"gap_on_avg_home": 104112,
|
||||||
|
"dominant_type": "Terraced",
|
||||||
|
"build_year": 1993,
|
||||||
|
"good_secondary_catchments": 2.8,
|
||||||
|
"station_km": 1.39,
|
||||||
|
"distance_km": 1.72
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50039&lon=0.12568&zoom=12.5&filter=Est.%20price%20per%20sqm:0:3600&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json
Normal file
42
analysis/out/findings/cheaper-twin__sw1x-8-vs-sw7-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/sw1x-8-vs-sw7-2",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/sw1x-8-vs-sw7-2",
|
||||||
|
"title": "SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a31,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart",
|
||||||
|
"shocking_number": "42%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "SW1X 8",
|
||||||
|
"name": null,
|
||||||
|
"label": "SW1X 8",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 26735,
|
||||||
|
"n": 1410
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "SW7 2",
|
||||||
|
"name": null,
|
||||||
|
"label": "SW7 2",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 15611,
|
||||||
|
"n": 1126
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 41.6,
|
||||||
|
"gap_per_sqm": 11124,
|
||||||
|
"gap_on_90sqm": 1001160,
|
||||||
|
"gap_on_avg_home": 1301508,
|
||||||
|
"dominant_type": "Flats/Maisonettes",
|
||||||
|
"build_year": 1890,
|
||||||
|
"good_secondary_catchments": 3.7,
|
||||||
|
"station_km": 0.43,
|
||||||
|
"distance_km": 1.31
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.49758&lon=-0.16439&zoom=12.5&filter=Est.%20price%20per%20sqm:0:16400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json
Normal file
42
analysis/out/findings/cheaper-twin__tw12-3-vs-kt8-1.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/tw12-3-vs-kt8-1",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/tw12-3-vs-kt8-1",
|
||||||
|
"title": "Hampton vs East Molesey: the same terraced house, about 19% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart",
|
||||||
|
"shocking_number": "19%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "TW12 3",
|
||||||
|
"name": "Hampton",
|
||||||
|
"label": "Hampton (TW12 3)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 7042,
|
||||||
|
"n": 2527
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "KT8 1",
|
||||||
|
"name": "East Molesey",
|
||||||
|
"label": "East Molesey (KT8 1)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 5708,
|
||||||
|
"n": 1567
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 18.9,
|
||||||
|
"gap_per_sqm": 1334,
|
||||||
|
"gap_on_90sqm": 120060,
|
||||||
|
"gap_on_avg_home": 107053,
|
||||||
|
"dominant_type": "Terraced",
|
||||||
|
"build_year": 1979,
|
||||||
|
"good_secondary_catchments": 3.2,
|
||||||
|
"station_km": 1.27,
|
||||||
|
"distance_km": 2.23
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.41616&lon=-0.37365&zoom=12.5&filter=Est.%20price%20per%20sqm:0:6000&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json
Normal file
42
analysis/out/findings/cheaper-twin__tw2-7-vs-tw3-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/tw2-7-vs-tw3-2",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/tw2-7-vs-tw3-2",
|
||||||
|
"title": "Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart",
|
||||||
|
"shocking_number": "19%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "TW2 7",
|
||||||
|
"name": "Twickenham",
|
||||||
|
"label": "Twickenham (TW2 7)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 6971,
|
||||||
|
"n": 3377
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "TW3 2",
|
||||||
|
"name": "Hounslow",
|
||||||
|
"label": "Hounslow (TW3 2)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 5620,
|
||||||
|
"n": 2964
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 19.4,
|
||||||
|
"gap_per_sqm": 1351,
|
||||||
|
"gap_on_90sqm": 121590,
|
||||||
|
"gap_on_avg_home": 113821,
|
||||||
|
"dominant_type": "Semi-Detached",
|
||||||
|
"build_year": 1940,
|
||||||
|
"good_secondary_catchments": 5.4,
|
||||||
|
"station_km": 0.59,
|
||||||
|
"distance_km": 1.0
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.45678&lon=-0.35702&zoom=12.5&filter=Est.%20price%20per%20sqm:0:5900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json
Normal file
42
analysis/out/findings/cheaper-twin__w1j-7-vs-sw7-3.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/w1j-7-vs-sw7-3",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/w1j-7-vs-sw7-3",
|
||||||
|
"title": "W1J 7 vs SW7 3: the same flat, about 41% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a31,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart",
|
||||||
|
"shocking_number": "41%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "W1J 7",
|
||||||
|
"name": "Mayfair",
|
||||||
|
"label": "Mayfair (W1J 7)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 32986,
|
||||||
|
"n": 724
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "SW7 3",
|
||||||
|
"name": null,
|
||||||
|
"label": "SW7 3",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 19392,
|
||||||
|
"n": 2581
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 41.2,
|
||||||
|
"gap_per_sqm": 13594,
|
||||||
|
"gap_on_90sqm": 1223460,
|
||||||
|
"gap_on_avg_home": 1077324,
|
||||||
|
"dominant_type": "Flats/Maisonettes",
|
||||||
|
"build_year": 1914,
|
||||||
|
"good_secondary_catchments": 2.0,
|
||||||
|
"station_km": 0.3,
|
||||||
|
"distance_km": 2.6
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.49856&lon=-0.16253&zoom=12.5&filter=Est.%20price%20per%20sqm:0:20400&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json
Normal file
42
analysis/out/findings/cheaper-twin__w1j-8-vs-sw1a-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/w1j-8-vs-sw1a-2",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/w1j-8-vs-sw1a-2",
|
||||||
|
"title": "W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart",
|
||||||
|
"shocking_number": "37%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "W1J 8",
|
||||||
|
"name": "Mayfair",
|
||||||
|
"label": "Mayfair (W1J 8)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 27270,
|
||||||
|
"n": 295
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "SW1A 2",
|
||||||
|
"name": null,
|
||||||
|
"label": "SW1A 2",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 17088,
|
||||||
|
"n": 261
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 37.3,
|
||||||
|
"gap_per_sqm": 10182,
|
||||||
|
"gap_on_90sqm": 916380,
|
||||||
|
"gap_on_avg_home": 1089474,
|
||||||
|
"dominant_type": "Flats/Maisonettes",
|
||||||
|
"build_year": 2000,
|
||||||
|
"good_secondary_catchments": 2.0,
|
||||||
|
"station_km": 0.18,
|
||||||
|
"distance_km": 1.31
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50663&lon=-0.13494&zoom=12.5&filter=Est.%20price%20per%20sqm:0:17900&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json
Normal file
42
analysis/out/findings/cheaper-twin__w1k-2-vs-sw1x-0.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/w1k-2-vs-sw1x-0",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/w1k-2-vs-sw1x-0",
|
||||||
|
"title": "W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart",
|
||||||
|
"shocking_number": "32%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "W1K 2",
|
||||||
|
"name": "Mayfair",
|
||||||
|
"label": "Mayfair (W1K 2)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 34362,
|
||||||
|
"n": 591
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "SW1X 0",
|
||||||
|
"name": null,
|
||||||
|
"label": "SW1X 0",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 23489,
|
||||||
|
"n": 1606
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 31.6,
|
||||||
|
"gap_per_sqm": 10873,
|
||||||
|
"gap_on_90sqm": 978570,
|
||||||
|
"gap_on_avg_home": 1293887,
|
||||||
|
"dominant_type": "Flats/Maisonettes",
|
||||||
|
"build_year": 1914,
|
||||||
|
"good_secondary_catchments": 2.0,
|
||||||
|
"station_km": 0.5,
|
||||||
|
"distance_km": 1.62
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.50311&lon=-0.15673&zoom=12.5&filter=Est.%20price%20per%20sqm:0:24700&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json
Normal file
42
analysis/out/findings/cheaper-twin__w1u-4-vs-nw1-4.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/w1u-4-vs-nw1-4",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/w1u-4-vs-nw1-4",
|
||||||
|
"title": "Marylebone vs Camden: the same flat, about 43% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart",
|
||||||
|
"shocking_number": "43%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "W1U 4",
|
||||||
|
"name": "Marylebone",
|
||||||
|
"label": "Marylebone (W1U 4)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 24238,
|
||||||
|
"n": 984
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "NW1 4",
|
||||||
|
"name": "Camden",
|
||||||
|
"label": "Camden (NW1 4)",
|
||||||
|
"named": true,
|
||||||
|
"est_psqm": 13766,
|
||||||
|
"n": 1340
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 43.2,
|
||||||
|
"gap_per_sqm": 10472,
|
||||||
|
"gap_on_90sqm": 942480,
|
||||||
|
"gap_on_avg_home": 759220,
|
||||||
|
"dominant_type": "Flats/Maisonettes",
|
||||||
|
"build_year": 1940,
|
||||||
|
"good_secondary_catchments": 1.0,
|
||||||
|
"station_km": 0.44,
|
||||||
|
"distance_km": 0.97
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.5238&lon=-0.15091&zoom=12.5&filter=Est.%20price%20per%20sqm:0:14500&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": false,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
42
analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json
Normal file
42
analysis/out/findings/cheaper-twin__wc2a-2-vs-ec2a-2.json
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"slug": "cheaper-twin/wc2a-2-vs-ec2a-2",
|
||||||
|
"type": "cheaper_twin",
|
||||||
|
"page_path": "/cheaper-twin/wc2a-2-vs-ec2a-2",
|
||||||
|
"title": "WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m\u00b2",
|
||||||
|
"hook": "\u00a3981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart",
|
||||||
|
"shocking_number": "43%",
|
||||||
|
"pricey": {
|
||||||
|
"sector": "WC2A 2",
|
||||||
|
"name": null,
|
||||||
|
"label": "WC2A 2",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 25482,
|
||||||
|
"n": 254
|
||||||
|
},
|
||||||
|
"twin": {
|
||||||
|
"sector": "EC2A 2",
|
||||||
|
"name": null,
|
||||||
|
"label": "EC2A 2",
|
||||||
|
"named": false,
|
||||||
|
"est_psqm": 14576,
|
||||||
|
"n": 772
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"gap_pct": 42.8,
|
||||||
|
"gap_per_sqm": 10906,
|
||||||
|
"gap_on_90sqm": 981540,
|
||||||
|
"gap_on_avg_home": 834309,
|
||||||
|
"dominant_type": "Flats/Maisonettes",
|
||||||
|
"build_year": 2019,
|
||||||
|
"good_secondary_catchments": 2.0,
|
||||||
|
"station_km": 0.42,
|
||||||
|
"distance_km": 2.3
|
||||||
|
},
|
||||||
|
"map_query": "lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"map_url": "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",
|
||||||
|
"og_image": "https://perfect-postcode.co.uk/api/screenshot?og=1&lat=51.51807&lon=-0.09837&zoom=12.5&filter=Est.%20price%20per%20sqm:0:15300&filter=Good%2B%20secondary%20school%20catchments:1:11",
|
||||||
|
"methodology": "Postcode sectors (e.g. N10 3) compared on estimated \u00a3/m\u00b2 of floor space. A pair is only called a 'twin' when the two sectors share the dominant property type, build era (\u00b130y), 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": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
28
analysis/out/findings/square-metres-per-100k.json
Normal file
28
analysis/out/findings/square-metres-per-100k.json
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"slug": "square-metres-per-100k",
|
||||||
|
"type": "national_table",
|
||||||
|
"page_path": "/square-metres-per-100k",
|
||||||
|
"title": "How many square metres \u00a3100,000 buys across England",
|
||||||
|
"shocking_number": "152 m\u00b2 vs 3 m\u00b2",
|
||||||
|
"hook": "\u00a3100k buys ~152 m\u00b2 of floor space in BD21 3 but only ~3 m\u00b2 in Mayfair (W1K 2)",
|
||||||
|
"stats": {
|
||||||
|
"best": {
|
||||||
|
"sector": "BD21 3",
|
||||||
|
"est_psqm": 660,
|
||||||
|
"sqm_per_100k": 151.6,
|
||||||
|
"n": 1377
|
||||||
|
},
|
||||||
|
"dearest": {
|
||||||
|
"sector": "W1K 2",
|
||||||
|
"est_psqm": 34362,
|
||||||
|
"sqm_per_100k": 2.9,
|
||||||
|
"n": 591
|
||||||
|
},
|
||||||
|
"n_sectors": 7560
|
||||||
|
},
|
||||||
|
"map_query": "zoom=6&filter=Est.%20price%20per%20sqm:0:4000",
|
||||||
|
"methodology": "100000 \u00f7 median estimated \u00a3/m\u00b2, per England postcode sector with sufficient sales.",
|
||||||
|
"needs_name_check": true,
|
||||||
|
"attribution": "Contains HM Land Registry data \u00a9 Crown copyright and database right. Licensed under the Open Government Licence v3.0.",
|
||||||
|
"sources": "HM Land Registry \u00b7 EPC (DLUHC) \u00b7 Ofsted \u00b7 DfT \u00b7 ONS \u00b7 Police.uk"
|
||||||
|
}
|
||||||
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/out/video_scripts/INDEX.md
Normal file
23
analysis/out/video_scripts/INDEX.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# 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 |
|
||||||
|
|-----|------|------|
|
||||||
|
| Beckenham → Croydon | 31% / £201,870 | `br3-3-vs-cr0-7.md` |
|
||||||
|
| Woodford Green → Barkingside | 26% / £164,070 | `ig8-7-vs-ig6-2.md` |
|
||||||
|
| SE28 8 → DA18 4 | 30% / £129,690 | `se28-8-vs-da18-4.md` |
|
||||||
|
| Twickenham → Hounslow | 19% / £121,590 | `tw2-7-vs-tw3-2.md` |
|
||||||
|
| Hampton → East Molesey | 19% / £120,060 | `tw12-3-vs-kt8-1.md` |
|
||||||
|
| Upminster → Hornchurch | 20% / £115,290 | `rm14-2-vs-rm12-5.md` |
|
||||||
|
| Stanmore → Kenton | 17% / £106,920 | `ha7-2-vs-ha3-0.md` |
|
||||||
|
| Childwall → Broadgreen | 30% / £106,740 | `l16-7-vs-l14-6.md` |
|
||||||
|
| Newton Heath → Harpurhey | 42% / £106,740 | `m40-5-vs-m9-4.md` |
|
||||||
|
| Mayfair → SW7 3 | 41% / £1,223,460 | `w1j-7-vs-sw7-3.md` |
|
||||||
|
| SW1X 8 → SW7 2 | 42% / £1,001,160 | `sw1x-8-vs-sw7-2.md` |
|
||||||
|
| WC2A 2 → EC2A 2 | 43% / £981,540 | `wc2a-2-vs-ec2a-2.md` |
|
||||||
|
| Mayfair → SW1X 0 | 32% / £978,570 | `w1k-2-vs-sw1x-0.md` |
|
||||||
|
| Marylebone → Camden | 43% / £942,480 | `w1u-4-vs-nw1-4.md` |
|
||||||
|
| Mayfair → SW1A 2 | 37% / £916,380 | `w1j-8-vs-sw1a-2.md` |
|
||||||
79
analysis/out/video_scripts/br3-3-vs-cr0-7.md
Normal file
79
analysis/out/video_scripts/br3-3-vs-cr0-7.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Beckenham vs Croydon
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/br3-3-vs-cr0-7 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£201,870 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£201,870 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 | Note the dominant type (terraced houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Beckenham £7,153 vs Croydon £4,910. | Caption: '31% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Beckenham. And this is Croydon, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Beckenham: £7,153. Croydon: £4,910. That's 31% cheaper, about £201,870 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Beckenham vs Croydon
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £201,870 cheaper
|
||||||
|
- Same terraced, ~1940
|
||||||
|
- 31% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Beckenham vs Croydon: the same terraced house, about 31% cheaper per m²
|
||||||
|
2. Beckenham vs Croydon: same station, same schools, £201,870 cheaper
|
||||||
|
3. Why Croydon is the smart-money version of Beckenham (31% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£201,870 cheaper` + the two names `Beckenham → Croydon`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Beckenham and Croydon share a station, a school catchment and the same era of housing, but Croydon costs about 31% less per square metre (£201,870 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Beckenham & Croydon)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Beckenham £7,153 → Croydon £4,910) + caption '31% 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
|
||||||
|
{
|
||||||
|
"name": "twin-br3-3-vs-cr0-7",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value terraceds near Beckenham: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
5200
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Croydon: same life, 31% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/ha7-2-vs-ha3-0.md
Normal file
79
analysis/out/video_scripts/ha7-2-vs-ha3-0.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Stanmore vs Kenton
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/ha7-2-vs-ha3-0 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£106,920 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,920 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Stanmore £6,834 vs Kenton £5,646. | Caption: '17% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Stanmore. And this is Kenton, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Stanmore: £6,834. Kenton: £5,646. That's 17% cheaper, about £106,920 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Stanmore vs Kenton
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £106,920 cheaper
|
||||||
|
- Same semi-detached, ~1940
|
||||||
|
- 17% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m²
|
||||||
|
2. Stanmore vs Kenton: same station, same schools, £106,920 cheaper
|
||||||
|
3. Why Kenton is the smart-money version of Stanmore (17% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£106,920 cheaper` + the two names `Stanmore → Kenton`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Stanmore and Kenton share a station, a school catchment and the same era of housing, but Kenton costs about 17% less per square metre (£106,920 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Stanmore & Kenton)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Stanmore £6,834 → Kenton £5,646) + caption '17% 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
|
||||||
|
{
|
||||||
|
"name": "twin-ha7-2-vs-ha3-0",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value semi-detacheds near Stanmore: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
5900
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Kenton: same life, 17% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/ig8-7-vs-ig6-2.md
Normal file
79
analysis/out/video_scripts/ig8-7-vs-ig6-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Woodford Green vs Barkingside
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/ig8-7-vs-ig6-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£164,070 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£164,070 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 | Note the dominant type (terraced houses) and build era (~1958). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Woodford Green £7,148 vs Barkingside £5,325. | Caption: '26% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Woodford Green. And this is Barkingside, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1958. On every measure that moves price, they're twins. But watch the price per square metre. Woodford Green: £7,148. Barkingside: £5,325. That's 26% cheaper, about £164,070 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Woodford Green vs Barkingside
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £164,070 cheaper
|
||||||
|
- Same terraced, ~1958
|
||||||
|
- 26% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m²
|
||||||
|
2. Woodford Green vs Barkingside: same station, same schools, £164,070 cheaper
|
||||||
|
3. Why Barkingside is the smart-money version of Woodford Green (26% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£164,070 cheaper` + the two names `Woodford Green → Barkingside`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Woodford Green and Barkingside share a station, a school catchment and the same era of housing, but Barkingside costs about 26% less per square metre (£164,070 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Woodford Green & Barkingside)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Woodford Green £7,148 → Barkingside £5,325) + caption '26% 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
|
||||||
|
{
|
||||||
|
"name": "twin-ig8-7-vs-ig6-2",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value terraceds near Woodford Green: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
5600
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Barkingside: same life, 26% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/l16-7-vs-l14-6.md
Normal file
79
analysis/out/video_scripts/l16-7-vs-l14-6.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Childwall vs Broadgreen
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/l16-7-vs-l14-6 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£106,740 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,740 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Childwall £4,026 vs Broadgreen £2,840. | Caption: '30% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Childwall. And this is Broadgreen, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Childwall: £4,026. Broadgreen: £2,840. That's 30% cheaper, about £106,740 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Childwall vs Broadgreen
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £106,740 cheaper
|
||||||
|
- Same semi-detached, ~1940
|
||||||
|
- 30% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m²
|
||||||
|
2. Childwall vs Broadgreen: same station, same schools, £106,740 cheaper
|
||||||
|
3. Why Broadgreen is the smart-money version of Childwall (30% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£106,740 cheaper` + the two names `Childwall → Broadgreen`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Childwall and Broadgreen share a station, a school catchment and the same era of housing, but Broadgreen costs about 30% less per square metre (£106,740 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Childwall & Broadgreen)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Childwall £4,026 → Broadgreen £2,840) + caption '30% 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
|
||||||
|
{
|
||||||
|
"name": "twin-l16-7-vs-l14-6",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value semi-detacheds near Childwall: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
3000
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Broadgreen: same life, 30% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/m40-5-vs-m9-4.md
Normal file
79
analysis/out/video_scripts/m40-5-vs-m9-4.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Newton Heath vs Harpurhey
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/m40-5-vs-m9-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£106,740 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£106,740 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 | Note the dominant type (terraced houses) and build era (~1958). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Newton Heath £2,812 vs Harpurhey £1,626. | Caption: '42% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Newton Heath. And this is Harpurhey, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1958. On every measure that moves price, they're twins. But watch the price per square metre. Newton Heath: £2,812. Harpurhey: £1,626. That's 42% cheaper, about £106,740 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Newton Heath vs Harpurhey
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £106,740 cheaper
|
||||||
|
- Same terraced, ~1958
|
||||||
|
- 42% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m²
|
||||||
|
2. Newton Heath vs Harpurhey: same station, same schools, £106,740 cheaper
|
||||||
|
3. Why Harpurhey is the smart-money version of Newton Heath (42% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£106,740 cheaper` + the two names `Newton Heath → Harpurhey`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Newton Heath and Harpurhey share a station, a school catchment and the same era of housing, but Harpurhey costs about 42% less per square metre (£106,740 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Newton Heath & Harpurhey)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Newton Heath £2,812 → Harpurhey £1,626) + caption '42% 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
|
||||||
|
{
|
||||||
|
"name": "twin-m40-5-vs-m9-4",
|
||||||
|
"city": "manchester",
|
||||||
|
"promptText": "Best value terraceds near Newton Heath: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
1700
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Harpurhey: same life, 42% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/rm14-2-vs-rm12-5.md
Normal file
79
analysis/out/video_scripts/rm14-2-vs-rm12-5.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Upminster vs Hornchurch
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/rm14-2-vs-rm12-5 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£115,290 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£115,290 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Upminster £6,360 vs Hornchurch £5,079. | Caption: '20% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Upminster. And this is Hornchurch, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Upminster: £6,360. Hornchurch: £5,079. That's 20% cheaper, about £115,290 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Upminster vs Hornchurch
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £115,290 cheaper
|
||||||
|
- Same semi-detached, ~1940
|
||||||
|
- 20% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m²
|
||||||
|
2. Upminster vs Hornchurch: same station, same schools, £115,290 cheaper
|
||||||
|
3. Why Hornchurch is the smart-money version of Upminster (20% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£115,290 cheaper` + the two names `Upminster → Hornchurch`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Upminster and Hornchurch share a station, a school catchment and the same era of housing, but Hornchurch costs about 20% less per square metre (£115,290 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Upminster & Hornchurch)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Upminster £6,360 → Hornchurch £5,079) + caption '20% 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
|
||||||
|
{
|
||||||
|
"name": "twin-rm14-2-vs-rm12-5",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value semi-detacheds near Upminster: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
5300
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Hornchurch: same life, 20% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/se28-8-vs-da18-4.md
Normal file
79
analysis/out/video_scripts/se28-8-vs-da18-4.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: SE28 8 vs DA18 4
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/se28-8-vs-da18-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£129,690 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£129,690 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 | Note the dominant type (terraced houses) and build era (~1993). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: SE28 8 £4,850 vs DA18 4 £3,409. | Caption: '30% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is SE28 8. And this is DA18 4, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1993. On every measure that moves price, they're twins. But watch the price per square metre. SE28 8: £4,850. DA18 4: £3,409. That's 30% cheaper, about £129,690 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- SE28 8 vs DA18 4
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £129,690 cheaper
|
||||||
|
- Same terraced, ~1993
|
||||||
|
- 30% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m²
|
||||||
|
2. SE28 8 vs DA18 4: same station, same schools, £129,690 cheaper
|
||||||
|
3. Why DA18 4 is the smart-money version of SE28 8 (30% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£129,690 cheaper` + the two names `SE28 8 → DA18 4`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
SE28 8 and DA18 4 share a station, a school catchment and the same era of housing, but DA18 4 costs about 30% less per square metre (£129,690 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (SE28 8 & DA18 4)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (SE28 8 £4,850 → DA18 4 £3,409) + caption '30% 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
|
||||||
|
{
|
||||||
|
"name": "twin-se28-8-vs-da18-4",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value terraceds near SE28 8: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
3600
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "DA18 4: same life, 30% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/sw1x-8-vs-sw7-2.md
Normal file
79
analysis/out/video_scripts/sw1x-8-vs-sw7-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: SW1X 8 vs SW7 2
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/sw1x-8-vs-sw7-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£1,001,160 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£1,001,160 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 | Note the dominant type (flats) and build era (~1890). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: SW1X 8 £26,735 vs SW7 2 £15,611. | Caption: '42% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is SW1X 8. And this is SW7 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1890. On every measure that moves price, they're twins. But watch the price per square metre. SW1X 8: £26,735. SW7 2: £15,611. That's 42% cheaper, about £1,001,160 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- SW1X 8 vs SW7 2
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £1,001,160 cheaper
|
||||||
|
- Same flats/maisonettes, ~1890
|
||||||
|
- 42% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m²
|
||||||
|
2. SW1X 8 vs SW7 2: same station, same schools, £1,001,160 cheaper
|
||||||
|
3. Why SW7 2 is the smart-money version of SW1X 8 (42% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£1,001,160 cheaper` + the two names `SW1X 8 → SW7 2`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
SW1X 8 and SW7 2 share a station, a school catchment and the same era of housing, but SW7 2 costs about 42% less per square metre (£1,001,160 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (SW1X 8 & SW7 2)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (SW1X 8 £26,735 → SW7 2 £15,611) + caption '42% 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
|
||||||
|
{
|
||||||
|
"name": "twin-sw1x-8-vs-sw7-2",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value flats/maisonettess near SW1X 8: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
16400
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "SW7 2: same life, 42% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/tw12-3-vs-kt8-1.md
Normal file
79
analysis/out/video_scripts/tw12-3-vs-kt8-1.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Hampton vs East Molesey
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/tw12-3-vs-kt8-1 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£120,060 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£120,060 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 | Note the dominant type (terraced houses) and build era (~1979). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Hampton £7,042 vs East Molesey £5,708. | Caption: '19% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Hampton. And this is East Molesey, right next door. Same station. Same secondary school catchment. The same kind of home: terraced houses built around 1979. On every measure that moves price, they're twins. But watch the price per square metre. Hampton: £7,042. East Molesey: £5,708. That's 19% cheaper, about £120,060 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Hampton vs East Molesey
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £120,060 cheaper
|
||||||
|
- Same terraced, ~1979
|
||||||
|
- 19% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Hampton vs East Molesey: the same terraced house, about 19% cheaper per m²
|
||||||
|
2. Hampton vs East Molesey: same station, same schools, £120,060 cheaper
|
||||||
|
3. Why East Molesey is the smart-money version of Hampton (19% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£120,060 cheaper` + the two names `Hampton → East Molesey`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Hampton and East Molesey share a station, a school catchment and the same era of housing, but East Molesey costs about 19% less per square metre (£120,060 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Hampton & East Molesey)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Hampton £7,042 → East Molesey £5,708) + caption '19% 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
|
||||||
|
{
|
||||||
|
"name": "twin-tw12-3-vs-kt8-1",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value terraceds near Hampton: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
6000
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "East Molesey: same life, 19% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/tw2-7-vs-tw3-2.md
Normal file
79
analysis/out/video_scripts/tw2-7-vs-tw3-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Twickenham vs Hounslow
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/tw2-7-vs-tw3-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£121,590 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£121,590 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 | Note the dominant type (semi-detached houses) and build era (~1940). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Twickenham £6,971 vs Hounslow £5,620. | Caption: '19% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Twickenham. And this is Hounslow, right next door. Same station. Same secondary school catchment. The same kind of home: semi-detached houses built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Twickenham: £6,971. Hounslow: £5,620. That's 19% cheaper, about £121,590 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Twickenham vs Hounslow
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £121,590 cheaper
|
||||||
|
- Same semi-detached, ~1940
|
||||||
|
- 19% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m²
|
||||||
|
2. Twickenham vs Hounslow: same station, same schools, £121,590 cheaper
|
||||||
|
3. Why Hounslow is the smart-money version of Twickenham (19% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£121,590 cheaper` + the two names `Twickenham → Hounslow`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Twickenham and Hounslow share a station, a school catchment and the same era of housing, but Hounslow costs about 19% less per square metre (£121,590 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Twickenham & Hounslow)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Twickenham £6,971 → Hounslow £5,620) + caption '19% 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
|
||||||
|
{
|
||||||
|
"name": "twin-tw2-7-vs-tw3-2",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value semi-detacheds near Twickenham: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
5900
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Hounslow: same life, 19% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/w1j-7-vs-sw7-3.md
Normal file
79
analysis/out/video_scripts/w1j-7-vs-sw7-3.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Mayfair vs SW7 3
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1j-7-vs-sw7-3 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£1,223,460 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£1,223,460 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 | Note the dominant type (flats) and build era (~1914). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £32,986 vs SW7 3 £19,392. | Caption: '41% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Mayfair. And this is SW7 3, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1914. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £32,986. SW7 3: £19,392. That's 41% cheaper, about £1,223,460 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Mayfair vs SW7 3
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £1,223,460 cheaper
|
||||||
|
- Same flats/maisonettes, ~1914
|
||||||
|
- 41% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. W1J 7 vs SW7 3: the same flat, about 41% cheaper per m²
|
||||||
|
2. Mayfair vs SW7 3: same station, same schools, £1,223,460 cheaper
|
||||||
|
3. Why SW7 3 is the smart-money version of Mayfair (41% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£1,223,460 cheaper` + the two names `Mayfair → SW7 3`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Mayfair and SW7 3 share a station, a school catchment and the same era of housing, but SW7 3 costs about 41% less per square metre (£1,223,460 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Mayfair & SW7 3)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Mayfair £32,986 → SW7 3 £19,392) + caption '41% 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
|
||||||
|
{
|
||||||
|
"name": "twin-w1j-7-vs-sw7-3",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
20400
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "SW7 3: same life, 41% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/w1j-8-vs-sw1a-2.md
Normal file
79
analysis/out/video_scripts/w1j-8-vs-sw1a-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Mayfair vs SW1A 2
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1j-8-vs-sw1a-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£916,380 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£916,380 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 | Note the dominant type (flats) and build era (~2000). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £27,270 vs SW1A 2 £17,088. | Caption: '37% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Mayfair. And this is SW1A 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 2000. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £27,270. SW1A 2: £17,088. That's 37% cheaper, about £916,380 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Mayfair vs SW1A 2
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £916,380 cheaper
|
||||||
|
- Same flats/maisonettes, ~2000
|
||||||
|
- 37% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m²
|
||||||
|
2. Mayfair vs SW1A 2: same station, same schools, £916,380 cheaper
|
||||||
|
3. Why SW1A 2 is the smart-money version of Mayfair (37% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£916,380 cheaper` + the two names `Mayfair → SW1A 2`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Mayfair and SW1A 2 share a station, a school catchment and the same era of housing, but SW1A 2 costs about 37% less per square metre (£916,380 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Mayfair & SW1A 2)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Mayfair £27,270 → SW1A 2 £17,088) + caption '37% 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
|
||||||
|
{
|
||||||
|
"name": "twin-w1j-8-vs-sw1a-2",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
17900
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "SW1A 2: same life, 37% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/w1k-2-vs-sw1x-0.md
Normal file
79
analysis/out/video_scripts/w1k-2-vs-sw1x-0.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Mayfair vs SW1X 0
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1k-2-vs-sw1x-0 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£978,570 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£978,570 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 | Note the dominant type (flats) and build era (~1914). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Mayfair £34,362 vs SW1X 0 £23,489. | Caption: '32% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Mayfair. And this is SW1X 0, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1914. On every measure that moves price, they're twins. But watch the price per square metre. Mayfair: £34,362. SW1X 0: £23,489. That's 32% cheaper, about £978,570 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Mayfair vs SW1X 0
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £978,570 cheaper
|
||||||
|
- Same flats/maisonettes, ~1914
|
||||||
|
- 32% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m²
|
||||||
|
2. Mayfair vs SW1X 0: same station, same schools, £978,570 cheaper
|
||||||
|
3. Why SW1X 0 is the smart-money version of Mayfair (32% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£978,570 cheaper` + the two names `Mayfair → SW1X 0`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Mayfair and SW1X 0 share a station, a school catchment and the same era of housing, but SW1X 0 costs about 32% less per square metre (£978,570 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Mayfair & SW1X 0)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Mayfair £34,362 → SW1X 0 £23,489) + caption '32% 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
|
||||||
|
{
|
||||||
|
"name": "twin-w1k-2-vs-sw1x-0",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value flats/maisonettess near Mayfair: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
24700
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "SW1X 0: same life, 32% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/w1u-4-vs-nw1-4.md
Normal file
79
analysis/out/video_scripts/w1u-4-vs-nw1-4.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: Marylebone vs Camden
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/w1u-4-vs-nw1-4 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£942,480 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£942,480 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 | Note the dominant type (flats) and build era (~1940). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: Marylebone £24,238 vs Camden £13,766. | Caption: '43% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is Marylebone. And this is Camden, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 1940. On every measure that moves price, they're twins. But watch the price per square metre. Marylebone: £24,238. Camden: £13,766. That's 43% cheaper, about £942,480 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- Marylebone vs Camden
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £942,480 cheaper
|
||||||
|
- Same flats/maisonettes, ~1940
|
||||||
|
- 43% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. Marylebone vs Camden: the same flat, about 43% cheaper per m²
|
||||||
|
2. Marylebone vs Camden: same station, same schools, £942,480 cheaper
|
||||||
|
3. Why Camden is the smart-money version of Marylebone (43% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£942,480 cheaper` + the two names `Marylebone → Camden`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
Marylebone and Camden share a station, a school catchment and the same era of housing, but Camden costs about 43% less per square metre (£942,480 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (Marylebone & Camden)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (Marylebone £24,238 → Camden £13,766) + caption '43% 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
|
||||||
|
{
|
||||||
|
"name": "twin-w1u-4-vs-nw1-4",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value flats/maisonettess near Marylebone: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
14500
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "Camden: same life, 43% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
79
analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md
Normal file
79
analysis/out/video_scripts/wc2a-2-vs-ec2a-2.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# Video kit: WC2A 2 vs EC2A 2
|
||||||
|
|
||||||
|
**Page:** https://perfect-postcode.co.uk/cheaper-twin/wc2a-2-vs-ec2a-2 · **Format:** faceless screen-record, ~45–60s long + a 9:16 Short cut
|
||||||
|
|
||||||
|
## 🎬 Map URL to record (open this, hit record)
|
||||||
|
`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`
|
||||||
|
*(filters are pre-applied so the value is on screen immediately)*
|
||||||
|
|
||||||
|
## Hook (first 2 seconds, on screen + said)
|
||||||
|
**"£981,540 cheaper. Same station. Same schools."**
|
||||||
|
|
||||||
|
## Shot list
|
||||||
|
| Time | Beat | What to show | On-screen |
|
||||||
|
|------|------|--------------|-----------|
|
||||||
|
| 0:00–0:06 | COLD OPEN: payoff first | Open on the map already showing both areas with the £/m² gap visible. Caption: '£981,540 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 | Note the dominant type (flats) and build era (~2019). | Caption: 'Same homes.' |
|
||||||
|
| 0:38–0:52 | THE REVEAL | Show the £/m² side by side: WC2A 2 £25,482 vs EC2A 2 £14,576. | Caption: '43% less per m²'. |
|
||||||
|
| 0:52–1:00 | CTA | End on the map; invite them to find their own cheaper twin. | Caption: 'Free. No signup.' |
|
||||||
|
|
||||||
|
## Narration (human voiceover, never raw TTS for a property audience)
|
||||||
|
> This is WC2A 2. And this is EC2A 2, right next door. Same station. Same secondary school catchment. The same kind of home: flats built around 2019. On every measure that moves price, they're twins. But watch the price per square metre. WC2A 2: £25,482. EC2A 2: £14,576. That's 43% cheaper, about £981,540 on a typical 90-square-metre home, for the same life, one postcode over. You're not paying for the house. You're paying for the name. You can find the cheaper twin of any postcode in England on the map for free, no signup.
|
||||||
|
|
||||||
|
## Captions (≤6 words, sound-off)
|
||||||
|
- WC2A 2 vs EC2A 2
|
||||||
|
- Same station. Same schools.
|
||||||
|
- £981,540 cheaper
|
||||||
|
- Same flats/maisonettes, ~2019
|
||||||
|
- 43% less per m²
|
||||||
|
- Find your cheaper twin, free
|
||||||
|
|
||||||
|
## YouTube
|
||||||
|
**Title options:**
|
||||||
|
1. WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m²
|
||||||
|
2. WC2A 2 vs EC2A 2: same station, same schools, £981,540 cheaper
|
||||||
|
3. Why EC2A 2 is the smart-money version of WC2A 2 (43% less per m²)
|
||||||
|
|
||||||
|
**Thumbnail text:** big number `£981,540 cheaper` + the two names `WC2A 2 → EC2A 2`
|
||||||
|
|
||||||
|
**Description (paste as-is):**
|
||||||
|
```
|
||||||
|
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
|
||||||
|
|
||||||
|
WC2A 2 and EC2A 2 share a station, a school catchment and the same era of housing, but EC2A 2 costs about 43% less per square metre (£981,540 on a 90 m² home). I built a map that ranks every postcode in England by what each pound actually buys, from official open data (Land Registry, EPC, Ofsted, DfT, Police.uk). Find the cheaper twin of any area, free and with no signup, at https://perfect-postcode.co.uk.
|
||||||
|
|
||||||
|
0:00 The two postcodes (WC2A 2 & EC2A 2)
|
||||||
|
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)
|
||||||
|
|
||||||
|
Data: Contains HM Land Registry data © Crown copyright and database right, OGL v3.0. Figures are estimates aggregated to postcode sector, not valuations.
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9:16 Short (cut from the same recording)
|
||||||
|
First 3 seconds: the £/m² reveal (WC2A 2 £25,482 → EC2A 2 £14,576) + caption '43% 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
|
||||||
|
{
|
||||||
|
"name": "twin-wc2a-2-vs-ec2a-2",
|
||||||
|
"city": "london",
|
||||||
|
"promptText": "Best value flats/maisonettess near WC2A 2: same schools and station, lower price",
|
||||||
|
"initialFilters": {
|
||||||
|
"Est. price per sqm": [
|
||||||
|
0,
|
||||||
|
15300
|
||||||
|
],
|
||||||
|
"Good+ secondary school catchments": [
|
||||||
|
1,
|
||||||
|
11
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"outroLine": "EC2A 2: same life, 43% cheaper."
|
||||||
|
}
|
||||||
|
```
|
||||||
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())
|
||||||
|
|
@ -30,7 +30,7 @@ services:
|
||||||
- cargo-home:/usr/local/cargo
|
- cargo-home:/usr/local/cargo
|
||||||
- cargo-target:/app/server-rs/target
|
- cargo-target:/app/server-rs/target
|
||||||
environment:
|
environment:
|
||||||
# Fallback only — the binary uses jemalloc as its global allocator
|
# Fallback only: the binary uses jemalloc as its global allocator
|
||||||
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
# (tuned via a baked-in malloc_conf). Caps glibc to 2 arenas.
|
||||||
MALLOC_ARENA_MAX: "2"
|
MALLOC_ARENA_MAX: "2"
|
||||||
# Dev only: spill the large property arrays (feature matrix +
|
# Dev only: spill the large property arrays (feature matrix +
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
# Finder — property listing scraper
|
# Finder: property listing scraper
|
||||||
|
|
||||||
Scrapes Greater-London sale listings from **Rightmove**, **OnTheMarket**, and
|
Scrapes Greater-London sale listings from **Rightmove**, **OnTheMarket**, and
|
||||||
**Zoopla**, recovers each property's true full postcode, and writes a single
|
**Zoopla**, recovers each property's true full postcode, and writes a single
|
||||||
parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes
|
parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes
|
||||||
(after a separate enrich step — see [Output](#output)).
|
(after a separate enrich step, see [Output](#output)).
|
||||||
|
|
||||||
`main.py` is the only entry point; everything else is library code.
|
`main.py` is the only entry point; everything else is library code.
|
||||||
|
|
||||||
|
|
@ -12,7 +12,7 @@ parquet (`data/online_listings_buy.parquet`) that the rest of the app consumes
|
||||||
## How it works (and why it's careful about postcodes)
|
## How it works (and why it's careful about postcodes)
|
||||||
|
|
||||||
Every portal's **search** API exposes only an *outcode*-level address (e.g.
|
Every portal's **search** API exposes only an *outcode*-level address (e.g.
|
||||||
`"…, London, SW9"`) plus map coordinates — never the full unit postcode. The
|
`"…, London, SW9"`) plus map coordinates, never the full unit postcode. The
|
||||||
full postcode lives on each listing's **detail page**, so the scraper fetches
|
full postcode lives on each listing's **detail page**, so the scraper fetches
|
||||||
detail pages to recover it, and only trusts a detail postcode when its outcode
|
detail pages to recover it, and only trusts a detail postcode when its outcode
|
||||||
agrees with the coordinate-nearest postcode (so a stale/wrong value can never
|
agrees with the coordinate-nearest postcode (so a stale/wrong value can never
|
||||||
|
|
@ -22,7 +22,7 @@ falls back to the coordinate-nearest postcode. See the module docstrings in
|
||||||
|
|
||||||
Detail fetching is the dominant cost, so it is:
|
Detail fetching is the dominant cost, so it is:
|
||||||
|
|
||||||
- **cached across runs** — `data/detail_cache/{source}.json` maps listing id →
|
- **cached across runs**: `data/detail_cache/{source}.json` maps listing id →
|
||||||
recovered postcode; a re-run only fetches *newly-appeared* listings;
|
recovered postcode; a re-run only fetches *newly-appeared* listings;
|
||||||
- **fetched concurrently** for the HTTP portals (Rightmove, OnTheMarket), bounded
|
- **fetched concurrently** for the HTTP portals (Rightmove, OnTheMarket), bounded
|
||||||
by a shared global rate limiter so the VPN egress stays polite;
|
by a shared global rate limiter so the VPN egress stays polite;
|
||||||
|
|
@ -51,7 +51,7 @@ Also required: the ARCGIS postcode parquet at `../property-data/arcgis_data.parq
|
||||||
|
|
||||||
## Running
|
## Running
|
||||||
|
|
||||||
### Docker Compose (recommended — the only way that does Zoopla)
|
### Docker Compose (recommended, the only way that does Zoopla)
|
||||||
|
|
||||||
`finder/docker-compose.yml` brings up the scraper plus **FlareSolverr** (which
|
`finder/docker-compose.yml` brings up the scraper plus **FlareSolverr** (which
|
||||||
solves Zoopla's Cloudflare challenge), both sharing `media_gluetun`'s netns. This
|
solves Zoopla's Cloudflare challenge), both sharing `media_gluetun`'s netns. This
|
||||||
|
|
@ -106,18 +106,18 @@ GLUETUN_PROXY="" .venv/bin/python main.py --source onthemarket --outcodes SW9 \
|
||||||
| Flag | Default | Meaning |
|
| Flag | Default | Meaning |
|
||||||
|------|---------|---------|
|
|------|---------|---------|
|
||||||
| `--source rightmove,onthemarket` | `all` | Comma-separated portal(s): any of `rightmove`, `onthemarket`, `zoopla`, or `all`. |
|
| `--source rightmove,onthemarket` | `all` | Comma-separated portal(s): any of `rightmove`, `onthemarket`, `zoopla`, or `all`. |
|
||||||
| `--outcodes SW9,E14,BR1` | — | Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS. |
|
| `--outcodes SW9,E14,BR1` | none | Specific outcodes (must be Greater-London-ish). Otherwise the full London set is loaded from ARCGIS. |
|
||||||
| `--limit-outcodes N` | — | Cap the number of outcodes (quick smoke). |
|
| `--limit-outcodes N` | none | Cap the number of outcodes (quick smoke). |
|
||||||
| `--max-properties-per-source N` | — | Stop each source after N transformed listings. |
|
| `--max-properties-per-source N` | none | Stop each source after N transformed listings. |
|
||||||
| `--output-dir DIR` | `data/` | Where the parquet (and `detail_cache/`) are written. |
|
| `--output-dir DIR` | `data/` | Where the parquet (and `detail_cache/`) are written. |
|
||||||
| `--test` | off | ~10 likely-London outcodes, ≤100 listings/source, writes to `data/test/`. |
|
| `--test` | off | ~10 likely-London outcodes, ≤100 listings/source, writes to `data/test/`. |
|
||||||
|
|
||||||
> **Always pass `--output-dir /tmp/...` for testing** — the default `data/` holds
|
> **Always pass `--output-dir /tmp/...` for testing**: the default `data/` holds
|
||||||
> the real listings the app consumes.
|
> the real listings the app consumes.
|
||||||
|
|
||||||
### Stopping a run
|
### Stopping a run
|
||||||
|
|
||||||
`Ctrl+C` (SIGINT) — or `docker stop` (SIGTERM) — triggers a **graceful
|
`Ctrl+C` (SIGINT), or `docker stop` (SIGTERM), triggers a **graceful
|
||||||
shutdown**: every source stops at its next outcode boundary, in-flight delays
|
shutdown**: every source stops at its next outcode boundary, in-flight delays
|
||||||
and retry backoffs wake immediately, and the run still persists the detail
|
and retry backoffs wake immediately, and the run still persists the detail
|
||||||
caches and writes the listings collected so far before exiting (code `130`).
|
caches and writes the listings collected so far before exiting (code `130`).
|
||||||
|
|
@ -147,7 +147,7 @@ A **separate enrich step** (outside `finder/`) turns that into
|
||||||
`online_listings_buy_enriched.parquet`, which is what the Rust backend actually
|
`online_listings_buy_enriched.parquet`, which is what the Rust backend actually
|
||||||
loads (`--actual-listings-path …/online_listings_buy_enriched.parquet` in the
|
loads (`--actual-listings-path …/online_listings_buy_enriched.parquet` in the
|
||||||
top-level `docker-compose.yml`). That enrich/scheduling pipeline is **not**
|
top-level `docker-compose.yml`). That enrich/scheduling pipeline is **not**
|
||||||
documented here — only the raw scrape is.
|
documented here. Only the raw scrape is documented.
|
||||||
|
|
||||||
The top-level `docker-compose.yml` (Rust `server`, `frontend`, `pocketbase`,
|
The top-level `docker-compose.yml` (Rust `server`, `frontend`, `pocketbase`,
|
||||||
`screenshot`) is the **web app**; it is downstream of the scrape and is **not**
|
`screenshot`) is the **web app**; it is downstream of the scrape and is **not**
|
||||||
|
|
|
||||||
|
|
@ -26,14 +26,14 @@ RETRY_BASE_DELAY = 2.0
|
||||||
DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8"))
|
DETAIL_FETCH_CONCURRENCY = int(os.environ.get("DETAIL_FETCH_CONCURRENCY", "8"))
|
||||||
REQUESTS_PER_SECOND = float(os.environ.get("REQUESTS_PER_SECOND", "10"))
|
REQUESTS_PER_SECOND = float(os.environ.get("REQUESTS_PER_SECOND", "10"))
|
||||||
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
|
GRID_CELL_SIZE = 0.01 # degrees for postcode spatial index
|
||||||
MAX_BEDROOMS = 20 # sanity cap — values above this are almost certainly parsing errors
|
MAX_BEDROOMS = 20 # sanity cap: values above this are almost certainly parsing errors
|
||||||
|
|
||||||
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
TYPEAHEAD_URL = "https://los.rightmove.co.uk/typeahead"
|
||||||
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
SEARCH_URL = "https://www.rightmove.co.uk/api/property-search/listing/search"
|
||||||
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
RIGHTMOVE_BASE = "https://www.rightmove.co.uk"
|
||||||
# Detail page (plain HTTPS GET, no Cloudflare). Its window.__PAGE_MODEL embeds
|
# Detail page (plain HTTPS GET, no Cloudflare). Its window.__PAGE_MODEL embeds
|
||||||
# propertyData.address.{outcode,incode}, which together form the property's TRUE
|
# propertyData.address.{outcode,incode}, which together form the property's TRUE
|
||||||
# full postcode — the search API only exposes the outcode. {id} is the numeric
|
# full postcode. The search API only exposes the outcode. {id} is the numeric
|
||||||
# listing id from the search response.
|
# listing id from the search response.
|
||||||
RIGHTMOVE_DETAIL_URL = "https://www.rightmove.co.uk/properties/{id}"
|
RIGHTMOVE_DETAIL_URL = "https://www.rightmove.co.uk/properties/{id}"
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ RIGHTMOVE_MAX_DETAILS_PER_OUTCODE = 4000 # max detail-page fetches per outcode
|
||||||
# keep on APPROXIMATE pins (new-builds/developments) where Rightmove
|
# keep on APPROXIMATE pins (new-builds/developments) where Rightmove
|
||||||
# deliberately fuzzes the coordinates. Degrades safely: when `pinType` is absent
|
# deliberately fuzzes the coordinates. Degrades safely: when `pinType` is absent
|
||||||
# from the search payload, nothing is skipped (behaviour is unchanged), so this
|
# from the search payload, nothing is skipped (behaviour is unchanged), so this
|
||||||
# is only a speed-up to the extent the field is present — verify against a live
|
# is only a speed-up to the extent the field is present. Verify against a live
|
||||||
# search response before relying on the saving.
|
# search response before relying on the saving.
|
||||||
RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS = (
|
RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS = (
|
||||||
os.environ.get("RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS", "1") != "0"
|
os.environ.get("RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS", "1") != "0"
|
||||||
|
|
@ -94,7 +94,7 @@ GLUETUN_API_KEY = "My8AbvnKhfyFdRhpTVfoTfa5DkAMmg8K"
|
||||||
GLUETUN_MAX_ROTATIONS = 0 # max egress-IP rotations per Cloudflare challenge
|
GLUETUN_MAX_ROTATIONS = 0 # max egress-IP rotations per Cloudflare challenge
|
||||||
|
|
||||||
# Zoopla fetcher: "flaresolverr" (default) solves Cloudflare via the FlareSolverr
|
# Zoopla fetcher: "flaresolverr" (default) solves Cloudflare via the FlareSolverr
|
||||||
# sidecar (docker-compose.yml) and needs no display/VNC — verified to return the
|
# sidecar (docker-compose.yml) and needs no display/VNC, verified to return the
|
||||||
# RSC flight stream with postcode + coordinates; "camoufox" drives a local
|
# RSC flight stream with postcode + coordinates; "camoufox" drives a local
|
||||||
# anti-fingerprint browser (needs an interactive solve on datacenter IPs).
|
# anti-fingerprint browser (needs an interactive solve on datacenter IPs).
|
||||||
ZOOPLA_FETCHER = os.environ.get("ZOOPLA_FETCHER", "flaresolverr")
|
ZOOPLA_FETCHER = os.environ.get("ZOOPLA_FETCHER", "flaresolverr")
|
||||||
|
|
@ -214,3 +214,16 @@ PROPERTY_TYPE_MAP = {
|
||||||
CHANNELS = [
|
CHANNELS = [
|
||||||
{"channel": "BUY", "transactionType": "BUY", "sortType": "2"},
|
{"channel": "BUY", "transactionType": "BUY", "sortType": "2"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# A second search pass that restricts the BUY channel to new-build developments
|
||||||
|
# via Rightmove's `mustHave=newHome` filter, so new homes (which can rank low in
|
||||||
|
# the default resale sort) are captured thoroughly. The API still echoes
|
||||||
|
# `?channel=RES_BUY` in every listing URL regardless of this filter, so new
|
||||||
|
# builds are identified by the per-listing `development` flag in
|
||||||
|
# `transform_property`, which re-stamps the URL channel as RES_NEW.
|
||||||
|
NEW_HOMES_CHANNEL = {
|
||||||
|
"channel": "BUY",
|
||||||
|
"transactionType": "BUY",
|
||||||
|
"sortType": "2",
|
||||||
|
"extra_params": {"mustHave": "newHome"},
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""FlareSolverr client — fetch Cloudflare-protected pages as rendered HTML.
|
"""FlareSolverr client: fetch Cloudflare-protected pages as rendered HTML.
|
||||||
|
|
||||||
FlareSolverr (https://github.com/FlareSolverr/FlareSolverr) drives an
|
FlareSolverr (https://github.com/FlareSolverr/FlareSolverr) drives an
|
||||||
undetected browser to pass Cloudflare's challenge and returns the fully
|
undetected browser to pass Cloudflare's challenge and returns the fully
|
||||||
|
|
@ -6,7 +6,7 @@ rendered HTML. It runs as a sidecar service (see docker-compose.yml) sharing
|
||||||
the Gluetun VPN network namespace, so its browser egresses through the VPN.
|
the Gluetun VPN network namespace, so its browser egresses through the VPN.
|
||||||
|
|
||||||
Verified working against Zoopla's managed Turnstile on a datacenter VPN IP,
|
Verified working against Zoopla's managed Turnstile on a datacenter VPN IP,
|
||||||
provided a reused session and a generous maxTimeout (~120s) — the first
|
provided a reused session and a generous maxTimeout (~120s): the first
|
||||||
challenge solve is slow, subsequent requests on the warm session are fast.
|
challenge solve is slow, subsequent requests on the warm session are fast.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class RateLimiter:
|
||||||
Detail-page fetches run concurrently across many worker threads (and across
|
Detail-page fetches run concurrently across many worker threads (and across
|
||||||
providers), but a single shared limiter caps their COMBINED rate so the VPN
|
providers), but a single shared limiter caps their COMBINED rate so the VPN
|
||||||
egress IP stays polite. Each ``acquire()`` reserves the next free time slot
|
egress IP stays polite. Each ``acquire()`` reserves the next free time slot
|
||||||
under a lock, then sleeps (outside the lock) until that slot — so N threads
|
under a lock, then sleeps (outside the lock) until that slot, so N threads
|
||||||
calling concurrently are spaced ``1/rate_per_second`` apart rather than all
|
calling concurrently are spaced ``1/rate_per_second`` apart rather than all
|
||||||
firing at once. ``rate_per_second <= 0`` disables limiting."""
|
firing at once. ``rate_per_second <= 0`` disables limiting."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ def main() -> int:
|
||||||
configure_standalone_runtime()
|
configure_standalone_runtime()
|
||||||
configure_logging()
|
configure_logging()
|
||||||
# Ctrl+C (and SIGTERM, e.g. `docker stop`) asks the scrapers to wind down
|
# Ctrl+C (and SIGTERM, e.g. `docker stop`) asks the scrapers to wind down
|
||||||
# gracefully — each source stops at its next outcode boundary and the run
|
# gracefully. Each source stops at its next outcode boundary and the run
|
||||||
# still persists detail caches and writes the listings collected so far.
|
# still persists detail caches and writes the listings collected so far.
|
||||||
shutdown.install_signal_handlers()
|
shutdown.install_signal_handlers()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""OnTheMarket (onthemarket.com) scraper — sale properties.
|
"""OnTheMarket (onthemarket.com) scraper: sale properties.
|
||||||
|
|
||||||
OnTheMarket serves a Next.js app with the full search-results payload embedded
|
OnTheMarket serves a Next.js app with the full search-results payload embedded
|
||||||
as JSON in a `__NEXT_DATA__` script tag. No JS execution or browser needed:
|
as JSON in a `__NEXT_DATA__` script tag. No JS execution or browser needed:
|
||||||
|
|
@ -15,19 +15,19 @@ Postcodes
|
||||||
---------
|
---------
|
||||||
The search card exposes only an *outcode*-level address (e.g. "Padfield Road,
|
The search card exposes only an *outcode*-level address (e.g. "Padfield Road,
|
||||||
London, SE5") and a map pin, so the old behaviour derived the postcode from the
|
London, SE5") and a map pin, so the old behaviour derived the postcode from the
|
||||||
nearest postcode to that pin — a guess that frequently lands on a neighbouring
|
nearest postcode to that pin, a guess that frequently lands on a neighbouring
|
||||||
unit (the pin can sit on the wrong side of a street boundary).
|
unit (the pin can sit on the wrong side of a street boundary).
|
||||||
|
|
||||||
Each *detail* page (`/details/{id}/`) is a plain HTTPS GET whose `__NEXT_DATA__`
|
Each *detail* page (`/details/{id}/`) is a plain HTTPS GET whose `__NEXT_DATA__`
|
||||||
embeds the property's analytics dataLayer at
|
embeds the property's analytics dataLayer at
|
||||||
`props.initialReduxState.metadata.dataLayer`, which carries the property's own
|
`props.initialReduxState.metadata.dataLayer`, which carries the property's own
|
||||||
`postcode` (full unit postcode, e.g. "SE5 9AA") keyed to this listing by
|
`postcode` (full unit postcode, e.g. "SE5 9AA") keyed to this listing by
|
||||||
`property-id`. Crucially this is NOT the agent's office postcode — that lives
|
`property-id`. Crucially this is NOT the agent's office postcode. That lives
|
||||||
separately at `…property.agent.postcode` ("SE5 8RS" for the same listing) and
|
separately at `…property.agent.postcode` ("SE5 8RS" for the same listing) and
|
||||||
is the classic trap when blindly scanning the page for a postcode. We read the
|
is the classic trap when blindly scanning the page for a postcode. We read the
|
||||||
dataLayer postcode, verify `property-id` matches the listing, and accept it only
|
dataLayer postcode, verify `property-id` matches the listing, and accept it only
|
||||||
when its outcode agrees with the coordinate-nearest postcode (via
|
when its outcode agrees with the coordinate-nearest postcode (via
|
||||||
``resolve_listing_postcode``) — exactly the trust rule the other scrapers use.
|
``resolve_listing_postcode``), exactly the trust rule the other scrapers use.
|
||||||
Measured over a sample of real listings this yields a trustworthy, usually
|
Measured over a sample of real listings this yields a trustworthy, usually
|
||||||
exact-unit postcode for ~11/12 listings; the rest safely fall back to the
|
exact-unit postcode for ~11/12 listings; the rest safely fall back to the
|
||||||
coordinate-nearest postcode.
|
coordinate-nearest postcode.
|
||||||
|
|
@ -68,8 +68,8 @@ from transform import (
|
||||||
log = logging.getLogger("rightmove")
|
log = logging.getLogger("rightmove")
|
||||||
|
|
||||||
# Detail-page postcode recovery (see module docstring). When enabled, each
|
# Detail-page postcode recovery (see module docstring). When enabled, each
|
||||||
# listing's detail page is fetched so its analytics dataLayer postcode — the
|
# listing's detail page is fetched so its analytics dataLayer postcode (the
|
||||||
# property's own full unit postcode — can replace the coordinate-nearest guess.
|
# property's own full unit postcode) can replace the coordinate-nearest guess.
|
||||||
# Bounded per outcode so a large outcode can't balloon into unbounded extra
|
# Bounded per outcode so a large outcode can't balloon into unbounded extra
|
||||||
# HTTPS GETs. Kept at parity with the Rightmove/Zoopla detail caps (400) so a
|
# HTTPS GETs. Kept at parity with the Rightmove/Zoopla detail caps (400) so a
|
||||||
# typical outcode's listings all get their real postcode rather than a
|
# typical outcode's listings all get their real postcode rather than a
|
||||||
|
|
@ -145,7 +145,7 @@ def _fetch_page_json(client: httpx.Client, outcode: str, page_num: int) -> dict
|
||||||
|
|
||||||
if 300 <= resp.status_code < 400:
|
if 300 <= resp.status_code < 400:
|
||||||
log.debug(
|
log.debug(
|
||||||
"OnTheMarket %s page %d redirected (%d) — end of results",
|
"OnTheMarket %s page %d redirected (%d): end of results",
|
||||||
outcode, page_num, resp.status_code,
|
outcode, page_num, resp.status_code,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
@ -189,7 +189,7 @@ def parse_detail_postcode(html: str, listing_id: str | None = None) -> str | Non
|
||||||
``props.initialReduxState.metadata.dataLayer.postcode`` and is the
|
``props.initialReduxState.metadata.dataLayer.postcode`` and is the
|
||||||
property's own unit postcode (e.g. "SE5 9AA"). It is deliberately NOT the
|
property's own unit postcode (e.g. "SE5 9AA"). It is deliberately NOT the
|
||||||
agent's office postcode, which sits separately at
|
agent's office postcode, which sits separately at
|
||||||
``…property.agent.postcode`` — the trap when scanning a detail page for "a"
|
``…property.agent.postcode``, the trap when scanning a detail page for "a"
|
||||||
postcode. When ``listing_id`` is given, the dataLayer's ``property-id`` must
|
postcode. When ``listing_id`` is given, the dataLayer's ``property-id`` must
|
||||||
match it, guaranteeing we read this listing's postcode and not a stray one.
|
match it, guaranteeing we read this listing's postcode and not a stray one.
|
||||||
|
|
||||||
|
|
@ -235,7 +235,7 @@ def _fetch_detail_postcode(
|
||||||
|
|
||||||
Results (including failures) are cached by listing id so a listing that
|
Results (including failures) are cached by listing id so a listing that
|
||||||
reappears across overlapping outcode searches is fetched at most once. Plain
|
reappears across overlapping outcode searches is fetched at most once. Plain
|
||||||
HTTPS GET — OnTheMarket detail pages have no Cloudflare challenge. Network /
|
HTTPS GET: OnTheMarket detail pages have no Cloudflare challenge. Network /
|
||||||
parse errors degrade gracefully to None so the caller falls back to the
|
parse errors degrade gracefully to None so the caller falls back to the
|
||||||
coordinate-nearest postcode. Safe to call concurrently: distinct listing ids
|
coordinate-nearest postcode. Safe to call concurrently: distinct listing ids
|
||||||
write distinct cache keys, and the shared RATE_LIMITER spaces the GETs.
|
write distinct cache keys, and the shared RATE_LIMITER spaces the GETs.
|
||||||
|
|
@ -452,7 +452,7 @@ def _prime_detail_postcodes(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fill ``_detail_postcode_cache`` for the listings that need a detail page.
|
"""Fill ``_detail_postcode_cache`` for the listings that need a detail page.
|
||||||
|
|
||||||
Picks the fresh (uncached) listings — up to ``detail_cap`` per outcode — then
|
Picks the fresh (uncached) listings, up to ``detail_cap`` per outcode, then
|
||||||
fetches their detail pages CONCURRENTLY, bounded by
|
fetches their detail pages CONCURRENTLY, bounded by
|
||||||
``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined
|
``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined
|
||||||
request rate polite). Cached listings cost neither a slot nor a GET. The
|
request rate polite). Cached listings cost neither a slot nor a GET. The
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
Each portal recovers a listing's true postcode (Rightmove/OnTheMarket) or full
|
Each portal recovers a listing's true postcode (Rightmove/OnTheMarket) or full
|
||||||
geo dict (Zoopla) from its detail page. That value never changes for a given
|
geo dict (Zoopla) from its detail page. That value never changes for a given
|
||||||
listing id, yet the in-memory caches are discarded at the end of every run — so
|
listing id, yet the in-memory caches are discarded at the end of every run, so
|
||||||
each run re-fetches every listing's detail page from scratch. Persisting the
|
each run re-fetches every listing's detail page from scratch. Persisting the
|
||||||
cache to disk means a steady-state run only fetches NEWLY-appeared listings,
|
cache to disk means a steady-state run only fetches NEWLY-appeared listings,
|
||||||
typically a small fraction of the market, which is the single biggest saving
|
typically a small fraction of the market, which is the single biggest saving
|
||||||
|
|
@ -27,7 +27,7 @@ log = logging.getLogger("rightmove")
|
||||||
def load_cache(path: str | Path) -> dict:
|
def load_cache(path: str | Path) -> dict:
|
||||||
"""Load a persisted detail cache. Returns ``{}`` when absent or unreadable.
|
"""Load a persisted detail cache. Returns ``{}`` when absent or unreadable.
|
||||||
|
|
||||||
A corrupt or non-object file is treated as empty rather than fatal — a bad
|
A corrupt or non-object file is treated as empty rather than fatal: a bad
|
||||||
cache must never block a scrape; the worst case is re-fetching details."""
|
cache must never block a scrape; the worst case is re-fetching details."""
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
if not p.exists():
|
if not p.exists():
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ _MAX_INDEX = 1008
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
#
|
#
|
||||||
# The search API (_paginate) only returns an outcode-level `displayAddress`
|
# The search API (_paginate) only returns an outcode-level `displayAddress`
|
||||||
# (e.g. "Akerman Road, Brixton, London, SW9") — never the full postcode. Each
|
# (e.g. "Akerman Road, Brixton, London, SW9"), never the full postcode. Each
|
||||||
# listing's detail page, however, embeds the property's OWN full postcode in a
|
# listing's detail page, however, embeds the property's OWN full postcode in a
|
||||||
# `window.__PAGE_MODEL` script as `propertyData.address.{outcode, incode}`
|
# `window.__PAGE_MODEL` script as `propertyData.address.{outcode, incode}`
|
||||||
# (e.g. outcode "SW9" + incode "0HD" → "SW9 0HD"), independently corroborated by
|
# (e.g. outcode "SW9" + incode "0HD" → "SW9 0HD"), independently corroborated by
|
||||||
|
|
@ -51,8 +51,8 @@ _MAX_INDEX = 1008
|
||||||
# __PAGE_MODEL is a "devalue"-style flattened object graph: its `data` field is
|
# __PAGE_MODEL is a "devalue"-style flattened object graph: its `data` field is
|
||||||
# a JSON STRING holding a flat array where every integer inside a container is
|
# a JSON STRING holding a flat array where every integer inside a container is
|
||||||
# an index reference into that same array (so the graph can dedupe). We
|
# an index reference into that same array (so the graph can dedupe). We
|
||||||
# brace-match the (large, deeply-nested) object literal — a non-greedy regex
|
# brace-match the (large, deeply-nested) object literal (a non-greedy regex
|
||||||
# cannot — then rehydrate the reference graph before reading the address.
|
# cannot), then rehydrate the reference graph before reading the address.
|
||||||
|
|
||||||
_PAGE_MODEL_RE = re.compile(r"window\.__PAGE_MODEL\s*=\s*")
|
_PAGE_MODEL_RE = re.compile(r"window\.__PAGE_MODEL\s*=\s*")
|
||||||
|
|
||||||
|
|
@ -128,7 +128,7 @@ def parse_detail_postcode(html: str) -> str | None:
|
||||||
Pure and network-free so it is unit-testable: callers pass the page HTML.
|
Pure and network-free so it is unit-testable: callers pass the page HTML.
|
||||||
Reads ``propertyData.address.outcode`` + ``.incode`` from window.__PAGE_MODEL
|
Reads ``propertyData.address.outcode`` + ``.incode`` from window.__PAGE_MODEL
|
||||||
and returns a normalised full postcode (e.g. "SW9 0HD"), or None when the
|
and returns a normalised full postcode (e.g. "SW9 0HD"), or None when the
|
||||||
page has no parseable address (the property location wrapper can be empty —
|
page has no parseable address (the property location wrapper can be empty;
|
||||||
the caller then keeps the coordinate fallback). The returned outcode is
|
the caller then keeps the coordinate fallback). The returned outcode is
|
||||||
re-validated against the joined postcode so a malformed incode is dropped.
|
re-validated against the joined postcode so a malformed incode is dropped.
|
||||||
"""
|
"""
|
||||||
|
|
@ -193,7 +193,7 @@ def _fetch_detail_postcode(client: httpx.Client, property_id: str) -> str | None
|
||||||
"""GET a listing detail page and return its true full postcode (or None).
|
"""GET a listing detail page and return its true full postcode (or None).
|
||||||
|
|
||||||
Results (including failures) are cached by listing id. The detail page is a
|
Results (including failures) are cached by listing id. The detail page is a
|
||||||
plain HTML GET — no Cloudflare, unlike Zoopla — so a single httpx call
|
plain HTML GET (no Cloudflare, unlike Zoopla), so a single httpx call
|
||||||
suffices; any error degrades gracefully to the coordinate fallback. Safe to
|
suffices; any error degrades gracefully to the coordinate fallback. Safe to
|
||||||
call concurrently: distinct listing ids write distinct cache keys, and the
|
call concurrently: distinct listing ids write distinct cache keys, and the
|
||||||
shared RATE_LIMITER spaces the GETs."""
|
shared RATE_LIMITER spaces the GETs."""
|
||||||
|
|
@ -245,7 +245,7 @@ def _needs_detail_fetch(prop: dict) -> bool:
|
||||||
Skips listings the search already pins precisely: an "ACCURATE_POINT"
|
Skips listings the search already pins precisely: an "ACCURATE_POINT"
|
||||||
``pinType`` means rooftop-exact coordinates, so the coordinate-nearest
|
``pinType`` means rooftop-exact coordinates, so the coordinate-nearest
|
||||||
postcode is trustworthy and the detail page would only confirm it. Listings
|
postcode is trustworthy and the detail page would only confirm it. Listings
|
||||||
with an approximate pin — or no ``pinType`` field at all — still get fetched,
|
with an approximate pin (or no ``pinType`` field at all) still get fetched,
|
||||||
so this degrades safely to the previous behaviour when the search payload
|
so this degrades safely to the previous behaviour when the search payload
|
||||||
omits ``pinType``."""
|
omits ``pinType``."""
|
||||||
if not RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS:
|
if not RIGHTMOVE_SKIP_DETAILS_FOR_ACCURATE_PINS:
|
||||||
|
|
@ -262,8 +262,8 @@ def _prime_detail_postcodes(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Fill ``_detail_postcode_cache`` for the listings that need a detail page.
|
"""Fill ``_detail_postcode_cache`` for the listings that need a detail page.
|
||||||
|
|
||||||
Picks the fresh (uncached, not-skipped) listings — up to ``detail_cap`` per
|
Picks the fresh (uncached, not-skipped) listings, up to ``detail_cap`` per
|
||||||
outcode — then fetches their detail pages CONCURRENTLY, bounded by
|
outcode, then fetches their detail pages CONCURRENTLY, bounded by
|
||||||
``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined
|
``DETAIL_FETCH_CONCURRENCY`` (the shared RATE_LIMITER keeps the combined
|
||||||
request rate polite). Cached listings cost neither a slot nor a GET. The
|
request rate polite). Cached listings cost neither a slot nor a GET. The
|
||||||
worklist is deduplicated, so distinct ids write distinct cache keys and the
|
worklist is deduplicated, so distinct ids write distinct cache keys and the
|
||||||
|
|
@ -305,8 +305,8 @@ def _collect_search_props(
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Paginate the search API for one outcode+channel, collecting raw results.
|
"""Paginate the search API for one outcode+channel, collecting raw results.
|
||||||
|
|
||||||
Returns ``(raw_props, result_count)``. Pagination stays serial — each page
|
Returns ``(raw_props, result_count)``. Pagination stays serial (each page
|
||||||
reveals the next — but is cheap relative to detail fetching, and the
|
reveals the next) but is cheap relative to detail fetching, and the
|
||||||
RATE_LIMITER spaces the page GETs. Collection stops at ``max_properties`` raw
|
RATE_LIMITER spaces the page GETs. Collection stops at ``max_properties`` raw
|
||||||
listings, the end of results, or Rightmove's ``_MAX_INDEX`` page cap."""
|
listings, the end of results, or Rightmove's ``_MAX_INDEX`` page cap."""
|
||||||
raw_props: list[dict] = []
|
raw_props: list[dict] = []
|
||||||
|
|
@ -324,6 +324,8 @@ def _collect_search_props(
|
||||||
"channel": channel_cfg["channel"],
|
"channel": channel_cfg["channel"],
|
||||||
"transactionType": channel_cfg["transactionType"],
|
"transactionType": channel_cfg["transactionType"],
|
||||||
}
|
}
|
||||||
|
# Optional per-channel filters, e.g. `mustHave=newHome` for the new-homes pass.
|
||||||
|
params.update(channel_cfg.get("extra_params", {}))
|
||||||
data = fetch_with_retry(client, SEARCH_URL, params)
|
data = fetch_with_retry(client, SEARCH_URL, params)
|
||||||
if not data:
|
if not data:
|
||||||
log.warning(
|
log.warning(
|
||||||
|
|
@ -372,7 +374,7 @@ def _paginate(
|
||||||
) -> tuple[list[dict], int]:
|
) -> tuple[list[dict], int]:
|
||||||
"""Collect search results, recover true postcodes, and transform them.
|
"""Collect search results, recover true postcodes, and transform them.
|
||||||
|
|
||||||
Search pages are paginated serially; then — when ``fetch_details`` is set —
|
Search pages are paginated serially; then, when ``fetch_details`` is set,
|
||||||
up to ``detail_cap`` listings per outcode have their detail page fetched
|
up to ``detail_cap`` listings per outcode have their detail page fetched
|
||||||
CONCURRENTLY for the property's TRUE full postcode (see
|
CONCURRENTLY for the property's TRUE full postcode (see
|
||||||
``parse_detail_postcode``), with listings the search already pins precisely
|
``parse_detail_postcode``), with listings the search already pins precisely
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import polars as pl
|
||||||
from constants import (
|
from constants import (
|
||||||
ARCGIS_PATH,
|
ARCGIS_PATH,
|
||||||
CHANNELS,
|
CHANNELS,
|
||||||
|
NEW_HOMES_CHANNEL,
|
||||||
DATA_DIR,
|
DATA_DIR,
|
||||||
DELAY_BETWEEN_OUTCODES,
|
DELAY_BETWEEN_OUTCODES,
|
||||||
LONDON_OUTCODE_PREFIXES,
|
LONDON_OUTCODE_PREFIXES,
|
||||||
|
|
@ -368,6 +369,34 @@ def _scrape_rightmove(
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_record_error(errors, "rightmove", outcode, exc)
|
_record_error(errors, "rightmove", outcode, exc)
|
||||||
|
|
||||||
|
# Second pass: new-build developments (mustHave=newHome). These can
|
||||||
|
# rank low in the default resale sort, so a dedicated pass ensures
|
||||||
|
# they are captured; transform_property stamps their URL as RES_NEW.
|
||||||
|
# Overlap with the resale pass is removed by id in _merge_properties.
|
||||||
|
# Skipped if a stop was requested mid-outcode (don't start a new pass).
|
||||||
|
remaining = _source_remaining(
|
||||||
|
results, "rightmove", max_properties_per_source
|
||||||
|
)
|
||||||
|
if not shutdown.stop_requested() and remaining != 0:
|
||||||
|
try:
|
||||||
|
new_props = rightmove_search_outcode(
|
||||||
|
client,
|
||||||
|
outcode_id,
|
||||||
|
outcode,
|
||||||
|
NEW_HOMES_CHANNEL,
|
||||||
|
pc_index,
|
||||||
|
max_properties=remaining,
|
||||||
|
)
|
||||||
|
added_new = _store_properties(
|
||||||
|
results,
|
||||||
|
"rightmove",
|
||||||
|
new_props,
|
||||||
|
max_properties_per_source,
|
||||||
|
)
|
||||||
|
log.info("Rightmove %s new-homes: +%d", outcode, added_new)
|
||||||
|
except Exception as exc:
|
||||||
|
_record_error(errors, "rightmove", outcode, exc)
|
||||||
|
|
||||||
shutdown.sleep(DELAY_BETWEEN_OUTCODES)
|
shutdown.sleep(DELAY_BETWEEN_OUTCODES)
|
||||||
finally:
|
finally:
|
||||||
client.close()
|
client.close()
|
||||||
|
|
@ -378,7 +407,7 @@ class OutcodeTimeout(BaseException):
|
||||||
|
|
||||||
Inherits BaseException (not Exception) so the SIGALRM-triggered raise can't
|
Inherits BaseException (not Exception) so the SIGALRM-triggered raise can't
|
||||||
be silently swallowed by any of the broad `except Exception:` handlers
|
be silently swallowed by any of the broad `except Exception:` handlers
|
||||||
inside zoopla.py — the signal may fire at any bytecode boundary, including
|
inside zoopla.py: the signal may fire at any bytecode boundary, including
|
||||||
inside those handlers."""
|
inside those handlers."""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -431,7 +460,7 @@ def _wall_clock_timeout(seconds: int, label: str):
|
||||||
|
|
||||||
Interrupts a hung Playwright IPC by delivering SIGALRM to the main thread;
|
Interrupts a hung Playwright IPC by delivering SIGALRM to the main thread;
|
||||||
socket waits return EINTR and the handler raises into the caller. The
|
socket waits return EINTR and the handler raises into the caller. The
|
||||||
browser is presumed unhealthy afterwards — caller must relaunch it."""
|
browser is presumed unhealthy afterwards. Caller must relaunch it."""
|
||||||
if seconds <= 0:
|
if seconds <= 0:
|
||||||
yield
|
yield
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ A single :class:`threading.Event` is set the first time the process receives
|
||||||
SIGINT (Ctrl+C) or SIGTERM. Every scrape loop polls :func:`stop_requested` at
|
SIGINT (Ctrl+C) or SIGTERM. Every scrape loop polls :func:`stop_requested` at
|
||||||
its outcode/page boundaries and every blocking delay goes through :func:`sleep`,
|
its outcode/page boundaries and every blocking delay goes through :func:`sleep`,
|
||||||
which wakes the instant a stop is requested. So Ctrl+C makes each source stop
|
which wakes the instant a stop is requested. So Ctrl+C makes each source stop
|
||||||
*starting* new work and unwind through its normal ``finally`` blocks — detail
|
*starting* new work and unwind through its normal ``finally`` blocks (detail
|
||||||
caches are persisted and whatever has been collected so far is still written —
|
caches are persisted and whatever has been collected so far is still written)
|
||||||
instead of hanging until the worker threads happen to finish (the orchestrator's
|
instead of hanging until the worker threads happen to finish (the orchestrator's
|
||||||
``ThreadPoolExecutor`` used to block the exit waiting on them) or losing the run
|
``ThreadPoolExecutor`` used to block the exit waiting on them) or losing the run
|
||||||
outright.
|
outright.
|
||||||
|
|
@ -34,7 +34,7 @@ def request_stop() -> None:
|
||||||
|
|
||||||
|
|
||||||
def reset() -> None:
|
def reset() -> None:
|
||||||
"""Clear the flag — for tests and repeated in-process runs."""
|
"""Clear the flag (for tests and repeated in-process runs)."""
|
||||||
_STOP.clear()
|
_STOP.clear()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
||||||
log.warning("No properties to write to %s", path)
|
log.warning("No properties to write to %s", path)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Sanitize bedroom/bathroom counts — values above MAX_BEDROOMS are
|
# Sanitize bedroom/bathroom counts: values above MAX_BEDROOMS are
|
||||||
# almost certainly prices or other numeric fields mis-parsed as bedrooms.
|
# almost certainly prices or other numeric fields mis-parsed as bedrooms.
|
||||||
bad_count = 0
|
bad_count = 0
|
||||||
for p in properties:
|
for p in properties:
|
||||||
|
|
@ -91,7 +91,7 @@ def write_parquet(properties: list[dict], path: Path) -> None:
|
||||||
else:
|
else:
|
||||||
listing_dates.append(None)
|
listing_dates.append(None)
|
||||||
|
|
||||||
# Zero prices indicate parsing failures or POA/auction listings — treat as null
|
# Zero prices indicate parsing failures or POA/auction listings: treat as null
|
||||||
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
asking_prices = [p["price"] if p["price"] > 0 else None for p in properties]
|
||||||
listing_statuses = ["For sale"] * len(properties)
|
listing_statuses = ["For sale"] * len(properties)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ def test_zero_rate_disables_limiting(monkeypatch):
|
||||||
def test_concurrent_acquires_are_all_spaced(monkeypatch):
|
def test_concurrent_acquires_are_all_spaced(monkeypatch):
|
||||||
# Real clock, tiny rate: N threads hitting acquire() at once must be
|
# Real clock, tiny rate: N threads hitting acquire() at once must be
|
||||||
# serialised so the total wall time is at least (N-1) * interval.
|
# serialised so the total wall time is at least (N-1) * interval.
|
||||||
rl = RateLimiter(200) # 5ms interval — fast but measurable
|
rl = RateLimiter(200) # 5ms interval, fast but measurable
|
||||||
barrier = threading.Barrier(8)
|
barrier = threading.Barrier(8)
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import main
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# selected_sources — comma-separated --source values
|
# selected_sources: comma-separated --source values
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ or None), so these tests use a trimmed but faithful copy of a real OnTheMarket
|
||||||
detail page's `__NEXT_DATA__` payload. The fixture mirrors the live structure:
|
detail page's `__NEXT_DATA__` payload. The fixture mirrors the live structure:
|
||||||
the property's own postcode lives in the analytics dataLayer
|
the property's own postcode lives in the analytics dataLayer
|
||||||
(`props.initialReduxState.metadata.dataLayer.postcode`) while the agent's office
|
(`props.initialReduxState.metadata.dataLayer.postcode`) while the agent's office
|
||||||
postcode sits separately under `…property.agent.postcode` — the trap we must not
|
postcode sits separately under `…property.agent.postcode`, the trap we must not
|
||||||
fall into.
|
fall into.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -49,7 +49,7 @@ def _detail_html(
|
||||||
"property": {
|
"property": {
|
||||||
"displayAddress": "Padfield Road, London, SE5",
|
"displayAddress": "Padfield Road, London, SE5",
|
||||||
"location": {"lon": -0.100233, "lat": 51.466129},
|
"location": {"lon": -0.100233, "lat": 51.466129},
|
||||||
# The agent block carries the AGENT'S office postcode — the
|
# The agent block carries the AGENT'S office postcode, the
|
||||||
# trap. parse_detail_postcode must not return this.
|
# trap. parse_detail_postcode must not return this.
|
||||||
"agent": {
|
"agent": {
|
||||||
"address": "29 Denmark Hill, Camberwell\nLondon\nSE5 8RS",
|
"address": "29 Denmark Hill, Camberwell\nLondon\nSE5 8RS",
|
||||||
|
|
@ -125,7 +125,7 @@ def test_parse_handles_missing_datalayer():
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# transform_property — detail postcode wiring + trust rule
|
# transform_property: detail postcode wiring + trust rule
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -176,7 +176,7 @@ def test_transform_without_detail_postcode_uses_coordinates():
|
||||||
def test_transform_detail_postcode_via_search_address_outcode():
|
def test_transform_detail_postcode_via_search_address_outcode():
|
||||||
# When the card address already carries a full postcode that agrees with the
|
# When the card address already carries a full postcode that agrees with the
|
||||||
# coordinates, the existing "address" source still wins absent a detail
|
# coordinates, the existing "address" source still wins absent a detail
|
||||||
# postcode — detail recovery never regresses that path.
|
# postcode. Detail recovery never regresses that path.
|
||||||
raw = dict(_RAW_LISTING, address="Padfield Road, London, SE5 1AA")
|
raw = dict(_RAW_LISTING, address="Padfield Road, London, SE5 1AA")
|
||||||
index = _StubIndex("SE5 1AA")
|
index = _StubIndex("SE5 1AA")
|
||||||
out = transform_property(raw, index, detail_postcode=None)
|
out = transform_property(raw, index, detail_postcode=None)
|
||||||
|
|
|
||||||
33
finder/test_rightmove_channels.py
Normal file
33
finder/test_rightmove_channels.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Tests for the new-homes search pass (mustHave=newHome) channel wiring."""
|
||||||
|
|
||||||
|
import rightmove
|
||||||
|
from constants import CHANNELS, NEW_HOMES_CHANNEL
|
||||||
|
from rightmove import _collect_search_props
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_params(monkeypatch):
|
||||||
|
captured: list[dict] = []
|
||||||
|
|
||||||
|
def fake_fetch(client, url, params):
|
||||||
|
captured.append(dict(params))
|
||||||
|
return {"properties": [], "resultCount": "0"}
|
||||||
|
|
||||||
|
monkeypatch.setattr(rightmove, "fetch_with_retry", fake_fetch)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_homes_channel_sends_must_have_new_home(monkeypatch):
|
||||||
|
captured = _capture_params(monkeypatch)
|
||||||
|
_collect_search_props(None, "749", "E14", NEW_HOMES_CHANNEL)
|
||||||
|
assert captured, "no search request was issued"
|
||||||
|
assert captured[0].get("mustHave") == "newHome"
|
||||||
|
# New homes are still requested on the BUY channel; only the filter differs.
|
||||||
|
assert captured[0]["channel"] == "BUY"
|
||||||
|
assert captured[0]["transactionType"] == "BUY"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resale_channel_sends_no_extra_filters(monkeypatch):
|
||||||
|
captured = _capture_params(monkeypatch)
|
||||||
|
_collect_search_props(None, "749", "E14", CHANNELS[0])
|
||||||
|
assert captured, "no search request was issued"
|
||||||
|
assert "mustHave" not in captured[0]
|
||||||
|
|
@ -14,7 +14,7 @@ def _prop(pid, pin=None):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _needs_detail_fetch — accurate-pin skip
|
# _needs_detail_fetch: accurate-pin skip
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ def test_needs_detail_fetch_disabled_always_fetches(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _prime_detail_postcodes — worklist selection + concurrent fetch
|
# _prime_detail_postcodes: worklist selection + concurrent fetch
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,7 +97,7 @@ def test_prime_is_a_noop_when_disabled_or_cap_zero(monkeypatch):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _paginate — end-to-end (network stubbed): accurate pins fall back to
|
# _paginate end-to-end (network stubbed): accurate pins fall back to
|
||||||
# coordinates, approximate pins use the detail postcode.
|
# coordinates, approximate pins use the detail postcode.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import zoopla
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# _run_sources — Zoopla inline, others in threads, failures isolated
|
# _run_sources: Zoopla inline, others in threads, failures isolated
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -85,13 +85,13 @@ def test_seed_and_save_detail_caches_round_trip(tmp_path):
|
||||||
|
|
||||||
def test_seed_detail_caches_tolerates_missing_files(tmp_path):
|
def test_seed_detail_caches_tolerates_missing_files(tmp_path):
|
||||||
rightmove._detail_postcode_cache.clear()
|
rightmove._detail_postcode_cache.clear()
|
||||||
# No file written yet — seeding must not raise and must leave cache empty.
|
# No file written yet: seeding must not raise and must leave cache empty.
|
||||||
scraper._seed_detail_caches(["rightmove"], tmp_path)
|
scraper._seed_detail_caches(["rightmove"], tmp_path)
|
||||||
assert rightmove._detail_postcode_cache == {}
|
assert rightmove._detail_postcode_cache == {}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# run_scrape — full orchestration wiring (sources stubbed, no network)
|
# run_scrape: full orchestration wiring (sources stubbed, no network)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from transform import (
|
from transform import (
|
||||||
|
build_listing_url,
|
||||||
build_register_address,
|
build_register_address,
|
||||||
clean_listing_address,
|
clean_listing_address,
|
||||||
extract_full_postcode,
|
extract_full_postcode,
|
||||||
|
|
@ -173,3 +174,33 @@ def test_rightmove_transform_without_detail_keeps_coordinate_logic() -> None:
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["Postcode"] == "SW9 7AA"
|
assert result["Postcode"] == "SW9 7AA"
|
||||||
assert result["Postcode source"] == "coordinates"
|
assert result["Postcode source"] == "coordinates"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_listing_url_stamps_channel_from_new_build_flag() -> None:
|
||||||
|
# Resale gets RES_BUY; new builds get RES_NEW.
|
||||||
|
assert build_listing_url("/properties/200", False) == (
|
||||||
|
"https://www.rightmove.co.uk/properties/200#/?channel=RES_BUY"
|
||||||
|
)
|
||||||
|
assert build_listing_url("/properties/200", True) == (
|
||||||
|
"https://www.rightmove.co.uk/properties/200#/?channel=RES_NEW"
|
||||||
|
)
|
||||||
|
# An existing channel/fragment on the source URL is stripped and re-stamped.
|
||||||
|
assert build_listing_url("/properties/200#/?channel=RES_BUY", True) == (
|
||||||
|
"https://www.rightmove.co.uk/properties/200#/?channel=RES_NEW"
|
||||||
|
)
|
||||||
|
# Missing URL stays empty.
|
||||||
|
assert build_listing_url("", True) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_rightmove_transform_tags_new_builds_res_new() -> None:
|
||||||
|
# The Rightmove search response marks new-build developments with
|
||||||
|
# development=True; transform_property must stamp the listing URL RES_NEW.
|
||||||
|
new_build = {**_rightmove_prop(), "development": True}
|
||||||
|
result = transform_property(new_build, "SW9", StubPostcodeIndex("SW9 7AA"))
|
||||||
|
assert result is not None
|
||||||
|
assert result["Listing URL"].endswith("#/?channel=RES_NEW")
|
||||||
|
|
||||||
|
# Ordinary resale (development absent/false) stays RES_BUY.
|
||||||
|
resale = transform_property(_rightmove_prop(), "SW9", StubPostcodeIndex("SW9 7AA"))
|
||||||
|
assert resale is not None
|
||||||
|
assert resale["Listing URL"].endswith("#/?channel=RES_BUY")
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ def test_parse_detail_geo_merges_location_uprn_with_address_full_address() -> No
|
||||||
def test_parse_detail_geo_does_not_borrow_comparable_full_address() -> None:
|
def test_parse_detail_geo_does_not_borrow_comparable_full_address() -> None:
|
||||||
# The only `address` twin on the page belongs to a different uprn (a
|
# The only `address` twin on the page belongs to a different uprn (a
|
||||||
# comparable listing). With a uprn to match on, an unrelated twin is never
|
# comparable listing). With a uprn to match on, an unrelated twin is never
|
||||||
# borrowed — full_address stays None rather than grabbing the wrong street.
|
# borrowed: full_address stays None rather than grabbing the wrong street.
|
||||||
html = (
|
html = (
|
||||||
'"location":{"outcode":"NR29",'
|
'"location":{"outcode":"NR29",'
|
||||||
'"coordinates":{"latitude":52.716014,"longitude":1.614495},'
|
'"coordinates":{"latitude":52.716014,"longitude":1.614495},'
|
||||||
|
|
@ -185,7 +185,7 @@ def test_parse_detail_geo_returns_none_for_garbage() -> None:
|
||||||
assert parse_detail_geo("<html><body>no data here</body></html>") is None
|
assert parse_detail_geo("<html><body>no data here</body></html>") is None
|
||||||
assert parse_detail_geo("") is None
|
assert parse_detail_geo("") is None
|
||||||
# Coordinates that are not inside a property location/address wrapper (e.g.
|
# Coordinates that are not inside a property location/address wrapper (e.g.
|
||||||
# only an unwrapped POI) yield nothing — safe degradation to the outcode.
|
# only an unwrapped POI) yield nothing, safe degradation to the outcode.
|
||||||
assert parse_detail_geo('"name":"X","coordinates":{"latitude":51.5,"longitude":-0.1}') is None
|
assert parse_detail_geo('"name":"X","coordinates":{"latitude":51.5,"longitude":-0.1}') is None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -149,7 +149,7 @@ def map_property_type(sub_type: str | None) -> str:
|
||||||
return "Terraced"
|
return "Terraced"
|
||||||
if "house" in lower or "cottage" in lower:
|
if "house" in lower or "cottage" in lower:
|
||||||
return "Detached"
|
return "Detached"
|
||||||
log.warning("Unknown propertySubType: %r — mapping to Other", sub_type)
|
log.warning("Unknown propertySubType: %r, mapping to Other", sub_type)
|
||||||
return "Other"
|
return "Other"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -267,7 +267,7 @@ def build_register_address(
|
||||||
property's own number or name (e.g. Zoopla detail pages expose
|
property's own number or name (e.g. Zoopla detail pages expose
|
||||||
``propertyNumberOrName`` = "12" or "Martham Mill"), prepend it so the address
|
``propertyNumberOrName`` = "12" or "Martham Mill"), prepend it so the address
|
||||||
carries the house identifier that the EPC/Price-Paid register addresses also
|
carries the house identifier that the EPC/Price-Paid register addresses also
|
||||||
use — turning a fuzzy street match into a near-exact one. Falls back to the
|
use, turning a fuzzy street match into a near-exact one. Falls back to the
|
||||||
plain cleaned address when no number/name is available.
|
plain cleaned address when no number/name is available.
|
||||||
"""
|
"""
|
||||||
cleaned = clean_listing_address(raw_address)
|
cleaned = clean_listing_address(raw_address)
|
||||||
|
|
@ -282,6 +282,23 @@ def build_register_address(
|
||||||
return f"{number_or_name}, {cleaned}" if cleaned else number_or_name
|
return f"{number_or_name}, {cleaned}" if cleaned else number_or_name
|
||||||
|
|
||||||
|
|
||||||
|
def build_listing_url(property_url: str | None, is_new_build: bool) -> str:
|
||||||
|
"""Build the canonical Rightmove listing URL with an explicit channel marker.
|
||||||
|
|
||||||
|
The search API always echoes ``?channel=RES_BUY`` in ``propertyUrl`` even for
|
||||||
|
new-build developments (the request channel stays BUY), so the channel is
|
||||||
|
re-stamped here from the per-listing ``development`` flag: ``RES_NEW`` for new
|
||||||
|
builds, ``RES_BUY`` for ordinary resale. The map UI reads this marker to split
|
||||||
|
new vs non-new listings. Any channel/fragment already on ``propertyUrl`` is
|
||||||
|
stripped first so the result is deterministic.
|
||||||
|
"""
|
||||||
|
if not property_url:
|
||||||
|
return ""
|
||||||
|
base = property_url.split("#", 1)[0].split("?", 1)[0]
|
||||||
|
channel = "RES_NEW" if is_new_build else "RES_BUY"
|
||||||
|
return f"{RIGHTMOVE_BASE}{base}#/?channel={channel}"
|
||||||
|
|
||||||
|
|
||||||
def transform_property(
|
def transform_property(
|
||||||
prop: dict,
|
prop: dict,
|
||||||
outcode: str,
|
outcode: str,
|
||||||
|
|
@ -337,7 +354,7 @@ def transform_property(
|
||||||
|
|
||||||
inferred_postcode = pc_index.nearest(lat, lng)
|
inferred_postcode = pc_index.nearest(lat, lng)
|
||||||
if not inferred_postcode:
|
if not inferred_postcode:
|
||||||
log.debug("No England postcode for property at %.4f, %.4f — skipping", lat, lng)
|
log.debug("No England postcode for property at %.4f, %.4f; skipping", lat, lng)
|
||||||
return None
|
return None
|
||||||
raw_address = prop.get("displayAddress", "") or ""
|
raw_address = prop.get("displayAddress", "") or ""
|
||||||
extracted_postcode = extract_full_postcode(raw_address)
|
extracted_postcode = extract_full_postcode(raw_address)
|
||||||
|
|
@ -391,7 +408,7 @@ def transform_property(
|
||||||
"price_frequency": "",
|
"price_frequency": "",
|
||||||
"Price qualifier": price_qualifier,
|
"Price qualifier": price_qualifier,
|
||||||
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
|
"Total floor area (sqm)": parse_display_size(prop.get("displaySize")),
|
||||||
"Listing URL": RIGHTMOVE_BASE + property_url if property_url else "",
|
"Listing URL": build_listing_url(property_url, bool(prop.get("development"))),
|
||||||
"Listing features": key_features,
|
"Listing features": key_features,
|
||||||
"first_visible_date": prop.get("firstVisibleDate", ""),
|
"first_visible_date": prop.get("firstVisibleDate", ""),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Zoopla (zoopla.co.uk) scraper — sale properties.
|
"""Zoopla (zoopla.co.uk) scraper: sale properties.
|
||||||
|
|
||||||
Zoopla is behind Cloudflare Turnstile (managed interactive challenge), which
|
Zoopla is behind Cloudflare Turnstile (managed interactive challenge), which
|
||||||
blocks non-browser HTTP clients and even Playwright with stealth patches. Only
|
blocks non-browser HTTP clients and even Playwright with stealth patches. Only
|
||||||
|
|
@ -522,7 +522,7 @@ def _gluetun_set_vpn_status(client: httpx.Client, status: str) -> bool:
|
||||||
return False
|
return False
|
||||||
if resp.status_code == 401:
|
if resp.status_code == 401:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Gluetun vpn/status %s: 401 Unauthorized — the API key must be "
|
"Gluetun vpn/status %s: 401 Unauthorized. The API key must be "
|
||||||
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
|
"authorised for 'PUT /v1/vpn/status' in Gluetun's auth config.toml",
|
||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
|
|
@ -593,7 +593,7 @@ def _rotate_and_retry_challenge(page, max_rotations: int) -> bool:
|
||||||
"""Rotate IP and reload until the challenge clears. Returns True on success."""
|
"""Rotate IP and reload until the challenge clears. Returns True on success."""
|
||||||
for attempt in range(1, max_rotations + 1):
|
for attempt in range(1, max_rotations + 1):
|
||||||
log.warning(
|
log.warning(
|
||||||
"Cloudflare Turnstile challenge — rotating Gluetun IP (attempt %d/%d)",
|
"Cloudflare Turnstile challenge, rotating Gluetun IP (attempt %d/%d)",
|
||||||
attempt, max_rotations,
|
attempt, max_rotations,
|
||||||
)
|
)
|
||||||
if not _rotate_gluetun_ip():
|
if not _rotate_gluetun_ip():
|
||||||
|
|
@ -637,7 +637,7 @@ def _wait_for_turnstile(page, headless_mode: bool | str) -> None:
|
||||||
if not _is_turnstile_challenge(page):
|
if not _is_turnstile_challenge(page):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try Gluetun IP rotation first — works in any mode and is the only option
|
# Try Gluetun IP rotation first: works in any mode and is the only option
|
||||||
# in headless/unattended runs where no human can click the challenge.
|
# in headless/unattended runs where no human can click the challenge.
|
||||||
max_rotations = _gluetun_max_rotations()
|
max_rotations = _gluetun_max_rotations()
|
||||||
if max_rotations > 0 and _rotate_and_retry_challenge(page, max_rotations):
|
if max_rotations > 0 and _rotate_and_retry_challenge(page, max_rotations):
|
||||||
|
|
@ -654,7 +654,7 @@ def _wait_for_turnstile(page, headless_mode: bool | str) -> None:
|
||||||
|
|
||||||
timeout = _challenge_timeout_seconds()
|
timeout = _challenge_timeout_seconds()
|
||||||
log.warning(
|
log.warning(
|
||||||
"Gluetun rotation insufficient — falling back to interactive solve. "
|
"Gluetun rotation insufficient. Falling back to interactive solve. "
|
||||||
"Complete the Cloudflare challenge in the Zoopla browser window; "
|
"Complete the Cloudflare challenge in the Zoopla browser window; "
|
||||||
"waiting up to %ds. Profile: %s",
|
"waiting up to %ds. Profile: %s",
|
||||||
timeout,
|
timeout,
|
||||||
|
|
@ -727,7 +727,7 @@ def launch_browser():
|
||||||
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=60000)
|
page.goto(f"{ZOOPLA_BASE}/", wait_until="domcontentloaded", timeout=60000)
|
||||||
_wait_for_turnstile(page, headless_mode)
|
_wait_for_turnstile(page, headless_mode)
|
||||||
|
|
||||||
log.info("Zoopla browser ready — title: %s", page.title())
|
log.info("Zoopla browser ready, title: %s", page.title())
|
||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
|
|
||||||
# Dismiss cookie consent
|
# Dismiss cookie consent
|
||||||
|
|
@ -1117,14 +1117,14 @@ def _extract_outcode(text: str) -> str | None:
|
||||||
# "outcode":...,"postcode":...,"uprn":...} feeding the map widgets.
|
# "outcode":...,"postcode":...,"uprn":...} feeding the map widgets.
|
||||||
# Nearby points of interest (stations, schools, EV chargers) and comparable
|
# Nearby points of interest (stations, schools, EV chargers) and comparable
|
||||||
# listings carry their own "coordinates" too, but never inside the property's
|
# listings carry their own "coordinates" too, but never inside the property's
|
||||||
# own "location" / "address":{"fullAddress" wrapper — so the wrapper, not a
|
# own "location" / "address":{"fullAddress" wrapper, so the wrapper, not a
|
||||||
# loose coordinates object, is what we anchor on (see parse_detail_geo).
|
# loose coordinates object, is what we anchor on (see parse_detail_geo).
|
||||||
|
|
||||||
# listingId -> parsed detail dict (or None). Failures are cached too, so a
|
# listingId -> parsed detail dict (or None). Failures are cached too, so a
|
||||||
# broken listing is not re-fetched within a run (the same listing reappears
|
# broken listing is not re-fetched within a run (the same listing reappears
|
||||||
# across overlapping outcode searches). Seeded from / dumped to a persistent
|
# across overlapping outcode searches). Seeded from / dumped to a persistent
|
||||||
# on-disk cache by the orchestrator (see postcode_cache.py) so a recurring
|
# on-disk cache by the orchestrator (see postcode_cache.py) so a recurring
|
||||||
# scrape only re-fetches newly-listed properties — the biggest saving for
|
# scrape only re-fetches newly-listed properties, the biggest saving for
|
||||||
# Zoopla, whose detail fetch drives a real browser tab.
|
# Zoopla, whose detail fetch drives a real browser tab.
|
||||||
_detail_cache: dict[str, dict | None] = {}
|
_detail_cache: dict[str, dict | None] = {}
|
||||||
|
|
||||||
|
|
@ -1144,7 +1144,7 @@ _LISTING_ID_RE = re.compile(r"/details/(\d+)/?")
|
||||||
# The property's own location is carried by a `"location":{...}` wrapper and a
|
# The property's own location is carried by a `"location":{...}` wrapper and a
|
||||||
# twin `"address":{"fullAddress":...}` widget object. We anchor on those
|
# twin `"address":{"fullAddress":...}` widget object. We anchor on those
|
||||||
# wrappers (and capture their full object body, which contains exactly one
|
# wrappers (and capture their full object body, which contains exactly one
|
||||||
# nested object — `coordinates`) rather than scanning for loose coordinate
|
# nested object, `coordinates`) rather than scanning for loose coordinate
|
||||||
# objects: nearby points of interest (stations/schools/EV chargers) and
|
# objects: nearby points of interest (stations/schools/EV chargers) and
|
||||||
# comparable/"similar" listings also embed coordinates, but never inside the
|
# comparable/"similar" listings also embed coordinates, but never inside the
|
||||||
# property's own `"location"` / `"address":{"fullAddress"` wrapper, so the
|
# property's own `"location"` / `"address":{"fullAddress"` wrapper, so the
|
||||||
|
|
@ -1225,7 +1225,7 @@ def parse_detail_geo(html: str, search_outcode: str | None = None) -> dict | Non
|
||||||
# twin, not the `location` wrapper we anchor coordinates on. Pull it from
|
# twin, not the `location` wrapper we anchor coordinates on. Pull it from
|
||||||
# the twin that shares this property's uprn; when there is no uprn to
|
# the twin that shares this property's uprn; when there is no uprn to
|
||||||
# disambiguate, fall back to the first twin (document order = primary
|
# disambiguate, fall back to the first twin (document order = primary
|
||||||
# listing), but never guess a twin when a uprn exists and none matches —
|
# listing), but never guess a twin when a uprn exists and none matches:
|
||||||
# that would risk grabbing a comparable listing's address.
|
# that would risk grabbing a comparable listing's address.
|
||||||
if result is None or result.get("full_address"):
|
if result is None or result.get("full_address"):
|
||||||
return result
|
return result
|
||||||
|
|
@ -1245,7 +1245,7 @@ def parse_detail_geo(html: str, search_outcode: str | None = None) -> dict | Non
|
||||||
result["full_address"] = first
|
result["full_address"] = first
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Strategy 1 — the property's own `location` wrapper (authoritative). Take
|
# Strategy 1: the property's own `location` wrapper (authoritative). Take
|
||||||
# the first match (the primary listing precedes any comparables in the
|
# the first match (the primary listing precedes any comparables in the
|
||||||
# flight stream), but prefer one whose outcode matches the searched outcode.
|
# flight stream), but prefer one whose outcode matches the searched outcode.
|
||||||
first_location = None
|
first_location = None
|
||||||
|
|
@ -1269,7 +1269,7 @@ def parse_detail_geo(html: str, search_outcode: str | None = None) -> dict | Non
|
||||||
if first_location is not None:
|
if first_location is not None:
|
||||||
return attach_full_address(first_location)
|
return attach_full_address(first_location)
|
||||||
|
|
||||||
# Strategy 2 — the `address` map-widget twin (same coordinates, backup).
|
# Strategy 2: the `address` map-widget twin (same coordinates, backup).
|
||||||
for match in _DETAIL_ADDRESS_RE.finditer(buf):
|
for match in _DETAIL_ADDRESS_RE.finditer(buf):
|
||||||
full_address = match.group(1) or None
|
full_address = match.group(1) or None
|
||||||
body = match.group(2)
|
body = match.group(2)
|
||||||
|
|
@ -1284,7 +1284,7 @@ def parse_detail_geo(html: str, search_outcode: str | None = None) -> dict | Non
|
||||||
|
|
||||||
|
|
||||||
def _detail_cache_key(listing_url: str) -> str:
|
def _detail_cache_key(listing_url: str) -> str:
|
||||||
"""Cache key for a listing detail page — its numeric id when present."""
|
"""Cache key for a listing detail page: its numeric id when present."""
|
||||||
id_match = _LISTING_ID_RE.search(listing_url)
|
id_match = _LISTING_ID_RE.search(listing_url)
|
||||||
return id_match.group(1) if id_match else listing_url
|
return id_match.group(1) if id_match else listing_url
|
||||||
|
|
||||||
|
|
@ -1388,7 +1388,7 @@ def transform_property(
|
||||||
location comes from the listing's detail page (see ``parse_detail_geo`` /
|
location comes from the listing's detail page (see ``parse_detail_geo`` /
|
||||||
``_fetch_listing_detail``), passed in as ``detail``. When detail-page
|
``_fetch_listing_detail``), passed in as ``detail``. When detail-page
|
||||||
coordinates are available we resolve the nearest postcode via the spatial
|
coordinates are available we resolve the nearest postcode via the spatial
|
||||||
index — mirroring rightmove/onthemarket — and only fall back to the coarse
|
index (mirroring rightmove/onthemarket) and only fall back to the coarse
|
||||||
outcode centroid when no detail location could be obtained."""
|
outcode centroid when no detail location could be obtained."""
|
||||||
price = parse_int_value(raw.get("price")) or 0
|
price = parse_int_value(raw.get("price")) or 0
|
||||||
|
|
||||||
|
|
@ -1563,7 +1563,7 @@ def search_outcode(
|
||||||
if detail_budget_seconds is not None:
|
if detail_budget_seconds is not None:
|
||||||
detail_deadline = time.monotonic() + detail_budget_seconds
|
detail_deadline = time.monotonic() + detail_budget_seconds
|
||||||
|
|
||||||
# Always try extraction even if result count is 0 — the count regex may
|
# Always try extraction even if result count is 0: the count regex may
|
||||||
# not match Zoopla's current text format, but listings may still be in DOM
|
# not match Zoopla's current text format, but listings may still be in DOM
|
||||||
raw_listings = _paginate(
|
raw_listings = _paginate(
|
||||||
page,
|
page,
|
||||||
|
|
@ -1577,7 +1577,7 @@ def search_outcode(
|
||||||
if not raw_listings:
|
if not raw_listings:
|
||||||
if total_results > 0:
|
if total_results > 0:
|
||||||
log.debug(
|
log.debug(
|
||||||
"Zoopla %s %s: page claims %d results but extraction found 0 — "
|
"Zoopla %s %s: page claims %d results but extraction found 0; "
|
||||||
"DOM selectors may need updating",
|
"DOM selectors may need updating",
|
||||||
outcode, "BUY", total_results,
|
outcode, "BUY", total_results,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""Zoopla scraping via FlareSolverr (no browser/VNC needed).
|
"""Zoopla scraping via FlareSolverr (no browser/VNC needed).
|
||||||
|
|
||||||
FlareSolverr solves Zoopla's Cloudflare and returns the rendered HTML, which
|
FlareSolverr solves Zoopla's Cloudflare and returns the rendered HTML, which
|
||||||
still contains the React Server Components flight stream — so the existing pure
|
still contains the React Server Components flight stream, so the existing pure
|
||||||
parsers work unchanged:
|
parsers work unchanged:
|
||||||
- the search page yields the outcode's listing detail URLs, and
|
- the search page yields the outcode's listing detail URLs, and
|
||||||
- each detail page's flight stream carries the property's location object
|
- each detail page's flight stream carries the property's location object
|
||||||
|
|
|
||||||
87
frontend/public/cheaper-twin/br3-3-vs-cr0-7/index.html
Normal file
87
frontend/public/cheaper-twin/br3-3-vs-cr0-7/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Beckenham vs Croydon: the same terraced house, about 31% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/br3-3-vs-cr0-7" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Beckenham vs Croydon", "item": "https://perfect-postcode.co.uk/cheaper-twin/br3-3-vs-cr0-7"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Beckenham vs Croydon: the same terraced house, about 31% cheaper per m²</h1>
|
||||||
|
<div class="big">31% cheaper / m²</div>
|
||||||
|
<p class="hook">£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Beckenham (BR3 3)</th><th>Croydon (CR0 7)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£7,153</td><td class='val'>£4,910</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£643,770</td><td class='val'><span class="cheaper">£441,900</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Terraced</td><td class='val'>Terraced</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1940</td><td class='val'>~1940</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>7.8</td><td class='val'>7.8</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.73 km</td><td class='val'>~0.73 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>4,514</td><td class='val'>5,143</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Beckenham (BR3 3) and Croydon (CR0 7) sit about 2.02 km apart, share the same dominant housing (terraced, typically built around 1940), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>31% (about £201,870 on a 90 m² property) cheaper in Croydon</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/ha7-2-vs-ha3-0"><b>Stanmore vs Kenton</b><br>£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart</a>
|
||||||
|
<a href="/cheaper-twin/ig8-7-vs-ig6-2"><b>Woodford Green vs Barkingside</b><br>£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart</a>
|
||||||
|
<a href="/cheaper-twin/l16-7-vs-l14-6"><b>Childwall vs Broadgreen</b><br>£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/ha7-2-vs-ha3-0/index.html
Normal file
87
frontend/public/cheaper-twin/ha7-2-vs-ha3-0/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/ha7-2-vs-ha3-0" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Stanmore vs Kenton", "item": "https://perfect-postcode.co.uk/cheaper-twin/ha7-2-vs-ha3-0"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Stanmore vs Kenton: the same semi-detached house, about 17% cheaper per m²</h1>
|
||||||
|
<div class="big">17% cheaper / m²</div>
|
||||||
|
<p class="hook">£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Stanmore (HA7 2)</th><th>Kenton (HA3 0)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£6,834</td><td class='val'>£5,646</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£615,060</td><td class='val'><span class="cheaper">£508,140</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Semi-Detached</td><td class='val'>Semi-Detached</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1940</td><td class='val'>~1940</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.9</td><td class='val'>2.9</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~1.31 km</td><td class='val'>~1.31 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>2,775</td><td class='val'>3,122</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Stanmore (HA7 2) and Kenton (HA3 0) sit about 2.57 km apart, share the same dominant housing (semi-detached, typically built around 1940), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>17% (about £106,920 on a 90 m² property) cheaper in Kenton</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/ig8-7-vs-ig6-2"><b>Woodford Green vs Barkingside</b><br>£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart</a>
|
||||||
|
<a href="/cheaper-twin/l16-7-vs-l14-6"><b>Childwall vs Broadgreen</b><br>£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart</a>
|
||||||
|
<a href="/cheaper-twin/m40-5-vs-m9-4"><b>Newton Heath vs Harpurhey</b><br>£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/ig8-7-vs-ig6-2/index.html
Normal file
87
frontend/public/cheaper-twin/ig8-7-vs-ig6-2/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/ig8-7-vs-ig6-2" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Woodford Green vs Barkingside", "item": "https://perfect-postcode.co.uk/cheaper-twin/ig8-7-vs-ig6-2"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Woodford Green vs Barkingside: the same terraced house, about 26% cheaper per m²</h1>
|
||||||
|
<div class="big">26% cheaper / m²</div>
|
||||||
|
<p class="hook">£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Woodford Green (IG8 7)</th><th>Barkingside (IG6 2)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£7,148</td><td class='val'>£5,325</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£643,320</td><td class='val'><span class="cheaper">£479,250</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Terraced</td><td class='val'>Terraced</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1958</td><td class='val'>~1958</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.8</td><td class='val'>2.8</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.58 km</td><td class='val'>~0.58 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>2,965</td><td class='val'>4,423</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Woodford Green (IG8 7) and Barkingside (IG6 2) sit about 2.98 km apart, share the same dominant housing (terraced, typically built around 1958), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>26% (about £164,070 on a 90 m² property) cheaper in Barkingside</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/l16-7-vs-l14-6"><b>Childwall vs Broadgreen</b><br>£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart</a>
|
||||||
|
<a href="/cheaper-twin/m40-5-vs-m9-4"><b>Newton Heath vs Harpurhey</b><br>£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart</a>
|
||||||
|
<a href="/cheaper-twin/rm14-2-vs-rm12-5"><b>Upminster vs Hornchurch</b><br>£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/l16-7-vs-l14-6/index.html
Normal file
87
frontend/public/cheaper-twin/l16-7-vs-l14-6/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/l16-7-vs-l14-6" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Childwall vs Broadgreen", "item": "https://perfect-postcode.co.uk/cheaper-twin/l16-7-vs-l14-6"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Childwall vs Broadgreen: the same semi-detached house, about 30% cheaper per m²</h1>
|
||||||
|
<div class="big">30% cheaper / m²</div>
|
||||||
|
<p class="hook">£106,740 less for an equivalent semi-detached house: same station, similar schools, ~1.88km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Childwall (L16 7)</th><th>Broadgreen (L14 6)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£4,026</td><td class='val'>£2,840</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£362,340</td><td class='val'><span class="cheaper">£255,600</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Semi-Detached</td><td class='val'>Semi-Detached</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1940</td><td class='val'>~1940</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>5.1</td><td class='val'>5.1</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~1.22 km</td><td class='val'>~1.22 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>500</td><td class='val'>809</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Childwall (L16 7) and Broadgreen (L14 6) sit about 1.88 km apart, share the same dominant housing (semi-detached, typically built around 1940), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>30% (about £106,740 on a 90 m² property) cheaper in Broadgreen</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/m40-5-vs-m9-4"><b>Newton Heath vs Harpurhey</b><br>£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart</a>
|
||||||
|
<a href="/cheaper-twin/rm14-2-vs-rm12-5"><b>Upminster vs Hornchurch</b><br>£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart</a>
|
||||||
|
<a href="/cheaper-twin/se28-8-vs-da18-4"><b>SE28 8 vs DA18 4</b><br>£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/m40-5-vs-m9-4/index.html
Normal file
87
frontend/public/cheaper-twin/m40-5-vs-m9-4/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/m40-5-vs-m9-4" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Newton Heath vs Harpurhey", "item": "https://perfect-postcode.co.uk/cheaper-twin/m40-5-vs-m9-4"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Newton Heath vs Harpurhey: the same terraced house, about 42% cheaper per m²</h1>
|
||||||
|
<div class="big">42% cheaper / m²</div>
|
||||||
|
<p class="hook">£106,740 less for an equivalent terraced house: same station, similar schools, ~1.18km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Newton Heath (M40 5)</th><th>Harpurhey (M9 4)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£2,812</td><td class='val'>£1,626</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£253,080</td><td class='val'><span class="cheaper">£146,340</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Terraced</td><td class='val'>Terraced</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1958</td><td class='val'>~1958</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.6</td><td class='val'>2.6</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.72 km</td><td class='val'>~0.72 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>1,632</td><td class='val'>3,530</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Newton Heath (M40 5) and Harpurhey (M9 4) sit about 1.18 km apart, share the same dominant housing (terraced, typically built around 1958), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>42% (about £106,740 on a 90 m² property) cheaper in Harpurhey</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/rm14-2-vs-rm12-5"><b>Upminster vs Hornchurch</b><br>£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart</a>
|
||||||
|
<a href="/cheaper-twin/se28-8-vs-da18-4"><b>SE28 8 vs DA18 4</b><br>£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart</a>
|
||||||
|
<a href="/cheaper-twin/sw1x-8-vs-sw7-2"><b>SW1X 8 vs SW7 2</b><br>£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/rm14-2-vs-rm12-5/index.html
Normal file
87
frontend/public/cheaper-twin/rm14-2-vs-rm12-5/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/rm14-2-vs-rm12-5" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Upminster vs Hornchurch", "item": "https://perfect-postcode.co.uk/cheaper-twin/rm14-2-vs-rm12-5"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Upminster vs Hornchurch: the same semi-detached house, about 20% cheaper per m²</h1>
|
||||||
|
<div class="big">20% cheaper / m²</div>
|
||||||
|
<p class="hook">£115,290 less for an equivalent semi-detached house: same station, similar schools, ~2.99km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Upminster (RM14 2)</th><th>Hornchurch (RM12 5)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£6,360</td><td class='val'>£5,079</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£572,400</td><td class='val'><span class="cheaper">£457,110</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Semi-Detached</td><td class='val'>Semi-Detached</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1940</td><td class='val'>~1940</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>3.7</td><td class='val'>3.7</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.77 km</td><td class='val'>~0.77 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>3,026</td><td class='val'>3,133</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Upminster (RM14 2) and Hornchurch (RM12 5) sit about 2.99 km apart, share the same dominant housing (semi-detached, typically built around 1940), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>20% (about £115,290 on a 90 m² property) cheaper in Hornchurch</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/se28-8-vs-da18-4"><b>SE28 8 vs DA18 4</b><br>£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart</a>
|
||||||
|
<a href="/cheaper-twin/sw1x-8-vs-sw7-2"><b>SW1X 8 vs SW7 2</b><br>£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart</a>
|
||||||
|
<a href="/cheaper-twin/tw12-3-vs-kt8-1"><b>Hampton vs East Molesey</b><br>£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/se28-8-vs-da18-4/index.html
Normal file
87
frontend/public/cheaper-twin/se28-8-vs-da18-4/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/se28-8-vs-da18-4" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "SE28 8 vs DA18 4", "item": "https://perfect-postcode.co.uk/cheaper-twin/se28-8-vs-da18-4"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>SE28 8 vs DA18 4: the same terraced house, about 30% cheaper per m²</h1>
|
||||||
|
<div class="big">30% cheaper / m²</div>
|
||||||
|
<p class="hook">£129,690 less for an equivalent terraced house: same station, similar schools, ~1.72km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>SE28 8</th><th>DA18 4</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£4,850</td><td class='val'>£3,409</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£436,500</td><td class='val'><span class="cheaper">£306,810</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Terraced</td><td class='val'>Terraced</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1993</td><td class='val'>~1993</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.8</td><td class='val'>2.8</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~1.39 km</td><td class='val'>~1.39 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>5,033</td><td class='val'>1,063</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>SE28 8 and DA18 4 sit about 1.72 km apart, share the same dominant housing (terraced, typically built around 1993), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>30% (about £129,690 on a 90 m² property) cheaper in DA18 4</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/sw1x-8-vs-sw7-2"><b>SW1X 8 vs SW7 2</b><br>£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart</a>
|
||||||
|
<a href="/cheaper-twin/tw12-3-vs-kt8-1"><b>Hampton vs East Molesey</b><br>£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart</a>
|
||||||
|
<a href="/cheaper-twin/tw2-7-vs-tw3-2"><b>Twickenham vs Hounslow</b><br>£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/sw1x-8-vs-sw7-2/index.html
Normal file
87
frontend/public/cheaper-twin/sw1x-8-vs-sw7-2/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/sw1x-8-vs-sw7-2" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "SW1X 8 vs SW7 2", "item": "https://perfect-postcode.co.uk/cheaper-twin/sw1x-8-vs-sw7-2"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>SW1X 8 vs SW7 2: the same flat, about 42% cheaper per m²</h1>
|
||||||
|
<div class="big">42% cheaper / m²</div>
|
||||||
|
<p class="hook">£1,001,160 less for an equivalent flat: same station, similar schools, ~1.31km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>SW1X 8</th><th>SW7 2</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£26,735</td><td class='val'>£15,611</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£2,406,150</td><td class='val'><span class="cheaper">£1,404,990</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Flats/Maisonettes</td><td class='val'>Flats/Maisonettes</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1890</td><td class='val'>~1890</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>3.7</td><td class='val'>3.7</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.43 km</td><td class='val'>~0.43 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>1,410</td><td class='val'>1,126</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>SW1X 8 and SW7 2 sit about 1.31 km apart, share the same dominant housing (flats/maisonettes, typically built around 1890), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>42% (about £1,001,160 on a 90 m² property) cheaper in SW7 2</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/tw12-3-vs-kt8-1"><b>Hampton vs East Molesey</b><br>£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart</a>
|
||||||
|
<a href="/cheaper-twin/tw2-7-vs-tw3-2"><b>Twickenham vs Hounslow</b><br>£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1j-7-vs-sw7-3"><b>W1J 7 vs SW7 3</b><br>£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/tw12-3-vs-kt8-1/index.html
Normal file
87
frontend/public/cheaper-twin/tw12-3-vs-kt8-1/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Hampton vs East Molesey: the same terraced house, about 19% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/tw12-3-vs-kt8-1" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Hampton vs East Molesey", "item": "https://perfect-postcode.co.uk/cheaper-twin/tw12-3-vs-kt8-1"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Hampton vs East Molesey: the same terraced house, about 19% cheaper per m²</h1>
|
||||||
|
<div class="big">19% cheaper / m²</div>
|
||||||
|
<p class="hook">£120,060 less for an equivalent terraced house: same station, similar schools, ~2.23km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Hampton (TW12 3)</th><th>East Molesey (KT8 1)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£7,042</td><td class='val'>£5,708</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£633,780</td><td class='val'><span class="cheaper">£513,720</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Terraced</td><td class='val'>Terraced</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1979</td><td class='val'>~1979</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>3.2</td><td class='val'>3.2</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~1.27 km</td><td class='val'>~1.27 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>2,527</td><td class='val'>1,567</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Hampton (TW12 3) and East Molesey (KT8 1) sit about 2.23 km apart, share the same dominant housing (terraced, typically built around 1979), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>19% (about £120,060 on a 90 m² property) cheaper in East Molesey</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/tw2-7-vs-tw3-2"><b>Twickenham vs Hounslow</b><br>£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1j-7-vs-sw7-3"><b>W1J 7 vs SW7 3</b><br>£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1j-8-vs-sw1a-2"><b>W1J 8 vs SW1A 2</b><br>£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/tw2-7-vs-tw3-2/index.html
Normal file
87
frontend/public/cheaper-twin/tw2-7-vs-tw3-2/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/tw2-7-vs-tw3-2" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Twickenham vs Hounslow", "item": "https://perfect-postcode.co.uk/cheaper-twin/tw2-7-vs-tw3-2"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Twickenham vs Hounslow: the same semi-detached house, about 19% cheaper per m²</h1>
|
||||||
|
<div class="big">19% cheaper / m²</div>
|
||||||
|
<p class="hook">£121,590 less for an equivalent semi-detached house: same station, similar schools, ~1.0km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Twickenham (TW2 7)</th><th>Hounslow (TW3 2)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£6,971</td><td class='val'>£5,620</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£627,390</td><td class='val'><span class="cheaper">£505,800</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Semi-Detached</td><td class='val'>Semi-Detached</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1940</td><td class='val'>~1940</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>5.4</td><td class='val'>5.4</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.59 km</td><td class='val'>~0.59 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>3,377</td><td class='val'>2,964</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Twickenham (TW2 7) and Hounslow (TW3 2) sit about 1.0 km apart, share the same dominant housing (semi-detached, typically built around 1940), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>19% (about £121,590 on a 90 m² property) cheaper in Hounslow</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/w1j-7-vs-sw7-3"><b>W1J 7 vs SW7 3</b><br>£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1j-8-vs-sw1a-2"><b>W1J 8 vs SW1A 2</b><br>£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1k-2-vs-sw1x-0"><b>W1K 2 vs SW1X 0</b><br>£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/w1j-7-vs-sw7-3/index.html
Normal file
87
frontend/public/cheaper-twin/w1j-7-vs-sw7-3/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>W1J 7 vs SW7 3: the same flat, about 41% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/w1j-7-vs-sw7-3" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "W1J 7 vs SW7 3", "item": "https://perfect-postcode.co.uk/cheaper-twin/w1j-7-vs-sw7-3"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>W1J 7 vs SW7 3: the same flat, about 41% cheaper per m²</h1>
|
||||||
|
<div class="big">41% cheaper / m²</div>
|
||||||
|
<p class="hook">£1,223,460 less for an equivalent flat: same station, similar schools, ~2.6km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Mayfair (W1J 7)</th><th>SW7 3</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£32,986</td><td class='val'>£19,392</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£2,968,740</td><td class='val'><span class="cheaper">£1,745,280</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Flats/Maisonettes</td><td class='val'>Flats/Maisonettes</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1914</td><td class='val'>~1914</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.0</td><td class='val'>2.0</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.3 km</td><td class='val'>~0.3 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>724</td><td class='val'>2,581</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Mayfair (W1J 7) and SW7 3 sit about 2.6 km apart, share the same dominant housing (flats/maisonettes, typically built around 1914), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>41% (about £1,223,460 on a 90 m² property) cheaper in SW7 3</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/w1j-8-vs-sw1a-2"><b>W1J 8 vs SW1A 2</b><br>£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1k-2-vs-sw1x-0"><b>W1K 2 vs SW1X 0</b><br>£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1u-4-vs-nw1-4"><b>Marylebone vs Camden</b><br>£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/w1j-8-vs-sw1a-2/index.html
Normal file
87
frontend/public/cheaper-twin/w1j-8-vs-sw1a-2/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/w1j-8-vs-sw1a-2" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "W1J 8 vs SW1A 2", "item": "https://perfect-postcode.co.uk/cheaper-twin/w1j-8-vs-sw1a-2"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>W1J 8 vs SW1A 2: the same flat, about 37% cheaper per m²</h1>
|
||||||
|
<div class="big">37% cheaper / m²</div>
|
||||||
|
<p class="hook">£916,380 less for an equivalent flat: same station, similar schools, ~1.31km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Mayfair (W1J 8)</th><th>SW1A 2</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£27,270</td><td class='val'>£17,088</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£2,454,300</td><td class='val'><span class="cheaper">£1,537,920</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Flats/Maisonettes</td><td class='val'>Flats/Maisonettes</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~2000</td><td class='val'>~2000</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.0</td><td class='val'>2.0</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.18 km</td><td class='val'>~0.18 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>295</td><td class='val'>261</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Mayfair (W1J 8) and SW1A 2 sit about 1.31 km apart, share the same dominant housing (flats/maisonettes, typically built around 2000), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>37% (about £916,380 on a 90 m² property) cheaper in SW1A 2</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/w1k-2-vs-sw1x-0"><b>W1K 2 vs SW1X 0</b><br>£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart</a>
|
||||||
|
<a href="/cheaper-twin/w1u-4-vs-nw1-4"><b>Marylebone vs Camden</b><br>£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart</a>
|
||||||
|
<a href="/cheaper-twin/wc2a-2-vs-ec2a-2"><b>WC2A 2 vs EC2A 2</b><br>£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/w1k-2-vs-sw1x-0/index.html
Normal file
87
frontend/public/cheaper-twin/w1k-2-vs-sw1x-0/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/w1k-2-vs-sw1x-0" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "W1K 2 vs SW1X 0", "item": "https://perfect-postcode.co.uk/cheaper-twin/w1k-2-vs-sw1x-0"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>W1K 2 vs SW1X 0: the same flat, about 32% cheaper per m²</h1>
|
||||||
|
<div class="big">32% cheaper / m²</div>
|
||||||
|
<p class="hook">£978,570 less for an equivalent flat: same station, similar schools, ~1.62km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Mayfair (W1K 2)</th><th>SW1X 0</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£34,362</td><td class='val'>£23,489</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£3,092,580</td><td class='val'><span class="cheaper">£2,114,010</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Flats/Maisonettes</td><td class='val'>Flats/Maisonettes</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1914</td><td class='val'>~1914</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.0</td><td class='val'>2.0</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.5 km</td><td class='val'>~0.5 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>591</td><td class='val'>1,606</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Mayfair (W1K 2) and SW1X 0 sit about 1.62 km apart, share the same dominant housing (flats/maisonettes, typically built around 1914), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>32% (about £978,570 on a 90 m² property) cheaper in SW1X 0</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/w1u-4-vs-nw1-4"><b>Marylebone vs Camden</b><br>£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart</a>
|
||||||
|
<a href="/cheaper-twin/wc2a-2-vs-ec2a-2"><b>WC2A 2 vs EC2A 2</b><br>£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart</a>
|
||||||
|
<a href="/cheaper-twin/br3-3-vs-cr0-7"><b>Beckenham vs Croydon</b><br>£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/w1u-4-vs-nw1-4/index.html
Normal file
87
frontend/public/cheaper-twin/w1u-4-vs-nw1-4/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Marylebone vs Camden: the same flat, about 43% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/w1u-4-vs-nw1-4" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "Marylebone vs Camden", "item": "https://perfect-postcode.co.uk/cheaper-twin/w1u-4-vs-nw1-4"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>Marylebone vs Camden: the same flat, about 43% cheaper per m²</h1>
|
||||||
|
<div class="big">43% cheaper / m²</div>
|
||||||
|
<p class="hook">£942,480 less for an equivalent flat: same station, similar schools, ~0.97km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>Marylebone (W1U 4)</th><th>Camden (NW1 4)</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£24,238</td><td class='val'>£13,766</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£2,181,420</td><td class='val'><span class="cheaper">£1,238,940</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Flats/Maisonettes</td><td class='val'>Flats/Maisonettes</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~1940</td><td class='val'>~1940</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>1.0</td><td class='val'>1.0</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.44 km</td><td class='val'>~0.44 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>984</td><td class='val'>1,340</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>Marylebone (W1U 4) and Camden (NW1 4) sit about 0.97 km apart, share the same dominant housing (flats/maisonettes, typically built around 1940), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>43% (about £942,480 on a 90 m² property) cheaper in Camden</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/wc2a-2-vs-ec2a-2"><b>WC2A 2 vs EC2A 2</b><br>£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart</a>
|
||||||
|
<a href="/cheaper-twin/br3-3-vs-cr0-7"><b>Beckenham vs Croydon</b><br>£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart</a>
|
||||||
|
<a href="/cheaper-twin/ha7-2-vs-ha3-0"><b>Stanmore vs Kenton</b><br>£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
87
frontend/public/cheaper-twin/wc2a-2-vs-ec2a-2/index.html
Normal file
87
frontend/public/cheaper-twin/wc2a-2-vs-ec2a-2/index.html
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m² | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twin/wc2a-2-vs-ec2a-2" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "WC2A 2 vs EC2A 2", "item": "https://perfect-postcode.co.uk/cheaper-twin/wc2a-2-vs-ec2a-2"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">Cheaper twin · England</p>
|
||||||
|
<h1>WC2A 2 vs EC2A 2: the same flat, about 43% cheaper per m²</h1>
|
||||||
|
<div class="big">43% cheaper / m²</div>
|
||||||
|
<p class="hook">£981,540 less for an equivalent flat: same station, similar schools, ~2.3km apart</p>
|
||||||
|
<a class="cta" href="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">See both areas on the live map →</a>
|
||||||
|
</div></div>
|
||||||
|
<div class="wrap">
|
||||||
|
<table><thead><tr><th></th><th>WC2A 2</th><th>EC2A 2</th></tr></thead>
|
||||||
|
<tbody><tr><td>Estimated £/m²</td><td class='val'>£25,482</td><td class='val'>£14,576</td></tr>
|
||||||
|
<tr><td>On a 90 m² home</td><td class='val'>£2,293,380</td><td class='val'><span class="cheaper">£1,311,840</span></td></tr>
|
||||||
|
<tr><td>Dominant property type</td><td class='val'>Flats/Maisonettes</td><td class='val'>Flats/Maisonettes</td></tr>
|
||||||
|
<tr><td>Typical build era</td><td class='val'>~2019</td><td class='val'>~2019</td></tr>
|
||||||
|
<tr><td>Good+ secondary catchments</td><td class='val'>2.0</td><td class='val'>2.0</td></tr>
|
||||||
|
<tr><td>Nearest station</td><td class='val'>~0.42 km</td><td class='val'>~0.42 km</td></tr>
|
||||||
|
<tr><td>Sales in sample (N)</td><td class='val'>254</td><td class='val'>772</td></tr></tbody></table>
|
||||||
|
<section><h2>The same life, one postcode cheaper</h2><p>WC2A 2 and EC2A 2 sit about 2.3 km apart, share the same dominant housing (flats/maisonettes, typically built around 2019), comparable good-school catchments and the same level of station access. Yet an equivalent home works out roughly <b>43% (about £981,540 on a 90 m² property) cheaper in EC2A 2</b>. On the measures that move price they are near-identical; the gap is mostly the premium attached to the better-known name.</p></section>
|
||||||
|
<section><h2>How we worked this out</h2><p>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.</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"><a href="/cheaper-twin/br3-3-vs-cr0-7"><b>Beckenham vs Croydon</b><br>£201,870 less for an equivalent terraced house: same station, similar schools, ~2.02km apart</a>
|
||||||
|
<a href="/cheaper-twin/ha7-2-vs-ha3-0"><b>Stanmore vs Kenton</b><br>£106,920 less for an equivalent semi-detached house: same station, similar schools, ~2.57km apart</a>
|
||||||
|
<a href="/cheaper-twin/ig8-7-vs-ig6-2"><b>Woodford Green vs Barkingside</b><br>£164,070 less for an equivalent terraced house: same station, similar schools, ~2.98km apart</a></div>
|
||||||
|
</section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0. Figures are estimates derived from recorded sales and EPC floor areas, aggregated to postcode sector, not valuations, and not address-level.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
82
frontend/public/cheaper-twins/index.html
Normal file
82
frontend/public/cheaper-twins/index.html
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Cheaper twin postcodes in England | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="Neighbouring England postcodes priced apart for the name, not the home. Find the cheaper twin of a pricier area." />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/cheaper-twins" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "CollectionPage", "name": "Cheaper twins", "url": "https://perfect-postcode.co.uk/cheaper-twins"}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<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 15 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"><a class="card" href="/cheaper-twin/br3-3-vs-cr0-7"><div class="n">31%</div><div>Beckenham vs Croydon</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/ha7-2-vs-ha3-0"><div class="n">17%</div><div>Stanmore vs Kenton</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/ig8-7-vs-ig6-2"><div class="n">26%</div><div>Woodford Green vs Barkingside</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/l16-7-vs-l14-6"><div class="n">30%</div><div>Childwall vs Broadgreen</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/m40-5-vs-m9-4"><div class="n">42%</div><div>Newton Heath vs Harpurhey</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/rm14-2-vs-rm12-5"><div class="n">20%</div><div>Upminster vs Hornchurch</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/se28-8-vs-da18-4"><div class="n">30%</div><div>SE28 8 vs DA18 4</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/sw1x-8-vs-sw7-2"><div class="n">42%</div><div>SW1X 8 vs SW7 2</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/tw12-3-vs-kt8-1"><div class="n">19%</div><div>Hampton vs East Molesey</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/tw2-7-vs-tw3-2"><div class="n">19%</div><div>Twickenham vs Hounslow</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/w1j-7-vs-sw7-3"><div class="n">41%</div><div>W1J 7 vs SW7 3</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/w1j-8-vs-sw1a-2"><div class="n">37%</div><div>W1J 8 vs SW1A 2</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/w1k-2-vs-sw1x-0"><div class="n">32%</div><div>W1K 2 vs SW1X 0</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/w1u-4-vs-nw1-4"><div class="n">43%</div><div>Marylebone vs Camden</div></a>
|
||||||
|
<a class="card" href="/cheaper-twin/wc2a-2-vs-ec2a-2"><div class="n">43%</div><div>WC2A 2 vs EC2A 2</div></a></div></section>
|
||||||
|
<p class="note">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -80,4 +80,91 @@
|
||||||
<changefreq>yearly</changefreq>
|
<changefreq>yearly</changefreq>
|
||||||
<priority>0.3</priority>
|
<priority>0.3</priority>
|
||||||
</url>
|
</url>
|
||||||
|
<!-- DATA_PAGES_START -->
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/br3-3-vs-cr0-7</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/ha7-2-vs-ha3-0</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/ig8-7-vs-ig6-2</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/l16-7-vs-l14-6</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/m40-5-vs-m9-4</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/rm14-2-vs-rm12-5</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/se28-8-vs-da18-4</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/sw1x-8-vs-sw7-2</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/tw12-3-vs-kt8-1</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/tw2-7-vs-tw3-2</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/w1j-7-vs-sw7-3</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/w1j-8-vs-sw1a-2</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/w1k-2-vs-sw1x-0</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/w1u-4-vs-nw1-4</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twin/wc2a-2-vs-ec2a-2</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/square-metres-per-100k</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://perfect-postcode.co.uk/cheaper-twins</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.7</priority>
|
||||||
|
</url>
|
||||||
|
<!-- DATA_PAGES_END -->
|
||||||
</urlset>
|
</urlset>
|
||||||
|
|
|
||||||
76
frontend/public/square-metres-per-100k/index.html
Normal file
76
frontend/public/square-metres-per-100k/index.html
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>How many square metres £100,000 buys across England | Perfect Postcode</title>
|
||||||
|
<meta name="description" content="£100k buys ~152 m² of floor space in BD21 3 but only ~3 m² in Mayfair (W1K 2)" />
|
||||||
|
<link rel="canonical" href="https://perfect-postcode.co.uk/square-metres-per-100k" />
|
||||||
|
<style>
|
||||||
|
: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}}
|
||||||
|
</style>
|
||||||
|
<script type="application/ld+json">{"@context": "https://schema.org", "@type": "BreadcrumbList", "itemListElement": [{"@type": "ListItem", "position": 1, "name": "Home", "item": "https://perfect-postcode.co.uk/"}, {"@type": "ListItem", "position": 2, "name": "Cheaper twins", "item": "https://perfect-postcode.co.uk/cheaper-twins"}, {"@type": "ListItem", "position": 3, "name": "How many square metres \u00a3100,000 buys across England", "item": "https://perfect-postcode.co.uk/square-metres-per-100k"}]}</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="topbar"><a href="/">Perfect Postcode</a><a href="/?ref=twin">Open the map →</a></div>
|
||||||
|
|
||||||
|
<div class="hero"><div class="wrap">
|
||||||
|
<p class="eyebrow">England · value index</p>
|
||||||
|
<h1>How many square metres £100,000 buys across England</h1>
|
||||||
|
<div class="big">152 m² vs 3 m²</div>
|
||||||
|
<p class="hook">£100k buys ~152 m² of floor space in BD21 3 but only ~3 m² in Mayfair (W1K 2)</p>
|
||||||
|
<a class="cta" href="https://perfect-postcode.co.uk/?zoom=6&filter=Est.%20price%20per%20sqm:0:4000">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>BD21 3 (best value)</td><td class='val'>£660</td><td class='val cheaper'>152 m²</td><td class='val'>1,377</td></tr>
|
||||||
|
<tr><td>W1K 2 (dearest)</td><td class='val'>£34,362</td><td class='val'>3 m²</td><td class='val'>591</td></tr>
|
||||||
|
</tbody></table>
|
||||||
|
<section><h2>How we worked this out</h2><p>100000 ÷ median estimated £/m², per England postcode sector with sufficient sales.</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">Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer><div class="wrap">Sources: HM Land Registry · EPC (DLUHC) · Ofsted · DfT · ONS · Police.uk. Contains HM Land Registry data © Crown copyright and database right. Licensed under the Open Government Licence v3.0.</div></footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
frontend/public/video/twin-beckenham-croydon.vtt
Normal file
14
frontend/public/video/twin-beckenham-croydon.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.203 --> 00:00:06.283
|
||||||
|
Beckenham and Croydon sit side by side: same trains, same school catchment.
|
||||||
|
|
||||||
|
00:00:06.683 --> 00:00:10.523
|
||||||
|
Rank them by what each pound of floor space actually buys.
|
||||||
|
|
||||||
|
00:00:11.823 --> 00:00:16.943
|
||||||
|
One postcode over, the same home quietly costs about a third less.
|
||||||
|
|
||||||
|
00:00:18.093 --> 00:00:22.893
|
||||||
|
Beckenham's cheaper twin is on this map. Find yours, free.
|
||||||
|
|
||||||
14
frontend/public/video/twin-ha7-2-vs-ha3-0.vtt
Normal file
14
frontend/public/video/twin-ha7-2-vs-ha3-0.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.202 --> 00:00:05.882
|
||||||
|
Stanmore and Kenton sit right next door, with the same schools and transport links.
|
||||||
|
|
||||||
|
00:00:06.282 --> 00:00:10.842
|
||||||
|
Rank every postcode by what each pound of floor space actually buys.
|
||||||
|
|
||||||
|
00:00:12.142 --> 00:00:17.422
|
||||||
|
One postcode over, the same home quietly costs about a sixth less.
|
||||||
|
|
||||||
|
00:00:18.572 --> 00:00:22.652
|
||||||
|
Stanmore's cheaper twin is on this map. Find yours, free.
|
||||||
|
|
||||||
14
frontend/public/video/twin-l16-7-vs-l14-6.vtt
Normal file
14
frontend/public/video/twin-l16-7-vs-l14-6.vtt
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.203 --> 00:00:06.843
|
||||||
|
Childwall and Broadgreen sit right next door, with the same schools and transport links.
|
||||||
|
|
||||||
|
00:00:07.243 --> 00:00:11.803
|
||||||
|
Rank every postcode by what each pound of floor space actually buys.
|
||||||
|
|
||||||
|
00:00:13.103 --> 00:00:18.223
|
||||||
|
One postcode over, the same home quietly costs about a third less.
|
||||||
|
|
||||||
|
00:00:19.373 --> 00:00:24.493
|
||||||
|
Childwall's cheaper twin is on this map. Find yours, free.
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue