1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
|
// 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<NewsPayload> {
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<NewsPayload["items"]> => 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 };
}
|