// ==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 = `
`;
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);
})();