// ==UserScript== // @name 12306 抢票助手 Pro // @namespace http://tampermonkey.net/ // @icon https://raw.githubusercontent.com/shiyi312/12306-/main/12306helper.jpg // @version 2.0.7 // @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'; console.log('>>> 12306 抢票助手 Pro (v2.0.7) 已加载 <<<'); // ========================================== // 0. 常量与配置 // ========================================== const BASE_URL = 'https://kyfw.12306.cn'; // 12306 会不定期把 leftTicket/query 换成 queryZ/queryO 等;用错时响应里的 c_url 才是当前正确接口 let QUERY_URL = '/otn/leftTicket/query'; const SEAT_TYPE_CODE = { '商务座': '9', '特等座': 'P', '一等座': 'M', '二等座': 'O', '高级软卧': '6', '软卧': '4', '硬卧': '3', '软座': '2', '硬座': '1', '无座': '1' }; // 查票与下单的 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)); } // 把 "HH:MM:SS" 换算成本地时间戳,用服务器时钟定位该时刻。 // 不向后滚动到次日:与原行为一致,设定时间已过就是「立即开始」。 function saleTimestampLocally(hms) { const [h, m, s] = String(hms).split(':').map(Number); const t = new Date(serverNow()); t.setHours(h || 0, m || 0, s || 0, 0); return t.getTime() - (serverClockOffset || 0); } const SEAT_MAP_UI = { swz: { name: '商务/特等' }, zy: { name: '一等座' }, ze: { name: '二等座' }, gr: { name: '高级软卧' }, rw: { name: '软卧' }, yw: { name: '硬卧' }, rz: { name: '软座' }, yz: { name: '硬座' }, wz: { name: '无座' } }; let stationMap = {}; const DEFAULT_CONFIG = { targetTrains: ['G1234'], seatTypes: ['ze', 'zy'], passengers: [], // 仅存储姓名,学生票信息在 passengerDetails 中 passengerDetails: {}, // key: passenger_name, value: { isStudent: true/false, ... } trainDate: new Date().toISOString().split('T')[0], startTime: '', soundAlert: true, autoSubmit: true, fromStation: '上海', toStation: '杭州', fromCode: '', // 保存简码用于查票 toCode: '', altToStation: '', // 备选到达站(买长乘短),留空即关闭 altToCode: '', multiDate: false, // 主目标无票时是否也查目标日 ±1 天 alarmEnabled: true // 掉线/被限流时是否连续报警,默认开 }; // ========================================== // 配置持久化(增强 #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 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.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 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').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 { console.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]; } }); console.log(`站点简码表加载完成,共 ${Object.keys(stationMap).length} 个站点`); UIModule.log('站点简码表加载完成', 'success'); return true; } else { throw new Error('解析站点数据失败'); } } catch (e) { console.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; } // ========================================== // 1. NetworkModule (API 接口层) // ========================================== const NetworkModule = (() => { // 带超时的 fetch 封装(#8) async function request(url, options = {}, timeout = 15000) { const defaultOptions = { method: 'GET', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', 'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init', 'Host': 'kyfw.12306.cn', 'Origin': 'https://kyfw.12306.cn' }, }; const finalOptions = { ...defaultOptions, ...options }; if (options.headers) finalOptions.headers = { ...defaultOptions.headers, ...options.headers }; 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'); } console.error('[Network] Request failed:', error); 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()}`, {}, 10000); // 接口被轮换时 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) { console.log(`[Query] 查票接口已轮换: ${QUERY_URL} -> ${next}`); QUERY_URL = next; return request(`${QUERY_URL}?${params.toString()}`, {}, 10000); } } 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=' }); }, 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) { const body = new URLSearchParams({ 'passengerTicketStr': passengerTicketStr, 'oldPassengerStr': oldPassengerStr, 'purpose_codes': '00', 'key_check_isChange': keyCheckIsChange, 'leftTicketStr': leftTicketStr, 'train_location': trainLocation, 'choose_seats': '', '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], 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] || ['二等座']; } // 查不到票时给出可读原因,避免面板只重复刷「正在查票」让人以为脚本卡住 function explainMiss(resultList, targetTrainCode, targetSeats = ['二等座']) { if (!Array.isArray(resultList)) return '查票结果格式异常'; const code = String(targetTrainCode).toUpperCase(); for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info || info.trainCode.toUpperCase() !== code) continue; if (info.canBuy !== 'Y') { return info.canBuy === 'IS_TIME_NOT_BUY' ? `${info.trainCode} 未到起售时间` : `${info.trainCode} 当前不可预订(canWebBuy=${info.canBuy})`; } 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} 不在本次查票结果中`; } return { findTargetTrain(resultList, targetTrainCode, targetSeats = ['二等座']) { if (!resultList || !Array.isArray(resultList)) return null; for (const rawStr of resultList) { const info = parseTrainInfo(rawStr); if (!info) continue; if (info.trainCode.toUpperCase() === targetTrainCode.toUpperCase()) { if (info.canBuy !== 'Y') continue; for (const seatCode of targetSeats) { for (const seatName of getSeatNamesByCode(seatCode)) { if (!hasTicket(info.tickets[seatName])) continue; return { 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 }; } } } } return null; }, explainMiss }; })(); // ========================================== // 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*["']([^"']+)["']/; 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 { async executeOrderSequence(trainInfo, passengers) { console.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 的下单页;会话失效时会被重定向到很短的登录页 if (!htmlContent || htmlContent.length < 5000) { throw new Error(`会话已过期或下单页异常(仅返回 ${htmlContent ? htmlContent.length : 0} 字节),请重新登录后再试`); } const missing = [!tokenMatch && 'Token', !keyMatch && 'Key', !leftTicketMatch && 'LeftTicket'].filter(Boolean).join('/'); throw new Error(`下单页解析失败,缺少 ${missing},12306 可能已改版`); } const token = tokenMatch[1]; const keyCheckIsChange = keyMatch[1]; const leftTicketStr = leftTicketMatch[1]; const seatCode = SEAT_TYPE_CODE[trainInfo.seatName] || 'O'; const { passengerTicketStr, oldPassengerStr } = buildPassengerStrings(passengers, seatCode); 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 ); console.log(`[OrderLogic] Queue count: ${queueRes.data?.countT}`); const confirmRes = await NetworkModule.confirmSingleForQueue( passengerTicketStr, oldPassengerStr, keyCheckIsChange, token, leftTicketStr, trainInfo.trainLocation ); if (confirmRes.data && confirmRes.data.submitStatus) { console.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) { console.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) { console.log('声音播放失败:', e); } } function showNotification(title, body) { if (cfg.soundAlert) playBeep(); if ('Notification' in window) { if (Notification.permission === 'granted') { new Notification(title, { body, icon: 'https://kyfw.12306.cn/favicon.ico' }); } else if (Notification.permission === 'default') { Notification.requestPermission().then(perm => { if (perm === 'granted') new Notification(title, { body }); }); } } try { window.focus(); } catch(e){} } function setStatus(text, cls) { if (!statusEl) return; statusEl.textContent = text; statusEl.className = cls || ''; } function createPanel() { const oldPanel = document.getElementById('ticket-helper-panel'); if (oldPanel) oldPanel.remove(); const styleEl = document.createElement('style'); styleEl.textContent = STYLES; document.head.appendChild(styleEl); // 声音开关复选框 const soundChecked = cfg.soundAlert ? 'checked' : ''; const panel = document.createElement('div'); panel.id = 'ticket-helper-panel'; panel.innerHTML = `
🚄 12306 抢票助手 Pro
${Object.entries(SEAT_MAP_UI).map(([k, v]) => ` `).join('')}
还没有加载乘客
● 未开始
面板就绪,请先加载乘车人。` + (location.pathname === '/' || location.pathname === '/index.html' ? '  当前处于主页,请前往【车票】-【单程】查询页开始抢票。' : '') + `
`; document.body.appendChild(panel); statusEl = document.getElementById('grab-status'); startBtn = document.getElementById('th-action-btn'); logContainer = document.getElementById('th-logs'); bindEvents(); makeDraggable(panel); 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); // 席别选中状态类,兼容不支持 :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(); } 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; } } 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); }); }); // 恢复选中状态 list.forEach(p => { const cb = container.querySelector(`.th-p-check[value="${p.passenger_name}"]`); 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(); 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 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 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 selectedPassengers = []; document.querySelectorAll('#th-passenger-list .th-p-check:checked').forEach(checkbox => { let passengerData; try { passengerData = JSON.parse(checkbox.dataset.full); } catch (e) { console.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); }); // 更新 cfg cfg.targetTrains = trains; cfg.seatTypes = seatList; 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.alarmEnabled = alarmEnabled; 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, trainCodes: trains, seatTypes: seatList, passengers: selectedPassengers, startTime }; } async function start() { try { // 必须在用户这次点击的手势里解锁音频上下文,否则浏览器会挂起它, // 之后保活失败时就没法从后台把报警声响起来。 //(后续 refreshPassengers 有 await,会丢掉这次手势。) ensureAudioContext(); // 冽红反馈:乘车人在手机端被增删后,面板里的旧列表会导致下单失败。 // 抢票开始前强制刷新一次,拿不到就沿用面板上现有的列表。 const refreshed = await refreshPassengers(); if (!refreshed) log('乘车人刷新失败,将沿用面板上当前的列表', 'warn'); const config = getConfig(); if (config.trainCodes.length === 0) return log('请输入目标车次', 'warn'); if (config.passengers.length === 0) return 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'); if (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 撑爆 let lastLogMsg = ''; let lastLogRepeat = 0; function log(msg, type = 'info') { if (!logContainer) return; if (msg === lastLogMsg) { lastLogRepeat++; if (lastLogRepeat % LOG_REPEAT_EVERY !== 0) return; } else { lastLogMsg = msg; lastLogRepeat = 1; } const entry = document.createElement('div'); entry.className = `th-log-entry th-log-${type}`; const time = new Date().toLocaleTimeString(); entry.textContent = `[${time}] ${msg}`; logContainer.appendChild(entry); while (logContainer.children.length > MAX_LOG_ENTRIES) { logContainer.removeChild(logContainer.children[0]); } logContainer.scrollTop = logContainer.scrollHeight; } 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, start: start, stop: stop }; })(); // ========================================== // 5. Main Logic (核心业务逻辑) // ========================================== let checkTimer = null; let isChecking = false; let countdownInterval = null; let countdownTimer = null; let keepAliveTimer = null; let saleTimeStr = null; // 面板设定的起售时刻 "HH:MM:SS",null 表示未设定时 const PREHEAT_MS = 1500; // 提前进入轮询,让请求的网络往返正好覆盖住放票瞬间 // 距起售还有多少毫秒(负值表示已过);按需计算,这样后续采样到的更准的服务器时间会自动生效 function msToSale() { return saleTimeStr === null ? null : saleTimestampLocally(saleTimeStr) - Date.now(); } // 各档轮询间隔(毫秒)。平时克制避免触发风控,临近起售升温,起售窗口内最快,窗口过后回落。 const POLL_PROFILE = { idle: [1000, 1500], // 距起售 > 60s,或未设定时抢票 warmup: [500, 800], // 起售前 60s ~ 5s hot: [250, 400], // 起售窗口 T-5s ~ T+15s cool: [800, 1200] // 起售窗口结束后回落 }; 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 > 5000) return randBetween(profile.warmup); if (msToSale > -15000) return randBetween(profile.hot); return randBetween(profile.cool); } function isHotWindow(msToSale) { return msToSale !== null && msToSale <= 5000 && msToSale > -15000; } // 会话失效时 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; console.log('[KeepAlive] checkUser ok'); return true; } // 单次失败可能只是网络抖动,连续两次才告警,避免误报 keepAliveFailures++; console.warn(`[KeepAlive] 第 ${keepAliveFailures} 次检测到未登录`); if (keepAliveFailures >= 2) { UIModule.log('⚠️ 保活检测到登录已失效,请尽快重新登录,否则开抢时会失败', 'error'); UIModule.setStatus('● 登录已失效', 'grab-status-stopped'); UIModule.showNotification('登录已失效', '请重新登录 12306,否则抢票会失败'); document.title = TITLE_WARN_PREFIX + document.title; // 登录掉了必须立刻把人叫回来,而且一直叫到人回到这个页面为止。 // 报警走音频线程,不受后台限流影响,所以切走了也听得到。 startAlarm(); } return false; } catch (e) { console.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) { console.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 }; console.log('[Alarm] 连续报警已启动'); } catch (e) { console.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) { /* 已经停了就算了 */ } console.log('[Alarm] 报警已停止'); } // 报警只在「现在速度真的要紧」时才响:没设定时说明在手动抢,随时都重要; // 设了定时则只在起售前后 5 分钟内重要,免得提前两小时就把人吵疯。 function throttleMattersNow() { const rem = msToSale(); if (rem === null) return true; return rem < 300000 && rem > -300000; } 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; // 只有现在速度真的要紧才拉响连续报警,免得提前两小时就把人吵疯 if (throttleMattersNow()) startAlarm(); } 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; if (drift > THROTTLE_DRIFT_MS) { throttleStrikes++; // 被限流时漂移通常就是几十秒,这种幅度一次就能定性,等第二轮会白白晚一分钟 if (drift > THROTTLE_CERTAIN_MS || throttleStrikes >= THROTTLE_STRIKES) { warnThrottled(`定时器被拉长到约 ${Math.round(drift / 1000)} 秒一次`); } } else { clearThrottleWarning(); } heartbeatTimer = setTimeout(throttleBeat, 1000); } function startThrottleWatch() { if (heartbeatTimer) return; throttleStrikes = 0; lastBeatAt = Date.now(); heartbeatTimer = setTimeout(throttleBeat, 1000); } function stopThrottleWatch() { if (heartbeatTimer) { clearTimeout(heartbeatTimer); heartbeatTimer = null; } clearThrottleWarning(); } // 切到后台就告警:心跳要等下一轮才判定,太慢,而 visibilitychange 立刻就知道。 document.addEventListener('visibilitychange', () => { if (document.hidden) { if (UIModule.getIsRunning()) warnThrottled('标签页已切到后台'); } else { // 人回来了,报警立刻停——这就是「切回页面报警声消失」。 // 放在这里还有个好处:切回来一定会触发,不受任何限流影响。 stopAlarm(); lastBeatAt = Date.now(); throttleStrikes = 0; clearThrottleWarning(); } }); // 启动任务(包含定时等待逻辑) async function startTask(config) { if (isChecking) return; const { startTime } = config; // 定时等待阶段与实际查票阶段都要保活 startKeepAlive(); // 盯着浏览器有没有把定时器掐掉 startThrottleWatch(); // 如果设置了定时,先等待 if (startTime) { saleTimeStr = startTime; const rem0 = msToSale(); if (rem0 !== null && rem0 > 0) { isChecking = true; UIModule.setStatus('● 等待开抢', 'grab-status-waiting'); UIModule.log(`定时抢票: ${startTime}${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'); }; // 1 秒心跳:既显示倒计时,也作为精确计时器失准时的兜底触发 countdownInterval = setInterval(() => { const rem = msToSale(); if (rem === null) return; if (rem <= PREHEAT_MS) { fire(); return; } 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 } = config; const purposeCodes = purposeCodesFor(passengers); const targets = buildQueryTargets(config); const altTargets = targets.slice(1); // 主目标每轮都查,其余轮转,请求数不随目标数膨胀 let altCursor = 0; let pollCount = 0; UIModule.log(`目标: ${trainCodes.join(',')} ${config.fromName || fromStation}→${config.toName || toStation} [${targets.map(t => `${t.tag} ${t.date}`).join(' | ')}]`, '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; } // 查一个目标;命中返回 train,未命中返回可读原因 const checkOne = async (t) => { try { const queryRes = await NetworkModule.queryTickets(t.date, t.fromCode, t.toCode, purposeCodes); // 会话失效时 12306 会返回提示登录的 status:false,确认后再停,避免误杀 if (looksLoggedOut(queryRes)) { const stillLogged = await NetworkModule.checkLoginStatus(); if (!stillLogged) return { loggedOut: true, target: t }; } if (!queryRes.status || !queryRes.data?.result) { return { error: '查票接口异常', target: t }; } for (const code of trainCodes) { const train = TicketLogicModule.findTargetTrain(queryRes.data.result, code, seatTypes); if (train) return { train: train, target: t }; } return { miss: trainCodes.map(c => TicketLogicModule.explainMiss(queryRes.data.result, c, seatTypes)).join(';'), target: t }; } catch (e) { return { error: e.message, target: t }; } }; // 自调度轮询:每轮结束才排下一轮,天然避免请求重叠;间隔按距起售时间自适应 const scheduleNextCheck = () => { if (!UIModule.getIsRunning()) return; checkTimer = setTimeout(doCheck, pollDelayMs(msToSale())); }; // 起售窗口内再补一路错峰请求:第一路可能刚好落在放票瞬间之前 const staggeredCheck = async (t) => { await new Promise(r => setTimeout(r, HOT_STAGGER_MS)); return checkOne(t); }; 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 { 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; if (!hit && results.some(r => r.loggedOut)) { stopWithMessage('⚠️ 登录已失效,停止抢票,请重新登录后再开始'); return; } if (targetTrain) { 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'); if (checkTimer) { clearTimeout(checkTimer); checkTimer = null; } isChecking = false; UIModule.setStatus('● 发现余票!', 'grab-status-found'); UIModule.playBeep(); const orderResult = await OrderLogicModule.executeOrderSequence(targetTrain, passengers); if (orderResult.success) { 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'); // #4 立即重查(延迟500ms避免过快) UIModule.log('立即重新查票...', 'warn'); isChecking = false; setTimeout(() => { if (UIModule.getIsRunning()) { executeTask(config); } }, 500); } 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; GM_setValue('grab_running', false); UIModule.log('已停止刷票', 'warn'); } // ========================================== // 初始化 // ========================================== setTimeout(async () => { await fetchStationMap(3); // #8 重试3次 UIModule.init(startTask, stopTask); // 如果之前有运行标记,自动启动(在面板内已处理) }, 1000); })();