examples / scheduled jobs

RSS digest

Collect new items from any RSS feed on a daily schedule.

cronlibsqlsecrets

Every morning a scheduled run fetches your FEED_URL, pulls the title and link out of each item, and stores anything it has not seen before in a linked libsql database. The link is the primary key, so re-running the job never adds a duplicate.

The schedule lives in the code (export const crons), and the run() export powers the Run button so you can pull the feed the moment you deploy. Open the service in a browser and the same handler renders the collected items as a digest page, telling a scheduled run apart from a page view by the X-Fb-Trigger header. Deploying creates the project, the digest database (linked as DIGEST_DB), and asks you for the feed to follow. Email delivery is coming soon.

code
main.ts
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("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;");
}

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.