Cloudflare worker to watch RSS feeds and post updates to Discord webhooks
1import { parseFeed } from 'feedsmith';
2import feeds from './feeds.json' with { type: "json" };
3
4export default {
5 async scheduled(event, env, ctx) {
6 ctx.waitUntil(checkAllFeeds(env));
7 },
8
9 async fetch(request, env) {
10 const path = new URL(request.url).pathname;
11 if (path === '/check') {
12 await checkAllFeeds(env);
13 return new Response('Checked all feeds');
14 }
15 return new Response('Multi-feed monitor. GET /check to trigger manually.');
16 }
17};
18
19async function checkAllFeeds(env) {
20 const webhooks = JSON.parse(env.WEBHOOKS);
21 for (const [feedUrl, channels] of Object.entries(feeds)) {
22 try {
23 await checkFeed(env, feedUrl, channels, webhooks);
24 } catch (e) {
25 console.error(`Error checking ${feedUrl}:`, e.message);
26 }
27 }
28}
29
30async function checkFeed(env, feedUrl, channels, webhooks) {
31 const res = await fetch(feedUrl);
32 if (!res.ok) return console.error(`Feed fetch failed: ${res.status}`);
33
34 const text = await res.text();
35 const entries = parseEntries(text);
36 if (!entries.length) return;
37
38 const kvKey = `last:${new URL(feedUrl).pathname}`;
39 const lastPosted = await env.KV.get(kvKey);
40 const lastDate = lastPosted ? new Date(lastPosted) : new Date(0);
41
42 const newEntries = entries
43 .filter(e => e.date && new Date(e.date) > lastDate)
44 .sort((a, b) => new Date(a.date) - new Date(b.date));
45
46 console.log(`Feed ${feedUrl}: ${entries.length} entries, ${newEntries.length} new (last: ${lastPosted || 'none'})`);
47 if (!newEntries.length) return;
48
49 for (const entry of newEntries) {
50 for (const channel of channels) {
51 const webhook = webhooks[channel];
52 if (webhook) await postToDiscord(webhook, entry, feedUrl);
53 }
54 }
55
56 const latestDate = newEntries[newEntries.length - 1].date;
57 await env.KV.put(kvKey, latestDate);
58}
59
60function parseEntries(text) {
61 const { format, feed } = parseFeed(text);
62
63 if (format === 'atom') {
64 return (feed.entries || []).map(e => ({
65 title: e.title,
66 link: e.links?.find(l => !l.rel || l.rel === 'alternate')?.href || e.links?.[0]?.href,
67 summary: e.summary || e.content,
68 date: e.updated || e.published,
69 author: e.authors?.[0]?.name,
70 }));
71 }
72
73 // RSS, RDF, JSON
74 return (feed.items || []).map(e => ({
75 title: e.title,
76 link: e.link,
77 summary: e.description,
78 date: e.pubDate,
79 author: e.authors?.[0],
80 }));
81}
82
83function stripHtml(html) {
84 return html
85 .replace(/<br\s*\/?>/gi, '\n')
86 .replace(/<\/li>/gi, '\n')
87 .replace(/<\/p>/gi, '\n')
88 .replace(/<[^>]+>/g, '')
89 .replace(/\n{3,}/g, '\n\n')
90 .trim();
91}
92
93async function postToDiscord(webhook, entry, feedUrl) {
94 const feedName = new URL(feedUrl).pathname.split('/')[1] || 'Feed';
95 const res = await fetch(webhook, {
96 method: 'POST',
97 headers: { 'Content-Type': 'application/json' },
98 body: JSON.stringify({
99 embeds: [{
100 title: entry.title || 'New activity',
101 url: entry.link || feedUrl,
102 description: entry.summary ? stripHtml(entry.summary).slice(0, 300) : '',
103 color: 0x5865F2,
104 timestamp: entry.date ? new Date(entry.date).toISOString() : new Date().toISOString(),
105 footer: { text: entry.author ? `${feedName} • ${entry.author}` : feedName }
106 }]
107 })
108 });
109 const body = await res.text();
110 console.log(`Discord ${res.status}: ${body}`);
111}