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