# artalk-cf —— 跑在 Cloudflare 上的 Artalk 评论后端 + RSS 订阅机器人
自研的 **Artalk API v2 兼容服务端**,以及合并进来的 **RSS 订阅机器人(rss-robot)**,
一起跑在 **Cloudflare Workers + D1 + KV** 上,零服务器、零运维、免费额度内。
> **状态:已上线** → `https://api.200181.xyz`
> D1 `artalk-cf`(`78140776-2bfd-439a-8b05-c3eebe5d5b47`)| KV `RSS_KV`(`d7f86a0fb43f4450a10b786fc2635128`)
> 管理员账号 `admin`(后台登录用邮箱+密码,见「部署」)
>
> **两个后台都可用**:`/admin/` 自研管理面板 | `/sidebar/` 官方 Artalk 后台
> **评论前端一行不用改**:继续用官方 Artalk 客户端(博客里打包的 2.8.7),换后端只改 `hugo.toml` 一行 `server`。
---
## 目录
1. [这个项目是什么](#这个项目是什么)
2. [技术选型](#技术选型)
3. [模块分布](#模块分布)
4. [数据模型](#数据模型)
5. [部署](#部署)
6. [接到博客上](#接到博客上)
7. [评论数据迁移](#评论数据迁移)
8. [性能与免费额度](#性能与免费额度)
---
## 这个项目是什么
两件事,各对应一个原本独立的仓库,现在合并进同一个 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 | 加 `` 省掉 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`](部署清单.md)**(照着敲约 10 分钟)。快速版:
```bash
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:///api/v2/setup
```
### ★ 域名这一步有个坑,动手前先看
**Cloudflare Worker 的自定义域名要求「域名的 NS 托管在 Cloudflare」。**
而 `usj.cc` 的 NS 在 DNSPod(还挂着又拍云 + 腾讯云 EdgeOne 调度)——**千万不要为了评论 API 把整站 NS 搬去 Cloudflare**,会弄坏现有国内双 CDN 链路。
本项目用的是 **`api.200181.xyz`**(NS 本来就在 Cloudflare):
```toml
[[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`:
```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`:
```bash
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),不是数据库。** 改善:博客里加 ``,省掉 0.5s TLS 握手,成本最低。
### 打爆复盘(2026-10-03 D1 额度用尽)
D1 免费版读额度(500 万行/天,UTC 00:00 = 北京 08:00 重置)被一次打爆,实测 525 万行。定位与修复:
1. **取证**:CF GraphQL `d1QueriesAdaptiveGroups`(维度名是 `query`)按 `sum_rowsRead` 排序,锁定 `COUNT(*)` + `LEFT JOIN users` 是无索引全表扫
2. **修复**:
- 三个覆盖索引(`idx_comments_page_list` / `_children` / `_site_list`)
- 计数 KV 缓存(只在增删/审核时失效)
- 列表/计数不再无谓 JOIN users
- 两个 COUNT 合并成一条 `COUNT(*) + SUM(CASE ...)`
3. **教训**:改动前**先本地预览 + 冒烟测试**,别拿线上打爆的额度再跑测试
### 自查方法
打开 看三个字段:
- `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」