#!/usr/bin/env python3 """Scan an OPML subscription snapshot and emit recent, relevant feed items as JSON.""" from __future__ import annotations from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, timedelta, timezone import email.utils import html import json import re import sys from urllib.request import Request, urlopen from xml.etree import ElementTree as ET ITUNES = "http://www.itunes.com/dtds/podcast-1.0.dtd" ATOM = "http://www.w3.org/2005/Atom" NOW = datetime.now(timezone.utc) CUTOFF = NOW - timedelta(days=21) TRACKS = { "AI": ["artificial intelligence", " ai ", "agentic", "language model", "llm", "machine learning", "automation"], "Orthodoxy/theology": ["orthodox", "coptic", "syriac", "ethiopian", "patrist", "theology", "christology", "liturgy", "church", "christian"], "Governance": ["governance", "congress", "administrative", "state capacity", "institution", "bureaucr", "constitution", "democracy", "public management", "government"], "Philosophy of science": ["philosophy of science", "epistem", "science studies", "pragmat", "peirce", "latour", "complex systems", "expertise", "scientific"], } ROUTINE = {"Economist Podcasts (subscriber edition)", "The Daily", "Daily Orthodox Scriptures"} def text(node: ET.Element | None, path: str) -> str: if node is None: return "" found = node.find(path) return (found.text or "").strip() if found is not None else "" def parse_date(value: str) -> datetime | None: if not value: return None try: parsed = email.utils.parsedate_to_datetime(value) return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc) except (TypeError, ValueError): try: return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(timezone.utc) except ValueError: return None def clean(value: str) -> str: return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", html.unescape(value))).strip() def fetch(entry: tuple[str, str]) -> tuple[list[dict], str | None]: show_hint, feed_url = entry try: request = Request(feed_url, headers={"User-Agent": "SorenPodcastDiscovery/1.0"}) with urlopen(request, timeout=18) as response: root = ET.fromstring(response.read(8_000_000)) channel = root.find("channel") show = text(channel, "title") or show_hint show_image_node = channel.find(f"{{{ITUNES}}}image") if channel is not None else None show_image = show_image_node.get("href", "") if show_image_node is not None else text(channel, "image/url") results = [] for item in root.findall(".//item"): title = text(item, "title") description = clean(text(item, "description") or text(item, f"{{{ITUNES}}}summary")) published_raw = text(item, "pubDate") published = parse_date(published_raw) if not published or published < CUTOFF or published > NOW + timedelta(days=1): continue enclosure = item.find("enclosure") if enclosure is None or not enclosure.get("url"): continue combined = f" {title} {description} ".lower() scores = {track: sum(1 for term in terms if term in combined) for track, terms in TRACKS.items()} score = sum(scores.values()) if score == 0: continue image_node = item.find(f"{{{ITUNES}}}image") link = text(item, "link") or enclosure.get("url", "") guid = text(item, "guid") or link results.append({ "kind": "rss", "guid": guid, "title": title, "show": show, "link": link, "description": description[:1200], "pub_date": email.utils.format_datetime(published), "published_iso": published.isoformat(), "enclosure_url": enclosure.get("url", ""), "enclosure_type": enclosure.get("type", "audio/mpeg"), "length": int(enclosure.get("length") or 0), "duration": text(item, f"{{{ITUNES}}}duration"), "image": image_node.get("href", "") if image_node is not None else show_image, "source_feed": feed_url, "track_scores": scores, "keyword_score": score, "routine": show in ROUTINE or show_hint in ROUTINE, }) return results, None except Exception as exc: return [], f"{show_hint}: {type(exc).__name__}: {str(exc)[:120]}" def main() -> int: if len(sys.argv) != 2: print(f"Usage: {sys.argv[0]} FILE.opml", file=sys.stderr) return 2 root = ET.parse(sys.argv[1]).getroot() entries = [(outline.get("text", ""), outline.get("xmlUrl", "")) for outline in root.findall(".//outline[@xmlUrl]")] items, errors = [], [] with ThreadPoolExecutor(max_workers=16) as pool: futures = [pool.submit(fetch, entry) for entry in entries] for future in as_completed(futures): found, error = future.result() items.extend(found) if error: errors.append(error) items.sort(key=lambda item: (item["routine"], -item["keyword_score"], item["published_iso"])) print(json.dumps({"feeds": len(entries), "items": items, "errors": errors}, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())