diff --git a/generate_episode_pages.py b/generate_episode_pages.py new file mode 100644 index 0000000..98dbf40 --- /dev/null +++ b/generate_episode_pages.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Generate a static, crawlable page for every episode in the podcast feed.""" + +import argparse +import shutil +import sys +import tempfile +import urllib.request +from pathlib import Path + +from website_gen.feed import parse_feed +from website_gen.render import FEED_URL, render_episode_page +from website_gen.transcript import parse_transcript + +REPO_ROOT = Path(__file__).resolve().parent +DEFAULT_OUTPUT = REPO_ROOT / "website" +DEFAULT_TRANSCRIPTS = DEFAULT_OUTPUT / "transcripts" +USER_AGENT = "lukeattheroost-site-generator/1.0" + + +def generate(feed_path, transcripts_dir, output_root, dry_run=False) -> int: + """Write /episode//index.html for each feed episode. + + Driven entirely by the feed: a transcript with no feed item is ignored, and a + feed item with no transcript still gets a page. Never touches the network. + """ + feed_path = Path(feed_path) + transcripts_dir = Path(transcripts_dir) + output_root = Path(output_root) + + episodes = parse_feed(feed_path.read_text(encoding="utf-8")) + episodes.sort(key=lambda ep: ep.number if ep.number is not None else -1, reverse=True) + + written = 0 + for i, episode in enumerate(episodes): + transcript_file = transcripts_dir / f"{episode.slug}.txt" + turns = ( + parse_transcript(transcript_file.read_text(encoding="utf-8")) + if transcript_file.is_file() + else [] + ) + html = render_episode_page( + episode, + turns, + prev_ep=episodes[i + 1] if i + 1 < len(episodes) else None, + next_ep=episodes[i - 1] if i > 0 else None, + ) + page = output_root / "episode" / episode.slug / "index.html" + if not dry_run: + page.parent.mkdir(parents=True, exist_ok=True) + page.write_text(html, encoding="utf-8") + written += 1 + print(f"{'would write' if dry_run else 'wrote'} {page} ({len(turns)} turns)") + + return written + + +def _fetch_feed(url: str) -> str: + # Cloudflare 403s the default Python-urllib user agent. + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=30) as resp: + return resp.read().decode("utf-8") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--feed", help="local feed XML file (default: fetch the live feed)") + parser.add_argument("--output", default=DEFAULT_OUTPUT, help="output directory") + parser.add_argument("--transcripts", default=DEFAULT_TRANSCRIPTS, help="transcript directory") + parser.add_argument("--dry-run", action="store_true", help="report without writing") + args = parser.parse_args() + + feed_path = args.feed + tmp_feed = None + if not feed_path: + try: + xml_text = _fetch_feed(FEED_URL) + except Exception as exc: + print(f"Failed to fetch {FEED_URL}: {exc}", file=sys.stderr) + sys.exit(1) + tmp_feed = Path(tempfile.mkdtemp(prefix="episode-feed-")) / "feed.xml" + tmp_feed.write_text(xml_text, encoding="utf-8") + feed_path = tmp_feed + + try: + count = generate(feed_path, args.transcripts, args.output, dry_run=args.dry_run) + finally: + if tmp_feed is not None: + shutil.rmtree(tmp_feed.parent, ignore_errors=True) + + print(f"{count} episode pages {'planned' if args.dry_run else 'written'}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_episode_generator.py b/tests/test_episode_generator.py new file mode 100644 index 0000000..ae9aeac --- /dev/null +++ b/tests/test_episode_generator.py @@ -0,0 +1,94 @@ +import shutil +from pathlib import Path + +import pytest + +from generate_episode_pages import generate + +FIXTURE_FEED = Path(__file__).parent / "fixtures" / "feed_sample.xml" + +EP58_SLUG = "episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho" +EP57_SLUG = "episode-57-trace-s-box-of-family-secrets" + + +@pytest.fixture +def feed_file(tmp_path): + dest = tmp_path / "feed.xml" + shutil.copyfile(FIXTURE_FEED, dest) + return dest + + +@pytest.fixture +def transcripts_dir(tmp_path): + d = tmp_path / "transcripts" + d.mkdir() + (d / f"{EP58_SLUG}.txt").write_text( + "LUKE: Marfa Lights, line one.\n\nCONCHO: Nobody believes me.\n" + ) + (d / f"{EP57_SLUG}.txt").write_text("LUKE: Trace, what's in the box?\n\nTRACE: Letters.\n") + return d + + +@pytest.fixture +def out_root(tmp_path): + return tmp_path / "site" + + +def test_writes_one_index_html_per_feed_episode(feed_file, transcripts_dir, out_root): + n = generate(feed_file, transcripts_dir, out_root) + assert n == 2 + assert (out_root / "episode" / EP58_SLUG / "index.html").exists() + assert (out_root / "episode" / EP57_SLUG / "index.html").exists() + + +def test_orphan_transcript_without_feed_item_is_skipped(feed_file, transcripts_dir, out_root): + """episode-32 has a transcript but was never published to the feed.""" + (transcripts_dir / "episode-32-tacos-taxes-and-tall-tales.txt").write_text("LUKE: Hi.") + generate(feed_file, transcripts_dir, out_root) + assert not (out_root / "episode" / "episode-32-tacos-taxes-and-tall-tales").exists() + + +def test_missing_transcript_still_produces_a_page(feed_file, transcripts_dir, out_root): + """An episode published before its transcript lands must not break the build.""" + for f in transcripts_dir.glob("*.txt"): + f.unlink() + n = generate(feed_file, transcripts_dir, out_root) + assert n == 2 + html = next((out_root / "episode").rglob("index.html")).read_text() + assert "Transcript not yet available" in html + + +def test_dry_run_writes_nothing(feed_file, transcripts_dir, out_root): + n = generate(feed_file, transcripts_dir, out_root, dry_run=True) + assert n == 2 + assert not out_root.exists() + + +def test_transcript_content_lands_in_the_page(feed_file, transcripts_dir, out_root): + generate(feed_file, transcripts_dir, out_root) + html = (out_root / "episode" / EP58_SLUG / "index.html").read_text() + assert "transcript-turn" in html + assert "Nobody believes me." in html + + +def test_pages_link_to_each_other(feed_file, transcripts_dir, out_root): + """Prev/next links are how a crawler reaches all 57 pages.""" + generate(feed_file, transcripts_dir, out_root) + pages = list((out_root / "episode").rglob("index.html")) + combined = "\n".join(p.read_text() for p in pages) + assert combined.count("/episode/") >= len(pages) + + +def test_prev_points_older_and_next_points_newer(feed_file, transcripts_dir, out_root): + generate(feed_file, transcripts_dir, out_root) + newest = (out_root / "episode" / EP58_SLUG / "index.html").read_text() + oldest = (out_root / "episode" / EP57_SLUG / "index.html").read_text() + assert f'rel="prev" href="/episode/{EP57_SLUG}/"' in newest + assert 'rel="next"' not in newest + assert f'rel="next" href="/episode/{EP58_SLUG}/"' in oldest + assert 'rel="prev"' not in oldest + + +def test_accepts_string_paths(feed_file, transcripts_dir, out_root): + n = generate(str(feed_file), str(transcripts_dir), str(out_root)) + assert n == 2