import { sha256, simhash, stripTrackingParams, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { NormalizeError, type WebSensorConnector } from "./types"; import { asArray, parseDate, parseXml, stripHtml, textOf } from "./xml"; export type FeedItem = { key: string; title: string; url: string; summary: string; publishedAt: string | null; updatedAt: string | null; author?: string; categories?: string[]; [k: string]: unknown; }; export interface ParsedFeed { kind: "rss" | "atom" | "rdf" | "jsonfeed"; title: string; link: string; items: FeedItem[]; } /** RSS 2.0 / Atom / RSS 1.0 (RDF) / JSON Feed parser tolerant to common malformations. */ export function parseFeed(text: string, baseUrl?: string): ParsedFeed { const trimmed = text.trimStart(); if (trimmed.startsWith("{")) return parseJsonFeed(trimmed); const doc = parseXml(text); if (doc.rss) { const ch = ((doc.rss as Record).channel ?? {}) as Record; const items = asArray(ch.item as unknown[]).map((raw) => rssItem(raw as Record, baseUrl)); return { kind: "rss", title: stripHtml(textOf(ch.title)), link: textOf(ch.link), items: dedupe(items) }; } if (doc.feed) { const f = doc.feed as Record; const items = asArray(f.entry as unknown[]).map((raw) => atomEntry(raw as Record, baseUrl)); const link = asArray(f.link as unknown[]) .map((l) => l as Record) .find((l) => !l["@_rel"] || l["@_rel"] === "alternate"); return { kind: "atom", title: stripHtml(textOf(f.title)), link: textOf(link?.["@_href"]), items: dedupe(items) }; } if (doc["rdf:RDF"]) { const r = doc["rdf:RDF"] as Record; const ch = (r.channel ?? {}) as Record; const items = asArray(r.item as unknown[]).map((raw) => rssItem(raw as Record, baseUrl)); return { kind: "rdf", title: stripHtml(textOf(ch.title)), link: textOf(ch.link), items: dedupe(items) }; } throw new NormalizeError("not_a_feed", "Document is neither RSS, Atom, RDF nor JSON Feed"); } function rssItem(raw: Record, baseUrl?: string): FeedItem { const guid = textOf(raw.guid); let link = textOf(raw.link); if (!link && typeof raw.link === "object") link = textOf((raw.link as Record)["@_href"]); if (!link && guid.startsWith("http")) link = guid; if (!link && raw["feedburner:origLink"]) link = textOf(raw["feedburner:origLink"]); const url = safeUrl(link, baseUrl); const title = stripHtml(textOf(raw.title)); const summary = stripHtml(textOf(raw.description) || textOf(raw["content:encoded"]) || textOf(raw.summary)).slice(0, 1200); const pub = parseDate(raw.pubDate) ?? parseDate(raw["dc:date"]) ?? parseDate(raw.published); const key = guid || url || sha256(title + summary).slice(0, 24); const categories = asArray(raw.category as unknown[]) .map((c) => stripHtml(textOf(c))) .filter(Boolean); return { key, title, url, summary, publishedAt: pub?.toISOString() ?? null, updatedAt: null, author: stripHtml(textOf(raw.author) || textOf(raw["dc:creator"])) || undefined, categories }; } function atomEntry(raw: Record, baseUrl?: string): FeedItem { const id = textOf(raw.id); const links = asArray(raw.link as unknown[]).map((l) => l as Record); const alt = links.find((l) => !l["@_rel"] || l["@_rel"] === "alternate") ?? links[0]; const link = textOf(alt?.["@_href"]) || (id.startsWith("http") ? id : ""); const url = safeUrl(link, baseUrl); const title = stripHtml(textOf(raw.title)); const summary = stripHtml(textOf(raw.summary) || textOf(raw.content)).slice(0, 1200); const pub = parseDate(raw.published) ?? parseDate(raw.issued); const upd = parseDate(raw.updated); const author = raw.author && typeof raw.author === "object" ? stripHtml(textOf((raw.author as Record).name)) : stripHtml(textOf(raw.author)); const categories = asArray(raw.category as unknown[]) .map((c) => textOf((c as Record)["@_term"]) || textOf((c as Record)["@_label"])) .filter(Boolean); return { key: id || url || sha256(title + summary).slice(0, 24), title, url, summary, publishedAt: (pub ?? upd)?.toISOString() ?? null, updatedAt: upd?.toISOString() ?? null, author: author || undefined, categories }; } function parseJsonFeed(text: string): ParsedFeed { let j: Record; try { j = JSON.parse(text) as Record; } catch { throw new NormalizeError("bad_json", "Invalid JSON Feed"); } const items = asArray(j.items as Record[]).map((it) => { const url = String(it.url ?? it.external_url ?? ""); const title = stripHtml(String(it.title ?? "")); const summary = stripHtml(String(it.summary ?? it.content_text ?? it.content_html ?? "")).slice(0, 1200); return { key: String(it.id ?? url), title, url, summary, publishedAt: it.date_published ? new Date(String(it.date_published)).toISOString() : null, updatedAt: it.date_modified ? new Date(String(it.date_modified)).toISOString() : null } satisfies FeedItem; }); return { kind: "jsonfeed", title: String(j.title ?? ""), link: String(j.home_page_url ?? ""), items: dedupe(items) }; } function safeUrl(link: string, baseUrl?: string): string { if (!link) return ""; try { return stripTrackingParams(new URL(link, baseUrl).toString()); } catch { return link; } } function dedupe(items: FeedItem[]): FeedItem[] { const seen = new Set(); const out: FeedItem[] = []; for (const it of items) { if (seen.has(it.key)) continue; seen.add(it.key); out.push(it); } return out; } /** * RSS/Atom connector. Sensor state keeps the set of seen item keys so items that scroll * out of the feed window are not reported as "removed". */ export class RssConnector implements WebSensorConnector { mode = "list" as const; metadata(): ConnectorMetadata { return { key: "rss", name: "RSS / Atom", sensorTypes: ["RSS", "ATOM"], description: "Feed parser with GUID deduplication and update detection", version: "1.0.0" }; } async fetch(endpoint: SensorEndpoint): Promise { return httpFetchWithRetry(endpoint.id, endpoint.url, { etag: endpoint.etag, lastModified: endpoint.lastModified, accept: "application/rss+xml, application/atom+xml, application/xml, text/xml, application/feed+json, application/json;q=0.8, */*;q=0.5" }); } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); const text = obs.body.toString("utf8"); if (/^\s* ({ ...it, key: it.key })); const newest = items.map((i) => i.publishedAt).filter((x): x is string => Boolean(x)).sort().at(-1); // Items comparison: key is the GUID; title/summary changes count as modifications. const canonical = items.map((i) => `${i.key}\t${i.title}\t${sha256(i.summary).slice(0, 12)}`).join("\n"); const seen = new Set(Array.isArray(endpoint.state?.seenKeys) ? (endpoint.state!.seenKeys as string[]) : []); for (const i of items) seen.add(i.key); const seenKeys = [...seen].slice(-2000); return { mode: "list", items, compareFields: ["title", "summary"], title: feed.title, rawHash: sha256(text), canonicalHash: sha256(canonical), semanticHash: simhash(items.map((i) => i.title).join("\n")), publishedAt: newest ? new Date(newest) : null, state: { seenKeys, feedKind: feed.kind }, extractionConfidence: 1, extra: { feedKind: feed.kind, itemCount: items.length }, }; } }