Add episode page generator CLI
This commit is contained in:
@@ -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 <output_root>/episode/<slug>/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()
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user