src / extractors / rssExtractor.ts
src / extractors / rssExtractor.ts
import { Logger } from "../utils/Logger";
import { createTurndownService } from "./htmlExtractor";
export interface ExtractedRssContent {
title?: string;
author?: string;
publishedDate?: string;
markdown: string;
}
/**
* Detects RSS or Atom feed link advertised in the document <head>.
*/
export function detectRssFeedUrl(doc: Document, baseUrl: string): string | undefined {
const selectors = [
'link[rel="alternate"][type="application/rss+xml"]',
'link[rel="alternate"][type="application/atom+xml"]',
];
for (const selector of selectors) {
const link = doc.querySelector(selector);
if (link) {
const href = link.getAttribute("href");
if (href && href.trim()) {
try {
return new URL(href.trim(), baseUrl).href;
} catch {
// Ignore invalid URL resolution
}
}
}
}
return undefined;
}
/**
* Lightweight RSS/Atom XML string parser.
* Extracts matching item/entry based on targetUrl and converts its HTML content to Markdown.
*/
export function parseRssFeed(xmlText: string, targetUrl: string): ExtractedRssContent | null {
if (!xmlText || !xmlText.trim()) return null;
try {
// Helper regex extractors
const getTagContent = (xmlFragment: string, tagName: string): string | undefined => {
const escapedTag = tagName.replace(":", "\\:");
const regex = new RegExp(`<${escapedTag}(?:\\s+[^>]*)?>([\\s\\S]*?)</${escapedTag}>`, "i");
const match = xmlFragment.match(regex);
if (!match) return undefined;
let val = match[1].trim();
// Unwrap CDATA if present
const cdataMatch = val.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/i);
if (cdataMatch) {
val = cdataMatch[1].trim();
}
return val || undefined;
};
const getAtomLink = (entryFragment: string): string | undefined => {
const match = entryFragment.match(/<link(?:\s+[^>]*)?href=["']([^"']+)["']/i);
return match ? match[1].trim() : undefined;
};
// Extract item (RSS 2.0) or entry (Atom) blocks
const isAtom = xmlText.includes("<entry") || xmlText.includes("http://www.w3.org/2005/Atom");
const itemRegex = isAtom ? /<entry(?:\s+[^>]*)?>([\s\S]*?)<\/entry>/gi : /<item(?:\s+[^>]*)?>([\s\S]*?)<\/item>/gi;
const items: { fragment: string; link?: string }[] = [];
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xmlText)) !== null) {
const fragment = match[1];
const link = isAtom
? getAtomLink(fragment) || getTagContent(fragment, "id")
: getTagContent(fragment, "link") || getTagContent(fragment, "guid");
items.push({ fragment, link });
}
if (items.length === 0) return null;
// Target URL matching
let targetItem = items.find((item) => {
if (!item.link) return false;
try {
const itemUrl = new URL(item.link, targetUrl).href;
const targetObj = new URL(targetUrl).href;
return itemUrl === targetObj || item.link.includes(targetUrl) || targetUrl.includes(item.link);
} catch {
return item.link === targetUrl;
}
});
// Fallback to first item if no direct URL match
if (!targetItem) {
targetItem = items[0];
}
const frag = targetItem.fragment;
const title = getTagContent(frag, "title");
const author =
getTagContent(frag, "dc:creator") ||
getTagContent(frag, "author") ||
getTagContent(frag, "name");
const publishedDate =
getTagContent(frag, "pubDate") ||
getTagContent(frag, "published") ||
getTagContent(frag, "updated") ||
getTagContent(frag, "dc:date");
const rawContentHtml =
getTagContent(frag, "content:encoded") ||
getTagContent(frag, "content") ||
getTagContent(frag, "description") ||
getTagContent(frag, "summary");
if (!rawContentHtml) return null;
const turndownService = createTurndownService();
const markdown = turndownService.turndown(rawContentHtml).trim();
if (!markdown) return null;
return {
title,
author,
publishedDate,
markdown,
};
} catch (err) {
Logger.debug(
"[WebpageReader]",
`Failed to parse RSS XML feed: ${err instanceof Error ? err.message : String(err)}`
);
return null;
}
}
import { Logger } from "../utils/Logger";
import { createTurndownService } from "./htmlExtractor";
export interface ExtractedRssContent {
title?: string;
author?: string;
publishedDate?: string;
markdown: string;
}
/**
* Detects RSS or Atom feed link advertised in the document <head>.
*/
export function detectRssFeedUrl(doc: Document, baseUrl: string): string | undefined {
const selectors = [
'link[rel="alternate"][type="application/rss+xml"]',
'link[rel="alternate"][type="application/atom+xml"]',
];
for (const selector of selectors) {
const link = doc.querySelector(selector);
if (link) {
const href = link.getAttribute("href");
if (href && href.trim()) {
try {
return new URL(href.trim(), baseUrl).href;
} catch {
// Ignore invalid URL resolution
}
}
}
}
return undefined;
}
/**
* Lightweight RSS/Atom XML string parser.
* Extracts matching item/entry based on targetUrl and converts its HTML content to Markdown.
*/
export function parseRssFeed(xmlText: string, targetUrl: string): ExtractedRssContent | null {
if (!xmlText || !xmlText.trim()) return null;
try {
// Helper regex extractors
const getTagContent = (xmlFragment: string, tagName: string): string | undefined => {
const escapedTag = tagName.replace(":", "\\:");
const regex = new RegExp(`<${escapedTag}(?:\\s+[^>]*)?>([\\s\\S]*?)</${escapedTag}>`, "i");
const match = xmlFragment.match(regex);
if (!match) return undefined;
let val = match[1].trim();
// Unwrap CDATA if present
const cdataMatch = val.match(/^<!\[CDATA\[([\s\S]*?)\]\]>$/i);
if (cdataMatch) {
val = cdataMatch[1].trim();
}
return val || undefined;
};
const getAtomLink = (entryFragment: string): string | undefined => {
const match = entryFragment.match(/<link(?:\s+[^>]*)?href=["']([^"']+)["']/i);
return match ? match[1].trim() : undefined;
};
// Extract item (RSS 2.0) or entry (Atom) blocks
const isAtom = xmlText.includes("<entry") || xmlText.includes("http://www.w3.org/2005/Atom");
const itemRegex = isAtom ? /<entry(?:\s+[^>]*)?>([\s\S]*?)<\/entry>/gi : /<item(?:\s+[^>]*)?>([\s\S]*?)<\/item>/gi;
const items: { fragment: string; link?: string }[] = [];
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xmlText)) !== null) {
const fragment = match[1];
const link = isAtom
? getAtomLink(fragment) || getTagContent(fragment, "id")
: getTagContent(fragment, "link") || getTagContent(fragment, "guid");
items.push({ fragment, link });
}
if (items.length === 0) return null;
// Target URL matching
let targetItem = items.find((item) => {
if (!item.link) return false;
try {
const itemUrl = new URL(item.link, targetUrl).href;
const targetObj = new URL(targetUrl).href;
return itemUrl === targetObj || item.link.includes(targetUrl) || targetUrl.includes(item.link);
} catch {
return item.link === targetUrl;
}
});
// Fallback to first item if no direct URL match
if (!targetItem) {
targetItem = items[0];
}
const frag = targetItem.fragment;
const title = getTagContent(frag, "title");
const author =
getTagContent(frag, "dc:creator") ||
getTagContent(frag, "author") ||
getTagContent(frag, "name");
const publishedDate =
getTagContent(frag, "pubDate") ||
getTagContent(frag, "published") ||
getTagContent(frag, "updated") ||
getTagContent(frag, "dc:date");
const rawContentHtml =
getTagContent(frag, "content:encoded") ||
getTagContent(frag, "content") ||
getTagContent(frag, "description") ||
getTagContent(frag, "summary");
if (!rawContentHtml) return null;
const turndownService = createTurndownService();
const markdown = turndownService.turndown(rawContentHtml).trim();
if (!markdown) return null;
return {
title,
author,
publishedDate,
markdown,
};
} catch (err) {
Logger.debug(
"[WebpageReader]",
`Failed to parse RSS XML feed: ${err instanceof Error ? err.message : String(err)}`
);
return null;
}
}