diff --git a/CHANGELOG.md b/CHANGELOG.md
index c745704..aa6ea99 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,24 @@
All notable changes to this project are documented here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com/); versions follow [SemVer](https://semver.org/).
+## [0.6.0] — 2026-07-07
+
+### Added
+- `-q/--quiet` flag for `podpull get`: suppresses the spinner/progress UI and prints
+ only saved file paths to stdout — for scripting/piping. (#1, thanks @adjenk!)
+- Robust feed parsing: RSS 2.0 / RSS 1.0 (RDF) / Atom, any-namespace matching,
+ `media:content` + Atom-enclosure fallbacks, dirty-XML sanitize-and-retry
+ (undefined entities, bare `&`, control chars), ISO-8601 dates.
+- Verified against Chinese-market hosts: xiaoyuzhou, Ximalaya, SoundOn, Firstory,
+ WavPub, Typlog, Fireside, Lizhi (offline fixtures + `pytest -m network` live suite).
+- `ximalaya.com/album/` links are now accepted directly.
+- Optional Podcast Index support (BYOK: `PODCASTINDEX_API_KEY`/`_SECRET`):
+ enriches `search` and adds a feed-resolution fallback when iTunes has no feed.
+
+### Fixed
+- Download guard: `text/*` responses (stale enclosures, e.g. Ximalaya CDN) now
+ error instead of writing a garbage audio file.
+
## [0.5.1] — 2026-06-30
### Fixed
diff --git a/README.md b/README.md
index 5b62574..c5a139f 100644
--- a/README.md
+++ b/README.md
@@ -144,7 +144,21 @@ Drive, OneDrive, Dropbox, iCloud, etc. (CJK and ordinary text are kept). When yo
**multiple** episodes at once, they're placed in a sub-folder named after the show.
`` accepts: an Apple show URL, a bare Apple ID, a raw RSS feed URL, an Apple
-episode URL (`?i=`), or a xiaoyuzhou episode URL.
+episode URL (`?i=`), a xiaoyuzhou episode URL, or a Ximalaya album URL
+(`ximalaya.com/album/`).
+
+### Podcast Index (optional)
+
+podpull can enrich `search` and feed resolution with the open
+[Podcast Index](https://podcastindex.org) directory. Get a free API key at
+[api.podcastindex.org](https://api.podcastindex.org/signup) and set:
+
+```bash
+export PODCASTINDEX_API_KEY=...
+export PODCASTINDEX_API_SECRET=...
+```
+
+Without these, podpull behaves exactly as before (iTunes only).
## Roadmap
@@ -153,8 +167,11 @@ episode URL (`?i=`), or a xiaoyuzhou episode URL.
colored help, scriptable stdout. Adds `rich` + `questionary`.
- **v0.3**: renamed `podget` → `podpull`.
- **v0.4**: cloud-safe filename normalization; multi-episode downloads grouped into a per-show folder.
-- **v0.5** (current): `pull` alias for `get`; `podpull skills install` sets up integrations for Claude Code, Codex, OpenCode, and Cursor.
-- **next**: more robust feed parsing, tests on more hosts, Podcast Index support.
+- **v0.5**: `pull` alias for `get`; `podpull skills install` sets up integrations for Claude Code, Codex, OpenCode, and Cursor.
+- **v0.6** (current): robust feed parsing (RSS 2.0 / RSS 1.0 / Atom, dirty-XML recovery),
+ verified against Chinese-market hosts, `ximalaya.com/album/` links, optional
+ Podcast Index (BYOK) search + feed-resolution fallback.
+- **next**: `--json` output mode for scripting.
- **v1+ (`podpull[ai]`)**: opt-in **BYOK summarization** — local transcription
(faster-whisper) + your own LLM key (Anthropic/OpenAI). Fully local, private,
no subscription. Cleanly isolated from the core.
diff --git a/docs/superpowers/plans/2026-07-07-feed-robustness-podcastindex.md b/docs/superpowers/plans/2026-07-07-feed-robustness-podcastindex.md
new file mode 100644
index 0000000..2739ab6
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-07-feed-robustness-podcastindex.md
@@ -0,0 +1,1514 @@
+# Feed Robustness + Podcast Index Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `core.parse_feed` survive real-world feeds (CN + global hosts, Atom/RDF, dirty XML), add optional BYOK Podcast Index search/resolution, and back it all with offline fixtures — shipping as v0.6.0.
+
+**Architecture:** All logic changes live in `src/podpull/core.py` (pure stdlib — CLAUDE.md invariant #1); the only `cli.py` change is `cmd_search` merging Podcast Index results. Tests are offline fixtures under `tests/fixtures/feeds/` driven by one parametrized case table; live-network checks go in a `network`-marked module excluded by default.
+
+**Tech Stack:** Python ≥3.9 stdlib (`xml.etree`, `html.entities`, `hashlib`, `urllib`); pytest; no new runtime dependencies.
+
+**Spec:** `docs/superpowers/specs/2026-07-07-feed-robustness-podcastindex-design.md` (approved 2026-07-07)
+
+**Working branch:** `feed-robustness-podcastindex` (already exists; all commits go here)
+
+**Conventions the engineer must know (from CLAUDE.md):**
+- `core.py` may import ONLY the standard library. UI/rich stays in `cli.py`.
+- stdout is machine output; all human messages go to stderr (`cli.ui` / `_err`).
+- Default test suite must never touch the network.
+- Run everything from the repo root with the venv: `. .venv/bin/activate` (or use `python3 -m pytest`). Full suite: `pytest -q`; lint: `ruff check src`.
+- Version lives in BOTH `src/podpull/__init__.py` and `pyproject.toml`.
+
+---
+
+### Task 1: pytest `network` marker configuration
+
+**Files:**
+- Modify: `pyproject.toml` (add `[tool.pytest.ini_options]` at end of file)
+
+- [ ] **Step 1: Add pytest config**
+
+Append to `pyproject.toml`:
+
+```toml
+[tool.pytest.ini_options]
+markers = [
+ "network: hits live podcast hosts; excluded by default (run: pytest -m network)",
+]
+addopts = "-m 'not network'"
+```
+
+- [ ] **Step 2: Verify the suite still passes and the marker is honored**
+
+Run: `pytest -q`
+Expected: all existing tests pass (24 passed at time of writing; count may differ after PR #3 merges), `no tests ran` is a FAILURE.
+
+Run: `pytest -q -m network`
+Expected: `no tests ran` / all deselected (no network tests exist yet — that's fine).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add pyproject.toml
+git commit -m "test: add network marker, excluded by default"
+```
+
+---
+
+### Task 2: `Episode.date` ISO-8601 fallback
+
+**Files:**
+- Modify: `src/podpull/core.py` (imports + `Episode.date`, currently lines 15–43)
+- Test: `tests/test_core.py` (extend `test_episode_date`)
+
+- [ ] **Step 1: Write the failing test**
+
+In `tests/test_core.py`, replace `test_episode_date` with:
+
+```python
+def test_episode_date():
+ assert _eps()[0].date == "2026-06-27"
+ assert Episode(title="x", pub="garbage", url="u").date == "0000-00-00"
+ # RFC-822 with non-zero-padded day (Lizhi) — parsedate handles it
+ assert Episode(title="x", pub="Sun, 5 Jul 2026 21:00:00 +0800", url="u").date == "2026-07-05"
+ # ISO-8601 (Atom published / dc:date), with and without Z
+ assert Episode(title="x", pub="2026-07-01T08:30:00Z", url="u").date == "2026-07-01"
+ assert Episode(title="x", pub="2026-07-01T08:30:00+08:00", url="u").date == "2026-07-01"
+ assert Episode(title="x", pub="", url="u").date == "0000-00-00"
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `pytest tests/test_core.py::test_episode_date -v`
+Expected: FAIL — the ISO strings return `"0000-00-00"`.
+
+- [ ] **Step 3: Implement**
+
+In `src/podpull/core.py`, add to imports (after `from email.utils import parsedate_to_datetime`):
+
+```python
+from datetime import datetime
+```
+
+Replace the `date` property of `Episode`:
+
+```python
+ @property
+ def date(self) -> str:
+ for parse in (
+ lambda s: parsedate_to_datetime(s), # RFC-822
+ lambda s: datetime.fromisoformat(s.strip().replace("Z", "+00:00")), # ISO-8601
+ ):
+ try:
+ return parse(self.pub).strftime("%Y-%m-%d")
+ except Exception:
+ continue
+ return "0000-00-00"
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pytest tests/test_core.py -q`
+Expected: PASS (all).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/podpull/core.py tests/test_core.py
+git commit -m "feat: parse ISO-8601 pub dates (Atom/dc:date) in Episode.date"
+```
+
+---
+
+### Task 3: namespace-agnostic feed parsing (RSS 2.0 / RSS 1.0 RDF / Atom)
+
+**Files:**
+- Modify: `src/podpull/core.py` — replace `parse_feed` (currently lines 114–139) with helpers + new implementation
+- Create: `tests/fixtures/feeds/rss2_plain.xml`, `tests/fixtures/feeds/rss10_rdf.xml`, `tests/fixtures/feeds/atom.xml`
+- Test: `tests/test_core.py` — add fixture harness + parametrized test
+
+- [ ] **Step 1: Create three fixture files**
+
+`tests/fixtures/feeds/rss2_plain.xml` (regression: today's happy path — itunes author, enclosure):
+
+```xml
+
+
+
+ Plain RSS2 Show
+ Plain Author
+ -
+ EP2
+ Fri, 03 Jul 2026 08:00:00 GMT
+ plain-ep2
+ https://example.test/ep2
+
+
+ -
+ EP1
+ Wed, 01 Jul 2026 08:00:00 GMT
+ plain-ep1
+
+
+
+
+```
+
+`tests/fixtures/feeds/rss10_rdf.xml` (RSS 1.0: default namespace on every element, items OUTSIDE channel — today's parser finds zero items because `.//item` misses namespaced tags):
+
+```xml
+
+
+
+ RDF Show
+ RDF Author
+
+ -
+ RDF EP1
+ 2026-06-30T10:00:00Z
+
+
+
+```
+
+`tests/fixtures/feeds/atom.xml` (Atom: `feed`/`entry`, `link rel="enclosure"`, `author/name`, ISO dates):
+
+```xml
+
+
+ Atom Show
+ Atom Author
+
+ Atom EP1
+ atom-ep1
+ 2026-07-01T08:30:00Z
+
+
+
+
+```
+
+- [ ] **Step 2: Add the fixture harness + failing parametrized test**
+
+In `tests/test_core.py`, add after the imports:
+
+```python
+import io
+from pathlib import Path
+
+FIXTURES = Path(__file__).parent / "fixtures" / "feeds"
+
+# (fixture, show_title, show_author, n_episodes, first_url, first_date)
+FEED_CASES = [
+ ("rss2_plain.xml", "Plain RSS2 Show", "Plain Author", 2,
+ "https://cdn.example.test/ep2.mp3", "2026-07-03"),
+ ("rss10_rdf.xml", "RDF Show", "RDF Author", 1,
+ "https://cdn.example.test/rdf1.mp3", "2026-06-30"),
+ ("atom.xml", "Atom Show", "Atom Author", 1,
+ "https://cdn.example.test/atom1.mp3", "2026-07-01"),
+]
+
+
+def _parse_fixture(monkeypatch, fname):
+ raw = (FIXTURES / fname).read_bytes()
+ monkeypatch.setattr(core, "fetch", lambda url, timeout=45: io.BytesIO(raw))
+ return core.parse_feed("https://example.test/feed")
+
+
+@pytest.mark.parametrize("fname,title,author,count,first_url,first_date", FEED_CASES)
+def test_parse_feed_fixture(monkeypatch, fname, title, author, count, first_url, first_date):
+ t, a, eps = _parse_fixture(monkeypatch, fname)
+ assert t == title
+ assert a == author
+ assert len(eps) == count
+ assert eps[0].url == first_url
+ assert eps[0].date == first_date
+```
+
+- [ ] **Step 3: Run to verify failure**
+
+Run: `pytest tests/test_core.py::test_parse_feed_fixture -v`
+Expected: `rss2_plain.xml` PASSES (regression guard); `rss10_rdf.xml` and `atom.xml` FAIL (0 episodes / empty title).
+
+- [ ] **Step 4: Implement**
+
+In `src/podpull/core.py`, replace the whole `parse_feed` function (keep its position in the file) with:
+
+```python
+def _localname(tag) -> str:
+ """'{ns}Tag' -> 'tag'. Comments/PIs have non-str tags -> ''."""
+ if not isinstance(tag, str):
+ return ""
+ return tag.rsplit("}", 1)[-1].lower()
+
+
+def _clean(text) -> str:
+ return re.sub(r"\s+", " ", (text or "").strip())
+
+
+def _first_text(el, *names) -> str:
+ """First non-empty direct-child text whose localname is in names."""
+ wanted = {n.lower() for n in names}
+ for child in el:
+ if _localname(child.tag) in wanted and _clean(child.text):
+ return _clean(child.text)
+ return ""
+
+
+def _author(el) -> str:
+ """itunes:author / atom author>name -> managingEditor -> dc:creator."""
+ for name in ("author", "managingeditor", "creator"):
+ for child in el:
+ if _localname(child.tag) == name:
+ txt = _first_text(child, "name") or _clean(child.text)
+ if txt:
+ return txt
+ return ""
+
+
+def _pub(item) -> str:
+ """pubDate -> dc:date -> atom published -> atom updated (raw string)."""
+ for name in ("pubdate", "date", "published", "updated"):
+ for child in item:
+ if _localname(child.tag) == name and _clean(child.text):
+ return child.text.strip()
+ return ""
+
+
+def _find_enclosure(item) -> tuple[str, str]:
+ """-> (audio_url, mime) or ('', ''). Priority: enclosure > media:content
+ (audio) > atom link rel=enclosure. First match wins — WavPub/Omny items
+ carry BOTH enclosure and media:content; one item must yield one URL."""
+ for child in item:
+ if _localname(child.tag) == "enclosure" and child.get("url"):
+ return child.get("url"), child.get("type") or ""
+ for child in item:
+ if _localname(child.tag) == "content" and child.get("url"):
+ mime = child.get("type") or ""
+ if mime.startswith("audio/") or child.get("medium") == "audio":
+ return child.get("url"), mime
+ for child in item:
+ if (_localname(child.tag) == "link" and child.get("rel") == "enclosure"
+ and child.get("href")):
+ return child.get("href"), child.get("type") or ""
+ return "", ""
+
+
+def _item_link(item) -> str:
+ link = _first_text(item, "link")
+ if link:
+ return link
+ for child in item: # atom: with no/alternate rel
+ if (_localname(child.tag) == "link" and child.get("href")
+ and child.get("rel") in (None, "alternate")):
+ return child.get("href")
+ return ""
+
+
+def parse_feed(feed_url: str) -> tuple[str, str, list[Episode]]:
+ """-> (show_title, show_author, episodes). Handles RSS 2.0, RSS 1.0 (RDF)
+ and Atom, with any namespace layout (matching by localname)."""
+ root = ET.fromstring(fetch(feed_url).read())
+ chan = next((el for el in root.iter()
+ if _localname(el.tag) in ("channel", "feed")), root)
+ eps: list[Episode] = []
+ for it in root.iter():
+ if _localname(it.tag) not in ("item", "entry"):
+ continue
+ url, mime = _find_enclosure(it)
+ if not url:
+ continue
+ eps.append(Episode(
+ title=_first_text(it, "title"),
+ pub=_pub(it),
+ url=url,
+ mime=mime,
+ guid=_first_text(it, "guid", "id"),
+ link=_item_link(it),
+ ))
+ return _first_text(chan, "title"), _author(chan), eps
+```
+
+Note: the old `itunes = "{http://www.itunes.com/dtds/podcast-1.0.dtd}"` constant inside `parse_feed` disappears — `_author`'s localname matching covers both `http://` and `https://` itunes namespace variants for free.
+
+- [ ] **Step 5: Run tests**
+
+Run: `pytest tests/test_core.py -q`
+Expected: PASS (all, including all three fixture rows).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/podpull/core.py tests/test_core.py tests/fixtures/feeds/
+git commit -m "feat: namespace-agnostic feed parsing — RSS 2.0, RSS 1.0/RDF, Atom"
+```
+
+---
+
+### Task 4: enclosure fallbacks + duplicate dedupe
+
+**Files:**
+- Create: `tests/fixtures/feeds/media_content_only.xml`, `tests/fixtures/feeds/wavpub.xml`, `tests/fixtures/feeds/omny.xml`
+- Test: `tests/test_core.py` (extend `FEED_CASES`)
+
+The implementation already landed in Task 3 (`_find_enclosure`); this task pins its behavior with fixtures. If any row fails, fix `_find_enclosure`, not the fixture.
+
+- [ ] **Step 1: Create fixtures**
+
+`tests/fixtures/feeds/media_content_only.xml` (MRSS only, no `` — today's parser yields zero episodes; also one non-audio `media:content` that must be skipped):
+
+```xml
+
+
+
+ MRSS Show
+ editor@example.test (MRSS Author)
+ -
+ MRSS EP1
+ Thu, 02 Jul 2026 08:00:00 GMT
+
+
+
+
+
+```
+
+`tests/fixtures/feeds/wavpub.xml` (半拿铁-shaped: duplicate `enclosure` + `media:content` per item — must yield ONE episode with the `enclosure` URL; note enclosure and media URLs differ so the test catches wrong-priority bugs):
+
+```xml
+
+
+
+ 半拿铁 | 商业沉浮录
+ 潇磊布道
+ -
+
+ Wed, 01 Jul 2026 22:00:00 +0800
+ wavpub-91
+
+
+
+
+
+```
+
+`tests/fixtures/feeds/omny.xml` (Omny-shaped: enclosure + TWO `media:content` per item):
+
+```xml
+
+
+
+ 馬力歐陪你喝一杯
+ 鏡好聽
+ -
+ EP388 對談
+ Tue, 30 Jun 2026 20:00:00 +0800
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add the (partly failing) case rows**
+
+Append to `FEED_CASES` in `tests/test_core.py`:
+
+```python
+ ("media_content_only.xml", "MRSS Show", "editor@example.test (MRSS Author)", 1,
+ "https://cdn.example.test/mrss1.mp3", "2026-07-02"),
+ ("wavpub.xml", "半拿铁 | 商业沉浮录", "潇磊布道", 1,
+ "https://tk.wavpub.com/track/caffebreve/91.m4a", "2026-07-01"),
+ ("omny.xml", "馬力歐陪你喝一杯", "鏡好聽", 1,
+ "https://omny.example.test/ep388.mp3", "2026-06-30"),
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `pytest tests/test_core.py::test_parse_feed_fixture -v`
+Expected: PASS for all rows (Task 3's `_find_enclosure` already implements the behavior; treat any failure as an implementation bug to fix in `core.py`).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/
+git commit -m "test: enclosure fallback + duplicate media:content dedupe fixtures"
+```
+
+---
+
+### Task 5: dirty-XML recovery (`_sanitize_xml`)
+
+**Files:**
+- Modify: `src/podpull/core.py` (imports; new `_parse_xml` + `_sanitize_xml`; `parse_feed` first line)
+- Create: `tests/fixtures/feeds/dirty_entities.xml`, `tests/fixtures/feeds/utf16_bom.xml`
+- Test: `tests/test_core.py`
+
+- [ ] **Step 1: Create fixtures**
+
+`tests/fixtures/feeds/dirty_entities.xml` — undefined HTML entity, bare `&`, control char, leading blank line. **Write this file exactly as shown** (the blank first line and the literal ` ` / bare `&` are the point; the `\x0b` control char is written via the printf command below since editors strip it):
+
+```xml
+
+
+
+
+ Dirty Show
+ Dirty & Sons
+ -
+ EP1 A & B
+ Mon, 29 Jun 2026 08:00:00 GMT
+
+
+
+
+```
+
+Then inject a control character into the title (after writing the file):
+
+```bash
+python3 - <<'EOF'
+from pathlib import Path
+p = Path("tests/fixtures/feeds/dirty_entities.xml")
+p.write_bytes(p.read_bytes().replace(b"Dirty Show", b"Dirty \x0bShow"))
+EOF
+```
+
+`tests/fixtures/feeds/utf16_bom.xml` — UTF-16 with BOM. Generate it (don't hand-write):
+
+```bash
+python3 - <<'EOF'
+from pathlib import Path
+xml = '''
+UTF16 Show
+- U16 EP1Sun, 28 Jun 2026 08:00:00 GMT
+
+'''
+Path("tests/fixtures/feeds/utf16_bom.xml").write_bytes(b"\xff\xfe" + xml.encode("utf-16-le"))
+EOF
+```
+
+- [ ] **Step 2: Add failing case rows + a fail-loudly test**
+
+Append to `FEED_CASES`:
+
+```python
+ ("dirty_entities.xml", "Dirty Show", "Dirty & Sons", 1,
+ "https://cdn.example.test/dirty1.mp3?a=1&b=2", "2026-06-29"),
+ ("utf16_bom.xml", "UTF16 Show", "", 1,
+ "https://cdn.example.test/u16.mp3", "2026-06-28"),
+```
+
+And add a standalone test (still-broken XML must raise, not return empty):
+
+```python
+def test_parse_feed_hopeless_xml_raises(monkeypatch):
+ monkeypatch.setattr(core, "fetch", lambda url, timeout=45: io.BytesIO(b""))
+ with pytest.raises(core.ET.ParseError):
+ core.parse_feed("https://example.test/feed")
+```
+
+Note on expectations: ` ` becomes a non-breaking space which `_clean` collapses to a regular space → title `"Dirty Show"`; the `\x0b` control char is dropped; `&b=2` keeps its literal `&` after ET unescapes the sanitized `&`.
+
+- [ ] **Step 3: Run to verify failure**
+
+Run: `pytest tests/test_core.py -q`
+Expected: the two new fixture rows FAIL with `xml.etree.ElementTree.ParseError` (undefined entity / junk before declaration). (`utf16_bom` may pass — expat handles UTF-16 BOMs natively; if so it's a cheap regression guard, keep it.)
+
+- [ ] **Step 4: Implement**
+
+In `src/podpull/core.py` add `import html.entities` to the stdlib imports block, in alphabetical order (immediately before `import json`).
+
+Add above `parse_feed`:
+
+```python
+_XML_PREDEFINED = frozenset({"amp", "lt", "gt", "quot", "apos"})
+
+
+def _sanitize_xml(raw: bytes) -> bytes:
+ """Best-effort repair of common real-world feed dirt: junk before the
+ declaration, control chars, undefined HTML entities, bare '&'."""
+ m = re.search(rb'<\?xml[^>]*encoding=["\']([A-Za-z0-9._-]+)["\']', raw[:200])
+ enc = m.group(1).decode("ascii", "replace") if m else "utf-8"
+ try:
+ text = raw.decode(enc, "replace")
+ except LookupError: # unknown codec name in declaration
+ text = raw.decode("utf-8", "replace")
+ text = text.lstrip("\ufeff\x00 \t\r\n")
+ # we re-encode as UTF-8 below, so the declared encoding must not disagree
+ text = re.sub(r'(<\?xml[^>]*?)\s+encoding=["\'][^"\']*["\']', r"\1", text, count=1)
+ text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)
+
+ def _entity(mm):
+ name = mm.group(1)
+ if name in _XML_PREDEFINED:
+ return mm.group(0)
+ ch = html.entities.html5.get(name + ";")
+ return "".join(f"{ord(c)};" for c in ch) if ch else ""
+ text = re.sub(r"&([A-Za-z][A-Za-z0-9]{1,31});", _entity, text)
+ text = re.sub(r"&(?![A-Za-z][A-Za-z0-9]{1,31};|#\d+;|#x[0-9A-Fa-f]+;)", "&", text)
+ return text.encode("utf-8")
+
+
+def _parse_xml(raw: bytes) -> "ET.Element":
+ try:
+ return ET.fromstring(raw)
+ except ET.ParseError as err:
+ try:
+ return ET.fromstring(_sanitize_xml(raw))
+ except ET.ParseError:
+ raise err from None # fail loudly with the original error
+```
+
+In `parse_feed`, change the first line to:
+
+```python
+ root = _parse_xml(fetch(feed_url).read())
+```
+
+- [ ] **Step 5: Run tests**
+
+Run: `pytest tests/test_core.py -q`
+Expected: PASS (all rows + `test_parse_feed_hopeless_xml_raises`).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/podpull/core.py tests/
+git commit -m "feat: sanitize-and-retry recovery for dirty feed XML"
+```
+
+---
+
+### Task 6: Chinese-host fixtures (xiaoyuzhou, Ximalaya, SoundOn, Firstory)
+
+**Files:**
+- Create: `tests/fixtures/feeds/xiaoyuzhou.xml`, `ximalaya.xml`, `soundon.xml`, `firstory.xml`
+- Test: `tests/test_core.py` (extend `FEED_CASES`)
+
+Pure regression pinning — parser code from Tasks 3–5 should already handle these. Any failure = fix `core.py`.
+
+- [ ] **Step 1: Create fixtures**
+
+`tests/fixtures/feeds/xiaoyuzhou.xml` (忽左忽右-shaped: CDATA, dts-api tracking enclosure with the target host embedded in the path):
+
+```xml
+
+
+
+
+
+
+ -
+
+ Thu, 02 Jul 2026 20:00:00 +0800
+ xyz-380
+ https://www.xiaoyuzhoufm.com/episode/abc380
+
+ 本期节目…
]]>
+
+
+
+```
+
+`tests/fixtures/feeds/ximalaya.xml` (声动早咖啡-shaped: `//` double-slash enclosure path, `&`-escaped query with nested percent-encoded URL in `jt=`):
+
+```xml
+
+
+
+ 声动早咖啡
+ 声动活泼
+ -
+ 硅谷巨头抢购核电,AI耗电有多猛?
+ Mon, 06 Jul 2026 07:30:00 +0800
+ xmly-1
+
+
+
+
+```
+
+`tests/fixtures/feeds/soundon.xml` (股癌-shaped: custom `soundon:` namespace, self-referencing `itunes:new-feed-url`, `?times=` querystring):
+
+```xml
+
+
+
+ 股癌
+ 謝孟恭
+ https://feeds.soundon.fm/podcasts/954689a5-3096-43a4-a80b-7810b219cef3.xml
+ 954689a5-3096-43a4-a80b-7810b219cef3
+ -
+ EP560 | 台股創高
+ Sun, 05 Jul 2026 10:00:00 +0800
+ soundon-560
+
+
+
+
+```
+
+`tests/fixtures/feeds/firstory.xml` (百靈果-shaped: EVERYTHING CDATA-wrapped including ``, stale channel-level `pubDate` that must NOT leak into episodes, percent-encoded URL embedded in enclosure path):
+
+```xml
+
+
+
+
+
+ Mon, 01 Dec 2025 00:00:00 +0000
+
+ -
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: Add case rows**
+
+Append to `FEED_CASES`:
+
+```python
+ ("xiaoyuzhou.xml", "忽左忽右", "JustPod", 1,
+ "https://dts-api.xiaoyuzhoufm.com/track/cv4bkgpuglwp/xyz380/media.xyzcdn.net/lvJzoGJDVn.m4a",
+ "2026-07-02"),
+ ("ximalaya.xml", "声动早咖啡", "声动活泼", 1,
+ "https://jt.ximalaya.com//GKwRIW8LWq_ZAX-DKgJcpzTV.m4a?channel=rss&jt=https%3A%2F%2Faod.cos.tx.xmcdn.com%2Fstorages%2Fabc.m4a",
+ "2026-07-06"),
+ ("soundon.xml", "股癌", "謝孟恭", 1,
+ "https://rss.soundon.fm/rssf/954689a5/ep560/rssFileVip.mp3?times=1751700000", "2026-07-05"),
+ ("firstory.xml", "百靈果 News", "百靈果 News", 1,
+ "https://m.cdn.firstory.me/track/cmjaz594i/https%3A%2F%2Ffile.cdn.firstory.me%2Fstory%2Fep500.mp3",
+ "2026-07-04"),
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `pytest tests/test_core.py::test_parse_feed_fixture -v`
+Expected: PASS for all. (Firstory's `2026-07-04` proves the item's CDATA pubDate is used, not the stale channel one — a wrong implementation that reads channel-level pubDate yields `2025-12-01`.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/
+git commit -m "test: CN host fixtures — xiaoyuzhou, ximalaya, soundon, firstory"
+```
+
+---
+
+### Task 7: remaining host fixtures (Lizhi, Typlog, Fireside, Anchor, Acast, Libsyn, Transistor)
+
+**Files:**
+- Create: `tests/fixtures/feeds/lizhi.xml`, `typlog.xml`, `fireside.xml`, `anchor.xml`, `acast.xml`, `libsyn.xml`, `transistor.xml`
+- Test: `tests/test_core.py` (extend `FEED_CASES`)
+
+- [ ] **Step 1: Create fixtures**
+
+`tests/fixtures/feeds/lizhi.xml` (大内密谈-shaped: non-zero-padded RFC-822 date):
+
+```xml
+
+
+
+ 大内密谈
+ 大内密谈
+ -
+ vol.1200 深夜谈谈
+ Sun, 5 Jul 2026 21:00:00 +0800
+
+
+
+
+```
+
+`tests/fixtures/feeds/typlog.xml` (疯投圈-shaped: zero CDATA, entity-escaped text, minimal namespaces):
+
+```xml
+
+
+
+ 疯投圈
+ 黄海、Rio
+ -
+ 76. 咖啡 & 茶饮:新消费的陈年老酒
+ Fri, 03 Jul 2026 12:00:00 GMT
+
+
+
+
+```
+
+`tests/fixtures/feeds/fireside.xml` (科技早知道-shaped: `+0800` dates, Podcasting 2.0 namespace):
+
+```xml
+
+
+
+ 科技早知道
+ 声动活泼
+ no
+ -
+ S8E20 硅谷现场
+ Thu, 02 Jul 2026 08:00:00 +0800
+
+
+
+
+```
+
+`tests/fixtures/feeds/anchor.xml` (台灣通勤第一品牌-shaped: percent-encoded CloudFront URL inside the enclosure path):
+
+```xml
+
+
+
+ 台灣通勤第一品牌
+ 台通
+ -
+ EP600 通勤路上
+ Wed, 01 Jul 2026 16:00:00 GMT
+
+
+
+
+```
+
+`tests/fixtures/feeds/acast.xml` (不明白播客-shaped):
+
+```xml
+
+
+
+ 不明白播客
+ 袁莉和她的朋友们
+ 68004395b4ef799a7a410371
+ -
+ EP-100 访谈
+ Tue, 30 Jun 2026 22:00:00 GMT
+
+
+
+
+```
+
+`tests/fixtures/feeds/libsyn.xml`:
+
+```xml
+
+
+
+ Libsyn Show
+ Libsyn Author
+ -
+ Libsyn EP1
+ Mon, 29 Jun 2026 09:00:00 +0000
+
+
+
+
+```
+
+`tests/fixtures/feeds/transistor.xml`:
+
+```xml
+
+
+
+ Transistor Show
+ Transistor Author
+ -
+ Transistor EP1
+ Sun, 28 Jun 2026 09:00:00 +0000
+
+
+
+
+```
+
+- [ ] **Step 2: Add case rows**
+
+Append to `FEED_CASES`:
+
+```python
+ ("lizhi.xml", "大内密谈", "大内密谈", 1,
+ "http://cdn.lizhi.fm/audio/2026/07/05/1200.mp3", "2026-07-05"),
+ ("typlog.xml", "疯投圈", "黄海、Rio", 1,
+ "https://rio.xyzcdn.net/crazy-capital/ep76.m4a", "2026-07-03"),
+ ("fireside.xml", "科技早知道", "声动活泼", 1,
+ "https://aphid.fireside.fm/d/1437767933/s8e20.mp3", "2026-07-02"),
+ ("anchor.xml", "台灣通勤第一品牌", "台通", 1,
+ "https://anchor.fm/s/1ea77470/podcast/play/999/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2Fep600.m4a",
+ "2026-07-01"),
+ ("acast.xml", "不明白播客", "袁莉和她的朋友们", 1,
+ "https://sphinx.acast.com/p/open/s/68004395/e/100/media.mp3", "2026-06-30"),
+ ("libsyn.xml", "Libsyn Show", "Libsyn Author", 1,
+ "https://traffic.libsyn.com/secure/example/ep1.mp3?dest-id=1", "2026-06-29"),
+ ("transistor.xml", "Transistor Show", "Transistor Author", 1,
+ "https://media.transistor.fm/abc123/ep1.mp3", "2026-06-28"),
+```
+
+- [ ] **Step 3: Run tests, then the whole suite**
+
+Run: `pytest tests/test_core.py::test_parse_feed_fixture -v` → PASS (all 18 rows)
+Run: `pytest -q` → PASS
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add tests/
+git commit -m "test: host fixtures — lizhi, typlog, fireside, anchor, acast, libsyn, transistor"
+```
+
+---
+
+### Task 8: browser-UA regression test
+
+**Files:**
+- Test: `tests/test_core.py`
+
+Four researched hosts (SoundOn, Typlog, Fireside, Lizhi CDN) 403 `Python-urllib` UAs; this pins CLAUDE.md invariant #5 at the code level.
+
+- [ ] **Step 1: Write the test**
+
+```python
+def test_fetch_and_download_send_browser_ua(monkeypatch, tmp_path):
+ seen = []
+
+ class _Resp(io.BytesIO):
+ status = 200
+ length = 2
+ headers = {"Content-Type": "audio/mpeg"}
+
+ def __init__(self):
+ super().__init__(b"ok")
+
+ def fake_urlopen(req, timeout=None):
+ seen.append(req.get_header("User-agent"))
+ return _Resp()
+
+ monkeypatch.setattr(core.urllib.request, "urlopen", fake_urlopen)
+ core.fetch("https://feeds.soundon.fm/x.xml")
+ core.download_url("https://cdn.lizhi.fm/a.mp3", str(tmp_path / "a.mp3"))
+ assert seen == [core.UA, core.UA]
+ assert all("mozilla" in ua.lower() and "python" not in ua.lower() for ua in seen)
+```
+
+Note: `_Resp.headers` being a plain dict is fine — both `resp.headers.get(...)` call sites only use `.get`.
+
+- [ ] **Step 2: Run it**
+
+Run: `pytest tests/test_core.py::test_fetch_and_download_send_browser_ua -v`
+Expected: PASS immediately (behavior already exists — this is a pin, not a change). If it fails, `core.fetch`/`download_url` stopped sending `core.UA`: fix core, not the test.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/test_core.py
+git commit -m "test: pin browser User-Agent on fetch and download (CLAUDE.md invariant 5)"
+```
+
+---
+
+### Task 9: `classify()` accepts Ximalaya album links
+
+**Files:**
+- Modify: `src/podpull/core.py` (`classify`, currently lines 70–84)
+- Test: `tests/test_core.py` (`test_classify`)
+
+- [ ] **Step 1: Extend the test (failing)**
+
+Add to `test_classify` in `tests/test_core.py`:
+
+```python
+ assert core.classify("https://www.ximalaya.com/album/51076156") == \
+ ("rss", "https://www.ximalaya.com/album/51076156.xml")
+ assert core.classify("https://www.ximalaya.com/album/51076156.xml") == \
+ ("rss", "https://www.ximalaya.com/album/51076156.xml")
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_core.py::test_classify -v`
+Expected: FAIL — the album page URL classifies as plain `rss` with the original (non-.xml) URL.
+
+- [ ] **Step 3: Implement**
+
+In `core.classify`, insert before the `if s.startswith("http"):` line:
+
+```python
+ m = re.search(r"ximalaya\.com/album/(\d+)", s)
+ if m: # Ximalaya Podcast托管 albums expose RSS at album/.xml
+ return "rss", f"https://www.ximalaya.com/album/{m.group(1)}.xml"
+```
+
+Update the docstring's kind list comment to mention that ximalaya album links normalize to `rss`.
+
+- [ ] **Step 4: Run tests**
+
+Run: `pytest tests/test_core.py -q` → PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/podpull/core.py tests/test_core.py
+git commit -m "feat: accept ximalaya.com/album/ links (normalize to RSS .xml)"
+```
+
+---
+
+### Task 10: download sanity guard (reject `text/*` responses)
+
+**Files:**
+- Modify: `src/podpull/core.py` (`download_url`, currently lines 240–273)
+- Test: `tests/test_core.py`
+
+Motivation: Ximalaya's CDN answers stale enclosure queries with `HTTP 200`, `Content-Type: text/plain`, 7-byte body — podpull would write a 7-byte ".m4a" and exit 0.
+
+- [ ] **Step 1: Write the failing test**
+
+```python
+def _fake_response(body: bytes, ctype: str):
+ class _Resp(io.BytesIO):
+ status = 200
+ headers = {"Content-Type": ctype}
+
+ def __init__(self):
+ super().__init__(body)
+ self.length = len(body)
+ return _Resp()
+
+
+def test_download_url_rejects_text_response(monkeypatch, tmp_path):
+ monkeypatch.setattr(core.urllib.request, "urlopen",
+ lambda req, timeout=None: _fake_response(b"deleted", "text/plain; charset=utf-8"))
+ dest = tmp_path / "ep.m4a"
+ with pytest.raises(ValueError, match="not audio"):
+ core.download_url("https://jt.ximalaya.com//x.m4a?bad=1", str(dest))
+ assert not dest.exists()
+
+
+def test_download_url_accepts_audio_and_octet_stream(monkeypatch, tmp_path):
+ for ctype in ("audio/mpeg", "application/octet-stream", ""):
+ monkeypatch.setattr(core.urllib.request, "urlopen",
+ lambda req, timeout=None, c=ctype: _fake_response(b"AUDIO", c))
+ dest = tmp_path / f"ok-{ctype.replace('/', '_') or 'none'}.mp3"
+ assert core.download_url("https://cdn.example.test/a.mp3", str(dest)) == str(dest)
+ assert dest.read_bytes() == b"AUDIO"
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_core.py::test_download_url_rejects_text_response -v`
+Expected: FAIL — no ValueError; the 7-byte file gets written.
+
+- [ ] **Step 3: Implement**
+
+In `core.download_url`, right after the `if existing and getattr(resp, "status", 200) == 200:` block and before `mode = "ab" if existing else "wb"`, insert:
+
+```python
+ ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower()
+ if ctype.startswith("text/"):
+ # e.g. Ximalaya's CDN answers a stale enclosure query with 200 text/plain
+ raise ValueError(f"server returned {ctype}, not audio — "
+ "the feed's enclosure URL may be stale")
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pytest tests/test_core.py -q` → PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/podpull/core.py tests/test_core.py
+git commit -m "feat: reject text/* responses in download_url (stale-enclosure guard)"
+```
+
+---
+
+### Task 11: Podcast Index core functions
+
+**Files:**
+- Modify: `src/podpull/core.py` (imports + new section between "search / resolve" and "direct-episode resolvers")
+- Test: `tests/test_core.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+def test_pi_credentials(monkeypatch):
+ monkeypatch.delenv("PODCASTINDEX_API_KEY", raising=False)
+ monkeypatch.delenv("PODCASTINDEX_API_SECRET", raising=False)
+ assert core.pi_credentials() is None
+ monkeypatch.setenv("PODCASTINDEX_API_KEY", "k")
+ assert core.pi_credentials() is None # secret still missing
+ monkeypatch.setenv("PODCASTINDEX_API_SECRET", "s")
+ assert core.pi_credentials() == ("k", "s")
+
+
+def test_pi_headers_deterministic():
+ h = core._pi_headers("key", "secret", now=1751900000)
+ assert h["X-Auth-Key"] == "key"
+ assert h["X-Auth-Date"] == "1751900000"
+ import hashlib
+ assert h["Authorization"] == hashlib.sha1(b"keysecret1751900000").hexdigest()
+ assert h["User-Agent"] == core.UA
+
+
+def test_pi_search_shows_normalizes(monkeypatch):
+ monkeypatch.setenv("PODCASTINDEX_API_KEY", "k")
+ monkeypatch.setenv("PODCASTINDEX_API_SECRET", "s")
+ monkeypatch.setattr(core, "_pi_get", lambda path, params: {
+ "feeds": [{"id": 887080, "title": "忽左忽右", "url": "https://feed.xyzfm.space/cv4bkgpuglwp",
+ "author": "JustPod", "itunesId": 1478791559, "episodeCount": 380}],
+ })
+ rows = core.pi_search_shows("忽左忽右", limit=5)
+ assert rows == [{"collectionId": 1478791559, "collectionName": "忽左忽右",
+ "artistName": "JustPod", "feedUrl": "https://feed.xyzfm.space/cv4bkgpuglwp",
+ "trackCount": 380}]
+
+
+def test_pi_feed_by_itunes_id(monkeypatch):
+ monkeypatch.setattr(core, "_pi_get",
+ lambda path, params: {"feed": {"url": "https://feed.example.test/x"}})
+ assert core.pi_feed_by_itunes_id("123") == "https://feed.example.test/x"
+ monkeypatch.setattr(core, "_pi_get", lambda path, params: {"feed": []})
+ assert core.pi_feed_by_itunes_id("123") is None
+
+ def _boom(path, params):
+ raise OSError("network down")
+ monkeypatch.setattr(core, "_pi_get", _boom)
+ assert core.pi_feed_by_itunes_id("123") is None # degrades, never raises
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_core.py -q -k pi_`
+Expected: FAIL with `AttributeError: module 'podpull.core' has no attribute ...`
+
+- [ ] **Step 3: Implement**
+
+In `src/podpull/core.py` imports, add `import hashlib` and `import time` (alphabetical order within the stdlib block). Then add a new section after `apple_show_to_feed` (before `parse_feed`'s helpers):
+
+```python
+# --------------------------------------------------------------------------- #
+# Podcast Index (optional, BYOK) — free keys at https://api.podcastindex.org
+# Active only when both env vars are set; otherwise podpull never contacts PI.
+# --------------------------------------------------------------------------- #
+PODCASTINDEX_API = "https://api.podcastindex.org/api/1.0"
+
+
+def pi_credentials() -> "tuple[str, str] | None":
+ key = os.environ.get("PODCASTINDEX_API_KEY", "").strip()
+ secret = os.environ.get("PODCASTINDEX_API_SECRET", "").strip()
+ return (key, secret) if key and secret else None
+
+
+def _pi_headers(key: str, secret: str, now: "int | None" = None) -> dict:
+ ts = str(int(time.time()) if now is None else now)
+ auth = hashlib.sha1((key + secret + ts).encode()).hexdigest()
+ return {"User-Agent": UA, "X-Auth-Key": key, "X-Auth-Date": ts,
+ "Authorization": auth}
+
+
+def _pi_get(path: str, params: dict) -> dict:
+ creds = pi_credentials()
+ if not creds:
+ raise ValueError("Podcast Index credentials not set "
+ "(PODCASTINDEX_API_KEY / PODCASTINDEX_API_SECRET)")
+ q = urllib.parse.urlencode(params)
+ req = urllib.request.Request(f"{PODCASTINDEX_API}{path}?{q}",
+ headers=_pi_headers(*creds))
+ return json.load(urllib.request.urlopen(req, timeout=45))
+
+
+def pi_search_shows(term: str, limit: int = 10) -> list:
+ """Search Podcast Index; rows use the same keys as iTunes search results
+ so the CLI table code needs no changes."""
+ data = _pi_get("/search/byterm", {"q": term, "max": limit})
+ return [{"collectionId": f.get("itunesId") or "",
+ "collectionName": f.get("title") or "",
+ "artistName": f.get("author") or "",
+ "feedUrl": f.get("url") or "",
+ "trackCount": f.get("episodeCount") or 0}
+ for f in data.get("feeds", [])]
+
+
+def pi_feed_by_itunes_id(pid: str) -> "str | None":
+ """Second-directory feed lookup by Apple ID. Never raises — returns None
+ so callers degrade gracefully when PI is down or has no entry."""
+ try:
+ feed = _pi_get("/podcasts/byitunesid", {"id": pid}).get("feed") or {}
+ return (feed.get("url") or None) if isinstance(feed, dict) else None
+ except Exception:
+ return None
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pytest tests/test_core.py -q` → PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/podpull/core.py tests/test_core.py
+git commit -m "feat: Podcast Index core — BYOK auth, search, by-itunes-id lookup"
+```
+
+---
+
+### Task 12: `apple_show_to_feed` falls back to Podcast Index
+
+**Files:**
+- Modify: `src/podpull/core.py` (`apple_show_to_feed`, currently lines 98–111)
+- Test: `tests/test_core.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+```python
+def test_apple_show_to_feed_pi_fallback(monkeypatch):
+ monkeypatch.setenv("PODCASTINDEX_API_KEY", "k")
+ monkeypatch.setenv("PODCASTINDEX_API_SECRET", "s")
+ monkeypatch.setattr(core, "fetch_json", lambda url: {"results": []}) # iTunes empty
+ monkeypatch.setattr(core, "pi_feed_by_itunes_id",
+ lambda pid: "https://feed.example.test/fallback")
+ feed, name, author, pid = core.apple_show_to_feed("1478791559")
+ assert feed == "https://feed.example.test/fallback"
+ assert pid == "1478791559"
+ assert name == "" and author == ""
+
+
+def test_apple_show_to_feed_no_creds_still_raises(monkeypatch):
+ monkeypatch.delenv("PODCASTINDEX_API_KEY", raising=False)
+ monkeypatch.delenv("PODCASTINDEX_API_SECRET", raising=False)
+ monkeypatch.setattr(core, "fetch_json", lambda url: {"results": []})
+ calls = []
+ monkeypatch.setattr(core, "pi_feed_by_itunes_id",
+ lambda pid: calls.append(pid))
+ with pytest.raises(ValueError):
+ core.apple_show_to_feed("123")
+ assert calls == [] # PI never contacted without keys
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_core.py -q -k apple_show`
+Expected: `test_apple_show_to_feed_pi_fallback` FAILS (raises "iTunes lookup returned nothing").
+
+- [ ] **Step 3: Implement**
+
+Replace the body of `apple_show_to_feed` after `pid = m.group(1)`:
+
+```python
+ results = fetch_json(f"{ITUNES_LOOKUP}?id={pid}").get("results", [])
+ r = results[0] if results else {}
+ feed = r.get("feedUrl")
+ if not feed and pi_credentials(): # second directory, BYOK-only
+ feed = pi_feed_by_itunes_id(pid)
+ if not feed:
+ if not results:
+ raise ValueError(f"iTunes lookup returned nothing for id={pid}")
+ raise ValueError(f"No feedUrl for id={pid} (not a podcast?)")
+ return feed, r.get("collectionName", ""), r.get("artistName", ""), pid
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pytest -q` → PASS (full suite: the two new tests plus no regressions).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/podpull/core.py tests/test_core.py
+git commit -m "feat: fall back to Podcast Index when iTunes lookup has no feed"
+```
+
+---
+
+### Task 13: `cmd_search` merges Podcast Index results
+
+**Files:**
+- Modify: `src/podpull/cli.py` (`cmd_search`, currently lines 93–109; `EXAMPLES`, lines 296–310)
+- Test: `tests/test_cli.py`
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `tests/test_cli.py` (it already imports `argparse`, `cli`, `core` — follow the file's existing conventions; add imports only if missing):
+
+```python
+def _search_args(term="故事"):
+ return argparse.Namespace(term=term, limit=10, country="US")
+
+
+def test_search_without_keys_never_calls_pi(monkeypatch):
+ monkeypatch.setattr(core, "pi_credentials", lambda: None)
+ monkeypatch.setattr(core, "search_shows", lambda term, limit, country: [
+ {"collectionId": 1, "collectionName": "A", "artistName": "x",
+ "feedUrl": "https://f/a", "trackCount": 3}])
+ monkeypatch.setattr(core, "pi_search_shows",
+ lambda *a, **k: pytest.fail("PI must not be called without keys"))
+ assert cli.cmd_search(_search_args()) == 0
+
+
+def test_search_merges_and_dedupes_pi(monkeypatch):
+ monkeypatch.setattr(core, "pi_credentials", lambda: ("k", "s"))
+ monkeypatch.setattr(core, "search_shows", lambda term, limit, country: [
+ {"collectionId": 1, "collectionName": "A", "artistName": "x",
+ "feedUrl": "https://f/a", "trackCount": 3}])
+ monkeypatch.setattr(core, "pi_search_shows", lambda term, limit: [
+ {"collectionId": 1, "collectionName": "A (PI)", "artistName": "x",
+ "feedUrl": "https://f/a/", "trackCount": 3}, # dup (trailing slash)
+ {"collectionId": 2, "collectionName": "B", "artistName": "y",
+ "feedUrl": "https://f/b", "trackCount": 9}]) # new
+ seen = []
+ monkeypatch.setattr(cli, "_render_search_table", lambda term, rows: seen.extend(rows))
+ assert cli.cmd_search(_search_args()) == 0
+ assert [r["collectionName"] for r in seen] == ["A", "B"] # iTunes wins the dup
+
+
+def test_search_survives_one_backend_failing(monkeypatch):
+ monkeypatch.setattr(core, "pi_credentials", lambda: ("k", "s"))
+
+ def _boom(term, limit, country):
+ raise OSError("itunes down")
+ monkeypatch.setattr(core, "search_shows", _boom)
+ monkeypatch.setattr(core, "pi_search_shows", lambda term, limit: [
+ {"collectionId": 2, "collectionName": "B", "artistName": "y",
+ "feedUrl": "https://f/b", "trackCount": 9}])
+ assert cli.cmd_search(_search_args()) == 0 # PI results still shown
+
+ monkeypatch.setattr(core, "pi_credentials", lambda: None)
+ with pytest.raises(OSError): # no keys -> unchanged behavior
+ cli.cmd_search(_search_args())
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `pytest tests/test_cli.py -q -k search`
+Expected: FAIL (`_render_search_table` doesn't exist; merge behavior missing).
+
+- [ ] **Step 3: Implement**
+
+In `src/podpull/cli.py`, replace `cmd_search` with:
+
+```python
+def _render_search_table(term: str, results: list) -> None:
+ table = Table(title=f"Podcasts matching “{term}”", header_style="bold", expand=False)
+ table.add_column("Apple ID", style="cyan", no_wrap=True)
+ table.add_column("Eps", justify="right", style="magenta")
+ table.add_column("Show")
+ table.add_column("Author", style="dim")
+ for r in results:
+ table.add_row(str(r.get("collectionId") or "—"), str(r.get("trackCount") or "?"),
+ r.get("collectionName") or "", r.get("artistName") or "")
+ ui.print(table)
+ ui.print("[dim]Next:[/] podpull list • podpull get ")
+
+
+def _merge_results(primary: list, extra: list) -> list:
+ seen = {(r.get("feedUrl") or "").strip().rstrip("/") for r in primary}
+ seen.discard("")
+ merged = list(primary)
+ for r in extra:
+ key = (r.get("feedUrl") or "").strip().rstrip("/")
+ if key and key in seen:
+ continue
+ seen.add(key)
+ merged.append(r)
+ return merged
+
+
+def cmd_search(args) -> int:
+ results, warnings = [], []
+ with ui.status(f"[cyan]Searching for “{args.term}”…"):
+ if core.pi_credentials() is None:
+ # no PI keys -> exactly the old behavior (errors propagate to main)
+ results = core.search_shows(args.term, limit=args.limit, country=args.country)
+ else:
+ try:
+ results = core.search_shows(args.term, limit=args.limit, country=args.country)
+ except Exception as e:
+ warnings.append(f"iTunes search failed: {e}")
+ try:
+ results = _merge_results(results, core.pi_search_shows(args.term, limit=args.limit))
+ except Exception as e:
+ warnings.append(f"Podcast Index search failed: {e}")
+ for w in warnings:
+ _err(w)
+ if not results:
+ _err("no shows found")
+ return 1
+ _render_search_table(args.term, results)
+ return 0
+```
+
+In `EXAMPLES` (the help epilog), add before the final `"""`:
+
+```
+[dim]Optional: set PODCASTINDEX_API_KEY + PODCASTINDEX_API_SECRET (free — podcastindex.org)[/]
+[dim]to enrich search results and add a feed-resolution fallback.[/]
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `pytest -q` → PASS (full suite).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/podpull/cli.py tests/test_cli.py
+git commit -m "feat: merge Podcast Index results into search (BYOK, graceful degradation)"
+```
+
+---
+
+### Task 14: network-marked integration tests
+
+**Files:**
+- Create: `tests/test_network.py`
+
+- [ ] **Step 1: Write the file**
+
+```python
+"""Live-network integration tests. EXCLUDED from the default suite.
+
+Run explicitly: pytest -m network
+These hit real hosts and will flake if a show migrates feeds — that's the
+point: they detect real-world drift the offline fixtures can't.
+"""
+import pytest
+
+from podpull import core
+
+pytestmark = pytest.mark.network
+
+LIVE_FEEDS = [
+ # (label, feed_url)
+ ("xiaoyuzhou 忽左忽右", "https://feed.xyzfm.space/cv4bkgpuglwp"),
+ ("ximalaya 声动早咖啡", "https://www.ximalaya.com/album/51076156.xml"),
+ ("soundon 股癌", "https://feeds.soundon.fm/podcasts/954689a5-3096-43a4-a80b-7810b219cef3.xml"),
+ ("firstory 百靈果", "https://feed.firstory.me/rss/user/cmjaz594i0000hdvpdpnd4fw8"),
+]
+
+
+@pytest.mark.parametrize("label,feed", LIVE_FEEDS, ids=[f[0] for f in LIVE_FEEDS])
+def test_live_feed_parses(label, feed):
+ title, _author, eps = core.parse_feed(feed)
+ assert title, f"{label}: empty show title"
+ assert eps, f"{label}: no episodes parsed"
+ first = eps[0]
+ assert first.url.startswith("http"), f"{label}: bad enclosure {first.url!r}"
+ assert first.date != "0000-00-00", f"{label}: unparseable pubDate {first.pub!r}"
+
+
+def test_live_itunes_resolution():
+ feed, name, _author, pid = core.apple_show_to_feed("1493503146") # 忽左忽右
+ assert feed.startswith("http") and pid == "1493503146" and name
+```
+
+- [ ] **Step 2: Verify exclusion and (optionally) run live**
+
+Run: `pytest -q` → the new tests are DESELECTED (count unchanged).
+Run: `pytest -m network -q` → should pass with live network; if a host is down or a show migrated, note it in the commit message but don't block the branch on it.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/test_network.py
+git commit -m "test: live-network integration tests (pytest -m network)"
+```
+
+---
+
+### Task 15: docs, integrations note, version bump, final verification
+
+**Files:**
+- Modify: `src/podpull/__init__.py` (version), `pyproject.toml` (version), `CHANGELOG.md`, `README.md`, `src/podpull/integrations/SKILL.md`, `src/podpull/integrations/opencode_command.md`, `src/podpull/integrations/cursor_rule.mdc`
+
+- [ ] **Step 1: Version bump — BOTH files**
+
+`src/podpull/__init__.py`: `__version__ = "0.6.0"`
+`pyproject.toml`: `version = "0.6.0"`
+
+- [ ] **Step 2: CHANGELOG entry**
+
+Add at the top of `CHANGELOG.md` (match the existing entry format in that file):
+
+```markdown
+## 0.6.0 — 2026-07-07
+
+- Robust feed parsing: RSS 2.0 / RSS 1.0 (RDF) / Atom, any-namespace matching,
+ `media:content` + Atom-enclosure fallbacks, dirty-XML sanitize-and-retry
+ (undefined entities, bare `&`, control chars), ISO-8601 dates.
+- Verified against Chinese-market hosts: xiaoyuzhou, Ximalaya, SoundOn, Firstory,
+ WavPub, Typlog, Fireside, Lizhi (offline fixtures + `pytest -m network` live suite).
+- `ximalaya.com/album/` links are now accepted directly.
+- Download guard: `text/*` responses (stale enclosures, e.g. Ximalaya CDN) now
+ error instead of writing a garbage audio file.
+- Optional Podcast Index support (BYOK: `PODCASTINDEX_API_KEY`/`_SECRET`):
+ enriches `search` and adds a feed-resolution fallback when iTunes has no feed.
+```
+
+(If the tag is cut on a later day, update the date in the same commit as the tag.)
+
+- [ ] **Step 3: README — add an optional-PI section + ximalaya mention**
+
+In `README.md`: add `ximalaya.com/album/` to wherever supported inputs are listed, and add a short section:
+
+```markdown
+### Podcast Index (optional)
+
+podpull can enrich `search` and feed resolution with the open
+[Podcast Index](https://podcastindex.org) directory. Get a free API key at
+[api.podcastindex.org](https://api.podcastindex.org/signup) and set:
+
+```bash
+export PODCASTINDEX_API_KEY=...
+export PODCASTINDEX_API_SECRET=...
+```
+
+Without these, podpull behaves exactly as before (iTunes only).
+```
+
+- [ ] **Step 4: Integrations one-liners**
+
+Add one line to each of `src/podpull/integrations/SKILL.md`, `opencode_command.md`, `cursor_rule.mdc`, in their existing style, near their command/rules list:
+
+```
+- Optional: if PODCASTINDEX_API_KEY/PODCASTINDEX_API_SECRET are set, `search` also queries Podcast Index and feed resolution gains a fallback; ximalaya.com/album/ links work as a source.
+```
+
+- [ ] **Step 5: Full verification**
+
+Run: `pytest -q` → ALL PASS
+Run: `ruff check src` → clean
+Run: `python -m build && unzip -l dist/*.whl | grep integrations` → integration files present in the wheel (`pip install build` first if missing; then delete `dist/` — it's gitignored but don't commit it)
+Run: `podpull --help` → epilog shows the PI note; `podpull search --help` still renders.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/podpull/__init__.py pyproject.toml CHANGELOG.md README.md src/podpull/integrations/
+git commit -m "docs: v0.6.0 — changelog, README Podcast Index section, integrations note"
+```
+
+---
+
+## Post-plan notes for the executor
+
+- **Do not** merge or rebase against PR #3 (`--quiet`) mid-plan; if it lands on main first, rebasing this branch is the integrator's job at the end (conflicts, if any, are confined to `cli.py`).
+- The release itself (tag `v0.6.0`, PyPI, Homebrew bump) is NOT part of this plan — the user decides when to cut it. No dependency changes were made, so no `brew update-python-resources` run is needed.
+- If a live `pytest -m network` run fails because a show migrated its feed, update `LIVE_FEEDS`, don't weaken assertions.
diff --git a/docs/superpowers/specs/2026-07-07-feed-robustness-podcastindex-design.md b/docs/superpowers/specs/2026-07-07-feed-robustness-podcastindex-design.md
new file mode 100644
index 0000000..04c1203
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-07-feed-robustness-podcastindex-design.md
@@ -0,0 +1,161 @@
+# Design: robust feed parsing, multi-host tests, Podcast Index support
+
+Date: 2026-07-07 · Target release: **v0.6.0** · Status: awaiting user review
+
+Backlog source: Obsidian `Projects/Podpull/跟进 Follow-ups.md` → "later hardening":
+robust parsing across arbitrary hosts, tests vs more hosts + a network-marked integ
+test, Podcast Index API ("richer search than iTunes"). `--json` mode is **out of
+scope** for this iteration (not requested).
+
+## Decisions (made while user was AFK — please confirm)
+
+1. **No `feedparser`; harden the stdlib parser instead.** CLAUDE.md invariant #1
+ (`core.py` stays pure stdlib) outweighs the backlog's "consider feedparser".
+ podpull needs only title/author/enclosure/date per item — a narrow slice that
+ stdlib `ElementTree` + targeted fallbacks covers. Avoids optional-dep matrix,
+ Homebrew resource churn, and a heavyweight dependency.
+2. **Podcast Index = search enrichment + resolve fallback**, BYOK via env vars
+ `PODCASTINDEX_API_KEY` / `PODCASTINDEX_API_SECRET`. With no keys set, behavior
+ is byte-for-byte unchanged. Not a first-class input source (no PI URLs/IDs as
+ `get` arguments) — YAGNI, can be added later.
+3. **Host coverage** (revised 2026-07-07 after Chinese-market research, per user
+ request) = the hosts that actually carry mainstream Chinese-language podcasts —
+ xiaoyuzhou/xyzfm, Ximalaya RSS export, SoundOn, Firstory, WavPub/播客公社,
+ Typlog, Fireside, Lizhi — plus global hosts (Anchor/Spotify, Acast, Omny,
+ Libsyn, Transistor) and synthetic edge-case fixtures (Atom, RSS 1.0/RDF,
+ dirty entities, media:content-only, UTF-16/BOM). Closed CN platforms
+ (蜻蜓FM, 网易云音乐播客, Ximalaya's non-enrolled/paid catalog) stay out of
+ scope — no public RSS; supporting them means scraping, a different project.
+
+## 1. Robust feed parsing (`core.py`)
+
+`parse_feed(feed_url) -> (title, author, episodes)` keeps its signature; all
+changes are internal. New small helpers (all stdlib):
+
+- **`_localname(tag)`** — strip `{namespace}` so traversal matches elements by
+ local name regardless of namespace or prefix. This one change makes RSS 2.0,
+ namespaced RSS 1.0 (RDF), and Atom traversable with shared code.
+- **Feed shapes**: RSS 2.0 `channel/item` (current), RSS 1.0 `{rss-1.0}item`,
+ Atom `feed/entry`.
+- **`_find_enclosure(item)`** — per-item audio URL discovery, priority order:
+ 1. `` (any namespace)
+ 2. `` (MRSS ns) with `type` audio/* or `medium="audio"` —
+ first match wins
+ 3. Atom ``
+ Items with no audio URL are skipped, as today. First match wins **and stops** —
+ WavPub and Omny feeds carry a duplicate `media:content` alongside `enclosure`
+ per item; the priority order must yield exactly one URL, never two episodes.
+- **Metadata fallbacks** (first non-empty wins):
+ - show author: `itunes:author` (accept both `http://` and `https://` namespace
+ variants) → `managingEditor` → `dc:creator` → Atom `author/name`
+ - episode pub date: `pubDate` → `dc:date` → Atom `published`/`updated`
+ - title/guid/link: by localname (ET already folds CDATA to text); collapse
+ whitespace
+- **`Episode.date`** — RFC-822 parse (current) then ISO-8601 fallback
+ (`datetime.fromisoformat`, tolerating trailing `Z`); `0000-00-00` as last resort.
+- **`_sanitize_xml(raw: bytes)` recovery path** — `ET.fromstring` is tried on the
+ raw bytes first (honors XML encoding declarations; strip UTF-8/16 BOM first).
+ Only on `ParseError` do we sanitize and retry once:
+ - undefined named HTML entities (` ` etc.) → numeric refs via
+ `html.entities.html5` (XML's five predefined entities left untouched)
+ - bare `&` not starting a valid entity → `&`
+ - stray control chars (except tab/newline/CR) → dropped
+ If the retry still fails, raise the original `ParseError` (fail loudly, not
+ silently-empty).
+
+- **Input classification**: `classify()` learns one new pattern —
+ `ximalaya.com/album/` (album page or `.xml` link) → kind `rss` normalized
+ to `https://www.ximalaya.com/album/.xml`. Ximalaya's Podcast托管 product
+ issues public RSS at exactly that URL; for non-enrolled albums the fetch 404s
+ and the existing error path reports it. (No page scraping.)
+- **Download sanity guard** (`download_url`) — Ximalaya's CDN answers a
+ malformed/stale enclosure query with **HTTP 200, `Content-Type: text/plain`,
+ a 7-byte body** — today podpull would write a 7-byte ".m4a" and exit 0. New
+ guard: when the destination is an audio download and the final response's
+ `Content-Type` starts with `text/`, raise `ValueError` ("server returned text,
+ not audio — feed enclosure may be stale") instead of writing the file.
+ `application/octet-stream` and all `audio/*` types remain accepted.
+
+Explicitly **not** doing: full HTML-in-XML soup recovery, itunes:duration/episode
+numbers (no consumer yet), paged feeds (RFC 5005), non-audio enclosure handling,
+scraping closed CN platforms (蜻蜓FM / 网易云音乐 / Lizhi-app-only content).
+
+## 2. Podcast Index support (`core.py` + `cli.py`)
+
+Core (pure stdlib — auth is `hashlib.sha1`):
+
+- `PODCASTINDEX_API = "https://api.podcastindex.org/api/1.0"`
+- `pi_credentials() -> tuple[str, str] | None` — reads the two env vars; `None`
+ disables everything PI-related.
+- `_pi_get(path, params)` — GET with headers `X-Auth-Key`, `X-Auth-Date` (unix ts),
+ `Authorization: sha1(key + secret + ts)` hexdigest, and the existing `UA`.
+- `pi_search_shows(term, limit) -> list[dict]` — `/search/byterm`; results
+ normalized to the **same dict keys the iTunes search path yields**
+ (`collectionName`, `artistName`, `feedUrl`, `collectionId`) so `cli.cmd_search`'s
+ table code is reused untouched.
+- `pi_feed_by_itunes_id(pid) -> str | None` — `/podcasts/byitunesid`; used by
+ `apple_show_to_feed` as a fallback **only when** iTunes lookup returns no
+ result or no `feedUrl` and credentials exist.
+- **Chinese-content caveat (from 2026-07-07 research):** Podcast Index does
+ index CN/TW shows (incl. `feed.xyzfm.space` feeds) but lags feed migrations —
+ observed stale feed URLs (故事FM) and duplicate entries (声动早咖啡 on both its
+ old Fireside and new xyzfm feeds). iTunes therefore stays the primary
+ directory; PI is enrichment/fallback only — which is exactly this design.
+ Related: keep `search_shows`'s `country=US` default; the US storefront indexes
+ every sampled CN/TW show while `country=CN` is censored (e.g. 不明白播客 absent).
+
+CLI (`cmd_search`): when credentials exist, query iTunes then Podcast Index,
+merge and dedupe by normalized `feedUrl` (iTunes result wins ties), render the
+same table. Either backend failing degrades gracefully: warning to stderr, the
+other backend's results still shown (search fails only when both fail — and
+without keys, iTunes failure behaves exactly as today). No new CLI flags. Help epilog + README document the env
+vars; integrations files get a one-line note (env vars only — no flag/UX change).
+
+## 3. Tests
+
+- **Offline fixtures** (default suite, no network — CLAUDE.md rule):
+ `tests/fixtures/feeds/*.xml`, each a trimmed 2–3-item sample reproducing the
+ host's observed quirks (verified live 2026-07-07):
+
+ | Fixture | Modeled on | Quirk the fixture must reproduce |
+ |---|---|---|
+ | `xiaoyuzhou` | 忽左忽右 (`feed.xyzfm.space/cv4bkgpuglwp`) | CDATA-heavy; `dts-api.xiaoyuzhoufm.com/track/...` tracking enclosure with nested host-in-path |
+ | `ximalaya` | 声动早咖啡 (`ximalaya.com/album/51076156.xml`) | `//` double-slash in enclosure path; `&`-escaped query with nested full URL in `jt=` param |
+ | `soundon` | 股癌 (`feeds.soundon.fm/podcasts/.xml`) | `soundon:` custom namespace; self-referencing `itunes:new-feed-url`; `?times=` querystring enclosure |
+ | `firstory` | 百靈果 News (`feed.firstory.me/rss/user/`) | **every** field CDATA-wrapped incl. ``; stale channel-level `pubDate`; percent-encoded URL embedded in enclosure path |
+ | `wavpub` | 半拿铁 (`proxy.wavpub.com/caffebreve.xml`) | duplicate `enclosure` **and** `media:content` per item (must yield 1 episode, not 2); 15 declared namespaces |
+ | `typlog` | 疯投圈 (`crazy.capital/feed`) | zero CDATA, fully entity-escaped text (counter-fixture) |
+ | `fireside` | 科技早知道 (`feeds.fireside.fm/guiguzaozhidao/rss`) | `+0800` timezone pubDates; `podcast:` (Podcasting 2.0) namespace |
+ | `lizhi` | 大内密谈 (`rss.lizhi.fm/rss/14275.xml`) | non-zero-padded RFC-822 dates (`Sun, 5 Jul 2026`); plain-`http` feed URL |
+ | `anchor` | 台灣通勤第一品牌 | percent-encoded CloudFront URL inside enclosure path |
+ | `acast`, `omny`, `libsyn`, `transistor` | 不明白播客 / 馬力歐陪你喝一杯 / — | Omny: 2× `media:content` + enclosure per item; others: mainstream-global regression |
+ | `atom`, `rss10_rdf`, `dirty_entities`, `media_content_only`, `utf16_bom` | synthetic | Atom feed/entry; RSS 1.0 namespaced items; undefined ` ` + bare `&`; MRSS-only items; UTF-16 + BOM |
+
+ A parametrized `test_core` case asserts per fixture: parse succeeds, expected
+ show title/author, episode count (dedupe!), first episode URL + date. Plus a
+ unit test that `fetch`/`download_url` send `core.UA` — four of the CN hosts
+ (SoundOn, Typlog, Fireside, Lizhi CDN) 403 `Python-urllib`, so the browser-UA
+ invariant (CLAUDE.md #5) now has teeth beyond xiaoyuzhou.
+- **Podcast Index tests** (offline): auth-header construction with a frozen
+ timestamp; env-var gating (no keys → PI functions never called); result
+ normalization; `apple_show_to_feed` fallback path; search merge/dedupe in
+ `cli` with mocked core.
+- **Network integ test**: `tests/test_network.py`, `@pytest.mark.network`,
+ excluded by default via `pyproject.toml` (`markers` + `addopts = -m "not network"`).
+ Hits ~4 live feeds end-to-end (resolve → parse → enclosure URL sanity):
+ xiaoyuzhou (忽左忽右), Ximalaya (声动早咖啡), SoundOn (股癌), Firstory (百靈果).
+ Run manually with `pytest -m network`; not wired into CI in this iteration.
+
+## Error handling summary
+
+- Malformed XML: one sanitize-retry, then original `ParseError` propagates (cli
+ already prints errors to stderr and exits non-zero).
+- PI unavailable/misconfigured: stderr warning, feature degrades to iTunes-only.
+- No credentials: PI code paths are dead — zero behavioral or network change.
+
+## Release notes / docs to touch
+
+- `CHANGELOG.md` v0.6.0; version bump in `__init__.py` + `pyproject.toml` (both).
+- README: "Podcast Index (optional)" section — free key signup, env vars.
+- `src/podpull/integrations/*`: one-line env-var note.
+- No dependency changes → no Homebrew resource work.
diff --git a/pyproject.toml b/pyproject.toml
index 512f895..3655165 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "podpull"
-version = "0.5.1"
+version = "0.6.0"
description = "Download specific podcast episode audio from Apple Podcasts, RSS feeds, or xiaoyuzhou links — with an interactive picker."
readme = "README.md"
requires-python = ">=3.9"
@@ -40,3 +40,9 @@ packages = ["src/podpull"]
[tool.ruff]
line-length = 100
target-version = "py39"
+
+[tool.pytest.ini_options]
+markers = [
+ "network: hits live podcast hosts; excluded by default (run: pytest -m network)",
+]
+addopts = "-m 'not network'"
diff --git a/src/podpull/__init__.py b/src/podpull/__init__.py
index 9932c67..bb0052b 100644
--- a/src/podpull/__init__.py
+++ b/src/podpull/__init__.py
@@ -6,4 +6,4 @@
the file. Stdlib only.
"""
-__version__ = "0.5.1"
+__version__ = "0.6.0"
diff --git a/src/podpull/cli.py b/src/podpull/cli.py
index fd2dd24..0dbe1de 100644
--- a/src/podpull/cli.py
+++ b/src/podpull/cli.py
@@ -112,22 +112,53 @@ def cb(done: int, total: int, _t=task) -> None:
# --------------------------------------------------------------------------- #
# commands
# --------------------------------------------------------------------------- #
-def cmd_search(args) -> int:
- with ui.status(f"[cyan]Searching iTunes for “{args.term}”…"):
- results = core.search_shows(args.term, limit=args.limit, country=args.country)
- if not results:
- _err("no shows found")
- return 1
- table = Table(title=f"Podcasts matching “{args.term}”", header_style="bold", expand=False)
+def _render_search_table(term: str, results: list) -> None:
+ table = Table(title=f"Podcasts matching “{term}”", header_style="bold", expand=False)
table.add_column("Apple ID", style="cyan", no_wrap=True)
table.add_column("Eps", justify="right", style="magenta")
table.add_column("Show")
table.add_column("Author", style="dim")
for r in results:
- table.add_row(str(r.get("collectionId")), str(r.get("trackCount") or "?"),
+ table.add_row(str(r.get("collectionId") or "—"), str(r.get("trackCount") or "?"),
r.get("collectionName") or "", r.get("artistName") or "")
ui.print(table)
ui.print("[dim]Next:[/] podpull list • podpull get ")
+
+
+def _merge_results(primary: list, extra: list) -> list:
+ seen = {(r.get("feedUrl") or "").strip().rstrip("/") for r in primary}
+ seen.discard("")
+ merged = list(primary)
+ for r in extra:
+ key = (r.get("feedUrl") or "").strip().rstrip("/")
+ if key and key in seen:
+ continue
+ seen.add(key)
+ merged.append(r)
+ return merged
+
+
+def cmd_search(args) -> int:
+ results, warnings = [], []
+ with ui.status(f"[cyan]Searching for “{args.term}”…"):
+ if core.pi_credentials() is None:
+ # no PI keys -> exactly the old behavior (errors propagate to main)
+ results = core.search_shows(args.term, limit=args.limit, country=args.country)
+ else:
+ try:
+ results = core.search_shows(args.term, limit=args.limit, country=args.country)
+ except Exception as e:
+ warnings.append(f"iTunes search failed: {e}")
+ try:
+ results = _merge_results(results, core.pi_search_shows(args.term, limit=args.limit))
+ except Exception as e:
+ warnings.append(f"Podcast Index search failed: {e}")
+ for w in warnings:
+ _err(w)
+ if not results:
+ _err("no shows found")
+ return 1
+ _render_search_table(args.term, results)
return 0
@@ -336,6 +367,9 @@ def cmd_skills_uninstall(args) -> int:
[dim] = Apple show URL · bare Apple ID · RSS feed URL · Apple ?i= episode URL · xiaoyuzhou link[/]
[dim]Downloads default to ~/Downloads/Podcasts (override with --out).[/]
+
+[dim]Optional: set PODCASTINDEX_API_KEY + PODCASTINDEX_API_SECRET (free — podcastindex.org)[/]
+[dim]to enrich search results and add a feed-resolution fallback.[/]
"""
diff --git a/src/podpull/core.py b/src/podpull/core.py
index 558c3b0..e75e4c7 100644
--- a/src/podpull/core.py
+++ b/src/podpull/core.py
@@ -4,15 +4,19 @@
"""
from __future__ import annotations
+import hashlib
+import html.entities
import json
import os
import re
+import time
import unicodedata
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
+from datetime import datetime
from email.utils import parsedate_to_datetime
# A plain browser User-Agent. Some podcast CDNs (e.g. xiaoyuzhou's feed.xyzfm.space)
@@ -37,10 +41,16 @@ class Episode:
@property
def date(self) -> str:
- try:
- return parsedate_to_datetime(self.pub).strftime("%Y-%m-%d")
- except Exception:
- return "0000-00-00"
+ for parse in (
+ lambda s: parsedate_to_datetime(s), # RFC-822
+ lambda s: datetime.fromisoformat( # ISO-8601
+ re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s.strip().replace("Z", "+00:00"))),
+ ):
+ try:
+ return parse(self.pub).strftime("%Y-%m-%d")
+ except Exception:
+ continue
+ return "0000-00-00"
@dataclass
@@ -69,7 +79,7 @@ def fetch_json(url: str):
# --------------------------------------------------------------------------- #
def classify(src: str) -> tuple[str, str]:
"""Return (kind, normalized_src). kind ∈ {apple_show, apple_episode,
- xyz_episode, rss}."""
+ xyz_episode, rss}. Ximalaya album links normalize to rss with .xml extension."""
s = src.strip()
if re.fullmatch(r"\d+", s):
return "apple_show", s
@@ -79,6 +89,9 @@ def classify(src: str) -> tuple[str, str]:
return "apple_episode", s
if "podcasts.apple.com" in s:
return "apple_show", s
+ m = re.search(r"ximalaya\.com/album/(\d+)", s)
+ if m: # Ximalaya Podcast托管 albums expose RSS at album/.xml
+ return "rss", f"https://www.ximalaya.com/album/{m.group(1)}.xml"
if s.startswith("http"):
return "rss", s
raise ValueError(f"Cannot classify input: {src!r}")
@@ -102,41 +115,220 @@ def apple_show_to_feed(src: str) -> tuple[str, str, str, str]:
raise ValueError(f"No Apple podcast id (idNNN) found in: {src}")
pid = m.group(1)
results = fetch_json(f"{ITUNES_LOOKUP}?id={pid}").get("results", [])
- if not results:
- raise ValueError(f"iTunes lookup returned nothing for id={pid}")
- r = results[0]
+ r = results[0] if results else {}
feed = r.get("feedUrl")
+ if not feed and pi_credentials(): # second directory, BYOK-only
+ feed = pi_feed_by_itunes_id(pid)
if not feed:
+ if not results:
+ raise ValueError(f"iTunes lookup returned nothing for id={pid}")
raise ValueError(f"No feedUrl for id={pid} (not a podcast?)")
return feed, r.get("collectionName", ""), r.get("artistName", ""), pid
+# --------------------------------------------------------------------------- #
+# Podcast Index (optional, BYOK) — free keys at https://api.podcastindex.org
+# Active only when both env vars are set; otherwise podpull never contacts PI.
+# --------------------------------------------------------------------------- #
+PODCASTINDEX_API = "https://api.podcastindex.org/api/1.0"
+
+
+def pi_credentials() -> "tuple[str, str] | None":
+ key = os.environ.get("PODCASTINDEX_API_KEY", "").strip()
+ secret = os.environ.get("PODCASTINDEX_API_SECRET", "").strip()
+ return (key, secret) if key and secret else None
+
+
+def _pi_headers(key: str, secret: str, now: "int | None" = None) -> dict:
+ ts = str(int(time.time()) if now is None else now)
+ auth = hashlib.sha1((key + secret + ts).encode()).hexdigest()
+ return {"User-Agent": UA, "X-Auth-Key": key, "X-Auth-Date": ts,
+ "Authorization": auth}
+
+
+def _pi_get(path: str, params: dict) -> dict:
+ creds = pi_credentials()
+ if not creds:
+ raise ValueError("Podcast Index credentials not set "
+ "(PODCASTINDEX_API_KEY / PODCASTINDEX_API_SECRET)")
+ q = urllib.parse.urlencode(params)
+ req = urllib.request.Request(f"{PODCASTINDEX_API}{path}?{q}",
+ headers=_pi_headers(*creds))
+ return json.load(urllib.request.urlopen(req, timeout=45))
+
+
+def pi_search_shows(term: str, limit: int = 10) -> list:
+ """Search Podcast Index; rows use the same keys as iTunes search results
+ so the CLI table code needs no changes."""
+ data = _pi_get("/search/byterm", {"q": term, "max": limit})
+ return [{"collectionId": f.get("itunesId") or "",
+ "collectionName": f.get("title") or "",
+ "artistName": f.get("author") or "",
+ "feedUrl": f.get("url") or "",
+ "trackCount": f.get("episodeCount") or 0}
+ for f in data.get("feeds", [])]
+
+
+def pi_feed_by_itunes_id(pid: str) -> "str | None":
+ """Second-directory feed lookup by Apple ID. Never raises — returns None
+ so callers degrade gracefully when PI is down or has no entry."""
+ try:
+ feed = _pi_get("/podcasts/byitunesid", {"id": pid}).get("feed") or {}
+ return (feed.get("url") or None) if isinstance(feed, dict) else None
+ except Exception:
+ return None
+
+
+def _localname(tag) -> str:
+ """'{ns}Tag' -> 'tag'. Comments/PIs have non-str tags -> ''."""
+ if not isinstance(tag, str):
+ return ""
+ return tag.rsplit("}", 1)[-1].lower()
+
+
+def _clean(text) -> str:
+ return re.sub(r"\s+", " ", (text or "").strip())
+
+
+def _first_text(el, *names) -> str:
+ """First non-empty direct-child text whose localname is in names.
+ Bare (un-namespaced) tags win over namespaced synonyms, so a real
+ beats regardless of document order."""
+ wanted = {n.lower() for n in names}
+ candidates = [c for c in el if _localname(c.tag) in wanted]
+ for c in candidates:
+ if isinstance(c.tag, str) and "}" not in c.tag and _clean(c.text):
+ return _clean(c.text)
+ for c in candidates:
+ if _clean(c.text):
+ return _clean(c.text)
+ return ""
+
+
+def _author(el) -> str:
+ """itunes:author / atom author>name -> managingEditor -> dc:creator."""
+ for name in ("author", "managingeditor", "creator"):
+ for child in el:
+ if _localname(child.tag) == name:
+ txt = _first_text(child, "name") or _clean(child.text)
+ if txt:
+ return txt
+ return ""
+
+
+def _pub(item) -> str:
+ """pubDate -> dc:date -> atom published -> atom updated (raw string)."""
+ for name in ("pubdate", "date", "published", "updated"):
+ for child in item:
+ if _localname(child.tag) == name and _clean(child.text):
+ return child.text.strip()
+ return ""
+
+
+def _find_enclosure(item) -> tuple[str, str]:
+ """-> (audio_url, mime) or ('', ''). Priority: enclosure > media:content
+ (audio) > atom link rel=enclosure. First match wins — WavPub/Omny items
+ carry BOTH enclosure and media:content; one item must yield one URL."""
+ for child in item:
+ if _localname(child.tag) == "enclosure" and child.get("url"):
+ return child.get("url"), child.get("type") or ""
+ for child in item:
+ if _localname(child.tag) == "content" and child.get("url"):
+ mime = child.get("type") or ""
+ if mime.startswith("audio/") or child.get("medium") == "audio":
+ return child.get("url"), mime
+ for child in item:
+ if (_localname(child.tag) == "link" and child.get("rel") == "enclosure"
+ and child.get("href")):
+ return child.get("href"), child.get("type") or ""
+ return "", ""
+
+
+def _item_link(item) -> str:
+ link = _first_text(item, "link")
+ if link:
+ return link
+ for child in item: # atom: with no/alternate rel
+ if (_localname(child.tag) == "link" and child.get("href")
+ and child.get("rel") in (None, "alternate")):
+ return child.get("href")
+ return ""
+
+
+_XML_PREDEFINED = frozenset({"amp", "lt", "gt", "quot", "apos"})
+
+
+def _repair_entities(text: str) -> str:
+ def _entity(mm):
+ name = mm.group(1)
+ if name in _XML_PREDEFINED:
+ return mm.group(0)
+ ch = html.entities.html5.get(name + ";")
+ # unknown entities stay visible as literal text, never dropped
+ return "".join(f"{ord(c)};" for c in ch) if ch else "&" + name + ";"
+ text = re.sub(r"&([A-Za-z][A-Za-z0-9]{1,31});", _entity, text)
+ return re.sub(r"&(?![A-Za-z][A-Za-z0-9]{1,31};|#\d+;|#x[0-9A-Fa-f]+;)", "&", text)
+
+
+def _sanitize_xml(raw: bytes) -> bytes:
+ """Best-effort repair of common real-world feed dirt: junk before the
+ declaration, control chars, undefined HTML entities, bare '&'. CDATA
+ sections pass through untouched. Known limitation: a bare '&' that spells
+ an HTML entity name inside a URL query (\u2026?id=1§ion=2) is converted
+ like an entity \u2014 unfixable without a full parser."""
+ head = raw[:64]
+ if head[:2] in (b"\xff\xfe", b"\xfe\xff"):
+ enc = "utf-16" # BOM decides the byte order
+ elif b"\x00" in head: # null-interleaved: BOM-less UTF-16
+ enc = "utf-16-be" if head.startswith(b"\x00") else "utf-16-le"
+ else:
+ m = re.search(rb'<\?xml[^>]*encoding=["\']([A-Za-z0-9._-]+)["\']', raw[:200])
+ enc = m.group(1).decode("ascii", "replace") if m else "utf-8"
+ try:
+ text = raw.decode(enc, "replace")
+ except LookupError: # unknown codec name in declaration
+ text = raw.decode("utf-8", "replace")
+ text = text.lstrip("\ufeff\x00 \t\r\n")
+ # we re-encode as UTF-8 below, so the declared encoding must not disagree
+ text = re.sub(r'(<\?xml[^>]*?)\s+encoding=["\'][^"\']*["\']', r"\1", text, count=1)
+ text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", text)
+ parts = re.split(r"()", text, flags=re.S)
+ return "".join(p if p.startswith(" "ET.Element":
+ try:
+ return ET.fromstring(raw)
+ except ET.ParseError as err:
+ try:
+ return ET.fromstring(_sanitize_xml(raw))
+ except ET.ParseError:
+ raise err from None # fail loudly with the original error
+
+
def parse_feed(feed_url: str) -> tuple[str, str, list[Episode]]:
- """-> (show_title, show_author, episodes)."""
- root = ET.fromstring(fetch(feed_url).read())
- chan = root.find(".//channel")
- title = (chan.findtext("title") if chan is not None else "") or ""
- itunes = "{http://www.itunes.com/dtds/podcast-1.0.dtd}"
- author = ""
- if chan is not None:
- author = (chan.findtext(f"{itunes}author") or "").strip()
+ """-> (show_title, show_author, episodes). Handles RSS 2.0, RSS 1.0 (RDF)
+ and Atom, with any namespace layout (matching by localname)."""
+ root = _parse_xml(fetch(feed_url).read())
+ chan = next((el for el in root.iter()
+ if _localname(el.tag) in ("channel", "feed")), root)
eps: list[Episode] = []
- for it in root.findall(".//item"):
- enc = it.find("enclosure")
- if enc is None:
+ for it in root.iter():
+ if _localname(it.tag) not in ("item", "entry"):
continue
- enc_url = enc.get("url")
- if not enc_url:
+ url, mime = _find_enclosure(it)
+ if not url:
continue
eps.append(Episode(
- title=(it.findtext("title") or "").strip(),
- pub=(it.findtext("pubDate") or "").strip(),
- url=enc_url,
- mime=enc.get("type") or "",
- guid=(it.findtext("guid") or "").strip(),
- link=(it.findtext("link") or "").strip(),
+ title=_first_text(it, "title"),
+ pub=_pub(it),
+ url=url,
+ mime=mime,
+ guid=_first_text(it, "guid", "id"),
+ link=_item_link(it),
))
- return title.strip(), author, eps
+ return _first_text(chan, "title"), _author(chan), eps
def resolve_show(src: str) -> Show:
@@ -257,6 +449,11 @@ def download_url(url: str, dest: str, *, resume: bool = True, on_progress=None)
raise
if existing and getattr(resp, "status", 200) == 200:
existing = 0 # server ignored Range; restart cleanly
+ ctype = (resp.headers.get("Content-Type") or "").split(";")[0].strip().lower()
+ if ctype.startswith("text/"):
+ # e.g. Ximalaya's CDN answers a stale enclosure query with 200 text/plain
+ raise ValueError(f"server returned {ctype}, not audio — "
+ "the feed's enclosure URL may be stale")
mode = "ab" if existing else "wb"
remaining = resp.length or 0
total = (remaining + existing) if remaining else 0
diff --git a/src/podpull/integrations/SKILL.md b/src/podpull/integrations/SKILL.md
index eae0995..56aeffe 100644
--- a/src/podpull/integrations/SKILL.md
+++ b/src/podpull/integrations/SKILL.md
@@ -48,6 +48,7 @@ podpull get ... -q / --quiet # suppress spinner/progress bar
- Downloads default to `~/Downloads/Podcasts`; pass `--out DIR` to change.
- Selecting **multiple** episodes creates a per-show sub-folder. Filenames are normalized to be
cloud-storage-safe (emoji/illegal characters removed; CJK kept).
+- Optional: if PODCASTINDEX_API_KEY/PODCASTINDEX_API_SECRET are set, `search` also queries Podcast Index and feed resolution gains a fallback; ximalaya.com/album/ links work as a source.
## Examples
diff --git a/src/podpull/integrations/cursor_rule.mdc b/src/podpull/integrations/cursor_rule.mdc
index 8711b5f..7b25b54 100644
--- a/src/podpull/integrations/cursor_rule.mdc
+++ b/src/podpull/integrations/cursor_rule.mdc
@@ -24,3 +24,4 @@ Rules:
output (the saved file path) is unaffected.
- File path prints to stdout, progress to stderr. Default output `~/Downloads/Podcasts` (`--out` to change).
- Multiple episodes → per-show folder; filenames are normalized to be cloud-storage-safe (CJK kept).
+- Optional: if PODCASTINDEX_API_KEY/PODCASTINDEX_API_SECRET are set, `search` also queries Podcast Index and feed resolution gains a fallback; ximalaya.com/album/ links work as a source.
diff --git a/src/podpull/integrations/opencode_command.md b/src/podpull/integrations/opencode_command.md
index a436b3c..4f1a01f 100644
--- a/src/podpull/integrations/opencode_command.md
+++ b/src/podpull/integrations/opencode_command.md
@@ -22,5 +22,6 @@ Rules:
meaningfully outside a live terminal anyway. The saved file path still prints to stdout regardless.
- The saved file path is printed to stdout; progress to stderr. Default output dir is `~/Downloads/Podcasts`
(`--out DIR` to change). Multiple episodes go into a per-show folder; filenames are cloud-safe.
+- Optional: if PODCASTINDEX_API_KEY/PODCASTINDEX_API_SECRET are set, `search` also queries Podcast Index and feed resolution gains a fallback; ximalaya.com/album/ links work as a source.
Steps: figure out the show (search/list if needed), then download with the right selector, then report the saved path(s).
diff --git a/tests/fixtures/feeds/acast.xml b/tests/fixtures/feeds/acast.xml
new file mode 100644
index 0000000..defa959
--- /dev/null
+++ b/tests/fixtures/feeds/acast.xml
@@ -0,0 +1,14 @@
+
+
+
+ 不明白播客
+ 袁莉和她的朋友们
+ 68004395b4ef799a7a410371
+ -
+ EP-100 访谈
+ Tue, 30 Jun 2026 22:00:00 GMT
+
+
+
+
diff --git a/tests/fixtures/feeds/anchor.xml b/tests/fixtures/feeds/anchor.xml
new file mode 100644
index 0000000..efec475
--- /dev/null
+++ b/tests/fixtures/feeds/anchor.xml
@@ -0,0 +1,12 @@
+
+
+
+ 台灣通勤第一品牌
+ 台通
+ -
+ EP600 通勤路上
+ Wed, 01 Jul 2026 16:00:00 GMT
+
+
+
+
diff --git a/tests/fixtures/feeds/atom.xml b/tests/fixtures/feeds/atom.xml
new file mode 100644
index 0000000..4183b58
--- /dev/null
+++ b/tests/fixtures/feeds/atom.xml
@@ -0,0 +1,12 @@
+
+
+ Atom Show
+ Atom Author
+
+ Atom EP1
+ atom-ep1
+ 2026-07-01T08:30:00Z
+
+
+
+
diff --git a/tests/fixtures/feeds/dirty_entities.xml b/tests/fixtures/feeds/dirty_entities.xml
new file mode 100644
index 0000000..0c7636a
--- /dev/null
+++ b/tests/fixtures/feeds/dirty_entities.xml
@@ -0,0 +1,13 @@
+
+
+
+
+ Dirty Show
+ Dirty & Sons
+ -
+ EP1 A & B
+ Mon, 29 Jun 2026 08:00:00 GMT
+
+
+
+
diff --git a/tests/fixtures/feeds/fireside.xml b/tests/fixtures/feeds/fireside.xml
new file mode 100644
index 0000000..3cdb690
--- /dev/null
+++ b/tests/fixtures/feeds/fireside.xml
@@ -0,0 +1,14 @@
+
+
+
+ 科技早知道
+ 声动活泼
+ no
+ -
+ S8E20 硅谷现场
+ Thu, 02 Jul 2026 08:00:00 +0800
+
+
+
+
diff --git a/tests/fixtures/feeds/firstory.xml b/tests/fixtures/feeds/firstory.xml
new file mode 100644
index 0000000..c1e6ad2
--- /dev/null
+++ b/tests/fixtures/feeds/firstory.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+ Mon, 01 Dec 2025 00:00:00 +0000
+
+ -
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/feeds/itunes_title_order.xml b/tests/fixtures/feeds/itunes_title_order.xml
new file mode 100644
index 0000000..6662e82
--- /dev/null
+++ b/tests/fixtures/feeds/itunes_title_order.xml
@@ -0,0 +1,14 @@
+
+
+
+ Short Channel
+ Real Channel Title
+ Order Author
+ -
+ Short EP
+ Real Episode Title
+ Sat, 04 Jul 2026 08:00:00 GMT
+
+
+
+
diff --git a/tests/fixtures/feeds/libsyn.xml b/tests/fixtures/feeds/libsyn.xml
new file mode 100644
index 0000000..70aec92
--- /dev/null
+++ b/tests/fixtures/feeds/libsyn.xml
@@ -0,0 +1,12 @@
+
+
+
+ Libsyn Show
+ Libsyn Author
+ -
+ Libsyn EP1
+ Mon, 29 Jun 2026 09:00:00 +0000
+
+
+
+
diff --git a/tests/fixtures/feeds/lizhi.xml b/tests/fixtures/feeds/lizhi.xml
new file mode 100644
index 0000000..78037f2
--- /dev/null
+++ b/tests/fixtures/feeds/lizhi.xml
@@ -0,0 +1,12 @@
+
+
+
+ 大内密谈
+ 大内密谈
+ -
+ vol.1200 深夜谈谈
+ Sun, 5 Jul 2026 21:00:00 +0800
+
+
+
+
diff --git a/tests/fixtures/feeds/media_content_only.xml b/tests/fixtures/feeds/media_content_only.xml
new file mode 100644
index 0000000..182c454
--- /dev/null
+++ b/tests/fixtures/feeds/media_content_only.xml
@@ -0,0 +1,13 @@
+
+
+
+ MRSS Show
+ editor@example.test (MRSS Author)
+ -
+ MRSS EP1
+ Thu, 02 Jul 2026 08:00:00 GMT
+
+
+
+
+
diff --git a/tests/fixtures/feeds/omny.xml b/tests/fixtures/feeds/omny.xml
new file mode 100644
index 0000000..663a967
--- /dev/null
+++ b/tests/fixtures/feeds/omny.xml
@@ -0,0 +1,15 @@
+
+
+
+ 馬力歐陪你喝一杯
+ 鏡好聽
+ -
+ EP388 對談
+ Tue, 30 Jun 2026 20:00:00 +0800
+
+
+
+
+
+
diff --git a/tests/fixtures/feeds/rss10_rdf.xml b/tests/fixtures/feeds/rss10_rdf.xml
new file mode 100644
index 0000000..16c2e46
--- /dev/null
+++ b/tests/fixtures/feeds/rss10_rdf.xml
@@ -0,0 +1,14 @@
+
+
+
+ RDF Show
+ RDF Author
+
+ -
+ RDF EP1
+ 2026-06-30T10:00:00Z
+
+
+
diff --git a/tests/fixtures/feeds/rss2_plain.xml b/tests/fixtures/feeds/rss2_plain.xml
new file mode 100644
index 0000000..0f7ff5e
--- /dev/null
+++ b/tests/fixtures/feeds/rss2_plain.xml
@@ -0,0 +1,20 @@
+
+
+
+ Plain RSS2 Show
+ Plain Author
+ -
+ EP2
+ Fri, 03 Jul 2026 08:00:00 GMT
+ plain-ep2
+ https://example.test/ep2
+
+
+ -
+ EP1
+ Wed, 01 Jul 2026 08:00:00 GMT
+ plain-ep1
+
+
+
+
diff --git a/tests/fixtures/feeds/soundon.xml b/tests/fixtures/feeds/soundon.xml
new file mode 100644
index 0000000..512c843
--- /dev/null
+++ b/tests/fixtures/feeds/soundon.xml
@@ -0,0 +1,16 @@
+
+
+
+ 股癌
+ 謝孟恭
+ https://feeds.soundon.fm/podcasts/954689a5-3096-43a4-a80b-7810b219cef3.xml
+ 954689a5-3096-43a4-a80b-7810b219cef3
+ -
+ EP560 | 台股創高
+ Sun, 05 Jul 2026 10:00:00 +0800
+ soundon-560
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/feeds/transistor.xml b/tests/fixtures/feeds/transistor.xml
new file mode 100644
index 0000000..f5439f5
--- /dev/null
+++ b/tests/fixtures/feeds/transistor.xml
@@ -0,0 +1,12 @@
+
+
+
+ Transistor Show
+ Transistor Author
+ -
+ Transistor EP1
+ Sun, 28 Jun 2026 09:00:00 +0000
+
+
+
+
diff --git a/tests/fixtures/feeds/typlog.xml b/tests/fixtures/feeds/typlog.xml
new file mode 100644
index 0000000..c20945e
--- /dev/null
+++ b/tests/fixtures/feeds/typlog.xml
@@ -0,0 +1,12 @@
+
+
+
+ 疯投圈
+ 黄海、Rio
+ -
+ 76. 咖啡 & 茶饮:新消费的陈年老酒
+ Fri, 03 Jul 2026 12:00:00 GMT
+
+
+
+
diff --git a/tests/fixtures/feeds/utf16_bom.xml b/tests/fixtures/feeds/utf16_bom.xml
new file mode 100644
index 0000000..20ff29f
Binary files /dev/null and b/tests/fixtures/feeds/utf16_bom.xml differ
diff --git a/tests/fixtures/feeds/wavpub.xml b/tests/fixtures/feeds/wavpub.xml
new file mode 100644
index 0000000..c056ad4
--- /dev/null
+++ b/tests/fixtures/feeds/wavpub.xml
@@ -0,0 +1,15 @@
+
+
+
+ 半拿铁 | 商业沉浮录
+ 潇磊布道
+ -
+
+ Wed, 01 Jul 2026 22:00:00 +0800
+ wavpub-91
+
+
+
+
+
diff --git a/tests/fixtures/feeds/xiaoyuzhou.xml b/tests/fixtures/feeds/xiaoyuzhou.xml
new file mode 100644
index 0000000..5ec0736
--- /dev/null
+++ b/tests/fixtures/feeds/xiaoyuzhou.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+ -
+
+ Thu, 02 Jul 2026 20:00:00 +0800
+ xyz-380
+ https://www.xiaoyuzhoufm.com/episode/abc380
+
+ 本期节目…]]>
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/feeds/ximalaya.xml b/tests/fixtures/feeds/ximalaya.xml
new file mode 100644
index 0000000..781f7cc
--- /dev/null
+++ b/tests/fixtures/feeds/ximalaya.xml
@@ -0,0 +1,13 @@
+
+
+
+ 声动早咖啡
+ 声动活泼
+ -
+ 硅谷巨头抢购核电,AI耗电有多猛?
+ Mon, 06 Jul 2026 07:30:00 +0800
+ xmly-1
+
+
+
+
\ No newline at end of file
diff --git a/tests/test_cli.py b/tests/test_cli.py
index a22ae7b..be9a58f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -5,6 +5,8 @@
import sys
import types
+import pytest
+
from podpull import cli, core
from rich.console import Console
@@ -168,3 +170,49 @@ def fake_dl(ep, out, **kw):
assert os.path.exists(os.path.join(show_dir, "EP0.mp3"))
assert os.path.exists(os.path.join(show_dir, "EP2.mp3"))
assert not os.path.exists(os.path.join(show_dir, "EP1.mp3"))
+
+
+def _search_args(term="故事"):
+ return argparse.Namespace(term=term, limit=10, country="US")
+
+
+def test_search_without_keys_never_calls_pi(monkeypatch):
+ monkeypatch.setattr(core, "pi_credentials", lambda: None)
+ monkeypatch.setattr(core, "search_shows", lambda term, limit, country: [
+ {"collectionId": 1, "collectionName": "A", "artistName": "x",
+ "feedUrl": "https://f/a", "trackCount": 3}])
+ monkeypatch.setattr(core, "pi_search_shows",
+ lambda *a, **k: pytest.fail("PI must not be called without keys"))
+ assert cli.cmd_search(_search_args()) == 0
+
+
+def test_search_merges_and_dedupes_pi(monkeypatch):
+ monkeypatch.setattr(core, "pi_credentials", lambda: ("k", "s"))
+ monkeypatch.setattr(core, "search_shows", lambda term, limit, country: [
+ {"collectionId": 1, "collectionName": "A", "artistName": "x",
+ "feedUrl": "https://f/a", "trackCount": 3}])
+ monkeypatch.setattr(core, "pi_search_shows", lambda term, limit: [
+ {"collectionId": 1, "collectionName": "A (PI)", "artistName": "x",
+ "feedUrl": "https://f/a/", "trackCount": 3}, # dup (trailing slash)
+ {"collectionId": 2, "collectionName": "B", "artistName": "y",
+ "feedUrl": "https://f/b", "trackCount": 9}]) # new
+ seen = []
+ monkeypatch.setattr(cli, "_render_search_table", lambda term, rows: seen.extend(rows))
+ assert cli.cmd_search(_search_args()) == 0
+ assert [r["collectionName"] for r in seen] == ["A", "B"] # iTunes wins the dup
+
+
+def test_search_survives_one_backend_failing(monkeypatch):
+ monkeypatch.setattr(core, "pi_credentials", lambda: ("k", "s"))
+
+ def _boom(term, limit, country):
+ raise OSError("itunes down")
+ monkeypatch.setattr(core, "search_shows", _boom)
+ monkeypatch.setattr(core, "pi_search_shows", lambda term, limit: [
+ {"collectionId": 2, "collectionName": "B", "artistName": "y",
+ "feedUrl": "https://f/b", "trackCount": 9}])
+ assert cli.cmd_search(_search_args()) == 0 # PI results still shown
+
+ monkeypatch.setattr(core, "pi_credentials", lambda: None)
+ with pytest.raises(OSError): # no keys -> unchanged behavior
+ cli.cmd_search(_search_args())
diff --git a/tests/test_core.py b/tests/test_core.py
index ca6ecb5..e87aacd 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,9 +1,117 @@
"""Pure-logic tests (no network)."""
+import io
+from pathlib import Path
+
import pytest
from podpull import core
from podpull.core import Episode
+FIXTURES = Path(__file__).parent / "fixtures" / "feeds"
+
+# (fixture, show_title, show_author, n_episodes, first_url, first_date)
+FEED_CASES = [
+ ("rss2_plain.xml", "Plain RSS2 Show", "Plain Author", 2,
+ "https://cdn.example.test/ep2.mp3", "2026-07-03"),
+ ("rss10_rdf.xml", "RDF Show", "RDF Author", 1,
+ "https://cdn.example.test/rdf1.mp3", "2026-06-30"),
+ ("atom.xml", "Atom Show", "Atom Author", 1,
+ "https://cdn.example.test/atom1.mp3", "2026-07-01"),
+ ("itunes_title_order.xml", "Real Channel Title", "Order Author", 1,
+ "https://cdn.example.test/order1.mp3", "2026-07-04"),
+ ("media_content_only.xml", "MRSS Show", "editor@example.test (MRSS Author)", 1,
+ "https://cdn.example.test/mrss1.mp3", "2026-07-02"),
+ ("wavpub.xml", "半拿铁 | 商业沉浮录", "潇磊布道", 1,
+ "https://tk.wavpub.com/track/caffebreve/91.m4a", "2026-07-01"),
+ ("omny.xml", "馬力歐陪你喝一杯", "鏡好聽", 1,
+ "https://omny.example.test/ep388.mp3", "2026-06-30"),
+ ("dirty_entities.xml", "Dirty Show", "Dirty & Sons", 1,
+ "https://cdn.example.test/dirty1.mp3?a=1&b=2", "2026-06-29"),
+ ("utf16_bom.xml", "UTF16 Show", "", 1,
+ "https://cdn.example.test/u16.mp3", "2026-06-28"),
+ ("xiaoyuzhou.xml", "忽左忽右", "JustPod", 1,
+ "https://dts-api.xiaoyuzhoufm.com/track/cv4bkgpuglwp/xyz380/media.xyzcdn.net/lvJzoGJDVn.m4a",
+ "2026-07-02"),
+ ("ximalaya.xml", "声动早咖啡", "声动活泼", 1,
+ "https://jt.ximalaya.com//GKwRIW8LWq_ZAX-DKgJcpzTV.m4a?channel=rss&jt=https%3A%2F%2Faod.cos.tx.xmcdn.com%2Fstorages%2Fabc.m4a",
+ "2026-07-06"),
+ ("soundon.xml", "股癌", "謝孟恭", 1,
+ "https://rss.soundon.fm/rssf/954689a5/ep560/rssFileVip.mp3?times=1751700000", "2026-07-05"),
+ ("firstory.xml", "百靈果 News", "百靈果 News", 1,
+ "https://m.cdn.firstory.me/track/cmjaz594i/https%3A%2F%2Ffile.cdn.firstory.me%2Fstory%2Fep500.mp3",
+ "2026-07-04"),
+ ("lizhi.xml", "大内密谈", "大内密谈", 1,
+ "http://cdn.lizhi.fm/audio/2026/07/05/1200.mp3", "2026-07-05"),
+ ("typlog.xml", "疯投圈", "黄海、Rio", 1,
+ "https://rio.xyzcdn.net/crazy-capital/ep76.m4a", "2026-07-03"),
+ ("fireside.xml", "科技早知道", "声动活泼", 1,
+ "https://aphid.fireside.fm/d/1437767933/s8e20.mp3", "2026-07-02"),
+ ("anchor.xml", "台灣通勤第一品牌", "台通", 1,
+ "https://anchor.fm/s/1ea77470/podcast/play/999/https%3A%2F%2Fd3ctxlq1ktw2nl.cloudfront.net%2Fstaging%2Fep600.m4a",
+ "2026-07-01"),
+ ("acast.xml", "不明白播客", "袁莉和她的朋友们", 1,
+ "https://sphinx.acast.com/p/open/s/68004395/e/100/media.mp3", "2026-06-30"),
+ ("libsyn.xml", "Libsyn Show", "Libsyn Author", 1,
+ "https://traffic.libsyn.com/secure/example/ep1.mp3?dest-id=1", "2026-06-29"),
+ ("transistor.xml", "Transistor Show", "Transistor Author", 1,
+ "https://media.transistor.fm/abc123/ep1.mp3", "2026-06-28"),
+]
+
+
+def _parse_fixture(monkeypatch, fname):
+ raw = (FIXTURES / fname).read_bytes()
+ monkeypatch.setattr(core, "fetch", lambda url, timeout=45: io.BytesIO(raw))
+ return core.parse_feed("https://example.test/feed")
+
+
+@pytest.mark.parametrize("fname,title,author,count,first_url,first_date", FEED_CASES)
+def test_parse_feed_fixture(monkeypatch, fname, title, author, count, first_url, first_date):
+ t, a, eps = _parse_fixture(monkeypatch, fname)
+ assert t == title
+ assert a == author
+ assert len(eps) == count
+ assert eps[0].url == first_url
+ assert eps[0].date == first_date
+ if fname == "itunes_title_order.xml":
+ assert eps[0].title == "Real Episode Title"
+
+
+def test_parse_feed_hopeless_xml_raises(monkeypatch):
+ monkeypatch.setattr(core, "fetch", lambda url, timeout=45: io.BytesIO(b""))
+ with pytest.raises(core.ET.ParseError):
+ core.parse_feed("https://example.test/feed")
+
+
+def test_sanitize_preserves_cdata_and_unknown_entities(monkeypatch):
+ raw = (
+ b'\n'
+ b'\n'
+ b'\n'
+ b'Dirty & Sons\n'
+ b'- A &bogus123; B\n'
+ b'Wed, 01 Jul 2026 08:00:00 GMT\n'
+ b'\n'
+ b'
'
+ )
+ monkeypatch.setattr(core, "fetch", lambda url, timeout=45: io.BytesIO(raw))
+ title, _a, eps = core.parse_feed("https://example.test/feed")
+ assert title == "AT&T R&D Show" # CDATA content untouched
+ assert eps[0].title == "A &bogus123; B" # unknown entity preserved, not dropped
+
+
+def test_sanitize_handles_utf16_without_bom(monkeypatch):
+ xml = (''
+ '中文播客 标题'
+ '- EP 一'
+ 'Wed, 01 Jul 2026 08:00:00 GMT'
+ ''
+ '
')
+ raw = xml.encode("utf-16-le") # deliberately NO BOM
+ monkeypatch.setattr(core, "fetch", lambda url, timeout=45: io.BytesIO(raw))
+ title, _a, eps = core.parse_feed("https://example.test/feed")
+ assert title == "中文播客 标题"
+ assert eps[0].title == "EP 一"
+
def test_classify():
assert core.classify("1532755821") == ("apple_show", "1532755821")
@@ -11,6 +119,10 @@ def test_classify():
assert core.classify("https://podcasts.apple.com/us/podcast/x/id123?i=456")[0] == "apple_episode"
assert core.classify("https://www.xiaoyuzhoufm.com/episode/abc")[0] == "xyz_episode"
assert core.classify("https://feed.firstory.me/rss/user/xyz")[0] == "rss"
+ assert core.classify("https://www.ximalaya.com/album/51076156") == \
+ ("rss", "https://www.ximalaya.com/album/51076156.xml")
+ assert core.classify("https://www.ximalaya.com/album/51076156.xml") == \
+ ("rss", "https://www.ximalaya.com/album/51076156.xml")
with pytest.raises(ValueError):
core.classify("not a url")
@@ -54,6 +166,14 @@ def _eps():
def test_episode_date():
assert _eps()[0].date == "2026-06-27"
assert Episode(title="x", pub="garbage", url="u").date == "0000-00-00"
+ # RFC-822 with non-zero-padded day (Lizhi) — parsedate handles it
+ assert Episode(title="x", pub="Sun, 5 Jul 2026 21:00:00 +0800", url="u").date == "2026-07-05"
+ # ISO-8601 (Atom published / dc:date), with and without Z
+ assert Episode(title="x", pub="2026-07-01T08:30:00Z", url="u").date == "2026-07-01"
+ assert Episode(title="x", pub="2026-07-01T08:30:00+08:00", url="u").date == "2026-07-01"
+ # ISO-8601 with colon-less offset (py3.9's fromisoformat rejects it raw)
+ assert Episode(title="x", pub="2026-07-01T08:30:00+0800", url="u").date == "2026-07-01"
+ assert Episode(title="x", pub="", url="u").date == "0000-00-00"
def test_select_match():
@@ -72,3 +192,135 @@ def test_select_index():
def test_select_none():
assert core.select(_eps()) == []
+
+
+def _fake_response(body: bytes, ctype: str):
+ class _Resp(io.BytesIO):
+ status = 200
+ headers = {"Content-Type": ctype}
+
+ def __init__(self):
+ super().__init__(body)
+ self.length = len(body)
+ return _Resp()
+
+
+def test_download_url_rejects_text_response(monkeypatch, tmp_path):
+ monkeypatch.setattr(core.urllib.request, "urlopen",
+ lambda req, timeout=None: _fake_response(b"deleted", "text/plain; charset=utf-8"))
+ dest = tmp_path / "ep.m4a"
+ with pytest.raises(ValueError, match="not audio"):
+ core.download_url("https://jt.ximalaya.com//x.m4a?bad=1", str(dest))
+ assert not dest.exists()
+
+
+def test_download_url_accepts_audio_and_octet_stream(monkeypatch, tmp_path):
+ for ctype in ("audio/mpeg", "application/octet-stream", ""):
+ monkeypatch.setattr(core.urllib.request, "urlopen",
+ lambda req, timeout=None, c=ctype: _fake_response(b"AUDIO", c))
+ dest = tmp_path / f"ok-{ctype.replace('/', '_') or 'none'}.mp3"
+ assert core.download_url("https://cdn.example.test/a.mp3", str(dest)) == str(dest)
+ assert dest.read_bytes() == b"AUDIO"
+
+
+def test_fetch_and_download_send_browser_ua(monkeypatch, tmp_path):
+ seen = []
+
+ class _Resp(io.BytesIO):
+ status = 200
+ length = 2
+ headers = {"Content-Type": "audio/mpeg"}
+
+ def __init__(self):
+ super().__init__(b"ok")
+
+ def fake_urlopen(req, timeout=None):
+ seen.append(req.get_header("User-agent"))
+ return _Resp()
+
+ monkeypatch.setattr(core.urllib.request, "urlopen", fake_urlopen)
+ core.fetch("https://feeds.soundon.fm/x.xml")
+ core.download_url("https://cdn.lizhi.fm/a.mp3", str(tmp_path / "a.mp3"))
+ assert seen == [core.UA, core.UA]
+ assert all("mozilla" in ua.lower() and "python" not in ua.lower() for ua in seen)
+
+
+def test_pi_credentials(monkeypatch):
+ monkeypatch.delenv("PODCASTINDEX_API_KEY", raising=False)
+ monkeypatch.delenv("PODCASTINDEX_API_SECRET", raising=False)
+ assert core.pi_credentials() is None
+ monkeypatch.setenv("PODCASTINDEX_API_KEY", "k")
+ assert core.pi_credentials() is None # secret still missing
+ monkeypatch.setenv("PODCASTINDEX_API_SECRET", "s")
+ assert core.pi_credentials() == ("k", "s")
+
+
+def test_pi_headers_deterministic():
+ h = core._pi_headers("key", "secret", now=1751900000)
+ assert h["X-Auth-Key"] == "key"
+ assert h["X-Auth-Date"] == "1751900000"
+ import hashlib
+ assert h["Authorization"] == hashlib.sha1(b"keysecret1751900000").hexdigest()
+ assert h["User-Agent"] == core.UA
+
+
+def test_pi_search_shows_normalizes(monkeypatch):
+ monkeypatch.setenv("PODCASTINDEX_API_KEY", "k")
+ monkeypatch.setenv("PODCASTINDEX_API_SECRET", "s")
+ monkeypatch.setattr(core, "_pi_get", lambda path, params: {
+ "feeds": [{"id": 887080, "title": "忽左忽右", "url": "https://feed.xyzfm.space/cv4bkgpuglwp",
+ "author": "JustPod", "itunesId": 1478791559, "episodeCount": 380}],
+ })
+ rows = core.pi_search_shows("忽左忽右", limit=5)
+ assert rows == [{"collectionId": 1478791559, "collectionName": "忽左忽右",
+ "artistName": "JustPod", "feedUrl": "https://feed.xyzfm.space/cv4bkgpuglwp",
+ "trackCount": 380}]
+
+
+def test_pi_feed_by_itunes_id(monkeypatch):
+ monkeypatch.setattr(core, "_pi_get",
+ lambda path, params: {"feed": {"url": "https://feed.example.test/x"}})
+ assert core.pi_feed_by_itunes_id("123") == "https://feed.example.test/x"
+ monkeypatch.setattr(core, "_pi_get", lambda path, params: {"feed": []})
+ assert core.pi_feed_by_itunes_id("123") is None
+
+ def _boom(path, params):
+ raise OSError("network down")
+ monkeypatch.setattr(core, "_pi_get", _boom)
+ assert core.pi_feed_by_itunes_id("123") is None # degrades, never raises
+
+
+def test_pi_get_never_touches_network_without_creds(monkeypatch):
+ monkeypatch.delenv("PODCASTINDEX_API_KEY", raising=False)
+ monkeypatch.delenv("PODCASTINDEX_API_SECRET", raising=False)
+
+ def _fail(*a, **k):
+ pytest.fail("network must not be touched without PI credentials")
+ monkeypatch.setattr(core.urllib.request, "urlopen", _fail)
+ with pytest.raises(ValueError, match="credentials not set"):
+ core._pi_get("/search/byterm", {"q": "x"})
+ assert core.pi_feed_by_itunes_id("123") is None # degrades, still no network
+
+
+def test_apple_show_to_feed_pi_fallback(monkeypatch):
+ monkeypatch.setenv("PODCASTINDEX_API_KEY", "k")
+ monkeypatch.setenv("PODCASTINDEX_API_SECRET", "s")
+ monkeypatch.setattr(core, "fetch_json", lambda url: {"results": []}) # iTunes empty
+ monkeypatch.setattr(core, "pi_feed_by_itunes_id",
+ lambda pid: "https://feed.example.test/fallback")
+ feed, name, author, pid = core.apple_show_to_feed("1478791559")
+ assert feed == "https://feed.example.test/fallback"
+ assert pid == "1478791559"
+ assert name == "" and author == ""
+
+
+def test_apple_show_to_feed_no_creds_still_raises(monkeypatch):
+ monkeypatch.delenv("PODCASTINDEX_API_KEY", raising=False)
+ monkeypatch.delenv("PODCASTINDEX_API_SECRET", raising=False)
+ monkeypatch.setattr(core, "fetch_json", lambda url: {"results": []})
+ calls = []
+ monkeypatch.setattr(core, "pi_feed_by_itunes_id",
+ lambda pid: calls.append(pid))
+ with pytest.raises(ValueError):
+ core.apple_show_to_feed("123")
+ assert calls == [] # PI never contacted without keys
diff --git a/tests/test_network.py b/tests/test_network.py
new file mode 100644
index 0000000..3cb559a
--- /dev/null
+++ b/tests/test_network.py
@@ -0,0 +1,34 @@
+"""Live-network integration tests. EXCLUDED from the default suite.
+
+Run explicitly: pytest -m network
+These hit real hosts and will flake if a show migrates feeds — that's the
+point: they detect real-world drift the offline fixtures can't.
+"""
+import pytest
+
+from podpull import core
+
+pytestmark = pytest.mark.network
+
+LIVE_FEEDS = [
+ # (label, feed_url)
+ ("xiaoyuzhou 忽左忽右", "https://feed.xyzfm.space/cv4bkgpuglwp"),
+ ("ximalaya 声动早咖啡", "https://www.ximalaya.com/album/51076156.xml"),
+ ("soundon 股癌", "https://feeds.soundon.fm/podcasts/954689a5-3096-43a4-a80b-7810b219cef3.xml"),
+ ("firstory 百靈果", "https://feed.firstory.me/rss/user/cmjaz594i0000hdvpdpnd4fw8"),
+]
+
+
+@pytest.mark.parametrize("label,feed", LIVE_FEEDS, ids=[f[0] for f in LIVE_FEEDS])
+def test_live_feed_parses(label, feed):
+ title, _author, eps = core.parse_feed(feed)
+ assert title, f"{label}: empty show title"
+ assert eps, f"{label}: no episodes parsed"
+ first = eps[0]
+ assert first.url.startswith("http"), f"{label}: bad enclosure {first.url!r}"
+ assert first.date != "0000-00-00", f"{label}: unparseable pubDate {first.pub!r}"
+
+
+def test_live_itunes_resolution():
+ feed, name, _author, pid = core.apple_show_to_feed("1493503146") # 忽左忽右
+ assert feed.startswith("http") and pid == "1493503146" and name