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 +0000 Castopod - https://castopod.org/ https://cyber.harvard.edu/rss/rss.html 46cfb731-b4a1-55e2-80f3-cadb1c6c18fa Luke at the Roost A 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
+]]>podcast en Smugglers' Roost no Comedy true MacNeil Media Group, LLC https://podcast.macneilmediagroup.com/@LukeAtTheRoostLuke MacNeil luke@macneilmediagroup.com episodic MacNeil Media Group, LLC https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png Luke at the Roost https://podcast.macneilmediagroup.com/@LukeAtTheRoostEpisode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho Tue, 04 Aug 2026 09:22:38 +0000 Luke 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.]]> 4770 https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-conchotrue 58 full 58 Episode 57: Trace's Box of Family Secrets https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets Tue, 02 Jun 2026 11:32:58 +0000 Luke 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?
+]]> 4066 https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secretstrue 57 full 57
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