příklady / plánované úlohy

RSS přehled

Sbírá nové položky z libovolného RSS kanálu podle denního plánu.

cronlibsqlsecrets

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.

kód
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.