feed.py 2.7 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
  1. """Fetch and parse the miroppb.com/ASOT RSS feed."""
  2. from __future__ import annotations
  3. import re
  4. import urllib.request
  5. import xml.etree.ElementTree as ET
  6. from dataclasses import dataclass
  7. from datetime import datetime
  8. from email.utils import parsedate_to_datetime
  9. BASE = "https://miroppb.com"
  10. FEED_URL = BASE + "/rss"
  11. USER_AGENT = (
  12. "asot-player/0.1 (a terminal ASOT player; reads episodes for playback) "
  13. "https://github.com/asot-player"
  14. )
  15. _EP_RE = re.compile(r"Episode\s+(\d+)", re.IGNORECASE)
  16. @dataclass
  17. class Episode:
  18. number: int
  19. url: str
  20. pubdate: datetime | None = None
  21. title: str = ""
  22. @property
  23. def date_str(self) -> str:
  24. return self.pubdate.strftime("%d %b %Y") if self.pubdate else ""
  25. def _ua_request(url: str, headers: dict[str, str] | None = None):
  26. req = urllib.request.Request(
  27. url,
  28. headers={**{"User-Agent": USER_AGENT}, **(headers or {})},
  29. method="GET",
  30. )
  31. try:
  32. with urllib.request.urlopen(req, timeout=20) as r:
  33. return r.status, r.headers, r.read()
  34. except Exception as e: # noqa: BLE001
  35. raise ConnectionError(f"fetch failed: {url} ({e})") from e
  36. def _parse_date(raw: str) -> datetime | None:
  37. if not raw:
  38. return None
  39. try:
  40. return parsedate_to_datetime(raw.strip())
  41. except Exception:
  42. return None
  43. def fetch_episodes(limit: int | None = None) -> list[Episode]:
  44. """Return episodes newest-first from the miroppb RSS feed."""
  45. _, _, body = _ua_request(FEED_URL)
  46. root = ET.fromstring(body)
  47. eps: list[Episode] = []
  48. for item in root.iter("item"):
  49. title = item.findtext("title", "") or ""
  50. m = _EP_RE.search(title)
  51. if not m:
  52. continue
  53. num = int(m.group(1))
  54. enc = item.find("enclosure")
  55. url = (enc.get("url") if enc is not None else "") or f"{BASE}/ASOT/{num}"
  56. num_from_url = re.search(r"ASOT_Ep_(\d+)\.mp3$", url)
  57. if num_from_url:
  58. num = int(num_from_url.group(1))
  59. eps.append(
  60. Episode(
  61. number=num,
  62. url=url,
  63. pubdate=_parse_date(item.findtext("pubDate", "")),
  64. title=title,
  65. )
  66. )
  67. # de-dupe on number (feed can repeat items after a rebuild)
  68. seen: dict[int, Episode] = {}
  69. for ep in eps:
  70. if ep.number not in seen:
  71. seen[ep.number] = ep
  72. out = sorted(seen.values(), key=lambda e: -e.number)
  73. return out[:limit] if limit else out
  74. def episode_by_number(episodes: list[Episode], number: int) -> Episode | None:
  75. for e in episodes:
  76. if e.number == number:
  77. return e
  78. return None
  79. def latest_number(episodes: list[Episode]) -> int:
  80. return episodes[0].number