From fdb9f57660cac89a337fd2168384041628087cf1 Mon Sep 17 00:00:00 2001 From: tcpsyn Date: Fri, 14 Aug 2026 03:41:53 -0500 Subject: [PATCH] Add RSS feed loader for episode page generation --- tests/fixtures/feed_sample.xml | 5 +++ tests/test_feed_loader.py | 42 +++++++++++++++++++ website_gen/feed.py | 75 ++++++++++++++++++++++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 tests/fixtures/feed_sample.xml create mode 100644 tests/test_feed_loader.py create mode 100644 website_gen/feed.py diff --git a/tests/fixtures/feed_sample.xml b/tests/fixtures/feed_sample.xml new file mode 100644 index 0000000..c74ac4d --- /dev/null +++ b/tests/fixtures/feed_sample.xml @@ -0,0 +1,5 @@ + +Tue, 04 Aug 2026 09:23:29 +0000Castopod - https://castopod.org/https://cyber.harvard.edu/rss/rss.html46cfb731-b4a1-55e2-80f3-cadb1c6c18faLuke at the RoostA late-night call-in radio show broadcast from a desert hermit’s RV, featuring a mix of real callers and AI-generated callers talking to Luke about life, love, and everything in between. Call in live: 208-439-LUKE (208-439-5853).

+

Website: https://lukeattheroost.com

+]]>
podcastenSmugglers' RoostnoComedytrueMacNeil Media Group, LLChttps://podcast.macneilmediagroup.com/@LukeAtTheRoostLuke MacNeilluke@macneilmediagroup.comepisodicMacNeil Media Group, LLChttps://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.pngLuke at the Roosthttps://podcast.macneilmediagroup.com/@LukeAtTheRoostEpisode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Conchohttps://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-conchoTue, 04 Aug 2026 09:22:38 +0000Luke fields calls about stolen catalytic converters, unexplained phenomena over Mitchell Flat, a sheriff's deputy's moral dilemma, and a fence builder convinced someone is mapping West Texas water sources. Plus: Donald Judd's aluminum boxes change everything for a skeptical rancher named Fern.

]]>
4770https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-conchotrue58full58
Episode 57: Trace's Box of Family Secretshttps://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secretsTue, 02 Jun 2026 11:32:58 +0000Luke hosts another late-night call-in show from Alpine, Texas, featuring intense conversations about personal dilemmas. Callers include Silas struggling with a community move, a landman wrestling with an ethical choice, and Trace discovering hidden letters with a potentially life-changing secret. What happens when the past suddenly becomes present?

+]]>
4066https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secretstrue57full57
diff --git a/tests/test_feed_loader.py b/tests/test_feed_loader.py new file mode 100644 index 0000000..e807c2f --- /dev/null +++ b/tests/test_feed_loader.py @@ -0,0 +1,42 @@ +from pathlib import Path + +import pytest + +from website_gen.feed import parse_feed + +FIXTURE = Path(__file__).parent / "fixtures" / "feed_sample.xml" + + +@pytest.fixture +def feed_xml(): + return FIXTURE.read_text(encoding="utf-8") + + +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 "" 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") + + +def test_returns_all_items_in_fixture(feed_xml): + assert len(parse_feed(feed_xml)) == 2 diff --git a/website_gen/feed.py b/website_gen/feed.py new file mode 100644 index 0000000..202c4d0 --- /dev/null +++ b/website_gen/feed.py @@ -0,0 +1,75 @@ +import html +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from email.utils import parsedate_to_datetime + +ITUNES_NS = {"itunes": "http://www.itunes.com/dtds/podcast-1.0.dtd"} + +TAG_RE = re.compile(r"<[^>]+>") +WHITESPACE_RE = re.compile(r"\s+") + + +@dataclass +class Episode: + number: int | None + slug: str + title: str + description: str + published_iso: str + duration_seconds: int | None + audio_url: str + + +def parse_feed(xml_text: str) -> list[Episode]: + """Turn RSS feed XML into Episode records, skipping items with no usable link.""" + channel = ET.fromstring(xml_text).find("channel") + if channel is None: + return [] + + episodes: list[Episode] = [] + for item in channel.findall("item"): + slug = _slug_from_link(item.findtext("link")) + if not slug: + continue + enclosure = item.find("enclosure") + episodes.append( + Episode( + number=_as_int(item.findtext("itunes:episode", namespaces=ITUNES_NS)), + slug=slug, + title=(item.findtext("title") or "").strip(), + description=_plain_text(item.findtext("description")), + published_iso=_iso_date(item.findtext("pubDate")), + duration_seconds=_as_int(item.findtext("itunes:duration", namespaces=ITUNES_NS)), + audio_url=(enclosure.get("url") or "") if enclosure is not None else "", + ) + ) + return episodes + + +def _slug_from_link(link: str | None) -> str: + slug = (link or "").strip().rstrip("/") + if "/episodes/" not in slug: + return "" + return slug.rsplit("/episodes/", 1)[-1] + + +def _plain_text(raw: str | None) -> str: + text = TAG_RE.sub(" ", raw or "") + return WHITESPACE_RE.sub(" ", html.unescape(text)).strip() + + +def _iso_date(raw: str | None) -> str: + if not raw: + return "" + try: + return parsedate_to_datetime(raw).isoformat() + except (TypeError, ValueError): + return "" + + +def _as_int(raw: str | None) -> int | None: + try: + return int((raw or "").strip()) + except ValueError: + return None