Add RSS feed loader for episode page generation
This commit is contained in:
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<rss xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:podcast="https://podcastindex.org/namespace/1.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" version="2.0"><channel><atom:link href="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/feed.xml" rel="self" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://pubsubhubbub.superfeedr.com/" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://websubhub.com/hub" rel="hub" type="application/rss+xml"></atom:link><atom:link href="https://switchboard.p3k.io/" rel="hub" type="application/rss+xml"></atom:link><lastBuildDate>Tue, 04 Aug 2026 09:23:29 +0000</lastBuildDate><generator>Castopod - https://castopod.org/</generator><docs>https://cyber.harvard.edu/rss/rss.html</docs><podcast:guid>46cfb731-b4a1-55e2-80f3-cadb1c6c18fa</podcast:guid><title>Luke at the Roost</title><description><![CDATA[<p>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).</p>
|
||||||
|
<p>Website: <a href="https://lukeattheroost.com">https://lukeattheroost.com</a></p>
|
||||||
|
]]></description><podcast:medium>podcast</podcast:medium><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><language>en</language><podcast:location geo="geo:50.8050519,-0.4525129" osm="W996958777">Smugglers&#039; Roost</podcast:location><podcast:locked owner="luke@macneilmediagroup.com">no</podcast:locked><podcast:social priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" accountUrl="https://podcast.macneilmediagroup.com/@LukeAtTheRoost"></podcast:social><itunes:category text="Comedy"></itunes:category><category>Comedy</category><itunes:explicit>true</itunes:explicit><itunes:author>MacNeil Media Group, LLC</itunes:author><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link><itunes:owner><itunes:name>Luke MacNeil</itunes:name><itunes:email>luke@macneilmediagroup.com</itunes:email></itunes:owner><itunes:type>episodic</itunes:type><copyright>MacNeil Media Group, LLC</copyright><image><url>https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png</url><title>Luke at the Roost</title><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost</link></image><item><title>Episode 58: Rayfield's Nephew, the Marfa Lights, and Why Nobody Believes Concho</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.mp3" length="114501589" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</guid><pubDate>Tue, 04 Aug 2026 09:22:38 +0000</pubDate><description><![CDATA[<p>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.</p>]]></description><itunes:duration>4770</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>58</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>58</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-58-rayfield-s-nephew-the-marfa-lights-and-why-nobody-believes-concho-chapters.json" type="application/json+chapters"></podcast:chapters></item><item><title>Episode 57: Trace's Box of Family Secrets</title><enclosure url="https://op3.dev/e,pg=46cfb731-b4a1-55e2-80f3-cadb1c6c18fa/podcast.macneilmediagroup.com/audio/@LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.mp3" length="97583646" type="audio/mpeg"></enclosure><guid>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</guid><pubDate>Tue, 02 Jun 2026 11:32:58 +0000</pubDate><description><![CDATA[<p>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?</p>
|
||||||
|
]]></description><itunes:duration>4066</itunes:duration><link>https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets</link><itunes:image href="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/cover_feed.png"></itunes:image><itunes:explicit>true</itunes:explicit><itunes:episode>57</itunes:episode><itunes:episodeType>full</itunes:episodeType><podcast:episode>57</podcast:episode><podcast:comments uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/episodes/episode-57-trace-s-box-of-family-secrets/comments" contentType="application/podcast-activity+json"></podcast:comments><podcast:socialInteract uri="https://podcast.macneilmediagroup.com/@LukeAtTheRoost/posts/418bda4c-56df-47c3-ae29-6e3eb4b70a50" priority="1" platform="castopod" protocol="activitypub" accountId="@LukeAtTheRoost@podcast.macneilmediagroup.com" pubDate="2026-06-02T11:32:58+0000"></podcast:socialInteract><podcast:transcript url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets.srt" type="application/x-subrip" rel="captions" language="en"></podcast:transcript><podcast:chapters url="https://podcast.macneilmediagroup.com/media/podcasts/LukeAtTheRoost/episode-57-trace-s-box-of-family-secrets-chapters.json" type="application/json+chapters"></podcast:chapters></item></channel></rss>
|
||||||
@@ -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 "<![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")
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_all_items_in_fixture(feed_xml):
|
||||||
|
assert len(parse_feed(feed_xml)) == 2
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user