diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44a3b4b --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# 依赖与构建产物 +node_modules/ +dist/ + +# Wrangler / Cloudflare 本地状态(里面有你的账号信息,别提交) +.wrangler/ +.dev.vars +.wrangler.toml + +# 打包产物(自己本地打出来的 zip) +*.zip + +# 编辑器 / 系统 +.vscode/ +.idea/ +.DS_Store +Thumbs.db +*~ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..49507fa --- /dev/null +++ b/CONTRIBUTING.md @@ -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/.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 许可发布你的贡献。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63eb89e --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 66ccdcb..d11f0c0 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,15 @@ > 本项目只需要 **Cloudflare 免费账号**,不需要买服务器、不需要 Cloudflare 的 API Token。 +

+ GitHub + License: MIT + PRs welcome +

+ +**开源共建**:没有内置的网站,谁都能贡献一份适配 —— 在面板里把签到调通,导出成一段 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` 抄最省事): diff --git a/community/README.md b/community/README.md new file mode 100644 index 0000000..cfd5038 --- /dev/null +++ b/community/README.md @@ -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 覆盖旧版 —— 这就是这个目录存在的意义。 diff --git a/community/index.json b/community/index.json new file mode 100644 index 0000000..41d64f6 --- /dev/null +++ b/community/index.json @@ -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" + } +} diff --git a/community/sites/_template.json b/community/sites/_template.json new file mode 100644 index 0000000..28983c2 --- /dev/null +++ b/community/sites/_template.json @@ -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": "这份是模板:字段名、地址、说明都按你自己的站点改。改完在「添加账号」里测试运行,通过后再提交。" +} diff --git a/community/sites/discuz-signin-template.json b/community/sites/discuz-signin-template.json new file mode 100644 index 0000000..cd1cc0e --- /dev/null +++ b/community/sites/discuz-signin-template.json @@ -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(本地网络,需扩展在线)。" +} diff --git a/community/sites/simple-api-signin.json b/community/sites/simple-api-signin.json new file mode 100644 index 0000000..f09bf80 --- /dev/null +++ b/community/sites/simple-api-signin.json @@ -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 / 接口路径 / 成功关键字换成你自己站点的即可。重复签到(站点回「今日已签到」)不会被判成失败。" +} diff --git a/public/cookie-tools.js b/public/cookie-tools.js index 25b5e4d..d1716ee 100644 --- a/public/cookie-tools.js +++ b/public/cookie-tools.js @@ -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; diff --git a/public/ext-src/background.js b/public/ext-src/background.js index c97afe1..47144bb 100644 --- a/public/ext-src/background.js +++ b/public/ext-src/background.js @@ -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 }; + } + } + // ② 已知的签到按钮(可能是 ,也可能是 JS 点击的 /
/) + 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(/]*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) { diff --git a/public/ext-src/icon128.png b/public/ext-src/icon128.png new file mode 100644 index 0000000..2c00f20 Binary files /dev/null and b/public/ext-src/icon128.png differ diff --git a/public/ext-src/icon16.png b/public/ext-src/icon16.png new file mode 100644 index 0000000..5efb186 Binary files /dev/null and b/public/ext-src/icon16.png differ diff --git a/public/ext-src/icon32.png b/public/ext-src/icon32.png new file mode 100644 index 0000000..90b3125 Binary files /dev/null and b/public/ext-src/icon32.png differ diff --git a/public/ext-src/icon48.png b/public/ext-src/icon48.png new file mode 100644 index 0000000..1d31fa3 Binary files /dev/null and b/public/ext-src/icon48.png differ diff --git a/public/ext-src/manifest.json b/public/ext-src/manifest.json index 1a826be..dda2d2e 100644 --- a/public/ext-src/manifest.json +++ b/public/ext-src/manifest.json @@ -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": [ "" ], + "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" -} \ No newline at end of file + "version": "2.3" +} diff --git a/public/ext-src/popup.html b/public/ext-src/popup.html index 3813530..2f5558e 100644 --- a/public/ext-src/popup.html +++ b/public/ext-src/popup.html @@ -20,11 +20,10 @@ -

🍪 cookie插件2.2

+

🖊️ cookie插件2.2

正在读取当前网站…
- diff --git a/public/ext-src/popup.js b/public/ext-src/popup.js index a5a7559..ac901e8 100644 --- a/public/ext-src/popup.js +++ b/public/ext-src/popup.js @@ -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(/]*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', diff --git a/public/index.html b/public/index.html index ad170de..b109366 100644 --- a/public/index.html +++ b/public/index.html @@ -198,17 +198,38 @@ .ck-more[open] > summary::before { content:"▾ "; } .ck-more > summary:hover { color:var(--text); } .ck-more-body { padding:2px 14px 14px; border-top:1px solid var(--line); } + /* Cookie 解析器:每段后面跟一个小标签说明它属于哪一类 */ + .ck-tag { display:inline-block; padding:1px 7px; border-radius:999px; font-size:10.5px; font-weight:600; + border:1px solid var(--line2); color:var(--muted); background:rgba(255,255,255,.03); } + .ck-tag.k-login { color:#5cdd9e; border-color:rgba(61,220,132,.45); background:rgba(61,220,132,.12); } + .ck-tag.k-waf { color:#ffb020; border-color:rgba(255,176,32,.45); background:rgba(255,176,32,.12); } + .ck-tag.k-csrf { color:#7aa5ff; border-color:rgba(91,140,255,.45); background:rgba(91,140,255,.12); } + .ck-tag.k-stat { color:var(--muted2); } + .ck-tag.k-pref { color:var(--muted); } + .ck-tag.k-other { color:#ff8181; border-color:rgba(255,107,107,.4); background:rgba(255,107,107,.1); } - /* 签到时间一行布局(紧凑) */ - /* 签到时间控制条:单独一条浅底区域,宽度不够时整组换行, - 不会像以前那样把标题挤歪、或者按钮掉到下面显得散。 */ - .sched-bar { margin-top:10px; display:flex; flex-wrap:wrap; align-items:flex-end; gap:16px; } - .sched-bar .sched-field { display:flex; flex-direction:column; gap:8px; } + /* 签到时间控制条:一块内层面板,圆角/描边/内边距和页面里其他内层块(扩展下载、社区块) + 完全一致 —— 整页的「框」看起来是一套。窄屏时整组换行,不会把标题挤歪。 */ + .sched-bar { margin-top:10px; display:flex; flex-wrap:wrap; align-items:flex-end; gap:16px; + padding:14px 16px; border-radius:var(--radius-sm); + background:rgba(255,255,255,.015); border:1px solid var(--line); } + /* 两个字段均分这一行(flex:1 1 0)—— 里面的胶囊 width:100%,所以两个胶囊等宽等高, + 不会出现一个 84px、一个 139px 那种看着像没对齐的情况 */ + .sched-bar .sched-field { display:flex; flex-direction:column; gap:6px; flex:1 1 0; min-width:0; } .sched-bar .sched-field > label { margin:0; font-size:12px; color:var(--muted2); font-weight:500; } + /* 两个胶囊 + 保存按钮统一度量:同高同宽同圆角、文字居中,排成一条像一套控件 */ + .sched-bar .tp-chip.lg, .sched-bar .tz-chip { + width:100%; height:44px; padding:0 12px; justify-content:center; + border-radius:var(--radius-sm); font-size:15px; font-weight:600; } + .sched-bar .tp-chip.lg .tp-dot { width:8px; height:8px; } + @media (max-width:520px) { + .sched-bar .sched-field { flex:1 1 100%; } + } /* 时区选择器:和时间胶囊(大号)同风格 */ + /* flex:none:中文时区名比「08:30」长,不能被压得比文字还窄(否则字会溢出胶囊) */ .tz-chip { display:inline-flex; align-items:center; justify-content:center; gap:8px; padding:8px 16px; border-radius:var(--radius-sm); background:var(--card2); border:1px solid var(--line); color:var(--text); font-size:17px; - font-weight:600; cursor:pointer; box-shadow:none; white-space:nowrap; min-width:176px; + font-weight:600; cursor:pointer; box-shadow:none; white-space:nowrap; min-width:176px; flex:none; font-variant-numeric:tabular-nums; transition:border-color .15s, box-shadow .15s; } .tz-chip:hover { transform:none; border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-dim); } @@ -216,20 +237,30 @@ .tz-chip .tz-dot { width:8px; height:8px; border-radius:50%; background:var(--accent2); flex:none; box-shadow:0 0 0 3px var(--accent-dim); } /* 时区弹窗:和时间选择器弹窗同风格同动画 */ - .tz-pop { position:fixed; z-index:70; width:220px; padding:10px; border-radius:var(--radius); + /* 选项多了(24 个时区)后弹窗会很高:限高 + 内部滚动,不再跑到屏幕外被截掉 */ + .tz-pop { position:fixed; z-index:70; width:250px; padding:12px; border-radius:var(--radius); + max-height:min(64vh, 460px); overflow-y:auto; overscroll-behavior:contain; background:#141c33; border:1px solid var(--line2); box-shadow:var(--shadow); animation:slideUp .25s; } .tz-pop.hidden { display:none; } - .tz-opt { display:block; width:100%; text-align:center; padding:8px 0; border:0; border-radius:var(--radius-sm); - background:transparent; color:var(--muted); font-size:13.5px; font-weight:500; cursor:pointer; box-shadow:none; - transition:background .12s, color .12s; } + /* 深色页面里不能冒出系统默认的浅色滚动条(Chrome 121+ 的 scrollbar-width 会覆盖 + ::-webkit-scrollbar,所以两边一起写,和 .tpx-list 同一个思路) */ + .tz-pop { scrollbar-width:thin; scrollbar-color:var(--line2) transparent; } + .tz-pop::-webkit-scrollbar { width:8px; } + .tz-pop::-webkit-scrollbar-thumb { background:var(--line2); border-radius:8px; } + .tz-pop::-webkit-scrollbar-track { background:transparent; } + .tz-opt { position:relative; display:block; width:100%; text-align:center; padding:9px 0; border:0; + border-radius:var(--radius-sm); background:transparent; color:var(--muted); font-size:13.5px; + font-weight:500; cursor:pointer; box-shadow:none; transition:background .12s, color .12s; } .tz-opt:hover { background:rgba(91,140,255,.14); color:var(--text); transform:none; box-shadow:none; } .tz-opt.sel:hover { background:var(--accent); color:#fff; } .tz-opt.sel { background:var(--accent); color:#fff; font-weight:700; box-shadow:0 4px 12px rgba(91,140,255,.35); } + /* 选中的那个打勾 —— 「点一下即确定」(与时间浮层的「完成」同义) */ + .tz-opt.sel::before { content:"✓"; position:absolute; left:12px; font-size:12px; opacity:.9; } .sched-bar button { margin:0; } - /* 保存按钮:和时间/时区大号胶囊同尺寸,文字居中 */ - #btn-save-sched { border-radius:var(--radius-sm); padding:8px 32px; font-size:17px; font-weight:600; + /* 保存按钮:和时间/时区大号胶囊同一高度与圆角,文字居中 */ + #btn-save-sched { height:34px; margin:0; border-radius:var(--radius-sm); padding:0 22px; font-size:13px; font-weight:600; display:inline-flex; align-items:center; justify-content:center; border:1px solid transparent; box-shadow:0 4px 14px rgba(91,140,255,.3); } .sched-tip { align-self:center; margin-left:auto; font-size:11.5px; color:var(--muted2); } @@ -260,36 +291,42 @@ /* 表格里该居中的列居中(数值/徽章/开关/按钮) */ th.c, td.c { text-align:center; } td.c { white-space:nowrap; } - .site-cell { min-width:140px; text-align:center; } - /* 包裹层居中,内部右对齐:切换按钮右边缘与徽章右边缘对齐 */ - .site-wrap { display:inline-block; text-align:right; width:148px; } + .site-cell { min-width:148px; text-align:center; } + /* 站点名、网络徽章、切换按钮整组居中(名字与下面的网络小模块对齐在同一中轴线上) */ + .site-wrap { display:inline-block; text-align:center; width:156px; } .site-wrap .badge { width:100%; box-sizing:border-box; justify-content:center; } .site-wrap .site-line { margin-bottom:4px; } - .site-head { display:flex; align-items:center; justify-content:flex-end; gap:6px; margin-bottom:4px; } + .site-head { display:flex; align-items:center; justify-content:center; gap:6px; margin-bottom:4px; } .site-head b { font-size:14px; } /* 切换按钮:边框提亮 */ .exec-switch { font-size:11px !important; padding:3px 10px !important; white-space:nowrap; flex-shrink:0; border-color:rgba(91,140,255,.45) !important; color:var(--accent2) !important; } .exec-switch:hover { border-color:var(--accent) !important; background:var(--accent-dim) !important; } - /* 网络徽章:右对齐,与切换按钮右边缘对齐;宽度拉满与分段器一致 */ - .site-line { display:flex; align-items:center; justify-content:flex-end; margin-top:0; margin-bottom:4px; } + /* 网络徽章:居中,与站点名同一中轴线;宽度拉满与分段器一致 */ + .site-line { display:flex; align-items:center; justify-content:center; margin-top:0; margin-bottom:4px; } .site-line .badge { flex-shrink:0; width:100%; justify-content:center; box-sizing:border-box; } - /* 网站反馈占剩下的宽度,否则字被挤成一列一列地折,行高很难看 */ - td.msg { width:100%; min-width:160px; max-width:280px; } - .msg-main { font-size:12.5px; line-height:1.5; - display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } - .msg-line { display:flex; align-items:flex-start; gap:8px; } + /* 网站反馈占剩下的宽度,并直接展示全部内容: + 不再用 line-clamp 截成两行(以前鼠标不移上去看不到全文),也不再靠横向滚动条。 + 宽度靠 table-layout:auto + min-width:0 自适应,窄屏下在卡片内折行而不是溢出。 */ + table { table-layout:auto; } + th, td { min-width:0; } + td.msg { width:100%; max-width:320px; } + .msg-main { font-size:12.5px; line-height:1.5; overflow-wrap:anywhere; } + /* 主文案是网站原话时的小标记:让人知道这句不是面板替网站说的 */ + .msg-by { margin-left:6px; padding:1px 6px; border-radius:999px; font-size:10px; white-space:nowrap; + background:rgba(61,220,132,.13); color:#5cdd9e; border:1px solid rgba(61,220,132,.3); } .msg-raw { margin-top:5px; font-size:11px; line-height:1.5; color:var(--muted2); - border-left:2px solid var(--line2); padding-left:8px; word-break:break-word; - display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } + border-left:2px solid var(--line2); padding-left:8px; overflow-wrap:anywhere; } + /* 红标(说明/建议)固定在「网站反馈」的末尾:主文案 → 网站原文 → 这里 */ + .msg-foot { margin-top:6px; display:flex; align-items:center; gap:6px; } + .hint-tag { font-size:10.5px; color:#ff9a9a; letter-spacing:.3px; } + .msg-foot .hint-dot { width:17px; height:17px; font-size:11.5px; } + /* 上次运行:固定两行(日期 / 时刻),每行都不折行,列宽稳定、不要横向滚动条 */ + .run-time { font-size:12px; line-height:1.5; } + .run-time .t-date, .run-time .t-time { white-space:nowrap; display:inline-block; } + .run-time .t-time { color:var(--muted2); } - /* 扩展下载区:常驻可见(之前塞进折叠里,用户根本找不到) */ - .ext-box { margin-top:16px; padding:14px 16px; border-radius:var(--radius-sm); - background:rgba(91,140,255,.06); border:1px solid rgba(91,140,255,.22); } - .ext-box-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } - .ext-box-head h3 { margin:0; font-size:14px; } - .ext-box-head .grow { flex:1 1 auto; } - .ext-box > p { margin:8px 0 0; font-size:12.5px; line-height:1.7; color:var(--muted); } + /* 扩展下载区:紧凑版(.ext-mini)放在「签到时间」下面;这里只共用步骤序号样式 */ /* 步骤用 ol + counter,比「一整段
」的行距和缩进稳得多 */ .steps { margin:10px 0 0; padding:0; list-style:none; counter-reset:st; } .steps > li { position:relative; padding:5px 0 5px 30px; font-size:12.5px; line-height:1.7; color:var(--muted); } @@ -331,7 +368,7 @@ .tp-chip .tp-dot { width:6px; height:6px; border-radius:50%; background:var(--muted2); flex:none; } .tp-chip:not(.is-follow) .tp-dot { background:var(--accent2); box-shadow:0 0 0 3px var(--accent-dim); } - .tpx-pop { position:fixed; z-index:70; width:250px; padding:14px; border-radius:var(--radius); + .tpx-pop { position:fixed; z-index:70; width:250px; padding:12px; border-radius:var(--radius); background:#141c33; border:1px solid var(--line2); box-shadow:var(--shadow); animation:slideUp .25s; } .tpx-pop.hidden { display:none; } @@ -380,6 +417,65 @@ /* 账号列表里的时间单元格 */ .sched-cell { display:flex; align-items:center; gap:6px; justify-content:center; } + /* ---------- 账号页:两栏(左:签到时间 + 扩展下载|右:粘贴添加) ---------- + 用 grid + 一根竖线,而不是 flex:1 的两块 —— 窄屏时自动叠成一列,不会互相挤。 */ + .two-col { display:grid; grid-template-columns:minmax(0,1fr) 1px minmax(0,1.06fr); gap:20px; } + .two-col > .vline { background:linear-gradient(180deg, transparent, var(--line2), transparent); } + @media (max-width:860px) { .two-col { grid-template-columns:1fr; gap:16px; } .two-col > .vline { display:none; } } + + /* 扩展下载(紧凑版):就放在「签到时间」下面那块空白里 */ + .ext-mini { margin-top:16px; padding:14px 16px; border-radius:var(--radius-sm); + background:linear-gradient(180deg, rgba(91,140,255,.10), rgba(91,140,255,.03)); + border:1px solid rgba(91,140,255,.24); } + .ext-mini-head { display:flex; align-items:center; gap:8px; flex-wrap:wrap; } + .ext-mini-head b { font-size:13px; } + .ext-mini-head .grow { flex:1 1 auto; } + .ext-mini-head .bookmarklet { padding:5px 12px; font-size:12px; margin:0; } + .ext-mini > p { margin:8px 0 0; font-size:12px; line-height:1.65; color:var(--muted); } + .ext-mini .steps { margin-top:6px; } + .ext-mini .steps > li { padding:3px 0 3px 26px; font-size:12px; line-height:1.6; } + .ext-mini .steps > li::before { width:17px; height:17px; font-size:10.5px; top:4px; } + .ext-mini kbd { background:rgba(255,255,255,.08); border:1px solid var(--line); border-radius:5px; + padding:0 4px; font-size:11px; font-family:inherit; } + + /* ---------- 社区站点(开源共享) ---------- */ + .badge.com { background:rgba(61,220,132,.15); color:#5cdd9e; } + .com-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:14px; margin-top:4px; } + @media (max-width:860px) { .com-grid { grid-template-columns:1fr; } } + .com-box { border:1px solid var(--line); border-radius:var(--radius-sm); padding:13px 14px; + background:rgba(255,255,255,.015); } + .com-box > h4 { margin:0 0 8px; font-size:13.5px; font-weight:600; display:flex; align-items:center; gap:6px; } + .com-box > p { margin:0 0 8px; font-size:12px; line-height:1.65; color:var(--muted); } + .com-box textarea { min-height:78px; font-size:12px; } + .com-box .row { gap:8px; } + .com-list { display:flex; flex-direction:column; gap:8px; margin-top:12px; } + .com-item { display:flex; align-items:center; gap:10px; padding:9px 12px; flex-wrap:wrap; + border-radius:10px; background:var(--card2); border:1px solid var(--line); } + .com-item .grow { flex:1 1 140px; min-width:0; } + .com-item b { font-size:13px; } + .com-item .meta { font-size:11.5px; color:var(--muted2); line-height:1.6; overflow-wrap:anywhere; } + .com-item .ghost.sm button, .com-item button { white-space:nowrap; } + .com-empty { padding:12px; text-align:center; font-size:12px; color:var(--muted2); + border:1px dashed var(--line); border-radius:10px; } + .com-msg { margin-top:10px; padding:10px 12px; border-radius:10px; font-size:12.5px; line-height:1.7; } + .com-msg.ok { background:var(--ok-dim); border:1px solid rgba(61,220,132,.3); color:var(--ok); } + .com-msg.bad { background:var(--fail-dim); border:1px solid rgba(255,107,107,.3); color:var(--fail); } + .com-msg.warn { background:var(--warn-dim); border:1px solid rgba(255,176,32,.3); color:var(--warn); } + .json-box { width:100%; min-height:170px; font-family:ui-monospace,monospace; font-size:11.5px; + line-height:1.55; white-space:pre; } + .com-tbl { width:100%; margin:8px 0 0; } + .com-tbl th, .com-tbl td { padding:5px 8px; font-size:12px; text-align:left; } + .com-tbl th { color:var(--muted2); font-weight:500; } + + /* 页面底部说明(含开源仓库地址) */ + .foot { margin-top:18px; padding:15px 18px; border-radius:var(--radius-sm); text-align:center; + border:1px dashed var(--line2); background:rgba(255,255,255,.015); + font-size:12px; line-height:2; color:var(--muted2); } + .foot b { color:var(--muted); } + .foot a { color:var(--accent2); text-decoration:none; } + .foot a:hover { text-decoration:underline; } + .foot .dot { padding:0 7px; opacity:.5; } + @media (max-width:640px) { .wrap { padding:16px 12px 60px; } .card { padding:16px; } @@ -449,18 +545,40 @@ 加载中…
-
-
-
-

签到时间

-
-
-
-
+
+
+
+
+

签到时间

+ +
+
+
+
+
+

💡 点胶囊展开下拉,鼠标移开会自动收起;时区列表点中哪个就是哪个(带 ✓ 的就是当前值),选好点右上角「保存」。单个账号也能在「签到时间」列点一下设独立时间。

+ + +
-
-
+
+

添加 / 更新账号

在网站登录好 → 点扩展图标 → 「📋 一键复制全部信息」→ 回到这里按 Ctrl+V 粘贴。 面板会自动认出站点、自动填好 Cookie 和 User-Agent(UA 不用自己找),保存后立刻可用。

@@ -471,28 +589,82 @@
+ +
+ 🔍 Cookie 解析器 —— 粘进来逐段拆解(只看不存) +
+

支持:扩展「📋 一键复制全部信息」的 JSON、F12 里复制的请求头、右键「复制为 cURL」、cookie.txt、光秃秃的 name=value; name2=value2。 + 解析结果会告诉你每一段是什么、哪些是登录必需、哪些只是统计。

+ +
+ + +
+
+
+
+
-
-
- -

它干两件事:① 一键取 Cookie(能读到 HttpOnly,NodeSeek 这类站也行); - ② 做「本地网络中继」,让吾爱破解这些屏蔽机房 IP 的站点也能签到。

-
    -
  1. 点右侧「⬇️ 下载 cookie插件2.2」,得到 zip,解压到任意文件夹
  2. -
  3. 浏览器地址栏输入 chrome://extensions/(Edge 用 edge://extensions/),打开右上角「开发者模式」
  4. -
  5. 点「加载已解压的扩展程序」,选中解压出来的文件夹(不是 zip 本身)
  6. -
  7. 点扩展图标 → 填「API Key」(在「设置」页复制)→ 点「🔌 面板连接检查」
  8. -
-

💡 从本面板下载的扩展已自动填好面板地址;以后改了域名,在扩展弹窗里同步改一下即可。

+ +
+
+

🌍 社区站点(开源共享)

+ ⭐ GitHub 仓库
+

面板内置的站点是写死在代码里的;没有内置的网站,谁都可以贡献一份适配: + 你用自己的面板把该网站的签到调通(「自定义 HTTP」多步录制,或直接导入别人发的 JSON) + → 点「导出为社区配置」拿到一段 JSON → 发到 GitHub 的 Issue / PR 或任意网盘 → 别人粘贴导入就能用。 + 配置里只会带占位符({{cookie}} 这种),不会带你的 Cookie,可以放心分享。

+ +
+
+

📥 导入一份配置

+

粘贴别人分享的 JSON。也可以只填一条原始链接(GitHub raw / Gist / 自己的网址都行),面板会自己拉取。

+ +
+ +
+
+ + +
+
+
+ +
+

📤 导出我的配置

+

选一个你已经调通的账号(「自定义 HTTP」录了多步的账号最合适),导出成可分享的 JSON。 + 凭据会自动换成占位符。

+ + + + +
+ + + +
+
+
+ +
+ + +
+ 已导入的社区站点 +
加载中…
+
+
+ +
+ 签到面板 · 跑在 Cloudflare Workers 上(免费额度就够) + · + github.com/guoxpeng/daily-checkin-panel +
+ 属于什么、怎么部署、怎么贡献站点适配 —— 都写在仓库的 README.md(照着做就能部署)和 CONTRIBUTING.md 里。
@@ -663,12 +835,16 @@ function toast(msg, ms = 2200) { t._h = setTimeout(() => (t.style.display = 'none'), ms); } +// 上次运行时间:固定显示两行(上:月-日,下:时:分:秒),整段完整展示。 +// 拆两行是为了列窄一点、不要再出现横向滚动条;完整时间放 title 里方便对照。 function fmtTime(ts) { if (!ts) return '-'; const d = new Date(ts); - const date = d.toLocaleDateString('zh-CN'); - const time = d.toLocaleTimeString('zh-CN', { hour12: false }); - return `${date}
${time}`; + const p = (n) => String(n).padStart(2, '0'); + const date = `${p(d.getMonth() + 1)}-${p(d.getDate())}`; + const time = `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; + return `` + + `${date}
${time}
`; } function esc(s) { @@ -758,6 +934,28 @@ document.querySelectorAll('.tabs button').forEach((b) => { // 两种形态:opts.inline=true → 内嵌时/分两列滚轮(设置页);否则 → 紧凑胶囊,点开浮层(账号列表) // 返回 { get(), set(hhmm), setFollow(bool) };用户改动时在 container 上派发 'tp-change',点“跟随全局”派发 'tp-follow' let _tpxPop = null; + +// 浮层「鼠标移开自动收起」(时间与时区两个浮层共用): +// 离开胶囊或浮层后等 320ms 再收(留给鼠标从胶囊滑进浮层的余量), +// 期间只要鼠标又进了任一方就取消。点选项 / 完成按钮仍是立即确定的路径。 +function bindAutoHide(pop, chip, delay = 320) { + let timer = 0; + const cancel = () => { if (timer) { clearTimeout(timer); timer = 0; } }; + // 只在浮层真的开着时才倒数 —— 否则「点选项收起」也会因鼠标离场排一个定时器, + // 下次刚打开就被它关掉。 + const arm = () => { + cancel(); + if (pop.classList.contains('hidden')) return; + timer = setTimeout(() => { pop.classList.add('hidden'); timer = 0; }, delay); + }; + pop.onmouseenter = cancel; + pop.onmouseleave = arm; + if (chip) { + chip.onmouseenter = cancel; + chip.onmouseleave = arm; + } + return { cancel, arm }; +} function _tpxPopover() { if (_tpxPop) return _tpxPop; const el = document.createElement('div'); @@ -863,7 +1061,7 @@ function renderTimePicker(container, initial, opts = {}) { pop.querySelector('.tpx-done').onclick = (e) => { e.stopPropagation(); pop.classList.add('hidden'); }; pop.classList.remove('hidden'); const r = chip.getBoundingClientRect(); - const w = 238; + const w = 250; // 与 .tpx-pop 的 CSS 宽度一致(不一致会让浮层歪一边) const left = Math.min(Math.max(8, r.left + r.width / 2 - w / 2), window.innerWidth - w - 8); let top = r.bottom + 8; if (top + 330 > window.innerHeight) top = Math.max(8, r.top - 338); @@ -872,6 +1070,7 @@ function renderTimePicker(container, initial, opts = {}) { requestAnimationFrame(() => center(pop)); }; chip.onclick = (e) => { e.stopPropagation(); open(); }; + bindAutoHide(pop, chip); return { get: time, @@ -881,8 +1080,43 @@ function renderTimePicker(container, initial, opts = {}) { } // ---------- 签到时间设置 ---------- -const TZ_OPTS = ['Asia/Shanghai', 'Asia/Taipei', 'Asia/Hong_Kong', 'Asia/Tokyo', 'Asia/Singapore', 'UTC', 'Europe/London', 'Europe/Berlin', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'Australia/Sydney']; -const TZ_CN = { 'Asia/Shanghai': '北京时间', 'Asia/Taipei': '台北时间', 'Asia/Hong_Kong': '香港时间', 'Asia/Tokyo': '东京时间', 'Asia/Singapore': '新加坡时间', 'UTC': 'UTC 时间', 'Europe/London': '伦敦时间', 'Europe/Berlin': '柏林时间', 'America/New_York': '纽约时间', 'America/Chicago': '芝加哥时间', 'America/Denver': '丹佛时间', 'America/Los_Angeles': '洛杉矶时间', 'Australia/Sydney': '悉尼时间' }; +// 时区:面板里存的是 IANA 名称(Asia/Shanghai 这种),显示一律用「国家或地区 · 城市(UTC 偏移)」, +// 不需要对着英文城市名猜是哪儿。 +const TZ_OPTS = [ + 'Asia/Shanghai', 'Asia/Hong_Kong', 'Asia/Macau', 'Asia/Taipei', 'Asia/Tokyo', 'Asia/Seoul', + 'Asia/Singapore', 'Asia/Bangkok', 'Asia/Jakarta', 'Asia/Kolkata', 'Asia/Dubai', + 'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Europe/Moscow', + 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles', 'America/Toronto', + 'America/Sao_Paulo', 'Australia/Sydney', 'Pacific/Auckland', 'UTC', +]; +const TZ_CN = { + 'Asia/Shanghai': '中国 · 北京', 'Asia/Hong_Kong': '中国香港', 'Asia/Macau': '中国澳门', + 'Asia/Taipei': '中国台湾 · 台北', 'Asia/Tokyo': '日本 · 东京', 'Asia/Seoul': '韩国 · 首尔', + 'Asia/Singapore': '新加坡', 'Asia/Bangkok': '泰国 · 曼谷', 'Asia/Jakarta': '印尼 · 雅加达', + 'Asia/Kolkata': '印度 · 新德里', 'Asia/Dubai': '阿联酋 · 迪拜', + 'Europe/London': '英国 · 伦敦', 'Europe/Paris': '法国 · 巴黎', 'Europe/Berlin': '德国 · 柏林', + 'Europe/Moscow': '俄罗斯 · 莫斯科', + 'America/New_York': '美国 · 纽约', 'America/Chicago': '美国 · 芝加哥', 'America/Denver': '美国 · 丹佛', + 'America/Los_Angeles': '美国 · 洛杉矶', 'America/Toronto': '加拿大 · 多伦多', + 'America/Sao_Paulo': '巴西 · 圣保罗', 'Australia/Sydney': '澳大利亚 · 悉尼', + 'Pacific/Auckland': '新西兰 · 奥克兰', 'UTC': '协调世界时(UTC)', +}; +// 当前偏移量(UTC+8 / UTC-5 这种),跟着夏令时自动变 +function tzOffsetText(z) { + try { + const p = new Intl.DateTimeFormat('en-US', { timeZone: z, timeZoneName: 'shortOffset' }) + .formatToParts(new Date()).find((x) => x.type === 'timeZoneName'); + return p ? String(p.value).replace('GMT', 'UTC') : ''; // GMT+8 → UTC+8,GMT → UTC + } catch { return ''; } +} +// 胶囊 / 顶栏用的短文案:中国 · 北京 +function tzLabel(z) { return TZ_CN[z] || z || ''; } +// 下拉选项用的完整文案:中国 · 北京 · UTC+8(用间隔符而不是括号—— +// 这句会被塞进「每天 08:00(…)」里,双重括号很难看) +function tzLabelFull(z) { + const off = tzOffsetText(z); + return /^UTC[+\-]/.test(off) ? `${tzLabel(z)} · ${off}` : tzLabel(z); +} // 自定义时区选择器:按钮+弹窗,风格与时间选择器一致 let _tzPop = null, _tzValue = 'Asia/Shanghai'; function _tzPopover() { @@ -902,13 +1136,13 @@ function renderTzPicker(container, initial) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'tz-chip'; - const label = () => `${TZ_CN[_tzValue] || _tzValue}`; + const label = () => tzLabel(_tzValue); const paint = () => { btn.innerHTML = `${esc(label())}▼`; }; paint(); btn.onclick = (e) => { e.stopPropagation(); const pop = _tzPopover(); - pop.innerHTML = TZ_OPTS.map((z) => ``).join(''); + pop.innerHTML = TZ_OPTS.map((z) => ``).join(''); pop.querySelectorAll('.tz-opt').forEach((b) => { b.onclick = (ev) => { ev.stopPropagation(); @@ -919,13 +1153,15 @@ function renderTzPicker(container, initial) { }); const r = btn.getBoundingClientRect(); pop.classList.remove('hidden'); - const pw = 220, ph = pop.offsetHeight; + const pw = 250, ph = pop.offsetHeight; // 与 .tz-pop 的 CSS 宽度一致 let left = r.left + r.width / 2 - pw / 2; left = Math.max(8, Math.min(left, window.innerWidth - pw - 8)); let top = r.bottom + 8; if (top + ph > window.innerHeight - 8) top = Math.max(8, r.top - ph - 8); pop.style.left = left + 'px'; pop.style.top = top + 'px'; + // 鼠标移开(胶囊或列表)→ 自动收起;点中某一项就是确定(立即生效并收起) + bindAutoHide(pop, btn); }; container.innerHTML = ''; container.appendChild(btn); @@ -936,21 +1172,34 @@ let GLOBAL_HOUR = '08'; // 全局签到时间(整点小时),账号独立 let SCHED_TZ = 'Asia/Shanghai'; // 面板设置的时区:状态列「今天」也按它算 let schedPicker = null; -// 「今天」按面板设置的时区计算(与签到时间同口径)。 +// 某个时区的「今天」(YYYY-MM-DD)。 // 跨过当地 00:00 就算新的一天,状态才会统一回到「未签到」。 -function todayInSchedTz() { +function todayInTz(tz) { try { - const parts = new Intl.DateTimeFormat('en-CA', { timeZone: SCHED_TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date()); + const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz || SCHED_TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date()); const p = {}; for (const x of parts) p[x.type] = x.value; return `${p.year}-${p.month}-${p.day}`; } catch { return ''; } } +function todayInSchedTz() { return todayInTz(SCHED_TZ); } -// 跨零点自动刷新:不刷新页面也能看到状态变回「未签到」 -let LAST_TODAY = todayInSchedTz(); +// 某个账号该按哪个时区判「今天」:站点声明了 dayTz 就按它(如糊涂鳄按 UTC, +// 北京时间 08:00 才重置),否则跟面板设置一致。 +function accountDay(a) { + const s = SITES.find((x) => x.id === a.site); + return todayInTz((s && s.dayTz) || SCHED_TZ); +} + +// 跨零点自动刷新:不刷新页面也能看到状态变回「未签到」。 +// 签名里带上每个站点的日界时区:糊涂鳄的 UTC 日界(北京时间 08:00)也会触发刷新。 +function daySignature() { + const zs = ['', ...SITES.map((s) => s.dayTz || '')]; + return zs.map((z) => todayInTz(z || SCHED_TZ)).join('|'); +} +let LAST_TODAY = daySignature(); function watchNewDay() { - const t = todayInSchedTz(); + const t = daySignature(); if (!t || t === LAST_TODAY) return false; LAST_TODAY = t; toast('已进入新的一天,签到状态已重置为「未签到」'); @@ -967,7 +1216,7 @@ async function loadSchedule() { SCHED_TZ = s.tz || 'Asia/Shanghai'; // 规范化显示:旧的 "08" 显示为 "08:00" const displayTime = s.time && s.time.includes(':') ? s.time : (s.time || '08') + ':00'; - $('sched-line').textContent = `每天 ${displayTime}(${TZ_CN[s.tz] || s.tz})自动签到 · 也可手动执行`; + $('sched-line').textContent = `每天 ${displayTime}(${tzLabel(s.tz)})自动签到 · 也可手动执行`; // 全局签到时间:紧凑胶囊(点开滚轮浮层),noFollow 因为它就是「全局」本身 if (!schedPicker) schedPicker = renderTimePicker($('sched-tp'), displayTime, { noFollow: true, large: true }); else schedPicker.set(displayTime); @@ -1029,75 +1278,62 @@ $('btn-akile-bm').onclick = async () => { // ③ 浏览器「复制为 cURL」的命令 // ④ Cookie-Editor / Netscape cookie.txt 导出的内容 // ⑤ 光秃秃的 name=value; name=value(控制台 document.cookie 拷出来的) +// 识别逻辑本身在 public/cookie-tools.js(有单测);这里只调用,不在页面里再写一份。 function parsePasteBox() { - const raw = $('ck-paste').value.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 || ''), - 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,请用扩展的「📋 一键复制全部信息」再试一次'); + return parsePasteText($('ck-paste').value); } -// 把 Cookie 串拆成一段段,并给每段中文说明(只看名字和属性,不展示值) -function splitCookieParts(cookie, cookieList) { - if (Array.isArray(cookieList) && cookieList.length) { - return cookieList.map((c) => ({ - name: String(c.name || ''), - 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) => ({ name: s.split('=')[0].trim(), note: '普通 Cookie' })) - .filter((x) => x.name); +// ---------- Cookie 解析器(只解析、不保存)---------- +// 每个 name=value 段的中文含义在 public/cookie-tools.js 里(有单测), +// 这里只负责渲染;脚本万一没加载也不把页面搞崩。 +// 一份「段类型 → 中文标签/颜色」的对照,只用于渲染;分类本身在 cookie-tools.js。 +const COOKIE_KIND_CN = { login: '登录必需', waf: '网站安全校验', csrf: '表单防护', pref: '界面偏好', stat: '统计(可忽略)', other: '未归类' }; +const COOKIE_KIND_CLS = { login: 'k-login', waf: 'k-waf', csrf: 'k-csrf', pref: 'k-pref', stat: 'k-stat', other: 'k-other' }; + +// 把 explainCookieText 的结果渲染成「每一段是什么」的列表(纯展示,不改数据) +function renderCookieAnalysis(info) { + const out = $('ck-analyze-out'); + if (!out) return; + const siteId = info.domain ? guessSiteByDomain(info.domain) : ''; + const site = SITES.find((s) => s.id === siteId); + const head = [ + ['识别为', esc(info.format || '未知格式')], + ['Web 站点', info.domain ? esc(info.domain) : '(这段内容里没带域名)'], + ['对应面板站点', site ? '' + esc(site.name) + '' : '未识别(可用「↗️ 填到上面并保存」后手动选)'], + ['总体', esc(info.summary)], + ['User-Agent', info.userAgent ? esc(String(info.userAgent).slice(0, 80)) + '…' : '(没带,面板会自动用当前浏览器 UA 兜底)'], + ].map(([k, v]) => '
' + esc(k) + '' + v + '
').join(''); + const parts = info.parts.map((p) => '
' + esc(p.name) + '' + + '' + esc(COOKIE_KIND_CN[p.kind] || '未归类') + ' ' + + esc(p.desc) + '
' + esc(p.detail) + '
').join(''); + const extra = Object.keys(info.localStorage || {}).length + ? '
localStorage' + Object.keys(info.localStorage).length + ' 项(有些站把登录 token 放这里)
' + : ''; + out.innerHTML = '
🔍 拆好了:每一段是什么
' + head + extra + '
' + + '
' + parts + '
' + + '
只拆分说明,没有保存任何东西;要真用就点上面的「↗️ 填到上面并保存」。
'; } +$('btn-ck-analyze').onclick = () => { + try { + renderCookieAnalysis(explainCookieText($('ck-analyze').value)); + } catch (e) { + const out = $('ck-analyze-out'); + if (out) out.innerHTML = '
✗ 没看懂这段内容
' + esc(e && e.message ? e.message : String(e)) + + '
支持:扩展「一键复制全部信息」的 JSON、F12 复制出来的请求头、右键「复制为 cURL」、cookie.txt、光秃秃的 name=value 串。
'; + } +}; + +// 「填到上面并保存」:解析结果直接进主粘贴框,然后走原来的自动建号流程 +$('btn-ck-analyze-fill').onclick = () => { + try { + const raw = $('ck-analyze').value.trim(); + if (!raw) return toast('请先把内容粘到解析框里'); + $('ck-paste').value = raw; + $('btn-parse-paste').click(); + } catch (e) { toast('解析失败:' + (e && e.message ? e.message : e)); } +}; + // 渲染「中文分解」面板:这个框告诉你面板到底读到了什么、每一段是什么 function renderPasteBreakdown(info, status) { const el = $('ck-preview'); @@ -1418,6 +1654,7 @@ async function initMain() { await loadSchedule(); // 先加载全局时间,供账号列表的"跟随全局"显示 await refreshExtState(); // 扩展在线状态:首页右上角展示,并决定能否切「本地网络」 await loadAccounts(); + await loadCommunity(); // 社区站点:列表 + 导出下拉(失败也不影响主流程) checkCookieHash(); } @@ -1432,7 +1669,7 @@ let ACCTS = []; // 站点级推荐说明:只在当前模式不是本地网络时显示,提醒用户某站必须用本机网络 // (CF 机房 IP 会被这些站点的 WAF / IPv6 要求直接挡下) const SITE_MODE_HINT = { - wuaipojie: '吾爱破解屏蔽了 CF 网络(403 UrlACL),必须用「本地网络」;本地网络需安装并打开浏览器扩展', + wuaipojie: '吾爱破解主站被网宿 WAF 保护:脚本化请求(含本地中继)整站读不到,已自动改用「浏览器导航签到」——点下面的「🌐 浏览器签到」立即执行,或直接点「执行」(也会自动走这条路)', nodeseek: 'NodeSeek 登录有人机验证、CF 网络又没有 IPv6,请切「本地网络」', hutue: '糊涂鳄对机房 IP 更严格,建议切「本地网络」', kanxue: '看雪建议用「本地网络」(CF 网络常被拦)', @@ -1458,15 +1695,57 @@ function execCellHTML(a, execOverride) { // 站点级推荐(如吾爱破解只能走本地网络),只在当前不是本地网络时提醒,避免添乱 const hint = !isLocalExec && SITE_MODE_HINT[a.site] ? SITE_MODE_HINT[a.site] : ''; const pills = togglePills(a); + // 社区导入的站点:名字旁挂一个绿标,鼠标移上去看出处(作者 / 版本 / 来源) + const com = site && site.community; + const comTip = com + ? `社区共享的站点适配:${com.author ? '作者 ' + com.author : '匿名'}${com.version ? ' · v' + com.version : ''}` + + (com.source ? `\n来源:${com.source}` : '\n在「社区站点」卡片里可以删除或重新导入') + : ''; + const comBadge = com ? `社区` : ''; + // 支持「浏览器导航签到」的站点(如吾爱破解):给一个手动触发入口。 + // 它的意义在于:该站点的脚本化请求会被 WAF 整站拦死,只有真实页导航能签上。 + const bsign = site && site.hasBrowserJob + ? `` + : ''; return { isLocalExec, siteHint: hint, - html: `
${esc(a.name)}` + html: `
${esc(a.name)}${comBadge}` + `` - + `
${execBadge}
${pills}
`, + + `
${execBadge}
${bsign}${pills}
`, }; } +// ---------- 网站反馈:尽量展示「网站原话」---------- +// 我们的站点模块有时会把网站的说法归纳成套话(「今日已签到,不能重复签到」), +// 但用户要看的是**网站到底说了什么**。所以: +// ① 网站原文里的 \u 转义先还原成人话(糊涂鳄返回的就是全转义 JSON) +// ② 当主文案是我们自己的套话、而原文里能读到网站自己的 message 时,改用网站原话 + +// 还原 \u4eca\u65e5 这类 JSON 转义 +function decodeEscapes(s) { + return String(s == null ? '' : s) + .replace(/\\u([0-9a-fA-F]{4})/g, (_, h) => String.fromCharCode(parseInt(h, 16))) + .replace(/\\\//g, '/'); +} + +// 我们自己的套话(不含任何站点信息,只有判定) +const BOILERPLATE_MSG = [ + '今日已签到,不能重复签到', '今日已签到,无需重复', '今日已签到,请明日再来', + '今日已签到', '签到成功', '签到失败', '请求成功', '领取成功', '签到完毕', +]; + +// 从网站原文里抠出网站自己的那句话(JSON 的 message / msg / errmsg / status_msg…) +function siteOwnMessage(raw) { + const m = String(raw || '').match(/"(?:message|msg|errmsg|info|status_msg)"\s*:\s*"((?:[^"\\]|\\.)*)"/i); + if (!m) return ''; + let s = decodeEscapes(m[1]).replace(/\s+/g, ' ').trim(); + // 太短(如单个「0」)或太长(整段 HTML)都不当反馈 + if (s.length < 4 || s.length > 120) return ''; + if (BOILERPLATE_MSG.includes(s)) return ''; + return s; +} + // 网站反馈的「怎么办」提示:根据回传的原始文本判断失败类型,给一句可执行的中文建议。 function feedbackHint(msg, siteId, isLocalExec) { const m = String(msg || ''); @@ -1479,14 +1758,34 @@ function feedbackHint(msg, siteId, isLocalExec) { if (/人机验证|验证码|captcha|turnstile|请先完成验证/i.test(m)) { return '建议:该网站需要人工验证,请在浏览器里登录/过一次验证,再点扩展图标重新获取 Cookie'; } + // 「结果未知」要在「已签到」那条之前判:它的文案里会提到「若已签到网站会直接回今日已签到」, + // 否则会被下面那条当成「已经签过了」而什么都不提示。 + if (/结果未知/.test(m)) { + return '建议:这次请求已经发出去了、但没等到网站回包,签到**可能已经生效** —— 面板会自动复核(约 15 分钟后);' + + '想立刻确认就过一分钟再点一次「执行」,若已签到,网站会直接回「今日已签到」'; + } if (/最少等待|一天只能|已签到|重复签到/.test(m)) return ''; if (/Cookie.*(失效|过期)|未登录|login required|请先登录/i.test(m)) { return isLocalExec ? '建议:浏览器里登录该网站后,点扩展图标「一键复制 Cookie」,回面板粘贴保存' : '建议:Cookie 已过期,请重新登录后再复制一次 Cookie;若仍失败可切「本地网络」'; } + // 站点自己的按钮写着「打卡签到」= 站点说今天还没签上(比从超时去猜可靠得多) + if (/没有生效|打卡签到|未签到/.test(m)) { + return siteId === 'wuaipojie' + ? '说明:网站顶部那个按钮仍是「打卡签到」,今天确实还没签上。' + + '吾爱破解的签到接口受网宿 WAF 保护,用「本地网络」(中继)时常把连接吊死;' + + '请在浏览器打开 www.52pojie.cn 手动点一次那个按钮(可能要先过滑块),然后再回面板执行一次就会读到「签到完毕」' + : '说明:网站按钮显示还没签到。请在浏览器里手动点一次签到按钮,再回面板执行一次'; + } if (/中继执行超时|超时|timed? ?out/i.test(m)) { - return '建议:请确认浏览器扩展在线(点扩展图标看连接状态),并保持该网站的标签页可以正常打开'; + return '建议:这次请求超时了,网站可能实际已经签到成功 —— 先刷新网站看一下顶部按钮:' + + '显示「签到完毕」就是签上了;再确认浏览器扩展在线(点扩展图标看连接状态)'; + } + // 糊涂鳄按 UTC 计日(北京时间 08:00 才重置),不解释一句很容易被当成「假签到」 + if (siteId === 'hutue' && /已签到|明日再来|重复/.test(m)) { + return '说明:糊涂鳄按 UTC 计日 —— 北京时间 08:00 才算它的一天,所以 08:00 之前它都会回「今日已签到」。' + + '如果你在网站里点签到还能领到积分,把「签到时间」设到 08:00 之后(例如 08:05)就能每天自动领到'; } if (/扩展(离线|不在线)|中继不可用|relay unavailable/i.test(m)) { return '建议:打开浏览器、确认扩展已安装并处于在线状态;中继任务由扩展接单执行'; @@ -1537,31 +1836,45 @@ async function loadAccounts() { execOverride = m.execution || ''; lastSigninDate = m.last_signin_date || ''; } catch { /* 忽略 */ } - // 今日是否已签到:比较面板设置时区下的日期(跨零点自动回到「未签到」) - const today = todayInSchedTz(); - const signedToday = lastSigninDate === today; + // 今日是否已签到:按该账号所属站点的日界时区比较日期 + // (跨零点自动回到「未签到」;糊涂鳄按 UTC 计日,北京时间 08:00 才算新的一天) + const signedToday = lastSigninDate === accountDay(a); + // 「结果未知」(请求发出去了但没等到回包):既没凭据说「已签到」,也不该武断说「未签到」—— + // 网站很可能已经签了。单独给一个「待确认」,鼠标移上去说清怎么核实。 + const unknown = !signedToday && a.last_status === 'skip' && /结果未知/.test(String(a.last_msg || '')); const statusBadge = signedToday ? '已签到' - : '未签到'; + : unknown + ? '待确认' + : '未签到'; const { html: execCell, isLocalExec, siteHint } = execCellHTML(a, execOverride); const rawMsg = String(a.last_msg || ''); // 旧版本遗留的提示文案(已无意义),不展示 const msg = (rawMsg.includes('浏览器模式') || rawMsg.includes('请确保扩展')) ? '' : rawMsg; - // 网站原文(站点模块回传的 detail):让人能直接对照网站到底说了什么 - const rawText = String(a.last_detail || '').replace(/^网站返回:/, '').trim(); - const rawShow = rawText && rawText !== msg ? rawText.slice(0, 220) : ''; + // 网站原文(站点模块回传的 detail):让人能直接对照网站到底说了什么。 + // \uXXXX 转义先还原成人话(糊涂鳄回的是一整串转义 JSON,直接展示没法看)。 + const rawText = decodeEscapes(String(a.last_detail || '')).replace(/^网站返回:/, '').trim(); + // 主文案:如果只有我们的套话,而原文里能读到网站自己的 message,就换用网站原话 + let msgShow = msg; + let bySite = false; + if (!msg || BOILERPLATE_MSG.includes(msg.trim())) { + const own = siteOwnMessage(rawText); + if (own && own !== msg) { msgShow = own; bySite = true; } + } + const rawShow = rawText && rawText !== msgShow ? rawText.slice(0, 220) : ''; // 红标提示要拿「反馈 + 网站原文」一起判: // 比如吾爱的 message 只有「HTTP 403」,真正能认出是网宿 UrlACL 的关键词在原文里。 // 站点级推荐(如糊涂鳄建议切本地网络)也统一放到网站反馈后面 - let hintText = feedbackHint(msg + '\n' + rawText, a.site, isLocalExec); + let hintText = feedbackHint((bySite ? msg + '\n' : '') + msgShow + '\n' + rawText, a.site, isLocalExec); if (siteHint) hintText = (hintText ? hintText + '\n' : '') + '推荐:' + siteHint; return ` ${execCell}
${statusBadge} -
${msg ? esc(msg) : '-'}${hintText ? `!` : ''}
${rawShow ? `
${esc(rawShow)}
` : ''} +
${msgShow ? esc(msgShow) : '-'}${bySite ? '网站原话' : ''}
${rawShow ? `
${esc(rawShow)}
` : ''}${hintText ? `
!怎么办
` : ''} ${fmtTime(a.last_run_at)} +
@@ -1599,6 +1912,7 @@ async function loadAccounts() { refreshCkSelect(); refreshLogAccountFilter(); + refreshExportAccounts(); // 「导出为社区配置」的下拉跟着账号列表走 } // 给一定范围内的行按钮绑定事件。单独抽出来是因为「就地更新一行」之后也要重新绑定。 @@ -1622,6 +1936,22 @@ function bindRowActions(tb, accounts) { applyExecToRow(id); })); + // 🌐 浏览器签到:让扩展打开该站的签到页(适合脚本化请求被 WAF 整站拦死的站点) + tb.querySelectorAll('[data-bsign]').forEach((b) => (b.onclick = async () => { + const id = b.dataset.bsign; + const orig = b.innerHTML; + b.disabled = true; b.textContent = '已交给浏览器…'; + try { + const r = await api('/api/accounts/' + id + '/browser-sign', 'POST'); + toast(r.hint || '已交给浏览器执行,稍后自动写回结果', 4200); + // 任务已入队,但对扩展是秒级窗口;给个提示后重新拉一次列表 + setTimeout(loadAccounts, 1500); + } catch (e) { + toast('浏览器签到失败:' + e.message, 3600); + b.disabled = false; b.innerHTML = orig; + } + })); + tb.querySelectorAll('[data-toggle]').forEach((b) => (b.onclick = async () => { const id = b.dataset.toggle; const acc = (await api('/api/accounts/' + id)).account; @@ -1932,7 +2262,7 @@ $('btn-probe').onclick = async () => { }; async function openModal(id) { - $('f-site').innerHTML = SITES.map((s) => ``).join(''); + renderModalSites(); HTTP_MODE = 'single'; HTTP_STEPS = []; if (id) { @@ -1985,6 +2315,224 @@ $('btn-save').onclick = async () => { } catch (e) { toast(e.message); } }; +// ---------- 社区站点(开源共享):导入 / 导出 / 删除 ---------- +// 一段配置长得什么样,面板要给人看懂:站点名、作者、版本、要填几个字段、几步请求。 +function communitySummary(cfg) { + if (!cfg) return ''; + const fields = (cfg.fields || []).map((f) => f.label || f.key).join('、') || '无(不需填任何东西)'; + const steps = (cfg.steps || []).map((st, i) => `${i + 1}. ${esc(st.name || '步骤')} ${esc((st.method || 'GET').toUpperCase())} ${esc(st.url || '')}`).join('
'); + return ` + + + + + + ${cfg.tips ? `` : ''} +
站点${esc(cfg.name || '-')} ${cfg.domain ? `(${esc(cfg.domain)})` : ''}
作者 / 版本${esc(cfg.author || '匿名')}${cfg.version ? ' · v' + esc(cfg.version) : ''}
内码 id${esc(cfg.id || '-')}
要填的字段${esc(fields)}
请求步骤${steps || '-'}
使用说明${esc(cfg.tips)}
`; +} + +function communityMsg(el, cls, html) { + el.innerHTML = html ? `
${html}
` : ''; +} + +async function loadCommunity() { + let list = []; + try { + const r = await api('/api/sites/community'); + list = r.sites || []; + if (r.author && !$('com-author').value) $('com-author').value = r.author; + } catch (e) { + $('com-list').innerHTML = `
读取失败:${esc(e.message)}
`; + return; + } + const box = $('com-list'); + if (!list.length) { + box.innerHTML = '
还没有导入任何社区站点。把别人分享的 JSON 粘到上面导入,或把自己的账号导出后一起去仓库里共建。
'; + return; + } + box.innerHTML = list.map((s) => { + const steps = (s.def && s.def.steps ? s.def.steps.length : 0); + return `
+ 社区 +
${esc(s.name)} +
${s.def && s.def.domain ? esc(s.def.domain) + ' · ' : ''}${esc(s.author || '匿名')}${s.version ? ' · v' + esc(s.version) : ''} · ${steps} 步请求${s.source ? ` · 来源` : ''}
+
+ + + +
`; + }).join(''); + box.querySelectorAll('[data-cact]').forEach((b) => (b.onclick = () => communityAct(b.dataset.cact, b.closest('[data-com]').dataset.com))); +} + +async function communityAct(act, id) { + const out = $('com-out'); + if (act === 'del') { + if (!confirm('删除社区站点「' + id + '」?\n用它的账号会变成未知站点(账号本身不会被删,重新导入同一份配置即可恢复)。')) return; + try { await api('/api/sites/community/' + encodeURIComponent(id), 'DELETE'); toast('已删除'); await reloadSites(); await loadCommunity(); } + catch (e) { toast(e.message); } + return; + } + let cfg; + try { + const r = await api('/api/sites/community/' + encodeURIComponent(id)); + cfg = r.config; + } catch (e) { return toast(e.message); } + const text = JSON.stringify(cfg, null, 2); + out.value = text; + out.classList.remove('hidden'); + $('btn-com-copy').style.display = ''; + $('btn-com-download').style.display = ''; + $('btn-com-download').dataset.name = cfg.id; + communityMsg($('com-msg2'), 'ok', `${esc(cfg.name)} 的配置(可以直接复制去分享):${communitySummary(cfg)}`); + if (act === 'copy') copyCommunityOut(); + out.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); +} + +async function copyCommunityOut() { + const v = $('com-out').value; + if (!v) return toast('还没有可复制的内容'); + try { await navigator.clipboard.writeText(v); toast('已复制,粘到 GitHub Issue / 聊天窗就能分享'); } + catch { toast('复制失败:请手动全选复制'); } +} + +async function reloadSites() { + const { sites } = await api('/api/sites'); + SITES = sites; + await loadAccounts(); + refreshExportAccounts(); + renderModalSites(); +} + +// 「导出我的配置」下拉:列出可以导出的账号(自定义 HTTP / 已导入的社区站点) +function refreshExportAccounts() { + const sel = $('com-export-account'); + if (!sel) return; + const cur = sel.value; + const ok = ACCTS.filter((a) => a.site === 'http' || (SITES.find((s) => s.id === a.site) || {}).community); + sel.innerHTML = ok.length + ? ok.map((a) => ``).join('') + : ''; + if (ok.some((a) => String(a.id) === cur)) sel.value = cur; +} + +// 站点下拉里把社区站点标出来,并保留当前选中项 +function renderModalSites() { + const sel = $('f-site'); + if (!sel) return; + const cur = sel.value; + sel.innerHTML = SITES.map((s) => { + const tag = s.community ? `(🌍 社区${s.community.author ? ' · ' + s.community.author : ''})` : ''; + return ``; + }).join(''); + if (cur && SITES.some((s) => s.id === cur)) sel.value = cur; +} + +$('btn-com-check').onclick = async () => { + const raw = $('com-paste').value.trim(); + const url = $('com-url').value.trim(); + if (!raw && !url) return toast('先粘贴配置 JSON,或填一个配置链接'); + const msg = $('com-msg'); + communityMsg(msg, 'warn', '正在校验…'); + try { + const r = await api('/api/sites/community', 'POST', { config: raw || undefined, url: url || undefined, dry_run: 1 }); + const conf = r.config || {}; + if (!r.ok) { + communityMsg(msg, 'bad', '这份配置还不能导入:
' + (r.errors || []).map(esc).join('
')); + return; + } + const warn = (r.warnings || []).length ? `
⚠️ ${(r.warnings || []).map(esc).join('
⚠️ ')}
` : ''; + communityMsg(msg, 'ok', '校验通过,可以导入:' + communitySummary(conf) + warn); + } catch (e) { communityMsg(msg, 'bad', '校验失败:' + esc(e.message)); } +}; + +$('btn-com-import').onclick = async (e) => { + const raw = $('com-paste').value.trim(); + const url = $('com-url').value.trim(); + if (!raw && !url) return toast('先粘贴配置 JSON,或填一个配置链接'); + const msg = $('com-msg'); + const btn = e.target; + btn.disabled = true; btn.textContent = '导入中…'; + try { + const payload = { config: raw || undefined, url: url || undefined }; + let r; + try { + r = await api('/api/sites/community', 'POST', payload); + } catch (err) { + // 已经导入过:问一句再覆盖 + if (!/已经导入过/.test(err.message)) throw err; + if (!confirm(err.message + '\n\n用这份新配置覆盖它?')) throw err; + r = await api('/api/sites/community', 'POST', { ...payload, overwrite: true }); + } + communityMsg(msg, 'ok', `✅ 已导入「${esc(r.name)}」(id: ${esc(r.id)})。现在去「添加账号」里选它,填好 Cookie 即可签到。`); + $('com-paste').value = ''; $('com-url').value = ''; + await reloadSites(); + await loadCommunity(); + } catch (err) { + communityMsg(msg, 'bad', '导入失败:' + esc(err.message)); + } finally { + btn.disabled = false; btn.textContent = '⬇️ 导入'; + } +}; + +$('btn-com-export').onclick = async (e) => { + const sel = $('com-export-account'); + const id = sel && sel.value; + const msg = $('com-msg2'); + const out = $('com-out'); + if (!id) return toast('先在「自定义 HTTP」里录一个账号,再回来导出'); + const btn = e.target; + btn.disabled = true; btn.textContent = '导出中…'; + try { + const author = $('com-author').value.trim(); + if (author) await api('/api/sites/community/author', 'PUT', { author }).catch(() => {}); + const q = new URLSearchParams(); + // 先按账号名签一个默认 id/name,用户导入的人看到的就是这个 + const acc = ACCTS.find((a) => String(a.id) === String(id)); + if (acc) q.set('name', acc.name); + const r = await api('/api/accounts/' + id + '/export-config?' + q.toString()); + if (r.ok === false && (r.errors || []).length) { + // 导出的配置自己没通过校验(作者必填项没写全之类)——把原因摊开,不假装成功 + communityMsg(msg, 'warn', '导出了,但配置还有问题,先改好再分享:
' + (r.errors || []).map(esc).join('
')); + out.value = JSON.stringify(r.config, null, 2); + out.classList.remove('hidden'); + $('btn-com-copy').style.display = ''; + $('btn-com-download').style.display = ''; + $('btn-com-download').dataset.name = r.config.id; + return; + } + out.value = JSON.stringify(r.config, null, 2); + out.classList.remove('hidden'); + $('btn-com-copy').style.display = ''; + $('btn-com-download').style.display = ''; + $('btn-com-download').dataset.name = r.config.id; + const warn = (r.warnings || []).length ? `
⚠️ ${(r.warnings || []).map(esc).join('
⚠️ ')}
` : ''; + communityMsg(msg, 'ok', '导出成功,底部就是可分享的配置:复制或存成 .json,发到仓库的 Issue / PR 即可。' + + communitySummary(r.config) + warn); + out.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } catch (err) { + communityMsg(msg, 'bad', esc(err.message)); + } finally { + btn.disabled = false; btn.textContent = '📤 导出为社区配置'; + } +}; + +$('btn-com-copy').onclick = copyCommunityOut; +$('btn-com-download').onclick = () => { + const v = $('com-out').value; + if (!v) return toast('还没有内容可下载'); + const name = ($('btn-com-download').dataset.name || 'community-site') + '.json'; + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([v], { type: 'application/json' })); + a.download = name; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 3000); + toast('已保存 ' + name); +}; +$('com-author').onchange = () => { + api('/api/sites/community/author', 'PUT', { author: $('com-author').value.trim() }).catch(() => {}); +}; + // ---------- 日志 ---------- function refreshLogAccountFilter() { const sel = $('log-filter-account'); @@ -2067,7 +2615,7 @@ async function refreshExtState() { } if (boxState) { boxState.className = RELAY_ONLINE ? 'badge ok' : 'badge off'; - boxState.textContent = RELAY_ONLINE ? '已安装 · 在线' : '未检测到(点这里看状态)'; + boxState.textContent = RELAY_ONLINE ? '已安装 · 在线' : '未安装 / 未开启'; } return RELAY_ONLINE; } diff --git a/src/community.js b/src/community.js new file mode 100644 index 0000000..5cb1d23 --- /dev/null +++ b/src/community.js @@ -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 }; +} diff --git a/src/db.js b/src/db.js index 44e125a..859209e 100644 --- a/src/db.js +++ b/src/db.js @@ -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) { diff --git a/src/ext-files.js b/src/ext-files.js index b4828a0..fd60d1d 100644 --- a/src/ext-files.js +++ b/src/ext-files.js @@ -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 \"\"\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": "\n\n\n\n\n\n\n

🍪 cookie插件2.2

\n
正在读取当前网站…
\n \n \n \n \n \n \n \n \n
\n \n\n\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(${stat.domains} 个域 / ${stat.httpOnly} 个 HttpOnly)` + (lsCount ? ` + ${lsCount} 项 localStorage` : '')\n : '该网站没有 Cookie',\n cookies.length ? 'ok' : 'err'\n );\n}\n\nfunction cookieString() {\n return cookies.map((c) => `${c.name}=${c.value}`).join('; ');\n}\n\nfunction fullPayload() {\n return JSON.stringify({\n domain,\n userAgent: window._pageUA || '',\n cookies: cookieString(),\n // 附带可读的分解信息,面板据此清晰展示:每个 Cookie 的名称/域/是否 HttpOnly\n cookieList: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain,\n path: c.path,\n secure: !!c.secure,\n httpOnly: !!c.httpOnly,\n sameSite: c.sameSite || '',\n session: !!c.session,\n expirationDate: c.expirationDate || null,\n })),\n localStorage: window._localStore || {},\n stats: describeCookies(cookies),\n ts: Date.now(),\n });\n}\n\n$('btn-copy').onclick = async () => {\n if (!cookies.length) return status('没有可复制的 Cookie', 'err');\n // 复制完整 JSON:面板粘贴后自动解析出域名、UA、Cookie\n await navigator.clipboard.writeText(fullPayload());\n status(`已复制 ${cookies.length} 个 Cookie + UA,去面板「粘贴扩展内容」粘贴即可`, 'ok');\n};\n\n$('btn-send').onclick = async () => {\n if (!cookies.length) return status('没有可发送的 Cookie', 'err');\n let panelUrl = $('panel-url').value.trim().replace(/\\/$/, '');\n if (!panelUrl) return status('请先填写签到面板地址', 'err');\n if (!panelUrl.startsWith('http')) panelUrl = 'https://' + panelUrl;\n await chrome.storage.sync.set({ panelUrl });\n\n // 把 Cookie + UA + 域名编码进 URL hash,面板 JS 读取后自动弹出确认框\n // 用 base64url 编码(+/= 替换为 -_.),避免特殊字符在地址栏被转义或截断\n const b64 = btoa(unescape(encodeURIComponent(fullPayload())));\n const payload = b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n chrome.tabs.create({ url: panelUrl + '#ext-cookies=' + payload });\n status('已打开面板,请在面板中确认保存', 'ok');\n};\n\n$('panel-url').oninput = () => {\n chrome.storage.sync.set({ panelUrl: $('panel-url').value.trim().replace(/\\/$/, '') });\n};\n\n$('api-key').oninput = () => {\n chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });\n};\n\n// 兼容旧的 onchange(保留)\n$('panel-url').onchange = () => {\n chrome.storage.sync.set({ panelUrl: $('panel-url').value.trim().replace(/\\/$/, '') });\n};\n\n$('api-key').onchange = () => {\n chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });\n};\n\n// 立即开始中继(让后台马上领任务)。\n// 后台常驻长轮询(挂起 20 秒),所以这里点一下等于“立刻唤醒”一次,\n// 配合面板端的「执行」,签到请求会在 1 秒内被扩展领走,不用再等 alarm 周期。\n$('btn-run-now').onclick = async () => {\n try {\n await chrome.runtime.sendMessage({ action: 'runRelayNow' });\n status('已唤醒中继,正在实时接单…去面板点「执行」即可', 'ok');\n chrome.alarms.create('relay-poll', { periodInMinutes: 0.5 });\n } catch (e) {\n status('唤醒失败:' + (e.message || e) + '(可重新加载扩展后重试)', 'err');\n }\n};\n\n// 原生页面巡检:不依赖 eval / new Function(MV3 禁止),用于读取网站真实反馈。\nasync function inspectPageInPopup(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 // 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))) return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|页面:' + title };\n const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'Access Denied', '403 Forbidden', '请求被拦截'];\n if (WAF.some((m) => all.includes(m))) return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };\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 重复申请会返回「您已完成过此任务」)\n if (/今日已签到|今天已签到|已经签到|已签到|重复签到|请勿重复|无需重复|下期再来|已完成过此任务|已领取/.test(all)) return { ok: true, message: '今日已签到,无需重复' };\n // 4) 未登录\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录/.test(all)) return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };\n },\n });\n return (results && results[0] && results[0].result) || null;\n}\n\n// 尝试执行面板下发的脚本字符串(MV3 下通常失效,失败时回退原生巡检)。\nasync function runPanelScriptInPopup(tabId, job) {\n const execPromise = chrome.scripting.executeScript({\n target: { tabId },\n func: (scriptStr, params) => {\n const fn = new Function('params', `return (${scriptStr})(params)`);\n return fn(params);\n },\n args: [job.script, job.params || {}],\n });\n const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('脚本执行超时(60秒)')), 60000));\n const results = await Promise.race([execPromise, timeoutPromise]);\n return (results && results[0] && results[0].result) || null;\n}\n\n// 在弹窗上下文中执行单个签到任务(复用目标域名标签页,携带用户 Cookie)\n// onStep: 步骤回调,用于显示详细进度\nasync function executeJobInPopup(job, onStep) {\n const startMs = Date.now();\n const step = (msg) => { if (onStep) onStep(msg); };\n let tab = null;\n let created = false;\n try {\n if (!job.domain) throw new Error('任务缺少目标域名');\n step('查找标签页…');\n const tabs = await chrome.tabs.query({ url: `*://${job.domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n step('复用已有标签页…');\n } else {\n step('打开新标签页…');\n tab = await chrome.tabs.create({ url: `https://${job.domain}/`, active: false });\n created = true;\n step('等待页面加载…');\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 15000);\n });\n }\n // 如果任务指定了导航 URL(如吾爱破解),先导航到该页面(模拟手动点击),再执行检查脚本\n if (job.navigate_url) {\n step('导航到签到页面…');\n await chrome.tabs.update(tab.id, { url: job.navigate_url });\n step('等待签到页面加载…');\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n // 多等 2 秒,让页面内跳转完成\n await new Promise((r) => setTimeout(r, 2000));\n }\n // MV3 禁止 new Function / eval:有 navigate_url 直接原生巡检,否则先试面板脚本再回退。\n step('读取页面结果…');\n let result = null;\n if (!job.navigate_url && job.script) {\n try { result = await runPanelScriptInPopup(tab.id, job); } catch { result = null; }\n }\n if (!result) result = await inspectPageInPopup(tab.id);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n step('解析结果…');\n return {\n status: result.ok ? 'ok' : 'fail',\n message: String(result.message || (result.ok ? '签到成功' : '签到失败')),\n durationMs: Date.now() - startMs,\n };\n } catch (e) {\n return { status: 'fail', message: '浏览器执行失败:' + (e.message || e), durationMs: Date.now() - startMs };\n } finally {\n // 只关闭自己新建的标签页(复用的保留)\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 面板连接检查:验证面板地址和 API Key 是否可用\n$('btn-check-conn').onclick = async () => {\n status('正在检查面板连接…', '');\n try {\n let panelUrl = $('panel-url').value.trim().replace(/\\/$/, '');\n if (!panelUrl) return status('请先填写签到面板地址', 'err');\n if (!panelUrl.startsWith('http')) panelUrl = 'https://' + panelUrl;\n const apiKey = $('api-key').value.trim();\n if (!apiKey) return status('请先填写 API Key(面板设置页获取)', 'err');\n\n // ① 检查面板是否可访问(用专用 ping 接口,不消费任务)\n let resp;\n try {\n resp = await fetch(panelUrl + '/api/external/ping', {\n headers: { 'X-Api-Key': apiKey },\n });\n } catch (e) {\n return status('连接失败:面板地址无法访问(' + (e.message || '网络错误') + ')', 'err');\n }\n // ② 检查 API Key 是否有效\n if (resp.status === 401) return status('连接失败:API Key 无效,请去面板设置页重新生成', 'err');\n if (!resp.ok) return status('连接失败:面板返回 HTTP ' + resp.status, 'err');\n // ③ 解析任务列表\n let data;\n try { data = await resp.json(); } catch { return status('连接失败:面板返回数据格式错误', 'err'); }\n status(`连接正常 ✅ API Key 有效(面板版本 ${data.version || '未知'})`, 'ok');\n } catch (e) {\n status('检查失败:' + (e.message || e), 'err');\n }\n};\n\ninit();\n", - "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 重复申请会返回「您已完成过此任务」)\n if (/今日已签到|今天已签到|已经签到|已签到|重复签到|请勿重复|无需重复|下期再来|已完成过此任务|已领取/.test(all)) {\n return { ok: true, message: '今日已签到,无需重复' };\n }\n // 4) 未登录\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录/.test(all)) {\n return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n }\n return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };\n },\n });\n return (results && results[0] && results[0].result) || null;\n}\n\n// 尝试执行面板下发的脚本字符串。\n// 注意:MV3 禁止 new Function / eval,多数情况下这里会失败——失败时由调用方回退到原生巡检。\nasync function runPanelScript(tabId, job) {\n const execPromise = chrome.scripting.executeScript({\n target: { tabId },\n func: (scriptStr, params) => {\n const fn = new Function('params', `return (${scriptStr})(params)`);\n return fn(params);\n },\n args: [job.script, job.params || {}],\n });\n const timeoutPromise = new Promise((_, reject) =>\n setTimeout(() => reject(new Error('脚本执行超时(60秒)')), 60000)\n );\n const results = await Promise.race([execPromise, timeoutPromise]);\n return (results && results[0] && results[0].result) || null;\n}\n\n// 在目标域名的标签页中执行签到脚本\nasync function executeJob(job) {\n const startMs = Date.now();\n let tab = null;\n let created = false;\n try {\n // 与 popup 保持一致:缺域名时直接报错,避免误开 https://undefined/ 标签页\n if (!job.domain) throw new Error('任务缺少目标域名');\n // 找一个该域名的已存在标签页,复用;没有则新建隐藏标签页\n const tabs = await chrome.tabs.query({ url: `*://${job.domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n } else {\n tab = await chrome.tabs.create({ url: `https://${job.domain}/`, active: false });\n created = true;\n // 等待页面加载完成\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 15000); // 超时保护\n });\n }\n\n // 如果任务指定了导航 URL(如吾爱破解),先导航到该页面(模拟手动点击),再执行检查脚本\n if (job.navigate_url) {\n await chrome.tabs.update(tab.id, { url: job.navigate_url });\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n await new Promise((r) => setTimeout(r, 2000));\n }\n\n // 执行签到。\n // MV3 禁止 new Function / eval,面板下发的脚本字符串通常无法执行;\n // 因此:有 navigate_url 的任务直接对目标页做原生巡检;否则先试面板脚本,拿不到结果就回退原生巡检。\n let result = null;\n if (!job.navigate_url && job.script) {\n try {\n result = await runPanelScript(tab.id, job);\n } catch (e) {\n console.log('[签到面板] 面板脚本执行失败,回退原生巡检:', e.message || e);\n result = null;\n }\n }\n if (!result) result = await inspectPage(tab.id);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n\n return {\n status: result.ok ? 'ok' : 'fail',\n message: String(result.message || (result.ok ? '签到成功' : '签到失败')),\n durationMs: Date.now() - startMs,\n };\n } catch (e) {\n return {\n status: 'fail',\n message: '浏览器执行失败:' + (e.message || e),\n durationMs: Date.now() - startMs,\n };\n } finally {\n // 只关闭“我们自己新建”的标签页(复用的、用户正在看的保留),避免每次签到累积一堆隐藏标签页\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 主流程:获取任务 → 逐个执行 → 上报\nasync function runJobs() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) {\n console.log('[签到面板] 未配置面板地址或 API Key,跳过');\n return;\n }\n\n let jobs;\n try {\n jobs = await fetchJobs(panelUrl, apiKey);\n } catch (e) {\n console.log('[签到面板] 获取任务失败:', e.message);\n return;\n }\n\n if (!jobs.length) {\n console.log('[签到面板] 暂无待执行任务');\n return;\n }\n\n console.log(`[签到面板] 获取到 ${jobs.length} 个任务,开始执行`);\n for (const job of jobs) {\n console.log(`[签到面板] 执行:${job.site_name}(账号 ${job.account_id})`);\n const r = await executeJob(job);\n console.log(`[签到面板] 结果:${r.status} - ${r.message}`);\n await reportResult(panelUrl, apiKey, job.account_id, r.status, r.message, r.durationMs);\n // 任务之间稍作间隔\n await new Promise((r) => setTimeout(r, 2000));\n }\n}\n\n// ============ 本地网络中继代理 ============\n// Worker 把 HTTP 请求存入面板队列,扩展用用户本地网络执行后回传响应。\n// 这样 Worker 端的站点逻辑可以使用用户本地 IP,绕过 CF/Worker IP 限制。\n\n// base64 ↔ Uint8Array\nfunction b64ToBytes(b64) {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n}\nfunction bytesToB64(bytes) {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n}\n\n// 获取待执行的中继任务(长轮询:没有任务时挂起,一有任务立刻返回)\nasync function fetchRelayJobs(panelUrl, apiKey, waitMs = RELAY_LONGPOLL_MS) {\n // 带上扩展版本(?v=),面板可以显示「扩展在线 · v2.2」并提醒版本陈旧\n let ver = '';\n try { ver = (chrome.runtime.getManifest() || {}).version || ''; } catch { /* 忽略 */ }\n const resp = await fetch(panelUrl + '/api/external/relay-pending?wait=' + waitMs + (ver ? '&v=' + encodeURIComponent(ver) : ''), {\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 submitRelayResult(panelUrl, apiKey, jobId, result) {\n await fetch(panelUrl + '/api/external/relay/' + jobId + '/result', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify(result),\n }).catch(() => { /* 忽略 */ });\n}\n\n// 在目标域名的页面上下文中执行单个 HTTP 请求(携带用户 Cookie)\nasync function executeRelayJob(job) {\n const url = new URL(job.url);\n const domain = url.hostname;\n let tab = null;\n let created = false;\n try {\n const tabs = await chrome.tabs.query({ url: `*://${domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n } else {\n tab = await chrome.tabs.create({ url: `https://${domain}/`, active: false });\n created = true;\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 8000); // 建标签页只为拿登录态,页面没加载完也照样能发请求,不必等满\n });\n }\n\n const results = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: async (url, method, headers, bodyB64, options, timeoutMs) => {\n const b64ToBytes = (b64) => {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n };\n const bytesToB64 = (bytes) => {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n };\n const init = {\n method,\n headers,\n credentials: 'include', // 始终携带用户 Cookie\n // 页面里拿不到 opaqueredirect 的响应头,manual 等于白跑一轮,统一 follow\n redirect: options.redirect === 'manual' ? 'follow' : (options.redirect || 'follow'),\n };\n if (bodyB64) init.body = b64ToBytes(bodyB64);\n // 页面内自超时:即便站点把连接吊死,也要抛出错误而不是让整个任务卡到硬超时\n // (AbortController 在某些受限环境里不存在,存在性判断不能省)\n const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;\n const timer = ctrl ? setTimeout(() => ctrl.abort(), timeoutMs || 18000) : null;\n if (ctrl) init.signal = ctrl.signal;\n try {\n const resp = await fetch(url, init);\n const buf = new Uint8Array(await resp.arrayBuffer());\n const h = {};\n resp.headers.forEach((v, k) => { h[k] = v; });\n return {\n status: resp.status,\n headers: h,\n body_base64: bytesToB64(buf),\n url: resp.url, // 最终 URL(跟随重定向后)\n };\n } finally {\n clearTimeout(timer);\n }\n },\n args: [job.url, job.method, job.headers || {}, job.body_base64 || null, job.options || {}, RELAY_FETCH_TIMEOUT_MS],\n });\n\n const r = results && results[0] && results[0].result;\n if (!r) throw new Error('无返回结果');\n // 把最终 URL 放入特殊头,Worker 端可读取\n const headers = { ...(r.headers || {}), 'x-relay-url': r.url || job.url };\n return { status: r.status, headers, body_base64: r.body_base64 };\n } catch (e) {\n return { error: String(e.message || e).slice(0, 500) };\n } finally {\n // 复用的标签页保留,自己新建的关闭(避免堆积)\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 中继主循环(单轮):取任务 → 执行 → 回传\n// 若正好有一轮在跑,先等它(避免「刚排队的任务被这轮跳过」),最多等 ~600ms。\nasync function runRelayOnce() {\n for (let i = 0; i < 3 && relayBusy; i++) await sleep(200);\n if (relayBusy) return { ok: true, count: 0, skipped: true };\n relayBusy = true;\n try {\n return await runRelayRound();\n } finally {\n relayBusy = false;\n }\n}\n\nasync function runRelayRound() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) return { ok: false, reason: 'noconfig' };\n let jobs;\n try {\n jobs = await fetchRelayJobs(panelUrl, apiKey);\n } catch (e) {\n return { ok: false, error: String((e && e.message) || e) }; // 面板不可达时静默重试\n }\n for (const job of jobs) {\n try {\n console.log(`[签到面板] 中继请求:${job.method} ${job.url}`);\n // 硬超时:即使页面 fetch 或注入挂起,也必须回传结果,否则任务会永久卡在队列\n const result = await Promise.race([\n executeRelayJob(job),\n new Promise((resolve) => setTimeout(() => resolve({ error: `中继执行超时(${RELAY_JOB_TIMEOUT_MS / 1000}秒),已放弃该请求` }), RELAY_JOB_TIMEOUT_MS)),\n ]);\n await submitRelayResult(panelUrl, apiKey, job.id, result);\n } catch (e) {\n console.error(`[签到面板] 中继任务 ${job.id} 失败:`, e);\n // 回传错误,避免任务卡在 pending\n try {\n await submitRelayResult(panelUrl, apiKey, job.id, { error: String(e.message || e) });\n } catch { /* 忽略 */ }\n }\n }\n return { ok: true, count: jobs.length };\n}\n\n// 中继长轮询「突发」:一轮结束立刻接下一轮,让面板点「执行」后 1 秒内就被领走。\n//\n// 为什么不用 while(true) 常驻循环:① 浏览器可能随时回收 Service Worker,常驻循环早晚会断;\n// ② 若面板侧不支持长轮询(旧版本),常驻循环会变成毫秒级空转打网络。\n// 所以改成「有界突发」:最多 RELAY_BURST_ROUNDS 轮,连续 RELAY_BURST_IDLE 轮没任务就结束;\n// 由 RELAY_ALARM(0.5 分钟)和 popup 的「立即执行」反复拉起,接得上就与常驻无异。\nlet relayGen = 0; // 代际令牌:新的突发会作废旧的,避免多份循环并发抢任务\nlet relayBusy = false; // 正在处理一轮长轮询(避免并发取同一批任务)\n\nfunction sleep(ms) {\n return new Promise((res) => setTimeout(res, ms));\n}\n\nfunction startRelayBurst(opts = {}) {\n const rounds = opts.rounds || RELAY_BURST_ROUNDS;\n const idleStop = opts.idleStop || RELAY_BURST_IDLE;\n const gen = ++relayGen;\n let idle = 0;\n (async () => {\n for (let i = 0; i < rounds; i++) {\n if (gen !== relayGen) return; // 已被更新的突发取代/已停止\n let r;\n try {\n r = await runRelayOnce();\n } catch (e) {\n r = { ok: false, error: String((e && e.message) || e) };\n }\n if (gen !== relayGen) return;\n if (r && r.reason === 'noconfig') return; // 未配置面板地址/Key,不空转\n if (r && r.count) idle = 0; else idle++;\n if (idle >= idleStop) return; // 一连几轮都没任务,先歇着,交给下次 alarm\n await sleep(r && r.ok ? RELAY_GAP_MS : 5000);\n }\n })();\n}\n\n// 供 popup / 测试使用:立刻停掉当前突发(不打断正在执行的那一轮)\nfunction stopRelayBurst() {\n relayGen++;\n}\n\n// 定时器\nchrome.alarms.onAlarm.addListener((alarm) => {\n if (alarm.name === ALARM_NAME) runJobs();\n if (alarm.name === RELAY_ALARM) startRelayBurst();\n});\n\n// 扩展安装/启动时设置定时器\nchrome.runtime.onInstalled.addListener(() => {\n chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n // 安装后立即跑一次(方便验证),并起一次中继突发\n setTimeout(runJobs, 5000);\n startRelayBurst();\n});\n\nchrome.runtime.onStartup.addListener(() => {\n chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n startRelayBurst();\n});\n\n// Service Worker 每次启动时确保定时器存在(覆盖\"重新加载\"场景:onInstalled/onStartup 都不触发)\nchrome.alarms.get(ALARM_NAME, (a) => {\n if (!a) chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n});\nchrome.alarms.get(RELAY_ALARM, (a) => {\n if (!a) chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n});\n\n// popup 手动触发\nchrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {\n if (msg && msg.action === 'runJobsNow') {\n runJobs().then(() => sendResponse({ ok: true })).catch((e) => sendResponse({ ok: false, error: String(e) }));\n return true; // 异步响应\n }\n if (msg && msg.action === 'runRelayNow') {\n // 先同步跑完一轮(已排队的任务立即执行),响应后再转入长轮询突发\n runRelayOnce()\n .then((r) => {\n sendResponse({ ok: true, count: (r && r.count) || 0 });\n startRelayBurst();\n })\n .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));\n return true;\n }\n if (msg && msg.action === 'stopRelay') {\n stopRelayBurst();\n sendResponse({ ok: true });\n return true;\n }\n});\n", + "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 \"\"\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": "\n\n\n\n\n\n\n

🖊️ cookie插件2.2

\n
正在读取当前网站…
\n \n \n \n \n \n \n \n
\n \n\n\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(${stat.domains} 个域 / ${stat.httpOnly} 个 HttpOnly)` + (lsCount ? ` + ${lsCount} 项 localStorage` : '')\n : '该网站没有 Cookie',\n cookies.length ? 'ok' : 'err'\n );\n}\n\nfunction cookieString() {\n return cookies.map((c) => `${c.name}=${c.value}`).join('; ');\n}\n\nfunction fullPayload() {\n return JSON.stringify({\n domain,\n userAgent: window._pageUA || '',\n cookies: cookieString(),\n // 附带可读的分解信息,面板据此清晰展示:每个 Cookie 的名称/域/是否 HttpOnly\n cookieList: cookies.map((c) => ({\n name: c.name,\n value: c.value,\n domain: c.domain,\n path: c.path,\n secure: !!c.secure,\n httpOnly: !!c.httpOnly,\n sameSite: c.sameSite || '',\n session: !!c.session,\n expirationDate: c.expirationDate || null,\n })),\n localStorage: window._localStore || {},\n stats: describeCookies(cookies),\n ts: Date.now(),\n });\n}\n\n$('btn-copy').onclick = async () => {\n if (!cookies.length) return status('没有可复制的 Cookie', 'err');\n // 复制完整 JSON:面板粘贴后自动解析出域名、UA、Cookie\n await navigator.clipboard.writeText(fullPayload());\n status(`已复制 ${cookies.length} 个 Cookie + UA,去面板「粘贴扩展内容」粘贴即可`, 'ok');\n};\n\n$('btn-send').onclick = async () => {\n if (!cookies.length) return status('没有可发送的 Cookie', 'err');\n let panelUrl = $('panel-url').value.trim().replace(/\\/$/, '');\n if (!panelUrl) return status('请先填写签到面板地址', 'err');\n if (!panelUrl.startsWith('http')) panelUrl = 'https://' + panelUrl;\n await chrome.storage.sync.set({ panelUrl });\n\n // 把 Cookie + UA + 域名编码进 URL hash,面板 JS 读取后自动弹出确认框\n // 用 base64url 编码(+/= 替换为 -_.),避免特殊字符在地址栏被转义或截断\n const b64 = btoa(unescape(encodeURIComponent(fullPayload())));\n const payload = b64.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');\n chrome.tabs.create({ url: panelUrl + '#ext-cookies=' + payload });\n status('已打开面板,请在面板中确认保存', 'ok');\n};\n\n$('panel-url').oninput = () => {\n chrome.storage.sync.set({ panelUrl: $('panel-url').value.trim().replace(/\\/$/, '') });\n};\n\n$('api-key').oninput = () => {\n chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });\n};\n\n// 兼容旧的 onchange(保留)\n$('panel-url').onchange = () => {\n chrome.storage.sync.set({ panelUrl: $('panel-url').value.trim().replace(/\\/$/, '') });\n};\n\n$('api-key').onchange = () => {\n chrome.storage.sync.set({ apiKey: $('api-key').value.trim() });\n};\n\n// 打开弹窗时顺手叫醒一次中继(后台常驻长轮询,本来就会自己跑)。\n// 以前这里还有个「立即执行待办签到」按钮——它会挂住弹窗、也让用户以为要手动点才干活,已删掉:\n// 面板点「执行」时后台会在 1 秒内接到单,不需要在弹窗上再点一次。\n// 延到下一个 tick:先把弹窗自己的 UI 初始化完,再去叫醒后台,\n// 不抢在主界面之前干活(后台不在时静默忽略,不影响取 Cookie)。\nsetTimeout(() => {\n try {\n const p = chrome.runtime.sendMessage({ action: 'runRelayNow' });\n if (p && typeof p.catch === 'function') p.catch(() => {});\n } catch { /* 忽略 */ }\n}, 0);\n\n// 原生页面巡检:不依赖 eval / new Function(MV3 禁止),用于读取网站真实反馈。\nasync function inspectPageInPopup(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 // 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))) return { ok: false, message: '遇到人机验证/安全验证(验证码/滑块),需人工验证后重试|页面:' + title };\n const WAF = ['waf_zw_verify', 'WZWS_CONFIRM_PREFIX_LABEL', 'Access Denied', '403 Forbidden', '请求被拦截'];\n if (WAF.some((m) => all.includes(m))) return { ok: false, message: '遇到网站安全防护(WAF),请在浏览器完成验证后重试|页面:' + title };\n // ---- 找「去签到」入口(Discuz 系论坛通用)----\n const ALREADY = /已签到|已经签到|已打卡|签到完毕|签到完成|连续签到|今日已签|明天再来|下期再来/;\n const SIGN_TEXT = /打卡签到|立即签到|点击签到|点击打卡|签到领奖|每日签到|每日打卡|签个到|去签到|未打卡|还没有签到|今天还没有/;\n const SIGN_IMG = /qds\\.png|signin_no\\.png|pperwb\\.gif|wb\\.png|qiandao|qdbg|sign_?in/i;\n 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';\n const attrOf = (el) => ((el.getAttribute && el.getAttribute('href') || '') + ' ' + (el.id || '') + ' ' +\n (el.className || '') + ' ' + (el.getAttribute && el.getAttribute('title') || '')).replace(/\\s+/g, ' ');\n const textOf = (el) => String(el.innerText || el.textContent || '').replace(/\\s+/g, '');\n const imgsOf = (el) => Array.from(el.querySelectorAll ? el.querySelectorAll('img') : [])\n .concat(el.tagName === 'IMG' ? [el] : [])\n .map((i) => (i.getAttribute('src') || '') + ' ' + (i.getAttribute('alt') || '')).join(' ');\n const findSignLink = () => {\n const applies = Array.from(document.querySelectorAll('a[href*=\"do=apply\"], a[href*=\"do=draw\"]'))\n .filter((a) => /mod=task/i.test(a.getAttribute('href') || ''));\n for (const a of applies) {\n if (ALREADY.test(textOf(a))) continue;\n const own = textOf(a) + ' ' + attrOf(a) + ' ' + imgsOf(a);\n const row = a.closest('tr') || a.closest('li') || a.closest('table') || a.parentElement;\n const rowTxt = ((row && row.innerText) || '').replace(/\\s+/g, ' ').slice(0, 200);\n const rowDone = /已完成|已申请|已领取|已打卡/.test(rowTxt) && !/立即申请|马上申请|申请任务/.test(rowTxt);\n if (rowDone) return { href: a.getAttribute('href') || '', label: (textOf(a) || '签到'), done: true };\n if (/qds\\.png|打卡|每日|签到/i.test(own + ' ' + rowTxt) || applies.length === 1) {\n return { href: a.getAttribute('href') || '', label: (textOf(a) || '打卡签到'), done: false };\n }\n }\n const known = Array.from(document.querySelectorAll(SIGN_SEL));\n for (const el of known) {\n const own = textOf(el) + ' ' + attrOf(el) + ' ' + imgsOf(el);\n if (ALREADY.test(own)) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n const usable = href && !/^javascript:void/i.test(href) ? href : '';\n el.setAttribute('data-panel-sign', '1');\n return { href: usable, clickSel: '[data-panel-sign=\"1\"]', label: (textOf(el) || '签到'), done: false };\n }\n for (const el of Array.from(document.querySelectorAll('a,button,[onclick],img'))) {\n const t = textOf(el);\n const im = imgsOf(el);\n if (ALREADY.test(t + ' ' + im)) continue;\n if (!(SIGN_TEXT.test(t) || SIGN_IMG.test(im))) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n if (!href && !el.onclick && !el.getAttribute('onclick')) continue;\n el.setAttribute('data-panel-sign', '1');\n return { href: /^javascript:/i.test(href) ? '' : href, clickSel: '[data-panel-sign=\"1\"]', label: (t || '签到'), done: false };\n }\n return null;\n };\n const SIGNED = /签到完毕|签到完成|您已完成过此任务|您已经完成此任务|您今日已经签到|您今天已经签到|今天已经签到|已经签到过|已经完成签到|今日任务已完成|今日已签到|今天已完成签到|已连续签到|下期再来|明天再来|无需重复|恭喜.{0,14}(完成|获得|领到|签到)/;\n const SUCCESS = /任务已完成|签到成功|打卡成功|领取成功|成功领取|获得.{0,10}(鸡腿|积分|金币|铜币|银币|吾爱币|AK|币|空间|MB|GB)/;\n let link = null;\n try { link = findSignLink(); } catch (e) { link = null; }\n if (!link) {\n const am = all.match(/]*href=[\"']([^\"']*mod=task[^\"']*do=(?:apply|draw)[^\"']*)[\"'][^>]*>/i);\n if (am && /qds\\.png|打卡签到|每日签到|每日打卡|签到完毕/i.test(all) && !/已完成过此任务|您已经完成此任务/.test(all)) {\n link = { href: am[1].replace(/&/g, '&'), label: '打卡签到', done: false };\n }\n }\n if (SUCCESS.test(all)) return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };\n if (SIGNED.test(all)) return { ok: true, message: '今日已签到,无需重复' };\n if (link && link.done) return { ok: true, message: '今日已签到(任务页显示:已完成)' };\n // 4) 未登录(Discuz 游客页会写「注册[Register]」)——比「未签到」更优先:\n // 登录失效时页面导航里也有「每日签到」这类字样。\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\\[Register\\]|立即注册|登录后才能/.test(all)) return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡/.test(all)) {\n return { ok: false, unsigned: true, href: '', clickSel: '', message: '网站显示还没签到,但页面上找不到可点的签到入口' };\n }\n if (link) return { ok: false, unsigned: true, href: link.href || '', clickSel: link.clickSel || '', message: '网站还挂着「' + (link.label || '签到') + '」入口 —— 今天还没签上' };\n return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };\n },\n });\n return (results && results[0] && results[0].result) || null;\n}\n\n// 尝试执行面板下发的脚本字符串(MV3 下通常失效,失败时回退原生巡检)。\nasync function runPanelScriptInPopup(tabId, job) {\n const execPromise = chrome.scripting.executeScript({\n target: { tabId },\n func: (scriptStr, params) => {\n const fn = new Function('params', `return (${scriptStr})(params)`);\n return fn(params);\n },\n args: [job.script, job.params || {}],\n });\n const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('脚本执行超时(60秒)')), 60000));\n const results = await Promise.race([execPromise, timeoutPromise]);\n return (results && results[0] && results[0].result) || null;\n}\n\n// 在弹窗上下文中执行单个签到任务(复用目标域名标签页,携带用户 Cookie)\n// onStep: 步骤回调,用于显示详细进度\nasync function executeJobInPopup(job, onStep) {\n const startMs = Date.now();\n const step = (msg) => { if (onStep) onStep(msg); };\n let tab = null;\n let created = false;\n try {\n if (!job.domain) throw new Error('任务缺少目标域名');\n step('查找标签页…');\n const tabs = await chrome.tabs.query({ url: `*://${job.domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n step('复用已有标签页…');\n } else {\n step('打开新标签页…');\n tab = await chrome.tabs.create({ url: `https://${job.domain}/`, active: false });\n created = true;\n step('等待页面加载…');\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 15000);\n });\n }\n // 如果任务指定了导航 URL(如吾爱破解),先导航到该页面(模拟手动点击),再执行检查脚本\n if (job.navigate_url) {\n step('导航到签到页面…');\n await chrome.tabs.update(tab.id, { url: job.navigate_url });\n step('等待签到页面加载…');\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n // 多等 2 秒,让页面内跳转完成\n await new Promise((r) => setTimeout(r, 2000));\n }\n // MV3 禁止 new Function / eval:有 navigate_url 直接原生巡检,否则先试面板脚本再回退。\n step('读取页面结果…');\n let result = null;\n if (!job.navigate_url && job.script) {\n try { result = await runPanelScriptInPopup(tab.id, job); } catch { result = null; }\n }\n if (!result) result = await inspectPageInPopup(tab.id);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n\n // 页面还挂着「打卡签到」入口 = 今天还没签:链接→导航过去,JS 按钮→在页面里点一下\n if (!result.ok && result.unsigned && (result.href || result.clickSel)) {\n try {\n let navigated = false;\n if (result.href) {\n const base = tab.url ? new URL(tab.url) : null;\n const u = new URL(result.href, tab.url || undefined);\n if ((u.protocol === 'http:' || u.protocol === 'https:') && (!base || u.host === base.host)) {\n step('网站显示还没签到,正在执行签到…');\n await chrome.tabs.update(tab.id, { url: u.href });\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n await new Promise((r) => setTimeout(r, 2000));\n navigated = true;\n }\n }\n if (!navigated && result.clickSel) {\n step('网站显示还没签到,正在点击签到按钮…');\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: (sel) => { const el = document.querySelector(sel); if (el) el.click(); },\n args: [result.clickSel],\n });\n await new Promise((r) => setTimeout(r, 3000));\n }\n const after = await inspectPageInPopup(tab.id);\n if (after) result = after;\n } catch (e) {\n console.log('[签到面板] 触发签到失败:', e.message || e);\n }\n }\n\n step('解析结果…');\n return {\n status: result.ok ? 'ok' : 'fail',\n message: String(result.message || (result.ok ? '签到成功' : '签到失败')),\n durationMs: Date.now() - startMs,\n };\n } catch (e) {\n return { status: 'fail', message: '浏览器执行失败:' + (e.message || e), durationMs: Date.now() - startMs };\n } finally {\n // 只关闭自己新建的标签页(复用的保留)\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 面板连接检查:验证面板地址和 API Key 是否可用\n$('btn-check-conn').onclick = async () => {\n status('正在检查面板连接…', '');\n try {\n let panelUrl = $('panel-url').value.trim().replace(/\\/$/, '');\n if (!panelUrl) return status('请先填写签到面板地址', 'err');\n if (!panelUrl.startsWith('http')) panelUrl = 'https://' + panelUrl;\n const apiKey = $('api-key').value.trim();\n if (!apiKey) return status('请先填写 API Key(面板设置页获取)', 'err');\n\n // ① 检查面板是否可访问(用专用 ping 接口,不消费任务)\n let resp;\n try {\n resp = await fetch(panelUrl + '/api/external/ping', {\n headers: { 'X-Api-Key': apiKey },\n });\n } catch (e) {\n return status('连接失败:面板地址无法访问(' + (e.message || '网络错误') + ')', 'err');\n }\n // ② 检查 API Key 是否有效\n if (resp.status === 401) return status('连接失败:API Key 无效,请去面板设置页重新生成', 'err');\n if (!resp.ok) return status('连接失败:面板返回 HTTP ' + resp.status, 'err');\n // ③ 解析任务列表\n let data;\n try { data = await resp.json(); } catch { return status('连接失败:面板返回数据格式错误', 'err'); }\n status(`连接正常 ✅ API Key 有效(面板版本 ${data.version || '未知'})`, 'ok');\n } catch (e) {\n status('检查失败:' + (e.message || e), 'err');\n }\n};\n\ninit();\n", + "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 || '';\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\n // ---- 找「去签到」入口(Discuz 系论坛通用)----\n // 已签到的视觉特征:文字或图片里直接写着已签/已打卡/连续签到\n const ALREADY = /已签到|已经签到|已打卡|签到完毕|签到完成|连续签到|今日已签|明天再来|下期再来/;\n // 未签到的视觉特征:文字\n const SIGN_TEXT = /打卡签到|立即签到|点击签到|点击打卡|签到领奖|每日签到|每日打卡|签个到|去签到|未打卡|没有签到|还没有签到|今天还没有签到|今天还没有|立即打卡/;\n // 未签到的视觉特征:图片(各 Discuz 签到插件用的图)\n const SIGN_IMG = /qds\\.png|signin_no\\.png|pperwb\\.gif|wb\\.png|qiandao|qdbg|sign_?in/i;\n // 已知的签到按钮 id / class(来自各社区签到脚本:discuz 任务、dsu_paulsign、k_misign、zqlj_sign 等)\n 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';\n\n const attrOf = (el) => ((el.getAttribute && el.getAttribute('href') || '') + ' ' +\n (el.id || '') + ' ' + (el.className || '') + ' ' + (el.getAttribute && el.getAttribute('title') || '')).replace(/\\s+/g, ' ');\n const textOf = (el) => String(el.innerText || el.textContent || '').replace(/\\s+/g, '');\n const imgsOf = (el) => Array.from(el.querySelectorAll ? el.querySelectorAll('img') : [])\n .concat(el.tagName === 'IMG' ? [el] : [])\n .map((i) => (i.getAttribute('src') || '') + ' ' + (i.getAttribute('alt') || '')).join(' ');\n\n const findSignLink = () => {\n // ① Discuz 任务申请链接(吾爱破解这类:mod=task&do=apply&id=N)\n const applies = Array.from(document.querySelectorAll('a[href*=\"do=apply\"], a[href*=\"do=draw\"]'))\n .filter((a) => /mod=task/i.test(a.getAttribute('href') || ''));\n for (const a of applies) {\n const own = textOf(a) + ' ' + attrOf(a) + ' ' + imgsOf(a);\n if (ALREADY.test(textOf(a))) continue;\n const row = a.closest('tr') || a.closest('li') || a.closest('table') || a.parentElement;\n const rowTxt = ((row && row.innerText) || '').replace(/\\s+/g, ' ').slice(0, 200);\n // 任务列表里那个「已完成」是隔壁任务的,必须限定在链接所在行\n const rowDone = /已完成|已申请|已领取|已打卡/.test(rowTxt) && !/立即申请|马上申请|申请任务/.test(rowTxt);\n if (rowDone) return { href: a.getAttribute('href') || '', label: (textOf(a) || '签到'), done: true };\n if (/qds\\.png|打卡|每日|签到/i.test(own + ' ' + rowTxt) || applies.length === 1) {\n return { href: a.getAttribute('href') || '', label: (textOf(a) || '打卡签到'), done: false };\n }\n }\n // ② 已知的签到按钮(可能是 ,也可能是 JS 点击的 /
/)\n const known = Array.from(document.querySelectorAll(SIGN_SEL));\n for (const el of known) {\n const own = textOf(el) + ' ' + attrOf(el) + ' ' + imgsOf(el);\n if (ALREADY.test(own)) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n const usable = href && !/^javascript:void/i.test(href) ? href : '';\n return { href: usable, clickSel: true, label: (textOf(el) || '签到'), done: false };\n }\n // ③ 兜底:页面上写着「打卡签到 / 每日签到」的可点元素\n const cands = Array.from(document.querySelectorAll('a,button,[onclick],img'));\n for (const el of cands) {\n const t = textOf(el);\n const im = imgsOf(el);\n if (ALREADY.test(t + ' ' + im)) continue;\n const hit = SIGN_TEXT.test(t) || SIGN_IMG.test(im);\n if (!hit) continue;\n const href = (el.getAttribute && el.getAttribute('href')) || '';\n if (!href && !el.onclick && !el.getAttribute('onclick')) continue; // 纯展示元素,点了也没用\n return { href: /^javascript:/i.test(href) ? '' : href, clickSel: true, label: (t || '签到'), done: false };\n }\n return null;\n };\n const markForClick = (res) => {\n if (!res || !res.clickSel) return '';\n const el = document.querySelector(SIGN_SEL) || (SIGN_TEXT.test(compact) ? document.querySelector('a[href*=\"do=apply\"]') : null);\n if (!el) return '';\n el.setAttribute('data-panel-sign', '1');\n return '[data-panel-sign=\"1\"]';\n };\n\n // 2) 「已完成」的强信号(Discuz:您已完成过此任务 / 今日已签到 / 恭喜…获得…)\n const SIGNED = /签到完毕|签到完成|您已完成过此任务|您已经完成此任务|您今日已经签到|您今天已经签到|今天已经签到|已经签到过|已经完成签到|今日任务已完成|今日已签到|今天已完成签到|已连续签到|下期再来|明天再来|无需重复|恭喜.{0,14}(完成|获得|领到|签到)/;\n // 成功信号:任务页直接告诉用户拿到了什么\n const SUCCESS = /任务已完成|签到成功|打卡成功|领取成功|成功领取|获得.{0,10}(鸡腿|积分|金币|铜币|银币|吾爱币|AK|币|空间|MB|GB)/;\n // 注入环境可能没有 DOM API(部分页面/测试台架):退回到纯文本 + 正则,\n // 只认「do=apply 链接 + 签到图/签到字样」这个最硬的组合。\n let link = null;\n try { link = findSignLink(); } catch (e) { link = null; }\n if (!link) {\n const am = all.match(/]*href=[\"']([^\"']*mod=task[^\"']*do=(?:apply|draw)[^\"']*)[\"'][^>]*>/i);\n if (am && /qds\\.png|打卡签到|每日签到|每日打卡|签到完毕/i.test(all) && !/已完成过此任务|您已经完成此任务/.test(all)) {\n link = { href: am[1].replace(/&/g, '&'), label: '打卡签到', done: false };\n }\n }\n if (SUCCESS.test(all)) {\n return { ok: true, message: '签到成功|' + (compact.slice(0, 120) || title) };\n }\n // 强信号优先于「还挂着链接」:例如任务列表里签到那条已「已完成」,\n // 而顶栏还挂着别的任务的 apply 链接\n if (SIGNED.test(all)) return { ok: true, message: '今日已签到,无需重复' };\n if (link && link.done) return { ok: true, message: '今日已签到(任务页显示:已完成)' };\n // 4) 未登录(Discuz 游客页会写「注册[Register]」)—— 比「未签到」更优先:\n // 登录失效时页面导航里也有「每日签到」这类字样,不能把它当成“还没签到”。\n if (/需要先登录|请先登录|还未登录|请登录后|登录已失效|登录失效|请重新登录|未登录|注册\\[Register\\]|立即注册|登录后才能/.test(all)) {\n return { ok: false, message: '登录已失效,请重新获取 Cookie' };\n }\n // 页面上写着「还没签到」却找不到可点的入口(插件式签到页、被遮挡的按钮):\n // 如实报未签到,并说清要人工处理,不要吐一句“未识别到成功标识”。\n if (!link && /打卡签到|立即签到|点击打卡|每日签到|每日打卡|还没有签到|未打卡/.test(all)) {\n return { ok: false, unsigned: true, href: '', clickSel: '',\n message: '网站显示还没签到,但页面上找不到可点的签到入口' };\n }\n if (link) {\n let clickSel = '';\n try { clickSel = markForClick(link); } catch (e) { clickSel = ''; }\n return {\n ok: false, unsigned: true, href: link.href || '', clickSel,\n message: '网站还挂着「' + (link.label || '签到') + '」入口 —— 今天还没签上',\n };\n }\n return { ok: false, message: '未识别到成功标识|页面:' + title + '|' + compact.slice(0, 140) };\n },\n });\n return (results && results[0] && results[0].result) || null;\n}\n\n// 把页面里找到的「去签到」链接解析成绝对地址,并做同源校验。\n// 页面内容不可信:只允许跳到当前站点的同源地址(http/https + 同 host)。\nfunction resolveSignHref(result, tab) {\n const raw = result && result.href;\n if (!raw) return '';\n try {\n const base = (tab && tab.url) || ('https://' + (tab && tab.pendingUrl ? '' : ''));\n const u = new URL(raw, base || undefined);\n const cur = tab && tab.url ? new URL(tab.url) : null;\n if (cur && u.host !== cur.host) return '';\n if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';\n return u.href;\n } catch {\n return '';\n }\n}\n\n// 等待标签页加载完成(带超时),用于导航后复查\nfunction waitTabComplete(tabId, timeoutMs) {\n return new Promise((resolve) => {\n const listener = (id, info) => {\n if (id === tabId && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs || 20000);\n });\n}\n\n// 尝试执行面板下发的脚本字符串。\n// 注意:MV3 禁止 new Function / eval,多数情况下这里会失败——失败时由调用方回退到原生巡检。\nasync function runPanelScript(tabId, job) {\n const execPromise = chrome.scripting.executeScript({\n target: { tabId },\n func: (scriptStr, params) => {\n const fn = new Function('params', `return (${scriptStr})(params)`);\n return fn(params);\n },\n args: [job.script, job.params || {}],\n });\n const timeoutPromise = new Promise((_, reject) =>\n setTimeout(() => reject(new Error('脚本执行超时(60秒)')), 60000)\n );\n const results = await Promise.race([execPromise, timeoutPromise]);\n return (results && results[0] && results[0].result) || null;\n}\n\n// 在目标域名的标签页中执行签到脚本\nasync function executeJob(job) {\n const startMs = Date.now();\n let tab = null;\n let created = false;\n try {\n // 与 popup 保持一致:缺域名时直接报错,避免误开 https://undefined/ 标签页\n if (!job.domain) throw new Error('任务缺少目标域名');\n // 找一个该域名的已存在标签页,复用;没有则新建隐藏标签页\n const tabs = await chrome.tabs.query({ url: `*://${job.domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n } else {\n tab = await chrome.tabs.create({ url: `https://${job.domain}/`, active: false });\n created = true;\n // 等待页面加载完成\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 15000); // 超时保护\n });\n }\n\n // 如果任务指定了导航 URL(如吾爱破解),先导航到该页面(模拟手动点击),再执行检查脚本\n if (job.navigate_url) {\n await chrome.tabs.update(tab.id, { url: job.navigate_url });\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 20000);\n });\n await new Promise((r) => setTimeout(r, 2000));\n }\n\n // 执行签到。\n // MV3 禁止 new Function / eval,面板下发的脚本字符串通常无法执行;\n // 因此:有 navigate_url 的任务直接对目标页做原生巡检;否则先试面板脚本,拿不到结果就回退原生巡检。\n let result = null;\n if (!job.navigate_url && job.script) {\n try {\n result = await runPanelScript(tab.id, job);\n } catch (e) {\n console.log('[签到面板] 面板脚本执行失败,回退原生巡检:', e.message || e);\n result = null;\n }\n }\n if (!result) result = await inspectPage(tab.id);\n if (!result) throw new Error('无法读取页面内容(标签页可能已关闭或被重定向)');\n\n // 页面还挂着「打卡签到」入口 = 服务端认为今天没签。\n // ① 是链接(吾爱破解这类 Discuz 任务)→ 导航过去,等同人手点一下;\n // ② 是 JS 按钮(dsu_paulsign / k_misign 等插件)→ 在页面里点一下。\n // 两条路都是真实用户行为,之前实测过:脚本化 fetch 会被 WAF 吊死,而导航/点击能过。\n if (!result.ok && result.unsigned) {\n const signHref = resolveSignHref(result, tab);\n try {\n if (signHref) {\n await chrome.tabs.update(tab.id, { url: signHref });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n } else if (result.clickSel) {\n await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: (sel) => { const el = document.querySelector(sel); if (el) el.click(); },\n args: [result.clickSel],\n });\n await new Promise((r) => setTimeout(r, 3000));\n }\n const after = (signHref || result.clickSel) ? await inspectPage(tab.id) : null;\n if (after) result = after;\n } catch (e) {\n console.log('[签到面板] 触发签到失败:', e.message || e);\n }\n }\n\n // 连入口都没认出来(站点改版/插件页),但面板给了「签到地址」——那是站点模块自己知道的,\n // 再试一次,别白自放弃(同源校验后仍然只跳本域)。\n if (!result.ok && result.unsigned && !result.href && !result.clickSel && job.sign_url) {\n try {\n const u = new URL(job.sign_url);\n const sameHost = !job.domain || u.hostname === job.domain || u.hostname.endsWith('.' + job.domain);\n if ((u.protocol === 'http:' || u.protocol === 'https:') && sameHost) {\n await chrome.tabs.update(tab.id, { url: u.href });\n await waitTabComplete(tab.id, 20000);\n await new Promise((r) => setTimeout(r, 2000));\n const after = await inspectPage(tab.id);\n if (after) result = after;\n }\n } catch (e) {\n console.log('[签到面板] 兑底签到地址不可用:', e.message || e);\n }\n }\n\n // 明确未签到、且始终找不到可点的入口/地址 → 把「接下来怎么办」说清楚,\n // 不要只丢一句含糊的“未识别到成功标识”。\n if (!result.ok && result.unsigned && !result.href && !result.clickSel) {\n result = { ...result, message: String(result.message || '今天还没签上') + ';页面上找不到可点的签到入口,请在浏览器手动签一次' };\n }\n\n return {\n status: result.ok ? 'ok' : 'fail',\n message: String(result.message || (result.ok ? '签到成功' : '签到失败')),\n durationMs: Date.now() - startMs,\n };\n } catch (e) {\n return {\n status: 'fail',\n message: '浏览器执行失败:' + (e.message || e),\n durationMs: Date.now() - startMs,\n };\n } finally {\n // 只关闭“我们自己新建”的标签页(复用的、用户正在看的保留),避免每次签到累积一堆隐藏标签页\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 主流程:获取任务 → 逐个执行 → 上报\nasync function runJobs() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) {\n console.log('[签到面板] 未配置面板地址或 API Key,跳过');\n return;\n }\n\n let jobs;\n try {\n jobs = await fetchJobs(panelUrl, apiKey);\n } catch (e) {\n console.log('[签到面板] 获取任务失败:', e.message);\n return;\n }\n\n if (!jobs.length) {\n console.log('[签到面板] 暂无待执行任务');\n return;\n }\n\n console.log(`[签到面板] 获取到 ${jobs.length} 个任务,开始执行`);\n for (const job of jobs) {\n console.log(`[签到面板] 执行:${job.site_name}(账号 ${job.account_id})`);\n const r = await executeJob(job);\n console.log(`[签到面板] 结果:${r.status} - ${r.message}`);\n await reportResult(panelUrl, apiKey, job.account_id, r.status, r.message, r.durationMs);\n // 任务之间稍作间隔\n await new Promise((r) => setTimeout(r, 2000));\n }\n}\n\n// ============ 本地网络中继代理 ============\n// Worker 把 HTTP 请求存入面板队列,扩展用用户本地网络执行后回传响应。\n// 这样 Worker 端的站点逻辑可以使用用户本地 IP,绕过 CF/Worker IP 限制。\n\n// base64 ↔ Uint8Array\nfunction b64ToBytes(b64) {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n}\nfunction bytesToB64(bytes) {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n}\n\n// 获取待执行的中继任务(长轮询:没有任务时挂起,一有任务立刻返回)\nasync function fetchRelayJobs(panelUrl, apiKey, waitMs = RELAY_LONGPOLL_MS) {\n // 带上扩展版本(?v=),面板可以显示「扩展在线 · v2.2」并提醒版本陈旧\n let ver = '';\n try { ver = (chrome.runtime.getManifest() || {}).version || ''; } catch { /* 忽略 */ }\n const resp = await fetch(panelUrl + '/api/external/relay-pending?wait=' + waitMs + (ver ? '&v=' + encodeURIComponent(ver) : ''), {\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 submitRelayResult(panelUrl, apiKey, jobId, result) {\n await fetch(panelUrl + '/api/external/relay/' + jobId + '/result', {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'X-Api-Key': apiKey },\n body: JSON.stringify(result),\n }).catch(() => { /* 忽略 */ });\n}\n\n// 在目标域名的页面上下文中执行单个 HTTP 请求(携带用户 Cookie)\n//\n// 为什么要「先短超时、超时就激活标签重试」:\n// 实测(2026-09-28)发现,对 www.52pojie.cn 这类**带反爬挑战的主站**,从**后台标签**发\n// fetch 会一直挂到硬超时(同一时刻同一浏览器:example.com 与 static.52pojie.cn 都正常返回 200,\n// 只有主站的 robots.txt / portal.php / home.php 全部超时)。\n// 原因是挑战/验证脚本在后台标签里被节流,服务端一直等不到「验证完成」,连接就不回包。\n// 所以:第一次用短超时快速试探;若超时,就把标签**激活到前台**再试一次,跑完把焦点还给用户。\nasync function executeRelayJob(job) {\n const url = new URL(job.url);\n const domain = url.hostname;\n let tab = null;\n let created = false;\n let prevActiveId = null;\n let activated = false;\n try {\n const tabs = await chrome.tabs.query({ url: `*://${domain}/*` });\n if (tabs.length > 0) {\n tab = tabs[0];\n } else {\n tab = await chrome.tabs.create({ url: `https://${domain}/`, active: false });\n created = true;\n await new Promise((resolve) => {\n const listener = (tabId, info) => {\n if (tabId === tab.id && info.status === 'complete') {\n chrome.tabs.onUpdated.removeListener(listener);\n resolve();\n }\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(resolve, 8000); // 建标签页只为拿登录态,页面没加载完也照样能发请求,不必等满\n });\n }\n\n const injectFetch = async (url, method, headers, bodyB64, options, timeoutMs) => {\n const b64ToBytes = (b64) => {\n const s = atob(b64);\n const arr = new Uint8Array(s.length);\n for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);\n return arr;\n };\n const bytesToB64 = (bytes) => {\n let s = '';\n for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);\n return btoa(s);\n };\n const init = {\n method,\n headers,\n credentials: 'include', // 始终携带用户 Cookie\n // 页面里拿不到 opaqueredirect 的响应头,manual 等于白跑一轮,统一 follow\n redirect: options.redirect === 'manual' ? 'follow' : (options.redirect || 'follow'),\n };\n if (bodyB64) init.body = b64ToBytes(bodyB64);\n const ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;\n if (ctrl) init.signal = ctrl.signal;\n let timer = null;\n // 不依赖 AbortController:某些受限环境里它不存在,那样整个任务会挂到硬超时。\n // 用 Promise.race 做兜底,保证无论如何都在 timeoutMs 内**返回**(返回超时标记,不抛出)。\n const timeout = new Promise((resolve) => {\n timer = setTimeout(() => {\n try { if (ctrl) ctrl.abort(); } catch { /* 忽略 */ }\n resolve({ timeout: true });\n }, timeoutMs || 18000);\n });\n const call = (async () => {\n const resp = await fetch(url, init);\n const buf = new Uint8Array(await resp.arrayBuffer());\n const h = {};\n resp.headers.forEach((v, k) => { h[k] = v; });\n return {\n status: resp.status,\n headers: h,\n body_base64: bytesToB64(buf),\n url: resp.url, // 最终 URL(跟随重定向后)\n };\n })();\n try {\n return await Promise.race([call, timeout]);\n } finally {\n clearTimeout(timer);\n call.catch(() => {}); // 超时后迟到的失败不要变成未处理拒绝\n }\n };\n\n const runOnce = async (timeoutMs) => {\n const results = await chrome.scripting.executeScript({\n target: { tabId: tab.id },\n func: injectFetch,\n args: [job.url, job.method, job.headers || {}, job.body_base64 || null, job.options || {}, timeoutMs],\n });\n return (results && results[0] && results[0].result) || null;\n };\n\n let r = await runOnce(RELAY_FIRST_TRY_MS);\n if (r && r.timeout) {\n // 后台标签里反爬挑战跑不动 → 把标签拿到前台重试一次\n try {\n const [cur] = await chrome.tabs.query({ active: true, currentWindow: true });\n prevActiveId = cur && cur.id != null ? cur.id : null;\n await chrome.tabs.update(tab.id, { active: true });\n activated = true;\n await new Promise((res) => setTimeout(res, RELAY_ACTIVATE_WAIT_MS)); // 给页面里的定时/挑战脚本一点时间\n } catch { /* 激活失败也得继续试 */ }\n r = await runOnce(RELAY_FETCH_TIMEOUT_MS);\n }\n if (!r) throw new Error('无返回结果');\n if (r.timeout) {\n // 把话说清楚:请求**已经发出去了**,只是没等到响应。\n // 签到这类写操作可能已经生效,面板不能当成「失败」(面板会记为「结果未知」并稍后自动复核)。\n return {\n error: activated\n ? `中继请求超时(前台标签也没等到响应,${Math.round(RELAY_FETCH_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包。该站点的反爬可能要求人工验证,或站点暂时不可达 —— 若站点其实处理了,签到可能已生效`\n : `中继请求超时(${Math.round(RELAY_FETCH_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包,站点可能已记录(结果未知)`,\n };\n }\n // 把最终 URL 放入特殊头,Worker 端可读取\n const headers = { ...(r.headers || {}), 'x-relay-url': r.url || job.url };\n return { status: r.status, headers, body_base64: r.body_base64 };\n } catch (e) {\n return { error: String(e.message || e).slice(0, 500) };\n } finally {\n // 把焦点还给用户原来的标签\n if (activated && prevActiveId != null) {\n chrome.tabs.update(prevActiveId, { active: true }).catch(() => {});\n }\n // 复用的标签页保留,自己新建的关闭(避免堆积)\n if (created && tab && tab.id) {\n chrome.tabs.remove(tab.id).catch(() => {});\n }\n }\n}\n\n// 中继主循环(单轮):取任务 → 执行 → 回传\n// 若正好有一轮在跑,先等它(避免「刚排队的任务被这轮跳过」),最多等 ~600ms。\nasync function runRelayOnce() {\n for (let i = 0; i < 3 && relayBusy; i++) await sleep(200);\n if (relayBusy) return { ok: true, count: 0, skipped: true };\n relayBusy = true;\n try {\n return await runRelayRound();\n } finally {\n relayBusy = false;\n }\n}\n\nasync function runRelayRound() {\n const { panelUrl, apiKey } = await getConfig();\n if (!panelUrl || !apiKey) return { ok: false, reason: 'noconfig' };\n let jobs;\n try {\n jobs = await fetchRelayJobs(panelUrl, apiKey);\n } catch (e) {\n return { ok: false, error: String((e && e.message) || e) }; // 面板不可达时静默重试\n }\n for (const job of jobs) {\n try {\n console.log(`[签到面板] 中继请求:${job.method} ${job.url}`);\n // 硬超时:即使页面 fetch 或注入挂起,也必须回传结果,否则任务会永久卡在队列\n const result = await Promise.race([\n executeRelayJob(job),\n new Promise((resolve) => setTimeout(() => resolve({ error: `中继执行超时(${Math.round(RELAY_JOB_TIMEOUT_MS / 1000)}秒):请求已发出但没收到回包,站点可能已记录(结果未知)` }), RELAY_JOB_TIMEOUT_MS)),\n ]);\n await submitRelayResult(panelUrl, apiKey, job.id, result);\n } catch (e) {\n console.error(`[签到面板] 中继任务 ${job.id} 失败:`, e);\n // 回传错误,避免任务卡在 pending\n try {\n await submitRelayResult(panelUrl, apiKey, job.id, { error: String(e.message || e) });\n } catch { /* 忽略 */ }\n }\n }\n return { ok: true, count: jobs.length };\n}\n\n// 中继长轮询「突发」:一轮结束立刻接下一轮,让面板点「执行」后 1 秒内就被领走。\n//\n// 为什么不用 while(true) 常驻循环:① 浏览器可能随时回收 Service Worker,常驻循环早晚会断;\n// ② 若面板侧不支持长轮询(旧版本),常驻循环会变成毫秒级空转打网络。\n// 所以改成「有界突发」:最多 RELAY_BURST_ROUNDS 轮,连续 RELAY_BURST_IDLE 轮没任务就结束;\n// 由 RELAY_ALARM(0.5 分钟)和 popup 的「立即执行」反复拉起,接得上就与常驻无异。\nlet relayGen = 0; // 代际令牌:新的突发会作废旧的,避免多份循环并发抢任务\nlet relayBusy = false; // 正在处理一轮长轮询(避免并发取同一批任务)\n\nfunction sleep(ms) {\n return new Promise((res) => setTimeout(res, ms));\n}\n\nfunction startRelayBurst(opts = {}) {\n const rounds = opts.rounds || RELAY_BURST_ROUNDS;\n const idleStop = opts.idleStop || RELAY_BURST_IDLE;\n const gen = ++relayGen;\n let idle = 0;\n (async () => {\n for (let i = 0; i < rounds; i++) {\n if (gen !== relayGen) return; // 已被更新的突发取代/已停止\n let r;\n try {\n r = await runRelayOnce();\n } catch (e) {\n r = { ok: false, error: String((e && e.message) || e) };\n }\n if (gen !== relayGen) return;\n if (r && r.reason === 'noconfig') return; // 未配置面板地址/Key,不空转\n if (r && r.count) idle = 0; else idle++;\n if (idle >= idleStop) return; // 一连几轮都没任务,先歇着,交给下次 alarm\n await sleep(r && r.ok ? RELAY_GAP_MS : 5000);\n }\n })();\n}\n\n// 供 popup / 测试使用:立刻停掉当前突发(不打断正在执行的那一轮)\nfunction stopRelayBurst() {\n relayGen++;\n}\n\n// 定时器\nchrome.alarms.onAlarm.addListener((alarm) => {\n if (alarm.name === ALARM_NAME) runJobs();\n if (alarm.name === RELAY_ALARM) startRelayBurst();\n});\n\n// 扩展安装/启动时设置定时器\nchrome.runtime.onInstalled.addListener(() => {\n chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n // 安装后立即跑一次(方便验证),并起一次中继突发\n setTimeout(runJobs, 5000);\n startRelayBurst();\n});\n\nchrome.runtime.onStartup.addListener(() => {\n chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n startRelayBurst();\n});\n\n// Service Worker 每次启动时确保定时器存在(覆盖\"重新加载\"场景:onInstalled/onStartup 都不触发)\nchrome.alarms.get(ALARM_NAME, (a) => {\n if (!a) chrome.alarms.create(ALARM_NAME, { periodInMinutes: CHECK_INTERVAL_MIN });\n});\nchrome.alarms.get(RELAY_ALARM, (a) => {\n if (!a) chrome.alarms.create(RELAY_ALARM, { periodInMinutes: RELAY_INTERVAL_MIN });\n});\n\n// popup 手动触发\nchrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {\n if (msg && msg.action === 'runJobsNow') {\n runJobs().then(() => sendResponse({ ok: true })).catch((e) => sendResponse({ ok: false, error: String(e) }));\n return true; // 异步响应\n }\n if (msg && msg.action === 'runRelayNow') {\n // 先同步跑完一轮(已排队的任务立即执行),响应后再转入长轮询突发\n runRelayOnce()\n .then((r) => {\n sendResponse({ ok: true, count: (r && r.count) || 0 });\n startRelayBurst();\n })\n .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));\n return true;\n }\n if (msg && msg.action === 'stopRelay') {\n stopRelayBurst();\n sendResponse({ ok: true });\n return true;\n }\n});\n", + "@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": "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", }; diff --git a/src/ext-zip.js b/src/ext-zip.js index c5e975e..cf8ff1f 100644 --- a/src/ext-zip.js +++ b/src/ext-zip.js @@ -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 }); diff --git a/src/index.js b/src/index.js index b3a1e6a..b179ed7 100644 --- a/src/index.js +++ b/src/index.js @@ -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)); } diff --git a/src/lib/relay.js b/src/lib/relay.js index 8b19ed8..142840f 100644 --- a/src/lib/relay.js +++ b/src/lib/relay.js @@ -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 请求 diff --git a/src/lib/signals.js b/src/lib/signals.js index 0c57acb..7bf4ca1 100644 --- a/src/lib/signals.js +++ b/src/lib/signals.js @@ -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) : '', + }; } } diff --git a/src/runner.js b/src/runner.js index e76632f..2c3e5a8 100644 --- a/src/runner.js +++ b/src/runner.js @@ -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 } = {}) { diff --git a/src/schedule.js b/src/schedule.js index 40ecb65..abfa2ae 100644 --- a/src/schedule.js +++ b/src/schedule.js @@ -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}`; } // 全角转半角(支持全角数字和冒号输入) diff --git a/src/sites/akile.js b/src/sites/akile.js index 99ed781..9f8a1b2 100644 --- a/src/sites/akile.js +++ b/src/sites/akile.js @@ -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}`) }; }, }, diff --git a/src/sites/http.js b/src/sites/http.js index 44a7d45..70070e5 100644 --- a/src/sites/http.js +++ b/src/sites/http.js @@ -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 = { diff --git a/src/sites/hutue.js b/src/sites/hutue.js index 8d08e97..441fabb 100644 --- a/src/sites/hutue.js +++ b/src/sites/hutue.js @@ -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 子主题, -// 签到按钮 的处理器在 xb-app.js: -// $.post(caozhuti.ajaxurl, { action: "xb_user_qiandao" }, ...) → 1 == n.status 为成功 -// 首页内联脚本里虽然有 ajaxurl,但不会写出 action 名,所以这个兜底顺序很重要(少打一次无用请求)。 -// 站点主题自己一定有的 action:永远放最高优先级,不靠猜也不靠记。 -const SITE_ACTIONS = ['xb_user_qiandao']; +// 站点真正的签到 action:永远放最高优先级,不靠猜也不靠记。 +// +// 【重要踩坑】站点有两个长得几乎一样的「签到」,奖励完全不同: +// ① 真签到:首页右侧悬浮窗 打卡签到, +// 首页文案写着「每天签到 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 积分 +// 而页面上的悬浮窗就是 , +// 即**真签到 = 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 里藏着另一个「会员中心签到」(奖励完全不同)。 + // 不排序的话,先抓到哪个全看页面里 ' - + ''; +// 真实站点首页(截取自 dj.hutue.cn 2026-09-28): +// ① 悬浮窗按钮 打卡签到,文案「每天签到 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 = '' + + '' + + '' + + '' + + '
仙尊会员可享全站资源免费下载(需1晶石,每天签到5晶石)
' + + '签到' + + '' + + ''; + +// ---------- 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: '首页' }; 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: '首页' }; - 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: '首页' }; + 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 () => '首页' }; + 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 () => '首页' }; + 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 () => '首页' }; + 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} 组通过`); diff --git a/test/relay.test.mjs b/test/relay.test.mjs new file mode 100644 index 0000000..d90ef34 --- /dev/null +++ b/test/relay.test.mjs @@ -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('首页', '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} 组通过`); diff --git a/test/run-detail.test.mjs b/test/run-detail.test.mjs index 03be418..03a28f4 100644 --- a/test/run-detail.test.mjs +++ b/test/run-detail.test.mjs @@ -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('/') ? '首页' : 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} 组通过`); diff --git a/test/schedule.test.mjs b/test/schedule.test.mjs index 1270e0e..c2a5f52 100644 --- a/test/schedule.test.mjs +++ b/test/schedule.test.mjs @@ -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 时区换算', () => { diff --git a/test/signals.test.mjs b/test/signals.test.mjs index 9c8de53..73c49e9 100644 --- a/test/signals.test.mjs +++ b/test/signals.test.mjs @@ -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} 组通过`); diff --git a/test/sites-new.test.mjs b/test/sites-new.test.mjs index e2c8786..ee08a7f 100644 --- a/test/sites-new.test.mjs +++ b/test/sites-new.test.mjs @@ -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: '每日签到 签到完毕' }, + { match: () => true, body: '不该走到这里' }, + ]); + 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 的两态识别(用户截图确认的站点按钮语义) ---------- +// 未签到 → 顶部按钮「📋 打卡签到」(橙色、可点);已签到 → 「✅ 签到完毕」(绿色、点了没反应)。 +// 注意: 的 title 属性里常年写着「打卡签到」,所以只能读按钮元素自己的文字。 +await t('52pojie:buttonState 三态(文字版 / 图片版 / 无按钮)', async () => { + const signedText = '
签到完毕
'; + const unsignedText = '
打卡签到
'; + // 52pojie 实际就是这样(用户 DevTools 截图):状态被画在 qds.png 里,文本节点是空的, + // 只能靠「那个「去签到」链接还在不在」来判断 + const unsignedImg = '

' + + '' + + '|

'; + const signedImg = '

签到完毕

'; + const unknown = '普通页面,没有那个按钮'; + 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 = '签到完毕' + + '
每日签到已完成
'; + 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 = '

' + + '
每日签到立即申请
'; + const SUCCESS = ''; + 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 = '
打卡签到
每日签到'; + 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 = '
签到完毕
'; + 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: '
' + (btnText || '') + '
' }, + 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: 'waf_zw_verify 请完成安全验证' }]); 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): +// · 站点把当天状态画在 里, +// 文本节点是空的 —— 链接还在 = 今天还没签(脚本界通行判法); +// · 任务 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 = '

' + + '

' + + '
每日签到立即申请
'; + const urls = []; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url) => { + const u = String(url); urls.push(u); + if (u.includes('do=apply')) return mkRes(''); + 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} 组通过`); diff --git a/test/v2board.test.mjs b/test/v2board.test.mjs index dcd988c..a2ab30f 100644 --- a/test/v2board.test.mjs +++ b/test/v2board.test.mjs @@ -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 () => { diff --git a/tools/gen-ext-files.mjs b/tools/gen-ext-files.mjs index 238e1e0..3224ec7 100644 --- a/tools/gen-ext-files.mjs +++ b/tools/gen-ext-files.mjs @@ -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)}`; }); diff --git a/tools/gen-table-preview.mjs b/tools/gen-table-preview.mjs index bff680c..144b58f 100644 --- a/tools/gen-table-preview.mjs +++ b/tools/gen-table-preview.mjs @@ -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 = ` diff --git a/tools/gen-ui-preview.mjs b/tools/gen-ui-preview.mjs index d80079c..69e2788 100644 --- a/tools/gen-ui-preview.mjs +++ b/tools/gen-ui-preview.mjs @@ -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('
', h2Abs); + // 注意:不是 '
'——卡上带 style(如
)时 + // 会匹配不到,lastIndexOf 就会往回找到上一张卡,把两张卡搞混。 + const cardStart = html.lastIndexOf('
外壳:' + title); return balancedDivAt(cardStart); } @@ -59,16 +61,31 @@ const accSecEnd = html.indexOf('
'); +// 「签到时间 + 添加账号 + 扩展下载」现在是同一张两栏卡(左:时间/扩展,右:粘贴添加) const schedCard = cardByTitle('签到时间', accSecStart, accSecEnd); -const cookieCard = cardByTitle('添加 / 更新账号', accSecStart, accSecEnd); +const comCard = cardByTitle('🌍 社区站点', accSecStart, accSecEnd); +const footStart = html.indexOf('
', accSecStart); +const foot = footStart < 0 ? '' : balancedDivAt(footStart); const extCard = cardByTitle('🔌 浏览器扩展', html.indexOf('
= 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 = `

首页顶栏 · 扩展在线(绿,呼吸点)

${stateTopbar('on', '扩展在线(自动中继已启用)', '可切「本地网络」:请求经您的浏览器发出')} -

签到时间(已移到「签到账号」页,账号表下面)

+

签到账号(表格)

+ ${accCard} + +

签到时间 + 添加账号 + 扩展下载(同一张两栏卡)

${schedCard} -

添加 / 更新账号(一个粘贴框搞定)

- ${cookieCard} +

社区站点(开源共享:导入 / 导出同一张卡)

+ ${comCard}

设置页 · 浏览器扩展

${extCard} + +

页脚说明(含开源仓库地址)

+ ${foot}
diff --git a/项目文档-记忆与踩坑.md b/项目文档-记忆与踩坑.md index b62ec65..cf9d534 100644 --- a/项目文档-记忆与踩坑.md +++ b/项目文档-记忆与踩坑.md @@ -54,10 +54,13 @@ ### 扩展(cookie插件2.2) - `manifest.json`:权限 cookies / activeTab / scripting / storage / alarms / tabs,host_permissions ``。 -- `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) | 奖励 | +|---|---|---|---| +| 首页右侧悬浮窗 `打卡签到`,**真签到** | 父主题 `/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`(父主题主文件)优先」排序,不再靠 `