artalk-cf —— 跑在 Cloudflare 上的 Artalk 评论后端 + RSS 订阅机器人
自研的 Artalk API v2 兼容服务端,以及合并进来的 RSS 订阅机器人(rss-robot), 一起跑在 Cloudflare Workers + D1 + KV 上,零服务器、零运维、免费额度内。
状态:已上线 →
https://api.200181.xyzD1artalk-cf(78140776-2bfd-439a-8b05-c3eebe5d5b47)| KVRSS_KV(d7f86a0fb43f4450a10b786fc2635128) 管理员账号admin(后台登录用邮箱+密码,见「部署」)两个后台都可用:
/admin/自研管理面板 |/sidebar/官方 Artalk 后台 评论前端一行不用改:继续用官方 Artalk 客户端(博客里打包的 2.8.7),换后端只改hugo.toml一行server。
目录
这个项目是什么
两件事,各对应一个原本独立的仓库,现在合并进同一个 Worker:
| 子项目 | 来源 | 职责 | 路径 |
|---|---|---|---|
| artalk-cf | 自研 | Artalk API v2 兼容评论后端(44 路径 / 54 操作已实现 46 个) | /api/v2/* |
| rss-robot | 迁移自 EdgeOne 的 api/*.js |
RSS 订阅抓取 + 友链/友圈 + 公众号内容 | /api/*(不含 /api/v2) |
两者共存于一个 Worker、一个 D1、一个 KV,互不冲突:
Artalk 客户端只打 /api/v2/*,RSS 模块只打 /api/*(非 v2),路径天然隔离。
技术选型
整体判断:为什么是 Cloudflare Workers + D1,而不是自建服务器
| 考量 | 结论 |
|---|---|
| 成本 | 免费版够用(Workers 10 万请求/天、D1 读 500 万行/天、存储 5GB),博客评论量级远用不满 |
| 运维 | 无服务器、无 SSH、无进程守护;wrangler deploy 一条命令发布 |
| 大陆可用性 | 见下方「已知取舍」——这是唯一的短板 |
逐项选型与理由
1. 运行环境:Cloudflare Workers(免费版)
- 全球边缘函数,
fetch入口 +scheduled定时入口 - 致命限制:没有 TCP socket → 无法直连 SMTP 发邮件、无法跑原生 bcrypt,这两点直接决定了后续选型(见下)
- 单请求 CPU 预算约 10ms(免费版),驱动了「读写分流 + 覆盖索引 + 计数缓存」一系列优化
2. 数据库:D1(Cloudflare 的 SQLite 边缘库)
- 兼容 SQLite 语法,
wrangler d1 execute直接跑 SQL,批量导入零成本 - 读写分离:D1 Sessions API + 只读副本(
first-primary写 /first-unconstrained读) - 免费额度:读 500 万行/天、写 10 万行/天。这是本项目最需要盯的资源(2026-10-03 曾被打爆过一次,见性能章节的「打爆复盘」)
3. KV(键值存储):RSS 订阅与验证码通行证
- RSS 的
feeds_config/ 抓取游标 / 失败退避、人机验证的通行证、管理员通知节流,全放 KV - 理由:这些是「低频读、高频写、无关系」的数据,放 D1 会白白烧行数额度
4. 语言:TypeScript + Wrangler
- 与 Workers 官方工具链天然契合;
tsc --noEmit做类型检查 - 不引入任何运行时依赖(
package.json里只有@cloudflare/workers-types、typescript、wrangler三个 devDependencies) - 需要原生缺失的东西就自己写:
MD5(Gravatar hash)、Markdown 子集解析、PBKDF2(用 WebCrypto)
5. 鉴权:无状态 HMAC-SHA256 token + PBKDF2 密码
- token 用
TOKEN_SECRET做 HMAC 签名、7 天过期,无状态不落库 - 密码 PBKDF2-SHA256 12 万轮(WebCrypto 实现);兼容读取
(md5)和明文(仅迁移期) - 不用 bcrypt 是因为 Workers 没有 Node 的 native 依赖、也不值得为一个算法引进包
6. 邮件:Resend 的 HTTP API(而非 SMTP)
- Workers 无 TCP,SMTP 连不上;Resend 免费 3000 封/月
- 全程
ctx.waitUntil()异步 + try/catch,发信失败绝不影响评论落库
7. 人机验证:自研「一键验证」(PoW + 行为信号),而非 Turnstile/reCAPTCHA
- 第三方脚本在大陆加载不稳定,失败会卡死评论提交;还会把访客指纹送给第三方
- 自研版:浏览器静默算 20~80ms 的 PoW(sha256 前缀 4 个 0),命中可疑信号才让访客「点一下」,再可疑才回退图形验证码
- 挑战无状态(HMAC 签名不写库),通行证放 KV 不占 D1 额度
8. 前端:零重写,直接复用官方产物
- 客户端 + 官方后台(
/sidebar/)是 Artalk v2.8.7 的预构建产物,内置在public/里 - 自研管理面板
/admin/用纯原生 JS + 零依赖(补齐官方后台缺的「待审视图」「关键词搜索」等)
已知取舍(有意为之)
| 项 | 取舍 | 影响 |
|---|---|---|
| 大陆 → CF 边缘 ~170ms 延迟 | CF 免费版在大陆无节点,流量被送到 LAX | 加 <link rel="preconnect"> 省掉 0.5s TLS 握手,立竿见影 |
| 页面标题自动抓取 | 不做(跨境请求收益低) | 标题改为发评论时从 page_title 落库 |
| 邮箱验证码登录 / 社交登录 | 不做 | 管理员走密码登录,不受影响 |
url_resolver |
必须保持 false | 开启会重写 page_key,现有 /20210911.html 全失配 |
模块分布
artalk-cf/
├── wrangler.toml ★ Worker 配置:D1/KV/静态资源/域名/cron/变量
├── schema.sql ★ 建表(10 张表 + 覆盖索引)
├── 部署清单.md ★ 照着敲的部署步骤(含域名那一坑)
├── .dev.vars.example 本地开发机密模板(复制成 .dev.vars)
│
├── src/ ===== 服务端(TypeScript)=====
│ ├── index.ts 入口:路由注册 + fetch + scheduled 定时任务
│ ├── router.ts 迷你路由器(路径参数匹配,挂 / 与 /api/v2)
│ ├── rss-api.ts RSS 模块入口(路由表 + cron 轮转抓取)
│ ├── types.ts Env / 实体 / 响应类型定义
│ ├── template.generated.ts 设置页 YAML 模板(由官方 conf 生成)
│ │
│ ├── lib/ ===== 通用库(无 I/O 依赖的纯逻辑)=====
│ │ ├── db.ts D1 查询封装 + 限流 + 验证码状态
│ │ ├── cook.ts 实体 → API 输出(对应官方 dao/cook.go)
│ │ ├── session.ts token 签发/校验 + 密码 + 管理员判定
│ │ ├── md5.ts MD5(Gravatar hash,Workers 没有)
│ │ ├── md.ts Markdown 子集 → HTML(先转义再解析,防 XSS)
│ │ ├── util.ts 时间 / CORS / 响应 / 校验工具
│ │ ├── errors.ts ★ 统一错误出口(技术错误翻译成人话 + 机器码)
│ │ ├── human.ts 人机验证:PoW 挑战 + KV 通行证
│ │ ├── mail.ts 邮件发送(Resend HTTP API)
│ │ ├── admin-notify.ts 管理员提醒邮件(带节流)
│ │ └── rss/ ===== RSS 订阅子系统 =====
│ │ ├── cron.ts 每小时轮转抓取(66 源 ÷ 22 = 3 小时一轮)
│ │ ├── fetch.ts 抓取(超时/重试/代理回退/熔断)
│ │ ├── discover.ts feed 地址自动发现
│ │ ├── parse.ts RSS/Atom 解析
│ │ ├── greetings.ts 问候语/每日一图
│ │ ├── notify.ts 更新推送(飞书 webhook 等)
│ │ ├── proxy-health.ts 国内代理(SCF)体检
│ │ └── util.ts RSS 工具
│ │
│ └── routes/ ===== 路由处理器(按业务域分组)=====
│ ├── public.ts conf / 验证码 / 投票 / pv / setup / healthz
│ ├── comments.ts 评论 CRUD + 列表与树(含人机验证门)
│ ├── user.ts 用户 / 登录 / 合并 / SSO
│ ├── admin.ts 设置 / 站点 / 页面 / 用户 / 消息 / 统计 / 导入导出 / 上传
│ ├── human.ts 人机验证端点(challenge / verify / status)
│ └── rss/ ===== RSS 路由 =====
│ ├── feeds.ts 订阅源管理
│ ├── links.ts 友链/友圈
│ ├── results.ts 抓取结果 / 文章
│ ├── media.ts 随机图 / 评论机器人
│ ├── greeting.ts 问候语
│ ├── misc.ts 鉴权 / 健康检查
│ ├── tools.ts favicon / 代理 / cron / 部署状态 / 邮件
│ └── wechat.ts 微信公众号内容
│
├── public/ 静态资源(优先级高于 Worker,改这里不用动 Worker 代码)
│ ├── index.html / 入口页(健康状态 + 两个后台入口)
│ ├── admin/ /admin/ 自研管理面板(原生 JS,6 个视图)
│ │ ├── admin.css 设计令牌 + 全部组件样式
│ │ ├── admin.js 仪表盘/评论/页面/用户/设置/状态
│ │ └── fonts/ Press Start 2P + JetBrains Mono
│ ├── sidebar/ /sidebar/ 官方 v2.8.7 管理后台(Vue 预构建)
│ ├── dist/ 官方 v2.8.7 客户端与插件
│ └── feed.html /feed/ RSS 订阅源管理页
│
└── tools/ 工具脚本(开发/部署/自检)
├── deploy.sh 一键部署
├── import_artrans.py .artrans → D1 SQL(批量迁移)
├── import_delta.py 增量导入
├── gen_template.py 从官方 conf YAML 生成设置模板常量
├── selftest.ts 纯逻辑自检(MD5/时间/Markdown/密码/token/YAML)
├── smoke.sh 冒烟测试
├── preview-mail.ts 邮件样张预览
├── mailtest.ts 发信测试
└── check-*.mjs / probe-*.mjs / shot-*.mjs / measure-*.mjs
无头浏览器(CDP)测量/回归脚本,调试评论前端用
数据流一览
浏览器(博客评论区 / 后台 iframe)
│ /api/v2/*(评论)/ /api/*(RSS)
▼
Worker fetch(src/index.ts)
├─ /api/* 且非 /api/v2/* → rss-api.ts(RSS 子系统)
└─ 其余 → router.ts 分发到 routes/*
│
├─ 读请求 → D1 只读副本(first-unconstrained)
├─ 写请求 → D1 主库(first-primary)
└─ 通行证 → KV(人机验证 / RSS 游标 / 通知节流)
│
scheduled(cron)
├─ 0 * * * * → RSS 轮转抓取(每小时 22 源)
└─ 17 3 * * * → 评论 GC(限流/验证码清理 + healthz 缓存刷新 + 代理体检)
数据模型
10 张表(schema.sql),时间统一存 unix 毫秒,输出时按 Asia/Shanghai 格式化:
| 表 | 用途 | 关键字段 |
|---|---|---|
sites |
站点 | name(唯一)、urls |
pages |
页面 | key(=page_key 如 /20210911.html)、site_name、pv、vote |
users |
用户 | name+email(唯一)、password(pbkdf2/md5/明文兼容)、徽章、头衔 |
comments |
评论 | rid(父节点)、root_id(顶层祖先)、is_pending/pinned/verified、软删除 deleted_at |
votes |
投票 | target_id + user_id + type 唯一 |
notifies |
通知 | user_id、comment_id、is_read/is_emailed |
settings |
键值配置 | key(主键)、value(frontend_conf / admin_users 等) |
rate_limits |
限流 | bucket(如 comment:1.2.3.4)、count、expires_at |
captcha_passes |
验证码通过状态 | ip(主键)、expires_at |
captcha_challenges |
验证码题目 | answer_hash(只存哈希) |
覆盖索引(性能关键,见下):
idx_comments_page_list— 列表ORDER BY is_pinned DESC, created_at DESC专用idx_comments_children— 子评论root_id IN (...)专用idx_comments_site_list— 站点级列表
root_id 语义与官方一致:顶层为 0,子孙为该顶层评论的 id。列表默认 nested 模式——取一页顶层,再捞回全部子孙交给前端组树。
部署
完整步骤看 部署清单.md(照着敲约 10 分钟)。快速版:
cd blog-admin && npm install
npx wrangler login
npx wrangler d1 create artalk-cf # 把返回的 database_id 填进 wrangler.toml
npx wrangler secret put TOKEN_SECRET
npx wrangler secret put ADMIN_PASSWORD
npx wrangler d1 execute artalk-cf --remote --file=./schema.sql
npx wrangler deploy
curl -X POST https://<worker>/api/v2/setup
★ 域名这一步有个坑,动手前先看
Cloudflare Worker 的自定义域名要求「域名的 NS 托管在 Cloudflare」。
而 usj.cc 的 NS 在 DNSPod(还挂着又拍云 + 腾讯云 EdgeOne 调度)——千万不要为了评论 API 把整站 NS 搬去 Cloudflare,会弄坏现有国内双 CDN 链路。
本项目用的是 api.200181.xyz(NS 本来就在 Cloudflare):
[[routes]]
pattern = "api.200181.xyz"
custom_domain = true
*.workers.dev 地址在大陆基本不通,只能自测。四种域名方案取舍见 部署清单.md 第 9 步。
机密清单(不写进仓库)
| Secret / 变量 | 用途 |
|---|---|
TOKEN_SECRET |
登录 token 签名 |
ADMIN_PASSWORD |
管理员密码 |
RESEND_API_KEY |
邮件(可选) |
MAIL_FROM / MAIL_ADMIN |
邮件发件人 / 收件人(可选) |
接到博客上
E:\GitHub\blog\hugo.toml:
[params.artalk]
server = "https://api.200181.xyz" # ← 只改这一行
site = "优世界" # 必须与 SITE_DEFAULT 一致
客户端调用 ${server}/api/v2/...,pageKey 用 .RelPermalink(/20210911.html),与 D1 的 page_key 完全对应,不需要改任何模板或前端代码。
评论数据迁移
方式一:批量 SQL(推荐)
Worker 逐条 INSERT 会产生几千次 D1 往返,免费版单请求 CPU 只有 10ms 容易超时,所以走 wrangler d1 execute --file:
python tools/import_artrans.py # 读 .artrans,只导「优世界」,生成 SQL
bash tools/import-out/run.sh --remote
脚本做了:按 site_name 过滤、按「昵称+邮箱」重建用户、保留原始 id 与 rid 关系、rid 回填与 root_id 计算分三趟做(与导入顺序无关)、最后自检打印各表条数。
方式二:后台界面导入
登录 /sidebar/ → 迁移页 → 上传 .artrans(小数据量可用)。
⚠️ 别踩的坑
url_resolver保持 false,否则 page_key 被重写,评论区变空- 导出里
created_at为 Go 零值(0001-01-01)的 6 条会被兜底成当前时间
性能与免费额度
免费额度够不够(3440 条评论 / 138 篇文章)
| 资源 | 免费额度 | 你的用量 | 结论 |
|---|---|---|---|
| Workers | 10 万请求/天 | 评论量级一天最多几千 | ✅ |
| D1 存储 | 5 GB | 评论约几 MB | ✅ |
| D1 读 | 500 万行/天 | 一次列表查询几十行 | ✅ |
| Workers CPU | 10 ms/请求 | 列表查询含 markdown 渲染,需观察 | ⚠️ |
分层耗时(中国大陆网络实测)
| 项 | 耗时 |
|---|---|
| 冷连接 TLS 握手 | ~0.51s |
| 连接复用后端到端 | ~190ms |
| 1 次 D1 查询 | 8~20ms |
真正的瓶颈是「大陆 → CF 边缘」这段(~170ms),不是数据库。 改善:博客里加 <link rel="preconnect" href="https://api.200181.xyz">,省掉 0.5s TLS 握手,成本最低。
打爆复盘(2026-10-03 D1 额度用尽)
D1 免费版读额度(500 万行/天,UTC 00:00 = 北京 08:00 重置)被一次打爆,实测 525 万行。定位与修复:
- 取证:CF GraphQL
d1QueriesAdaptiveGroups(维度名是query)按sum_rowsRead排序,锁定COUNT(*)+LEFT JOIN users是无索引全表扫 - 修复:
- 三个覆盖索引(
idx_comments_page_list/_children/_site_list) - 计数 KV 缓存(只在增删/审核时失效)
- 列表/计数不再无谓 JOIN users
- 两个 COUNT 合并成一条
COUNT(*) + SUM(CASE ...)
- 三个覆盖索引(
- 教训:改动前先本地预览 + 冒烟测试,别拿线上打爆的额度再跑测试
自查方法
打开 https://api.200181.xyz/api/v2/healthz 看三个字段:
colo—— 请求落在哪个 CF 机房(决定 RTT)region/primary—— 查询由主库还是副本处理mail—— 邮件是否已配置("on")
安全
- 登录 token:HMAC-SHA256 签名,无状态、7 天过期;改密码写
token_valid_from让旧 token 立即失效 - 密码:PBKDF2-SHA256 12 万轮;兼容
(md5)/ 明文(仅迁移) - 后台接口全部走
requireAdmin,未登录 403 - 评论软删除(
deleted_at),误删可从 D1 恢复 - CORS 白名单 + 预检处理;写接口走 D1 计数限流
- 验证码答案只存哈希;人机验证挑战 HMAC 签名无状态
- 顶层 fetch 兜底:任何漏网异常也带 CORS + 人话错误,不给读者甩「Failed to fetch」