// ==UserScript== // @name 12306 抢票助手 Pro // @namespace http://tampermonkey.net/ // @icon https://raw.githubusercontent.com/shiyi312/12306-/main/12306helper.jpg // @version 2.1.6 // @description 自动查票、下单,成功后自动跳转支付页。支持多车次、多席别、选座偏好、配置自动保存、动态状态提醒。 // @author 辻弌20 // @match https://www.12306.cn/* // @match https://kyfw.12306.cn/* // @grant GM_notification // @grant GM_setValue // @grant GM_getValue // @grant window.focus // @license MIT // ==/UserScript== (function() { 'use strict'; // 与文件头的 @version 保持一致(改版本号时两处一起改) const SCRIPT_VERSION = '2.1.6'; // 记录脚本自己打到 Console 的技术日志,供面板上的「复制日志」一键导出。 // 只记自己这几行,不去猴补丁全局 console,免得干扰页面本身和其它脚本。 const TECH_LOG_MAX = 200; const techLog = []; // 日志落盘。 // 为什么需要:抢到票之后脚本会跳转支付页,一跳转页面就重载,而日志全在内存里, // 于是「明明抢成功了,回头一看什么都没留下」。群友就是这么撞上的—— // 订单在手机上能看到,脚本这边却查无此人。 // 面板日志和技术日志分别存,各留最后 300 行,加载时捡回来(超过 2 小时的不要, // 免得把上一轮的日志混进这一轮)。 // 注意:这些声明必须在 tlog 之前——tlog 在脚本加载时就会跑一次, // 声明在后面的话会踩 let/const 的暂时性死区,直接在加载阶段抛错。 const LOG_PERSIST_KEY = 'grab_log_persist'; const LOG_PERSIST_MAX = 300; const LOG_PERSIST_TTL_MS = 2 * 3600 * 1000; const uiLogBuffer = []; let logPersistTimer = null; function flushLogsToStorage() { logPersistTimer = null; try { GM_setValue(LOG_PERSIST_KEY, JSON.stringify({ at: Date.now(), ui: uiLogBuffer.slice(-LOG_PERSIST_MAX), tech: techLog.slice(-LOG_PERSIST_MAX) })); } catch (e) { /* 存不上不影响抢票 */ } } // 攒一下再写,避免高频轮询时把存储写爆 function scheduleLogPersist() { if (logPersistTimer) return; logPersistTimer = setTimeout(flushLogsToStorage, 1000); } function loadPersistedLogs() { try { const raw = GM_getValue(LOG_PERSIST_KEY, ''); if (!raw) return null; const o = JSON.parse(raw); if (!o || !o.at || Date.now() - o.at > LOG_PERSIST_TTL_MS) return null; return o; } catch (e) { return null; } } function tlog(level, ...args) { const text = args.map(a => (a && a.stack) ? a.stack : String(a)).join(' '); techLog.push(`[${new Date().toLocaleTimeString()}]${level === 'log' ? '' : ' [' + level + ']'} ${text}`); if (techLog.length > TECH_LOG_MAX) techLog.shift(); scheduleLogPersist(); const emit = console[level] || console['log']; emit(...args); } tlog('log', `>>> 12306 抢票助手 Pro (v${SCRIPT_VERSION}) 已加载 <<<`); // 把上次的技术日志也捡回来:抢到票跳转支付页之后页面会重载, // 不捞的话「复制日志」里就只剩跳转之后那几行,关键的成功/失败过程全丢了。 (function restoreTechLog() { const saved = loadPersistedLogs(); if (!saved || !Array.isArray(saved.tech) || !saved.tech.length) return; techLog.push(...saved.tech.slice(-TECH_LOG_MAX)); })(); // ========================================== // 0. 常量与配置 // ========================================== const BASE_URL = 'https://kyfw.12306.cn'; // 联程(中转)流程的 Referer。 // 为什么必须单独给:request() 里的 Referer 是写死的「leftTicket/init」(普通查票页), // 而联程的接口属于另一条流程。实测(群友日志):在 lcQuery/init 页面上带着 // leftTicket/init 的 Referer 去请求 lcConfirmPassenger/checkOrderInfo,12306 会回 // {"status":false,"messages":["系统忙,请稍后重试"],"url":"/leftTicket/init"} // 注意那个 url —— 正好等于我们发出去的 Referer,等于服务端在说"你不是这个流程的"。 // 手动操作时浏览器自动带的 Referer 是确认页,所以手动能成、脚本不能。 const LC_QUERY_REFERER = 'https://kyfw.12306.cn/otn/lcQuery/init'; const LC_CONFIRM_REFERER = 'https://kyfw.12306.cn/otn/lcConfirmPassenger/initLc'; // 等价于 12306 自己的 otsRedirect("post", url, {}):造一个隐藏表单 POST 过去。 // 为什么不能用 location.href:支付页 payOrder/init **只认 POST**, // 用 GET 打开只会显示「系统忙,请稍后重试」——群友截图里就是这个现象 //(订单在手机上能正常看到"未完成支付",说明单子建了,是页面这边进不去)。 function postRedirect(url) { try { const form = document.createElement('form'); form.method = 'POST'; form.action = url; form.style.display = 'none'; (document.body || document.documentElement).appendChild(form); form.submit(); } catch (e) { // 兜底:表单跳不了就退回 GET,至少给个机会 window.location.href = url; } } // 查票请求单独用短超时:轮询是「上一轮结束才排下一轮」, // 一个卡住的请求会把整条流水线堵死(实测有一次卡了 8 秒,正好吃掉起售后的黄金几秒)。 const QUERY_TIMEOUT_MS = 3000; // 查票接口候选池。 // 12306 会不定期把 leftTicket/query 换成 queryZ / queryO 等,正常情况下响应里的 c_url // 会告诉我们新地址。但**被限流时连响应都拿不到**——光靠 c_url 就永远切不过去, // 只能一直撞那个已经被掐的路径。所以维护一个池子,连续被拒时换一条试。 // // 池子里的地址只来自「真正见过的 c_url」和默认地址,不凭空猜路径名: // 猜错等于白打一次请求,而这时候本来状态就不好,不该再浪费。 // 池子里只有一条时不切换(没有备用可切),照常走退避那一套。 const QUERY_URL_KEY = 'query_url_pool'; const QUERY_URL_DEFAULT = '/otn/leftTicket/query'; let queryUrlPool = [QUERY_URL_DEFAULT]; let QUERY_URL = QUERY_URL_DEFAULT; // 本次任务是否已经换过接口。只换一次:反复换会在池子里绕圈,等于没换。 let queryUrlRotated = false; // 记住一条地址并提到池子最前(最前面的就是下次启动默认用的那条)。 function rememberQueryUrl(url) { const next = String(url || '').trim(); if (!next) return; queryUrlPool = [next].concat(queryUrlPool.filter(u => u !== next)).slice(0, 5); QUERY_URL = next; try { GM_setValue(QUERY_URL_KEY, queryUrlPool.join('\n')); } catch (e) { /* 存不上就只影响下次启动 */ } } // 换到下一条候选。池子里只有一条时返回 false,调用方据此知道"没得换"。 function rotateQueryUrl() { if (queryUrlPool.length < 2) return false; const cur = queryUrlPool.indexOf(QUERY_URL); const next = queryUrlPool[(cur + 1) % queryUrlPool.length]; if (!next || next === QUERY_URL) return false; tlog('log', `[Query] 查票接口连续被拒,切换: ${QUERY_URL} -> ${next}`); QUERY_URL = next; return true; } // 启动时恢复上次用过的地址(上一轮跑通的那条排在最前) (function loadQueryUrlPool() { try { const saved = String(GM_getValue(QUERY_URL_KEY, '') || ''); const arr = saved.split('\n').map(s => s.trim()).filter(Boolean); if (!arr.length) return; queryUrlPool = arr.filter((v, i, a) => a.indexOf(v) === i).slice(0, 5); QUERY_URL = queryUrlPool[0]; tlog('log', `[Query] 已恢复查票接口: ${QUERY_URL}(候选 ${queryUrlPool.length} 条)`); } catch (e) { /* 读不到就用默认 */ } })(); const SEAT_TYPE_CODE = { '商务座': '9', '特等座': 'P', '一等座': 'M', '二等座': 'O', '高级软卧': '6', '软卧': '4', '硬卧': '3', '软座': '2', '硬座': '1', '无座': '1' }; // 中转查询接口返回的余票字段名(JSON 键),实测自 /lcquery/queryG 的 fullList const SEAT_NUM_FIELD = { '商务座': 'swz_num', '特等座': 'tz_num', '一等座': 'zy_num', '二等座': 'ze_num', '高级软卧': 'gr_num', '软卧': 'rw_num', '硬卧': 'yw_num', '软座': 'rz_num', '硬座': 'yz_num', '无座': 'wz_num' }; // 查票与下单的 purpose_codes 必须与所选票种一致(普通 ADULT / 学生 0X00) function purposeCodesFor(passengers) { return passengers.some(p => p.isStudent === true) ? '0X00' : 'ADULT'; } // 目标日期偏移 N 天,返回 YYYY-MM-DD function shiftDate(isoDate, days) { const d = new Date(`${isoDate}T00:00:00`); d.setDate(d.getDate() + days); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } // 查询目标列表。第一个是主目标(每轮都查),其余由主循环轮转,避免请求数随目标数线性膨胀 function buildQueryTargets(config) { const targets = [{ date: config.trainDate, fromCode: config.fromStation, toCode: config.toStation, fromName: config.fromName, toName: config.toName, tag: '主目标' }]; if (config.altToStation && config.altToCode) { targets.push({ date: config.trainDate, fromCode: config.fromStation, toCode: config.altToCode, fromName: config.fromName, toName: config.altToStation, tag: '买长乘短' }); } if (config.multiDate) { targets.push({ date: shiftDate(config.trainDate, -1), fromCode: config.fromStation, toCode: config.toStation, fromName: config.fromName, toName: config.toName, tag: '前一天' }); targets.push({ date: shiftDate(config.trainDate, 1), fromCode: config.fromStation, toCode: config.toStation, fromName: config.fromName, toName: config.toName, tag: '后一天' }); } return targets; } // ========================================== // 服务器时间校准 // ========================================== // 定时抢票原本用本地时钟判断「到点」,本机时钟若比 12306 慢几秒就等于晚出手几秒。 // 这里用响应头 Date 采样校准;Date 只有秒级精度且只会偏小,因此取多次采样的最大值。 let serverClockOffset = null; // 服务器时间 - 本地时间(毫秒) // HTTP Date 头形如 "Thu, 10 Sep 2026 10:06:20 GMT"。必须严格校验格式: // V8 的宽松日期解析会把任意含数字的字符串也解析成日期(实测 "application/json;charset=UTF-8" // 会得到 2001-08-01),一旦被采信就会把定时抢票定到完全错误的时刻。 const HTTP_DATE_RE = /^\w{3}, \d{2} \w{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/; const MAX_CLOCK_SKEW_MS = 12 * 3600 * 1000; function noteServerTime(dateHeader, sentAt, receivedAt) { if (!dateHeader || !HTTP_DATE_RE.test(dateHeader)) return; const rtt = receivedAt - sentAt; if (rtt < 0 || rtt > 3000) return; // 明显异常的样本直接丢弃 const serverMs = new Date(dateHeader).getTime(); if (isNaN(serverMs)) return; const sample = serverMs - Math.round((sentAt + receivedAt) / 2); // 偏差超过 12 小时说明本机时钟本身就不可信,宁可不校准也不要定到错误时刻 if (Math.abs(sample) > MAX_CLOCK_SKEW_MS) return; if (serverClockOffset === null || sample > serverClockOffset) serverClockOffset = sample; } function serverNow() { return new Date(Date.now() + (serverClockOffset || 0)); } // 服务器"今天"的日期串(YYYY-MM-DD)。和乘车日期同为 ISO 格式,可以直接按字典序比大小。 function serverTodayIso() { const d = serverNow(); const pad = n => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; } // 把 "HH:MM:SS" 换算成本地时间戳,用服务器时钟定位该时刻。 // 算出来的时刻如果已经过去,而乘车日期还在未来(典型场景:晚上设第二天早上的起售定时, // 比如 9/18 晚 22 点设 10/3 的票、起售是 9/19 08:00),就把定时顺延一天。 // 不顺延的话会直接「立即开始」——用户半夜白等一场,而且会往 12306 发一整晚的请求, // 本来就容易被限流(群友日志里那条 18:01:43「设定时间已过,立即开始」就是这么来的)。 // 乘车日期就是今天时不顺延:那种情况下的「已过」是真的过了,照旧立即开始。 function saleTimestampLocally(hms, trainDate = '') { const [h, m, s] = String(hms).split(':').map(Number); const t = new Date(serverNow()); t.setHours(h || 0, m || 0, s || 0, 0); let ts = t.getTime() - (serverClockOffset || 0); if (ts <= Date.now() && trainDate && trainDate > serverTodayIso()) { ts += 24 * 3600 * 1000; } return ts; } const SEAT_MAP_UI = { swz: { name: '商务/特等' }, zy: { name: '一等座' }, ze: { name: '二等座' }, gr: { name: '高级软卧' }, rw: { name: '软卧' }, yw: { name: '硬卧' }, rz: { name: '软座' }, yz: { name: '硬座' }, wz: { name: '无座' } }; // 选座偏好可选的座位字母。这里不按席别写死集合: // 字母集随席别和车型变(二等座 A/B/C/D/F,一等座 A/C/D/F,商务座 A/C/F), // 写死一份就等于多维护一份会过期的表。真正的"这个席别到底能不能选座" // 由 12306 的下单页自己说了算,见 resolveChooseSeats。 const SEAT_PREF_CHOICES = ['A', 'B', 'C', 'D', 'F']; // 选座偏好 → 12306 的 choose_seats 参数。 // 格式依据 12306 自己的 queryLeftTicket_end_js.js(cj() 把选中的座位元素 id 直接拼起来提交) // 加火车票接口文档的取值样例:每 2 个字符一组,第一位是排号、第二位是座位字母。 // 1A = 第一排 A 座 // 1A1B = 第一排 A 座和 B 座 // 1F2A = 第一排 F 座 + 第二排 A 座(可见第一位不是固定前缀,是排号) // 这里第一位固定给 1:用户要的是同一排挨着坐,跨排交给 12306 按余票安排。 function buildChooseSeats(seats) { return (seats || []).map(s => `1${s}`).join(''); } let stationMap = {}; const DEFAULT_CONFIG = { targetTrains: ['G1234'], seatTypes: ['ze', 'zy'], seatPrefs: [], // 选座偏好(A/B/C/D/F)。空 = 由 12306 自动分配,默认就是空 passengers: [], // 仅存储姓名,学生票信息在 passengerDetails 中 passengerDetails: {}, // key: passenger_name, value: { isStudent: true/false, ... } trainDate: new Date().toISOString().split('T')[0], startTime: '', soundAlert: true, fromStation: '上海', toStation: '杭州', fromCode: '', // 保存简码用于查票 toCode: '', altToStation: '', // 备选到达站(买长乘短),留空即关闭 altToCode: '', multiDate: false, // 主目标无票时是否也查目标日 ±1 天 departFrom: '', // 发车时间下限 "HH:MM",留空不限 departTo: '', // 发车时间上限 "HH:MM",留空不限 transferStation: '', // 中转站(中文站名),留空 = 不用中转 transferCode: '', autoTransfer: false, // 自动找中转方案(不用自己填中转站) lcTransfer: false, // 用中转联程下单(一次提交两段、一个订单)。默认关:大多数人用不着,用到再开 waitMinutes: 30, // 自动找中转时,两程之间至少留多少分钟换乘 waitlistEnabled: false, // 自动提交候补。默认关:候补会占用候补额度,得让用户自己决定 alarmEnabled: true // 掉线/被限流时是否连续报警,默认开 }; // "08:30" → 510(分钟)。格式不对返回 null,调用方据此放行,不因为填错就抢不到票。 function hhmmToMinutes(t) { const m = String(t || '').trim().match(/^(\d{1,2}):(\d{2})$/); if (!m) return null; const h = parseInt(m[1], 10), mi = parseInt(m[2], 10); if (h > 24 || mi > 59) return null; return h * 60 + mi; } // 12306 查票结果里的 sale_time 是 12 位 YYYYMMDDHHMM,含义是「该票的起售日期+时刻」。 // 依据(两条互相印证): // 1) 官方 queryLeftTicket_end_js.js 里把 c9[55] 当时间戳解析,且只在「现在 < sale_time」 // 时才显示「购票信息预填」入口 —— 说明它是个尚未到来的时刻; // 2) 实测核对:出发日期填 2026-09-29,12306 返回 202609151700, // 即 09-15 17:00 起售(09-15 是查询当天,预售期约 14 天)。这个日期跟出行日不同, // 所以它确实是「起售日」而不是出行日。 function parseSaleTime(raw) { const s = String(raw || '').trim(); if (!/^\d{12}$/.test(s)) return null; const y = s.slice(0, 4), mo = s.slice(4, 6), d = s.slice(6, 8); const h = s.slice(8, 10), mi = s.slice(10, 12); const nM = parseInt(mo, 10), nD = parseInt(d, 10), nH = parseInt(h, 10), nMi = parseInt(mi, 10); if (nM < 1 || nM > 12 || nD < 1 || nD > 31 || nH > 23 || nMi > 59) return null; return { date: `${y}-${mo}-${d}`, time: `${h}:${mi}` }; } // 发车时间段过滤。两端都留空则完全不限。 function inDepartRange(startTime, from, to) { const lo = hhmmToMinutes(from); const hiRaw = hhmmToMinutes(to); if (lo === null && hiRaw === null) return true; const t = hhmmToMinutes(startTime); if (t === null) return true; // 拿不到发车时间就不拦 const a = (lo === null) ? 0 : lo; const b = (hiRaw === null || hiRaw >= 1440) ? 1440 : hiRaw; // "24:00" 视为不限 return t >= a && t <= b; } // ========================================== // 配置持久化(增强 #6) // ========================================== function loadConfig() { const saved = GM_getValue('grab_config_pro', null); if (saved) { // 兼容旧版本,若没有 passengerDetails 则初始化 if (!saved.passengerDetails) saved.passengerDetails = {}; return Object.assign({}, DEFAULT_CONFIG, saved); } return DEFAULT_CONFIG; } function saveConfig(cfg) { GM_setValue('grab_config_pro', cfg); } let cfg = loadConfig(); // 实时保存监听(#6) function setupAutoSave() { const panel = document.getElementById('ticket-helper-panel'); if (!panel) return; // 输入框 panel.querySelectorAll('input, select').forEach(el => { el.addEventListener('input', () => { // 延迟一下,避免频繁写入 clearTimeout(el._saveTimer); el._saveTimer = setTimeout(() => { // 重新读取当前界面配置(不含乘客选择,因为乘客选择稍后单独保存) const date = document.getElementById('th-date').value; const fromName = document.getElementById('th-from').value.trim(); const toName = document.getElementById('th-to').value.trim(); const trains = document.getElementById('th-trains').value.split(/[,,]/).map(s => s.trim().toUpperCase()).filter(Boolean); const seats = document.querySelectorAll('.cfg-seat:checked'); const seatList = Array.from(seats).map(cb => cb.value); const seatPrefs = Array.from(document.querySelectorAll('.th-seat-check:checked')).map(cb => cb.value); const startTime = document.getElementById('th-start-time').value; const soundAlert = document.getElementById('cfg-sound') ? document.getElementById('cfg-sound').checked : true; const altToEl = document.getElementById('th-alt-to'); const multiDateEl = document.getElementById('cfg-multidate'); cfg.targetTrains = trains; cfg.seatTypes = seatList; cfg.seatPrefs = seatPrefs; cfg.trainDate = date; cfg.startTime = startTime; cfg.soundAlert = soundAlert; cfg.fromStation = fromName; cfg.toStation = toName; cfg.altToStation = altToEl ? altToEl.value.trim() : (cfg.altToStation || ''); cfg.multiDate = multiDateEl ? multiDateEl.checked : !!cfg.multiDate; const alarmEl = document.getElementById('cfg-alarm'); cfg.alarmEnabled = alarmEl ? alarmEl.checked : (cfg.alarmEnabled !== false); const dfEl = document.getElementById('th-depart-from'); const dtEl = document.getElementById('th-depart-to'); cfg.departFrom = dfEl ? dfEl.value.trim() : (cfg.departFrom || ''); cfg.departTo = dtEl ? dtEl.value.trim() : (cfg.departTo || ''); const wlEl = document.getElementById('cfg-waitlist'); cfg.waitlistEnabled = wlEl ? wlEl.checked : !!cfg.waitlistEnabled; const tfEl = document.getElementById('th-transfer'); cfg.transferStation = tfEl ? tfEl.value.trim() : (cfg.transferStation || ''); const atEl = document.getElementById('cfg-autotransfer'); cfg.autoTransfer = atEl ? atEl.checked : !!cfg.autoTransfer; const lcEl2 = document.getElementById('cfg-lctransfer'); cfg.lcTransfer = lcEl2 ? lcEl2.checked : !!cfg.lcTransfer; const wmEl = document.getElementById('th-wait-min'); if (wmEl && wmEl.value !== '') cfg.waitMinutes = Number(wmEl.value); // 简码转换(可能失败,但查票时会再验证) const findCode = (input) => { if (!input) return ''; if (stationMap[input]) return stationMap[input]; for (const name of Object.keys(stationMap)) { if (name.includes(input) || input.includes(name)) { return stationMap[name]; } } return ''; }; cfg.fromCode = findCode(fromName); cfg.toCode = findCode(toName); saveConfig(cfg); }, 500); }); }); // 复选框变化。选座偏好跟席别一样要跟着存,否则刷新页面勾选就丢了 panel.querySelectorAll('.cfg-seat, .th-seat-check').forEach(cb => { cb.addEventListener('change', () => { // 触发保存,复用上面的逻辑,手动触发 input 事件 const event = new Event('input', { bubbles: true }); document.getElementById('th-trains').dispatchEvent(event); }); }); // 声音开关(如果有) const soundCb = document.getElementById('cfg-sound'); if (soundCb) { soundCb.addEventListener('change', () => { const event = new Event('input', { bubbles: true }); document.getElementById('th-trains').dispatchEvent(event); }); } // 连续报警开关:关掉时要把正在响的报警立刻停掉,否则用户会觉得关不掉 const alarmCb = document.getElementById('cfg-alarm'); if (alarmCb) { alarmCb.addEventListener('change', () => { if (!alarmCb.checked) stopAlarm(); const event = new Event('input', { bubbles: true }); document.getElementById('th-trains').dispatchEvent(event); }); } } // ========================================== // 站点简码获取(增强重试 #8) // ========================================== async function fetchStationMap(retries = 3) { for (let i = 0; i < retries; i++) { try { tlog('log', `正在获取站点简码表 (尝试 ${i+1}/${retries})...`); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); // 10s超时 const response = await fetch('https://kyfw.12306.cn/otn/resources/js/framework/station_name.js', { signal: controller.signal }); clearTimeout(timeoutId); if (!response.ok) throw new Error(`HTTP ${response.status}`); const text = await response.text(); const start = text.indexOf("'"); const end = text.lastIndexOf("'"); if (start > -1 && end > -1) { const data = text.substring(start + 1, end); const parts = data.split('@'); parts.forEach(part => { if (!part) return; const fields = part.split('|'); if (fields.length >= 3) { stationMap[fields[1]] = fields[2]; } }); tlog('log', `站点简码表加载完成,共 ${Object.keys(stationMap).length} 个站点`); UIModule.log('站点简码表加载完成', 'success'); return true; } else { throw new Error('解析站点数据失败'); } } catch (e) { tlog('warn', `获取站点简码表失败 (尝试 ${i+1}):`, e.message); if (i === retries - 1) { UIModule.log('站点简码加载失败,请刷新页面重试', 'error'); return false; } await new Promise(r => setTimeout(r, 3000 * (i+1))); // 递增延迟 } } return false; } // 简码 -> 中文站名。查票结果里只有简码(AOH 这种),而日志是给人看的,得还原成站名。 // stationMap 的方向是「站名 -> 简码」,反查要遍历一遍(三千来条),所以建一次缓存反复用。 let stationNameByCode = null; function stationNameOf(code) { const c = String(code || '').trim(); if (!c) return ''; if (!stationNameByCode) { stationNameByCode = {}; for (const name of Object.keys(stationMap)) { // 同一简码对应多个名字的情况是有的(别名/简称),保留先撞上的那个就够用 if (!stationNameByCode[stationMap[name]]) stationNameByCode[stationMap[name]] = name; } } // 查不到就原样返回简码:宁可显示 AOH,也不要显示空白让人以为没解析到 return stationNameByCode[c] || c; } // ========================================== // 1. NetworkModule (API 接口层) // ========================================== const NetworkModule = (() => { // 带超时的 fetch 封装(#8) async function request(url, options = {}, timeout = 15000) { // Referer 默认用「当前页面地址」,而不是写死的查票页。 // 浏览器本来就是这么发的:页面上发出什么请求,Referer 就是那个页面的 URL。 // 写死 leftTicket/init 的后果是:只要用户不是停在查票页(比如停在联程查询页 // lcQuery/init),带出去的 Referer 就和"真实操作"对不上,12306 会当成流程 // 错乱直接拒掉(实测响应里会回一个 url 字段指回 leftTicket/init)。 // 普通抢票时 location.href 本来就是 leftTicket/init,行为不变。 const pageReferer = (typeof location !== 'undefined' && location.href) || 'https://kyfw.12306.cn/otn/leftTicket/init'; const defaultOptions = { method: 'GET', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'Referer': pageReferer, 'Host': 'kyfw.12306.cn', 'Origin': 'https://kyfw.12306.cn' }, }; const finalOptions = { ...defaultOptions, ...options }; if (options.headers) finalOptions.headers = { ...defaultOptions.headers, ...options.headers }; // 诊断:Referer 一旦和"当前页面"不一致就记一笔。联程那条线上, // 服务端是否接受请求几乎全看这个头,出问题时必须一眼能确认它到底是什么。 if (finalOptions.headers['Referer'] !== pageReferer) { tlog('log', `[Net] ${url} 使用自定义 Referer: ${finalOptions.headers['Referer']}`); } const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); finalOptions.signal = controller.signal; const sentAt = Date.now(); try { const response = await fetch(fullUrl, finalOptions); const receivedAt = Date.now(); clearTimeout(timeoutId); noteServerTime(response.headers.get('date'), sentAt, receivedAt); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { return await response.json(); } else { const text = await response.text(); try { return JSON.parse(text); } catch (e) { return { status: false, messages: ['Response is not JSON', text.substring(0, 200)] }; } } } catch (error) { if (error.name === 'AbortError') { throw new Error('Request timeout'); } // 带上耗时和 URL:并发请求时只报一句「Failed to fetch」, // 根本看不出是哪一段、哪一步挂的(群友的日志里就卡在这上面)。 tlog('error', `[Network] Request failed after ${Date.now() - sentAt}ms: ${error.message} @ ${url}`); throw error; } } return { async checkLoginStatus() { // 注意:请求失败会向上抛。调用方必须区分「服务器确认已掉线」与「压根没连上」, // 否则一次网络抖动就会被当成掉线而误报。 const data = await request('/otn/login/checkUser', { method: 'POST', body: '_json_att=' }, 10000); return !!(data && data.data && data.data.flag === true); }, async queryTickets(trainDate, fromStation, toStation, purposeCodes = 'ADULT') { const params = new URLSearchParams({ 'leftTicketDTO.train_date': trainDate, 'leftTicketDTO.from_station': fromStation, 'leftTicketDTO.to_station': toStation, 'purpose_codes': purposeCodes }); const res = await request(`${QUERY_URL}?${params.toString()}`, {}, QUERY_TIMEOUT_MS); // 接口被轮换时 12306 返回 c_url(如 leftTicket/queryZ),换过去立刻重试一次 if (res && res.c_url) { const cUrl = String(res.c_url).replace(/^\/+/, ''); const next = cUrl.startsWith('otn/') ? `/${cUrl}` : `/otn/${cUrl}`; if (next !== QUERY_URL) { tlog('log', `[Query] 12306 告知查票接口已轮换: ${QUERY_URL} -> ${next}`); rememberQueryUrl(next); return request(`${QUERY_URL}?${params.toString()}`, {}, QUERY_TIMEOUT_MS); } } return res; }, async submitOrderRequest(secretStr, trainDate, backTrainDate, fromStationName, toStationName, purposeCodes = 'ADULT') { const body = new URLSearchParams({ 'secretStr': decodeURIComponent(secretStr), 'train_date': trainDate, 'back_train_date': backTrainDate, 'tour_flag': 'dc', 'purpose_codes': purposeCodes, 'query_from_station_name': fromStationName, 'query_to_station_name': toStationName, 'undefined': '' }); return request('/otn/leftTicket/submitOrderRequest', { method: 'POST', body: body }); }, async getInitDcPage() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); try { const response = await fetch(`${BASE_URL}/otn/confirmPassenger/initDc`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: '_json_att=', signal: controller.signal }); if (!response.ok) throw new Error(`initDc HTTP ${response.status}`); return await response.text(); } finally { clearTimeout(timeoutId); } }, async getPassengerDTOs() { return request('/otn/confirmPassenger/getPassengerDTOs', { method: 'POST', body: '_json_att=' }); }, // 12306 的中转查询接口。注意:路径是 /lcquery/queryG,不在 /otn/ 下面, // 而且实测**不需要登录**。参数来自它自己的 lcquery_js.js。 async queryTransfer(trainDate, fromCode, toCode, middleCode = '') { const params = new URLSearchParams({ train_date: trainDate, from_station_telecode: fromCode, to_station_telecode: toCode, middle_station: middleCode, result_index: '0', can_query: 'Y', isShowWZ: 'Y', purpose_codes: '00', channel: 'E' }); return request(`${lcSearchUrl()}?${params.toString()}`, {}, QUERY_TIMEOUT_MS); }, // ===== 中转联程(一次提交两段、一个订单)===== // 接口和参数全部来自 12306 自己的 lcquery_js.js / lcpassengerInfo_js.js, // 不是猜的。联程的好处:一个订单、一次支付,不会像"分两次买"那样 // 第二段被限流,也不会被算成两次学生票优惠次数。 // ① 提交联程方案。secretStr 是中转查询结果里的 scretstr。 async submitTransferOrder(secretStr, trainDate, fromName, toName) { const body = new URLSearchParams({ secretStr: secretStr, train_date: trainDate, back_train_date: trainDate, tour_flag: 'lc', purpose_codes: '00', query_from_station_name: fromName || '', query_to_station_name: toName || '' }); return request('/otn/lcQuery/submitOrderRequest', { method: 'POST', body, headers: { 'Referer': LC_QUERY_REFERER } }, 15000); }, // ② 联程确认页(相当于普通下单的 initDc),返回 HTML async getLcConfirmPage() { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 15000); try { const response = await fetch(`${BASE_URL}/otn/lcConfirmPassenger/initLc`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: '_json_att=', signal: controller.signal }); if (!response.ok) throw new Error(`initLc HTTP ${response.status}`); return await response.text(); } finally { clearTimeout(timeoutId); } }, // ③ 检查订单信息(联程版)。 // 参数严格照 12306 自己 lcpassengerInfo_js.js 里的调用抄,只有 9 个: // {cancel_flag, bed_level_order_num, passengerTicketStr, oldPassengerStr, // tour_flag, randCode, sessionId, sig, scene} // 之前这里的 oldPassengerStr 传的是乘客串(错的,见 buildLcOldPassengerStr 的注释), // 另外还多了 whatsSelect / _json_att / REPEAT_SUBMIT_TOKEN 三个真实调用里没有的参数。 async checkOrderInfoLc(passengerTicketStr, lcOldPassengerStr) { const body = new URLSearchParams({ 'cancel_flag': '2', 'bed_level_order_num': '000000000000000000000000000000', 'passengerTicketStr': passengerTicketStr, 'oldPassengerStr': lcOldPassengerStr, 'tour_flag': 'lc', 'randCode': '', 'sessionId': '', 'sig': '', 'scene': 'nc_login' }); tlog('log', `[LC] checkOrderInfo 发送: 乘客串=${passengerTicketStr.length}字` + ` 区间串=${lcOldPassengerStr.length}字 段数=${(lcOldPassengerStr.match(/#/g) || []).length}`); return request('/otn/lcConfirmPassenger/checkOrderInfo', { method: 'POST', body, headers: { 'Referer': LC_CONFIRM_REFERER } }, 10000); }, // ④ 排队。联程的 dataStr 装**两段**,用 # 连接。 // 真实调用(lcpassengerInfo_js.js)里这个请求**只带 dataStr 一个参数**, // 之前补的 _json_att / REPEAT_SUBMIT_TOKEN 其实是我按普通下单猜的,去掉了。 async getQueueCountLc(dataStr) { const body = new URLSearchParams({ dataStr: dataStr }); tlog('log', `[LC] getQueueCount 发送: dataStr=${dataStr.length}字 段数=${dataStr.split('#').length}`); return request('/otn/lcConfirmPassenger/getQueueCount', { method: 'POST', body, headers: { 'Referer': LC_CONFIRM_REFERER } }, 10000); }, // ③ 联程是异步的,要轮询排队结果。 // 真实实现(lcpassengerInfo_js.js 的 OrderQueueWaitTime): // GET confirmPassenger/queryOrderWaitTime?tourFlag=lc // 返回的 data.waitTime 是个带符号的数字,取值含义见 waitForLcOrder 的注释: // -1 成功、-100 继续等、比 -1 更小的负数表示失败。 async queryOrderWaitTime(tourFlag) { const q = new URLSearchParams({ tourFlag: tourFlag, random: String(Date.now()) }); return request(`/otn/confirmPassenger/queryOrderWaitTime?${q.toString()}`, { method: 'GET', headers: { 'Referer': LC_CONFIRM_REFERER } }, 10000); }, // ④ 排队结束后用它把订单"确认"下来(联程专用接口)。 // 真实调用:POST lcConfirmPassenger/resultOrderForLCQueue { orderSequence_no: orderId } async resultOrderForLcQueue(orderId) { const body = new URLSearchParams({ orderSequence_no: orderId || '' }); return request('/otn/lcConfirmPassenger/resultOrderForLCQueue', { method: 'POST', body, headers: { 'Referer': LC_CONFIRM_REFERER } }, 10000); }, // ⑤ 等队列结束。 // waitTime 的含义(对着 12306 源码 + 实测日志一起定的): // 正数 → 还在排队,值是预估等待秒数 // -100 → 特殊值,"这次没拿到新数字,继续等",**不是结束** // -1 → 成功(源码 N() 里的 if (V == -1)),这时才去认领订单 // < -1 且不是 -100(实测见到 -2)→ **失败**,订单没生成。 // 注意 12306 自己的界面在这个值上什么都不做(直接卡住), // 所以只能由我们接住并说清楚。 // 另外:真正的失败原因(比如"取消次数过多,今日不能受理")写在响应的 // msg 字段里,不看它就永远只会说一句含糊的"排队中"。 async waitForLcOrder() { const deadline = Date.now() + 3 * 60 * 1000; let lastWait = null, lastOrderId = '', lastMsg = '', idleRounds = 0; while (Date.now() < deadline) { let res = null; try { res = await this.queryOrderWaitTime('lc'); } catch (e) { tlog('warn', `[LC] 查排队状态失败(继续等):${e.message}`); await new Promise(r => setTimeout(r, 2000)); continue; } const d = res && res.data; if (!d) { await new Promise(r => setTimeout(r, 2000)); continue; } if (d.orderId) lastOrderId = String(d.orderId); if (d.msg) lastMsg = String(d.msg); if (String(d.waitTime) !== String(lastWait) || d.msg) { lastWait = d.waitTime; tlog('log', `[LC] 排队中:waitTime=${d.waitTime}` + ` orderId=${lastOrderId || '(还没生成)'}` + ` msg=${lastMsg || '(无)'}`); } const w = String(d.waitTime); // 成功 if (w === '-1') { if (!lastOrderId) { // 结束了却没订单号:再探两轮,别急着下结论 idleRounds++; if (idleRounds >= 3) { return { ok: false, failReason: '队列结束但没拿到订单号', msg: lastMsg }; } continue; } try { const r2 = await this.resultOrderForLcQueue(lastOrderId); tlog('log', `[LC] resultOrderForLCQueue: ${JSON.stringify(r2).slice(0, 300)}`); } catch (e) { tlog('warn', `[LC] 认领订单出错(订单已生成,不影响支付):${e.message}`); } return { ok: true, orderId: lastOrderId, msg: lastMsg }; } // 明确的失败:比 -1 更小、又不是 -100 的负数 if (/^-\d+$/.test(w) && Number(w) < -1 && w !== '-100') { tlog('warn', `[LC] 队列给出否定结果:waitTime=${w} msg=${lastMsg || '(无)'}`); return { ok: false, failReason: `排队被拒(waitTime=${w})`, msg: lastMsg }; } await new Promise(r => setTimeout(r, 1500)); } tlog('warn', `[LC] 排队等待超时(等满 3 分钟),最后 waitTime=${lastWait} msg=${lastMsg || '(无)'}`); return { ok: false, timeout: true, msg: lastMsg }; }, // ⑤ 确认并进队列。注意接口名是 confirmLCForQueue(不是 confirmSingleForQueue)。 // leftTicketStr 只传第一段的(源码里就是 ticketInfoForPassengerForm[0].leftTicketStr) // 参数同样对着能用的 confirmSingleForQueue 补齐:whatsSelect / roomType / dwAll / _json_att // 这几个通用项原来全漏了。 async confirmLcForQueue(params) { const body = new URLSearchParams({ 'passengerTicketStr': params.passengerTicketStr, 'oldPassengerStr': params.oldPassengerStr, 'purpose_codes': '00', 'key_check_isChange': params.keyCheckIsChange, 'leftTicketStr': params.leftTicketStr, 'train_location': params.trainLocation, 'choose_seats': '', 'seatDetailType': '000', 'is_jy': 'N', 'is_cj': 'N', 'encryptedData': '', 'whatsSelect': '1', 'roomType': '00', 'dwAll': 'N', '_json_att': '', 'REPEAT_SUBMIT_TOKEN': params.token }); return request('/otn/lcConfirmPassenger/confirmLCForQueue', { method: 'POST', body, headers: { 'Referer': LC_CONFIRM_REFERER } }, 15000); }, // 提交候补订单。接口与参数来自 12306 自己的 queryLeftTicket_end_js.js(bg 函数): // POST /otn/afterNate/submitOrderRequest data: { secretList: "secretStr#席别代码|" } // 成功返回 data.flag=true;需要人证核验时返回 data.faceCheck 错误码。 async submitWaitlist(secretList) { return request('/otn/afterNate/submitOrderRequest', { method: 'POST', body: new URLSearchParams({ secretList: secretList }) }, 15000); }, async checkOrderInfo(passengerTicketStr, oldPassengerStr, tourFlag = 'dc', token) { const body = new URLSearchParams({ 'cancel_flag': '2', 'bed_level_order_num': '000000000000000000000000000000', 'passengerTicketStr': passengerTicketStr, 'oldPassengerStr': oldPassengerStr, 'tour_flag': tourFlag, 'randCode': '', 'whatsSelect': '1', 'sessionId': '', 'sig': '', 'scene': 'nc_login', '_json_att': '', 'REPEAT_SUBMIT_TOKEN': token }); return request('/otn/confirmPassenger/checkOrderInfo', { method: 'POST', body: body }); }, async getQueueCount(trainDate, trainNo, stationTrainCode, seatType, fromStationTelecode, toStationTelecode, token) { const body = new URLSearchParams({ 'train_date': new Date(trainDate).toString(), 'train_no': trainNo, 'stationTrainCode': stationTrainCode, 'seatType': seatType, 'fromStationTelecode': fromStationTelecode, 'toStationTelecode': toStationTelecode, 'leftTicket': '', 'purpose_codes': '00', 'train_location': '', '_json_att': '', 'REPEAT_SUBMIT_TOKEN': token }); return request('/otn/confirmPassenger/getQueueCount', { method: 'POST', body: body }); }, async confirmSingleForQueue(passengerTicketStr, oldPassengerStr, keyCheckIsChange, token, leftTicketStr, trainLocation, chooseSeats = '') { const body = new URLSearchParams({ 'passengerTicketStr': passengerTicketStr, 'oldPassengerStr': oldPassengerStr, 'purpose_codes': '00', 'key_check_isChange': keyCheckIsChange, 'leftTicketStr': leftTicketStr, 'train_location': trainLocation, // 选座偏好。原来这里是写死的空串(= 由 12306 随机分配)。 // 12306 官方 confirmSingleForQueue 的调用里这个字段就是 choose_seats, // 值由页面把选中的座位元素 id 拼出来,形如 "1D1F",格式与判据见 buildChooseSeats / // resolveChooseSeats。默认不选座时仍是空串,行为和以前完全一样。 'choose_seats': chooseSeats, 'seatDetailType': '000', 'is_jy': 'N', 'is_cj': 'Y', 'encryptedData': '', 'whatsSelect': '1', 'roomType': '00', 'dwAll': 'N', '_json_att': '', 'REPEAT_SUBMIT_TOKEN': token }); return request('/otn/confirmPassenger/confirmSingleForQueue', { method: 'POST', body: body }); } }; })(); // ========================================== // 2. TicketLogicModule (车次解析层) // ========================================== const TicketLogicModule = (() => { // leftTicket 返回的 start_train_date 是 YYYYMMDD,而 12306 下单接口要求 YYYY-MM-DD function toIsoDate(raw) { const s = String(raw || '').trim(); return /^\d{8}$/.test(s) ? `${s.slice(0,4)}-${s.slice(4,6)}-${s.slice(6,8)}` : s; } function parseTrainInfo(rawString) { if (!rawString) return null; const parts = rawString.split('|'); if (parts.length < 33) return null; return { secretStr: parts[0], status: parts[1], trainNo: parts[2], trainCode: parts[3], fromStation: parts[6], toStation: parts[7], startTime: parts[8], endTime: parts[9], duration: parts[10], canBuy: parts[11], leftTicket: parts[12], trainDate: toIsoDate(parts[13]), trainLocation: parts[15], // 索引经 12306 官方 queryLeftTicket_end_js.js 核对(c9[38]=houbu_seat_limit,c9[55]=sale_time) houbuSeatLimit: parts[38] || '', houbuEnabled: parts[37] === '1', saleTime: parts[55] || '', tickets: { '商务座': parts[32] || '', '特等座': parts[25] || '', '一等座': parts[31] || '', '二等座': parts[30] || '', '软卧': parts[23] || '', '硬卧': parts[28] || '', '软座': parts[24] || '', '硬座': parts[29] || '', '无座': parts[26] || '' }, raw: rawString }; } function hasTicket(stockStr) { if (!stockStr) return false; const s0 = String(stockStr).trim(); if (s0 === '有' || s0 === '是' || s0 === 'Y') return true; if (s0 === '无' || s0 === '--' || s0 === '-' || s0 === '' || s0 === '0' || s0 === 'N') return false; if (stockStr === '有') return true; if (stockStr === '无') return false; if (stockStr === '--' || stockStr === '-' || stockStr === '' || stockStr === '0' || stockStr === 'N') return false; if (stockStr === 'Y' || stockStr === '是') return true; const s = String(stockStr).trim(); const num = parseInt(s, 10); return !isNaN(num) && num > 0; } // UI 上的「商务/特等」是合并选项,命中其中任一席别都算成功 const SEAT_NAME_CANDIDATES = { swz: ['商务座', '特等座'], zy: ['一等座'], ze: ['二等座'], gr: ['高级软卧'], rw: ['软卧'], yw: ['硬卧'], rz: ['软座'], yz: ['硬座'], wz: ['无座'] }; function getSeatNamesByCode(code) { return SEAT_NAME_CANDIDATES[code] || ['二等座']; } // canWebBuy 不是 Y 时,结合 sale_time 说清到底是哪一种。 // 原来一律显示「当前不可预订(canWebBuy=N)」,群友看不懂,而且分不清 // 是"还没放票"(挂着等就行)还是"已经放完被抢光了"(该考虑候补或换车次)—— // 这两件事该做的事完全不同。sale_time 就是这趟票的起售日期+时刻, // 判断依据在 parseSaleTime 那儿有实测论证。 function explainNotBuyable(info) { if (info.canBuy === 'IS_TIME_NOT_BUY') return '未到起售时间'; const st = parseSaleTime(info.saleTime); if (!st) { return `当前不可预订(canWebBuy=${info.canBuy})——12306 这次没返回起售时间,多半是本次不放票`; } const when = `${st.date} ${st.time}`; const today = serverTodayIso(); if (st.date > today) return `未到起售时间(${when} 开售)`; if (st.date < today) return `已过起售时间且不可预订(起售时间 ${when},多半已售完)`; // 同一天就比时刻。这里用本地时区,和 saleTimestampLocally 保持同一套假设。 const now = serverNow(); const cur = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; return cur < st.time ? `未到起售时间(${when} 开售)` : `已过起售时间且不可预订(起售时间 ${when},多半已售完)`; } // 查不到票时给出可读原因,避免面板只重复刷「正在查票」让人以为脚本卡住 function explainMiss(resultList, targetTrainCode, targetSeats = ['二等座'], departFrom = '', departTo = '') { if (!Array.isArray(resultList)) return '查票结果格式异常'; const code = String(targetTrainCode || '').trim().toUpperCase(); if (!code) return explainMissAny(resultList, targetSeats, departFrom, departTo); for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info || info.trainCode.toUpperCase() !== code) continue; if (info.canBuy !== 'Y') { return `${info.trainCode} ${explainNotBuyable(info)}`; } if (!inDepartRange(info.startTime, departFrom, departTo)) { return `${info.trainCode}(${info.startTime}) 发车时间不在筛选范围内`; } const detail = targetSeats.map(sc => { const names = getSeatNamesByCode(sc); return `${names.join('/')}=${names.map(n => info.tickets[n] || '--').join('/')}`; }).join(' '); return `${info.trainCode} 可预订但无票 [${detail}]`; } return `${targetTrainCode} 不在本次查票结果中`; } // 多个车次时的原因汇总。 // 实测教训:有群友填了 44 个车次,结果每轮都输出 44 条「XX 未到起售时间」, // 一行 800 多字,把整个日志刷没了。原因相同就合并成一句。 function summarizeMiss(resultList, trainCodes, targetSeats, departFrom = '', departTo = '') { const list = (trainCodes && trainCodes.length) ? trainCodes : ['']; const msgs = list.map(c => explainMiss(resultList, c, targetSeats, departFrom, departTo)); if (list.length === 1) return msgs[0]; // 去重要比"原因",不能比整句——每条消息开头都带着车次号, // 拿整句去比等于永远不重复,44 趟车照样会刷 44 行。 const reasonOf = m => String(m).replace(/^[^\s(]+(\([^)]*\))?\s*/, '').trim(); const reasons = msgs.map(reasonOf); const distinct = reasons.filter((v, i, a) => a.indexOf(v) === i); if (distinct.length === 1) { return `${list.length} 趟车情况相同:${distinct[0]}`; } // 原因不同时才逐条列,但加上车次数量,别让人以为漏了 return `共 ${list.length} 趟车,情况如下:${msgs.join(';')}`; } // 不限车次(目标车次留空)时的原因说明 function explainMissAny(resultList, targetSeats, departFrom, departTo) { if (!resultList.length) return '本次查票结果为空'; let buyable = 0, inRange = 0; for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info || info.canBuy !== 'Y') continue; buyable++; if (inDepartRange(info.startTime, departFrom, departTo)) inRange++; } if (buyable === 0) return '所有车次都未到起售时间或当前不可预订'; if (inRange === 0) { return `没有任何车次的发车时间落在 ${departFrom || '00:00'}~${departTo || '24:00'} 内`; } return `共 ${inRange} 趟可订车次,但所选席别都没有余票`; } // 一个候补订单最多 5 个「车次+席别」组合(12306 源码 alowHBMaxNum=5) const HB_MAX_COMBOS = 5; // 从查票结果里挑出可候补的组合,拼成 12306 要的 secretList。 // 格式还原自 12306 自己的 hbSubmit 处理器: // secretList = secretStr + "#" + 席别代码 + "|" (多组依次拼接) // 会跳过:不可预订的、没 secretStr 的、发车时间不在范围内的、候补已满的席别。 function buildWaitlistSecretList(resultList, trainCodes, targetSeats, departFrom = '', departTo = '', maxCombos = HB_MAX_COMBOS) { if (!Array.isArray(resultList)) return { secretList: '', combos: [] }; const codes = (trainCodes && trainCodes.length) ? trainCodes.map(c => String(c).trim().toUpperCase()) : null; const combos = []; for (const rawStr of resultList) { if (combos.length >= maxCombos) break; const info = parseTrainInfo(rawStr); if (!info) continue; if (codes && codes.indexOf(info.trainCode.toUpperCase()) === -1) continue; if (info.canBuy !== 'Y') continue; // 未开售 / 不可预订,候补也提交不了 if (!info.secretStr) continue; if (!inDepartRange(info.startTime, departFrom, departTo)) continue; for (const seatCode of targetSeats) { for (const seatName of getSeatNamesByCode(seatCode)) { if (combos.length >= maxCombos) break; const hbSeatCode = SEAT_TYPE_CODE[seatName]; if (!hbSeatCode) continue; // houbu_seat_limit 里列出的席别表示该车次该席别候补已满,选了也会被拒 if (info.houbuSeatLimit && info.houbuSeatLimit.indexOf(hbSeatCode) > -1) continue; combos.push({ trainCode: info.trainCode, startTime: info.startTime, seatName: seatName, seatCode: hbSeatCode, secretStr: info.secretStr }); } } } const secretList = combos.map(c => `${c.secretStr}#${c.seatCode}|`).join(''); return { secretList: secretList, combos: combos }; } return { // targetTrainCode 留空 = 不限车次(抢到哪班算哪班)。 // departFrom / departTo 形如 "06:00",用于过滤发车时间,留空不限。 // stationExpect 形如 { from: 'SHH', to: 'HZH' },传"当前这个查询目标的起终站简码"。 // 为什么要有它:车次号本身不带区间。只看车次号,没法证明命中的那趟车就是你要坐的那一段。 // 为什么不做成"不匹配就跳过":12306 查询用的站码和结果里的乘车站码不保证逐字相等 // (城市码 / 同城多站),一旦不等就把整轮的车次全滤掉——票就在眼前却一趟都不抢,还不报警。 // 所以做两级:完全匹配的优先;一个都没有才退回原行为,并把实际区间标出来交给调用方去提示。 findTargetTrain(resultList, targetTrainCode, targetSeats = ['二等座'], departFrom = '', departTo = '', stationExpect = null) { if (!resultList || !Array.isArray(resultList)) return null; const code = String(targetTrainCode || '').trim().toUpperCase(); const wantFrom = String((stationExpect && stationExpect.from) || '').trim(); const wantTo = String((stationExpect && stationExpect.to) || '').trim(); const checkStation = !!(wantFrom || wantTo); // 一个都没给就等于没开这个校验 let fallback = null; for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info) continue; if (code && info.trainCode.toUpperCase() !== code) continue; if (info.canBuy !== 'Y') continue; if (!inDepartRange(info.startTime, departFrom, departTo)) continue; for (const seatCode of targetSeats) { let seatName = null; for (const name of getSeatNamesByCode(seatCode)) { if (hasTicket(info.tickets[name])) { seatName = name; break; } } if (!seatName) continue; const hit = { secretStr: info.secretStr, trainDate: info.trainDate, trainNo: info.trainNo, trainCode: info.trainCode, fromStation: info.fromStation, toStation: info.toStation, seatName: seatName, leftTicket: info.leftTicket, trainLocation: info.trainLocation }; if (!checkStation) return hit; const okFrom = !wantFrom || info.fromStation === wantFrom; const okTo = !wantTo || info.toStation === wantTo; if (okFrom && okTo) return hit; if (!fallback) { // 区间对不上,但票是真的有。先留着,遍历完没有完全匹配的再用它 fallback = Object.assign({}, hit, { stationMismatch: true, expectFrom: wantFrom, expectTo: wantTo }); } break; // 这趟车已经判定完了(同一趟车的区间只有一个),看下一趟 } } return fallback; }, explainMiss, summarizeMiss, buildWaitlistSecretList, // 读出目标车次的起售时刻。12306 的起售时间按「乘车站」算, // 所以同一区间里从不同车站发车的车次可能拿到不同的起售时间。 readSaleTimes(resultList, trainCodes, departFrom = '', departTo = '') { if (!Array.isArray(resultList)) return []; const codes = (trainCodes && trainCodes.length) ? trainCodes.map(c => String(c).trim().toUpperCase()) : null; const out = []; const seen = {}; for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info) continue; if (codes && codes.indexOf(info.trainCode.toUpperCase()) === -1) continue; if (!inDepartRange(info.startTime, departFrom, departTo)) continue; const st = parseSaleTime(info.saleTime); if (!st) continue; const key = `${info.trainCode}@${st.date} ${st.time}`; if (seen[key]) continue; seen[key] = true; out.push({ trainCode: info.trainCode, date: st.date, time: st.time }); } return out; }, // 从中转查询结果里挑一个可用的方案。 // 结构来自实测 /lcquery/queryG:middleList[] 每条是一个方案, // 关键字段:middle_station_name/code(中转站)、wait_time_minutes(换乘等待)、 // first_train_no/second_train_no、fullList[](两程各自的席别余票)。 // 筛选条件:两程都要有目标席别的余票,且换乘等待不小于 minWait 分钟 //(换乘时间太短赶不上,等于白买)。 pickTransferPlan(data, seatTypes, minWait = 30) { if (!data || !Array.isArray(data.middleList)) return null; const wantSeats = (seatTypes || []).flatMap(c => getSeatNamesByCode(c)); const limit = Number(minWait); const okWait = t => !Number.isFinite(limit) || limit <= 0 || (typeof t === 'number' && t >= limit); for (const p of data.middleList) { if (!p || !p.middle_station_code || !p.middle_station_name) continue; if (!Array.isArray(p.fullList) || p.fullList.length < 2) continue; if (!okWait(Number(p.wait_time_minutes))) continue; // 两程都要有票:fullList 里每程都带各席别的余票字段 const both = p.fullList.every(leg => wantSeats.some(name => { const key = SEAT_NUM_FIELD[name]; return key && hasTicket(leg[key]); })); if (!both) continue; // 顺便把"哪一段用哪个席别"也记下来:联程的 dataStr 每段各要一个席别码 const legSeats = p.fullList.map(leg => wantSeats.find(name => { const key = SEAT_NUM_FIELD[name]; return key && hasTicket(leg[key]); }) || ''); return { middleCode: p.middle_station_code, middleName: p.middle_station_name, waitMinutes: p.wait_time_minutes, totalTime: p.all_lishi, trainDate: p.train_date || '', trainCodes: p.fullList.map(l => l.station_train_code), legSeatNames: legSeats, seatName: legSeats[0] || '二等座', secretStr: p.scretstr || '' }; } return null; } }; })(); // ========================================== // 3. OrderLogicModule (下单逻辑层) // ========================================== const OrderLogicModule = (() => { // 12306 下单页在不同版本里混用单/双引号,两种都要能解析 const REGEX_TOKEN = /globalRepeatSubmitToken\s*=\s*['"]([^'"]+)['"]/; const REGEX_KEY_CHECK = /["']?key_check_isChange["']?\s*[:=]\s*["']([^"']+)["']/; const REGEX_LEFT_TICKET = /["']leftTicketStr["']\s*:\s*["']([^"']+)["']/; // 这个车次/席别能不能选座,12306 的下单页自己会声明: // canChooseSeats = 'Y' 表示该车次支持选座 // choose_Seats = '...' 是"允许选座的席别代码"串 // 官方 JS 的用法就是把两者一起判断(queryLeftTicket_end_js.js): // if (canChooseSeats && "Y" == canChooseSeats && "Y" == dd) { if (choose_Seats.indexOf(席别) > -1) 显示选座区 } // 这两个变量在页面里赋值、官方 JS 只读不写,所以从下单页 HTML 解析出来即可—— // 跟取 token / key_check_isChange 是同一个路子。 const REGEX_CAN_CHOOSE = /canChooseSeats\s*=\s*['"]([YN])['"]/; const REGEX_CHOOSE_SEATS_LIST = /choose_Seats\s*=\s*['"]([^'"]*)['"]/; // 决定这一次下单到底带不带选座偏好。 // 用户没勾 -> 空串;勾了但页面说这车次不支持、或这个席别不在允许列表里 -> 也退回空串。 // 宁可静默退回自动分配,也不拿一个可选偏好去撞 12306 的参数校验。 function resolveChooseSeats(htmlContent, want, seatCode) { if (!want) return ''; const html = String(htmlContent || ''); const can = html.match(REGEX_CAN_CHOOSE); if (!can || can[1] !== 'Y') return ''; const list = html.match(REGEX_CHOOSE_SEATS_LIST); if (!list) return ''; return String(list[1]).indexOf(seatCode) > -1 ? want : ''; } function buildPassengerStrings(passengers, seatCode) { let passengerTicketList = []; let oldPassengerList = []; passengers.forEach(p => { // 票种完全由面板勾选框决定,不再回退到 12306 返回的 passenger_type, // 否则 12306 里标注为学生的乘车人会在我方未勾选时被下单成学生票 const ticketType = p.isStudent === true ? '3' : '1'; const allEncStr = p.allEncStr || ''; const pStr = `${seatCode},0,${ticketType},${p.passenger_name},${p.passenger_id_type_code},${p.passenger_id_no},${p.mobile_no || ''},N,${allEncStr}`; passengerTicketList.push(pStr); const oldStr = `${p.passenger_name},${p.passenger_id_type_code},${p.passenger_id_no},${ticketType}_`; oldPassengerList.push(oldStr); }); return { passengerTicketStr: passengerTicketList.join('_'), oldPassengerStr: oldPassengerList.join('') }; } return { // 联程下单:一次提交两段、一个订单。 // 流程跟普通下单对称,只是接口换成 lcConfirmPassenger/ 那一套, // 而且 getQueueCount 的 dataStr 要装**两段**、confirmLCForQueue 只传第一段的 leftTicketStr。 // 全部依据 12306 自己的 lcpassengerInfo_js.js,不是猜的。 async executeTransferOrderSequence(plan, passengers, fromName, toName) { tlog('log', `[LC] 联程下单开始:${plan.trainCodes.join(' + ')}`); try { // 乘客串的席别字段联程传空——这是实测抓到的请求原文(",0,1,邱尚,..."), // 12306 从 dataStr 里读每段的席别,不从 passengerTicketStr 读。 // 注意这里**不接收** buildPassengerStrings 的 oldPassengerStr—— // 联程的 oldPassengerStr 是「区间串」,由 buildLcOldPassengerStr 单独生成。 const { passengerTicketStr } = buildPassengerStrings(passengers, ''); // ① 提交联程方案 const sub = await NetworkModule.submitTransferOrder( plan.secretStr, plan.trainDate, fromName, toName); if (sub && sub.status === false) { throw new Error(`提交联程方案失败: ${((sub.messages || []).join(',')) || '12306 没给原因'}`); } // ② 拿确认页并解析 const html = await NetworkModule.getLcConfirmPage(); if (!html || html.length < 5000) { throw new Error(`联程确认页异常(仅返回 ${html ? html.length : 0} 字节),可能登录已失效`); } const tokenMatch = html.match(REGEX_TOKEN); const keyMatch = html.match(REGEX_KEY_CHECK_LC) || html.match(REGEX_KEY_CHECK); const lcForm = parseLcConfirmForm(html); if (!tokenMatch || !keyMatch || !lcForm || !lcForm.length) { const missing = [!tokenMatch && 'Token', !keyMatch && 'Key', (!lcForm || !lcForm.length) && 'ticketInfoForPassengerForm'].filter(Boolean).join('/'); throw new Error(`联程确认页解析失败,缺少 ${missing},12306 可能已改版`); } const token = tokenMatch[1]; const keyCheckIsChange = keyMatch[1]; // 参数长度照普通下单一样记下来:出问题时一眼能看出是不是解析取偏了 tlog('log', `[LC] 确认页参数: token=${token.length}位 key=${keyCheckIsChange.length}位` + ` 段数=${lcForm.length} location=${lcForm.map(s => (s && s.train_location) || '(空)').join(',')}`); // 两段各自的席别(联程两段席别不同是常态),后面三个地方都要用 const legNames = lcForm.map((_, i) => (plan.legSeatNames && plan.legSeatNames[i]) || plan.seatName || '二等座'); const legCodes = legNames.map(n => SEAT_TYPE_CODE[n] || ''); // 联程的 oldPassengerStr 不是乘客串,是「每段的席别+区间」(见 buildLcOldPassengerStr) const lcOldPassengerStr = buildLcOldPassengerStr(lcForm, legCodes, legNames); // ③ 检查订单信息 const checkRes = await NetworkModule.checkOrderInfoLc(passengerTicketStr, lcOldPassengerStr); if (!checkRes.data || !checkRes.data.submitStatus) { // 诊断:这一步失败时 12306 往往不给 errMsg,只回一个 submitStatus:false, // 甚至整段返回 HTML(被重定向到登录页)——那样连 data 都没有。 // 只抛一句 "Unknown" 是查不下去的,把响应原文记进技术日志。 const brief = JSON.stringify(checkRes).slice(0, 600); tlog('error', `[LC] checkOrderInfo 响应原文: ${brief}`); throw new Error(`CheckOrderInfo failed: ${checkRes.data?.errMsg || (checkRes.messages || []).join(',') || brief}`); } // ④ 排队 const dataStr = buildLcQueueDataStr(lcForm, legCodes, legNames); const queueRes = await NetworkModule.getQueueCountLc(dataStr); tlog('log', `[LC] Queue count: ${queueRes.data && queueRes.data.resultDtoList ? queueRes.data.resultDtoList.map(r => r.countT).join(',') : '?'}`); // ⑤ 确认进队列。oldPassengerStr 同样是那个「区间串」 const first = lcForm[0] || {}; const confirmRes = await NetworkModule.confirmLcForQueue({ passengerTicketStr: passengerTicketStr, oldPassengerStr: lcOldPassengerStr, keyCheckIsChange: keyCheckIsChange, leftTicketStr: first.leftTicketStr || '', trainLocation: first.train_location || '', token: token }); if (!confirmRes.data || !confirmRes.data.submitStatus) { const brief = JSON.stringify(confirmRes).slice(0, 600); tlog('error', `[LC] confirmLCForQueue 响应原文: ${brief}`); throw new Error(`Confirm failed: ${confirmRes.data?.errMsg || (confirmRes.messages || []).join(',') || brief}`); } // 走到这里只代表 12306"收下了",订单还在队列里,没落地。 // 真实流程(lcpassengerInfo_js.js):isAsync=1 时要轮询排队状态, // 队列结束后拿 orderId 再认领一次,才算真下单成功。 // 少了这一步就去跳支付页,页面只会显示「系统忙,请稍后重试」。 const isAsync = String(confirmRes.data.isAsync); tlog('log', `[LC] 已受理,isAsync=${isAsync}`); if (isAsync === '1') { UIModule.log('📮 联程订单已受理,正在排队等分配席位…', 'info'); const r = await NetworkModule.waitForLcOrder(); if (r.ok) { tlog('log', `🎉 联程下单成功!订单号 ${r.orderId}`); return { success: true, orderId: r.orderId }; } // 账号级限制:取消/失败次数过多时 12306 会掐掉当天的下单资格。 // 这个必须单独拎出来说清楚——它跟"票被抢走"完全是两回事, // 继续重试、继续换车次都没用,只会把限制拖得更久。 if (/取消次数|次数过多|不能继续受理|明日|频繁|限制/.test(r.msg)) { return { success: false, blocked: r.msg, error: r.msg }; } if (r.timeout) { // 排队还没结论(3 分钟没等到结果)。这时候不能报"失败", // 订单可能已经落好了,只是状态还没回过来。 return { success: false, uncertain: true, error: '订单已受理,但排队状态一直没确认下来。请打开 12306 的订单列表看一眼(未支付那栏),票可能已经出好了' }; } // 队列明确给了否定结果(订单没生成) return { success: false, failReason: r.failReason || '订单没有生成', error: r.msg ? `排队被拒:${r.msg}` : '排队被拒,订单没有生成(12306 没给具体原因)。稍后可以再试一次' }; } tlog('log', '🎉 联程下单成功(同步返回)!'); return { success: true, orderId: confirmRes.data.orderId || '' }; } catch (e) { tlog('error', '[LC] Sequence Failed:', e); return { success: false, error: e.message }; } }, // chooseSeats 形如 "1D1F"(拼法见 buildChooseSeats),留空 = 由 12306 自动分配。 async executeOrderSequence(trainInfo, passengers, chooseSeats = '') { tlog('log', `[OrderLogic] Starting order for ${trainInfo.trainCode}`); try { const submitRes = await NetworkModule.submitOrderRequest( trainInfo.secretStr, trainInfo.trainDate, trainInfo.trainDate, trainInfo.fromStation, trainInfo.toStation, purposeCodesFor(passengers) ); if (submitRes.status === false) { throw new Error(`Submit failed: ${submitRes.messages?.join(',') || 'Unknown error'}`); } const htmlContent = await NetworkModule.getInitDcPage(); const tokenMatch = htmlContent.match(REGEX_TOKEN); const keyMatch = htmlContent.match(REGEX_KEY_CHECK); const leftTicketMatch = htmlContent.match(REGEX_LEFT_TICKET); if (!tokenMatch || !keyMatch || !leftTicketMatch) { // initDc 正常返回数十 KB 的下单页;会话失效时会被重定向到很短的登录页 // 注意:走到这里说明 submitOrderRequest 已经成功了——服务器上可能已经留下一笔订单, // 所以必须提醒用户去检查「未完成订单」,否则那笔单会把后续所有下单都堵死。 const hint = '。注意:submitOrderRequest 已提交,可能有订单挂在「未完成订单」里,建议去检查一下'; if (!htmlContent || htmlContent.length < 5000) { throw new Error(`会话已过期或下单页异常(仅返回 ${htmlContent ? htmlContent.length : 0} 字节),请重新登录${hint}`); } const missing = [!tokenMatch && 'Token', !keyMatch && 'Key', !leftTicketMatch && 'LeftTicket'].filter(Boolean).join('/'); throw new Error(`下单页解析失败,缺少 ${missing},12306 可能已改版${hint}`); } const token = tokenMatch[1]; const keyCheckIsChange = keyMatch[1]; const leftTicketStr = leftTicketMatch[1]; // 诊断:这三个值取自下单页的正则匹配,一旦取偏(比如匹配到页面里别处的同名字段), // 12306 只会在最后一步回一句笼统的「非法的订票请求!」,从报错上完全看不出原因。 // 这里把长度记下来(不记内容),下次出问题一眼就能判断是不是解析取偏了。 tlog('log', `[OrderLogic] 下单页参数: token=${token.length}位 key=${keyCheckIsChange.length}位` + ` leftTicketStr=${leftTicketStr.length}位 train_location=${trainInfo.trainLocation || '(空!)'}`); if (!trainInfo.trainLocation) { tlog('warn', '[OrderLogic] train_location 为空——这会被 12306 判为非法请求,查票结果里该字段可能没解析到'); } const seatCode = SEAT_TYPE_CODE[trainInfo.seatName] || 'O'; const { passengerTicketStr, oldPassengerStr } = buildPassengerStrings(passengers, seatCode); // 选座偏好:只有该车次、该席别确实支持时才带(判据来自下单页自身,见 resolveChooseSeats) const seatsToSubmit = resolveChooseSeats(htmlContent, chooseSeats, seatCode); if (chooseSeats && !seatsToSubmit) { tlog('log', `[OrderLogic] ${trainInfo.trainCode} ${trainInfo.seatName} 不支持选座,` + `已退回自动分配(原选座偏好 ${chooseSeats})`); } const checkRes = await NetworkModule.checkOrderInfo(passengerTicketStr, oldPassengerStr, 'dc', token); if (!checkRes.data || !checkRes.data.submitStatus) { throw new Error(`CheckOrderInfo failed: ${checkRes.data?.errMsg || 'Unknown'}`); } // trainDate 已归一化为 YYYY-MM-DD;补 T00:00:00 强制按本地时区解析,避免 UTC 解析把日期挪到前一天 const dateObj = new Date(`${trainInfo.trainDate}T00:00:00`); const queueRes = await NetworkModule.getQueueCount( dateObj, trainInfo.trainNo, trainInfo.trainCode, seatCode, trainInfo.fromStation, trainInfo.toStation, token ); tlog('log', `[OrderLogic] Queue count: ${queueRes.data?.countT}`); const confirmRes = await NetworkModule.confirmSingleForQueue( passengerTicketStr, oldPassengerStr, keyCheckIsChange, token, leftTicketStr, trainInfo.trainLocation, seatsToSubmit ); if (confirmRes.data && confirmRes.data.submitStatus) { tlog('log', '🎉 ORDER SUBMITTED SUCCESSFULLY!'); const orderId = confirmRes.data.orderId || confirmRes.data.order_id || ''; return { success: true, orderId: orderId }; } else { throw new Error(`Confirm failed: ${confirmRes.data?.errMsg || 'Unknown'}`); } } catch (error) { tlog('error', '[OrderLogic] Sequence Failed:', error); return { success: false, error: error.message }; } }, REGEX_TOKEN, REGEX_KEY_CHECK, REGEX_LEFT_TICKET }; })(); // ========================================== // 4. UIModule (面板与用户交互) // ========================================== const UIModule = (() => { const STYLES = ` #ticket-helper-panel { position:fixed; top:50px; right:20px; width:330px; background:#fff; border:1px solid #ddd; box-shadow:0 2px 10px rgba(0,0,0,0.1); z-index:9999; border-radius:8px; font-family:sans-serif; font-size:14px; } .th-header { padding:10px 15px; background:#3b82f6; color:white; border-radius:8px 8px 0 0; display:flex; justify-content:space-between; align-items:center; font-weight:bold; cursor:move; } .th-body { padding:15px; max-height:550px; overflow-y:auto; } .th-form-group { margin-bottom:12px; } .th-form-group label { display:block; margin-bottom:5px; color:#374151; font-weight:500; } .th-input, .th-select { width:100%; padding:8px; border:1px solid #d1d5db; border-radius:4px; box-sizing:border-box; } .th-btn { width:100%; padding:10px; border:none; border-radius:4px; color:white; font-weight:bold; cursor:pointer; transition:background 0.2s; } .th-btn-primary { background:#3b82f6; } .th-btn-primary:hover { background:#2563eb; } .th-btn-danger { background:#ef4444; } .th-btn-danger:hover { background:#dc2626; } .th-btn-secondary { width:100%; margin-top:8px; padding:9px 12px; background:#fff; color:#374151; border:1px solid var(--th-border); border-radius:10px; font-weight:600; font-size:13px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:6px; transition:background .15s,border-color .15s; } .th-btn-secondary:hover { background:#f3f4f6; border-color:#d1d5db; } .th-btn-test:hover { background:#d68910; } .th-log-area { margin-top:15px; padding:10px; background:#f3f4f6; border-radius:4px; height:150px; overflow-y:auto; font-family:monospace; font-size:12px; color:#333; border:1px solid #e5e7eb; } .th-log-entry { margin-bottom:4px; } .th-log-info { color:#2563eb; } .th-log-success { color:#059669; } .th-log-error { color:#dc2626; } .th-log-warn { color:#d97706; } .seat-checkboxes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; } .seat-checkboxes label { display: inline-flex; align-items: center; margin: 0; font-weight: normal; background: #f3f4f6; padding: 4px 8px; border-radius: 4px; cursor:pointer; } .seat-checkboxes input { width: auto; margin-right: 5px; } #grab-status { margin-top: 6px; padding: 6px; border-radius: 4px; text-align: center; font-weight: bold; font-size: 13px; background: #7f8c8d; color: #fff; } .grab-status-running { background: #27ae60 !important; animation: pulse 1.5s infinite; } .grab-status-waiting { background: #f39c12 !important; } .grab-status-stopped { background: #7f8c8d !important; } .grab-status-found { background: #e74c3c !important; animation: pulse 0.5s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } } /* ===== 优化版 UI 覆盖层 ===== */ :root { --th-primary: #2563eb; --th-primary-hover: #1d4ed8; --th-danger: #dc2626; --th-text: #1f2937; --th-muted: #6b7280; --th-border: #e5e7eb; --th-bg: #f9fafb; } #ticket-helper-panel { width: 340px; border: 1px solid rgba(255,255,255,.45); background: rgba(255,255,255,.62); -webkit-backdrop-filter: blur(18px) saturate(160%); backdrop-filter: blur(18px) saturate(160%); box-shadow: 0 16px 48px rgba(15,23,42,.28), 0 0 0 1px rgba(255,255,255,.2) inset; border-radius: 16px; font-family: -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--th-text); overflow: hidden; } .th-header { padding: 12px 16px; background: linear-gradient(135deg, rgba(37,99,235,.78), rgba(79,70,229,.78)); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-radius: 16px 16px 0 0; user-select: none; } .th-close { font-size: 13px; opacity: .75; cursor: pointer; transition: opacity .15s; } .th-close:hover { opacity: 1; } .th-body { padding: 16px; background: rgba(249,250,251,.32); max-height: 560px; } .th-form-group { margin-bottom: 14px; } .th-form-group label { color: var(--th-text); font-weight: 600; font-size: 13px; } .th-input, .th-select { padding: 9px 10px; border: 1px solid rgba(203,213,225,.85); border-radius: 8px; background: rgba(255,255,255,.72); font-size: 14px; transition: border-color .15s, box-shadow .15s, background .15s; } .th-input:focus, .th-select:focus { border-color: var(--th-primary); box-shadow: 0 0 0 3px rgba(37,99,235,.12); } .th-btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; border-radius: 10px; padding: 10px 12px; font-size: 14px; transition: transform .12s, background .2s, box-shadow .2s; } .th-btn:active { transform: scale(.98); } .th-btn-primary { box-shadow: 0 4px 12px rgba(37,99,235,.25); } .th-btn-primary:hover { background: var(--th-primary-hover); } .th-btn-danger { box-shadow: 0 4px 12px rgba(220,38,38,.25); } .th-btn-danger:hover { background: #b91c1c; } .th-btn-secondary { margin-top: 8px; padding: 9px 12px; background: rgba(255,255,255,.68); color: #374151; border: 1px solid rgba(203,213,225,.9); border-radius: 10px; font-weight: 600; font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; gap: 6px; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); transition: background .15s, border-color .15s; } .th-btn-secondary:hover { background: #f3f4f6; border-color: #d1d5db; } .th-log-area { margin-top: 14px; padding: 10px; background: rgba(17,24,39,.82); color: #d1d5db; -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); border-radius: 10px; height: 160px; font-size: 12px; line-height: 1.55; } .th-log-entry { margin-bottom: 5px; padding: 2px 4px; border-radius: 4px; } .th-log-info { color: #93c5fd; background: rgba(37,99,235,.08); } .th-log-success { color: #6ee7b7; background: rgba(5,150,105,.1); } .th-log-error { color: #fca5a5; background: rgba(220,38,38,.12); } .th-log-warn { color: #fcd34d; background: rgba(217,119,6,.12); } .seat-checkboxes { gap: 7px; margin-top: 7px; } .seat-checkboxes label { gap: 5px; background: rgba(255,255,255,.62); border: 1px solid rgba(203,213,225,.8); padding: 6px 10px; border-radius: 8px; font-size: 13px; transition: border-color .15s, background .15s, box-shadow .15s; } .seat-checkboxes label:has(input:checked), .seat-checkboxes label.th-seat-selected { border-color: var(--th-primary); background: #eff6ff; color: var(--th-primary); } .seat-checkboxes input { margin: 0; accent-color: var(--th-primary); } .th-card { background: rgba(255,255,255,.55); border: 1px solid rgba(255,255,255,.7); border-radius: 12px; padding: 10px; overflow: hidden; box-shadow: 0 4px 15px rgba(15,23,42,.06); } .th-passenger-list { max-height: 120px; min-height: 36px; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; gap: 4px; padding-right: 2px; } .th-passenger-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; background: #f9fafb; border: 1px solid #eef0f3; border-radius: 8px; padding: 6px 8px; } .th-passenger-row label { margin: 0; font-weight: 500; font-size: 13px; display: inline-flex; align-items: center; gap: 5px; } .th-passenger-row input { accent-color: var(--th-primary); } .th-passenger-empty { color: var(--th-muted); font-size: 13px; text-align: center; padding: 8px 0; } #grab-status { margin-top: 12px; padding: 10px; border-radius: 10px; font-size: 13px; } .grab-status-running { background: #059669 !important; } .grab-status-waiting { background: #d97706 !important; } .grab-status-stopped { background: #6b7280 !important; } .grab-status-found { background: #dc2626 !important; } @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { #ticket-helper-panel { background: rgba(255,255,255,.95); } .th-header { background: linear-gradient(135deg, #2563eb, #4f46e5); } .th-body { background: #f9fafb; } .th-input, .th-select { background: #fff; } .th-btn-secondary { background: #fff; } .th-log-area { background: #111827; } .seat-checkboxes label { background: #fff; } .th-card { background: #fff; } } `; let state = { isRunning: false, config: { ...cfg }, passengersList: [] }; let logContainer = null, onStartCallback = null, onStopCallback = null; let statusEl = null, startBtn = null, stopBtn = null; function playBeep() { try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); if (audioCtx.state === 'suspended') audioCtx.resume(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value = 880; oscillator.type = 'sine'; gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 0.4); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime + 0.4); } catch(e) { tlog('log', '声音播放失败:', e); } } function showNotification(title, body) { if (cfg.soundAlert) playBeep(); if ('Notification' in window) { if (Notification.permission === 'granted') { new Notification(title, { body, icon: 'https://kyfw.12306.cn/favicon.ico' }); } else if (Notification.permission === 'default') { Notification.requestPermission().then(perm => { if (perm === 'granted') new Notification(title, { body }); }); } } try { window.focus(); } catch(e){} } function setStatus(text, cls) { if (!statusEl) return; statusEl.textContent = text; statusEl.className = cls || ''; } function createPanel() { const oldPanel = document.getElementById('ticket-helper-panel'); if (oldPanel) oldPanel.remove(); const styleEl = document.createElement('style'); styleEl.textContent = STYLES; document.head.appendChild(styleEl); // 声音开关复选框 const soundChecked = cfg.soundAlert ? 'checked' : ''; const panel = document.createElement('div'); panel.id = 'ticket-helper-panel'; panel.innerHTML = `
🚄 12306 抢票助手 Pro ✕
${Object.entries(SEAT_MAP_UI).map(([k, v]) => ` `).join('')}
${SEAT_PREF_CHOICES.map(s => ` `).join('')}
勾选数量要和乘车人数一致;一个都不勾 = 由 12306 自动分配。 不同席别能选的字母不一样(二等座 A/B/C/D/F,一等座 A/C/D/F,商务座 A/C/F), 部分车次不支持选座,遇到这种车次会自动退回随机分配。
还没有加载乘客
● 未开始
运行日志
面板就绪,请先加载乘车人。` + (location.pathname === '/' || location.pathname === '/index.html' ? '  当前处于主页,请前往【车票】-【单程】查询页开始抢票。' : '') + `
`; document.body.appendChild(panel); statusEl = document.getElementById('grab-status'); startBtn = document.getElementById('th-action-btn'); logContainer = document.getElementById('th-logs'); bindEvents(); makeDraggable(panel); // 先把上次留下的日志捡回来(抢到票跳转支付页之后,回来还能看到成功记录) restorePersistedLogs(); log('面板就绪,请确认设置后点击开始抢票', 'success'); // 自动保存配置(#6) setupAutoSave(); // 加载乘客列表 refreshPassengers(); // 恢复运行状态(#8) const wasRunning = GM_getValue('grab_running', false); if (wasRunning) { log('检测到上次未停止的任务,自动继续...', 'info'); // 延迟启动,确保UI就绪 setTimeout(() => { // 尝试自动启动 const btn = document.getElementById('th-action-btn'); if (btn && btn.textContent.includes('开始')) { btn.click(); } }, 2000); } } function bindEvents() { document.getElementById('th-action-btn').addEventListener('click', () => { state.isRunning ? stop() : start(); }); document.getElementById('th-btn-test').addEventListener('click', () => { playBeep(); log('🔔 测试声音已播放', 'info'); }); document.getElementById('th-refresh-passengers').addEventListener('click', refreshPassengers); const copyBtn = document.getElementById('th-copy-log'); if (copyBtn) copyBtn.addEventListener('click', copyLogToClipboard); const saleBtn = document.getElementById('th-sale-time'); if (saleBtn) saleBtn.addEventListener('click', lookupSaleTime); // 席别选中状态类,兼容不支持 :has() 的浏览器 const syncSeatLabels = () => { document.querySelectorAll('.seat-checkboxes label').forEach(label => { const cb = label.querySelector('.cfg-seat'); if (cb) label.classList.toggle('th-seat-selected', cb.checked); }); }; document.querySelectorAll('.cfg-seat').forEach(cb => { cb.addEventListener('change', syncSeatLabels); }); syncSeatLabels(); } // 把「面板日志 + 技术日志 + 环境信息」拼成一段纯文本,方便用户一键复制发到群里。 // 加环境信息是为了让排查的人一眼看出问题出在哪:版本、页面、前台还是后台、有没有校准。 function buildLogText() { const lines = []; lines.push('===== 12306 抢票助手 Pro 日志 ====='); lines.push(`版本: v${SCRIPT_VERSION}`); lines.push(`导出时间: ${new Date().toLocaleString()}`); lines.push(`页面地址: ${location.href}`); lines.push(`标签页: ${document.hidden ? '后台(会被浏览器限流)' : '前台'}`); lines.push(`服务器时间校准: ${serverClockOffset === null ? '未校准' : (serverClockOffset > 0 ? '+' : '') + serverClockOffset + 'ms'}`); lines.push(`任务状态: ${state.isRunning ? '运行中' : '已停止'}`); lines.push(''); lines.push('----- 面板日志 -----'); if (logContainer && logContainer.children.length) { for (const entry of logContainer.children) { lines.push(entry.textContent || entry.innerText || ''); } } else { lines.push('(无)'); } lines.push(''); lines.push('----- 技术日志(Console)-----'); lines.push(...(techLog.length ? techLog : ['(无)'])); // 联程确认页探测结果(只在那种页面上才有内容) const lcProbe = readLcProbe(); if (lcProbe) { lines.push(''); lines.push('----- 联程确认页探测(发给开发者用)-----'); lines.push(...lcProbe.split(' ;; ')); } return lines.join('\n'); } async function copyLogToClipboard() { const text = buildLogText(); try { await navigator.clipboard.writeText(text); log(`✅ 日志已复制(${text.length} 字),直接粘贴到群里即可`, 'success'); return true; } catch (e) { // 剪贴板 API 不可用(权限/非安全上下文)时退回老办法 try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.top = '-9999px'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); log(`✅ 日志已复制(${text.length} 字),直接粘贴到群里即可`, 'success'); return true; } catch (e2) { log(`复制失败:${e2.message}。请手动截图面板日志`, 'error'); return false; } } } // 查一次起售时间并填进定时框。 // 起售时刻是「日期+时间」,而定时的输入框只有时间,所以起售日不是今天时必须提醒—— // 否则用户会在错误的日期空等一场。 let saleLookupBusy = false; async function lookupSaleTime() { // 防手抖狂点。实测:连点 10 次会让 12306 直接拒连(Failed to fetch)。 if (saleLookupBusy) return; saleLookupBusy = true; try { let config; try { config = getConfig(); // 失败原因 getConfig 自己已经写进日志了 } catch (e) { return; } log('正在查询起售时间...', 'info'); let res; try { res = await NetworkModule.queryTickets(config.trainDate, config.fromStation, config.toStation, purposeCodesFor(config.passengers)); } catch (e) { log(`查询起售时间失败:${e.message}`, 'error'); return; } if (!res || !res.status || !res.data || !res.data.result || !res.data.result.length) { // 最常见的坑就是出发日期没改(那天的车次根本不存在),所以要明确往这上面引导 log(`查询失败:${config.trainDate} ${config.fromName || ''}→${config.toName || ''} 没查到任何车次。` + `请检查「出发日期」是不是你要坐车的那天,以及出发/到达站有没有填错`, 'error'); return; } const times = TicketLogicModule.readSaleTimes( res.data.result, config.trainCodes, config.departFrom, config.departTo); if (!times.length) { log(`没查到起售时间:查到了 ${res.data.result.length} 趟车,但目标车次不在其中` + `(或 12306 本次没返回该字段)。请核对「目标车次」是否填对;留空则不限车次`, 'warn'); return; } const distinct = []; for (const t of times) { if (!distinct.some(x => x.date === t.date && x.time === t.time)) distinct.push(t); } log(`起售时间:${distinct.map(t => `${t.date} ${t.time}`).join(' / ')}`, 'success'); if (distinct.length > 1) { log('这些车次的起售时间不一致,没有自动填入,请按上面列出的手动填写', 'warn'); return; } const only = distinct[0]; const timeEl = document.getElementById('th-start-time'); if (timeEl) { timeEl.value = `${only.time}:00`; log(`已填入定时框:${only.time}:00`, 'success'); } const now = new Date(); const pad = n => String(n).padStart(2, '0'); const today = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`; if (only.date !== today) { // 以前这里让人"到那天再点开始",其实不必:定时框只填时间, // 点开始后脚本会自己顺延到那天的这个时刻(见 saleTimestampLocally)。 log(`ℹ️ 这趟票的起售日是 ${only.date} ${only.time}(不是今天)。` + `定时框只填时间就行——点开始后脚本会自动等到 ${only.date} ${only.time},现在挂着即可`, 'info'); } } finally { saleLookupBusy = false; } } async function refreshPassengers() { log('正在获取乘客列表...', 'info'); try { const res = await NetworkModule.getPassengerDTOs(); if (res.data && res.data.normal_passengers) { state.passengersList = res.data.normal_passengers; renderPassengers(state.passengersList); log(`已加载 ${state.passengersList.length} 位乘客`, 'success'); return true; } log('未获取到乘客,请确认已登录', 'error'); return false; } catch (e) { log('获取乘客失败: ' + e.message, 'error'); return false; } } // 把值安全地放进 HTML 属性。中文姓名一般不会出问题,但乘车人姓名也可能是 // 护照名(O'Brien、Mary "M" 这种),不转义会把属性直接截断—— // 结果是 data-full 解析失败、勾选状态丢失。多花一个函数的代价,换掉一整类偶发问题。 function escapeAttr(v) { return String(v == null ? '' : v) .replace(/&/g, '&').replace(/"/g, '"') .replace(/'/g, ''').replace(//g, '>'); } function renderPassengers(list) { const container = document.getElementById('th-passenger-list'); container.innerHTML = ''; const empty = document.getElementById('th-passenger-empty'); if (empty) empty.style.display = list.length === 0 ? 'block' : 'none'; list.forEach(p => { const name = p.passenger_name; const isStudent = cfg.passengerDetails && cfg.passengerDetails[name] && cfg.passengerDetails[name].isStudent === true; const div = document.createElement('div'); div.className = 'th-passenger-row'; let html = ``; // 学生票复选框(#7 持久化) html += ``; div.innerHTML = html; container.appendChild(div); // 监听学生票变化,即时保存到 cfg.passengerDetails const studentCb = div.querySelector('.th-p-student-check'); const passengerCb = div.querySelector('.th-p-check'); studentCb.addEventListener('change', function() { const name = passengerCb.value; if (!cfg.passengerDetails) cfg.passengerDetails = {}; if (!cfg.passengerDetails[name]) cfg.passengerDetails[name] = {}; cfg.passengerDetails[name].isStudent = this.checked; saveConfig(cfg); }); // 乘客勾选变化时也触发保存(但保存乘客姓名列表) passengerCb.addEventListener('change', function() { // 更新 cfg.passengers(选中的乘客姓名) const selected = []; document.querySelectorAll('#th-passenger-list .th-p-check:checked').forEach(cb => { selected.push(cb.value); }); cfg.passengers = selected; saveConfig(cfg); }); }); // 恢复选中状态。不用 querySelector 拼选择器:姓名里带引号会让选择器直接抛错, // 而那个异常会被 refreshPassengers 的 try 兜住、静默跳过——表现就是 // 勾好的乘车人被清空,用户点开始时被拦一句「请选择至少一位乘车人」。 // 这里按创建顺序一一对应取元素,不涉及任何转义。 const boxes = container.querySelectorAll('.th-p-check'); list.forEach((p, i) => { const cb = boxes[i]; if (cb && cfg.passengers.includes(p.passenger_name)) { cb.checked = true; } }); } function getConfig() { const date = document.getElementById('th-date').value; const fromName = document.getElementById('th-from').value.trim(); const toName = document.getElementById('th-to').value.trim(); // 日期清空时必须当场拦下:不然所有查询都带空日期,看不出错在哪; // 开了「前后一天」还会算出 NaN-NaN-NaN,日志里全是莫名其妙的车次。 if (!date) { log('请先选择出发日期', 'error'); throw new Error('出发日期为空'); } if (Object.keys(stationMap).length === 0) { log('站点简码未加载,请刷新页面重试', 'error'); throw new Error('站点简码未加载'); } const findStationCode = (input) => { if (!input) return null; if (stationMap[input]) return stationMap[input]; if (/^[A-Z]+$/.test(input)) return input; for (const name of Object.keys(stationMap)) { if (name.includes(input) || input.includes(name)) { return stationMap[name]; } } return null; }; const from = findStationCode(fromName); const to = findStationCode(toName); if (!from || !to) { log(`站点简码转换失败: ${fromName}/${toName},请使用中文站名或确认网络`, 'error'); throw new Error('站点简码无效'); } // 中转站(可选):填了就拆成两段监控,两段都有票才下单 const transferEl = document.getElementById('th-transfer'); const transferInput = transferEl ? transferEl.value.trim() : ''; const autoTransferEl = document.getElementById('cfg-autotransfer'); const autoTransfer = autoTransferEl ? autoTransferEl.checked : false; const waitEl = document.getElementById('th-wait-min'); const waitMinutes = waitEl && waitEl.value !== '' ? Number(waitEl.value) : 30; let transferName = ''; let transferCode = ''; if (transferInput) { transferCode = findStationCode(transferInput); if (!transferCode) { log(`中转站「${transferInput}」无法识别,请使用中文站名`, 'error'); throw new Error('中转站无效'); } if (transferCode === from || transferCode === to) { log('中转站和出发站/到达站重复,已忽略中转', 'warn'); transferCode = ''; } else { transferName = transferInput; } } // 发车时间段(可选):用来在"不限车次"时避开半夜的车 const departFromEl = document.getElementById('th-depart-from'); const departToEl = document.getElementById('th-depart-to'); const departFrom = departFromEl ? departFromEl.value.trim() : ''; const departTo = departToEl ? departToEl.value.trim() : ''; for (const [label, val] of [['发车时间从', departFrom], ['发车时间到', departTo]]) { if (val && hhmmToMinutes(val) === null) { log(`${label}「${val}」格式不对,请写成 06:00 这样(留空表示不限)`, 'error'); throw new Error('发车时间格式无效'); } } // 备选到达站(买长乘短):留空即关闭 const altEl = document.getElementById('th-alt-to'); const altToInput = altEl ? altEl.value.trim() : ''; let altToName = ''; let altToCode = ''; if (altToInput) { altToCode = findStationCode(altToInput); if (!altToCode) { log(`备选到达站「${altToInput}」无法识别,请使用中文站名`, 'error'); throw new Error('备选到达站无效'); } if (altToCode === to) { log('备选到达站与到达站相同,已忽略买长乘短', 'warn'); altToCode = ''; } else { altToName = altToInput; } } const trains = document.getElementById('th-trains').value.split(/[,,]/).map(s => s.trim().toUpperCase()).filter(s => s); const seats = document.querySelectorAll('.cfg-seat:checked'); const seatList = Array.from(seats).map(cb => cb.value); const seatPrefs = Array.from(document.querySelectorAll('.th-seat-check:checked')).map(cb => cb.value); const startTime = document.getElementById('th-start-time').value; const soundAlert = document.getElementById('cfg-sound') ? document.getElementById('cfg-sound').checked : true; const multiDate = document.getElementById('cfg-multidate') ? document.getElementById('cfg-multidate').checked : false; const alarmEnabled = document.getElementById('cfg-alarm') ? document.getElementById('cfg-alarm').checked : true; const lcEl = document.getElementById('cfg-lctransfer'); const lcTransfer = lcEl ? lcEl.checked : false; const waitlistEnabled = document.getElementById('cfg-waitlist') ? document.getElementById('cfg-waitlist').checked : false; // 获取选中的乘客(包含学生票信息) const selectedPassengers = []; document.querySelectorAll('#th-passenger-list .th-p-check:checked').forEach(checkbox => { let passengerData; try { passengerData = JSON.parse(checkbox.dataset.full); } catch (e) { tlog('warn', '跳过无法解析的乘客数据:', checkbox.value); return; } const parentDiv = checkbox.closest('div'); const studentCheck = parentDiv.querySelector('.th-p-student-check'); if (studentCheck) { passengerData.isStudent = studentCheck.checked; } selectedPassengers.push(passengerData); }); // 选座数量和乘车人数必须一致 —— 这不是我们加的规矩,是 12306 自己的: // 官方下单页在校验不过时弹的是「请按照乘车人个数选座对应的席位」。 // 当场拦下来,总比白丢一次下单机会强。 if (seatPrefs.length > 0 && seatPrefs.length !== selectedPassengers.length) { log(`选座数量(${seatPrefs.length} 个)要和乘车人数(${selectedPassengers.length} 人)一致,` + `或者一个都不勾用自动分配`, 'warn'); throw new Error('选座数量与乘车人数不一致'); } // 更新 cfg cfg.targetTrains = trains; cfg.seatTypes = seatList; cfg.seatPrefs = seatPrefs; cfg.trainDate = date; cfg.startTime = startTime; cfg.soundAlert = soundAlert; cfg.fromStation = fromName; cfg.toStation = toName; cfg.fromCode = from; cfg.toCode = to; cfg.altToStation = altToName; cfg.altToCode = altToCode; cfg.multiDate = multiDate; cfg.departFrom = departFrom; cfg.departTo = departTo; cfg.transferStation = transferName; cfg.transferCode = transferCode; cfg.autoTransfer = autoTransfer; cfg.lcTransfer = lcTransfer; cfg.waitMinutes = Number.isFinite(waitMinutes) ? waitMinutes : 30; cfg.alarmEnabled = alarmEnabled; cfg.waitlistEnabled = waitlistEnabled; cfg.passengers = selectedPassengers.map(p => p.passenger_name); // 保存学生票细节 if (!cfg.passengerDetails) cfg.passengerDetails = {}; selectedPassengers.forEach(p => { if (!cfg.passengerDetails[p.passenger_name]) cfg.passengerDetails[p.passenger_name] = {}; cfg.passengerDetails[p.passenger_name].isStudent = p.isStudent === true; }); saveConfig(cfg); return { trainDate: date, fromStation: from, toStation: to, fromName: fromName, toName: toName, altToStation: altToName, altToCode: altToCode, multiDate: multiDate, departFrom: departFrom, departTo: departTo, transferStation: transferName, transferCode: transferCode, autoTransfer: autoTransfer, lcTransfer: lcTransfer, waitMinutes: Number.isFinite(waitMinutes) ? waitMinutes : 30, trainCodes: trains, seatTypes: seatList, passengers: selectedPassengers, startTime, chooseSeats: buildChooseSeats(seatPrefs) }; } async function start() { try { // 冷却检查必须放在最前面:下面 refreshPassengers() 会立刻发请求, // 放到它后面就等于冷却了个寂寞。 // 反馈要够重:只写日志的话,用户看到按钮没变成「停止抢票」, // 只会以为按钮坏了然后继续点,越点越糟。 const cooldownLeft = restartCooldownLeftSec(); if (cooldownLeft > 0) { log(`⏸️ 刚停下不到 ${RESTART_COOLDOWN_MS / 1000} 秒,先等 ${cooldownLeft} 秒再开始` + `(连着重启会一直撞限流,反而更难抢到)`, 'warn'); alertBanner('先别急着重启', `任务刚停下来不到 ${RESTART_COOLDOWN_MS / 1000} 秒。\n\n` + `马上重启的话,脚本会立刻重新发一轮请求,而 12306 这会儿多半还在限流,` + `越撞越难恢复。\n\n请再等 ${cooldownLeft} 秒,然后重新点「开始抢票」。`, 'warn'); return; } // 必须在用户这次点击的手势里解锁音频上下文,否则浏览器会挂起它, // 之后保活失败时就没法从后台把报警声响起来。 //(后续 refreshPassengers 有 await,会丢掉这次手势。) ensureAudioContext(); // 冽红反馈:乘车人在手机端被增删后,面板里的旧列表会导致下单失败。 // 抢票开始前强制刷新一次,拿不到就沿用面板上现有的列表。 const refreshed = await refreshPassengers(); if (!refreshed) log('乘车人刷新失败,将沿用面板上当前的列表', 'warn'); const config = getConfig(); // 自动找中转站放在这里查,而不是等预热触发时查 —— // 否则那一个请求会吃掉起售前 1.5 秒的预热时间,直接拖慢第一枪。 // 时机点用户刚点完「开始抢票」,离抢票还有富余;而且找中转跟选谁坐车无关, // 所以放在乘车人校验之前。 if (config.autoTransfer && !config.transferCode) { try { const res = await NetworkModule.queryTransfer( config.trainDate, config.fromStation, config.toStation, ''); const plan = (res && res.data) ? TicketLogicModule.pickTransferPlan(res.data, config.seatTypes, config.waitMinutes) : null; if (plan) { config.transferCode = plan.middleCode; config.transferStation = plan.middleName; cfg.transferCode = plan.middleCode; cfg.transferStation = plan.middleName; log(`自动选中转方案:在「${plan.middleName}」换乘 ${plan.waitMinutes} 分钟,` + `全程 ${plan.totalTime},车次 ${plan.trainCodes.join(' + ')}`, 'success'); } else { log('自动找中转:没有两段都有票的方案,先按直达抢', 'warn'); // 必须把开关关掉!doCheck 是看 autoTransfer 决定走不走中转支线的, // 不关的话会带着空的中转站码一路跑下去:两段查询都用空站码, // 永远查不到票,于是既不抢直达也不报警,静默空转(日志里只会 // 反复刷「中转等待中 ❌ 无票」)。 config.autoTransfer = false; cfg.autoTransfer = false; } } catch (e) { log(`自动找中转失败(不影响直达抢票):${e.message}`, 'warn'); // 同上:查挂了也得关掉,否则一样是空站码空转 config.autoTransfer = false; cfg.autoTransfer = false; } } // 目标车次留空 = 不限车次(抢到哪班算哪班) if (config.passengers.length === 0) return log('请选择至少一位乘车人', 'warn'); // 一个席别都没勾的话,查票永远命中不了 —— 但表面上脚本"在正常运行", // 属于静默失效,必须当场拦下来 if (config.seatTypes.length === 0) return log('请至少勾选一个席别', 'warn'); // 联程那条路的 choose_seats 是另一套格式:官方 lcpassengerInfo_js.js 里拼的是 // 「车次_座位串#车次_座位串#」,和我们给普通下单拼的 "1D1F" 不是一回事。 // 没按它的格式拼之前先说清楚,别让人以为勾了选座却没反应。 if (config.lcTransfer && config.chooseSeats) { config.chooseSeats = ''; log('联程模式下暂不支持选座,本次按自动分配提交', 'warn'); } state.config = config; state.isRunning = true; GM_setValue('grab_running', true); // #8 持久化运行状态 startBtn.textContent = '停止抢票'; startBtn.className = 'th-btn th-btn-danger'; setStatus('● 正在运行', 'grab-status-running'); log('开始抢票任务...', 'info'); // 加 typeof 守卫:这行在 state.isRunning=true 之后、onStartCallback 之前, // 万一 Notification 不存在而抛异常,会被下面的 catch 收成「配置错误」—— // 那时按钮已经显示「停止抢票」、状态也变成"正在运行",但任务其实从没启动。 if (typeof Notification !== 'undefined' && Notification.permission === 'default') { Notification.requestPermission(); } if (onStartCallback) onStartCallback(config); } catch (e) { log(`配置错误: ${e.message}`, 'error'); } } function stop() { state.isRunning = false; GM_setValue('grab_running', false); // #8 清除运行标记 startBtn.textContent = '开始抢票'; startBtn.className = 'th-btn th-btn-primary'; setStatus('● 已停止', 'grab-status-stopped'); log('任务已停止', 'warn'); if (onStopCallback) onStopCallback(); } // 提速到 250~400ms 一轮后,同一条「无票」信息每秒会刷好几遍。 // 内容相同的日志按次数节流(轮询越快、节流越密,自动适配),内容一变立刻输出。 const LOG_REPEAT_EVERY = 8; const MAX_LOG_ENTRIES = 200; // 日志区最多保留条数,防止长时间挂机把 DOM 撑爆 // 同一类消息的最短输出间隔。光靠「每 8 次输出一条」挡不住高频场景: // 起售窗口里一秒能刷好几条,8 次也就一两秒的事。 const LOG_MIN_GAP_MS = 3000; // 按消息指纹分别记:key 是去掉数字后的消息,value 是 { count, lastAt } const logRepeatMap = new Map(); // 页面正中弹一个醒目横幅。 // 教训:日志区只有小字,群友真实反馈是「登录失效的告警他压根没注意到」,白等一场; // 另一个群友「只买到一段」也没看到。凡是需要用户立刻动手的事,必须打在页面上, // 而不是只写进日志。点「知道了」才关,不自动消失。 function alertBanner(title, detail, type = 'error') { try { let box = document.getElementById('th-alert-banner'); if (!box) { box = document.createElement('div'); box.id = 'th-alert-banner'; box.style.cssText = [ 'position:fixed', 'top:16px', 'left:50%', 'transform:translateX(-50%)', 'z-index:2147483647', 'max-width:min(560px, 92vw)', 'padding:14px 18px', 'border-radius:12px', 'box-shadow:0 10px 40px rgba(0,0,0,.35)', 'font-size:14px', 'line-height:1.6', 'white-space:pre-wrap', 'word-break:break-word', 'display:none' ].join(';'); (document.body || document.documentElement).appendChild(box); } const skins = { error: { bg: '#7f1d1d', fg: '#fecaca', hi: '#fca5a5' }, warn: { bg: '#78350f', fg: '#fef3c7', hi: '#fcd34d' }, success: { bg: '#064e3b', fg: '#d1fae5', hi: '#6ee7b7' } }; const s = skins[type] || skins.error; box.style.background = s.bg; box.style.color = s.fg; box.style.border = `2px solid ${s.hi}`; while (box.firstChild) box.removeChild(box.firstChild); const head = document.createElement('div'); head.textContent = title; head.style.cssText = `font-weight:700;font-size:16px;margin-bottom:6px;color:${s.hi}`; box.appendChild(head); if (detail) { const body = document.createElement('div'); body.textContent = detail; box.appendChild(body); } const close = document.createElement('div'); close.textContent = '知道了(点这里关掉)'; close.style.cssText = 'margin-top:10px;text-align:center;cursor:pointer;' + `border:1px solid ${s.hi};border-radius:6px;padding:5px 0;font-weight:600`; close.addEventListener('click', () => { box.style.display = 'none'; }); box.appendChild(close); box.style.display = 'block'; log(`【页面提示】${title}`, type === 'error' ? 'error' : 'warn'); } catch (e) { // 提示本身失败绝不能影响抢票 } } function log(msg, type = 'info') { if (!logContainer) return; // 按「消息指纹」分别节流,而不是只记"最后一条"。 // 实测教训:起售窗口里「连不上 12306」和「当前不可预订」是交替刷的, // 只记最后一条的话两条会互相把对方的计数冲掉,节流等于没有—— // 日志被一秒一条地冲爆。指纹里的数字会被归一化, // 这样「G1669 当前不可预订」和「G2944 当前不可预订」算同一类。 const fp = String(msg).replace(/\d+/g, '#'); const nowTs = Date.now(); const rec = logRepeatMap.get(fp) || { count: 0, lastAt: 0 }; rec.count++; const tooSoon = rec.lastAt && (nowTs - rec.lastAt) < LOG_MIN_GAP_MS; const notTimeYet = rec.count % LOG_REPEAT_EVERY !== 0; if (tooSoon && notTimeYet) { logRepeatMap.set(fp, rec); return; } rec.lastAt = nowTs; logRepeatMap.set(fp, rec); if (logRepeatMap.size > 60) logRepeatMap.clear(); // 别无限长 let shown = msg; if (rec.count > 1) { // 把折叠掉的次数标出来,否则看日志的人会把「8 次才输出一条」 // 误读成「这就是真实频率」——我自己第一次看群友日志就差点误判。 shown = `${msg}(同类已重复 ${rec.count} 次)`; } const entry = document.createElement('div'); entry.className = `th-log-entry th-log-${type}`; const time = new Date().toLocaleTimeString(); entry.textContent = `[${time}] ${shown}`; logContainer.appendChild(entry); // 同时留一份在内存里落盘,页面跳转/刷新后还能捡回来 uiLogBuffer.push(`[${time}] ${shown}`); if (uiLogBuffer.length > LOG_PERSIST_MAX) uiLogBuffer.shift(); scheduleLogPersist(); while (logContainer.children.length > MAX_LOG_ENTRIES) { logContainer.removeChild(logContainer.children[0]); } logContainer.scrollTop = logContainer.scrollHeight; } // 把上次页面留下的日志捡回来(抢到票跳转支付页之后,回来还能看到成功记录) function restorePersistedLogs() { if (!logContainer) return; const saved = loadPersistedLogs(); if (!saved) return; const lines = [].concat(saved.ui || []); if (!lines.length) return; logContainer.innerHTML = ''; uiLogBuffer.length = 0; for (const line of lines) { const entry = document.createElement('div'); entry.className = 'th-log-entry th-log-info'; entry.textContent = line; logContainer.appendChild(entry); uiLogBuffer.push(line); } logContainer.scrollTop = logContainer.scrollHeight; const t = new Date(saved.at).toLocaleTimeString(); log(`(以上是 ${t} 上次运行的日志,跳转/刷新前留下的)`, 'warn'); } function makeDraggable(element) { const header = element.querySelector('.th-header'); let isDragging = false, startX, startY, initialLeft, initialTop; header.addEventListener('mousedown', (e) => { e.preventDefault(); document.body.style.userSelect = 'none'; isDragging = true; startX = e.clientX; startY = e.clientY; const rect = element.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; element.style.right = 'auto'; element.style.left = initialLeft + 'px'; element.style.top = initialTop + 'px'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; element.style.left = (initialLeft + e.clientX - startX) + 'px'; element.style.top = (initialTop + e.clientY - startY) + 'px'; }); document.addEventListener('mouseup', () => isDragging = false); document.body.style.userSelect = ''; } return { init: (startCb, stopCb) => { createPanel(); onStartCallback = startCb; onStopCallback = stopCb; log('抢票助手 UI 已初始化', 'success'); }, log: log, getIsRunning: () => state.isRunning, showNotification: showNotification, playBeep: playBeep, setStatus: setStatus, alertBanner: alertBanner, start: start, stop: stop }; })(); // ========================================== // 5. Main Logic (核心业务逻辑) // ========================================== let checkTimer = null; let isChecking = false; // 下单连续失败计数。放在模块级而不是 executeTask 里,因为重试会重新调用 executeTask, // 局部变量会被重置,那样就永远数不到头了。 let orderFailStreak = 0; const ORDER_FAIL_LIMIT = 10; // 起售时间是不是已经核对过。必须放在模块级:核对是在 executeTask 里做的, // 而下单失败会重新调用 executeTask——放局部的话每次重试都重新核对一遍, // 实测把「定时对不上」那句刷了几十次。 let saleTimeChecked = false; // 「票被抢走」时下单重试的间隔。每次失败就拉长一档,成功即归零。 // 实测:固定 500ms 时每秒能跑完一整轮下单(5 个请求),几十轮下来必然被限流。 let orderRetryDelay = 500; // 网络层的退避状态同样必须放模块级。理由和上面的 orderFailStreak 一样, // 而且这里更要命:被限流最狠的时候正是「有票→下单失败→重试」最密的时候, // 每重试一次 executeTask 就重建一次局部变量,退避等于从零开始—— // 越撞越狠,而且日志上看不出来(每次都在"第 1 轮")。 const netFailWindow = []; // 最近若干轮的网络失败(1/0),用来算失败率 let netFailStreak = 0; // 当前连续网络失败轮数,成功一轮即归零 // 本任务里查票有没有成功过。用来区分「连不上」的两种完全不同的成因: // 哪怕只成功过一次,就说明链路本身是通的,后面那些 Failed to fetch 是 12306 // 在整点高负载下拒你;从头到尾一次都没成,才值得让人去查网络和拦截插件。 // 原来这两种情况共用一句「网络断了,或者浏览器/插件把请求拦下来了」, // 群友看完第一反应都是去换网络、关插件——方向被带偏了,白折腾。 let everQueryOk = false; // 候补是否已经提交过。本来它是 executeTask 的局部变量,但「下单失败会重新调用 // executeTask」——于是每重试一次就重置一次,候补会被重复提交,把 12306 的 // 候补额度白白占掉(原注释写着"只提交一次,避免重复占候补额度",实际没做到)。 // 跟 orderFailStreak 同理,这种"跨重试必须记住"的状态只能放模块级。 let waitlistSubmitted = false; // 刚停下就再点「开始」的冷却。每次启动都会先发一轮请求(查乘车人、校验登录、 // 起头几轮轮询),被限流时这就是往伤口上撒盐。群友日志里出现过 // 「停 → 1 秒后启 → 再停 → 再启」的连点,四轮下来全是白撞的请求。 // 15 秒够人从"急了"缓回"看一眼日志",又不至于耽误真想重开的场景。 // 注意:刷新页面不算重启(lastStopAt 是内存里的),所以不会误伤自动恢复那条路。 const RESTART_COOLDOWN_MS = 15000; let lastStopAt = 0; let countdownInterval = null; let countdownTimer = null; let keepAliveTimer = null; let saleTimeStr = null; // 面板设定的起售时刻 "HH:MM:SS",null 表示未设定时 // 起售时刻的绝对时间戳(本地时钟),**只在任务启动时算一次**。 // 关键:不能每次调用 msToSale() 都去重算。跨日顺延的判断条件是"算出的时刻已经过了", // 而起售时刻一过这个条件就翻面——重算会让 msToSale() 从 -100ms 瞬间跳到 +86399900ms, // pollDelayMs 把它当成"距起售还有一天",档位从 hot(250~400ms) 掉到 idle(1000~1500ms), // isHotWindow 也变成 false(错峰补发的请求再也不发)。 // 结果就是起售后最该抢的回流票窗口反而跑得最慢。定死一次,msToSale() 才是连续变化的 //(正数 → 0 → 负数),档位也才会按设计一路 hot → settle → cool。 let saleAtMs = null; const PREHEAT_MS = 1500; // 提前进入轮询,让请求的网络往返正好覆盖住放票瞬间 // 起售前多久先核对一次「定时和 12306 的起售时间对不对得上」。 // 原来是在第一轮查票之后才核对,而第一轮查票已经是起售前 1.5 秒了—— // 那时候再喊「你设的 13:59 和 12306 显示的 14:00 对不上」,人根本来不及改, // 只能眼看着白等一场(群友日志里就是这么发生的)。提前 5 分钟说才有意义。 const SALE_PRECHECK_MS = 5 * 60 * 1000; // 距起售还有多少毫秒(负值表示已过);saleAtMs 在任务启动时就定死了, // 所以起售时刻前后它是连续变化的,档位才切得对(见 saleAtMs 的注释)。 function msToSale() { return saleAtMs === null ? null : saleAtMs - Date.now(); } // 各档轮询间隔(毫秒)。平时克制避免触发风控,临近起售升温,起售窗口内最快。 // 起售窗口拉到 T+30s:热门车次有大量「回流票」(别人下单不付款、超时释放), // 通常出现在起售后半分钟到几分钟内,原来 T+15s 就降速太早了。 const POLL_PROFILE = { idle: [1000, 1500], // 距起售 > 60s,或未设定时抢票 warmup: [500, 800], // 起售前 60s ~ 5s hot: [250, 400], // 起售窗口 T-5s ~ T+30s settle: [500, 800], // T+30s ~ T+3min(回流票高发期,别太快降速) cool: [800, 1200] // T+3min 之后回落 }; const HOT_BEFORE_MS = 5000; // 起售窗口:起售前 5 秒开始 const HOT_AFTER_MS = 30000; // 起售窗口:起售后 30 秒结束 const SETTLE_AFTER_MS = 180000; // 缓冲带:起售后 3 分钟结束 const HOT_STAGGER_MS = 150; // 起售窗口内补发一路错峰请求的延迟 function randBetween(range) { return Math.floor(Math.random() * (range[1] - range[0] + 1)) + range[0]; } // msToSale 为距起售的毫秒数(负值表示已过起售),null 表示没有定时 function pollDelayMs(msToSale, profile = POLL_PROFILE) { if (msToSale === null || msToSale > 60000) return randBetween(profile.idle); if (msToSale > HOT_BEFORE_MS) return randBetween(profile.warmup); if (msToSale > -HOT_AFTER_MS) return randBetween(profile.hot); if (msToSale > -SETTLE_AFTER_MS) return randBetween(profile.settle); return randBetween(profile.cool); } function isHotWindow(msToSale) { return msToSale !== null && msToSale <= HOT_BEFORE_MS && msToSale > -HOT_AFTER_MS; } // 把原始报错翻译成人话。「Failed to fetch」「Request timeout」这种, // 用户看得见但看不懂,只会以为是脚本坏了。 function humanizeError(msg) { const m = String(msg || ''); if (/Failed to fetch/i.test(m)) { // 分两种:成功过一次 = 链路是通的,是 12306 在拒;一次都没成 = 才怀疑网络/插件 return everQueryOk ? '12306 没接这次请求(整点放票时最常见,不是你的网络问题)' : '一直连不上 12306(检查网络,或者浏览器插件是不是把请求拦下来了)'; } if (/Request timeout/i.test(m)) { return `12306 响应太慢,${QUERY_TIMEOUT_MS / 1000} 秒没回(网络卡或有波动)`; } if (/HTTP error! status: 5\d\d/.test(m)) { return `12306 服务器出错(${m})——整点放票时服务器压力大,稍后自动会好`; } if (/HTTP error! status: 4\d\d/.test(m)) { return `12306 拒绝了这次请求(${m})`; } if (/Response is not JSON/i.test(m)) { return '12306 返回的内容不是正常格式——多半是被重定向到登录页了,检查一下登录状态'; } return m; // 已经是人话的就原样返回 } // 从联程确认页的 HTML 里把 ticketInfoForPassengerForm 抽出来。 // 它是页面里的一个 JS 数组字面量(两段各一个元素),结构来自 12306 自己的 // lcpassengerInfo_js.js: // [{ leftTicketStr, train_location, // queryLeftTicketRequestDTO: { train_date, train_no, station_train_code, // from_station, to_station, ypInfoDetail }, // queryLeftNewDetailDTO: { station_train_code, from_station_telecode, to_station_telecode } }, // { …第二段… }] // 用括号配对扫,不用正则(数组里嵌套太多,正则靠不住)。 function parseLcConfirmForm(html) { const m = String(html || '').match(/ticketInfoForPassengerForm\s*=\s*/); if (!m) return null; const start = m.index + m[0].length; if (html[start] !== '[') return null; let depth = 0, end = -1, inStr = false, quote = ''; for (let i = start; i < html.length; i++) { const ch = html[i]; if (inStr) { if (ch === '\\') { i++; continue; } if (ch === quote) inStr = false; continue; } if (ch === '"' || ch === "'") { inStr = true; quote = ch; continue; } if (ch === '[' || ch === '{') depth++; else if (ch === ']' || ch === '}') { depth--; if (depth === 0) { end = i; break; } } } if (end < 0) return null; const raw = html.slice(start, end + 1); return looseParseLcForm(raw); } // 把确认页里那段 JS 对象字面量解析成数组。 // 实测(群友 v2.1.3 的日志,联程每次必失败):那段内容是**单引号**的 JS 字面量, // 原样丢给 JSON.parse 必然抛错,于是整个联程下单第一步就断在这儿: // [LC] 确认页结构解析失败(前 200 字):[{'cardTypes':[{'end_station_name':null,'end_time':null,'id':'1',... // 后面的 token / key_check_isChange_lc / train_location 其实都在页面上(探针已确认), // 只是永远走不到那一步。这里做保守规整——不 eval、不给页面内容任何执行机会: // 1) 单引号字符串换成双引号,顺手把 JSON 不认的 \' 还原成 ' // 2) 去掉对象/数组尾部的逗号 // 规整后仍解析不了才认输,并把片段打出来方便继续定位。 function looseParseLcForm(raw) { try { return JSON.parse(raw); } catch (e) { /* 走下面的规整 */ } const fixed = raw .replace(/'((?:[^'\\]|\\.)*)'/g, (m, inner) => '"' + inner.replace(/\\'/g, "'").replace(/"/g, '\\"') + '"') .replace(/,(\s*[\]}])/g, '$1'); try { return JSON.parse(fixed); } catch (e2) { tlog('error', `[LC] 规整后仍解析失败(前 200 字):${fixed.slice(0, 200)}`); return null; } } // 联程确认页里的 key_check_isChange 带 _lc 后缀(普通下单不带)。 // 冒号和等号都要认:它可能写在对象字面量里(冒号),也可能是独立的全局变量(等号)—— // 这个坑我在探针那边踩过一次,不能再来第二次。 const REGEX_KEY_CHECK_LC = /["']?key_check_isChange_lc["']?\s*[:=]\s*["']([^"']+)["']/; // 联程专用:oldPassengerStr。 // 这个字段名字骗人——联程流程里它**不是乘客串**。 // 真实实现来自 12306 自己的 lcpassengerInfo_js.js(getSeatTypeSelect): // aE += 车次号 + "," + 席别码 + "," + 席别名 + "," + 乘车日期 + "," + 出发站码 + "," + 到达站码; // aE += "#" // 也就是形如: // G3292,O,二等座,2026-09-19,SNU,UUH#G821,O,二等座,2026-09-19,UUH,YWH# // 而普通下单的 oldPassengerStr 是「姓名,证件类型,证件号,票种_」——两者完全不同。 // 传错的话 12306 会回一句笼统的「系统忙,请稍后重试」,从报错上看不出任何线索。 function buildLcOldPassengerStr(lcForm, seatCodes, seatNames) { if (!Array.isArray(lcForm) || !lcForm.length) return ''; const codeAt = i => (Array.isArray(seatCodes) ? seatCodes[i] : seatCodes); const nameAt = i => (Array.isArray(seatNames) ? seatNames[i] : seatNames); return lcForm.map((seg, i) => { const d = (seg && seg.queryLeftNewDetailDTO) || {}; const q = (seg && seg.queryLeftTicketRequestDTO) || {}; const trainCode = d.station_train_code || q.station_train_code || ''; // getSeatTypeSelect 用的是 *telecode 字段;万一这版页面没有就退回不带后缀的 const fromCode = d.from_station_telecode || d.from_station || q.from_station || ''; const toCode = d.to_station_telecode || d.to_station || q.to_station || ''; const date = q.train_date || d.train_date || ''; return `${trainCode},${codeAt(i) || ''},${nameAt(i) || ''},${date},${fromCode},${toCode}#`; }).join(''); } // 把联程的两段拼成 getQueueCount 要的 dataStr。 // 每段 10 个字段,顺序来自源码: // train_date|train_no|station_train_code|席别码|出发站|到达站|ypInfoDetail|purpose_codes|席别名|train_location // 席别可以是数组(每段各一个——联程两段席别不同是常态),也可以传单个值。 function buildLcQueueDataStr(lcForm, seatCodes, seatNames) { if (!Array.isArray(lcForm) || !lcForm.length) return ''; const codeAt = i => (Array.isArray(seatCodes) ? seatCodes[i] : seatCodes); const nameAt = i => (Array.isArray(seatNames) ? seatNames[i] : seatNames); return lcForm.map((seg, i) => { const q = seg && seg.queryLeftTicketRequestDTO; if (!q) return ''; const name = nameAt(i) || '二等座'; return [ q.train_date || '', q.train_no || '', q.station_train_code || '', codeAt(i) || SEAT_TYPE_CODE[name] || '', q.from_station || '', q.to_station || '', q.ypInfoDetail || '', '00', name, (seg.train_location || '') ].join('|'); }).filter(Boolean).join('#') + '#'; } // 会话失效时 12306 的查票接口会返回 status:false 且 messages 提示登录 function looksLoggedOut(res) { if (!res || res.status !== false) return false; return /登录|login|未登录/i.test((res.messages || []).join(' ')); } const KEEPALIVE_MIN_MS = 45000; const KEEPALIVE_MAX_MS = 75000; let keepAliveFailures = 0; // 保活(#5 使用 checkUser)。冽红反馈:挂两三个小时后会话会被踢掉, // 需要在开抢前就发现,而不是等到下单时才失败。 async function sendKeepAlive() { try { const ok = await NetworkModule.checkLoginStatus(); if (ok) { keepAliveFailures = 0; tlog('log', '[KeepAlive] checkUser ok'); return true; } // 单次失败可能只是网络抖动,连续两次才告警,避免误报 keepAliveFailures++; tlog('warn', `[KeepAlive] 第 ${keepAliveFailures} 次检测到未登录`); if (keepAliveFailures >= 2) { UIModule.log('⚠️ 保活检测到登录已失效,请尽快重新登录,否则开抢时会失败', 'error'); UIModule.setStatus('● 登录已失效', 'grab-status-stopped'); UIModule.showNotification('登录已失效', '请重新登录 12306,否则抢票会失败'); // 去重:保活会一直失败,不去重的话每 45~75 秒就叠一层前缀, // 而恢复标题的 clearThrottleWarning 只剥一层,标题会越滚越长、再也回不去。 if (!document.title.startsWith(TITLE_WARN_PREFIX)) { document.title = TITLE_WARN_PREFIX + document.title; } // 登录掉了必须立刻把人叫回来,而且一直叫到人回到这个页面为止。 // 报警走音频线程,不受后台限流影响,所以切走了也听得到。 startAlarm(); } return false; } catch (e) { tlog('error', '[KeepAlive] Error:', e); return true; // 网络异常不算掉线 } } async function keepAliveLoop() { keepAliveTimer = null; if (!UIModule.getIsRunning()) return; await sendKeepAlive(); if (!UIModule.getIsRunning()) return; keepAliveTimer = setTimeout(keepAliveLoop, randBetween([KEEPALIVE_MIN_MS, KEEPALIVE_MAX_MS])); } // 整个任务期间都要保活。原代码只在「定时等待」阶段起保活循环, // 真正进入查票阶段后反而不保活了。 function startKeepAlive() { if (keepAliveTimer) return; keepAliveFailures = 0; keepAliveLoop(); } // ========================================== // 后台限流防护 // ========================================== // 实测(Chrome,本机验证):标签页切到后台后,高频定时器被掐到 1~2 分钟才跑一次 // (10 秒的 setInterval 实测变成 60 秒,切回前台立刻恢复)。 // 抢票轮询是 250~400ms,一旦被掐就只剩预热那第一枪,之后等于停摆。 // 长 setTimeout(定时触发)不受影响,所以这里只需要处理轮询。 const THROTTLE_DRIFT_MS = 3000; // 1 秒心跳的实际间隔超过这个值就认为异常 const THROTTLE_CERTAIN_MS = 10000; // 超过这个值不可能是一次偶发卡顿,一次即告警,不必等第二轮 const THROTTLE_STRIKES = 2; // 小幅异常要连续两次才算,避免偶发卡顿误报 const TITLE_WARN_PREFIX = '⚠️ 请切回抢票页面 · '; let audioCtx = null; // 抢票期间常驻的音频上下文(在点击手势里解锁) let alarmNodes = null; // 正在响的报警 let heartbeatTimer = null; let lastBeatAt = 0; let throttleStrikes = 0; let throttleWarned = false; // 音频上下文必须在用户点击的那次手势里创建,否则浏览器会把它挂起, // 之后就没法从后台把报警声响起来了。这里只负责解锁,本身不出声。 function ensureAudioContext() { if (audioCtx) return audioCtx; try { const Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; audioCtx = new Ctx(); if (audioCtx.state === 'suspended') audioCtx.resume(); return audioCtx; } catch (e) { tlog('log', '[Audio] 无法创建音频上下文:', e.message); return null; } } // 连续报警:用音频线程上的 1Hz 方波去调制音量,得到「嘀—嘀—嘀」。 // 全程不依赖任何定时器,所以后台限流掐不掉它——这正是它存在的意义: // 人切走之后必须还能被叫回来。 function startAlarm() { // 面板上的「掉线时连续报警」可以关掉,默认是开的 if (cfg.alarmEnabled === false) return; if (alarmNodes) return; const ctx = ensureAudioContext(); if (!ctx) return; try { const carrier = ctx.createOscillator(); // 880Hz 载波 const gate = ctx.createOscillator(); // 1Hz 方波做通断 const gateDepth = ctx.createGain(); const out = ctx.createGain(); carrier.type = 'sine'; carrier.frequency.value = 880; gate.type = 'square'; gate.frequency.value = 1; gateDepth.gain.value = 0.2; out.gain.value = 0.2; gate.connect(gateDepth); gateDepth.connect(out.gain); // 用音频线程调制音量,天然就是断续的报警声 carrier.connect(out); out.connect(ctx.destination); carrier.start(); gate.start(); alarmNodes = { carrier, gate, gateDepth, out }; tlog('log', '[Alarm] 连续报警已启动'); } catch (e) { tlog('log', '[Alarm] 报警启动失败:', e.message); } // 尽力试着把标签页拉到前台。多数浏览器不允许脚本自己切标签, // 所以这只是「顺便试试」,真正靠得住的是上面那个报警声。 try { window.focus(); } catch (e) { /* 忽略 */ } } function stopAlarm() { if (!alarmNodes) return; const nodes = alarmNodes; alarmNodes = null; try { nodes.carrier.stop(); nodes.gate.stop(); nodes.carrier.disconnect(); nodes.gate.disconnect(); nodes.gateDepth.disconnect(); nodes.out.disconnect(); } catch (e) { /* 已经停了就算了 */ } tlog('log', '[Alarm] 报警已停止'); } // 起售前多久开始盯人。群友建议(2026-09-17):他习惯一开就挂在后台, // 而原来的告警一进后台就喊,他被吵到干脆把提醒关掉——真正开票时反而没提醒。 // 所以设了定时的情况下,只在起售前这段时间里才提醒,而且窗口里是**持续报警**, // 直到他切回来为止;窗口之前完全不吭声(轮询慢就慢,反正还在等)。 const SALE_WARN_BEFORE_MS = 5 * 60 * 1000; // 现在到底要不要因为「你在后台」去打扰用户? // 没设定时 → 要。手动抢,随时可能开火,切后台就得说。 // 设了定时 → 只在起售前 5 分钟到起售后 1 分钟内说。 function shouldWarnBackground() { const rem = msToSale(); if (rem === null) return true; return rem <= SALE_WARN_BEFORE_MS && rem > -60000; } // 本次任务里是不是已经为「该回来抢票了」提醒过(避免每秒重复弹) let saleWarned = false; function warnThrottled(reason) { if (throttleWarned) return; throttleWarned = true; UIModule.setStatus('● 后台被限流', 'grab-status-stopped'); UIModule.log(`⚠️ ${reason}:浏览器会把抢票轮询掐到每分钟 1 次,请立刻切回本页面!`, 'error'); UIModule.showNotification('请切回抢票页面', '浏览器会限制后台标签页的轮询,请切回 12306 页面'); document.title = TITLE_WARN_PREFIX + document.title; } function clearThrottleWarning() { if (!throttleWarned) return; throttleWarned = false; throttleStrikes = 0; if (document.title.startsWith(TITLE_WARN_PREFIX)) { document.title = document.title.slice(TITLE_WARN_PREFIX.length); } UIModule.log('✅ 定时器已恢复正常速度', 'success'); } // 1 秒心跳测量定时器实际精度——这是判断有没有被限流的唯一可靠依据, // 不管是被切后台还是被遮挡,只要真被掐了就一定会在这里露出来。 function throttleBeat() { const now = Date.now(); const drift = now - lastBeatAt - 1000; lastBeatAt = now; // 哨兵:用户可能「一开就挂后台」,那就没有 visibilitychange 事件可等, // 得自己盯着——一旦进入起售前那 5 分钟,立刻喊人(并持续报警到切回来)。 if (!saleWarned && document.hidden && UIModule.getIsRunning() && shouldWarnBackground()) { saleWarned = true; const rem = msToSale(); const mins = rem !== null ? Math.max(0, Math.round(rem / 60000)) : 0; UIModule.alertBanner('该切回抢票页面了', (rem !== null ? `离起售还有约 ${mins} 分钟。\n\n` : '') + '标签页在后台时,浏览器会把抢票轮询掐到每分钟 1 次——' + '开票那一秒你就只剩一枪的机会。\n\n' + '现在切回来,报警声就会停。'); warnThrottled('标签页在后台,马上要开票了'); startAlarm(); } if (drift > THROTTLE_DRIFT_MS) { throttleStrikes++; // 被限流时漂移通常就是几十秒,这种幅度一次就能定性,等第二轮会白白晚一分钟 if (drift > THROTTLE_CERTAIN_MS || throttleStrikes >= THROTTLE_STRIKES) { warnThrottled(`定时器被拉长到约 ${Math.round(drift / 1000)} 秒一次`); } } else if (!document.hidden) { // 只有「前台 + 定时器正常」才算真的恢复。 // 后台时定时器暂时正常并不代表不会限流(Chrome 要过几分钟才收紧), // 这时把警告撤掉等于刚提醒完又自己收回,用户就白被告知了。 clearThrottleWarning(); } heartbeatTimer = setTimeout(throttleBeat, 1000); } function startThrottleWatch() { if (heartbeatTimer) return; throttleStrikes = 0; saleWarned = false; // 新任务重新盯 lastBeatAt = Date.now(); heartbeatTimer = setTimeout(throttleBeat, 1000); } function stopThrottleWatch() { if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; } clearThrottleWarning(); } // 切到后台就告警:心跳要等下一轮才判定,太慢,而 visibilitychange 立刻就知道。 // 但要延迟确认——切走一秒钟看条消息、截个图是常事,立刻报警会误报还白白响一声。 // 而且只有「现在真该盯着」的时候才提醒,见 shouldWarnBackground。 const HIDDEN_CONFIRM_MS = 6000; let hiddenConfirmTimer = null; document.addEventListener('visibilitychange', () => { if (document.hidden) { if (hiddenConfirmTimer) clearTimeout(hiddenConfirmTimer); hiddenConfirmTimer = setTimeout(() => { hiddenConfirmTimer = null; if (!document.hidden || !UIModule.getIsRunning()) return; if (!shouldWarnBackground()) { // 离起售还早,静默记一笔就好——真正要紧的时候会有哨兵来喊 tlog('log', `[Background] 已切到后台,但离起售还早(>${SALE_WARN_BEFORE_MS / 60000} 分钟),暂不提醒`); return; } saleWarned = true; warnThrottled('标签页已切到后台'); startAlarm(); }, HIDDEN_CONFIRM_MS); } else { // 人回来了,报警立刻停——这就是「切回页面报警声消失」。 // 放在这里还有个好处:切回来一定会触发,不受任何限流影响。 if (hiddenConfirmTimer) { clearTimeout(hiddenConfirmTimer); hiddenConfirmTimer = null; } stopAlarm(); lastBeatAt = Date.now(); throttleStrikes = 0; clearThrottleWarning(); } }); // 启动任务(包含定时等待逻辑) async function startTask(config) { if (isChecking) return; orderFailStreak = 0; // 新任务从零开始计数 orderRetryDelay = 500; saleTimeChecked = false; // 新任务重新核对一次起售时间 // 网络退避也要归零:上一个任务留下的失败统计不该算到这次头上, // 否则一开抢就是"8 秒一轮",白白错过起售。 netFailWindow.length = 0; netFailStreak = 0; everQueryOk = false; // 新任务重新判断链路到底通不通 queryUrlRotated = false; // 新任务重新给一次换接口的机会 waitlistSubmitted = false; // 新任务重新允许提交一次候补 const { startTime } = config; // 定时等待阶段与实际查票阶段都要保活 startKeepAlive(); // 盯着浏览器有没有把定时器掐掉 startThrottleWatch(); // 如果设置了定时,先等待 if (startTime) { saleTimeStr = startTime; // 起售时刻只在这里算一次(含跨日顺延),之后 msToSale() 都用这个定值。 // 详见 saleAtMs 的注释:每次重算会让起售时刻一过就跳到次日, // 把 pollDelayMs 从 hot 档甩到 idle 档。 saleAtMs = saleTimestampLocally(startTime, config.trainDate || ''); const rem0 = msToSale(); if (rem0 !== null && rem0 > 0) { isChecking = true; UIModule.setStatus('● 等待开抢', 'grab-status-waiting'); // 把"到底会等到哪一天几点"写清楚。定时框只有时间没有日期, // 用户填的又可能是次日的起售,不写明白就没法一眼看出等错了。 const at = new Date(saleAtMs); const pad = n => String(n).padStart(2, '0'); const when = `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())} ` + `${pad(at.getHours())}:${pad(at.getMinutes())}:${pad(at.getSeconds())}`; UIModule.log(`定时抢票: ${startTime} → 实际等到 ${when}` + `${serverClockOffset !== null ? `(已按服务器时间校准 ${serverClockOffset > 0 ? '+' : ''}${serverClockOffset}ms)` : '(尚未完成服务器时间校准)'},等待中...`, 'info'); let fired = false; const fire = () => { if (fired) return; fired = true; clearInterval(countdownInterval); countdownInterval = null; if (countdownTimer) { clearTimeout(countdownTimer); countdownTimer = null; } if (!UIModule.getIsRunning()) { isChecking = false; return; } UIModule.setStatus('● 时间到', 'grab-status-running'); UIModule.log('⏰ 进入起售窗口(已提前预热),开始抢票!', 'success'); isChecking = false; executeTask(config); }; const showCountdown = (diff) => { const hours = Math.floor(diff / 3600000); const minutes = Math.floor((diff % 3600000) / 60000); const seconds = Math.floor((diff % 60000) / 1000); UIModule.log(`倒计时: ${hours}时${minutes}分${seconds}秒`, 'info'); }; // 起售前 5 分钟核对一次定时。只试一次:countdownInterval 是每秒跑的, // 失败就重试的话会变成每秒一个请求、连发五分钟,反而把自己送进限流。 // 这一枪打空了也不怕——executeTask 里第一轮查票后还有一次兜底核对。 let salePrecheckDone = false; const precheckSaleTime = async () => { if (saleTimeChecked || salePrecheckDone) return; salePrecheckDone = true; try { const res = await NetworkModule.queryTickets(config.trainDate, config.fromStation, config.toStation, purposeCodesFor(config.passengers)); if (!res || !res.status || !res.data || !res.data.result) return; const times = TicketLogicModule.readSaleTimes( res.data.result, config.trainCodes, config.departFrom, config.departTo); if (!times.length) return; const mine = String(config.startTime).slice(0, 5); // "08:00:00" → "08:00" if (times.some(t => t.time === mine)) { saleTimeChecked = true; UIModule.log(`✅ 已核对:你设的定时 ${mine} 和 12306 的起售时间一致`, 'success'); return; } const list = times.map(t => t.time).filter((v, i, a) => a.indexOf(v) === i).join(' / '); saleTimeChecked = true; UIModule.log(`⚠️ 你设的定时 ${mine} 和 12306 显示的起售时间 ${list} 对不上,` + `现在还来得及改`, 'error'); UIModule.alertBanner('定时可能填错了,现在改还来得及', `你填的定时是 ${mine},而 12306 显示的起售时间是 ${list}。\n\n` + `填错的话到点会空等一场。离起售还有几分钟,现在改还来得及:\n` + `停掉任务、把定时改成 ${list.split(' / ')[0]} 再重新开始。\n\n` + `(拿不准就点面板上的「查询起售时间」,脚本会帮你查好并填进去。)`, 'warn'); } catch (e) { // 查不到就算了,交给 executeTask 里的兜底核对 } }; // 1 秒心跳:既显示倒计时,也作为精确计时器失准时的兜底触发 countdownInterval = setInterval(() => { const rem = msToSale(); if (rem === null) return; if (rem <= PREHEAT_MS) { fire(); return; } // 进到起售前 5 分钟才核对;上面的守卫保证最多只发一次请求 if (rem <= SALE_PRECHECK_MS) precheckSaleTime(); if (rem <= 60000) showCountdown(rem); else if (Math.floor(rem / 1000) % 30 === 0) showCountdown(rem); }, 1000); // 提前 PREHEAT_MS 进入轮询,让请求的网络往返正好覆盖住放票瞬间 const beginAt = rem0 - PREHEAT_MS; if (beginAt <= 0) fire(); else countdownTimer = setTimeout(fire, beginAt); return; } UIModule.log('设定时间已过,立即开始', 'warn'); } executeTask(config); } // 核心查票下单循环(#2 增加登录检测,#4 下单失败立即重查) async function executeTask(config) { if (isChecking) return; isChecking = true; const { trainDate, fromStation, toStation, trainCodes, seatTypes, passengers, departFrom, departTo } = config; const purposeCodes = purposeCodesFor(passengers); const targets = buildQueryTargets(config); const altTargets = targets.slice(1); // 主目标每轮都查,其余轮转,请求数不随目标数膨胀 let altCursor = 0; let pollCount = 0; // 目标车次留空 = 不限车次。用 [''] 占位是为了让 findTargetTrain 至少迭代一次 //(它把空串当"不限"处理,见里面的 `if (code && ...)`)。 // 注意:这个占位符**只能给 findTargetTrain / explainMiss 这类"逐个车次试一遍"的用**。 // readSaleTimes / buildWaitlistSecretList 是"按车次号过滤"的语义:它们收到非空数组 // 就会拿里面的值去比对车次号,塞 [''] 进去等于要求车次号必须等于空串, // 于是所有车次全被过滤掉——不限车次时会静默失效(起售时间核对不出来、 // 候补永远提交不了)。这两处必须传真正的 trainCodes,空数组才是"不过滤"。 const codeList = trainCodes.length ? trainCodes : ['']; const trainLabel = trainCodes.length ? trainCodes.join(',') : '不限车次'; const rangeLabel = (departFrom || departTo) ? ` 发车 ${departFrom || '00:00'}~${departTo || '24:00'}` : ''; UIModule.log(`目标: ${trainLabel} ${config.fromName || fromStation}→${config.toName || toStation}${rangeLabel} [${targets.map(t => `${t.tag} ${t.date}`).join(' | ')}]`, 'info'); // 「目标车次」是两段共用的过滤器,而中转的两段通常是不同车次——填了就会把两段都卡死 if (config.transferCode && trainCodes.length) { UIModule.log(`提示:中转模式下两段通常是不同车次,而「目标车次」对两段都生效。` + `现在填的是 ${trainCodes.join(',')}——如果两段车次不同,建议清空它(留空 = 不限车次)`, 'warn'); } if (config.transferCode) { UIModule.log(`中转模式:第1段 ${config.fromName || ''}→${config.transferStation},` + `第2段 ${config.transferStation}→${config.toName || ''}。两段都有票才会下单`, 'info'); } // 首次检查登录 let logged = true; try { logged = await NetworkModule.checkLoginStatus(); } catch (e) { UIModule.log(`登录状态检查失败(网络异常,继续尝试): ${e.message}`, 'warn'); } if (!logged) { UIModule.log('未登录,请先登录!', 'error'); isChecking = false; UIModule.stop(); return; } // 第一轮拿到查票结果后核对一次起售时间:定时填错就是白等一场,必须提醒。 // 只看一次(saleTimeChecked 在模块级,重试不会重置)。 const checkSaleTimeOnce = (rawList) => { if (saleTimeChecked) return; saleTimeChecked = true; if (!config.startTime) return; // 没设定时就不用核对 const times = TicketLogicModule.readSaleTimes(rawList, trainCodes, departFrom, departTo); if (!times.length) return; const mine = String(config.startTime).slice(0, 5); // "08:00:00" → "08:00" if (times.some(t => t.time === mine)) return; const list = times.map(t => t.time).filter((v, i, a) => a.indexOf(v) === i).join(' / '); UIModule.log(`⚠️ 你设的定时 ${mine} 和 12306 显示的起售时间 ${list} 对不上,建议核对一下`, 'error'); }; // 查一个目标;命中返回 train,未命中返回可读原因 const checkOne = async (t) => { try { const queryRes = await NetworkModule.queryTickets(t.date, t.fromCode, t.toCode, purposeCodes); // 会话失效时 12306 会返回提示登录的 status:false,确认后再停,避免误杀 // 能拿到响应就说明链路是通的(哪怕内容是"请登录")。只有在这个前提下, // 后面那些 Failed to fetch 才有资格被解释成"12306 在整点拒连", // 而不是本机网络或拦截插件的问题。 everQueryOk = true; if (looksLoggedOut(queryRes)) { const stillLogged = await NetworkModule.checkLoginStatus(); if (!stillLogged) return { loggedOut: true, target: t }; } if (!queryRes.status || !queryRes.data?.result) { // 标成网络失败:有响应但拿不到数据,也算"这一轮不顺"。 // 不标的话 noteRound(false) 会把退避和熔断的计数清零,而 12306 在整点高负载时 // 回一句 status:false 是很常见的——那正是最该放慢的时候,却反而被复位成原速。 return { error: '查票接口异常', target: t, netError: true }; } checkSaleTimeOnce(queryRes.data.result); // 站点校验用「当前这个目标」的简码。买长乘短的到达站、中转的两段、前后一天, // 各自区间都不一样,传全局配置会把它们全判成"区间不符"。 const stationExpect = { from: t.fromCode, to: t.toCode }; for (const code of codeList) { const train = TicketLogicModule.findTargetTrain( queryRes.data.result, code, seatTypes, departFrom, departTo, stationExpect); if (train) return { train: train, target: t }; } return { miss: TicketLogicModule.summarizeMiss( queryRes.data.result, codeList, seatTypes, departFrom, departTo), target: t, raw: queryRes.data.result // 留给自动候补用 }; } catch (e) { const msg = String((e && e.message) || e || ''); // 只有真正的网络/传输层失败才算"这一轮被拒"。 // 脚本自身的异常(某个 API 不存在、取值方式写错)如果也算进去, // 后果有两个:日志会显示成"连不上 12306"把排查方向带偏, // 而且会白白触发退避——明明是代码问题,却让脚本自己放慢速度。 // 默认仍按网络失败算(宁可多退避也别漏判),只在能明确认出是代码错误时才排除。 const isTransport = /Failed to fetch|Request timeout|HTTP error|NetworkError|Load failed/i.test(msg); const isScriptBug = !isTransport && /is not defined|is not a function|is not a constructor|Cannot read propert|Cannot set propert/i.test(msg); return { error: humanizeError(msg), target: t, netError: !isScriptBug }; } }; // 自调度轮询:每轮结束才排下一轮,天然避免请求重叠;间隔按距起售时间自适应 // 连续网络失败时把轮询间隔拉长。 // 实测依据(三个群友的日志):起售前后会出现大量 Failed to fetch, // 而且规律很明显——成功一轮、失败一轮交替,甚至连续几十次失败。 // 同时保活请求(checkUser)是成功的,说明不是断网也不是全局限流, // 而是查票请求被 12306 拒了连接。这种时候继续按原速硬撞只会被拒得更狠, // 退避一下反而更容易打进去;一旦恢复成功立刻回到原速,不拖慢正常抢票。 // 用「最近若干轮的失败率」来决定退避,而不是「连续失败次数」。 // 实测教训:起售时是"成功一轮、失败一轮"交替出现,连续计数每轮都被成功的 // 那一轮清零,退避等于没生效——日志里反复出现「连续 3 次连不上」, // 一直卡在最低档,白撞几十次。 const NET_FAIL_WINDOW = 8; function netFailRate() { if (!netFailWindow.length) return 0; return netFailWindow.reduce((a, b) => a + b, 0) / netFailWindow.length; } function backoffFactor() { if (netFailWindow.length < 3) return 1; const rate = netFailRate(); if (rate >= 0.7) return 8; if (rate >= 0.5) return 4; if (rate >= 0.25) return 2; return 1; } // 连续失败的熔断档位。上面那个滑动窗口算的是"最近整体顺不顺", // 好处是能识别「成功一轮、失败一轮」的交替模式;但样本不足时它给不出倍数 // (要攒够 3 轮才生效),而整点被拒的头几轮恰恰是最容易把自己撞进黑名单的。 // 所以再加一条只看「当前连续失败了几次」的保险,两条取更慢的那个。 // // 档位只取 5 秒 / 8 秒,不设 2 秒档:算过一遍各档位 × 8 倍退避后的实际间隔—— // hot 250~400ms → 2.0~3.2 秒 warmup/settle 500~800ms → 4.0~6.4 秒 // idle 1000~1500ms → 8~12 秒 cool 800~1200ms → 6.4~9.6 秒 // 也就是说 2 秒档在任何档位下都会被 backoffFactor 吃掉,写了等于没写; // 5 秒 / 8 秒档才真正只在起售窗口(唯一退避不够的地方)起作用。 // 写成"8 秒一轮"而不是"暂停若干秒",是因为暂停期间感知不到 12306 是否已恢复; // 8 秒一轮既等于暂停,又保留了自动恢复的能力。 const NET_CIRCUIT = [[10, 8000], [6, 5000]]; function circuitFloorMs() { for (const [n, ms] of NET_CIRCUIT) if (netFailStreak >= n) return ms; return 0; } // 把"这一轮网络是成是败"记一笔。退避倍数、连续失败熔断、备用接口轮换 // 全都读这一份数据,所以**每一条 return 出去的分支都必须记**。 // 原来这段直接写在主循环末尾,而中转支线是提前 return 的,等于中转模式下 // 退避永远不生效(netFailStreak 一直是 0),整点照样按最低档硬撞。 const noteRound = (failed) => { netFailWindow.push(failed ? 1 : 0); if (netFailWindow.length > NET_FAIL_WINDOW) netFailWindow.shift(); netFailStreak = failed ? netFailStreak + 1 : 0; // 每连续失败 3 轮提示一次(不刷屏),并直接说清接下来会等多久。 // 实测:保活(checkUser)一直是成功的,只有查票请求连不上。 // 这说明不是断网、也不是整个账号被限流,而是查票接口被单独掐了。 // 注意 3 轮时熔断档位还没到(第一档是 6 轮),那句"放慢到 N 秒" // 会算出 0 秒,所以这里分开写。 if (netFailStreak >= 3 && netFailStreak % 3 === 0) { const floor = circuitFloorMs(); UIModule.log(`⚠️ 已连续 ${netFailStreak} 轮连不上 12306,` + (floor ? `自动放慢到 ${floor / 1000} 秒一轮。` : '已自动放慢请求间隔。') + `保活请求是正常的,所以这不是断网,是查票接口被掐——慢下来让它缓一缓,一恢复立刻回原速`, 'warn'); } // 连续被拒到第 5 轮,试一次备用查票接口。 // 依据:日志里同一时刻「保活(login/checkUser)成功、查票(leftTicket/query)全挂」, // 说明被单独掐掉的是这条路径,而不是整个域名连不上——那换一条路径就值得一试。 // 池子里只有默认一条时会静默跳过(没得换),继续靠退避兜底。 if (netFailStreak >= 5 && !queryUrlRotated) { queryUrlRotated = true; if (rotateQueryUrl()) { UIModule.log(`查票接口连续 ${netFailStreak} 轮被拒,已换到备用地址 ${QUERY_URL} 再试`, 'warn'); } } }; const scheduleNextCheck = () => { if (!UIModule.getIsRunning()) return; const base = pollDelayMs(msToSale()); const wait = Math.max(Math.round(base * backoffFactor()), circuitFloorMs()); checkTimer = setTimeout(doCheck, wait); }; // 起售窗口内再补一路错峰请求:第一路可能刚好落在放票瞬间之前 const staggeredCheck = async (t) => { await new Promise(r => setTimeout(r, HOT_STAGGER_MS)); return checkOne(t); }; // 模式 B:候补和抢票同时进行。 // 抢到票就直接下单(那就不需要候补了);没抢到就立刻把候补挂上——候补是排队, // 早提交才能排前面,所以不等到"彻底放弃"才提交。 // 全程**不 await**,绝不能让候补拖慢抢票轮询;且只提交一次,避免重复占候补额度。 //(waitlistSubmitted 在模块级,见那边的注释) const trySubmitWaitlist = async (results) => { if (!config.waitlistEnabled || waitlistSubmitted) return; const rawLists = results.filter(r => r && r.raw).map(r => r.raw); for (const raw of rawLists) { const built = TicketLogicModule.buildWaitlistSecretList( raw, trainCodes, seatTypes, departFrom, departTo); if (!built.combos.length) continue; waitlistSubmitted = true; // 先占位,避免并发重复提交 const desc = built.combos.map(c => `${c.trainCode}${c.seatName}`).join('、'); UIModule.log(`📝 提交候补(${built.combos.length} 个组合):${desc}`, 'info'); try { const res = await NetworkModule.submitWaitlist(built.secretList); if (res && res.data && res.data.flag) { UIModule.log(`✅ 候补已提交:${desc}。可在 12306 App 的「候补订单」里查看进度`, 'success'); } else { const fc = res && res.data && res.data.faceCheck; const fcTip = (fc === '04' || fc === '14') ? '需要先在 12306 App 完成人证核验' : (fc === '01' || fc === '11' ? '身份核验审核中' : (fc === '03' || fc === '13' ? '身份信息审核失败' : '')); const reason = fcTip || (res && res.messages && res.messages[0]) || '12306 未返回原因'; UIModule.log(`⚠️ 候补未能提交:${reason}`, 'error'); } } catch (e) { // 网络异常才允许下一轮重试;服务端明确拒绝就别反复试了 waitlistSubmitted = false; UIModule.log(`⚠️ 候补提交出错(下一轮会重试):${e.message}`, 'warn'); } return; } }; // 两段下单之间的间隔。实测(群友 2026-09-16):不间隔连着下两单,第二单必收到 //「您的请求过于频繁,请稍后重试!」——因为一段下单本身就是 5 个请求 //(提交→下单页→检查→排队→确认),两段连着就是 10 个,必然撞上 12306 的限流。 const TRANSFER_LEG_GAP_MS = 3000; // 下单失败时按错误类型给出针对性解释。 // 「非法的订票请求」的成因是实测出来的(2026-09-16,同车次同区间同参数、 // token/key/leftTicketStr/train_location 完全一致):勾学生票报这个错, // 取消勾选立刻下单成功——所以问题在票种上,最常见的就是买的路段 // 不在学生票绑定的「优惠区间」内。 const explainOrderError = (err) => { const msg = String(err || ''); if (/过于频繁|频繁/.test(msg)) { UIModule.log(' ↳ 这是 12306 的限流,不是票没了。等十几秒重新开始抢,' + '或者手动去 12306 把这一段补上', 'warn'); return; } if (/非法的订票请求/.test(msg)) { if (passengers.some(p => p.isStudent === true)) { UIModule.log(' ↳ 这个报错基本可以确定是学生票:你买的路段不在学生票的「优惠区间」内。', 'error'); UIModule.log(' 学生票的区间是绑定的(家庭↔学校),区间外的路段用不了学生票。' + '可以在 12306 App 里改区间,或者先取消勾选「学生票」再抢一次。', 'error'); UIModule.alertBanner('学生票用不了这段路', '12306 拒绝了下单("非法的订票请求")。\n' + '你勾了「学生票」,但买的路段不在学生票的优惠区间内。\n\n' + '两种办法:\n' + '1. 去 12306 App 把优惠区间改成你现在要坐的路段\n' + '2. 取消勾选「学生票」,改买成人票(已验证能下单成功)'); } else { UIModule.log(' ↳ 12306 没给具体原因。如果反复出现,把日志发出来看看', 'warn'); } } }; // 登录失效的统一处理。抽成函数是因为中转支线也要用:那条路在 doCheck 里是 // 提前 return 的,主循环的登录检测根本轮不到——不补的话,中转模式下脚本会一直 // 显示「两段都查不到票」空转,只能等 45~75 秒一轮的保活兜底(而且还要连续失败两次) // 才发现掉线。 // 用 function 声明是为了拿到提升:这样写在 stopWithMessage 前面也能被调用 //(真正调用发生在用户点开始之后,那时 stopWithMessage 早就初始化好了,不会踩 TDZ)。 function handleLoggedOut() { UIModule.alertBanner('登录已失效,抢票停止', '你的 12306 登录掉线了,脚本已经停下来。\n\n' + '请重新登录 12306,然后重新点「开始抢票」。\n' + '(不处理的话,就算开售了也下不了单。)'); stopWithMessage('⚠️ 登录已失效,停止抢票,请重新登录后再开始'); } // 联程支线:用 12306 的中转联程接口,一次提交两段、一个订单。 // 跟"分两次下单"的区别(这也正是当初做它的原因): // · 一个订单 → 不存在"第二段被限流",也不需要两段之间留间隔 // · 一次支付 → 不会被算成两次学生票优惠次数(新规只认同一行程) // 代价:流程比普通下单长(提交→确认页→检查→排队→认领→跳支付), // 而且参数必须严格对齐 12306 自己的实现,差一个字段就只回一句"系统忙"。 // 参数已经逐个对着 lcpassengerInfo_js.js 核对过,也跑通过真实下单,所以不再是实验功能; // 默认仍是关——用得到中转联程的人不多,用到再开就行。 const runLcTransferMode = async () => { let data = null; try { const res = await NetworkModule.queryTransfer( trainDate, fromStation, toStation, config.transferCode || ''); data = res && res.data; noteRound(false); // 请求通了就算这一轮没被拒(有没有方案是另一回事) } catch (e) { noteRound(true); UIModule.log(`联程:查中转方案失败(${humanizeError(e.message)}),下一轮再试`, 'warn'); scheduleNextCheck(); return; } const plan = data ? TicketLogicModule.pickTransferPlan(data, seatTypes, config.waitMinutes) : null; if (!plan) { UIModule.log('联程:本轮没有「两段都有你选席别余票」的方案', 'warn'); scheduleNextCheck(); return; } UIModule.log(`🎉 联程方案:在「${plan.middleName}」换乘 ${plan.waitMinutes} 分钟,` + `全程 ${plan.totalTime},车次 ${plan.trainCodes.join(' + ')}——开始下单`, 'success'); if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; UIModule.setStatus('● 联程下单中', 'grab-status-found'); UIModule.playBeep(); const res = await OrderLogicModule.executeTransferOrderSequence( plan, passengers, config.fromName, config.toName); if (res.success) { UIModule.log(`✅ 联程下单成功!订单号 ${res.orderId || '已提交'}。5 秒后带你跳支付页…`, 'success'); UIModule.showNotification('联程票抢到了', '请尽快去 12306 支付(30 分钟内有效)'); UIModule.alertBanner('联程票抢到了,请尽快支付', `订单号:${res.orderId || '已提交'}\n` + `两段:${plan.trainCodes.join(' + ')}(在 ${plan.middleName} 换乘)\n\n` + '席位已锁定,请在 30 分钟内完成支付,否则会自动释放。', 'success'); setTimeout(() => { // 必须用 POST 跳转:payOrder/init 只认 POST,location.href 是 GET, // 打过去只会看到「系统忙,请稍后重试」(群友截图就是这个)。 // 真实实现是 otsRedirect("post", ctx + "payOrder/init?random=" + ...) postRedirect(`https://kyfw.12306.cn/otn/payOrder/init?random=${Date.now()}`); }, 5000); GM_setValue('grab_running', false); } else if (res.blocked) { // 账号被临时限制(取消/失败次数太多)。这个跟"票被抢走"是两回事: // 继续重试、换车次、换席别全都没用,只会让限制更久。 const reason = String(res.error || '').replace(/<[^>]*>/g, ''); UIModule.log(`⛔ ${reason}`, 'error'); UIModule.log(' 这是 12306 对账号的限制,不是脚本能绕的。今天先别抢了,明天再来', 'error'); UIModule.showNotification('账号被限制下单', reason); UIModule.alertBanner('今天抢不了了:账号被 12306 限制', `${reason}\n\n` + '原因通常是短时间内取消/下单失败次数太多,12306 会掐掉当天的下单资格。\n\n' + '这不是脚本能解决的,继续试反而可能拖得更久。明天再抢,或者换个账号。'); stopWithMessage('已停止:账号今日被限制下单'); return; } else if (res.uncertain) { // 这**不是失败**:订单已经受理,只是排队状态还没确认下来。 // 这种时候最该做的是让人去 12306 看一眼订单,而不是报一句"失败" // 让他重抢——重抢反而会撞上那笔刚生成的待支付订单。 const notice = String(res.error || '').replace(/<[^>]*>/g, ''); UIModule.log(`⚠️ ${notice}`, 'warn'); UIModule.showNotification('订单可能已生成', '请打开 12306 订单列表确认并支付'); UIModule.alertBanner('订单已受理,请去 12306 确认', `${notice}\n\n` + '打开 12306 的【订单】→【未支付】:如果里面有你刚抢的票,' + '直接支付就行(30 分钟内有效)。\n\n' + '脚本不再重复下单,免得又留下一笔。'); stopWithMessage('已停止:订单已受理,请去 12306 确认'); return; } else { // 12306 的错误消息里可能夹着 HTML(未完成订单那条会塞一个 标签), // 面板是纯文本,原样显示很难看,先剥掉。 const plain = String(res.error || '').replace(/<[^>]*>/g, ''); UIModule.log(`❌ 联程下单失败:${plain}`, 'error'); explainOrderError(plain); // 账号里卡着未支付/未处理的订单时,12306 会拒绝一切新订单,重试毫无意义。 // 普通下单那条路一直有这道拦截,联程这条以前没有。 // 但要注意:**只有当失败原因真的是"账号被堵住"时才拦**。 // res.uncertain 表示"排队还没确认完",那时账号里那笔待支付订单 // 恰恰就是刚抢到的那张,绝不能拿它去吓唬人。 if (!res.uncertain && /未处理的订单|未完成订单/.test(plain)) { UIModule.log('⛔ 你的 12306 账号里卡着「未完成订单」,12306 会拒绝所有新订单。', 'error'); UIModule.log(' 请先打开 12306 的【未完成订单】页,把它支付掉或取消掉,再重新开始抢票:', 'error'); UIModule.log(' https://kyfw.12306.cn/otn/view/train_order.html', 'error'); UIModule.showNotification('有未完成订单挡着', '请先去 12306「未完成订单」页支付或取消,否则抢不到票'); UIModule.alertBanner('抢不了:账号里有未完成订单', '12306 在「未完成订单」被处理掉之前,会拒绝一切新订单。\n\n' + '请去 12306 的【未完成订单】页,把里面的订单全部支付或取消掉,' + '然后重新点「开始抢票」。\n\n' + '(脚本已经停止,不会继续白撞。)'); stopWithMessage('已停止:需要先处理 12306 的未完成订单'); return; } UIModule.alertBanner('联程下单没成功', `${plain}\n\n` + '如果反复失败,可以换普通中转试试(两段分别买,第二段可能需要等几秒)——' + '它是另一条独立的路,联程这条路走不通时它未必也走不通。'); stopWithMessage('已停止:联程下单未成功'); } }; // 中转支线:两段都有票才依次下单。 // 为什么必须"都查到才动手":下单一段要 5 个请求(1~2 秒),先买一段再去买另一段时, // 另一段很可能已经没票了——人就被卡在中转站,退票还有手续费。这是中转最大的风险, // 代码没法消除,只能用策略压低:宁可两边都错过,也不让人卡在半路。 const runTransferMode = async () => { const legs = [ { date: trainDate, fromCode: fromStation, toCode: config.transferCode, tag: '第1段', fromName: config.fromName, toName: config.transferStation }, { date: trainDate, fromCode: config.transferCode, toCode: toStation, tag: '第2段', fromName: config.transferStation, toName: config.toName } ]; const results = await Promise.all(legs.map(t => checkOne(t))); // 中转支线是提前 return 的,必须自己记这一轮的网络成败, // 否则整点被拒时退避和接口轮换永远不生效(见 noteRound 的注释) noteRound(results.some(r => r.netError)); // 中转支线是提前 return 的,走不到主循环那段登录检测,这里必须自己看一眼 if (results.some(r => r.loggedOut)) { handleLoggedOut(); return; } const missing = results.filter(r => !r.train); if (missing.length) { // 还没凑齐,报告卡在哪一段 const tip = results.map(r => r.train ? `${r.target.tag}(${r.target.fromName}→${r.target.toName}) ✅` : `${r.target.tag}(${r.target.fromName}→${r.target.toName}) ❌ ${r.miss || r.error || '无票'}`).join(' | '); UIModule.log(`中转等待中:${tip}`, 'warn'); scheduleNextCheck(); return; } // 两段都有票 —— 依次下单。先下第 1 段,失败就立刻收手,不去动第 2 段。 UIModule.log(`🎉 两段都有票!${results[0].train.trainCode} + ${results[1].train.trainCode},开始依次下单`, 'success'); if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; UIModule.setStatus('● 两段都有票!', 'grab-status-found'); UIModule.playBeep(); const ordered = []; for (let i = 0; i < results.length; i++) { const r = results[i]; if (i > 0) { // 实测:紧接着下第二段会被判「请求过于频繁」,必须留出间隔 UIModule.log(`等 ${TRANSFER_LEG_GAP_MS / 1000} 秒再下第 ${i + 1} 段` + `(连着下会被 12306 判为请求过于频繁)`, 'info'); await new Promise(res => setTimeout(res, TRANSFER_LEG_GAP_MS)); if (!UIModule.getIsRunning()) return; } const legInfo = `${r.target.tag} ${r.target.fromName}→${r.target.toName} ${r.train.trainCode} ${r.train.seatName}`; UIModule.log(`▶ 正在下单:${legInfo}`, 'info'); const res = await OrderLogicModule.executeOrderSequence(r.train, passengers, config.chooseSeats); if (!res.success) { UIModule.log(`❌ ${legInfo} 下单失败:${res.error}`, 'error'); explainOrderError(res.error); if (ordered.length) { // 最坏的情况:前一段买到了、后一段没买到 —— 必须说清楚,并告诉怎么退 UIModule.log(`⚠️ 已经买成了 ${ordered.join('、')},但剩下的段没买到。` + `你现在会停在半路——请去 12306 的【未完成订单】把已买的处理掉(可退票),或另想办法完成剩下那一段`, 'error'); UIModule.showNotification('中转只买到一段', '已买的那段请去 12306 处理,避免卡在半路'); UIModule.alertBanner('⚠️ 中转只买到一段,会卡在半路', `已经买成:${ordered.join('、')}\n` + `没买到的是:${legInfo}\n\n` + '请去 12306 的【未完成订单】处理已买的那段(可以退票,' + '开车前 8 天以上退票免手续费),或者手动把剩下的那段补上。'); } stopWithMessage('已停止:中转下单未能全部成功'); return; } ordered.push(`${legInfo}(订单 ${res.orderId || '已提交'})`); UIModule.log(`✅ ${legInfo} 下单成功`, 'success'); } UIModule.log(`✅ 两段都买到了:${ordered.join(' | ')}。5秒后自动跳转「未完成订单」页…`, 'success'); UIModule.showNotification('中转两段都抢到了', '请尽快去 12306 支付'); setTimeout(() => { window.location.href = 'https://kyfw.12306.cn/otn/queryOrder/initNoComplete'; }, 5000); GM_setValue('grab_running', false); }; const stopWithMessage = (msg) => { UIModule.log(msg, 'error'); if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; UIModule.stop(); }; const doCheck = async () => { if (!UIModule.getIsRunning()) { if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; return; } try { // 中转模式走单独一条支线:要求两段都有票,逻辑跟"任一目标命中就下单"完全不同 if (config.transferCode || config.autoTransfer) { if (config.lcTransfer) { await runLcTransferMode(); } else { await runTransferMode(); } return; } const hot = isHotWindow(msToSale()); // 主目标与一个轮转备选目标并发;起售窗口内再补一路错峰,不额外增加往返等待 const jobs = [checkOne(targets[0])]; if (altTargets.length) jobs.push(checkOne(altTargets[altCursor++ % altTargets.length])); if (hot) jobs.push(staggeredCheck(targets[0])); const results = await Promise.all(jobs); const main = results[0]; const hit = results.find(r => r.train); const targetTrain = hit ? hit.train : null; // 每一轮都要记一笔,**包括"有票"的那一轮**:那个分支是提前 return 的。 // 有票本身就证明链路是通的,所以那一轮按成功记——不记的话连续失败 // 计数会一直挂在那儿,把熔断档位带到下一轮去。 //(这段以前写在函数末尾,因为状态是局部变量、重入即重置,漏记看不出来; // 挪到模块级之后漏记就会真的累积,所以必须放在所有 return 之前。) noteRound(targetTrain ? false : results.some(r => r.netError)); if (!hit && results.some(r => r.loggedOut)) { handleLoggedOut(); return; } // 模式 B:候补和抢票同时进行。不 await,绝不能让候补拖慢轮询。 // 两个前提: // 1) 查票正被连续拒的时候先别提交——候补提交也是一次请求,在已经撞墙的 // 通道上再挤一个只会让链路恢复得更慢;恢复后 netFailStreak 归零,下一轮照常提。 // 2) 这一轮已经命中票时不提交——那时的正事是下单,再挂一份候补等于同时占两个位子, // 额度白扣(这段的注释一直写的就是"抢到票就不需要候补了",代码却没这么做)。 if (!targetTrain && config.waitlistEnabled && !waitlistSubmitted && netFailStreak < 3) { trySubmitWaitlist(results); } if (targetTrain) { // 查票是并发跑的,await 期间用户可能已经点了「停止」。 // 原来这里没有复查,停止之后只要这一轮的请求正好带回了有余票, // 脚本照样会下单,给用户留下一笔他没预期的订单(还得去取消)。 // "停止"应该是明确生效的,所以下单前必须再确认一次。 if (!UIModule.getIsRunning()) { if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; UIModule.log(`任务已停止,不再下单(本来要买的是 ${targetTrain.trainCode} ${targetTrain.seatName})`, 'warn'); return; } const hitTag = hit.target.tag === '主目标' ? '' : `[${hit.target.tag} ${hit.target.fromName}→${hit.target.toName} ${hit.target.date}] `; UIModule.log(`🎉 有票: ${hitTag}${targetTrain.trainCode} ${targetTrain.seatName}`, 'success'); // 下单前把「到底买哪趟、哪一段、几个人、选不选座」摊开写清楚。 // 群友反馈过只看得到车次号,心里没底——尤其是开了买长乘短、前后一天, // 或者车次号撞上其它区间的时候,光看车次根本不知道自己买的是什么。 UIModule.log(`▶ 准备下单: ${targetTrain.trainCode} ${targetTrain.trainDate} ` + `${stationNameOf(targetTrain.fromStation)}→${stationNameOf(targetTrain.toStation)} ` + `${targetTrain.seatName},${passengers.length} 人,` + (config.chooseSeats ? `选座 ${config.chooseSeats}` : '座位由系统自动分配'), 'info'); // 区间没对上照样下单(票是真的),但必须让你看见—— // 12306 的查询站码和结果里的乘车站码有时不是同一个(同城多站/城市码), // 直接拦截会一趟都抢不到,所以这里只报不拦。 if (targetTrain.stationMismatch) { UIModule.log(`注意:这趟车 12306 给的乘车区间是 ` + `${stationNameOf(targetTrain.fromStation)}→${stationNameOf(targetTrain.toStation)},` + `和你设的 ${stationNameOf(targetTrain.expectFrom)}→${stationNameOf(targetTrain.expectTo)} 不完全一致。` + `(查票用的站码有时不等于实际乘车站码,属正常情况,照常下单)`, 'warn'); } if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; UIModule.setStatus('● 发现余票!', 'grab-status-found'); UIModule.playBeep(); const orderResult = await OrderLogicModule.executeOrderSequence(targetTrain, passengers, config.chooseSeats); if (orderResult.success) { orderFailStreak = 0; orderRetryDelay = 500; UIModule.log(`✅ 下单成功!${orderResult.orderId ? '订单号 ' + orderResult.orderId + ',' : ''}5秒后自动跳转「未完成订单」页...`, 'success'); UIModule.showNotification('抢票成功', `车次 ${targetTrain.trainCode},请及时支付`); setTimeout(() => { window.location.href = 'https://kyfw.12306.cn/otn/queryOrder/initNoComplete'; }, 5000); // 清除运行状态 GM_setValue('grab_running', false); } else { UIModule.log(`❌ 下单失败: ${orderResult.error}`, 'error'); explainOrderError(orderResult.error); // 账号里卡着未支付/未处理的订单时,12306 会拒绝一切新订单。 // 这种情况重试毫无用处——而且每失败一次往往又留下一笔新的半成品挂单, // 越滚越乱(实测有人被这样刷了 60 多次全是失败)。必须停下来让人去处理。 if (/未处理的订单|未完成订单/.test(String(orderResult.error))) { UIModule.log('⛔ 你的 12306 账号里卡着「未完成订单」,12306 会拒绝所有新订单。', 'error'); UIModule.log(' 请先打开 12306 的【未完成订单】页,把它支付掉或取消掉,再重新开始抢票:', 'error'); UIModule.log(' https://kyfw.12306.cn/otn/view/train_order.html', 'error'); UIModule.showNotification('有未完成订单挡着', '请先去 12306「未完成订单」页支付或取消,否则抢不到票'); UIModule.alertBanner('抢不了:账号里有未完成订单', '12306 在「未完成订单」被处理掉之前,会拒绝一切新订单。\n\n' + '请去 12306 的【未完成订单】页,把里面的订单全部支付或取消掉,' + '然后重新点「开始抢票」。\n\n' + '(脚本已经停止,不会继续白撞。)'); stopWithMessage('已停止:需要先处理 12306 的未完成订单'); return; } // 区分"正常的抢票失败"和"真出问题了": // 「排队人数超过余票数」「没有足够的票」说明票就是被别人抢走了, // 这时候**必须继续抢**——热门车次会有回流票,停下来就彻底没机会了。 // 实测(群友日志):以前一视同仁地数到 10 次就停,结果在起售 22 秒后 // 就把任务停了,而那时候票还在陆续回流。 const normalRush = /排队人数|超过余票|没有足够的票|余票不足|票已售完|无票/.test(String(orderResult.error)); if (normalRush) { UIModule.log(`立即重新查票...(票被抢走了 ${orderFailStreak + 1} 次,继续抢——热门车次会有回流票)`, 'warn'); } else { orderFailStreak++; if (orderFailStreak >= ORDER_FAIL_LIMIT) { UIModule.log(`⛔ 下单连续失败 ${orderFailStreak} 次,而且不是"票被抢走",停止重试。` + `最后一次的原因:${orderResult.error}`, 'error'); stopWithMessage('已停止:下单反复失败,请检查上面的原因'); return; } UIModule.log(`立即重新查票...(本任务下单已连续失败 ${orderFailStreak} 次)`, 'warn'); } // #4 立即重查。间隔随连续失败逐档拉长: // 固定 500ms 时一秒能跑完五六个请求,几十轮下来必被限流 // (实测 66 秒内下单 300+ 次)。 isChecking = false; if (normalRush) { orderRetryDelay = Math.min(orderRetryDelay * 2, 4000); } else { orderRetryDelay = 500; } setTimeout(() => { if (UIModule.getIsRunning()) { executeTask(config); } }, orderRetryDelay); } return; } // 状态栏显示轮次,让人一眼看出脚本在动(原代码只反复刷「正在查票」) pollCount++; UIModule.setStatus(`● 查票中 第${pollCount}轮`, 'grab-status-running'); UIModule.log(main.miss || main.error || '查票接口异常', 'warn'); } catch (e) { UIModule.log(`查票出错: ${e.message}`, 'error'); } scheduleNextCheck(); }; doCheck(); } function stopTask() { if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } if (countdownTimer) { clearTimeout(countdownTimer); countdownTimer = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } if (keepAliveTimer) { clearTimeout(keepAliveTimer); keepAliveTimer = null; } stopThrottleWatch(); stopAlarm(); isChecking = false; saleTimeStr = null; saleAtMs = null; lastStopAt = Date.now(); // 记下停止时刻,给「立刻重启」留个冷却 GM_setValue('grab_running', false); UIModule.log('已停止刷票', 'warn'); } // 「刚停下又点开始」还剩几秒冷却;返回 0 表示可以直接开始。 // 函数声明会被提升,所以定义在这里也能被上面的 UIModule.start 调到。 function restartCooldownLeftSec() { if (!lastStopAt) return 0; const left = RESTART_COOLDOWN_MS - (Date.now() - lastStopAt); return left > 0 ? Math.ceil(left / 1000) : 0; } // ========================================== // 中转查询接口地址 // ========================================== // 默认用实测确认过的 /lcquery/queryG。万一 12306 以后换路径, // 用户访问一次「中转换乘」页时会被下面这段自动改成新地址。 // 只做纯属性读取,不发请求、不动 DOM,不占抢票路径的时间。 const LC_URL_KEY = 'lc_search_url'; const LC_URL_DEFAULT = '/lcquery/queryG'; function lcSearchUrl() { return GM_getValue(LC_URL_KEY, '') || LC_URL_DEFAULT; } function sniffLcSearchUrl() { let found = ''; try { if (typeof window.lc_search_url === 'string') found = window.lc_search_url; } catch (e) { /* 忽略 */ } try { if (!found && typeof unsafeWindow !== 'undefined' && unsafeWindow && typeof unsafeWindow.lc_search_url === 'string') { found = unsafeWindow.lc_search_url; } } catch (e) { /* 忽略 */ } if (found && found !== GM_getValue(LC_URL_KEY, '')) { GM_setValue(LC_URL_KEY, found); tlog('log', `[LC] 中转查询接口地址已更新: ${found}`); return true; } return false; } // ========================================== // 联程确认页探测(一次性调研用,拿到结果后就可以删掉) // ========================================== // 为什么需要它:中转联程的第一步(lcQuery/submitOrderRequest)参数我全知道, // 但提交后会跳到确认页 lcConfirmPassenger/initLc,那个页面**需要登录态**才拿得到 // (未登录访问只会返回 23KB 的通用页),所以离线挖不出它之后怎么走。 // 与其瞎猜着写(猜错的后果是又留一笔挂单),不如让脚本在用户真实走到那个页面时 // 自己把结构记下来 —— 和上次探 lc_search_url 是同一个路子,那次一次就成了。 // // 注入到页面上下文是必须的:userscript 沙箱里读不到页面的全局变量, // 也 hook 不到页面自己的 XMLHttpRequest。 const LC_PROBE_ATTR = 'data-dsh-lc-probe'; const LC_PROBE_STORE = '__dsh_lc_probe'; function isLcConfirmPage() { try { return /lcConfirm/i.test(location.pathname || ''); } catch (e) { return false; } } function installLcProbe() { if (!isLcConfirmPage()) return; try { const code = `(function(){ if (window.__dshLcProbe) return; window.__dshLcProbe = true; var out = []; function push(line) { if (out.length < 90) { out.push(line); flush(); } } function flush() { try { var s = out.join(' ;; '); document.documentElement.setAttribute('${LC_PROBE_ATTR}', s); // 关键:同时写 localStorage。用户一点提交,页面立刻就跳到支付页了, // 只把结果挂在 DOM 属性上的话换页就丢(第一次探测就是这么白跑的)。 try { localStorage.setItem('${LC_PROBE_STORE}', s); } catch (e) {} } catch (e) {} } // 1) 扫全局变量里名字可疑的(token/key/check/leftTicket 这一类) try { for (var k in window) { if (!/token|key|check|repeat|ticket|secret|lc|order/i.test(k)) continue; var v = window[k], t = typeof v; if (t === 'string' || t === 'number') { push('G ' + k + ' = ' + String(v).slice(0, 32) + ' [' + String(v).length + '字]'); } else if (t === 'object' && v) { push('G ' + k + ' = {' + Object.keys(v).slice(0, 20).join(',') + '}'); } } } catch (e) { push('G ERR ' + e.message); } // 2) 扫页面里的关键字段。两种形式都要认—— // 对象字面量是 'leftTicketStr': '...',而 globalRepeatSubmitToken = '...' 是赋值。 // 另外名字**以关键词开头**的也要认(真实页面上就有 key_check_isChange), // 所以正则只管抓「名字 + 冒号或等号 + 带引号的值」,关键词留给 JS 判断。 try { var html = document.documentElement.innerHTML || ''; var rx = new RegExp('["\\']?([A-Za-z_$][\\\\w$]{3,})["\\']?\\\\s*[:=]\\\\s*["\\']([^"\\']{4,})["\\']', 'g'); var m; // 注意:这里**不能去重**。联程页面上 leftTicketStr 这类字段两段各有一个, // 去掉重复的就会漏掉第二段(第一次探测就是这么漏的)。 while ((m = rx.exec(html)) !== null) { if (!/(token|key|str|secret|check|ticket|lc)/i.test(m[1])) continue; push('F ' + m[1] + ' = ' + m[2].slice(0, 40) + ' [' + m[2].length + '字]'); if (out.length > 70) break; } } catch (e) { push('F ERR ' + e.message); } // 3) 把页面加载的脚本和表单也记下来。 // 联程的提交逻辑就在这个页面自己的某个 JS 里(它读的是带 _lc 后缀的那套变量), // 拿到脚本地址我就能自己下载下来分析,不用麻烦用户真的下一单。 try { var ss = document.getElementsByTagName('script'); for (var i = 0; i < ss.length; i++) { var src = ss[i].src; if (src && src.indexOf('toolbar') < 0) push('S ' + String(src).slice(0, 150)); } var fs = document.getElementsByTagName('form'); for (var j = 0; j < fs.length; j++) { push('M form action=' + String(fs[j].action || '').slice(0, 120) + ' method=' + String(fs[j].method || '') + ' id=' + String(fs[j].id || '')); } // 页面上所有输入框的 id/name,提交请求的参数名多半就在里面 var ins = document.querySelectorAll('input'); var names = []; for (var k = 0; k < ins.length && names.length < 30; k++) { var nm = ins[k].id || ins[k].name; if (nm) names.push(nm); } if (names.length) push('I inputs: ' + names.join(',')); } catch (e) { push('S ERR ' + e.message); } // 4) hook 请求:用户在确认页上点"提交"之后发了什么,全记下来 try { var oOpen = XMLHttpRequest.prototype.open; var oSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(method, url) { this.__dshM = method; this.__dshU = url; return oOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function(body) { try { // body 要记全:联程的 getQueueCount 用一整串 dataStr 装两段行程, // 截断到两百来字就只剩第一段了(第一次探测就是这么漏的) push('X ' + (this.__dshM || '') + ' ' + String(this.__dshU || '').slice(0, 100) + ' << ' + String(body || '').slice(0, 900)); } catch (e) {} return oSend.apply(this, arguments); }; if (window.fetch) { var oFetch = window.fetch; window.fetch = function(input, init) { try { var u = (input && input.url) || String(input); var b = (init && init.body) || ''; push('X FETCH ' + String(u).slice(0, 100) + ' << ' + String(b).slice(0, 900)); } catch (e) {} return oFetch.apply(this, arguments); }; } } catch (e) { push('X ERR ' + e.message); } flush(); setInterval(flush, 1500);})();`; const s = document.createElement('script'); s.textContent = code; (document.head || document.documentElement).appendChild(s); s.remove(); tlog('log', '[LC探针] 已注入(当前在联程确认页)'); } catch (e) { tlog('warn', `[LC探针] 注入失败:${e.message}(页面 CSP 可能拦了)`); } } function readLcProbe() { // 优先读 localStorage:用户点提交后页面会立刻跳走, // 那时只存在 DOM 属性上的记录就没了;localStorage 能跨页面留下来。 try { const stored = localStorage.getItem(LC_PROBE_STORE); if (stored) return stored; } catch (e) { /* 忽略 */ } try { return document.documentElement.getAttribute(LC_PROBE_ATTR) || ''; } catch (e) { return ''; } } // ========================================== // 初始化 // ========================================== setTimeout(async () => { await fetchStationMap(3); // #8 重试3次 UIModule.init(startTask, stopTask); // 顺手看看中转查询接口地址有没有变(纯属性读取,不发请求) sniffLcSearchUrl(); // 如果当前正好在联程确认页,顺手把该页结构和后续请求记下来(一次性调研用) installLcProbe(); // 如果之前有运行标记,自动启动(在面板内已处理) }, 1000); })();