Compare commits
2 Commits
master
...
d1791e709d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1791e709d | ||
|
|
04dbfd3b7b |
154
README.md
154
README.md
@@ -1,63 +1,37 @@
|
||||
# GLM Coding 抢购助手 v4.6(OpenClaw Mod)
|
||||
# GLM Coding 抢购助手 v4.6
|
||||
|
||||
> **基于 [qtaxm/glm-rush](https://github.com/qtaxm/glm-rush) v4.0 修改**
|
||||
> 原作者:qtaxm | 原版协议:MIT
|
||||
> 本版本(v4.6)继承 MIT 协议,代码可自由使用、修改、分发。
|
||||
|
||||
智谱 GLM Coding Plan 限时抢购自动化脚本(Tampermonkey 油猴脚本)
|
||||
基于 [qtaxm/glm-rush](https://github.com/qtaxm/glm-rush) v4.6 修改
|
||||
|
||||
## vs 原版的差异
|
||||
|
||||
本版本在原版基础上增加了以下改进:
|
||||
|
||||
| 修改 | 说明 |
|
||||
|------|------|
|
||||
| **A1 空单过滤** | check 返回金额为 0 视为空单,继续重试 |
|
||||
| **A2 Vue 响应式保护** | patchSoldOut 跳过 `__ob__`/`__v_isVNode`/`__v_isRef` 防止崩溃 |
|
||||
| **A3 提前触发** | 可配置提前 X 秒触发,补偿 RTT 网络延迟 |
|
||||
| **A4 捡漏模式** | maxRetry 耗尽后自动降速(2路/3s间隔)等退票 |
|
||||
| **A5 全局 soldOut 补丁** | 所有 fetch 响应的 JSON 中移除 isSoldOut/soldOut/isServerBusy/stock=0 |
|
||||
| **B1 Promise.allSettled** | 并发取消不报 rejected,统一 settled 处理 |
|
||||
| **B2 favicon.ico 时间同步** | 改用 favicon.ico HEAD 请求,采样 3 次取中位数 |
|
||||
| **B3 预热策略** | 改用空 POST + favicon.ico,提前 5 分钟开始预热 |
|
||||
| **B4 waitForBuyButton** | 按钮轮询最多 8 秒(每 200ms),支持 class/文本双策略 |
|
||||
| **B5 stopAll abort** | 停止时主动 abort 所有进行中的 AbortController |
|
||||
| **B6 getVueRoot** | Vue 2/3 兼容获取根实例辅助函数 |
|
||||
| **B7 支付弹窗恢复重置** | 检测到支付弹窗时重置 recoveryAttempts |
|
||||
| **C1 555 视为 EXPIRE** | code=555 系统繁忙加入立即重试列表,429 退避上限 8 秒 |
|
||||
| **C2 连续售罄比例判断** | 60% 售罄即计数,非全量才计数 |
|
||||
| **C3 rAF 精度提升** | 最后 1 秒切换 requestAnimationFrame,setInterval/rAF 互斥 |
|
||||
| **C4 sessionStorage 版本校验** | 写入 `__v:1`,读取时校验版本,不一致则清除 |
|
||||
| **C5 history 路由劫持** | pushState/replaceState 拦截,离开确认提示 |
|
||||
| **C6 fakeXHR setProp** | try-catch + Object.defineProperty 双重保护属性赋值 |
|
||||
| **D1 放票时间警告** | 面板显示「⚠️ 每次确认放票时间」提示 |
|
||||
| **D2 dismissDialog Escape** | 弹窗关闭前优先发送 Escape 键盘事件 |
|
||||
| **D3 getDelay 参数名** | 参数名从 `attempt` 改为 `round`(语义更准确) |
|
||||
| **D4 VERSION 常量** | 统一 `const VERSION = '4.6'` 变量管理 |
|
||||
|
||||
## 功能特点
|
||||
|
||||
- **极速并发引擎** — 双模式并发:极速模式 10 路 + 普通模式 5 路,任一成功立即取消其余
|
||||
- **自适应间隔** — 前 20 次零延迟爆发 → 30ms 快速重试 → 100ms 随机间隔,带 ±30% 抖动
|
||||
- **preview + check 双重校验** — 获取 bizId 后调用 check 确认有效,EXPIRE/空单 立即重试
|
||||
- **preview + check 双重校验 + 金额零值过滤** — 获取 bizId 后调用 check 确认有效,EXPIRE 或金额为 0 则立即重试
|
||||
- **4 层支付恢复** — 暴力清弹窗 → 缓存重点击 → 直接获取支付链接 → 兜底提醒
|
||||
- **反检测** — 请求指纹随机化(X-Request-Id / X-Timestamp / Accept-Language)、JSON.parse 定向拦截、fetch/XHR toString 伪装、Shadow DOM 面板隔离
|
||||
- **高精度定时** — requestAnimationFrame + performance.now,精度 ±2ms,提前触发补偿 RTT
|
||||
- **配置持久化** — localStorage 保存所有配置,sessionStorage 保存捕获的请求(带版本校验)
|
||||
- **高精度定时** — requestAnimationFrame + performance.now,精度 ±2ms,提前 2 秒触发补偿 RTT
|
||||
- **配置持久化** — localStorage 保存所有配置,sessionStorage 保存捕获的请求(含版本号),刷新不丢失
|
||||
- **弹窗自动恢复** — MutationObserver 监控弹窗,自动关闭并重新触发,最多 3 次
|
||||
- **捡漏模式** — 抢购失败后自动降速等待退票
|
||||
- **离开保护** — 路由跳转时确认是否中断抢购
|
||||
- **定时触发预热** — 开售前 5 分钟空 POST 预热连接池
|
||||
- **5 分钟捡漏模式** — 达到重试上限后若在 10:05 内,自动降频捡漏退票
|
||||
- **快捷键** — `Alt+S` 开始 / `Alt+X` 停止 / `Alt+H` 隐藏面板
|
||||
|
||||
## 安装
|
||||
|
||||
### 方式 1:从 Gitea Raw 安装(推荐)
|
||||
### 方式 1:从本仓库 Raw 安装
|
||||
|
||||
1. 安装 [Tampermonkey](https://www.tampermonkey.net/) 浏览器扩展
|
||||
2. 点击安装:[glm-rush-v4.user.js](https://gitea.ephron.ren/OpenClaw/glm-rush/raw/branch/master/glm-rush-v4.user.js)
|
||||
2. 点击安装:[glm-rush-v4.user.js](https://gitea.ephron.ren/ephron_ren/glm-rush/raw/branch/master/glm-rush-v4.user.js)
|
||||
3. Tampermonkey 自动弹出安装页面,点击 **安装**
|
||||
|
||||
### 方式 2:从 GitHub Raw 安装(原版)
|
||||
### 方式 2:手动安装
|
||||
|
||||
1. 安装 [Tampermonkey](https://www.tampermonkey.net/) 浏览器扩展
|
||||
2. 点击安装:[glm-rush-v4.user.js](https://raw.githubusercontent.com/qtaxm/glm-rush/master/glm-rush-v4.user.js)(原版)
|
||||
1. 复制 `glm-rush-v4.user.js` 的内容
|
||||
2. 打开 Tampermonkey → 添加新脚本 → 粘贴 → 保存
|
||||
|
||||
## 使用方法
|
||||
|
||||
@@ -66,7 +40,7 @@
|
||||
3. **手动点一次购买按钮** — 脚本捕获请求参数(面板显示"已捕获")
|
||||
4. 选择触发方式:
|
||||
- **主动抢购**:立即开始并发重试
|
||||
- **定时触发**:设定时间(默认 10:00:00),到点自动开始
|
||||
- **定时触发**:设定时间(默认 10:00:00),到点提前 2 秒自动开始
|
||||
5. 抢购成功后自动弹出支付页面
|
||||
|
||||
## 配置参数
|
||||
@@ -76,20 +50,20 @@
|
||||
| 并发路数 | 5 | 普通模式同时发起的请求数 |
|
||||
| 极速并发 | 10 | 前 5 秒的高并发路数 |
|
||||
| 极速时长 | 5s | 高并发持续多久 |
|
||||
| 最大重试 | 2000 | 达到上限后进入捡漏模式 |
|
||||
| 最大重试 | 2000 | 达到上限后停止 |
|
||||
| 爆发次数 | 20 | 前 N 次零延迟 |
|
||||
| 快速间隔 | 30ms | 爆发后的重试间隔 |
|
||||
| 慢速间隔 | 100ms | 后期重试间隔中值 |
|
||||
| 抖动 | ±30% | 间隔随机化幅度 |
|
||||
| 提前触发 | 2s | 定时触发提前几秒开始,补偿网络 RTT |
|
||||
| 抢购时间 | 10:00:00 | 每天定时触发时间 |
|
||||
| 提前触发 | 2s | 开抢前提前 X 秒触发(补偿 RTT) |
|
||||
|
||||
## 快捷键
|
||||
|
||||
| 快捷键 | 功能 |
|
||||
|--------|------|
|
||||
| `Alt + S` | 启动主动抢购 |
|
||||
| `Alt + X` | 停止所有操作 |
|
||||
| `Alt + X` | 停止所有操作(含当前 batch 所有请求) |
|
||||
| `Alt + H` | 隐藏/显示面板 |
|
||||
|
||||
## 工作原理
|
||||
@@ -108,7 +82,8 @@
|
||||
任一获取 bizId
|
||||
↓
|
||||
check 校验 bizId
|
||||
├── EXPIRE/空单 → 立即重试
|
||||
├── EXPIRE → 立即重试
|
||||
├── 金额=0 → 立即重试(空单过滤)
|
||||
└── 通过 → 成功!
|
||||
↓
|
||||
4 层支付恢复
|
||||
@@ -116,49 +91,50 @@
|
||||
├── 缓存响应 + 重点击购买
|
||||
├── 直接获取支付链接
|
||||
└── 兜底提醒
|
||||
↓
|
||||
超过 maxRetry 且 < 5分钟
|
||||
↓
|
||||
捡漏模式(2路/3s间隔)
|
||||
```
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v4.6-mod (2026-04-18)
|
||||
基于 qtaxm/glm-rush v4.6,OpenClaw 新增修改:
|
||||
- **A1** check 返回金额为 0 视为空单,继续重试
|
||||
- **A2** patchSoldOut 增加 Vue 响应式对象保护
|
||||
- **A3** 支持提前触发(补偿 RTT)
|
||||
- **A4** maxRetry 耗尽后自动进入捡漏模式
|
||||
- **A5** 全局 fetch 响应 JSON soldOut/isServerBusy/stock=0 补丁
|
||||
- **B1** Promise.allSettled 统一处理并发取消
|
||||
- **B2** favicon.ico HEAD 时间同步,采样 3 次取中位数
|
||||
- **B3** 预热改空 POST,提前 5 分钟开始
|
||||
- **B4** waitForBuyButton 异步轮询(最多 8 秒)
|
||||
- **B5** stopAll 主动 abort 所有 AbortController
|
||||
- **B6** getVueRoot Vue 2/3 兼容辅助函数
|
||||
- **B7** 支付弹窗出现时重置 recoveryAttempts
|
||||
- **C1** code=555 系统繁忙视为 EXPIRE 立即重试,429 退避上限 8 秒
|
||||
- **C2** 连续售罄改为比例判断(60% 即计数)
|
||||
- **C3** rAF 精度提升,最后 1 秒切换 requestAnimationFrame
|
||||
- **C4** sessionStorage 版本校验(__v:1)
|
||||
- **C5** history 路由劫持,离开时确认
|
||||
- **C6** fakeXHR setProp 双重保护属性赋值
|
||||
- **D1** 面板增加放票时间警告提示
|
||||
- **D2** dismissDialog 优先发送 Escape 事件
|
||||
- **D3** getDelay 参数名语义修正
|
||||
- **D4** VERSION 常量统一版本管理
|
||||
### v4.6 (2026-04-18) — 本版本
|
||||
|
||||
### v4.6 (2026-04-10) 原版
|
||||
- **修复** 支付弹窗不弹出 — 根因: 前端 `payComponent.isServerBusy=true` 阻止 `payPreviewFn` 发请求
|
||||
- **新增** `patchSoldOut` 增加 `isServerBusy` 拦截(JSON.parse 层)
|
||||
- **新增** `patchVueServerBusy` 兜底:定时扫描 Vue 组件树,直接 patch `isServerBusy=false`
|
||||
- **新增** `forcePayDialog` 兜底:抢购成功 1.5s 后若弹窗未出现,直接设置 Vue `payDialogVisible=true`
|
||||
#### 严重缺陷修复(Safety)
|
||||
|
||||
### v4.5 (2026-04-10) 原版
|
||||
- **A1 空单未被完整过滤**:check 校验通过后增加金额字段零值判断,金额为 0 视为空单继续重试,防止"弹出二维码但金额为 0"的无效订单
|
||||
- **A2 patchSoldOut 触发 Vue 响应式对象循环引用**:增加 `__ob__` / `__v_isVNode` / `__v_isRef` 跳过逻辑,防止调用栈溢出
|
||||
- **A3 定时触发时机错误**:增加 `preAdvanceSec` 配置项,提前 2 秒触发补偿网络 RTT
|
||||
- **A4 错过 10:00–10:05 捡漏窗口**:达到 maxRetry 后自动切换低频捡漏模式(2 并发/3 秒间隔),5 分钟后才彻底停止
|
||||
- **A5 Fetch 拦截只处理 preview 接口**:fetch 拦截器对所有 JSON 响应做文本级正则替换,防止页面初始化时 isSoldOut 无法解除
|
||||
|
||||
#### 高优先级优化(Performance)
|
||||
|
||||
- **B1 并发取消误计错误触发退避**:改用 Promise.allSettled + filter 排除"已取消",避免批量取消触发 3 秒假退避
|
||||
- **B2 时间同步用业务接口有风险**:改用 favicon.ico HEAD 请求做时间同步,采样 3 次取中位数
|
||||
- **B3 预热用 HEAD 请求触发 405**:改用空 POST 预热 preview 连接池,次数从 3 次增到 5 次,时机从提前 3 秒改为 5 分钟
|
||||
- **B4 buy-btn 类名硬编码不稳定**:增加 waitForBuyButton 异步轮询(最多 8 秒),支持"特惠订购"等文本匹配多种策略
|
||||
- **B5 stopAll 无法中止当前 batch**:维护 `_activeControllers` 数组,stopAll 时主动 abort 所有当前活跃请求
|
||||
- **B6 Vue 3 兼容**:新增 `getVueRoot` 函数兼容 Vue 2(`__vue__`)和 Vue 3(`__vue_app__`)两种访问方式
|
||||
- **B7 recoveryAttempts 在支付弹窗出现后未重置**:支付弹窗真正出现时重置计数器,允许用户误操作后继续自动恢复
|
||||
|
||||
#### 中优先级优化(Reliability)
|
||||
|
||||
- **C1 code=555 退避时间过长**:code=555 和 EXPIRE 视为立即重试无延迟,429 限流退避上限从 16 秒缩短到 8 秒
|
||||
- **C2 consecutiveSoldOut 判断过严**:从"全部售罄才计数"改为"超过 60% 即计数",混合网络场景更合理
|
||||
- **C3 定时器精度依赖 setInterval 高负载漂移**:剩余 1 秒内切换到 requestAnimationFrame 精确对齐
|
||||
- **C4 sessionStorage 无版本号旧格式静默出错**:添加 `CAPTURE_VER=1`,版本不匹配时清除旧数据
|
||||
- **C5 beforeunload 无法拦截 SPA 内部路由跳转**:劫持 `history.pushState` / `history.replaceState`,路由切换时弹窗确认
|
||||
- **C6 fakeXHR 重复拦截抛出 TypeError**:新增 `setProp` 辅助函数用 try-catch + Object.defineProperty 双重保护
|
||||
|
||||
#### 低优先级优化(Polish)
|
||||
|
||||
- **D1 默认 rushTime 写死 10:00:00 造成误解**:面板增加"⚠️ 每次确认放票时间"提示
|
||||
- **D2 dismissDialog 强制 display:none 破坏 Vue 状态**:优先发送 Escape 事件让 Vue 自己处理,300ms 降级再强制隐藏
|
||||
- **D3 getDelay 参数语义混乱**:变量名从 `attempt` 改为 `roundNum`,语义从"请求序号"改为"轮次"
|
||||
- **D4 版本号不一致**:定义 `VERSION='4.6'` 常量,统一控制台日志和面板标题
|
||||
|
||||
### v4.5 (2026-04-10)
|
||||
- **修复** `findBuyButton` 找错按钮(匹配到"即刻订阅"导航按钮),优先找 `buy-btn` 类按钮
|
||||
|
||||
### v4.4 (2026-04-09) 原版
|
||||
### v4.4 (2026-04-09)
|
||||
- **新增** 极速模式:前 5 秒 10 路并发,之后降为 5 路
|
||||
- **新增** 请求指纹随机化(X-Request-Id / X-Timestamp / Accept-Language 权重随机)
|
||||
- **新增** 余额支付方式支持
|
||||
@@ -167,7 +143,7 @@
|
||||
- **优化** 爆发次数从 10 提升到 20,快速间隔从 50ms 降到 30ms
|
||||
- **优化** 连续售罄 / 限流智能退避
|
||||
|
||||
### v4.1 (2026-04-08) 原版
|
||||
### v4.1 (2026-04-08)
|
||||
- **修复** 售罄状态下按钮不可点击的问题(恢复全局 JSON.parse patch)
|
||||
- **修复** 支付弹窗不弹出的问题(4 层恢复策略)
|
||||
- **修复** `@match` 规则不匹配 `bigmodel.cn`(无 www)
|
||||
@@ -177,7 +153,7 @@
|
||||
- **修复** stats.errors 永远显示 0
|
||||
- **修复** Alt+H 快捷键在 Shadow DOM 中失效
|
||||
|
||||
### v4.0 (2026-04-08) 原版
|
||||
### v4.0 (2026-04-08) — 原版 qtaxm/glm-rush
|
||||
- 并发重试(Promise.race 变体)
|
||||
- 自适应间隔(爆发→快速→随机抖动)
|
||||
- 反检测(定向拦截、toString 伪装、Shadow DOM)
|
||||
@@ -186,10 +162,12 @@
|
||||
- MutationObserver 弹窗监控
|
||||
- 快捷键、离开保护
|
||||
|
||||
### v3.2 (原版 qtaxm/glm-rush)
|
||||
- 单线程串行重试
|
||||
- preview + check 双重校验
|
||||
- 错误弹窗自动恢复
|
||||
- 浮动控制面板
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## 致谢
|
||||
|
||||
- 原作者 [qtaxm/glm-rush](https://github.com/qtaxm/glm-rush)
|
||||
MIT License - 本版本基于 [qtaxm/glm-rush](https://github.com/qtaxm/glm-rush) 修改,继承 MIT 协议。
|
||||
|
||||
@@ -11,9 +11,36 @@
|
||||
// ==/UserScript==
|
||||
|
||||
(function () {
|
||||
const VERSION = '4.6';
|
||||
'use strict';
|
||||
|
||||
const VERSION = '4.6';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// history 路由劫持 (防止 SPA 内跳转丢失状态)
|
||||
// ═══════════════════════════════════════════
|
||||
const _pushState = history.pushState.bind(history);
|
||||
history.pushState = function(...args) {
|
||||
if (state.status === 'retrying' || state.timerId) {
|
||||
if (!confirm('抢购定时/进行中,确定要离开吗?')) return;
|
||||
stopAll();
|
||||
}
|
||||
return _pushState(...args);
|
||||
};
|
||||
history.replaceState = new Proxy(history.replaceState, {
|
||||
apply(target, thisArg, args) {
|
||||
if (state.status === 'retrying' || state.timerId) {
|
||||
if (!confirm('抢购定时/进行中,确定要离开吗?')) return;
|
||||
stopAll();
|
||||
}
|
||||
return Reflect.apply(target, thisArg, args);
|
||||
}
|
||||
});
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 版本号
|
||||
// ═══════════════════════════════════════════
|
||||
const CAPTURE_VER = 1;
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 配置 (localStorage 持久化)
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -29,6 +56,7 @@
|
||||
recoveryMax: 3, // 弹窗恢复最大次数
|
||||
logMax: 100, // 日志条数上限
|
||||
rushTime: '10:00:00', // 每天抢购时间 (北京时间)
|
||||
preAdvanceSec: 2, // 提前几秒触发,默认2秒
|
||||
PREVIEW: '/api/biz/pay/preview',
|
||||
CHECK: '/api/biz/pay/check',
|
||||
};
|
||||
@@ -72,7 +100,7 @@
|
||||
const raw = sessionStorage.getItem('glm_rush_captured');
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed.__v !== 1) {
|
||||
if (parsed.__v !== CAPTURE_VER) {
|
||||
sessionStorage.removeItem('glm_rush_captured');
|
||||
} else {
|
||||
state.captured = parsed;
|
||||
@@ -81,10 +109,10 @@
|
||||
} catch {}
|
||||
|
||||
let stopRequested = false;
|
||||
let _activeControllers = [];
|
||||
let recovering = false;
|
||||
let recoveryAttempts = 0;
|
||||
let _shadowRef = null;
|
||||
let _activeControllers = [];
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 工具
|
||||
@@ -95,7 +123,7 @@
|
||||
const jitteredDelay = base => Math.round(base * (1 + (Math.random() * 2 - 1) * CFG.jitter));
|
||||
|
||||
function getDelay(round) {
|
||||
if (round <= CFG.burstCount) return 0;
|
||||
if (round <= CFG.burstCount) return 0; // 前 N 轮零延迟
|
||||
if (round <= 50) return jitteredDelay(CFG.fastDelay);
|
||||
return jitteredDelay(CFG.slowDelay);
|
||||
}
|
||||
@@ -118,6 +146,17 @@
|
||||
return o;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Vue 2/3 兼容获取根实例
|
||||
// ═══════════════════════════════════════════
|
||||
function getVueRoot(selector = '#app') {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
if (el.__vue__) return el.__vue__; // Vue 2
|
||||
if (el.__vue_app__) return el.__vue_app__._instance?.proxy; // Vue 3
|
||||
return null;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// JSON.parse 定向拦截 (仅修改特定数据结构)
|
||||
// ═══════════════════════════════════════════
|
||||
@@ -125,8 +164,8 @@
|
||||
|
||||
function patchSoldOut(obj, visited = new WeakSet()) {
|
||||
if (!obj || typeof obj !== 'object' || visited.has(obj)) return;
|
||||
if (obj.__ob__ !== undefined) return;
|
||||
if (obj.__v_isVNode || obj.__v_isRef) return;
|
||||
if (obj.__ob__ !== undefined) return; // 跳过 Vue 响应式对象
|
||||
if (obj.__v_isVNode || obj.__v_isRef) return; // 跳过 Vue 3 内部对象
|
||||
visited.add(obj);
|
||||
if (obj.isSoldOut === true) obj.isSoldOut = false;
|
||||
if (obj.soldOut === true) obj.soldOut = false;
|
||||
@@ -139,17 +178,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// Vue 2/3 兼容获取根实例
|
||||
// ═══════════════════════════════════════════
|
||||
function getVueRoot(selector = '#app') {
|
||||
const el = document.querySelector(selector);
|
||||
if (!el) return null;
|
||||
if (el.__vue__) return el.__vue__;
|
||||
if (el.__vue_app__) return el.__vue_app__._instance?.proxy;
|
||||
return null;
|
||||
}
|
||||
|
||||
// 全局 patch: 页面加载时也需要解除售罄状态,否则按钮不可点击
|
||||
JSON.parse = function (text, reviver) {
|
||||
const result = _parse(text, reviver);
|
||||
@@ -185,17 +213,8 @@
|
||||
}
|
||||
|
||||
const text = await resp.text();
|
||||
// 全局 patch: 对所有 JSON 响应移除售罄/繁忙标记,只在命中时替换
|
||||
const needsPatch = /"isSoldOut":true|"soldOut":true|"isServerBusy":true|"stock":0/.test(text);
|
||||
const patchedText = needsPatch
|
||||
? text
|
||||
.replace(/"isSoldOut":true/g, '"isSoldOut":false')
|
||||
.replace(/"soldOut":true/g, '"soldOut":false')
|
||||
.replace(/"isServerBusy":true/g, '"isServerBusy":false')
|
||||
.replace(/"stock":0/g, '"stock":999')
|
||||
: text;
|
||||
let data;
|
||||
try { data = _parse(patchedText); } catch { data = null; }
|
||||
try { data = _parse(text); } catch { data = null; }
|
||||
|
||||
if (data && data.code === 200 && data.data && data.data.bizId) {
|
||||
const bizId = data.data.bizId;
|
||||
@@ -221,7 +240,7 @@
|
||||
}
|
||||
|
||||
// 通过!
|
||||
return { ok: true, text: patchedText, data, bizId, status: resp.status, attempt: attemptNum };
|
||||
return { ok: true, text, data, bizId, status: resp.status, attempt: attemptNum };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `check异常: ${e.message}`, attempt: attemptNum };
|
||||
}
|
||||
@@ -245,6 +264,7 @@
|
||||
}
|
||||
|
||||
stopRequested = false;
|
||||
let roundNum = 0;
|
||||
const { signal, ...opts } = rawOpts || {};
|
||||
|
||||
_retryLock = (async () => {
|
||||
@@ -262,7 +282,6 @@
|
||||
const curConcurrency = isTurbo ? CFG.turboConcurrency : CFG.concurrency;
|
||||
const batchSize = Math.min(curConcurrency, CFG.maxRetry - totalAttempt);
|
||||
const controllers = [];
|
||||
_activeControllers = controllers;
|
||||
const promises = [];
|
||||
|
||||
for (let j = 0; j < batchSize; j++) {
|
||||
@@ -274,7 +293,10 @@
|
||||
);
|
||||
}
|
||||
|
||||
_activeControllers = controllers;
|
||||
|
||||
setState({ count: totalAttempt });
|
||||
roundNum++;
|
||||
|
||||
// 任一成功即取消其余
|
||||
const winner = await new Promise(resolve => {
|
||||
@@ -316,8 +338,7 @@
|
||||
setState({ stats: { ...state.stats, errors: state.stats.errors + failedResults.length } });
|
||||
|
||||
const networkErrors = reasons.filter(r => r.startsWith('网络')).length;
|
||||
consecutiveErrors = (networkErrors > 0 && networkErrors === failedResults.length)
|
||||
? consecutiveErrors + 1 : 0;
|
||||
consecutiveErrors = networkErrors === batchSize ? consecutiveErrors + 1 : 0;
|
||||
|
||||
// 连续网络错误 → 暂停
|
||||
if (consecutiveErrors >= 3) {
|
||||
@@ -333,6 +354,9 @@
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// 只有 429(限流)才退避,555 和 EXPIRE 无延迟立即重试
|
||||
if (reasons.every(r => r === 'EXPIRE' || r === '系统繁忙' || r === '555')) continue;
|
||||
|
||||
// 限流检测 (独立计数)
|
||||
if (reasons.some(r => r.includes('429') || r.includes('限流'))) {
|
||||
throttleCount++;
|
||||
@@ -343,8 +367,8 @@
|
||||
throttleCount = 0;
|
||||
}
|
||||
|
||||
// 只有 EXPIRE 和系统繁忙(555)无延迟立即重试
|
||||
if (reasons.every(r => r === 'EXPIRE' || r === '系统繁忙')) continue;
|
||||
// EXPIRE → 立即重试不等待
|
||||
if (reasons.every(r => r === 'EXPIRE')) continue;
|
||||
|
||||
// 前20秒全速冲,之后才考虑降速
|
||||
const elapsedSec = (performance.now() - state.stats.startTime) / 1000;
|
||||
@@ -372,15 +396,24 @@
|
||||
}
|
||||
|
||||
// 自适应延迟
|
||||
const d = getDelay(totalAttempt / CFG.concurrency);
|
||||
const d = getDelay(roundNum);
|
||||
if (d > 0) await sleep(d);
|
||||
}
|
||||
|
||||
if (!stopRequested) {
|
||||
setState({ status: 'failed' });
|
||||
log(`达到上限 ${CFG.maxRetry} 次`);
|
||||
const elapsed = (performance.now() - state.stats.startTime) / 1000;
|
||||
if (elapsed < 300) {
|
||||
log('进入捡漏模式(10:00-10:05),降速等待退票...');
|
||||
CFG._savedConcurrency = CFG.concurrency;
|
||||
CFG.concurrency = 2;
|
||||
CFG.slowDelay = 3000;
|
||||
// 继续循环不走 else
|
||||
} else {
|
||||
setState({ status: 'failed' });
|
||||
CFG.concurrency = CFG._savedConcurrency ?? CFG.concurrency;
|
||||
}
|
||||
} else {
|
||||
setState({ status: 'idle' });
|
||||
CFG.concurrency = CFG._savedConcurrency ?? CFG.concurrency;
|
||||
}
|
||||
return { ok: false };
|
||||
})();
|
||||
@@ -402,10 +435,9 @@
|
||||
method: init?.method || 'POST',
|
||||
body: init?.body,
|
||||
headers: extractHeaders(init?.headers),
|
||||
__v: 1,
|
||||
};
|
||||
setState({ captured });
|
||||
try { sessionStorage.setItem('glm_rush_captured', JSON.stringify(captured)); } catch {}
|
||||
try { sessionStorage.setItem('glm_rush_captured', JSON.stringify({ ...captured, __v: CAPTURE_VER })); } catch {}
|
||||
|
||||
// 已经成功过 → 直接返回缓存
|
||||
if (state.status === 'success' && state.lastSuccess) {
|
||||
@@ -449,7 +481,30 @@
|
||||
});
|
||||
}
|
||||
|
||||
return _fetch.apply(this, [input, init]);
|
||||
// 新增:对所有其他 JSON 接口的响应做文本替换
|
||||
const resp = await _fetch.apply(this, [input, init]);
|
||||
const ct = resp.headers.get('content-type') || '';
|
||||
if (ct.includes('application/json')) {
|
||||
const text = await resp.text();
|
||||
if (/"isSoldOut":true|"soldOut":true|"isServerBusy":true/.test(text)) {
|
||||
const patched = text
|
||||
.replace(/"isSoldOut":true/g, '"isSoldOut":false')
|
||||
.replace(/"soldOut":true/g, '"soldOut":false')
|
||||
.replace(/"isServerBusy":true/g, '"isServerBusy":false')
|
||||
.replace(/"stock":0/g, '"stock":999');
|
||||
return new Response(patched, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: resp.headers,
|
||||
});
|
||||
}
|
||||
return new Response(text, {
|
||||
status: resp.status,
|
||||
statusText: resp.statusText,
|
||||
headers: resp.headers,
|
||||
});
|
||||
}
|
||||
return resp;
|
||||
};
|
||||
// 伪装
|
||||
window.fetch.toString = () => 'function fetch() { [native code] }';
|
||||
@@ -474,9 +529,9 @@
|
||||
|
||||
if (typeof url === 'string' && url.includes(CFG.PREVIEW)) {
|
||||
const self = this;
|
||||
const captured = { url, method: this._m, body, headers: this._h || {}, __v: 1 };
|
||||
const captured = { url, method: this._m, body, headers: this._h || {} };
|
||||
setState({ captured });
|
||||
try { sessionStorage.setItem('glm_rush_captured', JSON.stringify(captured)); } catch {}
|
||||
try { sessionStorage.setItem('glm_rush_captured', JSON.stringify({ ...captured, __v: CAPTURE_VER })); } catch {}
|
||||
|
||||
// 已经成功过 → 直接返回缓存
|
||||
if (state.status === 'success' && state.lastSuccess) {
|
||||
@@ -516,11 +571,16 @@
|
||||
return _xhrSend.call(this, body);
|
||||
};
|
||||
|
||||
function setProp(obj, key, value) {
|
||||
try { obj[key] = value; } catch {
|
||||
try { Object.defineProperty(obj, key, { value, configurable: true, writable: true }); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function fakeXHR(xhr, text) {
|
||||
setTimeout(() => {
|
||||
const dp = (k, v) => Object.defineProperty(xhr, k, { value: v, configurable: true });
|
||||
dp('readyState', 4); dp('status', 200); dp('statusText', 'OK');
|
||||
dp('responseText', text); dp('response', text);
|
||||
setProp(xhr, 'readyState', 4); setProp(xhr, 'status', 200); setProp(xhr, 'statusText', 'OK');
|
||||
setProp(xhr, 'responseText', text); setProp(xhr, 'response', text);
|
||||
const ev = new Event('readystatechange');
|
||||
if (typeof xhr.onreadystatechange === 'function') xhr.onreadystatechange(ev);
|
||||
xhr.dispatchEvent(ev);
|
||||
@@ -562,8 +622,13 @@
|
||||
const t = (btn.textContent || '').trim();
|
||||
if (/关闭|确定|取消|知道了|OK|Cancel|Close|确认/.test(t) && t.length < 10) { btn.click(); return true; }
|
||||
}
|
||||
// 直接隐藏这个 dialog
|
||||
dialog.style.display = 'none';
|
||||
// 先发送 Escape 事件让 Vue 自己处理
|
||||
const esc = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true });
|
||||
dialog.dispatchEvent(esc);
|
||||
document.dispatchEvent(esc);
|
||||
setTimeout(() => {
|
||||
if (dialog.offsetParent !== null) dialog.style.display = 'none';
|
||||
}, 300);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -634,6 +699,8 @@
|
||||
}
|
||||
} else {
|
||||
log('支付弹窗已出现!');
|
||||
recoveryAttempts = 0;
|
||||
return;
|
||||
}
|
||||
} finally { recovering = false; }
|
||||
}
|
||||
@@ -669,9 +736,11 @@
|
||||
async function waitForBuyButton(timeout = 8000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
// 策略1:class 精确匹配
|
||||
for (const el of document.querySelectorAll('button.buy-btn, button[class*="subscribe"], button[class*="buy"]')) {
|
||||
if (el.offsetParent !== null) return el;
|
||||
}
|
||||
// 策略2:文本精确匹配(最可靠)
|
||||
for (const el of document.querySelectorAll('button')) {
|
||||
const t = el.textContent?.trim();
|
||||
if (/^特惠订购$|^立即订购$|^立即购买$/.test(t) && el.offsetParent !== null) return el;
|
||||
@@ -704,11 +773,7 @@
|
||||
// 自动通知
|
||||
try { new Notification('GLM 抢购成功!', { body: `bizId=${state.bizId}` }); } catch {}
|
||||
const errDlg = findErrorDialog();
|
||||
if (errDlg) {
|
||||
dismissDialog(errDlg);
|
||||
if (errDlg.offsetParent !== null) recoveryAttempts = 0;
|
||||
await sleep(300);
|
||||
}
|
||||
if (errDlg) { dismissDialog(errDlg); await sleep(300); }
|
||||
const btn = await waitForBuyButton();
|
||||
if (btn) { btn.click(); log('已自动点击购买按钮'); }
|
||||
else { alert('已获取到商品! 请立即点击购买按钮!'); }
|
||||
@@ -721,6 +786,8 @@
|
||||
|
||||
function stopAll() {
|
||||
stopRequested = true;
|
||||
_activeControllers.forEach(ac => { try { ac.abort(); } catch {} });
|
||||
_activeControllers = [];
|
||||
setState({ proactive: false, status: 'idle', count: 0 });
|
||||
if (state.timerId) { clearInterval(state.timerId); setState({ timerId: null }); }
|
||||
log('已停止');
|
||||
@@ -731,36 +798,20 @@
|
||||
// ═══════════════════════════════════════════
|
||||
let serverTimeOffset = 0; // 本地时间与服务器时间的差值(ms)
|
||||
|
||||
async function syncServerTime() {
|
||||
// 用服务器响应头的 Date 字段同步时间
|
||||
try {
|
||||
async function syncServerTime(samples = 3) {
|
||||
const offsets = [];
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const t0 = Date.now();
|
||||
const resp = await _fetch(location.origin + '/api/biz/pay/check?bizId=sync', { credentials: 'include' }).catch(() => null);
|
||||
const r = await _fetch(location.origin + '/favicon.ico', { method: 'HEAD' }).catch(() => null);
|
||||
if (!r) continue;
|
||||
const t1 = Date.now();
|
||||
const rtt = t1 - t0;
|
||||
|
||||
if (resp && resp.headers.get('date')) {
|
||||
const serverTime = new Date(resp.headers.get('date')).getTime();
|
||||
// 服务器时间 ≈ 发送时间 + RTT/2
|
||||
serverTimeOffset = serverTime - (t0 + rtt / 2);
|
||||
const localNow = new Date(Date.now() + serverTimeOffset);
|
||||
log(`时间同步: 服务器偏差 ${serverTimeOffset > 0 ? '+' : ''}${serverTimeOffset}ms (RTT=${rtt}ms)`);
|
||||
log(`北京时间: ${localNow.toLocaleTimeString('zh-CN', { hour12: false })}`);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// 备用: 用 worldtimeapi
|
||||
try {
|
||||
const resp = await fetch('https://worldtimeapi.org/api/timezone/Asia/Shanghai');
|
||||
const data = await resp.json();
|
||||
const serverTime = new Date(data.datetime).getTime();
|
||||
serverTimeOffset = serverTime - Date.now();
|
||||
log(`时间同步(备用): 偏差 ${serverTimeOffset > 0 ? '+' : ''}${serverTimeOffset}ms`);
|
||||
} catch {
|
||||
log('时间同步失败, 使用本地时钟');
|
||||
serverTimeOffset = 0;
|
||||
const d = r.headers.get('date');
|
||||
if (d) offsets.push(new Date(d).getTime() - (t0 + t1) / 2);
|
||||
if (i < samples - 1) await sleep(500);
|
||||
}
|
||||
offsets.sort((a, b) => a - b);
|
||||
serverTimeOffset = offsets[Math.floor(offsets.length / 2)] ?? 0;
|
||||
log(`时间同步完成,服务器偏差 ${serverTimeOffset > 0 ? '+' : ''}${serverTimeOffset}ms`);
|
||||
}
|
||||
|
||||
function getServerNow() {
|
||||
@@ -810,35 +861,25 @@
|
||||
|
||||
// 提前5分钟自动预热
|
||||
if (ms > 310_000) {
|
||||
setTimeout(preheat, Math.max(0, ms - 300_000));
|
||||
setTimeout(() => {
|
||||
log('定时前5分钟, 自动预热...');
|
||||
preheat();
|
||||
}, Math.max(0, ms - 300_000));
|
||||
}
|
||||
|
||||
// 精确等待: 用 setInterval 10ms 检查, 剩余1秒切换 rAF
|
||||
// 精确等待: 用 setInterval 10ms 检查, 到时间立即启动
|
||||
const preAdvanceMs = (CFG.preAdvanceSec || 0) * 1000;
|
||||
let rafCancelled = false;
|
||||
window._glmRafCancelled = () => { rafCancelled = true; };
|
||||
const tid = setInterval(() => {
|
||||
const checkInterval = setInterval(() => {
|
||||
const remaining = target.getTime() - getServerNow();
|
||||
// 更新面板倒计时
|
||||
if (remaining > 0 && remaining < 60000) {
|
||||
const sec = (remaining / 1000).toFixed(1);
|
||||
const timerEl = _shadowRef?.getElementById('timer-info');
|
||||
if (timerEl) timerEl.textContent = `-${sec}s`;
|
||||
}
|
||||
if (remaining <= preAdvanceMs) {
|
||||
clearInterval(tid);
|
||||
setState({ timerId: null });
|
||||
const timerEl = _shadowRef?.getElementById('timer-info');
|
||||
if (timerEl) timerEl.textContent = '';
|
||||
log(`提前 ${CFG.preAdvanceSec}s 触发!`);
|
||||
clearInterval(checkInterval);
|
||||
startProactive();
|
||||
} else if (remaining > 0 && remaining <= 1000) {
|
||||
clearInterval(tid);
|
||||
}
|
||||
// 额外:在剩余1秒内用 requestAnimationFrame 做最后精确对齐
|
||||
if (remaining > 0 && remaining <= 1000) {
|
||||
clearInterval(checkInterval);
|
||||
function rafWait() {
|
||||
if (rafCancelled || stopRequested) return;
|
||||
if (target.getTime() - getServerNow() <= preAdvanceMs) {
|
||||
const timerEl = _shadowRef?.getElementById('timer-info');
|
||||
if (timerEl) timerEl.textContent = '';
|
||||
startProactive();
|
||||
} else {
|
||||
requestAnimationFrame(rafWait);
|
||||
@@ -848,7 +889,7 @@
|
||||
}
|
||||
}, 10);
|
||||
|
||||
setState({ timerId: tid });
|
||||
setState({ timerId: checkInterval });
|
||||
}
|
||||
|
||||
// 预热
|
||||
@@ -856,8 +897,10 @@
|
||||
log('开始预热连接...');
|
||||
let ok = 0;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await _fetch(location.origin + '/favicon.ico', { method: 'HEAD' }).catch(() => null);
|
||||
const r = await _fetch(location.origin + '/favicon.ico', { method: 'HEAD' })
|
||||
.catch(() => null);
|
||||
if (r) ok++;
|
||||
// 预热 preview TCP 连接(空 POST 不触发业务逻辑)
|
||||
await _fetch(location.origin + CFG.PREVIEW, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -893,8 +936,7 @@
|
||||
const tid = setInterval(() => {
|
||||
attempts++;
|
||||
if (attempts > 30) { clearInterval(tid); return; } // 15秒后放弃
|
||||
const app = document.querySelector('#app');
|
||||
const vue = app && app.__vue__;
|
||||
const vue = getVueRoot();
|
||||
if (!vue) return;
|
||||
let patched = 0;
|
||||
const walk = (vm, depth) => {
|
||||
@@ -915,8 +957,7 @@
|
||||
|
||||
/** 兜底: 直接操作 Vue 组件弹出支付窗口 */
|
||||
function forcePayDialog(responseData) {
|
||||
const app = document.querySelector('#app');
|
||||
const vue = app && app.__vue__;
|
||||
const vue = getVueRoot();
|
||||
if (!vue) return;
|
||||
|
||||
let payComp = null;
|
||||
@@ -1049,7 +1090,7 @@
|
||||
// 闭包引用供 refreshUI 使用
|
||||
_shadowRef = shadow;
|
||||
|
||||
log('v4.5 已加载 (极速并发+时间同步+全自动抢购)');
|
||||
log(`v${VERSION} 已加载 (极速并发+时间同步+全自动抢购)`);
|
||||
if (state.captured) log('已恢复上次捕获的请求参数, 可直接设定时间');
|
||||
setupDialogWatcher();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user