This commit is contained in:
Andras Schmelczer 2026-07-03 18:47:28 +01:00
parent ab688243d7
commit 463bd4c647
54 changed files with 13239 additions and 625 deletions

View 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:000: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:060: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:180:28", "Same schools", "Show the Good+ secondary catchment covering both.", "Caption: 'Same school catchment.'"),
("0:280:38", "Same homes", f"Note the dominant type ({plural}) and build era (~{s['build_year']}).", "Caption: 'Same homes.'"),
("0:380: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:521: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, ~4560s 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 12 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()