Files
ai-podcast/docs/plans/2026-08-14-seo-episode-pages.md
lukeandClaude Opus 5 8c71da543f Add Big Bend location signal to homepage metadata and llms.txt
The site carried no geographic signal at all: the homepage title, description
and schema keywords never mentioned Alpine, Marfa or the Big Bend, and llms.txt
still described the show as broadcasting from a fictional desert hermit's RV
and being produced in New Mexico.

- Homepage title, description and OG/Twitter tags now lead with Alpine, Texas
  and name the surrounding towns; keywords put the location terms first
- PodcastSeries schema gains contentLocation with Alpine's real coordinates
  (from BIG_BEND_TOWNS), spatialCoverage for the region, and an about array
  covering Alpine, Marfa, Terlingua, Big Bend National Park and the Marfa Lights
- llms.txt: corrects the setting throughout, adds the region to Key Facts, and
  answers "what places does the show talk about"
- Fixes the stale "37+ episodes" in five places, using 50+ so it ages better
- Corrects three claims that were also wrong on How It Works: 68 voice profiles
  (now 82), adaptive call shapes (deleted system), and 5 recording stems (now 7)

Also adds the Tier 2 plan for crawlable per-episode pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 03:28:16 -05:00

501 lines
19 KiB
Markdown

# Crawlable Episode Pages Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Turn 57 client-rendered `?slug=` episode pages into 57 static, crawlable HTML pages at `/episode/<slug>/` containing the full transcript, so the Big Bend content the show already produces becomes indexable.
**Architecture:** A build-time Python generator reads the Castopod RSS feed and the existing `website/transcripts/*.txt` files, and writes one fully static page per episode into `website/episode/<slug>/index.html`. No runtime feed dependency, no JS required to see content. The Cloudflare worker gains a 301 from the legacy `?slug=` URL, and loses its user-agent gate. `publish_episode.py` calls the generator so new episodes get a page automatically.
**Tech Stack:** Python 3.11 (stdlib `xml.etree`, `html`, `json`, `pathlib`), pytest, Cloudflare Pages + `_worker.js`, wrangler.
---
## Why This Matters (context for the implementer)
Current state, verified 2026-08-14:
- `website/episode.html` is a JS shell. It has **zero** server-rendered episode content — it fetches `/feed`, finds the matching `<item>` client-side, then fetches `/transcripts/<slug>.txt`.
- URLs are query params: `/episode.html?slug=episode-58-...`. 54 of the 64 sitemap entries are this shape.
- `website/transcripts/` holds **58 `.txt` files, 3.0 MB total**, none of which appear in `sitemap.xml` and which are linked only from JavaScript.
- `_worker.js:107-165` injects real `<title>`/OG tags — but **only when the User-Agent matches `facebookexternalhit|twitterbot|linkedinbot|slackbot|discordbot|telegrambot|whatsapp|pinterest|redditbot`**. Googlebot, Bingbot, GPTBot, ClaudeBot and PerplexityBot are all absent, so search and answer engines get the generic shell.
That UA gate is also a **cloaking risk**: serving different HTML to crawlers than to users is against Google's guidelines. Google deprecated "dynamic rendering" as a workaround. Task 6 removes the gate rather than extending it — once pages are static, nothing needs UA sniffing.
### Data facts confirmed before writing this plan
- RSS feed: `https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml`, HTTP 200, 124 KB, **57 `<item>` elements**.
- Each item has: `<title>`, `<link>`, `<pubDate>`, `<guid>`, `<description>` (CDATA-wrapped HTML), `<enclosure url=...>`, `<itunes:duration>` (seconds, e.g. `4770`), `<itunes:episode>` (e.g. `58`).
- Slug is derived from `<link>`: everything after `/episodes/`, trailing slash stripped.
- **All 57 feed episodes have a matching transcript file.** Zero missing.
- One orphan transcript, `episode-32-tacos-taxes-and-tall-tales.txt`, has no feed item — ep 32 never finished publishing (`data/publish_state.json` shows a castopod step and nothing else). **The generator must skip orphans, not crash on them.**
- Transcript format is plain text, blank-line separated, `SPEAKER: text` per paragraph — e.g. `LUKE: Alright, welcome back...` / `SLIM: Hey Luke, yeah thanks...`.
---
## Task 1: Transcript parser
**Files:**
- Create: `website_gen/transcript.py`
- Test: `tests/test_transcript_parser.py`
**Step 1: Write the failing test**
```python
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from website_gen.transcript import parse_transcript
def test_splits_speaker_turns():
raw = "LUKE: Welcome back.\n\nSLIM: Hey Luke, thanks for taking my call."
turns = parse_transcript(raw)
assert turns == [("LUKE", "Welcome back."),
("SLIM", "Hey Luke, thanks for taking my call.")]
def test_unlabeled_paragraph_carries_previous_speaker():
raw = "LUKE: First thing.\n\nStill Luke talking."
assert parse_transcript(raw) == [("LUKE", "First thing."),
("LUKE", "Still Luke talking.")]
def test_ignores_blank_and_whitespace_paragraphs():
raw = "LUKE: One.\n\n \n\nSLIM: Two."
assert len(parse_transcript(raw)) == 2
def test_speaker_name_with_spaces_is_not_treated_as_label():
"""A colon mid-sentence must not be mistaken for a speaker label."""
raw = "LUKE: Here's the thing: it was the alternator."
turns = parse_transcript(raw)
assert len(turns) == 1
assert turns[0][0] == "LUKE"
assert "the thing: it was" in turns[0][1]
def test_empty_input_returns_empty_list():
assert parse_transcript("") == []
assert parse_transcript(" \n\n ") == []
```
**Step 2: Run test to verify it fails**
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
Expected: FAIL — `ModuleNotFoundError: No module named 'website_gen'`
**Step 3: Write minimal implementation**
```python
import re
SPEAKER_RE = re.compile(r"^([A-Z][A-Z0-9 .'-]{0,30}):\s*(.*)$", re.DOTALL)
def parse_transcript(raw: str) -> list[tuple[str, str]]:
"""Split a transcript into (speaker, text) turns.
Paragraphs are blank-line separated. A paragraph that does not open with a
SPEAKER: label is attributed to whoever spoke last, which is how the
transcriber emits long turns that wrap.
"""
turns: list[tuple[str, str]] = []
current = None
for para in re.split(r"\n\s*\n", raw or ""):
para = para.strip()
if not para:
continue
m = SPEAKER_RE.match(para)
if m:
current = m.group(1).strip()
text = m.group(2).strip()
else:
text = para
if current is None:
current = "LUKE"
if text:
turns.append((current, text))
return turns
```
**Step 4: Run test to verify it passes**
Run: `./venv/bin/python -m pytest tests/test_transcript_parser.py -v`
Expected: PASS, 5 tests
**Step 5: Commit**
```bash
git add website_gen/transcript.py tests/test_transcript_parser.py
git commit -m "Add transcript parser for episode page generation"
```
---
## Task 2: RSS feed loader
**Files:**
- Create: `website_gen/feed.py`
- Test: `tests/test_feed_loader.py`
- Fixture: `tests/fixtures/feed_sample.xml` (hand-trim two `<item>` blocks out of the live feed)
**Step 1: Write the failing test**
```python
from website_gen.feed import parse_feed, Episode
def test_parses_core_fields(feed_xml):
eps = parse_feed(feed_xml)
ep = next(e for e in eps if e.number == 58)
assert ep.slug == "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho"
assert ep.title.startswith("Episode 58: Rayfield's Nephew")
assert ep.duration_seconds == 4770
assert ep.audio_url.startswith("https://")
def test_description_is_stripped_of_cdata_and_html(feed_xml):
ep = parse_feed(feed_xml)[0]
assert "<![CDATA[" not in ep.description
assert "<p>" not in ep.description
def test_slug_comes_from_link_and_drops_trailing_slash(feed_xml):
for ep in parse_feed(feed_xml):
assert not ep.slug.endswith("/")
assert "/" not in ep.slug
def test_pubdate_parses_to_iso_date(feed_xml):
ep = next(e for e in parse_feed(feed_xml) if e.number == 58)
assert ep.published_iso.startswith("2026-08-04")
```
**Step 2: Run to verify it fails**
Run: `./venv/bin/python -m pytest tests/test_feed_loader.py -v`
Expected: FAIL — module missing
**Step 3: Implement**
Use `xml.etree.ElementTree` with the itunes namespace `http://www.itunes.com/dtds/podcast-1.0.dtd`. Dataclass:
```python
@dataclass
class Episode:
number: int | None
slug: str
title: str
description: str
published_iso: str
duration_seconds: int | None
audio_url: str
```
Parse `pubDate` with `email.utils.parsedate_to_datetime`. Strip CDATA and tags from `<description>` with a regex, then `html.unescape`.
**Step 4: Verify passes.** **Step 5: Commit.**
```bash
git commit -m "Add RSS feed loader for episode page generation"
```
---
## Task 3: Page renderer
**Files:**
- Create: `website_gen/render.py`, `website_gen/templates/episode.html`
- Test: `tests/test_episode_render.py`
The template is a full standalone page matching the existing site chrome (copy the `<nav>`, footer markup and `css/style.css?v=6` link from `website/how-it-works.html` so it looks native).
**Step 1: Write the failing test**
```python
import json, re
from website_gen.render import render_episode_page
def test_title_and_canonical_are_episode_specific(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hello.")])
assert "<title>Episode 58: Rayfield" in html
assert '<link rel="canonical" href="https://lukeattheroost.com/episode/episode-58-' in html
def test_transcript_is_in_the_html_not_fetched_by_js(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "The Marfa Lights are real.")])
assert "The Marfa Lights are real." in html
assert "fetch(" not in html
def test_emits_valid_podcastepisode_schema(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi.")])
block = re.search(r'<script type="application/ld\+json">(.*?)</script>', html, re.S).group(1)
data = json.loads(block)
types = {o["@type"] for o in (data if isinstance(data, list) else [data])}
assert "PodcastEpisode" in types
def test_escapes_html_in_transcript_text(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "5 < 6 & <script>alert(1)</script>")])
assert "<script>alert(1)</script>" not in html
assert "&lt;script&gt;" in html
def test_escapes_quotes_in_title_meta(sample_episode):
sample_episode.title = 'Episode 1: The "Best" Show'
html = render_episode_page(sample_episode, turns=[])
assert 'content="Episode 1: The "Best"' not in html # must be escaped
def test_speaker_labels_get_semantic_markup(sample_episode):
html = render_episode_page(sample_episode, turns=[("LUKE", "Hi."), ("SLIM", "Hey.")])
assert html.count('class="transcript-turn"') == 2
assert "LUKE" in html and "SLIM" in html
```
**Step 3: Implementation notes**
- Escape every interpolated value with `html.escape(value, quote=True)`. The transcript is user-facing text from Whisper; treat it as untrusted.
- Schema block is a JSON array containing `PodcastEpisode` and `BreadcrumbList`:
```python
{
"@context": "https://schema.org",
"@type": "PodcastEpisode",
"url": f"https://lukeattheroost.com/episode/{ep.slug}/",
"name": ep.title,
"description": ep.description,
"datePublished": ep.published_iso,
"timeRequired": f"PT{ep.duration_seconds}S",
"associatedMedia": {"@type": "MediaObject", "contentUrl": ep.audio_url},
"partOfSeries": {
"@type": "PodcastSeries",
"name": "Luke at the Roost",
"url": "https://lukeattheroost.com",
},
"contentLocation": {
"@type": "Place",
"name": "Big Bend, West Texas",
"address": {"@type": "PostalAddress", "addressLocality": "Alpine",
"addressRegion": "TX", "addressCountry": "US"},
},
}
```
The `contentLocation` on every episode is the point of the whole exercise — it is what ties 57 pages of West Texas conversation to the region geographically.
- Include a native `<audio controls preload="none" src="{audio_url}">` so the page is useful without JS.
- Add prev/next episode links. Internal linking is what gets 57 pages crawled instead of 3.
**Step 5: Commit**
```bash
git commit -m "Add episode page renderer with PodcastEpisode schema"
```
---
## Task 4: Generator CLI
**Files:**
- Create: `generate_episode_pages.py` (repo root, matching the existing script convention)
- Test: `tests/test_episode_generator.py`
**Behaviour:**
```
python generate_episode_pages.py # fetch live feed, write all pages
python generate_episode_pages.py --feed FILE # use a local feed (tests, offline)
python generate_episode_pages.py --dry-run # report what would be written
```
**Step 1: Failing tests**
```python
def test_writes_one_index_html_per_feed_episode(tmp_path, feed_file, transcripts_dir):
n = generate(feed_file, transcripts_dir, tmp_path)
assert n == 2
assert (tmp_path / "episode" / "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" / "index.html").exists()
def test_orphan_transcript_without_feed_item_is_skipped(tmp_path, feed_file, transcripts_dir):
"""episode-32 has a transcript but was never published to the feed."""
generate(feed_file, transcripts_dir, tmp_path)
assert not (tmp_path / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists()
def test_missing_transcript_still_produces_a_page(tmp_path, feed_file, transcripts_dir):
"""An episode published before its transcript lands must not break the build."""
(transcripts_dir / "episode-58-....txt").unlink()
generate(feed_file, transcripts_dir, tmp_path)
html = (tmp_path / "episode" / "episode-58-..." / "index.html").read_text()
assert "Transcript not yet available" in html
def test_dry_run_writes_nothing(tmp_path, feed_file, transcripts_dir):
generate(feed_file, transcripts_dir, tmp_path, dry_run=True)
assert not (tmp_path / "episode").exists()
```
**Step 5: Commit**
```bash
git commit -m "Add episode page generator CLI"
```
---
## Task 5: Sitemap regeneration
**Files:**
- Modify: `generate_episode_pages.py` (add `--sitemap`)
- Modify: `website/sitemap.xml` (regenerated output)
- Test: `tests/test_sitemap.py`
The current sitemap has 64 `<url>` entries, 54 of them `?slug=` URLs. Those must be **replaced**, not supplemented — leaving both forms invites duplicate-content dilution even with a canonical.
**Tests:**
```python
def test_sitemap_contains_no_query_param_urls(generated_sitemap):
assert "episode.html?slug=" not in generated_sitemap
def test_sitemap_has_one_entry_per_episode(generated_sitemap):
assert generated_sitemap.count("<loc>https://lukeattheroost.com/episode/") == 57
def test_static_pages_survive_regeneration(generated_sitemap):
for path in ["", "/how-it-works", "/clips", "/stats", "/privacy", "/terms", "/llms.txt"]:
assert f"<loc>https://lukeattheroost.com{path}</loc>" in generated_sitemap
def test_lastmod_matches_episode_publish_date(generated_sitemap):
assert "<lastmod>2026-08-04</lastmod>" in generated_sitemap
```
**Commit:** `git commit -m "Generate sitemap from feed with clean episode URLs"`
---
## Task 6: Worker — 301 the old URL, remove the UA gate
**Files:**
- Modify: `website/_worker.js:107-165`
Replace the entire social-crawler injection block with a redirect. Once pages are static, the injection is dead code and the UA gate is a liability.
```javascript
// Legacy query-param episode URLs -> clean paths.
// Published social posts and YouTube descriptions still point at the old
// form, so this 301 has to stay indefinitely.
if (url.pathname === "/episode.html" && url.searchParams.get("slug")) {
const slug = url.searchParams.get("slug").replace(/[^a-z0-9-]/gi, "");
if (slug) {
return Response.redirect(`https://lukeattheroost.com/episode/${slug}/`, 301);
}
}
```
**Why the slug is sanitised:** it lands in a `Location:` header. Stripping to `[a-z0-9-]` prevents CRLF injection and open-redirect via a crafted `?slug=`.
**Verify manually after deploy:**
```bash
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" | head -3
# Expect: HTTP/2 301 + location: https://lukeattheroost.com/episode/episode-58-.../
```
**Commit:** `git commit -m "Redirect legacy episode URLs and drop crawler UA gate"`
---
## Task 7: Retire the client-rendered page
**Files:**
- Delete: `website/episode.html`, `website/js/episode.js`
- Modify: `website/js/app.js` — episode links must point at `/episode/<slug>/`
- Modify: `website/_redirects` — keep `/episodes.html /episode 302`
Check every internal link first:
```bash
grep -rn "episode.html?slug=" website/ --include=*.html --include=*.js
```
All must become `/episode/<slug>/`. Also fix `publish_episode.py:1168`, which builds the `episode_url` used in social posts and YouTube descriptions:
```python
episode_url = f"https://lukeattheroost.com/episode/{episode_slug}/"
```
**Commit:** `git commit -m "Point internal links at clean episode URLs"`
---
## Task 8: Wire into the publish pipeline
**Files:**
- Modify: `publish_episode.py` — after the transcript is written to `website/transcripts/`
```python
subprocess.run([sys.executable, "generate_episode_pages.py", "--sitemap"], check=False)
```
`check=False` on purpose: a generator failure must not abort a publish that has already pushed audio to Castopod. Log loudly instead.
**Test:** `tests/test_publish_generates_page.py` — assert the generator is invoked with `--sitemap` after a successful publish (monkeypatch `subprocess.run`).
**Commit:** `git commit -m "Regenerate episode pages during publish"`
---
## Task 9: Deploy and verify
```bash
./venv/bin/python -m pytest tests/ -q # full suite green
python generate_episode_pages.py --sitemap # 57 pages + sitemap
npx wrangler pages deploy website/ --project-name=lukeattheroost --branch=main
```
**Post-deploy checks — all must pass:**
```bash
# 1. Page is static: transcript present with JS disabled
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -c "Mitchell Flat" # >= 1
# 2. Real title in raw HTML, no UA spoofing
curl -s https://lukeattheroost.com/episode/episode-58-.../ | grep -o "<title>.*</title>"
# 3. Same bytes for Googlebot as for a browser (no cloaking)
diff <(curl -s -A "Mozilla/5.0" URL) <(curl -s -A "Googlebot/2.1" URL) && echo "no cloaking"
# 4. Legacy URL 301s
curl -sI "https://lukeattheroost.com/episode.html?slug=episode-58-..." | grep -i "^location"
# 5. Schema validates
# paste page source into https://validator.schema.org/
```
Then: submit the new `sitemap.xml` in Google Search Console and request indexing on three episode pages as a sample.
---
## Risks
| Risk | Mitigation |
|---|---|
| URL change loses existing ranking | 301 (permanent) from Task 6 passes equity; the old form stays supported forever |
| Published YouTube/social links break | Same 301. Ep 58's YouTube description contains a `?slug=` link — verify it after deploy |
| Transcripts contain explicit content | Show is already rated explicit; consider `<meta name="rating" content="adult">` on episode pages |
| Whisper errors become permanent indexed text | Transcripts are already public at `/transcripts/*.txt`; this changes exposure, not accuracy |
| 3 MB of new HTML on every deploy | Static assets, Cloudflare-cached; wrangler uploads only changed files |
| Feed unreachable at build time | `--feed` flag lets the build use a cached copy; generator exits non-zero rather than writing empty pages |
## Out of scope
Tier 3 — the "Big Bend episodes" topic hub, and off-site work (Alpine Avalanche, Marfa/Big Bend groups, Sul Ross). Worth doing after these pages exist to link to.