归档 artalk-cf 评论后端 + rss-robot 到 blog-admin(含技术选型/模块分布 README)
Deploy to Production / pre-check (push) Successful in 58s
Deploy to Production / build (push) Successful in 4m3s
Deploy to Production / deploy-edgeone (push) Successful in 3m48s
Deploy to Production / finalize (push) Successful in 26s
Deploy to Production / notify-failure (push) Skipped

This commit is contained in:
zqlit committed 2026-10-04 08:45:40 +08:00
1 parent 855a001046
commit a74b3c7127
98 files changed
+15657

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# 本地开发用:cp .dev.vars.example .dev.vars 然后改掉值
# .dev.vars 已在 .gitignore 里,不会被提交
TOKEN_SECRET="local-dev-secret-please-change-me-to-something-long"
ADMIN_PASSWORD="local-dev-password"
+16
View File
@@ -0,0 +1,16 @@
# 依赖与构建产物
node_modules/
.wrangler/
.selftest-build/
*.log
# 机密(本地开发用,不提交)
.dev.vars
# 数据迁移产物(含真实评论/用户邮箱,勿提交)
tools/import-out/
tools/delta-out/
tools/mail-preview/
# 无头浏览器测量截图
tools/*.png
+364
View File
@@ -0,0 +1,364 @@
# 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 | 加 `<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`](部署清单.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://<worker>/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),不是数据库。** 改善:博客里加 `<link rel="preconnect" href="https://api.200181.xyz">`,省掉 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. **教训**:改动前**先本地预览 + 冒烟测试**,别拿线上打爆的额度再跑测试
### 自查方法
打开 <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」
+1538
View File
File diff suppressed because it is too large. Load diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "artalk-cf",
"version": "0.1.0",
"private": true,
"description": "Artalk API v2 compatible comment backend on Cloudflare Workers + D1",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"typecheck": "tsc --noEmit",
"db:init:local": "wrangler d1 execute artalk-cf --local --file=./schema.sql",
"db:init:remote": "wrangler d1 execute artalk-cf --remote --file=./schema.sql",
"import:gen": "python tools/import_artrans.py",
"selftest": "tsc tools/selftest.ts --outDir .selftest-build --module commonjs --target es2022 --moduleResolution node --types ./node_modules/@cloudflare/workers-types --skipLibCheck --esModuleInterop --noEmit false && node .selftest-build/tools/selftest.js",
"tail": "wrangler tail"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250109.0",
"typescript": "^5.7.0",
"wrangler": "^3.99.0"
}
}
+550
View File
@@ -0,0 +1,550 @@
/* ==========================================================================
评论后台样式 —— 对齐 post.usj.cc(写作后台)的视觉语言
色板与度量照抄 write-server/src/app/globals.css
原则:可读性优先
· 暖白底 + 深蓝墨字(不是纯黑,降低长时间阅读的刺眼感)
· 1px 细边框代替投影,只有弹层才用阴影
· 字号偏小但层级明确:11 / 12 / 13 / 15 / 22
· 圆角克制:6px(控件)/ 8px(卡片)/ 16px(登录卡)
· 无装饰性动效,只保留 hover 的颜色过渡
========================================================================== */
/* ---------------------------------------------------------- 色板 */
/* 参考 EWD 后台:白色侧栏 + 中性浅灰内容底 + 蓝色点缀 */
:root{
color-scheme: light;
--bg:#f5f8f6; /* 内容区底 · 带一点绿意的浅灰 */
--surface:#edf2ee; /* 次级面 · hover / 代码底 */
--card:#ffffff; /* 卡片 / 侧栏 · 纯白 */
--text:#253830; /* 正文 · 深灰绿 */
--muted:#8b9a91; /* 次要文字 */
--line:#e3eae5; /* 分隔线 */
--accent:#2f9e63; /* 主色 · 清新绿 */
--accent-hover:#288a56;
--danger:#d75a4a;
--danger-hover:#c45151;
--success:#39b86f;
--warning:#c09a46;
--on-accent:#fff;
--accent-soft:rgba(47,158,99,.09);
--accent-line:rgba(47,158,99,.20);
--danger-soft:rgba(215,90,74,.10);
--danger-line:rgba(215,90,74,.24);
--ok-soft:rgba(57,184,111,.12);
--pend-fg:#92400e; --pend-bg:#fef3c7; --pend-line:#fde68a;
--err-fg:#991b1b; --err-bg:#fee2e2; --err-line:#fecaca;
--shadow-card:0 1px 2px rgba(24,48,36,.04);
--shadow-pop:0 12px 32px rgba(24,48,36,.16);
--side-w:216px;
--font:"PingFang SC","Hiragino Sans GB","Microsoft YaHei",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
}
/* 夜间模式:护眼向 —— 暖墨绿黑底、降对比、柔绿点缀,不用纯黑纯白 */
:root[data-theme="dark"]{
color-scheme: dark;
--bg:#141916; /* 暖墨绿黑,比纯黑柔和 */
--surface:#1d2420; /* hover / 代码底 */
--card:#191f1b; /* 卡片 / 侧栏 */
--text:#d6ded7; /* 正文 · 柔白(不用纯白,降低刺眼感) */
--muted:#8fa093; /* 次要文字 · 灰绿 */
--line:#2a332c; /* 分隔线 · 低对比 */
--accent:#7cc9a0; /* 主色 · 柔和绿 */
--accent-hover:#98d8b4;
--danger:#e39a8f;
--danger-hover:#f0b6ad;
--success:#8fd4a8;
--warning:#e3c98f;
/* 柔绿底上用深墨绿字,保证可读 */
--on-accent:#12241a;
--accent-soft:rgba(124,201,160,.12);
--accent-line:rgba(124,201,160,.28);
--danger-soft:rgba(227,154,143,.13);
--danger-line:rgba(227,154,143,.28);
--ok-soft:rgba(143,212,168,.14);
--pend-fg:#e3c98f; --pend-bg:rgba(227,201,143,.10); --pend-line:rgba(227,201,143,.26);
--err-fg:#e39a8f; --err-bg:rgba(227,154,143,.10); --err-line:rgba(227,154,143,.26);
--shadow-card:0 1px 2px rgba(0,0,0,.20);
--shadow-pop:0 12px 32px rgba(0,0,0,.45);
}
/* ---------------------------------------------------------- 基础 */
*{box-sizing:border-box}
/* 必须放最前面:.gate / .app / .modal 的 display 会盖掉 [hidden] 的 UA 样式 */
[hidden]{display:none!important}
html,body{margin:0;padding:0;height:100%}
body{
background:var(--bg);
color:var(--text);
font-family:var(--font);
font-size:13px;
line-height:1.65;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3,h4{margin:0;font-weight:700;line-height:1.4}
a{color:var(--accent);text-decoration:none}
a:hover{text-decoration:underline}
::selection{background:var(--accent-soft)}
::-webkit-scrollbar{width:6px;height:6px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}
::-webkit-scrollbar-thumb:hover{background:var(--muted)}
*:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
.px{font-size:11px;letter-spacing:.04em;color:var(--muted);text-transform:uppercase}
.mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace}
/* ---------------------------------------------------------- 按钮 */
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:6px;
padding:7px 12px;
font-family:inherit;font-size:12px;font-weight:500;line-height:1.4;
color:var(--text);
background:var(--card);
border:1px solid var(--line);
border-radius:6px;
cursor:pointer;white-space:nowrap;
transition:background-color .15s,color .15s,border-color .15s;
}
.btn:hover:not(:disabled){background:var(--surface);color:var(--accent);border-color:var(--accent-line)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent);font-weight:600}
.btn.primary:hover:not(:disabled){background:var(--accent-hover);border-color:var(--accent-hover);color:var(--on-accent)}
.btn.danger:hover:not(:disabled){color:var(--danger);border-color:var(--danger-line);background:var(--danger-soft)}
.btn.tiny{padding:5px 9px;font-size:11px}
.btn.block{display:flex;width:100%;padding:9px 12px}
.btn[data-busy]{opacity:.5;pointer-events:none}
.icon-btn{
display:inline-flex;align-items:center;justify-content:center;
width:28px;height:28px;padding:0;
color:var(--muted);background:transparent;
border:1px solid transparent;border-radius:6px;
font-size:14px;line-height:1;cursor:pointer;
transition:background-color .15s,color .15s;
}
.icon-btn:hover{background:var(--bg);color:var(--text)}
/* ---------------------------------------------------------- 表单 */
input,select,textarea{
width:100%;padding:9px 11px;
font-family:inherit;font-size:13px;color:var(--text);
background:var(--bg);
border:1px solid var(--line);border-radius:6px;
outline:none;
transition:border-color .15s;
}
input:focus,select:focus,textarea:focus{border-color:var(--accent);outline:none;box-shadow:0 0 0 3px var(--accent-soft)}
input::placeholder,textarea::placeholder{color:var(--muted);opacity:.7}
textarea{resize:vertical;min-height:88px;line-height:1.7}
select{appearance:none;cursor:pointer;padding-right:28px;
background-image:linear-gradient(45deg,transparent 50%,var(--muted) 50%),linear-gradient(135deg,var(--muted) 50%,transparent 50%);
background-position:calc(100% - 15px) 50%,calc(100% - 10px) 50%;
background-size:5px 5px,5px 5px;background-repeat:no-repeat;
}
.lbl{display:block;margin-bottom:6px;font-size:12px;font-weight:500;color:var(--text)}
.field{display:block;margin-bottom:14px}
.field-k{display:block;margin-bottom:6px;font-size:12px;font-weight:500;color:var(--text)}
.alert{
padding:10px 13px;margin-bottom:16px;
font-size:12px;line-height:1.6;
color:var(--err-fg);background:var(--err-bg);
border:1px solid var(--err-line);border-radius:6px;
}
.form-err{
margin:14px 0 0;padding:10px 13px;
font-size:12px;color:var(--err-fg);background:var(--err-bg);
border:1px solid var(--err-line);border-radius:6px;
}
/* ---------------------------------------------------------- 登录 */
.gate{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;background:var(--bg)}
.login-card{
width:100%;max-width:340px;padding:32px;
background:var(--card);
border:1px solid var(--line);border-radius:16px;
box-shadow:var(--shadow-pop);
}
.login-card h1{font-size:19px;color:var(--accent)}
.login-card .sub{margin:3px 0 26px;font-size:12px;color:var(--muted)}
.login-card form{margin-top:0}
.login-card .btn{margin-top:6px}
.login-card .hint{margin:18px 0 0;font-size:11px;line-height:1.7;color:var(--muted);text-align:center}
/* ---------------------------------------------------------- 主框架 */
.app{display:flex;min-height:100vh;background:var(--bg)}
.sidebar{
width:var(--side-w);flex:none;
display:flex;flex-direction:column;
background:var(--card);
border-right:1px solid var(--line);
position:fixed;inset:0 auto 0 0;z-index:50;
}
.side-head{
display:flex;align-items:center;gap:10px;
padding:16px 16px 14px;
border-bottom:1px solid var(--line);
}
.logo{
flex:none;width:30px;height:30px;
display:flex;align-items:center;justify-content:center;
background:var(--accent);color:var(--on-accent);
font-size:14px;font-weight:700;border-radius:8px;
}
.side-tt h1{font-size:14px;color:var(--text);letter-spacing:.02em}
.side-tt p{margin:0;font-size:11px;color:var(--muted)}
.side-nav{flex:1;padding:10px;display:flex;flex-direction:column;gap:2px;overflow-y:auto}
.tab{
display:flex;align-items:center;gap:10px;width:100%;
padding:8px 11px;
font-family:inherit;font-size:13px;font-weight:400;text-align:left;
color:var(--text);background:transparent;
border:0;border-radius:7px;cursor:pointer;
transition:background-color .15s,color .15s;
}
.tab svg{flex:none;opacity:.75}
.tab:hover{background:var(--surface);color:var(--accent)}
.tab.on{background:var(--accent-soft);color:var(--accent);font-weight:500}
.tab.on:hover{background:var(--accent-soft);color:var(--accent)}
.tab.on svg{opacity:1}
.tab .n{
margin-left:auto;padding:1px 6px;
font-size:10px;line-height:1.5;
background:var(--danger);color:#fff;border-radius:999px;
}
.tab.on .n{background:var(--danger);color:#fff}
.nav-group{
padding:12px 11px 4px;
font-size:10.5px;font-weight:600;letter-spacing:.6px;
color:var(--muted);
}
.nav-sep{height:1px;margin:6px 8px;background:var(--line)}
.side-foot{padding:10px 12px 12px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:2px}
.sfoot-btn{
display:flex;align-items:center;gap:9px;width:100%;
padding:7px 10px;
font-family:inherit;font-size:12px;color:var(--muted);
background:transparent;border:0;border-radius:7px;cursor:pointer;
transition:background-color .15s,color .15s;
}
.sfoot-btn:hover{background:var(--surface);color:var(--accent)}
.main{
flex:1;min-width:0;margin-left:var(--side-w);
display:flex;flex-direction:column;
padding:0 28px;
}
/* 顶栏:面包屑 + 登录身份 */
.topbar{
position:sticky;top:0;z-index:30;
margin:0 -28px;padding:13px 28px;
display:flex;align-items:center;gap:10px;
background:var(--card);
border-bottom:1px solid var(--line);
}
.crumb{display:flex;align-items:center;gap:7px;font-size:13px}
.crumb .cg{color:var(--muted)}
.crumb .sp{color:var(--line)}
.crumb .pg{color:var(--text);font-weight:500}
.top-right{margin-left:auto}
.side-name{font-size:12px;color:var(--muted)}
#view{padding-top:22px}
.mobile-bar{display:none}
.scrim{display:none}
.foot{
margin-top:auto;padding:20px 0 24px;
display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;
font-size:11px;color:var(--muted);
}
/* ---------------------------------------------------------- 页头 */
.page-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;margin:0 0 18px}
.page-head h2{font-size:18px}
.page-head .sub{font-size:12px;color:var(--muted)}
.page-head .right{margin-left:auto;display:flex;gap:8px;align-items:center;flex-wrap:wrap}
/* ---------------------------------------------------------- 卡片 */
.card{
background:var(--card);
border:1px solid var(--line);border-radius:8px;
box-shadow:var(--shadow-card);
overflow:hidden;
}
.card-head{
display:flex;align-items:center;gap:8px;
padding:12px 16px;border-bottom:1px solid var(--line);
}
.card-head h3{font-size:13px;font-weight:700}
.card-body{padding:16px}
/* ---------------------------------------------------------- 统计卡 */
.stats{display:grid;grid-template-columns:repeat(4,1fr);gap:12px}
.stat{
padding:16px;text-align:center;
background:var(--card);
border:1px solid var(--line);border-radius:8px;
}
.stat .k{font-size:11px;color:var(--muted);margin-bottom:4px}
.stat .v{font-size:22px;font-weight:700;line-height:1.3;color:var(--accent)}
.stat .x{margin-top:2px;font-size:11px;color:var(--muted)}
.grid2{display:grid;grid-template-columns:1.3fr 1fr;gap:14px;margin-top:14px;align-items:start}
/* ---------------------------------------------------------- 小列表 */
.mini{display:flex;flex-direction:column}
.mini-i{
display:flex;align-items:baseline;gap:10px;
padding:8px 0;border-bottom:1px solid var(--line);
font-size:13px;
}
.mini-i:last-child{border-bottom:0}
.mini-i .n{flex:none;width:62px;font-size:11px;color:var(--muted);white-space:nowrap}
.mini-i .b{flex:1;min-width:0}
.mini-i .b .nm{font-weight:500}
.mini-i .b .tx{margin-left:6px;font-size:12px;color:var(--muted)}
.mini-i .c{flex:none;font-weight:500;color:var(--accent)}
/* ---------------------------------------------------------- 标签 */
.tag{
display:inline-block;padding:1px 6px;
font-size:10px;line-height:1.6;border-radius:4px;
white-space:nowrap;
}
.tag.acc{color:var(--accent);background:var(--accent-soft)}
.tag.pink{color:var(--accent);background:var(--accent-soft)}
.tag.blue{color:var(--accent);background:var(--accent-soft)}
.tag.warn{color:var(--pend-fg);background:var(--pend-bg)}
.tag.dang{color:var(--danger);background:var(--danger-soft)}
.tag.line{color:var(--muted);border:1px solid var(--line)}
/* ---------------------------------------------------------- 评论列表 */
.ctool{display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:14px}
.ctool .grow{flex:1;min-width:180px}
.ctool input,.ctool select{height:36px;padding-top:0;padding-bottom:0}
.clist{display:flex;flex-direction:column;gap:8px}
.citem{
padding:14px 16px;
background:var(--card);
border:1px solid var(--line);border-radius:10px;
transition:border-color .15s;
}
.citem:hover{border-color:var(--accent-line)}
.citem.pend{border-left:3px solid var(--warning)}
.citem.pin{border-left:3px solid var(--accent)}
.citem.hid{opacity:.55}
/* 头像 + 内容 横排;回复嵌套画树状线,不用 margin hack */
.crow{display:flex;gap:12px;align-items:flex-start}
.cavatar{
flex:none;width:38px;height:38px;
border-radius:50%;object-fit:cover;
background:var(--surface);
border:1px solid var(--line);
}
.cmain{flex:1;min-width:0}
.chead{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:5px}
.chead .nick{font-size:13px;font-weight:600}
.chead .t{margin-left:auto;font-size:11px;color:var(--muted);white-space:nowrap}
.cbody{
font-size:14px;line-height:1.75;color:var(--text);
word-break:break-word;white-space:pre-wrap;
}
.cbody .ph{color:var(--muted)}
.creplies{
margin-top:11px;padding-left:14px;
border-left:2px solid var(--line);
display:flex;flex-direction:column;gap:8px;
}
.creplies .citem{background:var(--bg);border-radius:8px}
.creplies .citem.pend{border-left-color:var(--warning)}
.creplies .citem.pin{border-left-color:var(--accent)}
.cfoot{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
margin-top:9px;padding-top:9px;
border-top:1px solid var(--line);
font-size:11px;color:var(--muted);
}
.cfoot a{color:var(--muted)}
.cfoot a:hover{color:var(--accent)}
.cfoot .acts{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
/* ---------------------------------------------------------- 表格 */
.tblwrap{
background:var(--card);
border:1px solid var(--line);border-radius:8px;
overflow-x:auto;
}
table{width:100%;border-collapse:collapse;font-size:13px;min-width:620px}
thead th{
padding:10px 14px;text-align:left;
font-size:11px;font-weight:500;color:var(--muted);
border-bottom:1px solid var(--line);white-space:nowrap;
}
tbody td{padding:11px 14px;border-bottom:1px solid var(--line);vertical-align:middle}
tbody tr:last-child td{border-bottom:0}
tbody tr:hover{background:var(--surface)}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums;white-space:nowrap}
td .lk{font-size:12px;color:var(--muted)}
.ell{display:block;max-width:340px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* ---------------------------------------------------------- 分页 */
.pager{display:flex;align-items:center;justify-content:center;gap:10px;flex-wrap:wrap;margin:20px 0 4px}
.pager .info{font-size:12px;color:var(--muted)}
.pager .info b{color:var(--text);font-weight:600}
/* ---------------------------------------------------------- 空态 / 加载 */
.empty{
padding:48px 20px;text-align:center;
font-size:13px;color:var(--muted);
background:var(--card);
border:1px dashed var(--line);border-radius:8px;
}
.empty b{display:block;margin-bottom:6px;font-size:12px;color:var(--text)}
.loading{padding:40px;text-align:center;font-size:13px;color:var(--muted)}
/* ---------------------------------------------------------- 键值表 */
.kv{display:grid;grid-template-columns:auto 1fr;gap:9px 18px;margin:0;font-size:13px}
.kv dt{font-size:12px;color:var(--muted)}
.kv dd{margin:0;word-break:break-all}
.kv dd.ok{color:var(--success);font-weight:500}
.kv dd.bad{color:var(--danger)}
pre.code{
margin:0;padding:14px;
background:var(--surface);color:var(--text);
border:1px solid var(--line);border-radius:6px;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:12px;line-height:1.7;
overflow:auto;max-height:320px;white-space:pre;
}
textarea.code{
display:block;margin:0;padding:14px;
background:var(--surface);color:var(--text);
border:1px solid var(--line);border-radius:6px;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
font-size:12px;line-height:1.7;
white-space:pre;tab-size:2;
}
textarea.code:focus{outline:none;border-color:var(--accent)}
/* ---------------------------------------------------------- 提示 / 模态 */
.toast{
position:fixed;left:50%;bottom:24px;transform:translateX(-50%);
z-index:600;max-width:86vw;
padding:10px 16px;
font-size:13px;
color:var(--text);background:var(--card);
border:1px solid var(--line);border-radius:8px;
box-shadow:var(--shadow-pop);
}
.toast.bad{color:var(--danger);border-color:var(--danger-line)}
.modal{
position:fixed;inset:0;z-index:500;
display:flex;align-items:center;justify-content:center;padding:20px;
background:rgba(24,48,36,.30);
backdrop-filter:blur(2px);
}
:root[data-theme="dark"] .modal{background:rgba(0,0,0,.55)}
.modal-card{
width:100%;max-width:520px;max-height:86vh;
display:flex;flex-direction:column;
background:var(--card);
border:1px solid var(--line);border-radius:12px;
box-shadow:var(--shadow-pop);
}
.modal-head{
display:flex;align-items:center;gap:10px;
padding:14px 18px;border-bottom:1px solid var(--line);
}
.modal-head h3{flex:1;font-size:14px;font-weight:700}
.modal-body{padding:18px;overflow-y:auto}
/* ---------------------------------------------------------- 响应式 */
@media (max-width:1000px){
.stats{grid-template-columns:repeat(2,1fr)}
.grid2{grid-template-columns:1fr}
.main{padding:0 20px}
.topbar{margin:0 -20px;padding:13px 20px}
}
@media (max-width:760px){
.mobile-bar{
display:flex;align-items:center;gap:8px;
position:sticky;top:0;z-index:40;
height:48px;padding:0 12px;
background:var(--card);border-bottom:1px solid var(--line);
}
.mobile-title{font-size:13px;font-weight:700;color:var(--accent)}
.scrim{display:block;position:fixed;inset:0;z-index:45;background:rgba(18,30,23,.32)}
:root[data-theme="dark"] .scrim{background:rgba(0,0,0,.5)}
.sidebar{
width:78%;max-width:280px;
transform:translateX(-100%);
transition:transform .2s ease-out;
box-shadow:var(--shadow-pop);
}
.sidebar.open{transform:translateX(0)}
.main{margin-left:0;padding:0 16px}
.topbar{margin:0 -16px;padding:12px 16px;top:48px}
#view{padding-top:16px}
.stats{grid-template-columns:1fr 1fr;gap:10px}
.stat{padding:13px 10px}
.stat .v{font-size:19px}
.cavatar{width:32px;height:32px}
.ell{max-width:170px}
.page-head h2{font-size:16px}
.login-card{padding:26px 22px}
}
@media (prefers-reduced-motion:reduce){
*{transition:none!important}
}
File diff suppressed because it is too large. Load diff
+98
View File
@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>管理面板 · 优世界</title>
<meta name="robots" content="noindex,nofollow">
<script>
/* 主题要在样式生效前定好,否则会闪一下浅色 */
try { if (localStorage.getItem('atk-theme') === 'dark') document.documentElement.dataset.theme = 'dark'; } catch (e) {}
</script>
<link rel="stylesheet" href="./admin.css">
</head>
<body>
<!-- ===================== 登录 ===================== -->
<div id="gate" class="gate" hidden>
<div class="login-card">
<h1>管理面板</h1>
<p class="sub">优世界 · 评论 + 订阅统一管理</p>
<div id="lgErr" class="alert" hidden></div>
<form id="loginForm">
<label class="lbl" for="lgUser">账号</label>
<input id="lgUser" type="text" placeholder="admin" required autocomplete="username" spellcheck="false">
<label class="lbl" for="lgPass">密码</label>
<input id="lgPass" type="password" required autocomplete="current-password">
<label class="lbl" for="lgRss">订阅口令(管理订阅/友链用,留空则跳过)</label>
<input id="lgRss" type="password" autocomplete="off">
<button class="btn primary block" type="submit" id="lgBtn">登 录</button>
</form>
<p class="hint" title="Cloudflare Workers 没有 TCP,发不了 SMTP">邮箱验证码登录未启用</p>
</div>
</div>
<!-- ===================== 主界面 ===================== -->
<div id="app" class="app" hidden>
<header class="mobile-bar">
<button class="icon-btn" data-act="menu" aria-label="打开菜单">&#9776;</button>
<span class="mobile-title">管理面板</span>
</header>
<div class="scrim" id="scrim" data-act="menu" hidden></div>
<aside class="sidebar" id="sidebar">
<div class="side-head">
<span class="logo">优</span>
<div class="side-tt">
<h1>优世界</h1>
<p>管理面板</p>
</div>
</div>
<nav class="side-nav" id="tabs"></nav>
<div class="side-foot">
<button class="sfoot-btn" data-act="theme" id="themeBtn"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 14.5A8.5 8.5 0 1 1 9.5 3.5a7 7 0 0 0 11 11z"/></svg><span>深色模式</span></button>
<button class="sfoot-btn" data-act="logout"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 4H5.5A1.5 1.5 0 0 0 4 5.5v13A1.5 1.5 0 0 0 5.5 20H9"/><path d="M15 16l4-4-4-4M19 12H9"/></svg><span>退出登录</span></button>
</div>
</aside>
<main class="main">
<header class="topbar">
<div class="crumb" id="crumb"></div>
<div class="top-right">
<span class="side-name" id="who"></span>
</div>
</header>
<div id="view"></div>
<div class="foot">
<span id="footL"></span>
<span id="footR"></span>
</div>
</main>
</div>
<div id="toast" class="toast" hidden></div>
<div id="modal" class="modal" hidden>
<div class="modal-card" role="dialog" aria-modal="true">
<div class="modal-head">
<h3 id="modalTitle"></h3>
<button class="icon-btn" data-act="modal-close" aria-label="关闭">&#10005;</button>
</div>
<div class="modal-body" id="modalBody"></div>
</div>
</div>
<script src="./admin.js"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
@charset "UTF-8";.artalk,.atk-layer-wrap{--at-color-font: #2a2e2e;--at-color-deep: #2a2e2e;--at-color-sub: #757575;--at-color-grey: #747474;--at-color-meta: #697182;--at-color-border: #eceff2;--at-color-light: #4285f4;--at-color-bg: #fff;--at-color-bg-transl: rgba(255, 255, 255, .94);--at-color-bg-grey: #f3f4f5;--at-color-bg-grey-transl: rgba(244, 244, 244, .75);--at-color-bg-light: rgba(29, 161, 242, .1);--at-color-main: #0083ff;--at-color-red: #ff5652;--at-color-pink: #fa5a57;--at-color-yellow: #ff7c37;--at-color-green: #4caf50;--at-color-gradient: linear-gradient(180deg, transparent, rgba(255, 255, 255))}.artalk.atk-dark-mode,.atk-layer-wrap.atk-dark-mode{--at-color-font: #fff;--at-color-deep: #e7e7e7;--at-color-sub: #e7e7e7;--at-color-grey: #fff;--at-color-meta: #fff;--at-color-border: #2d3235;--at-color-light: #687a86;--at-color-bg: #1e2224;--at-color-bg-transl: rgba(30, 34, 36, .95);--at-color-bg-grey: #46494e;--at-color-bg-grey-transl: rgba(8, 8, 8, .95);--at-color-bg-light: rgba(29, 161, 242, .1);--at-color-main: #0083ff;--at-color-red: #ff5652;--at-color-pink: #fa5a57;--at-color-yellow: #ff7c37;--at-color-green: #4caf50;--at-color-gradient: linear-gradient(180deg, transparent, rgba(30, 34, 36, 1))}.atk-comment-wrap{overflow:hidden;position:relative;border-bottom:1px solid transparent}.atk-comment-wrap.atk-flash-once{animation:atkFlashOnce 1s ease-in-out 0s}@keyframes atkFlashOnce{0%{background:#0083ff33}to{background:transparent}}.atk-comment-wrap.atk-unread:before{content:" ";position:absolute;left:0;top:10%;width:3px;height:80%;background:var(--at-color-main)}.atk-comment-wrap.atk-openable{cursor:pointer}.atk-comment-wrap.atk-openable:hover{background:var(--at-color-bg-grey)}.atk-comment-wrap.atk-openable .atk-height-limit:after{background:transparent!important}.atk-comment-wrap:last-child{border-bottom:none}.atk-comment{display:block;padding:10px}.atk-comment>.atk-avatar{display:block;padding:2px 0;float:left}.atk-comment>.atk-avatar img{width:50px;height:50px;border-radius:3px}.atk-comment>.atk-main{display:block;margin-left:63px}.atk-comment>.atk-main>.atk-header{line-height:1.5;font-size:13px;margin-bottom:.5em;overflow:hidden;position:relative;display:flex;flex-wrap:wrap;align-items:center}.atk-comment>.atk-main>.atk-header .atk-item{display:flex;align-items:center;margin-top:2px;margin-bottom:2px;color:var(--at-color-meta)}.atk-comment>.atk-main>.atk-header .atk-item:not(:last-child){margin-right:6px}.atk-comment>.atk-main>.atk-header .atk-item.atk-nick,.atk-comment>.atk-main>.atk-header .atk-item.atk-nick a{font-size:14px;color:var(--at-color-main);text-decoration:none}.atk-comment>.atk-main>.atk-header .atk-item.atk-reply-at{margin-left:2px}.atk-comment>.atk-main>.atk-header .atk-item.atk-reply-at>.atk-arrow:before{content:"";vertical-align:middle;transform:rotate(90deg);border-bottom:4px solid var(--at-color-grey);border-left:3px solid transparent;border-right:3px solid transparent;display:inline-block;margin-top:-1px}.atk-comment>.atk-main>.atk-header .atk-item.atk-reply-at>.atk-nick{color:var(--at-color-main);cursor:pointer;margin-left:6px}.atk-comment>.atk-main>.atk-header .badge,.atk-comment>.atk-main>.atk-header .atk-ua,.atk-comment>.atk-main>.atk-header .atk-pinned-badge,.atk-comment>.atk-main>.atk-header .atk-region-badge,.atk-comment>.atk-main>.atk-header .atk-badge{display:inline-block;color:var(--at-color-meta);background:var(--at-color-bg-grey);padding:0 6px;line-height:17px;border-radius:3px}.atk-comment>.atk-main>.atk-header .badge:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-ua:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-pinned-badge:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-region-badge:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-badge:not(:last-child){margin-right:6px}.atk-comment>.atk-main>.atk-header .atk-badge-wrap>*:last-child{margin-right:6px}.atk-comment>.atk-main>.atk-header .atk-badge{color:#fff}.atk-comment>.atk-main>.atk-header .atk-pinned-badge{color:#fff;background:#f44336}.atk-comment>.atk-main>.atk-header .atk-verified-icon{height:1.4em;width:1.4em;background-repeat:no-repeat;background-position:center;background-size:contain;display:block;background-image:url('data:image/svg+xml,<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg" fill="%231d9bf0"><g><path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.Line truncated
+11
View File
@@ -0,0 +1,11 @@
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).Artalk={})}(this,(function(e){"use strict";var t,n,s,i,r=Object.defineProperty,o=Object.defineProperties,a=Object.getOwnPropertyDescriptors,l=Object.getOwnPropertySymbols,c=Object.prototype.hasOwnProperty,d=Object.prototype.propertyIsEnumerable,h=(e,t,n)=>t in e?r(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,u=(e,t)=>{for(var n in t||(t={}))c.call(t,n)&&h(e,n,t[n]);if(l)for(var n of l(t))d.call(t,n)&&h(e,n,t[n]);return e},p=(e,t)=>o(e,a(t)),g=(e,t,n)=>(h(e,"symbol"!=typeof t?t+"":t,n),n),m=(e,t,n)=>{if(t.has(e))throw TypeError("Cannot add the same private member more than once");t instanceof WeakSet?t.add(e):t.set(e,n)},f=(e,t,n)=>(((e,t,n)=>{if(!t.has(e))throw TypeError("Cannot "+n)})(e,t,"access private method"),n),k=(e,t,n)=>new Promise(((s,i)=>{var r=e=>{try{a(n.next(e))}catch(t){i(t)}},o=e=>{try{a(n.throw(e))}catch(t){i(t)}},a=e=>e.done?s(e.value):Promise.resolve(e.value).then(r,o);a((n=n.apply(e,t)).next())}));class y{constructor(e={}){g(this,"baseUrl","/api/v2"),g(this,"securityData",null),g(this,"securityWorker"),g(this,"abortControllers",new Map),g(this,"customFetch",((...e)=>fetch(...e))),g(this,"baseApiParams",{credentials:"same-origin",headers:{},redirect:"follow",referrerPolicy:"no-referrer"}),g(this,"setSecurityData",(e=>{this.securityData=e})),g(this,"contentFormatters",{"application/json":e=>null===e||"object"!=typeof e&&"string"!=typeof e?e:JSON.stringify(e),"text/plain":e=>null!==e&&"string"!=typeof e?JSON.stringify(e):e,"multipart/form-data":e=>Object.keys(e||{}).reduce(((t,n)=>{const s=e[n];return t.append(n,s instanceof Blob?s:"object"==typeof s&&null!==s?JSON.stringify(s):`${s}`),t}),new FormData),"application/x-www-form-urlencoded":e=>this.toQueryString(e)}),g(this,"createAbortSignal",(e=>{if(this.abortControllers.has(e)){const t=this.abortControllers.get(e);return t?t.signal:void 0}const t=new AbortController;return this.abortControllers.set(e,t),t.signal})),g(this,"abortRequest",(e=>{const t=this.abortControllers.get(e);t&&(t.abort(),this.abortControllers.delete(e))})),g(this,"request",(e=>k(this,null,(function*(){var t=e,{body:n,secure:s,path:i,type:r,query:o,format:a,baseUrl:h,cancelToken:g}=t,m=((e,t)=>{var n={};for(var s in e)c.call(e,s)&&t.indexOf(s)<0&&(n[s]=e[s]);if(null!=e&&l)for(var s of l(e))t.indexOf(s)<0&&d.call(e,s)&&(n[s]=e[s]);return n})(t,["body","secure","path","type","query","format","baseUrl","cancelToken"]);const f=("boolean"==typeof s?s:this.baseApiParams.secure)&&this.securityWorker&&(yield this.securityWorker(this.securityData))||{},y=this.mergeRequestParams(m,f),v=o&&this.toQueryString(o),$=this.contentFormatters[r||"application/json"],b=a||y.format;return this.customFetch(`${h||this.baseUrl||""}${i}${v?`?${v}`:""}`,p(u({},y),{headers:u(u({},y.headers||{}),r&&"multipart/form-data"!==r?{"Content-Type":r}:{}),signal:(g?this.createAbortSignal(g):y.signal)||null,body:null==n?null:$(n)})).then((e=>k(this,null,(function*(){const t=e;t.data=null,t.error=null;const n=b?yield e[b]().then((e=>(t.ok?t.data=e:t.error=e,t))).catch((e=>(t.error=e,t))):t;if(g&&this.abortControllers.delete(g),!e.ok)throw n;return n}))))})))),Object.assign(this,e)}encodeQueryParam(e,t){return`${encodeURIComponent(e)}=${encodeURIComponent("number"==typeof t?t:`${t}`)}`}addQueryParam(e,t){return this.encodeQueryParam(t,e[t])}addArrayQueryParam(e,t){return e[t].map((e=>this.encodeQueryParam(t,e))).join("&")}toQueryString(e){const t=e||{};return Object.keys(t).filter((e=>void 0!==t[e])).map((e=>Array.isArray(t[e])?this.addArrayQueryParam(t,e):this.addQueryParam(t,e))).join("&")}addQueryParams(e){const t=this.toQueryString(e);return t?`?${t}`:""}mergeRequestParams(e,t){return p(u(u(u({},this.baseApiParams),e),t||{}),{headers:u(u(u({},this.baseApiParams.headers||{}),e.headers||{}),t&&t.headers||{})})}}
/**
* @title Artalk API
* @version 2.0
* @license MIT (https://github.com/ArtalkJS/Artalk/blob/master/LICENSE)
* @baseUrl /api/v2
* @contact API Support <artalkjs@gmail.com> (https://artalk.js.org)
*
* Artalk is a modern comment system based on Golang.
*/let v=class extends y{constructor(){super(...arguments),g(this,"auth",{loginByEmail:(e,t={})=>this.request(u({path:"/auth/email/login",method:"POST",body:e,type:"application/json",format:"json"},t)),registerByEmail:(e,t={})=>this.request(u({path:"/auth/email/register",method:"POST",body:e,type:"application/json",format:"json"},t)),sendVerifyEmail:(e,t={})=>this.request(u({path:"/auth/email/send",method:"POST",body:e,type:"application/json",format:"json"},t)),checkDataMerge:(e={})=>this.request(u({path:"/auth/merge",method:"GET",secure:!0,format:"json"},e)),applyDataMerge:(e,t={})=>this.request(u({path:"/auth/merge",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t))}),g(this,"cache",{flushCache:(e={})=>this.request(u({path:"/cache/flush",method:"POST",secure:!0,format:"json"},e)),warmUpCache:(e={})=>this.request(u({path:"/cache/warm_up",method:"POST",secure:!0,format:"json"},e))}),g(this,"captcha",{getCaptcha:(e={})=>this.request(u({path:"/captcha",method:"GET",format:"json"},e)),getCaptchaStatus:(e={})=>this.request(u({path:"/captcha/status",method:"GET",format:"json"},e)),verifyCaptcha:(e,t={})=>this.request(u({path:"/captcha/verify",method:"POST",body:e,type:"application/json",format:"json"},t))}),g(this,"comments",{getComments:(e,t={})=>this.request(u({path:"/comments",method:"GET",query:e,secure:!0,type:"application/json",format:"json"},t)),createComment:(e,t={})=>this.request(u({path:"/comments",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t)),getComment:(e,t={})=>this.request(u({path:`/comments/${e}`,method:"GET",type:"application/json",format:"json"},t)),updateComment:(e,t,n={})=>this.request(u({path:`/comments/${e}`,method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},n)),deleteComment:(e,t={})=>this.request(u({path:`/comments/${e}`,method:"DELETE",secure:!0,format:"json"},t))}),g(this,"conf",{conf:(e={})=>this.request(u({path:"/conf",method:"GET",format:"json"},e)),getSocialLoginProviders:(e={})=>this.request(u({path:"/conf/auth/providers",method:"GET",format:"json"},e)),getDomain:(e,t={})=>this.request(u({path:"/conf/domain",method:"GET",query:e,format:"json"},t))}),g(this,"notifies",{getNotifies:(e,t={})=>this.request(u({path:"/notifies",method:"GET",query:e,type:"application/json",format:"json"},t)),markAllNotifyRead:(e,t={})=>this.request(u({path:"/notifies/read",method:"POST",body:e,type:"application/json",format:"json"},t)),markNotifyRead:(e,t,n={})=>this.request(u({path:`/notifies/${e}/${t}`,method:"POST",format:"json"},n))}),g(this,"pages",{getPages:(e,t={})=>this.request(u({path:"/pages",method:"GET",query:e,secure:!0,type:"application/json",format:"json"},t)),fetchAllPages:(e,t={})=>this.request(u({path:"/pages/fetch",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t)),getPageFetchStatus:(e={})=>this.request(u({path:"/pages/fetch/status",method:"GET",secure:!0,format:"json"},e)),logPv:(e,t={})=>this.request(u({path:"/pages/pv",method:"POST",body:e,type:"application/json",format:"json"},t)),updatePage:(e,t,n={})=>this.request(u({path:`/pages/${e}`,method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},n)),deletePage:(e,t={})=>this.request(u({path:`/pages/${e}`,method:"DELETE",secure:!0,format:"json"},t)),fetchPage:(e,t={})=>this.request(u({path:`/pages/${e}/fetch`,method:"POST",secure:!0,type:"application/json",format:"json"},t))}),g(this,"sendEmail",{sendEmail:(e,t={})=>this.request(u({path:"/send_email",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t))}),g(this,"settings",{getSettings:(e={})=>this.request(u({path:"/settings",method:"GET",secure:!0,format:"json"},e)),applySettings:(e,t={})=>this.request(u({path:"/settings",method:"PUT",body:e,secure:!0,type:"application/json",format:"json"},t)),getSettingsTemplate:(e,t={})=>this.request(u({path:`/settings/template/${e}`,method:"GET",secure:!0,format:"json"},t))}),g(this,"sites",{getSites:(e={})=>this.request(u({path:"/sites",method:"GET",secure:!0,format:"json"},e)),createSite:(e,t={})=>this.request(u({path:"/sites",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t)),updateSite:(e,t,n={})=>this.request(u({path:`/sites/${e}`,method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},n)),deleteSite:(e,t={})=>this.request(u({path:`/sites/${e}`,method:"DELETE",secure:!0,format:"json"},t))}),g(this,"stats",{getStats:(e,t,n={})=>this.request(u({path:`/stats/${e}`,method:"GET",query:t,type:"application/json",format:"json"},n))}),g(this,"transfer",{exportArtrans:(e={})=>this.request(u({path:"/transfer/export",method:"GET",secure:!0,format:"json"},e)),importArtrans:(e,t={})=>this.request(u({path:"/transfer/import",method:"POST",body:e,secure:!0,type:"application/json"},t)),uploadArtrans:(e,t={})=>this.request(u({path:"/transfer/upload",method:"POST",body:e,secure:!0,type:"multipart/form-data",format:"json"},t))}),g(this,"upload",{upload:(e,t={})=>this.request(u({path:"/upload",method:"POSLine truncated
//# sourceMappingURL=Artalk.js.map
+1
View File
@@ -0,0 +1 @@
@charset "UTF-8";.artalk,.atk-layer-wrap{--at-color-font: #2a2e2e;--at-color-deep: #2a2e2e;--at-color-sub: #757575;--at-color-grey: #747474;--at-color-meta: #697182;--at-color-border: #eceff2;--at-color-light: #4285f4;--at-color-bg: #fff;--at-color-bg-transl: rgba(255, 255, 255, .94);--at-color-bg-grey: #f3f4f5;--at-color-bg-grey-transl: rgba(244, 244, 244, .75);--at-color-bg-light: rgba(29, 161, 242, .1);--at-color-main: #0083ff;--at-color-red: #ff5652;--at-color-pink: #fa5a57;--at-color-yellow: #ff7c37;--at-color-green: #4caf50;--at-color-gradient: linear-gradient(180deg, transparent, rgba(255, 255, 255))}.artalk.atk-dark-mode,.atk-layer-wrap.atk-dark-mode{--at-color-font: #fff;--at-color-deep: #e7e7e7;--at-color-sub: #e7e7e7;--at-color-grey: #fff;--at-color-meta: #fff;--at-color-border: #2d3235;--at-color-light: #687a86;--at-color-bg: #1e2224;--at-color-bg-transl: rgba(30, 34, 36, .95);--at-color-bg-grey: #46494e;--at-color-bg-grey-transl: rgba(8, 8, 8, .95);--at-color-bg-light: rgba(29, 161, 242, .1);--at-color-main: #0083ff;--at-color-red: #ff5652;--at-color-pink: #fa5a57;--at-color-yellow: #ff7c37;--at-color-green: #4caf50;--at-color-gradient: linear-gradient(180deg, transparent, rgba(30, 34, 36, 1))}.atk-comment-wrap{overflow:hidden;position:relative;border-bottom:1px solid transparent}.atk-comment-wrap.atk-flash-once{animation:atkFlashOnce 1s ease-in-out 0s}@keyframes atkFlashOnce{0%{background:#0083ff33}to{background:transparent}}.atk-comment-wrap.atk-unread:before{content:" ";position:absolute;left:0;top:10%;width:3px;height:80%;background:var(--at-color-main)}.atk-comment-wrap.atk-openable{cursor:pointer}.atk-comment-wrap.atk-openable:hover{background:var(--at-color-bg-grey)}.atk-comment-wrap.atk-openable .atk-height-limit:after{background:transparent!important}.atk-comment-wrap:last-child{border-bottom:none}.atk-comment{display:block;padding:10px}.atk-comment>.atk-avatar{display:block;padding:2px 0;float:left}.atk-comment>.atk-avatar img{width:50px;height:50px;border-radius:3px}.atk-comment>.atk-main{display:block;margin-left:63px}.atk-comment>.atk-main>.atk-header{line-height:1.5;font-size:13px;margin-bottom:.5em;overflow:hidden;position:relative;display:flex;flex-wrap:wrap;align-items:center}.atk-comment>.atk-main>.atk-header .atk-item{display:flex;align-items:center;margin-top:2px;margin-bottom:2px;color:var(--at-color-meta)}.atk-comment>.atk-main>.atk-header .atk-item:not(:last-child){margin-right:6px}.atk-comment>.atk-main>.atk-header .atk-item.atk-nick,.atk-comment>.atk-main>.atk-header .atk-item.atk-nick a{font-size:14px;color:var(--at-color-main);text-decoration:none}.atk-comment>.atk-main>.atk-header .atk-item.atk-reply-at{margin-left:2px}.atk-comment>.atk-main>.atk-header .atk-item.atk-reply-at>.atk-arrow:before{content:"";vertical-align:middle;transform:rotate(90deg);border-bottom:4px solid var(--at-color-grey);border-left:3px solid transparent;border-right:3px solid transparent;display:inline-block;margin-top:-1px}.atk-comment>.atk-main>.atk-header .atk-item.atk-reply-at>.atk-nick{color:var(--at-color-main);cursor:pointer;margin-left:6px}.atk-comment>.atk-main>.atk-header .badge,.atk-comment>.atk-main>.atk-header .atk-ua,.atk-comment>.atk-main>.atk-header .atk-pinned-badge,.atk-comment>.atk-main>.atk-header .atk-region-badge,.atk-comment>.atk-main>.atk-header .atk-badge{display:inline-block;color:var(--at-color-meta);background:var(--at-color-bg-grey);padding:0 6px;line-height:17px;border-radius:3px}.atk-comment>.atk-main>.atk-header .badge:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-ua:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-pinned-badge:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-region-badge:not(:last-child),.atk-comment>.atk-main>.atk-header .atk-badge:not(:last-child){margin-right:6px}.atk-comment>.atk-main>.atk-header .atk-badge-wrap>*:last-child{margin-right:6px}.atk-comment>.atk-main>.atk-header .atk-badge{color:#fff}.atk-comment>.atk-main>.atk-header .atk-pinned-badge{color:#fff;background:#f44336}.atk-comment>.atk-main>.atk-header .atk-verified-icon{height:1.4em;width:1.4em;background-repeat:no-repeat;background-position:center;background-size:contain;display:block;background-image:url('data:image/svg+xml,<svg viewBox="0 0 22 22" xmlns="http://www.w3.org/2000/svg" fill="%231d9bf0"><g><path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.Line truncated
+11
View File
@@ -0,0 +1,11 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("marked")):"function"==typeof define&&define.amd?define(["exports","marked"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).Artalk={},t.marked)}(this,(function(t,e){"use strict";var n=Object.defineProperty,i=Object.defineProperties,s=Object.getOwnPropertyDescriptors,o=Object.getOwnPropertySymbols,r=Object.prototype.hasOwnProperty,a=Object.prototype.propertyIsEnumerable,l=(t,e,i)=>e in t?n(t,e,{enumerable:!0,configurable:!0,writable:!0,value:i}):t[e]=i,c=(t,e)=>{for(var n in e||(e={}))r.call(e,n)&&l(t,n,e[n]);if(o)for(var n of o(e))a.call(e,n)&&l(t,n,e[n]);return t},d=(t,e)=>i(t,s(e)),h=(t,e,n)=>(l(t,"symbol"!=typeof e?e+"":e,n),n),u=(t,e,n)=>new Promise(((i,s)=>{var o=t=>{try{a(n.next(t))}catch(e){s(e)}},r=t=>{try{a(n.throw(t))}catch(e){s(e)}},a=t=>t.done?i(t.value):Promise.resolve(t.value).then(o,r);a((n=n.apply(t,e)).next())}));class p{constructor(t={}){h(this,"baseUrl","/api/v2"),h(this,"securityData",null),h(this,"securityWorker"),h(this,"abortControllers",new Map),h(this,"customFetch",((...t)=>fetch(...t))),h(this,"baseApiParams",{credentials:"same-origin",headers:{},redirect:"follow",referrerPolicy:"no-referrer"}),h(this,"setSecurityData",(t=>{this.securityData=t})),h(this,"contentFormatters",{"application/json":t=>null===t||"object"!=typeof t&&"string"!=typeof t?t:JSON.stringify(t),"text/plain":t=>null!==t&&"string"!=typeof t?JSON.stringify(t):t,"multipart/form-data":t=>Object.keys(t||{}).reduce(((e,n)=>{const i=t[n];return e.append(n,i instanceof Blob?i:"object"==typeof i&&null!==i?JSON.stringify(i):`${i}`),e}),new FormData),"application/x-www-form-urlencoded":t=>this.toQueryString(t)}),h(this,"createAbortSignal",(t=>{if(this.abortControllers.has(t)){const e=this.abortControllers.get(t);return e?e.signal:void 0}const e=new AbortController;return this.abortControllers.set(t,e),e.signal})),h(this,"abortRequest",(t=>{const e=this.abortControllers.get(t);e&&(e.abort(),this.abortControllers.delete(t))})),h(this,"request",(t=>u(this,null,(function*(){var e=t,{body:n,secure:i,path:s,type:l,query:h,format:p,baseUrl:m,cancelToken:g}=e,f=((t,e)=>{var n={};for(var i in t)r.call(t,i)&&e.indexOf(i)<0&&(n[i]=t[i]);if(null!=t&&o)for(var i of o(t))e.indexOf(i)<0&&a.call(t,i)&&(n[i]=t[i]);return n})(e,["body","secure","path","type","query","format","baseUrl","cancelToken"]);const y=("boolean"==typeof i?i:this.baseApiParams.secure)&&this.securityWorker&&(yield this.securityWorker(this.securityData))||{},v=this.mergeRequestParams(f,y),k=h&&this.toQueryString(h),$=this.contentFormatters[l||"application/json"],w=p||v.format;return this.customFetch(`${m||this.baseUrl||""}${s}${k?`?${k}`:""}`,d(c({},v),{headers:c(c({},v.headers||{}),l&&"multipart/form-data"!==l?{"Content-Type":l}:{}),signal:(g?this.createAbortSignal(g):v.signal)||null,body:null==n?null:$(n)})).then((t=>u(this,null,(function*(){const e=t;e.data=null,e.error=null;const n=w?yield t[w]().then((t=>(e.ok?e.data=t:e.error=t,e))).catch((t=>(e.error=t,e))):e;if(g&&this.abortControllers.delete(g),!t.ok)throw n;return n}))))})))),Object.assign(this,t)}encodeQueryParam(t,e){return`${encodeURIComponent(t)}=${encodeURIComponent("number"==typeof e?e:`${e}`)}`}addQueryParam(t,e){return this.encodeQueryParam(e,t[e])}addArrayQueryParam(t,e){return t[e].map((t=>this.encodeQueryParam(e,t))).join("&")}toQueryString(t){const e=t||{};return Object.keys(e).filter((t=>void 0!==e[t])).map((t=>Array.isArray(e[t])?this.addArrayQueryParam(e,t):this.addQueryParam(e,t))).join("&")}addQueryParams(t){const e=this.toQueryString(t);return e?`?${e}`:""}mergeRequestParams(t,e){return d(c(c(c({},this.baseApiParams),t),e||{}),{headers:c(c(c({},this.baseApiParams.headers||{}),t.headers||{}),e&&e.headers||{})})}}
/**
* @title Artalk API
* @version 2.0
* @license MIT (https://github.com/ArtalkJS/Artalk/blob/master/LICENSE)
* @baseUrl /api/v2
* @contact API Support <artalkjs@gmail.com> (https://artalk.js.org)
*
* Artalk is a modern comment system based on Golang.
*/let m=class extends p{constructor(){super(...arguments),h(this,"auth",{loginByEmail:(t,e={})=>this.request(c({path:"/auth/email/login",method:"POST",body:t,type:"application/json",format:"json"},e)),registerByEmail:(t,e={})=>this.request(c({path:"/auth/email/register",method:"POST",body:t,type:"application/json",format:"json"},e)),sendVerifyEmail:(t,e={})=>this.request(c({path:"/auth/email/send",method:"POST",body:t,type:"application/json",format:"json"},e)),checkDataMerge:(t={})=>this.request(c({path:"/auth/merge",method:"GET",secure:!0,format:"json"},t)),applyDataMerge:(t,e={})=>this.request(c({path:"/auth/merge",method:"POST",body:t,secure:!0,type:"application/json",format:"json"},e))}),h(this,"cache",{flushCache:(t={})=>this.request(c({path:"/cache/flush",method:"POST",secure:!0,format:"json"},t)),warmUpCache:(t={})=>this.request(c({path:"/cache/warm_up",method:"POST",secure:!0,format:"json"},t))}),h(this,"captcha",{getCaptcha:(t={})=>this.request(c({path:"/captcha",method:"GET",format:"json"},t)),getCaptchaStatus:(t={})=>this.request(c({path:"/captcha/status",method:"GET",format:"json"},t)),verifyCaptcha:(t,e={})=>this.request(c({path:"/captcha/verify",method:"POST",body:t,type:"application/json",format:"json"},e))}),h(this,"comments",{getComments:(t,e={})=>this.request(c({path:"/comments",method:"GET",query:t,secure:!0,type:"application/json",format:"json"},e)),createComment:(t,e={})=>this.request(c({path:"/comments",method:"POST",body:t,secure:!0,type:"application/json",format:"json"},e)),getComment:(t,e={})=>this.request(c({path:`/comments/${t}`,method:"GET",type:"application/json",format:"json"},e)),updateComment:(t,e,n={})=>this.request(c({path:`/comments/${t}`,method:"PUT",body:e,secure:!0,type:"application/json",format:"json"},n)),deleteComment:(t,e={})=>this.request(c({path:`/comments/${t}`,method:"DELETE",secure:!0,format:"json"},e))}),h(this,"conf",{conf:(t={})=>this.request(c({path:"/conf",method:"GET",format:"json"},t)),getSocialLoginProviders:(t={})=>this.request(c({path:"/conf/auth/providers",method:"GET",format:"json"},t)),getDomain:(t,e={})=>this.request(c({path:"/conf/domain",method:"GET",query:t,format:"json"},e))}),h(this,"notifies",{getNotifies:(t,e={})=>this.request(c({path:"/notifies",method:"GET",query:t,type:"application/json",format:"json"},e)),markAllNotifyRead:(t,e={})=>this.request(c({path:"/notifies/read",method:"POST",body:t,type:"application/json",format:"json"},e)),markNotifyRead:(t,e,n={})=>this.request(c({path:`/notifies/${t}/${e}`,method:"POST",format:"json"},n))}),h(this,"pages",{getPages:(t,e={})=>this.request(c({path:"/pages",method:"GET",query:t,secure:!0,type:"application/json",format:"json"},e)),fetchAllPages:(t,e={})=>this.request(c({path:"/pages/fetch",method:"POST",body:t,secure:!0,type:"application/json",format:"json"},e)),getPageFetchStatus:(t={})=>this.request(c({path:"/pages/fetch/status",method:"GET",secure:!0,format:"json"},t)),logPv:(t,e={})=>this.request(c({path:"/pages/pv",method:"POST",body:t,type:"application/json",format:"json"},e)),updatePage:(t,e,n={})=>this.request(c({path:`/pages/${t}`,method:"PUT",body:e,secure:!0,type:"application/json",format:"json"},n)),deletePage:(t,e={})=>this.request(c({path:`/pages/${t}`,method:"DELETE",secure:!0,format:"json"},e)),fetchPage:(t,e={})=>this.request(c({path:`/pages/${t}/fetch`,method:"POST",secure:!0,type:"application/json",format:"json"},e))}),h(this,"sendEmail",{sendEmail:(t,e={})=>this.request(c({path:"/send_email",method:"POST",body:t,secure:!0,type:"application/json",format:"json"},e))}),h(this,"settings",{getSettings:(t={})=>this.request(c({path:"/settings",method:"GET",secure:!0,format:"json"},t)),applySettings:(t,e={})=>this.request(c({path:"/settings",method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},e)),getSettingsTemplate:(t,e={})=>this.request(c({path:`/settings/template/${t}`,method:"GET",secure:!0,format:"json"},e))}),h(this,"sites",{getSites:(t={})=>this.request(c({path:"/sites",method:"GET",secure:!0,format:"json"},t)),createSite:(t,e={})=>this.request(c({path:"/sites",method:"POST",body:t,secure:!0,type:"application/json",format:"json"},e)),updateSite:(t,e,n={})=>this.request(c({path:`/sites/${t}`,method:"PUT",body:e,secure:!0,type:"application/json",format:"json"},n)),deleteSite:(t,e={})=>this.request(c({path:`/sites/${t}`,method:"DELETE",secure:!0,format:"json"},e))}),h(this,"stats",{getStats:(t,e,n={})=>this.request(c({path:`/stats/${t}`,method:"GET",query:e,type:"application/json",format:"json"},n))}),h(this,"transfer",{exportArtrans:(t={})=>this.request(c({path:"/transfer/export",method:"GET",secure:!0,format:"json"},t)),importArtrans:(t,e={})=>this.request(c({path:"/transfer/import",method:"POST",body:t,secure:!0,type:"application/json"},e)),uploadArtrans:(t,e={})=>this.request(c({path:"/transfer/upload",method:"POST",body:t,secure:!0,type:"multipart/form-data",format:"json"},e))}),h(this,"upload",{upload:(t,e={})=>this.request(c({path:"/upload",method:"POSLine truncated
//# sourceMappingURL=ArtalkLite.js.map
+1
View File
@@ -0,0 +1 @@
!function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self).jp=o()}(this,(function(){"use strict";const e="ArtalkI18n";var o,n,t;return o="jp",n={placeholder:"内容を入力してください...",noComment:"コメントなし",send:"コメントを送信",signIn:"サインイン",signUp:"サインアップ",save:"コメントを保存",nick:"ニックネーム",email:"Eメール",link:"ウェブサイト",emoticon:"絵文字",preview:"プレビュー",uploadImage:"画像をアップロード",uploadFail:"アップロードに失敗しました",commentFail:"コメントに失敗しました",restoredMsg:"内容が自動的に復元されました",onlyAdminCanReply:"管理者のみがコメントできます",uploadLoginMsg:"名前とメールアドレスを入力してからアップロードしてください",counter:"{count}件のコメント",sortLatest:"最新",sortOldest:"最古",sortBest:"人気",sortAuthor:"著者",openComment:"コメントを表示",closeComment:"コメントを非表示",listLoadFailMsg:"コメントリストのデータを取得できません",listRetry:"再試行するにはクリック",loadMore:"もっと読み込む",admin:"管理者",reply:"返信",voteUp:"賛成",voteDown:"反対",voteFail:"投票に失敗しました",readMore:"続きを読む",actionConfirm:"操作を確認",collapse:"折り畳む",collapsed:"折りたたまれた",collapsedMsg:"このコメントはシステムまたは管理者によって折りたたまれました",expand:"展開",approved:"承認済み",pending:"保留中",pendingMsg:"審査中、自分自身のみが表示できます。",edit:"編集",editCancel:"編集をキャンセル",delete:"削除",deleteConfirm:"削除を確認",pin:"ピン留め",unpin:"ピン留めを解除",seconds:"秒前",minutes:"分前",hours:"時間前",days:"日前",now:"たった今",adminCheck:"管理者として認証するにはパスワードを入力してください:",captchaCheck:"続行するにはキャプチャを入力してください:",confirm:"確認",cancel:"キャンセル",msgCenter:"通知センター",ctrlCenter:"コントロールセンター",noAccountPrompt:"アカウントがありませんか?",haveAccountPrompt:"アカウントをお持ちですか?",forgetPassword:"パスワードを忘れた",resetPassword:"パスワードをリセット",verificationCode:"検証コード",verifySend:"検証",verifyResend:"再送信",waitSeconds:"{seconds}秒待つ",emailVerified:"メールアドレスが確認されました",password:"パスワード",username:"ユーザー名",nextStep:"次のステップ",skipNotVerify:"スキップ、検証しない",logoutConfirm:"ログアウトしてもよろしいですか?",accountMergeNotice:"あなたのメールには異なるIDを持つ複数のアカウントがあります。",accountMergeSelectOne:"すべてのデータを統合するアカウントを選択してください。",accountMergeConfirm:"すべてのデータは1つのアカウントに統合されます。そのIDは{id}です。",dismiss:"閉じる",merge:"統合する",frontend:"フロントエンド",backend:"バックエンド",loading:"読み込み中",loadFail:"読み込みに失敗しました",editing:"編集中",editFail:"編集に失敗しました",deleting:"削除中",deleteFail:"削除に失敗しました",reqGot:"リクエストが受信されました",reqAborted:"リクエストがタイムアウトまたは予期せず中止されました",updateMsg:"Artalk {name} を更新して、より良い体験をしてください!",currentVersion:"現在のバージョン",ignore:"無視",open:"開く",openName:"{name}を開く"},t=["jp-JP"],window[e]||(window[e]={}),window[e][o]=n,t&&t.forEach((o=>{window[e][o]=n})),n}));
+1
View File
@@ -0,0 +1 @@
!function(e,o){"object"==typeof exports&&"undefined"!=typeof module?module.exports=o():"function"==typeof define&&define.amd?define(o):(e="undefined"!=typeof globalThis?globalThis:e||self)["zh-TW"]=o()}(this,(function(){"use strict";const e="ArtalkI18n";var o,n;return o="zh-TW",n={placeholder:"輸入內容...",noComment:"「此時無聲勝有聲」",send:"發送",signIn:"登入",signUp:"註冊",save:"保存",nick:"暱稱",email:"電子郵件",link:"網址",emoticon:"表情",preview:"預覽",uploadImage:"上傳圖片",uploadFail:"上傳失敗",commentFail:"評論失敗",restoredMsg:"內容已自動恢復",onlyAdminCanReply:"僅管理員可評論",uploadLoginMsg:"填入你的名字郵箱才能上傳哦",counter:"{count} 條評論",sortLatest:"最新",sortOldest:"最舊",sortBest:"最熱",sortAuthor:"作者",openComment:"打開評論",closeComment:"關閉評論",listLoadFailMsg:"無法獲取評論列表數據",listRetry:"點擊重新獲取",loadMore:"加載更多",admin:"管理員",reply:"回覆",voteUp:"贊同",voteDown:"反對",voteFail:"投票失敗",readMore:"閱讀更多",actionConfirm:"確認操作",collapse:"折疊",collapsed:"已折疊",collapsedMsg:"該評論已被系統或管理員折疊",expand:"展開",approved:"已審",pending:"待審",pendingMsg:"審核中,僅本人可見。",edit:"編輯",editCancel:"取消編輯",delete:"刪除",deleteConfirm:"確認刪除",pin:"置頂",unpin:"取消置頂",seconds:"秒前",minutes:"分鐘前",hours:"小時前",days:"天前",now:"剛剛",adminCheck:"輸入密碼來驗證管理員身份:",captchaCheck:"輸入驗證碼繼續:",confirm:"確認",cancel:"取消",msgCenter:"通知中心",ctrlCenter:"控制中心",noAccountPrompt:"沒有帳號?",haveAccountPrompt:"已有帳號?",forgetPassword:"忘記密碼",resetPassword:"重置密碼",verificationCode:"驗證碼",verifySend:"發送驗證碼",verifyResend:"重新發送",waitSeconds:"等待 {seconds}秒",emailVerified:"郵箱已驗證",password:"密碼",username:"用戶名",nextStep:"下一步",skipNotVerify:"跳過,不驗證",logoutConfirm:"確定要登出嗎?",accountMergeNotice:"您的電子郵件下有多個不同 ID 的帳戶。",accountMergeSelectOne:"請選擇要將所有數據合併到其中的一個。",accountMergeConfirm:"所有數據將合併到 ID 為 {id} 的帳戶中。",dismiss:"忽略",merge:"合併",frontend:"前端",backend:"後端",loading:"加載中",loadFail:"加載失敗",editing:"修改中",editFail:"修改失敗",deleting:"刪除中",deleteFail:"刪除失敗",reqGot:"請求已接收",reqAborted:"請求超時或意外終止",updateMsg:"請更新 Artalk {name} 以獲得更好的體驗!",currentVersion:"當前版本",ignore:"忽略",open:"打開",openName:"打開{name}"},window[e]||(window[e]={}),window[e][o]=n,n}));
+2
View File
@@ -0,0 +1,2 @@
!function(){"use strict";try{if("undefined"!=typeof document){var t=document.createElement("style");t.appendChild(document.createTextNode('.atk-auth-plugin-dialog-wrap{display:flex;align-items:center;justify-content:center;flex-direction:column;height:100%}.atk-auth-plugin-dialog-wrap .atk-skip-btn{cursor:pointer;margin:10px auto 0;display:flex;justify-content:center;place-items:center;height:40px;padding:0 20px;font-size:16px;border-radius:50px;color:var(--at-color-font)}.atk-auth-plugin-dialog-wrap .atk-skip-btn:hover{opacity:.9}.atk-auth-plugin-dialog{overflow:hidden;transform:translateY(0) scale(1);animation:atkLoginDialogShowAnim .3s ease-in-out;will-change:transform,opacity,width,height;transition-property:transform,opacity,width,height;transition-duration:.2s;transition-timing-function:cubic-bezier(.25,.1,.25,1);display:flex;flex-direction:column;width:360px;max-height:500px;background:var(--at-color-bg);border-radius:18px}@keyframes atkLoginDialogShowAnim{0%{opacity:0;transform:translateY(15px) scale(1.02)}to{opacity:1;transform:translateY(0) scale(1)}}.atk-auth-plugin-dialog .atk-auth-dialog-title{display:flex;justify-content:space-between;place-items:center;padding:25px 25px 0;font-size:20px;font-weight:500;color:var(--at-color-font)}.atk-auth-plugin-dialog .atk-auth-dialog-title .atk-text{margin:0 10px}.atk-auth-plugin-dialog .atk-auth-dialog-title .atk-icon{display:flex;justify-content:center;place-items:center;height:40px;width:40px;border-radius:50%}.atk-auth-plugin-dialog .atk-auth-dialog-title .atk-icon:hover{background:var(--at-color-bg-grey)}.atk-auth-plugin-dialog .atk-view-wrap{max-height:calc(100% - 65px);overflow-y:auto}.atk-auth-plugin-dialog .atk-methods-page .atk-methods{padding:12px 20px 20px}.atk-auth-plugin-dialog .atk-methods-page .atk-methods .atk-method-item{display:flex;align-items:center;padding:15px 25px;cursor:pointer;border-radius:10px}.atk-auth-plugin-dialog .atk-methods-page .atk-methods .atk-method-item:hover{background:var(--at-color-bg-grey)}.atk-auth-plugin-dialog .atk-methods-page .atk-methods .atk-method-item .atk-method-icon{width:40px;height:40px;margin-right:20px;background-repeat:no-repeat;background-size:contain;background-position:center}.atk-auth-plugin-dialog .atk-methods-page .atk-methods .atk-method-item .atk-method-text{font-size:16px;font-weight:500;color:var(--at-color-font)}.atk-auth-plugin-dialog .atk-form{padding:20px 30px}.atk-auth-plugin-dialog .atk-form .atk-form-bottom{padding:5px 0;text-align:center;font-size:14px}.atk-auth-plugin-dialog .atk-form .atk-form-bottom .atk-link{cursor:pointer;text-align:center;color:var(--at-color-main)}.atk-auth-plugin-dialog .atk-form .atk-form-bottom .atk-link:hover{text-decoration:underline}.atk-auth-plugin-dialog .atk-form input{width:100%;padding:10px 20px;margin:5px 0;font-size:16px;border-radius:10px;border:1px solid var(--at-color-bg-grey);background:var(--at-color-bg-grey);color:var(--at-color-font);transition:border-color .2s;outline:none}.atk-auth-plugin-dialog .atk-form input:focus{border-color:var(--at-color-primary)}.atk-auth-plugin-dialog .atk-form button[type=submit]{width:100%;padding:10px 20px;margin:10px 0;font-size:16px;border-radius:10px;background:var(--at-color-main);color:var(--at-color-bg);cursor:pointer;transition:background .2s;border:0}.atk-auth-plugin-dialog .atk-form button[type=submit]:hover{opacity:.9}.atk-auth-plugin-dialog .atk-form .atk-input-grp{position:relative}.atk-auth-plugin-dialog .atk-form .atk-input-grp>input{padding-right:115px}.atk-auth-plugin-dialog .atk-form .atk-input-grp-btn{position:absolute;right:0;top:50%;transform:translateY(-50%);padding:10px 15px;font-size:14px;color:var(--at-color-main);cursor:pointer}.atk-auth-plugin-dialog[data-dialog-name=merge]{background-size:100% auto;background-position:center top;background-repeat:no-repeat;background-image:url(\'data:image/svg+xml,<svg fill="none" height="159" viewBox="0 0 390 159" width="390" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><linearGradient id="a" gradientUnits="userSpaceOnUse" x1="195" x2="195" y1="0" y2="159"><stop offset="0" stop-color="%23f6f0fe"/><stop offset=".835" stop-color="%23f6f0fe" stop-opacity="0"/></linearGradient><clipPath id="b"><path d="m0 0h390v159h-390z"/></clipPath><g clip-path="url(%23b)"><path d="m0 0h390v159h-390z" fill="url(%23a)"/><g clip-rule="evenodd" fill="%23743fc8" fill-rule="evenodd"><path d="m76.0859 23.5c-1.5187 0-2.75 1.2312-2.75 2.75s1.2313 2.75 2.75 2.75c1.5188 0 2.75-1.2312 2.75-2.75s-1.2312-2.75-2.75-2.75zm-5.5 2.75c0-3.0376 2.4625-5.5 5.5-5.5 3.0376 0 5.5 2.4624 5.5 5.5s-2.4624 5.5-5.5 5.5c-3.0375 0-5.5-2.4624-5.5-5.5z"/><path d="m76.0859 51c-1.5187 0-2.75 1.2312-2.75 2.75s1.2313 2.75 2.75 2.75c1.5188 0 2.75-1.2312 2.75-2.75s-1.2312-2.75-2.75-2.75zm-5.5 2.75c0-3.0376 2.4625-5.5 5.5-5.5 3.0376 0 5.5 2.4624 5.5 5.5s-2.4624 5.5-5.5 5.5c-3.0375 0-5.5-2.4624-5.5-5.5z"/><path d="m76.0859 29c.7594 0 1.375.6156 1.375 1.375v19.25c0 .7594-.Line truncated
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self)["artalk-plugin-auth"]=e["artalk-plugin-auth"]||{})}(this,(function(e){"use strict";var t,n=Object.defineProperty,r=Object.getOwnPropertySymbols,o=Object.prototype.hasOwnProperty,l=Object.prototype.propertyIsEnumerable,i=(e,t,r)=>t in e?n(e,t,{enumerable:!0,configurable:!0,writable:!0,value:r}):e[t]=r,s=(e,t)=>{var n={};for(var i in e)o.call(e,i)&&t.indexOf(i)<0&&(n[i]=e[i]);if(null!=e&&r)for(var i of r(e))t.indexOf(i)<0&&l.call(e,i)&&(n[i]=e[i]);return n},a=(e,t,n)=>new Promise(((r,o)=>{var l=e=>{try{s(n.next(e))}catch(t){o(t)}},i=e=>{try{s(n.throw(e))}catch(t){o(t)}},s=e=>e.done?r(e.value):Promise.resolve(e.value).then(l,i);s((n=n.apply(e,t)).next())}));const c=Symbol("solid-proxy"),u=Symbol("solid-track"),d={equals:(e,t)=>e===t};let f=N;const g=1,p=2,h={owned:null,cleanups:null,context:null,owner:null},v={};var m=null;let y=null,b=null,k=null,w=null,$=0;function A(e,t){const n=b,r=m,o=0===e.length,l=void 0===t?r:t,i=o?h:{owned:null,cleanups:null,context:l?l.context:null,owner:l},s=o?e:()=>e((()=>B((()=>K(i)))));m=i,b=null;try{return M(s,!0)}finally{b=n,m=r}}function x(e,t){const n={value:e,observers:null,observerSlots:null,comparator:(t=t?Object.assign({},d,t):d).equals||void 0};return[_.bind(n),e=>("function"==typeof e&&(e=e(n.value)),L(n,e))]}function C(e,t,n){D(I(e,t,!0,g))}function S(e,t,n){D(I(e,t,!1,g))}function O(e,t,n){f=H;const r=I(e,t,!1,g);r.user=!0,w?w.push(r):D(r)}function P(e,t,n){n=n?Object.assign({},d,n):d;const r=I(e,t,!0,0);return r.observers=null,r.observerSlots=null,r.comparator=n.equals||void 0,D(r),_.bind(r)}function j(e,t,n){let r,o,l;2===arguments.length&&"object"==typeof t||1===arguments.length?(r=!0,o=e,l={}):(r=e,o=t,l={});let i=null,s=v,a=!1,c="initialValue"in l,u="function"==typeof r&&P(r);const d=new Set,[f,g]=(l.storage||x)(l.initialValue),[p,h]=x(void 0),[m,y]=x(void 0,{equals:!1}),[k,w]=x(c?"ready":"unresolved");function $(e,t,n,r){return i===e&&(i=null,void 0!==r&&(c=!0),e!==s&&t!==s||!l.onHydrated||queueMicrotask((()=>l.onHydrated(r,{value:t}))),s=v,function(e,t){M((()=>{void 0===t&&g((()=>e)),w(void 0!==t?"errored":c?"ready":"unresolved"),h(t);for(const e of d.keys())e.decrement();d.clear()}),!1)}(t,n)),t}function A(){const e=undefined,t=f(),n=p();if(void 0!==n&&!i)throw n;return b&&!b.user&&e&&C((()=>{m(),i&&(e.resolved||d.has(e)||(e.increment(),d.add(e)))})),t}function S(e=!0){if(!1!==e&&a)return;a=!1;const t=u?u():r;if(null==t||!1===t)return void $(i,B(f));const n=s!==v?s:B((()=>o(t,{value:f(),refetching:e})));return(l=n)&&"object"==typeof l&&"then"in l?(i=n,"value"in n?("success"===n.status?$(i,n.value,void 0,t):$(i,void 0,void 0,t),n):(a=!0,queueMicrotask((()=>a=!1)),M((()=>{w(c?"refreshing":"pending"),y()}),!1),n.then((e=>$(n,e,void 0,t)),(e=>$(n,void 0,R(e),t))))):($(i,n,void 0,t),n);var l}return Object.defineProperties(A,{state:{get:()=>k()},error:{get:()=>p()},loading:{get(){const e=k();return"pending"===e||"refreshing"===e}},latest:{get(){if(!c)return A();const e=p();if(e&&!i)throw e;return f()}}}),u?C((()=>S(!1))):S(!1),[A,{refetch:S,mutate:g}]}function B(e){if(null===b)return e();const t=b;b=null;try{return e()}finally{b=t}}function T(e){return null===m||(null===m.cleanups?m.cleanups=[e]:m.cleanups.push(e)),e}function E(){return b}function _(){if(this.sources&&this.state)if(this.state===g)D(this);else{const e=k;k=null,M((()=>V(this)),!1),k=e}if(b){const e=this.observers?this.observers.length:0;b.sources?(b.sources.push(this),b.sourceSlots.push(e)):(b.sources=[this],b.sourceSlots=[e]),this.observers?(this.observers.push(b),this.observerSlots.push(b.sources.length-1)):(this.observers=[b],this.observerSlots=[b.sources.length-1])}return this.value}function L(e,t,n){let r=e.value;return e.comparator&&e.comparator(r,t)||(e.value=t,e.observers&&e.observers.length&&M((()=>{for(let t=0;t<e.observers.length;t+=1){const n=e.observers[t],r=y&&y.running;r&&y.disposed.has(n),(r?n.tState:n.state)||(n.pure?k.push(n):w.push(n),n.observers&&U(n)),r||(n.state=g)}if(k.length>1e6)throw k=[],new Error}),!1)),t}function D(e){if(!e.fn)return;K(e);const t=$;!function(e,t,n){let r;const o=m,l=b;b=m=e;try{r=e.fn(t)}catch(i){return e.pure&&(e.state=g,e.owned&&e.owned.forEach(K),e.owned=null),e.updatedAt=n+1,z(i)}finally{b=l,m=o}(!e.updatedAt||e.updatedAt<=n)&&(null!=e.updatedAt&&"observers"in e?L(e,r):e.value=r,e.updatedAt=n)}(e,e.value,t)}function I(e,t,n,r=g,o){const l={fn:e,state:r,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:m,context:m?m.context:null,pure:n};return null===m||m!==h&&(m.owned?m.owned.push(l):m.owned=[l]),l}function q(e){if(0===e.state)return;if(e.state===p)return V(e);if(e.suspense&&B(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt<$);)e.state&&t.push(e);for(let n=t.lengLine truncated
+487
View File
@@ -0,0 +1,487 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UAPI管理系统</title>
<style>
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff; --green: #3fb950; --red: #f85149; --yellow: #d2991d; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; padding: 40px 20px; }
.container { max-width: 960px; margin: 0 auto; }
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; flex-wrap: wrap; gap: 16px; }
header h1 { font-size: 24px; color: #fff; }
header .actions { display: flex; gap: 10px; flex-wrap: wrap; }
.btn { padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; border: none; display: inline-flex; align-items: center; gap: 6px; }
.btn-primary { background: #238636; color: #fff; }
.btn-primary:hover { background: #2ea043; }
.btn-secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }
.btn-secondary:hover { background: #1c2333; }
.btn-danger { background: var(--card); color: var(--red); border: 1px solid var(--red); }
.btn-danger:hover { background: #49020233; }
.stats { display: flex; gap: 12px; margin-bottom: 24px; flex-wrap: wrap; }
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 20px; }
.stat .num { font-size: 20px; font-weight: 700; color: #fff; }
.stat .label { font-size: 11px; color: var(--muted); }
.stat.alive .num { color: var(--green); }
.stat.dead .num { color: var(--red); }
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
th, td { padding: 12px 16px; text-align: left; font-size: 13px; }
th { background: #1c2333; color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; }
td { border-top: 1px solid var(--border); }
td.url { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; color: var(--accent); word-break: break-all; }
td.title { color: #fff; font-weight: 500; display: flex; align-items: center; }
td.actions { white-space: nowrap; }
td.actions button { padding: 4px 10px; font-size: 12px; margin-right: 6px; }
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 8px; flex-shrink: 0; }
.status-dot.alive { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status-dot.dead { background: var(--red); }
.status-dot.checking { background: var(--yellow); animation: pulse 0.8s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
.latency { font-size: 11px; color: var(--muted); margin-left: 6px; }
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); z-index: 100; align-items: center; justify-content: center; }
.modal-overlay.active { display: flex; }
.modal { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 32px; width: 100%; max-width: 480px; }
.modal h2 { font-size: 18px; color: #fff; margin-bottom: 20px; }
.modal label { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; margin-top: 12px; }
.modal input, .modal select { width: 100%; padding: 9px 12px; background: #0d1117; border: 1px solid var(--border); border-radius: 6px; color: #fff; font-size: 14px; outline: none; }
.modal input:focus { border-color: var(--accent); }
.modal .modal-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 24px; }
.toast { position: fixed; bottom: 24px; right: 24px; background: #238636; color: #fff; padding: 10px 20px; border-radius: 8px; font-size: 13px; z-index: 200; display: none; animation: fadeIn 0.2s ease; }
.toast.error { background: #da3633; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.empty { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty h3 { font-size: 16px; color: #fff; margin-bottom: 8px; }
.tab { display: inline-flex; gap: 2px; background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 4px; margin-bottom: 24px; }
.tab button { padding: 6px 16px; border: none; background: none; color: var(--muted); border-radius: 6px; cursor: pointer; font-size: 13px; }
.tab button.active { background: #1f6feb; color: #fff; }
.feed-url-bar { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 10px 16px; margin-bottom: 20px; display: flex; align-items: center; gap: 10px; font-size: 13px; }
.feed-url-bar code { font-family: 'SF Mono', 'Fira Code', monospace; color: var(--accent); font-size: 12px; }
.link-avatar { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; background: var(--border); }
.link-name { color: #fff; font-weight: 500; display: flex; align-items: center; gap: 10px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>UAPI管理系统</h1>
<div class="actions">
<a href="/" class="btn btn-secondary" target="_blank">API 文档</a>
<button class="btn btn-secondary" onclick="checkHealth()" id="healthBtn">检测存活</button>
<button class="btn btn-primary" onclick="openAddModal()">+ 添加订阅源</button>
</div>
</header>
<div class="feed-url-bar">
<span style="color:var(--muted)">订阅地址</span>
<code id="feedUrlText">https://api.200181.xyz/api/feeds</code>
<button class="btn btn-secondary" style="padding:4px 10px;font-size:11px" onclick="copyFeedUrl()">复制</button>
</div>
<div class="stats">
<div class="stat"><div class="num" id="feedCount">0</div><div class="label">订阅源</div></div>
<div class="stat"><div class="num" id="linkCount">0</div><div class="label">友链</div></div>
<div class="stat alive" id="aliveStat" style="display:none"><div class="num" id="aliveCount">0</div><div class="label">存活</div></div>
<div class="stat dead" id="deadStat" style="display:none"><div class="num" id="deadCount">0</div><div class="label">异常</div></div>
</div>
<div class="tab">
<button class="active" onclick="showTab('feeds')">订阅源</button>
<button onclick="showTab('links')">友链</button>
<button onclick="showTab('password')">访问口令</button>
</div>
<div id="tab-feeds">
<table><thead><tr><th style="width:220px">博客名称</th><th>URL</th><th style="width:100px">操作</th></tr></thead><tbody id="feedTableBody"></tbody></table>
<div class="empty" id="emptyMsg" style="display:none"><h3>暂无订阅源</h3><p>点击上方按钮添加第一个</p></div>
</div>
<div id="tab-links" style="display:none">
<div style="margin-bottom:16px">
<button class="btn btn-primary" onclick="openLinkModal()">+ 添加友链</button>
</div>
<table><thead><tr><th style="width:50px"></th><th style="width:180px">站点名称</th><th>URL</th><th style="width:200px">描述</th><th style="width:100px">操作</th></tr></thead><tbody id="linkTableBody"></tbody></table>
<div class="empty" id="linkEmpty" style="display:none"><h3>暂无友链</h3><p>点击上方按钮添加</p></div>
</div>
<div id="tab-password" style="display:none">
<div class="modal" style="max-width:400px; margin:0 auto; position:static; border-radius:8px;">
<h2>修改访问口令</h2>
<label>当前口令</label>
<input type="password" id="oldPwd" placeholder="当前口令">
<label>新口令(留空则清除保护)</label>
<input type="password" id="newPwd" placeholder="新口令">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="document.getElementById('oldPwd').value='';document.getElementById('newPwd').value='';">取消</button>
<button class="btn btn-primary" onclick="changePassword()">保存</button>
</div>
</div>
</div>
</div>
<!-- 添加/编辑弹窗 -->
<div class="modal-overlay" id="feedModal">
<div class="modal">
<h2 id="modalTitle">添加订阅源</h2>
<label>博客 URL</label>
<input type="text" id="feedUrl" placeholder="https://example.com">
<label>博客名称</label>
<input type="text" id="feedTitle" placeholder="示例博客">
<label>抓取格式</label>
<select id="feedFormat">
<option value="">自动检测 (auto)</option>
<option value="xml">RSS/XML</option>
<option value="json">JSON Feed</option>
</select>
<label>JSON 数据路径(format=json 时填写)</label>
<input type="text" id="feedPath" placeholder="例如 data.list">
<label>强制使用代理</label>
<select id="feedProxy">
<option value="">否</option>
<option value="true">是</option>
</select>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeModal()">取消</button>
<button class="btn btn-primary" id="saveBtn" onclick="saveFeed()">保存</button>
<button class="btn btn-danger" id="deleteBtn" style="display:none" onclick="deleteFeed()">删除</button>
</div>
</div>
</div>
<!-- 友链弹窗 -->
<div class="modal-overlay" id="linkModal">
<div class="modal">
<h2 id="linkModalTitle">添加友链</h2>
<label>站点名称</label>
<input type="text" id="linkName" placeholder="示例博客">
<label>站点 URL</label>
<input type="text" id="linkUrl" placeholder="https://example.com">
<label>头像图片 URL(留空自动获取 favicon)</label>
<input type="text" id="linkImage" placeholder="自动获取">
<label>描述</label>
<input type="text" id="linkDesc" placeholder="一个很棒的博客">
<label>RSS 订阅地址(选填)</label>
<input type="text" id="linkRss" placeholder="https://example.com/feed">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;margin-top:12px">
<input type="checkbox" id="linkHidden" style="width:auto;accent-color:var(--red)"> 隐藏(不在公开接口展示)
</label>
<div class="modal-actions">
<button class="btn btn-secondary" onclick="closeLinkModal()">取消</button>
<button class="btn btn-primary" id="linkSaveBtn" onclick="saveLink()">保存</button>
<button class="btn btn-danger" id="linkDeleteBtn" style="display:none" onclick="deleteLink()">删除</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
let token = '';
const cMatch = document.cookie.match(/site_token=([^;]+)/);
if (cMatch) token = cMatch[1];
if (!token) { location.href = '/feed'; }
let feeds = [];
let editingUrl = null;
function showToast(msg, isError) {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = 'toast' + (isError ? ' error' : '');
t.style.display = 'block';
setTimeout(() => t.style.display = 'none', 2500);
}
function copyFeedUrl() {
navigator.clipboard.writeText('https://api.200181.xyz/api/feeds').then(() => showToast('已复制订阅地址'));
}
function showTab(name) {
document.querySelectorAll('.tab button').forEach((b, i) => b.classList.toggle('active', (i === 0 && name === 'feeds') || (i === 1 && name === 'links') || (i === 2 && name === 'password')));
document.getElementById('tab-feeds').style.display = name === 'feeds' ? '' : 'none';
document.getElementById('tab-links').style.display = name === 'links' ? '' : 'none';
document.getElementById('tab-password').style.display = name === 'password' ? '' : 'none';
}
function renderTable(healthMap) {
const tbody = document.getElementById('feedTableBody');
if (feeds.length === 0) {
tbody.innerHTML = '';
document.getElementById('emptyMsg').style.display = '';
return;
}
document.getElementById('emptyMsg').style.display = 'none';
tbody.innerHTML = feeds.map((f, i) => {
const h = healthMap ? healthMap[f.url] : null;
let dot = '';
let latency = '';
if (h) {
if (h.ok) {
dot = '<span class="status-dot alive" title="HTTP ' + h.status + '"></span>';
latency = '<span class="latency">' + h.latency + 'ms</span>';
} else {
dot = '<span class="status-dot dead" title="' + (h.error || 'HTTP ' + h.status) + '"></span>';
}
}
return '<tr>' +
'<td class="title">' + dot + (f.feedTitle || '-') + latency + '</td>' +
'<td class="url">' + f.url + '</td>' +
'<td class="actions"><button class="btn btn-secondary" onclick="openEditModal(' + i + ')">编辑</button></td>' +
'</tr>';
}).join('');
}
async function loadFeeds() {
try {
const res = await fetch('/api/feeds?token=' + encodeURIComponent(token));
if (res.status === 401) { location.href = '/feed'; return; }
const data = await res.json();
feeds = data.feeds || [];
document.getElementById('feedCount').textContent = feeds.length;
renderTable(null);
} catch (err) {
showToast('加载失败: ' + err.message, true);
}
}
async function checkHealth() {
const btn = document.getElementById('healthBtn');
if (feeds.length === 0) { showToast('暂无订阅源', true); return; }
btn.textContent = '检测中 0/' + feeds.length;
btn.disabled = true;
const healthMap = {};
let done = 0;
// 每批 5 个并发,逐批更新 UI
for (let i = 0; i < feeds.length; i += 5) {
const batch = feeds.slice(i, i + 5);
await Promise.all(batch.map(async (f) => {
try {
const res = await fetch('/api/health?url=' + encodeURIComponent(f.url));
healthMap[f.url] = await res.json();
} catch {
healthMap[f.url] = { ok: false, error: 'network error' };
}
done++;
btn.textContent = '检测中 ' + done + '/' + feeds.length;
}));
renderTable(healthMap);
}
const alive = Object.values(healthMap).filter(r => r.ok).length;
const dead = Object.values(healthMap).filter(r => !r.ok).length;
document.getElementById('aliveStat').style.display = '';
document.getElementById('deadStat').style.display = '';
document.getElementById('aliveCount').textContent = alive;
document.getElementById('deadCount').textContent = dead;
btn.textContent = '检测存活';
btn.disabled = false;
showToast('存活 ' + alive + ' / 异常 ' + dead);
}
function openAddModal() {
editingUrl = null;
document.getElementById('modalTitle').textContent = '添加订阅源';
document.getElementById('feedUrl').value = '';
document.getElementById('feedTitle').value = '';
document.getElementById('feedFormat').value = '';
document.getElementById('feedPath').value = '';
document.getElementById('feedProxy').value = '';
document.getElementById('deleteBtn').style.display = 'none';
document.getElementById('feedModal').classList.add('active');
}
function openEditModal(idx) {
const f = feeds[idx];
editingUrl = f.url;
document.getElementById('modalTitle').textContent = '编辑订阅源';
document.getElementById('feedUrl').value = f.url || '';
document.getElementById('feedTitle').value = f.feedTitle || '';
document.getElementById('feedFormat').value = f.format || '';
document.getElementById('feedPath').value = f.path || '';
document.getElementById('feedProxy').value = f.proxy ? 'true' : '';
document.getElementById('deleteBtn').style.display = '';
document.getElementById('feedModal').classList.add('active');
}
function closeModal() {
document.getElementById('feedModal').classList.remove('active');
}
async function saveFeed() {
const url = document.getElementById('feedUrl').value.trim();
const feedTitle = document.getElementById('feedTitle').value.trim();
if (!url) { showToast('URL 不能为空', true); return; }
const body = { url };
if (feedTitle) body.feedTitle = feedTitle;
const format = document.getElementById('feedFormat').value;
if (format) body.format = format;
const path = document.getElementById('feedPath').value.trim();
if (path) body.path = path;
const proxy = document.getElementById('feedProxy').value;
if (proxy) body.proxy = true;
if (editingUrl) body.oldUrl = editingUrl;
try {
const res = await fetch('/api/feeds?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('HTTP ' + res.status);
closeModal();
showToast(editingUrl ? '已更新' : '已添加');
loadFeeds();
} catch (err) {
showToast('保存失败: ' + err.message, true);
}
}
async function deleteFeed() {
if (!editingUrl || !confirm('确定删除 ' + editingUrl + ' ?')) return;
try {
const res = await fetch('/api/feeds?token=' + encodeURIComponent(token) + '&url=' + encodeURIComponent(editingUrl), { method: 'DELETE' });
if (!res.ok) throw new Error('HTTP ' + res.status);
closeModal();
showToast('已删除');
loadFeeds();
} catch (err) {
showToast('删除失败: ' + err.message, true);
}
}
// ─── 友链管理 ───
let links = [];
let editingLinkUrl = null;
function renderLinkTable() {
const tbody = document.getElementById('linkTableBody');
if (links.length === 0) {
tbody.innerHTML = '';
document.getElementById('linkEmpty').style.display = '';
return;
}
document.getElementById('linkEmpty').style.display = 'none';
tbody.innerHTML = links.map((l, i) => {
var img = l.image || l.avatar || ('https://api.200181.xyz/api/favicon?url=' + encodeURIComponent(l.url));
var hiddenBadge = l.hidden ? ' <span style="font-size:10px;color:var(--red);border:1px solid var(--red);border-radius:4px;padding:1px 5px;margin-left:6px">隐藏</span>' : '';
return '<tr>' +
'<td><img src="' + img + '" class="link-avatar" onerror="this.style.opacity=0"></td>' +
'<td class="link-name">' + (l.name || '-') + hiddenBadge + '</td>' +
'<td class="url">' + l.url + '</td>' +
'<td style="font-size:12px;color:var(--muted)">' + (l.description || '') + '</td>' +
'<td class="actions"><button class="btn btn-secondary" onclick="openEditLink(' + i + ')">编辑</button></td>' +
'</tr>';
}).join('');
}
async function loadLinks() {
try {
const res = await fetch('/api/links?all=1');
const data = await res.json();
links = data.links || [];
document.getElementById('linkCount').textContent = links.length;
renderLinkTable();
} catch (err) {
showToast('加载友链失败: ' + err.message, true);
}
}
function openLinkModal() {
editingLinkUrl = null;
document.getElementById('linkModalTitle').textContent = '添加友链';
document.getElementById('linkName').value = '';
document.getElementById('linkUrl').value = '';
document.getElementById('linkImage').value = '';
document.getElementById('linkDesc').value = '';
document.getElementById('linkRss').value = '';
document.getElementById('linkHidden').checked = false;
document.getElementById('linkDeleteBtn').style.display = 'none';
document.getElementById('linkModal').classList.add('active');
}
function openEditLink(idx) {
const l = links[idx];
editingLinkUrl = l.url;
document.getElementById('linkModalTitle').textContent = '编辑友链';
document.getElementById('linkName').value = l.name || '';
document.getElementById('linkUrl').value = l.url || '';
document.getElementById('linkImage').value = l.image || '';
document.getElementById('linkDesc').value = l.description || '';
document.getElementById('linkRss').value = l.rss || '';
document.getElementById('linkHidden').checked = !!l.hidden;
document.getElementById('linkDeleteBtn').style.display = '';
document.getElementById('linkModal').classList.add('active');
}
function closeLinkModal() {
document.getElementById('linkModal').classList.remove('active');
}
async function saveLink() {
const name = document.getElementById('linkName').value.trim();
const url = document.getElementById('linkUrl').value.trim();
if (!name && !url) { showToast('名称和 URL 至少填一个', true); return; }
if (!url) { showToast('URL 不能为空', true); return; }
const body = { name: name || url, url: url };
const image = document.getElementById('linkImage').value.trim();
if (image) body.image = image;
const desc = document.getElementById('linkDesc').value.trim();
if (desc) body.description = desc;
const rss = document.getElementById('linkRss').value.trim();
if (rss) body.rss = rss;
body.hidden = document.getElementById('linkHidden').checked;
if (editingLinkUrl) body.oldUrl = editingLinkUrl;
try {
const res = await fetch('/api/links?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error('HTTP ' + res.status);
closeLinkModal();
showToast(editingLinkUrl ? '已更新' : '已添加');
loadLinks();
} catch (err) {
showToast('保存失败: ' + err.message, true);
}
}
async function deleteLink() {
if (!editingLinkUrl || !confirm('确定删除 ' + editingLinkUrl + ' ?')) return;
try {
const res = await fetch('/api/links?token=' + encodeURIComponent(token) + '&url=' + encodeURIComponent(editingLinkUrl), { method: 'DELETE' });
if (!res.ok) throw new Error('HTTP ' + res.status);
closeLinkModal();
showToast('已删除');
loadLinks();
} catch (err) {
showToast('删除失败: ' + err.message, true);
}
}
async function changePassword() {
const oldPwd = document.getElementById('oldPwd').value;
const newPwd = document.getElementById('newPwd').value;
try {
const res = await fetch('/api/auth?token=' + encodeURIComponent(token), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ oldPassword: oldPwd, newPassword: newPwd })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'HTTP ' + res.status);
if (newPwd) {
token = newPwd;
document.cookie = 'site_token=' + newPwd + ';path=/;max-age=2592000;SameSite=Lax';
}
document.getElementById('oldPwd').value = '';
document.getElementById('newPwd').value = '';
showToast(newPwd ? '口令已更新' : '口令保护已清除');
} catch (err) {
showToast('修改失败: ' + err.message, true);
}
}
loadFeeds();
loadLinks();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>UAPI</title>
<style>
:root { --bg: #0a0f0f; --card: #0f1a1a; --border: #1a3a2a; --text: #c9d1d9; --muted: #5a7a5a; --accent: #3fb950; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'JetBrains Mono','Fira Code','SF Mono',Consolas,monospace; min-height:100vh; display:flex; align-items:center; justify-content:center; }
.box { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:36px; width:100%; max-width:360px; text-align:center; }
.box h1 { font-size:18px; color:var(--accent); font-weight:400; margin-bottom:6px; }
.box p { color:var(--muted); font-size:12px; margin-bottom:20px; }
.box input { width:100%; padding:10px 12px; background:#050a0a; border:1px solid var(--border); border-radius:4px; color:#fff; font-size:14px; font-family:inherit; outline:none; }
.box input:focus { border-color:var(--accent); }
.box button { width:100%; margin-top:12px; padding:10px; background:var(--accent); color:#000; border:none; border-radius:4px; font-size:14px; font-family:inherit; cursor:pointer; font-weight:600; }
.box button:hover { background:#2ea043; color:#fff; }
.err { color:var(--red); font-size:12px; margin-top:10px; display:none; }
</style>
</head>
<body>
<div class="box">
<h1>[UAPI] 身份验证</h1>
<p>请输入访问口令</p>
<input type="password" id="pwd" placeholder="口令" autofocus>
<button onclick="login()">进入系统</button>
<div class="err" id="err">口令错误</div>
</div>
<script>
async function login(){const t=document.getElementById('pwd').value;if(!t)return;try{const r=await fetch('/api/auth?token='+encodeURIComponent(t));if(r.ok){document.cookie='site_token='+t+';path=/;max-age=2592000;SameSite=Lax';location.href='/?token='+encodeURIComponent(t)}else{document.getElementById('err').style.display='block'}}catch{document.getElementById('err').style.display='block'}}
document.getElementById('pwd').addEventListener('keydown',e=>{if(e.key==='Enter')login()});
</script>
</body>
</html>
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>评论后端 · 优世界</title>
<meta name="robots" content="noindex,nofollow">
<script>
try { if (localStorage.getItem('atk-theme') === 'dark') document.documentElement.dataset.theme = 'dark'; } catch (e) {}
</script>
<link rel="stylesheet" href="./admin/admin.css">
</head>
<body>
<div class="gate">
<div class="login-card">
<h1>评论后端</h1>
<p class="sub">优世界 · Cloudflare Workers + D1</p>
<dl class="kv" style="margin:0 0 22px">
<dt>接口</dt><dd class="mono">/api/v2/*</dd>
<dt>状态</dt><dd class="ok" id="st">检查中…</dd>
</dl>
<a class="btn primary block" href="./admin/" style="text-decoration:none">进入管理面板</a>
<a class="btn block" href="./sidebar/" style="text-decoration:none;margin-top:8px">官方后台(Artalk Sidebar)</a>
<p class="hint">博客端的 <code>server</code> 指向本域名即可</p>
</div>
</div>
<script>
(function () {
var el = document.getElementById('st');
fetch('./api/v2/healthz')
.then(function (r) { return r.json(); })
.then(function (d) {
el.textContent = '正常 · ' + Number(d.comments || 0).toLocaleString('zh-CN') +
' 条评论 · ' + d.latency_ms + 'ms';
})
.catch(function () { el.className = 'bad'; el.textContent = '不可达'; });
})();
</script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 · UAPI管理系统</title>
<style>
:root { --bg: #0d1117; --card: #161b22; --border: #30363d; --text: #c9d1d9; --muted: #8b949e; --accent: #58a6ff; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }
.login-box { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 40px; width: 100%; max-width: 380px; text-align: center; }
.login-box h1 { font-size: 22px; margin-bottom: 8px; color: #fff; }
.login-box p { color: var(--muted); font-size: 14px; margin-bottom: 24px; }
.login-box input { width: 100%; padding: 10px 14px; background: #0d1117; border: 1px solid var(--border); border-radius: 8px; color: #fff; font-size: 15px; outline: none; }
.login-box input:focus { border-color: var(--accent); }
.login-box button { width: 100%; margin-top: 14px; padding: 10px; background: #238636; color: #fff; border: none; border-radius: 8px; font-size: 15px; cursor: pointer; font-weight: 600; }
.login-box button:hover { background: #2ea043; }
.error { color: #f85149; font-size: 13px; margin-top: 10px; display: none; }
</style>
</head>
<body>
<div class="login-box">
<h1>UAPI管理系统</h1>
<p>请输入访问口令</p>
<input type="password" id="pwd" placeholder="口令" autofocus>
<button onclick="login()">进入</button>
<div class="error" id="err">口令错误</div>
</div>
<script>
async function login() {
const pwd = document.getElementById('pwd').value;
if (!pwd) return;
try {
const res = await fetch('/api/auth?token=' + encodeURIComponent(pwd));
if (res.ok) {
document.cookie = 'site_token=' + pwd + ';path=/;max-age=2592000;SameSite=Lax';
location.href = '/feed?token=' + encodeURIComponent(pwd);
} else {
document.getElementById('err').style.display = 'block';
}
} catch {
document.getElementById('err').style.display = 'block';
}
}
document.getElementById('pwd').addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); });
</script>
</body>
</html>
@@ -0,0 +1,61 @@
var e=Object.defineProperty,t=Object.defineProperties,n=Object.getOwnPropertyDescriptors,s=Object.getOwnPropertySymbols,r=Object.prototype.hasOwnProperty,i=Object.prototype.propertyIsEnumerable,o=(e,t)=>(t=Symbol[e])?t:Symbol.for("Symbol."+e),a=(t,n,s)=>n in t?e(t,n,{enumerable:!0,configurable:!0,writable:!0,value:s}):t[n]=s,l=(e,t)=>{for(var n in t||(t={}))r.call(t,n)&&a(e,n,t[n]);if(s)for(var n of s(t))i.call(t,n)&&a(e,n,t[n]);return e},c=(e,s)=>t(e,n(s)),u=(e,t,n)=>(a(e,"symbol"!=typeof t?t+"":t,n),n),d=(e,t,n)=>new Promise(((s,r)=>{var i=e=>{try{a(n.next(e))}catch(t){r(t)}},o=e=>{try{a(n.throw(e))}catch(t){r(t)}},a=e=>e.done?s(e.value):Promise.resolve(e.value).then(i,o);a((n=n.apply(e,t)).next())})),p=function(e,t){this[0]=e,this[1]=t},h=e=>{var t,n=e[o("asyncIterator")],s=!1,r={};return null==n?(n=e[o("iterator")](),t=e=>r[e]=t=>n[e](t)):(n=n.call(e),t=e=>r[e]=t=>{if(s){if(s=!1,"throw"===e)throw t;return t}return s=!0,{done:!1,value:new p(new Promise((s=>{var r=n[e](t);if(!(r instanceof Object))throw TypeError("Object expected");s(r)})),1)}}),r[o("iterator")]=()=>r,t("next"),"throw"in n?t("throw"):r.throw=e=>{throw e},"return"in n&&t("return"),r};
/**
* @vue/shared v3.4.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
/*! #__NO_SIDE_EFFECTS__ */
function f(e,t){const n=new Set(e.split(","));return e=>n.has(e)}!function(){const e=document.createElement("link").relList;if(!(e&&e.supports&&e.supports("modulepreload"))){for(const e of document.querySelectorAll('link[rel="modulepreload"]'))t(e);new MutationObserver((e=>{for(const n of e)if("childList"===n.type)for(const e of n.addedNodes)"LINK"===e.tagName&&"modulepreload"===e.rel&&t(e)})).observe(document,{childList:!0,subtree:!0})}function t(e){if(e.ep)return;e.ep=!0;const t=function(e){const t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),"use-credentials"===e.crossOrigin?t.credentials="include":"anonymous"===e.crossOrigin?t.credentials="omit":t.credentials="same-origin",t}(e);fetch(e.href,t)}}();const m={},g=[],v=()=>{},y=()=>!1,k=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),b=e=>e.startsWith("onUpdate:"),w=Object.assign,_=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},E=Object.prototype.hasOwnProperty,x=(e,t)=>E.call(e,t),S=Array.isArray,C=e=>"[object Map]"===R(e),A=e=>"[object Set]"===R(e),$=e=>"[object Date]"===R(e),T=e=>"function"==typeof e,L=e=>"string"==typeof e,O=e=>"symbol"==typeof e,N=e=>null!==e&&"object"==typeof e,I=e=>(N(e)||T(e))&&T(e.then)&&T(e.catch),P=Object.prototype.toString,R=e=>P.call(e),M=e=>R(e).slice(8,-1),D=e=>"[object Object]"===R(e),U=e=>L(e)&&"NaN"!==e&&"-"!==e[0]&&""+parseInt(e,10)===e,F=f(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),j=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},B=/-(\w)/g,q=j((e=>e.replace(B,((e,t)=>t?t.toUpperCase():"")))),W=/\B([A-Z])/g,H=j((e=>e.replace(W,"-$1").toLowerCase())),V=j((e=>e.charAt(0).toUpperCase()+e.slice(1))),K=j((e=>e?`on${V(e)}`:"")),z=(e,t)=>!Object.is(e,t),G=(e,t)=>{for(let n=0;n<e.length;n++)e[n](t)},Y=(e,t,n,s=!1)=>{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},Q=e=>{const t=parseFloat(e);return isNaN(t)?e:t},X=e=>{const t=L(e)?Number(e):NaN;return isNaN(t)?e:t};let J;const Z=()=>J||(J="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{});function ee(e){if(S(e)){const t={};for(let n=0;n<e.length;n++){const s=e[n],r=L(s)?re(s):ee(s);if(r)for(const e in r)t[e]=r[e]}return t}if(L(e)||N(e))return e}const te=/;(?![^(]*\))/g,ne=/:([^]+)/,se=/\/\*[^]*?\*\//g;function re(e){const t={};return e.replace(se,"").split(te).forEach((e=>{if(e){const n=e.split(ne);n.length>1&&(t[n[0].trim()]=n[1].trim())}})),t}function ie(e){let t="";if(L(e))t=e;else if(S(e))for(let n=0;n<e.length;n++){const s=ie(e[n]);s&&(t+=s+" ")}else if(N(e))for(const n in e)e[n]&&(t+=n+" ");return t.trim()}const oe=f("itemscope,allowfullscreen,formnovalidate,ismap,nomodule,novalidate,readonly");function ae(e){return!!e||""===e}function le(e,t){if(e===t)return!0;let n=$(e),s=$(t);if(n||s)return!(!n||!s)&&e.getTime()===t.getTime();if(n=O(e),s=O(t),n||s)return e===t;if(n=S(e),s=S(t),n||s)return!(!n||!s)&&function(e,t){if(e.length!==t.length)return!1;let n=!0;for(let s=0;n&&s<e.length;s++)n=le(e[s],t[s]);return n}(e,t);if(n=N(e),s=N(t),n||s){if(!n||!s)return!1;if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e){const s=e.hasOwnProperty(n),r=t.hasOwnProperty(n);if(s&&!r||!s&&r||!le(e[n],t[n]))return!1}}return String(e)===String(t)}function ce(e,t){return e.findIndex((e=>le(e,t)))}const ue=e=>L(e)?e:null==e?"":S(e)||N(e)&&(e.toString===P||!T(e.toString))?JSON.stringify(e,de,2):String(e),de=(e,t)=>t&&t.__v_isRef?de(e,t.value):C(t)?{[`Map(${t.size})`]:[...t.entries()].reduce(((e,[t,n],s)=>(e[pe(t,s)+" =>"]=n,e)),{})}:A(t)?{[`Set(${t.size})`]:[...t.values()].map((e=>pe(e)))}:O(t)?pe(t):!N(t)||S(t)||D(t)?t:String(t),pe=(e,t="")=>{var n;return O(e)?`Symbol(${null!=(n=e.description)?n:t})`:e};
/**
* @vue/reactivity v3.4.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/
let he,fe;class me{constructor(e=!1){this.detached=e,this._active=!0,this.effects=[],this.cleanups=[],this.parent=he,!e&&he&&(this.index=(he.scopes||(he.scopes=[])).push(this)-1)}get active(){return this._active}run(e){if(this._active){const t=he;try{return he=this,e()}finally{he=t}}}on(){he=this}off(){he=this.parent}stop(e){if(this._active){let t,n;for(t=0,n=this.effects.length;t<n;t++)this.effects[t].stop();for(t=0,n=this.cleanups.length;t<n;t++)this.cleanups[t]();if(this.scopes)for(t=0,n=this.scopes.length;t<n;t++)this.scopes[t].stop(!0);if(!this.detached&&this.parent&&!e){const e=this.parent.scopes.pop();e&&e!==this&&(this.parent.scopes[this.index]=e,e.index=this.index)}this.parent=void 0,this._active=!1}}}function ge(e){return new me(e)}function ve(){return he}class ye{constructor(e,t,n,s){this.fn=e,this.trigger=t,this.scheduler=n,this.active=!0,this.deps=[],this._dirtyLevel=4,this._trackId=0,this._runnings=0,this._shouldSchedule=!1,this._depsLength=0,function(e,t=he){t&&t.active&&t.effects.push(e)}(this,s)}get dirty(){if(2===this._dirtyLevel||3===this._dirtyLevel){this._dirtyLevel=1,Se();for(let e=0;e<this._depsLength;e++){const t=this.deps[e];if(t.computed&&(t.computed.value,this._dirtyLevel>=4))break}1===this._dirtyLevel&&(this._dirtyLevel=0),Ce()}return this._dirtyLevel>=4}set dirty(e){this._dirtyLevel=e?4:0}run(){if(this._dirtyLevel=0,!this.active)return this.fn();let e=_e,t=fe;try{return _e=!0,fe=this,this._runnings++,ke(this),this.fn()}finally{be(this),this._runnings--,fe=t,_e=e}}stop(){this.active&&(ke(this),be(this),this.onStop&&this.onStop(),this.active=!1)}}function ke(e){e._trackId++,e._depsLength=0}function be(e){if(e.deps.length>e._depsLength){for(let t=e._depsLength;t<e.deps.length;t++)we(e.deps[t],e);e.deps.length=e._depsLength}}function we(e,t){const n=e.get(t);void 0!==n&&t._trackId!==n&&(e.delete(t),0===e.size&&e.cleanup())}let _e=!0,Ee=0;const xe=[];function Se(){xe.push(_e),_e=!1}function Ce(){const e=xe.pop();_e=void 0===e||e}function Ae(){Ee++}function $e(){for(Ee--;!Ee&&Le.length;)Le.shift()()}function Te(e,t,n){if(t.get(e)!==e._trackId){t.set(e,e._trackId);const n=e.deps[e._depsLength];n!==t?(n&&we(n,e),e.deps[e._depsLength++]=t):e._depsLength++}}const Le=[];function Oe(e,t,n){Ae();for(const s of e.keys()){let n;s._dirtyLevel<t&&(null!=n?n:n=e.get(s)===s._trackId)&&(s._shouldSchedule||(s._shouldSchedule=0===s._dirtyLevel),s._dirtyLevel=t),s._shouldSchedule&&(null!=n?n:n=e.get(s)===s._trackId)&&(s.trigger(),s._runnings&&!s.allowRecurse||2===s._dirtyLevel||(s._shouldSchedule=!1,s.scheduler&&Le.push(s.scheduler)))}$e()}const Ne=(e,t)=>{const n=new Map;return n.cleanup=e,n.computed=t,n},Ie=new WeakMap,Pe=Symbol(""),Re=Symbol("");function Me(e,t,n){if(_e&&fe){let t=Ie.get(e);t||Ie.set(e,t=new Map);let s=t.get(n);s||t.set(n,s=Ne((()=>t.delete(n)))),Te(fe,s)}}function De(e,t,n,s,r,i){const o=Ie.get(e);if(!o)return;let a=[];if("clear"===t)a=[...o.values()];else if("length"===n&&S(e)){const e=Number(s);o.forEach(((t,n)=>{("length"===n||!O(n)&&n>=e)&&a.push(t)}))}else switch(void 0!==n&&a.push(o.get(n)),t){case"add":S(e)?U(n)&&a.push(o.get("length")):(a.push(o.get(Pe)),C(e)&&a.push(o.get(Re)));break;case"delete":S(e)||(a.push(o.get(Pe)),C(e)&&a.push(o.get(Re)));break;case"set":C(e)&&a.push(o.get(Pe))}Ae();for(const l of a)l&&Oe(l,4);$e()}const Ue=f("__proto__,__v_isRef,__isVue"),Fe=new Set(Object.getOwnPropertyNames(Symbol).filter((e=>"arguments"!==e&&"caller"!==e)).map((e=>Symbol[e])).filter(O)),je=Be();function Be(){const e={};return["includes","indexOf","lastIndexOf"].forEach((t=>{e[t]=function(...e){const n=$t(this);for(let t=0,r=this.length;t<r;t++)Me(n,0,t+"");const s=n[t](...e);return-1===s||!1===s?n[t](...e.map($t)):s}})),["push","pop","shift","unshift","splice"].forEach((t=>{e[t]=function(...e){Se(),Ae();const n=$t(this)[t].apply(this,e);return $e(),Ce(),n}})),e}function qe(e){O(e)||(e=String(e));const t=$t(this);return Me(t,0,e),t.hasOwnProperty(e)}class We{constructor(e=!1,t=!1){this._isReadonly=e,this._isShallow=t}get(e,t,n){const s=this._isReadonly,r=this._isShallow;if("__v_isReactive"===t)return!s;if("__v_isReadonly"===t)return s;if("__v_isShallow"===t)return r;if("__v_raw"===t)return n===(s?r?kt:yt:r?vt:gt).get(e)||Object.getPrototypeOf(e)===Object.getPrototypeOf(n)?e:void 0;const i=S(e);if(!s){if(i&&x(je,t))return Reflect.get(je,t,n);if("hasOwnProperty"===t)return qe}const o=Reflect.get(e,t,n);return(O(t)?Fe.has(t):Ue(t))?o:(s||Me(e,0,t),r?o:Rt(o)?i&&U(t)?o:o.value:N(o)?s?_t(o):bt(o):o)}}class He extends We{constructor(e=!1){super(!1,e)}set(e,t,n,s){let r=e[t];if(!this._isShallow){const t=St(r);if(Ct(n)||St(n)||(r=$t(r),n=$t(n)),!S(e)&&Rt(r)&&!Rt(n))return!t&&(r.value=n,!0)}const i=S(e)&&U(t)?Number(t)<e.length:x(e,t),o=Reflect.set(e,t,n,s);return e===$t(s)&&(i?z(n,r)&&De(e,"set",t,n):De(e,"add",t,n)),o}deleteProperty(e,t){const n=x(e,t);e[t];const s=Reflect.deleteProperty(e,t);return s&&n&&De(e,"delete",t,void 0),s}has(e,t){const n=Reflect.has(e,t);return O(t)&&Fe.has(tLine truncated
/**
* @vue/runtime-core v3.4.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/function Gt(e,t,n,s){try{return s?e(...s):e()}catch(r){Qt(r,t,n)}}function Yt(e,t,n,s){if(T(e)){const r=Gt(e,t,n,s);return r&&I(r)&&r.catch((e=>{Qt(e,t,n)})),r}if(S(e)){const r=[];for(let i=0;i<e.length;i++)r.push(Yt(e[i],t,n,s));return r}}function Qt(e,t,n,s=!0){t&&t.vnode;if(t){let s=t.parent;const r=t.proxy,i=`https://vuejs.org/error-reference/#runtime-${n}`;for(;s;){const t=s.ec;if(t)for(let n=0;n<t.length;n++)if(!1===t[n](e,r,i))return;s=s.parent}const o=t.appContext.config.errorHandler;if(o)return Se(),Gt(o,null,10,[e,r,i]),void Ce()}!function(e,t,n,s=!0){console.error(e)}(e,0,0,s)}let Xt=!1,Jt=!1;const Zt=[];let en=0;const tn=[];let nn=null,sn=0;const rn=Promise.resolve();let on=null;function an(e){const t=on||rn;return e?t.then(this?e.bind(this):e):t}function ln(e){Zt.length&&Zt.includes(e,Xt&&e.allowRecurse?en+1:en)||(null==e.id?Zt.push(e):Zt.splice(function(e){let t=en+1,n=Zt.length;for(;t<n;){const s=t+n>>>1,r=Zt[s],i=pn(r);i<e||i===e&&r.pre?t=s+1:n=s}return t}(e.id),0,e),cn())}function cn(){Xt||Jt||(Jt=!0,on=rn.then(fn))}function un(e,t,n=(Xt?en+1:0)){for(;n<Zt.length;n++){const t=Zt[n];if(t&&t.pre){if(e&&t.id!==e.uid)continue;Zt.splice(n,1),n--,t()}}}function dn(e){if(tn.length){const e=[...new Set(tn)].sort(((e,t)=>pn(e)-pn(t)));if(tn.length=0,nn)return void nn.push(...e);for(nn=e,sn=0;sn<nn.length;sn++)nn[sn]();nn=null,sn=0}}const pn=e=>null==e.id?1/0:e.id,hn=(e,t)=>{const n=pn(e)-pn(t);if(0===n){if(e.pre&&!t.pre)return-1;if(t.pre&&!e.pre)return 1}return n};function fn(e){Jt=!1,Xt=!0,Zt.sort(hn);try{for(en=0;en<Zt.length;en++){const e=Zt[en];e&&!1!==e.active&&Gt(e,null,14)}}finally{en=0,Zt.length=0,dn(),Xt=!1,on=null,(Zt.length||tn.length)&&fn()}}function mn(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||m;let r=n;const i=t.startsWith("update:"),o=i&&t.slice(7);if(o&&o in s){const e=`${"modelValue"===o?"model":o}Modifiers`,{number:t,trim:i}=s[e]||m;i&&(r=n.map((e=>L(e)?e.trim():e))),t&&(r=n.map(Q))}let a,l=s[a=K(t)]||s[a=K(q(t))];!l&&i&&(l=s[a=K(H(t))]),l&&Yt(l,e,6,r);const c=s[a+"Once"];if(c){if(e.emitted){if(e.emitted[a])return}else e.emitted={};e.emitted[a]=!0,Yt(c,e,6,r)}}function gn(e,t,n=!1){const s=t.emitsCache,r=s.get(e);if(void 0!==r)return r;const i=e.emits;let o={},a=!1;if(!T(e)){const s=e=>{const n=gn(e,t,!0);n&&(a=!0,w(o,n))};!n&&t.mixins.length&&t.mixins.forEach(s),e.extends&&s(e.extends),e.mixins&&e.mixins.forEach(s)}return i||a?(S(i)?i.forEach((e=>o[e]=null)):w(o,i),N(e)&&s.set(e,o),o):(N(e)&&s.set(e,null),null)}function vn(e,t){return!(!e||!k(t))&&(t=t.slice(2).replace(/Once$/,""),x(e,t[0].toLowerCase()+t.slice(1))||x(e,H(t))||x(e,t))}let yn=null,kn=null;function bn(e){const t=yn;return yn=e,kn=e&&e.type.__scopeId||null,t}function wn(e){kn=e}function _n(){kn=null}function En(e,t=yn,n){if(!t)return e;if(e._n)return e;const s=(...n)=>{s._d&&wr(-1);const r=bn(t);let i;try{i=e(...n)}finally{bn(r),s._d&&wr(1)}return i};return s._n=!0,s._c=!0,s._d=!0,s}function xn(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[i],slots:o,attrs:a,emit:l,render:c,renderCache:u,props:d,data:p,setupState:h,ctx:f,inheritAttrs:m}=e,g=bn(e);let v,y;try{if(4&n.shapeFlag){const e=r||s,t=e;v=Pr(c.call(t,e,u,d,h,p,f)),y=a}else{const e=t;0,v=Pr(e.length>1?e(d,{attrs:a,slots:o,emit:l}):e(d,null)),y=t.props?a:Sn(a)}}catch(w){vr.length=0,Qt(w,e,1),v=Lr(mr)}let k=v;if(y&&!1!==m){const e=Object.keys(y),{shapeFlag:t}=k;e.length&&7&t&&(i&&e.some(b)&&(y=Cn(y,i)),k=Or(k,y,!1,!0))}return n.dirs&&(k=Or(k,null,!1,!0),k.dirs=k.dirs?k.dirs.concat(n.dirs):n.dirs),n.transition&&(k.transition=n.transition),v=k,bn(g),v}const Sn=e=>{let t;for(const n in e)("class"===n||"style"===n||k(n))&&((t||(t={}))[n]=e[n]);return t},Cn=(e,t)=>{const n={};for(const s in e)b(s)&&s.slice(9)in t||(n[s]=e[s]);return n};function An(e,t,n){const s=Object.keys(t);if(s.length!==Object.keys(e).length)return!0;for(let r=0;r<s.length;r++){const i=s[r];if(t[i]!==e[i]&&!vn(n,i))return!0}return!1}function $n(e,t){return function(e,t,n=!0,s=!1){const r=yn||jr;if(r){const n=r.type;{const e=Zr(n,!1);if(e&&(e===t||e===q(t)||e===V(q(t))))return n}const i=Ln(r[e]||n[e],t)||Ln(r.appContext[e],t);return!i&&s?n:i}}("components",e,!0,t)||e}const Tn=Symbol.for("v-ndc");function Ln(e,t){return e&&(e[t]||e[q(t)]||e[V(q(t))])}const On=Symbol.for("v-scx"),Nn=()=>qs(On),In={};function Pn(e,t,n){return Rn(e,t,n)}function Rn(e,t,{immediate:n,deep:s,flush:r,once:i,onTrack:o,onTrigger:a}=m){if(t&&i){const e=t;t=(...t)=>{e(...t),x()}}const l=jr,c=e=>!0===s?e:Un(e,!1===s?1:void 0);let u,d,p=!1,h=!1;if(Rt(e)?(u=()=>e.value,p=Ct(e)):xt(e)?(u=()=>c(e),p=!0):S(e)?(h=!0,p=e.some((e=>xt(e)||Ct(e))),u=()=>e.map((e=>Rt(e)?e.value:xt(e)?c(e):T(e)?Gt(e,l,2):void 0))):u=T(e)?t?()=>Gt(e,l,2):()=>(d&&d(),Yt(e,l,3,[g])):v,t&&s){const e=u;u=()=>Un(e())}let f,g=e=>{d=w.onStop=()=>{Gt(e,l,4),d=w.onStop=void 0}};if(Gr){if(g=v,t?n&&Yt(t,l,3,[u(),h?[]:void 0,g]):u(),"sync"!==r)return v;{const e=Nn();f=e.__watcherHandles||(e.__watcherHandles=[])}}let y=h?new Array(e.length) Line truncated
/*! #__NO_SIDE_EFFECTS__ */function Jn(e,t){return T(e)?(()=>w({name:e.name},t,{setup:e}))():e}const Zn=e=>!!e.type.__asyncLoader,es=e=>e.type.__isKeepAlive;function ts(e,t){ss(e,"a",t)}function ns(e,t){ss(e,"da",t)}function ss(e,t,n=jr){const s=e.__wdc||(e.__wdc=()=>{let t=n;for(;t;){if(t.isDeactivated)return;t=t.parent}return e()});if(is(t,s,n),n){let e=n.parent;for(;e&&e.parent;)es(e.parent.vnode)&&rs(s,t,n,e),e=e.parent}}function rs(e,t,n,s){const r=is(t,e,s,!0);ps((()=>{_(s[t],r)}),n)}function is(e,t,n=jr,s=!1){if(n){const r=n[e]||(n[e]=[]),i=t.__weh||(t.__weh=(...s)=>{if(n.isUnmounted)return;Se();const r=Hr(n),i=Yt(t,n,e,s);return r(),Ce(),i});return s?r.unshift(i):r.push(i),i}}const os=e=>(t,n=jr)=>(!Gr||"sp"===e)&&is(e,((...e)=>t(...e)),n),as=os("bm"),ls=os("m"),cs=os("bu"),us=os("u"),ds=os("bum"),ps=os("um"),hs=os("sp"),fs=os("rtg"),ms=os("rtc");function gs(e,t=jr){is("ec",e,t)}function vs(e,t,n,s){let r;const i=n;if(S(e)||L(e)){r=new Array(e.length);for(let n=0,s=e.length;n<s;n++)r[n]=t(e[n],n,void 0,i)}else if("number"==typeof e){r=new Array(e);for(let n=0;n<e;n++)r[n]=t(n+1,n,void 0,i)}else if(N(e))if(e[Symbol.iterator])r=Array.from(e,((e,n)=>t(e,n,void 0,i)));else{const n=Object.keys(e);r=new Array(n.length);for(let s=0,o=n.length;s<o;s++){const o=n[s];r[s]=t(e[o],o,s,i)}}else r=[];return r}function ys(e,t,n={},s,r){if(yn.isCE||yn.parent&&Zn(yn.parent)&&yn.parent.isCE)return"default"!==t&&(n.name=t),Lr("slot",n,s);let i=e[t];i&&i._c&&(i._d=!1),kr();const o=i&&ks(i(n)),a=xr(hr,{key:n.key||o&&o.key||`_${t}`},o||[],o&&1===e._?64:-2);return a.scopeId&&(a.slotScopeIds=[a.scopeId+"-s"]),i&&i._c&&(i._d=!0),a}function ks(e){return e.some((e=>!Sr(e)||e.type!==mr&&!(e.type===hr&&!ks(e.children))))?e:null}const bs=e=>e?Kr(e)?Jr(e)||e.proxy:bs(e.parent):null,ws=w(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>bs(e.parent),$root:e=>bs(e.root),$emit:e=>e.emit,$options:e=>Ts(e),$forceUpdate:e=>e.f||(e.f=()=>{e.effect.dirty=!0,ln(e.update)}),$nextTick:e=>e.n||(e.n=an.bind(e.proxy)),$watch:e=>Mn.bind(e)}),_s=(e,t)=>e!==m&&!e.__isScriptSetup&&x(e,t),Es={get({_:e},t){if("__v_skip"===t)return!0;const{ctx:n,setupState:s,data:r,props:i,accessCache:o,type:a,appContext:l}=e;let c;if("$"!==t[0]){const a=o[t];if(void 0!==a)switch(a){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return i[t]}else{if(_s(s,t))return o[t]=1,s[t];if(r!==m&&x(r,t))return o[t]=2,r[t];if((c=e.propsOptions[0])&&x(c,t))return o[t]=3,i[t];if(n!==m&&x(n,t))return o[t]=4,n[t];Ss&&(o[t]=0)}}const u=ws[t];let d,p;return u?("$attrs"===t&&Me(e.attrs,0,""),u(e)):(d=a.__cssModules)&&(d=d[t])?d:n!==m&&x(n,t)?(o[t]=4,n[t]):(p=l.config.globalProperties,x(p,t)?p[t]:void 0)},set({_:e},t,n){const{data:s,setupState:r,ctx:i}=e;return _s(r,t)?(r[t]=n,!0):s!==m&&x(s,t)?(s[t]=n,!0):!x(e.props,t)&&(("$"!==t[0]||!(t.slice(1)in e))&&(i[t]=n,!0))},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,propsOptions:i}},o){let a;return!!n[o]||e!==m&&x(e,o)||_s(t,o)||(a=i[0])&&x(a,o)||x(s,o)||x(ws,o)||x(r.config.globalProperties,o)},defineProperty(e,t,n){return null!=n.get?e._.accessCache[t]=0:x(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function xs(e){return S(e)?e.reduce(((e,t)=>(e[t]=null,e)),{}):e}let Ss=!0;function Cs(e){const t=Ts(e),n=e.proxy,s=e.ctx;Ss=!1,t.beforeCreate&&As(t.beforeCreate,e,"bc");const{data:r,computed:i,methods:o,watch:a,provide:l,inject:c,created:u,beforeMount:d,mounted:p,beforeUpdate:h,updated:f,activated:m,deactivated:g,beforeDestroy:y,beforeUnmount:k,destroyed:b,unmounted:w,render:_,renderTracked:E,renderTriggered:x,errorCaptured:C,serverPrefetch:A,expose:$,inheritAttrs:L,components:O,directives:I,filters:P}=t;if(c&&function(e,t,n=v){S(e)&&(e=Is(e));for(const s in e){const n=e[s];let r;r=N(n)?"default"in n?qs(n.from||s,n.default,!0):qs(n.from||s):qs(n),Rt(r)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>r.value,set:e=>r.value=e}):t[s]=r}}(c,s,null),o)for(const v in o){const e=o[v];T(e)&&(s[v]=e.bind(n))}if(r){const t=r.call(n,n);N(t)&&(e.data=bt(t))}if(Ss=!0,i)for(const S in i){const e=i[S],t=T(e)?e.bind(n,n):T(e.get)?e.get.bind(n,n):v,r=!T(e)&&T(e.set)?e.set.bind(n):v,o=ei({get:t,set:r});Object.defineProperty(s,S,{enumerable:!0,configurable:!0,get:()=>o.value,set:e=>o.value=e})}if(a)for(const v in a)$s(a[v],s,n,v);if(l){const e=T(l)?l.call(n):l;Reflect.ownKeys(e).forEach((t=>{Bs(t,e[t])}))}function R(e,t){S(t)?t.forEach((t=>e(t.bind(n)))):t&&e(t.bind(n))}if(u&&As(u,e,"c"),R(as,d),R(ls,p),R(cs,h),R(us,f),R(ts,m),R(ns,g),R(gs,C),R(ms,E),R(fs,x),R(ds,k),R(ps,w),R(hs,A),S($))if($.length){const t=e.exposed||(e.exposed={});$.forEach((e=>{Object.defineProperty(t,e,{get:()=>n[e],set:t=>n[e]=t})}))}else e.exposed||(e.exposed={});_&&e.render===v&&(e.render=_),null!=L&&(e.inheritAttrs=L),O&&(e.components=O),I&&(e.directives=I)}function As(e,t,n){Yt(S(e)?e.map((e=>e.bind(t.proxy))):e.bind(t.Line truncated
/**
* @vue/runtime-dom v3.4.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors
* @license MIT
**/ci.displayName="Transition";const ui={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String};ci.props=w({},Hn,ui);const di=(e,t=[])=>{S(e)?e.forEach((e=>e(...t))):e&&e(...t)},pi=e=>!!e&&(S(e)?e.some((e=>e.length>1)):e.length>1);function hi(e){return X(e)}function fi(e,t){t.split(/\s+/).forEach((t=>t&&e.classList.add(t))),(e[li]||(e[li]=new Set)).add(t)}function mi(e,t){t.split(/\s+/).forEach((t=>t&&e.classList.remove(t)));const n=e[li];n&&(n.delete(t),n.size||(e[li]=void 0))}function gi(e){requestAnimationFrame((()=>{requestAnimationFrame(e)}))}let vi=0;function yi(e,t,n,s){const r=e._endId=++vi,i=()=>{r===e._endId&&s()};if(n)return setTimeout(i,n);const{type:o,timeout:a,propCount:l}=function(e,t){const n=window.getComputedStyle(e),s=e=>(n[e]||"").split(", "),r=s(`${oi}Delay`),i=s(`${oi}Duration`),o=ki(r,i),a=s(`${ai}Delay`),l=s(`${ai}Duration`),c=ki(a,l);let u=null,d=0,p=0;t===oi?o>0&&(u=oi,d=o,p=i.length):t===ai?c>0&&(u=ai,d=c,p=l.length):(d=Math.max(o,c),u=d>0?o>c?oi:ai:null,p=u?u===oi?i.length:l.length:0);const h=u===oi&&/\b(transform|all)(,|$)/.test(s(`${oi}Property`).toString());return{type:u,timeout:d,propCount:p,hasTransform:h}}(e,t);if(!o)return s();const c=o+"end";let u=0;const d=()=>{e.removeEventListener(c,p),i()},p=t=>{t.target===e&&++u>=l&&d()};setTimeout((()=>{u<l&&d()}),a+1),e.addEventListener(c,p)}function ki(e,t){for(;e.length<t.length;)e=e.concat(e);return Math.max(...t.map(((t,n)=>bi(t)+bi(e[n]))))}function bi(e){return"auto"===e?0:1e3*Number(e.slice(0,-1).replace(",","."))}const wi=Symbol("_vod"),_i=Symbol("_vsh"),Ei={beforeMount(e,{value:t},{transition:n}){e[wi]="none"===e.style.display?"":e.style.display,n&&t?n.beforeEnter(e):xi(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:s}){!t!=!n&&(s?t?(s.beforeEnter(e),xi(e,!0),s.enter(e)):s.leave(e,(()=>{xi(e,!1)})):xi(e,t))},beforeUnmount(e,{value:t}){xi(e,t)}};function xi(e,t){e.style.display=t?e[wi]:"none",e[_i]=!t}const Si=Symbol(""),Ci=/(^|;)\s*display\s*:/;const Ai=/\s*!important$/;function $i(e,t,n){if(S(n))n.forEach((n=>$i(e,t,n)));else if(null==n&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=function(e,t){const n=Li[t];if(n)return n;let s=q(t);if("filter"!==s&&s in e)return Li[t]=s;s=V(s);for(let r=0;r<Ti.length;r++){const n=Ti[r]+s;if(n in e)return Li[t]=n}return t}(e,t);Ai.test(n)?e.setProperty(H(s),n.replace(Ai,""),"important"):e[s]=n}}const Ti=["Webkit","Moz","ms"],Li={};const Oi="http://www.w3.org/1999/xlink";function Ni(e,t,n,s){e.addEventListener(t,n,s)}const Ii=Symbol("_vei");function Pi(e,t,n,s,r=null){const i=e[Ii]||(e[Ii]={}),o=i[t];if(s&&o)o.value=s;else{const[n,a]=function(e){let t;if(Ri.test(e)){let n;for(t={};n=e.match(Ri);)e=e.slice(0,e.length-n[0].length),t[n[0].toLowerCase()]=!0}const n=":"===e[2]?e.slice(3):H(e.slice(2));return[n,t]}(t);if(s){const o=i[t]=function(e,t){const n=e=>{if(e._vts){if(e._vts<=n.attached)return}else e._vts=Date.now();Yt(function(e,t){if(S(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map((e=>t=>!t._stopped&&e&&e(t)))}return t}(e,n.value),t,5,[e])};return n.value=e,n.attached=Ui(),n}(s,r);Ni(e,n,o,a)}else o&&(!function(e,t,n,s){e.removeEventListener(t,n,s)}(e,n,o,a),i[t]=void 0)}}const Ri=/(?:Once|Passive|Capture)$/;let Mi=0;const Di=Promise.resolve(),Ui=()=>Mi||(Di.then((()=>Mi=0)),Mi=Date.now());const Fi=e=>111===e.charCodeAt(0)&&110===e.charCodeAt(1)&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123;const ji=e=>{const t=e.props["onUpdate:modelValue"]||!1;return S(t)?e=>G(t,e):t};function Bi(e){e.target.composing=!0}function qi(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Wi=Symbol("_assign"),Hi={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e[Wi]=ji(r);const i=s||r.props&&"number"===r.props.type;Ni(e,t?"change":"input",(t=>{if(t.target.composing)return;let s=e.value;n&&(s=s.trim()),i&&(s=Q(s)),e[Wi](s)})),n&&Ni(e,"change",(()=>{e.value=e.value.trim()})),t||(Ni(e,"compositionstart",Bi),Ni(e,"compositionend",qi),Ni(e,"change",qi))},mounted(e,{value:t}){e.value=null==t?"":t},beforeUpdate(e,{value:t,modifiers:{lazy:n,trim:s,number:r}},i){if(e[Wi]=ji(i),e.composing)return;const o=null==t?"":t;if((!r&&"number"!==e.type||/^0\d/.test(e.value)?e.value:Q(e.value))!==o){if(document.activeElement===e&&"range"!==e.type){if(n)return;if(s&&e.value.trim()===o)return}e.value=o}}},Vi={deep:!0,created(e,t,n){e[Wi]=ji(n),Ni(e,"change",(()=>{const t=e._modelValue,n=Yi(e),s=e.checked,r=e[Wi];if(S(t)){const e=ce(t,n),i=-1!==e;if(s&&!i)r(t.concat(n));else if(!s&&i){const n=[...t];n.splice(e,1),r(n)}}else if(A(t)){const e=new Set(t);s?e.add(n):e.delete(n),r(e)}else r(Qi(e,s))}))},mounted:Ki,beforeUpdate(e,t,n){e[WLine truncated
/*!
* pinia v2.1.7
* (c) 2023 Eduardo San Martin Morote
* @license MIT
*/
let no;const so=e=>no=e,ro=Symbol();function io(e){return e&&"object"==typeof e&&"[object Object]"===Object.prototype.toString.call(e)&&"function"!=typeof e.toJSON}var oo,ao;(ao=oo||(oo={})).direct="direct",ao.patchObject="patch object",ao.patchFunction="patch function";const lo=()=>{};function co(e,t,n,s=lo){e.push(t);const r=()=>{const n=e.indexOf(t);n>-1&&(e.splice(n,1),s())};var i;return!n&&ve()&&(i=r,he&&he.cleanups.push(i)),r}function uo(e,...t){e.slice().forEach((e=>{e(...t)}))}const po=e=>e();function ho(e,t){e instanceof Map&&t instanceof Map&&t.forEach(((t,n)=>e.set(n,t))),e instanceof Set&&t instanceof Set&&t.forEach(e.add,e);for(const n in t){if(!t.hasOwnProperty(n))continue;const s=t[n],r=e[n];io(r)&&io(s)&&e.hasOwnProperty(n)&&!Rt(s)&&!xt(s)?e[n]=ho(r,s):e[n]=s}return e}const fo=Symbol();const{assign:mo}=Object;function go(e,t,n={},s,r,i){let o;const a=mo({actions:{}},n),l={deep:!0};let c,u,d,p=[],h=[];const f=s.state.value[e];let m;function g(t){let n;c=u=!1,"function"==typeof t?(t(s.state.value[e]),n={type:oo.patchFunction,storeId:e,events:d}):(ho(s.state.value[e],t),n={type:oo.patchObject,payload:t,storeId:e,events:d});const r=m=Symbol();an().then((()=>{m===r&&(c=!0)})),u=!0,uo(p,n,s.state.value[e])}i||f||(s.state.value[e]={}),Mt({});const v=i?function(){const{state:e}=n,t=e?e():{};this.$patch((e=>{mo(e,t)}))}:lo;function y(t,n){return function(){so(s);const r=Array.from(arguments),i=[],o=[];let a;uo(h,{args:r,name:t,store:k,after:function(e){i.push(e)},onError:function(e){o.push(e)}});try{a=n.apply(this&&this.$id===e?this:k,r)}catch(l){throw uo(o,l),l}return a instanceof Promise?a.then((e=>(uo(i,e),e))).catch((e=>(uo(o,e),Promise.reject(e)))):(uo(i,a),a)}}const k=bt({_p:s,$id:e,$onAction:co.bind(null,h),$patch:g,$reset:v,$subscribe(t,n={}){const r=co(p,t,n.detached,(()=>i())),i=o.run((()=>Pn((()=>s.state.value[e]),(s=>{("sync"===n.flush?u:c)&&t({storeId:e,type:oo.direct,events:d},s)}),mo({},l,n))));return r},$dispose:function(){o.stop(),p=[],h=[],s._s.delete(e)}});s._s.set(e,k);const b=(s._a&&s._a.runWithContext||po)((()=>s._e.run((()=>(o=ge()).run(t)))));for(const E in b){const t=b[E];if(Rt(t)&&(!Rt(_=t)||!_.effect)||xt(t))i||(!f||io(w=t)&&w.hasOwnProperty(fo)||(Rt(t)?t.value=f[E]:ho(t,f[E])),s.state.value[e][E]=t);else if("function"==typeof t){const e=y(E,t);b[E]=e,a.actions[E]=t}}var w,_;return mo(k,b),mo($t(k),b),Object.defineProperty(k,"$state",{get:()=>s.state.value[e],set:e=>{g((t=>{mo(t,e)}))}}),s._p.forEach((e=>{mo(k,o.run((()=>e({store:k,app:s._a,pinia:s,options:a}))))})),f&&i&&n.hydrate&&n.hydrate(k.$state,f),c=!0,u=!0,k}function vo(e,t,n){let s,r;const i="function"==typeof t;function o(e,n){(e=e||(!!(jr||yn||js)?qs(ro,null):null))&&so(e),(e=no)._s.has(s)||(i?go(s,t,r,e):function(e,t,n,s){const{state:r,actions:i,getters:o}=t,a=n.state.value[e];let l;l=go(e,(function(){a||(n.state.value[e]=r?r():{});const t=Wt(n.state.value[e]);return mo(t,i,Object.keys(o||{}).reduce(((t,s)=>(t[s]=Tt(ei((()=>{so(n);const t=n._s.get(e);return o[s].call(t,t)}))),t)),{}))}),t,n,0,!0)}(s,r,e));return e._s.get(s)}return"string"==typeof e?(s=e,r=i?n:t):(r=e,s=e.id),o.$id=s,o}function yo(e){{e=$t(e);const t={};for(const n in e){const s=e[n];(Rt(s)||xt(s))&&(t[n]=Kt(e,n))}return t}}
/*!
* shared v9.13.1
* (c) 2024 kazuya kawaguchi
* Released under the MIT License.
*/const ko="undefined"!=typeof window,bo=(e,t=!1)=>t?Symbol.for(e):Symbol(e),wo=(e,t,n)=>_o({l:e,k:t,s:n}),_o=e=>JSON.stringify(e).replace(/\u2028/g,"\\u2028").replace(/\u2029/g,"\\u2029").replace(/\u0027/g,"\\u0027"),Eo=e=>"number"==typeof e&&isFinite(e),xo=e=>"[object Date]"===jo(e),So=e=>"[object RegExp]"===jo(e),Co=e=>Bo(e)&&0===Object.keys(e).length,Ao=Object.assign;let $o;const To=()=>$o||($o="undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:"undefined"!=typeof window?window:"undefined"!=typeof global?global:{});function Lo(e){return e.replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&apos;")}const Oo=Object.prototype.hasOwnProperty;function No(e,t){return Oo.call(e,t)}const Io=Array.isArray,Po=e=>"function"==typeof e,Ro=e=>"string"==typeof e,Mo=e=>"boolean"==typeof e,Do=e=>null!==e&&"object"==typeof e,Uo=e=>Do(e)&&Po(e.then)&&Po(e.catch),Fo=Object.prototype.toString,jo=e=>Fo.call(e),Bo=e=>{if(!Do(e))return!1;const t=Object.getPrototypeOf(e);return null===t||t.constructor===Object};function qo(e){let t=e;return()=>++t}function Wo(e,t){"undefined"!=typeof console&&(console.warn("[intlify] "+e),t&&console.warn(t.stack))}const Ho=e=>!Do(e)||Io(e);function Vo(e,t){if(Ho(e)||Ho(t))throw new Error("Invalid value");const n=[{src:e,des:t}];for(;n.length;){const{src:e,des:t}=n.pop();Object.keys(e).forEach((s=>{Ho(e[s])||Ho(t[s])?t[s]=e[s]:n.push({src:e[s],des:t[s]})}))}}
/*!
* message-compiler v9.13.1
* (c) 2024 kazuya kawaguchi
* Released under the MIT License.
*/function Ko(e,t,n){return{start:e,end:t}}const zo=/\{([0-9a-zA-Z]+)\}/g;function Go(e,...t){return 1===t.length&&Xo(t[0])&&(t=t[0]),t&&t.hasOwnProperty||(t={}),e.replace(zo,((e,n)=>t.hasOwnProperty(n)?t[n]:""))}const Yo=Object.assign,Qo=e=>"string"==typeof e,Xo=e=>null!==e&&"object"==typeof e;function Jo(e,t=""){return e.reduce(((e,n,s)=>0===s?e+n:e+t+n),"")}const Zo={USE_MODULO_SYNTAX:1,__EXTEND_POINT__:2},ea={[Zo.USE_MODULO_SYNTAX]:"Use modulo before '{{0}}'."};const ta={EXPECTED_TOKEN:1,INVALID_TOKEN_IN_PLACEHOLDER:2,UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER:3,UNKNOWN_ESCAPE_SEQUENCE:4,INVALID_UNICODE_ESCAPE_SEQUENCE:5,UNBALANCED_CLOSING_BRACE:6,UNTERMINATED_CLOSING_BRACE:7,EMPTY_PLACEHOLDER:8,NOT_ALLOW_NEST_PLACEHOLDER:9,INVALID_LINKED_FORMAT:10,MUST_HAVE_MESSAGES_IN_PLURAL:11,UNEXPECTED_EMPTY_LINKED_MODIFIER:12,UNEXPECTED_EMPTY_LINKED_KEY:13,UNEXPECTED_LEXICAL_ANALYSIS:14,UNHANDLED_CODEGEN_NODE_TYPE:15,UNHANDLED_MINIFIER_NODE_TYPE:16,__EXTEND_POINT__:17},na={[ta.EXPECTED_TOKEN]:"Expected token: '{0}'",[ta.INVALID_TOKEN_IN_PLACEHOLDER]:"Invalid token in placeholder: '{0}'",[ta.UNTERMINATED_SINGLE_QUOTE_IN_PLACEHOLDER]:"Unterminated single quote in placeholder",[ta.UNKNOWN_ESCAPE_SEQUENCE]:"Unknown escape sequence: \\{0}",[ta.INVALID_UNICODE_ESCAPE_SEQUENCE]:"Invalid unicode escape sequence: {0}",[ta.UNBALANCED_CLOSING_BRACE]:"Unbalanced closing brace",[ta.UNTERMINATED_CLOSING_BRACE]:"Unterminated closing brace",[ta.EMPTY_PLACEHOLDER]:"Empty placeholder",[ta.NOT_ALLOW_NEST_PLACEHOLDER]:"Not allowed nest placeholder",[ta.INVALID_LINKED_FORMAT]:"Invalid linked format",[ta.MUST_HAVE_MESSAGES_IN_PLURAL]:"Plural must have messages",[ta.UNEXPECTED_EMPTY_LINKED_MODIFIER]:"Unexpected empty linked modifier",[ta.UNEXPECTED_EMPTY_LINKED_KEY]:"Unexpected empty linked key",[ta.UNEXPECTED_LEXICAL_ANALYSIS]:"Unexpected lexical analysis in token: '{0}'",[ta.UNHANDLED_CODEGEN_NODE_TYPE]:"unhandled codegen node type: '{0}'",[ta.UNHANDLED_MINIFIER_NODE_TYPE]:"unhandled mimifier node type: '{0}'"};function sa(e,t,n={}){const{domain:s,messages:r,args:i}=n,o=Go((r||na)[e]||"",...i||[]),a=new SyntaxError(String(o));return a.code=e,t&&(a.location=t),a.domain=s,a}function ra(e){throw e}const ia=" ",oa="\r",aa="\n",la=String.fromCharCode(8232),ca=String.fromCharCode(8233);function ua(e){const t=e;let n=0,s=1,r=1,i=0;const o=e=>t[e]===oa&&t[e+1]===aa,a=e=>t[e]===ca,l=e=>t[e]===la,c=e=>o(e)||(e=>t[e]===aa)(e)||a(e)||l(e),u=e=>o(e)||a(e)||l(e)?aa:t[e];function d(){return i=0,c(n)&&(s++,r=0),o(n)&&n++,n++,r++,t[n]}return{index:()=>n,line:()=>s,column:()=>r,peekOffset:()=>i,charAt:u,currentChar:()=>u(n),currentPeek:()=>u(n+i),next:d,peek:function(){return o(n+i)&&i++,i++,t[n+i]},reset:function(){n=0,s=1,r=1,i=0},resetPeek:function(e=0){i=e},skipToPeek:function(){const e=n+i;for(;e!==n;)d();i=0}}}const da=void 0,pa="'",ha="tokenizer";function fa(e,t={}){const n=!1!==t.location,s=ua(e),r=()=>s.index(),i=()=>{return e=s.line(),t=s.column(),n=s.index(),{line:e,column:t,offset:n};var e,t,n},o=i(),a=r(),l={currentType:14,offset:a,startLoc:o,endLoc:o,lastType:14,lastOffset:a,lastStartLoc:o,lastEndLoc:o,braceNest:0,inLinked:!1,text:""},c=()=>l,{onError:u}=t;function d(e,t,s,...r){const i=c();if(t.column+=s,t.offset+=s,u){const s=sa(e,n?Ko(i.startLoc,t):null,{domain:ha,args:r});u(s)}}function p(e,t,s){e.endLoc=i(),e.currentType=t;const r={type:t};return n&&(r.loc=Ko(e.startLoc,e.endLoc)),null!=s&&(r.value=s),r}const h=e=>p(e,14);function f(e,t){return e.currentChar()===t?(e.next(),t):(d(ta.EXPECTED_TOKEN,i(),0,t),"")}function m(e){let t="";for(;e.currentPeek()===ia||e.currentPeek()===aa;)t+=e.currentPeek(),e.peek();return t}function g(e){const t=m(e);return e.skipToPeek(),t}function v(e){if(e===da)return!1;const t=e.charCodeAt(0);return t>=97&&t<=122||t>=65&&t<=90||95===t}function y(e,t){const{currentType:n}=t;if(2!==n)return!1;m(e);const s=function(e){if(e===da)return!1;const t=e.charCodeAt(0);return t>=48&&t<=57}("-"===e.currentPeek()?e.peek():e.currentPeek());return e.resetPeek(),s}function k(e){m(e);const t="|"===e.currentPeek();return e.resetPeek(),t}function b(e,t=!0){const n=(t=!1,s="",r=!1)=>{const i=e.currentPeek();return"{"===i?"%"!==s&&t:"@"!==i&&i?"%"===i?(e.peek(),n(t,"%",!0)):"|"===i?!("%"!==s&&!r)||!(s===ia||s===aa):i===ia?(e.peek(),n(!0,ia,r)):i!==aa||(e.peek(),n(!0,aa,r)):"%"===s||t},s=n();return t&&e.resetPeek(),s}function w(e,t){const n=e.currentChar();return n===da?da:t(n)?(e.next(),n):null}function _(e){const t=e.charCodeAt(0);return t>=97&&t<=122||t>=65&&t<=90||t>=48&&t<=57||95===t||36===t}function E(e){return w(e,_)}function x(e){const t=e.charCodeAt(0);return t>=97&&t<=122||t>=65&&t<=90||t>=48&&t<=57||95===t||36===t||45===t}function S(e){return w(e,x)}function C(e){const t=e.charCodeAt(0);return t>=48&&t<=57}function A(e){return w(e,C)}function $(e){const t=e.charCodeAt(0);return t>=48&&t<=57||t>=65&&t<=70||t>=97&&t<=102}function T(e){return w(e,$)}function L(e){let t="",n="";for(;t=A(e);)n+=t;return n}function O(e)Line truncated
/*!
* core-base v9.13.1
* (c) 2024 kazuya kawaguchi
* Released under the MIT License.
*/const La=[];La[0]={w:[0],i:[3,0],"[":[4],o:[7]},La[1]={w:[1],".":[2],"[":[4],o:[7]},La[2]={w:[2],i:[3,0],0:[3,0]},La[3]={i:[3,0],0:[3,0],w:[1,1],".":[2,1],"[":[4,1],o:[7,1]},La[4]={"'":[5,0],'"':[6,0],"[":[4,2],"]":[1,3],o:8,l:[4,0]},La[5]={"'":[4,0],o:8,l:[5,0]},La[6]={'"':[4,0],o:8,l:[6,0]};const Oa=/^\s?(?:true|false|-?[\d.]+|'[^']*'|"[^"]*")\s?$/;function Na(e){if(null==e)return"o";switch(e.charCodeAt(0)){case 91:case 93:case 46:case 34:case 39:return e;case 95:case 36:case 45:return"i";case 9:case 10:case 13:case 160:case 65279:case 8232:case 8233:return"w"}return"i"}function Ia(e){const t=e.trim();return("0"!==e.charAt(0)||!isNaN(parseInt(e)))&&(n=t,Oa.test(n)?function(e){const t=e.charCodeAt(0);return t!==e.charCodeAt(e.length-1)||34!==t&&39!==t?e:e.slice(1,-1)}(t):"*"+t);var n}const Pa=new Map;function Ra(e,t){return Do(e)?e[t]:null}const Ma=e=>e,Da=e=>"",Ua="text",Fa=e=>0===e.length?"":function(e,t=""){return e.reduce(((e,n,s)=>0===s?e+n:e+t+n),"")}(e),ja=e=>null==e?"":Io(e)||Bo(e)&&e.toString===Fo?JSON.stringify(e,null,2):String(e);function Ba(e,t){return e=Math.abs(e),2===t?e?e>1?1:0:1:e?Math.min(e,2):0}function qa(e={}){const t=e.locale,n=function(e){const t=Eo(e.pluralIndex)?e.pluralIndex:-1;return e.named&&(Eo(e.named.count)||Eo(e.named.n))?Eo(e.named.count)?e.named.count:Eo(e.named.n)?e.named.n:t:t}(e),s=Do(e.pluralRules)&&Ro(t)&&Po(e.pluralRules[t])?e.pluralRules[t]:Ba,r=Do(e.pluralRules)&&Ro(t)&&Po(e.pluralRules[t])?Ba:void 0,i=e.list||[],o=e.named||{};Eo(e.pluralIndex)&&function(e,t){t.count||(t.count=e),t.n||(t.n=e)}(n,o);function a(t){const n=Po(e.messages)?e.messages(t):!!Do(e.messages)&&e.messages[t];return n||(e.parent?e.parent.message(t):Da)}const l=Bo(e.processor)&&Po(e.processor.normalize)?e.processor.normalize:Fa,c=Bo(e.processor)&&Po(e.processor.interpolate)?e.processor.interpolate:ja,u={list:e=>i[e],named:e=>o[e],plural:e=>e[s(n,e.length,r)],linked:(t,...n)=>{const[s,r]=n;let i="text",o="";1===n.length?Do(s)?(o=s.modifier||o,i=s.type||i):Ro(s)&&(o=s||o):2===n.length&&(Ro(s)&&(o=s||o),Ro(r)&&(i=r||i));const l=a(t)(u),c="vnode"===i&&Io(l)&&o?l[0]:l;return o?(d=o,e.modifiers?e.modifiers[d]:Ma)(c,i):c;var d},message:a,type:Bo(e.processor)&&Ro(e.processor.type)?e.processor.type:Ua,interpolate:c,normalize:l,values:Ao({},i,o)};return u}let Wa=null;const Ha=Va("function:translate");function Va(e){return t=>Wa&&Wa.emit(e,t)}const Ka=Zo.__EXTEND_POINT__,za=qo(Ka),Ga={NOT_FOUND_KEY:Ka,FALLBACK_TO_TRANSLATE:za(),CANNOT_FORMAT_NUMBER:za(),FALLBACK_TO_NUMBER_FORMAT:za(),CANNOT_FORMAT_DATE:za(),FALLBACK_TO_DATE_FORMAT:za(),EXPERIMENTAL_CUSTOM_MESSAGE_COMPILER:za(),__EXTEND_POINT__:za()},Ya=ta.__EXTEND_POINT__,Qa=qo(Ya),Xa={INVALID_ARGUMENT:Ya,INVALID_DATE_ARGUMENT:Qa(),INVALID_ISO_DATE_ARGUMENT:Qa(),NOT_SUPPORT_NON_STRING_MESSAGE:Qa(),NOT_SUPPORT_LOCALE_PROMISE_VALUE:Qa(),NOT_SUPPORT_LOCALE_ASYNC_FUNCTION:Qa(),NOT_SUPPORT_LOCALE_TYPE:Qa(),__EXTEND_POINT__:Qa()};function Ja(e){return sa(e,null,void 0)}function Za(e,t){return null!=t.locale?tl(t.locale):tl(e.locale)}let el;function tl(e){if(Ro(e))return e;if(Po(e)){if(e.resolvedOnce&&null!=el)return el;if("Function"===e.constructor.name){const t=e();if(Uo(t))throw Ja(Xa.NOT_SUPPORT_LOCALE_PROMISE_VALUE);return el=t}throw Ja(Xa.NOT_SUPPORT_LOCALE_ASYNC_FUNCTION)}throw Ja(Xa.NOT_SUPPORT_LOCALE_TYPE)}function nl(e,t,n){return[...new Set([n,...Io(t)?t:Do(t)?Object.keys(t):Ro(t)?[t]:[n]])]}function sl(e,t,n){const s=Ro(n)?n:cl,r=e;r.__localeChainCache||(r.__localeChainCache=new Map);let i=r.__localeChainCache.get(s);if(!i){i=[];let e=[n];for(;Io(e);)e=rl(i,e,t);const o=Io(t)||!Bo(t)?t:t.default?t.default:null;e=Ro(o)?[o]:o,Io(e)&&rl(i,e,!1),r.__localeChainCache.set(s,i)}return i}function rl(e,t,n){let s=!0;for(let r=0;r<t.length&&Mo(s);r++){const i=t[r];Ro(i)&&(s=il(e,t[r],n))}return s}function il(e,t,n){let s;const r=t.split("-");do{s=ol(e,r.join("-"),n),r.splice(-1,1)}while(r.length&&!0===s);return s}function ol(e,t,n){let s=!1;if(!e.includes(t)&&(s=!0,t)){s="!"!==t[t.length-1];const r=t.replace(/!/g,"");e.push(r),(Io(n)||Bo(n))&&n[r]&&(s=n[r])}return s}const al="9.13.1",ll=-1,cl="en-US",ul="",dl=e=>`${e.charAt(0).toLocaleUpperCase()}${e.substr(1)}`;let pl,hl,fl;function ml(e){pl=e}let gl=null;const vl=()=>gl;let yl=null;const kl=e=>{yl=e},bl=()=>yl;let wl=0;function _l(e={}){const t=Po(e.onWarn)?e.onWarn:Wo,n=Ro(e.version)?e.version:al,s=Ro(e.locale)||Po(e.locale)?e.locale:cl,r=Po(s)?cl:s,i=Io(e.fallbackLocale)||Bo(e.fallbackLocale)||Ro(e.fallbackLocale)||!1===e.fallbackLocale?e.fallbackLocale:r,o=Bo(e.messages)?e.messages:{[r]:{}},a=Bo(e.datetimeFormats)?e.datetimeFormats:{[r]:{}},l=Bo(e.numberFormats)?e.numberFormats:{[r]:{}},c=Ao({},e.modifiers||{},{upper:(e,t)=>"text"===t&&Ro(e)?e.toUpperCase():"vnode"===t&&Do(e)&&"__v_isVNode"in e?e.children.toUpperCase():e,lower:(e,t)=>"text"===t&&Ro(e)?e.toLowerCase():"vnode"===t&&Do(e)&&"__v_isVNode"in e?e.children.toLowerCase():e,capitalize:(e,t)=>"text"===t&&Ro(e)?dl(e):"vnode"===t&&Do(e)&&"__v_isVNLine truncated
/*!
* vue-i18n v9.13.1
* (c) 2024 kazuya kawaguchi
* Released under the MIT License.
*/
const Gl="9.13.1";const Yl=Ga.__EXTEND_POINT__,Ql=qo(Yl);Ql(),Ql(),Ql(),Ql(),Ql(),Ql(),Ql(),Ql(),Ql();const Xl=Xa.__EXTEND_POINT__,Jl=qo(Xl),Zl={UNEXPECTED_RETURN_TYPE:Xl,INVALID_ARGUMENT:Jl(),MUST_BE_CALL_SETUP_TOP:Jl(),NOT_INSTALLED:Jl(),NOT_AVAILABLE_IN_LEGACY_MODE:Jl(),REQUIRED_VALUE:Jl(),INVALID_VALUE:Jl(),CANNOT_SETUP_VUE_DEVTOOLS_PLUGIN:Jl(),NOT_INSTALLED_WITH_PROVIDE:Jl(),UNEXPECTED_ERROR:Jl(),NOT_COMPATIBLE_LEGACY_VUE_I18N:Jl(),BRIDGE_SUPPORT_VUE_2_ONLY:Jl(),MUST_DEFINE_I18N_OPTION_IN_ALLOW_COMPOSITION:Jl(),NOT_AVAILABLE_COMPOSITION_IN_LEGACY:Jl(),__EXTEND_POINT__:Jl()};function ec(e,...t){return sa(e,null,void 0)}const tc=bo("__translateVNode"),nc=bo("__datetimeParts"),sc=bo("__numberParts"),rc=bo("__setPluralRules"),ic=bo("__injectWithOption"),oc=bo("__dispose");function ac(e){if(!Do(e))return e;for(const t in e)if(No(e,t))if(t.includes(".")){const n=t.split("."),s=n.length-1;let r=e,i=!1;for(let e=0;e<s;e++){if(n[e]in r||(r[n[e]]={}),!Do(r[n[e]])){i=!0;break}r=r[n[e]]}i||(r[n[s]]=e[t],delete e[t]),Do(r[n[s]])&&ac(r[n[s]])}else Do(e[t])&&ac(e[t]);return e}function lc(e,t){const{messages:n,__i18n:s,messageResolver:r,flatJson:i}=t,o=Bo(n)?n:Io(s)?{}:{[e]:{}};if(Io(s)&&s.forEach((e=>{if("locale"in e&&"resource"in e){const{locale:t,resource:n}=e;t?(o[t]=o[t]||{},Vo(n,o[t])):Vo(n,o)}else Ro(e)&&Vo(JSON.parse(e),o)})),null==r&&i)for(const a in o)No(o,a)&&ac(o[a]);return o}function cc(e){return e.type}function uc(e,t,n){let s=Do(t.messages)?t.messages:{};"__i18nGlobal"in n&&(s=lc(e.locale.value,{messages:s,__i18n:n.__i18nGlobal}));const r=Object.keys(s);if(r.length&&r.forEach((t=>{e.mergeLocaleMessage(t,s[t])})),Do(t.datetimeFormats)){const n=Object.keys(t.datetimeFormats);n.length&&n.forEach((n=>{e.mergeDateTimeFormat(n,t.datetimeFormats[n])}))}if(Do(t.numberFormats)){const n=Object.keys(t.numberFormats);n.length&&n.forEach((n=>{e.mergeNumberFormat(n,t.numberFormats[n])}))}}function dc(e){return Lr(fr,null,e,0)}const pc=()=>[],hc=()=>!1;let fc=0;function mc(e){return(t,n,s,r)=>e(n,s,Br()||void 0,r)}function gc(e={},t){const{__root:n,__injectWithOption:s}=e,r=void 0===n,i=e.flatJson,o=ko?Mt:Dt,a=!!e.translateExistCompatible;let l=!Mo(e.inheritLocale)||e.inheritLocale;const c=o(n&&l?n.locale.value:Ro(e.locale)?e.locale:cl),u=o(n&&l?n.fallbackLocale.value:Ro(e.fallbackLocale)||Io(e.fallbackLocale)||Bo(e.fallbackLocale)||!1===e.fallbackLocale?e.fallbackLocale:c.value),d=o(lc(c.value,e)),p=o(Bo(e.datetimeFormats)?e.datetimeFormats:{[c.value]:{}}),h=o(Bo(e.numberFormats)?e.numberFormats:{[c.value]:{}});let f=n?n.missingWarn:!Mo(e.missingWarn)&&!So(e.missingWarn)||e.missingWarn,m=n?n.fallbackWarn:!Mo(e.fallbackWarn)&&!So(e.fallbackWarn)||e.fallbackWarn,g=n?n.fallbackRoot:!Mo(e.fallbackRoot)||e.fallbackRoot,v=!!e.fallbackFormat,y=Po(e.missing)?e.missing:null,k=Po(e.missing)?mc(e.missing):null,b=Po(e.postTranslation)?e.postTranslation:null,w=n?n.warnHtmlMessage:!Mo(e.warnHtmlMessage)||e.warnHtmlMessage,_=!!e.escapeParameter;const E=n?n.modifiers:Bo(e.modifiers)?e.modifiers:{};let x,S=e.pluralRules||n&&n.pluralRules;x=(()=>{r&&kl(null);const t={version:Gl,locale:c.value,fallbackLocale:u.value,messages:d.value,modifiers:E,pluralRules:S,missing:null===k?void 0:k,missingWarn:f,fallbackWarn:m,fallbackFormat:v,unresolving:!0,postTranslation:null===b?void 0:b,warnHtmlMessage:w,escapeParameter:_,messageResolver:e.messageResolver,messageCompiler:e.messageCompiler,__meta:{framework:"vue"}};t.datetimeFormats=p.value,t.numberFormats=h.value,t.__datetimeFormatters=Bo(x)?x.__datetimeFormatters:void 0,t.__numberFormatters=Bo(x)?x.__numberFormatters:void 0;const n=_l(t);return r&&kl(n),n})(),xl(x,c.value,u.value);const C=ei({get:()=>c.value,set:e=>{c.value=e,x.locale=c.value}}),A=ei({get:()=>u.value,set:e=>{u.value=e,x.fallbackLocale=u.value,xl(x,c.value,e)}}),$=ei((()=>d.value)),T=ei((()=>p.value)),L=ei((()=>h.value));const O=(e,t,s,i,o,a)=>{let l;c.value,u.value,d.value,p.value,h.value;try{__INTLIFY_PROD_DEVTOOLS__,r||(x.fallbackContext=n?bl():void 0),l=e(x)}finally{__INTLIFY_PROD_DEVTOOLS__,r||(x.fallbackContext=void 0)}if("translate exists"!==s&&Eo(l)&&l===ll||"translate exists"===s&&!l){const[e,s]=t();return n&&g?i(n):o(e)}if(a(l))return l;throw ec(Zl.UNEXPECTED_RETURN_TYPE)};function N(...e){return O((t=>Reflect.apply(Ml,null,[t,...e])),(()=>Fl(...e)),"translate",(t=>Reflect.apply(t.t,t,[...e])),(e=>e),(e=>Ro(e)))}const I={normalize:function(e){return e.map((e=>Ro(e)||Eo(e)||Mo(e)?dc(String(e)):e))},interpolate:e=>e,type:"vnode"};function P(e){return d.value[e]||{}}fc++,n&&ko&&(Pn(n.locale,(e=>{l&&(c.value=e,x.locale=e,xl(x,c.value,u.value))})),Pn(n.fallbackLocale,(e=>{l&&(u.value=e,x.fallbackLocale=e,xl(x,c.value,u.value))})));const R={id:fc,locale:C,fallbackLocale:A,get inheritLocale(){return l},set inheritLocale(e){l=e,e&&n&&(c.value=n.locale.value,u.value=n.fallbackLocale.value,xl(x,c.value,u.value))},get availableLocales(){return Object.keys(d.value).sort()},messages:$,get modifiers(){return E},get pluralRules(){reLine truncated
/**
* @title Artalk API
* @version 2.0
* @license MIT (https://github.com/ArtalkJS/Artalk/blob/master/LICENSE)
* @baseUrl /api/v2
* @contact API Support <artalkjs@gmail.com> (https://artalk.js.org)
*
* Artalk is a modern comment system based on Golang.
*/let Xc=class extends Qc{constructor(){super(...arguments),Kc(this,"auth",{loginByEmail:(e,t={})=>this.request(Hc({path:"/auth/email/login",method:"POST",body:e,type:"application/json",format:"json"},t)),registerByEmail:(e,t={})=>this.request(Hc({path:"/auth/email/register",method:"POST",body:e,type:"application/json",format:"json"},t)),sendVerifyEmail:(e,t={})=>this.request(Hc({path:"/auth/email/send",method:"POST",body:e,type:"application/json",format:"json"},t)),checkDataMerge:(e={})=>this.request(Hc({path:"/auth/merge",method:"GET",secure:!0,format:"json"},e)),applyDataMerge:(e,t={})=>this.request(Hc({path:"/auth/merge",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t))}),Kc(this,"cache",{flushCache:(e={})=>this.request(Hc({path:"/cache/flush",method:"POST",secure:!0,format:"json"},e)),warmUpCache:(e={})=>this.request(Hc({path:"/cache/warm_up",method:"POST",secure:!0,format:"json"},e))}),Kc(this,"captcha",{getCaptcha:(e={})=>this.request(Hc({path:"/captcha",method:"GET",format:"json"},e)),getCaptchaStatus:(e={})=>this.request(Hc({path:"/captcha/status",method:"GET",format:"json"},e)),verifyCaptcha:(e,t={})=>this.request(Hc({path:"/captcha/verify",method:"POST",body:e,type:"application/json",format:"json"},t))}),Kc(this,"comments",{getComments:(e,t={})=>this.request(Hc({path:"/comments",method:"GET",query:e,secure:!0,type:"application/json",format:"json"},t)),createComment:(e,t={})=>this.request(Hc({path:"/comments",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t)),getComment:(e,t={})=>this.request(Hc({path:`/comments/${e}`,method:"GET",type:"application/json",format:"json"},t)),updateComment:(e,t,n={})=>this.request(Hc({path:`/comments/${e}`,method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},n)),deleteComment:(e,t={})=>this.request(Hc({path:`/comments/${e}`,method:"DELETE",secure:!0,format:"json"},t))}),Kc(this,"conf",{conf:(e={})=>this.request(Hc({path:"/conf",method:"GET",format:"json"},e)),getSocialLoginProviders:(e={})=>this.request(Hc({path:"/conf/auth/providers",method:"GET",format:"json"},e)),getDomain:(e,t={})=>this.request(Hc({path:"/conf/domain",method:"GET",query:e,format:"json"},t))}),Kc(this,"notifies",{getNotifies:(e,t={})=>this.request(Hc({path:"/notifies",method:"GET",query:e,type:"application/json",format:"json"},t)),markAllNotifyRead:(e,t={})=>this.request(Hc({path:"/notifies/read",method:"POST",body:e,type:"application/json",format:"json"},t)),markNotifyRead:(e,t,n={})=>this.request(Hc({path:`/notifies/${e}/${t}`,method:"POST",format:"json"},n))}),Kc(this,"pages",{getPages:(e,t={})=>this.request(Hc({path:"/pages",method:"GET",query:e,secure:!0,type:"application/json",format:"json"},t)),fetchAllPages:(e,t={})=>this.request(Hc({path:"/pages/fetch",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t)),getPageFetchStatus:(e={})=>this.request(Hc({path:"/pages/fetch/status",method:"GET",secure:!0,format:"json"},e)),logPv:(e,t={})=>this.request(Hc({path:"/pages/pv",method:"POST",body:e,type:"application/json",format:"json"},t)),updatePage:(e,t,n={})=>this.request(Hc({path:`/pages/${e}`,method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},n)),deletePage:(e,t={})=>this.request(Hc({path:`/pages/${e}`,method:"DELETE",secure:!0,format:"json"},t)),fetchPage:(e,t={})=>this.request(Hc({path:`/pages/${e}/fetch`,method:"POST",secure:!0,type:"application/json",format:"json"},t))}),Kc(this,"sendEmail",{sendEmail:(e,t={})=>this.request(Hc({path:"/send_email",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t))}),Kc(this,"settings",{getSettings:(e={})=>this.request(Hc({path:"/settings",method:"GET",secure:!0,format:"json"},e)),applySettings:(e,t={})=>this.request(Hc({path:"/settings",method:"PUT",body:e,secure:!0,type:"application/json",format:"json"},t)),getSettingsTemplate:(e,t={})=>this.request(Hc({path:`/settings/template/${e}`,method:"GET",secure:!0,format:"json"},t))}),Kc(this,"sites",{getSites:(e={})=>this.request(Hc({path:"/sites",method:"GET",secure:!0,format:"json"},e)),createSite:(e,t={})=>this.request(Hc({path:"/sites",method:"POST",body:e,secure:!0,type:"application/json",format:"json"},t)),updateSite:(e,t,n={})=>this.request(Hc({path:`/sites/${e}`,method:"PUT",body:t,secure:!0,type:"application/json",format:"json"},n)),deleteSite:(e,t={})=>this.request(Hc({path:`/sites/${e}`,method:"DELETE",secure:!0,format:"json"},t))}),Kc(this,"stats",{getStats:(e,t,n={})=>this.request(Hc({path:`/stats/${e}`,method:"GET",query:t,type:"application/json",format:"json"},n))}),Kc(this,"transfer",{exportArtrans:(e={})=>this.request(Hc({path:"/transfer/export",method:"GET",secure:!0,format:"json"},e)),importArtrans:(e,t={})=>this.request(Hc({path:"/transfer/import",method:"POST",body:e,secure:!0,type:"application/json"},t)),uploadArtrans:(e,t={})=>this.request(Hc({path:"/transfer/upload",method:"POST",body:e,secure:!0,type:"multipart/form-data",format:"json"},t))}),Kc(this,"upload",{uploadLine truncated
@@ -0,0 +1 @@
@charset "UTF-8";.atk-slim-scrollbar[data-v-ae18da71]::-webkit-scrollbar{width:4px;height:4px;background:transparent}.atk-slim-scrollbar[data-v-ae18da71]::-webkit-scrollbar-thumb,.atk-slim-scrollbar[data-v-ae18da71]::-webkit-scrollbar-thumb:window-inactive{background:#5656564d}.atk-slim-scrollbar[data-v-ae18da71]::-webkit-scrollbar-thumb:vertical:hover{background:#414a52c4}.atk-slim-scrollbar[data-v-ae18da71]::-webkit-scrollbar-thumb:vertical:active{background:#292f35c4}.atk-list-text-actions[data-v-ae18da71]{display:flex;flex-direction:column;flex:auto;padding:10px 35px 0;flex-wrap:wrap;align-content:flex-start}.atk-list-text-actions .atk-item[data-v-ae18da71]{position:relative;font-size:17px;display:inline-flex;margin-right:15px;margin-bottom:6px;cursor:pointer;user-select:none}.atk-list-text-actions .atk-item.atk-green[data-v-ae18da71]:after{position:absolute;content:" ";height:5px;width:100%;left:0;bottom:5px;background:#32c78663}.atk-list-text-actions .atk-item.atk-yellow[data-v-ae18da71]:after{position:absolute;content:" ";height:5px;width:100%;left:0;bottom:5px;background:#ffcb0063}.atk-list-btn-actions[data-v-ae18da71]{display:flex;flex-direction:row;align-items:center;padding:0 20px}.atk-list-btn-actions .atk-item[data-v-ae18da71]{cursor:pointer;user-select:none;display:flex;width:35px;height:35px;border-radius:2px;align-items:center;justify-content:center}.atk-list-btn-actions .atk-item[data-v-ae18da71]:hover{background:var(--at-color-bg-grey)}.atk-list-btn-actions .atk-item[data-v-ae18da71]:not(:last-child){margin-right:15px}.atk-list-btn-actions .atk-item.atk-item-close[data-v-ae18da71]{margin-left:10px}.atk-list-btn-actions .atk-icon[data-v-ae18da71]:after{background-color:var(--at-color-meta)}.atk-list-btn-actions .atk-icon-yes[data-v-ae18da71]:after{background-color:var(--at-color-main)}.comments-wrap[data-v-ae18da71]{overflow-y:auto;height:100%}.comments-wrap[data-v-ae18da71] .atk-comment-wrap{border-bottom:1px solid var(--at-color-border)}.atk-slim-scrollbar[data-v-bcddeb94]::-webkit-scrollbar{width:4px;height:4px;background:transparent}.atk-slim-scrollbar[data-v-bcddeb94]::-webkit-scrollbar-thumb,.atk-slim-scrollbar[data-v-bcddeb94]::-webkit-scrollbar-thumb:window-inactive{background:#5656564d}.atk-slim-scrollbar[data-v-bcddeb94]::-webkit-scrollbar-thumb:vertical:hover{background:#414a52c4}.atk-slim-scrollbar[data-v-bcddeb94]::-webkit-scrollbar-thumb:vertical:active{background:#292f35c4}.atk-list-text-actions[data-v-bcddeb94]{display:flex;flex-direction:column;flex:auto;padding:10px 35px 0;flex-wrap:wrap;align-content:flex-start}.atk-list-text-actions .atk-item[data-v-bcddeb94]{position:relative;font-size:17px;display:inline-flex;margin-right:15px;margin-bottom:6px;cursor:pointer;user-select:none}.atk-list-text-actions .atk-item.atk-green[data-v-bcddeb94]:after{position:absolute;content:" ";height:5px;width:100%;left:0;bottom:5px;background:#32c78663}.atk-list-text-actions .atk-item.atk-yellow[data-v-bcddeb94]:after{position:absolute;content:" ";height:5px;width:100%;left:0;bottom:5px;background:#ffcb0063}.atk-list-btn-actions[data-v-bcddeb94]{display:flex;flex-direction:row;align-items:center;padding:0 20px}.atk-list-btn-actions .atk-item[data-v-bcddeb94]{cursor:pointer;user-select:none;display:flex;width:35px;height:35px;border-radius:2px;align-items:center;justify-content:center}.atk-list-btn-actions .atk-item[data-v-bcddeb94]:hover{background:var(--at-color-bg-grey)}.atk-list-btn-actions .atk-item[data-v-bcddeb94]:not(:last-child){margin-right:15px}.atk-list-btn-actions .atk-item.atk-item-close[data-v-bcddeb94]{margin-left:10px}.atk-list-btn-actions .atk-icon[data-v-bcddeb94]:after{background-color:var(--at-color-meta)}.atk-list-btn-actions .atk-icon-yes[data-v-bcddeb94]:after{background-color:var(--at-color-main)}.login-dialog[data-v-bcddeb94]{position:fixed;display:flex;flex-direction:column;place-items:center;justify-content:center;top:0;left:0;background:var(--at-color-bg);width:100%;height:100%}.login-dialog .logo[data-v-bcddeb94]{user-select:none;display:inline-block;width:50px;height:50px;margin-bottom:50px;margin-top:-50px;border-radius:2px}.login-dialog .copyright[data-v-bcddeb94]{font-size:13px;color:var(--at-color-meta);position:fixed;height:30px;bottom:0;width:100%;text-align:center}.login-form[data-v-bcddeb94]{display:flex;flex-direction:column;width:280px}.login-form input[data-v-bcddeb94]{display:block;border:0;background:transparent;border-bottom:1px solid var(--at-color-border);padding:0 20px;line-height:40px;margin-bottom:10px}.login-form input[data-v-bcddeb94]:focus{outline:none}.login-form button[data-v-bcddeb94]{color:#fff;background:#6e8392;border-radius:2px;cursor:pointer;border:0;line-height:35px;padding:0 20px;margin-top:20px;display:block}.login-form button[data-v-bcddeb94]:hover{opacity:.95}.login-form .err-msg[data-v-bcddeb94]{display:flex;flex-direction:row;align-items:center;margin:5px 10px 0;padding:0 10px;color:var(--at-color-red)}.login-form .err-msg[dLine truncated
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="strict-origin" />
<title>Artalk Sidebar</title>
<script type="module" crossorigin src="./assets/index-DWDx-G2X.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-DZ-EIihu.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+139
View File
@@ -0,0 +1,139 @@
-- ===========================================================================
-- artalk-cf —— D1 / SQLite schema
-- 执行:npx wrangler d1 execute artalk-cf --remote --file=./schema.sql
-- 设计与 Artalk v2 的 gorm 模型对应,但时间统一存 unix 毫秒(INTEGER),
-- 输出时再按 Asia/Shanghai 格式化成 "YYYY-MM-DD HH:mm:ss"。
-- ===========================================================================
CREATE TABLE IF NOT EXISTS sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
urls TEXT NOT NULL DEFAULT '', -- 逗号分隔
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL, -- Artalk 的 page_key,如 /20210911.html
site_name TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
admin_only INTEGER NOT NULL DEFAULT 0,
pv INTEGER NOT NULL DEFAULT 0,
vote_up INTEGER NOT NULL DEFAULT 0,
vote_down INTEGER NOT NULL DEFAULT 0,
accessible_url TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_pages_key_site ON pages (key, site_name);
CREATE INDEX IF NOT EXISTS idx_pages_site ON pages (site_name);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL,
link TEXT NOT NULL DEFAULT '',
password TEXT NOT NULL DEFAULT '', -- 空 = 无密码;支持 (pbkdf2) / (md5) / 明文(兼容)
badge_name TEXT NOT NULL DEFAULT '',
badge_color TEXT NOT NULL DEFAULT '',
title_name TEXT NOT NULL DEFAULT '', -- 头衔徽章(金色,优先级仅次于系统徽章)
last_ip TEXT NOT NULL DEFAULT '',
last_ua TEXT NOT NULL DEFAULT '',
is_admin INTEGER NOT NULL DEFAULT 0,
receive_email INTEGER NOT NULL DEFAULT 1,
is_in_conf INTEGER NOT NULL DEFAULT 0,
token_valid_from INTEGER NOT NULL DEFAULT 0, -- 早于此刻签发的 token 失效(改密码后踢下线)
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_name_email ON users (name, email);
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
content TEXT NOT NULL,
page_key TEXT NOT NULL,
site_name TEXT NOT NULL,
user_id INTEGER NOT NULL,
rid INTEGER NOT NULL DEFAULT 0, -- 直接父节点 id(0 = 顶层)
root_id INTEGER NOT NULL DEFAULT 0, -- 顶层祖先 id(顶层为 0)
is_collapsed INTEGER NOT NULL DEFAULT 0,
is_pending INTEGER NOT NULL DEFAULT 0,
is_pinned INTEGER NOT NULL DEFAULT 0,
is_verified INTEGER NOT NULL DEFAULT 0,
vote_up INTEGER NOT NULL DEFAULT 0,
vote_down INTEGER NOT NULL DEFAULT 0,
ua TEXT NOT NULL DEFAULT '',
ip TEXT NOT NULL DEFAULT '',
ip_region TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
deleted_at INTEGER NOT NULL DEFAULT 0 -- 软删除,0 = 未删除
);
CREATE INDEX IF NOT EXISTS idx_comments_page ON comments (page_key, site_name, deleted_at);
CREATE INDEX IF NOT EXISTS idx_comments_root ON comments (root_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_comments_user ON comments (user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_comments_created ON comments (created_at);
-- 列表/计数覆盖索引:原来列表要 ORDER BY is_pinned DESC, created_at DESC,
-- 没有匹配索引 → SQLite 每次全表扫 ~1000 行(实测列表 940 行/次、计数 1500-2000 行/次,
-- 是 D1 免费额度被打爆的主因)。带上这些列后只读命中行。
CREATE INDEX IF NOT EXISTS idx_comments_page_list
ON comments (site_name, page_key, deleted_at, is_pending, is_pinned DESC, created_at DESC, id DESC);
-- 子评论拉取(root_id IN (...))专用:让 SQLite 按 root_id seek,而不是扫整页
CREATE INDEX IF NOT EXISTS idx_comments_children
ON comments (root_id, deleted_at, is_pending, created_at, id);
CREATE INDEX IF NOT EXISTS idx_comments_site_list
ON comments (site_name, deleted_at, is_pending, created_at DESC, id DESC);
CREATE TABLE IF NOT EXISTS votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
type TEXT NOT NULL, -- 'comment' | 'page'
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_votes_unique ON votes (target_id, user_id, type);
CREATE TABLE IF NOT EXISTS notifies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
comment_id INTEGER NOT NULL,
is_read INTEGER NOT NULL DEFAULT 0,
is_emailed INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_notifies_user ON notifies (user_id, is_read);
-- 键值配置:frontend_conf / admin_users / moderator / captcha / ip_region / ...
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
-- 简单限流:按 IP + 动作统计,60 秒窗口
CREATE TABLE IF NOT EXISTS rate_limits (
bucket TEXT PRIMARY KEY, -- 如 "comment:1.2.3.4"
count INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_rate_expires ON rate_limits (expires_at);
-- 验证码通过状态(按 IP 记,跨域请求带不上 cookie,所以放服务端)
CREATE TABLE IF NOT EXISTS captcha_passes (
ip TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_captcha_expires ON captcha_passes (expires_at);
-- 验证码题目:answer 只存哈希,防止有人直接读库
CREATE TABLE IF NOT EXISTS captcha_challenges (
id TEXT PRIMARY KEY,
answer_hash TEXT NOT NULL,
ip TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_captcha_ch_expires ON captcha_challenges (expires_at);
+184
View File
@@ -0,0 +1,184 @@
import type { Env } from './types';
import { Router } from './router';
import { corsHeaders } from './lib/util';
import { friendlyError } from './lib/errors';
import { gcCaptcha, gcRateLimits } from './lib/db';
import { checkProxyHealth, notifyProxyDown } from './lib/rss/proxy-health';
import { RssApi } from './rss-api';
import * as C from './routes/comments';
import * as P from './routes/public';
import * as U from './routes/user';
import * as A from './routes/admin';
import * as H from './routes/human';
const router = new Router();
// ---------------------------------------------------------------- 公共 / 配置
router.all('/conf', P.getConf);
router.get('/version', P.getVersion);
router.get('/conf/domain', P.getConfDomain);
router.get('/conf/auth/providers', P.getAuthProviders);
router.get('/healthz', P.healthz);
router.post('/setup', P.setup);
// ---------------------------------------------------------------- 验证码
router.get('/captcha', P.getCaptcha);
router.get('/captcha/status', P.getCaptchaStatus);
router.post('/captcha/verify', P.verifyCaptcha);
// ------------------------------------------------- 人机验证(自研「一键验证」)
router.get('/human/challenge', H.humanChallenge);
router.post('/human/verify', H.humanVerify);
router.get('/human/status', H.humanStatus);
// ---------------------------------------------------------------- 评论
router.get('/comments', C.listComments);
router.post('/comments', C.createComment);
router.get('/comments/:id', C.getComment);
router.put('/comments/:id', C.updateComment);
router.del('/comments/:id', C.deleteComment);
// ---------------------------------------------------------------- 投票 / 浏览量
router.get('/votes/:target_name/:target_id', P.voteGet);
router.post('/votes/:target_name/:target_id/:choice', P.voteCreate);
router.post('/votes/sync', P.voteSync);
router.post('/pages/pv', P.pagePV);
// ---------------------------------------------------------------- 用户 / 登录
router.get('/user', U.userInfo);
router.post('/user', U.userUpdate);
router.get('/user/status', U.userStatus);
router.post('/user/access_token', U.userAccessToken);
router.post('/auth/email/login', U.authEmailLogin);
router.post('/auth/email/send', U.authEmailSend);
router.post('/auth/email/register', U.authEmailRegister);
router.get('/auth/merge', U.authMergeCheck);
router.post('/auth/merge', U.authMergeApply);
router.post('/sso/exchange', U.ssoExchange);
// ---------------------------------------------------------------- 后台:设置
router.get('/settings', A.settingGet);
router.put('/settings', A.settingApply);
router.get('/settings/template/:locale', A.settingTemplate);
// ---------------------------------------------------------------- 后台:站点
router.get('/sites', A.siteList);
router.post('/sites', A.siteCreate);
router.put('/sites/:id', A.siteUpdate);
router.del('/sites/:id', A.siteDelete);
// ---------------------------------------------------------------- 后台:页面
router.get('/pages', A.pageList);
router.post('/pages/fetch', A.pageFetch);
router.get('/pages/fetch/status', A.pageFetchStatus);
router.post('/pages/:id/fetch', A.pageFetch);
router.put('/pages/:id', A.pageUpdate);
router.del('/pages/:id', A.pageDelete);
// ---------------------------------------------------------------- 后台:用户
router.post('/users', A.userCreate);
router.get('/users/:type', A.userList);
router.put('/users/:id', A.userUpdateAdmin);
router.del('/users/:id', A.userDeleteAdmin);
// ---------------------------------------------------------------- 后台:消息 / 统计 / 缓存
router.get('/notifies', A.notifyList);
router.post('/notifies/read', A.notifyReadAll);
router.post('/notifies/:comment_id/:notify_key', A.notifyReadOne);
router.get('/stats/:type', A.stat);
router.post('/cache/flush', A.cacheFlush);
router.post('/cache/warm_up', A.cacheWarmUp);
// ---------------------------------------------------------------- 后台:上传 / 迁移
router.post('/upload', A.upload);
router.get('/transfer/export', A.transferExport);
router.post('/transfer/import', A.transferImport);
router.post('/transfer/upload', A.transferUpload);
export default {
async fetch(req: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
try {
// R2 里的图片:/file/** 或 /api/v2/file/**
if (req.method === 'GET' && /^(\/api\/v2)?\/file\//.test(url.pathname)) {
return A.serveFile({
env,
req,
url,
params: {},
user: null,
});
}
// RSS 订阅模块(rss-robot 合并进来):/api/*(Artalk 用 /api/v2/* 不会撞)
// + /feed 订阅源管理页 + 微信验证文件
const p = url.pathname;
if (
(p.startsWith('/api/') && !p.startsWith('/api/v2')) ||
p === '/feed' ||
p.startsWith('/MP_verify_')
) {
return RssApi.fetch(req, env, ctx);
}
return router.dispatch(req, env, ctx);
} catch (e) {
// 兜底:任何漏网的异常也必须带 CORS 头,否则浏览器把它当"网络故障",
// 读者只能看到 "TypeError: Failed to fetch",根本不知道发生了什么。
const err = friendlyError(e);
console.error('[artalk-cf] unhandled:', err.code, err.detail);
let cors = new Headers();
try {
cors = corsHeaders(req, env);
} catch {
cors.set('Access-Control-Allow-Origin', '*');
}
cors.set('Content-Type', 'application/json; charset=utf-8');
cors.set('Cache-Control', 'no-store');
return new Response(
JSON.stringify({ msg: err.msg, code: err.code, detail: err.detail }),
{ status: err.status, headers: cors },
);
}
},
// 定时任务:0 * * * * = RSS 轮转抓取(每小时一批,约 3 小时覆盖全部源)
// 17 3 * * * = 评论 GC(限流/验证码/healthz 缓存)
async scheduled(event: ScheduledController, env: Env): Promise<void> {
if (event.cron === '0 * * * *') {
await RssApi.scheduled(event, env, undefined as unknown as ExecutionContext);
return;
}
try {
await gcRateLimits(env);
await gcCaptcha(env);
// 顺带刷新 healthz 用的评论数缓存:COUNT(*) 是全表扫描,
// 放在 healthz 里每次请求都烧几千行 rows_read,挪到这里一天只烧一次
const n = await env.DB.prepare('SELECT COUNT(*) AS n FROM comments WHERE deleted_at = 0')
.first<{ n: number }>();
if (n?.n != null) {
await env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('healthz_comments', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.bind(JSON.stringify(n.n), Date.now())
.run();
}
} catch (e) {
console.error('[artalk-cf] scheduled gc failed:', e instanceof Error ? e.message : e);
}
// 国内代理体检(每天一次):挂掉就给站长发提醒邮件。
// 刻意放在服务器端 —— 站长电脑关着也能收到,不依赖本地定时任务。
try {
const health = await checkProxyHealth(env);
if (!health.ok) {
const r = await notifyProxyDown(env, health);
console.log('[artalk-cf] proxy down, notify:', JSON.stringify(r));
}
} catch (e) {
console.error('[artalk-cf] proxy health check failed:', e instanceof Error ? e.message : e);
}
},
};
+85
View File
@@ -0,0 +1,85 @@
// 给管理员发提醒邮件(带节流)。
//
// 用途:定时任务(证书/代理体检、用量告警等)需要给站长发提醒,
// 但又不该每天重复刷屏。收件人固定为 MAIL_ADMIN,发件人默认「小赵」。
//
// 节流:同一 dedupeKey 在 minGapHours 内只发一次,时间戳存 KV `notify:last:<key>`。
import type { Env } from '../types';
import { sendMail } from './mail';
export interface AdminNotifyInput {
subject: string;
text: string;
/** 同一 key 在 minGapHours 内只发一次;不传则不节流 */
dedupeKey?: string;
minGapHours?: number;
fromName?: string;
}
export interface AdminNotifyResult {
ok: boolean;
sent: boolean;
throttled?: boolean;
lastAt?: string;
error?: string;
}
function escapeHtml(x: string): string {
return x.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
export function textToHtml(subject: string, text: string): string {
const paras = text
.split('\n')
.map((l) =>
l.trim()
? `<p style="margin:0 0 8px">${escapeHtml(l)}</p>`
: '<p style="margin:0 0 8px">&nbsp;</p>',
)
.join('');
return (
`<div style="max-width:560px;margin:0 auto;padding:20px;font-family:'PingFang SC','Microsoft YaHei',sans-serif;color:#253830">` +
`<div style="font-size:15px;font-weight:600;color:#2f9e63;margin-bottom:12px">${escapeHtml(subject)}</div>` +
`<div style="background:#fff;border:1px solid #e7e9ee;border-radius:10px;padding:16px;font-size:14px;line-height:1.7">${paras}</div>` +
`<div style="font-size:12px;color:#8a94a6;margin-top:12px">来自 artalk-cf 定时提醒</div></div>`
);
}
export async function notifyAdmin(env: Env, input: AdminNotifyInput): Promise<AdminNotifyResult> {
const to = (env.MAIL_ADMIN || '').trim();
if (!to) return { ok: false, sent: false, error: 'MAIL_ADMIN not configured' };
const subject = String(input.subject || '').trim().slice(0, 160) || '提醒';
const text = String(input.text || '').trim().slice(0, 8000);
if (!text) return { ok: false, sent: false, error: 'text required' };
const dedupeKey = String(input.dedupeKey || '').trim().slice(0, 60);
const minGapHours = Math.min(Math.max(input.minGapHours || 0, 0), 24 * 30);
const throttleKey = dedupeKey ? `notify:last:${dedupeKey}` : '';
if (throttleKey && minGapHours > 0) {
const last = await env.RSS_KV.get(throttleKey);
const lastAt = last ? parseInt(last, 10) : 0;
if (lastAt && Date.now() - lastAt < minGapHours * 3600 * 1000) {
return { ok: true, sent: false, throttled: true, lastAt: new Date(lastAt).toISOString() };
}
}
const sent = await sendMail(env, {
to,
subject,
html: textToHtml(subject, text),
text,
fromName: input.fromName || '小赵',
});
if (sent && throttleKey) {
try {
await env.RSS_KV.put(throttleKey, String(Date.now()), { expirationTtl: 60 * 60 * 24 * 90 });
} catch {
/* 节流时间戳写失败不影响已发出的邮件 */
}
}
return { ok: sent, sent };
}
+256
View File
@@ -0,0 +1,256 @@
import type {
CommentRow,
CookedComment,
CookedNotify,
CookedPage,
CookedSite,
CookedUser,
Env,
PageRow,
SiteRow,
UserRow,
} from '../types';
import { findPagesByKeys, findUsersByIds, siteFirstUrl } from './db';
import { renderMarkdown } from './md';
import { md5Lower } from './md5';
import { formatDateCN } from './util';
// ============================================================================
// 评论等级(九品十八阶)——与博客主题 Ying 的 RANK_TABLE 保持一致。
// 主题原来用 localStorage 自己累计(换设备/清缓存就归零、别人看到的也不准),
// 现在改成后端按全站真实评论数算好,直接塞进官方 badge_name/badge_color
// 字段——官方前端原生渲染白字彩底徽章,跨设备全局一致。
// 只对「没有自定义徽章」的用户生效;博主等保留原徽章不覆盖。
// ============================================================================
interface Rank {
min: number;
title: string;
short: string;
/** 徽章文字色(与主题原版配色一致) */
color: string;
/** 徽章半透明底色(与主题原版配色一致) */
bg: string;
}
const RANK_TABLE: Rank[] = [
{ min: 2000, title: '正一品·太师', short: '太师', color: '#c5881b', bg: 'rgba(197,136,27,0.16)' },
{ min: 1400, title: '从一品·太尉', short: '太尉', color: '#cc942a', bg: 'rgba(204,148,42,0.13)' },
{ min: 1050, title: '正二品·参知政事', short: '参知政事', color: '#d46d33', bg: 'rgba(212,109,51,0.16)' },
{ min: 800, title: '从二品·节度使', short: '节度使', color: '#d87940', bg: 'rgba(216,121,64,0.13)' },
{ min: 600, title: '正三品·御史中丞', short: '御史中丞', color: '#cf4a58', bg: 'rgba(207,74,88,0.16)' },
{ min: 480, title: '从三品·秘书监', short: '秘书监', color: '#d45d68', bg: 'rgba(212,93,104,0.13)' },
{ min: 380, title: '正四品·谏议大夫', short: '谏议大夫', color: '#b05385', bg: 'rgba(176,83,133,0.16)' },
{ min: 300, title: '从四品·侍读学士', short: '侍读学士', color: '#b8638f', bg: 'rgba(184,99,143,0.13)' },
{ min: 240, title: '正五品·给事中', short: '给事中', color: '#9570d6', bg: 'rgba(149,112,214,0.16)' },
{ min: 190, title: '从五品·知州', short: '知州', color: '#a07bda', bg: 'rgba(160,123,218,0.13)' },
{ min: 150, title: '正六品·侍御史', short: '侍御史', color: '#5f7ace', bg: 'rgba(95,122,206,0.16)' },
{ min: 115, title: '从六品·通判', short: '通判', color: '#6b85d0', bg: 'rgba(107,133,208,0.13)' },
{ min: 85, title: '正七品·知县', short: '知县', color: '#2a9a84', bg: 'rgba(42,154,132,0.16)' },
{ min: 60, title: '从七品·殿中侍御史', short: '殿中侍御史', color: '#34a390', bg: 'rgba(52,163,144,0.13)' },
{ min: 40, title: '正八品·大理评事', short: '大理评事', color: '#628530', bg: 'rgba(98,133,48,0.16)' },
{ min: 25, title: '从八品·录事参军', short: '录事参军', color: '#6e913d', bg: 'rgba(110,145,61,0.13)' },
{ min: 15, title: '正九品·主簿', short: '主簿', color: '#8a7050', bg: 'rgba(138,112,80,0.16)' },
{ min: 5, title: '从九品·司户参军', short: '司户参军', color: '#917a5c', bg: 'rgba(145,122,92,0.13)' },
{ min: 0, title: '庶民', short: '庶民', color: '#8a8e98', bg: 'rgba(138,142,152,0.10)' },
];
function getRank(count: number): Rank {
for (const r of RANK_TABLE) {
if (count >= r.min) return r;
}
return RANK_TABLE[RANK_TABLE.length - 1];
}
// Worker isolate 级缓存:评论数变化慢,同一实例的后续请求零 D1 消耗。
// (D1 免费额度按 rows_read 计费,GROUP BY 全表扫很贵,必须挡在缓存后面)
const RANK_CACHE_TTL = 10 * 60 * 1000;
const rankCache = ((globalThis as Record<string, unknown>).__atkRankCache ??= new Map<
number,
{ n: number; t: number }
>()) as Map<number, { n: number; t: number }>;
async function getCommentCounts(env: Env, userIds: number[]): Promise<Map<number, number>> {
const out = new Map<number, number>();
const missing: number[] = [];
const now = Date.now();
for (const id of userIds) {
const hit = rankCache.get(id);
if (hit && now - hit.t < RANK_CACHE_TTL) out.set(id, hit.n);
else missing.push(id);
}
if (missing.length) {
const rows = (
await env.DB.prepare(
`SELECT user_id, COUNT(*) AS n FROM comments
WHERE deleted_at = 0 AND user_id IN (${missing.map(() => '?').join(',')})
GROUP BY user_id`,
)
.bind(...missing)
.all<{ user_id: number; n: number }>()
.catch(() => ({ results: [] as { user_id: number; n: number }[] }))
).results;
for (const r of rows ?? []) {
out.set(r.user_id, r.n);
rankCache.set(r.user_id, { n: r.n, t: now });
}
// 查不到的(0 条评论)也缓存,避免反复查
for (const id of missing) {
if (!out.has(id)) {
out.set(id, 0);
rankCache.set(id, { n: 0, t: now });
}
}
}
return out;
}
/** page_key 是相对路径时,拼到站点地址后面(与 Artalk GetPageAccessibleURL 一致) */
export function pageAccessibleUrl(pageKey: string, siteUrl: string): string {
if (/^https?:\/\//i.test(pageKey)) return pageKey;
if (!siteUrl) return pageKey;
try {
return new URL(pageKey, siteUrl.endsWith('/') ? siteUrl : `${siteUrl}/`).toString();
} catch {
return pageKey;
}
}
export function cookUser(u: UserRow): CookedUser {
return {
id: u.id,
name: u.name,
email: u.email,
link: u.link || '',
badge_name: u.badge_name || '',
badge_color: u.badge_color || '',
title_name: u.title_name || '',
is_admin: !!u.is_admin,
receive_email: !!u.receive_email,
};
}
export function cookSite(s: SiteRow): CookedSite {
const urls = (s.urls || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean);
return { id: s.id, name: s.name, urls, urls_raw: s.urls || '', first_url: urls[0] || '' };
}
export function cookPage(p: PageRow, siteUrl = ''): CookedPage {
return {
id: p.id,
admin_only: !!p.admin_only,
key: p.key,
url: p.accessible_url || pageAccessibleUrl(p.key, siteUrl),
title: p.title || '',
site_name: p.site_name,
vote_up: p.vote_up || 0,
vote_down: p.vote_down || 0,
pv: p.pv || 0,
date: formatDateCN(p.created_at),
};
}
export function cookNotify(n: {
id: number;
user_id: number;
comment_id: number;
is_read: number;
is_emailed: number;
}, readLink = ''): CookedNotify {
return {
id: n.id,
user_id: n.user_id,
comment_id: n.comment_id,
is_read: !!n.is_read,
is_emailed: !!n.is_emailed,
read_link: readLink,
};
}
/**
* 批量把评论行转成 API 结构。
* 一次性把用到的 user / page 捞出来,避免 N+1(Worker 里每次 D1 查询都是成本)。
*
* ★ includeMarked 默认 false:content_marked 是「把 content 再渲染一遍成 HTML」,
* 体积几乎翻倍,而且 **Artalk 2.8.x 客户端根本不消费它**(它自带 marked,在浏览器里
* 渲染 content;只有官方后台 sidebar 会用到)。实测 20 条评论 36KB,
* 去掉后能砍掉一大半,跨境加载明显更快。
* → 只有管理员请求(后台)才带上这个字段。
*/
export async function cookComments(
env: Env,
rows: CommentRow[],
opts: { ipRegion?: boolean; visibility?: Map<number, boolean>; includeMarked?: boolean } = {},
): Promise<CookedComment[]> {
if (!rows.length) return [];
const users = await findUsersByIds(
env,
rows.map((r) => r.user_id),
);
// 评论等级:批量取本批用户的真实评论数(带 isolate 缓存)
const distinctUserIds = [...new Set(rows.map((r) => r.user_id))];
const commentCounts = await getCommentCounts(env, distinctUserIds);
const bySite = new Map<string, string[]>();
for (const r of rows) {
const arr = bySite.get(r.site_name) ?? [];
arr.push(r.page_key);
bySite.set(r.site_name, arr);
}
const pageMap = new Map<string, PageRow>();
const siteUrlMap = new Map<string, string>();
for (const [siteName, keys] of bySite) {
for (const [k, v] of await findPagesByKeys(env, keys, siteName)) {
pageMap.set(`${siteName}\u0000${k}`, v);
}
siteUrlMap.set(siteName, await siteFirstUrl(env, siteName));
}
return rows.map((c) => {
const user = users.get(c.user_id);
const siteUrl = siteUrlMap.get(c.site_name) || '';
const page = pageMap.get(`${c.site_name}\u0000${c.page_key}`);
const cook: CookedComment = {
id: c.id,
content: c.content,
content_marked: opts.includeMarked ? renderMarkdown(c.content) : '',
user_id: c.user_id,
nick: user?.name ?? '',
email_encrypted: md5Lower(user?.email ?? ''),
link: user?.link ?? '',
ua: c.ua || '',
date: formatDateCN(c.created_at),
is_collapsed: !!c.is_collapsed,
is_pending: !!c.is_pending,
is_pinned: !!c.is_pinned,
is_allow_reply: !c.is_collapsed && !c.is_pending,
is_verified: user?.is_admin ? true : !!c.is_verified,
rid: c.rid,
badge_name: user?.badge_name ?? '',
badge_color: user?.badge_color ?? '',
title_name: user?.title_name || '',
visible: opts.visibility?.get(c.id) ?? true,
vote_up: c.vote_up || 0,
vote_down: c.vote_down || 0,
page_key: c.page_key,
page_url: page ? cookPage(page, siteUrl).url : pageAccessibleUrl(c.page_key, siteUrl),
site_name: c.site_name,
};
if (opts.ipRegion && c.ip_region) cook.ip_region = c.ip_region;
// 官职徽标独立字段:不占用官方 badge 槽(博主的「博主」徽章独一无二,
// 官职接在它后面由主题渲染),配色沿用主题原版(彩字 + 半透明底)
const rank = getRank(commentCounts.get(c.user_id) ?? 0);
const myCount = commentCounts.get(c.user_id) ?? 0;
cook.rank_name = rank.short;
cook.rank_title = rank.title;
cook.rank_count = myCount;
cook.rank_color = rank.color;
cook.rank_bg = rank.bg;
return cook;
});
}
+368
View File
@@ -0,0 +1,368 @@
import type { CommentRow, Env, PageRow, SiteRow, UserRow } from '../types';
import { now } from './util';
// ===================================================================== 配置
export interface AdminUserConf {
name: string;
email: string;
password: string;
}
export async function getSetting<T>(env: Env, key: string, dft: T): Promise<T> {
const row = await env.DB.prepare('SELECT value FROM settings WHERE key = ?').bind(key).first<{
value: string;
}>();
if (!row) return dft;
try {
return JSON.parse(row.value) as T;
} catch {
return dft;
}
}
export async function setSetting(env: Env, key: string, value: unknown): Promise<void> {
await env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.bind(key, JSON.stringify(value), now())
.run();
}
/** 前端配置:/conf 里原样下发的 frontend_conf(默认值对齐旧 artalk.yml 的 frontend 段) */
export const DEFAULT_FRONTEND_CONF: Record<string, unknown> = {
imgUpload: false,
versionCheck: false, // 自研服务端版本号与官方客户端对不齐时会弹警告,默认关掉
pvAdd: true,
countOfCommentsOnPageInit: 20,
notify: true,
vote: true,
voteDown: false,
captchaMode: 'off',
placeholder: '',
noComment: '',
sendBtn: '评论一下',
editorTravel: true,
uaBadge: false,
listSort: true,
preview: true,
flatMode: false,
nestMax: 2,
nestSort: 'DATE_ASC',
// OwO 格式官方前端原生支持(isOwOFormat/convertOwO),直接指向博客自己的表情包
emoticons: 'https://usj.cc/emotion/OwO.json',
gravatar: { mirror: 'https://weavatar.com/avatar/', params: 'd=mp&s=240' },
useBackendConf: true,
locale: 'zh-CN',
pluginURLs: [] as string[],
darkMode: 'inherit',
pagination: { pageSize: 20, readMore: true, autoLoad: true },
heightLimit: { content: 10000, children: 10000, scrollable: true },
imgLazyLoad: 'data-src',
reqTimeout: 15000,
};
export async function getFrontendConf(env: Env): Promise<Record<string, unknown>> {
const saved = await getSetting<Record<string, unknown>>(env, 'frontend_conf', {});
return { ...DEFAULT_FRONTEND_CONF, ...saved };
}
export async function getAdminUsers(env: Env): Promise<AdminUserConf[]> {
const list = await getSetting<AdminUserConf[]>(env, 'admin_users', []);
if (Array.isArray(list) && list.length) return list;
return [{ name: env.ADMIN_NAME, email: env.ADMIN_EMAIL, password: env.ADMIN_PASSWORD }];
}
export async function isPendingDefault(env: Env): Promise<boolean> {
const saved = await getSetting<{ pendingDefault?: boolean } | null>(env, 'moderator', null);
if (saved && typeof saved.pendingDefault === 'boolean') return saved.pendingDefault;
return (env.PENDING_DEFAULT || 'false').toLowerCase() === 'true';
}
export async function isCaptchaEnabled(env: Env): Promise<boolean> {
const saved = await getSetting<{ enabled?: boolean } | null>(env, 'captcha', null);
return !!(saved && saved.enabled);
}
export async function isIPRegionEnabled(env: Env): Promise<boolean> {
const saved = await getSetting<{ enabled?: boolean } | null>(env, 'ip_region', null);
return !!(saved && saved.enabled);
}
export async function defaultSiteName(env: Env): Promise<string> {
return (await getSetting<string>(env, 'site_default', env.SITE_DEFAULT)) || env.SITE_DEFAULT;
}
// ===================================================================== 站点
export async function findSite(env: Env, name: string): Promise<SiteRow | null> {
return env.DB.prepare('SELECT * FROM sites WHERE name = ?').bind(name).first<SiteRow>();
}
export async function findOrCreateSite(env: Env, name: string, urls = ''): Promise<SiteRow> {
const found = await findSite(env, name);
if (found) return found;
const t = now();
await env.DB.prepare(
'INSERT INTO sites (name, urls, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.bind(name, urls, t, t)
.run();
const created = await findSite(env, name);
return created as SiteRow;
}
export async function findAllSites(env: Env): Promise<SiteRow[]> {
const res = await env.DB.prepare('SELECT * FROM sites ORDER BY id ASC').all<SiteRow>();
return res.results ?? [];
}
export async function siteFirstUrl(env: Env, siteName: string): Promise<string> {
const site = await findSite(env, siteName);
const raw = (site?.urls || '').split(',')[0]?.trim();
return raw || env.SITE_URL;
}
// ===================================================================== 页面
export async function findPage(env: Env, key: string, siteName: string): Promise<PageRow | null> {
return env.DB.prepare('SELECT * FROM pages WHERE key = ? AND site_name = ?')
.bind(key, siteName)
.first<PageRow>();
}
export async function findPageById(env: Env, id: number): Promise<PageRow | null> {
return env.DB.prepare('SELECT * FROM pages WHERE id = ?').bind(id).first<PageRow>();
}
export async function findOrCreatePage(
env: Env,
key: string,
title: string,
siteName: string,
): Promise<PageRow> {
const found = await findPage(env, key, siteName);
if (found) {
if (title && found.title !== title) {
await env.DB.prepare('UPDATE pages SET title = ?, updated_at = ? WHERE id = ?')
.bind(title, now(), found.id)
.run();
found.title = title;
}
return found;
}
const t = now();
await env.DB.prepare(
'INSERT INTO pages (key, site_name, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
)
.bind(key, siteName, title || '', t, t)
.run();
const created = await findPage(env, key, siteName);
return created as PageRow;
}
// ===================================================================== 用户
export async function findUserByNameEmail(
env: Env,
name: string,
email: string,
): Promise<UserRow | null> {
return env.DB.prepare('SELECT * FROM users WHERE name = ? AND email = ?')
.bind(name, email)
.first<UserRow>();
}
export async function findUserByEmail(env: Env, email: string): Promise<UserRow[]> {
const res = await env.DB.prepare('SELECT * FROM users WHERE email = ?')
.bind(email)
.all<UserRow>();
return res.results ?? [];
}
export async function findUserByName(env: Env, name: string): Promise<UserRow[]> {
const res = await env.DB.prepare('SELECT * FROM users WHERE name = ?').bind(name).all<UserRow>();
return res.results ?? [];
}
export async function findUserById(env: Env, id: number): Promise<UserRow | null> {
return env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(id).first<UserRow>();
}
/** 与 Artalk 的 FindCreateUser 一致:按 name+email 找,找不到就建 */
export async function findOrCreateUser(
env: Env,
name: string,
email: string,
link = '',
): Promise<UserRow> {
const found = await findUserByNameEmail(env, name, email);
if (found) return found;
const t = now();
await env.DB.prepare(
`INSERT INTO users (name, email, link, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`,
)
.bind(name, email, link, t, t)
.run();
const created = await findUserByNameEmail(env, name, email);
return created as UserRow;
}
export async function touchUser(env: Env, id: number, ip: string, ua: string): Promise<void> {
await env.DB.prepare('UPDATE users SET last_ip = ?, last_ua = ?, updated_at = ? WHERE id = ?')
.bind(ip, ua, now(), id)
.run();
}
// ===================================================================== 评论
export async function findComment(env: Env, id: number): Promise<CommentRow | null> {
return env.DB.prepare('SELECT * FROM comments WHERE id = ? AND deleted_at = 0')
.bind(id)
.first<CommentRow>();
}
/** 与 Artalk FindCommentRootID 一致:沿 rid 链上溯到顶层 */
export async function findCommentRootId(env: Env, rid: number): Promise<number> {
const visited = new Set<number>();
let cur = rid;
while (cur !== 0 && !visited.has(cur)) {
visited.add(cur);
const row = await env.DB.prepare('SELECT id, rid FROM comments WHERE id = ?')
.bind(cur)
.first<{ id: number; rid: number }>();
if (!row) return 0;
if (row.rid === 0) return row.id;
cur = row.rid;
}
return cur;
}
export async function findUsersByIds(env: Env, ids: number[]): Promise<Map<number, UserRow>> {
const map = new Map<number, UserRow>();
const uniq = [...new Set(ids.filter((n) => n > 0))];
for (let i = 0; i < uniq.length; i += 80) {
const chunk = uniq.slice(i, i + 80);
const placeholders = chunk.map(() => '?').join(',');
const res = await env.DB.prepare(`SELECT * FROM users WHERE id IN (${placeholders})`)
.bind(...chunk)
.all<UserRow>();
for (const u of res.results ?? []) map.set(u.id, u);
}
return map;
}
export async function findPagesByKeys(
env: Env,
keys: string[],
siteName: string,
): Promise<Map<string, PageRow>> {
const map = new Map<string, PageRow>();
const uniq = [...new Set(keys.filter(Boolean))];
for (let i = 0; i < uniq.length; i += 80) {
const chunk = uniq.slice(i, i + 80);
const placeholders = chunk.map(() => '?').join(',');
const res = await env.DB.prepare(
`SELECT * FROM pages WHERE site_name = ? AND key IN (${placeholders})`,
)
.bind(siteName, ...chunk)
.all<PageRow>();
for (const p of res.results ?? []) map.set(p.key, p);
}
return map;
}
// ===================================================================== 投票
export async function findVotesFor(
env: Env,
type: string,
ids: number[],
): Promise<Map<number, { up: number; down: number }>> {
const map = new Map<number, { up: number; down: number }>();
if (!ids.length) return map;
const placeholders = ids.map(() => '?').join(',');
const res = await env.DB.prepare(
`SELECT target_id,
SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS up,
SUM(CASE WHEN type = ? THEN 1 ELSE 0 END) AS down
FROM votes WHERE target_id IN (${placeholders})
GROUP BY target_id`,
)
.bind(`${type}_up`, `${type}_down`, ...ids)
.all<{ target_id: number; up: number; down: number }>();
for (const r of res.results ?? []) map.set(r.target_id, { up: r.up ?? 0, down: r.down ?? 0 });
return map;
}
// ===================================================================== 限流
/**
* 简单滑动窗口限流。同一 bucket 在 windowSec 内超过 limit 次就拒绝。
* 用于发评论 / 登录,防止被刷。
*/
export async function rateLimit(
env: Env,
bucket: string,
limit: number,
windowSec: number,
): Promise<boolean> {
const t = now();
const row = await env.DB.prepare('SELECT count, expires_at FROM rate_limits WHERE bucket = ?')
.bind(bucket)
.first<{ count: number; expires_at: number }>();
if (!row || row.expires_at < t) {
await env.DB.prepare(
`INSERT INTO rate_limits (bucket, count, expires_at) VALUES (?, 1, ?)
ON CONFLICT(bucket) DO UPDATE SET count = 1, expires_at = excluded.expires_at`,
)
.bind(bucket, t + windowSec * 1000)
.run();
return true;
}
if (row.count >= limit) return false;
await env.DB.prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?')
.bind(bucket)
.run();
return true;
}
/** 顺手清理过期的限流记录(低频调用即可) */
export async function gcRateLimits(env: Env): Promise<void> {
await env.DB.prepare('DELETE FROM rate_limits WHERE expires_at < ?').bind(now()).run();
}
// ===================================================================== 验证码
/** /captcha/verify 通过后写入;发评论时检查 */
export async function hasCaptchaPassed(env: Env, ip: string): Promise<boolean> {
const row = await env.DB.prepare(
'SELECT expires_at FROM captcha_passes WHERE ip = ? AND expires_at > ?',
)
.bind(ip, now())
.first<{ expires_at: number }>();
return !!row;
}
export async function grantCaptchaPass(env: Env, ip: string, ttlSec: number): Promise<void> {
await env.DB.prepare(
`INSERT INTO captcha_passes (ip, expires_at) VALUES (?, ?)
ON CONFLICT(ip) DO UPDATE SET expires_at = excluded.expires_at`,
)
.bind(ip, now() + ttlSec * 1000)
.run();
}
export async function gcCaptcha(env: Env): Promise<void> {
const t = now();
await env.DB.batch([
env.DB.prepare('DELETE FROM captcha_passes WHERE expires_at < ?').bind(t),
env.DB.prepare('DELETE FROM captcha_challenges WHERE expires_at < ?').bind(t),
]);
}
+93
View File
@@ -0,0 +1,93 @@
// ============================================================================
// 统一错误出口
//
// 起因:D1 免费额度用尽时,Artalk 客户端弹出的是
// 「Failed to load comments / TypeError: Failed to fetch」这种模棱两可的报错。
// 两个原因:
// 1) 异常路径不带 CORS 头(例如 userFromToken 抛错时在 try 之外)
// → 浏览器把它当网络故障,连状态码都拿不到;
// 2) 原始错误串(D1_ERROR: ... see https://developers.cloudflare.com/...)
// 直接甩给读者,既是英文黑话又带外链。
//
// 这里做两件事:把技术错误翻译成人话 + 附上机器可读的 code,
// 让前端能按 code 给出对应提示(而不是去猜字符串)。
// ============================================================================
export type ErrCode = 'd1_quota' | 'd1_unavailable' | 'timeout' | 'unavailable' | 'internal';
export interface FriendlyError {
/** HTTP 状态码 */
status: number;
/** 机器可读的错误类别(前端按它选文案) */
code: ErrCode;
/** 给读者看的中文文案 */
msg: string;
/** 原始技术信息,折叠展示,方便站长排查 */
detail: string;
}
/** 判断是不是 D1 免费额度用尽 */
export function isD1QuotaError(msg: string): boolean {
return (
/exceeded D1'?s free tier/i.test(msg) ||
/daily row (read|write) limit/i.test(msg) ||
/D1_ERROR.*(limit|exceeded|quota)/i.test(msg)
);
}
/** 判断是不是 D1 本身不可用(不是配额,是服务/连接问题) */
export function isD1Unavailable(msg: string): boolean {
return /D1_ERROR|D1 database|no such DB|storage operation|internal error in D1/i.test(msg);
}
/**
* 把任意异常翻译成「给人看的 + 给程序看的」错误结构。
* 不认识的异常一律 500 + internal,但 detail 里保留原文。
*/
export function friendlyError(e: unknown): FriendlyError {
const raw = e instanceof Error ? e.message : String(e ?? '');
const detail = raw.slice(0, 500);
if (isD1QuotaError(raw)) {
return {
status: 503,
code: 'd1_quota',
msg: '评论服务暂时不可用:数据库今日读取额度已用尽,北京时间明早 8:00 自动恢复。给你带来不便,可以先看看文章,稍后再来。',
detail,
};
}
if (isD1Unavailable(raw)) {
return {
status: 503,
code: 'd1_unavailable',
msg: '评论服务暂时不可用(数据库连接异常),我们正在处理,请稍后再试。',
detail,
};
}
if (/abort|timeout|timed out/i.test(raw)) {
return {
status: 504,
code: 'timeout',
msg: '评论服务响应超时,请稍后重试。',
detail,
};
}
if (/fetch failed|Network|ECONNREFUSED|ENOTFOUND|socket/i.test(raw)) {
return {
status: 503,
code: 'unavailable',
msg: '评论服务暂时不可用,请稍后重试。',
detail,
};
}
return {
status: 500,
code: 'internal',
msg: '评论服务出了点小问题,请稍后重试。',
detail,
};
}
+170
View File
@@ -0,0 +1,170 @@
// 人机验证(自研「一键验证」)
//
// 设计目标:正常读者**无感**(浏览器静默算一个 20~80ms 的 PoW,什么也不用手动做),
// 命中可疑信号时才让访客「点一下」,再可疑才回退到图形验证码。
//
// 为什么不用 Turnstile/reCAPTCHA:
// 1) 国内加载不稳定(Google/Cloudflare 的脚本经常拉不下来),一旦失败评论就发不出去;
// 2) 会把访客 IP/浏览器指纹送给第三方。
// 自研版靠 PoW + 行为信号,挡得住脚本刷评论和低成本机器人,对个人博客足够。
//
// 关键点:**挑战是无状态的**(HMAC 签名,不写库);通行证放 KV(不占 D1 额度)。
import type { Env } from '../types';
/** PoW 难度:sha256(challenge + nonce) 的十六进制前缀要有这么多个 0 */
export const POW_DIFFICULTY = 4;
const CHALLENGE_TTL_MS = 10 * 60 * 1000;
/** 通行证有效期(秒):过了一次就不用再验 */
const PASS_TTL_SEC = 1800;
/** 开关:存 KV,读一次比读 D1 settings 便宜 */
const ENABLED_KEY = 'human_check';
export interface HumanChallenge {
challenge: string;
difficulty: number;
exp: number;
sig: string;
}
function bytesToHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
export async function sha256Hex(input: string): Promise<string> {
const data = new TextEncoder().encode(input);
return bytesToHex(await crypto.subtle.digest('SHA-256', data));
}
async function hmacHex(secret: string, msg: string): Promise<string> {
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign'],
);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(msg));
return bytesToHex(sig);
}
// ------------------------------------------------------------------ 开关
export async function isHumanCheckEnabled(env: Env): Promise<boolean> {
try {
return (await env.RSS_KV.get(ENABLED_KEY)) === '1';
} catch {
return false;
}
}
export async function setHumanCheck(env: Env, on: boolean): Promise<void> {
try {
await env.RSS_KV.put(ENABLED_KEY, on ? '1' : '0');
} catch {
/* 开关写失败不影响主流程 */
}
}
// ---------------------------------------------------------------- 挑战签发
export async function issueChallenge(env: Env): Promise<HumanChallenge> {
const buf = crypto.getRandomValues(new Uint8Array(12));
const challenge = bytesToHex(buf.buffer);
const exp = Date.now() + CHALLENGE_TTL_MS;
const sig = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
return { challenge, difficulty: POW_DIFFICULTY, exp, sig };
}
/** 校验 PoW 证明:签名有效 + 未过期 + 哈希前缀达标 */
export async function verifyProof(
env: Env,
p: { challenge?: string; nonce?: number | string; exp?: number; sig?: string },
): Promise<boolean> {
const challenge = String(p.challenge || '');
const exp = Number(p.exp || 0);
const sig = String(p.sig || '');
if (!challenge || !exp || !sig || p.nonce === undefined || p.nonce === null) return false;
if (Date.now() > exp) return false;
const expect = await hmacHex(env.TOKEN_SECRET, `${challenge}|${POW_DIFFICULTY}|${exp}`);
if (expect !== sig) return false;
const hash = await sha256Hex(`${challenge}${p.nonce}`);
return hash.startsWith('0'.repeat(POW_DIFFICULTY));
}
// ---------------------------------------------------------------- 通行证
export function humanPassKey(ip: string): string {
return `human:pass:${ip}`;
}
export async function hasHumanPass(env: Env, ip: string): Promise<boolean> {
try {
return (await env.RSS_KV.get(humanPassKey(ip))) === '1';
} catch {
return false;
}
}
export async function grantHumanPass(env: Env, ip: string, ttl = PASS_TTL_SEC): Promise<void> {
try {
await env.RSS_KV.put(humanPassKey(ip), '1', { expirationTtl: ttl });
} catch {
/* 通行证写失败 → 下次再验,不影响本次放行 */
}
}
// ------------------------------------------------------------ 行为信号评分
export interface HumanSignals {
/** 蜜罐字段:人类看不见也不会填,填了就一定是脚本 */
honeypot?: string;
/** 从拿到挑战到提交验证的耗时(毫秒) */
elapsedMs?: number;
/** 页面上的鼠标/键盘/滚动/触摸事件次数 */
events?: number;
/** navigator.webdriver(自动化浏览器通常为 true) */
webdriver?: boolean;
/** 是否是「点一下」触发的验证(点击本身就是人类信号) */
clicked?: boolean;
}
export type RiskLevel = 'low' | 'medium' | 'high';
export interface RiskResult {
level: RiskLevel;
reasons: string[];
}
/**
* 信号评分。
* low → 直接发通行证(读者无感)
* medium → 要求「点一下」(点击会补齐 elapsedMs / events 信号,重试即通过)
* high → 回退图形验证码
*/
export function assessSignals(s: HumanSignals): RiskResult {
const reasons: string[] = [];
if (s.honeypot) {
return { level: 'high', reasons: ['honeypot filled'] };
}
if (s.webdriver === true) {
reasons.push('webdriver');
}
const elapsed = Number(s.elapsedMs || 0);
const events = Number(s.events || 0);
if (elapsed > 0 && elapsed < 1200) reasons.push('too fast');
if (elapsed > 2 * 3600 * 1000) reasons.push('too slow');
if (events <= 0 && !s.clicked) reasons.push('no interaction');
if (reasons.includes('webdriver')) return { level: 'high', reasons };
if (s.clicked) {
// 点击过就直接放行(点击 + PoW 已经足够;elapsed 太短仍视为可疑)
return reasons.includes('too fast') && elapsed < 400
? { level: 'medium', reasons }
: { level: 'low', reasons };
}
if (reasons.length) return { level: 'medium', reasons };
return { level: 'low', reasons: [] };
}
+443
View File
@@ -0,0 +1,443 @@
import type { Env, CommentRow, UserRow } from '../types';
import { formatDateCN } from './util';
import { md5Lower } from './md5';
import { renderMarkdown } from './md';
/**
* 邮件通知 —— 走 Resend 的 HTTP API。
*
* 为什么不用 SMTP:**Cloudflare Workers 没有 TCP socket**,连不上 SMTP 服务器。
* (Cloudflare 2026-04 推出的原生 Email Service 需要 Workers Paid,
* 本项目跑在免费版上,所以走第三方 HTTP API。)
*
* 没配 RESEND_API_KEY 时全部静默跳过,不影响评论提交。
*/
const RESEND_ENDPOINT = 'https://api.resend.com/emails';
const DEFAULT_FROM = '小赵 <noreply@mail.200181.xyz>';
export function mailEnabled(env: Env): boolean {
return !!env.RESEND_API_KEY;
}
export interface MailInput {
to: string;
subject: string;
html: string;
text?: string;
replyTo?: string;
/** 覆盖发件人显示名(对应旧配置里的 email.send_name) */
fromName?: string;
}
/** 从 `名字 <地址>` 或裸地址里取出纯地址 */
function addrOf(from: string): string {
const m = /<([^>]+)>/.exec(from || '');
return (m ? m[1] : from || '').trim();
}
/** 发一封。失败只记日志,不抛异常 —— 邮件不该影响评论主流程。 */
export async function sendMail(env: Env, mail: MailInput): Promise<boolean> {
if (!env.RESEND_API_KEY) return false;
try {
const baseFrom = env.MAIL_FROM || DEFAULT_FROM;
const from = mail.fromName
? `${mail.fromName} <${addrOf(baseFrom)}>`
: baseFrom;
const res = await fetch(RESEND_ENDPOINT, {
method: 'POST',
headers: {
Authorization: `Bearer ${env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from,
to: [mail.to],
subject: mail.subject,
html: mail.html,
...(mail.text ? { text: mail.text } : {}),
...(mail.replyTo ? { reply_to: mail.replyTo } : {}),
}),
});
if (!res.ok) {
const body = await res.text();
console.error('[mail] resend 返回', res.status, body.slice(0, 300));
return false;
}
return true;
} catch (e) {
console.error('[mail] 请求失败', e instanceof Error ? e.message : e);
return false;
}
}
/**
* 每日发信配额保护。
* Resend 免费版是 **100 封/天**,评论突然变多不能把额度打爆。
* 复用已有的 rate_limits 表当计数器(bucket = "mail:2026-10-02")。
*/
export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
const day = new Date().toISOString().slice(0, 10);
const bucket = `mail:${day}`;
const t = Date.now();
try {
const row = await env.DB.prepare('SELECT count, expires_at FROM rate_limits WHERE bucket = ?')
.bind(bucket)
.first<{ count: number; expires_at: number }>();
if (row && row.expires_at > t && row.count >= limit) return false;
if (!row || row.expires_at <= t) {
await env.DB.prepare(
`INSERT INTO rate_limits (bucket, count, expires_at) VALUES (?, 1, ?)
ON CONFLICT(bucket) DO UPDATE SET count = 1, expires_at = excluded.expires_at`,
)
.bind(bucket, t + 86_400_000)
.run();
} else {
await env.DB.prepare('UPDATE rate_limits SET count = count + 1 WHERE bucket = ?')
.bind(bucket)
.run();
}
return true;
} catch {
return true; // 计数本身失败就别拦着发信
}
}
// ============================================================================
// 模板 —— 与 artalk.yml 里 mail_tpl / notify_tpl 指向的两个文件一致
// 改模板只需要动下面两段字符串
//
// 风格:聊天对话式(自己的评论靠右绿色气泡,对方靠左白色气泡),
// 配色清新(浅薄荷底 + 白卡 + 鲜绿主色 #2fa872)。
// ============================================================================
/** 取首字(码点安全,emoji/生僻字不会被截断),用作头像字 */
const firstChar = (v: unknown): string => {
const s = String(v ?? '').trim();
return [...s][0] || '?';
};
/** 回复提醒(原 reply.html) */
const REPLY_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body style="margin:0;padding:0;background-color:#f3f9f5">
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 对话</div>
<div style="display:flex;flex-direction:row-reverse;gap:8px;margin-bottom:14px">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#2fa872;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{parent_comment.name_first}}</div>
<div style="max-width:82%">
<div style="font-size:11px;color:#8fa096;margin-bottom:4px;text-align:right">{{parent_comment.nick}} · {{parent_comment.datetime}}</div>
<div style="background-color:#d7f0e2;border-radius:12px 2px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{parent_comment.content}}</div></div>
</div>
</div>
<div style="display:flex;gap:8px">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
<div style="max-width:82%">
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
</div>
</div>
</div>
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
查看并回复
</a>
</div>
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
如果你不想再收到这类通知,可以在评论时关闭"接收邮件通知"
</div>
</div>
</body>
</html>`;
/** 管理员通知(原 notice.html) */
const NOTICE_TPL = `<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title></title>
</head>
<body style="margin:0;padding:0;background-color:#f3f9f5">
<div style="width:100%;max-width:560px;margin:0 auto;padding:24px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,sans-serif">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
<span style="width:28px;height:28px;border-radius:8px;background-color:#2fa872;color:#ffffff;font-size:14px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{site_name_first}}</span>
<span style="font-size:15px;font-weight:600;color:#2b3a31">{{site_name}}</span>
</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:12px;padding:18px">
<div style="background-color:#eef6f1;border-radius:12px;padding:16px 14px">
<div style="text-align:center;font-size:11px;color:#8fa096;margin-bottom:14px">《{{page_title}}》 · 新留言</div>
<div style="display:flex;gap:8px">
<div style="flex:none;width:32px;height:32px;border-radius:50%;background-color:#9cbfa9;color:#ffffff;font-size:13px;font-weight:600;display:inline-flex;align-items:center;justify-content:center">{{comment.name_first}}</div>
<div style="max-width:82%">
<div style="font-size:11px;color:#8fa096;margin-bottom:4px">{{comment.nick}} · {{comment.datetime}}</div>
<div style="background-color:#ffffff;border:1px solid #e6efe9;border-radius:2px 12px 12px 12px;padding:9px 12px;font-size:14px;color:#2b3a31;line-height:1.8;word-break:break-all"><div class="comment_img">{{comment.content}}</div></div>
</div>
</div>
</div>
<a href="{{link_to_reply}}" target="_blank" rel="noopener"
style="display:block;margin-top:16px;background-color:#2fa872;color:#ffffff;text-align:center;padding:11px 0;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none">
前往查看
</a>
</div>
<div style="text-align:center;font-size:12px;color:#8fa096;margin-top:18px;line-height:1.8">
此邮件来自 <a href="{{site_url}}" target="_blank" rel="noopener" style="color:#2fa872;text-decoration:none">{{site_name}}</a>,请勿直接回复
</div>
</div>
</body>
</html>`;
/** 主题沿用 artalk.yml 里的 mail_subject */
export const REPLY_SUBJECT = '{{site_name}} · 新的评论回复';
export const ADMIN_SUBJECT = '{{site_name}} · 新的评论待审';
// ------------------------------------------------------------------ 渲染
const esc = (v: unknown): string =>
String(v ?? '').replace(/[&<>"']/g, (m) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[m] as string,
);
/**
* 评论正文 → 邮件里可用的 HTML。
* 先走 Markdown(保留正文里的原生 HTML),再把表情图加上内联宽度 ——
* 原模板靠 <style> 里的 `.comment_img img{width:100px}`,而多数邮件客户端会
* 丢掉 head 里的样式表,所以这里补一份内联的。
*/
function contentHtml(raw: string): string {
return renderMarkdown(String(raw || '')).replace(
/<img\s+([^>]*?)>/gi,
(m, attrs: string) =>
/atk-emoticon=/i.test(attrs) && !/style=/i.test(attrs)
? `<img style="width:100px;height:auto;vertical-align:middle" ${attrs}>`
: m,
);
}
type Vars = Record<string, string>;
function commentVars(prefix: string, c: CommentRow, nick: string, email: string): Vars {
const d = formatDateCN(c.created_at);
return {
[`${prefix}.id`]: esc(c.id),
[`${prefix}.nick`]: esc(nick),
[`${prefix}.name_first`]: esc(firstChar(nick)),
[`${prefix}.email_encrypted`]: md5Lower(email || ''),
[`${prefix}.email`]: esc(email),
[`${prefix}.datetime`]: esc(d),
[`${prefix}.date`]: esc(d.slice(0, 10)),
[`${prefix}.time`]: esc(d.slice(11)),
// 正文与链接是白名单不转义的字段(与原实现 getPurifiedValue 一致)
[`${prefix}.content`]: contentHtml(c.content),
[`${prefix}.content_marked`]: contentHtml(c.content),
[`${prefix}.content_raw`]: esc(c.content),
[`${prefix}.page_key`]: esc(c.page_key),
};
}
/** 页面可访问链接 + 定位到某条评论(对应官方 GetLinkToReplyByComment) */
function linkToReply(pageUrl: string, commentId: number): string {
const base = String(pageUrl || '').trim();
if (!base) return '';
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}atk_comment=${commentId}`;
}
function renderTpl(tpl: string, vars: Vars): string {
return tpl.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (m, k: string) => (k in vars ? vars[k] : m));
}
export interface ReplyMailInput {
siteName: string;
siteUrl: string;
pageTitle: string;
pageUrl: string;
/** 被回复者(收信人)的评论 */
parentComment: CommentRow;
parentNick: string;
parentEmail: string;
/** 新评论(回复方) */
comment: CommentRow;
commenterNick: string;
commenterEmail: string;
/** 收信人昵称,用于 {{nick}} */
toNick: string;
}
export function replyMailHtml(o: ReplyMailInput): string {
const ltr = linkToReply(o.pageUrl, o.comment.id);
const vars: Vars = {
site_name: esc(o.siteName),
site_name_first: esc(firstChar(o.siteName)),
site_url: esc(o.siteUrl),
page_title: esc(o.pageTitle),
page_url: esc(o.pageUrl),
link_to_reply: ltr, // 白名单:不转义
nick: esc(o.toNick),
reply_nick: esc(o.commenterNick),
reply_content: o.comment.content,
...commentVars('comment', o.comment, o.commenterNick, o.commenterEmail),
...commentVars('from', o.comment, o.commenterNick, o.commenterEmail),
...commentVars('parent_comment', o.parentComment, o.parentNick, o.parentEmail),
...commentVars('to', o.parentComment, o.parentNick, o.parentEmail),
};
return renderTpl(REPLY_TPL, vars);
}
export function replyMailSubject(o: ReplyMailInput): string {
return renderTpl(REPLY_SUBJECT, {
site_name: o.siteName,
reply_nick: o.commenterNick,
page_title: o.pageTitle,
});
}
export interface AdminMailInput {
siteName: string;
siteUrl: string;
pageTitle: string;
pageUrl: string;
comment: CommentRow;
commenterNick: string;
commenterEmail: string;
adminNick: string;
}
export function adminMailHtml(o: AdminMailInput): string {
const vars: Vars = {
site_name: esc(o.siteName),
site_name_first: esc(firstChar(o.siteName)),
site_url: esc(o.siteUrl),
page_title: esc(o.pageTitle),
page_url: esc(o.pageUrl),
link_to_reply: linkToReply(o.pageUrl, o.comment.id),
nick: esc(o.adminNick),
reply_nick: esc(o.commenterNick),
reply_content: o.comment.content,
...commentVars('comment', o.comment, o.commenterNick, o.commenterEmail),
...commentVars('from', o.comment, o.commenterNick, o.commenterEmail),
};
return renderTpl(NOTICE_TPL, vars);
}
export function adminMailSubject(o: AdminMailInput): string {
return renderTpl(ADMIN_SUBJECT, { site_name: o.siteName, page_title: o.pageTitle });
}
// ------------------------------------------------------------------ 触发
export interface NotifyCtx {
newComment: CommentRow;
author: UserRow;
/** 被回复的评论(顶层评论则没有) */
parent: CommentRow | null;
parentAuthor: UserRow | null;
siteName: string;
siteUrl: string;
pageTitle: string;
pageUrl: string;
}
/**
* 发评论之后的邮件通知。整体容错:任何一步失败都不影响评论已提交的事实。
*
* 两个场景:
* 1. 回复别人的评论 → 通知被回复者(尊重其 receive_email 设置)
* 2. 新评论(顶层)→ 通知管理员(MAIL_ADMIN)
*/
export async function notifyByEmail(env: Env, c: NotifyCtx): Promise<void> {
if (!mailEnabled(env)) return;
const targets: { to: string; subject: string; html: string; fromName?: string }[] = [];
const fromName = (env.MAIL_FROM_NAME || '').trim() || undefined;
// --- 被回复者
if (c.parent && c.parentAuthor) {
const pa = c.parentAuthor;
const selfReply = pa.id === c.author.id;
if (!selfReply && pa.receive_email && pa.email && pa.email.includes('@')) {
const input: ReplyMailInput = {
siteName: c.siteName,
siteUrl: c.siteUrl,
pageTitle: c.pageTitle,
pageUrl: c.pageUrl,
parentComment: c.parent,
parentNick: pa.name,
parentEmail: pa.email,
comment: c.newComment,
commenterNick: c.author.name,
commenterEmail: c.author.email,
toNick: pa.name,
};
targets.push({
to: pa.email,
subject: replyMailSubject(input),
html: replyMailHtml(input),
fromName,
});
}
}
// --- 管理员(用 MAIL_ADMIN,不复用 ADMIN_EMAIL)
const adminTo = (env.MAIL_ADMIN || '').trim();
if (
adminTo.includes('@') &&
adminTo.toLowerCase() !== c.author.email.toLowerCase() &&
(!c.parentAuthor || c.parentAuthor.email.toLowerCase() !== adminTo.toLowerCase())
) {
const input: AdminMailInput = {
siteName: c.siteName,
siteUrl: c.siteUrl,
pageTitle: c.pageTitle,
pageUrl: c.pageUrl,
comment: c.newComment,
commenterNick: c.author.name,
commenterEmail: c.author.email,
adminNick: '博主',
};
targets.push({
to: adminTo,
subject: adminMailSubject(input),
html: adminMailHtml(input),
fromName,
});
}
if (!targets.length) return;
for (const t of targets) {
if (!(await mailQuotaOk(env))) {
console.warn('[mail] 已达当日发信上限,跳过');
return;
}
console.log('[mail] 发送中 →', t.to, '|', t.subject);
const okSent = await sendMail(env, t);
if (!okSent) console.warn('[mail] 发送失败 →', t.to);
}
}
+220
View File
@@ -0,0 +1,220 @@
// content_marked:Markdown → HTML。
//
// 官方 Artalk 用 goldmark(GFM + HardWraps + Unsafe) 渲染后再过 bluemonday 的 UGC 白名单清洗。
// 这里采取「先抽白名单标签 → 其余全部转义 → 再解析 Markdown」的策略:
// 语义上等价于 UGC 白名单之外全部剔除,而且不可能出现漏网标签。
//
// 唯一保留的裸 HTML 是 <img>:小赵站上 302 条(8%)评论用了表情包,内容是
// <img src="https://usj.cc/emotion/douyin/19.png" atk-emoticon="19">
// 全部转义的话,后台预览里会变成一堆可见的标签文本。
//
// 保留范围严格对齐 Artalk 2.8.7 客户端自己的白名单(Artalk.js 里的 allowedTags /
// allowedAttributes):img 只留 src / alt / title / atk-emoticon,script、iframe、on* 一律丢。
//
// 另:客户端其实**不消费** content_marked(它自己用打包的 marked 渲染 content),
// 所以这里主要影响后台与导出预览。
const IMG_TAG_RE = /<img\b[^>]*>/gi;
const ALLOWED_IMG_ATTRS = ['alt', 'title', 'atk-emoticon'] as const;
const SAFE_SRC_RE = /^(https?:\/\/|\/|\.\/|data:image\/)/i;
function readAttr(tag: string, name: string): string | null {
// 双引号 / 单引号 / 无引号三种写法都兼容
const re = new RegExp(`\\b${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, 'i');
const m = tag.match(re);
if (!m) return null;
return (m[1] ?? m[2] ?? m[3] ?? '').trim();
}
function escapeAttr(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 把一条 <img> 收敛成只带白名单属性的安全标签;不安全就返回 null(当普通文本处理) */
function sanitizeImgTag(tag: string): string | null {
const src = readAttr(tag, 'src');
if (!src) return null;
if (!SAFE_SRC_RE.test(src) || /^\s*javascript:/i.test(src)) return null;
const parts = [`src="${escapeAttr(src)}"`];
for (const name of ALLOWED_IMG_ATTRS) {
const v = readAttr(tag, name);
if (v) parts.push(`${name}="${escapeAttr(v)}"`);
}
parts.push('referrerpolicy="no-referrer"');
return `<img ${parts.join(' ')} />`;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function inline(text: string): string {
let s = text;
// 行内代码优先,避免里面的符号被当成语法
const codes: string[] = [];
s = s.replace(/`([^`\n]+)`/g, (_m, code: string) => {
codes.push(`<code>${code}</code>`);
return `\u0000${codes.length - 1}\u0000`;
});
// 图片
s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, alt: string, url: string) => {
if (!isSafeUrl(url)) return alt;
return `<img src="${url}" alt="${alt}" referrerpolicy="no-referrer" />`;
});
// 链接
s = s.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_m, label: string, url: string) => {
if (!isSafeUrl(url)) return label;
return `<a href="${url}" target="_blank" rel="nofollow noopener noreferrer">${label}</a>`;
});
// 裸链接
s = s.replace(/(^|[\s(])((?:https?:\/\/|mailto:)[^\s<)]+)/g, (_m, pre: string, url: string) => {
const clean = url.replace(/[.,;:!?]+$/, '');
const trail = url.slice(clean.length);
return `${pre}<a href="${clean}" target="_blank" rel="nofollow noopener noreferrer">${clean}</a>${trail}`;
});
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1<em>$2</em>');
s = s.replace(/~~([^~\n]+)~~/g, '<del>$1</del>');
// 还原行内代码
s = s.replace(/\u0000(\d+)\u0000/g, (_m, i: string) => codes[Number(i)]);
return s;
}
function isSafeUrl(url: string): boolean {
const u = url.trim().toLowerCase();
if (u.startsWith('javascript:') || u.startsWith('data:') || u.startsWith('vbscript:')) return false;
return /^(https?:\/\/|mailto:|\/|#|\.)/.test(u) || !u.includes(':');
}
export function renderMarkdown(input: string): string {
const raw = String(input ?? '').replace(/\r\n?/g, '\n');
const blocks: string[] = [];
const imgs: string[] = [];
// 1) 先抽掉围栏代码块(里面的内容一律当字面量,不做二次解析)
let text = raw.replace(/```([^\n]*)\n([\s\S]*?)```/g, (_m, lang: string, body: string) => {
const cls = lang.trim() ? ` class="language-${lang.trim()}"` : '';
blocks.push(`<pre><code${cls}>${escapeHtml(body.replace(/\n$/, ''))}</code></pre>`);
return `\u0001${blocks.length - 1}\u0001`;
});
// 2) 再抽掉白名单内的 <img>(表情包);其余标签留给第 3 步整体转义
text = text.replace(IMG_TAG_RE, (tag) => {
const safe = sanitizeImgTag(tag);
if (!safe) return tag; // 不安全 → 原样留下,稍后被转义成可见文本
imgs.push(safe);
return `\u0002${imgs.length - 1}\u0002`;
});
// 3) 整体转义,保证不会有漏网的 HTML
text = escapeHtml(text);
const lines = text.split('\n');
const out: string[] = [];
let list: 'ul' | 'ol' | null = null;
let quote: string[] = [];
let para: string[] = [];
const flushPara = () => {
if (para.length) {
out.push(`<p>${para.join('<br>')}</p>`);
para = [];
}
};
const flushList = () => {
if (list) {
out.push(`</${list}>`);
list = null;
}
};
const flushQuote = () => {
if (quote.length) {
out.push(`<blockquote><p>${quote.join('<br>')}</p></blockquote>`);
quote = [];
}
};
const flushAll = () => {
flushPara();
flushList();
flushQuote();
};
for (const line of lines) {
// 代码块 / 图片占位符可能独占一行
if (/^\s*[\u0001\u0002]\d+[\u0001\u0002]\s*$/.test(line)) {
flushAll();
out.push(line.trim());
continue;
}
if (!line.trim()) {
flushAll();
continue;
}
const q = line.match(/^&gt;\s?(.*)$/);
if (q) {
flushPara();
flushList();
quote.push(inline(q[1]));
continue;
}
flushQuote();
const ul = line.match(/^\s*[-*+]\s+(.*)$/);
const ol = line.match(/^\s*\d+\.\s+(.*)$/);
if (ul || ol) {
flushPara();
const want: 'ul' | 'ol' = ul ? 'ul' : 'ol';
if (list !== want) {
flushList();
out.push(`<${want}>`);
list = want;
}
out.push(`<li>${inline((ul ? ul[1] : ol![1]) as string)}</li>`);
continue;
}
flushList();
const heading = line.match(/^(#{1,6})\s+(.*)$/);
if (heading) {
flushPara();
const level = heading[1].length;
out.push(`<h${level}>${inline(heading[2])}</h${level}>`);
continue;
}
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) {
flushPara();
out.push('<hr>');
continue;
}
para.push(inline(line));
}
flushAll();
let html = out.join('\n');
html = html.replace(/\u0001(\d+)\u0001/g, (_m, i: string) => blocks[Number(i)]);
html = html.replace(/\u0002(\d+)\u0002/g, (_m, i: string) => imgs[Number(i)]);
return html;
}
+89
View File
@@ -0,0 +1,89 @@
// Artalk 的 email_encrypted 字段 = MD5(email 小写),前端拿它当 Gravatar / Cravatar 的 hash。
// Workers 的 crypto.subtle 不提供 MD5,所以这里自带一份。
const K_HEX = [
'd76aa478', 'e8c7b756', '242070db', 'c1bdceee', 'f57c0faf', '4787c62a', 'a8304613', 'fd469501',
'698098d8', '8b44f7af', 'ffff5bb1', '895cd7be', '6b901122', 'fd987193', 'a679438e', '49b40821',
'f61e2562', 'c040b340', '265e5a51', 'e9b6c7aa', 'd62f105d', '02441453', 'd8a1e681', 'e7d3fbc8',
'21e1cde6', 'c33707d6', 'f4d50d87', '455a14ed', 'a9e3e905', 'fcefa3f8', '676f02d9', '8d2a4c8a',
'fffa3942', '8771f681', '6d9d6122', 'fde5380c', 'a4beea44', '4bdecfa9', 'f6bb4b60', 'bebfbc70',
'289b7ec6', 'eaa127fa', 'd4ef3085', '04881d05', 'd9d4d039', 'e6db99e5', '1fa27cf8', 'c4ac5665',
'f4292244', '432aff97', 'ab9423a7', 'fc93a039', '655b59c3', '8f0ccc92', 'ffeff47d', '85845dd1',
'6fa87e4f', 'fe2ce6e0', 'a3014314', '4e0811a1', 'f7537e82', 'bd3af235', '2ad7d2bb', 'eb86d391',
].map((h) => parseInt(h, 16) >>> 0);
const S = [
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
];
function wordToHexLE(n: number): string {
let out = '';
for (let i = 0; i < 4; i++) out += ((n >>> (i * 8)) & 0xff).toString(16).padStart(2, '0');
return out;
}
export function md5(input: string): string {
const bytes = new TextEncoder().encode(input);
const len = bytes.length;
const padded = new Uint8Array((((len + 8) >> 6) + 1) * 64);
padded.set(bytes);
padded[len] = 0x80;
const dv = new DataView(padded.buffer);
const bitLen = len * 8;
dv.setUint32(padded.length - 8, bitLen >>> 0, true);
dv.setUint32(padded.length - 4, Math.floor(bitLen / 0x100000000), true);
let a0 = 0x67452301;
let b0 = 0xefcdab89;
let c0 = 0x98badcfe;
let d0 = 0x10325476;
for (let chunk = 0; chunk < padded.length; chunk += 64) {
const M = new Uint32Array(16);
for (let i = 0; i < 16; i++) M[i] = dv.getUint32(chunk + i * 4, true);
let A = a0;
let B = b0;
let C = c0;
let D = d0;
for (let i = 0; i < 64; i++) {
let F: number;
let g: number;
if (i < 16) {
F = (B & C) | (~B & D);
g = i;
} else if (i < 32) {
F = (D & B) | (~D & C);
g = (5 * i + 1) % 16;
} else if (i < 48) {
F = B ^ C ^ D;
g = (3 * i + 5) % 16;
} else {
F = C ^ (B | ~D);
g = (7 * i) % 16;
}
const sum = (F + A + K_HEX[i] + M[g]) >>> 0;
A = D;
D = C;
C = B;
B = (B + (((sum << S[i]) | (sum >>> (32 - S[i]))) >>> 0)) >>> 0;
}
a0 = (a0 + A) >>> 0;
b0 = (b0 + B) >>> 0;
c0 = (c0 + C) >>> 0;
d0 = (d0 + D) >>> 0;
}
return wordToHexLE(a0) + wordToHexLE(b0) + wordToHexLE(c0) + wordToHexLE(d0);
}
export function md5Lower(input: string): string {
return md5(input.trim().toLowerCase());
}
+406
View File
@@ -0,0 +1,406 @@
// 抓取主流程:遍历订阅源 → 抓取 → 解析 → 去重 → 通知 → 写 KV
// 迁自 check-feeds.js 的 resolveFeed + main,作为 CF Cron 的 scheduled handler。
import type { Env } from '../../types';
import { fetchUrl } from './fetch';
import { discoverFeedUrl, tryCommonFeedPaths } from './discover';
import {
parseFeedXml,
parseJsonFeed,
parseCustomJson,
isJsonResponse,
type Article,
type FeedConfig,
type ParsedFeed,
} from './parse';
import { sendFeishuNotification } from './notify';
import { kvGetJson, kvPutJson } from './util';
const MAX_SEEN = 500;
/** 单源抓取超时(原来 30s:一个挂掉的源能把整批拖到 4 分钟以上) */
const FEED_TIMEOUT = 8000;
/** 连续失败这么多次就进入退避期,不再每次抓(否则每小时白等 8 秒) */
const FAIL_THRESHOLD = 3;
/** 退避时长:6 小时(期间只在其它源都抓完后才可能被跳过) */
const FAIL_BACKOFF_MS = 6 * 3600 * 1000;
interface FailRecord { n: number; at: number }
/** 抓取 + 解析单个订阅源(含 feed 自动发现)。失败返回 null。 */
async function resolveFeed(env: Env, feedConfig: FeedConfig): Promise<ParsedFeed | null> {
const url = feedConfig.url;
const format = feedConfig.format || 'auto';
const forceProxy = feedConfig.proxy === true;
let response;
try {
response = await fetchUrl(env, url, FEED_TIMEOUT, forceProxy);
} catch {
// 直连 + 代理都失败:不再重复抓同一个地址(原来会白等一个超时),
// 直接把常见的 feed 路径猜一遍就放弃。
response = undefined;
}
if (!response) {
const guessedUrl = await tryCommonFeedPaths(env, url);
if (!guessedUrl) return null;
try {
response = await fetchUrl(env, guessedUrl, FEED_TIMEOUT, forceProxy);
} catch {
return null;
}
}
const { text, contentType } = response;
const isJson = isJsonResponse(text, contentType);
if (format === 'json' || (format === 'auto' && isJson)) {
try {
const json = JSON.parse(text);
if (
json.version?.includes('jsonfeed.org') ||
(json.items && Array.isArray(json.items) && !feedConfig.path)
) {
return parseJsonFeed(json, url);
}
return parseCustomJson(json, feedConfig);
} catch {
return null;
}
}
if (
text.includes('<rss') ||
text.includes('<feed') ||
text.includes('<channel') ||
text.includes('<entry')
) {
return parseFeedXml(text, url);
}
// HTML 发现
const feedUrl = discoverFeedUrl(text, url);
if (feedUrl) {
const feedResp = await fetchUrl(env, feedUrl, FEED_TIMEOUT, forceProxy);
if (feedResp) {
if (isJsonResponse(feedResp.text, feedResp.contentType)) {
try {
const json = JSON.parse(feedResp.text);
if (json.version?.includes('jsonfeed.org') || json.items) return parseJsonFeed(json, feedUrl);
return parseCustomJson(json, { url: feedUrl });
} catch {
return null;
}
}
return parseFeedXml(feedResp.text, feedUrl);
}
}
const guessedUrl = await tryCommonFeedPaths(env, url);
if (guessedUrl) {
const guessResp = await fetchUrl(env, guessedUrl, FEED_TIMEOUT, forceProxy);
if (guessResp) {
if (isJsonResponse(guessResp.text, guessResp.contentType)) {
try {
const json = JSON.parse(guessResp.text);
if (json.version?.includes('jsonfeed.org') || json.items) return parseJsonFeed(json, guessedUrl);
return parseCustomJson(json, { url: guessedUrl });
} catch {
return null;
}
}
return parseFeedXml(guessResp.text, guessedUrl);
}
}
return null;
}
/** 从远程 JSON / KV 加载订阅源列表 */
async function loadFeeds(env: Env): Promise<FeedConfig[]> {
// 优先从 KV 读(管理页维护的最新订阅源)
const kvFeeds = await kvGetJson<{ feeds: FeedConfig[] }>(env, 'feeds_config', { feeds: [] });
if (kvFeeds.feeds && kvFeeds.feeds.length > 0) {
return kvFeeds.feeds.map((f) => (typeof f === 'string' ? { url: f } : f));
}
// 回退:FEEDS_URL 远程 JSON
if (env.FEEDS_URL) {
try {
const res = await fetch(env.FEEDS_URL, {
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' },
});
if (res.ok) {
const json = (await res.json()) as any;
if (Array.isArray(json)) {
return json.map((item: string | FeedConfig) =>
typeof item === 'string' ? { url: item } : item,
);
}
if (json.feeds && Array.isArray(json.feeds)) {
return json.feeds.map((item: string | FeedConfig) =>
typeof item === 'string' ? { url: item } : item,
);
}
}
} catch {
// 忽略,走空
}
}
return [];
}
interface SeenData {
lastCheck: string | null;
articles: Record<string, string>;
}
/** scheduled 入口:每天抓取一次 */
/**
* 抓取一批订阅源。
* 免费版 Worker 每次调用限 50 个 subrequest,66 个源全量一把抓会炸,
* 所以按 offset/limit 分批轮转(一天两批跑完全部);latest 按 feed 维度合并写入。
*/
export interface CronStats {
/** 本批计划抓取的源数 */
batch: number;
/** 订阅源总数 */
total: number;
ok: number;
failed: number;
/** 本批抓到的文章条数(去重前) */
articles: number;
/** 判定为新文章的条数 */
newArticles: number;
/** 是否 dry-run(不通知、不写 KV) */
dry: boolean;
durationMs: number;
/** 因连续失败处于退避期、本次跳过的源数 */
skipped: number;
/** 最慢的几个源,用于排查拖后腿的 feed */
slowest: { url: string; ms: number }[];
/** 本次失败的源(最多列 10 个,便于排查) */
failedUrls: string[];
}
export async function runCron(
env: Env,
opts?: { offset?: number; limit?: number; dry?: boolean; rotate?: boolean },
): Promise<CronStats> {
const startedAt = Date.now();
const dry = opts?.dry === true;
const rotate = opts?.rotate === true;
const offset = Math.max(opts?.offset ?? 0, 0);
const batchLimit = Math.max(opts?.limit ?? 0, 0);
const timings: { url: string; ms: number }[] = [];
let okCount = 0;
let failedCount = 0;
let articleCount = 0;
let skippedCount = 0;
const failedUrls: string[] = [];
const emptyStats: CronStats = {
batch: 0, total: 0, ok: 0, failed: 0, articles: 0, newArticles: 0,
dry, durationMs: 0, skipped: 0, slowest: [], failedUrls: [],
};
console.log('🔍 开始检查 RSS Feed...');
const allFeeds = await loadFeeds(env);
if (allFeeds.length === 0) {
console.log('⚠️ 没有可用的订阅源');
return emptyStats;
}
// 失败退避表:连续失败 >= 阈值的源,在退避期内不再每次白等超时
const failMap = await kvGetJson<Record<string, FailRecord>>(env, 'rss_fail', {});
const nowTs = Date.now();
const inBackoff = (u: string): boolean => {
const r = failMap[u];
return !!r && r.n >= FAIL_THRESHOLD && nowTs - r.at < FAIL_BACKOFF_MS;
};
const markFail = (u: string): void => {
const r = failMap[u];
failMap[u] = { n: (r?.n ?? 0) + 1, at: nowTs };
};
const clearFail = (u: string): void => {
if (failMap[u]) delete failMap[u];
};
console.log(`📋 共 ${allFeeds.length} 个订阅源`);
for (const u of Object.keys(failMap)) if (!inBackoff(u)) delete failMap[u]; // 退避期满自动重试
// 环形取本批:offset 开始取 limit 个(limit=0 表示全量)。
// rotate=true 时从 KV 里的游标继续(定时任务用,保证每轮都能覆盖到所有源)。
const feeds: FeedConfig[] = [];
let cursorAfter = offset;
if (batchLimit > 0 && allFeeds.length > batchLimit) {
let start = offset % allFeeds.length;
if (rotate && !dry) {
const saved = await env.RSS_KV.get('rss_cursor');
const n = parseInt(saved || '0', 10);
if (Number.isFinite(n)) start = ((n % allFeeds.length) + allFeeds.length) % allFeeds.length;
}
let examined = 0;
let i = start;
while (feeds.length < batchLimit && examined < allFeeds.length) {
const f = allFeeds[i % allFeeds.length];
i += 1;
examined += 1;
if (inBackoff(f.url)) {
skippedCount += 1;
continue;
}
feeds.push(f);
}
cursorAfter = i % allFeeds.length;
if (rotate && !dry) await env.RSS_KV.put('rss_cursor', String(cursorAfter));
console.log(
`📋 本批 ${feeds.length} 个(cursor=${start}${skippedCount ? `,跳过退避中 ${skippedCount} 个` : ''})`,
);
} else {
feeds.push(...allFeeds);
}
const seenData = await kvGetJson<SeenData>(env, 'seen_articles', {
lastCheck: null,
articles: {},
});
const allNewArticles: Article[] = [];
const allFetchedArticles: (Article & { siteUrl: string })[] = [];
for (const feedConfig of feeds) {
const url = feedConfig.url;
console.log(`🔍 检查: ${url}`);
const t0 = Date.now();
try {
const result = await resolveFeed(env, feedConfig);
timings.push({ url, ms: Date.now() - t0 });
if (!result) {
failedCount++;
markFail(url);
if (failedUrls.length < 10) failedUrls.push(url);
console.log(' ❌ 未找到 Feed');
continue;
}
okCount++;
articleCount += result.articles.length;
clearFail(url);
const { feedTitle, articles } = result;
console.log(` 📰 ${feedTitle || '未知'} - 共 ${articles.length} 篇`);
const recentArticles = articles.slice(0, 10);
allFetchedArticles.push(
...recentArticles.map((a) => ({ ...a, feedTitle: feedTitle || '未知', siteUrl: url })),
);
const newOnes = recentArticles.filter((a) => !seenData.articles[a.link]);
if (newOnes.length > 0) {
console.log(` 🆕 发现 ${newOnes.length} 篇新文章`);
allNewArticles.push(...newOnes);
} else {
console.log(' ✅ 无新文章');
}
for (const a of recentArticles) {
seenData.articles[a.link] = a.pubDate;
}
} catch (err) {
timings.push({ url, ms: Date.now() - t0 });
failedCount++;
markFail(url);
if (failedUrls.length < 10) failedUrls.push(url);
console.error(` ❌ 抓取失败: ${(err as Error).message}`);
}
}
// 通知(仅最近 24h 内的)
const oneDayAgo = Date.now() - 86400000;
const recentNew = allNewArticles.filter(
(a) => new Date(a.pubDate).getTime() > oneDayAgo || !seenData.lastCheck,
);
if (dry) {
console.log(`🧪 dry-run:跳过通知(本应通知 ${recentNew.length} 篇)与 KV 写入`);
} else if (recentNew.length > 0) {
await sendFeishuNotification(env, recentNew);
} else if (allNewArticles.length > 0) {
console.log('ℹ️ 发现新文章但超过 24 小时,不推送');
} else {
console.log('✅ 所有博客均无新文章');
}
// 写 latest 缓存(供 /api/results、/api/articles 读)
// 分批模式:本批源的条目替换旧缓存里的同源条目,其他源的保留
const byFeed: Record<string, { articles: Article[]; siteUrl: string }> = {};
for (const a of allFetchedArticles) {
const key = a.feedTitle || '未知';
if (!byFeed[key]) byFeed[key] = { articles: [], siteUrl: a.siteUrl || '' };
byFeed[key].articles.push({ title: a.title, link: a.link, pubDate: a.pubDate, author: a.author, feedTitle: key });
}
if (!dry) {
try {
await kvPutJson(env, 'rss_fail', failMap);
} catch {
/* 失败表写失败不影响主流程 */
}
}
const durationMs = Date.now() - startedAt;
const stats: CronStats = {
batch: feeds.length,
total: allFeeds.length,
ok: okCount,
failed: failedCount,
articles: articleCount,
newArticles: allNewArticles.length,
dry,
durationMs,
skipped: skippedCount,
slowest: timings.sort((a, b) => b.ms - a.ms).slice(0, 5),
failedUrls,
};
if (dry) return stats;
const prev = await kvGetJson<{ timestamp: string | null; total: number; feeds: { name: string; siteUrl: string; favicon: string; articles: Article[] }[] }>(
env,
'latest',
{ timestamp: null, total: 0, feeds: [] },
);
const batchUrls = new Set(feeds.map((f) => f.url));
const kept = prev.feeds.filter((f) => !batchUrls.has(f.siteUrl || f.name));
const mergedFeeds = [
...kept,
...Object.entries(byFeed).map(([name, { articles, siteUrl }]) => ({
name,
siteUrl,
// 必须写绝对地址:友圈页在 usj.cc 上,相对路径 /api/* 会 404 → 退化成第三方默认头像
favicon: siteUrl
? `${env.PUBLIC_API_BASE || 'https://api.200181.xyz'}/api/favicon?url=${encodeURIComponent(siteUrl)}`
: '',
articles,
})),
];
const mergedTotal = mergedFeeds.reduce((n, f) => n + f.articles.length, 0);
await kvPutJson(env, 'latest', {
timestamp: new Date().toISOString(),
total: mergedTotal,
feeds: mergedFeeds,
});
// 清理 + 保存去重记录
const entries = Object.entries(seenData.articles);
if (entries.length > MAX_SEEN) {
entries.sort((a, b) => new Date(b[1]).getTime() - new Date(a[1]).getTime());
seenData.articles = Object.fromEntries(entries.slice(0, MAX_SEEN));
}
seenData.lastCheck = new Date().toISOString();
await kvPutJson(env, 'seen_articles', seenData);
console.log(`📝 已保存去重记录 (${Object.keys(seenData.articles).length} 条)`);
return stats;
}
+51
View File
@@ -0,0 +1,51 @@
// Feed 自动发现:从 HTML 里找 RSS/Atom/JSON Feed 链接,或试常见路径
// 迁自 check-feeds.js 的 discoverFeedUrl / tryCommonFeedPaths
import { probeDirect } from './fetch';
import type { Env } from '../../types';
export function discoverFeedUrl(html: string, baseUrl: string): string | null {
const patterns = [
/<link[^>]+type=["']application\/rss\+xml["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+type=["']application\/rss\+xml["']/i,
/<link[^>]+type=["']application\/atom\+xml["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+type=["']application\/atom\+xml["']/i,
/<link[^>]+type=["']application\/feed\+json["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+type=["']application\/feed\+json["']/i,
/<link[^>]+type=["']application\/json["'][^>]+title=["'][^"']*feed[^"']*["'][^>]+href=["']([^"']+)["']/i,
];
for (const pattern of patterns) {
const match = html.match(pattern);
if (match) {
let href = match[1];
const urlObj = new URL(baseUrl);
if (href.startsWith('/')) {
href = `${urlObj.protocol}//${urlObj.host}${href}`;
} else if (!href.startsWith('http')) {
href = `${urlObj.protocol}//${urlObj.host}/${href}`;
}
return href;
}
}
return null;
}
export async function tryCommonFeedPaths(env: Env, baseUrl: string): Promise<string | null> {
const urlObj = new URL(baseUrl);
// 只留最常见的三个:候选多一个,最坏情况就多等一个超时(原来的 9 个候选能把
// 单个源拖到 180 秒以上,整批跑几分钟)
const paths = ['/feed', '/feed.xml', '/atom.xml'];
for (const path of paths) {
const candidate = `${urlObj.protocol}//${urlObj.host}${path}`;
try {
const { contentType } = await probeDirect(candidate, 4000);
const ct = contentType.toLowerCase();
if (ct.includes('xml') || ct.includes('rss') || ct.includes('atom') || ct.includes('json')) {
return candidate;
}
} catch {
// 忽略,继续试
}
}
return null;
}
+152
View File
@@ -0,0 +1,152 @@
// 抓取逻辑:直连 + 腾讯云 SCF 国内代理回退
// 迁自 check-feeds.js 的 fetchUrl / fetchViaProxy。
//
// 关键:CF Worker 跑在境外节点,抓国内博客可能被拦。
// 复用已搭好的腾讯云 SCF(scfapi.usj.cc,国内 IP)作为回退代理。
// 原 EdgeOne 的 /api/proxy 也是境外节点,迁移后不再需要——Worker 直连即等价。
import type { Env } from '../../types';
// 单源超时:原来 30s 太长(一个挂掉的源能把整批拖到 4 分钟以上)。
// 正常 RSS 1-3 秒就能回来,8 秒足够;代理(国内 SCF)多给 4 秒余量。
const REQUEST_TIMEOUT = 8000;
const PROXY_TIMEOUT_EXTRA = 4000;
// ── 代理熔断 ─────────────────────────────────────────────────────────────
// 国内代理(scfapi.usj.cc)证书过期/挂掉时,每个源都要先直连超时、再代理超时,
// 一批 20 多个源能白等好几分钟。连续失败若干次后就暂时跳过代理,省掉这一半时间。
let proxyFailStreak = 0;
let proxySkipUntil = 0;
const PROXY_FAIL_THRESHOLD = 5;
const PROXY_SKIP_MS = 10 * 60 * 1000;
export interface FetchResult {
text: string;
contentType: string;
via: 'direct' | 'proxy';
}
/** 直连抓取 */
async function fetchDirect(url: string, timeout: number): Promise<FetchResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,application/xml,application/json;q=0.9,*/*;q=0.8',
},
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const contentType = (res.headers.get('content-type') || '').toLowerCase();
return { text, contentType, via: 'direct' };
} finally {
clearTimeout(timer);
}
}
/** 通过腾讯云 SCF 国内代理抓取 */
async function fetchViaSCF(
env: Env,
url: string,
timeout: number,
): Promise<FetchResult> {
const proxyUrl = env.SCF_PROXY_URL;
if (!proxyUrl) throw new Error('SCF_PROXY_URL not configured');
if (Date.now() < proxySkipUntil) throw new Error('proxy temporarily disabled (recent failures)');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const res = await fetch(proxyUrl, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, timeout }),
});
// 代理这一层通了(HTTP 2xx)就不算代理故障
if (res.ok) {
proxyFailStreak = 0;
proxySkipUntil = 0;
}
const data = (await res.json().catch(() => ({}))) as {
ok?: boolean;
status?: number;
error?: string;
body?: string;
contentType?: string;
};
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
if (!data.ok) {
// 代理连得上,是目标站点抓不动(站点挂了/被墙)→ 不熔断代理
throw new Error(data.error || 'target fetch failed');
}
if (data.status && data.status >= 400) throw new Error(`目标 HTTP ${data.status}`);
proxyFailStreak = 0;
return {
text: data.body || '',
contentType: (data.contentType || '').toLowerCase(),
via: 'proxy',
};
} catch (err) {
const msg = (err as Error).message || '';
// 只有"连不上代理/代理返回错误状态"才计故障;目标站点失败不算
const proxyLevelFailure = /^(HTTP \d|proxy temporarily disabled|SCF_PROXY_URL)/.test(msg) ||
/fetch failed|Network|TLS|abort/i.test(msg);
if (proxyLevelFailure && !/target fetch failed/.test(msg)) {
proxyFailStreak += 1;
if (proxyFailStreak >= PROXY_FAIL_THRESHOLD) {
proxySkipUntil = Date.now() + PROXY_SKIP_MS;
proxyFailStreak = 0;
console.log('⚠️ 国内代理连续失败,暂时跳过代理(10 分钟)');
}
}
throw err;
} finally {
clearTimeout(timer);
}
}
/**
* 只走直连的轻量探测:用于"猜常见 feed 路径"。
* 猜路径最多试几个候选,如果每个都再走一遍代理,一个挂掉的源能拖 3 分钟
* (实测有单源 237 秒),所以探测只用直连 + 短超时。
*/
export async function probeDirect(url: string, timeout = 4000): Promise<FetchResult> {
return fetchDirect(url, timeout);
}
/**
* 抓取一个 URL:先直连,失败回退 SCF 代理。
* 与 check-feeds.js 一致:feed.proxy === true 时强制走代理。
*/
export async function fetchUrl(
env: Env,
url: string,
timeout = REQUEST_TIMEOUT,
forceProxy = false,
): Promise<FetchResult> {
const proxyTimeout = timeout + PROXY_TIMEOUT_EXTRA;
if (forceProxy) {
return fetchViaSCF(env, url, proxyTimeout);
}
try {
return await fetchDirect(url, timeout);
} catch (directErr) {
// 直连失败 → 回退国内代理
try {
const viaProxy = await fetchViaSCF(env, url, proxyTimeout);
return viaProxy;
} catch (proxyErr) {
throw new Error(
`直连失败(${(directErr as Error).message}),代理也失败(${(proxyErr as Error).message})`,
);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
// 默认问候语词库(迁自 edgeone/functions/api/ai/greeting.js 的 defaultPool)
export interface Greeting {
text: string;
time: string | null;
holiday: string | null;
}
export function defaultPool(): Greeting[] {
return [
{ text: '早上好,今天的咖啡够浓吗', time: 'weekday-morning', holiday: null },
{ text: '新的一天,新的 bug 等着你', time: 'weekday-morning', holiday: null },
{ text: '周一的闹钟总是响得特别早', time: 'weekday-morning', holiday: null },
{ text: '周二了,距离周末还有 3 天', time: 'weekday-morning', holiday: null },
{ text: '周三,一周的折返点', time: 'weekday-morning', holiday: null },
{ text: '周四了,胜利在望', time: 'weekday-morning', holiday: null },
{ text: '周五早晨的空气都是甜的', time: 'weekday-morning', holiday: null },
{ text: '上班前来看看博客吧', time: 'weekday-morning', holiday: null },
{ text: '通勤路上,刷一篇好文章', time: 'weekday-morning', holiday: null },
{ text: '阳光正好,写点什么吧', time: 'morning', holiday: null },
{ text: '一杯茶,一篇文章,一个上午', time: 'morning', holiday: null },
{ text: '灵感总在上午悄悄来访', time: 'morning', holiday: null },
{ text: '窗外鸟鸣,键盘轻敲', time: 'morning', holiday: null },
{ text: '午饭吃好了吗,来读篇博客', time: 'noon', holiday: null },
{ text: '午休时间,偷得浮生一刻闲', time: 'noon', holiday: null },
{ text: '饱了才有力气写代码', time: 'noon', holiday: null },
{ text: '午餐后的惬意,属于博客时光', time: 'noon', holiday: null },
{ text: '午后阳光很暖,文字也很温柔', time: 'afternoon', holiday: null },
{ text: '来杯下午茶,配一篇好博客', time: 'afternoon', holiday: null },
{ text: '不想工作的时候,就读博客吧', time: 'afternoon', holiday: null },
{ text: '下午三点,正是摸鱼好时光', time: 'afternoon', holiday: null },
{ text: '代码写累了,换个脑子', time: 'afternoon', holiday: null },
{ text: '夕阳之下,该给今天收个尾了', time: 'evening', holiday: null },
{ text: '晚霞温柔,适合安静地读点东西', time: 'evening', holiday: null },
{ text: '下班了吗,博客等你回家', time: 'evening', holiday: null },
{ text: '暮色四合,一天又悄悄过去了', time: 'evening', holiday: null },
{ text: '夜深了,只有你还在折腾博客吧', time: 'night', holiday: null },
{ text: '凌晨三点,灵感比白天更活跃', time: 'night', holiday: null },
{ text: '熬夜冠军,博客世界永远亮着灯', time: 'night', holiday: null },
{ text: '星星都睡了,你的博客还醒着', time: 'night', holiday: null },
{ text: '深夜的代码写给自己看', time: 'night', holiday: null },
{ text: '失眠的夜晚,幸好有博客陪伴', time: 'night', holiday: null },
{ text: '周末不用早起,但可以早起写博客', time: 'weekend', holiday: null },
{ text: '窝在沙发里,手机刷博客', time: 'weekend', holiday: null },
{ text: '周末的早晨,适合赖床和码字', time: 'weekend', holiday: null },
{ text: '终于有空了,把攒了一周的文章读完', time: 'weekend', holiday: null },
{ text: '周末宅家,博客是最好的伴侣', time: 'weekend', holiday: null },
{ text: '咖啡 + 面包 + 博客 = 完美周末', time: 'weekend', holiday: null },
{ text: '没有 deadline 的周末,写点想写的', time: 'weekend', holiday: null },
{ text: '元旦快乐,新的一年从一篇博客开始', time: null, holiday: '01-01' },
{ text: '新年新气象,博客也要更新啦', time: null, holiday: '01-01' },
{ text: '元旦快乐,今年第一篇写什么', time: null, holiday: '01-01' },
{ text: '情人节快乐,代码和爱情可以兼得', time: null, holiday: '02-14' },
{ text: '今天不写代码,陪 ta 看看博客', time: null, holiday: '02-14' },
{ text: '三八妇女节,致敬所有闪闪发光的她', time: null, holiday: '03-08' },
{ text: '愚人节快乐,今天看到什么都别信', time: null, holiday: '04-01' },
{ text: '劳动节快乐,今天不写代码', time: null, holiday: '05-01' },
{ text: '五一劳动节,劳动者最光荣', time: null, holiday: '05-01' },
{ text: '五四青年节,趁年轻多写点博客', time: null, holiday: '05-04' },
{ text: '六一快乐,谁还不是个孩子呢', time: null, holiday: '06-01' },
{ text: '国庆快乐,祖国繁荣昌盛', time: null, holiday: '10-01' },
{ text: '假期余额不多,抓紧时间写博客', time: null, holiday: '10-01' },
{ text: '圣诞快乐,博客就是你的圣诞老人', time: null, holiday: '12-25' },
{ text: '圣诞夜,许个愿,明年博客涨粉', time: null, holiday: '12-25' },
{ text: '立春了,博客也要焕发新生', time: null, holiday: '02-04' },
{ text: '春分时节,昼夜平分,灵感均分', time: null, holiday: '03-20' },
{ text: '夏至已至,白天很长,文章也可以很长', time: null, holiday: '06-21' },
{ text: '秋分,收获的季节,盘点一下今年的博客', time: null, holiday: '09-23' },
{ text: '冬至了,吃碗饺子暖暖心,写篇博客暖暖手', time: null, holiday: '12-22' },
{ text: '又是美好的一天', time: null, holiday: null },
{ text: '今天想写点什么吗', time: null, holiday: null },
{ text: '来博客串个门吧', time: null, holiday: null },
{ text: '保持好奇心,世界不会无趣', time: null, holiday: null },
{ text: '你有多久没有好好写点东西了', time: null, holiday: null },
{ text: '每个博客都是一扇窗', time: null, holiday: null },
{ text: '写作是和自己的对话', time: null, holiday: null },
{ text: '今天遇到什么有趣的事了吗', time: null, holiday: null },
{ text: '读别人的故事,写自己的心情', time: null, holiday: null },
{ text: '博客是一个人的宇宙', time: null, holiday: null },
{ text: '别让灵感溜走,赶紧码下来', time: null, holiday: null },
{ text: '有人默默关注着你的博客呢', time: null, holiday: null },
{ text: '每个字都是时间的印记', time: null, holiday: null },
{ text: '博客不老,我们永远年轻', time: null, holiday: null },
];
}
+67
View File
@@ -0,0 +1,67 @@
// 通知推送:飞书(邮件后续可加)
// 迁自 check-feeds.js 的 sendFeishuNotification + formatRelativeTime
import type { Env } from '../../types';
import type { Article } from './parse';
function formatRelativeTime(isoDate: string): string {
const diff = Date.now() - new Date(isoDate).getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days} 天前`;
return new Date(isoDate).toISOString().slice(0, 10);
}
export async function sendFeishuNotification(env: Env, newArticles: Article[]): Promise<void> {
if (!env.FEISHU_WEBHOOK_URL) {
console.log('⚠️ 未配置 FEISHU_WEBHOOK_URL,跳过飞书通知');
return;
}
const grouped: Record<string, Article[]> = {};
for (const article of newArticles) {
const key = article.feedTitle || '未知博客';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(article);
}
const elements: unknown[] = [];
for (const [feedName, articles] of Object.entries(grouped)) {
elements.push({ tag: 'markdown', content: `**📰 ${feedName}**` });
const articleLines = articles.map(
(a) => `- [${a.title}](${a.link}) <font color="grey">${formatRelativeTime(a.pubDate)}</font>`,
);
elements.push({ tag: 'markdown', content: articleLines.join('\n') });
}
const card = {
msg_type: 'interactive',
card: {
header: {
title: { tag: 'plain_text', content: `🔔 博客更新 · ${newArticles.length} 篇新文章` },
template: 'blue',
},
elements,
},
};
try {
const res = await fetch(env.FEISHU_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(card),
});
const result = (await res.json()) as { code?: number };
if (result.code === 0) {
console.log(`✅ 飞书通知发送成功 (${newArticles.length} 篇新文章)`);
} else {
console.error('❌ 飞书通知发送失败:', result);
}
} catch (err) {
console.error('❌ 飞书通知发送异常:', (err as Error).message);
}
}
+208
View File
@@ -0,0 +1,208 @@
// RSS / Atom / JSON Feed / 自定义 JSON 解析
// 迁自 check-feeds.js 的 parseFeedXml / parseJsonFeed / parseCustomJson 等。
export interface Article {
title: string;
link: string;
pubDate: string;
author: string;
feedTitle: string;
}
export interface FeedConfig {
url: string;
feedTitle?: string;
format?: string;
path?: string;
proxy?: boolean;
mapping?: Record<string, string>;
}
export interface ParsedFeed {
feedTitle: string;
articles: Article[];
}
function extractTag(xml: string, tag: string): string {
const regex = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i');
const match = xml.match(regex);
return match ? match[1] : '';
}
function decodeXml(str: string): string {
return str
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
export function parseDate(val: unknown): string | null {
if (!val) return null;
if (typeof val === 'number') {
const d = new Date(val > 9999999999 ? val : val * 1000);
return isNaN(d.getTime()) ? null : d.toISOString();
}
const str = String(val).trim();
if (!str) return null;
const d = new Date(str);
if (isNaN(d.getTime())) return null;
try {
return d.toISOString();
} catch {
return null;
}
}
export function parseFeedXml(xml: string, feedUrl: string): ParsedFeed {
const articles: Article[] = [];
let feedTitle = '';
const titleMatch = xml.match(/<title[^>]*>([\s\S]*?)<\/title>/);
if (titleMatch) feedTitle = decodeXml(titleMatch[1]).trim();
const itemRegex = /<item[\s>]?>([\s\S]*?)<\/item>/gi;
const entryRegex = /<entry[\s>]?>([\s\S]*?)<\/entry>/gi;
const parseItem = (itemXml: string) => {
const title = extractTag(itemXml, 'title');
let link = '';
const rssLink = extractTag(itemXml, 'link');
if (rssLink) link = rssLink;
const atomLinkMatch = itemXml.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i);
if (atomLinkMatch && atomLinkMatch[1]) link = atomLinkMatch[1];
const altLinkMatch = itemXml.match(
/<link[^>]+rel=["']alternate["'][^>]+href=["']([^"']+)["']/i,
);
if (altLinkMatch) link = altLinkMatch[1];
const pubDate =
extractTag(itemXml, 'pubDate') ||
extractTag(itemXml, 'published') ||
extractTag(itemXml, 'updated') ||
extractTag(itemXml, 'dc:date') ||
extractTag(itemXml, 'lastBuildDate') ||
extractTag(itemXml, 'date') ||
extractTag(itemXml, 'modified') ||
extractTag(itemXml, 'created');
const author = extractTag(itemXml, 'dc:creator') || extractTag(itemXml, 'author') || '';
const authorName = author.replace(/<name>([\s\S]*?)<\/name>/gi, '$1').trim();
if (title && link) {
articles.push({
title: decodeXml(title).trim(),
link: link.trim(),
pubDate: parseDate(pubDate) || new Date().toISOString(),
author: decodeXml(authorName).trim(),
feedTitle: feedTitle || feedUrl,
});
}
};
let match: RegExpExecArray | null;
while ((match = itemRegex.exec(xml)) !== null) parseItem(match[1]);
while ((match = entryRegex.exec(xml)) !== null) parseItem(match[1]);
return { feedTitle, articles };
}
export function parseJsonFeed(json: any, feedUrl: string): ParsedFeed {
const articles: Article[] = [];
const feedTitle = json.title || feedUrl;
const items = json.items || [];
for (const item of items) {
const title = item.title || '';
const link = item.url || item.id || '';
const pubDate =
item.date_published || item.date_modified || item.date || item.published || item.modified || item.created_at || item.createdAt || item.pubDate || item.timestamp || null;
const author = Array.isArray(item.authors)
? item.authors.map((a: any) => a.name).join(', ')
: item.author?.name || item.author || '';
if (title && link) {
articles.push({
title: title.trim(),
link: link.trim(),
pubDate: parseDate(pubDate) || new Date().toISOString(),
author: typeof author === 'string' ? author.trim() : '',
feedTitle,
});
}
}
return { feedTitle, articles };
}
function getNestedValue(obj: any, path?: string): unknown {
if (!obj || !path) return undefined;
return path.split('.').reduce((o: any, key) => o?.[key], obj);
}
export function parseCustomJson(json: any, config: FeedConfig): ParsedFeed {
const articles: Article[] = [];
const mapping = config.mapping || {};
const path = config.path || '';
let data: any = json;
if (path) {
for (const key of path.split('.')) {
if (data && typeof data === 'object') data = data[key];
}
}
const items = Array.isArray(data) ? data : [];
const titleKey = mapping.title || 'title';
const linkKey = mapping.link || 'link';
const pubDateKey = mapping.pubDate || 'pubDate';
const authorKey = mapping.author || 'author';
const feedTitleKey = mapping.feedTitle || 'feedTitle';
for (const item of items) {
const title = getNestedValue(item, titleKey) || '';
const link = getNestedValue(item, linkKey) || getNestedValue(item, 'url') || '';
const pubDate =
getNestedValue(item, pubDateKey) ||
getNestedValue(item, 'publishedAt') ||
getNestedValue(item, 'createdAt') ||
getNestedValue(item, 'created_at') ||
getNestedValue(item, 'date') ||
getNestedValue(item, 'published') ||
getNestedValue(item, 'updatedAt') ||
getNestedValue(item, 'timestamp') ||
getNestedValue(item, 'datePublished') ||
getNestedValue(item, 'dateModified') ||
null;
const author = getNestedValue(item, authorKey) || '';
const feedTitle = getNestedValue(item, feedTitleKey) || config.feedTitle || config.url || '';
if (title && link) {
articles.push({
title: String(title).trim(),
link: String(link).trim(),
pubDate: parseDate(pubDate) || new Date().toISOString(),
author: String(typeof author === 'object' ? '' : author).trim(),
feedTitle: String(feedTitle),
});
}
}
return { feedTitle: config.feedTitle || config.url || '', articles };
}
export function isJsonResponse(text: string, contentType: string): boolean {
if (contentType?.includes('json')) return true;
try {
const parsed = JSON.parse(text);
return typeof parsed === 'object' && parsed !== null;
} catch {
return false;
}
}
+93
View File
@@ -0,0 +1,93 @@
// 国内代理(腾讯云 SCF)体检。
//
// 为什么要在服务器端做:代理挂掉(最常见的原因是 SCF 自定义域名的免费证书 90 天到期、
// 而它不会自动续)会让抓国内博客全部失败,但这件事本地电脑开着才会被想起来。
// 这里放进 Worker 自己的每日 cron:电脑关着也能发提醒邮件。
//
// 判断方式不看证书日期,直接"能不能抓到东西" —— 用国内知名站点探测,
// 任意一个成功就认为代理可用(比解析证书更贴近实际效果)。
import type { Env } from '../../types';
import { notifyAdmin } from '../admin-notify';
/** 探测站:国内可直连、响应快、不易挂 */
const PROBE_URLS = ['https://www.rz.sb', 'https://blog.qydzz.cn', 'https://t-t.live'];
const PROBE_TIMEOUT_MS = 12000;
export interface ProxyProbe {
url: string;
ok: boolean;
ms: number;
error?: string;
}
export interface ProxyHealth {
/** 至少一个探测站成功 */
ok: boolean;
probes: ProxyProbe[];
checkedAt: string;
}
/** 通过代理抓一个 URL(与 RSS 抓取走同一条链路) */
async function probe(env: Env, target: string): Promise<ProxyProbe> {
const started = Date.now();
const proxyUrl = env.SCF_PROXY_URL;
if (!proxyUrl) return { url: target, ok: false, ms: 0, error: 'SCF_PROXY_URL not configured' };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const res = await fetch(proxyUrl, {
method: 'POST',
signal: controller.signal,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: target, timeout: PROBE_TIMEOUT_MS }),
});
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string };
const ms = Date.now() - started;
if (res.ok && data.ok) return { url: target, ok: true, ms };
return { url: target, ok: false, ms, error: data.error || `HTTP ${res.status}` };
} catch (e) {
return { url: target, ok: false, ms: Date.now() - started, error: (e as Error).message };
} finally {
clearTimeout(timer);
}
}
export async function checkProxyHealth(env: Env): Promise<ProxyHealth> {
const probes: ProxyProbe[] = [];
for (const target of PROBE_URLS) {
const r = await probe(env, target);
probes.push(r);
if (r.ok) break; // 有一个通就算代理没问题,不用继续
}
return {
ok: probes.some((p) => p.ok),
probes,
checkedAt: new Date().toISOString(),
};
}
/** 代理不可用 → 给站长发提醒(48 小时最多一封,避免重复刷屏) */
export async function notifyProxyDown(env: Env, health: ProxyHealth) {
const lines = health.probes
.map((p) => `· ${p.url}:${p.ok ? `OK(${p.ms}ms)` : `失败(${p.error || '未知'})`}`)
.join('\n');
const text =
`国内代理 scfapi.usj.cc 体检不通过:${health.probes.length} 个探测站全部抓不到。\n\n` +
`探测结果:\n${lines}\n\n` +
'影响:Cloudflare Worker 抓国内博客时的代理回退会全部失败,友圈/订阅数据会停止更新(直连只对少数境外可访问的站点有效)。\n\n' +
'最常见原因:scfapi.usj.cc 用的是腾讯云 SCF 自定义域名上的免费 DV 证书,90 天到期且不会自动续。\n\n' +
'续期步骤(约 5 分钟):\n' +
'1) 腾讯云 SSL 证书控制台 → 申请免费证书:域名 scfapi.usj.cc,验证方式选「自动 DNS 验证」(usj.cc 托管在 DNSPod,几分钟签发)\n' +
'2) 打开 https://console.cloud.tencent.com/scf/domain?rid=1 (云函数 → 高级能力 → 自定义域名,广州地域)→ 找到 scfapi.usj.cc → 编辑 → HTTPS 里重新选择刚签发的新证书 → 保存\n' +
'3) 验证:curl -s -X POST https://scfapi.usj.cc -H "Content-Type: application/json" -d \'{"url":"https://www.rz.sb","timeout":15000}\' 返回 {"ok":true,...} 即恢复\n\n' +
'(本提醒由服务器端每日任务发出,同一状态最多两天一封;恢复后自动停止。)';
return notifyAdmin(env, {
subject: '【提醒】国内代理 scfapi.usj.cc 不可用(多半是证书过期)',
text,
dedupeKey: 'scf-proxy-down',
minGapHours: 48,
});
}
+46
View File
@@ -0,0 +1,46 @@
// 通用工具:鉴权、CORS、KV 封装
// 把原 EdgeOne 每个 api/*.js 里重复的 requireAuth / respond / corsOptions 收拢到一处
import type { Env } from '../../types';
export const CORS_HEADERS = {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
} as const;
export function respond(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { status, headers: CORS_HEADERS });
}
export function corsOptions(): Response {
return new Response(null, { status: 204, headers: CORS_HEADERS });
}
/** 校验站点口令:URL ?token= / Cookie site_token=。KV 无口令时放行(与原实现一致)。 */
export async function requireAuth(request: Request, env: Env): Promise<boolean> {
const url = new URL(request.url);
const cookie = request.headers.get('Cookie') || '';
const cookieMatch = cookie.match(/site_token=([^;]+)/);
const token = url.searchParams.get('token') || (cookieMatch ? cookieMatch[1] : '');
const password = await env.RSS_KV.get('site_password');
if (!password) return true; // 未设置口令 → 放行
return token === password;
}
/** 读 KV 里的 JSON,解析失败或不存在返回 fallback */
export async function kvGetJson<T>(env: Env, key: string, fallback: T): Promise<T> {
const raw = await env.RSS_KV.get(key);
if (!raw) return fallback;
try {
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
export async function kvPutJson(env: Env, key: string, value: unknown): Promise<void> {
await env.RSS_KV.put(key, JSON.stringify(value));
}
+210
View File
@@ -0,0 +1,210 @@
import type { Env, SessionUser, UserRow } from '../types';
import { findUserByEmail, findUserById, getAdminUsers } from './db';
import { md5 } from './md5';
import { now } from './util';
// ================================================================= 编解码
function b64urlEncode(bytes: Uint8Array | string): string {
const buf = typeof bytes === 'string' ? new TextEncoder().encode(bytes) : bytes;
let bin = '';
for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
function b64urlDecodeToText(s: string): string {
const pad = s.length % 4 ? '='.repeat(4 - (s.length % 4)) : '';
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/') + pad);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder().decode(bytes);
}
function b64ToBytes(s: string): Uint8Array {
const bin = atob(s);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function bytesToB64(bytes: Uint8Array): string {
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
// ================================================================= 密码
// ★ 不要超过 100000:Cloudflare Workers 生产环境的 WebCrypto 会直接报
// "Pbkdf2 failed: iteration counts above 100000 are not supported"。
// 本地 workerd 不拦这一条,所以只在线上才会炸——别再往上调。
const PBKDF2_ITER = 100000;
export async function hashPassword(plain: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const bits = await pbkdf2(plain, salt, PBKDF2_ITER);
return `(pbkdf2)${PBKDF2_ITER}$${bytesToB64(salt)}$${bytesToB64(bits)}`;
}
async function pbkdf2(plain: string, salt: Uint8Array, iter: number): Promise<Uint8Array> {
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(plain), 'PBKDF2', false, [
'deriveBits',
]);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', hash: 'SHA-256', salt: salt as unknown as BufferSource, iterations: iter },
key,
256,
);
return new Uint8Array(bits);
}
/** 兼容 Artalk 的三种存储格式:(bcrypt) 不支持,这里只处理 pbkdf2 / md5 / 明文 */
export async function verifyPassword(stored: string, input: string): Promise<boolean> {
const v = (stored || '').trim();
if (!v) return false;
if (v.startsWith('(pbkdf2)')) {
const body = v.slice('(pbkdf2)'.length);
const [iterStr, saltB64, hashB64] = body.split('$');
const iter = parseInt(iterStr, 10);
if (!iter || !saltB64 || !hashB64) return false;
const bits = await pbkdf2(input, b64ToBytes(saltB64), iter);
return timingSafeEqual(bytesToB64(bits), hashB64);
}
if (v.startsWith('(md5)')) {
return timingSafeEqual(md5(input), v.slice('(md5)'.length).toLowerCase());
}
if (v.startsWith('(bcrypt)')) {
// Workers 里不引入 bcrypt 依赖。若迁移自官方 Artalk 的 bcrypt 密码,
// 请在后台重设一次密码(会写成 pbkdf2)。
return false;
}
return timingSafeEqual(input, v);
}
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
// ================================================================= Token
const TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 天,与官方一致
async function hmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
);
}
export async function signToken(env: Env, userId: number): Promise<string> {
const payload = b64urlEncode(
JSON.stringify({ uid: userId, iat: now(), exp: now() + TOKEN_TTL_MS }),
);
const key = await hmacKey(env.TOKEN_SECRET);
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload));
return `${payload}.${b64urlEncode(new Uint8Array(sig))}`;
}
export async function verifyToken(
env: Env,
token: string,
): Promise<{ uid: number; iat: number } | null> {
const parts = (token || '').split('.');
if (parts.length !== 2) return null;
const [payload, sig] = parts;
try {
const key = await hmacKey(env.TOKEN_SECRET);
const pad = sig.length % 4 ? '='.repeat(4 - (sig.length % 4)) : '';
const bin = atob(sig.replace(/-/g, '+').replace(/_/g, '/') + pad);
const sigBytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) sigBytes[i] = bin.charCodeAt(i);
const valid = await crypto.subtle.verify(
'HMAC',
key,
sigBytes as unknown as BufferSource,
new TextEncoder().encode(payload),
);
if (!valid) return null;
const data = JSON.parse(b64urlDecodeToText(payload)) as { uid: number; iat: number; exp: number };
if (!data.exp || data.exp < now()) return null;
return { uid: data.uid, iat: data.iat };
} catch {
return null;
}
}
// ================================================================= 当前用户
export async function userFromToken(env: Env, authHeader: string | null): Promise<UserRow | null> {
if (!authHeader) return null;
const m = authHeader.match(/^Bearer\s+(.+)$/i);
if (!m) return null;
const payload = await verifyToken(env, m[1].trim());
if (!payload) return null;
const user = await findUserById(env, payload.uid);
if (!user) return null;
// 改过密码 / 被踢下线:token_valid_from 之后的签发才有效
if (user.token_valid_from && payload.iat < user.token_valid_from) return null;
return user;
}
export function sessionOf(user: UserRow | null): SessionUser | null {
if (!user) return null;
return { id: user.id, name: user.name, email: user.email, isAdmin: !!user.is_admin };
}
/**
* 判断请求是否拥有管理员权限:
* 1) 带合法 token 且用户 is_admin=1,或
* 2) 配置里的 admin_users(name+email 匹配)—— 兼容官方 Artalk 的 config 管理员
*/
export async function isAdminRequest(
env: Env,
req: Request,
user?: UserRow | null,
): Promise<boolean> {
const u = user === undefined ? await userFromToken(env, req.headers.get('Authorization')) : user;
if (u?.is_admin) return true;
const admins = await getAdminUsers(env);
// 已登录用户:与配置里的管理员邮箱一致也算
if (u && admins.some((a) => a.email && a.email.toLowerCase() === u.email.toLowerCase())) return true;
// 匿名请求:Artalk 允许带 name/email 的查询参数来判定
const url = new URL(req.url);
const name = url.searchParams.get('name') || '';
const email = url.searchParams.get('email') || '';
if (name && email) {
return admins.some(
(a) => a.name === name && a.email.toLowerCase() === email.toLowerCase(),
);
}
return false;
}
export async function isAdminByNameEmail(env: Env, name: string, email: string): Promise<boolean> {
if (!name || !email) return false;
const admins = await getAdminUsers(env);
const hitConf = admins.some(
(a) => a.name === name && a.email.toLowerCase() === email.toLowerCase(),
);
if (hitConf) return true;
const existing = (await findUserByEmail(env, email)).find((u) => u.name === name);
return !!existing?.is_admin;
}
+140
View File
@@ -0,0 +1,140 @@
import type { Env } from '../types';
export const CN_OFFSET_MS = 8 * 60 * 60 * 1000;
/** 毫秒时间戳 → "YYYY-MM-DD HH:mm:ss"(Asia/Shanghai,中国无夏令时) */
export function formatDateCN(ms: number): string {
const d = new Date((ms || 0) + CN_OFFSET_MS);
const p = (n: number, len = 2) => String(n).padStart(len, '0');
return (
`${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
`${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`
);
}
export function now(): number {
return Date.now();
}
// ------------------------------------------------------------------ 响应封装
export function json(data: unknown, init: ResponseInit = {}): Response {
const headers = new Headers(init.headers);
headers.set('Content-Type', 'application/json; charset=utf-8');
return new Response(JSON.stringify(data ?? {}), { ...init, headers });
}
/** Artalk 成功响应:RespData 是「裸数据」,RespSuccess 是 {"msg":"Success"} */
export function ok(data: unknown): Response {
return json(data);
}
export function okMsg(msg = 'Success', extra: Record<string, unknown> = {}): Response {
return json({ msg, ...extra });
}
/** Artalk 错误响应:{"msg": "..."} + 非 200 状态码 */
export function fail(status: number, msg: string, extra: Record<string, unknown> = {}): Response {
return json({ msg, ...extra }, { status });
}
// ------------------------------------------------------------------ 请求解析
export function getClientIP(req: Request): string {
return (
req.headers.get('CF-Connecting-IP') ||
(req.headers.get('X-Forwarded-For') || '').split(',')[0].trim() ||
'127.0.0.1'
);
}
export function getUserAgent(req: Request): string {
return req.headers.get('User-Agent') || '';
}
export async function readBody(req: Request): Promise<Record<string, any>> {
const ct = req.headers.get('Content-Type') || '';
try {
if (ct.includes('application/json')) {
const v = await req.json();
return v && typeof v === 'object' ? (v as Record<string, any>) : {};
}
if (ct.includes('form-urlencoded')) {
const text = await req.text();
const out: Record<string, any> = {};
for (const [k, v] of new URLSearchParams(text)) out[k] = v;
return out;
}
const text = await req.text();
if (!text) return {};
try {
const v = JSON.parse(text);
return v && typeof v === 'object' ? v : {};
} catch {
const out: Record<string, any> = {};
for (const [k, v] of new URLSearchParams(text)) out[k] = v;
return out;
}
} catch {
return {};
}
}
export function qp(url: URL, key: string): string {
return url.searchParams.get(key) ?? '';
}
export function qInt(url: URL, key: string, dft = 0): number {
const v = parseInt(url.searchParams.get(key) ?? '', 10);
return Number.isFinite(v) ? v : dft;
}
export function qBool(url: URL, key: string): boolean {
const v = (url.searchParams.get(key) ?? '').toLowerCase();
return v === '1' || v === 'true' || v === 'yes';
}
// ------------------------------------------------------------------ 校验
export function isEmail(s: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test((s || '').trim());
}
export function isUrl(s: string): boolean {
try {
const u = new URL(s);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
export function trimTo(s: string, max: number): string {
const v = String(s ?? '');
return v.length > max ? v.slice(0, max) : v;
}
// ------------------------------------------------------------------ CORS
export function corsHeaders(req: Request, env: Env): Headers {
const headers = new Headers();
const origin = req.headers.get('Origin') || '';
const allow = (env.ALLOWED_ORIGINS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
if (origin && (allow.includes(origin) || allow.includes('*'))) {
headers.set('Access-Control-Allow-Origin', origin);
} else if (!origin) {
headers.set('Access-Control-Allow-Origin', '*');
}
headers.set('Vary', 'Origin');
headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS');
headers.set(
'Access-Control-Allow-Headers',
'Content-Type,Authorization,X-Requested-With,X-Api-Version',
);
headers.set('Access-Control-Max-Age', '86400');
return headers;
}
+173
View File
@@ -0,0 +1,173 @@
import type { Env, UserRow } from './types';
import { corsHeaders, json } from './lib/util';
import { userFromToken } from './lib/session';
import { friendlyError } from './lib/errors';
export interface Ctx {
env: Env;
req: Request;
url: URL;
params: Record<string, string>;
user: UserRow | null;
/**
* 请求级后台任务(主要用于发邮件通知)。
* 邮件走外部 HTTP API,跨境往返几百毫秒,绝不能阻塞评论提交;
* 但注意:Worker 在响应返回后可能被冻结,waitUntil 注册的 Promise 会继续跑完。
*/
waitUntil?: (p: Promise<unknown>) => void;
}
export type Handler = (ctx: Ctx) => Promise<Response>;
interface Route {
method: string;
segs: string[];
handler: Handler;
}
export class Router {
private routes: Route[] = [];
add(method: string, path: string, handler: Handler): this {
this.routes.push({
method: method.toUpperCase(),
segs: path.split('/').filter(Boolean),
handler,
});
return this;
}
get(p: string, h: Handler) {
return this.add('GET', p, h);
}
post(p: string, h: Handler) {
return this.add('POST', p, h);
}
put(p: string, h: Handler) {
return this.add('PUT', p, h);
}
del(p: string, h: Handler) {
return this.add('DELETE', p, h);
}
all(p: string, h: Handler) {
return this.add('*', p, h);
}
private match(method: string, segs: string[]): { handler: Handler; params: Record<string, string> } | null {
for (const r of this.routes) {
if (r.method !== '*' && r.method !== method) continue;
if (r.segs.length !== segs.length) continue;
const params: Record<string, string> = {};
let ok = true;
for (let i = 0; i < r.segs.length; i++) {
const pat = r.segs[i];
const val = segs[i];
if (pat.startsWith(':')) {
params[pat.slice(1).replace(/\?$/, '')] = decodeURIComponent(val);
} else if (pat !== val) {
ok = false;
break;
}
}
if (ok) return { handler: r.handler, params };
}
return null;
}
/**
* 同时挂在 / 和 /api/v2 两个前缀下:
* Artalk 客户端请求的是 `${server}/api/v2/xxx`,但有人会直接打根路径。
*/
async dispatch(req: Request, env: Env, execCtx?: ExecutionContext): Promise<Response> {
const url = new URL(req.url);
const cors = corsHeaders(req, env);
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors });
}
const all = url.pathname.split('/').filter(Boolean);
const candidates: string[][] = [all];
if (all[0] === 'api' && all[1] === 'v2') candidates.unshift(all.slice(2));
if (all[0] === 'api') candidates.push(all.slice(1));
for (const segs of candidates) {
const hit = this.match(req.method.toUpperCase(), segs);
if (!hit) continue;
try {
// 本次请求挂一个 D1 会话(只读副本要靠它才会生效)。
// ⚠️ sessionEnv / userFromToken 必须放在 try 里:userFromToken 会查 D1,
// 额度用尽时它会抛错;以前它在 try 之外,于是整个 Worker 抛 500 且
// **不带 CORS 头** → 浏览器只能报 "TypeError: Failed to fetch"。
const scoped = sessionEnv(env, req);
const user = await userFromToken(scoped, req.headers.get('Authorization'));
const res = await hit.handler({
env: scoped,
req,
url,
params: hit.params,
user,
waitUntil: execCtx ? (p: Promise<unknown>) => execCtx.waitUntil(p) : undefined,
});
return withCors(res, cors);
} catch (e) {
const err = friendlyError(e);
console.error('[artalk-cf] handler error:', err.code, err.detail);
// 明确告诉前端「哪一类故障」,前端据此给读者看不同的人话;
// detail 保留原始信息,前端折叠展示,方便排查。
return withCors(
json({ msg: err.msg, code: err.code, detail: err.detail }, {
status: err.status,
headers: { 'Cache-Control': 'no-store' },
}),
cors,
);
}
}
return withCors(
json({ msg: `Not Found: ${req.method} ${url.pathname}` }, { status: 404 }),
cors,
);
}
}
function withCors(res: Response, cors: Headers): Response {
const headers = new Headers(res.headers);
cors.forEach((v, k) => headers.set(k, v));
return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
}
/**
* 给本次请求挂一个 D1 会话。
*
* D1 默认把**所有**查询都打主库(我们在 WNAM,读者在亚洲,每次读都要跨太平洋)。
* 只有走 Sessions API,读请求才会被路由到就近的只读副本 —— 官方原话:
* "To use read replication, you have to use the D1 Sessions API,
* otherwise all queries will continue to be executed only by the primary database."
*
* 选型策略:
* · 写请求(非 GET/HEAD/OPTIONS)→ first-primary
* 写只能落主库;同一会话内「写后读」由 read-my-own-writes 保证
* · 带 Authorization 的请求(管理面板)→ first-primary
* 管理操作要求看到最新数据,刚删的评论不能还留在列表里
* · 其余(读者流量,占绝大多数)→ first-unconstrained
* 第一条查询就允许落到副本,延迟最低
*
* 同一会话内的多次查询具备顺序一致性(单调读 / 单调写 / 写随读 / 读己写),
* 所以「查用户 → 查评论 → 查页面」不会读到互相错乱的版本。
*
* D1DatabaseSession 与 D1Database 共享 prepare()/batch(),可直接当 DB 用;
* 但它**没有** exec()/dump()/withSession(),本项目没用到这三个。
*/
function sessionEnv(env: Env, req: Request): Env {
const m = req.method.toUpperCase();
const isWrite = m !== 'GET' && m !== 'HEAD' && m !== 'OPTIONS';
const authed = !!req.headers.get('Authorization');
const mode: D1SessionConstraint = isWrite || authed ? 'first-primary' : 'first-unconstrained';
const session = env.DB.withSession(mode);
return { ...env, DB: session as unknown as D1Database };
}
File diff suppressed because it is too large. Load diff
+585
View File
@@ -0,0 +1,585 @@
import type { CommentRow, CookedComment, Env, UserRow } from '../types';
import {
defaultSiteName,
findComment,
findCommentRootId,
findOrCreatePage,
findOrCreateSite,
findOrCreateUser,
findUserById,
hasCaptchaPassed,
isCaptchaEnabled,
isIPRegionEnabled,
isPendingDefault,
rateLimit,
siteFirstUrl,
touchUser,
} from '../lib/db';
import { cookComments, cookPage, pageAccessibleUrl } from '../lib/cook';
import { mailEnabled, notifyByEmail } from '../lib/mail';
import { isAdminByNameEmail, isAdminRequest } from '../lib/session';
import { md5Lower } from '../lib/md5';
import {
fail,
getClientIP,
getUserAgent,
isEmail,
isUrl,
now,
ok,
okMsg,
qBool,
qInt,
qp,
readBody,
trimTo,
} from '../lib/util';
import { humanGate } from './human';
import type { Ctx } from '../router';
const MAX_CONTENT = 10000;
const MAX_NAME = 60;
// ============================================================ GET /comments
/**
* 评论计数缓存的版本号:评论新增/删除后写一次时间戳,
* 计数缓存 key 里带上它 → 有变化时旧缓存立刻失效(不用等 TTL 过期)。
*/
async function countVersion(env: Env, siteName: string, pageKey: string): Promise<string> {
try {
return (await env.RSS_KV.get(`cml:ver:${siteName}:${pageKey}`)) || '0';
} catch {
return '0';
}
}
async function bumpCountVersion(env: Env, siteName: string, pageKey: string): Promise<void> {
try {
await env.RSS_KV.put(`cml:ver:${siteName}:${pageKey}`, Date.now().toString());
} catch {
/* 缓存版本写失败不影响主流程 */
}
}
export async function listComments(ctx: Ctx): Promise<Response> {
const { env, url, user } = ctx;
const pageKey = qp(url, 'page_key');
const siteName = qp(url, 'site_name') || (await defaultSiteName(env));
const limit = Math.min(Math.max(qInt(url, 'limit', 20), 1), 100);
const offset = Math.max(qInt(url, 'offset', 0), 0);
const flatMode = qBool(url, 'flat_mode');
const sortBy = qp(url, 'sort_by') || 'date_desc';
const search = qp(url, 'search').trim();
const type = qp(url, 'type') || 'all';
const scope = qp(url, 'scope') || 'page';
const viewOnlyAdmin = qBool(url, 'view_only_admin');
const name = qp(url, 'name');
const email = qp(url, 'email');
const admin = await isAdminRequest(env, ctx.req, user);
// 站点必须存在(与官方 CheckSiteExist 一致)
if (scope === 'page' && !(await siteExists(env, siteName))) {
return fail(400, `Site "${siteName}" not found`);
}
// 未登录时按 name/email 定位用户;若该账号是管理员则视为未登录
let viewUser: UserRow | null = user;
if (!viewUser && name && email) {
const r = await env.DB.prepare('SELECT * FROM users WHERE name = ? AND email = ?')
.bind(name, email)
.first<UserRow>();
if (r && r.is_admin) viewUser = null;
else viewUser = r;
}
const where: string[] = ['c.deleted_at = 0'];
const binds: unknown[] = [];
if (!admin) where.push('c.is_pending = 0');
if (viewOnlyAdmin) {
where.push('u.is_admin = 1');
}
if (search) {
where.push('(c.content LIKE ? OR u.name LIKE ? OR u.email LIKE ?)');
const like = `%${search}%`;
binds.push(like, like, like);
}
if (scope === 'page') {
where.push('c.page_key = ?', 'c.site_name = ?');
binds.push(pageKey, siteName);
} else if (scope === 'site') {
where.push('c.site_name = ?');
binds.push(siteName);
if (type === 'pending') where.push('c.is_pending = 1');
if (type === 'mine' && viewUser) {
where.push('c.user_id = ?');
binds.push(viewUser.id);
}
if (type === 'mentions' && viewUser) {
where.push('c.rid IN (SELECT id FROM comments WHERE user_id = ? AND deleted_at = 0)');
binds.push(viewUser.id);
}
} else {
// scope=user:我参与的(我发的 + 回复我的)
if (!viewUser) {
return ok({ comments: [], count: 0, roots_count: 0 });
}
if (type === 'pending') {
if (!admin) return fail(403, 'Admin access required');
where.push('c.is_pending = 1');
} else if (type === 'mine') {
where.push('c.user_id = ?');
binds.push(viewUser.id);
} else if (type === 'mentions') {
where.push('c.rid IN (SELECT id FROM comments WHERE user_id = ? AND deleted_at = 0)');
binds.push(viewUser.id);
} else {
where.push(
'(c.user_id = ? OR c.rid IN (SELECT id FROM comments WHERE user_id = ? AND deleted_at = 0))',
);
binds.push(viewUser.id, viewUser.id);
}
}
const whereSql = where.join(' AND ');
// 前台按官方行为置顶优先;后台管理列表要的是"纯时间流",
// 置顶条插在中间会打断"从上往下扫新评论"的操作节奏。
const orderSql =
sortBy === 'date_asc'
? 'c.created_at ASC, c.id ASC'
: sortBy === 'vote'
? '(c.vote_up - c.vote_down) DESC, c.created_at DESC'
: admin
? 'c.created_at DESC, c.id DESC'
: 'c.is_pinned DESC, c.created_at DESC, c.id DESC';
const fromSql = 'FROM comments c LEFT JOIN users u ON u.id = c.user_id';
// 计数只需要 comments 表:where 里没用到 u.* 就不要 JOIN users ——
// LEFT JOIN 会让每行评论都多读一行用户记录(D1 按行计费,这是之前额度被打爆的主因之一)
const needUsers = /(^|[^\w.])u\./.test(whereSql);
const countFromSql = needUsers ? fromSql : 'FROM comments c';
// 计数走 KV 短缓存(评论增删会 bump 版本号,缓存立刻失效)
let count = -1;
let rootsCount = -1;
let countCacheKey = '';
try {
const ver = await countVersion(env, siteName, pageKey);
countCacheKey = `cml:cnt:${md5Lower(
`${whereSql}|${binds.join(',')}|${siteName}|${pageKey}|${scope}|${type}|${sortBy}`,
)}:${ver}`;
const cached = await env.RSS_KV.get(countCacheKey);
if (cached) {
const o = JSON.parse(cached) as { c: number; r: number };
if (typeof o.c === 'number' && typeof o.r === 'number') {
count = o.c;
rootsCount = o.r;
}
}
} catch {
/* 缓存读失败 → 走原始查询 */
}
if (count < 0 || rootsCount < 0) {
// 一条 SQL 同时算「总数」和「顶层评论数」:COUNT(*) 与 SUM(root_id=0)
// 共用同一次扫描 —— 原来跑两条 COUNT 就是扫两遍(留言页 996 条 → 白读 996 行)
const countRow = await env.DB.prepare(
`SELECT COUNT(*) AS n, SUM(CASE WHEN c.root_id = 0 THEN 1 ELSE 0 END) AS r
${countFromSql} WHERE ${whereSql}`,
)
.bind(...binds)
.first<{ n: number; r: number | null }>();
count = countRow?.n ?? 0;
rootsCount = countRow?.r ?? 0;
try {
if (countCacheKey) {
await env.RSS_KV.put(countCacheKey, JSON.stringify({ c: count, r: rootsCount }), {
// 评论增删/审核会立刻 bump 版本让缓存失效(见 bumpCountVersion),
// 所以 TTL 可以放心放长 —— 它决定"最坏情况下计数查询多久真跑一次":
// 10 分钟 TTL = 每小时最多 6 次真 COUNT(*),比原来每次请求都跑省几十倍。
expirationTtl: 600,
});
}
} catch {
/* 写缓存失败无所谓 */
}
}
// nested:先取一页顶层,再把它们的全部子孙捞回来(与官方一致,不分页)
let rows: CommentRow[];
if (flatMode) {
rows = (
await env.DB.prepare(
`SELECT c.* ${fromSql} WHERE ${whereSql} ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
)
.bind(...binds, limit, offset)
.all<CommentRow>()
).results ?? [];
} else {
const roots = (
await env.DB.prepare(
`SELECT c.* ${fromSql} WHERE ${whereSql} AND c.root_id = 0
ORDER BY ${orderSql} LIMIT ? OFFSET ?`,
)
.bind(...binds, limit, offset)
.all<CommentRow>()
).results ?? [];
rows = roots;
if (roots.length) {
const ids = roots.map((r) => r.id);
for (let i = 0; i < ids.length; i += 80) {
const chunk = ids.slice(i, i + 80);
const ph = chunk.map(() => '?').join(',');
// 优先走 idx_comments_children(按 root_id 逐个 seek);万一索引不存在
// (老库/迁移未跑)就回退到普通写法,保证接口不报错。
let children: CommentRow[];
try {
children =
(
await env.DB.prepare(
`SELECT c.* FROM comments c INDEXED BY idx_comments_children
LEFT JOIN users u ON u.id = c.user_id
WHERE ${whereSql} AND c.root_id IN (${ph}) AND c.rid != 0
ORDER BY c.created_at ASC, c.id ASC`,
)
.bind(...binds, ...chunk)
.all<CommentRow>()
).results ?? [];
} catch {
children =
(
await env.DB.prepare(
`SELECT c.* ${fromSql} WHERE ${whereSql} AND c.root_id IN (${ph}) AND c.rid != 0
ORDER BY c.created_at ASC, c.id ASC`,
)
.bind(...binds, ...chunk)
.all<CommentRow>()
).results ?? [];
}
rows = rows.concat(children);
}
}
}
// flat 模式:把缺失的父评论补进来并标记 visible=false
const visibility = new Map<number, boolean>();
if (flatMode && rows.length) {
const present = new Set(rows.map((r) => r.id));
const missing = [...new Set(rows.filter((r) => r.rid && !present.has(r.rid)).map((r) => r.rid))];
if (missing.length) {
for (let i = 0; i < missing.length; i += 80) {
const chunk = missing.slice(i, i + 80);
const ph = chunk.map(() => '?').join(',');
const extra = (
await env.DB.prepare(`SELECT c.* ${fromSql} WHERE c.id IN (${ph}) AND c.deleted_at = 0`)
.bind(...chunk)
.all<CommentRow>()
).results ?? [];
for (const e of extra) {
rows.push(e);
visibility.set(e.id, false);
}
}
}
}
const comments = await cookComments(env, rows, {
ipRegion: await isIPRegionEnabled(env),
visibility,
includeMarked: admin, // 只有后台要 content_marked,前台不带(省一半流量)
});
const resp: Record<string, unknown> = { comments, count, roots_count: rootsCount };
if (scope === 'page') {
let page = await env.DB.prepare('SELECT * FROM pages WHERE key = ? AND site_name = ?')
.bind(pageKey, siteName)
.first<any>();
if (!page) {
page = { id: 0, key: pageKey, site_name: siteName, title: '', admin_only: 0, pv: 0, vote_up: 0, vote_down: 0, accessible_url: '', created_at: now(), updated_at: now() };
}
const site = await env.DB.prepare('SELECT urls FROM sites WHERE name = ?')
.bind(siteName)
.first<{ urls: string }>();
resp.page = cookPage(page, (site?.urls || '').split(',')[0]?.trim() || env.SITE_URL);
}
return ok(resp);
}
async function siteExists(env: Env, siteName: string): Promise<boolean> {
const r = await env.DB.prepare('SELECT id FROM sites WHERE name = ?').bind(siteName).first();
return !!r;
}
// ============================================================ POST /comments
export async function createComment(ctx: Ctx): Promise<Response> {
const { env, req, user } = ctx;
const body = await readBody(req);
const name = trimTo(body.name || '', MAX_NAME).trim();
const email = trimTo(body.email || '', 120).trim();
const link = trimTo(body.link || '', 255).trim();
const content = trimTo(body.content || '', MAX_CONTENT);
const rid = Number(body.rid || 0);
const pageKey = trimTo(body.page_key || '', 255).trim();
const pageTitle = trimTo(body.page_title || '', 255);
const siteName = trimTo(body.site_name || '', 120) || (await defaultSiteName(env));
if (!name || !email || !content || !pageKey) {
return fail(400, 'Invalid parameters: name / email / content / page_key are required');
}
if (!isEmail(email)) return fail(400, 'Invalid Email');
if (link && !isUrl(link)) return fail(400, 'Invalid Link');
const ip = getClientIP(req);
const ua = trimTo(body.ua || getUserAgent(req), 255);
if (!(await siteExists(env, siteName))) {
return fail(400, `Site "${siteName}" not found`);
}
const admin = await isAdminRequest(env, req, user);
// 同一 IP 60 秒内最多 5 条;管理员不限
if (!admin) {
const allowed = await rateLimit(env, `comment:${ip}`, 5, 60);
if (!allowed) return fail(429, 'Too many requests, please slow down');
}
// 人机验证门禁:KV 通行证(自研一键验证)或图形验证码通行证,任一通过即可。
// 管理员豁免;正常读者在页面加载时就静默拿到通行证,走到这里 0 额外 D1 读。
{
const gate = await humanGate(env, ip, admin);
if (!gate.pass) {
return fail(403, 'Human verification required', {
need_captcha: true,
need_human: gate.humanOn,
});
}
}
const page = await findOrCreatePage(env, pageKey, pageTitle, siteName);
if (!page?.key) return fail(500, 'Comment failed');
// 父评论校验
let parent: CommentRow | null = null;
if (rid) {
parent = await findComment(env, rid);
if (!parent) return fail(404, 'Parent comment not found');
if (parent.page_key !== pageKey) {
return fail(400, 'Inconsistent with the page_key of the parent comment');
}
if (parent.is_collapsed || parent.is_pending) {
return fail(400, 'Cannot reply to this comment');
}
}
// 管理员昵称/邮箱不允许匿名发表
if (!admin && (await isAdminByNameEmail(env, name, email))) {
return fail(403, 'Admin access required', { need_login: true });
}
// 匿名用户:按 name+email 找或建
let author = user;
let isVerified = true;
if (!author) {
isVerified = false;
author = await findOrCreateUser(env, name, email, link);
await env.DB.prepare(
'UPDATE users SET link = ?, last_ip = ?, last_ua = ?, name = ?, email = ?, updated_at = ? WHERE id = ?',
)
.bind(link, ip, ua, name, email, now(), author.id)
.run();
isVerified = false;
} else {
await touchUser(env, author.id, ip, ua);
}
if (author.is_admin) isVerified = true;
const rootId = rid ? await findCommentRootId(env, rid) : 0;
const pending = !admin && (await isPendingDefault(env));
// Workers 自带 IP 归属(cf.country 是 ISO 国家码),存下来供 ip_region 开启时使用
const cf = (req as unknown as { cf?: { country?: string; city?: string } }).cf;
const ipRegion = cf?.country ? String(cf.country) : '';
const t = now();
const res = await env.DB.prepare(
`INSERT INTO comments
(content, page_key, site_name, user_id, rid, root_id, is_pending, is_verified, ua, ip, ip_region, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
)
.bind(
content,
pageKey,
siteName,
author.id,
rid,
rootId,
pending ? 1 : 0,
isVerified ? 1 : 0,
ua,
ip,
ipRegion,
t,
t,
)
.run();
const newId = Number(res.meta?.last_row_id ?? 0);
await bumpCountVersion(env, siteName, pageKey); // 计数缓存失效
const row = await findComment(env, newId);
if (!row) return fail(500, 'Comment failed');
const [cooked] = await cookComments(env, [row], {
ipRegion: await isIPRegionEnabled(env),
includeMarked: !!author.is_admin,
});
// 通知(站内):回复时给父评论作者写一条
if (parent && parent.user_id !== author.id) {
await env.DB.prepare(
'INSERT INTO notifies (user_id, comment_id, created_at, updated_at) VALUES (?, ?, ?, ?)',
)
.bind(parent.user_id, newId, t, t)
.run();
}
// 通知(邮件):用 waitUntil 异步发,不拖慢评论提交。
// 没配 RESEND_API_KEY 时 mailEnabled() 直接返回 false,整段跳过。
if (ctx.waitUntil && mailEnabled(env)) {
ctx.waitUntil(
(async () => {
try {
const parentAuthor = parent ? await findUserById(env, parent.user_id) : null;
const siteUrl = await siteFirstUrl(env, siteName);
await notifyByEmail(env, {
newComment: row,
author,
parent,
parentAuthor,
siteName,
siteUrl,
pageTitle: page.title || pageTitle,
pageUrl: pageAccessibleUrl(pageKey, siteUrl),
});
} catch (e) {
console.error('[mail] 通知流程失败:', e instanceof Error ? e.message : e);
}
})(),
);
}
return ok(cooked);
}
// ============================================================ 单条操作
export async function getComment(ctx: Ctx): Promise<Response> {
const id = parseInt(ctx.params.id, 10);
if (!Number.isFinite(id)) return fail(400, 'invalid comment id');
const row = await findComment(ctx.env, id);
if (!row) return fail(404, 'Comment not found');
const admin = await isAdminRequest(ctx.env, ctx.req, ctx.user);
if (row.is_pending && !admin) return fail(404, 'Comment not found');
const [cooked] = await cookComments(ctx.env, [row], { includeMarked: admin });
let reply: CookedComment | undefined;
if (row.rid) {
const parentRow = await findComment(ctx.env, row.rid);
if (parentRow) {
[reply] = await cookComments(ctx.env, [parentRow], { includeMarked: admin });
}
}
return ok({ comment: cooked, reply_comment: reply });
}
export async function updateComment(ctx: Ctx): Promise<Response> {
const { env, req, user } = ctx;
const id = parseInt(ctx.params.id, 10);
if (!Number.isFinite(id)) return fail(400, 'invalid comment id');
const row = await findComment(env, id);
if (!row) return fail(404, 'Comment not found');
const admin = await isAdminRequest(env, req, user);
const isOwner = !!user && user.id === row.user_id;
if (!admin && !isOwner) return fail(403, 'Forbidden');
const body = await readBody(req);
const sets: string[] = [];
const binds: unknown[] = [];
// 普通用户只能改自己的正文;其余字段只有管理员能动
if (body.content !== undefined) {
sets.push('content = ?');
binds.push(trimTo(body.content, MAX_CONTENT));
}
if (admin) {
for (const [field, col] of [
['is_collapsed', 'is_collapsed'],
['is_pending', 'is_pending'],
['is_pinned', 'is_pinned'],
['is_verified', 'is_verified'],
] as const) {
if (body[field] !== undefined) {
sets.push(`${col} = ?`);
binds.push(body[field] ? 1 : 0);
}
}
if (body.rid !== undefined) {
sets.push('rid = ?');
binds.push(Number(body.rid) || 0);
}
}
if (!sets.length) return okMsg();
sets.push('updated_at = ?');
binds.push(now(), id);
await env.DB.prepare(`UPDATE comments SET ${sets.join(', ')} WHERE id = ?`)
.bind(...binds)
.run();
// 只有"计数会变"的字段(待审状态 / 置顶 / 软删)才需要让计数缓存失效;
// 改正文、投票之类不该每次都被打掉——否则缓存永远命中不了,COUNT(*) 照跑。
if (body.is_pending !== undefined || body.is_pinned !== undefined || body.rid !== undefined) {
await bumpCountVersion(env, row.site_name, row.page_key);
}
const updated = await findComment(env, id);
const [cooked] = await cookComments(env, updated ? [updated] : [], { includeMarked: admin });
return ok(cooked ?? {});
}
export async function deleteComment(ctx: Ctx): Promise<Response> {
const { env, req, user } = ctx;
const id = parseInt(ctx.params.id, 10);
if (!Number.isFinite(id)) return fail(400, 'invalid comment id');
const row = await findComment(env, id);
if (!row) return fail(404, 'Comment not found');
const admin = await isAdminRequest(env, req, user);
const isOwner = !!user && user.id === row.user_id;
if (!admin && !isOwner) return fail(403, 'Forbidden');
await env.DB.prepare('UPDATE comments SET deleted_at = ?, updated_at = ? WHERE id = ?')
.bind(now(), now(), id)
.run();
await bumpCountVersion(env, row.site_name, row.page_key); // 计数缓存失效
return okMsg();
}
+103
View File
@@ -0,0 +1,103 @@
// 人机验证三个端点(挂在 /api/v2/human/*,也兼容根路径 /human/*)
//
// GET /human/challenge → 领挑战(无状态 HMAC 签名)
// POST /human/verify → 交 PoW + 行为信号;通过则发「通行证」(KV,IP 维度)
// GET /human/status → 当前 IP 是否已通过(前端决定要不要显示控件)
//
// 通行证存在 KV 而不是 D1:评论提交时的校验是每个请求都要做的,
// 走 KV 既便宜(免费版 10 万读/天)又不占 D1 的 rows_read 额度。
import type { Ctx } from '../router';
import type { Env } from '../types';
import { getClientIP, ok, readBody } from '../lib/util';
import {
assessSignals,
grantHumanPass,
hasHumanPass,
isHumanCheckEnabled,
issueChallenge,
verifyProof,
type HumanSignals,
} from '../lib/human';
export async function humanChallenge(ctx: Ctx): Promise<Response> {
const { env } = ctx;
if (!(await isHumanCheckEnabled(env))) {
return ok({ enabled: false, pass: true });
}
const ip = getClientIP(ctx.req);
// ?fresh=1 时忽略"已有通行证",照样发一张新挑战(预览/排查用)
const fresh = new URL(ctx.req.url).searchParams.get('fresh') === '1';
const already = !fresh && (await hasHumanPass(env, ip));
return ok({ enabled: true, pass: already, ...(already ? {} : await issueChallenge(env)) });
}
export async function humanStatus(ctx: Ctx): Promise<Response> {
const { env } = ctx;
const enabled = await isHumanCheckEnabled(env);
const pass = enabled ? await hasHumanPass(env, getClientIP(ctx.req)) : true;
return ok({ enabled, pass });
}
export async function humanVerify(ctx: Ctx): Promise<Response> {
const { env, req } = ctx;
if (!(await isHumanCheckEnabled(env))) return ok({ pass: true, enabled: false });
const ip = getClientIP(req);
// ?fresh=1:即使该 IP 已有通行证也重新校验一遍 —— 给预览页/排查用。
// 不构成后门:仍然需要算出有效 PoW,只是跳过"缓存通行证直接放行"。
const fresh = new URL(req.url).searchParams.get('fresh') === '1';
if (!fresh && (await hasHumanPass(env, ip))) {
return ok({ pass: true, enabled: true, cached: true });
}
const body = await readBody(req);
const signals: HumanSignals = {
honeypot: typeof body.honeypot === 'string' ? body.honeypot : '',
elapsedMs: Number(body.elapsedMs) || 0,
events: Number(body.events) || 0,
webdriver: body.webdriver === true,
clicked: body.clicked === true,
};
const powOk = await verifyProof(env, {
challenge: body.challenge,
nonce: body.nonce,
exp: Number(body.exp) || 0,
sig: body.sig,
});
if (!powOk) {
return ok({ pass: false, need_captcha: true, reason: 'pow' });
}
const risk = assessSignals(signals);
if (risk.level === 'high') {
return ok({ pass: false, need_captcha: true, reason: risk.reasons.join(',') });
}
if (risk.level === 'medium') {
// 让前端显示「点一下」控件:点击会补齐 elapsedMs / events,重试即通过
return ok({ pass: false, need_click: true, reason: risk.reasons.join(',') });
}
await grantHumanPass(env, ip);
return ok({ pass: true, enabled: true });
}
/** 评论提交时的统一门禁:人机通行证(KV)或图形验证码通行证(D1)任一通过即可 */
export async function humanGate(
env: Env,
ip: string,
admin: boolean,
): Promise<{ pass: boolean; humanOn: boolean; captchaOn: boolean }> {
if (admin) return { pass: true, humanOn: false, captchaOn: false };
// 先看最便宜的 KV 通行证;命中就不用再读 D1 settings(正常读者走这条)
if (await hasHumanPass(env, ip)) return { pass: true, humanOn: true, captchaOn: false };
const { isCaptchaEnabled, hasCaptchaPassed } = await import('../lib/db');
const humanOn = await isHumanCheckEnabled(env);
const captchaOn = await isCaptchaEnabled(env);
if (!humanOn && !captchaOn) return { pass: true, humanOn, captchaOn };
if (await hasCaptchaPassed(env, ip)) return { pass: true, humanOn, captchaOn };
return { pass: false, humanOn, captchaOn };
}
+419
View File
@@ -0,0 +1,419 @@
import type { Env } from '../types';
import {
defaultSiteName,
findAllSites,
findOrCreatePage,
findOrCreateSite,
findOrCreateUser,
getAdminUsers,
getFrontendConf,
grantCaptchaPass,
isCaptchaEnabled,
rateLimit,
} from '../lib/db';
import { cookSite } from '../lib/cook';
import { md5 } from '../lib/md5';
import { isHumanCheckEnabled } from '../lib/human';
import { mailEnabled } from '../lib/mail';
import { hashPassword, isAdminRequest } from '../lib/session';
import { fail, getClientIP, now, ok, qp, readBody, trimTo } from '../lib/util';
import type { Ctx } from '../router';
/** 服务端实现的 API 版本 —— 必须与博客里打包的 Artalk 客户端版本一致,否则前端会弹版本警告 */
export const SERVER_API_VERSION = '2.8.7';
// ==================================================================== /conf
export async function getConf(ctx: Ctx): Promise<Response> {
const { env, req, user } = ctx;
const frontendConf = await getFrontendConf(env);
const admin = await isAdminRequest(env, req, user);
const imgUpload = !!(env.UPLOADS && env.IMG_UPLOAD_ENABLED !== 'false');
return ok({
frontend_conf: { ...frontendConf, imgUpload: imgUpload || admin },
version: { app: 'artalk', version: SERVER_API_VERSION, commit_hash: '' },
});
}
export async function getVersion(ctx: Ctx): Promise<Response> {
return ok({ app: 'artalk', version: SERVER_API_VERSION, commit_hash: '' });
}
/** 用于判断某个 URL 是否属于已配置的站点、以及取站点 origin */
export async function getConfDomain(ctx: Ctx): Promise<Response> {
const target = qp(ctx.url, 'url');
const sites = await findAllSites(ctx.env);
const trusted = sites.some((s) =>
(s.urls || '')
.split(',')
.map((x) => x.trim())
.filter(Boolean)
.some((u) => target.startsWith(u.replace(/\/$/, ''))),
);
let origin = '';
try {
origin = target ? new URL(target).origin : '';
} catch {
origin = '';
}
return ok({ origin, is_trusted: trusted });
}
export async function getAuthProviders(): Promise<Response> {
// 未接入第三方登录(GitHub / Google / OIDC),返回空数组让前端隐藏入口
return ok([]);
}
// ================================================================== 状态检查
/** /healthz —— 给你自己用的探活,官方客户端不调 */
export async function healthz(ctx: Ctx): Promise<Response> {
const t0 = Date.now();
// 只用 SELECT 1 探活——COUNT(*) 是全表扫描,监控/刷新频繁打的话
// 一天能烧掉几十万 rows_read(免费额度 5M/天)。评论数走 settings 缓存。
const res = await ctx.env.DB.prepare('SELECT 1 AS ok')
.run()
.catch(() => null);
const meta = res?.meta as
| { served_by_region?: string; served_by_primary?: boolean; served_by?: string }
| undefined;
let comments = -1;
const cached = await ctx.env.DB.prepare(`SELECT value FROM settings WHERE key = 'healthz_comments'`)
.first<{ value: string }>()
.catch(() => null);
if (cached?.value != null) {
try {
comments = JSON.parse(cached.value) as number;
} catch {
/* 缓存值损坏时不致命 */
}
}
return ok({
ok: true,
db: res ? 'up' : 'down',
comments,
latency_ms: Date.now() - t0,
// read replication 生效后,这两个字段能看出请求落在了哪个区域、是否主库
region: meta?.served_by_region ?? 'unknown',
primary: meta?.served_by_primary ?? null,
// Worker 实际在哪个机房执行 —— 判断「要不要副本」的关键:Worker 若已在 D1 同区域,
// 副本就带不来收益(本地开发时 cf 不存在,给 unknown)
colo: (ctx.req as unknown as { cf?: { colo?: string; country?: string } }).cf?.colo ?? 'unknown',
// 邮件通知是否配置好(配了 RESEND_API_KEY 才会真发信)
mail: mailEnabled(ctx.env) ? 'on' : 'off',
version: SERVER_API_VERSION,
});
}
// ==================================================================== 验证码
function svgCaptcha(code: string): string {
const chars = code.split('');
const noise: string[] = [];
for (let i = 0; i < 6; i++) {
const x1 = Math.random() * 120;
const y1 = Math.random() * 40;
const x2 = Math.random() * 120;
const y2 = Math.random() * 40;
noise.push(
`<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke="#c8ccd4" stroke-width="1"/>`,
);
}
const glyphs = chars
.map((c, i) => {
const x = 14 + i * 24;
const rot = (Math.random() * 30 - 15).toFixed(1);
const y = 30 + (Math.random() * 6 - 3);
return `<text x="${x}" y="${y.toFixed(1)}" font-family="monospace" font-size="26" font-weight="700" fill="#2b2f38" transform="rotate(${rot} ${x} ${y.toFixed(1)})">${c}</text>`;
})
.join('');
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="120" height="40" viewBox="0 0 120 40">` +
`<rect width="120" height="40" fill="#f2f3f5"/>${noise}${glyphs}</svg>`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
}
function randomCode(): string {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const bytes = crypto.getRandomValues(new Uint8Array(4));
return [...bytes].map((b) => alphabet[b % alphabet.length]).join('');
}
export async function getCaptcha(ctx: Ctx): Promise<Response> {
// 人机验证(自研一键验证)开启时也要能取图形验证码 —— 它是高风险访客的回退方案
if (!(await isCaptchaEnabled(ctx.env)) && !(await isHumanCheckEnabled(ctx.env))) {
return ok({ img_data: '', iframe: false });
}
const ip = getClientIP(ctx.req);
const code = randomCode();
const id = crypto.randomUUID();
const t = now();
await ctx.env.DB.prepare(
'INSERT INTO captcha_challenges (id, answer_hash, ip, expires_at) VALUES (?, ?, ?, ?)',
)
.bind(id, md5(code.toUpperCase()), ip, t + 10 * 60 * 1000)
.run();
return ok({ img_data: svgCaptcha(code), iframe: false, challenge_id: id });
}
export async function getCaptchaStatus(ctx: Ctx): Promise<Response> {
if (!(await isCaptchaEnabled(ctx.env)) && !(await isHumanCheckEnabled(ctx.env))) {
return ok({ is_pass: true });
}
const ip = getClientIP(ctx.req);
const row = await ctx.env.DB.prepare(
'SELECT expires_at FROM captcha_passes WHERE ip = ? AND expires_at > ?',
)
.bind(ip, now())
.first<{ expires_at: number }>();
return ok({ is_pass: !!row });
}
export async function verifyCaptcha(ctx: Ctx): Promise<Response> {
if (!(await isCaptchaEnabled(ctx.env)) && !(await isHumanCheckEnabled(ctx.env))) {
return ok({ msg: 'Success' });
}
const ip = getClientIP(ctx.req);
const body = await readBody(ctx.req);
const value = trimTo(body.value || '', 16).trim().toUpperCase();
if (!(await rateLimit(ctx.env, `captcha:${ip}`, 10, 60))) {
return fail(429, 'Too many attempts');
}
const challenge = await ctx.env.DB.prepare(
'SELECT id, answer_hash, expires_at FROM captcha_challenges WHERE ip = ? ORDER BY expires_at DESC LIMIT 1',
)
.bind(ip)
.first<{ id: string; answer_hash: string; expires_at: number }>();
if (!challenge || challenge.expires_at < now()) return fail(403, 'Captcha expired');
if (md5(value) !== challenge.answer_hash) return fail(403, 'Captcha incorrect');
const t = now();
await ctx.env.DB.prepare('DELETE FROM captcha_challenges WHERE id = ?').bind(challenge.id).run();
await grantCaptchaPass(ctx.env, ip, 1800);
return ok({ msg: 'Success' });
}
// ==================================================================== 投票
export async function voteGet(ctx: Ctx): Promise<Response> {
const targetName = ctx.params.target_name;
const targetId = parseInt(ctx.params.target_id, 10);
const type = targetName === 'comment' ? 'comment' : targetName === 'page' ? 'page' : '';
if (!type) return fail(404, 'unknown vote target name');
if (!Number.isFinite(targetId)) return fail(400, 'invalid vote target id');
const row = await voteTotals(ctx.env, type, targetId);
if (!row) return fail(404, `${targetName} not found`);
let isUp = false;
let isDown = false;
if (ctx.user) {
const mine = await ctx.env.DB.prepare(
'SELECT type FROM votes WHERE target_id = ? AND user_id = ? AND type IN (?, ?)',
)
.bind(targetId, ctx.user.id, `${type}_up`, `${type}_down`)
.all<{ type: string }>();
for (const v of mine.results ?? []) {
if (v.type === `${type}_up`) isUp = true;
if (v.type === `${type}_down`) isDown = true;
}
}
return ok({ up: row.up, down: row.down, is_up: isUp, is_down: isDown });
}
async function voteTotals(
env: Env,
type: 'comment' | 'page',
id: number,
): Promise<{ up: number; down: number } | null> {
if (type === 'comment') {
const r = await env.DB.prepare('SELECT vote_up, vote_down FROM comments WHERE id = ? AND deleted_at = 0')
.bind(id)
.first<{ vote_up: number; vote_down: number }>();
return r ? { up: r.vote_up, down: r.vote_down } : null;
}
const r = await env.DB.prepare('SELECT vote_up, vote_down FROM pages WHERE id = ?')
.bind(id)
.first<{ vote_up: number; vote_down: number }>();
return r ? { up: r.vote_up, down: r.vote_down } : null;
}
export async function voteCreate(ctx: Ctx): Promise<Response> {
const targetName = ctx.params.target_name;
const targetId = parseInt(ctx.params.target_id, 10);
const choice = ctx.params.choice;
const type = targetName === 'comment' ? 'comment' : targetName === 'page' ? 'page' : '';
if (!type) return fail(404, 'unknown vote target name');
if (choice !== 'up' && choice !== 'down') return fail(404, 'unknown vote choice');
if (!Number.isFinite(targetId)) return fail(400, 'invalid vote target id');
const ip = getClientIP(ctx.req);
if (!(await rateLimit(ctx.env, `vote:${ip}`, 30, 60))) return fail(429, 'Too many requests');
const totals = await voteTotals(ctx.env, type, targetId);
if (!totals) return fail(404, `${targetName} not found`);
const body = await readBody(ctx.req);
let voter = ctx.user;
if (!voter) {
const name = trimTo(body.name || '', 60).trim();
const email = trimTo(body.email || '', 120).trim();
if (!name || !email) return fail(400, 'name and email are required when not logged in');
voter = await findOrCreateUser(ctx.env, name, email);
}
const voteType = `${type}_${choice}`;
const existing = await ctx.env.DB.prepare(
'SELECT id, type FROM votes WHERE target_id = ? AND user_id = ? AND type IN (?, ?)',
)
.bind(targetId, voter.id, `${type}_up`, `${type}_down`)
.all<{ id: number; type: string }>();
const rows = existing.results ?? [];
const t = now();
const stmts: D1PreparedStatement[] = [];
if (rows.some((r) => r.type === voteType)) {
// 再点一次 = 取消
stmts.push(ctx.env.DB.prepare('DELETE FROM votes WHERE id = ?').bind(rows[0].id));
stmts.push(deltaVote(ctx.env, type, targetId, choice, -1));
} else {
// 先清掉反方向的票
for (const r of rows) {
stmts.push(ctx.env.DB.prepare('DELETE FROM votes WHERE id = ?').bind(r.id));
stmts.push(deltaVote(ctx.env, type, targetId, r.type.endsWith('_up') ? 'up' : 'down', -1));
}
stmts.push(
ctx.env.DB.prepare(
'INSERT INTO votes (target_id, user_id, type, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
).bind(targetId, voter.id, voteType, t, t),
);
stmts.push(deltaVote(ctx.env, type, targetId, choice, 1));
}
await ctx.env.DB.batch(stmts);
const after = await voteTotals(ctx.env, type, targetId);
return ok({
up: after?.up ?? 0,
down: after?.down ?? 0,
is_up: choice === 'up' && !rows.some((r) => r.type === voteType),
is_down: choice === 'down' && !rows.some((r) => r.type === voteType),
});
}
function deltaVote(
env: Env,
type: 'comment' | 'page',
id: number,
choice: string,
delta: number,
): D1PreparedStatement {
const col = choice === 'up' ? 'vote_up' : 'vote_down';
const table = type === 'comment' ? 'comments' : 'pages';
return env.DB.prepare(
`UPDATE ${table} SET ${col} = MAX(0, ${col} + ?), updated_at = ? WHERE id = ?`,
).bind(delta, now(), id);
}
export async function voteSync(ctx: Ctx): Promise<Response> {
// 不做客户端缓存同步,直接回成功(前端只是用来对齐本地缓存)
return ok({ msg: 'Success' });
}
// ================================================================== 浏览量
export async function pagePV(ctx: Ctx): Promise<Response> {
const { env } = ctx;
const body = await readBody(ctx.req);
const pageKey = trimTo(body.page_key || '', 255).trim();
const pageTitle = trimTo(body.page_title || '', 255);
const siteName = trimTo(body.site_name || '', 120) || (await defaultSiteName(env));
if (!pageKey) return fail(400, 'page_key is required');
if (!(await findOrCreateSite(env, siteName))) return fail(400, `Site "${siteName}" not found`);
const page = await findOrCreatePage(env, pageKey, pageTitle, siteName);
await env.DB.prepare('UPDATE pages SET pv = pv + 1, updated_at = ? WHERE id = ?')
.bind(now(), page.id)
.run();
return ok({ pv: (page.pv || 0) + 1 });
}
// ==================================================================== 站点列表
export async function siteListPublic(ctx: Ctx): Promise<Response> {
const sites = await findAllSites(ctx.env);
return ok(sites.map(cookSite));
}
/** 首次部署初始化:写入默认站点 + 管理员账号。 */
export async function setup(ctx: Ctx): Promise<Response> {
const { env, url } = ctx;
// 放行条件(三选一):
// 1) 还没有任何管理员 —— 全新部署,随便谁先来都能初始化(此时也没有可被劫持的东西)
// 2) 已经是管理员(带 token)
// 3) 请求带了 ?force=<TOKEN_SECRET> —— 证明调用者掌握部署密钥,用于「忘了密码」的恢复
// 注意:不能只判断"库里有没有站点"——先导入评论再 init 时站点已存在,会把管理员卡在外面。
const adminRow = await env.DB.prepare('SELECT COUNT(*) AS n FROM users WHERE is_admin = 1')
.first<{ n: number }>()
.catch(() => null);
const adminCount = adminRow?.n ?? 0;
const force = url.searchParams.get('force') || '';
const canForce = !!force && !!env.TOKEN_SECRET && force === env.TOKEN_SECRET;
if (adminCount > 0 && !ctx.user?.is_admin && !canForce) {
return fail(
403,
'Already initialized: an admin already exists. ' +
'用管理员 token 调用,或带 ?force=<TOKEN_SECRET>(用于重设密码)。',
);
}
const body = await readBody(ctx.req).catch(() => ({}) as Record<string, any>);
const siteName = trimTo(body.site || env.SITE_DEFAULT, 120);
const siteUrl = trimTo(body.site_url || env.SITE_URL, 255);
await findOrCreateSite(env, siteName, siteUrl);
const admins = await getAdminUsers(env);
for (const a of admins) {
const u = await findOrCreateUser(env, a.name, a.email);
const pw =
a.password && !a.password.startsWith('(') ? await hashPassword(a.password) : a.password;
await env.DB.prepare(
'UPDATE users SET is_admin = 1, password = ?, token_valid_from = ?, updated_at = ? WHERE id = ?',
)
.bind(pw, now(), now(), u.id)
.run();
}
const t = now();
await env.DB.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES ('site_default', ?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
)
.bind(JSON.stringify(siteName), t)
.run();
return ok({
msg: 'Success',
site: siteName,
admins: admins.map((a) => a.email),
reseted: adminCount > 0,
});
}
+76
View File
@@ -0,0 +1,76 @@
// /api/feeds —— 订阅源 CRUD
// 迁自 edgeone/functions/api/feeds.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth, kvGetJson, kvPutJson } from '../../lib/rss/util';
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
const url = new URL(request.url);
switch (request.method) {
case 'GET': {
const data = await kvGetJson<{ feeds: unknown[] }>(env, 'feeds_config', { feeds: [] });
return respond(data);
}
case 'POST': {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const data = await kvGetJson<{ feeds: any[] }>(env, 'feeds_config', { feeds: [] });
const incoming = body.feeds && Array.isArray(body.feeds) ? body.feeds : [body];
const duplicates: string[] = [];
for (const feed of incoming) {
if (!feed.url) continue;
const idx = data.feeds.findIndex((f) => f.url === feed.url);
if (idx >= 0 && feed.url !== feed.oldUrl) duplicates.push(feed.url);
}
if (duplicates.length > 0) {
return respond({ error: '链接已存在,不允许重复添加', duplicates }, 409);
}
for (const feed of incoming) {
if (!feed.url) continue;
if (feed.oldUrl) {
const idx = data.feeds.findIndex((f) => f.url === feed.oldUrl);
if (idx >= 0) {
data.feeds[idx] = {
...data.feeds[idx],
url: feed.url,
feedTitle: feed.feedTitle || data.feeds[idx].feedTitle,
};
} else {
data.feeds.push({ url: feed.url, feedTitle: feed.feedTitle || '' });
}
} else {
data.feeds.push({ url: feed.url, feedTitle: feed.feedTitle || '' });
}
}
await kvPutJson(env, 'feeds_config', data);
return respond({ ok: true, total: data.feeds.length });
}
case 'DELETE': {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const targetUrl = url.searchParams.get('url');
if (!targetUrl) return respond({ error: 'url parameter required' }, 400);
const data = await kvGetJson<{ feeds: any[] }>(env, 'feeds_config', { feeds: [] });
const before = data.feeds.length;
data.feeds = data.feeds.filter((f) => f.url !== targetUrl);
const removed = before - data.feeds.length;
await kvPutJson(env, 'feeds_config', data);
return respond({ ok: true, removed, total: data.feeds.length });
}
default:
return respond({ error: 'method not allowed' }, 405);
}
}
+151
View File
@@ -0,0 +1,151 @@
// /api/ai/greeting —— 随机问候语 + DeepSeek 词库扩充
// 迁自 edgeone/functions/api/ai/greeting.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
import { defaultPool, type Greeting } from '../../lib/rss/greetings';
function getTodayMMDD(): string {
const now = new Date();
return String(now.getMonth() + 1).padStart(2, '0') + '-' + String(now.getDate()).padStart(2, '0');
}
function getTimeSlot(): string {
const h = new Date().getHours();
const d = new Date().getDay();
if (d === 0 || d === 6) {
if (h < 22) return 'weekend';
return 'night';
}
if (h >= 6 && h < 9) return 'weekday-morning';
if (h >= 9 && h < 12) return 'morning';
if (h >= 12 && h < 14) return 'noon';
if (h >= 14 && h < 18) return 'afternoon';
if (h >= 18 && h < 22) return 'evening';
return 'night';
}
function shuffle<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
async function getPool(env: Env): Promise<Greeting[]> {
const raw = await env.RSS_KV.get('greetings_pool');
return raw ? (JSON.parse(raw) as Greeting[]) : defaultPool();
}
async function callDeepSeek(apiKey: string, count: number): Promise<Greeting[]> {
const today = getTodayMMDD();
const slot = getTimeSlot();
const slotNames: Record<string, string> = {
'weekday-morning': '工作日早晨', morning: '上午', noon: '中午',
afternoon: '下午', evening: '傍晚', night: '深夜', weekend: '周末',
};
const prompt = `你是"优世界"博客的AI助手。请生成${count}条新的博客问候语,每条15字以内,语气轻松温暖幽默。
当前场景:${slotNames[slot] || slot},日期 ${today}。
返回纯JSON数组:[{"text":"问候语","time":"morning","holiday":null}]`;
const res = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
model: 'deepseek-chat',
messages: [{ role: 'user', content: prompt }],
max_tokens: 2048,
temperature: 0.9,
}),
});
const json = (await res.json()) as any;
if (json.error) throw new Error(`DeepSeek API: ${json.error.message}`);
const text = json.choices?.[0]?.message?.content || '';
const match = text.match(/\[[\s\S]*\]/);
if (!match) throw new Error('AI 返回格式异常');
const generated = JSON.parse(match[0]);
if (!Array.isArray(generated)) throw new Error('AI 返回的不是数组');
return generated.filter((g: Greeting) => g.text && g.text.length <= 20);
}
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
if (request.method === 'GET') {
const count = Math.min(Math.max(parseInt(url.searchParams.get('count') || '10'), 1), 50);
const pool = await getPool(env);
const today = getTodayMMDD();
const slot = getTimeSlot();
const holidayMatches = pool.filter((g) => g.holiday === today);
const slotMatches = pool.filter((g) => !g.holiday && g.time === slot);
const genericMatches = pool.filter((g) => !g.holiday && !g.time);
let result = shuffle(holidayMatches).slice(0, Math.ceil(count * 0.3));
result = result.concat(shuffle(slotMatches).slice(0, count - result.length));
result = result.concat(shuffle(genericMatches).slice(0, count - result.length));
return respond({ greetings: shuffle(result).slice(0, count) });
}
if (request.method === 'POST') {
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
if (body.action === 'config') {
if (!body.deepseek_key) return respond({ error: '请提供 deepseek_key' }, 400);
await env.RSS_KV.put('ai_config', JSON.stringify({ deepseek_key: body.deepseek_key }));
return respond({ ok: true, message: 'DeepSeek API Key 已保存' });
}
if (body.action === 'generate') {
const count = Math.min(body.count || 10, 30);
let apiKey = body.deepseek_key;
if (!apiKey) {
const configRaw = await env.RSS_KV.get('ai_config');
if (configRaw) {
try {
apiKey = JSON.parse(configRaw).deepseek_key;
} catch {
// 忽略
}
}
}
if (!apiKey) return respond({ error: '请先配置 DeepSeek API Key' }, 400);
try {
const generated = await callDeepSeek(apiKey, count);
const pool = await getPool(env);
const existing = new Set(pool.map((g) => g.text));
let added = 0;
for (const g of generated) {
if (!existing.has(g.text)) {
pool.push({ text: g.text, time: g.time || null, holiday: g.holiday || null });
existing.add(g.text);
added++;
}
}
await env.RSS_KV.put('greetings_pool', JSON.stringify(pool));
return respond({ ok: true, generated: generated.length, added, total: pool.length });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
const pool = body.pool && Array.isArray(body.pool) ? body.pool : defaultPool();
await env.RSS_KV.put('greetings_pool', JSON.stringify(pool));
return respond({ ok: true, total: pool.length, message: '词库已更新' });
}
return respond({ error: 'method not allowed' }, 405);
}
+262
View File
@@ -0,0 +1,262 @@
// /api/links —— 友链 CRUD
// 迁自 edgeone/functions/api/links.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth, kvGetJson, kvPutJson } from '../../lib/rss/util';
interface Link {
name: string;
url: string;
image?: string;
description?: string;
rss?: string;
hidden?: boolean;
addedAt?: string;
avatar?: string;
}
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
const url = new URL(request.url);
const method = request.method.toUpperCase();
if (method === 'GET') {
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
const showAll = url.searchParams.get('all') === '1';
// 历史数据里 /api/favicon 存的是相对地址 → 读取时补成绝对(博客域名下相对路径会 404)
const apiBase = env.PUBLIC_API_BASE || 'https://api.200181.xyz';
let links = data.links.map((l) => {
const img = l.image || l.avatar || '';
return { ...l, image: img.startsWith('/') ? apiBase + img : img };
});
if (!showAll) links = links.filter((l) => !l.hidden);
return respond({ links });
}
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
if (method === 'DELETE') {
const targetUrl = url.searchParams.get('url');
if (!targetUrl) return respond({ error: 'missing url param' }, 400);
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
data.links = data.links.filter((l) => l.url !== targetUrl);
await kvPutJson(env, 'friend_links', data);
return respond({ ok: true, links: data.links });
}
if (method === 'POST') {
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const data = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
const incoming = body.links || (body.url ? [body] : []);
if (incoming.length === 0) return respond({ error: 'empty body' }, 400);
const duplicates: string[] = [];
for (const link of incoming) {
if (!link.url) continue;
const idx = data.links.findIndex((l) => l.url === link.url);
if (idx >= 0 && link.url !== link.oldUrl) duplicates.push(link.url);
}
if (duplicates.length > 0) {
return respond({ error: '链接已存在,不允许重复添加', duplicates }, 409);
}
for (const link of incoming) {
if (!link.url) continue;
const entry: Link = {
name: link.name || link.title || '',
url: link.url,
image:
link.image || link.avatar || `/api/favicon?url=${encodeURIComponent(link.url)}`,
description: link.description || '',
rss: link.rss || '',
hidden: link.hidden || false,
addedAt: new Date().toISOString(),
};
if (link.oldUrl) {
const idx = data.links.findIndex((l) => l.url === link.oldUrl);
if (idx >= 0) {
entry.addedAt = data.links[idx].addedAt;
data.links[idx] = entry;
} else {
data.links.push(entry);
}
} else {
data.links.push(entry);
}
}
await kvPutJson(env, 'friend_links', data);
return respond({ ok: true, links: data.links });
}
return respond({ error: 'method not allowed' }, 405);
}
// ============================================================================
// 友链自助申请
// 公开提交 → KV link_applications(pending)→ 邮件提醒管理员;
// 管理面板审核:approve → 写入 friend_links + 邮件告知申请人;
// reject → 记录反馈意见 + 邮件告知申请人。
// ============================================================================
interface LinkApplication {
id: string;
name: string;
url: string;
feed: string;
email: string;
/** 申请人自填的图标地址;为空时审核通过会自动用 /api/favicon 抓站点图标 */
image?: string;
description?: string;
status: 'pending' | 'approved' | 'rejected';
feedback?: string;
appliedAt: string;
reviewedAt?: string;
}
function validHttpUrl(s: string): boolean {
try {
const u = new URL(s);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
function normUrl(s: string): string {
return s.replace(/\/+$/, '');
}
export async function linkApply(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method !== 'POST') return respond({ error: 'method not allowed' }, 405);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const name = String(body.name || '').trim().slice(0, 60);
const url = String(body.url || '').trim().slice(0, 300);
const feed = String(body.feed || '').trim().slice(0, 300);
const email = String(body.email || '').trim().slice(0, 120);
const image = String(body.image || '').trim().slice(0, 300);
const description = String(body.description || '').trim().slice(0, 200);
if (!name || !url || !feed || !email) return respond({ error: '名称、链接、订阅地址、邮箱均为必填' }, 400);
if (!validHttpUrl(url)) return respond({ error: '站点链接格式不正确(需 http/https 开头)' }, 400);
if (!validHttpUrl(feed)) return respond({ error: '订阅地址格式不正确(需 http/https 开头)' }, 400);
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return respond({ error: '邮箱格式不正确' }, 400);
if (image && !validHttpUrl(image)) return respond({ error: '图标链接格式不正确(需 http/https 开头,或留空自动获取)' }, 400);
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
if (links.links.some((l) => normUrl(l.url) === normUrl(url)))
return respond({ error: '该站点已经是友链啦,无需重复申请' }, 409);
if (st.apps.some((a) => normUrl(a.url) === normUrl(url) && a.status === 'pending'))
return respond({ error: '该站点已有待审申请,请耐心等待审核结果' }, 409);
const app: LinkApplication = {
id: 'la' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
name, url, feed, email, description, image,
status: 'pending',
appliedAt: new Date().toISOString(),
};
st.apps.unshift(app);
st.apps = st.apps.slice(0, 200);
await kvPutJson(env, 'link_applications', st);
// 邮件提醒管理员
const { sendMail } = await import('../../lib/mail');
await sendMail(env, {
to: 'imql@qq.com',
subject: `🔗 新的友链申请:${name}`,
html:
`<p><b>${name}</b> 提交了友链申请:</p>` +
`<ul><li>链接:<a href="${url}">${url}</a></li>` +
(image ? `<li>图标:<a href="${image}">${image}</a></li>` : `<li>图标:未填写(通过后自动抓取站点图标)</li>`) +
`<li>订阅:${feed}</li><li>邮箱:${email}</li>` +
(description ? `<li>描述:${description}</li>` : '') + `</ul>` +
`<p><a href="https://api.200181.xyz/admin/">前往管理面板审核</a></p>`,
});
return respond({ ok: true, message: '申请已提交,审核结果将邮件通知您' });
}
export async function linkApplyList(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
return respond({ apps: st.apps || [] });
}
export async function linkApplyReview(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { id?: string; action?: string; feedback?: string };
try {
body = (await request.json()) as { id?: string; action?: string; feedback?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const id = String(body.id || '');
const action = body.action === 'approve' ? 'approve' : body.action === 'reject' ? 'reject' : '';
if (!id || !action) return respond({ error: 'id and action required' }, 400);
const st = await kvGetJson<{ apps: LinkApplication[] }>(env, 'link_applications', { apps: [] });
const app = st.apps.find((a) => a.id === id);
if (!app) return respond({ error: '申请不存在' }, 404);
if (app.status !== 'pending') return respond({ error: '该申请已处理过' }, 409);
app.status = action === 'approve' ? 'approved' : 'rejected';
app.feedback = String(body.feedback || '').slice(0, 300);
app.reviewedAt = new Date().toISOString();
await kvPutJson(env, 'link_applications', st);
let mailOk = false;
if (action === 'approve') {
// 写入友链
const links = await kvGetJson<{ links: Link[] }>(env, 'friend_links', { links: [] });
links.links.push({
name: app.name,
url: app.url,
// 申请人没填图标 → 用站内 favicon 服务自动抓(页面/友圈直接用这个地址)
image: app.image || `/api/favicon?url=${encodeURIComponent(app.url)}`,
description: app.description || '',
rss: app.feed,
addedAt: new Date().toISOString(),
});
await kvPutJson(env, 'friend_links', links);
mailOk = await sendMailSafe(env, app.email,
`✅ 你的友链申请已通过:${app.name}`,
`<p>你的友链申请(<a href="${app.url}">${app.url}</a>)已审核通过,现已加进友链列表,感谢支持!</p>` +
(app.feedback ? `<p>站长留言:${app.feedback}</p>` : ''));
} else {
mailOk = await sendMailSafe(env, app.email,
`关于你的友链申请:${app.name}`,
`<p>很抱歉,你的友链申请(<a href="${app.url}">${app.url}</a>)本次未能通过。</p>` +
`<p>站长反馈:${app.feedback || '(未填写)'}</p>` +
`<p>欢迎完善站点内容后再次申请。</p>`);
}
return respond({ ok: true, mailOk });
}
async function sendMailSafe(env: Env, to: string, subject: string, html: string): Promise<boolean> {
try {
const { sendMail } = await import('../../lib/mail');
return await sendMail(env, { to, subject, html });
} catch {
return false;
}
}
+170
View File
@@ -0,0 +1,170 @@
// /api/random-image —— 随机图
// /api/artalk/commenter —— 代理 Artalk 查用户最新评论(已修 login 路径 bug)
// 迁自 edgeone/functions/api/{random-image.js, artalk/commenter.js}
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
const FOLDER_PREFIX = 'img_folder:';
const DATA_PREFIX = 'img_data:';
function getContentType(filename: string): string {
const ext = (filename || '').split('.').pop()?.toLowerCase();
const map: Record<string, string> = {
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml', ico: 'image/x-icon',
};
return map[ext || ''] || 'image/jpeg';
}
export async function randomImage(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
const url = new URL(request.url);
if (request.method === 'GET') {
const folder = url.searchParams.get('folder') || 'jiege';
const meta = url.searchParams.get('meta');
const list = url.searchParams.get('list');
if (list === '1') {
const raw = await env.RSS_KV.get('img_folders');
const folders = raw ? JSON.parse(raw) : [];
return respond({ folders });
}
const folderRaw = await env.RSS_KV.get(FOLDER_PREFIX + folder);
const images: { name?: string; type?: string; url?: string }[] = folderRaw ? JSON.parse(folderRaw) : [];
if (images.length === 0) return respond({ error: `文件夹 "${folder}" 为空或不存在` }, 404);
const idx = Math.floor(Math.random() * images.length);
const img = images[idx];
if (meta === '1') {
return respond({ name: img.name, type: img.type, url: img.url || null, folder, total: images.length, index: idx });
}
if (img.url && !img.name) {
return new Response(null, { status: 302, headers: { Location: img.url } });
}
const dataKey = DATA_PREFIX + folder + '/' + img.name;
const base64 = await env.RSS_KV.get(dataKey);
if (!base64) return respond({ error: `图片数据不存在: ${img.name}` }, 404);
const binary = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
return new Response(binary, {
status: 200,
headers: {
'Content-Type': img.type || getContentType(img.name || ''),
'Cache-Control': 'public, max-age=86400',
'Content-Disposition': `inline; filename="${img.name}"`,
},
});
}
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const folder = url.searchParams.get('folder') || 'jiege';
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const folderKey = FOLDER_PREFIX + folder;
const folderRaw = await env.RSS_KV.get(folderKey);
const images: any[] = folderRaw ? JSON.parse(folderRaw) : [];
let added = 0;
if (body.files && Array.isArray(body.files)) {
for (const f of body.files) {
if (!f.name || !f.data) continue;
if (images.find((i) => i.name === f.name)) continue;
const type = f.type || getContentType(f.name);
await env.RSS_KV.put(DATA_PREFIX + folder + '/' + f.name, f.data);
images.push({ name: f.name, type });
added++;
}
}
if (body.urls && Array.isArray(body.urls)) {
for (const item of body.urls) {
const u = typeof item === 'string' ? item : item.url;
if (!u) continue;
if (!images.find((i) => (i.url || i.name) === u)) {
images.push(typeof item === 'string' ? { url: u } : item);
added++;
}
}
}
if (added === 0) return respond({ error: '没有可添加的图片' }, 400);
await env.RSS_KV.put(folderKey, JSON.stringify(images));
const foldersRaw = await env.RSS_KV.get('img_folders');
const folders: string[] = foldersRaw ? JSON.parse(foldersRaw) : [];
if (!folders.includes(folder)) {
folders.push(folder);
await env.RSS_KV.put('img_folders', JSON.stringify(folders));
}
return respond({ ok: true, folder, added, total: images.length });
}
return respond({ error: 'method not allowed' }, 405);
}
export async function artalkCommenter(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { name, email, password, site_name, api_url } = body;
if (!name || !email || !password) return respond({ error: 'name, email, password 必填' }, 400);
await env.RSS_KV.put(
'artalk_config',
JSON.stringify({ name, email, password, site_name: site_name || '优世界', api_url: api_url || 'https://artalk.usj.cc' }),
);
return respond({ ok: true, message: 'Artalk 配置已保存' });
}
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
const targetEmail = url.searchParams.get('email');
if (!targetEmail) return respond({ error: 'email 参数必填' }, 400);
const configRaw = await env.RSS_KV.get('artalk_config');
if (!configRaw) return respond({ error: 'Artalk 未配置,请先 POST 保存 admin 账号' }, 400);
const config = JSON.parse(configRaw);
const { name, email, password, site_name, api_url } = config;
try {
// 登录(★ 修正:Artalk 登录接口是 /user/access_token,不是 /login)
const loginRes = await fetch(`${api_url}/api/v2/user/access_token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
});
const loginJson = (await loginRes.json()) as { token?: string; msg?: string };
if (!loginJson.token) {
return respond({ error: 'Artalk 登录失败: ' + (loginJson.msg || 'unknown') }, 502);
}
const adminToken = loginJson.token;
const searchUrl = `${api_url}/api/v2/comments?scope=site&site_name=${encodeURIComponent(site_name)}&search=${encodeURIComponent(targetEmail)}&limit=1`;
const commentRes = await fetch(searchUrl, { headers: { Authorization: `Bearer ${adminToken}` } });
const commentJson = (await commentRes.json()) as { comments?: any[]; data?: any };
const comments = commentJson.comments || commentJson.data?.comments || commentJson.data || [];
if (!Array.isArray(comments) || comments.length === 0) return respond({ nick: '', link: '' });
const last = comments[0];
return respond({ nick: last.nick || '', link: last.link || '' });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
+90
View File
@@ -0,0 +1,90 @@
// /api/auth —— 验证 / 修改口令
// /api/health —— 站点存活检测
// 迁自 edgeone/functions/api/auth.js + health.js
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth, kvGetJson } from '../../lib/rss/util';
export async function auth(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { oldPassword, newPassword } = body;
if (oldPassword) {
const current = (await env.RSS_KV.get('site_password')) || '';
if (current && oldPassword !== current) return respond({ error: '当前口令错误' }, 403);
}
if (newPassword) {
await env.RSS_KV.put('site_password', newPassword);
} else if (newPassword === '') {
await env.RSS_KV.delete('site_password');
}
return respond({ ok: true });
}
const authed = await requireAuth(request, env);
return respond({ ok: authed }, authed ? 200 : 401);
}
async function checkSite(url: string): Promise<Record<string, unknown>> {
const start = Date.now();
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
const res = await fetch(url, {
method: 'HEAD',
signal: ctrl.signal,
redirect: 'follow',
headers: { 'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)' },
});
clearTimeout(t);
return { url, status: res.status, ok: res.ok, latency: Date.now() - start };
} catch (err) {
return {
url,
status: 0,
ok: false,
latency: Date.now() - start,
error: (err as Error).message,
};
}
}
export async function health(request: Request, env: Env): Promise<Response> {
const reqUrl = new URL(request.url);
const targetUrl = reqUrl.searchParams.get('url');
if (targetUrl) {
return respond(await checkSite(targetUrl));
}
const data = await kvGetJson<{ feeds: { url: string }[] }>(env, 'feeds_config', { feeds: [] });
const feeds = data.feeds || [];
if (feeds.length === 0) return respond({ results: [], total: 0 });
const results: Record<string, unknown>[] = [];
const concurrency = 5;
for (let i = 0; i < feeds.length; i += concurrency) {
const batch = feeds.slice(i, i + concurrency);
const batchResults = await Promise.all(batch.map((f) => checkSite(f.url)));
results.push(...batchResults);
}
const alive = results.filter((r) => r.ok).length;
const dead = results.filter((r) => !r.ok).length;
return respond({
timestamp: new Date().toISOString(),
total: results.length,
alive,
dead,
results,
});
}
+63
View File
@@ -0,0 +1,63 @@
// /api/results —— 最新 RSS 聚合 JSON(按博客分组)
// /api/articles —— 按时间排序的文章列表
// 迁自 edgeone/functions/api/results.js + articles.js
import type { Env } from '../../types';
import { respond, kvGetJson } from '../../lib/rss/util';
interface FeedData {
name: string;
siteUrl: string;
favicon: string;
articles: unknown[];
}
interface Latest {
timestamp: string | null;
total: number;
feeds: FeedData[];
}
export async function results(request: Request, env: Env): Promise<Response> {
const data = await kvGetJson<Latest>(env, 'latest', { timestamp: null, total: 0, feeds: [] });
const url = new URL(request.url);
const feedFilter = url.searchParams.get('feed');
const limit = parseInt(url.searchParams.get('limit') || '0');
if (feedFilter) data.feeds = data.feeds.filter((f) => f.name === feedFilter);
if (limit > 0) data.feeds = data.feeds.map((f) => ({ ...f, articles: f.articles.slice(0, limit) }));
return respond(data, 200);
}
export async function articles(request: Request, env: Env): Promise<Response> {
const data = await kvGetJson<Latest>(env, 'latest', { timestamp: null, total: 0, feeds: [] });
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '50');
const flat: any[] = [];
for (const feed of data.feeds || []) {
for (const article of feed.articles || []) {
const a = article as any;
flat.push({
title: a.title,
link: a.link,
pubDate: a.pubDate,
author: a.author || feed.name,
feedName: feed.name,
siteUrl: feed.siteUrl,
// 源里没抓到图标时,直接用站内 favicon 服务(会返回真实图标或字母 SVG,绝不是灰头像)。
// 用 request 的 origin 拼绝对地址 —— 友圈页在 usj.cc 上,相对路径 /api/* 会 404。
favicon: (() => {
const raw = feed.favicon || '';
// 历史数据里存的是相对路径 → 补成绝对(否则在博客域名下会 404)
if (raw) return raw.startsWith('/') ? url.origin + raw : raw;
return feed.siteUrl ? `${url.origin}/api/favicon?url=${encodeURIComponent(feed.siteUrl)}` : '';
})(),
});
}
}
flat.sort((a, b) => new Date(b.pubDate).getTime() - new Date(a.pubDate).getTime());
return respond({ timestamp: data.timestamp, total: flat.length, articles: flat.slice(0, limit) }, 200);
}
+404
View File
@@ -0,0 +1,404 @@
// /api/favicon —— 自动发现并返回站点 favicon
// /api/update —— 接收抓取结果写入 KV(scheduled 已内置,此端点保留兼容外部上报)
// /api/proxy —— RSS 抓取代理(EdgeOne 版迁过来;注意本 Worker 在境外,等价于原 EdgeOne)
// 迁自 edgeone/functions/api/{favicon,update,proxy}.js
import type { Env } from '../../types';
import { respond, requireAuth } from '../../lib/rss/util';
import { notifyAdmin } from '../../lib/admin-notify';
import { checkProxyHealth, notifyProxyDown } from '../../lib/rss/proxy-health';
import { kvGetJson, kvPutJson } from '../../lib/rss/util';
const CACHE_TTL = 604800;
function resolveUrl(href: string, baseUrl: string): string | null {
if (href.startsWith('http://') || href.startsWith('https://')) return href;
try {
return new URL(href, baseUrl).href;
} catch {
return null;
}
}
async function discoverFaviconUrl(siteUrl: string): Promise<string | null> {
if (!/^https?:\/\//i.test(siteUrl)) siteUrl = 'https://' + siteUrl;
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12000);
const res = await fetch(siteUrl, {
signal: ctrl.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,*/*',
},
});
clearTimeout(t);
const html = await res.text();
const iconPatterns = [
/<link[^>]+rel=["'](?:shortcut )?icon["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["'](?:shortcut )?icon["']/i,
/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/i,
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']apple-touch-icon["']/i,
];
for (const pattern of iconPatterns) {
const match = html.match(pattern);
if (match) return resolveUrl(match[1].trim(), siteUrl);
}
const urlObj = new URL(siteUrl);
const defaultFavicon = `${urlObj.protocol}//${urlObj.host}/favicon.ico`;
try {
const ctrl2 = new AbortController();
const t2 = setTimeout(() => ctrl2.abort(), 8000);
const check = await fetch(defaultFavicon, { method: 'HEAD', signal: ctrl2.signal, redirect: 'follow' });
clearTimeout(t2);
const ct = (check.headers.get('content-type') || '').toLowerCase();
// 必须确认返回的真是图片:Typecho/WordPress 等会把 404 页面以 200 + text/html 返回
if (check.ok && (ct.startsWith('image/') || ct.includes('octet-stream'))) return defaultFavicon;
} catch {
/* 继续走兜底 */
}
// favicon.ico 不通或返回的不是图片 → 兜底第三方 favicon 服务(国内可达),
// 避免友圈卡片大面积落回灰色默认头像
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
} catch {
try {
const urlObj = new URL(siteUrl);
// 同上:抓取失败兜底第三方服务
return `https://api.iowen.cn/favicon/${urlObj.host}.png`;
} catch {
return null;
}
}
}
export async function favicon(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const siteUrl = url.searchParams.get('url');
if (!siteUrl) return respond({ error: 'url parameter required' }, 400);
const bodyKey = 'favicon:body:v2:' + siteUrl;
// 一级缓存:图片本体直接存 KV(base64)——命中后零外站回源,响应最快
const cached = await env.RSS_KV.get(bodyKey);
if (cached) {
const [meta, b64] = cached.split('|');
const body = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0));
return new Response(body, {
status: 200,
headers: {
'Content-Type': meta,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
// 失败短缓存:抓取失败的站点 1 小时内不再反复打外站
const missKey = 'favicon:miss:' + siteUrl;
if (await env.RSS_KV.get(missKey)) {
return letterSvg(siteUrl);
}
if (url.searchParams.get('meta')) {
const u = await discoverFaviconUrl(siteUrl);
return respond({ url: u });
}
// 发现 + 下载图片本体(兼容读旧版 URL 字符串缓存:存量友链的发现结果都在旧 key 里)
let faviconUrl: string | null = await env.RSS_KV.get('favicon:' + siteUrl).catch(() => null);
if (!faviconUrl) {
try {
faviconUrl = await discoverFaviconUrl(siteUrl);
if (faviconUrl) await env.RSS_KV.put('favicon:' + siteUrl, faviconUrl, { expirationTtl: CACHE_TTL });
} catch {
faviconUrl = null;
}
}
if (faviconUrl) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10000);
const imgRes = await fetch(faviconUrl, {
signal: ctrl.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
Accept: 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
},
});
clearTimeout(t);
if (imgRes.ok) {
const rawCt = (imgRes.headers.get('content-type') || '').toLowerCase();
// 只接受真正的图片(含 octet-stream 的 .ico);HTML/文本一律视为抓取失败
if (!rawCt.startsWith('image/') && !rawCt.includes('octet-stream')) {
throw new Error('favicon not an image: ' + rawCt);
}
const contentType = rawCt || 'image/x-icon';
const buf = await imgRes.arrayBuffer();
// 只缓存小于 300KB 的图(KV 单值上限 25MB,异常大图不占空间)
if (buf.byteLength <= 307200) {
let bin = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
}
return new Response(buf, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
} catch {
/* 落到第三方兜底 */
}
}
// 兜底链:第三方 favicon 服务代理返回(仍尝试写缓存,下次直接命中)
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0];
const fallback = `https://api.iowen.cn/favicon/${host}.png`;
try {
const ctrl3 = new AbortController();
const t3 = setTimeout(() => ctrl3.abort(), 10000);
const fRes = await fetch(fallback, { signal: ctrl3.signal, redirect: 'follow' });
clearTimeout(t3);
if (fRes.ok) {
const contentType = fRes.headers.get('content-type') || 'image/png';
const buf = await fRes.arrayBuffer();
let bin = '';
const bytes = new Uint8Array(buf);
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)) as unknown as number[]);
}
await env.RSS_KV.put(bodyKey, `${contentType}|${btoa(bin)}`, { expirationTtl: CACHE_TTL });
return new Response(buf, {
status: 200,
headers: {
'Content-Type': contentType,
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
} catch {
/* 彻底失败 */
}
// 记一次失败,1 小时内不再打外站;返回自绘字母 SVG 兜底
// (零依赖永远可用,比第三方服务和灰色默认头像都体面)
await env.RSS_KV.put(missKey, '1', { expirationTtl: 3600 });
return letterSvg(siteUrl);
}
/** 站点首字母占位图(永远 200 的最后兜底) */
function letterSvg(siteUrl: string): Response {
let host = siteUrl.replace(/^https?:\/\//, '').split('/')[0].replace(/^www\./, '');
const letter = (host[0] || '?').toUpperCase();
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64">` +
`<rect width="64" height="64" rx="12" fill="#2f9e63"/>` +
`<text x="32" y="43" font-family="Arial,sans-serif" font-size="32" font-weight="600" fill="#ffffff" text-anchor="middle">${letter}</text></svg>`;
return new Response(svg, {
status: 200,
headers: {
'Content-Type': 'image/svg+xml',
'Cache-Control': 'public, max-age=86400',
'Access-Control-Allow-Origin': '*',
},
});
}
export async function update(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
try {
const data = await request.json();
await env.RSS_KV.put('latest', JSON.stringify(data), { expirationTtl: 604800 });
return respond({ ok: true });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
/**
* POST /api/notify-mail —— 通用提醒邮件(给自己发一封)
* body: { subject, text, dedupeKey?, minGapHours? }
* 鉴权:requireAuth;收件人固定 MAIL_ADMIN,不能指定别人。
*/
export async function notifyMail(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const result = await notifyAdmin(env, {
subject: String(body?.subject || '提醒'),
text: String(body?.text || ''),
dedupeKey: body?.dedupeKey,
minGapHours: Number(body?.minGapHours) || 0,
});
if (result.error) return respond({ error: result.error }, 400);
return respond(result);
}
/**
* GET /api/proxy-health —— 国内代理(scfapi.usj.cc)体检
* ?notify=1 时若不健康还会给站长发一封提醒邮件(48h 节流)。
*/
export async function proxyHealth(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const health = await checkProxyHealth(env);
let mailed: unknown = null;
if (!health.ok && new URL(request.url).searchParams.get('notify') === '1') {
mailed = await notifyProxyDown(env, health);
}
return respond({ ...health, mailed });
}
export async function proxy(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' } });
}
if (request.method !== 'POST') return respond({ error: 'POST only' }, 405);
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { url, timeout = 15000 } = body;
if (!url) return respond({ error: 'url required' }, 400);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const targetRes = await fetch(url, {
signal: controller.signal,
redirect: 'follow',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; RSSBot/1.0)',
Accept: 'text/html,application/xhtml+xml,application/xml,application/json;q=0.9,*/*;q=0.8',
},
});
clearTimeout(timer);
const text = await targetRes.text();
const ct = targetRes.headers.get('content-type') || '';
return respond({ ok: true, status: targetRes.status, contentType: ct, body: text });
} catch (err) {
return respond({ ok: false, error: (err as Error).message }, 502);
}
}
/**
* POST /api/cron/run?offset=0&limit=35 —— 手动触发一批抓取(token 鉴权)。
* 免费版 subrequest 限制下用于补跑/首灌数据。
*/
export async function cronRun(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
const url = new URL(request.url);
const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);
const limit = Math.max(parseInt(url.searchParams.get('limit') || '35', 10) || 0, 0);
// dry=1:只跑抓取、不写 KV、不发通知 —— 用于安全地测耗时/成功率(不会污染线上数据)
const dry = url.searchParams.get('dry') === '1';
// rotate=1:从 KV 游标继续抓(模拟定时任务的行为)
const rotate = url.searchParams.get('rotate') === '1';
// 抓取主要是 IO 等待(不计 CPU),HTTP 触发也没有时长上限,35 个源串行能跑完
const { runCron } = await import('../../lib/rss/cron');
const stats = await runCron(env, { offset, limit, dry, rotate });
return respond({ ok: true, offset, limit, stats });
}
// ============================================================================
// 部署状态(国内中转机同步监控)
// CI 的 build job 在 COS 上传后上报 phase=built;中转机 sync.sh 同步完成后
// 上报 phase=synced。GET 公开可查,对比两者就知道国内线路部署完了没。
// ============================================================================
interface DeployPhase {
hash: string;
phase: string;
at: string;
}
export async function deployStatusGet(_request: Request, env: Env): Promise<Response> {
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
const builds = (st.builds || []).slice(0, 5);
const built = builds.find((b) => b.phase === 'built');
const synced = builds.find((b) => b.phase === 'synced');
let state = 'unknown';
if (built && synced && synced.hash === built.hash) state = 'synced';
else if (built && !synced) state = 'syncing';
else if (synced && !built) state = 'synced'; // 未配置 CI 上报时,以中转机记录为准
else if (built && synced && synced.hash !== built.hash) state = 'syncing';
return respond({
state, // synced = 国内已生效;syncing = 构建完成等待中转机同步
latest_build: built || null,
latest_sync: synced || null,
builds,
});
}
export async function deployStatusPost(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { hash?: string; phase?: string } = {};
try {
body = (await request.json()) as { hash?: string; phase?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const hash = String(body.hash || '').slice(0, 64);
const phase = String(body.phase || '').slice(0, 20);
if (!hash || !phase) return respond({ error: 'hash and phase required' }, 400);
const st = await kvGetJson<{ builds: DeployPhase[] }>(env, 'deploy_status', { builds: [] });
st.builds = (st.builds || []).filter((b) => !(b.phase === phase && b.hash === hash));
st.builds.unshift({ hash, phase, at: new Date().toISOString() });
st.builds = st.builds.slice(0, 20);
await kvPutJson(env, 'deploy_status', st);
return respond({ ok: true });
}
/**
* POST /api/deploy-notify —— 中转机同步完成后发部署完成邮件(token 鉴权)。
* 与 Gitea Actions 的构建通知配套:构建通知告诉你"构建好了",
* 这封邮件告诉你"国内线路真正生效了"。
*/
export async function deployNotify(request: Request, env: Env): Promise<Response> {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: { hash?: string } = {};
try {
body = (await request.json()) as { hash?: string };
} catch {
return respond({ error: 'invalid json' }, 400);
}
const hash = String(body.hash || '').slice(0, 12) || '未知';
const { sendMail } = await import('../../lib/mail');
const okMail = await sendMail(env, {
to: 'imql@qq.com',
subject: `✅ 博客国内线路部署完成 ${hash}`,
text: `构建产物 ${hash} 已同步到又拍云并刷新多吉云 CDN,国内线路已生效。\n时间:${new Date().toISOString()}`,
html:
`<p>构建产物 <code>${hash}</code> 已同步到又拍云并刷新多吉云 CDN,<b>国内线路已生效</b>。</p>` +
`<p style="color:#8a8e98">时间:${new Date().toISOString()} · artalk-cf deploy monitor</p>`,
});
return respond({ ok: okMail });
}
+196
View File
@@ -0,0 +1,196 @@
// /api/wechat-material —— 同步文章到微信公众号草稿箱
// 迁自 edgeone/functions/api/wechat-material.js(319 行,最复杂的端点)
// 调用链:token → material/add_material(封面) → media/uploadimg(文内图) → draft/add
import type { Env } from '../../types';
import { respond, corsOptions, requireAuth } from '../../lib/rss/util';
const WECHAT_CONFIG_KEY = 'wechat_config';
const WECHAT_TOKEN_KEY = 'wechat_token';
async function getAccessToken(env: Env, appId: string, appSecret: string): Promise<string> {
const cached = await env.RSS_KV.get(WECHAT_TOKEN_KEY);
if (cached) {
try {
const data = JSON.parse(cached);
if (data.expiresAt > Date.now()) return data.token;
} catch {
// 忽略,重新获取
}
}
const res = await fetch(
`https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`,
);
const json = (await res.json()) as { errcode?: number; errmsg?: string; access_token?: string; expires_in?: number };
if (json.errcode) throw new Error(`获取 access_token 失败: ${json.errmsg} (code=${json.errcode})`);
const ttl = Math.max((json.expires_in || 7200) - 300, 60);
await env.RSS_KV.put(
WECHAT_TOKEN_KEY,
JSON.stringify({ token: json.access_token, expiresAt: Date.now() + ttl * 1000 }),
);
return json.access_token!;
}
function buildMultipartBody(fieldName: string, filename: string, data: ArrayBuffer, contentType: string): { body: Uint8Array; boundary: string } {
const boundary = '----WechatMaterial' + Date.now();
const encoder = new TextEncoder();
const parts: Uint8Array[] = [];
parts.push(encoder.encode(`--${boundary}\r\n`));
parts.push(encoder.encode(`Content-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\n`));
parts.push(encoder.encode(`Content-Type: ${contentType}\r\n\r\n`));
parts.push(new Uint8Array(data));
parts.push(encoder.encode('\r\n'));
parts.push(encoder.encode(`--${boundary}--\r\n`));
const totalLen = parts.reduce((acc, p) => acc + p.length, 0);
const body = new Uint8Array(totalLen);
let offset = 0;
for (const p of parts) {
body.set(p, offset);
offset += p.length;
}
return { body, boundary };
}
async function uploadThumbImage(accessToken: string, imageUrl: string): Promise<string> {
const downloadRes = await fetch(imageUrl);
if (!downloadRes.ok) throw new Error(`下载封面图失败: HTTP ${downloadRes.status} — ${imageUrl}`);
const buffer = await downloadRes.arrayBuffer();
const contentType = downloadRes.headers.get('content-type') || 'image/jpeg';
const ext = contentType.split('/')[1] || 'jpg';
const { body, boundary } = buildMultipartBody('media', `cover.${ext}`, buffer, contentType);
const res = await fetch(
`https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=${accessToken}&type=image`,
{ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body },
);
const json = (await res.json()) as { errcode?: number; errmsg?: string; media_id?: string };
if (json.errcode) throw new Error(`上传封面图失败: ${json.errmsg} (code=${json.errcode})`);
return json.media_id!;
}
async function uploadContentImage(accessToken: string, imageUrl: string): Promise<string> {
const downloadRes = await fetch(imageUrl);
if (!downloadRes.ok) throw new Error(`下载文内图片失败: HTTP ${downloadRes.status} — ${imageUrl}`);
const buffer = await downloadRes.arrayBuffer();
const contentType = downloadRes.headers.get('content-type') || 'image/jpeg';
const ext = contentType.split('/')[1] || 'jpg';
const { body, boundary } = buildMultipartBody('media', `content.${ext}`, buffer, contentType);
const res = await fetch(
`https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token=${accessToken}`,
{ method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` }, body },
);
const json = (await res.json()) as { errcode?: number; errmsg?: string; url?: string };
if (json.errcode) throw new Error(`上传文内图片失败: ${json.errmsg} (code=${json.errcode})`);
return json.url!;
}
async function addDraft(accessToken: string, articles: unknown[]): Promise<string> {
const res = await fetch(`https://api.weixin.qq.com/cgi-bin/draft/add?access_token=${accessToken}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articles }),
});
const json = (await res.json()) as { errcode?: number; errmsg?: string; media_id?: string };
if (json.errcode) throw new Error(`新增草稿失败: ${json.errmsg} (code=${json.errcode})`);
return json.media_id!;
}
export async function onRequest(request: Request, env: Env): Promise<Response> {
if (request.method === 'OPTIONS') return corsOptions();
if (request.method === 'GET') {
const configRaw = await env.RSS_KV.get(WECHAT_CONFIG_KEY);
if (!configRaw) return respond({ configured: false, message: '尚未配置微信公众号凭证' });
try {
const config = JSON.parse(configRaw);
return respond({ configured: true, appId: config.appId, hasSecret: !!config.appSecret });
} catch {
return respond({ configured: false, message: '配置解析失败' });
}
}
if (request.method === 'POST') {
if (!(await requireAuth(request, env))) return respond({ error: 'unauthorized' }, 401);
let body: any;
try {
body = await request.json();
} catch {
return respond({ error: 'invalid json' }, 400);
}
const { appId, appSecret, articles } = body;
if (!articles || !Array.isArray(articles) || articles.length === 0) {
return respond({ error: 'articles 数组不能为空' }, 400);
}
let wechatAppId = appId;
let wechatAppSecret = appSecret;
if ((!wechatAppId || !wechatAppSecret)) {
const configRaw = await env.RSS_KV.get(WECHAT_CONFIG_KEY);
if (configRaw) {
try {
const config = JSON.parse(configRaw);
if (!wechatAppId) wechatAppId = config.appId;
if (!wechatAppSecret) wechatAppSecret = config.appSecret;
} catch {
// 忽略
}
}
}
if (!wechatAppId || !wechatAppSecret) {
return respond({ error: '请提供微信 appId 和 appSecret' }, 400);
}
try {
if (appId && appSecret) {
await env.RSS_KV.put(WECHAT_CONFIG_KEY, JSON.stringify({ appId, appSecret }));
}
const accessToken = await getAccessToken(env, wechatAppId, wechatAppSecret);
const processed: Record<string, unknown>[] = [];
for (const article of articles) {
const item = { ...article };
if (!item.title) throw new Error('文章 title 不能为空');
if (item.thumb_media_url && !item.thumb_media_id) {
item.thumb_media_id = await uploadThumbImage(accessToken, item.thumb_media_url);
}
delete item.thumb_media_url;
if (!item.thumb_media_id) throw new Error(`文章「${item.title}」缺少 thumb_media_id 或 thumb_media_url`);
if (item.content_image_urls && Array.isArray(item.content_image_urls)) {
for (const img of item.content_image_urls) {
const wxUrl = await uploadContentImage(accessToken, img.original_url);
item.content = item.content.split(img.original_url).join(wxUrl);
}
}
delete item.content_image_urls;
const allowed = [
'title', 'author', 'digest', 'content', 'content_source_url',
'thumb_media_id', 'show_cover_pic', 'need_open_comment',
'only_fans_can_comment', 'article_type',
];
const draft: Record<string, unknown> = {};
for (const key of allowed) {
if (item[key] !== undefined) draft[key] = item[key];
}
if (draft.show_cover_pic == null) draft.show_cover_pic = 1;
if (!draft.content) draft.content = '';
processed.push(draft);
}
const mediaId = await addDraft(accessToken, processed);
return respond({ ok: true, media_id: mediaId, message: `已同步 ${processed.length} 篇文章到公众号草稿箱` });
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
}
return respond({ error: 'method not allowed' }, 405);
}
+255
View File
@@ -0,0 +1,255 @@
import type { Env, UserRow } from '../types';
import { findUserByEmail, findUserByName, findUserByNameEmail, rateLimit } from '../lib/db';
import { cookNotify, cookUser } from '../lib/cook';
import { isAdminByNameEmail, signToken, verifyPassword } from '../lib/session';
import { fail, getClientIP, isEmail, now, ok, okMsg, qp, readBody, trimTo } from '../lib/util';
import type { Ctx } from '../router';
// ==================================================================== GET /user
export async function userInfo(ctx: Ctx): Promise<Response> {
const { env, url, user } = ctx;
const name = qp(url, 'name');
const email = qp(url, 'email');
// 官方语义:name+email 即凭证(sidebar 用这种),email 是调用者已知的
// 信息,不构成泄露;token 优先。
let target: UserRow | null = user;
if (!target && name && email) {
target = await findUserByNameEmail(env, name, email);
}
if (!target) {
return ok({ user: null, is_login: false, notifies: [], notifies_count: 0 });
}
const notifies = await env.DB.prepare(
`SELECT n.*, c.page_key FROM notifies n
LEFT JOIN comments c ON c.id = n.comment_id
WHERE n.user_id = ? AND n.is_read = 0 ORDER BY n.created_at DESC LIMIT 20`,
)
.bind(target.id)
.all<{
id: number;
user_id: number;
comment_id: number;
is_read: number;
is_emailed: number;
page_key: string | null;
}>();
const list = (notifies.results ?? []).map((n) =>
cookNotify(n, notifyAnchor(n.page_key, n.comment_id)),
);
return ok({
user: cookUser(target),
is_login: !!user && user.id === target.id,
notifies: list,
notifies_count: list.length,
});
}
/** 官方 read_link 语义:跳回文章页定位到那条评论 */
function notifyAnchor(pageKey: string | null, commentId: number): string {
const key = (pageKey || '').trim();
return key ? `${key}?atk_comment=${commentId}` : `/admin/comments?comment_id=${commentId}`;
}
// =================================================================== POST /user
export async function userUpdate(ctx: Ctx): Promise<Response> {
const { env, req, user } = ctx;
const body = await readBody(req);
const name = trimTo(body.name || '', 60).trim();
const email = trimTo(body.email || '', 120).trim();
const link = trimTo(body.link || '', 255).trim();
if (!name || !email) return fail(400, 'name and email are required');
if (!isEmail(email)) return fail(400, 'Invalid Email');
// 必须登录且只能改自己;未登录时代官方的 name+email 匹配也允许
let target: UserRow | null = user;
if (!target) {
target = await findUserByNameEmail(env, name, email);
if (!target) return fail(401, 'Login required');
} else if (target.name !== name || target.email !== email) {
return fail(403, 'Forbidden');
}
await env.DB.prepare('UPDATE users SET name = ?, email = ?, link = ?, updated_at = ? WHERE id = ?')
.bind(name, email, link, now(), target.id)
.run();
const updated = await env.DB.prepare('SELECT * FROM users WHERE id = ?')
.bind(target.id)
.first<UserRow>();
return ok({ user: cookUser(updated as UserRow) });
}
// ========================================================== GET /user/status
export async function userStatus(ctx: Ctx): Promise<Response> {
const { env, url, user } = ctx;
const name = qp(url, 'name');
const email = qp(url, 'email');
if (user) {
return ok({ is_admin: !!user.is_admin, is_login: true });
}
if (name && email) {
return ok({ is_admin: await isAdminByNameEmail(env, name, email), is_login: false });
}
return ok({ is_admin: false, is_login: false });
}
/**
* 登录标识:可能是邮箱(user@x.com),也可能是用户名(admin)。
* 后台登录框只有一个「邮箱」字段,用户很可能把账号名直接填进去,
* 所以这里对两种写法都做匹配,避免"账号明明是对的却登不上"。
*/
async function findLoginCandidates(
env: Env,
identifier: string,
name?: string,
): Promise<UserRow[]> {
const id = (identifier || '').trim();
const nm = (name || '').trim();
if (id && id.includes('@')) {
const byEmail = await findUserByEmail(env, id);
return nm ? byEmail.filter((u) => u.name === nm) : byEmail;
}
// 不是邮箱形态 → 当成用户名;也顺带用 name 参数兜一下
const names = [...new Set([id, nm].filter(Boolean))];
const out: UserRow[] = [];
for (const n of names) out.push(...(await findUserByName(env, n)));
// 万一有人把用户名写成了邮箱格式的账号,再补一次邮箱匹配
if (!out.length && id) out.push(...(await findUserByEmail(env, id)));
return out;
}
// =================================================== POST /user/access_token
export async function userAccessToken(ctx: Ctx): Promise<Response> {
const { env, req } = ctx;
const ip = getClientIP(req);
if (!(await rateLimit(env, `login:${ip}`, 10, 300))) {
return fail(429, 'Too many login attempts, try again later');
}
const body = await readBody(req);
const identifier = trimTo(body.email || body.username || body.account || '', 120).trim();
const name = trimTo(body.name || '', 60).trim();
const password = String(body.password ?? '');
if (!identifier && !name) return fail(400, '账号不能为空');
if (!password) return fail(400, '密码不能为空');
const candidates = await findLoginCandidates(env, identifier, name);
if (!candidates.length) return fail(401, 'Unauthorized');
for (const u of candidates) {
if (await verifyPassword(u.password, password)) {
return ok({ token: await signToken(env, u.id), user: cookUser(u) });
}
}
return fail(401, 'Unauthorized');
}
// ==================================================== POST /auth/email/login
export async function authEmailLogin(ctx: Ctx): Promise<Response> {
const { env, req } = ctx;
const ip = getClientIP(req);
if (!(await rateLimit(env, `login:${ip}`, 10, 300))) {
return fail(429, 'Too many login attempts, try again later');
}
const body = await readBody(req);
const identifier = trimTo(body.email || body.username || body.account || '', 120).trim();
const password = String(body.password ?? '');
const code = String(body.code ?? '').trim();
if (code) {
return fail(
501,
'邮箱验证码登录未启用:Cloudflare Workers 没有 SMTP,发不出验证邮件。请改用密码登录。',
);
}
if (!identifier || !password) return fail(400, '账号和密码不能为空');
for (const u of await findLoginCandidates(env, identifier)) {
if (await verifyPassword(u.password, password)) {
return ok({ token: await signToken(env, u.id), user: cookUser(u) });
}
}
return fail(401, 'Unauthorized');
}
export async function authEmailSend(ctx: Ctx): Promise<Response> {
// Workers 无法直连 SMTP。要恢复「邮箱验证码」,接一个 HTTP 邮件 API
// (Resend / MailChannels / 你自己的转发接口)后在 sendMail() 里实现。
return fail(
501,
'邮件发送未启用:Workers 环境没有 SMTP,需要接 Resend / MailChannels 之类的 HTTP 邮件 API。',
);
}
export async function authEmailRegister(ctx: Ctx): Promise<Response> {
return fail(403, '注册已关闭:本服务端只保留「昵称 + 邮箱直接评论」和「管理员密码登录」。');
}
// ============================================================== auth/merge
export async function authMergeCheck(ctx: Ctx): Promise<Response> {
const { env, user } = ctx;
if (!user) return fail(401, 'Login required');
const same = await findUserByEmail(env, user.email);
const names = same.filter((u) => u.id !== user.id).map((u) => u.name);
return ok({ need_merge: names.length > 0, user_names: names });
}
export async function authMergeApply(ctx: Ctx): Promise<Response> {
const { env, req, user } = ctx;
if (!user) return fail(401, 'Login required');
const body = await readBody(req);
const fromName = trimTo(body.user_name || '', 60).trim();
if (!fromName) return fail(400, 'user_name is required');
const from = await findUserByNameEmail(env, fromName, user.email);
if (!from || from.id === user.id) return okMsg('Nothing to merge');
const updated = await env.DB.prepare(
'UPDATE comments SET user_id = ? WHERE user_id = ?',
)
.bind(user.id, from.id)
.run();
await env.DB.prepare('UPDATE notifies SET user_id = ? WHERE user_id = ?')
.bind(user.id, from.id)
.run();
await env.DB.prepare('UPDATE votes SET user_id = ? WHERE user_id = ?')
.bind(user.id, from.id)
.run();
await env.DB.prepare('DELETE FROM users WHERE id = ?').bind(from.id).run();
return ok({
deleted_user_count: 1,
update_comments_count: updated.meta?.changes ?? 0,
update_notifies_count: 0,
update_votes_count: 0,
user_token: await signToken(env, user.id),
});
}
// ============================================================ sso/exchange
export async function ssoExchange(): Promise<Response> {
return fail(501, 'SSO 未配置');
}
+109
View File
@@ -0,0 +1,109 @@
// rss-robot-cf —— 主入口
// 路由表 + Cron scheduled。迁自 EdgeOne 的 api/*.js 各端点 + check-feeds.js 抓取逻辑。
import type { Env } from './types';
import { runCron } from './lib/rss/cron';
/** 每小时抓取的源数(66 源 ÷ 22 ≈ 3 小时跑完一轮) */
const RSS_BATCH = 22;
import * as FeedsRoute from './routes/rss/feeds';
import * as LinksRoute from './routes/rss/links';
import { results, articles } from './routes/rss/results';
import { auth, health } from './routes/rss/misc';
import { favicon, update, proxy, cronRun, notifyMail, proxyHealth, deployStatusGet, deployStatusPost, deployNotify } from './routes/rss/tools';
import { linkApply, linkApplyList, linkApplyReview } from './routes/rss/links';
import * as WechatRoute from './routes/rss/wechat';
import { randomImage, artalkCommenter } from './routes/rss/media';
import * as GreetingRoute from './routes/rss/greeting';
import { respond } from './lib/rss/util';
const CORS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
};
export const RssApi = {
// 每小时轮转抓取:每次抓 RSS_BATCH 个源,游标存在 KV(rss_cursor),
// 三四个小时覆盖全部 66 个源。免费版单次调用限 50 subrequest,
// 所以批量不能大;连续失败的源会进入退避期被跳过(见 cron.ts)。
async scheduled(_controller: ScheduledController, env: Env, _ctx: ExecutionContext): Promise<void> {
await runCron(env, { limit: RSS_BATCH, rotate: true });
},
async fetch(request: Request, env: Env, _ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const path = url.pathname;
// CORS 预检(所有端点)
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: CORS });
}
// 微信验证文件(MP_verify_xxx.txt)
if (path.startsWith('/MP_verify_') && path.endsWith('.txt')) {
const code = path.replace('/MP_verify_', '').replace('.txt', '');
return new Response(code, { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
// 健康检查
if (path === '/api/health') return health(request, env);
// 管理页(/feed 订阅源管理)与首页登录门
if (path === '/feed') {
const resp = await env.ASSETS!.fetch(new URL('/feed.html', request.url));
if (resp.ok) return new Response(resp.body, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
return respond({ error: 'feed.html not found' }, 404);
}
// 路由分发
try {
switch (path) {
case '/api/feeds':
return FeedsRoute.onRequest(request, env);
case '/api/links':
return LinksRoute.onRequest(request, env);
case '/api/results':
return results(request, env);
case '/api/articles':
return articles(request, env);
case '/api/auth':
return auth(request, env);
case '/api/favicon':
return favicon(request, env);
case '/api/update':
return update(request, env);
case '/api/cron/run':
return cronRun(request, env);
case '/api/deploy-status':
return request.method === 'POST' ? deployStatusPost(request, env) : deployStatusGet(request, env);
case '/api/deploy-notify':
return deployNotify(request, env);
case '/api/link-apply':
return linkApply(request, env);
case '/api/link-apply/list':
return linkApplyList(request, env);
case '/api/link-apply/review':
return linkApplyReview(request, env);
case '/api/proxy':
return proxy(request, env);
case '/api/notify-mail':
return notifyMail(request, env);
case '/api/proxy-health':
return proxyHealth(request, env);
case '/api/wechat-material':
return WechatRoute.onRequest(request, env);
case '/api/random-image':
return randomImage(request, env);
case '/api/artalk/commenter':
return artalkCommenter(request, env);
case '/api/ai/greeting':
return GreetingRoute.onRequest(request, env);
default:
return respond({ error: 'not found', path }, 404);
}
} catch (err) {
return respond({ error: (err as Error).message }, 500);
}
},
};
+5
View File
@@ -0,0 +1,5 @@
// 由 tools/gen_template.py 生成,请勿手改。
// 来源:Artalk 仓库 conf/artalk.example.zh-CN.yml
// 作用:/settings/template/:locale 下发,供官方后台的设置页渲染表单。
export const SETTINGS_TEMPLATE_ZH_CN = "# 服务器地址\nhost: \"0.0.0.0\"\n\n# 服务器端口\nport: 23366\n\n# 加密密钥\napp_key: \"\"\n\n# 调试模式\ndebug: false\n\n# 语言 [\"en\", \"zh-CN\", \"zh-TW\", \"ja\", \"fr\", \"ko\", \"ru\"]\nlocale: zh-CN\n\n# 时间区域\ntimezone: Asia/Shanghai\n\n# 默认站点名\nsite_default: 默认站点\n\n# 默认站点地址\nsite_url: \"\"\n\n# 登录有效时长 (单位:秒)\nlogin_timeout: 259200\n\n# 数据库\ndb:\n # 数据库类型 [\"sqlite\", \"mysql\", \"pgsql\", \"mssql\"]\n type: sqlite\n # 数据库文件 (仅 SQLite 数据库需填写)\n file: ./data/artalk.db\n # 数据库名称\n name: artalk\n # 数据库地址\n host: localhost\n # 数据库端口\n port: 3306\n # 数据库账户\n user: root\n # 数据库密码\n password: \"\"\n # 编码格式\n charset: utf8mb4\n # 表前缀 (例如:\"atk_\")\n table_prefix: \"\"\n # 启用 SSL\n ssl: false\n # 预编译语句\n prepare_stmt: true\n\n# 服务器\nhttp:\n # 请求体大小限制 (单位:MB)\n body_limit: 100\n # 代理标头名 (当使用 CDN 时填写 `X-Forwarded-For` 获取用户真实 IP)\n proxy_header: \"\"\n\n# 日志\nlog:\n # 启用日志\n enabled: true\n # 日志文件路径\n filename: ./data/artalk.log\n\n# 缓存\ncache:\n # 启用缓存\n enabled: false\n # 缓存类型 [\"redis\", \"memcache\", \"builtin\"]\n type: builtin\n # 缓存过期时间 (单位:分钟)\n expires: 30\n # 缓存启动预热 (程序启动时预热缓存)\n warm_up: false\n # 缓存服务器地址 (例如:\"localhost:6379\")\n server: \"\"\n # Redis 配置\n redis:\n # 连接方式 [\"tcp\", \"unix\"]\n network: tcp\n # 用户名\n username: \"\"\n # 密码\n password: \"\"\n # 数据库编号 (例如使用零号数据库填写 0)\n db: 0\n\n# 可信域名\n# -- 例如:[\"https://artalk.example.com:23366\"] --\ntrusted_domains: []\n\n# SSL\nssl:\n # 启用 SSL\n enabled: false\n # 证书文件路径\n cert_path: \"\"\n # 密钥文件路径\n key_path: \"\"\n\n# 管理员账户\n# --\n# - name: \"admin\"\n# email: \"admin@example.com\"\n# password: \"\" # 支持 bcrypt 或 md5 加密,如:\"(bcrypt)$2y$10$ti4vZYIrxVN8rLcYXVgXCO.GJND0dyI49r7IoF3xqIx8bBRmIBZRm\"\n# badge_name: \"管理员\"\n# badge_color: \"#0083FF\"\n# --\nadmin_users: []\n\n# 评论审核\nmoderator:\n # 默认待审 (发表新评论需要后台人工审核后才能显示)\n pending_default: false\n # API 请求错误时拦截 (关闭此项当请求错误时让评论放行)\n api_fail_block: false\n # Akismet Key\n # (Akismet 反垃圾服务,https://akismet.com)\n akismet_key: \"\"\n # 腾讯云文本内容安全\n # (https://cloud.tencent.com/document/product/1124/64508)\n tencent:\n enabled: false\n secret_id: \"\"\n secret_key: \"\"\n region: ap-guangzhou\n # 阿里云内容安全\n # (https://help.aliyun.com/document_detail/28417.html)\n aliyun:\n enabled: false\n access_key_id: \"\"\n access_key_secret: \"\"\n region: cn-shanghai\n # 关键词过滤 (本地离线词库)\n keywords:\n enabled: false\n # 匹配成功设为待审状态\n pending: false\n # 词库文件 (支持多个词库文件)\n files:\n - ./data/词库_1.txt\n # 词库文件内容分割符 (例如填写 \"\\n\" 文件中一行一个关键词)\n file_sep: \"\\n\"\n # 替换字符\n replace_to: x\n\n# 验证码\ncaptcha:\n # 启用验证码\n enabled: true\n # 总是需要验证码\n always: false\n # 验证类型 [\"image\", \"turnstile\", \"recaptcha\", \"hcaptcha\", \"geetest\"]\n captcha_type: image\n # 激活验证码所需操作次数\n action_limit: 3\n # 重置操作计数器超时 (单位:s, 设为 -1 不重置)\n action_reset: 60\n # Turnstile\n # (https://www.cloudflare.com/products/turnstile/)\n turnstile:\n site_key: \"\"\n secret_key: \"\"\n # reCaptcha\n # (https://www.google.com/recaptcha/about/)\n recaptcha:\n site_key: \"\"\n secret_key: \"\"\n # hCaptcha (https://www.hcaptcha.com/)\n hcaptcha:\n site_key: \"\"\n secret_key: \"\"\n # Geetest 极验 (https://www.geetest.com)\n geetest:\n captcha_id: \"\"\n captcha_key: \"\"\n\n# IP 属地\nip_region:\n # 启用 IP 属地展示\n enabled: false\n # 数据文件路径 (.xdb 格式)\n db_path: ./data/ip2region.xdb\n # 显示精度 [\"province\", \"city\", \"country\"]\n precision: province\n\n# 图片上传\nimg_upload:\n # 启用图片上传\n enabled: true\n # 图片存放路径\n path: ./data/artalk-img/\n # 图片大小限制 (单位:MB)\n max_size: 5\n # 图片链接基础路径 (默认为 \"/static/images/\")\n public_path: null\n # Upgit 配置\n # (使用 Upgit 将图片上传到 GitHub 或图床:https://github.com/pluveto/upgit)\n upgit:\n # 启用 Upgit\n enabled: false\n # 命令行参数\n exec: upgit -c <upgit配置文件路径> -t /artalk- Line truncated
+196
View File
@@ -0,0 +1,196 @@
export interface Env {
DB: D1Database;
/** RSS 订阅数据(rss-robot 合并进来,KV 与 D1 共存于同一 Worker) */
RSS_KV: KVNamespace;
UPLOADS?: R2Bucket;
ASSETS?: Fetcher;
// ---- RSS 订阅(rss-robot-cf 合并进来的 vars/secrets)----
FEEDS_URL?: string;
SCF_PROXY_URL?: string;
FEISHU_WEBHOOK_URL?: string;
SITE_TOKEN?: string;
WECHAT_APPID?: string;
WECHAT_APPSECRET?: string;
DEEPSEEK_API_KEY?: string;
SITE_DEFAULT: string;
SITE_URL: string;
/** 站内公开 API 基址,如 https://api.200181.xyz(拼绝对 favicon 地址用) */
PUBLIC_API_BASE?: string;
ADMIN_NAME: string;
ADMIN_EMAIL: string;
ADMIN_PASSWORD: string;
TOKEN_SECRET: string;
ALLOWED_ORIGINS: string;
ITEMS_PER_PAGE: string;
PENDING_DEFAULT: string;
IMG_UPLOAD_ENABLED?: string;
// ---- 邮件通知(可选,走 Resend 的 HTTP API;Workers 没有 TCP,发不了 SMTP)
// 两个都走 `wrangler secret put`,不要写进 wrangler.toml 的 [vars]
RESEND_API_KEY?: string;
MAIL_FROM?: string; // 形如 `artalk <noreply@200181.xyz>`,域名要先在 Resend 验证过
/**
* 发件人显示名。可选;留空就用 MAIL_FROM 里那段名字。
* 对应旧 artalk.yml 的 `email.send_name`。
*/
MAIL_FROM_NAME?: string;
/**
* 管理员通知的收件地址。
* **不要**复用 ADMIN_EMAIL —— 那个是后台登录标识,改成真实邮箱会影响登录。
* 不配这一项就只发「回复提醒」,不发管理员通知。
*/
MAIL_ADMIN?: string;
}
// ---------------------------------------------------------------- 数据库实体
export interface SiteRow {
id: number;
name: string;
urls: string;
created_at: number;
updated_at: number;
}
export interface PageRow {
id: number;
key: string;
site_name: string;
title: string;
admin_only: number;
pv: number;
vote_up: number;
vote_down: number;
accessible_url: string;
created_at: number;
updated_at: number;
}
export interface UserRow {
id: number;
name: string;
email: string;
link: string;
password: string;
badge_name: string;
badge_color: string;
title_name: string;
last_ip: string;
last_ua: string;
is_admin: number;
receive_email: number;
is_in_conf: number;
token_valid_from: number;
created_at: number;
updated_at: number;
}
export interface CommentRow {
id: number;
content: string;
page_key: string;
site_name: string;
user_id: number;
rid: number;
root_id: number;
is_collapsed: number;
is_pending: number;
is_pinned: number;
is_verified: number;
vote_up: number;
vote_down: number;
ua: string;
ip: string;
ip_region: string;
created_at: number;
updated_at: number;
deleted_at: number;
}
// ------------------------------------------------------- API 输出(与 Artalk 对齐)
export interface CookedComment {
id: number;
content: string;
content_marked: string;
user_id: number;
nick: string;
email_encrypted: string;
link: string;
ua: string;
date: string;
is_collapsed: boolean;
is_pending: boolean;
is_pinned: boolean;
is_allow_reply: boolean;
is_verified: boolean;
rid: number;
badge_name: string;
badge_color: string;
/** 头衔徽章(金色,管理员手动设置,优先级仅次于系统徽章) */
title_name?: string;
/** 评论等级(九品官阶)——独立于官方 badge 槽,按全站真实评论数由后端计算 */
rank_name?: string;
rank_title?: string;
rank_count?: number;
rank_color?: string;
rank_bg?: string;
ip_region?: string;
visible: boolean;
vote_up: number;
vote_down: number;
page_key: string;
page_url: string;
site_name: string;
}
export interface CookedPage {
id: number;
admin_only: boolean;
key: string;
url: string;
title: string;
site_name: string;
vote_up: number;
vote_down: number;
pv: number;
date: string;
}
export interface CookedUser {
id: number;
name: string;
email: string;
link: string;
badge_name: string;
badge_color: string;
title_name?: string;
is_admin: boolean;
receive_email: boolean;
}
export interface CookedSite {
id: number;
name: string;
urls: string[];
urls_raw: string;
first_url: string;
}
export interface CookedNotify {
id: number;
user_id: number;
comment_id: number;
is_read: boolean;
is_emailed: boolean;
read_link: string;
}
export interface SessionUser {
id: number;
name: string;
email: string;
isAdmin: boolean;
}
+68
View File
@@ -0,0 +1,68 @@
// 在「真实 Artalk 评论区」里逐状态截图:包装页 iframe 里是 /comment.html
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9385;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/human-demo.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/artalk-human.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-atk-human-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1100', 'about:blank'], { stdio: 'ignore' });
const PROBE = "JSON.stringify((()=>{const f=document.getElementById('fr');const d=f&&f.contentDocument;if(!d)return 'no-iframe-doc';const w=d.querySelector('.atk-human-box');const ed=d.querySelector('.atk-main-editor');const r=(e)=>{if(!e)return null;const b=e.getBoundingClientRect();return {x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height)};};const im=w?w.querySelector('.atk-human-img'):null;return {box:w?{cls:w.className,text:w.textContent.trim(),rect:r(w),imgW:im?im.naturalWidth:0}:null,editor:r(ed),hasHook:typeof f.contentWindow.__humanCheck};})())";
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?` + encodeURIComponent(URL_), { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (m, p = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method: m, params: p })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
// 等 iframe 里的评论页 + Artalk 编辑器渲染出来
for (let i = 0; i < 30; i++) {
const p = await ev(PROBE);
if (p && p.indexOf('"editor":{') >= 0) { console.log('— 评论区已就绪(' + (i * 0.5).toFixed(1) + 's)'); break; }
if (i === 29) console.log('— 评论区未就绪,最后探测:', p);
await sleep(500);
}
await sleep(4500); // 等页面自己的静默验证跑完,避免它的 drop 把我的控件带走
console.log('— 初始(正常读者):', await ev(PROBE));
let s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-none.png'), Buffer.from(s.result.data, 'base64'));
await ev("call('click')");
await sleep(700);
console.log('— 点一下状态:', await ev(PROBE));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-click.png'), Buffer.from(s.result.data, 'base64'));
await ev("call('captcha')");
await sleep(2200);
console.log('— 图形验证码状态:', await ev(PROBE));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
await ev("call('run')");
await sleep(5000);
console.log('— 真实验证后:', await ev(PROBE));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-run.png'), Buffer.from(s.result.data, 'base64'));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+66
View File
@@ -0,0 +1,66 @@
// 验证卡片评论的自愈逻辑:即使 Artalk 重渲染把 .atk-main 的内联 display 清掉,
// 只靠 class + !important 的 CSS 规则也仍然隐藏(不再把卡片挤下去)
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9377;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-selfheal-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', `--window-size=${process.argv[4] || '1280,900'}`, 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const holder = document.querySelector('.atk-fl-card-wrap');
const card = document.querySelector('.atk-fl-card');
const wrap = document.querySelector('.atk-comment-wrap.atk-fl-apply');
const firstCmt = document.querySelector('.atk-list-comments-wrap > .atk-comment-wrap');
const r = (el) => { if (!el) return null; const b = el.getBoundingClientRect(); return { x: Math.round(b.x), y: Math.round(b.y), w: Math.round(b.width), h: Math.round(b.height) }; };
return JSON.stringify({
holderParent: holder && holder.parentNode ? holder.parentNode.className : null,
holderIsFirstChild: holder && holder.parentNode ? holder.parentNode.firstElementChild === holder : null,
card: r(card),
cardDisplay: card ? getComputedStyle(card).display : null,
cardCount: document.querySelectorAll('.atk-fl-card').length,
cardBorderRadiusBtn: card ? getComputedStyle(card.querySelector('.atk-fl-btn')).borderRadius : null,
transformedWrapDisplay: wrap ? getComputedStyle(wrap).display : 'no transformed comment',
firstComment: r(firstCmt),
avatarDisplay: (() => { const a = card ? card.querySelector('.atk-fl-avatar') : null; return a ? getComputedStyle(a).display : null; })(),
cardFontSize: card ? getComputedStyle(card.querySelector('.atk-fl-desc')).fontSize : null,
cardLetterSpacing: card ? getComputedStyle(card.querySelector('.atk-fl-desc')).letterSpacing : null,
cardPadding: card ? getComputedStyle(card).padding : null,
cardGap: card ? getComputedStyle(card).gap : null,
cardBadge: (() => { const b = card ? card.querySelector('.atk-badge') : null; if (!b) return null; const cs = getComputedStyle(b); return { text: b.textContent.trim(), color: cs.color, background: cs.backgroundColor, h: b.offsetHeight, padding: cs.padding, display: cs.display, fontWeight: cs.fontWeight }; })(),
}, null, 1);
})()`;
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable');
await send('Page.enable');
await sleep(5000);
// 移动端评论可能是进入视口才加载,先滚到评论区
await send('Runtime.evaluate', { expression: `(() => { const el = document.querySelector('#Comments'); if (el) el.scrollIntoView({ block: 'center' }); return 'ok'; })()`, returnByValue: true });
await sleep(3000);
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(r.result.result.value);
if (process.argv[3]) {
const shot = await send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3], Buffer.from(shot.result.data, 'base64'));
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+53
View File
@@ -0,0 +1,53 @@
// 检查友圈卡片:背景头像效果(opacity)与头像地址是否正常
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9388;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/circles.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-circles-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,900', 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const items = [...document.querySelectorAll('.fc-item')];
const info = (el) => {
if (!el) return null;
return { src: (el.getAttribute('src') || '').slice(0, 80), opacity: getComputedStyle(el).opacity, natural: el.naturalWidth };
};
const first = items[0];
return JSON.stringify({
itemCount: items.length,
bgImage: first ? info(first.querySelector('.fc-bg-image')) : null,
avatar: first ? info(first.querySelector('.fc-avatar')) : null,
relativeSrcs: items.filter((i) => (i.querySelector('.fc-avatar') || {}).getAttribute
&& (i.querySelector('.fc-avatar').getAttribute('src') || '').charAt(0) === '/').length,
}, null, 1);
})()`;
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable'); await send('Page.enable');
await sleep(7000);
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(r.result.result.value);
if (process.argv[3]) {
const shot = await send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3], Buffer.from(shot.result.data, 'base64'));
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+143
View File
@@ -0,0 +1,143 @@
// 打开评论页,抓「加载失败的请求」+ 控制台错误 + Artalk 错误层文案
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9391;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-cmterr-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,900', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map(); const net = []; const consoleMsgs = [];
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); return; }
if (m.method === 'Network.responseReceived') {
const r = m.params.response;
if (r.status >= 400 || r.url.includes('/api/')) {
net.push(`HTTP ${r.status} ${r.url.slice(0, 120)}`);
}
}
if (m.method === 'Network.loadingFailed') {
net.push(`FAIL ${m.params.errorText} ${(m.params.blockedReason || '')} type=${m.params.type}`);
}
if (m.method === 'Runtime.consoleAPICalled') {
consoleMsgs.push(`[${m.params.type}] ` + (m.params.args || []).map((a) => a.value ?? a.description ?? a.type).join(' '));
}
if (m.method === 'Runtime.exceptionThrown') {
consoleMsgs.push('[exception] ' + (m.params.exceptionDetails?.exception?.description || m.params.exceptionDetails?.text || ''));
}
});
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Network.enable');
await send('Page.enable');
await send('Page.navigate', { url: URL_ });
await sleep(9000);
console.log('=== 网络(>=400 或 /api/) ===');
console.log(net.length ? [...new Set(net)].slice(0, 20).join('\n') : '(无)');
console.log('\n=== 控制台 ===');
console.log(consoleMsgs.length ? consoleMsgs.slice(0, 12).join('\n') : '(无)');
console.log('\n=== 卡片计算样式(代码级验证) ===');
console.log(await ev(`(() => {
const c = document.querySelector('.atk-err-card');
if (!c) return 'NO CARD';
const cs = getComputedStyle(c);
const ic = c.querySelector('.atk-err-ic');
const t = c.querySelector('.atk-err-t');
const d = c.querySelector('.atk-err-d');
const r = c.querySelector('.atk-err-retry');
const rect = c.getBoundingClientRect();
const center = Math.abs((rect.x + rect.width / 2) - window.innerWidth / 2);
return JSON.stringify({
textAlign: cs.textAlign,
padding: cs.padding,
border: cs.borderTopWidth + ' ' + cs.borderTopStyle,
background: cs.backgroundColor,
hasDetails: !!c.querySelector('details'),
hasBodyWrap: !!c.querySelector('.atk-err-body'),
icon: ic ? getComputedStyle(ic).display + '/' + getComputedStyle(ic).fontSize : null,
titleAlign: t ? getComputedStyle(t).textAlign : null,
descMaxW: d ? getComputedStyle(d).maxWidth : null,
retryRadius: r ? getComputedStyle(r).borderRadius : null,
cardCentered: center < 8 ? 'yes(off=' + Math.round(center) + 'px)' : 'no(off=' + Math.round(center) + 'px)'
}, null, 1);
})()`));
console.log('\n=== Artalk 错误层 ===');
console.log(await ev(`(() => {
const el = document.querySelector('.atk-error-layer');
if (!el) return '(没有错误层)';
const h = el.getBoundingClientRect();
return JSON.stringify({ text: el.innerText.replace(/\\n/g, ' | '), h: Math.round(h.height), html: el.innerHTML.slice(0, 500) }, null, 1);
})()`));
if (OUT) {
const s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
// 错误卡片特写(放大 2 倍,用文档坐标 + captureBeyondViewport 才准)
const rectRaw = await ev(`(() => {
const el = document.querySelector('.atk-err-card');
if (!el) return 'null';
const r = el.getBoundingClientRect();
return JSON.stringify({
x: Math.max(0, r.x + window.scrollX - 10),
y: Math.max(0, r.y + window.scrollY - 10),
width: r.width + 20,
height: r.height + 20
});
})()`);
if (rectRaw && rectRaw !== 'null') {
const clip = JSON.parse(rectRaw);
const s2 = await send('Page.captureScreenshot', {
format: 'png', captureBeyondViewport: true, clip: { ...clip, scale: 2 },
});
writeFileSync(OUT.replace(/\.png$/, '.card.png'), Buffer.from(s2.result.data, 'base64'));
}
}
// ── 点「重新加载」→ 应触发新的一轮 /api/v2 请求(验证移动节点后事件还在)
const countApi = () => net.filter((x) => x.includes('/api/v2/')).length;
const before = countApi();
const clicked = await ev(`(() => { const b = document.querySelector('.atk-err-retry'); if (b) b.click(); return !!b; })()`);
await sleep(4500);
const after = countApi();
console.log(`\n=== 点「重新加载」 ===`);
console.log(`按钮存在=${clicked} | /api/v2 请求数 ${before} → ${after} ${after > before ? '✓ 事件仍在' : '✗ 事件丢了'}`);
console.log('错误层现状:', await ev(`(() => {
const el = document.querySelector('.atk-error-layer');
if (!el) return '(已移除)';
const c = el.querySelector('.atk-err-card');
return c ? '仍是我们重写后的卡片(重试再次失败)' : 'Artalk 原生层:' + el.innerText.slice(0, 60);
})()`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+40
View File
@@ -0,0 +1,40 @@
// 验证 svc-down 状态下 .atk-copyright 是否隐藏
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9407;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-cp-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await sleep(9000);
console.log('copyright:', await ev(`JSON.stringify((() => { const c = document.querySelector('.atk-copyright'); return c ? { display: getComputedStyle(c).display, svcDown: document.body.classList.contains('atk-svc-down') } : '(未渲染)'; })())`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+74
View File
@@ -0,0 +1,74 @@
// 验证禁用态:置灰、全子元素禁点、点击弹 toast、UIKit 入场动画
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9403;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || '';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-edoff3-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
// 状态检查
console.log(await ev(`JSON.stringify({
bodySvcDown: document.body.classList.contains('atk-svc-down'),
editorOpacity: getComputedStyle(document.querySelector('.atk-main-editor')).opacity,
barAnim: document.getElementById('atk-editor-off-bar')?.className || null,
barText: document.getElementById('atk-editor-off-bar')?.textContent?.trim()?.slice(0, 40) || null,
humanHidden: (() => { const h = document.querySelector('.atk-human-box'); return h ? getComputedStyle(h).display === 'none' : '(未渲染)'; })(),
})`));
// 坐标级真实点击编辑器工具栏区域(表情/预览按钮所在的一排)
const pos = JSON.parse(await ev(`(() => {
const ed = document.querySelector('.atk-main-editor');
const r = ed.getBoundingClientRect();
return JSON.stringify({ x: Math.round(r.x + r.width / 2), y: Math.round(r.top + 14) });
})()`));
await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: pos.x, y: pos.y, button: 'left', clickCount: 1 });
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: pos.x, y: pos.y, button: 'left', clickCount: 1 });
await sleep(600);
console.log('点击后 toast:', await ev(`(() => {
const t = document.getElementById('atk-svc-toast');
return t ? { text: t.textContent.trim().slice(0, 40), visible: getComputedStyle(t).opacity !== '0' } : null;
})()`));
// 确认编辑器内没有任何输入被聚焦(真禁用)
console.log('焦点仍在 body:', await ev('document.activeElement === document.body || document.activeElement === document.documentElement'));
if (OUT) {
const s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+58
View File
@@ -0,0 +1,58 @@
// 量「重新加载按钮」与 footer border-top 的真实间距
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9409;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/ftgap.json';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-ft3-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
const out = await ev(`(() => {
const btn = document.querySelector('.atk-err-retry');
const ft = document.querySelector('.footer');
if (!btn || !ft) return JSON.stringify({ btn: !!btn, footer: !!ft });
const b = btn.getBoundingClientRect();
const f = ft.getBoundingClientRect();
return JSON.stringify({
btnBottom: Math.round(b.bottom + scrollY),
footerBorderTop: Math.round(f.top + scrollY),
gap: Math.round(f.top - b.bottom),
layerMargin: getComputedStyle(document.querySelector('.atk-error-layer')).margin
}, null, 1);
})()`);
writeFileSync(OUT, String(out));
ws.close();
} catch (e) {
writeFileSync(OUT, 'ERR ' + e.message);
} finally {
chrome.kill();
}
+71
View File
@@ -0,0 +1,71 @@
// 量错误卡片与下方 <hr> 的真实几何关系
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9395;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-hr-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,2000', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
console.log(await ev(`(() => {
const layer = document.querySelector('.atk-error-layer');
const card = document.querySelector('.atk-err-card');
const btn = document.querySelector('.atk-err-retry');
if (!card || !btn) return 'NO CARD';
const r = (el) => { const b = el.getBoundingClientRect(); return { top: Math.round(b.top + scrollY), bottom: Math.round(b.bottom + scrollY), h: Math.round(b.height) }; };
// 找卡片之后最近的 hr(含主题里的)
let hr = null, el = layer;
while (el && !hr) {
let s = el.nextElementSibling;
while (s) { if (s.tagName === 'HR') { hr = s; break; } s = s.nextElementSibling; }
el = el.parentElement;
}
const lr = r(layer), br = r(btn), cr = r(card);
const hrr = hr ? r(hr) : null;
const cs = getComputedStyle(layer);
return JSON.stringify({
layer: lr, card: cr, button: br,
hr: hrr,
hrInsideLayer: hr ? layer.contains(hr) : null,
layerMargin: cs.margin,
gapBtnToHr: (hrr && br) ? hrr.top - br.bottom : null,
overlap: (hrr && br) ? (br.bottom > hrr.top) : null,
layerParentCls: layer.parentElement ? (layer.parentElement.className || '').slice(0, 60) : null,
nextSiblings: (() => { const out = []; let s = layer.nextElementSibling; for (let i = 0; s && i < 4; i++) { out.push(s.tagName + '.' + String(s.className).slice(0, 30)); s = s.nextElementSibling; } return out; })(),
}, null, 1);
})()`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+69
View File
@@ -0,0 +1,69 @@
// 打开预览页 → 跑真实链路 → 依次预览三种控件状态并截图
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9383;
const URL_ = process.argv[2] || 'http://127.0.0.1:8791/';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/human-demo.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-demo-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=820,1400', 'about:blank'], { stdio: 'ignore' });
const PROBE_BOX = 'JSON.stringify((()=>{const w=document.querySelector(".atk-human-box");if(!w)return null;const b=w.getBoundingClientRect();const im=w.querySelector(".atk-human-img");return {cls:w.className,text:w.textContent.trim(),w:Math.round(b.width),h:Math.round(b.height),imgW:im?im.naturalWidth:0};})())';
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?` + encodeURIComponent(URL_), { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (m, p = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method: m, params: p })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(1500);
console.log('— 页面标题:', await ev('document.title'));
// 真实链路(浏览器真的算 PoW)
await ev('__demo.runReal()');
await sleep(7000);
console.log('— 真实链路日志:\n' + (await ev('document.getElementById("log").innerText')));
await ev('__demo.previewNone()');
await sleep(500);
let s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-none.png'), Buffer.from(s.result.data, 'base64'));
await ev('__demo.previewClick()');
await sleep(500);
console.log('— 点一下控件:', await ev(PROBE_BOX));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-click.png'), Buffer.from(s.result.data, 'base64'));
await ev('document.querySelector(".atk-human-box").click()');
await sleep(1400);
console.log('— 点击后:', await ev(PROBE_BOX));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-done.png'), Buffer.from(s.result.data, 'base64'));
await ev('__demo.previewCaptcha()');
await sleep(3000);
console.log('— 图形验证码:', await ev(PROBE_BOX));
s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+93
View File
@@ -0,0 +1,93 @@
// 人机验证前端控件自测:本地评论页加载后
// 1) 看静默流程结果(__humanCheck.state())
// 2) 手动触发「点一下验证」控件并截图
// 3) 手动触发「图形验证码」控件并截图
// 4) 收集控制台错误
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9381;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/human-widget.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-human-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1000', 'about:blank'], { stdio: 'ignore' });
const errs = [];
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data);
if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); }
if (m.method === 'Runtime.consoleAPICalled' && m.params.type === 'error') {
errs.push((m.params.args || []).map((a) => a.value || a.description || '').join(' ').slice(0, 160));
}
if (m.method === 'Runtime.exceptionThrown') {
errs.push('EXCEPTION ' + (m.params.exceptionDetails?.exception?.description || '').slice(0, 160));
}
});
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const evalJs = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(7000);
await evalJs(`(() => { const el = document.querySelector('#Comments') || document.querySelector('.comments-area'); if (el) el.scrollIntoView({ block: 'center' }); return 'ok'; })()`);
await sleep(2500);
console.log('— 钩子存在:', await evalJs('typeof window.__humanCheck'));
// 每 500ms 采一次状态,直到拿到通行证(用于量静默 PoW 的耗时)
const t0 = Date.now();
for (let i = 0; i < 40; i++) {
const st = await evalJs('JSON.stringify(window.__humanCheck ? window.__humanCheck.state() : null)');
if (st && st.indexOf('"hasPass":true') >= 0) {
console.log(`— 静默流程:${((Date.now() - t0) / 1000).toFixed(1)}s 后拿到通行证 ${st}`);
break;
}
if (i === 39) console.log('— 静默流程:60s 内未拿到通行证,最后状态', st);
await sleep(500);
}
console.log('— 是否已有控件:', await evalJs('document.querySelectorAll(".atk-human-box").length'));
// 手动触发「点一下验证」
await evalJs('window.__humanCheck && window.__humanCheck.drop(); window.__humanCheck && window.__humanCheck.showClick();');
await sleep(900);
console.log('— 点一下控件:', await evalJs('JSON.stringify((()=>{const w=document.querySelector(".atk-human-box");if(!w)return null;const b=w.getBoundingClientRect();return {cls:w.className,text:w.textContent.trim(),x:Math.round(b.x),y:Math.round(b.y),w:Math.round(b.width),h:Math.round(b.height)};})())'));
const shot1 = await send('Page.captureScreenshot', { format: 'png' });
// 点一下 → 应立即拿到通行证(复用缓存的 PoW 证明,不重算)
const tClick = Date.now();
await evalJs('document.querySelector(".atk-human-box") && document.querySelector(".atk-human-box").click(); "ok"');
let clicked;
for (let i = 0; i < 20; i++) {
clicked = await evalJs('JSON.stringify(window.__humanCheck.state())');
if (clicked && clicked.indexOf('"hasPass":true') >= 0) break;
await sleep(250);
}
console.log(`— 点击验证:${((Date.now() - tClick) / 1000).toFixed(2)}s → ${clicked}`);
writeFileSync(OUT.replace(/\.png$/, '-click.png'), Buffer.from(shot1.result.data, 'base64'));
// 手动触发「图形验证码」
await evalJs('window.__humanCheck && window.__humanCheck.drop(); window.__humanCheck && window.__humanCheck.showCaptcha();');
await sleep(1800);
console.log('— 图形验证码控件:', await evalJs('JSON.stringify((()=>{const w=document.querySelector(".atk-human-box");if(!w)return null;const img=w.querySelector(".atk-human-img");return {cls:w.className,imgLoaded:!!(img&&img.src&&img.naturalWidth>0),imgW:img?img.naturalWidth:0,text:w.textContent.trim()};})())'));
const shot2 = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT.replace(/\.png$/, '-captcha.png'), Buffer.from(shot2.result.data, 'base64'));
console.log('— 控制台错误:', errs.length ? errs.slice(0, 5) : '(无)');
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+67
View File
@@ -0,0 +1,67 @@
// 诊断:错误态下评论容器们的实际高度来源
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9411;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/.workbuddy/listdiag.json';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-lsd-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await send('Page.enable');
await sleep(9000);
const out = await ev(`(() => {
const pick = (sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const b = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
h: Math.round(b.height),
minH: cs.minHeight,
inline: (el.getAttribute('style') || '').slice(0, 80),
children: el.children.length,
};
};
return JSON.stringify({
list: pick('.atk-list'),
listBody: pick('.atk-list-body'),
commentsWrap: pick('.atk-list-comments-wrap'),
errorLayer: pick('.atk-error-layer'),
errCard: pick('.atk-err-card'),
editor: pick('.atk-main-editor'),
}, null, 1);
})()`);
writeFileSync(OUT, String(out));
ws.close();
} catch (e) {
writeFileSync(OUT, 'ERR ' + e.message);
} finally {
chrome.kill();
}
+58
View File
@@ -0,0 +1,58 @@
// 打开友链申请弹窗,测量各元素盒子与计算样式,定位错位/溢出原因
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9355;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-modal-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1000', 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const info = (sel) => {
const el = document.querySelector(sel);
if (!el) return { sel, missing: true };
const r = el.getBoundingClientRect();
const s = getComputedStyle(el);
return {
sel,
x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height),
display: s.display, width: s.width, minWidth: s.minWidth, maxWidth: s.maxWidth,
margin: s.margin, padding: s.padding, float: s.float, textAlign: s.textAlign,
boxSizing: s.boxSizing, position: s.position, overflowX: s.overflowX,
scrollW: el.scrollWidth, clientW: el.clientWidth,
};
};
return JSON.stringify([
info('#flApplyMask'), info('#flApplyBox'), info('#flName'), info('#flDesc'), info('#flSubmit'),
], null, 1);
})()`;
try {
for (let i = 0; i < 40; i++) { try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {} await sleep(300); }
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable');
await send('Page.enable');
await sleep(5000);
await send('Runtime.evaluate', { expression: `(window.flApplyOpenModal && window.flApplyOpenModal(), 'opened')`, returnByValue: true });
await sleep(600);
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(r.result.result.value);
const shot = await send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3] || 'modal.png', Buffer.from(shot.result.data, 'base64'));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+48
View File
@@ -0,0 +1,48 @@
// 采样检查卡片打字机是否真的在逐字打出
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9344;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-type-'));
const chrome = spawn(CHROME, ['--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,900', 'about:blank'], { stdio: 'ignore' });
const PROBE = `(() => {
const el = document.querySelector('.atk-fl-typed');
const caret = document.querySelector('.atk-fl-caret');
return JSON.stringify({
reduce: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
len: el ? (el.textContent || '').length : -1,
caretShown: caret ? getComputedStyle(caret).display !== 'none' : null,
cardConnected: !!(document.querySelector('.atk-fl-card') && document.querySelector('.atk-fl-card').isConnected),
});
})()`;
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch {}
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
await send('Runtime.enable');
for (let t = 0; t < 26; t++) {
const r = await send('Runtime.evaluate', { expression: PROBE, returnByValue: true });
console.log(String(t * 200).padStart(5) + 'ms', r.result && r.result.result ? r.result.result.value : r);
await sleep(200);
}
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env bash
# ===========================================================================
# artalk-cf 一键部署(第 2~8 步)
#
# 前置:已经跑过 `npx wrangler login`
# 用法:
# export ADMIN_PASSWORD='你的后台密码'
# bash tools/deploy.sh
#
# 可选环境变量:
# TOKEN_SECRET 不传则自动生成随机串
# DB_NAME 默认 artalk-cf
# SKIP_IMPORT=1 跳过数据导入
# ===========================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
DB_NAME="${DB_NAME:-artalk-cf}"
WR="node node_modules/wrangler/bin/wrangler.js"
if [ ! -f node_modules/wrangler/package.json ]; then
echo "缺少依赖,先跑:npm install" >&2
exit 1
fi
if [ -z "${ADMIN_PASSWORD:-}" ]; then
echo "请先设置管理员密码:export ADMIN_PASSWORD='...'" >&2
exit 1
fi
echo "==> 1/8 检查登录状态"
# 兼容三种情况:OAuth 登录、作用域 Token(cfut_)、账户 Token(cfat_)
WHOAMI=$($WR whoami 2>&1 || true)
if ! printf '%s' "$WHOAMI" | grep -qiE 'associated with the email|Account Name|Account ID|accounts'; then
echo "$WHOAMI" | tail -5
echo
echo "没检测到可用的凭据。二选一:" >&2
echo " a) 交互登录: npx wrangler login" >&2
echo " b) 用作用域 Token: export CLOUDFLARE_API_TOKEN='cfut_...'(可再加 CLOUDFLARE_ACCOUNT_ID)" >&2
exit 1
fi
echo "$WHOAMI" | grep -iE 'Account Name|Account ID|associated with' | head -4 | sed 's/^/ /'
echo "==> 2/8 创建 D1 数据库(已存在则复用)"
DB_ID=$($WR d1 list --json 2>/dev/null \
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{try{const a=JSON.parse(s);const m=a.find(x=>x.name===process.argv[1]);if(m)console.log(m.uuid)}catch{}})" "$DB_NAME")
if [ -z "$DB_ID" ]; then
OUT=$($WR d1 create "$DB_NAME" 2>&1)
echo "$OUT"
DB_ID=$(printf '%s' "$OUT" | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}' | head -1)
fi
if [ -z "$DB_ID" ]; then
echo "拿不到 database_id,请手动去控制台复制后填进 wrangler.toml" >&2
exit 1
fi
echo " database_id = $DB_ID"
echo "==> 3/8 写入 wrangler.toml"
node -e "
const fs=require('fs');
const p='wrangler.toml';
let s=fs.readFileSync(p,'utf8');
const before=s;
s=s.replace(/database_id\s*=\s*\"[^\"]*\"/, 'database_id = \"' + process.argv[1] + '\"');
s=s.replace(/database_name\s*=\s*\"[^\"]*\"/, 'database_name = \"' + process.argv[2] + '\"');
if(s===before){console.error('wrangler.toml 里没找到 database_id,请手动填');process.exit(1);}
fs.writeFileSync(p,s);
console.log(' ok');
" "$DB_ID" "$DB_NAME"
echo "==> 4/8 写入机密"
TOKEN_SECRET="${TOKEN_SECRET:-$(node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))")}"
printf '%s' "$TOKEN_SECRET" | $WR secret put TOKEN_SECRET
printf '%s' "$ADMIN_PASSWORD" | $WR secret put ADMIN_PASSWORD
echo "==> 5/8 建表"
$WR d1 execute "$DB_NAME" --remote -y --file=./schema.sql
echo "==> 6/8 部署"
DEPLOY_OUT=$($WR deploy 2>&1 | tee /dev/stderr)
BASE=$(printf '%s' "$DEPLOY_OUT" \
| grep -oE 'https://[A-Za-z0-9._-]+\.workers\.dev' | head -1)
if [ -z "$BASE" ]; then
read -r -p "请手动粘贴部署输出里的地址(如 https://artalk-cf.xxx.workers.dev): " BASE
fi
BASE="${BASE%/}"
echo " BASE = $BASE"
echo "==> 7/8 导入评论数据(必须在初始化管理员之前——导入会清空 users 表)"
if [ "${SKIP_IMPORT:-}" != "1" ]; then
if command -v python >/dev/null 2>&1; then
python tools/import_artrans.py
else
python3 tools/import_artrans.py
fi
bash tools/import-out/run.sh --remote
else
echo " (SKIP_IMPORT=1,跳过)"
fi
echo "==> 8/8 初始化站点与管理员"
curl -sS -X POST "$BASE/api/v2/setup" -H 'Content-Type: application/json' -d '{}'
echo
echo
echo "============================================================"
echo " 完成。接下来:"
echo " 1) 验证:bash tools/smoke.sh $BASE"
echo " 2) 域名:见 部署清单.md 第 9 步(NS 必须在 Cloudflare)"
echo " 3) 切博客:hugo.toml 的 [params.artalk] server = $BASE"
echo "============================================================"
+22
View File
@@ -0,0 +1,22 @@
"""把 Artalk 的配置模板 YAML 打包成 TS 常量,供设置页使用。"""
import json
import pathlib
SRC = pathlib.Path(r"C:\Users\QunLIn\WorkBuddy\2026-10-02-15-13-24\Artalk-master\conf\artalk.example.zh-CN.yml")
OUT = pathlib.Path(r"C:\Users\QunLIn\WorkBuddy\2026-10-02-15-13-24\artalk-cf\src\template.generated.ts")
text = SRC.read_text(encoding="utf-8")
# 用 JSON 字面量转义,最省事也最安全
literal = json.dumps(text, ensure_ascii=False)
OUT.write_text(
"// 由 tools/gen_template.py 生成,请勿手改。\n"
"// 来源:Artalk 仓库 conf/artalk.example.zh-CN.yml\n"
"// 作用:/settings/template/:locale 下发,供官方后台的设置页渲染表单。\n\n"
f"export const SETTINGS_TEMPLATE_ZH_CN = {literal};\n",
encoding="utf-8",
newline="\n",
)
print("写出:", OUT)
print("模板字符数:", len(text), " 生成文件字节:", OUT.stat().st_size)
+46
View File
@@ -0,0 +1,46 @@
// 人机验证预览页的本地服务:静态托管 demo + 把 /api/* 同源转发到线上 Worker。
// 为什么要转发:Worker 的 CORS 白名单只放行自己的域名,file:// 或本地预览源的
// 跨域请求会被浏览器拦掉;同源就不存在这个问题,预览也能跑真实链路。
import { readFileSync } from 'node:fs';
import { createServer } from 'node:http';
const PORT = Number(process.argv[2] || 8791);
const FILE = process.argv[3] || 'C:/Users/QunLIn/WorkBuddy/2026-10-02-15-13-24/human-check-demo.html';
const UPSTREAM = 'https://api.200181.xyz';
const server = createServer(async (req, res) => {
if (req.url.startsWith('/api/')) {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks);
try {
const up = await fetch(UPSTREAM + req.url, {
method: req.method,
headers: {
'Content-Type': req.headers['content-type'] || 'application/json',
'User-Agent': 'human-demo-proxy/1.0',
},
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
});
const buf = Buffer.from(await up.arrayBuffer());
res.writeHead(up.status, { 'Content-Type': up.headers.get('content-type') || 'application/json' });
res.end(buf);
} catch (e) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'upstream failed: ' + e.message }));
}
return;
}
try {
const html = readFileSync(FILE);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (e) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('demo file not found: ' + FILE);
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log('人机验证预览: http://127.0.0.1:' + PORT + '/');
});
+319
View File
@@ -0,0 +1,319 @@
"""
把 Artalk 导出的 .artrans 转成可直接灌进 Cloudflare D1 的 SQL。
为什么不用 Worker 的 /transfer/import:
3440 条评论逐条 INSERT 会产生 3440 次 D1 往返,Worker 免费版单请求 CPU 只有 10ms,
很容易直接超时。批量导入走 wrangler d1 execute --file 最稳、最快。
用法:
python tools/import_artrans.py # 默认只导「优世界」
python tools/import_artrans.py --site 优世界 --src <path.artrans> --out out/
# 然后:
# npx wrangler d1 execute artalk-cf --remote --file=out/import-01-users.sql
# ... 按生成的 run.sh 顺序执行
"""
import argparse
import json
import pathlib
import sys
from collections import OrderedDict
DEFAULT_SRC = "" # 不写死机器路径;用 --src <path.artrans> 显式传入
DEFAULT_SITE = "优世界"
DEFAULT_SITE_URL = "https://usj.cc"
CHUNK = 400 # 每个 SQL 文件最多多少条语句,避免单次 execute 过大
def sql_str(v) -> str:
if v is None:
return "NULL"
s = str(v)
return "'" + s.replace("'", "''") + "'"
def sql_bool(v) -> str:
return "1" if str(v).lower() in ("true", "1", "yes") else "0"
def sql_int(v, dft=0) -> str:
try:
return str(int(str(v).strip()))
except Exception:
return str(dft)
def parse_dt(s: str) -> int:
"""'2021-09-11 20:33:56 +0800 +0800' -> unix 毫秒(+0800)"""
s = (s or "").strip()
if len(s) < 19:
return 0
try:
import datetime as dt
naive = dt.datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
aware = naive.replace(tzinfo=dt.timezone(dt.timedelta(hours=8)))
ms = int(aware.timestamp() * 1000)
return ms if ms > 0 else 0
except Exception:
return 0
def write_chunks(out_dir: pathlib.Path, name: str, stmts: list) -> list:
"""把语句按 CHUNK 切成多个 .sql,返回生成的文件名列表"""
files = []
for i in range(0, len(stmts), CHUNK):
part = stmts[i : i + CHUNK]
fn = f"{name}-{i // CHUNK + 1:03d}.sql"
(out_dir / fn).write_text("\n".join(part) + "\n", encoding="utf-8", newline="\n")
files.append(fn)
return files
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--src", default=DEFAULT_SRC)
ap.add_argument("--site", default=DEFAULT_SITE, help="只导这个站点名,逗号分隔可多个;all = 全导")
ap.add_argument("--site-url", default=DEFAULT_SITE_URL)
ap.add_argument("--out", default="tools/import-out")
ap.add_argument("--db", default="artalk-cf", help="wrangler d1 的数据库名")
args = ap.parse_args()
src = pathlib.Path(args.src)
if not args.src:
print("请用 --src <path.artrans> 指定导出的 .artrans 文件路径", file=sys.stderr)
return 1
if not src.exists():
print(f"找不到源文件: {src}", file=sys.stderr)
return 1
data = json.loads(src.read_text(encoding="utf-8"))
want = None if args.site.strip().lower() == "all" else [s.strip() for s in args.site.split(",") if s.strip()]
site_name = want[0] if want else DEFAULT_SITE
items = [c for c in data if (want is None or c.get("site_name") in want)]
print(f"源文件 {len(data)} 条,按站点筛选后 {len(items)} 条 -> 归入站点「{site_name}」")
if not items:
print("筛选后为空,检查 --site", file=sys.stderr)
return 1
out_dir = pathlib.Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
# ---------------- 站点
head = [
"-- =====================================================================",
"-- ⚠️ 这个文件会先清空 comments / pages / users / sites 四张表。",
"-- 只在**首次导入到全新数据库**时执行;库里已有数据请勿再跑。",
"-- =====================================================================",
"DELETE FROM comments;",
"DELETE FROM pages;",
"DELETE FROM users;",
"DELETE FROM sites;",
f"INSERT INTO sites (id, name, urls, created_at, updated_at) "
f"VALUES (1, {sql_str(site_name)}, {sql_str(args.site_url)}, "
f"{parse_dt(max((c.get('created_at') or '') for c in items)) or 0}, 0);",
]
# ---------------- 用户(按 nick+email 去重)
users = OrderedDict()
for c in items:
nick = (c.get("nick") or "").strip()
email = (c.get("email") or "").strip()
if not nick or not email:
continue
users.setdefault(f"{nick}\u0000{email}", {"nick": nick, "email": email, "link": c.get("link") or "",
"badge_name": c.get("badge_name") or "",
"badge_color": c.get("badge_color") or ""})
user_id = {}
user_stmts = ["-- 用户(原数据没有注册账号,全部按 昵称+邮箱 去重重建)"]
for idx, (key, u) in enumerate(users.items(), start=1):
user_id[key] = idx
ts = next((parse_dt(c.get("created_at")) for c in items
if (c.get("nick") or "").strip() == u["nick"] and (c.get("email") or "").strip() == u["email"]), 0)
user_stmts.append(
"INSERT INTO users (id, name, email, link, badge_name, badge_color, receive_email, created_at, updated_at) "
f"VALUES ({idx}, {sql_str(u['nick'])}, {sql_str(u['email'])}, {sql_str(u['link'])}, "
f"{sql_str(u['badge_name'])}, {sql_str(u['badge_color'])}, 1, {ts or 0}, {ts or 0});"
)
print(f"用户 {len(users)} 个")
# ---------------- 页面
pages = OrderedDict()
for c in items:
key = (c.get("page_key") or "").strip()
if not key:
continue
pages.setdefault(key, {"title": c.get("page_title") or "", "admin_only": c.get("page_admin_only"),
"created_at": parse_dt(c.get("created_at"))})
page_id = {}
page_stmts = ["-- 页面"]
for idx, (key, p) in enumerate(pages.items(), start=1):
page_id[key] = idx
page_stmts.append(
"INSERT INTO pages (id, key, site_name, title, admin_only, created_at, updated_at) "
f"VALUES ({idx}, {sql_str(key)}, {sql_str(site_name)}, {sql_str(p['title'])}, "
f"{sql_bool(p['admin_only'])}, {p['created_at'] or 0}, {p['created_at'] or 0});"
)
print(f"页面 {len(pages)} 个")
# ---------------- 评论(保留原始 id,保证 rid 关系不变)
rid_of = {}
rows = []
skip_no_id = skip_no_page = skip_no_user = 0
for c in items:
try:
cid = int(str(c.get("id")))
except Exception:
skip_no_id += 1
continue
try:
rid = int(str(c.get("rid") or 0))
except Exception:
rid = 0
key = f"{(c.get('nick') or '').strip()}\u0000{(c.get('email') or '').strip()}"
pk = (c.get("page_key") or "").strip()
if not pk:
skip_no_page += 1
continue
if key not in user_id:
skip_no_user += 1
continue
rid_of[cid] = rid
rows.append({
"id": cid, "rid": rid, "uid": user_id[key], "pid": page_id[pk], "page_key": pk,
"content": c.get("content") or "",
"is_collapsed": c.get("is_collapsed"), "is_pending": c.get("is_pending"),
"is_pinned": c.get("is_pinned"), "vote_up": c.get("vote_up"), "vote_down": c.get("vote_down"),
"ua": c.get("ua") or "", "ip": c.get("ip") or "",
"created_at": parse_dt(c.get("created_at")), "updated_at": parse_dt(c.get("updated_at")),
})
# 只保留指向本批次内评论的 rid,避免指到没导进来的兄弟站点评论
ids = {r["id"] for r in rows}
for r in rows:
if r["rid"] and r["rid"] not in ids:
r["rid"] = 0
# 计算 root(沿 rid 链上溯到「rid==0 的那条顶层评论」)
#
# ★ 这里必须返回顶层评论自身的 id,而不是 0。
# 对应官方 dao.FindCommentRootID:
# for rootId != 0 { if comment.Rid == 0 { return rootId }; rootId = comment.Rid }
# 即「父链上第一个 rid 为 0 的那条评论」。
# 注意语义是:顶层评论 root_id=0,它的所有子孙 root_id=该顶层评论的 id。
root_cache = {}
def root_of(cid: int) -> int:
if cid in root_cache:
return root_cache[cid]
chain = []
cur = cid
seen = set()
while cur and cur not in root_cache and cur not in seen:
seen.add(cur)
parent = rid_of.get(cur, 0)
if parent == 0:
break # cur 就是顶层评论
chain.append(cur)
cur = parent
root = root_cache.get(cur, cur)
for n in chain:
root_cache[n] = root
root_cache[root] = root
return root
# 先全部插入成顶层,再统一回填 rid / root_id —— 与导入顺序无关
ins, fix = [], []
for r in rows:
# is_verified 在 .artrans 里没有这个字段(官方导出不带)。
# 前端只在「没有徽章」时才把 is_verified 渲染成小勾,所以这里统一给 0:
# 有徽章的 328 条(博主)照样显示徽章,其余 3000+ 匿名评论不会多出勾。
ins.append(
"INSERT INTO comments (id, content, page_key, site_name, user_id, rid, root_id, "
"is_collapsed, is_pending, is_pinned, is_verified, vote_up, vote_down, ua, ip, created_at, updated_at) "
f"VALUES ({r['id']}, {sql_str(r['content'])}, {sql_str(r['page_key'])}, {sql_str(site_name)}, "
f"{r['uid']}, 0, 0, {sql_bool(r['is_collapsed'])}, {sql_bool(r['is_pending'])}, {sql_bool(r['is_pinned'])}, "
f"0, {sql_int(r['vote_up'])}, {sql_int(r['vote_down'])}, {sql_str(r['ua'])}, {sql_str(r['ip'])}, "
f"{r['created_at']}, {r['updated_at'] or r['created_at']});"
)
if r["rid"]:
fix.append(f"UPDATE comments SET rid = {r['rid']}, root_id = {root_of(r['rid'])} WHERE id = {r['id']};")
print(f"评论 {len(rows)} 条(其中回复 {len(fix)} 条)")
if skip_no_page or skip_no_user or skip_no_id:
print(
f"跳过 {skip_no_page + skip_no_user + skip_no_id} 条:"
f"page_key 为空 {skip_no_page}、昵称或邮箱为空 {skip_no_user}、id 非法 {skip_no_id}"
)
if skip_no_page:
print(" ↑ page_key 为空的评论在官方 Artalk 里同样无法挂到任何页面,属于历史脏数据")
# ---------------- 落盘
files = []
(out_dir / "00-reset-and-site.sql").write_text("\n".join(head) + "\n", encoding="utf-8", newline="\n")
files.append("00-reset-and-site.sql")
files += write_chunks(out_dir, "01-users", user_stmts)
files += write_chunks(out_dir, "02-pages", page_stmts)
files += write_chunks(out_dir, "03-comments", ins)
files += write_chunks(out_dir, "04-rid-fix", fix)
# 为了让 wrangler d1 execute 的 SQLite 序列号往后走
tail = ["-- 让 AUTOINCREMENT 从最大值之后继续", f"UPDATE sqlite_sequence SET seq = "
f"(SELECT MAX(id) FROM comments) WHERE name = 'comments';",
f"UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM users) WHERE name = 'users';",
f"UPDATE sqlite_sequence SET seq = (SELECT MAX(id) FROM pages) WHERE name = 'pages';",
"-- 自检",
"SELECT 'sites' AS t, COUNT(*) AS n FROM sites",
"UNION ALL SELECT 'pages', COUNT(*) FROM pages",
"UNION ALL SELECT 'users', COUNT(*) FROM users",
"UNION ALL SELECT 'comments', COUNT(*) FROM comments",
"UNION ALL SELECT 'replies', COUNT(*) FROM comments WHERE rid != 0;"]
(out_dir / "99-post.sql").write_text("\n".join(tail) + "\n", encoding="utf-8", newline="\n")
files.append("99-post.sql")
# 顺序执行脚本
# -y 必须加:remote 执行会先问一次确认,非交互环境下不加会直接报错退出
# 每个文件重试 3 次:跨境的 fetch 偶发失败(实测在 03-comments-007 断过一次)
lines = [
"#!/usr/bin/env bash",
"set -uo pipefail",
f"DB={args.db}",
"MODE=${1:---remote}",
"export WRANGLER_SEND_METRICS=false",
"WR=\"node node_modules/wrangler/bin/wrangler.js\"",
'[ -f "node_modules/wrangler/bin/wrangler.js" ] || WR="npx wrangler"',
"",
"FAILED=0",
]
for f in files:
lines.append(f'echo "==> {f}"')
lines.append("ok=0")
lines.append("for try in 1 2 3; do")
lines.append(f' if $WR d1 execute "$DB" "$MODE" -y --file={out_dir.as_posix()}/{f} >/dev/null 2>&1; then ok=1; break; fi')
lines.append(' echo " 第 $try 次失败,重试(跨境 fetch 偶发失败)"')
lines.append(" sleep 3")
lines.append("done")
lines.append('if [ "$ok" = "1" ]; then echo " ok"; else echo " FAIL"; FAILED=1; fi')
lines.append("")
lines.append('echo "核对条数:"')
lines.append(
f'$WR d1 execute "$DB" "$MODE" -y --command '
'"SELECT (SELECT COUNT(*) FROM sites) AS sites, (SELECT COUNT(*) FROM pages) AS pages,'
' (SELECT COUNT(*) FROM users) AS users, (SELECT COUNT(*) FROM comments) AS comments,'
' (SELECT COUNT(*) FROM comments WHERE rid!=0) AS replies;"'
)
lines.append("")
lines.append('[ "$FAILED" = "0" ] && echo "全部导入成功" || echo "有文件失败,看上面 FAIL 那几行"')
(out_dir / "run.sh").write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
print(f"\n生成 {len(files)} 个 SQL 于 {out_dir}")
print(f"顺序执行: bash {out_dir.as_posix()}/run.sh --remote")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+388
View File
@@ -0,0 +1,388 @@
"""
增量导入 .artrans —— 只插库里没有的评论/用户/页面,不动已有数据。
和 import_artrans.py 的区别:
import_artrans.py 首次全量导入,头部是 DELETE FROM comments/pages/users/sites(破坏性)
import_delta.py 增量:先从远端 D1 拉现状(已有 comment id / user / page),
再只生成「差集」的 INSERT,rid / root_id 依旧按全量 rid 链计算,
所以新回复挂到已有父评论上时 root_id 也是对的。
用法:
python tools/import_delta.py --src <path.artrans> --site 优世界 # 只生成 SQL,打印计划
python tools/import_delta.py --src <path.artrans> --site 优世界 --apply # 生成并执行
依赖环境变量 CLOUDFLARE_API_TOKEN(wrangler 用)。
"""
import argparse
import json
import os
import pathlib
import subprocess
import sys
import time
from collections import OrderedDict
DEFAULT_SITE = "优世界"
DEFAULT_SITE_URL = "https://usj.cc"
CHUNK = 400
ROOT = pathlib.Path(__file__).resolve().parent.parent
WRANGLER = ROOT / "node_modules" / "wrangler" / "bin" / "wrangler.js"
# ----------------------------------------------------------------- SQL helpers
def sql_str(v) -> str:
if v is None:
return "NULL"
return "'" + str(v).replace("'", "''") + "'"
def sql_bool(v) -> str:
return "1" if str(v).lower() in ("true", "1", "yes") else "0"
def sql_int(v, dft=0) -> str:
try:
return str(int(str(v).strip()))
except Exception:
return str(dft)
def parse_dt(s) -> int:
"""'2021-09-11 20:33:56 +0800 +0800' -> unix 毫秒"""
s = (s or "").strip()
if len(s) < 19:
return 0
try:
import datetime as dt
naive = dt.datetime.strptime(s[:19], "%Y-%m-%d %H:%M:%S")
aware = naive.replace(tzinfo=dt.timezone(dt.timedelta(hours=8)))
return int(aware.timestamp() * 1000)
except Exception:
return 0
# ----------------------------------------------------------------- 远端现状
def wrangler(args: list, tries: int = 3):
cmd = ["node", str(WRANGLER)] + args
last = None
for i in range(tries):
try:
p = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, timeout=600)
if p.returncode == 0:
return p.stdout
last = (p.stdout or "") + (p.stderr or "")
except Exception as e: # noqa: BLE001
last = str(e)
print(f" wrangler 第 {i + 1} 次失败,3 秒后重试", file=sys.stderr)
time.sleep(3)
raise RuntimeError(f"wrangler 失败:{(last or '')[:400]}")
def parse_json_out(s: str):
"""wrangler --json 前面可能插一行代理告警,从第一个 [ 或 { 开始截"""
i = min([x for x in (s.find("["), s.find("{")) if x >= 0] or [0])
return json.loads(s[i:])
def fetch_state(db: str) -> dict:
sql = (
"SELECT id FROM comments;"
"SELECT id, name, email FROM users;"
"SELECT id, key FROM pages;"
"SELECT (SELECT IFNULL(MAX(id),0) FROM comments) mc,"
" (SELECT IFNULL(MAX(id),0) FROM users) mu,"
" (SELECT IFNULL(MAX(id),0) FROM pages) mp;"
)
out = wrangler(["d1", "execute", db, "--remote", "-y", "--command", sql, "--json"])
j = parse_json_out(out)
comment_ids = {int(r["id"]) for r in j[0]["results"]}
users = {(r["name"], r["email"]): int(r["id"]) for r in j[1]["results"]}
pages = {r["key"]: int(r["id"]) for r in j[2]["results"]}
mx = j[3]["results"][0]
return {
"comment_ids": comment_ids,
"users": users,
"pages": pages,
"max_comment": int(mx["mc"]),
"max_user": int(mx["mu"]),
"max_page": int(mx["mp"]),
}
# ----------------------------------------------------------------- 主流程
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--src", required=True)
ap.add_argument("--site", default=DEFAULT_SITE, help="逗号分隔;all = 全导")
ap.add_argument("--site-url", default=DEFAULT_SITE_URL)
ap.add_argument("--out", default="tools/delta-out")
ap.add_argument("--db", default="artalk-cf")
ap.add_argument("--apply", action="store_true", help="生成后直接执行到远端")
args = ap.parse_args()
src = pathlib.Path(args.src)
if not src.exists():
print(f"找不到源文件: {src}", file=sys.stderr)
return 1
data = json.loads(src.read_text(encoding="utf-8"))
want = (
None
if args.site.strip().lower() == "all"
else [s.strip() for s in args.site.split(",") if s.strip()]
)
items = [c for c in data if want is None or c.get("site_name") in want]
site_name = (want[0] if want else DEFAULT_SITE) or DEFAULT_SITE
print(f"源文件 {len(data)} 条 → 站点「{site_name}」{len(items)} 条")
print("拉取远端现状 …")
st = fetch_state(args.db)
print(
f" 现状:comments {len(st['comment_ids'])}、users {len(st['users'])}、"
f"pages {len(st['pages'])}"
)
# ---------- 用户差集
# 按 (昵称,邮箱) 认人;但如果邮箱在库里已存在(昵称变了的情况,比如"小赵同学"→"小赵"),
# 复用老用户,避免同一个博主被拆成两个账号(新的那条也就不会继承 is_admin / 徽章)。
user_id = dict(st["users"]) # (name,email) -> id
by_email = {}
for (n, e), i in st["users"].items():
by_email.setdefault(e, i)
next_uid = st["max_user"] + 1
new_users = OrderedDict()
for c in items:
nick = (c.get("nick") or "").strip()
email = (c.get("email") or "").strip()
if not nick or not email:
continue
k = (nick, email)
if k in user_id:
continue
if email in by_email:
user_id[k] = by_email[email] # 同邮箱复用老 id
continue
if k in new_users:
continue
new_users[k] = {
"id": next_uid,
"nick": nick,
"email": email,
"link": c.get("link") or "",
"badge_name": c.get("badge_name") or "",
"badge_color": c.get("badge_color") or "",
"ts": parse_dt(c.get("created_at")),
}
user_id[k] = next_uid
by_email[email] = next_uid
next_uid += 1
# ---------- 页面差集
page_id = dict(st["pages"])
next_pid = st["max_page"] + 1
new_pages = OrderedDict()
for c in items:
key = (c.get("page_key") or "").strip()
if not key or key in page_id:
continue
new_pages[key] = {
"id": next_pid,
"title": c.get("page_title") or "",
"admin_only": c.get("page_admin_only"),
"ts": parse_dt(c.get("created_at")),
}
page_id[key] = next_pid
next_pid += 1
# ---------- 评论差集(保留原始 id,rid 关系才不用重算)
rows = []
rid_of = {}
have = st["comment_ids"]
dup = 0
for c in items:
try:
cid = int(str(c.get("id")))
except Exception:
continue
try:
rid = int(str(c.get("rid") or 0))
except Exception:
rid = 0
rid_of[cid] = rid
pk = (c.get("page_key") or "").strip()
if not pk or pk not in page_id:
continue
uk = ((c.get("nick") or "").strip(), (c.get("email") or "").strip())
if uk not in user_id:
continue
if cid in have:
dup += 1
continue
have.add(cid)
rows.append(
{
"id": cid,
"rid": rid,
"uid": user_id[uk],
"pid": page_id[pk],
"page_key": pk,
"content": c.get("content") or "",
"is_collapsed": c.get("is_collapsed"),
"is_pending": c.get("is_pending"),
"is_pinned": c.get("is_pinned"),
"vote_up": c.get("vote_up"),
"vote_down": c.get("vote_down"),
"ua": c.get("ua") or "",
"ip": c.get("ip") or "",
"created_at": parse_dt(c.get("created_at")),
"updated_at": parse_dt(c.get("updated_at")),
}
)
# root_id:沿 rid 链上溯。rid_of 来自**全量导出**,所以父评论在库里也能算对
root_cache = {}
def root_of(cid: int) -> int:
if cid in root_cache:
return root_cache[cid]
chain = []
cur = cid
seen = set()
while cur and cur not in root_cache and cur not in seen:
seen.add(cur)
parent = rid_of.get(cur, 0)
if parent == 0:
break # cur 就是顶层评论
chain.append(cur)
cur = parent
root = root_cache.get(cur, cur)
for n in chain:
root_cache[n] = root
root_cache[root] = root
return root
print(
f"\n差集:新用户 {len(new_users)}、新页面 {len(new_pages)}、新评论 {len(rows)}"
f"(导出里已有 {dup} 条跳过)"
)
if not rows and not new_users and not new_pages:
print("库里已经是最新,无需导入。")
return 0
out_dir = ROOT / args.out
out_dir.mkdir(parents=True, exist_ok=True)
for old in out_dir.glob("*.sql"):
old.unlink()
files = []
def dump(name: str, stmts: list):
for i in range(0, len(stmts), CHUNK):
fn = f"{name}-{i // CHUNK + 1:03d}.sql"
(out_dir / fn).write_text(
"\n".join(stmts[i : i + CHUNK]) + "\n", encoding="utf-8", newline="\n"
)
files.append(fn)
if new_users:
us = ["-- 新增用户"]
for u in new_users.values():
us.append(
"INSERT INTO users (id, name, email, link, badge_name, badge_color, receive_email, created_at, updated_at) "
f"VALUES ({u['id']}, {sql_str(u['nick'])}, {sql_str(u['email'])}, {sql_str(u['link'])}, "
f"{sql_str(u['badge_name'])}, {sql_str(u['badge_color'])}, 1, {u['ts']}, {u['ts']});"
)
dump("01-users", us)
if new_pages:
ps = ["-- 新增页面"]
for key, p in new_pages.items():
ps.append(
"INSERT INTO pages (id, key, site_name, title, admin_only, created_at, updated_at) "
f"VALUES ({p['id']}, {sql_str(key)}, {sql_str(site_name)}, "
f"{sql_str(p['title'])}, {sql_bool(p['admin_only'])}, {p['ts']}, {p['ts']});"
)
dump("02-pages", ps)
if rows:
ins = ["-- 新增评论(先全部按顶层插入,再回填 rid / root_id)"]
fix = []
for r in rows:
ins.append(
"INSERT INTO comments (id, content, page_key, site_name, user_id, rid, root_id, "
"is_collapsed, is_pending, is_pinned, is_verified, vote_up, vote_down, ua, ip, created_at, updated_at) "
f"VALUES ({r['id']}, {sql_str(r['content'])}, {sql_str(r['page_key'])}, {sql_str(site_name)}, "
f"{r['uid']}, 0, 0, {sql_bool(r['is_collapsed'])}, {sql_bool(r['is_pending'])}, {sql_bool(r['is_pinned'])}, "
f"0, {sql_int(r['vote_up'])}, {sql_int(r['vote_down'])}, {sql_str(r['ua'])}, {sql_str(r['ip'])}, "
f"{r['created_at']}, {r['updated_at'] or r['created_at']});"
)
if r["rid"]:
fix.append(
f"UPDATE comments SET rid = {r['rid']}, root_id = {root_of(r['rid'])} WHERE id = {r['id']};"
)
dump("03-comments", ins)
if fix:
dump("04-rid-fix", fix)
check = [
"-- 核对",
"SELECT (SELECT COUNT(*) FROM comments) AS comments,"
" (SELECT COUNT(*) FROM comments WHERE rid != 0) AS replies,"
" (SELECT COUNT(*) FROM users) AS users,"
" (SELECT COUNT(*) FROM pages) AS pages;",
]
(out_dir / "99-check.sql").write_text("\n".join(check) + "\n", encoding="utf-8", newline="\n")
files.append("99-check.sql")
print(f"\n生成 {len(files)} 个 SQL 于 {out_dir}")
if not args.apply:
print(f"执行: bash {args.out}/run.sh --remote (或加 --apply 自动跑)")
return 0
env = dict(os.environ)
for f in files:
print(f"==> {f}")
ok = False
for attempt in range(3):
p = subprocess.run(
["node", str(WRANGLER), "d1", "execute", args.db, "--remote", "-y", f"--file={out_dir.as_posix()}/{f}"],
cwd=ROOT,
capture_output=True,
text=True,
env=env,
)
if p.returncode == 0:
ok = True
break
print(f" 第 {attempt + 1} 次失败,重试(跨境 fetch 偶发)", file=sys.stderr)
time.sleep(3)
if not ok:
print(f" FAIL {f}\n{(p.stdout or '')[:300]}\n{(p.stderr or '')[:300]}", file=sys.stderr)
return 1
print(" ok")
out = wrangler(
[
"d1",
"execute",
args.db,
"--remote",
"-y",
"--command",
"SELECT (SELECT COUNT(*) FROM comments) AS comments,"
" (SELECT COUNT(*) FROM comments WHERE rid != 0) AS replies,"
" (SELECT COUNT(*) FROM users) AS users,"
" (SELECT COUNT(*) FROM pages) AS pages;",
"--json",
]
)
print("\n导入后:", json.dumps(parse_json_out(out)[0]["results"][0], ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+80
View File
@@ -0,0 +1,80 @@
/* 邮件模板自检:验证渲染不炸、转义生效、关键字段都在。
* 跑法(见 package.json selftest 同款套路,tools/ 不在 src 的 tsc 范围里):
* npx tsc tools/mailtest.ts --outDir .selftest-build --module commonjs \
* --target es2022 --moduleResolution node --skipLibCheck --esModuleInterop
* node .selftest-build/tools/mailtest.js
*/
import { replyMailHtml, replyMailSubject, adminMailHtml, adminMailSubject } from '../src/lib/mail';
import type { CommentRow } from '../src/types';
let pass = 0,
fail = 0;
const ok = (name: string, cond: boolean) => {
if (cond) {
pass++;
console.log(' ✓', name);
} else {
fail++;
console.log(' ✗', name);
}
};
// CommentRow 只要模板用到的字段;其余给空值占位
const row = (id: number, content: string, createdAt: string): CommentRow =>
({
id,
content,
created_at: createdAt,
page_key: '/20240811.html',
site_name: '优世界',
}) as unknown as CommentRow;
console.log('== 回复提醒模板(reply.html 气泡风) ==');
const rInput = {
siteName: '优世界',
siteUrl: 'https://usj.cc',
pageTitle: '迁移博客至hugo',
pageUrl: 'https://usj.cc/20240811.html',
parentComment: row(15580, '原始评论 <b>加粗</b>', '2026-10-01 10:00:00'),
parentNick: '小赵',
parentEmail: 'imql@qq.com',
comment: row(15590, '试试 <script>alert(1)</script> 和 <img src=x onerror=alert(1)>', '2026-10-02 10:00:00'),
commenterNick: '路人甲',
commenterEmail: 'walker@example.com',
toNick: '小赵',
};
const r = replyMailHtml(rInput);
ok('渲染出 HTML', r.startsWith('<!DOCTYPE html>'));
ok('包含站点名', r.includes('优世界'));
ok('包含页面标题', r.includes('迁移博客至hugo'));
ok('跳转链接带 atk_comment 锚参数', r.includes('atk_comment=15590'));
ok('script 标签被转义', !r.includes('<script>alert(1)</script>'));
ok('没有可执行的 onerror(img 白名单外的 img 一律转义成文本)', !/<img[^>]*onerror/i.test(r));
ok('被回复者昵称出现', r.includes('小赵'));
ok('回复者昵称出现', r.includes('路人甲'));
ok('两个头像 MD5 都渲染', (r.match(/weavatar\.com\/avatar\//g) || []).length >= 2);
ok('site_url 出现在落款', r.includes('https://usj.cc'));
ok('主题格式正确', replyMailSubject(rInput) === '[优世界] 您收到了来自 @路人甲 的回信');
console.log('== 管理员通知模板(notice.html 气泡风) ==');
const aInput = {
siteName: '优世界',
siteUrl: 'https://usj.cc',
pageTitle: '留言',
pageUrl: 'https://usj.cc/comment.html',
comment: row(999, '你好,<img src="https://usj.cc/emotion/douyin/19.png" atk-emoticon="19"> 哈哈', '2026-10-02 11:00:00'),
commenterNick: '路人',
commenterEmail: 'x@example.com',
adminNick: '博主',
};
const a = adminMailHtml(aInput);
ok('渲染出 HTML', a.startsWith('<!DOCTYPE html>'));
ok('标题是新评论', a.includes('有了新的评论'));
ok('含作者昵称', a.includes('路人'));
ok('表情包 img 被保留并内联宽度', /<img[^>]*width:100px[^>]*atk-emoticon/.test(a));
ok('跳转链接带锚参数', a.includes('atk_comment=999'));
ok('site_url 出现在落款', a.includes('https://usj.cc'));
ok('主题格式正确', adminMailSubject(aInput) === '[优世界] 其中 "留言" 页面有一条新的评论!');
console.log('\n通过 ' + pass + ' / 失败 ' + fail);
if (fail > 0) (globalThis as { __fail?: boolean }).__fail = true;
+209
View File
@@ -0,0 +1,209 @@
// 用本地 Chromium 无头渲染评论页,精确测量各徽章的盒子尺寸(CDP,无需第三方依赖)
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9333;
const URL_ = process.argv[2] || 'http://127.0.0.1:1313/comment.html';
const userDir = mkdtempSync(join(tmpdir(), 'cdp-badge-'));
const chrome = spawn(CHROME, [
'--headless=new',
`--remote-debugging-port=${PORT}`,
`--user-data-dir=${userDir}`,
'--no-first-run',
'--no-default-browser-check',
'--disable-gpu',
'--window-size=1280,900',
'about:blank',
], { stdio: 'ignore' });
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitPort() {
for (let i = 0; i < 40; i++) {
try {
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
if (r.ok) return await r.json();
} catch { /* not ready */ }
await sleep(300);
}
throw new Error('chrome 调试端口未就绪');
}
function cdp(wsUrl) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(wsUrl);
let id = 0;
const pending = new Map();
ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data);
if (msg.id && pending.has(msg.id)) {
const { res, rej } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? rej(new Error(JSON.stringify(msg.error))) : res(msg.result);
}
});
ws.addEventListener('error', reject);
ws.addEventListener('open', () => {
resolve({
send(method, params = {}) {
return new Promise((res, rej) => {
const mid = ++id;
pending.set(mid, { res, rej });
ws.send(JSON.stringify({ id: mid, method, params }));
});
},
close: () => ws.close(),
});
});
});
}
const MEASURE = `(() => {
const px = (n) => Math.round(n * 100) / 100;
const list = document.querySelector('.atk-list');
if (list) list.scrollIntoView({ block: 'start' });
const info = (el) => {
if (!el) return null;
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
return {
text: (el.textContent || '').trim(),
cls: el.className.replace(' atk-cmt-badge', ''),
offW: el.offsetWidth, offH: el.offsetHeight,
w: px(r.width), h: px(r.height), top: px(r.top),
fontSize: cs.fontSize, lineHeight: cs.lineHeight, fontWeight: cs.fontWeight,
padding: cs.padding, border: cs.borderTopWidth, display: cs.display,
marginLeft: cs.marginLeft, marginRight: cs.marginRight,
parent: el.parentElement ? el.parentElement.className : null,
};
};
const heads = [...document.querySelectorAll('.atk-header')];
const out = { headerCount: heads.length, samples: [] };
heads.slice(0, 4).forEach((h, i) => {
const badges = [...h.querySelectorAll('.atk-badge, .atk-pinned-badge, .atk-title-badge, .atk-rank-badge, .atk-link-badge, .atk-feed-badge, .atk-region-badge')];
if (!badges.length) return;
out.samples.push({
i,
hidden: !!h.closest('[style*="display: none"]'),
innerWidth: window.innerWidth,
badges: badges.map(info),
});
});
return JSON.stringify(out, null, 1);
})()`;
try {
await waitPort();
// 新建标签页
const created = await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' });
const tab = await created.json();
const c = await cdp(tab.webSocketDebuggerUrl);
await c.send('Runtime.enable');
await c.send('Page.enable');
await sleep(6000); // 等评论异步加载
// 强制日间模式并把评论区滚到视口
await c.send('Runtime.evaluate', {
expression: `(() => {
document.documentElement.setAttribute('data-theme', 'light');
const el = document.querySelector('.atk-comment .atk-header') || document.querySelector('.atk-list');
if (el) el.scrollIntoView({ block: 'center' });
return 'ok';
})()`,
returnByValue: true,
});
await sleep(1200);
const res = await c.send('Runtime.evaluate', { expression: MEASURE, returnByValue: true, awaitPromise: false });
console.log(res.result.value);
const shot = await c.send('Page.captureScreenshot', { format: 'png' });
const { writeFileSync } = await import('node:fs');
writeFileSync(process.argv[3] || 'badge-measure.png', Buffer.from(shot.data, 'base64'));
// 额外抓一张"徽章行"特写(放大 3 倍):先滚动,等布局稳定再取 rect
await c.send('Runtime.evaluate', {
expression: `(() => {
const hs = [...document.querySelectorAll('.atk-header')];
const h = hs.find((x) => x.querySelector('.atk-title-badge') || x.querySelector('.atk-link-badge')) || hs[0];
if (h) h.scrollIntoView({ block: 'center' });
return 'ok';
})()`,
returnByValue: true,
});
await sleep(600);
const rectRes = await c.send('Runtime.evaluate', {
expression: `(() => {
const hs = [...document.querySelectorAll('.atk-header')];
const h = hs.find((x) => x.querySelector('.atk-title-badge') || x.querySelector('.atk-link-badge')) || hs[0];
if (!h) return 'null';
const r = h.getBoundingClientRect();
return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8), width: Math.min(window.innerWidth - Math.max(0, r.x - 8), r.width + 16), height: r.height + 16 });
})()`,
returnByValue: true,
});
if (rectRes.result.value !== 'null') {
const clip = JSON.parse(rectRes.result.value);
const shot2 = await c.send('Page.captureScreenshot', { format: 'png', clip: { ...clip, scale: 3 } });
writeFileSync(String(process.argv[3] || 'badge-measure.png').replace(/\.png$/, '.row.png'), Buffer.from(shot2.data, 'base64'));
}
// 聚焦某个选择器截图(第 4 个参数,例如 .atk-fl-apply),并把它的几何也打印出来
const focusSel = process.argv[4];
if (focusSel) {
const fRes = await c.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector(${JSON.stringify(focusSel)});
if (!el) return 'null';
el.scrollIntoView({ block: 'center' });
return 'ok';
})()`,
returnByValue: true,
});
if (fRes.result.value === 'ok') {
await sleep(700);
const gRes = await c.send('Runtime.evaluate', {
expression: `(() => {
const el = document.querySelector(${JSON.stringify(focusSel)});
const r = el.getBoundingClientRect();
const cs = getComputedStyle(el);
const chain = (node) => {
const out = [];
let n = node;
while (n && n !== document.body) {
const s = getComputedStyle(n);
const b = n.getBoundingClientRect();
out.push({
tag: n.tagName.toLowerCase() + '.' + (n.className || '').toString().split(' ').slice(0, 2).join('.'),
x: Math.round(b.x), w: Math.round(b.width),
ml: s.marginLeft, mr: s.marginRight, pl: s.paddingLeft, pr: s.paddingRight,
bl: s.borderLeftWidth, disp: s.display, style: n.getAttribute('style') || '',
});
n = n.parentElement;
}
return out;
};
const other = [...document.querySelectorAll('.atk-content')].find((x) => !x.closest('.atk-fl-apply'));
const normal = [...document.querySelectorAll('.atk-comment-wrap, .atk-comment')].find((x) => !x.querySelector('.atk-fl-apply'));
return JSON.stringify({
card: { x: Math.round(r.x), w: Math.round(r.width), h: Math.round(r.height) },
cardStyle: { margin: cs.margin, padding: cs.padding, width: cs.width },
cardChain: chain(el),
otherContent: other ? { x: Math.round(other.getBoundingClientRect().x), w: Math.round(other.getBoundingClientRect().width) } : null,
otherChain: other ? chain(other) : null,
normalCommentChain: normal ? chain(normal) : null,
}, null, 1);
})()`,
returnByValue: true,
});
console.log('FOCUS', gRes.result.value);
const shotF = await c.send('Page.captureScreenshot', { format: 'png' });
writeFileSync(String(process.argv[3] || 'badge-measure.png').replace(/\.png$/, '.focus.png'), Buffer.from(shotF.data, 'base64'));
}
}
c.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+57
View File
@@ -0,0 +1,57 @@
// 渲染两封示例邮件 HTML 到 .mail-preview/,供浏览器预览(不发送)
// 用法(在仓库根目录):
// npx tsc tools/preview-mail.ts src/lib/mail.ts --outDir .mail-build --module commonjs --target es2022 --moduleResolution node --skipLibCheck --esModuleInterop --noEmit false
// node .mail-build/tools/preview-mail.js
import * as path from 'path';
const { replyMailHtml, adminMailHtml, replyMailSubject, adminMailSubject } = require('../src/lib/mail');
const now = Date.now();
const parent = {
id: 15630,
content: '终于恢复更新了啊 确实有点无聊了,做些损人不利己的事 :prophet:',
created_at: now - 3600_000,
page_key: '/comment.html',
};
const reply = {
id: 15631,
content: '恢复了,今天有空,把服务全部迁移到 cloudflare 上了,GitHub 仓库只做文件备份了 😄[doge]',
created_at: now,
page_key: '/comment.html',
};
const newMsg = {
id: 15632,
content: '测试留言:这个聊天气泡样式的邮件通知看起来真不错!',
created_at: now,
page_key: '/comment.html',
};
const site = { siteName: '优世界', siteUrl: 'https://usj.cc', pageTitle: '留言', pageUrl: 'https://usj.cc/comment.html' };
const replyHtml = replyMailHtml({
...site,
parentComment: parent,
parentNick: '扳布',
parentEmail: 'banbu@example.com',
comment: reply,
commenterNick: '小赵',
commenterEmail: 'imql@qq.com',
toNick: '扳布',
});
const adminHtml = adminMailHtml({
...site,
comment: newMsg,
commenterNick: '测试访客',
commenterEmail: 'guest@example.com',
adminNick: '小赵',
});
const fs = require('fs');
fs.mkdirSync(__dirname + '/.mail-preview', { recursive: true });
fs.writeFileSync(__dirname + '/.mail-preview/reply.html', replyHtml);
fs.writeFileSync(__dirname + '/.mail-preview/notice.html', adminHtml);
console.log('SUBJECT reply :', replyMailSubject({
...site, parentComment: parent, parentNick: 'a', parentEmail: 'a@a.com',
comment: reply, commenterNick: '小赵', commenterEmail: 'b@b.com', toNick: '扳布',
}));
console.log('SUBJECT notice:', adminMailSubject({ ...site, comment: newMsg, commenterNick: 'c', commenterEmail: 'c@c.com', adminNick: 'x' }));
console.log('OK');
+51
View File
@@ -0,0 +1,51 @@
// 探查 svc-down 状态下编辑器 bottom/header 的真实 DOM
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9405;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-pb-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await sleep(9000);
console.log(JSON.stringify({
bottom: documentBottom(),
header: documentHeader(),
}));
function documentBottom() { return null; }
function documentHeader() { return null; }
console.log(await ev(`JSON.stringify({
bottom: (document.querySelector('.atk-main-editor .atk-bottom')||{}).outerHTML?.slice(0,700)||null,
header: (document.querySelector('.atk-main-editor .atk-header')||{}).outerHTML?.slice(0,500)||null,
})`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+67
View File
@@ -0,0 +1,67 @@
// 探查 svc-down 状态下编辑器内部的渲染细节
import { spawn } from 'node:child_process';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9404;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-probe-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await send('Runtime.enable');
await sleep(9000);
console.log(await ev(`JSON.stringify((() => {
const ed = document.querySelector('.atk-main-editor');
if (!ed) return { err: 'no editor' };
const btn = ed.querySelector('.atk-send-btn, [class*=send]');
const ta = ed.querySelector('.atk-textarea');
const cs = (el) => {
if (!el) return null;
const s = getComputedStyle(el);
return { color: s.color, bg: s.backgroundColor, opacity: s.opacity, fontSize: s.fontSize,
filter: s.filter, visibility: s.visibility, display: s.display, content: (el.innerText||'').slice(0,20) };
};
return {
btnHtml: btn ? btn.outerHTML.slice(0, 200) : null,
btnStyle: cs(btn),
btnChildren: btn ? btn.children.length : 0,
taStyle: cs(ta),
placeholder: ta ? ta.getAttribute('placeholder') : null,
headerInputs: [...ed.querySelectorAll('input')].map(i => ({ cls: i.className, ph: i.placeholder, val: i.value.slice(0,10), display: getComputedStyle(i).display })),
editorChildren: [...ed.children].map(c => c.className),
mainEditorOpacity: getComputedStyle(ed).opacity,
mainEditorFilter: getComputedStyle(ed).filter,
artalkRootOpacity: getComputedStyle(document.querySelector('.artalk') || ed.closest('.artalk-container') || ed).opacity,
};
})())`, ));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+64
View File
@@ -0,0 +1,64 @@
# Artalk 配置(artalk-cf 兼容实现 · 存于 D1 settings 表)
# 服务端识别的键:frontend / captcha / moderator.pending_default / ip_region / site_default
# 邮件相关(email / admin_notify)在 Workers 上走 Resend HTTP API,由 secret 配置,
# 这里的段落仅作参考存档。
# 默认站点名(前台 site 参数必须与此一致)
site_default: 优世界
# 审核
moderator:
# 新评论默认待审
pending_default: false
# 验证码(Workers 无 TCP,turnstile 校验可走 HTTP,暂未启用)
captcha:
enabled: false
always: false
# IP 归属地(回显 Cloudflare 边缘给出的国家码)
ip_region:
enabled: true
# 前台 UI
frontend:
placeholder: ""
noComment: ""
sendBtn: "评论一下"
editorTravel: true
emoticons: https://usj.cc/emotion/OwO.json
vote: true
voteDown: false
uaBadge: false
listSort: true
preview: true
flatMode: false
darkMode: inherit
nestMax: 2
nestSort: DATE_ASC
gravatar:
mirror: https://weavatar.com/avatar/
params: d=mp&s=240
pagination:
pageSize: 20
readMore: true
autoLoad: true
heightLimit:
content: 10000
children: 10000
scrollable: true
imgLazyLoad: data-src
reqTimeout: 15000
versionCheck: false
pluginURLs: []
# 邮件(仅参考;实际发信走 Resend:RESEND_API_KEY / MAIL_FROM / MAIL_ADMIN)
email:
enabled: true
send_name: "书呈{{parent_comment.nick}}台鉴:"
mail_subject: "[{{site_name}}] 您收到了来自 @{{reply_nick}} 的回信"
admin_notify:
email:
enabled: true
mail_subject: '[{{site_name}}] 其中 "{{page_title}}" 页面有一条新的评论!'
+117
View File
@@ -0,0 +1,117 @@
/**
* 自检:不依赖 wrangler / 网络,直接验证最容易出错的那几块纯逻辑。
* node node_modules/.bin/esbuild tools/selftest.ts --bundle --platform=node --format=esm --outfile=tools/.selftest.mjs && node tools/.selftest.mjs
*/
import { md5, md5Lower } from '../src/lib/md5';
import { formatDateCN } from '../src/lib/util';
import { renderMarkdown } from '../src/lib/md';
import { hashPassword, verifyPassword, signToken, verifyToken } from '../src/lib/session';
import { parseSimpleYaml } from '../src/routes/admin';
import type { Env } from '../src/types';
let pass = 0;
let fail = 0;
function eq(name: string, actual: unknown, expected: unknown) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a === e) {
pass++;
console.log(` PASS ${name}`);
} else {
fail++;
console.log(` FAIL ${name}\n 实际: ${a}\n 期望: ${e}`);
}
}
function ok(name: string, cond: boolean) {
eq(name, cond, true);
}
// ------------------------------------------------------------------ MD5
console.log('== MD5(Gravatar hash,必须与官方一致) ==');
eq('md5("")', md5(''), 'd41d8cd98f00b204e9800998ecf8427e');
eq('md5("abc")', md5('abc'), '900150983cd24fb0d6963f7d28e17f72');
eq('md5("test@example.com")', md5('test@example.com'), '55502f40dc8b7c769880b10874abc9d0');
eq('md5("z@usj.cc")', md5('z@usj.cc'), '1cce0a22c2c7648eab76ec876c6a54d9');
eq('md5("优世界") 多字节', md5('优世界'), '7fc1dce97eead18fa9e9ae4fbb8044cb');
eq('md5 长输入(跨 block)', md5('a'.repeat(200)), '887f30b43b2867f4a9accceee7d16e6c');
eq('md5Lower 去空格+小写', md5Lower(' Z@USJ.CC '), '1cce0a22c2c7648eab76ec876c6a54d9');
async function main() {
// ------------------------------------------------------------------ 时间
console.log('== 时间格式(Asia/Shanghai) ==');
eq('1631363636000', formatDateCN(1631363636000), '2021-09-11 20:33:56');
eq('0', formatDateCN(0), '1970-01-01 08:00:00');
// ------------------------------------------------------------------ Markdown
console.log('== Markdown 渲染 ==');
ok('<script> 被转义', !renderMarkdown('<script>alert(1)</script>').includes('<script>'));
ok('img onerror 被转义', !renderMarkdown('<img src=x onerror=alert(1)>').includes('onerror='));
ok('javascript: 链接被剔除', !renderMarkdown('[x](javascript:alert(1))').includes('javascript:'));
ok('**粗体**', renderMarkdown('**hi**').includes('<strong>hi</strong>'));
ok('`行内代码`', renderMarkdown('`code`').includes('<code>code</code>'));
ok('裸链接可点', renderMarkdown('见 https://usj.cc 一游').includes('<a href="https://usj.cc"'));
ok('单换行 → <br>', renderMarkdown('a\nb').includes('a<br>b'));
ok('``` 代码块', renderMarkdown('```js\nlet a=1;\n```').includes('<pre><code class="language-js">'));
ok('& 被转义', renderMarkdown('a & b').includes('a &amp; b'));
ok('含单引号的正文不炸', renderMarkdown("it's fine").includes('it&#39;s fine'));
// ------------------------------------------------------------------ 密码
console.log('== 密码(PBKDF2 / md5 / 明文兼容) ==');
const hashed = await hashPassword('s3cret-密码');
ok('hash 前缀是 (pbkdf2)', hashed.startsWith('(pbkdf2)'));
ok('正确密码通过', await verifyPassword(hashed, 's3cret-密码'));
ok('错误密码拒绝', !(await verifyPassword(hashed, 'wrong')));
ok('空密码拒绝', !(await verifyPassword('', '')));
ok('兼容 (md5) 旧密码', await verifyPassword('(md5)1cce0a22c2c7648eab76ec876c6a54d9', 'z@usj.cc'));
ok('兼容明文旧密码', await verifyPassword('plain', 'plain'));
ok('(bcrypt) 明确不支持', !(await verifyPassword('(bcrypt)$2a$10$xxx', 'whatever')));
// ------------------------------------------------------------------ Token
console.log('== 登录 token(HMAC 无状态) ==');
const env = { TOKEN_SECRET: 'test-secret-please-change' } as Env;
const token = await signToken(env, 42);
ok('token 是两段式', token.split('.').length === 2);
const payload = await verifyToken(env, token);
eq('解出的 uid', payload?.uid, 42);
ok(
'改一位就失效',
!(await verifyToken(env, token.slice(0, -2) + (token.endsWith('AA') ? 'BB' : 'AA'))),
);
ok('换密钥就失效', !(await verifyToken({ TOKEN_SECRET: 'other' } as Env, token)));
ok('乱串被拒', !(await verifyToken(env, 'not-a-token')));
ok('空串被拒', !(await verifyToken(env, '')));
// ------------------------------------------------------------------ YAML
console.log('== 设置模板 YAML 解析 ==');
const tpl = `host: "0.0.0.0"
port: 23366
captcha:
enabled: false
mode: off # 关闭验证码
moderator:
pending_default: true
ip_region:
enabled: false
frontend:
pvAdd: true
locale: zh-CN
pluginURLs: []
site_default: 优世界
`;
const parsed = parseSimpleYaml(tpl) as any;
eq('顶层字符串', parsed.host, '0.0.0.0');
eq('顶层数字', parsed.port, 23366);
eq('嵌套布尔', parsed.captcha.enabled, false);
eq('行内注释被剔除', parsed.captcha.mode, 'off');
eq('嵌套布尔2', parsed.moderator.pending_default, true);
eq('三层取值', parsed.frontend.pvAdd, true);
eq('中文值', parsed.site_default, '优世界');
eq('空数组', parsed.frontend.pluginURLs, []);
console.log(`\n通过 ${pass} / 失败 ${fail}`);
process.exit(fail === 0 ? 0 : 1);
}
void main();
+45
View File
@@ -0,0 +1,45 @@
// 浅色模式截图验证(切换 data-theme 后截图)
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const CHROME = 'C:/Users/QunLIn/.agent-browser/browsers/chrome-154.0.8037.92/chrome.exe';
const PORT = 9406;
const URL_ = 'http://127.0.0.1:1313/comment.html';
const OUT = process.argv[2] || 'light.png';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const userDir = mkdtempSync(join(tmpdir(), 'cdp-light-'));
const chrome = spawn(CHROME, [
'--headless=new', `--remote-debugging-port=${PORT}`, `--user-data-dir=${userDir}`,
'--no-first-run', '--disable-gpu', '--window-size=1280,1400', 'about:blank',
], { stdio: 'ignore' });
try {
for (let i = 0; i < 40; i++) {
try { const r = await fetch(`http://127.0.0.1:${PORT}/json/version`); if (r.ok) break; } catch { /* */ }
await sleep(300);
}
const tab = await (await fetch(`http://127.0.0.1:${PORT}/json/new?${encodeURIComponent(URL_)}`, { method: 'PUT' })).json();
const ws = new WebSocket(tab.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
ws.addEventListener('message', (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id); } });
await new Promise((res) => ws.addEventListener('open', res));
const send = (method, params = {}) => new Promise((res) => { const mid = ++id; pending.set(mid, res); ws.send(JSON.stringify({ id: mid, method, params })); });
const ev = async (expr) => {
const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
return r.result?.result?.value ?? r.result?.value ?? null;
};
await sleep(9000);
await ev(`document.documentElement.setAttribute('data-theme','light')`);
await sleep(800);
const s = await send('Page.captureScreenshot', { format: 'png' });
writeFileSync(OUT, Buffer.from(s.result.data, 'base64'));
console.log('btn:', await ev(`JSON.stringify((() => { const b = document.querySelector('.atk-send-btn'); const s = getComputedStyle(b); return { text: b.textContent, color: s.color, bg: s.backgroundColor, opacity: getComputedStyle(b.closest('.atk-main-editor')).opacity }; })())`));
ws.close();
} catch (e) {
console.error('ERROR', e.message);
} finally {
chrome.kill();
}
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# 部署后冒烟测试:把 BASE 换成你的 Worker 地址即可
# bash tools/smoke.sh https://artalk-cf.<你的子域>.workers.dev
set -uo pipefail
BASE="${1:-http://127.0.0.1:8787}"
SITE="${SITE:-优世界}"
PAGE_KEY="${PAGE_KEY:-/20210911.html}"
TOKEN="${TOKEN:-}"
# ★ 中文必须 percent-encode 再拼进 URL:直接发原始 UTF-8 字节,
# Cloudflare 边缘会把这行请求判成 400 Bad Request(空 body),看起来像服务端坏了。
# 浏览器里的 Artalk 客户端用 URLSearchParams 会自动编码,所以真实流量没这个问题。
enc() { node -e 'process.stdout.write(encodeURIComponent(process.argv[1]))' "$1"; }
SITE_Q=$(enc "$SITE")
PAGE_Q=$(enc "$PAGE_KEY")
pass=0
fail=0
chk() {
local name="$1"; shift
local expect="$1"; shift
local code
code=$(curl -s -o /tmp/atk_out -w '%{http_code}' "$@" --max-time 20)
if [ "$code" = "$expect" ]; then
printf ' \033[32mPASS\033[0m %-42s %s\n' "$name" "$code"
pass=$((pass + 1))
else
printf ' \033[31mFAIL\033[0m %-42s %s (期望 %s)\n' "$name" "$code" "$expect"
printf ' %s\n' "$(head -c 300 /tmp/atk_out)"
fail=$((fail + 1))
fi
}
echo "== 基础 =="
chk "GET /api/v2/healthz" 200 "$BASE/api/v2/healthz"
chk "GET /api/v2/version" 200 "$BASE/api/v2/version"
chk "GET /api/v2/conf" 200 "$BASE/api/v2/conf"
chk "GET /api/v2/conf/auth/providers" 200 "$BASE/api/v2/conf/auth/providers"
chk "OPTIONS 预检" 204 -X OPTIONS "$BASE/api/v2/comments" -H "Origin: https://usj.cc"
echo "== 评论读取 =="
chk "GET /api/v2/comments (page 作用域)" 200 "$BASE/api/v2/comments?page_key=$PAGE_Q&site_name=$SITE_Q&limit=5"
chk "GET /api/v2/comments (site 作用域)" 200 "$BASE/api/v2/comments?scope=site&site_name=$SITE_Q&limit=5"
chk "GET /api/v2/comments (搜索)" 200 "$BASE/api/v2/comments?scope=site&site_name=$SITE_Q&search=%E5%B0%8F%E8%B5%B5&limit=5"
chk "GET /api/v2/comments (不存在的站点)" 400 "$BASE/api/v2/comments?page_key=/x.html&site_name=__nope__"
echo "== 后台(未带 token 应 403) =="
chk "GET /api/v2/sites" 403 "$BASE/api/v2/sites"
chk "GET /api/v2/settings" 403 "$BASE/api/v2/settings"
chk "GET /api/v2/stats/site_comment" 403 "$BASE/api/v2/stats/site_comment"
chk "GET /api/v2/users/all" 403 "$BASE/api/v2/users/all"
if [ -n "$TOKEN" ]; then
echo "== 后台(带管理员 token) =="
chk "GET /api/v2/sites" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/sites"
chk "GET /api/v2/settings" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/settings"
chk "GET /api/v2/settings/template/zh-CN" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/settings/template/zh-CN"
chk "GET /api/v2/stats/site_comment" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/stats/site_comment"
chk "GET /api/v2/users/all" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/users/all"
chk "GET /api/v2/pages" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/pages"
chk "GET /api/v2/transfer/export" 200 -H "Authorization: Bearer $TOKEN" "$BASE/api/v2/transfer/export?site_name=$SITE_Q"
fi
echo
echo "通过 $pass / 失败 $fail"
[ "$fail" -eq 0 ]
+110
View File
@@ -0,0 +1,110 @@
// 人机验证全链路自测(Node 侧算 PoW,与服务端 webcrypto 同算法)
import crypto from 'node:crypto';
const BASE = 'https://api.200181.xyz/api/v2/human';
const POW_DIFFICULTY = 4;
function sha256Hex(s) {
return crypto.createHash('sha256').update(s).digest('hex');
}
function solve(challenge, difficulty) {
const prefix = '0'.repeat(difficulty);
const t0 = Date.now();
for (let nonce = 0; nonce < 5_000_000; nonce++) {
if (sha256Hex(challenge + nonce).startsWith(prefix)) {
return { nonce, ms: Date.now() - t0, tries: nonce + 1 };
}
}
throw new Error('no solution');
}
async function j(path, init) {
const r = await fetch(BASE + path, init);
const text = await r.text();
try {
return { status: r.status, body: JSON.parse(text) };
} catch {
return { status: r.status, body: text.slice(0, 200) };
}
}
async function main() {
console.log('--- 1) GET /human/challenge');
const ch = await j('/challenge');
console.log(' ', ch.status, JSON.stringify(ch.body).slice(0, 160));
if (!ch.body || ch.body.enabled !== true) {
console.log('开关没开(human_check 不是 1),先设置 KV 再测');
return;
}
console.log('--- 2) POST /human/verify 错误 PoW(应 need_captcha)');
const bad = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: 12345,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 5000,
events: 30,
}),
});
console.log(' ', bad.status, JSON.stringify(bad.body));
console.log('--- 3) 正确 PoW + 无交互信号(应 need_click)');
const sol = solve(ch.body.challenge, POW_DIFFICULTY);
console.log(` 解出 nonce=${sol.nonce}(${sol.tries} 次尝试 / ${sol.ms}ms)`);
const mid = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: sol.nonce,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 300,
events: 0,
}),
});
console.log(' ', mid.status, JSON.stringify(mid.body));
console.log('--- 4) 蜜罐被填(应 need_captcha)');
const honey = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: sol.nonce,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 8000,
events: 12,
honeypot: 'bot@example.com',
}),
});
console.log(' ', honey.status, JSON.stringify(honey.body));
console.log('--- 5) 正确 PoW + 有交互(应 pass:true 并下发通行证)');
const okRes = await j('/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: ch.body.challenge,
nonce: sol.nonce,
exp: ch.body.exp,
sig: ch.body.sig,
elapsedMs: 8000,
events: 12,
}),
});
console.log(' ', okRes.status, JSON.stringify(okRes.body));
console.log('--- 6) GET /human/status(应 pass:true,说明 KV 通行证生效)');
const st = await j('/status');
console.log(' ', st.status, JSON.stringify(st.body));
}
main().catch((e) => console.error('ERR', e.message));
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"lib": ["es2022"],
"types": ["@cloudflare/workers-types"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*.ts"]
}
+114
View File
@@ -0,0 +1,114 @@
name = "artalk-cf"
main = "src/index.ts"
compatibility_date = "2025-06-01"
# ---------------------------------------------------------------------------
# D1 (必须) 创建:npx wrangler d1 create artalk-cf
# 然后把返回的 database_id 填到下面
# ---------------------------------------------------------------------------
[[d1_databases]]
binding = "DB"
database_name = "artalk-cf"
database_id = "78140776-2bfd-439a-8b05-c3eebe5d5b47"
# ---------------------------------------------------------------------------
# KV —— RSS 订阅数据(rss-robot 合并进来;KV 与 D1 共存于同一 Worker,
# 数据沿用原 rss-robot-cf 的 namespace,零迁移)
# ---------------------------------------------------------------------------
[[kv_namespaces]]
binding = "RSS_KV"
id = "d7f86a0fb43f4450a10b786fc2635128"
# ---------------------------------------------------------------------------
# 普通变量(可以明文写在仓库里)
# SITE_DEFAULT 默认站点名(博客里 site: '优世界',必须一致)
# SITE_URL 默认站点地址,用于把相对 page_key 拼成完整 page_url
# ADMIN_NAME/EMAIL 初始管理员(首次部署后调 /api/v2/setup 初始化)
# ALLOWED_ORIGINS CORS 白名单,逗号分隔;**必须包含你博客的域名**
# ---------------------------------------------------------------------------
[vars]
SITE_DEFAULT = "优世界"
SITE_URL = "https://usj.cc"
# 站内公开 API 基址(用于把 /api/favicon 这类相对地址拼成绝对地址,友圈页在博客域名下用)
PUBLIC_API_BASE = "https://api.200181.xyz"
ADMIN_NAME = "admin"
ADMIN_EMAIL = "admin@200181.xyz"
ALLOWED_ORIGINS = "https://usj.cc,https://www.usj.cc,https://post.usj.cc,https://artalk.200181.xyz,https://api.200181.xyz,http://localhost:1313,http://127.0.0.1:1313,http://localhost:8799,http://127.0.0.1:8799"
ITEMS_PER_PAGE = "20"
PENDING_DEFAULT = "false"
# ---- RSS 订阅(rss-robot 合并进来的变量)----
# SCF_PROXY_URL 腾讯云 SCF 国内代理(scfapi.usj.cc),抓国内博客用
# FEEDS_URL 远程订阅源 JSON(回退用;KV 里已有 feeds_config,留空避免 Worker 自调用)
# FEISHU_WEBHOOK_URL 飞书机器人 webhook(可选)
SCF_PROXY_URL = "https://scfapi.usj.cc"
FEEDS_URL = ""
FEISHU_WEBHOOK_URL = ""
# ⚠️ 下面两个是机密,不要写在这里,用命令写入(详见「部署清单.md」第 4 步):
# npx wrangler secret put TOKEN_SECRET
# npx wrangler secret put ADMIN_PASSWORD
# 本地开发用 .dev.vars 文件(见 .dev.vars.example)
# ---------------------------------------------------------------------------
# 邮件通知(可选)
#
# 为什么走 HTTP API 而不是 SMTP:**Cloudflare Workers 没有 TCP socket**,
# 根本连不上 SMTP 服务器。(Cloudflare 2026-04 推出的原生 Email Service
# 要 Workers Paid,本项目跑在免费版上。)
#
# 用 Resend:免费 3000 封/月(**每天上限 100 封**),本项目内置了日配额保护。
# 三个都走 secret / 环境变量:
# npx wrangler secret put RESEND_API_KEY # re_xxxxxxxx(Resend 后台创建)
# npx wrangler secret put MAIL_FROM # 形如 artalk <noreply@你的域名>
# npx wrangler secret put MAIL_ADMIN # 管理员收件邮箱;不配则不发管理员通知
#
# ⚠️ MAIL_FROM 里的域名必须先在 Resend 后台验证(加 SPF/DKIM 的 DNS 记录)。
# ⚠️ 三者任一缺失都只是「少发一类邮件」,不会影响评论提交 —— 邮件全程 try/catch。
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# R2(可选)—— 评论里插图上传用。没建这个桶就自动禁用上传功能。
# 创建:npx wrangler r2 bucket create artalk-uploads
# ---------------------------------------------------------------------------
# [[r2_buckets]]
# binding = "UPLOADS"
# bucket_name = "artalk-uploads"
# ---------------------------------------------------------------------------
# 静态资源(必须)
# public/sidebar/ = 官方管理后台(客户端用它打开 iframe:{server}/sidebar/)
# public/dist/ = 官方客户端与插件,博客若想直接从服务端引也可以用
# 来源:Artalk v2.8.7 的 artalk_ui.tar.gz(预构建,无需前端构建环境)
# ---------------------------------------------------------------------------
[assets]
directory = "public"
binding = "ASSETS"
# ---------------------------------------------------------------------------
# 自定义域名
# 200181.xyz 的 NS 就在 Cloudflare 上,所以直接绑 artalk.200181.xyz 即可——
# custom_domain = true 会让 Cloudflare 自动建 DNS 记录 + 签 SSL 证书。
#
# 对比:usj.cc 的 NS 在 DNSPod,所以它不能这么绑(绑了会破坏又拍云/EdgeOne 调度)。
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 自定义域名
# 2026-10-03 起统一到 api.200181.xyz(评论 /api/v2/* + 订阅 /api/* 同一
# Worker),artalk.200181.xyz 已解绑废弃。usj.cc 的 NS 在 DNSPod 不能这样绑。
# ---------------------------------------------------------------------------
[[routes]]
pattern = "api.200181.xyz"
custom_domain = true
# ---------------------------------------------------------------------------
# 定时任务(合并 rss-robot 的抓取):
# 17 3 * * * 评论 GC(限流/验证码清理 + healthz 缓存刷新)
# 0 0 * * * RSS 抓取批1(offset 0,35 个源)
# 0 12 * * * RSS 抓取批2(offset 35)
# ---------------------------------------------------------------------------
[triggers]
crons = ["17 3 * * *", "0 * * * *"]
[observability]
enabled = true
+288
View File
@@ -0,0 +1,288 @@
# 部署清单(照着敲,约 10 分钟)
在 `artalk-cf` 目录里执行。**第 9 步(域名)有个必须先想清楚的坑**,其余都是机械操作。
需要你有的东西:Cloudflare 账号、Node.js(本机已有 22.x)、一个能连上 Cloudflare 的网络。
---
## 0. 先确认环境
```bash
cd artalk-cf
npm install
node node_modules/typescript/bin/tsc --noEmit # 应该没有任何输出
```
> 这台机器上 npm 可能被沙箱网络打断(`stub` 装不全)。若报 `Cannot find module`,
> 重跑 `npm install` 直到 `node_modules/wrangler/package.json` 能读出版本号。
---
## 1. 登录 Cloudflare
```bash
npx wrangler login
```
会弹出浏览器让你授权(这一步必须你自己点,我代不了)。
成功后会显示 `Successfully logged in.`
检查:
```bash
npx wrangler whoami
```
---
## 2. 建 D1 数据库
```bash
npx wrangler d1 create artalk-cf
```
输出里会有一行 `database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`,**复制它**。
---
## 3. 把 database_id 填进配置
编辑 `wrangler.toml`,把这一行改掉:
```toml
[[d1_databases]]
binding = "DB"
database_name = "artalk-cf"
database_id = "00000000-0000-0000-0000-000000000000" # ← 换成刚才复制的那串
```
同一文件里再确认这几项是否符合你的情况:
```toml
[vars]
SITE_DEFAULT = "优世界" # 必须和博客 hugo.toml 里的 site 一致
SITE_URL = "https://usj.cc"
ADMIN_NAME = "小赵"
ADMIN_EMAIL = "z@usj.cc"
ALLOWED_ORIGINS = "https://usj.cc,https://www.usj.cc,https://post.usj.cc,http://localhost:1313"
```
> `ALLOWED_ORIGINS` 少了博客域名的话,浏览器会因 CORS 拒绝评论请求(评论区一直转圈)。
---
## 4. 写入两个机密(不要写进文件)
```bash
# 签 token 的密钥:先生成一串随机的
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
npx wrangler secret put TOKEN_SECRET # 粘贴上面那串
npx wrangler secret put ADMIN_PASSWORD # 输入你要用的后台密码
```
(本地开发时改用 `.dev.vars`:`cp .dev.vars.example .dev.vars` 然后改值。)
---
## 5. 建表
```bash
npx wrangler d1 execute artalk-cf --remote --file=./schema.sql
```
应该看到若干条 `🚣 Executed 8 commands` 之类的成功输出。
核对:
```bash
npx wrangler d1 execute artalk-cf --remote --command \
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;"
```
应含:`captcha_challenges captcha_passes comments notifies pages rate_limits settings sites users votes`
---
## 6. 部署
```bash
npx wrangler deploy
```
输出末尾会给一个地址:
```
Uploaded artalk-cf (x.xx sec)
https://artalk-cf.<你的账号子域>.workers.dev
```
**把它记下来**,下面用 `<BASE>` 代替。
---
## 7. 导入评论数据(**必须在初始化管理员之前**)
```bash
python tools/import_artrans.py # 默认只导「优世界」,跳过朋友那三个站
bash tools/import-out/run.sh --remote
```
脚本会打印:
```
源文件 4048 条,按站点筛选后 3440 条 -> 归入站点「优世界」
用户 491 个
页面 91 个
评论 3339 条(其中回复 1791 条)
跳过 101 条:page_key 为空 101、昵称或邮箱为空 0、id 非法 0
```
最后 `run.sh` 会跑一段自检,打印各表条数,对一下:
`sites 1 / pages 91 / users 491 / comments 3339 / replies 1791`
> ⚠️ **两个坑**
> 1. `00-reset-and-site.sql` 会**清空 comments/pages/users/sites 四张表**——
> 所以它会把第 8 步建的管理员一起删掉。**顺序必须是「先导入、后初始化」**。
> 库里已有数据时也别再跑,会被清空。
> 2. `wrangler d1 execute --remote` 会先问一次确认,脚本里已经带了 `-y`。
> 如果你是手动逐条敲命令,漏了 `-y` 会直接报错退出。
---
## 8. 初始化站点与管理员
```bash
curl -X POST <BASE>/api/v2/setup
```
期望返回:
```json
{"msg":"Success","site":"优世界","admins":["admin@200181.xyz"],"reseted":false}
```
它会建默认站点、把 `ADMIN_NAME/EMAIL/PASSWORD` 写成管理员(密码存 PBKDF2 哈希)。
**立刻验证管理员能登录**(账号名或邮箱都行):
```bash
curl -X POST <BASE>/api/v2/user/access_token \
-H 'Content-Type: application/json' \
-d '{"email":"admin","password":"你刚设的密码"}'
```
返回里有 `"token":"..."` 就对了。**没有 token 说明第 4 步的密码没写进去。**
> 忘了密码想重设:`curl -X POST "<BASE>/api/v2/setup?force=<TOKEN_SECRET>"`,
> `TOKEN_SECRET` 就是你第 4 步写进去的那串(拿它当"部署密钥"用)。
---
## 9. ★ 域名:这一步有个坑,先想清楚
### 问题
**Cloudflare Worker 的自定义域名要求「域名的 NS 托管在 Cloudflare」。**
官方文档写得很明确:绑定自定义域名时,Cloudflare 会自动创建 DNS 记录和 SSL 证书——
这需要 zone 在 Cloudflare 里。外部 DNS 用 CNAME 直接指过去,社区反馈是"各种奇奇怪怪的错误"。
而 **`usj.cc` 的 NS 在 DNSPod**(还挂着又拍云 + 腾讯云 EdgeOne 的调度)。
**千万不要为了评论 API 把整站 NS 搬去 Cloudflare**——那会把现有的国内双 CDN 链路一起弄坏。
### 四个选项
| 选项 | 做法 | 代价 | 建议 |
|---|---|---|---|
| **A. 另用一个 CF 托管域名** | 用另一个域名(新注册的也行,便宜)把 NS 放到 Cloudflare,绑 `artalk-cf.<那个域名>` | 一年域名钱 | ✅ **推荐** |
| B. 先用 workers.dev | 第 6 步的地址直接当正式地址 | **大陆访问不通**,只能自测 | 仅用于验证功能 |
| C. DNSPod 加 CNAME | `artalk-cf` CNAME 到 `<worker>.workers.dev` | 官方不支持,可能不通/证书异常 | 可以试,别当正式方案 |
| D. 整站 NS 迁到 Cloudflare | usj.cc → Cloudflare | **会破坏又拍云/EdgeOne 调度** | ❌ 不要 |
### 选了 A 之后怎么做
1. 新域名加进 Cloudflare → 按提示把 **NS 改到 Cloudflare**
2. 打开 `wrangler.toml`,取消注释并改成你的域名:
```toml
[[routes]]
pattern = "artalk-cf.你的域名"
custom_domain = true
```
3. `npx wrangler deploy`
4. 把域名加进 `ALLOWED_ORIGINS` 后再 deploy 一次
5. `curl https://artalk-cf.你的域名/api/v2/healthz` 应返回 `{"ok":true,...}`
---
## 10. 接到博客上(改一行 + 重新发布)
编辑 `E:\GitHub\blog\hugo.toml`:
```toml
[params.artalk]
server = "https://artalk-cf.你的域名" # ← 只改这一行
site = "优世界"
```
然后按你平时的流程发布(写作后台点发布,或按 `hugo-writeserver-publish-ops` 里那套精确发布命令)。
**前端一个字符都不用动**——客户端调的就是 `${server}/api/v2/...`,
`pageKey` 用 `.RelPermalink`(`/20210911.html`),和 D1 里的 `page_key` 完全对应。
---
## 11. 验收
```bash
bash tools/smoke.sh https://artalk-cf.你的域名
```
然后拿 token 再跑一遍后台用例:
```bash
TOKEN=$(curl -s -X POST https://artalk-cf.你的域名/api/v2/user/access_token \
-H 'Content-Type: application/json' \
-d '{"email":"z@usj.cc","password":"你的密码"}' | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>console.log(JSON.parse(s).token))")
TOKEN=$TOKEN bash tools/smoke.sh https://artalk-cf.你的域名
```
### 手动验收清单
- [ ] 打开 `https://artalk-cf.你的域名/sidebar/` —— 能看到 Artalk 后台登录页
- [ ] 用邮箱 + 密码登录进去,能看到 **3339 条评论**
- [ ] 打开博客任意一篇有评论的文章 —— 评论、回复层级、博主徽章都对
- [ ] 打开 `/comment.html`(994 条那篇)—— 分页/加载更多正常
- [ ] 发一条新评论 —— 立刻出现
- [ ] 后台改一条评论的置顶/审核状态 —— 前台刷新后生效
---
## 12. 出问题怎么查
| 现象 | 原因 | 处理 |
|---|---|---|
| 评论区一直转圈 | CORS:`ALLOWED_ORIGINS` 没有博客域名 | 加上域名重新 deploy |
| `401 Unauthorized` 登录失败 | 第 4 步密码没写进去 | `npx wrangler secret put ADMIN_PASSWORD` 后重新跑第 7 步 |
| 后台打不开、404 | `/sidebar/` 静态资源没上传 | 确认 `artalk-cf/public/sidebar/index.html` 存在,重新 deploy |
| 评论为空但后台有数据 | `page_key` 对不上 | 检查博客的 `pageKey` 是不是 `/xxx.html`;D1 里 `SELECT page_key FROM comments LIMIT 5` 比对 |
| 部分回复层级错乱 | 导入时 rid 没回填 | 确认 `run.sh` 里 `04-rid-fix-*.sql` 都执行过了 |
| 报 `Too many requests` | 限流生效(同 IP 60 秒 5 条) | 正常行为;管理员发表不受限 |
| 前端弹版本不匹配黄条 | 客户端版本 ≠ 服务端上报版本 | 服务端固定报 `2.8.7`;若你把博客的 `Artalk.js` 升级了,改 `src/routes/public.ts` 的 `SERVER_API_VERSION` |
| 大页面(994 条评论)超时 | Workers 免费版 CPU 10ms | 调小客户端 `pagination.pageSize`,或上 Workers Paid |
实时日志:
```bash
npx wrangler tail
```
---
## 附:也可以让我代跑
如果你不想自己敲,可以给我一个 **Cloudflare API Token**(权限只需
`Workers Scripts: Edit` + `D1: Edit` + `Account Settings: Read`),我可以直接执行第 2~8 步。
用完请立刻在 Cloudflare 后台把那个 token 删掉。
第 1 步(浏览器登录授权)和第 9 步(改 NS)无论如何都得你自己来。