// ==UserScript== // @name 12306 抢票助手 Pro // @namespace http://tampermonkey.net/ // @icon https://raw.githubusercontent.com/shiyi312/12306-/main/12306helper.jpg // @version 2.1.16 // @description 自动查票、下单,成功后自动跳转支付页。支持多车次、多席别、选座偏好、配置自动保存、动态状态提醒。 // @author 辻弌20 // @match https://www.12306.cn/* // @match https://kyfw.12306.cn/* // @grant GM_notification // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @grant window.focus // @license MIT // ==/UserScript== (function() { 'use strict'; // 与文件头的 @version 保持一致(改版本号时两处一起改) const SCRIPT_VERSION = '2.1.16'; // 记录脚本自己打到 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; } } // ========================================== // 抢到票之后的"落地保护" // ========================================== // 群友真实事故:票抢到了、脚本也提示了"需要支付",但 5 秒后跳转过去却显示 // 「请确认已登录」,他以为失败——其实订单就躺在账号里,未支付订单 30 分钟就没了。 // 根因不是没抢到,而是「跳转」这个动作依赖登录态,而登录态可能刚好在那几秒里失效 // (那时脚本刚打完一波高频请求,又被用户手动刷新过页面)。 // 三道保险: // ① 下单成功立刻把订单信息落盘(跳转丢了、用户关了页面,痕迹都还在) // ② 跳转前确认登录态,失效就不跳——跳过去只会看到登录页 // ③ 下一个页面加载时接着提醒,直到用户处理 const ORDER_NOTICE_KEY = 'grab_last_order'; const ORDER_NOTICE_TTL_MS = 6 * 3600 * 1000; // 未支付订单值得提醒半天 function saveOrderNotice(info) { try { GM_setValue(ORDER_NOTICE_KEY, JSON.stringify(Object.assign({ at: Date.now() }, info))); } catch (e) { /* 存不上不影响抢票 */ } } function readOrderNotice() { try { const raw = GM_getValue(ORDER_NOTICE_KEY, ''); if (!raw) return null; const o = JSON.parse(raw); if (!o || !o.at || Date.now() - o.at > ORDER_NOTICE_TTL_MS) return null; return o; } catch (e) { return null; } } function clearOrderNotice() { try { GM_setValue(ORDER_NOTICE_KEY, ''); } catch (e) { /* 忽略 */ } } function orderNoticeLabel(n) { return `${n.trainCode || ''} ${n.trainDate || ''} ${n.fromName || ''}→${n.toName || ''} ${n.seatName || ''}` .replace(/\s+/g, ' ').trim(); } 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 自己也会变慢,3 秒经常不够——把"马上就要回来"的请求当超时杀掉, // 丢掉的就是一次查票机会,而那时候每一次都金贵。所以整点前后放宽到 5 秒。 const QUERY_TIMEOUT_MS_HOT = 5000; // 查票接口候选池。 // 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) { /* 读不到就用默认 */ } })(); // 从 302 的 Location 里解析出查票接口路径。 // 两个坑(都是实测/自测踩出来的): // 1) Location 是**相对于实际发出的那个请求地址**的:实测给的是裸的「queryG?leftTicketDTO=...」。 // 当根路径处理会拼出 /otn/queryG 这种根本不存在的地址,所以必须按 baseUrl 解析。 // 2) 12306 的查票接口在 query / queryA..queryZ 之间轮换,末段必须长这样; // 这样登录页、下单页之类的跳转不会被误学进来(宁可不学,也不能把接口指错)。 function queryUrlFromLocation(loc, baseUrl) { const raw = String(loc || '').trim(); if (!raw) return ''; let path = ''; try { path = /^https?:\/\//i.test(raw) ? new URL(raw).pathname : new URL(raw, baseUrl || `${BASE_URL}${QUERY_URL_DEFAULT}`).pathname; } catch (e) { return ''; // 畸形地址(含非字符串)一律当作学不到 } const tail = path.split('/').filter(Boolean).pop() || ''; if (!/^query[A-Z]?$/.test(tail)) return ''; return path; } // 12306 自己会把「当前该用的查票接口」写在页面 JS 的变量里(实测 init 页里就是这一行): // var CLeftTicketUrl = 'leftTicket/queryG'; // 这是最权威的来源——它是 12306 自己声明给前端用的,不用猜、不用追重定向。 // 值不带 /otn/ 前缀,所以要自己拼回去。 const REGEX_C_LEFT_TICKET_URL = /var\s+CLeftTicketUrl\s*=\s*['"]([^'"]+)['"]/; // 把 CLeftTicketUrl 的值规范化成池子里用的形式(/otn/leftTicket/queryX)。 // 校验和 queryUrlFromLocation 一致:末段必须是 query / queryA..queryZ, // 且倒数第二段必须是 leftTicket。宁可不学,也不能把接口指错—— // 指错了等于每轮都白打一次请求,比"没学到"更糟。 function queryUrlFromCLeftTicket(raw) { const val = String(raw || '').trim(); if (!val) return ''; let path = val; try { if (/^https?:\/\//i.test(val)) path = new URL(val).pathname; } catch (e) { return ''; } const seg = path.split('/').filter(Boolean); if (seg.length < 2) return ''; const tail = seg[seg.length - 1]; if (!/^query[A-Z]?$/.test(tail)) return ''; if (seg[seg.length - 2] !== 'leftTicket') return ''; return `/otn/${seg[seg.length - 2]}/${tail}`; } // 向 12306 问「现在是哪个查票接口」。 // 两条路,先便宜的再花钱的: // 1) 当前页面:脚本本来就跑在 12306 上,查询页的 JS 里就有 CLeftTicketUrl, // 零请求直接读(需要 @grant unsafeWindow;取值前用 typeof 守卫, // 就算哪天授权被去掉也只是走第二条路,不会抛 ReferenceError)。 // 2) 拉一次 /otn/leftTicket/init:不在查询页时(比如停在订单页)走这条。 // init 是个普通页面请求,比查票请求轻,也不占查票那份额度。 // 两条都拿不到就返回 null,调用方会退回原来的 302 探测。 async function learnQueryUrlFromCLeftTicket() { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow && unsafeWindow.CLeftTicketUrl) { const u = queryUrlFromCLeftTicket(unsafeWindow.CLeftTicketUrl); if (u) return { url: u, from: '当前页面变量' }; } } catch (e) { /* 跨沙箱读不到就算了,走下面那条 */ } try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); let res; try { res = await fetch(`${BASE_URL}/otn/leftTicket/init`, { method: 'GET', redirect: 'follow', signal: controller.signal, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': (typeof location !== 'undefined' && location.href) || 'https://kyfw.12306.cn/otn/leftTicket/init' } }); } finally { clearTimeout(timer); } if (!res || !res.ok) return null; const html = await res.text(); const m = String(html).match(REGEX_C_LEFT_TICKET_URL); if (!m) return null; const u = queryUrlFromCLeftTicket(m[1]); return u ? { url: u, from: 'init 页面' } : null; } catch (e) { return null; } } // 确认当前该用哪个查票接口:优先读 12306 自己声明的 CLeftTicketUrl(见上一段), // 读不到才走下面的 302 探测。两条路的取舍与踩过的坑都记在这里。 // 为什么需要:12306 现在把 /otn/leftTicket/query 用 302 跳到新地址(实测是 queryG), // 而 fetch 默认自动跟随重定向——表面上"能用",代价是**每一轮查票都多走一个往返**; // 起售窗口一轮本来只有 250~400ms,多一个 RTT 就是两位数百分比的节奏损耗。 // 更隐蔽的是:脚本原有的 c_url 自动切换机制因此永远收不到 c_url(被 302 吃掉了), // 接口池里一直只有默认地址——群友日志里「12306 告知查票接口已轮换」那行从没出现过,就是这个原因。 // // ⚠ 这里踩过一个坑,必须记下来:一开始用的是 redirect:'manual' + 读 Location 头, // 在 Node 里实测能拿到,**但浏览器里 manual 重定向返回的是 opaqueredirect 响应 // (status 0、headers 全空、Location 读不到)**——群友日志里那行平平的「HTTP 0」就是它。 // 结果探测在真实环境里永远学不到地址,功能等于白做。 // 正确姿势:redirect:'follow',然后读 response.url —— 它才是跨浏览器都能拿到的最终地址。 // 探不到(超时/改版/离线/接口没变)就什么都不改,行为与以前完全一致。 async function probeQueryUrl(retries = 1) { // 首选:直接问 12306 自己现在用哪个查票接口(CLeftTicketUrl)。 // 这条路不占查票额度,也不受浏览器对重定向的实现差异影响—— // 正是那个差异让上一版探测在真实环境里空转过。 // 拿不到就往下走 302 探测,行为与以前完全一致。 try { const got = await learnQueryUrlFromCLeftTicket(); if (got) { if (got.url !== QUERY_URL) { rememberQueryUrl(got.url); tlog('log', `[Query] 已拿到官方查票接口: ${got.url}(来源:${got.from})` + `(以后直连,每轮省掉一次重定向往返)`); return true; } tlog('log', `[Query] 官方查票接口就是 ${QUERY_URL}(来源:${got.from}),无需切换`); return false; } tlog('log', '[Query] 没读到 CLeftTicketUrl,改用 302 探测'); } catch (e) { tlog('log', `[Query] 读 CLeftTicketUrl 失败(${e.message}),改用 302 探测`); } try { // 特意用「北京→天津」这种冷门短途:探测不该去挤最热门的干线 // (北京→上海这种本身就容易被限流),反正我们只看"跳没跳",不看内容 const q = new URLSearchParams({ 'leftTicketDTO.train_date': serverTodayIso(), 'leftTicketDTO.from_station': 'BJP', 'leftTicketDTO.to_station': 'TJP', 'purpose_codes': 'ADULT' }); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 5000); const probeUrl = `${BASE_URL}${QUERY_URL_DEFAULT}?${q.toString()}`; let res; try { res = await fetch(probeUrl, { method: 'GET', redirect: 'follow', signal: controller.signal, headers: { 'X-Requested-With': 'XMLHttpRequest', 'Referer': (typeof location !== 'undefined' && location.href) || 'https://kyfw.12306.cn/otn/leftTicket/init' } }); } finally { clearTimeout(timer); } // follow 之后 response.url 是最终地址;没发生重定向时它就等于请求地址, // 这时 learned === QUERY_URL,走下面的"没有重定向"分支,一切照旧。 const learned = queryUrlFromLocation(res.url, probeUrl); if (learned && learned !== QUERY_URL) { rememberQueryUrl(learned); tlog('log', `[Query] 已探到真实查票接口: ${QUERY_URL_DEFAULT} -> ${learned}` + `(以后直连,每轮省掉一次重定向往返)`); return true; } tlog('log', `[Query] 探测查票接口: 没有重定向(HTTP ${res.status}),沿用 ${QUERY_URL}`); return false; } catch (e) { tlog('warn', `[Query] 探测真实查票接口失败(不影响抢票,继续用 ${QUERY_URL}): ${e.message}`); // 启动那一瞬间网络还没就绪、或正好撞上 12306 拒连,都会探不到。 // 隔 15 秒再来一次,比"错过这一次就永远多走一个往返"划算——探测本身很轻。 if (retries > 0) { tlog('log', `[Query] 15 秒后重试探测查票接口(还剩 ${retries} 次)`); setTimeout(() => { probeQueryUrl(retries - 1); }, 15000); } return false; } } const SEAT_TYPE_CODE = { '商务座': '9', '特等座': 'P', '一等座': 'M', '优选一等座': 'D', '二等座': 'O', '高级软卧': '6', '软卧': '4', '一等卧': 'I', '硬卧': '3', '二等卧': 'J', '软座': '2', '硬座': '1', '无座': '1' }; // 下单用的席别代码。绝大多数席别与 SEAT_TYPE_CODE 相同,唯独"无座"特殊。 // 依据全部来自 12306 自己的代码(queryLeftTicket_end_js.js),不是猜的: // 1) 无座在 checkOrderInfo 这一步要传 'WZ'——服务端认这个码。 // 上一版传 'W' 是看错了地方:那个 cQ() 属于票价/余票展示链,它内部硬编码的合法码集合 // ("9","P","M","O","6","4","F","3","2","1","W")跟下单链不是一回事。 // 实测证据(群友 v2.1.13 日志):传 'W' 服务端直接回「非法的席别,请重新选择!」。 // 2) 但 'WZ' 只在第一步有效。官方 getpassengerTicketsForAutoSubmit() 里,乘客席别是 WZ 时 // 会就地换成 aP(m())——m() 按固定优先级返回第一个"席别存在"(!= "--")的缩写: // 硬座YZ → 二等座ZE → 硬卧YW → 一等座ZY → 软座RZ → 软卧RW → 特等座TZ → 高级软卧GR → 商务座SWZ // aP() 再转成提交用席别码:1 / O / 3 / M / 2 / 4 / P / 6 / 9(无座 WZ 自己没有下单码)。 // (更早的 b1() 只有硬座/二等座两个分支: // if (seat_type == "WZ") { if (yz_num != "--") seat_type = "1" // else if (ze_num != "--") seat_type = "O" } // 两条规则方向一致,m() 是它的完整版。) // 这一步就是"明明有票却一直报余票不足"的真凶——v2.1.7~v2.1.12 全程传 'WZ', // getQueueCount 返回的 ticket 是空的,confirmSingleForQueue 必然回「余票不足!」。 // (v2.1.6 及以前全程传 '1':硬座真没票时就卡死在「硬座仅剩0张」,不会按官方规则退到 'O'。) // 3) 换码必须在 checkOrderInfo 之前完成,让"校验"和"下单"说的是同一个码。 // 换码函数见 wuzuoOrderSeatCode(),在 executeOrderSequence 里调。 // 候补那条线不一样:seatTypeForHB 里写的是 WZ:"1_无座",候补提交必须用 '1', // 所以上面的 SEAT_TYPE_CODE 一个字都不能动。 function orderSeatCode(seatName) { if (seatName === '无座') return 'WZ'; return SEAT_TYPE_CODE[seatName] || 'O'; } // 卧铺列的票种随车型变,同一个余票字段挂两种票,下单码不能照着字段名给。 // 12306 的余票字段只有 rw_num / yw_num 两个卧铺列,但它们各自能代表两种票: // 普通车 rw_num = 软卧(4) yw_num = 硬卧(3) // 动卧(D 字头) rw_num = 一等卧(I) yw_num = 二等卧(J) // 这三条都是 12306 自己的代码写的,不是推断: // 1) seatTypeCodeForName 的映射:I:"一等卧" J:"二等卧" "4":"软卧" "3":"硬卧" // 2) function U() 里直接写着 YW→"硬卧/二等卧"、RW→"软卧/一等卧"——官方自己承认一个缩写两种票 // 3) 票价那条链按前缀分码:RW_ 下 4→"软卧票价"、I→"一等卧票价";YW_ 下 3→"硬卧票价"、J→"二等卧票价" // 实测(2026-10-13):D17/D7/D35 的 seat_types=JOIO,只有 I 和 J;票价接口只回 AI(一等卧)/AJ(二等卧), // 不回 A4/A3——也就是说这几趟车根本没有软卧和硬卧,拿 '4' 去下单只会被服务端拒。 // seat_types(响应第 35 段)就是"这趟车实际有哪些席别码"的权威列表,所以按它挑码。 // 用户勾「软卧/一等卧」时不需要知道这趟车是哪种,脚本按实车说话。 function resolveOrderSeatCode(trainInfo, seatName) { const berthCodes = { '软卧': ['I', '4'], '一等卧': ['I', '4'], '硬卧': ['J', '3'], '二等卧': ['J', '3'] }; const want = berthCodes[seatName]; if (!want) return orderSeatCode(seatName); const st = String((trainInfo && trainInfo.seatTypes) || ''); for (const c of want) if (st.indexOf(c) !== -1) return c; // seat_types 拿不到(老版本响应、解析异常)时按字段名原本的含义给码, // 至少保持 v2.1.15 的行为,不会比现在更糟。 return want[want.length - 1]; } // 无座在 getQueueCount / confirmSingleForQueue 这两步必须换成硬座或二等座的码,规则见上。 // 判据首选查票结果的 tickets(对应官方用的 yz_num / ze_num),'--' 表示该车次根本没有这个席别。 // 查票数据拿不到时退回用下单页回来的余票串 leftTicketStr 探测:它每 10 个字符一组、首位是席别码, // 只按 10 字符步长读第 0 位,不会把数字里的 '1' 误当成硬座席别码。 function hasSeatType(stockStr) { return !!stockStr && stockStr !== '--'; } // 「有票」判据:'有' / '是' / 'Y' 或正整数算有票;'无'、'--'、'0'、空都算没票 function hasTicketStock(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; const num = parseInt(s0, 10); return !isNaN(num) && num > 0; } function leftTicketHasSeat(leftTicketStr, code) { const s = String(leftTicketStr || ''); if (!s || !code) return false; for (let i = 0; i + 10 <= s.length; i += 10) { if (s.charAt(i) === code) return true; } return false; } // 无座下单替代席别链(严格对齐 12306 官方 queryLeftTicket_end_js.js): // 官方 getpassengerTicketsForAutoSubmit() 里,乘客席别是 WZ 时会就地换成 aP(m()); // m() 按固定优先级返回第一个「席别存在」(!= "--")的缩写,aP() 再转成提交用席别码: // 硬座YZ→'1' 二等座ZE→'O' 硬卧YW→'3' 一等座ZY→'M' 软座RZ→'2' // 软卧RW→'4' 特等座TZ→'P' 高级软卧GR→'6' 商务座SWZ→'9' 无座WZ→'WZ' // 分三段找:① 先按「有票」找(抢票场景换到一个没票的席别等于白跑); // ② 都没票再按「席别存在」找——硬座显示无票、无座有票时走的正是这一档, // 无座票是挂在硬座车厢上卖的,用硬座码照样能买到无座; // ③ 查票数据没带上 tickets 时,退回用余票串探测同一优先级。 const WUZUO_SEAT_CHAIN = [ { name: '硬座', code: '1' }, { name: '二等座', code: 'O' }, { name: '硬卧', code: '3' }, { name: '一等座', code: 'M' }, { name: '软座', code: '2' }, { name: '软卧', code: '4' }, { name: '特等座', code: 'P' }, { name: '高级软卧', code: '6' }, { name: '商务座', code: '9' } ]; function wuzuoOrderSeatCode(trainInfo, leftTicketStr) { const t = (trainInfo && trainInfo.tickets) || null; for (const s of WUZUO_SEAT_CHAIN) { if (t && hasTicketStock(t[s.name])) { return { code: s.code, name: s.name, from: `查票结果-${s.name}有票` }; } } for (const s of WUZUO_SEAT_CHAIN) { if (t && hasSeatType(t[s.name])) { return { code: s.code, name: s.name, from: `查票结果-${s.name}席别存在` }; } } for (const s of WUZUO_SEAT_CHAIN) { if (leftTicketHasSeat(leftTicketStr, s.code)) { return { code: s.code, name: s.name, from: `余票串-${s.name}席别存在` }; } } return null; } // 中转查询接口返回的余票字段名(JSON 键),实测自 /lcquery/queryG 的 fullList // 卧铺同理:一等卧/二等卧和软卧/硬卧共用 rw_num / yw_num 两个字段(见 resolveOrderSeatCode), // 少了这两个别名,勾「软卧/一等卧」时 wantSeats 里那个匹配不上的名字会让整个方案被跳过。 const SEAT_NUM_FIELD = { '商务座': 'swz_num', '特等座': 'tz_num', '一等座': 'zy_num', '优选一等座': 'gg_num', '二等座': 'ze_num', '高级软卧': 'gr_num', '软卧': 'rw_num', '一等卧': 'rw_num', '硬卧': 'yw_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'; } // 下单接口(checkOrderInfo / getQueueCount / confirmSingleForQueue)要的是另一套值:普通 '00'、学生 '0X00' function orderPurposeCodesFor(passengers) { return passengers.some(p => p.isStudent === true) ? '0X00' : '00'; } // 目标日期偏移 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: '一等座' }, yy: { 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) { if (!Array.isArray(seats)) return ''; // 只放行 A/B/C/D/F:这个值是要发给 12306 的参数, // 万一 UI 或存档被改坏(比如冒出一个 "G100"),宁可不选座也绝不发畸形参数过去—— // 一个畸形的 choose_seats 足以让整单被判非法,比"不选座"严重得多。 return seats .filter(s => typeof s === 'string' && SEAT_PREF_CHOICES.indexOf(s) > -1) .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: '', // 是否接受同城其它车站。默认否:你填广州南,就只买广州南上车的车次。 // 背景(实测):12306 查「广州南→长沙南」会返回 275 趟车,其中 136 趟的出发站是 // 广州北/广州白云/广州/广州东/广州新塘——同城车站被一起返回了。这些车在广州南上不了车, // 买到了也只能退,所以默认严格;确实愿意自己换站过去坐的,才勾它。 acceptSameCity: false, 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'); const sameCityEl = document.getElementById('cfg-samecity'); 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.acceptSameCity = sameCityEl ? sameCityEl.checked : !!cfg.acceptSameCity; 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', timeout = QUERY_TIMEOUT_MS) { 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()}`, {}, timeout); // 接口被轮换时 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()}`, {}, timeout); } } 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, leftTicketStr = '', trainLocation = '', purposeCodes = '00') { const body = new URLSearchParams({ 'train_date': new Date(trainDate).toString(), 'train_no': trainNo, 'stationTrainCode': stationTrainCode, 'seatType': seatType, 'fromStationTelecode': fromStationTelecode, 'toStationTelecode': toStationTelecode, // leftTicketStr 必须用 checkOrderInfo 返回的服务端余票串(# 分段第 2 段), // 官方 b1() 就是这么取的。以前这里写死空串,12306 拿不到余票快照, // 回的 ticket 是空的,confirmSingleForQueue 就会被反复拒。 'leftTicket': leftTicketStr, 'purpose_codes': purposeCodes, 'train_location': trainLocation, '_json_att': '', 'REPEAT_SUBMIT_TOKEN': token }); return request('/otn/confirmPassenger/getQueueCount', { method: 'POST', body: body }); }, async confirmSingleForQueue(passengerTicketStr, oldPassengerStr, keyCheckIsChange, token, leftTicketStr, trainLocation, chooseSeats = '', purposeCodes = '00') { const body = new URLSearchParams({ 'passengerTicketStr': passengerTicketStr, 'oldPassengerStr': oldPassengerStr, 'purpose_codes': purposeCodes, '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] || '', // 这趟车实际有哪些席别码(官方 seat_types):如 "13461" = 硬座/硬卧/软卧/高级软卧/硬座, // "JOIO" = 二等卧/二等座/一等卧/二等座。卧铺下单码要靠它挑,见 resolveOrderSeatCode。 seatTypes: parts[35] || '', tickets: { '商务座': parts[32] || '', '特等座': parts[25] || '', '一等座': parts[31] || '', '二等座': parts[30] || '', '优选一等座': parts[20] || '', // rw_num / yw_num 这两个字段名只在普通车上名副其实;动卧上它们装的是一等卧/二等卧的票。 // 这里给同一段值挂两个名字,让 UI 的候选席别名(软卧/一等卧)都能命中同一个字段。 '软卧': parts[23] || '', '一等卧': parts[23] || '', '硬卧': parts[28] || '', '二等卧': 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: ['一等座'], yy: ['优选一等座'], ze: ['二等座'], gr: ['高级软卧'], rw: ['软卧', '一等卧'], yw: ['硬卧', '二等卧'], rz: ['软座'], yz: ['硬座'], wz: ['无座'] }; // 上面那两个卧铺名不是"两个并列席别",是同一段余票字段的两个票种名 //(官方 seat_types 用 I/J 表示卧铺票种,普通车则是 4/3)。这里标出谁是谁的别名, // 好让候补去重:一趟车只会是其中一种,不能两个都提交。 // 「商务座/特等座」不在这里——那是两个独立字段(swz_num 和 tz_num),各算各的。 const SEAT_NAME_ALIAS = { '一等卧': '软卧', '二等卧': '硬卧' }; 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); // 同一车次号可能有多条(12306 会把同城车站的车次一并返回,区间不同)。 // 所以先把它们都收起来,优先挑"可订且有票"的那条来解释——拿第一条硬说"无票", // 就会出现「可预订但无票 [二等座=有]」这种自相矛盾的提示(群友日志里撞上过)。 const same = []; for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (info && info.trainCode.toUpperCase() === code) same.push(info); } if (!same.length) return `${targetTrainCode} 不在本次查票结果中`; const detailOf = info => targetSeats.map(sc => { const names = getSeatNamesByCode(sc); return `${names.join('/')}=${names.map(n => info.tickets[n] || '--').join('/')}`; }).join(' '); const hasAny = info => targetSeats.some(sc => getSeatNamesByCode(sc).some(n => hasTicket(info.tickets[n]))); // ① 先看有没有"可订且有票"的那条:它才是真正该被解释的 const withTicket = same.find(info => info.canBuy === 'Y' && hasAny(info)); if (withTicket) { if (!inDepartRange(withTicket.startTime, departFrom, departTo)) { return `${withTicket.trainCode}(${withTicket.startTime}) 有票但发车时间不在筛选范围内`; } // 可订、时间对、票也有——那就只剩车站不符,被站点校验跳过了 return `${withTicket.trainCode} 这趟有票 [${detailOf(withTicket)}],但它的乘车区间是 ` + `${stationNameOf(withTicket.fromStation)}→${stationNameOf(withTicket.toStation)},` + `和你填的不是同一个站,按默认设置跳过了(要接受同城其它车站请勾面板上那个开关)`; } // ② 确实都没票,再按原有顺序说清是哪一类原因 const info = same[0]; if (info.canBuy !== 'Y') return `${info.trainCode} ${explainNotBuyable(info)}`; if (!inDepartRange(info.startTime, departFrom, departTo)) { return `${info.trainCode}(${info.startTime}) 发车时间不在筛选范围内`; } return `${info.trainCode} 可预订但无票 [${detailOf(info)}]`; } // 多个车次时的原因汇总。 // 实测教训:有群友填了 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, withTicket = 0; for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info || info.canBuy !== 'Y') continue; buyable++; if (!inDepartRange(info.startTime, departFrom, departTo)) continue; inRange++; // 顺便数一下"其实有票"的:它们多半是乘车区间不符、被站点校验跳过的。 // 这时候再说"所选席别都没有余票"就不对了(和 explainMiss 那个坑同一个根源) if (targetSeats.some(sc => getSeatNamesByCode(sc).some(n => hasTicket(info.tickets[n])))) withTicket++; } if (buyable === 0) return '所有车次都未到起售时间或当前不可预订'; if (inRange === 0) { return `没有任何车次的发车时间落在 ${departFrom || '00:00'}~${departTo || '24:00'} 内`; } if (withTicket > 0) { return `共 ${inRange} 趟可订车次,其中 ${withTicket} 趟你要的席别有票` + `(多半是乘车区间和你填的不一致,按默认设置跳过了)`; } 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) { if (combos.length >= maxCombos) break; // 每个"字段组"最多出一个组合。「软卧/一等卧」「硬卧/二等卧」是同一段余票字段 // 的两个票种名,一趟车只会是其中一种,由 seat_types 决定;说不清就取第一个有值的。 // 「商务座/特等座」是两个独立字段,会分成两组,各出一个。 const groups = []; for (const n of getSeatNamesByCode(seatCode)) { const canon = SEAT_NAME_ALIAS[n] || n; let g = groups.find(x => x.canon === canon); if (!g) { g = { canon: canon, names: [] }; groups.push(g); } g.names.push(n); } for (const g of groups) { if (combos.length >= maxCombos) break; const valid = g.names.filter(n => hasSeatType(info.tickets[n])); if (!valid.length) continue; const seatName = valid.find(n => info.seatTypes && info.seatTypes.indexOf(SEAT_TYPE_CODE[n]) !== -1) || valid[0]; 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: 'IZQ', to: 'CWQ', acceptSameCity: false },传"当前这个查询目标的起终站简码"。 // 为什么要有它:车次号本身不带区间,只看车次号没法证明命中的那趟车就是你要坐的那一段。 // 为什么默认严格:12306 会把同城车站的车次一并返回。实测查「广州南→长沙南」返回 275 趟, // 出发站分布为 广州南 139、广州北 52、广州白云 50、广州 25、广州新塘 4、广州东 3、增城 2, // 到达站里还有 46 趟是「长沙」而不是「长沙南」。这些车在用户填的那个站根本上不了车, // 买到也只能退——所以默认只认完全一致的区间,不再"退而求其次"。 // acceptSameCity 为 true 时才退回老行为(优先精确、没有就用同城其它站),并带上 mismatch 标记。 // stats:调用方传进来的计数对象({ skipCount, skipSample }),用来统计"有票但车站不符"被跳过的趟数。 // 为什么用参数传而不是模块级变量:中转模式是两段并发查票(Promise.all), // 共用一个模块级计数会把两段的数字混在一起,提示出来的区间甚至是另一段的。 findTargetTrain(resultList, targetTrainCode, targetSeats = ['二等座'], departFrom = '', departTo = '', stationExpect = null, stats = 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 acceptSameCity = !!(stationExpect && stationExpect.acceptSameCity); 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, // 各席别的票额串,无座下单时要靠它判断该用硬座码还是二等座码(见 wuzuoOrderSeatCode) tickets: info.tickets }; if (!checkStation) return hit; const okFrom = !wantFrom || info.fromStation === wantFrom; const okTo = !wantTo || info.toStation === wantTo; if (okFrom && okTo) return hit; // 车站对不上:默认跳过(你填哪个站,就是要从哪个站上车、在哪个站下车)。 // 只有勾了「接受同城其它车站」才留作兜底候选。跳过的记一笔,供日志解释为什么没抢。 if (stats) { stats.skipCount = (stats.skipCount || 0) + 1; if (!stats.skipSample) { stats.skipSample = { trainCode: info.trainCode, fromStation: info.fromStation, toStation: info.toStation, seatName: seatName, startTime: info.startTime }; } } if (acceptSameCity && !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 || '二等座'); // 注意:这里的席别码仍走 SEAT_TYPE_CODE(无座 = '1'),没有跟着普通下单改成 WZ。 // 普通下单用 WZ 是有论证的(orderSeatCode 的注释,官方自动提交那条线的席别标识), // 但联程的 dataStr 是另一套格式,"WZ 在联程里是否被接受"没有实测过—— // 没验证就不动,免得把刚跑通的联程又搞挂。已知限制:联程 + 无座不保证能下单。 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]; let keyCheckIsChange = keyMatch[1]; let leftTicketStr = leftTicketMatch[1]; let effTrainLocation = trainInfo.trainLocation || ''; // 诊断:这几个值一旦取偏(比如匹配到页面里别处的同名字段),12306 只会在最后一步 // 回一句笼统的「非法的订票请求!」,从报错上完全看不出原因。 // 这里只记长度不记内容,下次出问题一眼就能判断是不是解析取偏了。 tlog('log', `[OrderLogic] 下单页参数: token=${token.length}位 key=${keyCheckIsChange.length}位` + ` leftTicketStr=${leftTicketStr.length}位 train_location=${effTrainLocation || '(空!)'}`); if (!effTrainLocation) { tlog('warn', '[OrderLogic] train_location 为空——这会被 12306 判为非法请求,查票结果里该字段可能没解析到'); } let seatCode = resolveOrderSeatCode(trainInfo, trainInfo.seatName); // 卧铺的勾选名和实际票种可能不是一回事:D 字头动卧的 rw_num 装的是一等卧。 // 不说明白的话,用户看到订单页写着"一等卧"会以为脚本买错了。 const BERTH_NAME_OF_CODE = { '4': '软卧', 'I': '一等卧', '3': '硬卧', 'J': '二等卧' }; if (BERTH_NAME_OF_CODE[seatCode] && BERTH_NAME_OF_CODE[seatCode] !== trainInfo.seatName) { tlog('log', `[OrderLogic] ${trainInfo.trainCode} 勾的是「${trainInfo.seatName}」,` + `这趟车的卧铺票种是「${BERTH_NAME_OF_CODE[seatCode]}」(席别码 ${seatCode}),按实际票种下单`); } // ★ 关键一步:无座必须在**下单前**就换成硬座/二等座的码(官方 m()+aP() 规则,见 wuzuoOrderSeatCode)。 // 以前这一步放在 checkOrderInfo 之后,等于先拿 WZ 去问、再拿别的码去下单,两步说的不是一回事。 // 换码后乘客串里的席别字段也要用新码重建,否则提交上去的仍是 WZ,照样被拒。 const altCodes = []; if (trainInfo.seatName === '无座') { const swap = wuzuoOrderSeatCode(trainInfo, leftTicketStr); if (swap && swap.code !== seatCode) { seatCode = swap.code; tlog('log', `[OrderLogic] 无座按 12306 规则改用 ${swap.code}(${swap.name}) 下单(依据:${swap.from})`); } else if (!swap) { tlog('warn', '[OrderLogic] 拿不到硬座/二等座席别信息,无座只能按 WZ 提交' + '——若随后被拒「余票不足!」,就是这一步没能换成码'); } // 万一某个车次的服务端连换过的码都不认,按下单码再退一步;无座的替代码只有 1 和 O for (const c of ['1', 'O']) { if (c !== seatCode) altCodes.push(c); } } let { passengerTicketStr, oldPassengerStr } = buildPassengerStrings(passengers, seatCode); // 选座偏好:只有该车次、该席别确实支持时才带(判据来自下单页自身,见 resolveChooseSeats) const seatsToSubmit = resolveChooseSeats(htmlContent, chooseSeats, seatCode); if (chooseSeats && !seatsToSubmit) { tlog('log', `[OrderLogic] ${trainInfo.trainCode} ${trainInfo.seatName} 不支持选座,` + `已退回自动分配(原选座偏好 ${chooseSeats})`); } let checkRes = await NetworkModule.checkOrderInfo(passengerTicketStr, oldPassengerStr, 'dc', token); // 只有服务端明确在说"硬座/席别/座位"的问题时才退,别的错误(票没了、会话过期)照原样抛。 if (altCodes.length && (!checkRes.data || !checkRes.data.submitStatus) && /硬座|席别|座位/.test(String((checkRes.data && checkRes.data.errMsg) || ''))) { for (const fallback of altCodes) { tlog('warn', `[OrderLogic] 席别码 ${seatCode} 被拒(${(checkRes.data && checkRes.data.errMsg) || ''}),改试 '${fallback}'`); seatCode = fallback; ({ passengerTicketStr, oldPassengerStr } = buildPassengerStrings(passengers, seatCode)); checkRes = await NetworkModule.checkOrderInfo(passengerTicketStr, oldPassengerStr, 'dc', token); if (checkRes.data && checkRes.data.submitStatus) break; } } if (!checkRes.data || !checkRes.data.submitStatus) { throw new Error(`CheckOrderInfo failed: ${checkRes.data?.errMsg || 'Unknown'}`); } // ★ leftTicketStr / key_check_isChange / train_location 的权威来源是 checkOrderInfo // 的响应,不是下单页 HTML。官方 b1() 里写的就是: // location_code = dd.split("#")[0]; md5Str = dd.split("#")[1]; // leftTicketStr = dd.split("#")[2]; isAsync = dd.split("#")[3]; // 第 2 段是服务端给这次下单的余票串(正常 300 位以上),下单页里那个往往只有几十位。 // getQueueCount 拿不到正确余票串时回的 ticket 是空的,confirmSingleForQueue 必然被拒。 const ord = (checkRes.data && checkRes.data.result) || ''; if (ord) { const seg = String(ord).split('#'); if (seg[0]) effTrainLocation = seg[0]; if (seg[1]) keyCheckIsChange = seg[1]; tlog('log', `[OrderLogic] checkOrderInfo 响应分段: location=${seg[0] || '(空)'}` + ` key=${(seg[1] || '').length}位 leftTicketStr=${(seg[2] || '').length}位`); if (seg[2] && seg[2].length > leftTicketStr.length) leftTicketStr = seg[2]; } else { tlog('log', '[OrderLogic] checkOrderInfo 响应没有 result 分段,沿用下单页取到的 key/leftTicketStr'); } if (leftTicketStr.length < 100) { tlog('warn', `[OrderLogic] leftTicketStr 只有 ${leftTicketStr.length} 位——正常应在 300 位以上,` + 'getQueueCount 很可能拿不到票额快照'); } // trainDate 已归一化为 YYYY-MM-DD;补 T00:00:00 强制按本地时区解析,避免 UTC 解析把日期挪到前一天 const dateObj = new Date(`${trainInfo.trainDate}T00:00:00`); const orderPurpose = orderPurposeCodesFor(passengers); const queueRes = await NetworkModule.getQueueCount( dateObj, trainInfo.trainNo, trainInfo.trainCode, seatCode, trainInfo.fromStation, trainInfo.toStation, token, leftTicketStr, effTrainLocation, orderPurpose ); const qd = queueRes.data || {}; // ticket 是服务端对"这个席别还剩多少"的最终回答:它为空基本就等于下一步必被拒。 // 顺手把席别码打出来——下次群友贴日志,一眼就能看出是席别码没对上还是票真没了。 tlog('log', `[OrderLogic] Queue count: ${qd.countT} 席别码=${seatCode}` + ` ticket=${qd.ticket === undefined || qd.ticket === null || qd.ticket === '' ? '(空)' : String(qd.ticket).slice(0, 30)}`); const confirmRes = await NetworkModule.confirmSingleForQueue( passengerTicketStr, oldPassengerStr, keyCheckIsChange, token, leftTicketStr, effTrainLocation, seatsToSubmit, orderPurpose ); 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 { const errMsg = confirmRes.data?.errMsg || 'Unknown'; tlog('warn', `[OrderLogic] 下单被拒:席别码=${seatCode}(${trainInfo.seatName})` + ` countT=${qd.countT} 服务端=${errMsg}`); throw new Error(`Confirm failed: ${errMsg}`); } } 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 = `