用 Deno 写一个定时抓取脚本:权限、cron 与部署

要点

做一个每天定时跑的抓取脚本:抓 Hacker News 公开 API 的热门条目、按关键词过滤、落到本地 SQLite、把当日命中推到 webhook 群机器人。规模刻意压在”单机单文件”量级——定时任务这个约束会让每个设计决策都变重:权限要收口到无人值守也不出事、请求失败要自己重试、重复执行不能产生脏数据、部署要么长驻要么托管。开山篇(Deno 2 入门)讲过 Deno 适合”本来不值得配一整套 Node 工具链的东西”,本篇就是那个场景的完整落地;站内此前讨论过热搜抓取与聚合方案的选型,本文把同类需求压到 Deno 单机脚本上。版本基线:Deno 2.5.x(2025-09-10 发布的 LTS 线)。

目标与验收

指标 目标 怎么测
权限最小集 只开 ./data 读写、两个固定域名、一个环境变量 代码里故意 Deno.env.get(“PATH”),应抛 NotCapable
抓取韧性 单请求失败按 1s/2s/4s 退避重试三次 把域名改错跑一遍,看日志重试节奏
幂等落库 同一天重复跑不产生重复行、不重复推送 连跑两遍:第二遍全部 changes=0,webhook 无消息
无人值守 无终端环境不弹权限提示、缺权限快速失败 crontab 或 CI 里跑,确认报错进日志而非挂起
定时准确 周期触发误差分钟级 crontab 执行时间戳对照日志

性能不在验收清单里:30 次小请求加一次落库,任何一台能跑 Deno 的机器都绰绰有余,压测没有意义。HN API 的限流策略以官方说明为准,本文只取前 30 条避免打满配额。

整体架构与存储选型

crontab / Deno.cron 触发
      ▼
pull.ts:fetch(超时+退避)→ 关键词过滤 → node:sqlite 落库(UNIQUE 幂等)→ 命中才推 webhook
      ▼
./data/news.db(唯一状态)+ ./data/pull.log(唯一观测)

落库选型对比如下,结论是 node:sqlite:

方案 优点 缺点与适用
JSONL 追加文件 零依赖,人肉可读 没有唯一约束,幂等要自己写;只适合追加不查的流水
node:sqlite(本文) 2.2 起内置,同步 API,UNIQUE 约束天然做幂等 并发写弱;单机定时任务的首选
Deno.KV 本地/Deploy 同一套 API,键值语义干净 截至 2.5 仍实验(–unstable-kv),缺 SQL 约束;等它稳定再迁
远程 PostgreSQL 并发与查询强 为一个脚本引入一台数据库,运维成本倒挂

克制建议:单机脚本不要一上来就配数据库服务,UNIQUE 约束能解决的问题不值得为此加一台 PG;等出现”多机都要写”的真实需求再迁,SQLite 文件本身也好搬。

难点一:权限收口——无人值守的第一道闸

定时任务没有人在终端前,权限全靠配置表达。2.5 的 permission sets 正好把”给谁开什么”写成代码:

{
  "permissions": {
    "pull": {
      "read": ["./data"],
      "write": ["./data"],
      "net": ["hacker-news.firebaseio.com:443", "open.feishu.cn:443"],
      "env": ["WEBHOOK_URL"]
    }
  },
  "tasks": {
    "pull": "deno run --no-prompt -P=pull pull.ts"
  }
}

四个细节:net 限定到 host:port,webhook 域名按你实际用的服务替换;env 只放行一个变量名,密钥永远走环境不进代码;–no-prompt 保证无终端环境下缺权限直接报错而不是挂住;-P=pull 引用命名集合,改权限只动配置一处。权限标志的完整语义见权限模型详解。

反模式一:用 -A 跑无人值守任务。-A 加上”定时、没人看、读环境变量”,等于给每次执行发一张全权通行证——webhook 地址这类密钥一旦泄漏进依赖,攻击者拿到的不是一次机会而是每天定时触发的能力。这正是权限模型存在的原因,验收表第一行就是冲它去的。

难点二:抓取——超时与退避是自己的责任

// pull.ts —— 抓取、过滤、落库、通知;import.meta.main 保证既能直接跑也能被导入
import { DatabaseSync } from "node:sqlite";

const TOP = "https://hacker-news.firebaseio.com/v0";
const KEYWORDS = ["deno", "node", "typescript"];
const webhook = Deno.env.get("WEBHOOK_URL");   // 权限只放行了这一个变量名

export function filterByKeywords(titles: string[], words: string[]) {
  return titles.filter((t) => words.some((w) => t.toLowerCase().includes(w)));
}

async function getJson(url: string, tries = 3): Promise
<unknown> {
  for (let i = 0; i < tries; i++) {
    try {
      // 超时 10 秒:fetch 本身没有超时语义,不配 AbortSignal 挂死的是整个 cron 周期
      const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      return await res.json();
    } catch (err) {
      if (i === tries - 1) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 1000));   // 指数退避:1s/2s/4s
    }
  }
}

export async function pull() {
  const ids = (await getJson(`${TOP}/topstories.json`)) as number[];
  const db = new DatabaseSync("./data/news.db");
  db.exec(`CREATE TABLE IF NOT EXISTS stories (
    id INTEGER NOT NULL, title TEXT NOT NULL, url TEXT, day TEXT NOT NULL,
    UNIQUE(id, day))`);
  const day = new Date().toISOString().slice(0, 10);
  const insert = db.prepare(
    "INSERT OR IGNORE INTO stories (id, title, url, day) VALUES (?, ?, ?, ?)");

  const hits: string[] = [];
  for (const id of ids.slice(0, 30)) {   // 只取前 30 条,别把配额打满
    const item = (await getJson(`${TOP}/item/${id}.json`)) as {
      title?: string; url?: string;
    };
    const title = item.title ?? "";
    if (!filterByKeywords([title], KEYWORDS).length) continue;
    // INSERT OR IGNORE:同一天同一条故事再插是 changes=0,幂等由数据库约束保证
    const { changes } = insert.run(id, title, item.url ?? "", day);
    if (changes > 0) hits.push(`- ${title} ${item.url ?? ""}`);
  }

  if (webhook && hits.length) {   // 只在真有新命中时打扰群聊
    await fetch(webhook, {
      method: "POST",
      headers: { "content-type": "application/json" },
      signal: AbortSignal.timeout(10_000),
      body: JSON.stringify({ msg_type: "text", content: { text: hits.join("\n") } }),
    });
  }
}

if (import.meta.main) await pull();</unknown>

反模式二:裸 fetch 不配超时。默认 fetch 会一直等——上游半死不活时,你的 10 秒任务变成 10 分钟挂死,crontab 还会准时再叠一个进程上去。所有外部请求都带 AbortSignal.timeout,webhook 也不例外。

难点三:幂等——”先查再插”必错

反模式三:SELECT 判断存在再 INSERT。看起来天衣无缝,但它假设同一时刻只有自己:crontab 叠跑、手动补跑与定时撞车时,两个进程同时查到”不存在”,然后都插入,重复行就此产生。正确做法是把唯一性交给数据库约束——UNIQUE(id, day) 加 INSERT OR IGNORE,冲突时 changes 返回 0,用返回值区分”新命中”与”旧闻”,推送去重顺带解决。

这个模式还有个隐藏收益:任务可以随时重跑。修了 bug、改了关键词,直接手动再执行一遍 deno task pull,已入库的行自动跳过,不需要任何”清库重来”的操作。

定时:四种方案一张表

方案 形态 优点 坑
系统 crontab + deno task(本文选它) 每次起新进程,跑完即退 零常驻、零新依赖,失败影响面只有一次 cron 环境 PATH 贫瘠,deno 要写绝对路径
Deno.cron 长驻进程 进程内调度 代码即配置;Deno Deploy 上零配置且不重叠 2.5 仍是实验 API(–unstable-cron);进程死了任务就停
Deno Deploy 托管 cron 平台调度 不占自己机器,识别顶层 Deno.cron 定义 部署形态与配额以官方文档为准
GitHub Actions schedule 托管容器 免机器,日志与重跑现成 触发有分钟级延迟,非精确周期

长驻形态只需把 pull 挂进 cron:

// cron.ts —— 长驻形态:deno run --unstable-cron -P=pull cron.ts
import { pull } from "./pull.ts";
Deno.cron("每 30 分钟抓一次", "*/30 * * * *", pull);   // 表达式是 5 字段 UTC

自托管就选 crontab——脚本跑完即退,进程不存在自然没有泄漏,语义也最直白:

crontab -e   # 加一行:工作目录给足,deno 用绝对路径(cron 的 PATH 里通常没有它)
*/30 * * * * cd /srv/newsbot && /usr/local/bin/deno task pull >> ./data/pull.log 2>&1

验证

纯逻辑与 IO 分层之后,核心过滤逻辑的测试不需要任何权限;涉及落库的用例用 2.5 新增的生命周期钩子搭夹具:

// pull_test.ts
import { assertEquals } from "@std/assert";
import { filterByKeywords } from "./pull.ts";

Deno.test("关键词命中且大小写不敏感", () => {
  assertEquals(
    filterByKeywords(["Deno 2.5 发布", " unrelated "], ["deno"]),
    ["Deno 2.5 发布"],
  );
});

Deno.test("过滤空标题与全不命中", () => {
  assertEquals(filterByKeywords(["", "天气"], ["deno"]), []);
});

// 幂等测试:beforeEach 重建临时库,afterEach 清理——2.5 起 hooks 与 -P 权限集配合使用
Deno.test.beforeEach(async () => {
  await Deno.mkdir("./tmp-fixtures", { recursive: true });
});
Deno.test.afterEach(async () => {
  await Deno.remove("./tmp-fixtures", { recursive: true });
});
"tasks": { "test": "deno test -P --allow-write=./tmp-fixtures" }

验收按阶段走,现象与处理对照:

阶段 现象 根因在哪 怎么改
启动 NotCapable: env WEBHOOK_URL 权限集合没放行该变量 补进 permissions,别在命令行临时加
抓取 重试三次后 RetryError 域名拼写或出网受限 核对 net 作用域与防火墙出网规则
落库 第二遍仍有 changes>0 UNIQUE 约束没建或 day 粒度不一致 检查建表语句与 day 生成口径
crontab 日志空、任务没跑 deno 不在 cron 的 PATH which deno 换绝对路径,先手动跑通 task
webhook 403/429 密钥错或触发限流 以服务方文档为准,退避逻辑已内置

发布可选 deno compile:deno compile –no-prompt -P 参数编进产物,目标机不装 Deno 也能跑;但权限随产物固化,编译前按同样配置完整验证一遍。

总结

这个项目教会四件事:无人值守的权限收口写在 deno.json 里而不是命令行里;fetch 的超时与退避是调用方的责任;幂等靠数据库约束不靠”先查再插”;自托管定时交给 crontab,长驻与托管形态留给 Deno.cron 和 Deploy。不适合的场景同样明确:需要秒级调度、复杂重试拓扑或跨机协调的,这是工作流引擎与消息队列的地盘;Webhook 重试语义也只是”尽力而为”,要确认送达得自己落一张发送表。HN API 的字段与限流以官方文档为准,webhook 报文格式按所用服务替换。

延伸阅读

权限标志的完整语义见Deno 权限模型详解,本篇用到的 CLI 与 API 逐条速查见Deno 开发参考字典,从 Node 过来的系统学习路径见Deno 学习路线;运行时定位与选型见开山篇Deno 2 入门。

版本核实(以官方文档为准):基线 Deno 2.5.x(2025-09-10,LTS);permission sets(-P、命名集合)与 Deno.test 生命周期钩子为 2.5 新增;Deno.cron 截至 2.5 为实验 API(–unstable-cron 或 deno.json unstable 数组),表达式为 5 字段 UTC,Deno Deploy 支持识别 Deno.cron 定义托管调度;node:sqlite 兼容自 2.2;fetch 无默认超时、AbortSignal.timeout 为 Web 标准;HN API(Firebase 实时端点)字段以官方 API 文档为准。文中数据流程与故障表为本站实践总结,未涉及压测数字。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇