221 lines
9.5 KiB
Python
221 lines
9.5 KiB
Python
#!/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()
|