Sbírá nové položky z libovolného RSS kanálu podle denního plánu.
Každé ráno naplánovaný běh stáhne vaši FEED_URL, z každé položky vytáhne titulek a odkaz a uloží vše, co ještě neviděl, do připojené libsql databáze. Odkaz je primární klíč, takže opakované spuštění nikdy nepřidá duplicitu.
Plán je přímo v kódu (export const crons) a export run() napojuje tlačítko Run, takže kanál stáhnete hned po nasazení. Když službu otevřete v prohlížeči, stejný handler vykreslí nasbírané položky jako stránku s přehledem; naplánovaný běh pozná podle hlavičky X-Fb-Trigger. Nasazením vznikne projekt, databáze digest (připojená jako DIGEST_DB) a formulář se zeptá na kanál, který má sledovat. Doručování e-mailem už brzy.
import { createClient } from "npm:@libsql/client@0.14.0/web"; // A daily RSS digest in a single service. It does two jobs: // - on a schedule it fetches an RSS feed and stores any items it has not seen // before (deduplicated by link), // - on a web request it renders the collected items as a simple digest page. // // The schedule is defined in the code: the crons export registers the "refresh" // job, and each firing calls the default handler with "X-Fb-Trigger: cron", so we // branch on it. The run() export powers the Run button for an instant refresh. // Email delivery is coming soon; for now the digest lives on this page. export const crons = { "refresh": "0 7 * * *" }; // Read a secret that is only present in some setups. In a normal deploy the linked // database and the FEED_URL secret are always configured; this just keeps the service // from crashing if you copy it somewhere they are not set up yet. function optionalSecret(name: string): string | undefined { try { return Deno.env.get(name) || undefined; } catch { return undefined; // this service was not granted access to that variable } } // This service was deployed with a libsql database linked as DIGEST_DB. We read that; // if you copy the service without the link it falls back to its built-in database. const db = createClient({ url: optionalSecret("DIGEST_DB") ?? Deno.env.get("DATABASE_URL")!, }); // The feed to follow is provided as the FEED_URL secret when you deploy. const FEED_URL = optionalSecret("FEED_URL") ?? "https://hnrss.org/frontpage"; async function ensureSchema(): Promise<void> { // The link is the primary key, which is what lets us deduplicate items cheaply. await db.execute( `CREATE TABLE IF NOT EXISTS items ( link TEXT PRIMARY KEY, title TEXT NOT NULL, seen_at TEXT NOT NULL DEFAULT (datetime('now')) )`, ); } interface FeedItem { title: string; link: string; } // A deliberately tiny RSS reader: no XML dependency, just enough regex to pull the // <title> and <link> out of each <item>. This targets RSS 2.0 feeds (like the one // in the placeholder). A production app would use a real parser, but this keeps the // whole example to one file. function parseItems(xml: string): FeedItem[] { const items: FeedItem[] = []; for (const block of xml.match(/<item[\s>][\s\S]*?<\/item>/gi) ?? []) { const link = tagText(block, "link"); if (link) items.push({ title: tagText(block, "title") || "(untitled)", link }); } return items; } // Text of the first <tag>...</tag> in the block, unwrapping a CDATA section if present. function tagText(block: string, tag: string): string { const m = block.match(new RegExp(`<${tag}[^>]*>([\\s\\S]*?)</${tag}>`, "i")); if (!m) return ""; return m[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim(); } // Fetch the feed and store items we have not seen before. Returns how many were new. async function refresh(): Promise<number> { await ensureSchema(); const res = await fetch(FEED_URL, { headers: { accept: "application/rss+xml, application/xml, text/xml" }, }); const xml = await res.text(); let added = 0; for (const item of parseItems(xml)) { // INSERT OR IGNORE relies on the link primary key: re-running the cron only // ever inserts links we do not already have. const r = await db.execute({ sql: "INSERT OR IGNORE INTO items (link, title) VALUES (?, ?)", args: [item.link, item.title], }); added += r.rowsAffected; } return added; } function escapeHtml(s: string): string { return s .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } async function renderPage(): Promise<Response> { await ensureSchema(); const result = await db.execute( "SELECT title, link, seen_at FROM items ORDER BY seen_at DESC, rowid DESC LIMIT 50", ); const list = result.rows .map( (r) => ` <li> <a href="${escapeHtml(String(r.link))}">${escapeHtml(String(r.title))}</a> <span class="muted">${escapeHtml(String(r.seen_at))} UTC</span> </li>`, ) .join("\n"); const body = result.rows.length === 0 ? `<p class="muted">Nothing collected yet. The digest fills up on the next scheduled run.</p>` : `<ul>\n${list}\n </ul>`; const html = `<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>RSS digest</title> <style> body { font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; max-width: 720px; margin: 2.5rem auto; padding: 0 1rem; } h1 { font-size: 1.2rem; } ul { list-style: none; padding: 0; } li { padding: 8px 0; border-bottom: 1px solid #e2e2e2; } a { color: #1a4fd6; text-decoration: none; } a:hover { text-decoration: underline; } .muted { color: #888; font-size: 0.85em; display: block; margin-top: 2px; } </style> </head> <body> <h1>RSS digest</h1> <p class="muted">Latest items from <strong>${escapeHtml(FEED_URL)}</strong>. Refreshed daily. Email delivery coming soon.</p> ${body} </body> </html> `; return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" }, }); } // The Run button: pull the feed right now instead of waiting for the morning run. export async function run(): Promise<string> { await refresh(); return "feed refreshed"; } export default async (req: Request): Promise<Response> => { // The scheduled refresh and the digest page share this one handler. if (req.headers.get("x-fb-trigger") === "cron") { await refresh(); return new Response("ok\n"); // the scheduled run has no visible page } return renderPage(); };
Go beyond what seems possible.