同步用户更新:扩展v2.3(中继重试+前台激活+智能签到检测),含图标与社区模块
This commit is contained in:
1 parent
7740c5702a
commit
fcd2f70bb8
49 files changed
+4548
-466
No files matched your search
+18
@@ -0,0 +1,18 @@
|
||||
# 依赖与构建产物
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Wrangler / Cloudflare 本地状态(里面有你的账号信息,别提交)
|
||||
.wrangler/
|
||||
.dev.vars
|
||||
.wrangler.toml
|
||||
|
||||
# 打包产物(自己本地打出来的 zip)
|
||||
*.zip
|
||||
|
||||
# 编辑器 / 系统
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*~
|
||||
@@ -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 许可发布你的贡献。
|
||||
@@ -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.
|
||||
@@ -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` 抄最省事):
|
||||
|
||||
|
||||
@@ -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 覆盖旧版 —— 这就是这个目录存在的意义。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(本地网络,需扩展在线)。"
|
||||
}
|
||||
@@ -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 / 接口路径 / 成功关键字换成你自己站点的即可。重复签到(站点回「今日已签到」)不会被判成失败。"
|
||||
}
|
||||
@@ -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
@@ -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(/&/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 |
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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(/&/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
File diff suppressed because it is too large.
Load diff
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(/&/g, '&');
|
||||
const inner = m[3].replace(/<[^>]*>/g, ' ').replace(/ /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(/ /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);
|
||||
|
||||
@@ -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} 组通过`);
|
||||
@@ -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} 组通过`);
|
||||
@@ -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
@@ -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} 组通过`);
|
||||
@@ -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} 组通过`);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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&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
@@ -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 () => {
|
||||
|
||||
@@ -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)}`;
|
||||
});
|
||||
|
||||
@@ -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
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
@@ -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」。
|
||||
Reference in new issue
Block a user