From 7ecbc1ea7d62ae90d72a51afd142269f477a6bf0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 10 Jul 2026 18:32:03 +0200 Subject: [PATCH] refactor: separate feed retrieval from parsing --- src/parser/feedDocumentParser.test.ts | 121 +++++++++++ src/parser/feedDocumentParser.ts | 221 ++++++++++++++++++++ src/parser/feedDocumentSource.ts | 21 ++ src/parser/feedParser.test.ts | 50 +++++ src/parser/feedParser.ts | 286 ++++++-------------------- 5 files changed, 479 insertions(+), 220 deletions(-) create mode 100644 src/parser/feedDocumentParser.test.ts create mode 100644 src/parser/feedDocumentParser.ts create mode 100644 src/parser/feedDocumentSource.ts 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 = `<?xml version="1.0"?> +<rss version="2.0"> + <channel> + <title>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; - } }