要点
做一个每天定时跑的抓取脚本:抓 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 文档为准。文中数据流程与故障表为本站实践总结,未涉及压测数字。