diff --git a/src/parser/feedDocumentParser.test.ts b/src/parser/feedDocumentParser.test.ts
new file mode 100644
index 0000000..8ba43f8
--- /dev/null
+++ b/src/parser/feedDocumentParser.test.ts
@@ -0,0 +1,121 @@
+import { describe, expect, it } from "vitest";
+
+import FeedDocumentParser from "./feedDocumentParser";
+
+const subscriptionUrl = "https://feeds.example.com/podcast.xml";
+const xml = `
+
+
+ Pure Parser Podcast
+ https://example.com/show
+
+ feed-guid
+ -
+ Episode One
+
+ https://example.com/episodes/one
+ episode-guid
+ Mon, 01 Jan 2024 00:00:00 GMT
+ 90
+
+
+`;
+
+describe("FeedDocumentParser", () => {
+ it("keeps feed and episode targets purpose-explicit without performing retrieval", () => {
+ const parsed = new FeedDocumentParser().parseEpisodes(xml, subscriptionUrl);
+
+ expect(parsed.feed).toEqual({
+ title: "Pure Parser Podcast",
+ subscriptionUrl,
+ artworkUrl: "https://cdn.example.com/feed.jpg",
+ siteUrl: "https://example.com/show",
+ guid: "feed-guid",
+ });
+ expect(parsed.episodes).toEqual([
+ expect.objectContaining({
+ title: "Episode One",
+ streamUrl: "https://media.example.com/one.mp3",
+ itemLink: "https://example.com/episodes/one",
+ guid: "episode-guid",
+ duration: 90,
+ }),
+ ]);
+ expect(parsed.episodes[0]).not.toHaveProperty("artworkUrl");
+ expect(parsed.episodes[0]).not.toHaveProperty("feedUrl");
+ expect(parsed.episodes[0]).not.toHaveProperty("podcastName");
+ });
+
+ it("does not turn feed fallbacks into episode-scoped targets", () => {
+ const noEpisodeTargets = `
+
+
+ Fallback Podcast
+ https://cdn.example.com/feed.jpg
+ -
+ No Link or Artwork
+
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+
+`;
+
+ const parsed = new FeedDocumentParser().parseEpisodes(noEpisodeTargets, subscriptionUrl);
+
+ expect(parsed.feed.artworkUrl).toBe("https://cdn.example.com/feed.jpg");
+ expect(parsed.episodes[0]).not.toHaveProperty("artworkUrl");
+ expect(parsed.episodes[0]).not.toHaveProperty("itemLink");
+ });
+
+ it("rejects a parser-generated malformed XML document", () => {
+ expect(() =>
+ new FeedDocumentParser().parseFeed("broken", subscriptionUrl),
+ ).toThrow("Invalid RSS feed");
+ });
+
+ it("accepts a valid feed containing an extension named parsererror", () => {
+ const validExtension = `
+
+
+ Valid Extension Podcast
+ extension payload
+
+`;
+
+ expect(new FeedDocumentParser().parseFeed(validExtension, subscriptionUrl)).toEqual({
+ title: "Valid Extension Podcast",
+ subscriptionUrl,
+ });
+ });
+
+ it("rejects Chromium's partial-root XHTML parser error document", () => {
+ const chromiumErrorShape = `
+
+
+ This page contains the following errors:
+ Premature end of data
+ Below is a rendering of the page up to the first error.
+
+ Partial Recovery
+`;
+
+ expect(() =>
+ new FeedDocumentParser().parseFeed(chromiumErrorShape, subscriptionUrl),
+ ).toThrow("Invalid RSS feed");
+ });
+
+ it("accepts a nested XHTML extension with the same local name", () => {
+ const nestedXhtmlExtension = `
+
+
+ Nested Extension Podcast
+ extension payload
+
+`;
+
+ expect(new FeedDocumentParser().parseFeed(nestedXhtmlExtension, subscriptionUrl)).toEqual({
+ title: "Nested Extension Podcast",
+ subscriptionUrl,
+ });
+ });
+});
diff --git a/src/parser/feedDocumentParser.ts b/src/parser/feedDocumentParser.ts
new file mode 100644
index 0000000..8fe176e
--- /dev/null
+++ b/src/parser/feedDocumentParser.ts
@@ -0,0 +1,221 @@
+import { decodeDate } from "src/persistence/dateCodec";
+import type { EpisodeMediaType } from "src/types/Episode";
+import {
+ getMediaTypeFromContentType,
+ getUnambiguousMediaTypeFromPath,
+} from "src/utility/mediaType";
+import { parseDurationToSeconds } from "src/utility/parseDuration";
+import { parseEpisodeNumber } from "src/utility/parseEpisodeNumber";
+
+/**
+ * Transient parser output. Raw targets stay purpose-explicit here so the
+ * capability broker can seal each one under the correct resource kind. This
+ * object must never be persisted or exposed to the UI, public API, or template
+ * engine.
+ */
+export interface ParsedFeedDocument {
+ title: string;
+ subscriptionUrl: string;
+ artworkUrl?: string;
+ siteUrl?: string;
+ description?: string;
+ author?: string;
+ guid?: string;
+}
+
+/** Transient episode metadata and purpose-explicit raw targets. */
+export interface ParsedEpisodeDocument {
+ title: string;
+ streamUrl: string;
+ itemLink?: string;
+ description: string;
+ content: string;
+ artworkUrl?: string;
+ episodeDate?: Date;
+ itunesTitle?: string;
+ episodeNumber?: number;
+ duration?: number;
+ chaptersUrl?: string;
+ guid?: string;
+ mediaType: EpisodeMediaType;
+}
+
+export interface ParsedFeedEpisodes {
+ feed: ParsedFeedDocument;
+ episodes: ParsedEpisodeDocument[];
+}
+
+const XHTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
+
+function isParserErrorDocument(document: Document): boolean {
+ const root = document.documentElement;
+ if (!root) return true;
+ if (root.localName.toLowerCase() === "parsererror") return true;
+
+ // Chromium preserves the partial XML root and inserts its generated XHTML
+ // parsererror as a direct root child. A feed-owned element with the same local
+ // name remains ordinary XML unless it has that engine-owned provenance.
+ return Array.from(document.getElementsByTagName("parsererror")).some(
+ (element) => element.namespaceURI === XHTML_NAMESPACE && element.parentElement === root,
+ );
+}
+
+/** Pure XML parsing boundary with no network or Obsidian dependency. */
+export default class FeedDocumentParser {
+ parseFeed(xml: string, subscriptionUrl: string): ParsedFeedDocument {
+ return this.extractFeed(this.parseXml(xml), subscriptionUrl);
+ }
+
+ parseEpisodes(xml: string, subscriptionUrl: string): ParsedFeedEpisodes {
+ const document = this.parseXml(xml);
+ return {
+ feed: this.extractFeed(document, subscriptionUrl),
+ episodes: this.parsePage(document),
+ };
+ }
+
+ /** Parse only item records when the legacy facade already has matching feed metadata. */
+ parseEpisodeItems(xml: string): ParsedEpisodeDocument[] {
+ return this.parsePage(this.parseXml(xml));
+ }
+
+ private parseXml(xml: string): Document {
+ const document = new DOMParser().parseFromString(xml, "text/xml");
+ if (isParserErrorDocument(document)) throw new Error("Invalid RSS feed");
+ return document;
+ }
+
+ private extractFeed(body: Document, subscriptionUrl: string): ParsedFeedDocument {
+ const titleEl = body.querySelector("title");
+
+ // A feed must have a title. is intentionally not required: it is the
+ // human website, but many valid feeds omit it or only include an Atom self link.
+ if (!titleEl) throw new Error("Invalid RSS feed");
+
+ const channel = body.querySelector("channel") ?? body;
+ const imageEl = this.findChannelImageElement(channel);
+ const artworkUrl =
+ imageEl?.getAttribute("href") || imageEl?.querySelector("url")?.textContent || "";
+ const siteUrl = this.findFeedLink(channel);
+ const description = this.findDirectChildText(channel, ["description", "itunes:summary"]);
+ const author = this.findDirectChildText(channel, [
+ "itunes:author",
+ "author",
+ "managingEditor",
+ ]);
+ const guid = this.findDirectChildText(channel, ["podcast:guid", "id"]);
+
+ return {
+ title: titleEl.textContent || "",
+ subscriptionUrl,
+ ...(artworkUrl ? { artworkUrl } : {}),
+ ...(siteUrl ? { siteUrl } : {}),
+ ...(description ? { description } : {}),
+ ...(author ? { author } : {}),
+ ...(guid ? { guid } : {}),
+ };
+ }
+
+ private findImageElement(document: Document | Element): Element | null {
+ const itunesImage = document.getElementsByTagName("itunes:image")[0];
+ return itunesImage ?? document.querySelector("image");
+ }
+
+ /** A nested item image must never become channel artwork. */
+ private findChannelImageElement(scope: Element | Document): Element | null {
+ const directChildren = Array.from(scope.children);
+ const itunesImage = directChildren.find(
+ (element) => element.tagName.toLowerCase() === "itunes:image",
+ );
+ if (itunesImage) return itunesImage;
+ return directChildren.find((element) => element.tagName.toLowerCase() === "image") ?? null;
+ }
+
+ /** Resolve only the human website link, never an Atom self or hub endpoint. */
+ private findFeedLink(scope: Document | Element): string {
+ const directLinks = Array.from(scope.children).filter((element) => {
+ const tag = element.tagName.toLowerCase();
+ return tag === "link" || tag === "atom:link";
+ });
+
+ for (const link of directLinks) {
+ const text = link.textContent?.trim();
+ if (text && /^https?:\/\//i.test(text)) return text;
+ }
+
+ for (const link of directLinks) {
+ const relation = link.getAttribute("rel");
+ if (relation && relation !== "alternate") continue;
+ const href = link.getAttribute("href")?.trim();
+ if (href && /^https?:\/\//i.test(href)) return href;
+ }
+
+ return "";
+ }
+
+ /** Read direct-child metadata in explicit tag-priority order. */
+ private findDirectChildText(scope: Document | Element, tagNames: string[]): string {
+ const children = Array.from(scope.children);
+ for (const tag of tagNames) {
+ const wanted = tag.toLowerCase();
+ for (const child of children) {
+ if (child.tagName.toLowerCase() !== wanted) continue;
+ const text = child.textContent?.trim() ?? "";
+ if (text) return text;
+ }
+ }
+ return "";
+ }
+
+ private parsePage(page: Document): ParsedEpisodeDocument[] {
+ return Array.from(page.querySelectorAll("item"))
+ .map((item) => this.parseItem(item))
+ .filter((episode): episode is ParsedEpisodeDocument => episode !== null);
+ }
+
+ private parseItem(item: Element): ParsedEpisodeDocument | null {
+ const titleEl = item.querySelector("title");
+ const streamUrlEl = item.querySelector("enclosure");
+ const pubDateEl = item.querySelector("pubDate");
+ if (!titleEl || !streamUrlEl || !pubDateEl) return null;
+
+ const title = titleEl.textContent || "";
+ const streamUrl = streamUrlEl.getAttribute("url") || "";
+ const itemLink = item.querySelector("link")?.textContent || "";
+ const description = item.querySelector("description")?.textContent || "";
+ const content = item.querySelector("*|encoded")?.textContent || "";
+ const episodeDate = decodeDate(pubDateEl.textContent);
+ const artworkUrl = this.findImageElement(item)?.getAttribute("href") || "";
+ const itunesTitle = item.getElementsByTagName("itunes:title")[0]?.textContent || "";
+ const episodeNumber = parseEpisodeNumber(
+ item.getElementsByTagName("itunes:episode")[0]?.textContent,
+ title,
+ );
+ const duration = parseDurationToSeconds(
+ item.getElementsByTagName("itunes:duration")[0]?.textContent,
+ );
+ const chaptersUrl =
+ item.getElementsByTagName("podcast:chapters")[0]?.getAttribute("url") || "";
+ const guid = this.findDirectChildText(item, ["guid"]);
+ const enclosureType = streamUrlEl.getAttribute("type");
+
+ return {
+ title,
+ streamUrl,
+ ...(itemLink ? { itemLink } : {}),
+ description,
+ content,
+ ...(artworkUrl ? { artworkUrl } : {}),
+ ...(episodeDate ? { episodeDate } : {}),
+ ...(itunesTitle ? { itunesTitle } : {}),
+ ...(episodeNumber === undefined ? {} : { episodeNumber }),
+ ...(duration === undefined ? {} : { duration }),
+ ...(chaptersUrl ? { chaptersUrl } : {}),
+ ...(guid ? { guid } : {}),
+ mediaType:
+ getMediaTypeFromContentType(enclosureType) ??
+ getUnambiguousMediaTypeFromPath(streamUrl) ??
+ "audio",
+ };
+ }
+}
diff --git a/src/parser/feedDocumentSource.ts b/src/parser/feedDocumentSource.ts
new file mode 100644
index 0000000..cf69304
--- /dev/null
+++ b/src/parser/feedDocumentSource.ts
@@ -0,0 +1,21 @@
+import { requestWithTimeout } from "src/utility/networkRequest";
+
+/** Retrieval port for the legacy target-shaped feed parser. */
+export interface FeedDocumentSource {
+ load(sourceUrl: string): Promise;
+}
+
+/**
+ * Current Obsidian compatibility source.
+ *
+ * This is deliberately named legacy: `requestUrl` does not expose DNS pinning,
+ * the connected peer, redirect hops, or native cancellation, so it must never
+ * be presented as a `PinnedNetworkHopAdapter` or as equivalent to the
+ * capability-scoped transport.
+ */
+export const legacyObsidianFeedDocumentSource: FeedDocumentSource = Object.freeze({
+ async load(sourceUrl: string): Promise {
+ const response = await requestWithTimeout(sourceUrl, { timeoutMs: 30_000 });
+ return response.text;
+ },
+});
diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts
index 6eaa283..a0e77cb 100644
--- a/src/parser/feedParser.test.ts
+++ b/src/parser/feedParser.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, test, vi, beforeEach } from "vitest";
import FeedParser from "./feedParser";
+import type { FeedDocumentSource } from "./feedDocumentSource";
import type { PodcastFeed } from "src/types/PodcastFeed";
vi.mock("src/utility/networkRequest", () => ({
@@ -227,6 +228,9 @@ describe("FeedParser", () => {
expect(feed.title).toBe("Test Podcast");
expect(feed.url).toBe("https://example.com/feed.xml");
+ expect(mockRequestWithTimeout).toHaveBeenCalledWith("https://example.com/feed.xml", {
+ timeoutMs: 30_000,
+ });
});
test("parses artwork URL from image element", async () => {
@@ -549,6 +553,36 @@ describe("FeedParser", () => {
expect(mockRequestWithTimeout).toHaveBeenCalledTimes(1);
});
+ test("uses matching cached feed metadata without requiring it in the fetched XML", async () => {
+ const cachedFeed: PodcastFeed = {
+ title: "Cached Podcast",
+ url: "https://example.com/feed.xml",
+ artworkUrl: "https://example.com/cached.jpg",
+ };
+ const itemsOnlyFeed = `
+
+
+ -
+ Cached Episode
+
+ Mon, 01 Jan 2024 00:00:00 GMT
+
+
+`;
+ mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(itemsOnlyFeed));
+
+ const episodes = await new FeedParser(cachedFeed).getEpisodes(cachedFeed.url);
+
+ expect(episodes).toEqual([
+ expect.objectContaining({
+ title: "Cached Episode",
+ podcastName: "Cached Podcast",
+ artworkUrl: "https://example.com/cached.jpg",
+ feedUrl: cachedFeed.url,
+ }),
+ ]);
+ });
+
test("uses episode artwork from itunes:image when available", async () => {
mockRequestWithTimeout.mockResolvedValueOnce(
feedResponse(sampleRssFeedWithItunesImage),
@@ -586,6 +620,22 @@ describe("FeedParser", () => {
});
describe("constructor", () => {
+ test("accepts an injected document source without using the legacy source", async () => {
+ const source: FeedDocumentSource = {
+ load: vi.fn().mockResolvedValue(sampleRssFeed),
+ };
+ const parser = new FeedParser(undefined, source);
+ const injectedUrl = "https://injected.example/feed.xml";
+
+ await expect(parser.getFeed(injectedUrl)).resolves.toMatchObject({
+ title: "Test Podcast",
+ url: injectedUrl,
+ });
+ expect(source.load).toHaveBeenCalledOnce();
+ expect(source.load).toHaveBeenCalledWith(injectedUrl);
+ expect(mockRequestWithTimeout).not.toHaveBeenCalled();
+ });
+
test("accepts optional feed parameter", () => {
const mockFeed: PodcastFeed = {
title: "Test Podcast",
diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts
index 5bc367c..5646a0a 100644
--- a/src/parser/feedParser.ts
+++ b/src/parser/feedParser.ts
@@ -1,234 +1,80 @@
-import type { PodcastFeed } from "src/types/PodcastFeed";
import type { Episode } from "src/types/Episode";
-import { decodeDate } from "src/persistence/dateCodec";
-import { requestWithTimeout } from "src/utility/networkRequest";
-import { parseEpisodeNumber } from "src/utility/parseEpisodeNumber";
-import { parseDurationToSeconds } from "src/utility/parseDuration";
-import {
- getMediaTypeFromContentType,
- getUnambiguousMediaTypeFromPath,
-} from "src/utility/mediaType";
+import type { PodcastFeed } from "src/types/PodcastFeed";
+
+import FeedDocumentParser, {
+ type ParsedEpisodeDocument,
+ type ParsedFeedDocument,
+} from "./feedDocumentParser";
+import { legacyObsidianFeedDocumentSource, type FeedDocumentSource } from "./feedDocumentSource";
+
+const defaultDocumentParser = new FeedDocumentParser();
+
+function projectLegacyFeed(parsed: ParsedFeedDocument): PodcastFeed {
+ return {
+ title: parsed.title,
+ url: parsed.subscriptionUrl,
+ artworkUrl: parsed.artworkUrl ?? "",
+ ...(parsed.siteUrl ? { link: parsed.siteUrl } : {}),
+ ...(parsed.description ? { description: parsed.description } : {}),
+ ...(parsed.author ? { author: parsed.author } : {}),
+ };
+}
+function projectLegacyEpisode(parsed: ParsedEpisodeDocument, feed: PodcastFeed): Episode {
+ return {
+ title: parsed.title,
+ streamUrl: parsed.streamUrl,
+ url: parsed.itemLink || feed.url,
+ description: parsed.description,
+ content: parsed.content,
+ podcastName: feed.title,
+ artworkUrl: parsed.artworkUrl || feed.artworkUrl,
+ episodeDate: parsed.episodeDate,
+ feedUrl: feed.url,
+ itunesTitle: parsed.itunesTitle || "",
+ episodeNumber: parsed.episodeNumber,
+ duration: parsed.duration,
+ chaptersUrl: parsed.chaptersUrl,
+ mediaType: parsed.mediaType,
+ };
+}
+
+/**
+ * Compatibility facade for the existing target-shaped runtime.
+ * Retrieval is injected so parsing can migrate to the capability broker without
+ * pretending Obsidian's opaque `requestUrl` primitive is a pinned transport.
+ * Legacy target fallbacks live only here and never erase parser provenance.
+ */
export default class FeedParser {
private feed: PodcastFeed | undefined;
- constructor(feed?: PodcastFeed) {
+ constructor(
+ feed?: PodcastFeed,
+ private readonly source: FeedDocumentSource = legacyObsidianFeedDocumentSource,
+ ) {
+ if (typeof source?.load !== "function") {
+ throw new TypeError("A feed document source is required.");
+ }
this.feed = feed;
}
- public async getEpisodes(url: string): Promise {
- // Fetch and parse the feed exactly ONCE, then reuse that single document
- // for both the channel metadata and the episode items. Previously this
- // parsed the feed here AND again inside getFeed, doubling the network
- // round-trips and XML parses on every cold call (e.g. the URIHandler
- // resume-link path that constructs a FeedParser with no cached feed).
- const body = await this.parseFeed(url);
-
- // Ensure feed metadata is loaded and cached; parseItem reads this.feed for
- // the podcast name and the per-episode artwork fallback.
- if (!this.feed || this.feed.url !== url) {
- this.feed = this.extractFeed(body, url);
+ async getEpisodes(sourceUrl: string): Promise {
+ const xml = await this.source.load(sourceUrl);
+ const existingFeed = this.feed;
+ if (existingFeed?.url === sourceUrl) {
+ return defaultDocumentParser
+ .parseEpisodeItems(xml)
+ .map((episode) => projectLegacyEpisode(episode, existingFeed));
}
-
- return this.parsePage(body);
+ const parsed = defaultDocumentParser.parseEpisodes(xml, sourceUrl);
+ const feed = projectLegacyFeed(parsed.feed);
+ this.feed = feed;
+ return parsed.episodes.map((episode) => projectLegacyEpisode(episode, feed));
}
- public async getFeed(url: string): Promise {
- const body = await this.parseFeed(url);
- this.feed = this.extractFeed(body, url);
+ async getFeed(sourceUrl: string): Promise {
+ const xml = await this.source.load(sourceUrl);
+ this.feed = projectLegacyFeed(defaultDocumentParser.parseFeed(xml, sourceUrl));
return this.feed;
}
-
- private extractFeed(body: Document, url: string): PodcastFeed {
- const titleEl = body.querySelector("title");
-
- // A feed must have a title. is intentionally NOT required: it is the
- // human website (now surfaced as {{url}} in feed notes), but many valid
- // feeds omit a channel or only carry an .
- if (!titleEl) {
- throw new Error("Invalid RSS feed");
- }
-
- const channel = body.querySelector("channel") ?? body;
- // Resolve artwork from the channel's DIRECT children only. Searching the
- // whole document (getElementsByTagName/querySelector) recurses into
- // - descendants, so a feed with no channel image but item images
- // would wrongly adopt an episode's image as its artwork (FP-02).
- const itunesImageEl = this.findChannelImageElement(channel);
-
- const title = titleEl.textContent || "";
- const artworkUrl =
- itunesImageEl?.getAttribute("href") ||
- itunesImageEl?.querySelector("url")?.textContent ||
- "";
-
- const feed: PodcastFeed = {
- title,
- url,
- artworkUrl,
- };
-
- const link = this.findFeedLink(channel);
- if (link) feed.link = link;
-
- const description = this.findDirectChildText(channel, ["description", "itunes:summary"]);
- if (description) feed.description = description;
-
- const author = this.findDirectChildText(channel, [
- "itunes:author",
- "author",
- "managingEditor",
- ]);
- if (author) feed.author = author;
-
- return feed;
- }
-
- private findImageElement(doc: Document | Element): Element | null {
- // Try iTunes-specific first (handles )
- const itunesImage = doc.getElementsByTagName("itunes:image")[0];
- if (itunesImage) return itunesImage;
-
- // Fallback to generic element
- return doc.querySelector("image");
- }
-
- /**
- * Resolve the feed's image from the channel's DIRECT children only, so a
- * nested
- image never leaks up as the feed artwork (FP-02). Prefers
- * then a generic . Unlike findImageElement
- * (used for items, which can't nest images), this never recurses.
- */
- private findChannelImageElement(scope: Element | Document): Element | null {
- const directChildren = Array.from(scope.children);
- const itunesImage = directChildren.find(
- (el) => el.tagName.toLowerCase() === "itunes:image",
- );
- if (itunesImage) return itunesImage;
-
- return directChildren.find((el) => el.tagName.toLowerCase() === "image") ?? null;
- }
-
- /**
- * Resolve the feed's website URL from the channel's direct children.
- * Prefers a whose text content is an http(s) URL; RSS feeds commonly
- * place an (URL in the attribute, empty
- * text) before the website , so those are skipped, with an atom
- * alternate href used only as a last resort. Returns "" when none is present.
- */
- private findFeedLink(scope: Document | Element): string {
- const directLinks = Array.from(scope.children).filter((el) => {
- const tag = el.tagName.toLowerCase();
- return tag === "link" || tag === "atom:link";
- });
-
- for (const link of directLinks) {
- const text = link.textContent?.trim();
- if (text && /^https?:\/\//i.test(text)) return text;
- }
-
- for (const link of directLinks) {
- // Only an "alternate" (or rel-less) atom link is the human website.
- // Skip self/hub/next/prev/first/last/payment/edit and similar relations
- // so a PubSubHubbub hub or pagination URL never becomes the feed link.
- const rel = link.getAttribute("rel");
- if (rel && rel !== "alternate") continue;
- const href = link.getAttribute("href")?.trim();
- if (href && /^https?:\/\//i.test(href)) return href;
- }
-
- return "";
- }
-
- /**
- * Read a feed-level value from the first DIRECT child of `scope` matching
- * `tagNames`, honouring the PRIORITY of `tagNames` (not document order): the
- * first tag name with a non-empty value wins. This keeps a preferred tag
- * (e.g. ) ahead of a fallback () regardless of
- * their order in the XML, and lets an empty fall through to
- * . Direct-child scoping keeps it from matching the same tags
- * nested inside an
- .
- */
- private findDirectChildText(scope: Document | Element, tagNames: string[]): string {
- const children = Array.from(scope.children);
- for (const tag of tagNames) {
- const wanted = tag.toLowerCase();
- for (const child of children) {
- if (child.tagName.toLowerCase() !== wanted) continue;
- const text = child.textContent?.trim() ?? "";
- if (text) return text;
- }
- }
- return "";
- }
-
- protected parsePage(page: Document): Episode[] {
- const items = page.querySelectorAll("item");
-
- function isEpisode(ep: Episode | null): ep is Episode {
- return !!ep;
- }
-
- return Array.from(items).map(this.parseItem.bind(this)).filter(isEpisode);
- }
-
- protected parseItem(item: Element): Episode | null {
- const titleEl = item.querySelector("title");
- const streamUrlEl = item.querySelector("enclosure");
- const linkEl = item.querySelector("link");
- const descriptionEl = item.querySelector("description");
- const contentEl = item.querySelector("*|encoded");
- const pubDateEl = item.querySelector("pubDate");
- const itunesImageEl = this.findImageElement(item);
- const itunesTitleEl = item.getElementsByTagName("itunes:title")[0];
- const itunesEpisodeEl = item.getElementsByTagName("itunes:episode")[0];
- const itunesDurationEl = item.getElementsByTagName("itunes:duration")[0];
- const chaptersEl = item.getElementsByTagName("podcast:chapters")[0];
-
- if (!titleEl || !streamUrlEl || !pubDateEl) {
- return null;
- }
-
- const title = titleEl.textContent || "";
- const streamUrl = streamUrlEl.getAttribute("url") || "";
- const enclosureType = streamUrlEl.getAttribute("type");
- const url = linkEl?.textContent || "";
- const description = descriptionEl?.textContent || "";
- const content = contentEl?.textContent || "";
- const pubDate = decodeDate(pubDateEl.textContent);
- const artworkUrl = itunesImageEl?.getAttribute("href") || this.feed?.artworkUrl;
- const itunesTitle = itunesTitleEl?.textContent;
- const episodeNumber = parseEpisodeNumber(itunesEpisodeEl?.textContent, title);
- const duration = parseDurationToSeconds(itunesDurationEl?.textContent);
- const chaptersUrl = chaptersEl?.getAttribute("url") || undefined;
-
- return {
- title,
- streamUrl,
- url: url || this.feed?.url || "",
- description,
- content,
- podcastName: this.feed?.title || "",
- artworkUrl,
- episodeDate: pubDate,
- feedUrl: this.feed?.url || "",
- itunesTitle: itunesTitle || "",
- episodeNumber,
- duration,
- chaptersUrl,
- mediaType:
- getMediaTypeFromContentType(enclosureType) ??
- getUnambiguousMediaTypeFromPath(streamUrl) ??
- "audio",
- };
- }
-
- private async parseFeed(feedUrl: string): Promise {
- const req = await requestWithTimeout(feedUrl, { timeoutMs: 30000 });
- const dp = new DOMParser();
-
- const body = dp.parseFromString(req.text, "text/xml");
-
- return body;
- }
}