// ==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 = `