Cloudflare worker to watch RSS feeds and post updates to Discord webhooks
at main 111 lines 3.4 kB view raw
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}