import { Buffer } from 'node:buffer'; import { parseString } from 'xml2js'; import { type ZodError, z } from 'zod'; import { type AtomEntry, AtomEntrySchema, AtomFeedSchema, type RssItem, RssItemSchema, RssSchema, } from '../types/schemas'; import { boundArticleText, boundFeedText, truncateUtf16 } from '../utils/bounded-text'; import { MAX_ARTICLE_AUTHOR_SIZE, MAX_ARTICLE_CATEGORIES_SIZE, MAX_ARTICLE_CATEGORY_COUNT, MAX_ARTICLE_CATEGORY_SIZE, MAX_ARTICLE_LINK_SIZE, MAX_CONTENT_SIZE, MAX_FEED_XML_DEPTH, MAX_FEED_XML_NODES, MAX_RESPONSE_SIZE, } from '../utils/constants'; import { hasRenderableContent } from '../utils/html'; import { parseHttpUrl } from '../utils/validation'; import { lenientValidateArray } from './lenient-validate'; /** * Format a ZodError into a concise human-readable message. * Shows at most 3 issues with dot-notation paths, adding "... and N more" if truncated. */ export function formatZodError(error: ZodError): string { const MAX_ISSUES = 3; const issues = error.issues; const formatted = issues.slice(0, MAX_ISSUES).map((issue) => { // Build dot-notation path: entry[0].title, channel.link, etc. const pathStr = issue.path .map((segment, i) => { if (typeof segment === 'number') { return `[${segment}]`; } const pathPart = String(segment); return i > 0 ? `.${pathPart}` : pathPart; }) .join(''); // For invalid_type, show "expected X, received Y"; otherwise use the message const detail = issue.code === 'invalid_type' && 'expected' in issue && 'received' in issue ? `expected ${issue.expected}, received ${issue.received}` : issue.message; return pathStr ? `${pathStr} (${detail})` : `(${detail})`; }); let result = `Feed validation failed: ${formatted.join(', ')}`; if (issues.length > MAX_ISSUES) { result += ` ... and ${issues.length - MAX_ISSUES} more`; } return result; } export interface ParsedArticle { guid?: string; title?: string; link?: string; description?: string; content?: string; author?: string; categories?: string[]; publishedAt?: Date; } export interface ParsedFeed { title?: string; link?: string; description?: string; language?: string; articles: ParsedArticle[]; feedType: 'rss' | 'atom'; etag?: string; lastModified?: string; } /** An unrecoverable Feed document failure. These use ADR-0009's permanent threshold. */ export class FeedParseError extends Error { readonly category = 'permanent' as const; constructor(message: string, cause?: unknown) { super(message, cause === undefined ? undefined : { cause }); this.name = 'FeedParseError'; } } export type FeedParseDiagnostic = | { kind: 'feed-validation-error'; feedType: 'rss' | 'atom'; feedUrl?: string; issueCount: number; } | { kind: 'article-missing-critical-fields'; feedType: 'rss' | 'atom'; hasTitle: boolean; hasLink: boolean; guid?: string; keys: string[]; } | { kind: 'article-invalid-link'; feedType: 'rss' | 'atom'; guid?: string; link?: string; reason: 'missing' | 'invalid-url' | 'unsupported-protocol' | 'credentials' | 'too-long'; keys: string[]; }; export interface ParseFeedOptions { etag?: string; lastModified?: string; feedUrl?: string; diagnostics?: (diagnostic: FeedParseDiagnostic) => void; } const ATOM_NAMESPACE = 'http://www.w3.org/2005/Atom'; const XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; function firstElementStartTag(xml: string): { name: string; attributes: string } | undefined { const skipDelimited = (start: number, delimiter: string): number => { const end = xml.indexOf(delimiter, start); return end === -1 ? xml.length : end + delimiter.length; }; const findMarkupEnd = (start: number, trackDoctypeSubset = false): number => { let quote: '"' | "'" | undefined; let subsetDepth = 0; for (let index = start; index < xml.length; index++) { const character = xml[index]; if (quote) { if (character === quote) quote = undefined; continue; } if (character === '"' || character === "'") quote = character; else if (trackDoctypeSubset && character === '[') subsetDepth++; else if (trackDoctypeSubset && character === ']') subsetDepth = Math.max(0, subsetDepth - 1); else if (character === '>' && subsetDepth === 0) return index; } return xml.length; }; let cursor = 0; while (cursor < xml.length) { const open = xml.indexOf('<', cursor); if (open < 0) return undefined; if (xml.startsWith(''); continue; } if (xml.startsWith(''); continue; } if (xml.startsWith(''); continue; } if (xml.startsWith('= xml.length) return undefined; const tag = xml.slice(open + 1, end); const name = tag.match(/^([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)(?=\s|\/|$)/)?.[1]; if (name) { return { name, attributes: tag.slice(name.length) }; } cursor = end + 1; } return undefined; } /** * Validate feed XML structure */ export function validateFeedStructure(xml: string): { valid: boolean; error?: string } { const trimmed = xml.trimStart().toLowerCase(); if (trimmed.startsWith('(); for (const match of attributes.matchAll(/\s+xmlns(?::([A-Za-z_][\w.-]*))?\s*=\s*(['"])(.*?)\2/g)) { const namespaceUri = match[3]; if (namespaceUri !== undefined) { namespaces.set(match[1] ?? '', namespaceUri); } } if (namespaces.get(prefix ?? '') === ATOM_NAMESPACE) { return { valid: true }; } } return { valid: false, error: 'XML document does not appear to be an RSS or Atom feed' }; } /** * Parse date string to Date object */ function parseDate(dateStr?: string): Date | undefined { if (!dateStr) return undefined; // Try parsing directly const parsed = new Date(dateStr); if (!Number.isNaN(parsed.getTime())) { return parsed; } // Try fixing common issues with GMT const gmtDate = dateStr.trim().replace(/GMT$/, '+0000'); const gmtParsed = new Date(gmtDate); if (!Number.isNaN(gmtParsed.getTime())) { return gmtParsed; } return undefined; } /** * Extract text from mixed content. * Handles: string, {_: string, ...}, arrays (takes first element), null/undefined */ function extractText(content: unknown): string | undefined { if (typeof content === 'string') return content; if (Array.isArray(content)) return extractText(content[0]); if (content && typeof content === 'object' && '_' in content) { const val = (content as Record)._; return typeof val === 'string' ? val : undefined; } return undefined; } /** Preserve the first feed-provided body that will actually render for readers. */ function firstRenderableContent(...candidates: Array): string | undefined { return candidates.map((candidate) => truncateUtf16(candidate, MAX_CONTENT_SIZE)).find(hasRenderableContent); } /** * Resolve and canonicalize a usable HTTP(S) URL. Relative URLs retain Atom/RSS * base resolution, while fragments and equivalent URL spellings collapse to a * single resource identity. Credential-bearing and overlong links are rejected. */ function resolveHttpLink(link: string | undefined, baseUrl?: string, canonicalize = true): string | undefined { if (!link) return undefined; const value = link.trim(); if (!value || value.length > MAX_ARTICLE_LINK_SIZE) return undefined; try { let sourceIsAbsolute = true; try { new URL(value); } catch { sourceIsAbsolute = false; } const resolved = baseUrl ? new URL(value, baseUrl) : new URL(value); if (resolved.href.length > MAX_ARTICLE_LINK_SIZE) return undefined; const canonical = parseHttpUrl(resolved.href); if (!canonical.valid) return undefined; if (!canonicalize) return sourceIsAbsolute ? value : resolved.href; return canonical.value; } catch { return undefined; } } function invalidLinkReason( link: string | undefined, baseUrl?: string, ): 'missing' | 'invalid-url' | 'unsupported-protocol' | 'credentials' | 'too-long' { if (!link?.trim()) return 'missing'; if (link.trim().length > MAX_ARTICLE_LINK_SIZE) return 'too-long'; try { const resolved = baseUrl ? new URL(link.trim(), baseUrl) : new URL(link.trim()); if (resolved.href.length > MAX_ARTICLE_LINK_SIZE) return 'too-long'; if (resolved.protocol !== 'http:' && resolved.protocol !== 'https:') return 'unsupported-protocol'; if (resolved.username || resolved.password) return 'credentials'; return 'invalid-url'; } catch { return 'invalid-url'; } } function emitInvalidArticleLink( options: ParseFeedOptions, feedType: 'rss' | 'atom', article: Pick, rawLink: string | undefined, keys: string[], baseUrl?: string, ): void { if (!article.title || !rawLink) { options.diagnostics?.({ kind: 'article-missing-critical-fields', feedType, hasTitle: !!article.title, hasLink: !!rawLink, guid: article.guid, keys, }); } options.diagnostics?.({ kind: 'article-invalid-link', feedType, guid: article.guid, link: rawLink, reason: invalidLinkReason(rawLink, baseUrl), keys, }); } function throwWhenSourceArticlesHaveNoUsableLinks( sourceArticleCount: number, articles: ParsedArticle[], feedType: 'rss' | 'atom', ): void { if (sourceArticleCount > 0 && articles.length === 0) { throw new Error( `Feed contains ${sourceArticleCount} ${feedType === 'rss' ? 'item' : 'entry'}(s), but none have usable HTTP(S) links.`, ); } } /** * Extract link from RSS item. * Handles: string, array of strings (takes first), object with _ property */ function extractRssLinkCandidates(link: unknown): string[] { const candidates = Array.isArray(link) ? link : [link]; return candidates.map(extractText).filter((candidate): candidate is string => candidate !== undefined); } function resolveRssLink(link: unknown, baseUrl?: string, canonicalize = true): string | undefined { return extractRssLinkCandidates(link) .map((candidate) => resolveHttpLink(candidate, baseUrl, canonicalize)) .find((candidate): candidate is string => candidate !== undefined); } function extractRssGuidLink(item: RssItem): string | undefined { if (!item.guid) return undefined; // RSS 2.0 defaults isPermaLink to true when the attribute is omitted. xml2js // represents an unattributed GUID as a string and an attributed GUID as an // object, so accept either form unless the feed explicitly opts out. if (typeof item.guid !== 'string' && item.guid.isPermaLink === false) return undefined; return extractText(item.guid); } /** * Extract categories from RSS item */ function extractRssCategories(item: RssItem): string[] { const categories: string[] = []; let remaining = MAX_ARTICLE_CATEGORIES_SIZE; if (!item.category) return categories; const catArray = Array.isArray(item.category) ? item.category : [item.category]; for (const cat of catArray) { if (categories.length >= MAX_ARTICLE_CATEGORY_COUNT || remaining <= 0) break; const raw = typeof cat === 'string' ? cat : cat?._; if (raw === undefined) continue; const category = truncateUtf16(raw, Math.min(MAX_ARTICLE_CATEGORY_SIZE, remaining)) ?? ''; if (category.length === 0 && raw.length > 0) break; categories.push(category); remaining -= category.length; if (category.length < raw.length) break; } return categories; } /** * Parse RSS feed */ function parseRssFeed(xmlObj: Record, options: ParseFeedOptions): ParsedFeed { let rss: z.infer; try { rss = RssSchema.parse({ version: (xmlObj.rss as Record)?.version || ((xmlObj.rss as Record)?.$ as Record)?.version, channel: (xmlObj.rss as Record)?.channel || xmlObj.channel, }); } catch (error) { if (!(error instanceof z.ZodError)) throw error; options.diagnostics?.({ kind: 'feed-validation-error', feedType: 'rss', feedUrl: options.feedUrl, issueCount: error.issues.length, }); // Lenient parse: keep items that validate; only escalate if zero survive. const rawChannel = (xmlObj.rss as Record)?.channel || xmlObj.channel; if (!rawChannel || typeof rawChannel !== 'object') { throw new Error(formatZodError(error)); } const validItems = lenientValidateArray((rawChannel as Record).item, RssItemSchema); if (validItems.length === 0) { throw new Error(formatZodError(error)); } rss = { channel: { ...(rawChannel as Record), title: extractText((rawChannel as Record).title) || 'Unknown', description: extractText((rawChannel as Record).description) || '', item: validItems, }, } as z.infer; } const channel = rss.channel; const parsedArticles: ParsedArticle[] = []; // Process items const items: RssItem[] = channel.item ? (Array.isArray(channel.item) ? channel.item : [channel.item]) : []; for (const item of items) { const guid = typeof item.guid === 'string' ? item.guid : extractText(item.guid); const explicitLinks = extractRssLinkCandidates(item.link); const explicitLink = explicitLinks[0]; const guidLink = extractRssGuidLink(item); const rawLink = explicitLink || guidLink; const article: ParsedArticle = boundArticleText({ guid, title: extractText(item.title), // A valid isPermaLink GUID is a useful recovery path when an otherwise // present is malformed or uses an unsupported scheme. link: resolveRssLink(item.link, options.feedUrl) || resolveHttpLink(guidLink, options.feedUrl), description: extractText(item.description), content: firstRenderableContent( extractText(item['content:encoded']), extractText(item.encoded), extractText(item.content), ), author: extractText(item['dc:creator']) || extractText(item.creator) || extractText(item.author), categories: extractRssCategories(item), publishedAt: parseDate(extractText(item.pubDate) || extractText(item['dc:date']) || item.date), }); if (!article.link) { emitInvalidArticleLink(options, 'rss', article, rawLink, Object.keys(item), options.feedUrl); continue; } parsedArticles.push(article); } throwWhenSourceArticlesHaveNoUsableLinks(items.length, parsedArticles, 'rss'); return boundFeedText({ title: extractText(channel.title), link: resolveRssLink(channel.link, options.feedUrl, false), description: extractText(channel.description), language: channel.language, articles: parsedArticles, feedType: 'rss', }); } /** * Extract author from Atom entry */ function extractAtomAuthor(entry: AtomEntry): string | undefined { if (!entry.author) return undefined; const authors = Array.isArray(entry.author) ? entry.author : [entry.author]; let result = ''; for (const author of authors) { const name = typeof author === 'string' ? author : author.name; if (!name) continue; const separator = result ? ', ' : ''; if (result.length + separator.length >= MAX_ARTICLE_AUTHOR_SIZE) break; result += separator; const chunk = truncateUtf16(name, MAX_ARTICLE_AUTHOR_SIZE - result.length) ?? ''; result += chunk; if (chunk.length < name.length || result.length >= MAX_ARTICLE_AUTHOR_SIZE) break; } return result || undefined; } /** * Extract categories from Atom entry */ function extractAtomCategories(entry: AtomEntry): string[] { if (!entry.category) return []; const cats = Array.isArray(entry.category) ? entry.category : [entry.category]; const categories: string[] = []; let remaining = MAX_ARTICLE_CATEGORIES_SIZE; for (const cat of cats) { if (categories.length >= MAX_ARTICLE_CATEGORY_COUNT || remaining <= 0) break; const raw = typeof cat === 'string' ? cat : cat.label || cat.term; if (!raw) continue; const category = truncateUtf16(raw, Math.min(MAX_ARTICLE_CATEGORY_SIZE, remaining)) ?? ''; if (category.length === 0) break; categories.push(category); remaining -= category.length; if (category.length < raw.length) break; } return categories; } const ATOM_LOCAL_NAMES = new Set([ 'feed', 'entry', 'id', 'title', 'updated', 'author', 'name', 'uri', 'email', 'content', 'summary', 'category', 'contributor', 'link', 'published', 'rights', 'source', 'subtitle', 'generator', 'icon', 'logo', 'div', ]); interface XmlNamespace { uri?: unknown; local?: unknown; } function elementNamespace(value: unknown): XmlNamespace | undefined { const element = Array.isArray(value) ? value[0] : value; if (!element || typeof element !== 'object') return undefined; const namespace = (element as Record).$ns; return namespace && typeof namespace === 'object' ? (namespace as XmlNamespace) : undefined; } function isXmlAttribute(value: unknown): value is { value: string } { return ( !!value && typeof value === 'object' && 'name' in value && 'value' in value && typeof (value as { value: unknown }).value === 'string' ); } /** * Namespace-aware Atom normalization. xml2js retains element prefixes, but * its `xmlns` mode also tells us the URI each element resolves to. Normalize * an Atom local name only when that URI is Atom's, so `` cannot be * mistaken for an Atom entry merely because it has the same local name. */ function normalizeAtomNamespaces(value: unknown, parentLocalName?: string, inXhtmlContent = false): unknown { type SetResult = (result: unknown) => void; const work: Array<() => void> = []; let result: unknown; const visit = (node: unknown, parentName: string | undefined, inXhtml: boolean, setResult: SetResult): void => { if (Array.isArray(node)) { const normalized: unknown[] = new Array(node.length); setResult(normalized); for (let index = node.length - 1; index >= 0; index--) { const childIndex = index; work.push(() => visit(node[childIndex], parentName, inXhtml, (child) => (normalized[childIndex] = child))); } return; } if (!node || typeof node !== 'object') { setResult(node); return; } const normalized: Record = {}; // Finalize only after all child work. This preserves xml2js's string shape // for text-only elements without using publisher-controlled recursion. work.push(() => { setResult(Object.keys(normalized).length === 1 && typeof normalized._ === 'string' ? normalized._ : normalized); }); const entries = Object.entries(node as Record); for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) { const entry = entries[entryIndex]; if (!entry) continue; const [key, child] = entry; if (key === '$ns' || key === 'xmlns' || key.startsWith('xmlns:')) continue; if (isXmlAttribute(child)) { work.push(() => { // Atom attributes drive parsing. XHTML attributes are metadata, not // visible text, so discard them before flattening the XHTML subtree. if (!inXhtml) normalized[key] = child.value; }); continue; } // xml2js groups repeated same-spelling elements into one array even when // individual elements redeclare the default namespace. Inspect each one. const children = Array.isArray(child) ? child : [child]; for (let childIndex = children.length - 1; childIndex >= 0; childIndex--) { const childValue = children[childIndex]; const namespace = elementNamespace(childValue); const localName = typeof namespace?.local === 'string' ? namespace.local : undefined; const isAtomElement = namespace?.uri === ATOM_NAMESPACE && localName !== undefined && ATOM_LOCAL_NAMES.has(localName); const isXhtmlContentDiv = namespace?.uri === XHTML_NAMESPACE && localName === 'div' && parentName === 'content'; const isForeignAtomName = !inXhtml && localName !== undefined && ATOM_LOCAL_NAMES.has(localName) && !isAtomElement && !isXhtmlContentDiv; if (isForeignAtomName) continue; const normalizedKey = isAtomElement || isXhtmlContentDiv ? localName : key; work.push(() => visit(childValue, localName, inXhtml || isXhtmlContentDiv, (normalizedChild) => { if (normalized[normalizedKey] === undefined) { normalized[normalizedKey] = normalizedChild; } else { const existing = normalized[normalizedKey]; const values = Array.isArray(existing) ? existing : [existing]; if (Array.isArray(normalizedChild)) values.push(...normalizedChild); else values.push(normalizedChild); normalized[normalizedKey] = values; } }), ); } } }; visit(value, parentLocalName, inXhtmlContent, (normalized) => (result = normalized)); while (work.length > 0) { work.pop()?.(); } return result; } function atomBaseUrl(xmlBase: unknown, inheritedBase?: string): string | undefined { return typeof xmlBase === 'string' ? resolveHttpLink(xmlBase, inheritedBase) : inheritedBase; } /** * Resolve an Atom link value (string, object, or array of either) to a URL. * Prefers rel="alternate" / relless links, falling back to the first link's href. * Shared by entry-level and feed-level link extraction. */ function resolveAtomLink(link: unknown, baseUrl?: string, canonicalize = true): string | undefined { if (!link) return undefined; const links = Array.isArray(link) ? link : [link]; const resolve = (candidate: unknown): string | undefined => { if (typeof candidate === 'string') return resolveHttpLink(candidate, baseUrl, canonicalize); if (!candidate || typeof candidate !== 'object') return undefined; const atomLink = candidate as Record; return typeof atomLink.href === 'string' ? resolveHttpLink(atomLink.href, atomBaseUrl(atomLink['xml:base'], baseUrl), canonicalize) : undefined; }; // Prefer alternate link for (const l of links) { if (typeof l === 'string') { const resolved = resolve(l); if (resolved) return resolved; continue; } if (l && typeof l === 'object') { const rel = (l as Record).rel; if (rel === 'alternate' || !rel) { const resolved = resolve(l); if (resolved) return resolved; } } } // Fall back to the first usable link, rather than returning a malformed or // unsupported first href when a later link is valid. return links.map(resolve).find((resolved): resolved is string => !!resolved); } /** * Extract link from Atom entry */ function extractAtomLink(entry: AtomEntry, baseUrl?: string): string | undefined { return resolveAtomLink(entry.link, baseUrl); } function firstRawAtomLink(link: unknown): string | undefined { const links = Array.isArray(link) ? link : [link]; for (const candidate of links) { if (typeof candidate === 'string') return candidate; if (candidate && typeof candidate === 'object') { const href = (candidate as Record).href; if (typeof href === 'string') return href; } } return undefined; } /** * Iteratively collect text from an xml2js node (string, {_: text, ...children}, arrays, * or nested element objects). Used to flatten XHTML trees without publisher-controlled recursion. */ function collectNodeText(node: unknown, maxLength = MAX_CONTENT_SIZE): string { const chunks: string[] = []; const work: unknown[] = [node]; let remaining = maxLength; while (work.length > 0 && remaining > 0) { const current = work.pop(); if (typeof current === 'string') { const chunk = truncateUtf16(current, remaining) ?? ''; chunks.push(chunk); remaining -= chunk.length; // A truncated string marks the exact prefix boundary; do not append text // from a later sibling merely because avoiding a split surrogate left one unit. if (chunk.length < current.length) break; continue; } if (Array.isArray(current)) { for (let index = current.length - 1; index >= 0; index--) work.push(current[index]); continue; } if (current && typeof current === 'object') { const entries = Object.entries(current as Record); for (let index = entries.length - 1; index >= 0; index--) { const entry = entries[index]; if (!entry) continue; const [key, value] = entry; if (key === 'xmlns' || key.startsWith('xmlns:')) continue; if (typeof value === 'string' || (value && typeof value === 'object')) work.push(value); } } } return chunks.join(''); } /** * Extract content from an Atom node. * Handles: string, {_: text} (text/html), {div: ...} (type="xhtml"), {src: ...} (out-of-line). */ function extractAtomContent(content: AtomEntry['content']): string | undefined { if (content === undefined) return undefined; if (typeof content === 'string') return content; // Atom content carrying src is out-of-line. The URI is metadata identifying // an external representation, not reader-visible body text; leaving content // absent allows the normal Article extraction path to fetch usable content. if (typeof content.src === 'string') return undefined; // type="xhtml" wraps the body in a
; xml2js yields { div: string | { _: ..., ...children } } if ('div' in content && content.div !== undefined) { if (typeof content.div === 'string') return content.div; const text = collectNodeText(content.div); if (text.length > 0) return text; } if (typeof content._ === 'string') return content._; return undefined; } /** * Parse Atom feed */ function parseAtomFeed(xmlObj: Record, options: ParseFeedOptions): ParsedFeed { let atom: z.infer; try { atom = AtomFeedSchema.parse(xmlObj.feed || xmlObj); } catch (error) { if (!(error instanceof z.ZodError)) throw error; options.diagnostics?.({ kind: 'feed-validation-error', feedType: 'atom', feedUrl: options.feedUrl, issueCount: error.issues.length, }); // Lenient parse: keep entries that validate; only escalate if zero survive. const rawFeed = (xmlObj.feed || xmlObj) as Record; if (!rawFeed || typeof rawFeed !== 'object') { throw new Error(formatZodError(error)); } const validEntries = lenientValidateArray(rawFeed.entry, AtomEntrySchema); if (validEntries.length === 0) { throw new Error(formatZodError(error)); } atom = { ...rawFeed, title: extractText(rawFeed.title) || 'Unknown', entry: validEntries, } as z.infer; } const parsedArticles: ParsedArticle[] = []; const feedBaseUrl = atomBaseUrl(atom['xml:base'], options.feedUrl); // Process entries const entries: AtomEntry[] = atom.entry ? (Array.isArray(atom.entry) ? atom.entry : [atom.entry]) : []; for (const entry of entries) { const entryBaseUrl = atomBaseUrl(entry['xml:base'], feedBaseUrl); const article: ParsedArticle = boundArticleText({ guid: extractText(entry.id), title: extractText(entry.title), link: extractAtomLink(entry, entryBaseUrl), description: extractText(entry.summary) || (Array.isArray(entry.summary) ? extractText(entry.summary[0]) : undefined), content: firstRenderableContent(extractAtomContent(entry.content)), author: extractAtomAuthor(entry), categories: extractAtomCategories(entry), publishedAt: parseDate(entry.published || entry.updated), }); if (!article.link) { emitInvalidArticleLink(options, 'atom', article, firstRawAtomLink(entry.link), Object.keys(entry), entryBaseUrl); continue; } parsedArticles.push(article); } throwWhenSourceArticlesHaveNoUsableLinks(entries.length, parsedArticles, 'atom'); // Extract feed link (same alternate-preferred + first-link fallback as entries) const feedLink = resolveAtomLink(atom.link, feedBaseUrl, false); return boundFeedText({ title: extractText(atom.title), link: feedLink, description: extractText(atom.subtitle) || undefined, language: atom.language || atom['xml:lang'], articles: parsedArticles, feedType: 'atom', }); } /** * Detect feed type from XML */ function detectFeedType(xmlObj: Record): 'rss' | 'atom' | null { if (xmlObj.rss || xmlObj.channel) return 'rss'; return null; } /** * Scan markup before object construction so a small-but-pathological document * cannot amplify into an unbounded xml2js tree. This is deliberately a * complexity scan rather than a second XML validator; xml2js remains the * authority for tag matching and well-formedness. */ function enforceFeedXmlComplexity(xml: string): void { let cursor = 0; let depth = 0; let nodes = 0; const skipDelimited = (start: number, delimiter: string): number => { const end = xml.indexOf(delimiter, start); return end === -1 ? xml.length : end + delimiter.length; }; const findMarkupEnd = (start: number, trackDoctypeSubset = false): number => { let quote: '"' | "'" | undefined; let subsetDepth = 0; for (let index = start; index < xml.length; index++) { const char = xml[index]; if (quote) { if (char === quote) quote = undefined; continue; } if (char === '"' || char === "'") { quote = char; } else if (trackDoctypeSubset && char === '[') { subsetDepth++; } else if (trackDoctypeSubset && char === ']') { subsetDepth = Math.max(0, subsetDepth - 1); } else if (char === '>' && subsetDepth === 0) { return index; } } return xml.length; }; while (cursor < xml.length) { const open = xml.indexOf('<', cursor); if (open === -1) break; if (xml.startsWith(''); continue; } if (xml.startsWith(''); continue; } if (xml.startsWith(''); continue; } if (xml.startsWith(' MAX_FEED_XML_NODES) { throw new Error(`Feed XML node count exceeds limit of ${MAX_FEED_XML_NODES}`); } let last = end - 1; while (last > open && /\s/.test(xml[last] ?? '')) last--; if (xml[last] !== '/') { depth++; if (depth > MAX_FEED_XML_DEPTH) { throw new Error(`Feed XML depth exceeds limit of ${MAX_FEED_XML_DEPTH}`); } } cursor = Math.min(xml.length, end + 1); } } /** * Parse XML string to object. * XXE safety: xml2js 0.6.x does not process external entities or DTDs by default, * so no additional XXE mitigation options are needed. */ export async function parseXml(xml: string): Promise> { return new Promise((resolve, reject) => { parseString( xml, { explicitArray: false, // Keep false; multiple siblings are handled by Array.isArray checks mergeAttrs: true, // Merge attributes into element objects (e.g., link.href instead of link.$.href) normalizeTags: false, // normalize:true collapses whitespace runs inside text/CDATA, destroying
/
        // formatting in content:encoded. Leave it off so code blocks survive.
        trim: true,
        xmlns: false, // Do not parse xmlns attributes
      },
      (err, result) => {
        if (err) reject(err);
        else resolve(result as Record);
      },
    );
  });
}

/** Parse XML with per-element namespace information for the Atom-only path. */
async function parseXmlWithNamespaces(xml: string): Promise> {
  return new Promise((resolve, reject) => {
    parseString(
      xml,
      {
        explicitArray: false,
        mergeAttrs: true,
        normalizeTags: false,
        trim: true,
        xmlns: true,
      },
      (err, result) => {
        if (err) reject(err);
        else resolve(result as Record);
      },
    );
  });
}

function atomRoot(xmlObj: Record): unknown {
  const roots = Object.values(xmlObj);
  if (roots.length !== 1) return undefined;

  const root = roots[0];
  const namespace = elementNamespace(root);
  return namespace?.uri === ATOM_NAMESPACE && namespace.local === 'feed' ? root : undefined;
}

/**
 * Parse feed from XML string
 */
export async function parseFeedFromXml(xml: string, options: ParseFeedOptions = {}): Promise {
  try {
    const xmlBytes = Buffer.byteLength(xml, 'utf8');
    if (xmlBytes > MAX_RESPONSE_SIZE) {
      throw new Error(`Feed XML too large: ${xmlBytes} bytes (max: ${MAX_RESPONSE_SIZE})`);
    }
    const validation = validateFeedStructure(xml);
    if (!validation.valid) {
      throw new Error(validation.error ?? 'Feed validation failed');
    }
    enforceFeedXmlComplexity(xml);
    const xmlObj = await parseXml(xml);
    const feedType = detectFeedType(xmlObj);

    if (feedType === 'rss') {
      const feed = parseRssFeed(xmlObj, options);
      return {
        ...feed,
        etag: options.etag,
        lastModified: options.lastModified,
      };
    }

    const namespaceAwareXml = await parseXmlWithNamespaces(xml);
    const root = atomRoot(namespaceAwareXml);
    if (!root) {
      throw new Error('Unable to identify feed type. Expected RSS or Atom format.');
    }

    const feed = parseAtomFeed({ feed: normalizeAtomNamespaces(root) }, options);

    return {
      ...feed,
      etag: options.etag,
      lastModified: options.lastModified,
    };
  } catch (error) {
    if (error instanceof FeedParseError) throw error;
    throw new FeedParseError(error instanceof Error ? error.message : String(error), error);
  }
}