..
This commit is contained in:
parent
ab688243d7
commit
463bd4c647
54 changed files with 13239 additions and 625 deletions
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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue