同步用户更新:扩展v2.3(中继重试+前台激活+智能签到检测),含图标与社区模块

This commit is contained in:
hani committed 2026-09-28 13:06:11 +08:00
1 parent 7740c5702a
commit fcd2f70bb8
49 files changed
+4548 -466

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
# 依赖与构建产物
node_modules/
dist/
# Wrangler / Cloudflare 本地状态(里面有你的账号信息,别提交)
.wrangler/
.dev.vars
.wrangler.toml
# 打包产物(自己本地打出来的 zip)
*.zip
# 编辑器 / 系统
.vscode/
.idea/
.DS_Store
Thumbs.db
*~
+61
View File
@@ -0,0 +1,61 @@
# 贡献指南(CONTRIBUTING)
感谢愿意一起把这个面板做厚一点。三条最常走的路,选一条就行:
| 我想… | 怎么做 | 需要会写代码吗 |
|---|---|---|
| **加一个网站的签到** | 在自己的面板里用「自定义 HTTP」多步录制调通 → 导出为社区配置 → 提 PR 到 `community/sites/` | ❌ 不用,填表格即可 |
| **修站点改版** | 站点改版后配置失效 → 重录一遍 → 覆盖 `community/sites/` 里那份旧配置 | ❌ 不用 |
| **改面板本身** | 改 `src/` 或 `public/index.html` → 跑 `node tools/verify.mjs` → 提 PR | ✅ 会 JS |
---
## 一、零代码贡献:交一份站点适配
这是本项目最需要的贡献。**别人不会再为了同一个网站重复踩坑。**
1. 面板 → 账号页 → 「添加账号」→ 站点选 **自定义 HTTP** → **多步录制**
- 在电脑浏览器人工登录该网站、完成一次真实签到
- F12 → 网络面板 → 对流程里每个请求「复制为 cURL」→ 每步点「从 cURL 填入」
- 需要跨步骤传值的(先登录拿 token 再签到)用「提取变量」,如 `token=data.token`,下一步用 `{{token}}` 引用
- 点「测试运行」确认整条链路能跑通
2. 回到 **🌍 社区站点(开源共享)** 卡片 → 「导出我的配置」→ 选这个账号 → 导出
- 面板会自动做三件事:凭据换成 `{{cookie}}` 占位符、域名换成 `{{site_url}}`、按用到的占位符倒推 `fields`
3. 把 JSON 存成 `community/sites/<域名用下划线>.json`,在 `community/index.json` 里加一条,开 PR
**PR 检查清单**
- [ ] 文件里**没有任何真实凭据**(Cookie / token / 密码 / 密钥);`grep` 一下自己都看不懂的长字符串
- [ ] `id` 用站点域名(如 `bbs_example_com`),不与 `src/sites/` 里的内置 id 冲突
- [ ] `name` / `desc` / `tips` 写清楚,`author` 填你的 GitHub ID,`version` 从 `1.0.0` 起
- [ ] 站点屏蔽机房 IP(403 WAF、要 IPv6、要人机验证)→ `execution` 写 `"browser"`(= 本地网络,需浏览器扩展在线)
- [ ] 在面板里「校验预览」能看到「校验通过」
安全底线:**配置是公开分享物,任何情况下都不允许带别人的 Cookie。** 面板的 `validateSiteConfig`
会直接拒绝带明文凭据的配置,`test/community.test.mjs` 里有对应的断言。
## 二、改代码
```bash
node tools/verify.mjs # 必须全绿:语法 / HTML 配对 / DOM 引用 / 内联 JS / 15 个测试文件
node tools/gen-ui-preview.mjs # 生成 ../../_preview/ui.html,用浏览器看排版(不部署也能看)
```
约定:
- 站点模块放 `src/sites/<id>.js`,导出 `{ id, name, fields, execution, run }`,并在 `src/sites/index.js` 注册
- **不要让面板报假的「签到成功」**:只有站点自己说成功才算成功;站点说「今日已签到」按已签到处理(`src/lib/signals.js`)
- 站点有独立日界时区的(例如按 UTC 计日),在模块里声明 `dayTz`,否则面板状态列会显示假状态
- 改 `public/index.html` 后**一定**要跑 `node tools/check-dom-refs.mjs`:顶格 `$('x').onclick = ...` 引用的 id 必须存在于静态 HTML,否则整段脚本会静默中断
## 三、报 Bug / 提需求
开 Issue 时带上这几样,能省掉一轮来回:
- 面板版本 / 站点名 / 执行模式(CF 网络 还是 本地网络)
- 「网站反馈」列里的**原文**(站点到底回了什么),以及网站上的真实状态
- 浏览器扩展是否在线
## 许可
提交即同意以本仓库的 MIT 许可发布你的贡献。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 guoxpeng
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+160 -6
View File
@@ -5,6 +5,15 @@
> 本项目只需要 **Cloudflare 免费账号**,不需要买服务器、不需要 Cloudflare 的 API Token。
<p>
<a href="https://github.com/guoxpeng/daily-checkin-panel"><img alt="GitHub" src="https://img.shields.io/badge/GitHub-guoxpeng%2Fdaily--checkin--panel-5b8cff?logo=github"></a>
<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-3ddc84"></a>
<a href="CONTRIBUTING.md"><img alt="PRs welcome" src="https://img.shields.io/badge/PRs-welcome-ffb020"></a>
</p>
**开源共建**:没有内置的网站,谁都能贡献一份适配 —— 在面板里把签到调通,导出成一段 JSON 发过来,
别人导入就能用,不用改代码、不用重新部署。见 [社区站点适配](#开源共建不写代码也能加站点社区站点适配)。
---
## 先看这里:3 个最容易踩的坑
@@ -36,6 +45,7 @@
- [日常使用](#日常使用)
- [执行模式:云端 / 本地网络](#执行模式云端--本地网络)
- [内置站点](#内置站点)
- [开源共建:不写代码也能加站点(社区站点适配)](#开源共建不写代码也能加站点社区站点适配)
- [手动取 Cookie 的各种方式](#手动取-cookie-的各种方式)
- [常见问题 FAQ](#常见问题-faq)
- [数据与安全](#数据与安全)
@@ -320,8 +330,10 @@ https://你的域名/cookie-plugin-2.2.zip
|---|---|
| 一键发送到签到面板 | 把当前网页的 Cookie + UA 发给面板,自动打开面板并保存 |
| 一键复制全部信息(含 UA) | 复制成一段 JSON,去面板「添加/更新账号」粘贴即可 |
| ▶️ 唤醒中继(立即接单) | 让后台马上开始接单(一般不用点,扩展本来就在等) |
| 🔌 面板连接检查 | 验证面板地址和 API Key 是否正确 |
| 🔌 面板连接检查 | 验证面板地址 + API Key 是否配对成功 |
工具栏上那个图标是蓝色方块里的**「签」**。弹窗里**不需要**点任何「执行」类按钮:扩展后台一直在等单,
面板点「执行」就会自己接单。
---
@@ -330,8 +342,8 @@ https://你的域名/cookie-plugin-2.2.zip
| 站点 | 需要填 | 备注 |
|---|---|---|
| NodeSeek | Cookie | 登录带 Cloudflare 人机验证,无法自动登录;支持「试试手气 / 固定 5 鸡腿」开关。**建议本地网络**(云端出口没有 IPv6 会被跳提示页) |
| 吾爱破解 | Cookie + User-Agent | Cookie 必须含 `wzws_cid`,UA 必须与抓 Cookie 的浏览器一致。**云端一定会被 403(网宿 UrlACL 封机房 IP),请用本地网络**;会自动回读吾爱币/威望 |
| 糊涂鳄 | 站点地址 + Cookie | WordPress/RiPro。自动从首页发现悬浮窗用的签到接口,并在本地记忆命中的 action |
| 吾爱破解 | Cookie + User-Agent | Cookie 必须含 `wzws_cid`,UA 必须与抓 Cookie 的浏览器一致。**云端一定会被 403(网宿 UrlACL 封机房 IP),请用本地网络**;会自动回读吾爱币/威望。每日状态看顶栏那个按钮(未签到 =「打卡签到」,已签到 =「签到完毕」);已签到就直接结束,不打签到接口 |
| 糊涂鳄 | 站点地址 + Cookie | WordPress/RiPro。只打首页悬浮窗真正的签到接口 `user_qiandao`(每日 5 晶石,站点主题 `ripro/assets/js/app.js` 里就是这么绑的),**不会**退回会员中心那个只给 1 积分的 `xb_user_qiandao`(只有真接口「不存在」时才降级,且会在反馈里标注)。中继超时后会先重打同一接口复核(可能其实已签成功);两次都没回包就报「结果未知」并自动补跑。**本站按 UTC 计日(北京时间 08:00 才重置)**,签到时间建议设在 08:05 之后 |
| V2EX | Cookie | 每日登录奖励,先读页面再取动态参数 |
| 看雪论坛 | Cookie | csrf_token 自动获取 |
| AkileCloud | `akile-token` | 从浏览器 localStorage 取;临近过期自动续期 |
@@ -339,7 +351,39 @@ https://你的域名/cookie-plugin-2.2.zip
| Discuz 每日签到 | 论坛地址 + Cookie | 通用模块,适用于装 `k_misign` 插件的论坛 |
| 天翼云盘 | 189 手机号 + 密码 | 登录前自动检测是否需要验证码;需要时先在官方 App/网页登录一次(关掉设备锁) |
| 夸克网盘 | `kps` / `sign` / `vcode` | 手机抓包获取,约 2 个月过期 |
| 自定义 HTTP | URL/方法/头/体 + 判定规则 | 万能模块,支持多步录制 |
| 自定义 HTTP | URL/方法/头/体 + 判定规则 | 万能模块,支持多步录制。**没内置的网站用这个录一遍,然后导出成社区配置分享出去** |
> 🌍 在这个列表之外的网站,不需要等作者更新代码:用下面的「社区站点适配」自己录一份,或者导入别人录好的。
---
## 开源共建:不写代码也能加站点(社区站点适配)
面板里的站点分两类:
- **内置站点**:写在 `src/sites/` 里的代码模块(上表那些),要改代码才能加
- **社区站点**:一段 JSON(怎么发请求、怎么判成功),存进 D1,**导入后立刻生效,不用重新部署**
### 用别人分享的配置(30 秒)
面板 → 账号页 → **🌍 社区站点(开源共享)** 卡片:
1. 把配置的 **raw 链接**(GitHub raw / Gist / 任意网址都行)粘到「或:配置的原始链接」,或直接把 JSON 全文粘进文本框
2. 点 **🔎 校验预览** —— 会告诉你这是什么站、要填哪些字段、几步请求、有没有夹带凭据
3. 点 **⬇️ 导入** → 然后「添加账号」里就能选到它(列表里带 🌍 社区 标记)
仓库里 `community/sites/` 放着可用的配置和模板,索引在 `community/index.json`。
### 把自己调通的站点分享出去
1. 「添加账号」→ 站点选 **自定义 HTTP** → **多步录制**:把人工签到的过程(F12 →「复制为 cURL」)一步步录进去,点「测试运行」确认能跑通
2. 回到 🌍 社区站点卡片 → 右侧「导出我的配置」→ 选这个账号 → **📤 导出为社区配置**
- 面板自动把真实 Cookie / token 换成 `{{cookie}}` 这类**占位符**,把 `https://你的域名/xx` 换成 `{{site_url}}/xx`
- 再按用到的占位符**倒推出「别人要填哪些字段」**,所以导出就是一份别人能直接用的配置
3. 复制或存成 `.json` → 放到仓库 `community/sites/` 并更新 `index.json` → 开 PR(或先开 Issue 贴出来)
> 安全:配置里**绝不允许带任何凭据**。导出时面板会自动清洗,导入时校验器也会直接拒绝带明文 Cookie 的配置。
> 详细规范(字段速查、PR 检查清单、Discuz 两步示例)见 [CONTRIBUTING.md](CONTRIBUTING.md) 和 [community/README.md](community/README.md)。
---
@@ -366,6 +410,31 @@ https://你的域名/cookie-plugin-2.2.zip
## 常见问题 FAQ
**Q:面板显示「已签到」,但我去网站点签到还能领到积分?**
A:这曾在糊涂鳄(hutue.cn / dj.hutue.cn)真实发生过,根因是**站点有两个长得一模一样的「签到」**:
| 入口 | 接口(action) | 奖励 |
|---|---|---|
| 首页右侧悬浮窗「打卡签到」(真签到) | `user_qiandao`(父主题 RiPro 的 app.js) | 每天 **5 晶石** |
| 会员中心的「首页签到」 | `xb_user_qiandao`(子主题 xb-app.js) | 只有 **1 积分** |
旧版面板打错了后者:它确实回 `status:1`「签到成功,赠送1积分」,于是面板报成功 —— 但真签到根本没做,你去网站点悬浮窗照样能再领 5 晶石。现在面板优先打 `user_qiandao`,并且会从首页按钮真正绑定的代码里发现它(主题升级也不容易失效)。
另外糊涂鳄**按 UTC 计日**(北京时间 08:00 才算它新的一天),签到时间建议设在 **08:05 之后**;面板也按 UTC 记糊涂鳄的「今天」,08:00 一到状态列会自己回到「未签到」。
**Q:吾爱破解怎么判断我到底签没签?**
A:看站点顶栏那个按钮 —— **「打卡签到」= 还没签**(可点、还挂着去签到的链接);**「签到完毕」= 已签**(点了没反应)。面板就是照它判的:看到「签到完毕」就直接结束、连签到接口都不打;等接口超时后也会回读这个按钮再下结论 —— 是「签到完毕」就报已签到,是「打卡签到」就明确告诉你「本次没有生效」,并提示你在浏览器手动点一次。
**Q:本地网络执行时提示「中继超时」,但网站里其实签到过了?**
A:这多发生在吾爱破解 —— 它的签到接口 `home.php?mod=task&do=apply` 受网宿 WAF 最严格的那一档保护,经中继时连接会被吊死到超时,而那一次请求往往已经生效。现在的做法是:每次先读一次任务页(`home.php?mod=task`),只要那儿显示「签到完毕」就直接判已签到,连签到接口都不打;真的超时了也会再回读一次任务页,确认签过了就报成功。
糊涂鳄(RiPro)则会**先自己重打一次同一个签到接口**,用站点的回答来判断上一次到底送达没有;两次都没回包就记「结果未知」,并交给定时任务自动补跑复核 —— 不再直接写「签到失败」冤枉网站。另外超时文案也直说了「请求已发出但没收到回包,站点可能已记录」,不再是含糊的「已放弃该请求」。
> 旧版扩展的任务硬超时是 45 秒,而「首次试探 12 秒 + 激活前台 1.2 秒 + 前台重试 30 秒」就要 43 秒,
> 于是第二次明明快要拿到响应也被掐掉 —— 面板上永远只有「中继执行超时」。
> 现在硬超时按公式算(`12 + 1.2 + 30 + 15 ≈ 58 秒`),并且有单测钉住这个预算关系。
> **所以升级后请务必在面板里重新下载扩展并「重新加载」**,否则跑的还是旧的 45 秒逻辑。
**Q:扩展显示「API Key 无效 / 连不上面板」,但面板里明明复制了 Key?**
A:九成是你之前给 Worker 设过 `EXTERNAL_API_KEY` 这个 Secret —— 它的优先级高于面板里生成的那串。去 Cloudflare 控制台 → 你的 Worker → **设置 → 变量和密钥**,把 `EXTERNAL_API_KEY` 删掉;或者用命令删:
@@ -403,12 +472,89 @@ A:这个账号需要本地网络但扩展不在线(见上一条 Q)。
**Q:想加一个没有内置的网站?**
A:用「自定义 HTTP」:浏览器里人工登录并手动签到一次 → F12 → 网络 → 找到签到请求 → 右键「复制为 cURL」→ 面板粘贴导入 → 填「响应应包含」关键词 → 保存试跑。多步流程(先登录拿 token)用「多步录制」。
**Q:粘进来的那一长串 Cookie,我怎么知道哪段是干什么的?**
A:用面板「添加 / 更新账号」下面的 **Cookie 解析器**(只解析、不保存):把扩展复制的内容(或 F12 的请求头、cURL、cookie.txt、光秃秃的 `a=1; b=2`)粘进去点「拆分看看」,它会逐段告诉你:
- **登录必需**(绿的):`PHPSESSID`、`wordpress_logged_in_*`、`saltkey`(Discuz 登录盐)… 这些丢了就签不了;
- **网站安全校验**(橙的):`wzws_cid`(网宿 WAF,吾爱破解缺它直接 403)、`cf_clearance`(Cloudflare 人机校验,换 IP/换浏览器会失效);
- **表单防护**(蓝的):`XSRF-TOKEN` 这类,提交任务时要一起带上;
- **统计 / 界面偏好**(灰的):`_ga`、`Hm_lvt_*`、`theme` 这些丢了也不影响签到。
值本身只显示**形态**(长度、像不像 JWT / 时间戳 / base64),不把凭据全文摊在屏幕上。想真用起来点「↗️ 填到上面并保存」即可。
**Q:扩展弹窗里需要点「立即执行」吗?**
A:不需要(那个按钮已经删了)。扩展后台是常驻长轮询,面板点「执行」后 1 秒内就会接单;弹窗打开时也只是顺手叫醒一次。另外,工具栏上那个图标是蓝色方块里的**「签」**,看到它就说明扩展已经装好了。
**Q:签到时面板要打开浏览器标签页吗?**
A:只有「本地网络」且站点声明了需要浏览器执行时才开(比如吾爱破解)。扩展会复用该站已打开的标签页,没有就新建一个**后台**标签页;执行完只关掉自己新建的那个,不会碰你正在看的页面。
**Q:更新面板代码会丢账号吗?**
A:不会。更新只替换 Worker 脚本,D1 数据库是独立的;表结构变更走版本化迁移(缺列自动补,不重建表)。
**Q:D1 免费额度够吗?**
A:个人使用完全够。每分钟一次 Cron 唤醒,绝大多数只是比对时间后立即返回,不写数据库。
**Q:定时签到到底什么时候跑?漏了一分钟会怎样?**
A:三条规则,都好预期:
1. **到点就跑**:到达「签到时间」(各账号可不同),Cron 下一分钟就执行;
2. **一天只成功一次**:当天已经**成功**过就不再重复(改了时间也一样,不会重复签);
3. **失败/结果未知会自动补跑**:隔 15 分钟再试一次,直到成功或超出**补跑窗口(6 小时)**。
第 3 条是给「漏一分钟」兜底的:旧逻辑要求「当前分钟恰好等于设定分钟」,只要那一分钟没被触发
(部署、抖动),或者你把时间改成了**已经过去**的时刻,当天就再也不会自动签到了 —— 看起来就是「不会自动签到」。
超出 6 小时窗口(比如晚上才部署、或把 20:00 改成 08:05)不会深夜突然批量签到,点一次「执行」即可。
> 部署新版后的头一次 Cron 属于「补跑窗口内」,可能会把所有已过点的账号都跑一遍 ——
> 站点当天已签的话会直接回「已签到」,纯属正常,不会重复签。
---
### 「网站反馈」里那行字是谁说的?
第一行是**网站自己的原话**(响应 JSON 里的 `message` / `msg` / `status_msg`),
下面缩进的一行是**网站原始返回**(完整 JSON / 页面片段),鼠标移上去看全文。
- 面板**不替网站说话**:不会把「今天已完成签到,请勿重复操作」改写成「今日已签到,不能重复签到」;
判定结果看「状态」列的已签到/未签到徽章
- 如果是把早先版本存的旧记录,面板会从原始返回里读出网站原话补上,并打一个「网站原话」小标记
- 原始返回后面还会跟一句 **`· 接口 user_qiandao`** 这样的标注:这是**这次到底把请求打给了哪个接口**。
想核实「面板有没有真打通」,看这一句就行 —— 站点有两个长得一样的签到时,它能一眼看出走的是哪个。
例如糊涂鳄打的是首页悬浮窗的 `user_qiandao`(每天 5 晶石);如果哪天被迫退回会员中心的
`xb_user_qiandao`(只给 1 积分),后面会额外写明「注意:本次走的是会员中心签到」
### 「结果未知」是什么状态?
本地中继把请求发出去了、但没等到回包(站点慢 / 反爬把连接吊住),面板**不会**报「签到失败」——
因为请求可能已经送达、站点可能已经签好了。这种情况记「**签到结果未知**」(状态列仍是未签到,
不会冒充成功),并说明白:
- 糊涂鳄这类站会**先自己重打一次同一个接口**:上一次若真生效了,站点这次会直接回「今日已签到」,
面板就正确地报已签到(反馈里会留一句「重试过」的痕迹)
- 仍然拿不到回包 → 定时任务会隔 15 分钟**自动补跑复核**,一直到当天成功或超出补跑窗口
- 机场这类把促销公告塞进 msg 的,只展示第一段,不会把广告刷满一屏
- 红标「!」在反馈末尾:鼠标移上去看怎么处理(切本地网络 / 重新取 Cookie / 过滑块…)
---
### 吾爱破解这类论坛,面板是怎么判断「今天签没签」的?
这类 Discuz 论坛(52pojie、飘云阁、卡饭…)有个坑:**当天状态常常是图片,不是文字**。
吾爱破解把「📋打卡签到」画在 `…/common/qds.png` 里,那个链接的文本节点是空的,
所以「读按钮文字」对它无效。可靠判据是**服务端状态**:
| 页面上的迹象 | 面板结论 |
|---|---|
| 还挂着 `home.php?mod=task&do=apply&id=2` 这条「去签到」链接(或 `…/qds.png` 那个按钮) | 未签到 → **自己去触发签到**(浏览器执行时导航过去,等同人手点一下) |
| `签到完毕` / `您已完成过此任务` / 恭喜…获得… | 已签到(任务页已显示时不打签到接口、不重复请求) |
| 游客页(`注册[Register]`) | 登录失效,提示重新取 Cookie |
判定用的是**那个任务行**的信息,不会把任务列表里别的任务(验证邮箱、推广任务)的「已完成」算到签到头上。
「签到完毕」是图片版也认:签完之后那条 `do=apply` 链接会消失,链接不在了就是今天已签。
> 同类站点(`plugin.php?id=dsu_paulsign:sign`、`k_misign:sign`、`zqlj_sign`、`.punch_btn` 等)也能识别;
> 认得出入口就直接触发,认不出就老实告诉你「请在浏览器手动签一次」,不会假装成功。
---
## 数据与安全
@@ -464,9 +610,12 @@ daily-checkin-panel/
│ │ ├── signals.js # 统一网站反馈识别(成功/已签/登录/验证码/WAF)
│ │ └── ext-auth.js # 扩展请求鉴权(API Key + 签名 + 防重放)
│ └── sites/ # 各站点签到模块 + index.js 注册表
│ ├── community.js # 社区站点适配:校验 / 导入导出 / 声明式配置 → 可执行站点
│ ├── sites/http.js # 自定义 HTTP 多步引擎(社区配置也跑它)
├── public/
│ ├── index.html # 管理后台 SPA
│ └── ext-src/ # 扩展源文件(改完需重新生成 ext-files.js)
├── community/ # 社区贡献的站点适配(JSON)+ 模板 + 索引
├── tools/
│ ├── gen-ext-files.mjs # 把 public/ext-src/ 打包进 src/ext-files.js
│ ├── gen-tp-preview.mjs # 生成时间选择器预览页
@@ -475,7 +624,12 @@ daily-checkin-panel/
└── test/ # 零依赖单测:node test/xxx.test.mjs
```
**自己加一个站点**:
**加一个站点(两种选择)**:
- **不改代码**:用「自定义 HTTP」录一遍 → 导出为社区配置 → 自己导入或分享(见上一节)。这适用于绝大多数「带 Cookie 打一两个接口」的网站。
- **要写代码**(需要特殊逻辑:人机验证、签名、多步浏览器流程):按下面走。
自己加一个内置站点:
1. 在 `src/sites/` 新建文件(照着 `nodeseek.js` 抄最省事):
+91
View File
@@ -0,0 +1,91 @@
# 社区站点适配(community)
面板**内置**的站点写在 `src/sites/*.js` 里;这份目录放的是**社区贡献**的站点适配 ——
一份 JSON 描述「怎么给这个网站签到」,任何人导入后就能用,**不用改代码、不用重新部署**。
> 一句话:**你调通一个网站,导出一段 JSON 发出来,别人就多一个能签的站。**
---
## 目录结构
```
community/
├── README.md ← 你在看的这份
├── index.json ← 已收录配置的索引(面板「导入」框可以吃这里的 raw 链接)
└── sites/
├── _template.json ← 模板:照着填就能做一个新站点
├── simple-api-signin.json ← 示例:单次 GET/POST 签到
└── discuz-signin-template.json ← 示例:两步(先取 formhash,再提交)
```
## 怎么用(使用者)
1. 打开面板 → 账号页 → **🌍 社区站点(开源共享)** 卡片
2. 把 `community/sites/xxx.json` 的 **raw 链接**粘到「或:配置的原始链接」输入框 →
**🔎 校验预览**(先看清它是什么、要填什么)→ **⬇️ 导入**
3. 回到「添加账号」,站点下拉里会多出这个站(带 🌍 社区 标记)→ 填 Cookie 保存即可
也可以直接把 JSON 全文粘进那个文本框导入。
## 怎么贡献(作者)
1. 在自己的面板里 **添加账号 → 站点选「自定义 HTTP」→ 多步录制**:把你人工签到的过程
(F12 网络面板里「复制为 cURL」)一步步录进去,点「测试运行」确认能跑通
2. 回到 **🌍 社区站点** 卡片 → 右侧「导出我的配置」→ 选这个账号 → **📤 导出为社区配置**
- 面板会自动把真实 Cookie / token 换成 `{{cookie}}` 这样的**占位符**
- 同时把 `https://你的站点域名/xxx` 换成 `{{site_url}}/xxx`,别人换成自己的域名也能用
3. 把导出的 JSON 放进这个目录(或直接开 Issue 贴出来),并更新 `index.json`
4. 开 PR。CI(`node tools/verify.mjs`)会跑校验,`test/community.test.mjs` 里有针对配置的安全断言
## 硬性要求(PR 会被打回的情形)
| 要求 | 说明 |
|---|---|
| **不能带任何凭据** | Cookie / token / 密码 / 密钥一律用 `{{占位符}}`。校验器会拦下明文凭据 |
| `id` 用站点域名 | 例如 `bbs_example_com`(2–40 位字母数字下划线短横线),避免和内置站点撞名 |
| 至少一个 `steps` | 配置就得能真的发出请求 |
| 建议写 `expect_status` / `expect_contains` | 不写就只能靠通用信号识别判成败 |
| `execution` 注明 | 站点屏蔽机房 IP(WAF 403、要 IPv6)→ 写 `"browser"`(= 本地网络,需扩展在线) |
## 配置字段速查
```jsonc
{
"schema": "daily-checkin-site/1", // 固定值
"id": "bbs_example_com", // 内码,唯一
"name": "示例论坛", // 面板里显示的名字
"desc": "每天签到领积分",
"author": "你的 GitHub ID",
"version": "1.0.0",
"domain": "bbs.example.com", // 扩展按它匹配域名(可留空)
"execution": "server", // server = CF 网络;browser = 本地网络(需扩展在线)
"fields": [ // 用户添加账号时要填的东西
{ "key": "site_url", "label": "站点地址", "type": "text", "required": true, "placeholder": "https://bbs.example.com" },
{ "key": "cookie", "label": "Cookie", "type": "textarea", "required": true },
{ "key": "user_agent","label": "User-Agent","type": "text", "required": false }
],
"steps": [ // 按顺序执行;后一步能用前一步提取的变量
{
"name": "签到",
"method": "POST",
"url": "{{site_url}}/api/sign",
"headers": { "Cookie": "{{cookie}}", "User-Agent": "{{user_agent}}" },
"body": "a=1&b=2",
"extract": { "token": "data.token" }, // 从 JSON 响应按点路径取值
"expect_status": 200,
"expect_contains": "成功"
}
],
"tips": "登录后用浏览器扩展「一键复制全部信息」,回面板 Ctrl+V 粘贴即自动保存。"
}
```
- 变量:`{{site_url}}` `{{cookie}}` `{{user_agent}}` 来自用户填的 `fields`;`{{token}}` 这类来自前面步骤的 `extract`。
- 请求头 / 请求体**写对象或字符串都行**(对象更适合手写,导出的是字符串形式)。
- `execution: "browser"` 的站点,请求会经用户的浏览器扩展发出(躲开机房 IP 封禁与人机验证)。
## 免责
这些配置是社区贡献的,站点改版后可能失效。失效时:在面板里直接用「自定义 HTTP」重新录一遍,
再把新配置导出、提 PR 覆盖旧版 —— 这就是这个目录存在的意义。
+26
View File
@@ -0,0 +1,26 @@
{
"schema": "daily-checkin-community-index/1",
"note": "已收录的社区站点适配。面板里可以直接把这些 raw 链接粘进「导入」框,一行一个。",
"sites": [
{
"id": "simple_api_signin",
"name": "示例:单次请求签到(JSON 接口)",
"author": "guoxpeng",
"version": "1.0.0",
"verified": false,
"raw": "https://raw.githubusercontent.com/guoxpeng/daily-checkin-panel/main/community/sites/simple-api-signin.json"
},
{
"id": "discuz_signin_template",
"name": "示例:Discuz 论坛签到(两步)",
"author": "guoxpeng",
"version": "1.0.0",
"verified": false,
"raw": "https://raw.githubusercontent.com/guoxpeng/daily-checkin-panel/main/community/sites/discuz-signin-template.json"
}
],
"howto": {
"use": "把上面任意 raw 链接粘到面板『🌍 社区站点』卡片的『或:配置的原始链接』里 → 校验预览 → 导入",
"contribute": "自己录一个能跑的账号 → 『导出我的配置』→ 把 JSON 放到 community/sites/ 并更新本文件 → 开 PR"
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"schema": "daily-checkin-site/1",
"id": "bbs_example_com",
"name": "示例论坛(请改成本站名)",
"desc": "模板:把这份复制走,改成你要适配的站点",
"author": "你的 GitHub ID",
"version": "1.0.0",
"domain": "bbs.example.com",
"execution": "server",
"fields": [
{
"key": "site_url",
"label": "站点地址",
"type": "text",
"required": true,
"placeholder": "https://bbs.example.com(末尾不要带 /)"
},
{
"key": "cookie",
"label": "Cookie",
"type": "textarea",
"required": true,
"placeholder": "在该网站登录后,用浏览器扩展「一键复制全部信息」"
},
{
"key": "user_agent",
"label": "User-Agent",
"type": "text",
"required": false,
"placeholder": "扩展复制时通常会自动带上,可留空"
}
],
"steps": [
{
"name": "签到",
"method": "POST",
"url": "{{site_url}}/api/sign",
"headers": {
"Cookie": "{{cookie}}",
"User-Agent": "{{user_agent}}",
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "action=sign&inajax=1",
"expect_status": 200,
"expect_contains": "成功"
}
],
"tips": "这份是模板:字段名、地址、说明都按你自己的站点改。改完在「添加账号」里测试运行,通过后再提交。"
}
@@ -0,0 +1,63 @@
{
"schema": "daily-checkin-site/1",
"id": "discuz_signin_template",
"name": "示例:Discuz 论坛签到(两步)",
"desc": "两步形态:先取页面里的 formhash,再带上它提交签到请求",
"author": "guoxpeng",
"version": "1.0.0",
"domain": "",
"execution": "browser",
"fields": [
{
"key": "site_url",
"label": "论坛地址",
"type": "text",
"required": true,
"placeholder": "https://bbs.example.com(末尾不要带 /)"
},
{
"key": "cookie",
"label": "Cookie",
"type": "textarea",
"required": true,
"placeholder": "在论坛登录后,用浏览器扩展「一键复制全部信息」"
},
{
"key": "user_agent",
"label": "User-Agent",
"type": "text",
"required": false,
"placeholder": "留空则用面板所在浏览器的 UA"
}
],
"steps": [
{
"name": "取 formhash",
"method": "GET",
"url": "{{site_url}}/plugin.php?id=dsu_paulsign:sign",
"headers": {
"Cookie": "{{cookie}}",
"User-Agent": "{{user_agent}}"
},
"extract": {
"formhash": "formhash"
},
"expect_status": 200,
"expect_contains": "formhash"
},
{
"name": "提交签到",
"method": "POST",
"url": "{{site_url}}/plugin.php?id=dsu_paulsign:sign&operation=qiandao&infloat=1&inajax=1",
"headers": {
"Cookie": "{{cookie}}",
"User-Agent": "{{user_agent}}",
"Content-Type": "application/x-www-form-urlencoded"
},
"body": "formhash={{formhash}}&qdxq=kx&qdmode=1&todaysay=&fastreply=0",
"expect_status": 200,
"expect_contains": "恭喜"
}
],
"tips": "Discuz 的签到插件五花八门(dsu_paulsign / k_misign / xb_user_qiandao…),这份只示范「先取变量再提交」的写法:把两个 URL 换成你自己站点 F12 里真实抓到的那两个,并改掉 extract 的取值路径。多数论坛会拦机房 IP,所以这里写成 execution=browser(本地网络,需扩展在线)。"
}
+43
View File
@@ -0,0 +1,43 @@
{
"schema": "daily-checkin-site/1",
"id": "simple_api_signin",
"name": "示例:单次请求签到(JSON 接口)",
"desc": "最常见的形态:带 Cookie 打一个接口,返回里含成功字样就算签到成功",
"author": "guoxpeng",
"version": "1.0.0",
"domain": "",
"execution": "server",
"fields": [
{
"key": "site_url",
"label": "站点地址",
"type": "text",
"required": true,
"placeholder": "https://example.com(末尾不要带 /)"
},
{
"key": "cookie",
"label": "Cookie",
"type": "textarea",
"required": true,
"placeholder": "登录后用浏览器扩展「一键复制全部信息」"
}
],
"steps": [
{
"name": "每日签到",
"method": "POST",
"url": "{{site_url}}/api/checkin",
"headers": {
"Cookie": "{{cookie}}",
"Content-Type": "application/json"
},
"body": {
"action": "checkin"
},
"expect_status": 200,
"expect_contains": "成功"
}
],
"tips": "这是教学示例:把 site_url / 接口路径 / 成功关键字换成你自己站点的即可。重复签到(站点回「今日已签到」)不会被判成失败。"
}
+166
View File
@@ -68,6 +68,117 @@ function parseNetscapeCookies(text) {
return parts.join('; ');
}
// ---------------------------------------------------------------------------
// 「粘进来的到底是什么」统一识别
// 一个函数吃下所有常见格式,免得页面与保存流程各判一次、迟早判出两种结果。
// 支持:扩展「一键复制全部信息」JSON / Cookie-Editor JSON / 请求头文本 /
// cURL 命令 / Netscape cookie.txt / 光秃秃的 name=value 串
// ---------------------------------------------------------------------------
function parsePasteText(rawInput) {
const raw = String(rawInput == null ? '' : rawInput).trim();
if (!raw) throw new Error('请先在目标网站用扩展「📋 一键复制全部信息」,然后在这里粘贴');
const v = raw.replace(/^["']|["'];?$/g, '');
// ①/④ JSON:扩展格式带 cookies 字段;数组则是 Cookie-Editor 导出
if (v.startsWith('{') || v.startsWith('[')) {
let d = null;
try { d = JSON.parse(v); } catch { throw new Error('JSON 格式不对,请在扩展里点「📋 一键复制全部信息」重新复制一次'); }
if (Array.isArray(d)) {
return { cookie: parseCookieEditorJson(v), domain: '', userAgent: '', format: 'Cookie-Editor JSON' };
}
if (!d || !d.cookies) throw new Error('这段 JSON 里没有 Cookie,请确认是扩展「一键复制全部信息」复制出来的');
return {
cookie: String(d.cookies),
domain: String(d.domain || ''),
userAgent: String(d.userAgent || d.ua || ''),
cookieList: Array.isArray(d.cookieList) ? d.cookieList : [],
localStorage: d.localStorage && typeof d.localStorage === 'object' ? d.localStorage : {},
stats: d.stats || null,
format: '扩展「一键复制全部信息」',
};
}
// ③ cURL:浏览器右键「复制为 cURL」
if (/^curl\s|curl\s+'https?:/i.test(v)) {
const r = extractCookieFromCurl(v);
return { cookie: r.cookie, domain: '', userAgent: r.userAgent || '', format: '浏览器复制的 cURL 命令' };
}
// ② 请求头文本:带 Cookie: / User-Agent: 这类头
if (/^\s*cookie\s*:/im.test(v) || /^\s*user-agent\s*:/im.test(v)) {
const r = extractCookieFromHeaders(v);
return { cookie: r.cookie, domain: '', userAgent: r.userAgent || '', format: '浏览器复制的请求头' };
}
// ④ Netscape cookie.txt:以 # 开头的注释 + 制表符分隔的 7 列
if (/^#(?: Netscape )?HTTP Cookie File/im.test(v) || v.split('\n').some((l) => l.split('\t').length === 7)) {
return { cookie: parseNetscapeCookies(v), domain: '', userAgent: '', format: 'cookie.txt(Netscape 格式)' };
}
// ⑤ 纯 Cookie 串
if (v.includes('=')) {
return { cookie: v, domain: '', userAgent: '', format: '纯 Cookie 字符串' };
}
throw new Error('没看出这是 Cookie,请用扩展的「📋 一键复制全部信息」再试一次');
}
// 把解析结果拆成「一段一段 + 中文说明」,供面板直接渲染(不关心 DOM)。
function splitCookieParts(cookie, cookieList) {
if (Array.isArray(cookieList) && cookieList.length) {
return cookieList.map((c) => ({
name: String(c.name || ''),
value: String(c.value == null ? '' : c.value),
note: [
c.httpOnly ? 'HttpOnly(脚本读不到、只能用扩展抓)' : '',
c.session ? '会话 Cookie(关浏览器就失效)' : '',
c.path && c.path !== '/' ? '路径 ' + c.path : '',
c.expirationDate ? '有效期至 ' + new Date(c.expirationDate * 1000).toLocaleDateString('zh-CN') : '',
].filter(Boolean).join(',') || '普通 Cookie',
}));
}
return String(cookie || '')
.split(';')
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const i = s.indexOf('=');
return { name: (i >= 0 ? s.slice(0, i) : s).trim(), value: i >= 0 ? s.slice(i + 1) : '' };
})
.filter((x) => x.name);
}
// 给每一段加上「它是什么」:类型(登录必需/安全校验/表单防护/界面偏好/统计)+ 中文说明 + 值形态
function explainCookieText(raw) {
const info = parsePasteText(raw);
const parts = splitCookieParts(info.cookie, info.cookieList).map((p) => {
const named = /^[A-Za-z0-9_.\-]+$/.test(p.name);
const d = named ? describeCookieName(p.name) : { desc: '名字含特殊字符,可能是值里多了分隔符', kind: 'other' };
const fromList = Array.isArray(info.cookieList) && info.cookieList.length;
return {
name: p.name,
kind: d.kind,
desc: d.desc,
detail: fromList ? p.note : describeCookieValue(p.value),
};
});
const counts = { login: 0, waf: 0, csrf: 0, pref: 0, stat: 0, other: 0 };
for (const p of parts) counts[p.kind] = (counts[p.kind] || 0) + 1;
return {
format: info.format,
domain: info.domain || '',
userAgent: info.userAgent || '',
cookie: info.cookie,
cookieList: info.cookieList || [],
localStorage: info.localStorage || {},
parts,
counts,
// 一句人话小结:登录必需的几段、安全校验几段
summary: '共 ' + parts.length + ' 段:登录必需 ' + counts.login + ' 段、网站安全校验 ' + counts.waf +
' 段' + (counts.csrf ? '、表单防护 ' + counts.csrf + ' 段' : '') +
(counts.stat ? '、纯统计 ' + counts.stat + ' 段(可忽略)' : ''),
};
}
function extractCookieFromCurl(cmd) {
// parseCurl 由 curl-import.js 提供(浏览器端先引入该文件,测试时先 eval 它)
const pc = typeof parseCurl === 'function' ? parseCurl : (typeof globalThis !== 'undefined' && typeof globalThis.parseCurl === 'function' ? globalThis.parseCurl : null);
@@ -79,7 +190,62 @@ function extractCookieFromCurl(cmd) {
return { cookie: parseCookieHeader(cookie), userAgent: ua, url: p.url };
}
// ============================================================================
// Cookie 逐段中文说明(面板的「Cookie 解析器」用)
// 目的:用户粘一大串 Cookie 时,能看懂哪段是登录必需的、哪段是网站安全校验
// (比如吾爱破解的 wzws_cid)、哪些只是统计(丢了也不影响签到)。
// ============================================================================
const COOKIE_KNOWN = [
[/^PHPSESSID$/i, 'PHP 会话 ID', 'login'],
[/^(session|sessionid|sid|sess|bbs_sid|connect_sid)$/i, '会话标识(服务器靠它认得你)', 'login'],
[/^JSESSIONID$/i, 'Java 会话 ID', 'login'],
[/^laravel_session$/i, 'Laravel 会话 ID', 'login'],
[/^(ASP\.NET_SessionId|__RequestVerificationToken)$/i, 'ASP.NET 会话 / 表单令牌', 'login'],
[/^wordpress_(logged_in|sec)/i, 'WordPress 登录态(糊涂鳄这类站就是它)', 'login'],
[/^wp-settings/i, 'WordPress 界面偏好', 'pref'],
[/^saltkey$/i, 'Discuz 登录盐值(配合会话一起校验)', 'login'],
[/^(c_token|token|access_token|auth|auth_token|jwt|refresh_token|x-token)$/i, '登录令牌(很多站把它当唯一凭据)', 'login'],
[/^(cf_clearance|cf_chl_\w+|__cf_bm|__cfduid)$/i, 'Cloudflare 人机校验通行证(换 IP / 换浏览器会失效)', 'waf'],
[/^(wzws_\w+|csm_\w+)$/i, '网宿 WAF 校验(吾爱破解要的就是它,缺了会被 403)', 'waf'],
[/^(acw_tc|cdn_sec_tc|aliyungf_tc|cna)$/i, '阿里云 CDN / WAF 标记', 'waf'],
[/^(XSRF-TOKEN|csrf\w*|_csrf)$/i, '跨站请求伪造防护(提交表单时要带上)', 'csrf'],
[/^(_ga|_gid|_gat\w*|Hm_lvt_\w+|Hm_lpvt_\w+|CNZZDATA\w*|_clck|_clsk)$/i, '访问统计,与登录无关(丢了也不碍事)', 'stat'],
[/^(_pk_id|_pk_ses|matomo\w*)$/i, '站点统计', 'stat'],
[/^(theme|lang|language|noticeTitle|style|fontsize|_style_\w+)$/i, '界面偏好', 'pref'],
[/^(cookie_?consent|consent|gdpr\w*)$/i, 'Cookie 同意提示的记录', 'pref'],
[/^(referer|lastvisit|lastactive|online\w*)$/i, '上次访问痕迹', 'pref'],
[/^(seraph\w*|tt_\w+|s_v_\w+|ssid|fip)$/i, '风控 / 指纹类标记', 'waf'],
];
function describeCookieName(name) {
const n = String(name || '');
for (const [re, desc, kind] of COOKIE_KNOWN) {
if (re.test(n)) return { desc: desc, kind: kind };
}
return { desc: '站点自定义', kind: 'other' };
}
// 值只讲「长什么样」,不把敏感内容摊开:长度、像不像 JWT / 时间戳 / base64。
function describeCookieValue(v) {
const s = String(v == null ? '' : v);
if (!s) return '空值(可能没抓到或已被清掉)';
const bits = ['长度 ' + s.length];
if (/^\d{10}$/.test(s)) bits.push('像 Unix 时间戳(' + new Date(Number(s) * 1000).toLocaleString('zh-CN') + ')');
else if (/^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}$/.test(s)) bits.push('像 JWT 令牌');
else if (/^[A-Za-z0-9+/]{24,}={0,2}$/.test(s)) bits.push('像 base64 数据');
else if (/^[0-9a-f]{32}$/i.test(s)) bits.push('像 32 位十六进制(Discuz 会话常见)');
else if (/\|/.test(s)) bits.push('含竖线(WordPress 登录态常见形态)');
if (/[^\x20-\x7E]/.test(s)) bits.push('含中文 / 非 ASCII 字符');
// 只露头尾给用户对一下(面板截图/录屏时不该把凭据全文摊在屏幕上)
return bits.join(',') + ':' + (s.length > 12 ? s.slice(0, 6) + '…' + s.slice(-4) : '短值(不展示)');
}
if (typeof globalThis !== 'undefined') {
globalThis.describeCookieName = describeCookieName;
globalThis.describeCookieValue = describeCookieValue;
globalThis.parsePasteText = parsePasteText;
globalThis.splitCookieParts = splitCookieParts;
globalThis.explainCookieText = explainCookieText;
globalThis.parseCookieHeader = parseCookieHeader;
globalThis.extractCookieFromHeaders = extractCookieFromHeaders;
globalThis.parseCookieEditorJson = parseCookieEditorJson;
+297 -57
View File
@@ -5,7 +5,7 @@
const DEFAULT_PANEL_URL = '__PANEL_URL__';
const ALARM_NAME = 'checkin-jobs';
const CHECK_INTERVAL_MIN = 5; // 每 5 分钟领一次待办签到任务(面板点“执行”后无需等一小时)
const CHECK_INTERVAL_MIN = 1; // 每分钟领一次「浏览器导航签到」任务(面板点「浏览器签到」后很快就会被领走)
const RELAY_ALARM = 'relay-poll';
// 注意:Chrome 会把 alarms 的周期压到最小 0.5 分钟,靠短轮询做不到「秒级响应」。
// 所以中继改成「长轮询」:请求挂起最多 20 秒,一有任务 Worker 立刻返回;
@@ -15,8 +15,17 @@ const RELAY_LONGPOLL_MS = 15000; // 单次长轮询挂起时长(Worker 端上
const RELAY_GAP_MS = 200; // 两轮长轮询之间的间隔
const RELAY_BURST_ROUNDS = 24; // 单次突发最多跑几轮长轮询(防止无限循环/异常时死循环)
const RELAY_BURST_IDLE = 6; // 连续几轮没接到任务就结束本次突发(6×15s≈90s 空转覆盖)
const RELAY_JOB_TIMEOUT_MS = 60000; // 单个中继任务硬超时,超时也要回传,避免任务永久卡在 pending
const RELAY_FETCH_TIMEOUT_MS = 30000; // 页面内单次 fetch 的超时(52pojie 这类慢站需要更久)
const RELAY_FETCH_TIMEOUT_MS = 30000; // 页面内单次 fetch 的超时(52pojie / 糊涂鳄这类慢站需要更久)
// 第一次尝试用较短超时:普通站点几百毫秒就回来了,只有「后台标签里反爬挑战跑不动」的站点会超时。
// 超时后把标签激活到前台再试一次(见 executeRelayJob)。
const RELAY_FIRST_TRY_MS = 12000;
const RELAY_ACTIVATE_WAIT_MS = 1200; // 激活前台后等页面里的挑战/定时脚本醒过来
// 任务级硬超时:超时也要回传,避免任务永久卡在 pending。
// 【必须能装下「首次试探 + 激活前台 + 前台重试」】—— 否则第二次明明快要拿到响应了,
// 也会被硬超时掐掉。线上就是这么坏的:45 秒的硬超时把 12 + 1.2 + 30 的重试链条掐死,
// 面板上永远只看到「中继执行超时」,而那个 POST 其实已经发出去了(站点可能已签到)。
// 所以下面的值用公式算,改上面任何一个常量都不会再踩这个坑。
const RELAY_JOB_TIMEOUT_MS = RELAY_FIRST_TRY_MS + RELAY_ACTIVATE_WAIT_MS + RELAY_FETCH_TIMEOUT_MS + 15000; // = 58.2s
// 获取面板地址和 API Key
async function getConfig() {
@@ -55,7 +64,16 @@ async function reportResult(panelUrl, apiKey, accountId, status, message, durati
}
// 原生页面巡检:不依赖 eval / new Function(MV3 已禁止),用于读取网站真实反馈。
// 返回 { ok, message } 或 null。
// 返回 { ok, message, unsigned?, href? } 或 null。
//
// 判定顺序很关键(都是踩过的坑):
// ① 验证码/WAF 优先 —— 这些页面里也夹带「签到」字样
// ② 再有「已验证签到」的正信号(Discuz 任务:「签到完毕 / 您已完成过此任务 / 恭喜…获得 N 吾爱币」)
// ③ 才看「去签到链接」还在不在:
// 论坛(尤其吾爱破解)把当天状态画在图片里(qds.png),文本节点是空的,
// 但**链接还在 = 服务端认为今天还没签**;签完之后链接就没了。
// 注意:必须**限定在链接自己所在的那一行**找「任务/签到」字样 —— 任务列表页里
// 还有其他任务(验证邮箱等)也带 apply 链接,拿整页搜会搞错。
async function inspectPage(tabId) {
const results = await chrome.scripting.executeScript({
target: { tabId },
@@ -75,24 +93,148 @@ async function inspectPage(tabId) {
if (WAF.some((m) => all.includes(m))) {
return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };
}
// 2) 签到成功
if (/任务已完成|签到成功|打卡成功|签到完毕|签到完成|已连续签到|领取成功|成功领取|恭喜.{0,12}(获得|领到|签到)|获得.{0,10}(鸡腿|积分|金币|铜币|银币|AK|币|空间|MB|GB|M|G)/.test(all)) {
// ---- 找「去签到」入口(Discuz 系论坛通用)----
// 已签到的视觉特征:文字或图片里直接写着已签/已打卡/连续签到
const ALREADY = /已签到|已经签到|已打卡|签到完毕|签到完成|连续签到|今日已签|明天再来|下期再来/;
// 未签到的视觉特征:文字
const SIGN_TEXT = /打卡签到|立即签到|点击签到|点击打卡|签到领奖|每日签到|每日打卡|签个到|去签到|未打卡|没有签到|还没有签到|今天还没有签到|今天还没有|立即打卡/;
// 未签到的视觉特征:图片(各 Discuz 签到插件用的图)
const SIGN_IMG = /qds\.png|signin_no\.png|pperwb\.gif|wb\.png|qiandao|qdbg|sign_?in/i;
// 已知的签到按钮 id / class(来自各社区签到脚本:discuz 任务、dsu_paulsign、k_misign、zqlj_sign 等)
const SIGN_SEL = '#kx,#JD_sign,#addsign,#sg_sign,#dcsignin_tips,#tt_sign,.punch_btn,.go-user-qiandao,a.initiate-checkin,#my_amupper,#pper_a,#sign_title,#setsign,.click-qiandao,.zzhuti_qd_1,.user-index-qd,.taskbtn';
const attrOf = (el) => ((el.getAttribute && el.getAttribute('href') || '') + ' ' +
(el.id || '') + ' ' + (el.className || '') + ' ' + (el.getAttribute && el.getAttribute('title') || '')).replace(/\s+/g, ' ');
const textOf = (el) => String(el.innerText || el.textContent || '').replace(/\s+/g, '');
const imgsOf = (el) => Array.from(el.querySelectorAll ? el.querySelectorAll('img') : [])
.concat(el.tagName === 'IMG' ? [el] : [])
.map((i) => (i.getAttribute('src') || '') + ' ' + (i.getAttribute('alt') || '')).join(' ');
const findSignLink = () => {
// ① Discuz 任务申请链接(吾爱破解这类:mod=task&do=apply&id=N)
const applies = Array.from(document.querySelectorAll('a[href*="do=apply"], a[href*="do=draw"]'))
.filter((a) => /mod=task/i.test(a.getAttribute('href') || ''));
for (const a of applies) {
const own = textOf(a) + ' ' + attrOf(a) + ' ' + imgsOf(a);
if (ALREADY.test(textOf(a))) continue;
const row = a.closest('tr') || a.closest('li') || a.closest('table') || a.parentElement;
const rowTxt = ((row && row.innerText) || '').replace(/\s+/g, ' ').slice(0, 200);
// 任务列表里那个「已完成」是隔壁任务的,必须限定在链接所在行
const rowDone = /已完成|已申请|已领取|已打卡/.test(rowTxt) && !/立即申请|马上申请|申请任务/.test(rowTxt);
if (rowDone) return { href: a.getAttribute('href') || '', label: (textOf(a) || '签到'), done: true };
if (/qds\.png|打卡|每日|签到/i.test(own + ' ' + rowTxt) || applies.length === 1) {
return { href: a.getAttribute('href') || '', label: (textOf(a) || '打卡签到'), done: false };
}
}
// ② 已知的签到按钮(可能是 <a href>,也可能是 JS 点击的 <span>/<div>/<img>)
const known = Array.from(document.querySelectorAll(SIGN_SEL));
for (const el of known) {
const own = textOf(el) + ' ' + attrOf(el) + ' ' + imgsOf(el);
if (ALREADY.test(own)) continue;
const href = (el.getAttribute && el.getAttribute('href')) || '';
const usable = href && !/^javascript:void/i.test(href) ? href : '';
return { href: usable, clickSel: true, label: (textOf(el) || '签到'), done: false };
}
// ③ 兜底:页面上写着「打卡签到 / 每日签到」的可点元素
const cands = Array.from(document.querySelectorAll('a,button,[onclick],img'));
for (const el of cands) {
const t = textOf(el);
const im = imgsOf(el);
if (ALREADY.test(t + ' ' + im)) continue;
const hit = SIGN_TEXT.test(t) || SIGN_IMG.test(im);
if (!hit) continue;
const href = (el.getAttribute && el.getAttribute('href')) || '';
if (!href && !el.onclick && !el.getAttribute('onclick')) continue; // 纯展示元素,点了也没用
return { href: /^javascript:/i.test(href) ? '' : href, clickSel: true, label: (t || '签到'), done: false };
}
return null;
};
const markForClick = (res) => {
if (!res || !res.clickSel) return '';
const el = document.querySelector(SIGN_SEL) || (SIGN_TEXT.test(compact) ? document.querySelector('a[href*="do=apply"]') : null);
if (!el) return '';
el.setAttribute('data-panel-sign', '1');
return '[data-panel-sign="1"]';
};
// 2) 「已完成」的强信号(Discuz:您已完成过此任务 / 今日已签到 / 恭喜…获得…)
const SIGNED = /签到完毕|签到完成|您已完成过此任务|您已经完成此任务|您今日已经签到|您今天已经签到|今天已经签到|已经签到过|已经完成签到|今日任务已完成|今日已签到|今天已完成签到|已连续签到|下期再来|明天再来|无需重复|恭喜.{0,14}(完成|获得|领到|签到)/;
// 成功信号:任务页直接告诉用户拿到了什么
const SUCCESS = /任务已完成|签到成功|打卡成功|领取成功|成功领取|获得.{0,10}(鸡腿|积分|金币|铜币|银币|吾爱币|AK|币|空间|MB|GB)/;
// 注入环境可能没有 DOM API(部分页面/测试台架):退回到纯文本 + 正则,
// 只认「do=apply 链接 + 签到图/签到字样」这个最硬的组合。
let link = null;
try { link = findSignLink(); } catch (e) { link = null; }
if (!link) {
const am = all.match(/<a[^>]*href=["']([^"']*mod=task[^"']*do=(?:apply|draw)[^"']*)["'][^>]*>/i);
if (am && /qds\.png|打卡签到|每日签到|每日打卡|签到完毕/i.test(all) && !/已完成过此任务|您已经完成此任务/.test(all)) {
link = { href: am[1].replace(/&amp;/g, '&'), label: '打卡签到', done: false };
}
}
if (SUCCESS.test(all)) {
return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };
}
// 3) 今日已签到(Discuz 重复申请会返回「您已完成过此任务」)
if (/今日已签到|今天已签到|已经签到|已签到|重复签到|请勿重复|无需重复|下期再来|已完成过此任务|已领取/.test(all)) {
return { ok: true, message: '今日已签到,无需重复' };
}
// 4) 未登录
if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录/.test(all)) {
// 强信号优先于「还挂着链接」:例如任务列表里签到那条已「已完成」,
// 而顶栏还挂着别的任务的 apply 链接
if (SIGNED.test(all)) return { ok: true, message: '今日已签到,无需重复' };
if (link && link.done) return { ok: true, message: '今日已签到(任务页显示:已完成)' };
// 4) 未登录(Discuz 游客页会写「注册[Register]」)—— 比「未签到」更优先:
// 登录失效时页面导航里也有「每日签到」这类字样,不能把它当成“还没签到”。
if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\[Register\]|立即注册|登录后才能/.test(all)) {
return { ok: false, message: '登录已失效,请重新获取 Cookie' };
}
// 页面上写着「还没签到」却找不到可点的入口(插件式签到页、被遮挡的按钮):
// 如实报未签到,并说清要人工处理,不要吐一句“未识别到成功标识”。
if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡/.test(all)) {
return { ok: false, unsigned: true, href: '', clickSel: '',
message: '网站显示还没签到,但页面上找不到可点的签到入口' };
}
if (link) {
let clickSel = '';
try { clickSel = markForClick(link); } catch (e) { clickSel = ''; }
return {
ok: false, unsigned: true, href: link.href || '', clickSel,
message: '网站还挂着「' + (link.label || '签到') + '」入口 —— 今天还没签上',
};
}
return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };
},
});
return (results && results[0] && results[0].result) || null;
}
// 把页面里找到的「去签到」链接解析成绝对地址,并做同源校验。
// 页面内容不可信:只允许跳到当前站点的同源地址(http/https + 同 host)。
function resolveSignHref(result, tab) {
const raw = result && result.href;
if (!raw) return '';
try {
const base = (tab && tab.url) || ('https://' + (tab && tab.pendingUrl ? '' : ''));
const u = new URL(raw, base || undefined);
const cur = tab && tab.url ? new URL(tab.url) : null;
if (cur && u.host !== cur.host) return '';
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
return u.href;
} catch {
return '';
}
}
// 等待标签页加载完成(带超时),用于导航后复查
function waitTabComplete(tabId, timeoutMs) {
return new Promise((resolve) => {
const listener = (id, info) => {
if (id === tabId && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs || 20000);
});
}
// 尝试执行面板下发的脚本字符串。
// 注意:MV3 禁止 new Function / eval,多数情况下这里会失败——失败时由调用方回退到原生巡检。
async function runPanelScript(tabId, job) {
@@ -170,6 +312,56 @@ async function executeJob(job) {
if (!result) result = await inspectPage(tab.id);
if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');
// 页面还挂着「打卡签到」入口 = 服务端认为今天没签。
// ① 是链接(吾爱破解这类 Discuz 任务)→ 导航过去,等同人手点一下;
// ② 是 JS 按钮(dsu_paulsign / k_misign 等插件)→ 在页面里点一下。
// 两条路都是真实用户行为,之前实测过:脚本化 fetch 会被 WAF 吊死,而导航/点击能过。
if (!result.ok && result.unsigned) {
const signHref = resolveSignHref(result, tab);
try {
if (signHref) {
await chrome.tabs.update(tab.id, { url: signHref });
await waitTabComplete(tab.id, 20000);
await new Promise((r) => setTimeout(r, 2000));
} else if (result.clickSel) {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (sel) => { const el = document.querySelector(sel); if (el) el.click(); },
args: [result.clickSel],
});
await new Promise((r) => setTimeout(r, 3000));
}
const after = (signHref || result.clickSel) ? await inspectPage(tab.id) : null;
if (after) result = after;
} catch (e) {
console.log('[签到面板] 触发签到失败:', e.message || e);
}
}
// 连入口都没认出来(站点改版/插件页),但面板给了「签到地址」——那是站点模块自己知道的,
// 再试一次,别白自放弃(同源校验后仍然只跳本域)。
if (!result.ok && result.unsigned && !result.href && !result.clickSel && job.sign_url) {
try {
const u = new URL(job.sign_url);
const sameHost = !job.domain || u.hostname === job.domain || u.hostname.endsWith('.' + job.domain);
if ((u.protocol === 'http:' || u.protocol === 'https:') && sameHost) {
await chrome.tabs.update(tab.id, { url: u.href });
await waitTabComplete(tab.id, 20000);
await new Promise((r) => setTimeout(r, 2000));
const after = await inspectPage(tab.id);
if (after) result = after;
}
} catch (e) {
console.log('[签到面板] 兑底签到地址不可用:', e.message || e);
}
}
// 明确未签到、且始终找不到可点的入口/地址 → 把「接下来怎么办」说清楚,
// 不要只丢一句含糊的“未识别到成功标识”。
if (!result.ok && result.unsigned && !result.href && !result.clickSel) {
result = { ...result, message: String(result.message || '今天还没签上') + ';页面上找不到可点的签到入口,请在浏览器手动签一次' };
}
return {
status: result.ok ? 'ok' : 'fail',
message: String(result.message || (result.ok ? '签到成功' : '签到失败')),
@@ -261,11 +453,20 @@ async function submitRelayResult(panelUrl, apiKey, jobId, result) {
}
// 在目标域名的页面上下文中执行单个 HTTP 请求(携带用户 Cookie)
//
// 为什么要「先短超时、超时就激活标签重试」:
// 实测(2026-09-28)发现,对 www.52pojie.cn 这类**带反爬挑战的主站**,从**后台标签**发
// fetch 会一直挂到硬超时(同一时刻同一浏览器:example.com 与 static.52pojie.cn 都正常返回 200,
// 只有主站的 robots.txt / portal.php / home.php 全部超时)。
// 原因是挑战/验证脚本在后台标签里被节流,服务端一直等不到「验证完成」,连接就不回包。
// 所以:第一次用短超时快速试探;若超时,就把标签**激活到前台**再试一次,跑完把焦点还给用户。
async function executeRelayJob(job) {
const url = new URL(job.url);
const domain = url.hostname;
let tab = null;
let created = false;
let prevActiveId = null;
let activated = false;
try {
const tabs = await chrome.tabs.query({ url: `*://${domain}/*` });
if (tabs.length > 0) {
@@ -285,59 +486,98 @@ async function executeRelayJob(job) {
});
}
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: async (url, method, headers, bodyB64, options, timeoutMs) => {
const b64ToBytes = (b64) => {
const s = atob(b64);
const arr = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
return arr;
const injectFetch = async (url, method, headers, bodyB64, options, timeoutMs) => {
const b64ToBytes = (b64) => {
const s = atob(b64);
const arr = new Uint8Array(s.length);
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
return arr;
};
const bytesToB64 = (bytes) => {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
};
const init = {
method,
headers,
credentials: 'include', // 始终携带用户 Cookie
// 页面里拿不到 opaqueredirect 的响应头,manual 等于白跑一轮,统一 follow
redirect: options.redirect === 'manual' ? 'follow' : (options.redirect || 'follow'),
};
if (bodyB64) init.body = b64ToBytes(bodyB64);
const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
if (ctrl) init.signal = ctrl.signal;
let timer = null;
// 不依赖 AbortController:某些受限环境里它不存在,那样整个任务会挂到硬超时。
// 用 Promise.race 做兜底,保证无论如何都在 timeoutMs 内**返回**(返回超时标记,不抛出)。
const timeout = new Promise((resolve) => {
timer = setTimeout(() => {
try { if (ctrl) ctrl.abort(); } catch { /* 忽略 */ }
resolve({ timeout: true });
}, timeoutMs || 18000);
});
const call = (async () => {
const resp = await fetch(url, init);
const buf = new Uint8Array(await resp.arrayBuffer());
const h = {};
resp.headers.forEach((v, k) => { h[k] = v; });
return {
status: resp.status,
headers: h,
body_base64: bytesToB64(buf),
url: resp.url, // 最终 URL(跟随重定向后)
};
const bytesToB64 = (bytes) => {
let s = '';
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
return btoa(s);
};
const init = {
method,
headers,
credentials: 'include', // 始终携带用户 Cookie
// 页面里拿不到 opaqueredirect 的响应头,manual 等于白跑一轮,统一 follow
redirect: options.redirect === 'manual' ? 'follow' : (options.redirect || 'follow'),
};
if (bodyB64) init.body = b64ToBytes(bodyB64);
// 页面内自超时:即便站点把连接吊死,也要抛出错误而不是让整个任务卡到硬超时
// (AbortController 在某些受限环境里不存在,存在性判断不能省)
const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs || 18000) : null;
if (ctrl) init.signal = ctrl.signal;
try {
const resp = await fetch(url, init);
const buf = new Uint8Array(await resp.arrayBuffer());
const h = {};
resp.headers.forEach((v, k) => { h[k] = v; });
return {
status: resp.status,
headers: h,
body_base64: bytesToB64(buf),
url: resp.url, // 最终 URL(跟随重定向后)
};
} finally {
clearTimeout(timer);
}
},
args: [job.url, job.method, job.headers || {}, job.body_base64 || null, job.options || {}, RELAY_FETCH_TIMEOUT_MS],
});
})();
try {
return await Promise.race([call, timeout]);
} finally {
clearTimeout(timer);
call.catch(() => {}); // 超时后迟到的失败不要变成未处理拒绝
}
};
const r = results && results[0] && results[0].result;
const runOnce = async (timeoutMs) => {
const results = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: injectFetch,
args: [job.url, job.method, job.headers || {}, job.body_base64 || null, job.options || {}, timeoutMs],
});
return (results && results[0] && results[0].result) || null;
};
let r = await runOnce(RELAY_FIRST_TRY_MS);
if (r && r.timeout) {
// 后台标签里反爬挑战跑不动 → 把标签拿到前台重试一次
try {
const [cur] = await chrome.tabs.query({ active: true, currentWindow: true });
prevActiveId = cur && cur.id != null ? cur.id : null;
await chrome.tabs.update(tab.id, { active: true });
activated = true;
await new Promise((res) => setTimeout(res, RELAY_ACTIVATE_WAIT_MS)); // 给页面里的定时/挑战脚本一点时间
} catch { /* 激活失败也得继续试 */ }
r = await runOnce(RELAY_FETCH_TIMEOUT_MS);
}
if (!r) throw new Error('无返回结果');
if (r.timeout) {
// 把话说清楚:请求**已经发出去了**,只是没等到响应。
// 签到这类写操作可能已经生效,面板不能当成「失败」(面板会记为「结果未知」并稍后自动复核)。
return {
error: activated
? `中继请求超时(前台标签也没等到响应,${Math.round(RELAY_FETCH_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包。该站点的反爬可能要求人工验证,或站点暂时不可达 —— 若站点其实处理了,签到可能已生效`
: `中继请求超时(${Math.round(RELAY_FETCH_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包,站点可能已记录(结果未知)`,
};
}
// 把最终 URL 放入特殊头,Worker 端可读取
const headers = { ...(r.headers || {}), 'x-relay-url': r.url || job.url };
return { status: r.status, headers, body_base64: r.body_base64 };
} catch (e) {
return { error: String(e.message || e).slice(0, 500) };
} finally {
// 把焦点还给用户原来的标签
if (activated && prevActiveId != null) {
chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});
}
// 复用的标签页保留,自己新建的关闭(避免堆积)
if (created && tab && tab.id) {
chrome.tabs.remove(tab.id).catch(() => {});
@@ -373,7 +613,7 @@ async function runRelayRound() {
// 硬超时:即使页面 fetch 或注入挂起,也必须回传结果,否则任务会永久卡在队列
const result = await Promise.race([
executeRelayJob(job),
new Promise((resolve) => setTimeout(() => resolve({ error: `中继执行超时(${RELAY_JOB_TIMEOUT_MS / 1000}秒),已放弃该请求` }), RELAY_JOB_TIMEOUT_MS)),
new Promise((resolve) => setTimeout(() => resolve({ error: `中继执行超时(${Math.round(RELAY_JOB_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包,站点可能已记录(结果未知)` }), RELAY_JOB_TIMEOUT_MS)),
]);
await submitRelayResult(panelUrl, apiKey, job.id, result);
} catch (e) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+16 -4
View File
@@ -1,15 +1,27 @@
{
"action": {
"default_popup": "popup.html",
"default_title": "cookie插件2.2"
"default_title": "cookie插件2.2:一键取 Cookie、本地网络中继、浏览器签到",
"default_icon": {
"16": "icon16.png",
"32": "icon32.png",
"48": "icon48.png",
"128": "icon128.png"
}
},
"background": {
"service_worker": "background.js"
},
"description": "签到面板本地网络代理:在您的网络中自动执行签到,支持 Cookie 一键获取",
"description": "签到面板助手:一键取 Cookie、本地网络中继、需要时自动完成浏览器签到",
"host_permissions": [
"<all_urls>"
],
"icons": {
"16": "icon16.png",
"32": "icon32.png",
"48": "icon48.png",
"128": "icon128.png"
},
"manifest_version": 3,
"name": "cookie插件2.2",
"permissions": [
@@ -20,5 +32,5 @@
"alarms",
"tabs"
],
"version": "2.2"
}
"version": "2.3"
}
+1 -2
View File
@@ -20,11 +20,10 @@
</style>
</head>
<body>
<h3>🍪 cookie插件2.2 <span class="count" id="count"></span></h3>
<h3>🖊️ cookie插件2.2 <span class="count" id="count"></span></h3>
<div class="muted" id="domain">正在读取当前网站…</div>
<button class="primary" id="btn-send">一键发送到签到面板</button>
<button class="ghost" id="btn-copy">📋 一键复制全部信息(含 UA)</button>
<button class="ghost" id="btn-run-now">▶️ 唤醒中继(立即接单)</button>
<button class="ghost" id="btn-check-conn">🔌 面板连接检查</button>
<label>签到面板地址</label>
<input id="panel-url" placeholder="https://daily-checkin-panel.xxx.workers.dev">
+116 -18
View File
@@ -183,18 +183,17 @@ $('api-key').onchange = () => {
chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });
};
// 立即开始中继(让后台马上领任务)。
// 后台常驻长轮询(挂起 20 秒),所以这里点一下等于“立刻唤醒”一次,
// 配合面板端的「执行」,签到请求会在 1 秒内被扩展领走,不用再等 alarm 周期。
$('btn-run-now').onclick = async () => {
// 打开弹窗时顺手叫醒一次中继(后台常驻长轮询,本来就会自己跑)。
// 以前这里还有个「立即执行待办签到」按钮——它会挂住弹窗、也让用户以为要手动点才干活,已删掉:
// 面板点「执行」时后台会在 1 秒内接到单,不需要在弹窗上再点一次。
// 延到下一个 tick:先把弹窗自己的 UI 初始化完,再去叫醒后台,
// 不抢在主界面之前干活(后台不在时静默忽略,不影响取 Cookie)。
setTimeout(() => {
try {
await chrome.runtime.sendMessage({ action: 'runRelayNow' });
status('已唤醒中继,正在实时接单…去面板点「执行」即可', 'ok');
chrome.alarms.create('relay-poll', { periodInMinutes: 0.5 });
} catch (e) {
status('唤醒失败:' + (e.message || e) + '(可重新加载扩展后重试)', 'err');
}
};
const p = chrome.runtime.sendMessage({ action: 'runRelayNow' });
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch { /* 忽略 */ }
}, 0);
// 原生页面巡检:不依赖 eval / new Function(MV3 禁止),用于读取网站真实反馈。
async function inspectPageInPopup(tabId) {
@@ -211,14 +210,72 @@ async function inspectPageInPopup(tabId) {
if (CAPTCHA.some((m) => all.includes(m))) return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|页面:' + title };
const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'Access Denied', '403 Forbidden', '请求被拦截'];
if (WAF.some((m) => all.includes(m))) return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };
// 2) 签到成功
if (/任务已完成|签到成功|打卡成功|签到完毕|签到完成|已连续签到|领取成功|成功领取|恭喜.{0,12}(获得|领到|签到)|获得.{0,10}(鸡腿|积分|金币|铜币|银币|AK|币|空间|MB|GB|M|G)/.test(all)) {
return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };
// ---- 找「去签到」入口(Discuz 系论坛通用)----
const ALREADY = /已签到|已经签到|已打卡|签到完毕|签到完成|连续签到|今日已签|明天再来|下期再来/;
const SIGN_TEXT = /打卡签到|立即签到|点击签到|点击打卡|签到领奖|每日签到|每日打卡|签个到|去签到|未打卡|还没有签到|今天还没有/;
const SIGN_IMG = /qds\.png|signin_no\.png|pperwb\.gif|wb\.png|qiandao|qdbg|sign_?in/i;
const SIGN_SEL = '#kx,#JD_sign,#addsign,#sg_sign,#dcsignin_tips,#tt_sign,.punch_btn,.go-user-qiandao,a.initiate-checkin,#my_amupper,#pper_a,#sign_title,#setsign,.click-qiandao,.zzhuti_qd_1,.user-index-qd,.taskbtn';
const attrOf = (el) => ((el.getAttribute && el.getAttribute('href') || '') + ' ' + (el.id || '') + ' ' +
(el.className || '') + ' ' + (el.getAttribute && el.getAttribute('title') || '')).replace(/\s+/g, ' ');
const textOf = (el) => String(el.innerText || el.textContent || '').replace(/\s+/g, '');
const imgsOf = (el) => Array.from(el.querySelectorAll ? el.querySelectorAll('img') : [])
.concat(el.tagName === 'IMG' ? [el] : [])
.map((i) => (i.getAttribute('src') || '') + ' ' + (i.getAttribute('alt') || '')).join(' ');
const findSignLink = () => {
const applies = Array.from(document.querySelectorAll('a[href*="do=apply"], a[href*="do=draw"]'))
.filter((a) => /mod=task/i.test(a.getAttribute('href') || ''));
for (const a of applies) {
if (ALREADY.test(textOf(a))) continue;
const own = textOf(a) + ' ' + attrOf(a) + ' ' + imgsOf(a);
const row = a.closest('tr') || a.closest('li') || a.closest('table') || a.parentElement;
const rowTxt = ((row && row.innerText) || '').replace(/\s+/g, ' ').slice(0, 200);
const rowDone = /已完成|已申请|已领取|已打卡/.test(rowTxt) && !/立即申请|马上申请|申请任务/.test(rowTxt);
if (rowDone) return { href: a.getAttribute('href') || '', label: (textOf(a) || '签到'), done: true };
if (/qds\.png|打卡|每日|签到/i.test(own + ' ' + rowTxt) || applies.length === 1) {
return { href: a.getAttribute('href') || '', label: (textOf(a) || '打卡签到'), done: false };
}
}
const known = Array.from(document.querySelectorAll(SIGN_SEL));
for (const el of known) {
const own = textOf(el) + ' ' + attrOf(el) + ' ' + imgsOf(el);
if (ALREADY.test(own)) continue;
const href = (el.getAttribute && el.getAttribute('href')) || '';
const usable = href && !/^javascript:void/i.test(href) ? href : '';
el.setAttribute('data-panel-sign', '1');
return { href: usable, clickSel: '[data-panel-sign="1"]', label: (textOf(el) || '签到'), done: false };
}
for (const el of Array.from(document.querySelectorAll('a,button,[onclick],img'))) {
const t = textOf(el);
const im = imgsOf(el);
if (ALREADY.test(t + ' ' + im)) continue;
if (!(SIGN_TEXT.test(t) || SIGN_IMG.test(im))) continue;
const href = (el.getAttribute && el.getAttribute('href')) || '';
if (!href && !el.onclick && !el.getAttribute('onclick')) continue;
el.setAttribute('data-panel-sign', '1');
return { href: /^javascript:/i.test(href) ? '' : href, clickSel: '[data-panel-sign="1"]', label: (t || '签到'), done: false };
}
return null;
};
const SIGNED = /签到完毕|签到完成|您已完成过此任务|您已经完成此任务|您今日已经签到|您今天已经签到|今天已经签到|已经签到过|已经完成签到|今日任务已完成|今日已签到|今天已完成签到|已连续签到|下期再来|明天再来|无需重复|恭喜.{0,14}(完成|获得|领到|签到)/;
const SUCCESS = /任务已完成|签到成功|打卡成功|领取成功|成功领取|获得.{0,10}(鸡腿|积分|金币|铜币|银币|吾爱币|AK|币|空间|MB|GB)/;
let link = null;
try { link = findSignLink(); } catch (e) { link = null; }
if (!link) {
const am = all.match(/<a[^>]*href=["']([^"']*mod=task[^"']*do=(?:apply|draw)[^"']*)["'][^>]*>/i);
if (am && /qds\.png|打卡签到|每日签到|每日打卡|签到完毕/i.test(all) && !/已完成过此任务|您已经完成此任务/.test(all)) {
link = { href: am[1].replace(/&amp;/g, '&'), label: '打卡签到', done: false };
}
}
// 3) 今日已签到(Discuz 重复申请会返回「您已完成过此任务」)
if (/今日已签到|今天已签到|已经签到|已签到|重复签到|请勿重复|无需重复|下期再来|已完成过此任务|已领取/.test(all)) return { ok: true, message: '今日已签到,无需重复' };
// 4) 未登录
if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录/.test(all)) return { ok: false, message: '登录已失效,请重新获取 Cookie' };
if (SUCCESS.test(all)) return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };
if (SIGNED.test(all)) return { ok: true, message: '今日已签到,无需重复' };
if (link && link.done) return { ok: true, message: '今日已签到(任务页显示:已完成)' };
// 4) 未登录(Discuz 游客页会写「注册[Register]」)——比「未签到」更优先:
// 登录失效时页面导航里也有「每日签到」这类字样。
if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\[Register\]|立即注册|登录后才能/.test(all)) return { ok: false, message: '登录已失效,请重新获取 Cookie' };
if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡/.test(all)) {
return { ok: false, unsigned: true, href: '', clickSel: '', message: '网站显示还没签到,但页面上找不到可点的签到入口' };
}
if (link) return { ok: false, unsigned: true, href: link.href || '', clickSel: link.clickSel || '', message: '网站还挂着「' + (link.label || '签到') + '」入口 —— 今天还没签上' };
return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };
},
});
@@ -296,6 +353,47 @@ async function executeJobInPopup(job, onStep) {
}
if (!result) result = await inspectPageInPopup(tab.id);
if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');
// 页面还挂着「打卡签到」入口 = 今天还没签:链接→导航过去,JS 按钮→在页面里点一下
if (!result.ok && result.unsigned && (result.href || result.clickSel)) {
try {
let navigated = false;
if (result.href) {
const base = tab.url ? new URL(tab.url) : null;
const u = new URL(result.href, tab.url || undefined);
if ((u.protocol === 'http:' || u.protocol === 'https:') && (!base || u.host === base.host)) {
step('网站显示还没签到,正在执行签到…');
await chrome.tabs.update(tab.id, { url: u.href });
await new Promise((resolve) => {
const listener = (tabId, info) => {
if (tabId === tab.id && info.status === 'complete') {
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
};
chrome.tabs.onUpdated.addListener(listener);
setTimeout(resolve, 20000);
});
await new Promise((r) => setTimeout(r, 2000));
navigated = true;
}
}
if (!navigated && result.clickSel) {
step('网站显示还没签到,正在点击签到按钮…');
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (sel) => { const el = document.querySelector(sel); if (el) el.click(); },
args: [result.clickSel],
});
await new Promise((r) => setTimeout(r, 3000));
}
const after = await inspectPageInPopup(tab.id);
if (after) result = after;
} catch (e) {
console.log('[签到面板] 触发签到失败:', e.message || e);
}
}
step('解析结果…');
return {
status: result.ok ? 'ok' : 'fail',
+703 -155
View File
File diff suppressed because it is too large. Load diff
+354
View File
@@ -0,0 +1,354 @@
// 社区共享的「站点适配配置」
// ---------------------------------------------------------------------------
// 开源配套的核心:让**不会写代码的人也能贡献一个站点的适配**。
//
// 思路:大多数站点的签到其实就是「发一两个 HTTP 请求 + 看返回里有没有成功字样」,
// 而这套东西本项目的「自定义 HTTP 多步引擎」(src/sites/http.js)已经能做。
// 所以社区配置 = 一份**声明式 JSON**:
// · 站点叫什么、要用户填哪些字段(Cookie / UA / 站点地址 …)
// · 按顺序发哪些请求(可引用 {{cookie}}、{{user_agent}}、{{site_url}} 与上一步提取的变量)
// · 怎么判定成功(expect_status / expect_contains,再叠加统一的信号识别器)
//
// 贡献流程:在自己面板里把某个站点配好 → 点「导出/分享」拿到这段 JSON →
// 发到 GitHub(Issue / PR / Gist 都行)→ 别人粘贴导入,就能给自己的账号签这个站。
//
// 安全:配置里**绝不能出现凭据**。导出时会把 cookie / password / token 之类的字段值清空,
// 导入时也会再扫一遍(validateSiteConfig 里拒绝任何看起来像凭据的明文)。
// ---------------------------------------------------------------------------
import { runHttpSteps } from './sites/http.js';
export const CONFIG_SCHEMA = 'daily-checkin-site/1';
// 字段 key 里出现这些词,就认为它是凭据/隐私,导出时必须清空、导入时必须拒绝带值。
// 注意:是**子串**匹配,所以「auth」会误伤「author」,这里写完整的 authorization / bearer。
const SECRET_KEY_RE = /cookie|password|passwd|token|secret|apikey|api_key|authorization|bearer|sign|vcode|kps|credential|sess(ion)?id/i;
function asText(v) {
return v == null ? '' : String(v);
}
// 把请求头名变成合法的占位符名:X-Csrf-Token → {{x_csrf_token}}
// (占位符只会被 [\w$]+ 替换,名字里带短横线的会原样发出去,所以必须归一化)
function placeholderName(key) {
return asText(key).toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'value';
}
// ---------- 校验 ----------
// 返回 { ok, errors: [], warnings: [], def }。不抛错,方便面板把问题一次列给用户。
export function validateSiteConfig(raw) {
const errors = [];
const warnings = [];
let def = raw;
if (typeof raw === 'string') {
try { def = JSON.parse(raw); }
catch (e) { return { ok: false, errors: ['不是合法的 JSON:' + String((e && e.message) || e)], warnings: [], def: null }; }
}
if (!def || typeof def !== 'object' || Array.isArray(def)) {
return { ok: false, errors: ['配置必须是一个 JSON 对象'], warnings: [], def: null };
}
if (def.schema && def.schema !== CONFIG_SCHEMA) {
errors.push(`配置版本不认识:${def.schema}(本面板支持 ${CONFIG_SCHEMA})`);
}
const id = asText(def.id).trim();
if (!/^[a-z0-9][a-z0-9_\-]{1,39}$/i.test(id)) {
errors.push('id 必须是 2–40 位的字母/数字/下划线/短横线(建议用站点域名,如 bbs_example_com)');
}
const name = asText(def.name).trim();
if (!name) errors.push('缺少站点名称 name');
if (!Array.isArray(def.fields) || !def.fields.length) {
warnings.push('没有 fields:用户添加账号时没有任何可填项(如果签到确实不需要任何输入,可以忽略)');
}
if (!Array.isArray(def.steps) || !def.steps.length) {
errors.push('缺少 steps:至少要有一个请求步骤');
}
const steps = Array.isArray(def.steps) ? def.steps : [];
steps.forEach((st, i) => {
const label = `第 ${i + 1} 步` + (st && st.name ? `(${st.name})` : '');
if (!st || typeof st !== 'object') { errors.push(`${label}:不是对象`); return; }
if (!asText(st.url).trim()) errors.push(`${label}:缺少 url`);
const m = asText(st.method || 'GET').toUpperCase();
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].includes(m)) errors.push(`${label}:不支持的方法 ${m}`);
if (st.headers != null && typeof st.headers !== 'string' && typeof st.headers !== 'object') {
errors.push(`${label}:headers 必须是 JSON 字符串或对象`);
}
// 凭据检查:导出过的配置里不该带着任何真凭据
const raw = JSON.stringify(st);
if (st.headers && typeof st.headers === 'object') {
for (const [k, v] of Object.entries(st.headers)) {
if (SECRET_KEY_RE.test(k) && asText(v) && !/\{\{/.test(asText(v))) {
errors.push(`${label}:请求头 ${k} 里带着明文凭据 —— 请改成占位符,例如 {{cookie}}(分享配置时不能带任何真实凭据)`);
}
}
}
if (SECRET_KEY_RE.test(asText(st.body)) && /=[A-Za-z0-9%_\-.]{12,}/.test(asText(st.body)) && !/\{\{/.test(asText(st.body))) {
warnings.push(`${label}:请求体里可能带着明文凭据,确认一下里面用的都是 {{占位符}}`);
}
if (!st.expect_status && !st.expect_contains) {
warnings.push(`${label}:没写 expect_status / expect_contains,只能靠通用信号识别判定成败,建议补上站点的成功特征`);
}
void raw;
});
return { ok: errors.length === 0, errors, warnings, def };
}
// ---------- 导出:把一份站点定义变成可分享的 JSON(清空一切凭据)----------
export function sanitizeSiteConfig(def) {
const out = JSON.parse(JSON.stringify(def || {}));
out.schema = CONFIG_SCHEMA;
// 清掉只对本机有意义的内部字段
delete out.community;
delete out.localId;
// 字段默认值里若有凭据痕迹,一并清掉
if (Array.isArray(out.fields)) {
out.fields = out.fields.map((f) => {
if (!f || typeof f !== 'object') return f;
const key = asText(f.key);
const copy = { ...f };
if (SECRET_KEY_RE.test(key)) copy.value = '';
delete copy.value;
return copy;
});
}
if (Array.isArray(out.steps)) {
out.steps = out.steps.map((st) => {
const copy = { ...(st || {}) };
// headers 若是 JSON 字符串,把里面看起来像凭据的值换成占位符
if (typeof copy.headers === 'string') {
try {
const h = JSON.parse(copy.headers);
for (const k of Object.keys(h)) {
if (SECRET_KEY_RE.test(k) && !/\{\{/.test(asText(h[k]))) h[k] = `{{${placeholderName(k)}}}`;
}
copy.headers = JSON.stringify(h, null, 0);
} catch { /* 不是 JSON 就原样保留 */ }
} else if (copy.headers && typeof copy.headers === 'object') {
const h = { ...copy.headers };
for (const k of Object.keys(h)) {
if (SECRET_KEY_RE.test(k) && !/\{\{/.test(asText(h[k]))) h[k] = `{{${placeholderName(k)}}}`;
}
copy.headers = h;
}
if (typeof copy.body === 'string' && SECRET_KEY_RE.test(copy.body) && !/\{\{/.test(copy.body)) {
// 拿不准的请求体:不猜,直接删掉,避免把凭据分享出去
delete copy.body;
}
return copy;
});
}
return out;
}
// ---------- 导入(存 D1)----------
export async function importSiteConfig(db, raw, opts = {}) {
const v = validateSiteConfig(raw);
if (!v.ok) {
const err = new Error(v.errors.join(';'));
err.errors = v.errors;
throw err;
}
const def = sanitizeSiteConfig(v.def);
const now = Date.now();
const existing = await db.prepare('SELECT id FROM community_sites WHERE id = ?').bind(def.id).first();
if (existing && !opts.overwrite) {
const err = new Error(`已经导入过 id 为 ${def.id} 的站点配置;再次导入会覆盖它(确认后重试)`);
err.needOverwrite = true;
throw err;
}
await db
.prepare(
'INSERT INTO community_sites(id, name, author, version, source, def, created_at, updated_at) VALUES(?,?,?,?,?,?,?,?) ' +
'ON CONFLICT(id) DO UPDATE SET name=excluded.name, author=excluded.author, version=excluded.version, source=excluded.source, def=excluded.def, updated_at=excluded.updated_at'
)
.bind(def.id, asText(def.name), asText(def.author), asText(def.version), asText(opts.source || def.source), JSON.stringify(def), now, now)
.run();
return { ok: true, id: def.id, def, warnings: v.warnings };
}
export async function listCommunitySites(db) {
try {
const { results } = await db.prepare('SELECT * FROM community_sites ORDER BY name').all();
return (results || []).map((r) => {
let def = {};
try { def = JSON.parse(r.def || '{}'); } catch { /* 坏数据跳过解析 */ }
return { id: r.id, name: r.name || def.name || r.id, author: r.author || '', version: r.version || '', source: r.source || '', def };
});
} catch {
return []; // 表还没建(老库首次请求)时不要炸
}
}
export async function deleteCommunitySite(db, id) {
await db.prepare('DELETE FROM community_sites WHERE id = ?').bind(String(id)).run();
return { ok: true };
}
// ---------- 把一份声明式配置变成可执行的站点模块 ----------
// 与内置站点模块同构(id/name/fields/run…),所以注册表、表单、调度器都不用改。
export function makeCommunitySite(def) {
const steps = Array.isArray(def.steps) ? def.steps : [];
const fields = Array.isArray(def.fields) && def.fields.length
? def.fields
: [{ key: 'cookie', label: 'Cookie', type: 'textarea', required: true }];
return {
id: def.id,
name: def.name || def.id,
desc: def.desc || `社区配置(${def.author || '匿名'}${def.version ? ' v' + def.version : ''})`,
execution: def.execution === 'browser' || def.execution === 'relay' ? 'browser' : 'server',
domain: def.domain || '',
fields,
tips: def.tips || '',
community: {
author: asText(def.author),
version: asText(def.version),
source: asText(def.source),
notes: asText(def.notes),
},
// 账号里的字段直接作为变量喂给步骤:{{cookie}} / {{user_agent}} / {{site_url}} …
async run(creds) {
const vars = {};
for (const [k, v] of Object.entries(creds || {})) vars[k] = asText(v);
// 常见别名,降低配置作者的书写负担
if (vars.cookie && !vars.cookies) vars.cookies = vars.cookie;
if (vars.user_agent && !vars.ua) vars.ua = vars.user_agent;
const r = await runHttpSteps(steps, vars);
return { ok: true, message: r.message, detail: r.detail || '' };
},
};
}
// ---------- 反向:把一个账号里录好的配置导出成可分享的 JSON ----------
// 场景:你花力气录好了一个「先登录拿 token 再签到」的多步流程 ——
// 导出这段 JSON 发到 GitHub,别人粘贴导入就能直接签这个站,不用再录一遍。
//
// 自动做三件事:
// 1. 把请求头/请求体里的真实 Cookie、token 换成 {{占位符}}(绝不能分享凭据)
// 2. 扫出步骤里用到的每个 {{变量}},倒推出「用户需要填哪些字段」
// 3. 把绝对地址拆成「{{site_url}} + 路径」,换个人用也能改域名
// 字段提示:让分享出去的配置在别人面板里也有一句像样的人话说明
const FIELD_PRESETS = {
cookie: { label: 'Cookie', type: 'textarea', required: true, placeholder: '在该网站登录后,用浏览器扩展「一键复制全部信息」;面板会自动填好' },
cookies: { label: 'Cookie', type: 'textarea', required: true, placeholder: '在该网站登录后,用浏览器扩展「一键复制全部信息」' },
user_agent: { label: 'User-Agent', type: 'text', required: true, placeholder: '用浏览器扩展复制时自动带上,通常不用手填' },
site_url: { label: '站点地址', type: 'text', required: true, placeholder: 'https://example.com(末尾不要带 /)' },
username: { label: '用户名', type: 'text', required: true },
password: { label: '密码', type: 'password', required: true },
};
function safeJSON(s, fallback) {
try { return JSON.parse(s); } catch { return fallback; }
}
// 把绝对地址拆成 {{site_url}} + 路径(换个人用也能改域名)
function splitUrlForShare(url) {
const m = String(url || '').match(/^(https?:\/\/[^/]+)(\/.*)?$/i);
if (!m) return { url: asText(url), origin: '' };
return { url: '{{site_url}}' + (m[2] || '/'), origin: m[1] };
}
/**
* 账号 → 可分享的社区配置。
* account:{ id, name, site, creds }(creds 可为 JSON 字符串或对象)
* site:siteMeta 里的那一项(可空)
* extra:{ id, name, author, version, desc, source, domain } 允许调用方覆盖
*
* 返回 { ok, config, warnings };不能分享的账号(比如内置站点,没有可复制的请求序列)
* 返回 { ok:false, reason },由面板给出人话解释。
*/
export function exportAccountConfig(account, site = {}, extra = {}) {
const creds = typeof account.creds === 'string' ? safeJSON(account.creds, {}) : (account.creds || {});
const warnings = [];
// 1) 取出步骤:多步录制优先;单次请求的账号也转成一步,同样能分享
let steps = Array.isArray(creds.steps) ? creds.steps.filter(Boolean).map((st) => ({ ...st })) : [];
let domain = site.domain || '';
if (!steps.length && creds.url) {
const split = splitUrlForShare(creds.url);
if (split.origin) domain = domain || split.origin.replace(/^https?:\/\//i, '');
steps = [{
name: '签到请求',
method: creds.method || 'GET',
url: split.url,
headers: creds.headers || '',
body: creds.body || '',
expect_status: creds.expect_status || '',
expect_contains: creds.expect_contains || '',
}];
}
if (!steps.length) {
// 用的是社区站点:配置本来就是别人分享的那份,直接复制原配置即可
if (site && site.community) {
return { ok: false, reason: `这个账号用的是社区站点「${asText(site.name)}」——它的配置就是导入进来的那一份,直接在上面「已导入的社区站点」列表里点「复制 JSON」就能再分享出去。` };
}
return { ok: false, reason: '这个账号用的是面板内置的站点模块(代码写死的签到逻辑),没有可以分享的请求序列。想分享的话,请用「自定义 HTTP」录一遍该网站的签到流程,再导出。' };
}
// 站点没登记域名时,从步骤里第一个绝对地址推一个出来
if (!domain) {
for (const st of steps) {
const m = asText(st.url).match(/^https?:\/\/([^/]+)/i);
if (m) { domain = m[1]; break; }
}
}
// 多步录制的绝对地址也换成 {{site_url}}(只在拿得到域名时做)
if (domain) {
steps = steps.map((st) => {
const u = asText(st.url);
if (!/^https?:\/\//i.test(u)) return st;
try {
const abs = new URL(u);
if (abs.hostname && abs.hostname === domain) {
return { ...st, url: '{{site_url}}' + abs.pathname + abs.search };
}
} catch { /* 地址不合法就原样保留 */ }
return st;
});
}
// 2) 先清掉一切凭据(真实 Cookie / token → {{占位符}}),再据此倒推字段。
// 顺序很关键:字段就是「清完之后还剩哪些占位符」。
const id = asText(extra.id) || (domain ? domain.replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '') : '') || 'site_' + account.id;
const config = sanitizeSiteConfig({
id,
name: asText(extra.name) || (site.name && site.id !== 'http' ? site.name : '') || domain || '社区站点',
desc: asText(extra.desc) || `每日签到(${steps.length} 步)`,
author: asText(extra.author),
version: asText(extra.version) || '1.0.0',
source: asText(extra.source),
execution: site.execution === 'browser' || site.execution === 'relay' ? 'browser' : 'server',
domain,
steps,
tips: '导入后添加账号:登录该网站 → 用浏览器扩展「一键复制全部信息」→ 回面板 Ctrl+V 粘贴即自动保存。',
});
// 3) 扫出用到的占位符,排除「自己从响应里提取」的那些(那是内部变量,不是用户要填的)
const produced = new Set();
for (const st of config.steps || []) for (const k of Object.keys(st.extract || {})) produced.add(k);
const used = new Set();
for (const m of JSON.stringify(config.steps || []).matchAll(/\{\{\s*([\w$]+)\s*\}\}/g)) used.add(m[1]);
const need = [...used].filter((k) => !produced.has(k)).sort();
config.fields = need.map((k) => {
const p = FIELD_PRESETS[k];
return p ? { key: k, ...p } : { key: k, label: k, type: 'text', required: true };
});
if (!need.length) {
warnings.push('这份配置不需要用户填任何东西,导入的人直接点执行就能跑 —— 请确认它确实不需要 Cookie');
} else if (!need.includes('cookie') && need.some((k) => /cookies?/.test(k))) {
warnings.push(`配置里用的是 {{${need.find((k) => /cookies?/.test(k))}}},建议统一改成 {{cookie}}(面板的「粘贴即保存」会填这个变量)`);
}
// 分享出去的东西里绝不允许残留凭据:再自查一遍,命中就只给警告(sanitize 已处理)
const cfgText = JSON.stringify(config);
if (/(sk-[A-Za-z0-9]{16,}|[A-Za-z0-9_\-]{40,})/.test(cfgText.replace(/\{\{[^}]*\}\}/g, ''))) {
warnings.push('配置里疑似还残留长字符串凭证,请人工确认后再分享');
}
const v = validateSiteConfig(config);
return { ok: v.ok, config, warnings: [...warnings, ...v.warnings], errors: v.errors };
}
+13
View File
@@ -75,6 +75,19 @@ const MIGRATIONS = [
async (db) => db.prepare('CREATE INDEX IF NOT EXISTS idx_ext_cmd_status ON ext_commands(status, created_at)').run(),
// v10:accounts.last_detail(网站原始回馈,账号列表「网站反馈」列直接展示网站原话)
async (db) => ensureColumn(db, 'accounts', 'last_detail', 'TEXT'),
// v11:community_sites(社区共享的「站点适配配置」)
// 开源配套:别人把自己站点的适配配置(声明式 JSON)导出分享,
// 其他人导入后就能直接签到;这里只存配置本身,不存任何凭据。
async (db) => db.prepare(`CREATE TABLE IF NOT EXISTS community_sites (
id TEXT PRIMARY KEY,
name TEXT NOT NULL DEFAULT '',
author TEXT NOT NULL DEFAULT '',
version TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL DEFAULT '',
def TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)`).run(),
];
export async function ensureSchema(db) {
+8 -4
View File
@@ -1,8 +1,12 @@
// 自动生成:扩展源文件嵌入(运行 node tools/gen-ext-files.mjs 更新)
// 源文件在 public/ext-src/;下载时由 src/ext-zip.js 注入面板地址。
export const EXT_FILES = {
"manifest.json": "{\n \"action\": {\n \"default_popup\": \"popup.html\",\n \"default_title\": \"cookie插件2.2\"\n },\n \"background\": {\n \"service_worker\": \"background.js\"\n },\n \"description\": \"签到面板本地网络代理:在您的网络中自动执行签到,支持 Cookie 一键获取\",\n \"host_permissions\": [\n \"<all_urls>\"\n ],\n \"manifest_version\": 3,\n \"name\": \"cookie插件2.2\",\n \"permissions\": [\n \"cookies\",\n \"activeTab\",\n \"scripting\",\n \"storage\",\n \"alarms\",\n \"tabs\"\n ],\n \"version\": \"2.2\"\n}",
"popup.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<style>\n body { width: 320px; padding: 16px; font-family: -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif; margin: 0; background: #0f1420; color: #e8edf7; }\n h3 { margin: 0 0 12px; font-size: 15px; }\n .muted { color: #8b98b3; font-size: 12px; line-height: 1.6; }\n button { width: 100%; padding: 11px; margin: 6px 0; border: 0; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }\n .primary { background: linear-gradient(135deg, #5b8cff, #4a7de8); color: #fff; }\n .ghost { background: transparent; border: 1px solid #26314a; color: #e8edf7; }\n button:hover { opacity: .9; }\n button:disabled { opacity: .5; cursor: wait; }\n #status { font-size: 12px; color: #8b98b3; margin-top: 8px; min-height: 18px; }\n #status.ok { color: #3ddc84; }\n #status.err { color: #ff6b6b; }\n label { font-size: 12px; color: #8b98b3; display: block; margin: 10px 0 4px; }\n input { width: 100%; box-sizing: border-box; background: #0d1322; border: 1px solid #26314a; color: #e8edf7; border-radius: 8px; padding: 9px 12px; font-size: 13px; }\n .count { display: inline-block; background: rgba(91,140,255,.15); color: #7aa5ff; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin-left: 6px; }\n</style>\n</head>\n<body>\n <h3>🍪 cookie插件2.2 <span class=\"count\" id=\"count\"></span></h3>\n <div class=\"muted\" id=\"domain\">正在读取当前网站…</div>\n <button class=\"primary\" id=\"btn-send\">一键发送到签到面板</button>\n <button class=\"ghost\" id=\"btn-copy\">📋 一键复制全部信息(含 UA)</button>\n <button class=\"ghost\" id=\"btn-run-now\">▶️ 唤醒中继(立即接单)</button>\n <button class=\"ghost\" id=\"btn-check-conn\">🔌 面板连接检查</button>\n <label>签到面板地址</label>\n <input id=\"panel-url\" placeholder=\"https://daily-checkin-panel.xxx.workers.dev\">\n <label>API Key(面板设置页获取)</label>\n <input id=\"api-key\" type=\"password\" placeholder=\"填入后面板点「执行」即用你的本机网络发请求\">\n <div id=\"status\"></div>\n <script src=\"popup.js\"></script>\n</body>\n</html>\n",
"popup.js": "// 签到面板 Cookie 助手 - popup.js\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://daily-checkin-panel.guo527029137.workers.dev 占位符),\n// 扩展首次打开时自动带出,用户仍可手动修改。\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n// API Key 不从下载包里注入:由用户在面板「设置」页复制后手动填入,避免 Key 随文件传播、也方便随时更换。\nconst $ = (id) => document.getElementById(id);\nconst status = (msg, cls = '') => { const s = $('status'); s.textContent = msg; s.className = cls; };\n\nlet cookies = [];\nlet domain = '';\n\n// 采集当前标签页的 Cookie,力求“精准而全面”:\n// ① 主源 getAll({ url }) —— 与浏览器真正会发给该地址的 Cookie 完全一致\n// (自动考虑 domain 匹配含父域、path、secure、sameSite、是否过期),对任意网站都准。\n// ② host-only 兜底 getAll({ domain: hostname })。\n// ③ 尝试分区 Cookie(CHIPS),失败则跳过。\n// 合并去重(name|domain|path),并按“路径长→域名→名称”排序,尽量贴近浏览器发送顺序。\n// 刻意不猜父域(避免把 co.uk / com.cn 这种当成域名,反而混入无关 Cookie)。\nasync function collectCookies(tab) {\n const bag = new Map();\n const put = (list) => { for (const c of list || []) bag.set(`${c.name}|${c.domain}|${c.path}`, c); };\n let host = '';\n let origin = '';\n try { const u = new URL(tab.url); host = u.hostname; origin = u.origin; } catch { /* 忽略 */ }\n\n const queries = [];\n if (tab.url) queries.push({ url: tab.url });\n if (host) queries.push({ domain: host });\n if (origin) queries.push({ url: tab.url, partitionKey: { topLevelSite: origin } });\n\n for (const q of queries) {\n try { put(await chrome.cookies.getAll(q)); } catch { /* 忽略不支持的查询 */ }\n }\n\n const list = Array.from(bag.values());\n list.sort((a, b) =>\n (b.path || '').length - (a.path || '').length ||\n String(a.domain).localeCompare(String(b.domain)) ||\n String(a.name).localeCompare(String(b.name)));\n return list;\n}\n\n// Cookie 概况:Cookie 数 / 域数 / HttpOnly 数 / 会话与持久数 / 最近过期时间\nfunction describeCookies(list) {\n const domains = new Set();\n let httpOnly = 0;\n let session = 0;\n let persistent = 0;\n for (const c of list) {\n domains.add(c.domain || '');\n if (c.httpOnly) httpOnly++;\n if (c.session) session++; else persistent++;\n }\n return { count: list.length, domains: domains.size, httpOnly, session, persistent };\n}\n\nasync function init() {\n // 读取保存的面板地址和 API Key;没有保存过则用下载时注入的默认地址(面板动态生成 zip 时填入)\n const { panelUrl, apiKey } = await chrome.storage.sync.get(['panelUrl', 'apiKey']);\n const hasDefault = typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http');\n if (panelUrl) {\n $('panel-url').value = panelUrl;\n } else if (hasDefault) {\n $('panel-url').value = DEFAULT_PANEL_URL;\n // 自动保存默认地址,避免下次为空\n chrome.storage.sync.set({ panelUrl: DEFAULT_PANEL_URL }).catch(() => {});\n }\n if (apiKey) $('api-key').value = apiKey;\n\n // 获取当前标签页\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab || !tab.url || !tab.url.startsWith('http')) {\n status('请在目标网站页面使用', 'err');\n return;\n }\n const url = new URL(tab.url);\n domain = url.hostname;\n $('domain').textContent = '当前网站:' + domain;\n\n // 获取页面真实的 User-Agent(吾爱等站点要求 UA 与 Cookie 配对)\n let pageUA = '';\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => navigator.userAgent,\n });\n pageUA = result || '';\n } catch { /* 忽略,用默认 */ }\n\n // 精准而全面地抓取 Cookie(与浏览器实际发送的登录态一致)\n cookies = await collectCookies(tab);\n const stat = describeCookies(cookies);\n\n // 顺带抓 localStorage(部分站点把 token 放这里,如 akile)\n let localStore = {};\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => {\n const o = {};\n try { for (let i = 0; i < localStorage.length && i < 200; i++) { const k = localStorage.key(i); if (k) o[k] = String(localStorage.getItem(k) || '').slice(0, 2000); } } catch { /* 忽略 */ }\n return o;\n },\n });\n localStore = result || {};\n } catch { /* 忽略 */ }\n\n window._pageUA = pageUA;\n window._localStore = localStore;\n const lsCount = Object.keys(localStore).length;\n $('count').textContent = cookies.length + ' 个';\n status(\n cookies.length\n ? `已读取 ${cookies.length} 个 Cookie(${sta Line truncated
"background.js": "// 签到面板 Cookie 助手 - 后台执行引擎\n// 每小时从面板获取 browser 模式的待执行任务,在用户浏览器中完成签到(使用用户网络),上报结果。\n\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://daily-checkin-panel.guo527029137.workers.dev 占位符)\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n\nconst ALARM_NAME = 'checkin-jobs';\nconst CHECK_INTERVAL_MIN = 5; // 每 5 分钟领一次待办签到任务(面板点“执行”后无需等一小时)\nconst RELAY_ALARM = 'relay-poll';\n// 注意:Chrome 会把 alarms 的周期压到最小 0.5 分钟,靠短轮询做不到「秒级响应」。\n// 所以中继改成「长轮询」:请求挂起最多 20 秒,一有任务 Worker 立刻返回;\n// 一轮结束马上接下一轮(RELAY_GAP_MS),延迟从 15~30 秒降到 1 秒内。\nconst RELAY_INTERVAL_MIN = 0.5; // 兜底 alarm(Service Worker 被回收后由它重新拉起长轮询循环)\nconst RELAY_LONGPOLL_MS = 15000; // 单次长轮询挂起时长(Worker 端上限 15 秒)\nconst RELAY_GAP_MS = 200; // 两轮长轮询之间的间隔\nconst RELAY_BURST_ROUNDS = 24; // 单次突发最多跑几轮长轮询(防止无限循环/异常时死循环)\nconst RELAY_BURST_IDLE = 6; // 连续几轮没接到任务就结束本次突发(6×15s≈90s 空转覆盖)\nconst RELAY_JOB_TIMEOUT_MS = 60000; // 单个中继任务硬超时,超时也要回传,避免任务永久卡在 pending\nconst RELAY_FETCH_TIMEOUT_MS = 30000; // 页面内单次 fetch 的超时(52pojie 这类慢站需要更久)\n\n// 获取面板地址和 API Key\nasync function getConfig() {\n const { panelUrl, apiKey } = await chrome.storage.sync.get(['panelUrl', 'apiKey']);\n // 面板地址:已保存 > 下载时注入的默认值\n let url = (panelUrl || '').trim().replace(/\\/$/, '');\n if (!url && typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http')) {\n url = DEFAULT_PANEL_URL.replace(/\\/$/, '');\n }\n return { panelUrl: url, apiKey: (apiKey || '').trim() };\n}\n\n// 从面板获取待执行任务\nasync function fetchJobs(panelUrl, apiKey) {\n const resp = await fetch(panelUrl + '/api/external/browser-jobs', {\n headers: { 'X-Api-Key': apiKey },\n });\n if (!resp.ok) throw new Error('获取任务失败:HTTP ' + resp.status);\n const data = await resp.json();\n return data.jobs || [];\n}\n\n// 上报结果到面板\nasync function reportResult(panelUrl, apiKey, accountId, status, message, durationMs) {\n await fetch(panelUrl + '/api/external/report', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify({\n account_id: accountId,\n status,\n message,\n detail: '',\n duration_ms: durationMs || 0,\n }),\n }).catch(() => { /* 上报失败不阻塞 */ });\n}\n\n// 原生页面巡检:不依赖 eval / new Function(MV3 已禁止),用于读取网站真实反馈。\n// 返回 { ok, message } 或 null。\nasync function inspectPage(tabId) {\n const results = await chrome.scripting.executeScript({\n target: { tabId },\n func: () => {\n const title = document.title || '';\n const bodyText = (document.body && document.body.innerText) || '';\n const html = document.documentElement ? document.documentElement.innerHTML : '';\n const all = bodyText + '\\n' + html;\n const compact = bodyText.replace(/\\s+/g, ' ').trim();\n\n // 1) 人机验证 / 验证码 / WAF:优先判定——这些页面里往往也夹带「签到」字样\n const CAPTCHA = ['验证码', '人机验证', '安全验证', '请完成验证', '滑动验证', '滑块', '智能验证', 'slidercaptcha', 'geetest', '极验', 'recaptcha', 'hcaptcha', 'turnstile', 'cf_chl', 'challenge-platform', 'Just a moment', 'checking your browser', 'turing.captcha.qcloud.com', 'TCaptcha', 'CaptchaAId', 'ibex'];\n if (CAPTCHA.some((m) => all.includes(m))) {\n return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|页面:' + title };\n }\n const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'Access Denied', '403 Forbidden', '请求被拦截'];\n if (WAF.some((m) => all.includes(m))) {\n return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };\n }\n // 2) 签到成功\n if (/任务已完成|签到成功|打卡成功|签到完毕|签到完成|已连续签到|领取成功|成功领取|恭喜.{0,12}(获得|领到|签到)|获得.{0,10}(鸡腿|积分|金币|铜币|银币|AK|币|空间|MB|GB|M|G)/.test(all)) {\n return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };\n }\n // 3) 今日已签到(Discuz 重复申请会返回「您已完成过此任务」) Line truncated
"manifest.json": "{\n \"action\": {\n \"default_popup\": \"popup.html\",\n \"default_title\": \"cookie插件2.2:一键取 Cookie、本地网络中继、浏览器签到\",\n \"default_icon\": {\n \"16\": \"icon16.png\",\n \"32\": \"icon32.png\",\n \"48\": \"icon48.png\",\n \"128\": \"icon128.png\"\n }\n },\n \"background\": {\n \"service_worker\": \"background.js\"\n },\n \"description\": \"签到面板助手:一键取 Cookie、本地网络中继、需要时自动完成浏览器签到\",\n \"host_permissions\": [\n \"<all_urls>\"\n ],\n \"icons\": {\n \"16\": \"icon16.png\",\n \"32\": \"icon32.png\",\n \"48\": \"icon48.png\",\n \"128\": \"icon128.png\"\n },\n \"manifest_version\": 3,\n \"name\": \"cookie插件2.2\",\n \"permissions\": [\n \"cookies\",\n \"activeTab\",\n \"scripting\",\n \"storage\",\n \"alarms\",\n \"tabs\"\n ],\n \"version\": \"2.3\"\n}\n",
"popup.html": "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n<meta charset=\"utf-8\">\n<style>\n body { width: 320px; padding: 16px; font-family: -apple-system, \"PingFang SC\", \"Microsoft YaHei\", sans-serif; margin: 0; background: #0f1420; color: #e8edf7; }\n h3 { margin: 0 0 12px; font-size: 15px; }\n .muted { color: #8b98b3; font-size: 12px; line-height: 1.6; }\n button { width: 100%; padding: 11px; margin: 6px 0; border: 0; border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer; }\n .primary { background: linear-gradient(135deg, #5b8cff, #4a7de8); color: #fff; }\n .ghost { background: transparent; border: 1px solid #26314a; color: #e8edf7; }\n button:hover { opacity: .9; }\n button:disabled { opacity: .5; cursor: wait; }\n #status { font-size: 12px; color: #8b98b3; margin-top: 8px; min-height: 18px; }\n #status.ok { color: #3ddc84; }\n #status.err { color: #ff6b6b; }\n label { font-size: 12px; color: #8b98b3; display: block; margin: 10px 0 4px; }\n input { width: 100%; box-sizing: border-box; background: #0d1322; border: 1px solid #26314a; color: #e8edf7; border-radius: 8px; padding: 9px 12px; font-size: 13px; }\n .count { display: inline-block; background: rgba(91,140,255,.15); color: #7aa5ff; padding: 2px 10px; border-radius: 12px; font-size: 12px; margin-left: 6px; }\n</style>\n</head>\n<body>\n <h3>🖊️ cookie插件2.2 <span class=\"count\" id=\"count\"></span></h3>\n <div class=\"muted\" id=\"domain\">正在读取当前网站…</div>\n <button class=\"primary\" id=\"btn-send\">一键发送到签到面板</button>\n <button class=\"ghost\" id=\"btn-copy\">📋 一键复制全部信息(含 UA)</button>\n <button class=\"ghost\" id=\"btn-check-conn\">🔌 面板连接检查</button>\n <label>签到面板地址</label>\n <input id=\"panel-url\" placeholder=\"https://daily-checkin-panel.xxx.workers.dev\">\n <label>API Key(面板设置页获取)</label>\n <input id=\"api-key\" type=\"password\" placeholder=\"填入后面板点「执行」即用你的本机网络发请求\">\n <div id=\"status\"></div>\n <script src=\"popup.js\"></script>\n</body>\n</html>\n",
"popup.js": "// 签到面板 Cookie 助手 - popup.js\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://daily-checkin-panel.guo527029137.workers.dev 占位符),\n// 扩展首次打开时自动带出,用户仍可手动修改。\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n// API Key 不从下载包里注入:由用户在面板「设置」页复制后手动填入,避免 Key 随文件传播、也方便随时更换。\nconst $ = (id) => document.getElementById(id);\nconst status = (msg, cls = '') => { const s = $('status'); s.textContent = msg; s.className = cls; };\n\nlet cookies = [];\nlet domain = '';\n\n// 采集当前标签页的 Cookie,力求“精准而全面”:\n// ① 主源 getAll({ url }) —— 与浏览器真正会发给该地址的 Cookie 完全一致\n// (自动考虑 domain 匹配含父域、path、secure、sameSite、是否过期),对任意网站都准。\n// ② host-only 兜底 getAll({ domain: hostname })。\n// ③ 尝试分区 Cookie(CHIPS),失败则跳过。\n// 合并去重(name|domain|path),并按“路径长→域名→名称”排序,尽量贴近浏览器发送顺序。\n// 刻意不猜父域(避免把 co.uk / com.cn 这种当成域名,反而混入无关 Cookie)。\nasync function collectCookies(tab) {\n const bag = new Map();\n const put = (list) => { for (const c of list || []) bag.set(`${c.name}|${c.domain}|${c.path}`, c); };\n let host = '';\n let origin = '';\n try { const u = new URL(tab.url); host = u.hostname; origin = u.origin; } catch { /* 忽略 */ }\n\n const queries = [];\n if (tab.url) queries.push({ url: tab.url });\n if (host) queries.push({ domain: host });\n if (origin) queries.push({ url: tab.url, partitionKey: { topLevelSite: origin } });\n\n for (const q of queries) {\n try { put(await chrome.cookies.getAll(q)); } catch { /* 忽略不支持的查询 */ }\n }\n\n const list = Array.from(bag.values());\n list.sort((a, b) =>\n (b.path || '').length - (a.path || '').length ||\n String(a.domain).localeCompare(String(b.domain)) ||\n String(a.name).localeCompare(String(b.name)));\n return list;\n}\n\n// Cookie 概况:Cookie 数 / 域数 / HttpOnly 数 / 会话与持久数 / 最近过期时间\nfunction describeCookies(list) {\n const domains = new Set();\n let httpOnly = 0;\n let session = 0;\n let persistent = 0;\n for (const c of list) {\n domains.add(c.domain || '');\n if (c.httpOnly) httpOnly++;\n if (c.session) session++; else persistent++;\n }\n return { count: list.length, domains: domains.size, httpOnly, session, persistent };\n}\n\nasync function init() {\n // 读取保存的面板地址和 API Key;没有保存过则用下载时注入的默认地址(面板动态生成 zip 时填入)\n const { panelUrl, apiKey } = await chrome.storage.sync.get(['panelUrl', 'apiKey']);\n const hasDefault = typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http');\n if (panelUrl) {\n $('panel-url').value = panelUrl;\n } else if (hasDefault) {\n $('panel-url').value = DEFAULT_PANEL_URL;\n // 自动保存默认地址,避免下次为空\n chrome.storage.sync.set({ panelUrl: DEFAULT_PANEL_URL }).catch(() => {});\n }\n if (apiKey) $('api-key').value = apiKey;\n\n // 获取当前标签页\n const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });\n if (!tab || !tab.url || !tab.url.startsWith('http')) {\n status('请在目标网站页面使用', 'err');\n return;\n }\n const url = new URL(tab.url);\n domain = url.hostname;\n $('domain').textContent = '当前网站:' + domain;\n\n // 获取页面真实的 User-Agent(吾爱等站点要求 UA 与 Cookie 配对)\n let pageUA = '';\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => navigator.userAgent,\n });\n pageUA = result || '';\n } catch { /* 忽略,用默认 */ }\n\n // 精准而全面地抓取 Cookie(与浏览器实际发送的登录态一致)\n cookies = await collectCookies(tab);\n const stat = describeCookies(cookies);\n\n // 顺带抓 localStorage(部分站点把 token 放这里,如 akile)\n let localStore = {};\n try {\n const [{ result }] = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: () => {\n const o = {};\n try { for (let i = 0; i < localStorage.length && i < 200; i++) { const k = localStorage.key(i); if (k) o[k] = String(localStorage.getItem(k) || '').slice(0, 2000); } } catch { /* 忽略 */ }\n return o;\n },\n });\n localStore = result || {};\n } catch { /* 忽略 */ }\n\n window._pageUA = pageUA;\n window._localStore = localStore;\n const lsCount = Object.keys(localStore).length;\n $('count').textContent = cookies.length + ' 个';\n status(\n cookies.length\n ? `已读取 ${cookies.length} 个 Cookie(${sta Line truncated
"background.js": "// 签到面板 Cookie 助手 - 后台执行引擎\n// 每小时从面板获取 browser 模式的待执行任务,在用户浏览器中完成签到(使用用户网络),上报结果。\n\n// DEFAULT_PANEL_URL 由面板在用户下载时动态注入(替换 https://daily-checkin-panel.guo527029137.workers.dev 占位符)\nconst DEFAULT_PANEL_URL = '__PANEL_URL__';\n\nconst ALARM_NAME = 'checkin-jobs';\nconst CHECK_INTERVAL_MIN = 1; // 每分钟领一次「浏览器导航签到」任务(面板点「浏览器签到」后很快就会被领走)\nconst RELAY_ALARM = 'relay-poll';\n// 注意:Chrome 会把 alarms 的周期压到最小 0.5 分钟,靠短轮询做不到「秒级响应」。\n// 所以中继改成「长轮询」:请求挂起最多 20 秒,一有任务 Worker 立刻返回;\n// 一轮结束马上接下一轮(RELAY_GAP_MS),延迟从 15~30 秒降到 1 秒内。\nconst RELAY_INTERVAL_MIN = 0.5; // 兜底 alarm(Service Worker 被回收后由它重新拉起长轮询循环)\nconst RELAY_LONGPOLL_MS = 15000; // 单次长轮询挂起时长(Worker 端上限 15 秒)\nconst RELAY_GAP_MS = 200; // 两轮长轮询之间的间隔\nconst RELAY_BURST_ROUNDS = 24; // 单次突发最多跑几轮长轮询(防止无限循环/异常时死循环)\nconst RELAY_BURST_IDLE = 6; // 连续几轮没接到任务就结束本次突发(6×15s≈90s 空转覆盖)\nconst RELAY_FETCH_TIMEOUT_MS = 30000; // 页面内单次 fetch 的超时(52pojie / 糊涂鳄这类慢站需要更久)\n// 第一次尝试用较短超时:普通站点几百毫秒就回来了,只有「后台标签里反爬挑战跑不动」的站点会超时。\n// 超时后把标签激活到前台再试一次(见 executeRelayJob)。\nconst RELAY_FIRST_TRY_MS = 12000;\nconst RELAY_ACTIVATE_WAIT_MS = 1200; // 激活前台后等页面里的挑战/定时脚本醒过来\n// 任务级硬超时:超时也要回传,避免任务永久卡在 pending。\n// 【必须能装下「首次试探 + 激活前台 + 前台重试」】—— 否则第二次明明快要拿到响应了,\n// 也会被硬超时掐掉。线上就是这么坏的:45 秒的硬超时把 12 + 1.2 + 30 的重试链条掐死,\n// 面板上永远只看到「中继执行超时」,而那个 POST 其实已经发出去了(站点可能已签到)。\n// 所以下面的值用公式算,改上面任何一个常量都不会再踩这个坑。\nconst RELAY_JOB_TIMEOUT_MS = RELAY_FIRST_TRY_MS + RELAY_ACTIVATE_WAIT_MS + RELAY_FETCH_TIMEOUT_MS + 15000; // = 58.2s\n\n// 获取面板地址和 API Key\nasync function getConfig() {\n const { panelUrl, apiKey } = await chrome.storage.sync.get(['panelUrl', 'apiKey']);\n // 面板地址:已保存 > 下载时注入的默认值\n let url = (panelUrl || '').trim().replace(/\\/$/, '');\n if (!url && typeof DEFAULT_PANEL_URL !== 'undefined' && DEFAULT_PANEL_URL && DEFAULT_PANEL_URL.startsWith('http')) {\n url = DEFAULT_PANEL_URL.replace(/\\/$/, '');\n }\n return { panelUrl: url, apiKey: (apiKey || '').trim() };\n}\n\n// 从面板获取待执行任务\nasync function fetchJobs(panelUrl, apiKey) {\n const resp = await fetch(panelUrl + '/api/external/browser-jobs', {\n headers: { 'X-Api-Key': apiKey },\n });\n if (!resp.ok) throw new Error('获取任务失败:HTTP ' + resp.status);\n const data = await resp.json();\n return data.jobs || [];\n}\n\n// 上报结果到面板\nasync function reportResult(panelUrl, apiKey, accountId, status, message, durationMs) {\n await fetch(panelUrl + '/api/external/report', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify({\n account_id: accountId,\n status,\n message,\n detail: '',\n duration_ms: durationMs || 0,\n }),\n }).catch(() => { /* 上报失败不阻塞 */ });\n}\n\n// 原生页面巡检:不依赖 eval / new Function(MV3 已禁止),用于读取网站真实反馈。\n// 返回 { ok, message, unsigned?, href? } 或 null。\n//\n// 判定顺序很关键(都是踩过的坑):\n// ① 验证码/WAF 优先 —— 这些页面里也夹带「签到」字样\n// ② 再有「已验证签到」的正信号(Discuz 任务:「签到完毕 / 您已完成过此任务 / 恭喜…获得 N 吾爱币」)\n// ③ 才看「去签到链接」还在不在:\n// 论坛(尤其吾爱破解)把当天状态画在图片里(qds.png),文本节点是空的,\n// 但**链接还在 = 服务端认为今天还没签**;签完之后链接就没了。\n// 注意:必须**限定在链接自己所在的那一行**找「任务/签到」字样 —— 任务列表页里\n// 还有其他任务(验证邮箱等)也带 apply 链接,拿整页搜会搞错。\nasync function inspectPage(tabId) {\n const results = await chrome.scripting.executeScript({\n target: { tabId },\n func: () => {\n const title = document.title || '' Line truncated
"@b64:icon16.png": "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAZlJREFUOE+Vk0svQ0EUx8/UQqOVku4kvoQPYWFD4hmNVyteYYFF6QJBidJGU6GJhehCWViLRT+B+B5Cqvqmbe6RM3NnOtU2OIt7M/ee///8zpkZBmYsRSuIiGAgAhpN3vTfQKC8G6+DkZQ//iuWJne+TsYaiS+XW8EdLiqS6zUbPD6XIZYoKgJpwhYvyqhjk1iPyWAOYuv2mm8j/hRvg0zY/HkJZc+6eCqU50kkXohkIbrSDmOHH/UEc2dfisBmBcjkDT7IUhkh7hWVh/1pYIAAIAYo8SmPzUY+FYEkoZ4pXIEMT45vOITR/nuNmMyYJ1xUBFerbTW9NlsM7LxVZzBzWqgS6HhmZTmw3h4rPDwVoMUCUKoYgoSGOB3KCwJTHHDboMtp4QO73ezgEIO7SbjfcoJeWbQLwCaDOXECzRMme9a3isQU/duvqjKJOcHESRal+Nhj59V/iz7fizpkzBXI1BH83Cq1NrHVXaFdGD9KKwK9DXWxZHsNxPwkEu7oQareRBusHJhemcSJYDfjBhRDe8mqyR/FpPsG9KUzUWvclUUAAAAASUVORK5CYII=",
"@b64:icon32.png": "iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAABAFJREFUWEe9V1tPE0EUPkMtqIB4e+HFd/UXEOKjD8ZLDN64CYIIxVvEK8RL1CiVggKCSLwQIYBG0QcTHzT6aPgJ/gWNiAUKpS3tmHNmZzu7O7sWQfrQZna3853zne98Z5aB7dM0xHlsLgkAHDgH4JwDx2fwl9Z0B7/Me/iMdq3sgfdX5eTD4DnGVEhzcXaA8+RCEniK0JYdHIOWCWVl+WH0Si5h09dKggvyOLAsP7xqzhN0nHqywFcicwkumXhzbSNj/7PmKu12cEzcl50P7GT/giDFQ3BPT+eYuqntjmoFN3gh13ymMjRDNMs9deAkbg4YQEKI2EPtT88oAXRFHepPcg7DF/MogMWAIyZrfIwBuLeaCm5vWVxXtEdg5JIAd/uUBcOQTFEzi+CNhKkLAn1xXGv7PMcP0BtIZ++J4nGztDUMKQK2ghMDDX1xuqozmWdnV5vbjn1NwMFiP62r789SjYcuWDMvb5uG0SvrzP8caQ0TqC5zyTpreBQzNOB0uOdGADWdc+SGL5rW0maB3lnoP50WHV5DcNzoZXOBGcDhu789wYmB+l4jAI29xhIcsn0kepGJYSL2zCViWXCKmHnVsh4yAcf92ImemJBgBt5evG0V1O9Ka6IihFmDhfbAwymYnCE/19bcngyr65kX+EYnuA0Wtc9J/Qa4fP5lS5p6vH/ozqRF7Y79kasUAKt7OG+2oQ588Ly11ovthJJbv8Rws5URwWkmHO+Okk7t4AW5AN31QnTy8+5rDEqKvdty/FscirZmW/73fjwKAx8jpoYkOGmgFgNwmeeS9sbeCMzMkbkC+Ymmn1Vzwf2qd+bCviLRxvtv/tSCUxfUdNHOTg3YvHz4cr7Zavi8WnNUvDSZsesbCbTk1gTRnkpxmvkUvkG7isdqOuecGtCA61pNXpOCQ5ix65tM+r0yN4gEduzBrFUDmikW2L0GdmwXLnjobhiymGDs9dUNdA39ojw4CW9vLA4cS8mqMQDFZNxGqM7hMIADtyeJ4Pk4hw93NlNAZa0TEI0JzehoNzIW47jqfsTwIef8Hrmc9vXFtp/6/N5rPxSNKSLGNqzqiJAF6TJXB8tSAthz9bvhA1ZwYuBox4x5ILGfXFTalxLAbgxAbV8jYdJAZTsGkB40mRyjHCNWzpIMai7ZJi/BA0lFaNpVA3Zzcaxd7NXqK07aJTgZUUXbtKsGPA8T6hvSP2RuHkqruziPz4vDhNfp1RLMMmSOcL7sPPFmVBoMO8uQ4Tz/W5/bay4zxzJ8ebBFvBmVh2Z5ciHunN/K6dVtnjsPM941l90APj98aS9Mv6mW3otQEJmeZJaSOfP54XN7YfrlVO3x0lCCJ6QmZBmWseafghssr+d/AJMFI9vo7StdAAAAAElFTkSuQmCC",
"@b64:icon48.png": "iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAYAAABXAvmHAAAAAXNSR0IArs4c6QAAB0dJREFUaEPVWktslUUUPmMfgJRH0cSVGF2IMcTEmMjKFQtNIOEhSIW2vBSRgghYykup8ii0thZRBKoJj5aXlZWJKyAuXLnTuNGVCxJE7PtJ2zvm/DNn5sz8//z33lIB76Lc+z/Pd+ab73xnBgEpn6rTYx0gZamUABKiP9FH4gH8N/pOf/R3daU6ru+h77Hj6mHx693jnRdqps8KhSmSTmw+NcrikjoY9jIdvroo5TiC0bgJtMmDDlz9psyo5Bjsgm5W11zcNTMWr3NgS4uUGZkxWYmyk5B1ylr0cjYaznGWRTNqNIgUJB9NlggOyoweJUsIuLR7honbfMHgpczYgB4sbfTQEkXVCFAyQQBc3q1GwwB4WGlDNDWzTo/ilb2lFkDV6bEInuLew0cbmiMOnQDg232zRISi6tQoxf4g1SadNoxCNNHbEQBKpczI0ohlesIaaUtRm5bNkxwBW39sUAsSUxQ+kSXA2R1TnXt++PkutN0YBumpTYg2RjBIpkB0ik2Y/Txp4wePUa07NmhrREBtzu0ocQCU1/caCfXVJkQbn0Zi00nU/NyLFE7701Vu9tOKIZ6rbOyHc172s92D58vquhwZN4lnbBHvfjWiJ7iuiFmKVMvmybm827mmsrEP/Ozn8pAVhxGACc9Wdl0/8EwEIFiMvCLVsiX/4HMJNHTNG4c6NS2VLYkVVZkAIORt6iqL4fHpic7jXmJMvRcBJNGG2w2x8cRdk+eQtxkaBji/w81+RiK3B6D1g0dNEKub+rV/kzAyag1da/U0KCxQly071AOTCgFefq4Iti6aYu5FvtNUzEYbQoXxindO3GVFLNnbfJ1AnbXNAzA6CnCeAVATtg9ObJoKJVPio5XJAJQ39ER0uFAzI5b9FYc7GU3CtOF0igBks8R4/pv37AisbUbJVMq1ccEkmPesTi8AnLs2DJXzk1Vqf2s//HFzzIzSxV0uCJq02Wijyoua3GLDl8PGkJPhMwj1k/DaDa8Vw7w5BYCZ9y3x2e1T4cYvI/D87EJ4Iu54nUz/+OsInPx+QBtoCZd2z4zO+xOW0ykGSHAAXwyHVUhBJbeuUOdYpLLNbKvxXF1yow3VrUhGN0QA9I3j6aSQNttdi0DBr6pHvgO07ZyeiAcpQ3TIhzbcdIq3jw/FVSjHTsr3NhTl7zfH4OM2q0j4Ap/vHNHyg6j3dnS5TBo6M9qYhicDIN46PuSqUA60qX59Msx9yk5cHszKoz2AHjfJ28ybUwTbliaP1tBdCeVHUYXcymuKFwNoAKCMIgB6mWoRA3QCgK2LiuHFZwqz0fuezy/95J/EBYQk0ynWfz5ouuhQA35mmy1W9xxdHg9YXHvH9AicNkottYyuPzaokx5WmzOBSYqxoCVu3Tktj7DspcsOdED7h8EVE1hc+zdTPbu0YyYxqlAEIIU2JKOo9fxT0YBeXlGOPJJtREwHCwIkoO3gxYeswO2uDJ2A9o8ec56/pPZOaotrCtm65gFdiMOdFI4N2oDW6hJYUdcLRTgNcL4EOqmRMQnt+1SBwk+oSL2/pASarvZqvkt44ekiqK1U1dlmXwLgSg+jDXcOYm3zgB6RcJHSwsTWiwBefakIKuYre7HyaLdeoVPDTNWVA/A7qYt7SqGowPolzLiRD1Msk2njqBACcFSIyWjoOLpLRzqP9DjrNm01M6DgEXdakNaTLCZxP1faOADWfNZvO8ocl/vaquOVtayu2ylGl/dYCiGU5Qc6TNP/ncd3Qxk1BEphUmhDaonDLtY09SerEFu4pTLPG+0LNS4IsgVUOa/sLXWGYPvJHvjz9kh0zAfw02/D0HAFR5EB8D0XWykkiYj6AR9AVjppYD4AfDf3Nq/MLYYti6xyoWQiV5Ozfye4oOYuCrt2A98pVjf2sZiZEqWsEkdDKAEXWU2WkxpwYwPYs67ud+UyV7XhtOHGT1Q29lkVSqFNyFG6U3Xifi3c95edD2bVR1tvRqcIQC60eRgAJNFJVHzaa8pm2uZCUg87cfmOP2nh3ltmc4TvO5BIkJ2IAFBlS1IbuiHNz/8XQBbsuWXaTseieHQSFQ29jowaOpn54G0uJKwS82UOVQftNhH91go5brXhxUt5MO1Gy+t77BQY7yox2wdLa8DzLVL+s7gLVb5DglhV36MrcUInxDbgfC8T/51fJ+UEEyhSftZjy4tYyBDA/402hpYaQIeUspTQ2glj6rrxMMaVjrMBD1niUJGKHSejaXcDOiM/++aRbjWI6g3Jmw5syYVXWA7KHA804BNFG3rOtabZao8sAqADpyB8vsUm531WG04bUqHrBABPlh3u0rHHDZNfqfNZt5lw2miJxuAjM0dFqKyuW290K+5bmvi/H4zaOPQTAq43PukCiOxwXRf+XwMtsbwYubsjlk4Jy/FeA8+LTqgYBY9TwWJ1BlfNKHhnBLgdWH4QhSmuQve7SPkqRLThsabuGS070BFJbL7LfROqNiA7rzXODi4e/QutLYGIHeCyTgAAAABJRU5ErkJggg==",
"@b64:icon128.png": "iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAAAXNSR0IArs4c6QAAHFBJREFUeF7tXQmUVNWZ/u+rbrqBpoFG40bQJCYumTMRx3GZ5eRkmaMeF8C1aZFNQWRTNIDSiCD70oBs0uDG3giKGkfHCJgxxjmZTMAYNaMTHSdjJiqm6X2prqo753/r3V7VfdXvVRdYdQ7a9eq9++67//f92/3vfQQi+kzeEr+IpkgVAFxT0rvowlQSAP/RFAVKU0Apde/s/mX+IRz3vprnU+d35jh3im+71u3c65lbZbxeOte+IkMf+Had+/PP6D0OAUIMIIYBhlEExIhBvKP5fQB4iYKxZ/essrejEBUJq9Epm+MTiVFcaw4xClh4TnfwBYG6p6mEL42VPPDcKRmFL0tdFpJ1JKPwggjfblBqWf04AJSDqYccguIiQCFx1+5ZA7aEIbtuAWBqLR3eqw8c6GxLcn2RhW8/kMRm+7KvNPOZobMBLHGESnrRPFDcuxy1xIg9s8ufzxYMWQFg6tbkIgJGdSqVErlSYL4oCVd4ssqQBa1mvsJYmgBwjhOCWoEu3jO7/9ygQAgEgGmP06EAcISm0IbLOr7AfJbNmWx+9sxXWFfTbKDfkEolLq57cOBRXSBoA2DqlmQ9AAw0b64lfNvhKth8TxYBbX4m5rsNiz4DheN1cwZU6IBACwD3PkVpogvVva7wCzZfy+ELaPP9mK/gGBhFpbB7ZmlG+WY8YdpWStGr1xd+gflawrcHVMvh46II3nSohO8EQ+gb1D3YP62M0/4YXPgF5msJP0Lme5GwDQ1iwN40IPAFQHC1X2C+lvBzwHwui0HBNAd7ZvdWylp5MLjDV2C+lvBzyXzXX3NMBj3+THWF5BhKAMBQj6ZSRwo2X8wGKjKEIWX4svb2nTya2wCfaxD9A8MAKURUAYAW4nwN4XMhXYYkT48y39UAQEgR7J1Tzsmc+4IZPqBQXYjzgyRposvw+cb5AZlvaXMXsYv3PVThZgw5AKDXr5feLdj8/Lf5HvOZOTJzxvGZ6gGu3N0/cGKHQuqAoPwKuf0eyO1HwnzmOYhBRjwzZ6A5geQC4L4dlBZm9XjdyjlRJ6DNZ5nv6gOcRSwthz0zi0zZuwCYUpuQn1dwI03FX8jt5z633x2bL7sz5pH9D1V4AMBiDiBGrWc15IoIxfyPhwXhR/W58kwIjzjv2/WXxsy2rSPE/AN76xOIwXNvJSBmKIMpF7DctbLfZj26ylunAFdcUAT/NLRYMAaqJKx1ysM72/hzfebz7ShSOFcxJczF9MxzKruABTmi3XIfzR3FFCV3PTevYouJgqlbMN9vFXX4Ci9HzO/oAtg+o0R+Ap8jyRTAxPUdanio1HYQ4dsDsu3+vtr9Gb2qGVLsWKmEb7crDamqEshH+D7KmCu1Y9W+pLZIDPbPHWDWGMHULSlzwkcWfu69/XUTS6B3L+3xhrFr2iFm2ARmL+uu8O3rvzaQwMrxfbQ7NGplMzfWkl6SFaGrfVSElnL7UobP09s6zHdITsCA/Q8NtACA9r+nmW+qfArw+DR99mPf73i03dNc3lgwx/RMj8xGSxtuD8D+O9e2QEeXdz+lUVK5UTlkPivnZ+cNIgSrd4EaR8UqSDUg7BEWfvQFj2DkuEFWFHBunRpM+OPWtoNhuQiShuuOzfe6TWH7/WXB2Z+HNl9l3o2YMZRMrk0uB0pn8WOYe29/yKkEHrpVX/e3dwJM3dwervA52xxM+ONWN0MXulF5avNVJCVAVpC7N3e9R4Bc6FjRnmB+Nqr/hsVtUFJsFkNKDoCrEVSaitFKjpZALVJabEXEzrG+pQQem6Lv/N2wsEmOkG3V7kYwkgPKH+B8R0rBIAR6l7AevEBThdOg9BkETey2Qun75N5tlMbbnQgg98zHUZsxrBguHGJ7ctoKN7wTP/pzCh7Z7WgTa1SDqP7wesK3VN+cgsnrFcBic/us9VPYQhWhTTxQaiaEyPQnKU3EEz7hX7Q2H2+KIdMT04PZ/rAHHAGwYDc6k9YI/u13imDadaVh3yZwewiAu9c3Cdfpxfkqm+805EwMxYp7A5m6ldJkMtFjGb6tAb3+wKOocYEFACt5k0wC7Jql7/hpNJ/1KSoAsEvqPIHypkFH+HiOESsGMqU2RVO4aI9VJW7LblPmkbC8fcdYTriyCC79TizrAQrrQhYAayb0gUHlPWeO2GfiARAe8517mGsRJ29OYBpQBkAGB8oChOyAcZBJs1avpBhgw6SeVf3OQzsAOKXcgNUT9JM+YQHQrx0WAGEy37sfAXL3Y07mgrECEcX5Dl7ywe6zg/7Rn5NQva0d6h7ID9Xv9M0CQKNWbl9X7YvKXQZAxMzHDqyfVAKl4txK1HRK0z4C4Ftn9LwpEruIAJi0rlHqOa+wmZSXMiyUD7IiNgGQNm4Wru/u+vzhVxTBNZfk32D3IP58b60CQJA4X2k2eBttmQAPEZ60eVBYfeyOzcfr+5QAPDoxP+x+Pgpc7FNrB4VxNQ3u4TCZ7062T0IfIGKbj0/Q2UVh+33BY2uc7XPy/X1L0XHsLcnujXcT8ORrcd/5fA/0UirOfPQhpxqweIye8xdPUMC0r+j/diUo7Kvur8TVpHVN0NDq3JshmUJlWxtsyM2EzXxnTMikTegERst8M9WbRbJnWm0HtLZbfRvxd8Vw3aVFvsT96a+64NlfxhXpWEUhiSJ4QcHufaBfRsUwclkTGBglMqOG4H52rlr4ToNL6lrhnf/u8tpXJ1215/PTZfjEh+DPZXwGLLeZtCnu5wL4qn0+45gePDhSW6aWgjXxrP85+nES1r8YN9mwcXIp9CnJ3MCOw3E4+LY9yJyQKcQTAItHI8uVtLOelQKce6a/fzK2pgm6Eo45tP6fjvni0770q07YebhdyfBcM9/pmwuAKGw+3mTR7b3gNK8KWRsB49dauXksDtk0WVb7fg0t3dcB//m/uBuVx3yEzrYA07qqtne93gEv/xrNjAchbHfX7HLtZ8IT3/ufBCzc1SLBMIo4Px3zbXZbGkClTrrr7eP1550Vg5k3BI/3Rq1qg2KzaNWC5SXnFsGUa/WniqdtboNG0+Za16+Z2BcG9cusQfwk+UVDCmbUtjhj5p6GlUg7ZgYDAF4cdYbP1Gbuf7w/eM1tPQa5yzEBnH+kMJKs8kyT4RNvN/fWXnDO1/RTq5M3tUN7nOm93ZWZN5bAhUP0w8dxa1ohmaIw+ZpSuPx8f99Bh763rWgS5vntq+xx2P1Aevuvugcmw0Yubci5zRdRTO7a6PkA1rDLwg9i81X78D0+Xc/7r3ujC149gkZWDcCt0/tAkQYGPv4sCfN3tcONf18Cwy4ProFYgS3e2wbvf2LPlrI/MGVciIO6B4OB4Be/i8P6F1slbETl7bs3EojOAaC7cT7nXzIyxBm2p2ekB8FHn6VgUV2nUvhOn3Fwtt2XPlzb/HIHvPX7BFx9SS8Y+X19s5FOE6zc3wZHP7K9P5Mlcuk2bpi2d44eCJ57swPq/tWqZRQwxXxlRlMZFsoHddU+S3QXAFEwX9QcT9yjduYSSSztRqcvs/ZBb373LHWlzsT1rdARpzDmRyXwo4u6x3xRODXPtcFv/gunzdPU7aMmyACCTT9thZ+/Y9o4H+ELUVWEwsfhNgEQFfN5tUPhsvNiMPEqmZVY3KlS+yrTg8e+dboB86p4MFUua4FexQBzbu0N5w/WsBM6xl845+nXOuDV36CWSi+8ugcHKFtfuLsFfsfmAnhXwvnmmemIhY83IhM3dnKa21IPrCYSECmcEHTv3VV3lEJFmeeRj1ndDoazqoezT3If2H7NvqnUdArbOilM2mDZ0tppZYHWFGSBAfjw0yQ8vNOKCMyhcDvl9a44BrBjFg+Cn2xtgj9+wddd+F3Pq3Kvlzq5fVl+zPUKDWsBQECa90yZhM/cTuW3MU/CjtOT91rsvb2mzXPqAgjfociEq3rBln/phM44wN4HczeViyZr1Ap2lo7vPH4bUEagdrrlE+CMHoZ+4ieK3D5P3vTCx3PJxA22BoiY+R5lADDtig+PKtvTd9KfWrn9U/sbUHOnXh5fFAAmd37wvV5wZoV+mMq2ccPCBujFRJhiDv+8wUXw8WcJiHslF2k1Ry6Z7wBFDYAAcb4yYlMxX1IWmR0+SZ3ZBxybNf36Urjk29nF+Dtf74BXMLMHAJum9IP+fbNLFC3f1wpH/9ClzhOEVL0raQ5uYCQLztnwTGXzJgCitPks871+ZyF85hKcfNnbjeqd7YfQmbM9cVMPAjx1XznHZklfpzlgzds3CfMduZ3VE8fZGus042z/xAMgz5mPD3X1JcVQ2Y34fsGuNvjwT2xM7xFm3d1lgHWB2X5mbG6G/6u311go5nTzwebzJpcCmeD4ABmFL3tpGTVHGGrfvklngkLd7O45endvaIGmNtsZUzmtADCvqg+c//XszAo+bks7hdGrGqBIwFGuM3y+vpUQtVgAyCh8SZJCqCgpd86j46xUFt7+5GtL4LLzshcKdqZyWZO7jNwZHCH4cZM8ld8vhWFXdK9yacvLbXDwqJUzyEfmOxIjE9Z3CJjgw4GgcX6YNh8nkRbcrj8V7Ke6K5c1ezuI+DBfzPCdPtCANXdlLhDJZC4mb2iEYw38izV6wtv3GMs7jS4AZBQIlTSqgYvI28c4e9dM/YWZfkL44NMkLNzdJr0oyo/5YjtY/LE/Q6VPJgA4vw+bX286mfkkfDMVjBpALfzc2/zOOIXa6X2hf5/sQjJWGNXbWuGTzxnmaTLfacMZFMy+PXV/f62KpExgwCngmxbWmzWO7CeKDJ+fWhePkzsdEyDJmx8x3uEL1+Yj0xaMKoVvpynHyjS47O83LW4yl44LAbGiXlB/Q6YrLy6BcVd23xw5/axcUg+o6XpS+NgXCwCc8HPHfJzZe+DmUvjrb4Q3edNsF5EGAYzuuagVykPQTuz9Fuxsgnc+ Line truncated
};
+20 -3
View File
@@ -5,6 +5,14 @@ import { EXT_FILES } from './ext-files.js';
const te = new TextEncoder();
// base64 → 字节(Workers 里可用 atob;不依赖 Buffer)
function b64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// CRC32 表
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
@@ -48,12 +56,15 @@ export function buildZip(files) {
const nameBytes = te.encode(f.name);
const crc = crc32(f.data);
const size = f.data.length;
// 文件名带中文(如「项目文档-记忆与踩坑.md」)时必须置 UTF-8 标志位(bit 11),
// 否则 Windows 自带解压会把名字解成乱码。
const flags = /[^\x00-\x7F]/.test(f.name) ? 0x0800 : 0;
// Local file header
const local = concat(
u32(0x04034b50), // signature
u16(20), // version needed
u16(0), // flags
u16(flags), // flags(中文名 → UTF-8 位)
u16(0), // method = STORE
u16(0), u16(0), // time, date
u32(crc),
@@ -68,7 +79,7 @@ export function buildZip(files) {
header: concat(
u32(0x02014b50), // signature
u16(20), u16(20), // version made by, version needed
u16(0), u16(0), // flags, method
u16(flags), u16(0), // flags(同上), method
u16(0), u16(0), // time, date
u32(crc),
u32(size), u32(size),
@@ -109,10 +120,16 @@ export async function handleExtZip(req, env) {
const url = new URL(req.url);
const origin = url.origin; // 当前面板地址,如 https://xxx.workers.dev
const fileNames = ['manifest.json', 'popup.html', 'popup.js', 'background.js'];
const fileNames = ['manifest.json', 'popup.html', 'popup.js', 'background.js', 'icon16.png', 'icon32.png', 'icon48.png', 'icon128.png'];
const files = [];
for (const name of fileNames) {
// 图标是二进制:生成器把它存成 "@b64:icon16.png",这里解回字节
const b64 = EXT_FILES['@b64:' + name];
if (b64 != null) {
files.push({ name, data: b64ToBytes(b64) });
continue;
}
let text = EXT_FILES[name];
if (text == null) {
return new Response('扩展源文件缺失:' + name, { status: 500 });
+210 -14
View File
@@ -6,9 +6,10 @@ import { hashPassword, verifyPassword, encryptJSON, decryptJSON, randomHex } fro
import { handleExtZip } from './ext-zip.js';
import { runAll, runAccount } from './runner.js';
import { getSite, siteMeta, getBrowserScript } from './sites/index.js';
import { listCommunitySites, importSiteConfig, deleteCommunitySite, validateSiteConfig, makeCommunitySite, exportAccountConfig } from './community.js';
import { getNotifyConfig, setNotifyConfig } from './notify.js';
import { probeSignEndpoints } from './probe.js';
import { shouldRun, validHour, validTime, validTz, accountHour, dayInTz, dayStartInTz } from './schedule.js';
import { shouldRun, nextLastKey, validHour, validTime, validTz, accountHour, dayInTz, dayStartInTz } from './schedule.js';
import { runHttpSteps } from './sites/http.js';
import { verifyExternalRequest } from './lib/ext-auth.js';
@@ -84,6 +85,16 @@ async function createSession(env) {
return sid;
}
// 内置站点 + 社区导入的站点:每次请求读一次 D1(量很小,导入后立刻生效,不用重启)
async function loadCustomSites(env) {
try {
const list = await listCommunitySites(env.DB);
return list.map((r) => makeCommunitySite(r.def));
} catch {
return [];
}
}
async function handleApi(req, env, url) {
const path = url.pathname;
const method = req.method.toUpperCase();
@@ -204,10 +215,73 @@ async function handleApi(req, env, url) {
// 站点逻辑仍在 Worker,HTTP 请求由扩展在用户本地网络中执行(见 runner.js),
// 既解决了 MV3 无法执行面板脚本的问题,也避免与中继重复执行。
// 若站点需要人工过人机验证,请用面板上的「打开验证页」,验证后重新「一键发送」刷新 Cookie。
// ---- 浏览器导航签到(browser jobs)----
// 适用于「脚本化请求整站读不到」的站点(典型:吾爱破解被网宿 WAF 保护,
// 实测连 robots.txt 经中继都超时,只有真实页导航才能过挑战)。
// 扩展每隔 1 分钟来领一次:导航到签到页 → 读结果 → POST /api/external/report 回填。
if (path === '/api/external/browser-jobs' && method === 'GET') {
const deny = await extGuard();
if (deny) return deny;
return json({ jobs: [] });
const custom = await loadCustomSites(env);
const jobs = [];
const today = dayInTz(new Date(), await scheduleTz(env.DB));
const now = Date.now();
// 把一个账号变成扩展能执行的「导航签到」工单
const jobFor = async (acc, site) => {
if (!acc || !acc.enabled || !site || typeof site.browserJob !== 'function') return null;
const creds = await decryptJSON(env, env.DB, acc.creds).catch(() => ({}));
let meta = {};
try { meta = JSON.parse(acc.meta || '{}'); } catch { /* 忽略 */ }
const j = site.browserJob(creds, { meta, env }) || {};
const navigate = j.navigate_url || site.navigateUrl || '';
if (!navigate) return null;
return {
account_id: acc.id,
site: acc.site,
site_name: site.name,
domain: j.domain || site.domain || '',
navigate_url: navigate,
// 扩展端拿到页面后,如果还没签到,可以再去这个地址(页面上那个「去签到」链接的原样地址)
sign_url: j.sign_url || site.navigateUrl || navigate,
script: null, // MV3 禁止 new Function,页内判定由扩展原生巡检验完成
params: {},
};
};
// ① 手动队列:面板点「浏览器签到」时写入,最优先
const { results: manual } = await env.DB.prepare(
'SELECT id, account_id FROM browser_manual_jobs ORDER BY created_at LIMIT 10'
).all().catch(() => ({ results: [] }));
const queued = new Set();
for (const row of manual || []) {
// 领走就删(不管成不成),避免每分钟重复下发
await env.DB.prepare('DELETE FROM browser_manual_jobs WHERE id = ?').bind(row.id).run().catch(() => {});
const acc = await env.DB.prepare('SELECT * FROM accounts WHERE id = ?').bind(row.account_id).first();
const j = await jobFor(acc, acc && getSite(acc.site, custom));
if (j) { jobs.push(j); queued.add(acc.id); }
}
// ② 自动工单:声明了浏览器签到的站点(如吾爱破解),当天还没签上就交给扩展去导航签到。
// 节流:同一账号至少隔 60 分钟才自动下发一次,避免反复开标签页。
const { results: autos } = await env.DB.prepare(
'SELECT * FROM accounts WHERE enabled = 1 ORDER BY id'
).all();
for (const acc of autos || []) {
if (queued.has(acc.id)) continue;
const site = getSite(acc.site, custom);
if (!site || typeof site.browserJob !== 'function') continue;
let m = {};
try { m = JSON.parse(acc.meta || '{}'); } catch { /* 忽略 */ }
if (m.last_signin_date === today) continue; // 今天已签上,不用再打扰
if (m.browser_job_at && now - m.browser_job_at < 60 * 60 * 1000) continue;
const j = await jobFor(acc, site);
if (!j) continue;
m.browser_job_at = now;
await env.DB.prepare('UPDATE accounts SET meta = ? WHERE id = ?').bind(JSON.stringify(m), acc.id).run().catch(() => {});
jobs.push(j);
}
return json({ jobs });
}
// ---- 一次性交接码(扩展 → 面板)----
@@ -446,7 +520,94 @@ async function handleApi(req, env, url) {
if (path === '/api/me' && method === 'GET') return json({ logged_in: true });
if (path === '/api/sites' && method === 'GET') return json({ sites: siteMeta() });
if (path === '/api/sites' && method === 'GET') return json({ sites: siteMeta(await loadCustomSites(env)) });
// ---- 社区站点配置(开源配套):导入 / 列出 / 删除 / 导出 ----
// 导入:粘贴一段 JSON,或给一个原始链接(Gist / raw 文件都行)
if (path === '/api/sites/community' && method === 'GET') {
const list = await listCommunitySites(env.DB);
// author:导出配置时默认署的名字(开源共建署名,存在设置里)
const author = (await getSetting(env.DB, 'community_author')) || '';
return json({ sites: list, author });
}
if (path === '/api/sites/community/author' && method === 'PUT') {
const { author } = await readBody(req);
await setSetting(env.DB, 'community_author', String(author || '').slice(0, 60));
return json({ ok: true });
}
if (path === '/api/sites/community' && method === 'POST') {
const body = await readBody(req);
let raw = body.config;
const url = String(body.url || '').trim();
if (!raw && url) {
if (!/^https?:\/\//i.test(url)) return json({ error: '链接必须是 http(s) 开头' }, 400);
try {
const r = await fetch(url, { headers: { Accept: 'application/json,text/plain,*/*' } });
if (!r.ok) return json({ error: `拉取配置失败:HTTP ${r.status}` }, 502);
raw = (await r.text()).slice(0, 200000);
} catch (e) {
return json({ error: '拉取配置失败:' + String((e && e.message) || e) }, 502);
}
}
if (!raw) return json({ error: '请粘贴配置 JSON,或给出配置的原始链接' }, 400);
const v = validateSiteConfig(raw);
// 先预览后导入:dry_run=1 时只校验,不写库
if (body.dry_run) return json({ ok: v.ok, errors: v.errors, warnings: v.warnings, config: v.def || null });
if (!v.ok) return json({ error: v.errors.join(';'), errors: v.errors }, 400);
try {
const r = await importSiteConfig(env.DB, v.def, { overwrite: !!body.overwrite, source: url });
return json({ ok: true, id: r.id, name: r.def.name, warnings: r.warnings });
} catch (e) {
return json({ error: String((e && e.message) || e), need_overwrite: !!e.needOverwrite }, e.needOverwrite ? 409 : 400);
}
}
// 导入前先校验(dry_run):面板据此显示“这份配置是什么、缺什么、会不会带凭据”
if (path === '/api/sites/community/check' && method === 'POST') {
const body = await readBody(req);
const v = validateSiteConfig(body.config || body || {});
return json({ ok: v.ok, errors: v.errors, warnings: v.warnings, config: v.def || null });
}
const mCommunity = path.match(/^\/api\/sites\/community\/([A-Za-z0-9_\-]+)$/);
if (mCommunity && method === 'DELETE') {
await deleteCommunitySite(env.DB, mCommunity[1]);
return json({ ok: true });
}
if (mCommunity && method === 'GET') {
const list = await listCommunitySites(env.DB);
const one = list.find((s) => s.id === mCommunity[1]);
if (!one) return json({ error: '没有导入过这个站点配置' }, 404);
return json({ config: one.def, meta: { author: one.author, version: one.version, source: one.source } });
}
// ---- 反向:把一个账号录好的请求序列导出成可分享的社区配置 ----
// 这是「开源共建」的另一半:你录一遍 → 导出 JSON → 发到 GitHub → 别人导入即可用。
const mExport = path.match(/^\/api\/accounts\/(\d+)\/export-config$/);
if (mExport && method === 'GET') {
const acc = await env.DB.prepare('SELECT * FROM accounts WHERE id = ?').bind(Number(mExport[1])).first();
if (!acc) return json({ error: '账号不存在' }, 404);
const creds = await decryptJSON(env, env.DB, acc.creds);
const meta = siteMeta(await loadCustomSites(env)).find((s) => s.id === acc.site) || {};
const q = url.searchParams;
const r = exportAccountConfig(
{ id: acc.id, name: acc.name, site: acc.site, creds },
meta,
{
id: q.get('id') || '',
name: q.get('name') || '',
author: q.get('author') || '',
version: q.get('version') || '',
desc: q.get('desc') || '',
source: q.get('source') || '',
}
);
if (!r.ok && r.reason) return json({ error: r.reason }, 400);
// 把作者默认值写进去:面板设置里的「开源署名」;没设就留空由前端问
const author = q.get('author') || (await getSetting(env.DB, 'community_author')) || '';
if (!author) { /* 留空不报错,前端会提示补署名 */ }
if (author) r.config.author = author;
return json({ ok: true, config: r.config, warnings: r.warnings, errors: r.errors });
}
// 账号列表(不含凭据,含 meta 以便前端渲染站点独立开关)
if (path === '/api/accounts' && method === 'GET') {
@@ -512,7 +673,7 @@ async function handleApi(req, env, url) {
// 手动执行单个账号(browser 模式由浏览器扩展执行,面板不直接执行)
if (mAcc[2] === '/run' && method === 'POST') {
const site = getSite(acc.site);
const site = getSite(acc.site, await loadCustomSites(env));
let execMode = site?.execution || 'server';
try {
const m = JSON.parse(acc.meta || '{}');
@@ -532,7 +693,7 @@ async function handleApi(req, env, url) {
// 更新账号
if (!mAcc[2] && method === 'PUT') {
const { name, site, creds, enabled } = await readBody(req);
const s = getSite(site || acc.site);
const s = getSite(site || acc.site, await loadCustomSites(env));
if (!s) return json({ error: '未知站点' }, 400);
const enc = creds ? await encryptJSON(env, env.DB, creds) : acc.creds;
await env.DB.prepare('UPDATE accounts SET name=?, site=?, creds=?, enabled=?, updated_at=? WHERE id=?')
@@ -561,7 +722,7 @@ async function handleApi(req, env, url) {
const id = Number(mToggle[1]);
const acc = await env.DB.prepare('SELECT * FROM accounts WHERE id = ?').bind(id).first();
if (!acc) return json({ error: '账号不存在' }, 404);
const site = getSite(acc.site);
const site = getSite(acc.site, await loadCustomSites(env));
const { key, value } = await readBody(req);
const def = site && site.toggles ? site.toggles.find((t) => t.key === key) : null;
if (!def) return json({ error: '该站点不支持此开关' }, 400);
@@ -789,12 +950,40 @@ async function handleApi(req, env, url) {
return json({ ok: true, require_sign: !!require_sign });
}
// ---- 浏览器导航签到:面板点一下,让扩展开标签页去真的打开签到页(适合被 WAF 拦死的站点)----
// 返回后立即返回,结果由扩展跑完 POST /api/external/report 回填到这一行。
const mBrowserSign = path.match(/^\/api\/accounts\/(\d+)\/browser-sign$/);
if (mBrowserSign && method === 'POST') {
const id = Number(mBrowserSign[1]);
const acc = await env.DB.prepare('SELECT * FROM accounts WHERE id = ?').bind(id).first();
if (!acc) return json({ error: '账号不存在' }, 404);
const site = getSite(acc.site, await loadCustomSites(env));
if (!site || typeof site.browserJob !== 'function') {
return json({ error: '该站点不支持浏览器导航签到(它可以用普通请求直接签)' }, 400);
}
const { isRelayAvailable } = await import('./lib/relay.js');
if (!(await isRelayAvailable(env.DB))) {
return json({ error: '浏览器扩展离线:浏览器导航签到需要扩展在线(它会用你自己的浏览器打开签到页)' }, 400);
}
const now = Date.now();
await env.DB.prepare('INSERT INTO browser_manual_jobs(account_id, created_at) VALUES(?,?)').bind(id, now).run();
await env.DB.prepare('DELETE FROM browser_manual_jobs WHERE created_at < ?').bind(now - 3600000).run().catch(() => {});
const j = site.browserJob(await decryptJSON(env, env.DB, acc.creds).catch(() => ({})), { meta: {} }) || {};
return json({
ok: true,
queued: true,
site_name: site.name,
navigate_url: j.navigate_url || site.navigateUrl || '',
hint: '已交给浏览器扩展:它会打开一个标签页完成签到,完成后自动把结果写回这一行(通常 1 分钟内)',
});
}
// ---- 让扩展帮某个账号打开登录页(登录/过验证后自动把新 Cookie 交接回面板)----
if (path.startsWith('/api/accounts/') && path.endsWith('/assist') && method === 'POST') {
const id = Number(path.slice('/api/accounts/'.length, -'/assist'.length));
const acc = await env.DB.prepare('SELECT id, site, name FROM accounts WHERE id = ?').bind(id).first();
if (!acc) return json({ error: '账号不存在' }, 404);
const meta = siteMeta().find((s) => s.id === acc.site) || {};
const meta = siteMeta(await loadCustomSites(env)).find((s) => s.id === acc.site) || {};
const login = meta.login || {};
const loginUrl = login.url || (meta.domain ? 'https://' + meta.domain + '/' : '');
if (!loginUrl) return json({ error: '该站点没有登记登录地址,请手动在浏览器打开站点登录后再用扩展抓取' }, 400);
@@ -831,8 +1020,9 @@ export default {
}
},
// Cron 触发:每小时触发一次,按各账号的签到时间(独立时间或全局时间)
// 判断是否到达整点才执行。每个账号独立记录上次执行 key,避免重复。
// Cron 触发:每分钟触发一次,按各账号的签到时间(独立时间或全局时间)
// 判断是否该执行。每个账号独立记录上次执行 key:成功则当天不再跑;
// 失败/结果未知则隔一会儿自动补跑(见 schedule.js 的 shouldRun / nextLastKey)。
async scheduled(event, env, ctx) {
ctx.waitUntil(
(async () => {
@@ -840,7 +1030,8 @@ export default {
await ensureSchema(env.DB);
const globalTime = (await getSetting(env.DB, 'schedule_time')) || '08';
const tz = (await getSetting(env.DB, 'schedule_tz')) || 'Asia/Shanghai';
// 各账号上次执行 key:{ accountId: "YYYY-MM-DD HH" }
// 各账号上次执行记录:{ accountId: "YYYY-MM-DD HH:MM" }(成功)
// { accountId: "YYYY-MM-DD HH:MM@YYYY-MM-DD HH:MM" }(失败/结果未知,带尝试时刻)
let lastMap = {};
try { lastMap = JSON.parse((await getSetting(env.DB, 'sched_last_map')) || '{}'); } catch { /* 忽略 */ }
const { results } = await env.DB.prepare('SELECT id, site, meta FROM accounts WHERE enabled = 1').all();
@@ -849,7 +1040,7 @@ export default {
for (const acc of results || []) {
// 执行模式:browser 由扩展执行,Worker 跳过;relay/server/auto 由 runner.js 统一处理
// (auto 时扩展在线则自动中继,否则云端直连)
const site = getSite(acc.site);
const site = getSite(acc.site, await loadCustomSites(env));
let execMode = site?.execution || 'server';
try {
const m = JSON.parse(acc.meta || '{}');
@@ -858,16 +1049,21 @@ export default {
// 浏览器模式已并入本地中继:不再跳过,由 runAccount 决定(扩展在线走本地网络,离线则记 skip)
const hour = accountHour(acc.meta, globalTime);
const lastKey = lastMap[String(acc.id)];
const { run, key } = shouldRun(now, hour, tz, lastKey);
const { run, key, nowKey } = shouldRun(now, hour, tz, lastKey);
if (!run) continue;
lastMap[String(acc.id)] = key;
changed = true;
let runRes = null;
try {
const full = await env.DB.prepare('SELECT * FROM accounts WHERE id = ?').bind(acc.id).first();
if (full) await runAccount(env, full);
if (full) runRes = await runAccount(env, full);
} catch (e) {
console.error('[cron] account', acc.id, e);
}
// 成功 → 记「今天这个时刻已完成」;失败/结果未知 → 记尝试时刻,
// 让 shouldRun 过 RETRY_GAP_MIN 分钟后再自动补一次(漏一分钟不再等于整天不签)。
// 已经交给扩展去做的(如吾爱破解转浏览器导航)不需要重试,避免排一堆重复任务。
const keep = runRes && runRes.retryable === false;
lastMap[String(acc.id)] = keep ? key : nextLastKey(key, nowKey, (runRes && runRes.status) || 'fail');
// 账号之间稍作间隔,降低被目标站点限流的概率
await new Promise((r) => setTimeout(r, 1200));
}
+10 -1
View File
@@ -46,12 +46,18 @@ export async function queueRelayJob(db, { url, method = 'GET', headers = {}, bod
// 52pojie 这类慢站:扩展端 fetch 超时 30s + 任务超时 60s,Worker 端给 90s 兜底
export async function waitRelayResult(db, jobId, timeoutMs = 90000, pollMs = 300) {
const deadline = Date.now() + timeoutMs;
let lastJob = null; // 超时告警里要写清卡在哪个请求
while (Date.now() < deadline) {
const job = await db.prepare('SELECT * FROM relay_jobs WHERE id = ?').bind(jobId).first();
if (!job) throw new Error('中继任务不存在');
lastJob = job;
// 报错时把「卡住的到底是哪个请求」一并带上(存放 detail)。
// 吾爱破解这类超时以前只显示一句「已放弃该请求」,用户在面板上完全看不出卡在哪一步。
const where = `中继请求:${String(job.method || 'GET').toUpperCase()} ${String(job.url || '').slice(0, 160)}`;
if (job.status === 'failed') {
const err = new Error('本地网络失败:' + (job.error || '未知错误'));
err.outcome = 'relay';
err.detail = where;
throw err;
}
if (job.status === 'done') {
@@ -70,7 +76,10 @@ export async function waitRelayResult(db, jobId, timeoutMs = 90000, pollMs = 300
}
await sleep(pollMs);
}
throw new Error('等待本地网络响应超时:扩展没有在 ' + Math.round(timeoutMs / 1000) + ' 秒内回传(扩展可能休眠、被关闭,或未配置 API Key)');
const err = new Error('等待本地网络响应超时:扩展没有在 ' + Math.round(timeoutMs / 1000) + ' 秒内回传(扩展可能休眠、被关闭,或未配置 API Key)');
err.outcome = 'relay';
err.detail = `中继请求:${lastJob ? String(lastJob.method || 'GET').toUpperCase() + ' ' + String(lastJob.url || '').slice(0, 160) : '(未取到任务信息)'}`;
throw err;
}
// 模拟 fetch 的中继版本:在用户本地网络中执行 HTTP 请求
+50 -12
View File
@@ -15,6 +15,30 @@
// 注意:识别是有意「保守」的——只有命中明确的短语才算数,避免把正常页面的
// 「登录」「签到」菜单误判成登录失效或成功。
// ---------------------------------------------------------------------------
// 「网站自己说的那句话」
// ---------------------------------------------------------------------------
// 面板的「网站反馈」列应该展示**网站的原话**,而不是我们的总结:
// · 站点的 JSON 里几乎都有 message / msg / errmsg 字段,那才是网站真正的说法
// (例如 NodeSeek 回 {"success":false,"message":"今天已完成签到,请勿重复操作"})
// · 以前各站点模块把它归纳成自己的套话(「今日已签到,不能重复签到」),
// 结果面板显示的是我们的口径,用户拿不到任何网站信息。
// 所以统一提供这个提取器:能拿到网站原话就用网站原话,拿不到才回退到 fallback。
export function siteMessageFrom(text, fallback = '') {
const t = String(text == null ? '' : text);
const m = t.match(/"(?:message|msg|errmsg|info|status_msg|errorMsg)"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
if (!m) return fallback;
let s = m[1];
// JSON 里的转义(\u4eca 等)还原成人话;还原失败就用原始串
try { s = JSON.parse('"' + s + '"'); } catch { /* 保持原样 */ }
s = String(s)
// 机场/论坛的 message 常把促销公告一起塞进来(换行 + emoji):主文案只取第一段
.split(/\n+/).map((x) => x.trim()).find(Boolean) || '';
s = s.replace(/\s+/g, ' ').trim();
return s || fallback;
}
export const OUTCOME = {
SUCCESS: 'success', // 签到/领取成功
ALREADY: 'already', // 今天已经签过,属正常
@@ -43,6 +67,7 @@ export const NEEDS_HUMAN = [OUTCOME.NEED_LOGIN, OUTCOME.CAPTCHA, OUTCOME.WAF];
// ---------------------------------------------------------------------------
// 短语表:顺序即优先级(先匹配到的结果胜出)。
// 每条规则可写 marks(子串,中文直接 includes)与 pats(正则,用于需要上下文的弱词)。
// 原则:先判「被拦截/需人工」(WAF/验证码/限流/暂停),再判登录失效,
// 最后才判成功/已签到——因为被拦截的页面里也常带「登录」「签到」字样。
// ---------------------------------------------------------------------------
@@ -86,15 +111,22 @@ const RULES = [
},
{
outcome: OUTCOME.ALREADY,
// 注意:单独的「已签到」「已经签到」「已领取」太宽泛,页面模板/按钮文字里就可能带有,
// 曾导致没真签到却被误判为「今日已签到」——所以这里只放「不可能出现在别处」的强短语。
marks: [
// 注意:单独的「已签到」「已经签到」太宽泛,页面模板/按钮文字里就可能带有,
// 曾导致 Cookie 失效也没真签到,却被误判为「今日已签到」。必须带「今日/今天/重复」等限定词才算。
'今日已签到', '今天已签到', '今日已经签到', '重复签到', '请勿重复',
'无需重复', '不能重复签到', '明日再来', '下期再来', '今日已领取',
'重复签到', '请勿重复', '无需重复', '不能重复签到', '明日再来', '下期再来',
// Discuz 任务插件:重复申请时返回「抱歉,您已完成过此任务」
'您已完成过此任务', '已完成过此任务',
'already signed', 'already checked in', 'already claimed',
],
// 弱词(已签到 / 已经签到 / 已领取)改用「带上下文的正则」判定:
// 必须与「今日/今天/该奖励」等限定词同现才算数,既认得出真话、又不会被模板文字骗到。
pats: [
/今[日天](?:已|已经)经?(?:签到|签过到|打过卡)/, // 今日已签到 / 您今天已经签到过了
/今[日天](?:已|已经)?领取(?:过)?/, // 今日已领取 / 今天已经领取过
/(?:该|此|本次)?奖励(?:已|已经)(?:经)?领取(?:过)?/, // 该奖励已领取 / 奖励已经领取过
/(?:已|已经)领取(?:过)?(?:该|此|本次)?奖励/, // 已经领取该奖励
],
},
{
outcome: OUTCOME.SUCCESS,
@@ -143,17 +175,23 @@ export function classifySignal(input, opts = {}) {
const status = Number(opts.status) || 0;
for (const rule of RULES) {
let hit = false;
for (const mark of rule.marks) {
const hit = mark.length > 2 && /[\u4e00-\u9fa5]/.test(mark)
hit = mark.length > 2 && /[\u4e00-\u9fa5]/.test(mark)
? text.includes(mark) // 中文短语直接包含匹配
: new RegExp(escapeRe(mark), 'i').test(text); // 英文/混合短语忽略大小写
if (hit) {
return {
outcome: rule.outcome,
label: OUTCOME_TEXT[rule.outcome],
reward: rule.outcome === OUTCOME.SUCCESS ? extractReward(text) : '',
};
}
if (hit) break;
}
// pats:需要上下文的正则(比裸词严格,用来判定「已签到/已领取」这类弱词)
if (!hit && rule.pats) {
for (const re of rule.pats) { if (re.test(text)) { hit = true; break; } }
}
if (hit) {
return {
outcome: rule.outcome,
label: OUTCOME_TEXT[rule.outcome],
reward: rule.outcome === OUTCOME.SUCCESS ? extractReward(text) : '',
};
}
}
+68 -7
View File
@@ -3,6 +3,7 @@
import { ensureSchema, getSetting } from './db.js';
import { decryptJSON } from './crypto.js';
import { getSite } from './sites/index.js';
import { listCommunitySites, makeCommunitySite } from './community.js';
import { sendNotify } from './notify.js';
import { dayInTz } from './schedule.js';
@@ -21,12 +22,19 @@ export async function runAccount(env, account) {
let message = '';
let detail = ''; // 网站原始回馈(站点模块可返回 detail),日志页展示
let meta = {};
let handedOff = false; // 已交给扩展去执行(不需要定时器再重试)
try {
meta = JSON.parse(account.meta || '{}');
} catch { /* 忽略 */ }
// 社区导入的站点(声明式配置)也在这里注册起来:账号可能用的是社区站点
let customSites = [];
try {
const site = getSite(account.site);
customSites = (await listCommunitySites(db)).map((r) => makeCommunitySite(r.def));
} catch { /* 老库没有 community_sites 表时忽略 */ }
try {
const site = getSite(account.site, customSites);
if (!site) throw new Error('未知站点:' + account.site);
// ---- 执行模式解析 ----
@@ -57,7 +65,8 @@ export async function runAccount(env, account) {
}
if (skipReason) {
// 浏览器模式定时跳过:不记为失败,记为跳过;不覆盖上次网站真实回馈
// 执行模式需要扩展但扩展不在线:不记为失败,记为跳过;不覆盖上次网站真实回馈。
// retryable:这类跳过是暂时的(用户一会儿打开浏览器就能签),定时器该自动补跑。
const duration = Date.now() - t0;
const now = Date.now();
await db
@@ -68,7 +77,33 @@ export async function runAccount(env, account) {
.prepare('UPDATE accounts SET last_status=?, last_run_at=?, meta=?, updated_at=? WHERE id=?')
.bind('skip', now, JSON.stringify(meta), now, account.id)
.run();
return { status: 'skip', message: skipReason, duration_ms: duration };
return { status: 'skip', message: skipReason, duration_ms: duration, retryable: true };
}
// ---- 被 WAF 整站拦死的站点:不在这里白等,直接交给「浏览器导航签到」----
// 吾爱破解(www.52pojie.cn)的网宿 WAF 会让**脚本化请求整站读不到**:
// 实测 2026-09-28,同一浏览器里 example.com / static.52pojie.cn 都是 200,
// 而主站的 robots.txt、portal.php、home.php 全部挂到超时(45s/90s 白白浪费,
// 用户看到的就是「本地网络执行失败:中继执行超时」)。这种站点只有**真实页导航**
// 能过挑战,所以把任务入队,由扩展打开标签页完成(等同人手点一下)。
if (typeof site.browserJob === 'function' && site.preferNavigationSign) {
const { isRelayAvailable } = await import('./lib/relay.js');
if (await isRelayAvailable(db)) {
const now = Date.now();
await db.prepare('INSERT INTO browser_manual_jobs(account_id, created_at) VALUES(?,?)').bind(account.id, now).run();
const msg = `${site.name}:已交给浏览器执行 —— 该站点的脚本化请求会被 WAF 拦死(本地中继读不到),改由扩展打开标签页完成签到,结果通常 1 分钟内自动写回这一行`;
const duration = Date.now() - t0;
await db
.prepare('INSERT INTO runs(account_id, site, name, status, message, detail, duration_ms, created_at) VALUES(?,?,?,?,?,?,?,?)')
.bind(account.id, account.site, account.name, 'skip', msg, '', duration, now)
.run();
await db
.prepare('UPDATE accounts SET last_status=?, last_msg=?, last_run_at=?, meta=?, updated_at=? WHERE id=?')
.bind('skip', msg, now, JSON.stringify(meta), now, account.id)
.run();
// 已经交给扩展去做了(谁去做、做没做由扩展写回),定时器不要再排一次
return { status: 'skip', message: msg, duration_ms: duration, retryable: false };
}
}
const creds = await decryptJSON(env, db, account.creds);
@@ -96,9 +131,30 @@ export async function runAccount(env, account) {
message = String(res.message || '').slice(0, 800);
detail = String(res.detail || '').slice(0, 800);
} catch (e) {
status = 'fail';
message = String((e && e.message) || e).slice(0, 800);
const rawMsg = String((e && e.message) || e);
detail = String((e && e.detail) || '').slice(0, 800);
// 中继超时 + 该站点支持浏览器导航签到 → 自动改道(省得用户每次都看 45 秒超时)
const siteObj = getSite(account.site, customSites);
if (typeof siteObj?.browserJob === 'function' && /中继请求超时|中继执行超时|等待本地网络响应超时/.test(rawMsg)) {
try {
await db.prepare('INSERT INTO browser_manual_jobs(account_id, created_at) VALUES(?,?)').bind(account.id, Date.now()).run();
status = 'skip';
handedOff = true;
message = `${siteObj.name}:本地中继读不到该站点(被 WAF 拦死),已自动改用浏览器导航签到(扩展会打开标签页完成),结果稍后自动写回`;
} catch {
status = 'fail';
message = rawMsg.slice(0, 800);
}
} else if (e && e.outcome === 'relay-unknown') {
// 本地中继没等到回包:请求**可能已经送达**站点(签到可能已生效),只是响应丢了。
// 这时既不能报成功(没凭据),也不能报失败(冤枉站点,用户看到「失败」但网站其实签了)。
// 记为「结果未知」,不写 last_signin_date(不冒充成功),并让定时器稍后自动补跑复核。
status = 'skip';
message = rawMsg.slice(0, 800);
} else {
status = 'fail';
message = rawMsg.slice(0, 800);
}
}
const duration = Date.now() - t0;
@@ -111,14 +167,19 @@ export async function runAccount(env, account) {
// 只有 status === 'ok' 才写:fail / skip 一律不写,保证过了当地 00:00
// 状态统一回到「未签到」,而只有当天真正签到成功才变回「已签到」。
if (status === 'ok') {
meta.last_signin_date = dayInTz(new Date(now), await scheduleTz(db));
// 站点可以用 dayTz 声明自己的「一天」从几点算起,默认跟面板设置一致。
// 踩过的坑:糊涂鳄(WordPress + RiPro)按 UTC 计日,也就是北京时间 08:00 才重置。
// 若一律按面板的北京时间记「今天」,08:00 之后面板会继续显示「已签到」,
// 而站点那边其实已是新的一天(用户手动点签到还能领到积分)——看起来就像「假签到」。
const siteDayTz = (getSite(account.site, customSites) || {}).dayTz || '';
meta.last_signin_date = dayInTz(new Date(now), siteDayTz || (await scheduleTz(db)));
}
await db
.prepare('UPDATE accounts SET last_status=?, last_msg=?, last_detail=?, last_run_at=?, meta=?, updated_at=? WHERE id=?')
.bind(status, message, detail || '', now, JSON.stringify(meta), now, account.id)
.run();
return { status, message, duration_ms: duration };
return { status, message, duration_ms: duration, retryable: status !== 'ok' && !handedOff };
}
export async function runAll(env, { manual = false } = {}) {
+73 -20
View File
@@ -1,7 +1,18 @@
// 签到时间设置:settings 表存 schedule_time(小时 "00"~"23",默认 "08")
// 签到时间设置:settings 表存 schedule_time("HH:MM",如 "08:05",默认 "08")
// 与 schedule_tz(IANA 时区,默认 Asia/Shanghai)。
// Worker Cron 每小时触发一次(0 * * * *),在这里判断当前(设定时区)
// 是否到达整点、且本小时未执行过,避免重复签到。
// Worker Cron 每分钟触发一次(* * * * *),在这里判断当前(设定时区)
// 是否该执行,避免重复签到。
//
// 【为什么不是「当前分钟必须等于设定分钟」】
// 旧逻辑要求严格相等,后果是:只要那一分钟没被触发(Cloudflare 在部署/抖动时会漏),
// 或者用户把时间改成了**已经过去**的时刻(例如 09:00 才把 07:00 改成 08:05),
// 这一天就再也不会自动签到了 —— 面板看起来就像「不会自动签到」。
// 现在改成三个规则,既可预期又不会漏:
// ① 到达/超过设定时刻 → 执行(当天该时刻第一次)
// ② 执行过且**成功** → 当天不再执行
// ③ 执行过但**失败/结果未知** → 隔 RETRY_GAP_MIN 分钟自动补一次,直到成功或超出补跑窗口
// 窗口 CATCHUP_WINDOW_MIN 存在的意义:晚上才部署、或把时间从 20:00 改到 08:05 时,
// 不该深夜突然批量签到;超出窗口就等下一个自然日(想立刻签到点面板的「执行」)。
export function tzParts(date, tz) {
const fmt = new Intl.DateTimeFormat('en-CA', {
@@ -46,30 +57,72 @@ export function dayStartInTz(date, tz) {
return Date.parse(`${day}T00:00:00Z`) - tzOffsetMs(date, tz);
}
// now: Date;timeHH: "08";tz: IANA;lastKey: 上次执行 key("YYYY-MM-DD HH")
// 返回 { run, key }:run 为 true 表示本小时应该执行。
export function shouldRun(now, timeHHMM, tz, lastKey) {
// 没跑成功的补跑窗口(分钟):默认 6 小时。
export const CATCHUP_WINDOW_MIN = 360;
// 失败后的重试间隔(分钟):避免每分钟重试(那会一直占着扩展的中继通道)。
export const RETRY_GAP_MIN = 15;
// 设定时间的解析结果:"08" / "08:05" 都接受,缺分钟按 00 算。
function parseWant(timeHHMM) {
const t = String(timeHHMM || '');
if (t.includes(':')) {
const [h, m] = t.split(':');
return { wantHour: String(h).padStart(2, '0'), wantMin: String(m).padStart(2, '0') };
}
return { wantHour: String(t).padStart(2, '0'), wantMin: '00' };
}
// "YYYY-MM-DD HH:MM" → 当天的「第几分钟」,便于算间隔(不涉及时区换算,两端同一时区)。
function clockMinutes(s) {
const m = String(s || '').match(/\d{4}-\d{2}-\d{2}\s+(\d{2}):(\d{2})/);
if (!m) return null;
return Number(m[1]) * 60 + Number(m[2]);
}
// 上次执行记录 → { base: 设定时刻 key, at: 上次尝试时刻(同一 key 当天), ok: 是否成功 }
// 格式:成功 "2026-09-28 08:05";失败/结果未知 "2026-09-28 08:05@2026-09-28 08:07"
export function parseLastKey(lastKey) {
const s = String(lastKey || '');
const i = s.indexOf('@');
if (i < 0) return { base: s, at: '', ok: true };
return { base: s.slice(0, i), at: s.slice(i + 1), ok: false };
}
// now: Date;timeHHMM: "08:05";tz: IANA;lastKey: 上次执行记录(见 parseLastKey)
// 返回 { run, key, nowKey }:run 为 true 表示现在应该执行;key 记录「本设定时刻已处理」。
export function shouldRun(now, timeHHMM, tz, lastKey, opts = {}) {
const windowMin = Number.isFinite(opts.windowMin) ? opts.windowMin : CATCHUP_WINDOW_MIN;
const retryGap = Number.isFinite(opts.retryGap) ? opts.retryGap : RETRY_GAP_MIN;
let parts;
try {
parts = tzParts(now, tz);
} catch {
parts = tzParts(now, 'Asia/Shanghai');
}
// 支持 HH:MM 格式,也兼容旧的 HH 格式
const t = String(timeHHMM || '');
let wantHour, wantMin;
if (t.includes(':')) {
const [h, m] = t.split(':');
wantHour = String(h).padStart(2, '0');
wantMin = String(m).padStart(2, '0');
} else {
wantHour = String(t).padStart(2, '0');
wantMin = '00';
const { wantHour, wantMin } = parseWant(timeHHMM);
const key = `${parts.day} ${wantHour}:${wantMin}`;
const nowKey = `${parts.day} ${parts.hour}:${parts.minute}`;
const nowMin = Number(parts.hour) * 60 + Number(parts.minute);
const wantMinTotal = Number(wantHour) * 60 + Number(wantMin);
const last = parseLastKey(lastKey);
if (last.base === key) {
if (last.ok) return { run: false, key, nowKey }; // 今天这个时刻已经成功签过
// 上一次尝试过但没成功(失败/结果未知):隔 retryGap 再补一次
const atMin = clockMinutes(last.at);
if (atMin != null && nowMin - atMin < retryGap) return { run: false, key, nowKey };
}
const key = `${parts.day} ${parts.hour}:${parts.minute}`;
if (parts.hour !== wantHour || parts.minute !== wantMin) return { run: false, key };
if (lastKey === key) return { run: false, key };
return { run: true, key };
// 还没到设定时刻 → 等
if (nowMin < wantMinTotal) return { run: false, key, nowKey };
// 已经过太久(例如晚上才部署)→ 不补跑,避免深夜突然签到
if (nowMin - wantMinTotal > windowMin) return { run: false, key, nowKey };
return { run: true, key, nowKey };
}
// 执行后要写回 lastMap 的值:成功记 key;失败/结果未知记 "key@本次时刻",好让 shouldRun 决定何时补跑。
export function nextLastKey(key, nowKey, status) {
return status === 'ok' ? key : `${key}@${nowKey}`;
}
// 全角转半角(支持全角数字和冒号输入)
+7 -4
View File
@@ -126,9 +126,12 @@ export const akile = {
if (okCode(chk.body.status_code)) {
const d = chk.body.data || {};
const amount = d.amount ?? d.akCoin ?? d.coin ?? '';
return { ok: true, message: amount ? `签到成功,获得 ${amount} AK币` : `签到成功:${msg || '领取成功'}` };
// 站点的 status_msg 经常就是「签到成功」四个字,直接用它,不再加前缀
// (以前会变成「签到成功:签到成功」);有结构化金额时用金额说人话
return { ok: true, message: amount ? `签到成功,获得 ${amount} AK币` : (msg || '签到成功') };
}
if (msg.includes('已签到')) return { ok: true, message: '今日已签到,无需重复' };
// 主文案用网站原话(status_msg),拿不到才用我们的套话
if (msg.includes('已签到')) return { ok: true, message: msg || '今日已签到,无需重复' };
throw new Error('签到失败:' + (msg || `status_code=${chk.body.status_code}`));
},
@@ -150,12 +153,12 @@ export const akile = {
if (okCode(body.status_code)) {
const d = body.data || {};
const amount = d.amount ?? d.akCoin ?? d.coin ?? '';
return { ok: true, message: amount ? `签到成功,获得 ${amount} AK币` : `签到成功:${msg || '领取成功'}` };
return { ok: true, message: amount ? `签到成功,获得 ${amount} AK币` : (msg || '签到成功') };
}
if (/过期|无效|未登录|unauthorized|token/i.test(msg) || res.status === 401) {
return { ok: false, message: '登录已过期,请重新从浏览器复制 akile-token' };
}
if (msg.includes('已签到')) return { ok: true, message: '今日已签到,无需重复' };
if (msg.includes('已签到')) return { ok: true, message: msg || '今日已签到,无需重复' };
return { ok: false, message: '签到失败:' + (msg || `status_code=${body.status_code}`) };
},
},
+32 -7
View File
@@ -10,7 +10,7 @@
//
// extract 的点路径:如 data.token;也支持 data.list.0.id。
import { classifySignal, needsHuman, OUTCOME } from '../lib/signals.js';
import { classifySignal, needsHuman, OUTCOME, siteMessageFrom } from '../lib/signals.js';
function subst(str, vars) {
return String(str == null ? '' : str).replace(/\{\{\s*([\w$]+)\s*\}\}/g, (_, k) =>
@@ -34,7 +34,16 @@ function jsonPath(text, path) {
}
function parseHeaders(raw, vars) {
if (!raw || !String(raw).trim()) return {};
if (!raw) return {};
// 两种写法都支持:
// · 对象(社区配置里作者手写,更自然)
// · JSON 字符串(账号录制器里存的就是这种)
if (typeof raw === 'object') {
const out = {};
for (const [k, v] of Object.entries(raw)) out[k] = subst(v == null ? '' : String(v), vars);
return out;
}
if (!String(raw).trim()) return {};
let h;
try {
h = JSON.parse(subst(raw, vars));
@@ -45,10 +54,13 @@ function parseHeaders(raw, vars) {
return h;
}
// 执行多步场景,返回 { ok, message, vars };失败时抛错(含步骤名)。
export async function runHttpSteps(steps) {
// 执行多步场景,返回 { ok, message, vars, detail };失败时抛错(含步骤名)。
// vars:变量表。initialVars 会先铺进去(账号字段直接当变量用,如 {{cookie}}、{{site_url}}),
// 后续步骤 extract 出的变量可以覆盖它们;社区配置就是靠这个机制做到「零代码适配」。
export async function runHttpSteps(steps, initialVars = {}) {
if (!Array.isArray(steps) || steps.length === 0) throw new Error('至少需要一个步骤');
const vars = {};
const vars = { ...(initialVars || {}) };
let lastText = ''; // 最后一步的原始响应:作为「网站反馈」原话展示
for (let i = 0; i < steps.length; i++) {
const st = steps[i] || {};
const name = st.name || `步骤${i + 1}`;
@@ -57,7 +69,9 @@ export async function runHttpSteps(steps) {
const method = String(st.method || 'GET').toUpperCase();
const headers = parseHeaders(st.headers, vars);
const init = { method, headers };
const body = subst(st.body || '', vars);
// 请求体同样两种写法都收:对象(社区配置)或字符串(录制器)
const rawBody = st.body && typeof st.body === 'object' ? JSON.stringify(st.body) : st.body;
const body = subst(rawBody || '', vars);
if (body && method !== 'GET' && method !== 'HEAD') init.body = body;
let res, text;
@@ -80,6 +94,7 @@ export async function runHttpSteps(steps) {
}
}
lastText = text;
const expectStatus = parseInt(st.expect_status || '200', 10);
// 统一识别网站反馈:登录失效 / 验证码 / WAF / 已签到 都能给出可操作结论,
// 而不是只报一个生硬的状态码。很多站点重复签到会返回非预期内容,这里不该算失败。
@@ -98,7 +113,17 @@ export async function runHttpSteps(steps) {
}
}
const varDesc = Object.keys(vars).length ? `(提取变量:${Object.keys(vars).join('、')})` : '';
return { ok: true, message: `${steps.length} 步全部成功${varDesc}`, vars };
// 主文案尽量用**网站自己的话**(响应 JSON 里的 message/msg):
// 社区配置的判定是通用的,但反馈应该让用户看到网站怎么说。
const siteMsg = siteMessageFrom(lastText);
return {
ok: true,
message: steps.length === 1
? (siteMsg || '签到成功')
: `${steps.length} 步全部成功${siteMsg ? `:${siteMsg}` : varDesc}`,
vars,
detail: lastText ? '网站返回:' + lastText.replace(/\s+/g, ' ').slice(0, 300) : '',
};
}
export const httpTask = {
+205 -42
View File
@@ -20,16 +20,34 @@ import { classifySignal, OUTCOME } from '../lib/signals.js';
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36';
// 兜底 action 名(发现阶段没找到线索时才用)。
// 顺序按实测命中率排:dj.hutue.cn / hutue.cn 用的是 xb-child 子主题,
// 签到按钮 <a class="click-qiandao zzhuti_qd_1"> 的处理器在 xb-app.js:
// $.post(caozhuti.ajaxurl, { action: "xb_user_qiandao" }, ...) → 1 == n.status 为成功
// 首页内联脚本里虽然有 ajaxurl,但不会写出 action 名,所以这个兜底顺序很重要(少打一次无用请求)。
// 站点主题自己一定有的 action:永远放最高优先级,不靠猜也不靠记。
const SITE_ACTIONS = ['xb_user_qiandao'];
// 站点真正的签到 action:永远放最高优先级,不靠猜也不靠记。
//
// 【重要踩坑】站点有两个长得几乎一样的「签到」,奖励完全不同:
// ① 真签到:首页右侧悬浮窗 <a class="click-qiandao zzhuti_qd_1">打卡签到</a>,
// 首页文案写着「每天签到 5 晶石」。它的处理器在**父主题** RiPro 的
// /wp-content/themes/ripro/assets/js/app.js:
// $(".click-qiandao").on("click", … $.post(caozhuti.ajaxurl, {action:"user_qiandao"}) )
// → 1 == status 为成功。
// ② 会员中心的「首页签到」:/wp-content/themes/xb-child/assets/js/xb-app.js 里
// $(".user-index-qd") → action:"xb_user_qiandao",奖励只有 1 积分。
//
// 线上事故:面板一直打的是 ②。它确实会回 {status:1,msg:"签到成功,赠送1积分"},
// 于是面板报「签到成功」——但真签到 ① 根本没动,用户去网站点悬浮窗照样能领 5 晶石,
// 看起来就是「面板的签到是假的」。所以 ① 必须排第一,② 仅当 ① **接口不存在**时兼用。
//
// 2026-09-28 复核(直读站点主题 JS,不是猜):
// dj.hutue.cn/wp-content/themes/ripro/assets/js/app.js:
// $(".click-qiandao").on("click", function(){ … $.post(caozhuti.ajaxurl,{action:"user_qiandao"}) … })
// dj.hutue.cn/wp-content/themes/xb-child/assets/js/xb-app.js:
// action: "xb_user_qiandao" ← 会员中心那个「首页签到」,只给 1 积分
// 而页面上的悬浮窗就是 <a class="click-qiandao zzhuti_qd_1" title="打卡签到">,
// 即**真签到 = user_qiandao**。换接口只允许发生在「真接口不存在」时,否则就是假签到。
const SITE_ACTIONS = ['user_qiandao'];
// 会员中心的另一个签到(只给 1 积分):只当真接口不存在时才轮到它,且要在反馈里说清楚。
const LAST_RESORT_ACTIONS = ['xb_user_qiandao'];
// 兜底 action 名(发现阶段与主题 action 都没命中时才用)
const FALLBACK_ACTIONS = ['user_qiandao', 'qiandao', 'user_checkin'];
const MAX_ATTEMPTS = 4; // 最多打几次,避免把站点打烦(中继模式下每次尝试都是一次网络往返,要省着用)
const FALLBACK_ACTIONS = ['qiandao', 'user_checkin', 'xb_qiandao'];
const MAX_ATTEMPTS = 5; // 最多打几次,避免把站点打烦(中继模式下每次尝试都是一次网络往返,要省着用)
function normBase(u) {
let s = String(u || '').trim();
@@ -51,25 +69,39 @@ function tryJson(text) {
try { return JSON.parse(text); } catch { return null; }
}
// 排 JS 文件优先级:主题主文件(assets/js/app.js)最可能写着「按钮 → action」的绑定关系
function rankScript(u) {
const s = String(u || '');
if (/\/assets\/js\/(app|main)(\.min)?\.js/i.test(s)) return 0;
if (/\/themes\/[^/]+\/assets\//i.test(s)) return 1;
return 2;
}
// ---------- ① 从首页发现签到接口 ----------
// 返回 { ajaxUrl, actions: [], routes: [], nonce, jsUrls: [] }
// jsUrls: 页面引用的主题 JS 文件(签到 action 可能藏在其中,如 xb-app.js)
// jsUrls: 页面引用的主题 JS 文件(签到 action 可能藏在其中)
// actions: 按「可信度」排序——按钮真实绑定的 action 排最前,泛匹配的靠后
export function discoverSignin(base, html) {
const out = { ajaxUrl: '', actions: [], routes: [], nonce: '', jsUrls: [] };
const out = { ajaxUrl: '', actions: [], buttonActions: [], routes: [], nonce: '', jsUrls: [] };
const ajax = String(html || '').match(/["']?(?:ajaxurl|ajax_url|adminAjax)["']?\s*[:=]\s*["']([^"']+)["']/i);
if (ajax) out.ajaxUrl = absolutize(ajax[1], base);
// 收集主题相关的外部 JS 文件地址(签到 action 可能藏在主题 JS 里)
// 收集主题相关的外部 JS 文件地址(签到 action 可能藏在主题 JS 里)。
// 排序很关键:签到按钮的处理器在**父主题**的 /assets/js/app.js 里,
// 而子主题的 xb-app.js 里藏着另一个「会员中心签到」(奖励完全不同)。
// 不排序的话,先抓到哪个全看页面里 <script> 的先后,很容易抓错。
const scriptRe = /<script[^>]+src=["']([^"']+\.js[^"']*)["']/gi;
let sm;
while ((sm = scriptRe.exec(String(html || ''))) !== null) {
const abs = absolutize(sm[1], base);
if (abs && /wp-content\/(themes|plugins)/i.test(abs) && !out.jsUrls.includes(abs)) {
out.jsUrls.push(abs);
if (out.jsUrls.length >= 5) break;
if (out.jsUrls.length >= 8) break;
}
}
// 主 JS(assets/js/app.js)排最前,其余的靠后
out.jsUrls.sort((a, b) => rankScript(a) - rankScript(b));
// action:'user_qiandao' / action: "xb_user_qiandao" / data-action="qiandao"
const actionRe = /(?:action|do|type)\s*[:=]\s*["']([A-Za-z0-9_-]*(?:qiandao|checkin|check_in|signin|sign_in)[A-Za-z0-9_-]*)["']/gi;
@@ -86,6 +118,24 @@ export function discoverSignin(base, html) {
if (m[2] && !out.actions.includes(m[2])) out.actions.push(m[2]);
}
// 【关键】按钮真正绑定的 action:页面里出现站点的签到按钮(RiPro 是
// <a class="click-qiandao …">)时,往后找最近的 action:"…"——那个才是点按钮会走的接口。
// 实测:不认按钮,只做泛匹配时,会先抓到另一个名字很像、奖励却完全不同的 action。
const buttonActions = [];
const btnRe = /(?:class|id)\s*=\s*["'][^"']*(click-qiandao|qiandao-btn|qd-btn)[^"']*["'][\s\S]{0,800}?action\s*:\s*["']([A-Za-z0-9_-]+)["']/gi;
for (const m of String(html || '').matchAll(btnRe)) {
if (m[2] && !buttonActions.includes(m[2])) buttonActions.push(m[2]);
}
// 同一份代码里也可能是反过来的顺序:先绑事件再取按钮
const btnRe2 = /click-qiandao[\s\S]{0,400}?action\s*:\s*["']([A-Za-z0-9_-]+)["']/gi;
for (const m of String(html || '').matchAll(btnRe2)) {
if (m[1] && !buttonActions.includes(m[1])) buttonActions.push(m[1]);
}
if (buttonActions.length) {
out.buttonActions = buttonActions;
out.actions = [...buttonActions, ...out.actions.filter((a) => !buttonActions.includes(a))];
}
// REST 路由线索:/wp-json/xxx/qiandao(绝对或站内相对路径都要能识别)
const routeRe = /["']([^"'\s]*wp-json[^"'\s]*(?:qiandao|checkin|sign)[^"'\s]*)["']/gi;
for (const m of String(html || '').matchAll(routeRe)) {
@@ -157,12 +207,55 @@ function definitive(message, sig, detail) {
return { done: true, result: { ok: false, message, outcome: (sig && sig.outcome) || '', detail } };
}
// 「站点不认这个接口」的判定 —— 只有这种情况才允许换下一个 action。
//
// 为什么必须分清:WordPress 对不存在的 action 会**原样返回字符串 "0"**,插件也可能回
// 「无效的请求」。这种才可以换接口试。反过来,只要站点给出了真正的业务回答
// (哪怕是「签到失败:xxx」「还没绑定手机号」),就说明这个接口是真的 ——
// 那时再换一个奖励不同的接口去「试成功」,用户看到的就是「面板说签好了,网站却还能再签」。
export function isUnknownAction(raw) {
const t = String(raw == null ? '' : raw).trim();
if (!t) return true;
if (t === '0' || t === '-1' || t === 'null' || t === 'false') return true;
if (/^(无效的请求|无效请求|非法请求|invalid|unknown|bad request)/i.test(t)) return true;
const j = tryJson(t);
if (j && typeof j === 'object') {
const msg = String(j.msg || j.message || j.error || (j.data && (j.data.msg || j.data.message)) || '');
const okFlag =
j.status == 1 || j.code == 1 || j.ret == 1 || j.success === true ||
(j.data && (j.data.status == 1 || j.data.success === true || j.data.code == 1));
if (okFlag) return false;
if (/无效的请求|无效请求|非法请求|不存在|未知的|invalid|unknown action|not found/i.test(msg)) return true;
if (!msg) return true;
return false; // 有业务文字回答 → 接口是存在的,不该再换接口
}
return false; // 非 JSON 的非空响应(HTML/文本)→ 当作「有回答」
}
// 「网站返回」那一行的写法:网站原话在前(面板主文案直接用它),
// 后面跟上「打的是哪个接口」—— 这样「面板到底有没有真打通」在面板上就能自查,不用猜。
function detailOf(action, raw, trace) {
const text = String(raw == null ? '' : raw).replace(/\s+/g, ' ').trim();
const head = text ? `网站返回:${text.slice(0, 300)}` : '网站返回:(无响应)';
const meta = action ? ` · 接口 ${action}` : '';
const extra = (trace || []).filter((x) => /超时|不认/.test(x)).slice(-2).join(';');
return head + meta + (extra ? ` · ${extra.slice(0, 160)}` : '');
}
export const hutue = {
id: 'hutue',
name: '糊涂鳄',
desc: '糊涂鳄资源站每日签到(WordPress/RiPro 悬浮窗)。支持 hutue.cn 与 dj.hutue.cn,自动发现签到接口。',
execution: 'server', // 默认云端执行(用面板保存的 Cookie);扩展在线时自动走中继用用户网络
domain: 'dj.hutue.cn', // 默认域名;实际按账号的 site_url 动态决定
// 本站「一天」按 UTC 算(= 北京时间 08:00 重置),不是面板时区的 00:00。
// 依据(2026-09-28 线上日志):9/28 00:33 / 07:04 / 07:56(北京)三次站点都回
// 「今日已签到,请明日再来」,而这三个时刻都还在 UTC 9/27 之内;9/28 08:05(北京,
// 刚进 UTC 9/28)用户手动点签到就成功领到 5 晶石。
// 注意:这个日界是从站点的「签到锁」行为推出来的(WordPress 默认时区就是 UTC)。
// 万一某个站实际按北京时间算,最坏后果也只是 00:00–08:00 这 8 小时面板先显示「未签到」,
// 不影响签到本身(建议签到时间设在 08:05 之后,两种日界都能签得上)。
dayTz: 'UTC',
fields: [
{
key: 'site_url',
@@ -186,7 +279,7 @@ export const hutue = {
placeholder: '留空用默认;扩展会自动抓取',
},
],
tips: '先在浏览器中登录站点(右侧会出现签到悬浮窗)→ 用扩展「一键发送到签到面板」→ 面板会自动识别并保存。面板会自动从站点页面里发现悬浮窗真正使用的签到接口,主题升级也不易失效。hutue.cn 和 dj.hutue.cn 是两个独立站点,需要分别添加账号。',
tips: '先在浏览器中登录站点(首页右侧会出现「打卡签到」悬浮窗,每天 5 晶石)→ 用扩展「一键发送到签到面板」→ 面板会自动识别并保存。面板认的是悬浮窗按钮真正绑定的接口(RiPro 的 user_qiandao);站点还有另一个长得很像的「会员中心首页签到」(只给 1 积分),不会被误用。hutue.cn 和 dj.hutue.cn 是两个独立站点,需要分别添加账号。注意:本站按 UTC 计日,北京时间 08:00 才算它新的一天——签到时间建议设在 08:05 之后。',
async run(creds, ctx = {}) {
const base = normBase(creds.site_url);
@@ -232,15 +325,23 @@ export const hutue = {
return await jr.text();
})
);
const jsButtonActions = []; // 主题 JS 里「按钮真正绑定的 action」,优先级最高
for (const r of jsResults) {
if (r.status !== 'fulfilled' || !r.value) continue;
try {
const d2 = discoverSignin(base, r.value);
for (const a of d2.buttonActions || []) {
if (a && !jsButtonActions.includes(a)) jsButtonActions.push(a);
}
for (const a of d2.actions) {
if (a && !seenActions.includes(a)) seenActions.push(a);
}
} catch { /* 解析失败不影响 */ }
}
// 按钮绑定的 action 提到最前(已发现的其他 action 依次排后)
if (jsButtonActions.length) {
seenActions.splice(0, seenActions.length, ...jsButtonActions, ...seenActions.filter((a) => !jsButtonActions.includes(a)));
}
// 首页本身若是验证码/登录页,直接给出明确结论,不再瞎试
const snippet = '网站返回:' + String(html).replace(/<script[\s\S]*?<\/script>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 240);
@@ -278,54 +379,91 @@ export const hutue = {
} // end if (!skipDiscovery)
// ---- ② 组装尝试列表 ----
// 顺序:上次命中的(最可信,已验证过)→ 主题自己用的 action → 本次页面发现的 → 其余兜底
// 顺序:站点真签到(悬浮窗那个)→ 上次命中的 → 本次页面发现的
// → 会员中心那个(只给 1 积分,最后才轮到)→ 其余兜底。
// 为什么主题 action 永远排第一:踩过的坑——若某个别的插件 action 也返回 status=1,
// 一旦它先命中就会被记住(hutue_action),以后每次都走它,
// 网站反馈就变成那个插件的话、奖励也跟本站对不上(实测线上出现过 1 积分 vs 本站 5 积分)。
// 排在前面不额外花网络请求,所以不跟「跳过首页发现」的优化冲突。
const ordered = [];
const push = (a) => { if (a && !ordered.includes(a)) ordered.push(a); };
push(remembered);
for (const a of SITE_ACTIONS) push(a);
push(remembered);
for (const a of seenActions) push(a);
for (const a of LAST_RESORT_ACTIONS) push(a);
for (const a of FALLBACK_ACTIONS) push(a);
// 同一个 action 的「带 nonce」版本优先,但不做全局排序——
// 全局按 nonce 排序会把首选 action 的无 nonce 版本挤到很后面。
// 跳过发现时(有记住的 action):只用记住的 nonce 设置试一次,失败才走完整流程
// 每个 action 先试「不带 nonce」:站点自己的 JS 调签到接口时也不带 nonce
// (RiPro 的 user_qiandao 就只发 action 一个参数),先带 nonce 反而多打一次、
// 甚至可能被别的 action 的 nonce 校验撚回。真需要 nonce 的主题会在第二次命中。
// 跳过发现时(有记住的 action):按同样顺序先试一遍,失败才走完整流程。
const attempts = [];
if (skipDiscovery && remembered) {
attempts.push({ action: remembered, nonce: rememberedNonce ? nonce : '' });
for (const action of ordered.slice(0, 2)) {
attempts.push({ action, nonce: (action === remembered && rememberedNonce) ? nonce : '' });
}
} else {
for (const action of ordered) {
if (nonce) attempts.push({ action, nonce });
attempts.push({ action, nonce: '' });
if (nonce) attempts.push({ action, nonce });
if (attempts.length >= MAX_ATTEMPTS) break;
}
}
// ---- ③ 依次尝试:成功即返回;负面结论记录下来,全部试完再抛最有信息量的那个 ----
// ---- ③ 依次尝试:记录每一步「打的是谁、站点回了什么」,成功即返回 ----
const trace = [];
const note = (s) => { if (s) trace.push(String(s).slice(0, 200)); };
let lastReason = '';
let definitiveFail = null;
let timeoutFail = ''; // 最后一次「发出去了但没等到回包」
let usedAction = '';
let usedRaw = '';
const sendOnce = async (t) => {
const body = new URLSearchParams({ action: t.action, ...(t.nonce ? { nonce: t.nonce } : {}) });
const res = await fetch(ajaxUrl, {
method: 'POST',
headers: {
...headers,
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
Referer: base + '/',
},
body: body.toString(),
});
return { status: res.status, raw: await res.text() };
};
for (const t of attempts.slice(0, MAX_ATTEMPTS)) {
timeoutFail = '';
let raw = '';
let status = 0;
try {
const body = new URLSearchParams({ action: t.action, ...(t.nonce ? { nonce: t.nonce } : {}) });
const res = await fetch(ajaxUrl, {
method: 'POST',
headers: {
...headers,
Accept: 'application/json, text/plain, */*',
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
Referer: base + '/',
},
body: body.toString(),
});
status = res.status;
raw = await res.text();
const r0 = await sendOnce(t);
status = r0.status;
raw = r0.raw;
} catch (e) {
lastReason = `请求 ${t.action} 失败:${String((e && e.message) || e).slice(0, 120)}`;
continue;
const em = String((e && e.message) || e);
// 超时只说明**我们没等到回包**,请求很可能已经送达站点(站点可能已经签成功了)。
// 所以先重打一次同一个 action:上一次若真生效,站点这次会直接回「今日已签到」。
note(`${t.action}:首次请求超时(${em.slice(0, 50)}),重试一次以确认到底送达没有`);
try {
await new Promise((r) => setTimeout(r, 1200));
const r1 = await sendOnce(t);
status = r1.status;
raw = r1.raw;
} catch (e2) {
const em2 = String((e2 && e2.message) || e2);
timeoutFail = `请求已发出但没等到回包(${em2.slice(0, 100)})`;
lastReason = `${t.action}:${timeoutFail}`;
note(`${t.action}:重试仍无回包`);
continue;
}
}
usedAction = t.action;
usedRaw = raw;
const verdict = judgeSigninResponse(raw, status);
if (verdict.done) {
const r = verdict.result;
@@ -335,23 +473,48 @@ export const hutue = {
ctx.meta.hutue_action = t.action;
ctx.meta.hutue_use_nonce = !!t.nonce;
}
return r;
const fallbackWarn = LAST_RESORT_ACTIONS.includes(t.action)
? ' · 注意:本次走的是「会员中心签到」(只给 1 积分),首页悬浮窗那个真签到接口本次不可用'
: '';
return { ...r, detail: detailOf(t.action, raw, trace) + fallbackWarn };
}
// 明确负面结论(登录失效/验证码/WAF/限流):与接口无关,换接口也一样,不白试。
if (!definitiveFail) definitiveFail = r;
lastReason = `${t.action}:${r.message}`;
break;
}
if (isUnknownAction(raw)) {
lastReason = `${t.action}:站点不认这个接口`;
note(`${t.action}:站点不认这个接口(${String(raw || '').replace(/\s+/g, ' ').slice(0, 40)}),继续试下一个`);
continue;
}
// 站点有业务回答但既不是成功也不是已签(例如「签到失败:xx」「还没绑定手机号」):
// 这是**这个接口**的真实结论,不再换接口 —— 换成会员中心那个只会得到另一个「签到成功」,
// 奖励完全不同,用户看到的就是「面板说签好了,网站却还能再签」。
lastReason = `${t.action}:${verdict.reason}`;
note(`${t.action}:站点有回答但未识别为成功,不再换接口`);
break;
}
if (definitiveFail) {
const err = new Error(definitiveFail.message);
err.detail = definitiveFail.detail;
err.detail = detailOf(usedAction, usedRaw, trace) || definitiveFail.detail;
err.outcome = definitiveFail.outcome;
throw err;
}
if (timeoutFail) {
// 结果未知:不冒充成功,也不冤枉网站为失败(runner 会记为「结果未知」,
// 并让定时器稍后自动补跑一次复核)。
const err = new Error(`签到结果未知:${timeoutFail}。请求可能已经送达网站(签到可能已生效),也可能没有;稍后会自动复核,若已签到网站会直接回「今日已签到」。`);
err.outcome = 'relay-unknown';
err.detail = detailOf(usedAction, usedRaw, trace);
throw err;
}
const planned = attempts.slice(0, MAX_ATTEMPTS).map((p) => p.action);
const triedHint = planned.length ? `(已试 ${planned.join('、')})` : '';
const routeHint = seenRoutes.length ? `(页面里发现 REST 路由 ${seenRoutes[0]},该站可能改用 REST 签到,请告知开发者)` : '';
const triedHint = ordered.length ? `(已试 ${ordered.slice(0, MAX_ATTEMPTS).join('、')})` : '';
throw new Error('签到失败:' + (lastReason || '未识别到成功标识') + triedHint + routeHint);
const err = new Error('签到失败:' + (lastReason || '未识别到成功标识') + triedHint + routeHint);
err.detail = detailOf(usedAction, usedRaw, trace);
throw err;
},
};
+24 -4
View File
@@ -16,8 +16,19 @@ import { hutue } from './hutue.js';
export const SITES = [quark, cloud189, nodeseek, akile, v2ex, misign, kanxue, wuaipojie, v2board, hutue, httpTask];
export function getSite(id) {
return SITES.find((s) => s.id === id);
// 内置站点 + 社区导入的站点(声明式配置,见 src/community.js)
// custom 由调用方从 D1 读出后传进来(保持本文件无副作用,测试也好写)。
export function allSites(custom = []) {
const ids = new Set(SITES.map((s) => s.id));
const extra = (Array.isArray(custom) ? custom : [])
.filter((s) => s && s.id && !ids.has(s.id));
return [...SITES, ...extra];
}
export function getSite(id, custom = []) {
const s = SITES.find((x) => x.id === id);
if (s) return s;
return (Array.isArray(custom) ? custom : []).find((x) => x && x.id === id);
}
// 登录协助提示:面板据此打开登录页、并提前告诉用户会遇到什么验证。
@@ -36,8 +47,8 @@ const LOGIN_HINTS = {
http: { kind: 'cookie', captcha: 'maybe', url: '', note: '' },
};
export function siteMeta() {
return SITES.map((s) => {
export function siteMeta(custom = []) {
return allSites(custom).map((s) => {
const hint = LOGIN_HINTS[s.id] || {};
const own = s.login || {};
return {
@@ -49,7 +60,16 @@ export function siteMeta() {
toggles: s.toggles || [],
execution: s.execution || 'server', // 默认执行模式
domain: s.domain || '', // 浏览器执行时的目标域名
// 站点自己的「一天」以哪个时区算(空 = 跟面板设置一致)。
// 面板状态列的「已签到/未签到」用它判定,站点日界不同时才不会出现假签到。
dayTz: s.dayTz || '',
// 社区导入的站点:带上作者/版本/来源,面板据此显示「社区」徽章与出处
community: s.community || null,
hasBrowserScript: !!s.browserScript, // 是否有浏览器端签到脚本
// 是否支持「浏览器导航签到」:脚本化请求被整站拦死(如吾爱破解的网宿 WAF)时,
// 由扩展把用户的标签页导航到签到页(等同人手点一下)来完成签到。
hasBrowserJob: typeof s.browserJob === 'function',
preferNavigationSign: !!s.preferNavigationSign,
// 登录协助:面板用它渲染「去登录/验证」入口,扩展用它决定打开哪个地址
login: {
kind: own.kind || hint.kind || 'cookie', // cookie / token / password / none
+14 -6
View File
@@ -33,8 +33,12 @@ export const nodeseek = {
}
const msg = String(j.message || '');
const okFlag = j.success === true || (j.data && j.data.success === true);
if (okFlag || msg.includes('鸡腿')) return { ok: true, message: '签到成功:' + (msg.slice(0, 60) || '领取成功') };
if (/已签到|已经签到|已完成签到/.test(msg)) return { ok: true, message: '今日已签到,不能重复签到' };
// 收益信息也在响应里(gain=本次、current=累计),一起报给用户。
// 注意:这里是 browserScript 的源码字符串(外层是反引号),内部不能再套反引号。
const gain = j.gain != null ? '(本次 +' + j.gain + (j.current != null ? ',累计 ' + j.current : '') + ')' : '';
// 直接用网站原话当反馈,不再归纳成我们的套话
if (okFlag || msg.includes('鸡腿')) return { ok: true, message: (msg.slice(0, 80) || '领取成功') + gain };
if (/已签到|已经签到|已完成签到/.test(msg)) return { ok: true, message: msg || '今日已签到,不能重复签到' };
if (j.status === 404 || msg.includes('USER NOT FOUND')) return { ok: false, message: '登录已失效,请重新获取 Cookie' };
return { ok: false, message: msg ? '签到失败:' + msg.slice(0, 60) : '签到失败,稍后重试' };
}`,
@@ -117,13 +121,17 @@ export const nodeseek = {
// 网站原始回馈:存入日志 detail,面板日志页"网站回馈"展示
const detail = `网站返回:${text.slice(0, 300)}`;
// 成功
// 成功(若响应里带了收益,一并带上)
if (okFlag || msg.includes('鸡腿')) {
return { ok: true, message: `签到成功:${msg.slice(0, 60) || '领取成功'}`, detail };
const gain = j.gain != null ? `(本次 +${j.gain}${j.current != null ? `,累计 ${j.current}` : ''})` : '';
// 主文案=网站原话(如「今天的签到收益是2个鸡腿」),收益数字用响应里的结构化字段补上
return { ok: true, message: (msg.slice(0, 80) || '领取成功') + gain, detail };
}
// 已签到:一天只能签一次,属正常情况
// 已签到:一天只能签一次,属正常情况。
// 主文案用**网站原话**(「今天已完成签到,请勿重复操作」)——
// 以前这里归纳成「今日已签到,不能重复签到」,面板上看到的全是我们的口径。
if (msg.includes('已签到') || msg.includes('已经签到') || msg.includes('已完成签到')) {
return { ok: true, message: '今日已签到,不能重复签到', detail };
return { ok: true, message: msg || '今日已签到,不能重复签到', detail };
}
// 会话无效:HTTP 500 + {"message":"USER NOT FOUND","status":404}
if (j.status === 404 || msg.includes('USER NOT FOUND')) {
+17 -6
View File
@@ -15,6 +15,13 @@ import { MOBILE_UA, cookiesFrom, postForm, postJSON } from '../lib/web.js';
const UA = MOBILE_UA;
// 只取第一段(换行分隔),并限长:机场签到接口的 msg 里常挂着一整段促销公告
function firstParagraph(s, max = 120) {
const first = String(s == null ? '' : s).split(/\n+/).map((x) => x.trim()).find(Boolean) || '';
const one = first.replace(/\s+/g, ' ').trim();
return one.length > max ? one.slice(0, max) + '…' : one;
}
function normDomain(d) {
let s = String(d || '').trim();
if (!s) return '';
@@ -81,11 +88,14 @@ export const v2board = {
// 2. 签到
const checkinUrl = `${domain}/user/checkin`;
const { j: chk } = await postJSON(checkinUrl, {}, { cookie, pageUrl: checkinUrl, ua: UA });
const msg = String((chk && chk.msg) || '');
// 机场的 msg 常把「活动公告」一起塞进来(换行 + emoji 促销),只取第一段,
// 否则整段广告会刷在面板的「网站反馈」里(69 机场的真实日志就是这样)
const msg = firstParagraph(String((chk && chk.msg) || ''));
if (chk && chk.ret === 1) {
return { ok: true, message: `签到成功:${msg || '领取成功'}` };
// 网站原话直接当反馈(如「尊贵的王者Lv7,您获得了 1.535GB 流量.」)
return { ok: true, message: msg || '签到成功' };
}
if (/已签到|已经签到|签到过/.test(msg)) return { ok: true, message: '今日已签到,无需重复' };
if (/已签到|已经签到|签到过/.test(msg)) return { ok: true, message: msg || '今日已签到,无需重复' };
throw new Error('签到失败:' + (msg || `ret=${chk && chk.ret}`));
},
@@ -123,9 +133,10 @@ export const v2board = {
credentials: 'include',
});
const chk = await chkRes.json().catch(() => null);
const msg = String((chk && chk.msg) || '');
if (chk && chk.ret === 1) return { ok: true, message: `签到成功:${msg || '领取成功'}` };
if (/已签到|已经签到|签到过/.test(msg)) return { ok: true, message: '今日已签到,无需重复' };
// 只取第一段:机场的 msg 里常挂着整段促销公告
const msg = firstParagraph(String((chk && chk.msg) || ''));
if (chk && chk.ret === 1) return { ok: true, message: msg || '签到成功' };
if (/已签到|已经签到|签到过/.test(msg)) return { ok: true, message: msg || '今日已签到,无需重复' };
return { ok: false, message: '签到失败:' + (msg || `ret=${chk && chk.ret}`) };
},
},
+215 -40
View File
@@ -37,6 +37,75 @@ const SUCCESS_MARKS = ['任务已完成', '签到成功', '打卡成功', '签
const PAUSED_MARKS = ['暂停签到', '签到暂停', '暂停每日签到', '签到功能维护'];
const LOGIN_MARKS = ['请先登录', '需要先登录', '请登录后'];
// 站点用**顶部那一个按钮**表示当天状态(2026-09-28 用户截图确认):
// 未签到 → 「📋 打卡签到」(橙色,可点)
// 已签到 → 「✅ 签到完毕」(绿色,点下去没任何反应)
// 这是全站最可靠的状态指示,比正文里泛泛的「已签到」准得多。
const BTN_SIGNED = /签到完毕|签到完成|今日已签到/;
const BTN_UNSIGNED = /打卡签到|立即签到|点击签到|签到领/;
// 读「签到按钮自己的状态」(只取那个元素,不看整页):
// ① 文字版:按钮里写着「签到完毕」/「打卡签到」
// ② 图片版(52pojie 实际就是这样,DevTools 截图确认):状态被画在图片里
// <a href="home.php?mod=task&do=apply&id=2&referer=…"><img src="…/qds.png"></a>
// 图里写着「打卡签到」,文本节点里什么都没有 —— 但**这个「去签到」链接还在**,
// 就说明服务端认为今天还没签(签完链接就没了,点也没反应)。
// 关键:只看按钮/链接元素,不要拿整页文本搜——<a> 的 title 里常年写着「打卡签到」,
// 整页搜会把已签到误判成未签到。
// 返回 'signed' | 'unsigned' | 'unknown'
export function buttonState(texts) {
const raw = (typeof texts === 'string') ? texts : pickText(texts) || '';
// 按钮本体:优先按已知 class,其次按「去签到」链接
const m = raw.match(/<a[^>]*class=["'][^"']*(?:click-qiandao|zzhuti_qd|qq_bind)[^"']*["'][^>]*>([\s\S]{0,200}?)<\/a>/i)
|| raw.match(/<a[^>]*href=["'][^"']*mod=task[^"']*do=apply[^"']*["'][^>]*>([\s\S]{0,200}?)<\/a>/i);
if (m) {
const btnText = m[1].replace(/<[^>]*>/g, '').replace(/\s+/g, '');
if (BTN_SIGNED.test(btnText)) return 'signed';
// 链接还挂着「去签到」= 未签到(图片版状态靠这个认)
if (/href=["'][^"']*mod=task[^"']*do=apply/i.test(m[0])) return 'unsigned';
if (BTN_UNSIGNED.test(btnText)) return 'unsigned';
}
// 按钮没找着(未登录 / 主题改版):退一步只看「签到完毕」这种强信号,
// 不要因为整页里出现「打卡签到」就断定未签到(那条多半来自 title / 导航)
if (/签到完毕/.test(raw)) return 'signed';
return 'unknown';
}
// 从任务页里把「打卡签到」这个任务的**真实链接**找出来。
// 为什么要这样(而不是硬编 id=2):
// · Discuz 的任务 id 由论坛后台配置,不同论坛/改版会变(社区脚本里就有遍历任务 id 的做法);
// · 页面上那个「去签到」链接自带 id 和 referer,照它的原样发请求,和真人点一下完全一致。
// 同时读回:该任务是否已完成(行内的「已完成 / 已申请 / 进行中」文字)。
// texts 可以是 fetchDualText 的返回值,也可以是纯字符串。
export function findSignTask(texts) {
const raw = (typeof texts === 'string') ? texts : pickText(texts) || '';
const out = { id: '', href: '', label: '', done: false, formhash: '' };
const fm = raw.match(/formhash=([0-9a-f]{6,10})/i);
if (fm) out.formhash = fm[1];
const re = /<a\b[^>]*href=["']([^"']*mod=task[^"']*do=apply[^"']*?id=(\d+)[^"']*)["'][^>]*>([\s\S]{0,300}?)<\/a>/gi;
const cands = [];
let m;
while ((m = re.exec(raw))) {
const href = m[1].replace(/&amp;/g, '&');
const inner = m[3].replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, '');
const img = (m[3].match(/<img[^>]*src=["']([^"']+)["']/i) || [])[1] || '';
// 只取这个链接所在的那一小片文字(上下各一屏左右),用来判断是哪条任务、完成没有;
// 不能拿整页搜「已完成」——任务列表页里别的任务就是已完成状态。
const around = raw.slice(Math.max(0, m.index - 260), m.index + m[0].length + 420)
.replace(/<[^>]*>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ');
cands.push({ href, id: m[2], inner, img, around });
}
if (!cands.length) return out;
const looksSign = (c) => /打卡签到|每日签到|每日打卡|签到/.test(c.inner + ' ' + c.img + ' ' + c.around);
const pick = cands.find(looksSign) || cands[0];
out.href = pick.href;
out.id = pick.id;
out.label = pick.inner || '打卡签到';
// 「已完成 / 已申请(当天已领)/ 进行中」且附近没有「立即申请」→ 这个任务今天已做过
out.done = /已完成|已申请|已领取|进行中/.test(pick.around) && !/立即申请|马上申请|申请任务/.test(pick.around);
return out;
}
async function fetchDualText(url, init) {
const res = await fetch(url, init);
const ab = await res.arrayBuffer();
@@ -116,16 +185,34 @@ function assertNoWaf(texts, status) {
}
}
// 「打卡签到」任务的真实地址(Discuz 任务:do=apply + 任务 id)。
// 社区里各类 52pojie 签到脚本普遍用 id=2;任务列表页里那个按钮自己也是这个地址,
// 所以这里既有默认值,会在页面里查到链接时以页面为准(findSignTask)。
const SIGN_APPLY_URL = 'https://www.52pojie.cn/home.php?mod=task&do=apply&id=2&referer=%2Fportal.php';
export const wuaipojie = {
id: 'wuaipojie',
name: '吾爱破解',
desc: '吾爱破解论坛每日签到(Discuz 任务)。Cookie 方式,需先用浏览器过安全验证,UA 须与抓包时一致。',
execution: 'browser', // 默认执行模式:server=云端执行,browser=浏览器扩展执行(用户网络)
domain: 'www.52pojie.cn', // 浏览器执行时的目标域名
// 该站的主站(www.52pojie.cn)被网宿 WAF 保护:**脚本化的 fetch 整站都读不到**
// (实测 2026-09-28:同一浏览器里 example.com / static.52pojie.cn 正常,而该主站的
// robots.txt、portal.php、home.php 全部挂到超时)。
// 只有「真实的页导航」才能过挑战 —— 所以这个站点默认改走**浏览器导航签到**:
// 由扩展把用户浏览器的一个标签页打开到「打卡签到」地址(等同人手点一下),再读回结果页。
preferNavigationSign: true,
browserJob: () => ({
domain: 'www.52pojie.cn',
// 先到任务列表页(普通页面,好加载)—— 签到链接就在这页上,
// 扩展端会自己找「去签到」链接并点击进入。
navigate_url: 'https://www.52pojie.cn/home.php?mod=task',
sign_url: SIGN_APPLY_URL,
}),
// 浏览器端签到脚本:在用户浏览器中运行,自动携带登录 Cookie,使用用户网络(无 WAF)
// 入参 params:{};返回 { ok, message }
// 注意:扩展会先导航标签页到 navigate_url(模拟手动点击),脚本只需检查当前页面内容
navigateUrl: 'https://www.52pojie.cn/home.php?mod=task&do=apply&id=2&referer=%2Fportal.php',
navigateUrl: SIGN_APPLY_URL,
browserScript: `async (params) => {
const html = document.documentElement.innerHTML || '';
const text = document.body ? document.body.innerText || '' : '';
@@ -133,8 +220,23 @@ export const wuaipojie = {
const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'slidercaptcha', '请完成安全验证', '安全检查中'];
if (WAF.some((m) => all.includes(m))) return { ok: false, message: '遇到安全验证(WAF):请在浏览器中打开 www.52pojie.cn 完成验证后重试' };
if (/需要先登录|请先登录/.test(all)) return { ok: false, message: '登录已失效,请重新获取 Cookie' };
// 先读顶部那个按钮自己的文字——它是站点当天的状态指示:
// 未签到 = 「打卡签到」(橙色) 已签到 = 「签到完毕」(绿色,点下去没反应)
// 只取按钮元素内部:<a> 的 title 里常年写着「打卡签到」,拿整页文本搜会误判。
const btn = document.querySelector('.click-qiandao, .zzhuti_qd_1, .zzhuti_qd_2, .user-index-qd, a[href*="do=apply"]');
const btnText = btn ? String(btn.innerText || btn.textContent || '').replace(/\\s+/g, '') : '';
const btnSigned = /签到完毕|签到完成|今日已签到/.test(btnText);
const btnUnsigned = /打卡签到|立即签到|点击签到/.test(btnText);
// 图片版状态(52pojie 把「打卡签到」画在 qds.png 里):读不到文字,
// 但顶栏那个「去签到」的链接还在 = 服务端认为今天还没签
const linkUnsigned = !!(btn && /do=apply/.test((btn.getAttribute && btn.getAttribute('href')) || ''));
if (/任务已完成|签到成功|打卡成功/.test(all)) return { ok: true, message: '签到成功' };
if (/今日已签到|已经签到|已签到|签到完毕|签到完成|下期再来|明天再来|无需重复/.test(all)) return { ok: true, message: '今日已签到,无需重复' };
if (btnSigned || /今日已签到|签到完毕|签到完成|下期再来|明天再来|无需重复/.test(all)) {
return { ok: true, message: '今日已签到,无需重复(网站按钮:' + (btnText || '签到完毕') + ')' };
}
if (linkUnsigned || btnUnsigned) {
return { ok: false, message: '本次没有签上:网站顶栏仍显示「' + (btnText || '打卡签到') + '」,且「去签到」链接还在。可在浏览器打开 www.52pojie.cn 手动点一次(可能要先过滑块验证),再重试' };
}
return { ok: false, message: '未识别到成功标识,页面标题:' + (document.title || '未知') };
}`,
fields: [
@@ -169,31 +271,55 @@ export const wuaipojie = {
Cookie: cookie,
};
// 超时后的验证:如果签到请求超时/中继失败,可能是请求实际成功但响应没回来,
// 去任务页看一眼有没有「签到完毕」,有就按已签到处理,避免误报失败。
// 查两个页面:apply 页(直接结果)+ task 列表页(按钮状态是铁证)
const verifySigned = async () => {
const checkPage = (v) => {
if (has(v, SIGNED_MARKS)) return true;
const t = clean(v);
if (/签到完毕/.test(t)) return true;
// 任务列表页:找 id=2 任务行的按钮文字
const raw = pickText(v);
const taskRow = raw.match(/id=2[\s\S]{0,500}/i);
if (taskRow && /签到完毕|已完成|已领取/.test(taskRow[0])) return true;
return false;
};
try {
const v = await fetchDualText('https://www.52pojie.cn/home.php?mod=task&do=apply&id=2', { headers: baseHeaders });
if (checkPage(v)) return true;
} catch { /* 忽略,继续查列表页 */ }
try {
const v = await fetchDualText('https://www.52pojie.cn/home.php?mod=task', { headers: baseHeaders });
if (checkPage(v)) return true;
} catch { /* 验证失败则忽略 */ }
return false;
// 状态探测:读「签到按钮 / 任务页」到底说没说已签到。
// 返回 'signed' | 'unsigned' | 'unknown':
// 超时/中继失败时,'signed' 才能当成功(不能凭空报成功),
// 'unsigned' 才能确定「本次没签上」并给出人能看懂的原因。
const isRelay = !!(ctx && ctx.relayDb);
// 「确定没签上」的统一出口:给出原因 + 下一步怎么操作。
// 为什么需要它:以前这种情况只报一句「未识别到成功标识」,用户完全不知道该干什么,
// 而现在站点按钮自己就写着「打卡签到」——那就是明确的未签到状态,可以直说。
const failUnsigned = (reason) => {
const err = new Error(
reason + ' —— 本次签到没有生效。\n' +
(isRelay
? '· 当前是「本地网络」:请在浏览器打开 www.52pojie.cn,手动点一次顶部那个「打卡签到」(可能要先过滑块验证);成功后本模块下次就能读到「签到完毕」。'
: '· 当前是「CF 网络」:该站屏蔽机房 IP(403 UrlACL),请把此账号切到「本地网络」再试。')
);
err.outcome = 'unsigned';
throw err;
};
const probeSignedState = async () => {
// 先找「已签到」的正信号,再谈未签到——否则任务列表页里那个
// 「已完成」文字会被同页的另一条「去签到」链接盖过去。
const checkPage = (v) => {
if (buttonState(v) === 'signed') return 'signed';
if (has(v, SIGNED_MARKS)) return 'signed';
const raw = pickText(v) || '';
const taskRow = raw.match(/id=2[\s\S]{0,500}/i);
if (taskRow && /签到完毕|已完成|已领取/.test(taskRow[0])) return 'signed';
if (buttonState(v) === 'unsigned') return 'unsigned';
return 'unknown';
};
// 先查任务页(普通页面,能读到);只有它说不清才去看申请页
// (申请页本身就可能被 WAF 吊死,能不去就不去)。有结论就立刻停,不等第二次。
const urls = [
'https://www.52pojie.cn/home.php?mod=task',
'https://www.52pojie.cn/home.php?mod=task&do=apply&id=2',
];
for (const u of urls) {
try {
const v = await fetchDualText(u, { headers: baseHeaders });
const st = checkPage(v);
if (st !== 'unknown') return st;
} catch { /* 查不到就继续换下一页 */ }
}
return 'unknown';
};
const verifySigned = async () => (await probeSignedState()) === 'signed';
// Discuz showmessage 的消息藏在 <script> 里,clean() 会把它删掉。
// 这里单独从原始 HTML(含 script)里提取消息文本,用于判定。
const extractShowMessage = (texts) => {
@@ -204,6 +330,37 @@ export const wuaipojie = {
return '';
};
// ⓿ 先查任务页(home.php?mod=task):当天是否已领过,这一页就是铁证。
// 为什么放在最前面(这三条都是线上实测出来的):
// ① 签到接口(do=apply)受网宿 WAF 最严格的那一档保护:经「本地网络」中继时
// 会把连接吊到超时(用户看到「中继执行超时(45秒),已放弃该请求」),
// 而那一次请求其实已经签到成功了——面板却报失败、状态还是「未签到」。
// ② 任务页是普通页面,能正常读到;当天领过之后按钮文字就是「签到完毕」。
// ③ 不重复打签到接口,又快又不会被 WAF 反复惦记。
// 判定不成立(没查到 / 页面读不到)时,继续走下面的完整流程。
const taskListUrl = 'https://www.52pojie.cn/home.php?mod=task';
let knownUnsigned = false; // 按钮写着「打卡签到」= 站点自己的明确未签到状态
let signTask = { id: '', href: '', label: '', done: false, formhash: '' };
try {
const t = await fetchDualText(taskListUrl, { headers: baseHeaders });
assertNoWaf(t, t.res.status);
signTask = findSignTask(t); // 以页面上的「打卡签到」链接为准(id/地址都可能变)
const btnSt = buttonState(t);
if (btnSt === 'unsigned') knownUnsigned = true;
// 已签到的判据(正信号优先):按钮/强文案,或这条任务自己写着已完成
if (btnSt === 'signed' || has(t, SIGNED_MARKS) || signTask.done) {
return {
ok: true,
message: '今日已签到(任务页显示:' + (signTask.done ? signTask.label + ' 已完成' : pickLine(t)) + ')',
detail: '网站返回:' + clean(t).slice(0, 300),
};
}
} catch (e) {
// WAF / 安全验证要如实上报,不能吞掉(否则用户永远不知道要过滑块)
if (/安全验证|拦截/.test((e && e.message) || '')) throw e;
// 其它错误(超时、中继失败)忽略,继续尝试签到
}
// ① 预检首页:**只看 WAF**。
// 注意:portal.php 是门户页,上面除了博客卡片,还有论坛的最新帖标题 + 「最新公告」块。
// 实测该页的公告里就写着「开放注册期间论坛暂停签到」,而帖子标题里什么都可能有。
@@ -212,9 +369,10 @@ export const wuaipojie = {
// 注:中继模式下 runner.js 会透明替换 global fetch,站点代码无需改动
// 优化:中继模式下跳过 portal.php 预检(省一次中继往返,52pojie 本来就慢);
// WAF 检测移到签到页做,签到页有 WAF 同样能识别。
const isRelay = !!(ctx && ctx.relayDb);
let home = null, pauseHint = '';
if (!isRelay) {
// 任务页已经明确写着「打卡签到」(未签到)时,门户页预检没意义,省一次请求;
// 中继模式下也跳过门户页(省一次中继往返,52pojie 本来就慢),WAF 在签到页同样能识别。
if (!isRelay && !knownUnsigned) {
try {
home = await fetchDualText('https://www.52pojie.cn/portal.php', { headers: baseHeaders });
assertNoWaf(home, home.res.status);
@@ -223,18 +381,21 @@ export const wuaipojie = {
? '(注:门户页公告提到暂停签到,若确实暂停请等恢复)'
: '';
} catch (e) {
// 预检超时/中继失败:直接验证是否已签到,避免误报
// 预检超时/中继失败:先看站点按钮的状态再下结论(不能凭空报成功,也不能胡报失败)
if (/超时|timeout|中继|abort|network/i.test(e.message || '')) {
if (await verifySigned()) {
return { ok: true, message: '今日已签到(预检超时后验证确认:签到完毕)' };
}
const st = await probeSignedState();
if (st === 'signed') return { ok: true, message: '今日已签到(预检超时后验证确认:签到完毕)' };
if (st === 'unsigned') failUnsigned('预检请求超时,但网站按钮仍是「打卡签到」');
}
throw e;
}
}
// ② 签到(手动跟随重定向,最多 3 跳)
let url = 'https://www.52pojie.cn/home.php?mod=task&do=apply&id=2&referer=%2Fportal.php';
// ② 签到:用页面上那条「打卡签到」链接的真实地址(含它自己的 id 与 referer)。
// Discuz 的任务申请接口认 formhash:页面上有就带上(漏了反而会返回「非法请求」)。
const abs = (h) => /^https?:\/\//i.test(h) ? h : 'https://www.52pojie.cn/' + String(h).replace(/^\/+/, '');
let url = signTask.href ? abs(signTask.href) : SIGN_APPLY_URL;
if (signTask.formhash && !/formhash=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'formhash=' + signTask.formhash;
let final = null;
// 中继模式下无法使用 redirect:'manual'(opaqueredirect 响应头不可读),改用 follow 让浏览器自动跟随
const redirectMode = ctx && ctx.relayDb ? 'follow' : 'manual';
@@ -252,11 +413,13 @@ export const wuaipojie = {
break;
}
} catch (e) {
// 中继超时/网络错误:请求可能实际成功但响应没回来,验证一次再下结论
// 中继超时/网络错误:请求可能实际成功但响应没回来,先探测一次再下结论
if (/超时|timeout|中继|abort|network/i.test(e.message || '')) {
if (await verifySigned()) {
const st = await probeSignedState();
if (st === 'signed') {
return { ok: true, message: '今日已签到,无需重复(超时后验证确认:签到完毕)' };
}
if (st === 'unsigned') failUnsigned('签到请求超时,网站按钮仍是「打卡签到」');
}
throw e;
}
@@ -294,9 +457,16 @@ export const wuaipojie = {
if (has(final, PAUSED_MARKS)) {
throw new Error('论坛官方暂停签到:' + pickLine(final) + '。等恢复后再试。');
}
// 到这一步还没结论。如果站点按钮自己写着「打卡签到」,那就是**明确未签到**:
// 直接告诉用户没签上(带原因和建议),不要报一句含糊的「未识别到成功标识」。
if (buttonState(final) === 'unsigned' || knownUnsigned) {
failUnsigned(buttonState(final) === 'unknown'
? '任务页显示网站按钮仍是「打卡签到」'
: '签到页已返回,但网站按钮仍是「打卡签到」');
}
// apply 没出明确结论时,试一次 do=draw 领奖励(Discuz 每日任务:apply 是接任务,draw 是领奖励完成签到)
try {
const draw = await fetchDualText('https://www.52pojie.cn/home.php?mod=task&do=draw&id=2', { headers: baseHeaders, redirect: redirectMode });
const draw = await fetchDualText('https://www.52pojie.cn/home.php?mod=task&do=draw&id=' + (signTask.id || '2'), { headers: baseHeaders, redirect: redirectMode });
assertNoWaf(draw, draw.res.status);
if (has(draw, SUCCESS_MARKS)) {
const suffix = isRelay ? '' : await creditSuffix(baseHeaders);
@@ -312,13 +482,18 @@ export const wuaipojie = {
// draw 失败不致命,继续用 apply 的结果报错
if (/WAF|安全验证|403|UrlACL/.test(e.message || '')) throw e;
}
// 最后一道兜底:页面解析不出结论时,直接查任务实际状态(按钮是不是"签到完毕")
// 避免"网站其实已签到,面板却报未签到"的情况
// 最后一道兜底:页面解析不出结论时,直接查任务实际状态(按钮是「签到完毕」还是「打卡签到」)
// 避免「网站其实已签到,面板却报未签到」;反过来确定了没签上也要直说。
try {
if (await verifySigned()) {
const st = await probeSignedState();
if (st === 'signed') {
return { ok: true, message: '今日已签到,无需重复(任务页验证确认:签到完毕)' };
}
} catch { /* 忽略,走下面的报错 */ }
if (st === 'unsigned') failUnsigned('签到接口已返回,但重新查验发现网站按钮仍是「打卡签到」');
} catch (e) {
if (e && e.outcome === 'unsigned') throw e; // 明确未签到的结论要抛出去
// 其它情况(探测不通)忽略,走下面的报错
}
// 诊断:记录页面关键特征,帮助排查为什么没识别到
const diag = [];
const diagText = pickText(final);
+262
View File
@@ -0,0 +1,262 @@
// 社区站点配置测试:node test/community.test.mjs(纯 mock,不联网)
// 这套东西是「开源 + 别人贡献站点适配」的入口,所以判定要严:
// · 配置里的明文凭据必须被拦下(分享配置不能带上别人的 Cookie)
// · 导入 / 导出 / 执行 三条链路都要能跑通
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import {
CONFIG_SCHEMA,
validateSiteConfig,
sanitizeSiteConfig,
importSiteConfig,
listCommunitySites,
deleteCommunitySite,
makeCommunitySite,
exportAccountConfig,
} from '../src/community.js';
let n = 0;
const t = async (name, fn) => { await fn(); n++; console.log('ok -', name); };
const GOOD = {
schema: CONFIG_SCHEMA,
id: 'demo_bbs',
name: '示例论坛',
desc: '示例论坛每日签到',
author: 'someone',
version: '1.0.0',
execution: 'server',
fields: [
{ key: 'site_url', label: '站点地址', type: 'text', required: true },
{ key: 'cookie', label: 'Cookie', type: 'textarea', required: true },
{ key: 'user_agent', label: 'User-Agent', type: 'text' },
],
steps: [
{
name: '每日签到',
method: 'POST',
url: '{{site_url}}/api/sign',
headers: { Cookie: '{{cookie}}', 'User-Agent': '{{user_agent}}' },
body: '',
expect_status: 200,
expect_contains: 'ok',
},
],
tips: '登录后用扩展抓 Cookie',
};
// ---------- 极简 D1 fake(只认 community_sites 那几条 SQL) ----------
function fakeDb() {
const rows = new Map();
return {
rows,
prepare(sql) {
const stmt = {
_args: [],
bind(...args) { stmt._args = args; return stmt; },
async run() {
if (/INSERT INTO community_sites/i.test(sql)) {
const [id, name, author, version, source, def, created_at, updated_at] = stmt._args;
rows.set(id, { id, name, author, version, source, def, created_at, updated_at });
}
if (/DELETE FROM community_sites/i.test(sql)) rows.delete(stmt._args[0]);
return {};
},
async first() {
if (/SELECT id FROM community_sites WHERE id = \?/i.test(sql)) {
const r = rows.get(stmt._args[0]);
return r ? { id: r.id } : null;
}
return null;
},
async all() {
if (/FROM community_sites ORDER BY name/i.test(sql)) {
return { results: [...rows.values()].sort((a, b) => String(a.name).localeCompare(String(b.name))) };
}
return { results: [] };
},
};
return stmt;
},
async batch() { return []; },
};
}
await t('社区配置:合法配置通过校验(无错误、无警告)', () => {
const v = validateSiteConfig(GOOD);
assert.equal(v.ok, true, JSON.stringify(v.errors));
assert.deepEqual(v.warnings, []);
});
await t('社区配置:缺 id / steps 会被明确拦下', () => {
const v = validateSiteConfig({ name: 'X', steps: [] });
assert.equal(v.ok, false);
assert.ok(v.errors.some((e) => /id/.test(e)), JSON.stringify(v.errors));
assert.ok(v.errors.some((e) => /steps/.test(e)), JSON.stringify(v.errors));
});
await t('社区配置:非法 JSON 直接报错而不是崩', () => {
const v = validateSiteConfig('{不是 json');
assert.equal(v.ok, false);
assert.match(v.errors[0], /JSON/);
});
await t('社区配置:请求头里带明文 Cookie 的配置必须被拒绝', () => {
const bad = JSON.parse(JSON.stringify(GOOD));
bad.steps[0].headers.Cookie = 'abc=1; sess=deadbeefdeadbeef';
const v = validateSiteConfig(bad);
assert.equal(v.ok, false);
assert.ok(v.errors.some((e) => /明文凭据/.test(e)), JSON.stringify(v.errors));
});
await t('社区配置:导出时把凭据换成占位符(不会把别人的 Cookie 分享出去)', () => {
const withSecret = JSON.parse(JSON.stringify(GOOD));
withSecret.steps[0].headers.Cookie = 'sess=deadbeefdeadbeef';
withSecret.steps[0].headers.Authorization = 'Bearer abcdefghijklmnop';
const out = sanitizeSiteConfig(withSecret);
assert.equal(out.steps[0].headers.Cookie, '{{cookie}}');
assert.equal(out.steps[0].headers.Authorization, '{{authorization}}');
assert.equal(out.schema, CONFIG_SCHEMA);
assert.ok(!JSON.stringify(out).includes('deadbeef'));
});
await t('社区配置:导入 → 列出 → 再导入需确认覆盖 → 删除', async () => {
const db = fakeDb();
const r = await importSiteConfig(db, GOOD);
assert.equal(r.ok, true);
assert.equal(r.id, 'demo_bbs');
const list = await listCommunitySites(db);
assert.equal(list.length, 1);
assert.equal(list[0].name, '示例论坛');
assert.equal(list[0].author, 'someone');
const again = await importSiteConfig(db, GOOD).catch((e) => e);
assert.ok(again instanceof Error);
assert.equal(again.needOverwrite, true);
const forced = await importSiteConfig(db, { ...GOOD, version: '1.0.1' }, { overwrite: true });
assert.equal(forced.ok, true);
const list2 = await listCommunitySites(db);
assert.equal(list2[0].version, '1.0.1');
await deleteCommunitySite(db, 'demo_bbs');
assert.equal((await listCommunitySites(db)).length, 0);
});
await t('社区配置:makeCommunitySite 真能执行(占位符被账号字段替换)', async () => {
const site = makeCommunitySite(GOOD);
assert.equal(site.id, 'demo_bbs');
assert.equal(site.execution, 'server');
assert.ok(site.fields.some((f) => f.key === 'cookie'));
const seen = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (url, init = {}) => {
seen.push({ url: String(url), headers: init.headers || {}, method: init.method });
return { status: 200, text: async () => JSON.stringify({ ok: true, msg: 'ok' }) };
};
let r;
try {
r = await site.run({ site_url: 'https://bbs.example.com', cookie: 'a=1', user_agent: 'UA1' });
} finally {
globalThis.fetch = origFetch;
}
assert.equal(seen.length, 1);
assert.equal(seen[0].url, 'https://bbs.example.com/api/sign');
assert.equal(seen[0].headers.Cookie, 'a=1', '{{cookie}} 必须被账号字段替换');
assert.equal(seen[0].headers['User-Agent'], 'UA1');
assert.equal(r.ok, true);
assert.match(r.detail, /网站返回/);
});
await t('社区配置:站点的失败判定沿用统一信号识别(登录失效能说清楚)', async () => {
const site = makeCommunitySite(GOOD);
const origFetch = globalThis.fetch;
globalThis.fetch = async () => ({ status: 200, text: async () => '请先登录后再签到' });
const err = await (async () => { try { await site.run({ site_url: 'https://x.com', cookie: 'c' }); } catch (e) { return e; } finally { globalThis.fetch = origFetch; } return null; })();
assert.ok(err instanceof Error, '应抛错');
assert.match(err.message, /登录/);
});
// ---------- 反向:账号 → 可分享配置 ----------
await t('导出:把录好的多步流程变成可分享配置(凭据变占位符、域名变 {{site_url}})', () => {
const acc = {
id: 7,
name: '我的论坛',
site: 'http',
creds: {
steps: [
{
name: '签到',
method: 'POST',
url: 'https://bbs.example.com/api/sign?inajax=1',
headers: JSON.stringify({ Cookie: 'sess=deadbeefdeadbeefdead', 'User-Agent': 'UA-abc', 'X-Csrf-Token': 'tok_1234567890abcdef' }),
body: 'a=1',
expect_status: '200',
expect_contains: '成功',
},
],
},
};
const r = exportAccountConfig(acc, { id: 'http', name: '自定义 HTTP', domain: '', execution: 'server' });
assert.equal(r.ok, true, JSON.stringify(r.errors));
const cfg = r.config;
assert.equal(cfg.schema, CONFIG_SCHEMA);
assert.equal(cfg.id, 'bbs_example_com', 'id 应从域名推出来');
assert.equal(cfg.domain, 'bbs.example.com');
assert.match(cfg.steps[0].url, /^\{\{site_url\}\}\/api\/sign/);
const h = JSON.parse(cfg.steps[0].headers);
assert.equal(h.Cookie, '{{cookie}}');
assert.equal(h['X-Csrf-Token'], '{{x_csrf_token}}', '不合法的占位符名要归一化');
assert.ok(!JSON.stringify(cfg).includes('deadbeef'), '配置里不允许出现真实凭据');
const keys = cfg.fields.map((f) => f.key).sort();
assert.deepEqual(keys, ['cookie', 'site_url', 'x_csrf_token'], '用到的占位符都应变成要填的字段(UA 不是占位符就不出现)');
assert.equal(validateSiteConfig(cfg).ok, true, '导出的配置必须能再导进去');
});
await t('导出:单次请求的账号也能变成一步配置', () => {
const r = exportAccountConfig(
{ id: 9, name: 'x', site: 'http', creds: { url: 'https://x.com/sign', method: 'GET', headers: '{"Cookie":"a=1"}', expect_status: '200' } },
{ id: 'http' }
);
assert.equal(r.ok, true, JSON.stringify(r.errors));
assert.equal(r.config.steps.length, 1);
assert.equal(r.config.steps[0].url, '{{site_url}}/sign');
assert.deepEqual(r.config.fields.map((f) => f.key).sort(), ['cookie', 'site_url']);
assert.equal(r.config.domain, 'x.com');
});
await t('导出:内置站点(代码写死的逻辑)明确拒绝并给出人话解释', () => {
const r = exportAccountConfig({ id: 1, name: 'x', site: 'nodeseek', creds: { cookie: 'a=1' } }, { id: 'nodeseek', name: 'NodeSeek' });
assert.equal(r.ok, false);
assert.match(r.reason, /自定义 HTTP/);
});
// 仓库自带的示例 / 贡献配置必须能通过校验。
// 这是开源共建的第一道关:别人照着改的时候,不应该一上来就踩坑。
await t('社区配置:community/sites/*.json 全部能通过校验(含不带明文凭据)', () => {
const dir = new URL('../community/sites/', import.meta.url);
const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
assert.ok(files.length >= 3, '至少要有模板 + 两个示例:' + files.join(','));
for (const f of files) {
const cfg = JSON.parse(readFileSync(new URL(f, dir), 'utf8'));
const v = validateSiteConfig(cfg);
assert.equal(v.ok, true, `${f}:${JSON.stringify(v.errors)}`);
assert.ok(cfg.desc, `${f}:配置要写 desc`);
assert.ok(cfg.author, `${f}:配置要写作者`);
}
});
await t('社区配置:community/index.json 的 raw 链接都指向真实存在的文件', () => {
const idx = JSON.parse(readFileSync(new URL('../community/index.json', import.meta.url), 'utf8'));
assert.equal(idx.schema, 'daily-checkin-community-index/1');
for (const s of idx.sites) {
assert.match(s.raw, /\/community\/sites\/[a-z0-9_\-]+\.json$/i, s.raw);
const name = s.raw.split('/').pop();
const cfg = JSON.parse(readFileSync(new URL('../community/sites/' + name, import.meta.url), 'utf8'));
assert.equal(cfg.id, s.id, `${name} 的 id 与索引不一致`);
}
});
console.log(`\n${n} 组通过`);
+87
View File
@@ -75,4 +75,91 @@ t('cURL 无 Cookie 报错', () => {
assert.throws(() => extractCookieFromCurl(`curl 'https://example.com/'`), /没有 Cookie/);
});
// ---------- Cookie 逐段中文说明(面板「Cookie 解析器」的核心字典)----------
// 目的:用户粘一大串时能看懂哪段是登录必需、哪段是网站安全校验、哪些只是统计。
t('cookie 说明:登录必需 / 安全校验 / 统计 三类能分清', () => {
assert.equal(describeCookieName('PHPSESSID').kind, 'login');
assert.match(describeCookieName('PHPSESSID').desc, /PHP 会话/);
assert.equal(describeCookieName('wordpress_logged_in_abc').kind, 'login');
assert.match(describeCookieName('wordpress_logged_in_abc').desc, /WordPress/);
// 吾爱破解的 WAF 校验 Cookie 必须单独归为「安全校验」——它失效就等于 403
assert.equal(describeCookieName('wzws_cid').kind, 'waf');
assert.match(describeCookieName('wzws_cid').desc, /网宿/);
assert.equal(describeCookieName('cf_clearance').kind, 'waf');
assert.equal(describeCookieName('_ga').kind, 'stat');
assert.equal(describeCookieName('Hm_lvt_1234').kind, 'stat');
});
t('cookie 说明:不认识的名字归为「站点自定义」,不能瞎猜', () => {
const d = describeCookieName('some_random_bbs_key');
assert.equal(d.kind, 'other');
assert.match(d.desc, /站点自定义/);
});
t('cookie 值说明:只讲形态、不展开值,长度和类型都要报出来', () => {
const jwt = describeCookieValue('eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOjEyMzQ1Njc4fQ.abcdefghijklmn');
assert.match(jwt, /长度 \d+/);
assert.match(jwt, /JWT/);
assert.match(describeCookieValue(String(Math.floor(Date.now() / 1000))), /Unix 时间戳/);
assert.match(describeCookieValue(''), /空值/);
// 值不能整段吐出来:只留头尾做比对用
const long = 'x'.repeat(40);
const shown = describeCookieValue(long);
assert.ok(!shown.includes(long), '不应把完整值原样展示');
});
// ---------- parsePasteText / explainCookieText:面板「Cookie 解析器」的底座 ----------
// 用户会粘进来的东西很杂(扩展 JSON / 请求头 / cURL / cookie.txt / 裸串),都要认。
t('识别:裸 Cookie 串', () => {
const r = parsePasteText('a=1; b=2');
assert.equal(r.format, '纯 Cookie 字符串');
assert.equal(r.cookie, 'a=1; b=2');
});
t('识别:F12 请求头(Cookie + UA 一起拿)', () => {
const r = parsePasteText('Accept: */*\nCookie: PHPSESSID=abc; token=xyz\nUser-Agent: MyUA/9.9');
assert.equal(r.format, '浏览器复制的请求头');
assert.equal(r.cookie, 'PHPSESSID=abc; token=xyz');
assert.equal(r.userAgent, 'MyUA/9.9');
});
t('识别:扩展「一键复制全部信息」的 JSON', () => {
const payload = JSON.stringify({
domain: 'www.52pojie.cn', cookies: 'wzws_cid=abc; cdb_sid=1', userAgent: 'UA-from-ext',
cookieList: [{ name: 'wzws_cid', value: 'abc', httpOnly: true }, { name: 'cdb_sid', value: '1' }],
localStorage: { a: '1' },
});
const r = parsePasteText(payload);
assert.equal(r.format, '扩展「一键复制全部信息」');
assert.equal(r.domain, 'www.52pojie.cn');
assert.equal(r.userAgent, 'UA-from-ext');
assert.equal(r.cookieList.length, 2);
});
t('识别:不认识的内容要报错,不能默默当成 Cookie', () => {
assert.throws(() => parsePasteText('这是一段普通文字,没有任何等号'), /没看出这是 Cookie/);
assert.throws(() => parsePasteText(''), /请先在目标网站/);
});
t('explainCookieText:每一段都给出类型 + 中文说明 + 值形态', () => {
const raw = 'Cookie: PHPSESSID=abc123; wordpress_logged_in_a=laoguo%7C1730000000%7Cabcdef; wzws_cid=0123456789abcdef0123456789abcdef; _ga=GA1.2.99';
const info = explainCookieText(raw);
assert.equal(info.parts.length, 4);
const byName = Object.fromEntries(info.parts.map((p) => [p.name, p]));
assert.equal(byName.PHPSESSID.kind, 'login');
assert.match(byName.PHPSESSID.desc, /PHP 会话/);
assert.equal(byName.wordpress_logged_in_a.kind, 'login');
// 吾爱破解的 WAF cookie 必须被单独标出来,用户才知道它不能丢
assert.equal(byName.wzws_cid.kind, 'waf');
assert.equal(byName._ga.kind, 'stat');
// 值形态:长度 + 特征,不把整段值吐出来
assert.match(byName.PHPSESSID.detail, /长度 6/);
assert.ok(!byName.PHPSESSID.detail.includes('abc123'), '不应原样展示值');
// 小结要把“几段登录必需 / 几段安全校验”说人话
assert.match(info.summary, /共 4 段/);
assert.match(info.summary, /登录必需 2 段/);
assert.match(info.summary, /安全校验 1 段/);
assert.match(info.summary, /纯统计 1 段/);
});
console.log(`\n${n} 组通过`);
+51
View File
@@ -0,0 +1,51 @@
// 扩展侧超时预算:node test/ext-timeout.test.mjs
//
// 为什么要有这个测试(线上真实故障,2026-09-28):
// 扩展执行中继任务时会「先短超时试探 → 超时就把标签激活到前台再试一次」。
// 旧版本把任务级硬超时设成 45 秒,而这条链最多要 12(首次)+ 1.2(激活等待)+ 30(前台重试)
// ≈ 43 秒,再加上取任务/注入的开销就顶到了 45 秒 —— 于是**第二次明明快要拿到响应了也被掐掉**,
// 面板上永远只看到「中继执行超时」,而那个 POST 其实早就发出去了(站点可能已经签到成功)。
// 糊涂鳄的签到请求就是这么反复「失败」的。下面的断言把这个预算关系钉住。
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
let n = 0;
const t = (name, fn) => { fn(); n++; console.log('ok -', name); };
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const ext = readFileSync(join(root, 'public', 'ext-src', 'background.js'), 'utf8');
const bundled = readFileSync(join(root, 'src', 'ext-files.js'), 'utf8');
// 从源码里取常量值:既支持字面量,也支持「用公式算出来」的写法
function constOf(name) {
const m = ext.match(new RegExp('const\\s+' + name + '\\s*=\\s*([^;]+);'));
assert.ok(m, '找不到常量 ' + name);
const expr = m[1].trim();
if (/^\d+$/.test(expr)) return Number(expr);
// 公式:把里面引用的常量名换成数值再求值(只允许数字与 + - * / 和括号)
const resolved = expr.replace(/[A-Za-z_][A-Za-z0-9_]*/g, (name2) => String(constOf(name2)));
assert.match(resolved, /^[\d+\-*/().\s]+$/, name + ' 的表达式无法求值:' + expr);
// eslint-disable-next-line no-new-func
return Number(new Function('return ' + resolved)());
}
t('中继任务硬超时能装下「首次试探 + 激活前台 + 前台重试」', () => {
const job = constOf('RELAY_JOB_TIMEOUT_MS');
const budget = constOf('RELAY_FIRST_TRY_MS') + constOf('RELAY_ACTIVATE_WAIT_MS') + constOf('RELAY_FETCH_TIMEOUT_MS');
assert.ok(job > budget, `硬超时 ${job}ms 必须大于重试链预算 ${budget}ms`);
assert.ok(job - budget >= 5000, `至少留 5 秒余量,实际只有 ${job - budget}ms`);
});
t('超时文案说明「请求已发出、结果未知」,不再写成「已放弃该请求」', () => {
assert.match(ext, /请求已发出但没收到回包/);
assert.doesNotMatch(ext, /已放弃该请求/);
});
t('面板里的扩展副本与源码同步(src/ext-files.js 是生成的)', () => {
assert.ok(bundled.includes('RELAY_ACTIVATE_WAIT_MS'), 'ext-files.js 需要重新生成:node tools/gen-ext-files.mjs');
assert.match(bundled, /请求已发出但没收到回包/);
});
console.log(`\n${n} 组通过`);
+117 -24
View File
@@ -19,14 +19,27 @@ function mock(handler) {
return calls;
}
// ---------- 1. 从首页发现悬浮窗真正的 action / nonce ----------
await t('hutue:从首页悬浮窗发现 action 与 nonce 并优先使用', async () => {
const HOME = '<html><script>var ripro={"ajaxurl":"\\/wp-admin\\/admin-ajax.php","nonce":"abc123"};'
+ 'jQuery.post(ripro.ajaxurl,{action:"xb_user_qiandao",nonce:ripro.nonce},function(r){})</script>'
+ '<div class="sign-float"><a data-action="xb_user_qiandao">签到</a></div></html>';
// 真实站点首页(截取自 dj.hutue.cn 2026-09-28):
// ① 悬浮窗按钮 <a class="click-qiandao zzhuti_qd_1">打卡签到</a>,文案「每天签到 5 晶石」
// ② 父主题 RiPro app.js:$(".click-qiandao").on("click", … action:"user_qiandao")
// ③ 子主题 xb-child xb-app.js:$(".user-index-qd") … action:"xb_user_qiandao"(会员中心的另一个签到,只给 1 积分)
const HOME_REAL = '<html><head>'
+ '<script id="app-js-extra">var caozhuti={"ajaxurl":"https:\\/\\/dj.hutue.cn\\/wp-admin\\/admin-ajax.php","nonce":"abc123"};</script>'
+ '<script src="https://dj.hutue.cn/wp-content/themes/ripro/assets/js/app.js?ver=6.6.1"></script>'
+ '</head><body>'
+ '<div class="vip-desc">仙尊会员可享全站资源免费下载(需1晶石,每天签到5晶石)</div>'
+ '<a class="click-qiandao zzhuti_qd_1" href="javascript:void(0);" title="打卡签到">签到</a>'
+ '<script>jQuery(".click-qiandao").on("click",function(){jQuery.post(caozhuti.ajaxurl,{action:"user_qiandao"},function(a){1==a.status&&location.reload()})});'
+ 'jQuery(".user-index-qd").on("click",function(){jQuery.post(caozhuti.ajaxurl,{action:"xb_user_qiandao"},function(n){})});</script>'
+ '</body></html>';
// ---------- 1. 从首页发现悬浮窗真正的 action ----------
// 线上事故:面板一直打的是会员中心的 xb_user_qiandao(奖励 1 积分),它确实回 status:1,
// 于是报「签到成功」——但悬浮窗的真签到(5 晶石)根本没做,用户去网站照样能再签一次。
await t('hutue:从首页悬浮窗发现 action,并优先使用按钮真正绑定的那个', async () => {
const calls = mock((u, m) => {
if (u === 'https://dj.hutue.cn/') return { body: HOME };
if (u.includes('admin-ajax.php')) return { body: JSON.stringify({ status: 1, msg: '签到成功,获得 10 积分' }) };
if (u === 'https://dj.hutue.cn/') return { body: HOME_REAL };
if (u.includes('admin-ajax.php')) return { body: JSON.stringify({ status: 1, msg: '签到成功,赠送 5 晶石' }) };
return { throw: 'unexpected ' + u };
});
const ctx = { meta: {} };
@@ -34,14 +47,20 @@ await t('hutue:从首页悬浮窗发现 action 与 nonce 并优先使用', asy
assert.equal(r.ok, true);
assert.match(r.message, /签到成功/);
assert.match(r.message, /10 积分/);
assert.match(r.message, /5 晶石/);
const post = calls.find((c) => c.method === 'POST');
assert.ok(post, '应有 AJAX POST');
assert.match(post.url, /admin-ajax\.php/);
assert.match(post.body, /action=xb_user_qiandao/);
assert.match(post.body, /nonce=abc123/);
assert.equal(ctx.meta.hutue_action, 'xb_user_qiandao', '命中后应记住 action');
assert.equal(ctx.meta.hutue_use_nonce, true);
assert.match(post.body, /action=user_qiandao/, '必须打真签到:' + post.body);
assert.doesNotMatch(post.body, /xb_user_qiandao/);
assert.equal(ctx.meta.hutue_action, 'user_qiandao', '命中后应记住真签到 action');
});
await t('discoverSignin:按钮绑定的 action 排在其他泛匹配之前', async () => {
const d = discoverSignin('https://dj.hutue.cn', HOME_REAL);
assert.equal(d.actions[0], 'user_qiandao', JSON.stringify(d.actions));
assert.deepEqual(d.buttonActions, ['user_qiandao']);
assert.ok(d.actions.includes('xb_user_qiandao'));
});
// ---------- 2. 首页没有线索 → 兜底 action ----------
@@ -54,9 +73,9 @@ await t('hutue:首页无线索时用兜底 action', async () => {
const r = await hutue.run({ site_url: 'https://hutue.cn', cookie: 'c=x' }, { meta: {} });
assert.equal(r.ok, true);
const post = calls.find((c) => c.method === 'POST');
// 实测:首页内联脚本只给 ajaxurl、不写 action 名(真正的处理器在 xb-app.js),
// 所以兜底顺序首位应该是 xb_user_qiandao,否则每次都要白打一个请求
assert.match(post.body, /action=xb_user_qiandao/);
// 实测:首页内联脚本只给 ajaxurl、不写 action 名(处理器在主题 JS 里),
// 所以硬编码的首位必须是站点真正的签到 action:RiPro 的 user_qiandao
assert.match(post.body, /action=user_qiandao/);
});
// ---------- 3. 今日已签到 ----------
@@ -87,15 +106,17 @@ await t('hutue:成功时逐字回传网站 msg,不拼任何前缀', async ()
// 主题自带 action 永远优先:否则某个别的插件 action 先返回 status=1 就会被记住,
// 以后每次都走它,网站反馈变成那个插件的话(奖励对不上本站)。
await t('hutue:即使记住过别的 action,主题 action 仍排第一', async () => {
// 主题自带的真签到 action 永远第一:否则某次命中会员中心那个(1 积分)就会被记住,
// 以后每次都走它,真签到(5 晶石)永远不做——这正是线上的「假签到」。
await t('hutue:站点真签到 action 永远排第一(不被记住的旧 action 顶掉)', async () => {
const calls = mock((u) => {
if (u.endsWith('/')) return { body: '<html>首页</html>' };
return { body: JSON.stringify({ status: 1, msg: '签到成功' }) };
});
const ctx = { meta: { hutue_action: 'user_qiandao' } };
const ctx = { meta: { hutue_action: 'xb_user_qiandao' } };
await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, ctx);
const first = new URLSearchParams(calls.find((c) => c.method === 'POST').body).get('action');
assert.equal(first, 'xb_user_qiandao');
assert.equal(first, 'user_qiandao');
});
// ---------- 4. 登录失效 ----------
@@ -144,15 +165,18 @@ await t('hutue:AJAX 返回登录页时提示登录失效', async () => {
assert.match(err.message, /登录已失效/);
});
// ---------- 8. 记住上次命中的 action,下次优先 ----------
await t('hutue:下次优先用上次命中的 action', async () => {
// ---------- 8. 记住上次命中的 action:跳过首页发现(省一次中继往返),但真签到仍排第一 ----------
await t('hutue:有记住的 action 时跳过首页抓取,且先打真签到', async () => {
const calls = mock((u) => {
if (u.endsWith('/')) return { body: '<html>首页</html>' };
return { body: JSON.stringify({ status: 1, msg: '签到成功' }) };
if (u.endsWith('/')) return { body: HOME_REAL };
return { body: JSON.stringify({ status: 1, msg: '签到成功,赠送 5 晶石' }) };
});
await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, { meta: { hutue_action: 'xb_user_qiandao' } });
// 故意把上次命中的记成错误的那个(线上真实状态):也不该被它抢走
const r = await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, { meta: { hutue_action: 'xb_user_qiandao' } });
assert.equal(r.ok, true);
assert.ok(!calls.some((c) => c.method === 'GET' && c.url === 'https://dj.hutue.cn/'), '记住 action 后不再抓首页');
const first = calls.find((c) => c.method === 'POST');
assert.match(first.body, /action=xb_user_qiandao/);
assert.match(first.body, /action=user_qiandao/);
});
// ---------- 9. discoverSignin 纯函数 ----------
@@ -211,4 +235,73 @@ await t('hutue:首页真的是登录表单 → 报 Cookie 失效', async () =>
assert.match(err.detail, /网站返回/);
});
// ---------- 12. 「不再用别的接口冒充成功」 ----------
// 线上真相(用户投诉的核心):真签到接口 user_qiandao 就算本次给不出成功,
// 也不能偷偷换成会员中心的 xb_user_qiandao(它确实会回「签到成功」,但只给 1 积分),
// 否则面板显示「已签到」而网站悬浮窗照样能再签一次 —— 那就是「假签到」。
await t('hutue:真接口给出业务回答(非成功)时,不再换接口冒充成功', async () => {
const calls = mock((u) => {
if (u.endsWith('/')) return { body: '<html>首页</html>' };
return { body: JSON.stringify({ status: 0, msg: '签到失败:今天还没到签到时间' }) };
});
const err = await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, { meta: {} }).catch((e) => e);
assert.ok(err instanceof Error, '不能报成功');
assert.match(err.message, /今天还没到签到时间/);
const actions = calls.filter((c) => c.method === 'POST').map((c) => new URLSearchParams(c.body).get('action'));
assert.deepEqual(actions, ['user_qiandao'], '只该打真签到接口,实测:' + JSON.stringify(actions));
assert.match(err.detail || '', /接口 user_qiandao/, 'detail 要说清打的是哪个接口');
});
// 只有「站点不认这个接口」(WordPress 对不存在的 action 回字符串 0)才允许换下一个接口,
// 并且要在反馈里明说「本次是会员中心签到,只给 1 积分」。
await t('hutue:真接口不存在(返回 0)→ 才换到会员中心接口,并在反馈里标注', async () => {
let posts = 0;
globalThis.fetch = async (url, init = {}) => {
const u = String(url);
if (u.endsWith('/')) return { status: 200, text: async () => '<html>首页</html>' };
posts++;
const action = new URLSearchParams(String(init.body || '')).get('action');
if (action === 'user_qiandao') return { status: 200, text: async () => '0' }; // 站点不认这个 action
return { status: 200, text: async () => JSON.stringify({ status: 1, msg: '签到成功,赠送1积分' }) };
};
const r = await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, { meta: {} });
assert.equal(r.ok, true);
assert.equal(posts, 2, '第一次被站点拒绝后才试第二个接口');
assert.match(r.detail || '', /接口 xb_user_qiandao/, '要说清用的是哪个接口');
assert.match(r.detail || '', /只给 1 积分/, '降级用会员中心接口时必须明说');
});
// ---------- 13. 中继超时:请求可能已经送达,不能直接当失败 ----------
// 线上现象(2026-09-28 糊涂鳄):中继打 admin-ajax 挂到 45 秒,扩展回「已放弃该请求」,
// 面板记「失败」;但服务端很可能已经处理了这次 POST(网站那天确实已签)。
// 现在的做法:同一接口再打一次 —— 若上一次真的生效了,站点会直接回「今日已签到」。
await t('hutue:中继超时后重试一次,站点回「今日已签到」→ 判为已签(说明请求其实已送达)', async () => {
let posts = 0;
globalThis.fetch = async (url) => {
const u = String(url);
if (u.endsWith('/')) return { status: 200, text: async () => '<html>首页</html>' };
posts++;
if (posts === 1) throw new Error('本地网络失败:中继执行超时(45秒),已放弃该请求');
return { status: 200, text: async () => JSON.stringify({ status: 0, msg: '今日已签到,请明日再来' }) };
};
const r = await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, { meta: {} });
assert.equal(r.ok, true);
assert.equal(r.message, '今日已签到,请明日再来');
assert.equal(posts, 2, '超时后应重试同一个接口');
assert.match(r.detail || '', /重试/, '反馈里要留下「重试过」的痕迹');
});
await t('hutue:重试仍等不到回包 → 报「结果未知」(不是「失败」,不冤枉网站)', async () => {
globalThis.fetch = async (url) => {
const u = String(url);
if (u.endsWith('/')) return { status: 200, text: async () => '<html>首页</html>' };
throw new Error('本地网络失败:中继执行超时(45秒),已放弃该请求');
};
const err = await hutue.run({ site_url: 'https://dj.hutue.cn', cookie: 'c=x' }, { meta: {} }).catch((e) => e);
assert.ok(err instanceof Error);
assert.equal(err.outcome, 'relay-unknown');
assert.match(err.message, /结果未知/);
assert.doesNotMatch(err.message, /^签到失败/);
});
console.log(`\n${n} 组通过`);
+102
View File
@@ -0,0 +1,102 @@
// 本地网络中继(Worker 侧):node test/relay.test.mjs
// 重点不是「中继能跑」(那要真扩展),而是**出错时面板能不能看出卡在哪里**:
// 吾爱破解线上日志曾只有一句「本地网络失败:中继执行超时(45秒)」,用户在面板上
// 完全不知道卡的是哪个请求。所以失败/超时都必须把请求行写进 err.detail。
import assert from 'node:assert/strict';
import { waitRelayResult } from '../src/lib/relay.js';
let n = 0;
const t = async (name, fn) => { await fn(); n++; console.log('ok -', name); };
// 极简 D1 fake:一条 relay_jobs 记录
function fakeDb(job) {
return {
prepare() {
const stmt = {
bind() { return stmt; },
async first() { return job; },
async run() { return {}; },
};
return stmt;
},
};
}
await t('中继:扩展回传失败时,错误里带上卡住的请求(method + url)', async () => {
const db = fakeDb({ status: 'failed', error: '中继执行超时(45秒),已放弃该请求', method: 'post', url: 'https://www.52pojie.cn/home.php?mod=task&do=apply&id=2' });
const err = await waitRelayResult(db, 'r1', 200, 20).catch((e) => e);
assert.ok(err instanceof Error);
assert.match(err.message, /本地网络失败/);
assert.match(err.detail, /POST https:\/\/www\.52pojie\.cn\/home\.php\?mod=task/);
});
await t('中继:Worker 端等超时,也要说清是哪个请求没回来', async () => {
const db = fakeDb({ status: 'pending', method: 'GET', url: 'https://example.com/api/sign' });
const err = await waitRelayResult(db, 'r2', 150, 30).catch((e) => e);
assert.ok(err instanceof Error);
assert.match(err.message, /等待本地网络响应超时/);
assert.match(err.detail, /GET https:\/\/example\.com\/api\/sign/);
});
await t('中继:url 过长时截断,不把整串查询参数灌进日志', async () => {
const long = 'https://example.com/api/sign?' + 'a=1&'.repeat(200);
const db = fakeDb({ status: 'failed', error: 'boom', method: 'GET', url: long });
const err = await waitRelayResult(db, 'r3', 200, 20).catch((e) => e);
assert.ok(err.detail.length < 200, 'detail 应被截断,实际长度 ' + err.detail.length);
});
// ---- runner:中继没等到回包时不能报「失败」 ----
// 线上现象(糊涂鳄):POST admin-ajax 被吊到超时,面板显示「签到失败」,
// 但服务端很可能已经处理了这次请求(网站那天确实已签)—— 报失败就是冤枉网站。
// 现在记「结果未知」(skip,不写 last_signin_date),并允许定时器稍后自动复核。
function fakeDbRelayTimeout() {
const inserted = [];
const now = Date.now();
let lastUrl = '';
let lastMethod = 'GET';
const home = Buffer.from('<html>首页</html>', 'utf8').toString('base64');
return {
inserted,
prepare(sql) {
const stmt = {
_args: [],
bind(...args) { stmt._args = args; return stmt; },
async run() {
if (/INSERT INTO runs/i.test(sql)) inserted.push({ sql, args: stmt._args });
// 记下队列里的任务,好让后续查询能区分「主页 GET」与「签到 POST」
if (/INSERT INTO relay_jobs/i.test(sql)) { lastUrl = String(stmt._args[1]); lastMethod = String(stmt._args[2]).toUpperCase(); }
return {};
},
async all() { return { results: [] }; },
async first() {
if (/FROM settings/i.test(sql)) return { value: String(now) }; // 扩展在线
if (/FROM relay_jobs/i.test(sql)) {
// 主页能读到,签到 POST 被吊到超时(线上糊涂鳄的真实情形)
if (lastMethod === 'POST') return { status: 'failed', error: '中继执行超时:请求已发出但没收到回包', method: lastMethod, url: lastUrl };
return { status: 'done', resp_status: 200, resp_headers: '{}', resp_body: home };
}
return null;
},
};
return stmt;
},
async batch() { return []; },
};
}
await t('runner:中继超时 → 记「结果未知」(skip)而不是「失败」,并允许自动补跑', async () => {
const { encryptJSON } = await import('../src/crypto.js');
const { runAccount } = await import('../src/runner.js');
const db = fakeDbRelayTimeout();
const env = { DB: db, ENCRYPT_KEY: 'El771KvGwTGzl6K9C2dqmMOsOBYgF3LR9pIm/FvTEbs=' };
const credsEnc = await encryptJSON(env, db, { site_url: 'https://dj.hutue.cn', cookie: 'c=x' });
const account = { id: 21, site: 'hutue', name: '糊涂鳄', creds: credsEnc, meta: '{"execution":"relay"}', enabled: 1 };
const r = await runAccount(env, account);
assert.equal(r.status, 'skip', '不能记成 fail,实际:' + r.status + ' / ' + r.message);
assert.equal(r.retryable, true, '稍后应能自动补跑复核');
assert.match(r.message, /结果未知/);
// 写入 runs 的 status 也必须是 skip(面板才不会显示 ❌ 失败)
assert.equal(db.inserted[0].args[3], 'skip');
});
console.log(`\n${n} 组通过`);
+56 -1
View File
@@ -167,8 +167,63 @@ await t('runner:中继模式的消息不带 [中继] 前缀,且站点拿得
const r = await runAccount(env, account);
assert.equal(r.status, 'ok');
assert.doesNotMatch(r.message, /\[中继\]/, '消息里不应再出现 [中继],实际:' + r.message);
assert.match(r.message, /已签到/);
// 主文案必须是**网站原话**(NodeSeek 对已签到回「今天已完成签到,请勿重复操作」),
// 而不是我们归纳的「今日已签到,不能重复签到」—— 面板「网站反馈」展示的就是这句
assert.equal(r.message, '今天已完成签到,请勿重复操作');
assert.equal(db.inserted.length, 1);
});
// ---- runner:按站点自己的「一天」记 last_signin_date ----
// 线上真实现象(糊涂鳄):面板按北京时间记「今天」,站点却按 UTC 计日(北京时间 08:00 才重置),
// 于是 08:00 之后面板仍显示「已签到」,用户去网站手动点签到却能再领一次积分,看起来就像「假签到」。
function fakeDbMeta() {
const metaWrites = [];
return {
metaWrites,
prepare(sql) {
const stmt = {
_args: [],
bind(...args) { stmt._args = args; return stmt; },
async run() {
if (/UPDATE accounts/i.test(sql)) metaWrites.push(stmt._args[4]);
return {};
},
async all() { return { results: [] }; },
async first() { return null; },
};
return stmt;
},
async batch() { return []; },
};
}
await t('runner:站点声明 dayTz 时按站点日界记 last_signin_date(糊涂鳄=UTC)', async () => {
const { dayInTz } = await import('../src/schedule.js');
const { getSite } = await import('../src/sites/index.js');
assert.equal((getSite('hutue') || {}).dayTz, 'UTC', '糊涂鳄必须声明 dayTz=UTC(本站按 UTC 计日)');
assert.equal((getSite('nodeseek') || {}).dayTz, undefined, '未声明的站点不能凭空多出 dayTz');
const db = fakeDbMeta();
const env = { DB: db, ENCRYPT_KEY: 'El771KvGwTGzl6K9C2dqmMOsOBYgF3LR9pIm/FvTEbs=' };
const credsEnc = await encryptJSON(env, db, { site_url: 'https://dj.hutue.cn', cookie: 'c=x' });
const account = { id: 11, site: 'hutue', name: '糊涂鳄', creds: credsEnc, meta: '{"execution":"server"}', enabled: 1 };
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const u = String(url);
const body = u.endsWith('/') ? '<html>首页</html>' : JSON.stringify({ status: 1, msg: '签到成功,赠送5积分' });
return { status: 200, text: async () => body, arrayBuffer: async () => new TextEncoder().encode(body).buffer };
};
let r;
try {
r = await runAccount(env, account);
} finally {
globalThis.fetch = origFetch;
}
assert.equal(r.status, 'ok');
assert.equal(db.metaWrites.length, 1);
const meta = JSON.parse(db.metaWrites[0]);
assert.equal(meta.last_signin_date, dayInTz(new Date(), 'UTC'), '糊涂鳄的「今天」应按 UTC 算,实际:' + meta.last_signin_date);
});
console.log(`\n${n} 组通过`);
+41 -6
View File
@@ -1,6 +1,6 @@
// schedule 测试:node test/schedule.test.mjs
import assert from 'node:assert/strict';
import { shouldRun, validHour, validTz, tzParts, accountHour, dayInTz, dayStartInTz } from '../src/schedule.js';
import { shouldRun, nextLastKey, parseLastKey, validHour, validTz, tzParts, accountHour, dayInTz, dayStartInTz } from '../src/schedule.js';
let n = 0;
const t = (name, fn) => { fn(); n++; console.log('ok -', name); };
@@ -14,25 +14,60 @@ t('北京时间整点判断', () => {
assert.equal(p.hour, '08');
});
t('到达设定分钟且未跑过 → 执行(含分钟键)', () => {
t('到达设定分钟且未跑过 → 执行(key 记「设定时刻」)', () => {
const r = shouldRun(D0830, '08:30', 'Asia/Shanghai', null);
assert.equal(r.run, true);
assert.equal(r.key, '2026-09-27 08:30');
assert.equal(r.nowKey, '2026-09-27 08:30');
});
t('纯小时格式按整点匹配:08:30 未到 08:00 → 跳过', () => {
t('纯小时格式:08:30 已过 08:00 → 补跑(不要求分秒相等)', () => {
const r = shouldRun(D0830, '08', 'Asia/Shanghai', null);
assert.equal(r.run, false);
assert.equal(r.run, true);
assert.equal(r.key, '2026-09-27 08:00');
});
t('该分钟已跑过 → 跳过', () => {
t('当天该时刻已成功跑过 → 跳过', () => {
const r = shouldRun(D0830, '08:30', 'Asia/Shanghai', '2026-09-27 08:30');
assert.equal(r.run, false);
});
// ---------- 补跑(Cron 漏一分钟不再等于整天不签) ----------
// 线上现象:把全局时间从 07:00 改成 08:05 之后,当天再也没有自动签到 ——
// 因为旧逻辑要求「当前分钟恰好等于设定分钟」,错过就永远错过。
t('漏了一分钟 → 当天补跑一次(key 仍是设定时刻)', () => {
const r = shouldRun(D0830, '08:05', 'Asia/Shanghai', null);
assert.equal(r.run, true);
assert.equal(r.key, '2026-09-27 08:05');
// 补跑记录写回后,同一设定时刻不再重复
assert.equal(shouldRun(D0830, '08:05', 'Asia/Shanghai', r.key).run, false);
});
t('已改到过去很久的时刻(超过补跑窗口)→ 不补跑,避免深夜突然签到', () => {
// 北京时间 21:00,设定 08:05(差 12 小时 55 分 > 6 小时窗口)
const D2100 = new Date('2026-09-27T13:00:00Z');
assert.equal(shouldRun(D2100, '08:05', 'Asia/Shanghai', null).run, false);
// 窗口边界:正好 6 小时(21:00 时设定 15:00)→ 仍补跑
assert.equal(shouldRun(D2100, '15:00', 'Asia/Shanghai', null).run, true);
assert.equal(shouldRun(D2100, '14:59', 'Asia/Shanghai', null).run, false);
});
t('失败/结果未知 → 隔 RETRY_GAP_MIN 分钟自动补一次(不会一分钟一次)', () => {
// 08:05 跑过但失败:lastKey 记「2026-09-27 08:05@2026-09-27 08:05」
const failed = nextLastKey('2026-09-27 08:05', '2026-09-27 08:05', 'fail');
assert.equal(failed, '2026-09-27 08:05@2026-09-27 08:05');
assert.deepEqual(parseLastKey(failed), { base: '2026-09-27 08:05', at: '2026-09-27 08:05', ok: false });
// 08:10 刚失败过 → 先不重试
assert.equal(shouldRun(new Date('2026-09-27T00:10:00Z'), '08:05', 'Asia/Shanghai', failed).run, false);
// 08:25(隔了 20 分钟)→ 自动补一次
assert.equal(shouldRun(new Date('2026-09-27T00:25:00Z'), '08:05', 'Asia/Shanghai', failed).run, true);
// 成功的记录仍是纯 key(当天不再跑)
assert.equal(nextLastKey('2026-09-27 08:05', '2026-09-27 08:05', 'ok'), '2026-09-27 08:05');
});
t('未到设定时间 → 跳过', () => {
assert.equal(shouldRun(D0830, '09:00', 'Asia/Shanghai', null).run, false);
assert.equal(shouldRun(D0830, '08:29', 'Asia/Shanghai', null).run, false);
assert.equal(shouldRun(D0830, '08:31', 'Asia/Shanghai', null).run, false);
});
t('UTC 时区换算', () => {
+18 -1
View File
@@ -1,6 +1,6 @@
// 统一网站反馈识别器测试:node test/signals.test.mjs(纯函数,不依赖网络)
import assert from 'node:assert/strict';
import { classifySignal, toResult, failWith, extractReward, isDone, OUTCOME } from '../src/lib/signals.js';
import { classifySignal, toResult, failWith, extractReward, isDone, OUTCOME, siteMessageFrom } from '../src/lib/signals.js';
let n = 0;
const t = (name, fn) => { fn(); n++; console.log('ok -', name); };
@@ -109,4 +109,21 @@ t('failWith:未知文本用站点兜底文案', () => {
assert.match(err.message, /未找到签到按钮/);
});
// ---------- 网站原话提取(面板「网站反馈」用) ----------
t('siteMessageFrom:抠出网站自己的那句话(message/msg/status_msg)', () => {
assert.equal(siteMessageFrom('{"success":false,"message":"今天已完成签到,请勿重复操作"}'), '今天已完成签到,请勿重复操作');
assert.equal(siteMessageFrom('{"status":"0","msg":"今日已签到,请明日再来"}'), '今日已签到,请明日再来');
assert.equal(siteMessageFrom('{"status_code":0,"status_msg":"签到成功"}'), '签到成功');
assert.equal(siteMessageFrom('这是一页 HTML,没有 JSON'), '');
assert.equal(siteMessageFrom('', '兜底'), '兜底');
});
t('siteMessageFrom:还转义、只取第一段(把促销公告挡在外面)', () => {
// 糊涂鳄返回的是全转义 JSON
assert.equal(siteMessageFrom('{"status":"0","msg":"\\u4eca\\u65e5\\u5df2\\u7b7e\\u5230"}'), '今日已签到');
// 机场的 msg 经常把整段促销公告一起塞进来
const v2 = '{"ret":1,"msg":"您获得了 1.5GB 流量.\\n\\n🎉中秋活动 7.8 折"}';
assert.equal(siteMessageFrom(v2), '您获得了 1.5GB 流量.');
});
console.log(`\n${n} 组通过`);
+196 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { v2ex } from '../src/sites/v2ex.js';
import { misign } from '../src/sites/misign.js';
import { kanxue } from '../src/sites/kanxue.js';
import { wuaipojie } from '../src/sites/wuaipojie.js';
import { wuaipojie, buttonState } from '../src/sites/wuaipojie.js';
import { nodeseek } from '../src/sites/nodeseek.js';
let n = 0;
@@ -149,6 +149,25 @@ await t('52pojie:已签到(签到完毕)算成功,不再报未识别', a
assert.match(r.message, /已签到|签到完毕/);
});
// 线上真实现象:本地网络中继下,签到接口被网宿 WAF 吊死到超时(用户看到
// 「中继执行超时(45秒),已放弃该请求」),而那一次其实已经签到成功。
// 修法:先用任务页判定「今天是否已领过」,已签到就直接返回,连签到接口都不打。
await t('52pojie:任务页显示「签到完毕」→ 直接判已签到,不再打签到接口', async () => {
const urls = [];
const orig = globalThis.fetch;
mockFetch([
{ match: (u) => u.includes('mod=task') && !u.includes('do=apply'), body: '<html>每日签到 <span>签到完毕</span></html>' },
{ match: () => true, body: '<html>不该走到这里</html>' },
]);
const inner = globalThis.fetch;
globalThis.fetch = async (url, init) => { urls.push(String(url)); return inner(url, init); };
const r = await wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }, { relayDb: {} });
globalThis.fetch = orig;
assert.equal(r.ok, true);
assert.match(r.message, /已签到|签到完毕/);
assert.ok(!urls.some((u) => u.includes('do=apply')), '已签到就不该再去打卡住的签到接口:' + JSON.stringify(urls));
});
// 线上真实现象(已确认根因):门户页 portal.php 的「最新公告」块里常年挂着
// 「开放注册期间论坛暂停签到」。早期实现拿「暂停签到」扫整个首页,
// 把一个完全正常的账号报成「论坛官方暂停签到,等恢复后再试」,死活签不上。
@@ -189,6 +208,127 @@ await t('52pojie:签到页确实说暂停 → 给出暂停结论', async () =>
assert.match(err.message, /暂停签到/);
});
// ---------- 52pojie 的两态识别(用户截图确认的站点按钮语义) ----------
// 未签到 → 顶部按钮「📋 打卡签到」(橙色、可点);已签到 → 「✅ 签到完毕」(绿色、点了没反应)。
// 注意:<a> 的 title 属性里常年写着「打卡签到」,所以只能读按钮元素自己的文字。
await t('52pojie:buttonState 三态(文字版 / 图片版 / 无按钮)', async () => {
const signedText = '<html><a class="click-qiandao zzhuti_qd_2" title="打卡签到"><i class="icon-Sign2"></i><br>签到完毕</a></html>';
const unsignedText = '<html><a class="click-qiandao zzhuti_qd_1" title="打卡签到"><i class="icon-Sign"></i><br>打卡签到</a></html>';
// 52pojie 实际就是这样(用户 DevTools 截图):状态被画在 qds.png 里,文本节点是空的,
// 只能靠「那个「去签到」链接还在不在」来判断
const unsignedImg = '<html><body><p><a href="home.php?mod=task&do=apply&id=2&referer=%2Fhome.php%3Fmod%3Dspacecp">'
+ '<img src="https://static.52pojie.cn/static/image/common/qds.png" class="qq_bind" align="absmiddle" alt></a>'
+ '<span class="pipe">|</span></p></body></html>';
const signedImg = '<html><body><p><span class="qq_bind_done"><img src="https://static.52pojie.cn/static/image/common/qds.png"></span>签到完毕</p></body></html>';
const unknown = '<html>普通页面,没有那个按钮</html>';
assert.equal(buttonState(signedText), 'signed');
assert.equal(buttonState(unsignedText), 'unsigned');
assert.equal(buttonState(unsignedImg), 'unsigned', '图片版状态:链接还在就是未签到');
assert.equal(buttonState(signedImg), 'signed');
assert.equal(buttonState(unknown), 'unknown');
});
// 已签到(服务器渲染)→ 顶栏那个 apply 链接已经没了,任务行写着「已完成」
await t('52pojie:任务页显示已签到 → 直接判已签到,不打签到接口', async () => {
const page = '<html><span>签到完毕</span>'
+ '<table><tr id="task_2"><td>每日签到</td><td>已完成</td></tr></table></html>';
const urls = [];
const te0 = new TextEncoder();
const mkRes = (body) => ({ status: 200, url: 'https://www.52pojie.cn/x', headers: { get: () => null }, text: async () => body, arrayBuffer: async () => te0.encode(body).buffer });
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => { urls.push(String(url)); return mkRes(page); };
let r = null;
try { r = await wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }, { relayDb: {} }); } finally { globalThis.fetch = origFetch; }
assert.ok(r && r.ok, '应判已签到:' + JSON.stringify(r));
assert.match(r.message, /已签到/);
assert.ok(!urls.some((u) => u.includes('do=apply')), '已签到就不该再打签到接口:' + JSON.stringify(urls));
});
// 反过来:顶栏还挂着 qds.png 那个「去签到」链接(图片版未签到)→ 去签,不能提前判成功
await t('52pojie:顶栏还挂着「去签到」链接 → 不判已签到,去执行签到', async () => {
const te0 = new TextEncoder();
const mkRes = (body) => ({ status: 200, url: 'https://www.52pojie.cn/x', headers: { get: () => null }, text: async () => body, arrayBuffer: async () => te0.encode(body).buffer });
const UNSIGNED = '<html><p><a href="home.php?mod=task&do=apply&id=2"><img src="https://static.52pojie.cn/static/image/common/qds.png"></a></p>'
+ '<table><tr id="task_2"><td>每日签到</td><td>立即申请</td></tr></table></html>';
const SUCCESS = '<html><script>showmessage(\'恭喜您,任务已完成\', \'home.php?mod=task\');</script></html>';
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => mkRes(String(url).includes('do=apply') ? SUCCESS : UNSIGNED);
let r = null;
try { r = await wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }, { relayDb: {} }); } finally { globalThis.fetch = origFetch; }
assert.ok(r && r.ok, '应真的去签到:' + JSON.stringify(r));
assert.match(r.message, /签到成功|任务已完成/);
});
// 线上真实现象:本地网络中继下签到接口被吊死到超时(用户看到「中继执行超时(45秒)」)。
// 修法之后:超时不再拍脑袋报失败——先看站点按钮,还是「打卡签到」就是明确的未签到,
// 直说原因 + 下一步怎么操作(以前只会报一句「未识别到成功标识」)。
await t('52pojie:中继超时 + 网站按钮仍是「打卡签到」→ 明确报未签到并给操作建议', async () => {
const te0 = new TextEncoder();
const mkRes = (body) => ({
status: 200,
url: 'https://www.52pojie.cn/x',
headers: { get: () => null },
text: async () => body,
arrayBuffer: async () => te0.encode(body).buffer,
});
const UNSIGNED_PAGE = '<html><a class="click-qiandao zzhuti_qd_1"><br>打卡签到</a> 每日签到</html>';
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes('mod=task') && !u.includes('do=apply')) return mkRes(UNSIGNED_PAGE);
throw new Error('中继执行超时(45秒),已放弃该请求');
};
const err = await (async () => { try { await wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }, { relayDb: {} }); } catch (e) { return e; } finally { globalThis.fetch = origFetch; } return null; })();
assert.ok(err instanceof Error, '不应报成功');
assert.match(err.message, /打卡签到/);
assert.match(err.message, /没有生效/);
assert.equal(err.outcome, 'unsigned');
});
// 反向:超时前其实已经签上了(按钮已经是「签到完毕」)→ 必须判成功,不能报失败
await t('52pojie:中继超时但按钮已是「签到完毕」→ 判已签到', async () => {
const te0 = new TextEncoder();
const mkRes = (body) => ({ status: 200, url: 'https://www.52pojie.cn/x', headers: { get: () => null }, text: async () => body, arrayBuffer: async () => te0.encode(body).buffer });
const SIGNED_PAGE = '<html><a class="click-qiandao zzhuti_qd_2" title="打卡签到"><br>签到完毕</a></html>';
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const u = String(url);
if (u.includes('mod=task') && !u.includes('do=apply')) return mkRes(SIGNED_PAGE);
throw new Error('中继执行超时(45秒),已放弃该请求');
};
let r = null;
try { r = await wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }, { relayDb: {} }); } finally { globalThis.fetch = origFetch; }
assert.ok(r, '应返回结果而不是抛错');
assert.equal(r.ok, true);
assert.match(r.message, /已签到/);
});
// 浏览器/导航模式(52pojie 的默认执行方式)也要认两态:
// 扩展把标签页导航到签到地址后读页面,不能把「打卡签到」当成签到成功。
await t('52pojie:浏览器模式巡检 — 按钮两态各自的结论', async () => {
const fn = new Function('return (' + wuaipojie.browserScript + ')')();
const mkDoc = (btnText) => ({
documentElement: { innerHTML: '<a class="click-qiandao"><br>' + (btnText || '') + '</a>' },
body: { innerText: btnText || '' },
title: '每日签到',
querySelector: () => (btnText === null ? null : { innerText: btnText, textContent: btnText }),
});
const origDoc = globalThis.document;
try {
globalThis.document = mkDoc('签到完毕');
const signed = await fn({});
assert.equal(signed.ok, true, JSON.stringify(signed));
assert.match(signed.message, /已签到/);
globalThis.document = mkDoc('打卡签到');
const unsigned = await fn({});
assert.equal(unsigned.ok, false, '「打卡签到」是未签到,不能算成功:' + JSON.stringify(unsigned));
assert.match(unsigned.message, /打卡签到/);
} finally {
globalThis.document = origDoc;
}
});
await t('52pojie:WAF 拦截提示', async () => {
mockFetch([{ match: () => true, body: '<html>waf_zw_verify 请完成安全验证</html>' }]);
await assert.rejects(wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }), /安全验证/);
@@ -312,6 +452,28 @@ await t('nodeseek:今日已签到算成功并带回馈', async () => {
assert.match(r.message, /今日已签到/);
});
// 线上真实响应(面板「网站反馈」列展示的就是这句):
// {"success":false,"message":"今天已完成签到,请勿重复操作"}
// 以前这里被归纳成我们自己的「今日已签到,不能重复签到」,用户看不到网站怎么说。
await t('nodeseek:已签到的反馈用网站原话,不再换成我们的套话', async () => {
const body = JSON.stringify({ success: false, message: '今天已完成签到,请勿重复操作' });
mockFetch([{ match: (u) => u.includes('/api/attendance'), body }]);
const r = await nodeseek.run({ cookie: 'a=1' }, {});
assert.equal(r.ok, true);
assert.equal(r.message, '今天已完成签到,请勿重复操作');
assert.doesNotMatch(r.message, /不能重复签到/);
});
// 线上真实成功响应:{"success":true,"message":"今天的签到收益是2个鸡腿","gain":2,"current":1811}
await t('nodeseek:成功反馈=网站原话 + 响应里的收益数字', async () => {
const body = JSON.stringify({ success: true, message: '今天的签到收益是2个鸡腿', gain: 2, current: 1811 });
mockFetch([{ match: (u) => u.includes('/api/attendance'), body }]);
const r = await nodeseek.run({ cookie: 'a=1' }, {});
assert.equal(r.ok, true);
assert.match(r.message, /^今天的签到收益是2个鸡腿/);
assert.match(r.message, /本次 \+2,累计 1811/);
});
await t('nodeseek:失败时错误带 detail', async () => {
const body = JSON.stringify({ success: false, message: '参数错误' });
mockFetch([{ match: (u) => u.includes('/api/attendance'), body }]);
@@ -360,4 +522,37 @@ await t('52pojie:GBK 页面按 GBK 解码,网站回馈不乱码', async () =
assert.ok(!/�|\uFFFD/.test(r.message), '不应出现替换字符');
});
// 52pojie 浏览器任务:扩展要先去「任务列表页」(普通页面),自己找那个「去签到」入口再触发。
// 为什么要这样(来自社区脚本的校准,2026-09):
// · 站点把当天状态画在 <a href="home.php?mod=task&do=apply&id=2"><img src="…/qds.png"></a> 里,
// 文本节点是空的 —— 链接还在 = 今天还没签(脚本界通行判法);
// · 任务 id / 地址由论坛后台配,硬编码 id=2 换站就不灵,所以以页面上的链接为准。
await t('52pojie:browserJob 给扩展的是任务列表页 + 签到地址,不带过期硬编码', async () => {
const job = wuaipojie.browserJob();
assert.equal(job.domain, 'www.52pojie.cn');
assert.match(job.navigate_url, /home\.php\?mod=task$/, '先落到任务列表页,扩展自己找入口');
assert.match(job.sign_url, /do=apply&id=\d+/, '兜底的签到地址');
});
// 站点改版把任务 id 换掉时,面板不能只会打 id=2:要能从任务页上读出真实链接
await t('52pojie:任务页上的签到链接(id 变了)优先于硬编码 id', async () => {
const te0 = new TextEncoder();
const mkRes = (body) => ({ status: 200, url: 'https://www.52pojie.cn/x', headers: { get: () => null }, text: async () => body, arrayBuffer: async () => te0.encode(body).buffer });
const TASKLIST = '<html><p><a href="home.php?mod=task&do=apply&id=77&amp;referer=%2Fportal.php">'
+ '<img src="https://static.52pojie.cn/static/image/common/qds.png"></a></p>'
+ '<table><tr><td>每日签到</td><td>立即申请</td></tr></table></html>';
const urls = [];
const origFetch = globalThis.fetch;
globalThis.fetch = async (url) => {
const u = String(url); urls.push(u);
if (u.includes('do=apply')) return mkRes('<html><script>showmessage(\'恭喜您,任务已完成\');</script></html>');
return mkRes(TASKLIST);
};
let r = null;
try { r = await wuaipojie.run({ cookie: 'c=x', user_agent: 'UA' }); } finally { globalThis.fetch = origFetch; }
assert.ok(r && r.ok, JSON.stringify(r));
assert.ok(urls.some((u) => u.includes('id=77')), '应该照页面上的链接签:' + JSON.stringify(urls));
assert.ok(!urls.some((u) => /do=apply&id=2(?!\d)/.test(u)), '不该打过期硬编码的 id=2');
});
console.log(`\n${n} 组通过`);
+13 -1
View File
@@ -107,7 +107,19 @@ await t('已签到判为成功', async () => {
]);
const r = await v2board.run({ domain: 'example.com', email: 'a@b.c', password: 'pw' });
assert.equal(r.ok, true);
assert.match(r.message, /已签到/);
// 主文案必须是**网站原话**(面板「网站反馈」展示的就是这句),不再归纳成我们的套话
assert.equal(r.message, '您今天已经签到过了');
});
await t('签到成功:用网站原话当反馈,且不把促销公告一起刷出来', async () => {
mockFetch([
['/auth/login', loginOk],
['/user/checkin', { ret: 1, msg: '尊贵的王者Lv7,您获得了 1.535GB 流量.\n\n🎉【69云】中秋国庆季 全场 7.8 折!🎉\n📅【活动时间】9月25日' }],
]);
const r = await v2board.run({ domain: 'example.com', email: 'a@b.c', password: 'pw' });
assert.equal(r.ok, true);
assert.equal(r.message, '尊贵的王者Lv7,您获得了 1.535GB 流量.');
assert.ok(!r.message.includes('7.8 折'), '促销公告不应出现在网站反馈里');
});
await t('缺字段报错', async () => {
+7 -1
View File
@@ -10,9 +10,15 @@ import { dirname, join } from 'node:path';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const srcDir = join(root, 'public', 'ext-src');
const files = ['manifest.json', 'popup.html', 'popup.js', 'background.js'];
const files = ['manifest.json', 'popup.html', 'popup.js', 'background.js', 'icon16.png', 'icon32.png', 'icon48.png', 'icon128.png'];
// PNG 是二进制:存成 base64,并在键名前加 "@b64:" 前缀告诉打包器要解码
// (ext-zip.js 看到前缀就把 base64 解回字节,不再当文本注入面板地址)。
const entries = files.map((name) => {
if (name.endsWith('.png')) {
const b64 = readFileSync(join(srcDir, name)).toString('base64');
return ` ${JSON.stringify('@b64:' + name)}: ${JSON.stringify(b64)}`;
}
const text = readFileSync(join(srcDir, name), 'utf8');
return ` ${JSON.stringify(name)}: ${JSON.stringify(text)}`;
});
+17 -3
View File
@@ -32,8 +32,12 @@ const accounts = [
{
id: 1, name: 'NodeSeek', site: 'nodeseek', enabled: 1,
meta: JSON.stringify({ toggles: { random: true }, last_signin_date: today }),
last_status: 'ok', last_msg: '今日已签到,不能重复签到',
last_detail: '网站返回:{"success":false,"message":"今日已签到"}',
// 这里故意用**旧的存库文案**(我们自己的套话)+ 线上真实的网站原文:
// 面板会改用网站原话当主文案,并打上「网站原话」标记 ——
// 也就是说部署后,**已经存在的那些旧记录立刻就会显示成网站原话**,不用等下一次执行。
last_status: 'ok',
last_msg: '今日已签到,不能重复签到',
last_detail: '网站返回:{"success":false,"message":"今天已完成签到,请勿重复操作"}',
last_run_at: now - 4000,
},
{
@@ -64,7 +68,8 @@ const accounts = [
id: 5, name: '糊涂鳄', site: 'hutue', enabled: 1,
meta: JSON.stringify({ sched_hour: '07:00', last_signin_date: today }),
last_status: 'ok', last_msg: '签到成功,赠送5晶石',
last_detail: '网站返回:{"status":1,"msg":"签到成功,赠送5晶石"}',
// 线上糊涂鳄返回的是全转义 JSON(\u4eca\u65e5…),面板要还原成人话
last_detail: '网站返回:{"status":"0","msg":"\\u4eca\\u65e5\\u5df2\\u7b7e\\u5230\\uff0c\\u8bf7\\u660e\\u65e5\\u518d\\u6765"}',
last_run_at: now - 120000,
},
{
@@ -74,6 +79,15 @@ const accounts = [
last_detail: '',
last_run_at: now - 900000,
},
// 「结果未知」:请求发出去了但没等到回包 —— 状态列要显示「待确认」,而不是武断的「未签到」
{
id: 8, name: '糊涂鳄(hf)', site: 'hutue', enabled: 1,
meta: JSON.stringify({ sched_hour: '08:05' }),
last_status: 'skip',
last_msg: '签到结果未知:请求已发出但没等到回包(本地网络失败:中继执行超时(58秒):请求已发出但没收到回包)。请求可能已经送达网站(签到可能已生效),也可能没有;稍后会自动复核。',
last_detail: '网站返回:(无响应) · 接口 user_qiandao · user_qiandao:重试仍无回包',
last_run_at: now - 60000,
},
];
const page = `<!DOCTYPE html>
+53 -10
View File
@@ -49,7 +49,9 @@ function cardByTitle(title, from = 0, to = html.length) {
const m = sec.match(re);
if (!m) throw new Error('找不到卡片:' + title);
const h2Abs = from + m.index;
const cardStart = html.lastIndexOf('<div class="card">', h2Abs);
// 注意:不是 '<div class="card">'——卡上带 style(如 <div class="card" style="padding:18px 20px">)时
// 会匹配不到,lastIndexOf 就会往回找到上一张卡,把两张卡搞混。
const cardStart = html.lastIndexOf('<div class="card"', h2Abs);
if (cardStart < 0) throw new Error('卡片没有 <div class="card"> 外壳:' + title);
return balancedDivAt(cardStart);
}
@@ -59,16 +61,31 @@ const accSecEnd = html.indexOf('<section id="tab-logs"');
if (accSecStart < 0 || accSecEnd <= accSecStart) throw new Error('找不到「签到账号」区块');
const topbar = balancedDivByTag('<div class="topbar">');
// 「签到时间 + 添加账号 + 扩展下载」现在是同一张两栏卡(左:时间/扩展,右:粘贴添加)
const schedCard = cardByTitle('签到时间', accSecStart, accSecEnd);
const cookieCard = cardByTitle('添加 / 更新账号', accSecStart, accSecEnd);
const comCard = cardByTitle('🌍 社区站点', accSecStart, accSecEnd);
const footStart = html.indexOf('<div class="foot">', accSecStart);
const foot = footStart < 0 ? '' : balancedDivAt(footStart);
const extCard = cardByTitle('🔌 浏览器扩展', html.indexOf('<section id="tab-settings"'));
const accCard = cardByTitle('签到账号', accSecStart, accSecEnd);
// 时间选择器脚本段(预览页要用它把胶囊/滚轮真正渲染出来)
// 时间选择器 + 时区选择器脚本段(预览页要用它们把胶囊/滚轮/时区弹窗真正渲染出来)
// 起点:时间选择器;终点:let tzPicker = null;(即 renderTzPicker 定义完)
const jsStart = html.indexOf('// ---------- 时间选择器');
const jsEnd = html.indexOf('// ---------- 签到时间设置 ----------');
if (jsStart < 0 || jsEnd <= jsStart) throw new Error('找不到时间选择器脚本段');
const jsEnd = html.indexOf('let tzPicker = null;');
if (jsStart < 0 || jsEnd <= jsStart) throw new Error('找不到时间/时区选择器脚本段');
const js = html.slice(jsStart, jsEnd);
// Cookie 解析器那一段(要真的点一下看排版,就得把它的代码也带上)。
// 它依赖 public/cookie-tools.js 里的 explainCookieText/describeCookieName,
// 以及面板全局的 SITES / guessSiteByDomain(预览里给空实现,只看样式)。
const ckJsStart = html.indexOf('// ---------- Cookie 解析器');
const ckJsEnd = html.indexOf('// 「填到上面并保存」');
const ckJs = (ckJsStart >= 0 && ckJsEnd > ckJsStart) ? html.slice(ckJsStart, ckJsEnd) : '';
const readPublic = (f) => readFileSync(resolve(root, 'public', f), 'utf8');
const curlJs = readPublic('curl-import.js');
const cookieToolsJs = readPublic('cookie-tools.js');
function stateTopbar(cls, text, sub) {
return topbar
.replace('class="wait"', 'class="' + cls + '"')
@@ -94,26 +111,52 @@ const page = `<!DOCTYPE html>
<h2 style="margin-top:18px">首页顶栏 · 扩展在线(绿,呼吸点)</h2>
${stateTopbar('on', '扩展在线(自动中继已启用)', '可切「本地网络」:请求经您的浏览器发出')}
<h2 style="margin-top:22px">签到时间(已移到「签到账号」页,账号表下面)</h2>
<h2 style="margin-top:22px">签到账号(表格)</h2>
${accCard}
<h2 style="margin-top:22px">签到时间 + 添加账号 + 扩展下载(同一张两栏卡)</h2>
${schedCard}
<h2 style="margin-top:22px">添加 / 更新账号(一个粘贴框搞定)</h2>
${cookieCard}
<h2 style="margin-top:22px">社区站点(开源共享:导入 / 导出同一张卡)</h2>
${comCard}
<h2 style="margin-top:22px">设置页 · 浏览器扩展</h2>
${extCard}
<h2 style="margin-top:22px">页脚说明(含开源仓库地址)</h2>
${foot}
</div>
<script>
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
${curlJs}
${cookieToolsJs}
${js}
const SITES = [];
const guessSiteByDomain = () => '';
${ckJs}
// 把 Cookie 解析器展开并跑一遍,直接看到「每一段是什么」的真实排版
{
const box = $('ck-analyze-box');
const ta = $('ck-analyze');
if (box && ta) {
box.open = true;
ta.value = 'Cookie: PHPSESSID=abc123def456ghi; wordpress_logged_in_a=laoguo%7C1730000000%7Cabcdef0123456789; wzws_cid=0123456789abcdef0123456789abcdef; cf_clearance=xyz123abc-1759000000-1.2.3.4.5; _ga=GA1.2.987654321\\nUser-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/128.0 Safari/537.36';
$('btn-ck-analyze').click();
}
}
const sched = $('sched-tp');
if (sched) {
const p = renderTimePicker(sched, '08:00', { noFollow: true, large: true });
p.set('08:30');
}
const sel = $('sched-tz');
if (sel) sel.innerHTML = ['Asia/Shanghai', 'UTC', 'America/New_York'].map((z) => '<option>' + z + '</option>').join('');
const tzBox = $('sched-tz');
let tzPick = null;
if (tzBox) tzPick = renderTzPicker(tzBox, 'Asia/Shanghai');
// 预览里直接把时区弹窗展开,省得手动去点
if (tzPick) setTimeout(() => { const b = document.querySelector('.tz-chip'); if (b) b.onclick({ stopPropagation(){} }); }, 30);
</script>
</body>
</html>
+377 -6
View File
@@ -54,10 +54,13 @@
### 扩展(cookie插件2.2)
- `manifest.json`:权限 cookies / activeTab / scripting / storage / alarms / tabs,host_permissions `<all_urls>`。
- `popup.js`:弹窗 UI,一键复制/发送 Cookie,立即执行待办。
- `popup.js`:弹窗 UI,一键复制/发送 Cookie;打开时静默叫醒一次中继(不再有「立即执行待办」按钮)。
- `background.js`:Service Worker,每小时自动执行 + 每 15 秒中继轮询,单任务 60 秒超时。
- 面板地址通过 `__PANEL_URL__` 占位符在下载时动态注入(split/join 全替换)。
- **构建注意**:改完 `public/ext-src/` 必须重新跑生成脚本产出 `src/ext-files.js`(当前脚本在 `/tmp/gen-ext-files.py`,尚未迁入仓库)。
- 图标:`icon16/32/48/128.png`(蓝色圆角方 + 白色「签」),由 manifest 的 `action.default_icon` / `icons` 引用;
它们也是 `public/ext-src/` 里的源文件,会随 zip 一起下载。
- **构建注意**:改完 `public/ext-src/`(包括图标)必须重跑 `tools/gen-ext-files.mjs` 产出 `src/ext-files.js`;
该脚本已进仓库(不再依赖 `/tmp` 下的临时脚本),PNG 以 `@b64:` 前缀存 base64,由 `src/ext-zip.js` 解码。
---
@@ -160,11 +163,15 @@
1. **吾爱破解端到端验证**(高)
导航方案已推送,待:更新扩展 → 面板点执行 → 扩展立即执行 → 导航签到 URL → 读到"任务已完成" → 上报 → 面板显示成功。全链路未跑通前不算修好。
2. **糊涂鳄端到端验证**(高)
已改云端执行,待 Cloudflare 部署后点"执行"看真实结果。之后再接入 `hutue.cn` 主站。
2. **糊涂鳄端到端验证**(高,已有明确验法)
接口已定案(读主题 JS 得到 `user_qiandao`,见第十三节),面板也改成只打真接口、并在反馈里写清接口名。
**待验**:部署 + 重载扩展后点一次「执行」,确认反馈里出现 `· 接口 user_qiandao`;
另需在**次日 08:05 之后**确认糊涂鳄自动签到真领到 5 晶石(当天已签的情况下看不出来)。
之后再接入 `hutue.cn` 主站(同为 ripro 主题,预期同一个接口)。
3. **Cron 分钟级线上真实触发验证**(中)
已改为 `* * * * *`,但未用临近分钟验证过 Cloudflare 是否真实每分钟触发、各账号独立分钟是否生效。
3. ~~Cron 分钟级线上真实触发验证~~(已完成 2026-09-28)
已用「把账号独立时间设成 3 分钟后」的方法在线上验证:12:03 设 12:06,12:07:12 实际执行(run #121)→
`* * * * *` 与各账号独立分钟都生效。
4. **时间选择器线上验收**(中)
对齐、居中、深色主题、表格列宽、快速点击是否重复保存,用户尚未确认最终效果。
@@ -195,6 +202,9 @@
| `src/lib/relay.js` | 中继代理(relayFetch / 队列 / 等结果) |
| `src/sites/*.js` | 各站点模块(含 browserScript) |
| `src/sites/index.js` | `getBrowserScript()` 导出 navigateUrl |
| `src/community.js` | 社区站点适配:校验 / 导入导出 / 声明式配置 → 可执行站点模块 |
| `community/` | 社区贡献的站点配置(JSON)+ 模板 + 索引 |
| `CONTRIBUTING.md` | 贡献指南(零代码交一份站点适配的完整流程) |
| `public/index.html` | 面板前端(含 #ext-cookies= 接收逻辑) |
| `public/ext-src/` | 扩展源码(popup / background / manifest) |
| `src/ext-files.js` | 生成的扩展文件(勿手改,由脚本生成) |
@@ -209,3 +219,364 @@
3. 响应体只能读一次;Cookie 有 HttpOnly 和浏览器之分,搞清到底用的是哪一份。
4. 宣布修好之前,必须跑完"下载→安装→领取→执行→回传→展示"全链路。
5. 凭据永不落文档、不进仓库、不复述。
---
## 九、2026-09-28 复盘:糊涂鳄的「假签到」真凶
### 9.1 主因:站点有两个「签到」,面板一直打的是奖励少的那个
从站点自己的代码里看得清清楚楚:
| 入口 | 怎么找到的 | 接口(action) | 奖励 |
|---|---|---|---|
| 首页右侧悬浮窗 `<a class="click-qiandao zzhuti_qd_1">打卡签到</a>`,**真签到** | 父主题 `/wp-content/themes/ripro/assets/js/app.js`:`$(".click-qiandao").on("click",… $.post(caozhuti.ajaxurl,{action:"user_qiandao"})` | `user_qiandao` | 首页文案写着「每天签到5晶石」= 用户看到的 5 积分 |
| 会员中心的「首页签到」 | 子主题 `/wp-content/themes/xb-child/assets/js/xb-app.js`:`$(".user-index-qd") … {action:"xb_user_qiandao"}` | `xb_user_qiandao` | 只有 1 积分 |
**面板一直打的是后者**(子主题的 JS 先被扫描到,而且它确实回 `status:1`),于是:
| 时间(北京) | 面板调用 | 站点原话 |
|---|---|---|
| 9/27 20:49 | xb_user_qiandao | `{"status":"1","msg":"签到成功,赠送1积分"}` |
| 9/28 00:33 / 07:04 / 07:56 | xb_user_qiandao | `{"status":"0","msg":"今日已签到,请明日再来"}` |
| 9/28 08:05 | (用户手动点悬浮窗) | 真签到成功,**5 晶石** |
所以「面板显示签到成功,去网站还能签领 5 晶石」的真相是:**面板签的是另一个签到**,真签到从头到尾没动过。
**修法(已落地)**:
- `SITE_ACTIONS = ['user_qiandao', 'xb_user_qiandao']` —— 真签到排第一,会员中心那个只做兼容;
- `discoverSignin()` 新增「按钮 → action」反查:页面里出现签到按钮(`click-qiandao`)时,
往后找最近的 `action:"…"` 作为首选(按钮自己绑的那个才是真接口);
- JS 文件抓取按「`assets/js/app.js`(父主题主文件)优先」排序,不再靠 `<script>` 先后碰运气;
- 同一个 action 先试不带 nonce 的版本(站点自己的 JS 也不带),少打一次无用请求;
- 另用你的浏览器 + 扩展真实验证过:`user_qiandao` 在登录态下正常响应(回「今日已签到,请明日再来」,与你手动签过的状态一致)。
> 教训:**站点里同名同形的按钮可能绑定不同的接口,奖励也可能差 5 倍**。
> 不要靠「名字像不像」猜,去读按钮元素旁边那段 JS 到底 post 了什么。
### 9.2 次因:站点按 UTC 计日(北京时间 08:00 才是新一天)
站点自己的签到锁按 UTC 算:9/28 00:33 / 07:04 / 07:56(北京)都在 UTC 9/27 之内,所以回「今日已签到」。
而面板状态列原来按面板时区(北京)算「今天」,两边每天会错开 8 小时。
**修法(已落地)**:站点模块可声明 `dayTz`(糊涂鳄 = `'UTC'`),
`runner.js` 写 `last_signin_date`、前端状态列判「已签到」都按它算,
`sites/index.js` 的 `siteMeta()` 把它透出给前端。
线上全局签到时间已从 07:00 调到 **08:05**(北京),跨过站点日界;糊涂鳄的提示里也写明了这一点。
### 9.2 吾爱破解「本地网络中继超时(45 秒)」,但网站里其实签到过了
签到接口 `home.php?mod=task&do=apply&id=2` 受网宿 WAF 最严格的一档保护,
经中继(页面内 fetch)经常被吊死到超时——那次请求往往已经生效。
**修法(已落地)**:`run()` 第一步先读任务页 `home.php?mod=task`(普通页面,能读到),
看到「签到完毕」就直接判已签到、**不再去打签到接口**(既避免白等超时,又不会反复惊动 WAF);
真的超时了也会回读一次任务页再下结论。扩展端中继超时同步放宽(fetch 30s / 任务 60s / Worker 等 90s),
面板等待轮询 300ms。
> 教训:能通过「读一眼状态页」确定的事,不要靠「再打一次写接口」去赌。
### 9.3 吾爱破解:状态是「图片」,不是文字
用户 DevTools 截图(2026-09-28)显示顶栏那个按钮的真实标记:
```html
<a href="home.php?mod=task&do=apply&id=2&referer=%2Fhome.php%3Fmod%3Dspacecp…">
<img src="https://static.52pojie.cn/static/image/common/qds.png" class="qq_bind" alt>
</a>
```
也就是说「📋打卡签到」这四个字**是画在 PNG 里的**,文本节点是空的(与整张图宽度 75×20 对得上),
所以「读按钮文字」对它无效;签名后按钮变绿、点下去没反应,链接也就没了。
结论:本站能用机器区分两态的**最硬信号是那个「去签到」链接还在不在**:
- 链接在(`<a href=…mod=task&do=apply…>`)→ 服务端认为今天未签到
- 链接消失 + 页面有「签到完毕 / 已完成 / 您已完成过此任务」→ 已签到
实现:`wuaipojie.js` 的 `buttonState()` 先认文字(兼容文字版),再认链接(图片版),
并且**正信号优先**(任务列表页里「已完成」文字不能被同页另一个链接盖过去)。
浏览器/导航模式(`browserScript`)用的是同一套判据。
超时/中继失败后的结论也换成它:签到完毕 → 判已签到;打卡签到 → 明确报「本次没有生效」+ 操作建议。
> 教训:判定页面前先看一眼 **DevTools 里那个元素到底是什么** —— 状态可能根本不在文字里。
### 9.4 同时收紧的误判源
- `signals.js`:裸的「已签到/已经签到/已领取」不再算已签到,改成必须带上下文
(`今[日天]…签到` / `今日已领取` / `该奖励已领取` 等正则),否则页面模板文字就能骗过判定。
- `hutue.js`:非 JSON 响应不再当成功/已签到(HTML 里的「已签到」可能是模板/按钮文字);
主题自带的 `xb_user_qiandao` 永远排第一,避免被别的插件 action 抢走(奖励会与本站对不上)。
- 面板「网站反馈」直接展示站点原话 + 原文,不再拼我们自己造的前缀。
---
## 十、2026-09-28 新增:开源共建(社区站点适配)
### 10.1 为什么做
面板内置站点是**写死在 `src/sites/*.js` 里的**:想加一个网站,得改代码 + 重新部署。
对「不会写代码、但会录 cURL」的用户来说,这条路太长。所以做了一套**声明式配置**:
一段 JSON 描述「发什么请求、怎么判成功」,存进 D1,**导入即生效,不用重新部署**。
### 10.2 它是什么(三块)
| 位置 | 作用 |
|---|---|
| `src/community.js` | 配置的校验(`validateSiteConfig`)、脱敏(`sanitizeSiteConfig`)、导入/列表/删除、`makeCommunitySite()` 把 JSON 变成与内置站点同构的可执行模块、反向的 `exportAccountConfig()` |
| `src/sites/http.js` | 多步 HTTP 引擎(社区配置复用它,没另写一套执行器);新增支持 **对象形式的 headers/body**(手写 JSON 更自然)和 initialVars(账号字段直接当变量用) |
| 前端「🌍 社区站点」卡片 | 粘贴 JSON / 给 raw 链接 → 校验预览 → 导入;列表可查看/复制/删除;「导出我的配置」把已调通的账号变成可分享 JSON |
后端路由:`GET/POST /api/sites/community`、`POST /api/sites/community/check`(dry_run)、
`GET/DELETE /api/sites/community/:id`、`PUT /api/sites/community/author`(署名)、
`GET /api/accounts/:id/export-config`。D1 新表 `community_sites`(v11 迁移,只存配置、不存凭据)。
### 10.3 关键设计:两个方向都要顺
- **导入方向**:配置能跑。`makeCommunitySite()` 产出的模块和内置站点**同构**(id/name/fields/execution/run),
所以注册表、账号表单、调度器、状态列全部零改动就能用;`domain` 字段让浏览器扩展也能按域名匹配。
- **导出方向**:配置能分享。这一步比想象中难,因为**账号里存的是真实凭据和绝对地址**:
1. 凭据脱敏:请求头里 key 命中 `SECRET_KEY_RE`(cookie/token/authorization/sessid…)的值一律换成 `{{占位符}}`;
占位符名还会归一化(`X-Csrf-Token` → `{{x_csrf_token}}`,因为替换正则只认 `[\w$]+`,带短横线的会原样发出去)
2. 域名脱敏:`https://bbs.example.com/xxx` → `{{site_url}}/xxx`,换个人用也能改域名
3. **倒推字段**:先脱敏,再扫「还剩哪些 `{{变量}}`」,排除各步 `extract` 自己产生的(那是内部变量)→ 就是别人要填的 `fields`
—— 顺序很关键,先扫后脱敏会得到空的 fields(踩过)
### 10.4 安全底线(有测试钉住)
- 明文凭据的配置**直接拒绝导入**(`validateSiteConfig` 报「请求头 X 里带着明文凭据」)
- 导出时会清空凭据,并额外自查一遍长字符串
- 测试:`test/community.test.mjs` 11 组,含「导出→再导入」的往返验证(导出的配置必须能通过校验)
### 10.5 教训
1. **能不改代码就不改代码**:多步录制 + JSON 已经能覆盖绝大多数「带 Cookie 打一两个接口」的网站,
剩下的(人机验证、签名、多步浏览器流程)才值得写模块。
2. 分享出去的配置是**公开物**,脱敏必须做成默认行为,而不是「提醒用户手动删」。
3. 正则里 `sign`/`auth` 这类短词是**子串**匹配:`authorization` 要写全,否则漏掉 Bearer 头;
写 `auth` 又会误伤 `author`。
---
## 十一、2026-09-28 复盘:面板不能「替网站说话」
### 11.1 现象
用户在面板「网站反馈」里看到的是 **我们归纳的套话**,而不是网站的原话:
| 面板显示的(我们的套话) | 网站真实返回(存在 last_detail 里,只显示在第二行) |
|---|---|
| 今日已签到,不能重复签到 | `{"success":false,"message":"今天已完成签到,请勿重复操作"}`(NodeSeek) |
| 签到成功:签到成功 | `{"status_msg":"签到成功"}`(Akile) |
| 签到成功:尊贵的王者Lv7,您获得了 1.535GB 流量.\n\n🎉【69云】中秋国庆季… | 机场把整段促销公告塞进了 `msg` |
用户的诉求很直接:**「网站反馈显示真实的网站反馈信息」**。
### 11.2 根因
各站点模块各自 `return { ok, message: '今日已签到,…' }`,把网站的说法**重新叙述了一遍**。
好处是文案统一,坏处是:① 用户拿不到网站的任何信息;② 网站改版/语境变化时,我们的套话反而误导
(对比糊涂鳄那次「假签到」,如果一开始就照着网站原话显示,会少走很多弯路)。
### 11.3 改法(三层,缺一层都不完整)
1. **站点模块:主文案=网站原话**
- NodeSeek:已签到直接用 `message`(「今天已完成签到,请勿重复操作」);成功用原话+响应里的
结构化收益(`gain`/`current`)补一句「(本次 +2,累计 1811)」。
- Akile / V2Board:原话直接当反馈,不再拼「签到成功:」前缀(AI 之前真的拼出过「签到成功:签到成功」)。
- V2Board:`msg` 只取**第一段**(/ 换行分隔)——机场把整段促销公告塞在 msg 里,会把「网站反馈」刷成广告位。
2. **通用引擎:`siteMessageFrom(text)`**(`src/lib/signals.js`)
从响应里抠 `message`/`msg`/`errmsg`/`status_msg` 字段,还转义(\uXXXX)、只取第一段。
`sites/http.js`(社区配置也跑它)用它生成成功反馈。
3. **面板兜底**(`public/index.html`)
万一某个模块还是返回套话(或库里存的是**旧记录**),只要 `last_msg` 命中「套话清单」、
而 `last_detail` 里能读出网站自己的 message,就改用网站原话并打上「**网站原话**」小标记。
→ 部署后不用等下一次执行,**历史记录当场就正确的显示**。
### 11.4 顺手修的两处「看得出问题但看不出原因」
- **中继失败/超时**(`src/lib/relay.js`):错误里补 `err.detail = '中继请求:POST https://…'`,
runner 会写进 `last_detail`。吾爱破解以前只显示「已放弃该请求」,用户在面板上看不出卡在哪一步。
- **详情里的转义**:糊涂鳄回的是全转义 JSON(`\u4eca\u65e5…`),面板现在先还原成人话再展示。
### 11.5 教训
1. **反馈列是「网站说了什么」,不是「我们认为发生了什么」。** 判定放在状态列/徽章里,两个口径不要混。
2. 展示别人的数据时,**先照抄,再补充**:结构化字段(收益数字)可以加,但不要覆盖原文。
3. 已有的脏数据也要能正确渲染 —— 只在写入端修,用户得等到下一次执行才看到效果。
---
## 十二、2026-09-28 复盘:Discuz 系论坛的「精准识别 + 触发签到」
目标:吾爱破解这类 Discuz 论坛,要能**看出今天到底签没签**,并且**自己把签到触发掉**,而不是干等超时。
方法:不再靠猜,去翻开源社区的签到脚本,把它们的判据校准进来(来源:GreasyFork《网站签到工具》
`update.greasyfork.org/scripts/439136`,Apache-2.0;已把判据重写进我们自己的代码,不搬代码)。
### 12.1 校准到的硬事实
**吾爱破解(Discuz 任务制)**
```js
// 社区脚本的原话(关键三行):
var qq_bind = $("#um p:eq(1) a:eq(0) img").attr('src');
var g_upmine = document.getElementById("g_upmine"); // 登录标志
if (qq_bind.indexOf("qds.png") !== -1) { …click… } // 出现 qds.png = 未签到 → 点它
```
- 当天状态**画在图片里**(`…/common/qds.png`),那个 `<a>` 的文本节点是空的 —— 所以「读按钮文字」对它无效;
- 真正可机器判定的信号是 **`home.php?mod=task&do=apply&id=N` 这条链接还在不在**:还在 = 服务端认为今天没签;签完链接就没了、按钮变绿;
- 游客页会写 **`注册[Register]`**(登录失效的判据);
- 任务 id 由论坛后台配(社区脚本里有直接写 id=2 的,也有遍历任务 id 的)→ **以页面上的链接为准**,id=2 只当兜底。
**同源的一大票 Discuz 站(同一脚本里的旁证)**:`plugin.php?id=dsu_paulsign:sign`(打卡按钮 `#kx`)、
`k_misign:sign`(`#JD_sign`)、`zqlj_sign`(`.btna`「点击打卡」)、`.punch_btn`、`#sg_sign`、`#addsign`、
`a.initiate-checkin`、`#my_amupper` / `#pper_a`(`pperwb.gif` / `wb.png`)、背景图 `signin_no.png`、
以及「还没签到 / 您今天还没有签到 / 今天签到了吗」这类文案。
### 12.2 于是扩展端改成三层判定(顺序不能乱)
1. **验证码 / WAF** —— 最先判。这些页面上也常夹带「签到」两个字,放后面必误判。
2. **已签到的正信号** —— `任务已完成 / 签到完毕 / 您已完成过此任务 / 今日已签到 / 恭喜…获得…`。
任务列表里「已完成」必须**限定在该链接所在那一行**(别的任务也是「已完成」,拿整页搜会把它当成签到已完成)。
3. **未签到的入口** —— ①`mod=task&do=apply` 链接(吾爱破解这种)→ ②已知插件按钮 → ③页面上写着「打卡签到/每日签到」
的可点元素。**登录失效比「未签到」优先**:游客页导航里也有「每日签到」字样,不能当未签到。
找到了入口就**真的去触发**:链接 → `tabs.update` 导航过去(等同人手点一下,之前实测过 fetch 会被网宿 WAF 吊死、导航不会);
JS 按钮 → 打上 `data-panel-sign` 属性后在页面里 `click()`。触发完**重新巡检一次**再下结论。
找不到入口就老实说「网站显示还没签到,但找不到可点的入口,请在浏览器手动签一次」——不假报成功。
安全:只允许跳**同源 http(s)** 地址(页面内容不可信,防止被注入的外链带走);`resolveSignHref` 里显式校验 host。
### 12.3 面板侧同时补齐
- `wuaipojie.browserJob()` 先落**任务列表页**(普通页面,好加载),扩展自己找入口 —— 不再直接把申请地址丢给扩展;
- 站点模块本来就会从任务页读真实链接(`findSignTask`),所以**别站改 id 也能签**,硬编码 id=2 只作兜底(有测试钉住)。
### 12.4 2026-09-28 追加:扩展图标 / 去掉多余按钮 / 面板 Cookie 解析器
**① 扩展图标不能再显示「C」。** Chrome 在没有图标时会拿扩展名首字母画个圈——本扩展叫
「cookie插件2.2」,工具栏上就是个「c」。现在补了真图标:蓝色圆角方 + 白色「签」(16/32/48/128),
结果如下(可复现):
- 图标是**用分享浏览器(Chromium)的 canvas 现场渲染**的(系统字体「Microsoft YaHei」),
再用一个临时本地小服务把 PNG 字节直接写进 `public/ext-src/`——避免把二进制手动粘贴进仓库,
也避免仓库里出现「谁也不知道图是怎么来的」的黑盒资源。
- 打包链跟着改了两处:`tools/gen-ext-files.mjs` 把 `.png` 存成 base64(键名加 `@b64:` 前缀),
`src/ext-zip.js` 见到前缀就 base64 解码成字节(不再当文本注入面板地址)。
- `manifest.json` 补 `action.default_icon` + `icons`;`name` 保持「cookie插件2.2」不改(文档/下载文件名都靠它)。
- 验证:直接调 `handleExtZip()` 把下载 zip 拆开看条目,4 个 PNG 的字节数与源文件**逐字节一致**。
**② 删掉「立即执行待办签到」。** 弹窗里那个 `btn-run-now`(“唤醒中继”)会让人以为「必须手点一下才会干活」,
而实际上后台本来就是常驻长轮询、面板点「执行」1 秒内就接单。现在弹窗打开时**自动静默叫醒一次**
(`setTimeout(0)` 延到 UI 初始化之后,后台不在也不报错),按钮彻底删掉。
(台架里的 `document` 是共享变量,弹窗与后台两个 realm 会互相覆盖——所以这个叫醒必须延到一个 tick,
否则 popup 的 `init()` 会拿不到 `getElementById`。真实浏览器里本来就不会有这问题。)
**③ 面板加了「Cookie 解析器」(只看不存)。** 以前只有点「粘贴并保存」后才知道面板读到了什么,
现在粘进去可以先拆开看:
- 识别格式(扩展 JSON / 请求头 / cURL / cookie.txt / 裸串)→ 每段 name + **它是什么**:
「登录必需 / 网站安全校验 / 表单防护 / 界面偏好 / 统计(可忽略)」五种标签,带颜色;
- 字典写在 `public/cookie-tools.js`(有单测),关键条目:`PHPSESSID`、`wordpress_logged_in_*`、
`saltkey`(Discuz)、**`wzws_cid`(网宿 WAF,吾爱破解缺它就 403)**、`cf_clearance`(Cloudflare)、
`_ga`/`Hm_lvt_*`(统计,丢了不碍事);
- 值只讲**形态**(长度、像 JWT / 时间戳 / base64),长值只露头尾,**不把凭据全文打印在屏幕上**
(面板常被截图/录屏);
- 「识别逻辑」从页面里搬到了 `cookie-tools.js`,页面只负责渲染—— 以前页面与保存流程各写一份识别,
迟早会判出两种结果。
### 12.5 教训
1. **「按钮文字」不是通用判据**:有的站把状态画进图片(`qds.png`),文本节点是空的;**链接在不在**才是服务端状态。
2. **校准要找一手材料**:社区脚本里一行 `$("#um p:eq(1) a:eq(0) img")` 比十次猜测都值钱;但判据要重写进自己代码、并写清来源(Apache-2.0)。
3. **同页多任务必须行内限定**:整页搜「已完成」一定会撞车(已在这份文档第 9 节和大教训里出现过一次)。
4. **测试台架的 DOM 很弱**(只有 title/innerText/innerHTML)→ 注入脚本必须**自己 try/catch 回退到正则**,这反而顺带让真浏览器上遇到怪页面也不会整段崩掉。
5. **图标/资源要能说清来源**:能现场生成就别手搬二进制;生成器一变,图就能重生,不会出现「这张图是谁做的、怎么改」。
6. **UI 上的每个按钮都要有职责**:让人以为「不点它就不干活」的按钮,比没有按钮更糟。
## 十三、2026-09-28 复盘:糊涂鳄到底有没有「真打通」
用户问得很准:*「糊涂鳄已经签到过,点签到会回『今日已签到,请明日再来』——面板是怎么回事?能不能自动签到?有没有真实打通?」*
下面是逐条查证的结果,以及由此改的三处硬伤。
### 13.1 「面板签到是假的」——证据链
不再靠猜,直接读站点自己的代码(curl 公开页面即可,无需登录):
```
dj.hutue.cn 首页: <a class="click-qiandao zzhuti_qd_1" title="打卡签到">
ripro/assets/js/app.js :$(".click-qiandao").on("click", … $.post(caozhuti.ajaxurl,{action:"user_qiandao"}) … )
xb-child/assets/js/xb-app.js:action: "xb_user_qiandao" ← 会员中心那个,只给 1 积分
```
**真签到 = `user_qiandao`**(按钮就是这个 class、这个文件绑的)。再看面板自己的账:
- `accounts.meta.hutue_action = "xb_user_qiandao"`,那条记录写的是「签到成功,赠送1积分」(9/27 20:49)
→ 面板当时打的确实是**会员中心那个 1 积分接口**;
- 今天 10:54 那条 `ok`(今日已签到)**没有**把 `hutue_action` 改成 `user_qiandao` ——
只有在「某接口回了成功/已签」时才会写这个字段,所以答 ok 的**仍是 xb**。
结论:面板报的「已签到」可能是那个 1 积分接口的状态,真签到(5 晶石)没有确认做过。
用户去网站点悬浮窗还能再领一次 —— 这就是「假签到」的观感来源。
### 13.2 修法(三处,缺一处都会再犯)
**① 不许用别的接口冒充成功。** 新增 `isUnknownAction(raw)`:只有「站点不认这个接口」
(WordPress 对未知 action 原样回字符串 `0`、或回「无效的请求」)才允许换下一个 action。
站点只要给了**真正的业务回答**(哪怕是「签到失败:xx」),就停下并如实报出来 ——
再换一个奖励不同的接口去凑「成功」,就是在制造假签到。
降级真发生时(`LAST_RESORT_ACTIONS`)也必须在反馈里写明「本次走的是会员中心签到(只给 1 积分)」。
**② 反馈里写明打的是哪个接口。** `detail` 现在是
`网站返回:{…} · 接口 user_qiandao`(+ 重试/降级标注)。
「面板有没有真打通」从此可以在面板上自查,不用再靠人猜。
**③ 超时 ≠ 失败。** 中继超时只说明**我们没等到回包**,请求很可能已经送达(站点可能已签)。
现在:同一接口**先重打一次复核** —— 上一次若真生效,站点这次会直接回「今日已签到」,判为已签并在反馈里留下重试痕迹;
两次都没回包 → 抛 `err.outcome='relay-unknown'` → runner 记 **`skip`「结果未知」**
(不写 `last_signin_date`,不冒充成功,也不冤枉网站),并标记可自动补跑。
### 13.3 定时器:漏一分钟不再等于整天不签
先实测了「Cron 到底会不会按分钟触发」:把糊涂鳄的**独立时间**设成 3 分钟后(12:03 设 12:06),
12:07:12 确实跑了一次(run #121)→ `* * * * *` 生效,调度链路是通的。
但旧 `shouldRun` 要求「当前分钟**恰好等于**设定分钟」,后果是:
- 那一分钟没被触发(部署、抖动)→ 当天**再也不会**自动签到;
- 用户把时间改成**已经过去**的时刻(09:00 才改成 08:05)→ 当天同样不签。
新规则(`src/schedule.js`):
| 情形 | 行为 |
|---|---|
| 到达/超过设定时刻,当天没成功过 | 执行(补跑窗口 6 小时) |
| 当天该时刻已**成功** | 不再执行(`key` 记录) |
| 失败 / 结果未知 | 隔 **15 分钟**自动补一次(`key@尝试时刻` 记录),直到成功或超出窗口 |
`lastMap` 的值因此有两种形态:`2026-09-28 08:05`(成功)与 `2026-09-28 08:05@2026-09-28 08:07`(失败),
由 `parseLastKey` / `nextLastKey` 读写(有单测)。
已交给扩展去做的(吾爱破解转浏览器导航)标 `retryable: false`,不会被定时器重复排一堆任务。
顺手修了一处会误导排查的地方:失败文案里的「已试 xxx」以前拿 `ordered.slice(0, 5)` 拼,
明明只试了 2 个 action 却写 5 个 → 现在列的是**真正尝试过的**那些。
### 13.4 扩展:45 秒的硬超时装不下重试链
线上扩展(旧版)报「中继执行超时(45秒),已放弃该请求」,而重试链最多要
`首次试探 12s + 激活前台 1.2s + 前台重试 30s ≈ 43s`,再加取任务/注入开销就顶到 45s ——
**第二次明明快要拿到响应也被掐掉**,面板上永远只有「超时」。
- 现在硬超时按公式算:`RELAY_FIRST_TRY_MS + RELAY_ACTIVATE_WAIT_MS + RELAY_FETCH_TIMEOUT_MS + 15s ≈ 58s`,
并用 `test/ext-timeout.test.mjs` 把这个预算关系钉住(改任一常量都不会再踩);
- 超时文案改成「请求已发出但没收到回包,站点可能已记录(结果未知)」,不再写含糊的「已放弃该请求」。
- **改的是扩展代码 → 必须重新下载扩展并「重新加载」才生效**。
### 13.5 教训
1. **「面板说成功」必须能被核实**:主文案用网站原话,后面跟上**实际请求的接口名**。
只说「签到成功」而不说打给了谁,就是给假签到留门。
2. **超时是「不知道」,不是「失败」**:写操作超时后要**回读/重放**去判断到底生效没有;
判不出来就如实记「结果未知」,并且要能自动补跑复核。
3. **定时不要要求分秒相等**:Cron 会漏、用户会改时间;要「到点 + 当天一次 + 失败自愈」。
4. **超时预算要用公式表达**:`A + B + C` 的重试链被比它小的硬超时掐死,是很容易复发的一类 bug。
5. **再次验证:一手材料 > 推测**。读站点主题 JS 的 10 秒,胜过十次「大概应该是这个 action」。