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

221 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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()