// Shared RSS fetch used by both the "news" (curated) and "feeds" (personal) // source modules — same fetch/parse/sort/slice over a list of feed URLs. import Parser from "rss-parser"; import type { NewsConfig, NewsPayload } from "@/lib/schemas/sources/news"; const parser = new Parser(); function hostname(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ""); } catch { return url; } } export async function fetchRss( config: NewsConfig, signal?: AbortSignal, ): Promise { const results = await Promise.allSettled( config.feeds.map(async (url) => { // Fetch ourselves so the AbortSignal/timeout is honored, then parse text. const res = await fetch(url, { signal, headers: { "User-Agent": "PersonalDashboard/0.1" }, }); if (!res.ok) throw new Error(`${hostname(url)} ${res.status}`); const xml = await res.text(); const feed = await parser.parseString(xml); const source = feed.title || hostname(url); return (feed.items ?? []).map((it) => ({ title: (it.title ?? "Untitled").trim(), link: it.link ?? "", source, isoDate: it.isoDate ?? null, })); }), ); const items = results .filter((r): r is PromiseFulfilledResult => r.status === "fulfilled") .flatMap((r) => r.value) .filter((it) => it.link) .sort((a, b) => { const ta = a.isoDate ? Date.parse(a.isoDate) : 0; const tb = b.isoDate ? Date.parse(b.isoDate) : 0; return tb - ta; }) .slice(0, config.limit); if (items.length === 0) { const firstErr = results.find((r) => r.status === "rejected") as | PromiseRejectedResult | undefined; if (firstErr) throw new Error(String(firstErr.reason?.message ?? firstErr.reason)); } return { items }; }