import { XMLParser } from "fast-xml-parser"; import { sha256 } from "./fingerprint.js"; const xmlParser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", textNodeName: "#text", parseTagValue: false, trimValues: true, cdataPropName: "#cdata", removeNSPrefix: false, }); export function parseXml>(text: string): T { return xmlParser.parse(text) as T; } export interface FeedItem { id: string | null; title: string; link: string | null; published: string | null; updated: string | null; summary: string | null; categories: Array<{ term: string; label?: string; scheme?: string }>; /** Any namespaced or custom element (e.g. ndaq:HaltDate) kept verbatim. */ extra: Record; } const asArray = (v: T | T[] | undefined | null): T[] => (v == null ? [] : Array.isArray(v) ? v : [v]); const text = (v: unknown): string | null => { if (v == null) return null; if (typeof v === "string") return v; if (typeof v === "number") return String(v); if (typeof v === "object") { const o = v as Record; if (typeof o["#cdata"] === "string") return o["#cdata"] as string; if (typeof o["#text"] === "string") return o["#text"] as string; if (typeof o["@_href"] === "string") return o["@_href"] as string; } return null; }; /** Parse RSS 2.0 or Atom into a common item list. */ export function parseFeed(xml: string): { title: string | null; items: FeedItem[] } { const doc = parseXml>(xml); if (doc.feed) { const feed = doc.feed; const items = asArray(feed.entry).map((e: Record) => { const links = asArray(e.link as any); const alt = links.find((l: any) => l?.["@_rel"] === "alternate") ?? links[0]; return { id: text(e.id), title: text(e.title) ?? "", link: alt ? text(alt) : null, published: text(e.published), updated: text(e.updated), summary: text(e.summary) ?? text(e.content), categories: asArray(e.category as any).map((c: any) => ({ term: String(c?.["@_term"] ?? ""), label: c?.["@_label"], scheme: c?.["@_scheme"] })), extra: e as Record, } satisfies FeedItem; }); return { title: text(feed.title), items }; } const channel = doc.rss?.channel ?? doc.channel; if (channel) { const items = asArray(channel.item).map((e: Record) => ({ id: text(e.guid) ?? text(e.link), title: text(e.title) ?? "", link: text(e.link), published: text(e.pubDate), updated: null, summary: text(e.description), categories: asArray(e.category as any).map((c: any) => ({ term: text(c) ?? "" })), extra: e as Record, })); return { title: text(channel.title), items }; } return { title: null, items: [] }; } /** Delimited text (pipe/comma/tab) → array of records keyed by header. Handles simple quoted fields. */ export function parseDelimited(textInput: string, delimiter = ","): Record[] { const lines = textInput.replace(/\r\n?/g, "\n").split("\n").filter((l) => l.length > 0); if (!lines.length) return []; const split = (line: string): string[] => { const out: string[] = []; let cur = ""; let q = false; for (let i = 0; i < line.length; i++) { const ch = line[i]!; if (ch === '"') { if (q && line[i + 1] === '"') { cur += '"'; i++; } else q = !q; } else if (ch === delimiter && !q) { out.push(cur); cur = ""; } else cur += ch; } out.push(cur); return out; }; const header = split(lines[0]!).map((h) => h.trim()); const rows: Record[] = []; for (const line of lines.slice(1)) { const cells = split(line); if (cells.length < 2) continue; const rec: Record = {}; header.forEach((h, i) => (rec[h] = (cells[i] ?? "").trim())); rows.push(rec); } return rows; } /** Locale-tolerant number parser: "1,234.5", "1 234,5", "(12.3)", "12.3%", "N/A" → number|null. */ export function parseNumber(v: unknown): number | null { if (v == null) return null; if (typeof v === "number") return Number.isFinite(v) ? v : null; if (typeof v !== "string") return null; let s = v.trim(); if (!s || /^(n\/?a|null|undefined|-|—|–)$/i.test(s)) return null; let neg = false; if (/^\(.*\)$/.test(s)) { neg = true; s = s.slice(1, -1); } s = s.replace(/[%$€£¥\s]/g, "").replace(/[+]/g, ""); if (/^-?\d{1,3}(\.\d{3})+(,\d+)?$/.test(s)) s = s.replace(/\./g, "").replace(",", "."); else if (/^-?\d+,\d{1,2}$/.test(s) || /^-?\d+,\d{4,}$/.test(s)) s = s.replace(",", "."); // "12,5" decimal comma; "1,000" stays thousands else s = s.replace(/,/g, ""); const n = Number(s); if (!Number.isFinite(n)) return null; return neg ? -n : n; } export function stripHtml(html: string): string { return html .replace(//gi, " ") .replace(//gi, " ") .replace(/<[^>]+>/g, " ") .replace(/ /g, " ") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/'|'/g, "'") .replace(/"/g, '"') .replace(/\s+/g, " ") .trim(); } /** Extract simple HTML tables as arrays of cell texts (no nested tables). */ export function extractTables(html: string): string[][][] { const tables: string[][][] = []; const tableRe = //gi; for (const t of html.match(tableRe) ?? []) { const rows: string[][] = []; for (const tr of t.match(//gi) ?? []) { const cells = (tr.match(/]*>[\s\S]*?<\/t[hd]>/gi) ?? []).map((c) => stripHtml(c)); if (cells.length) rows.push(cells); } if (rows.length) tables.push(rows); } return tables; } /** * Change-detection fingerprints for HTML documents: whole normalized text + per-section hashes * (split on headings) so that a page can be compared section by section. */ export function htmlFingerprints(html: string): { document: string; sections: Record } { const normalized = stripHtml(html).toLowerCase(); const sections: Record = {}; const parts = html.split(/]*>/i); parts.forEach((p, i) => { const title = stripHtml(p.split(/<\/h[1-4]>/i)[0] ?? "").slice(0, 60) || `section-${i}`; sections[title] = sha256(stripHtml(p).toLowerCase()).slice(0, 16); }); return { document: sha256(normalized).slice(0, 24), sections }; }