Source code for curated_feed.opml

"""OPML subscription-list parsing.

Feedly (and most readers) export feeds nested inside folder outlines, so the
walk is recursive and records the folder path for each feed. Phase 0 does not
use the folder, but later phases weight and quota by source group.
"""

from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path

FOLDER_SEPARATOR = "/"


[docs] class OpmlError(Exception): """Raised when the OPML file is missing or cannot be parsed."""
[docs] @dataclass(frozen=True, slots=True) class Feed: """A single subscription from the OPML file.""" title: str xml_url: str folder: str | None = None
[docs] def parse_opml(path: Path | str) -> list[Feed]: """Parse an OPML export into feeds, in document order. Outlines without an `xmlUrl` are treated as folders and recursed into. Feeds appearing more than once (the same `xmlUrl` filed under two folders) are kept once, at their first occurrence. """ path = Path(path) try: # The OPML file is a subscription list the user exports and places here # themselves, so it is treated as local input rather than a hostile # document; defusedxml is not pulled in for it. tree = ET.parse(path) # noqa: S314 except FileNotFoundError as exc: raise OpmlError(f"OPML file not found: {path}") from exc except ET.ParseError as exc: raise OpmlError(f"could not parse OPML file {path}: {exc}") from exc body = tree.getroot().find("body") if body is None: raise OpmlError(f"{path}: OPML has no <body> element") feeds: list[Feed] = [] seen: set[str] = set() for outline in body: _walk(outline, folders=(), feeds=feeds, seen=seen) return feeds
def _walk( outline: ET.Element, *, folders: tuple[str, ...], feeds: list[Feed], seen: set[str], ) -> None: if outline.tag != "outline": return xml_url = (outline.get("xmlUrl") or "").strip() label = _label(outline) if xml_url: if xml_url in seen: return seen.add(xml_url) feeds.append( Feed( title=label or xml_url, xml_url=xml_url, folder=FOLDER_SEPARATOR.join(folders) if folders else None, ) ) return # No xmlUrl: a folder (or a decorative outline). Recurse, extending the path. child_folders = (*folders, label) if label else folders for child in outline: _walk(child, folders=child_folders, feeds=feeds, seen=seen) def _label(outline: ET.Element) -> str: for attribute in ("title", "text"): value = (outline.get(attribute) or "").strip() if value: return value return ""