diff --git a/glm-rush-v4.user.js b/glm-rush-v4.user.js index b168f2a..97b0248 100644 --- a/glm-rush-v4.user.js +++ b/glm-rush-v4.user.js @@ -13,6 +13,34 @@ (function () { '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 持久化) // ═══════════════════════════════════════════ @@ -28,6 +56,7 @@ recoveryMax: 3, // 弹窗恢复最大次数 logMax: 100, // 日志条数上限 rushTime: '10:00:00', // 每天抢购时间 (北京时间) + preAdvanceSec: 2, // 提前几秒触发,默认2秒 PREVIEW: '/api/biz/pay/preview', CHECK: '/api/biz/pay/check', }; @@ -68,14 +97,22 @@ // 恢复上次捕获的请求 try { - const saved = sessionStorage.getItem('glm_rush_captured'); - if (saved) state.captured = JSON.parse(saved); + const raw = sessionStorage.getItem('glm_rush_captured'); + if (raw) { + const parsed = JSON.parse(raw); + if (parsed.__v !== CAPTURE_VER) { + sessionStorage.removeItem('glm_rush_captured'); + } else { + state.captured = parsed; + } + } } catch {} let stopRequested = false; let recovering = false; let recoveryAttempts = 0; let _shadowRef = null; + let _activeControllers = []; // ═══════════════════════════════════════════ // 工具 @@ -85,9 +122,9 @@ const rand = (min, max) => min + Math.random() * (max - min); const jitteredDelay = base => Math.round(base * (1 + (Math.random() * 2 - 1) * CFG.jitter)); - function getDelay(attempt) { - if (attempt <= CFG.burstCount) return 0; - if (attempt <= 50) return jitteredDelay(CFG.fastDelay); + function getDelay(round) { + if (round <= CFG.burstCount) return 0; // 前 N 轮零延迟 + if (round <= 50) return jitteredDelay(CFG.fastDelay); return jitteredDelay(CFG.slowDelay); } @@ -109,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 定向拦截 (仅修改特定数据结构) // ═══════════════════════════════════════════ @@ -116,6 +164,8 @@ function patchSoldOut(obj, visited = new WeakSet()) { if (!obj || typeof obj !== 'object' || visited.has(obj)) 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; @@ -180,6 +230,14 @@ if (checkData && checkData.data === 'EXPIRE') { return { ok: false, reason: 'EXPIRE', attempt: attemptNum }; } + // 新增:金额为0 = 空单,继续重试 + const payData = checkData?.data; + if (payData) { + const amount = payData.amount ?? payData.totalAmount ?? payData.payAmount ?? 0; + if (amount === 0) { + return { ok: false, reason: '空单(金额为0)', attempt: attemptNum }; + } + } // 通过! return { ok: true, text, data, bizId, status: resp.status, attempt: attemptNum }; @@ -206,6 +264,7 @@ } stopRequested = false; + let roundNum = 0; const { signal, ...opts } = rawOpts || {}; _retryLock = (async () => { @@ -234,7 +293,10 @@ ); } + _activeControllers = controllers; + setState({ count: totalAttempt }); + roundNum++; // 任一成功即取消其余 const winner = await new Promise(resolve => { @@ -253,7 +315,10 @@ }); // 收集失败原因 (用于日志) - const results = await Promise.all(promises.map(p => p.catch(() => ({ ok: false, reason: '已取消' })))); + const settled = await Promise.allSettled(promises); + const failedResults = settled + .filter(r => r.status === 'fulfilled' && !r.value.ok && r.value.reason !== '已取消') + .map(r => r.value); if (winner) { setState({ @@ -269,7 +334,6 @@ } // 统计错误 - const failedResults = results.filter(r => !r.ok); const reasons = failedResults.map(r => r.reason || '未知'); setState({ stats: { ...state.stats, errors: state.stats.errors + failedResults.length } }); @@ -290,10 +354,13 @@ 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++; - const backoff = Math.min(2000 * (2 ** Math.min(throttleCount, 4)), 16000); + const backoff = Math.min(1000 * (2 ** Math.min(throttleCount, 3)), 8000); log(`限流, 退避${backoff}ms...`, 'warn'); await sleep(backoff); } else { @@ -308,8 +375,8 @@ if (elapsedSec > 20) { // 超过20秒 — 检测是否该降速 - const soldOutCount = reasons.filter(r => r === '售罄').length; - if (soldOutCount === batchSize) { + const soldOutRatio = reasons.filter(r => r === '售罄').length / batchSize; + if (soldOutRatio >= 0.6) { consecutiveSoldOut++; } else { consecutiveSoldOut = 0; @@ -329,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 }; })(); @@ -361,7 +437,7 @@ headers: extractHeaders(init?.headers), }; 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) { @@ -405,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] }'; @@ -432,7 +531,7 @@ const self = this; 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) { @@ -472,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); @@ -518,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; } @@ -546,7 +655,7 @@ // 策略2: 缓存响应 + 重新点购买按钮 setState({ cache: state.lastSuccess }); - const btn = findBuyButton(); + const btn = await waitForBuyButton(); if (btn) { btn.click(); log('已重新点击购买按钮 (策略2)'); @@ -590,6 +699,8 @@ } } else { log('支付弹窗已出现!'); + recoveryAttempts = 0; + return; } } finally { recovering = false; } } @@ -622,16 +733,19 @@ // ═══════════════════════════════════════════ // 主动抢购 & 定时 // ═══════════════════════════════════════════ - function findBuyButton() { - // 优先找 buy-btn 类的按钮(特惠订阅/订阅升级) - for (const el of document.querySelectorAll('button.buy-btn')) { - const t = el.textContent.trim(); - if (el.offsetParent !== null) return el; - } - // 降级:通用匹配,排除导航按钮 - for (const el of document.querySelectorAll('button, [role="button"]')) { - const t = el.textContent.trim(); - if (/购买|抢购|下单|特惠/.test(t) && t.length < 15 && el.offsetParent !== null) return el; + 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; + } + await sleep(200); } return null; } @@ -660,7 +774,7 @@ try { new Notification('GLM 抢购成功!', { body: `bizId=${state.bizId}` }); } catch {} const errDlg = findErrorDialog(); if (errDlg) { dismissDialog(errDlg); await sleep(300); } - const btn = findBuyButton(); + const btn = await waitForBuyButton(); if (btn) { btn.click(); log('已自动点击购买按钮'); } else { alert('已获取到商品! 请立即点击购买按钮!'); } @@ -672,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('已停止'); @@ -682,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() { @@ -759,52 +859,57 @@ const ms = target.getTime() - getServerNow(); log(`定时: ${timeStr} (${Math.ceil(ms / 1000)}秒后, 北京时间)`); - // 提前3秒自动预热 - if (ms > 4000) { + // 提前5分钟自动预热 + if (ms > 310_000) { setTimeout(() => { - log('定时前3秒, 自动预热...'); + log('定时前5分钟, 自动预热...'); preheat(); - }, Math.max(0, ms - 3000)); + }, Math.max(0, ms - 300_000)); } // 精确等待: 用 setInterval 10ms 检查, 到时间立即启动 - const tid = setInterval(() => { + const preAdvanceMs = (CFG.preAdvanceSec || 0) * 1000; + 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 <= 0) { - clearInterval(tid); - setState({ timerId: null }); - const timerEl = _shadowRef?.getElementById('timer-info'); - if (timerEl) timerEl.textContent = ''; - log('时间到! 自动启动抢购!'); + if (remaining <= preAdvanceMs) { + clearInterval(checkInterval); startProactive(); } + // 额外:在剩余1秒内用 requestAnimationFrame 做最后精确对齐 + if (remaining > 0 && remaining <= 1000) { + clearInterval(checkInterval); + function rafWait() { + if (target.getTime() - getServerNow() <= preAdvanceMs) { + startProactive(); + } else { + requestAnimationFrame(rafWait); + } + } + requestAnimationFrame(rafWait); + } }, 10); - setState({ timerId: tid }); + setState({ timerId: checkInterval }); } // 预热 async function preheat() { - try { - log('TCP预热中...'); - // 连发3次预热请求,确保连接池暖好 - for (let i = 0; i < 3; i++) { - await _fetch(location.origin + '/api/biz/pay/check?bizId=preheat_' + i, { credentials: 'include' }).catch(() => {}); - await sleep(200); - } - // 也预热 preview 的 DNS + TCP (用 HEAD 请求不产生副作用) + log('开始预热连接...'); + let ok = 0; + for (let i = 0; i < 5; i++) { + 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: 'HEAD', + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: '{}', }).catch(() => {}); - log('预热完成 (4次连接已建立)'); - } catch { log('预热部分失败,不影响使用'); } + await sleep(300); + } + log(`预热完成:${ok}/5 连接建立成功`); } // ═══════════════════════════════════════════ @@ -831,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) => { @@ -853,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; @@ -925,7 +1028,7 @@ .keys{font-size:10px;color:#636e72;text-align:center;margin-top:6px}