// ==UserScript== // @name 12306 抢票助手 Pro // @namespace http://tampermonkey.net/ // @icon https://raw.githubusercontent.com/shiyi312/12306-/main/12306helper.jpg // @version 2.0.0 // @description 自动查票、下单,成功后自动跳转支付页。支持多车次、多席别、声音提醒、配置自动保存。 // @author kl2 & wsz // @match *://kyfw.12306.cn/otn/leftTicket/init* // @match *://kyfw.12306.cn/otn/confirmPassenger/initDc* // @match *://kyfw.12306.cn/otn//leftTicket/init* // @grant GM_notification // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant window.focus // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; console.log('>>> 12306 抢票助手 Pro (v2.0.0) 已加载 <<<'); // ========================================== // 0. Configuration (配置与持久化) // ========================================== let stationMap = {}; const DEFAULT_CONFIG = { targetTrains: ['G1234'], // 目标车次 seatTypes: ['ze', 'zy'], // 目标席位 (ze=二等座, zy=一等座) passengers: [], // 乘客姓名 trainDate: new Date().toISOString().split('T')[0], // 出发日期 startTime: '', // 定时抢票时间 refreshInterval: 4500, // 刷新间隔 (4.5秒) autoSubmit: true, // 自动提交 soundAlert: true, // 声音提醒 fromStation: '上海', // 出发站中文名 toStation: '杭州' // 到达站中文名 }; // 加载和保存配置 function loadConfig() { const saved = GM_getValue('grab_config', null); return saved ? Object.assign({}, DEFAULT_CONFIG, saved) : DEFAULT_CONFIG; } function saveConfig(cfg) { GM_setValue('grab_config', cfg); } let cfg = loadConfig(); // 全局配置 // ========================================== // 站点简码映射 (支持模糊匹配) // ========================================== async function fetchStationMap() { try { console.log('正在获取站点简码表...'); const response = await fetch('https://kyfw.12306.cn/otn/resources/js/framework/station_name.js'); 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} 个站点`); log('站点简码表加载完成', 'success'); } } catch (e) { console.error('获取站点简码表失败:', e); log('获取站点简码表失败,请检查网络', 'error'); } } // ========================================== // 1. NetworkModule (API 接口层 - 保持不变) // ========================================== const NetworkModule = (() => { const BASE_URL = 'https://kyfw.12306.cn'; const QUERY_URL = '/otn/leftTicket/query'; async function request(url, options = {}) { 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}`; try { const response = await fetch(fullUrl, finalOptions); 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) { console.error('[Network] Request failed:', error); throw error; } } return { async checkLoginStatus() { try { const data = await request('/otn/login/checkUser', { method: 'POST', body: '_json_att=' }); return data && data.data && data.data.flag === true; } catch (e) { return false; } }, 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 }); return request(`${QUERY_URL}?${params.toString()}`); }, async submitOrderRequest(secretStr, trainDate, backTrainDate, fromStationName, toStationName) { const body = new URLSearchParams({ 'secretStr': decodeURIComponent(secretStr), 'train_date': trainDate, 'back_train_date': backTrainDate, 'tour_flag': 'dc', 'purpose_codes': 'ADULT', 'query_from_station_name': fromStationName, 'query_to_station_name': toStationName, 'undefined': '' }); return request('/otn/leftTicket/submitOrderRequest', { method: 'POST', body: body }); }, async getInitDcPage() { const response = await fetch(`${BASE_URL}/otn/confirmPassenger/initDc`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: '_json_att=' }); return await response.text(); }, 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 = (() => { const SEAT_TYPE_MAP = { '商务座': '9', '特等座': 'P', '一等座': 'M', '二等座': 'O', '高级软卧': '6', '软卧': '4', '硬卧': '3', '硬座': '1', '无座': '1' }; function parseTrainInfo(rawString) { if (!rawString) return null; const parts = rawString.split('|'); if (parts.length < 30) 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: parts[13], trainLocation: parts[15], tickets: { '商务座': parts[32] || '', '一等座': parts[31] || '', '二等座': parts[30] || '', '软卧': parts[23] || '', '硬卧': parts[28] || '', '硬座': parts[29] || '', '无座': parts[26] || '' }, raw: rawString }; } function hasTicket(stockStr) { if (!stockStr) return false; if (stockStr === '有') return true; if (stockStr === '无') return false; const num = parseInt(stockStr, 10); return !isNaN(num) && num > 0; } // 简码转中文席位名称 function getSeatNameByCode(code) { const map = { swz: '商务座', zy: '一等座', ze: '二等座', gr: '高级软卧', rw: '软卧', yw: '硬卧', rz: '软座', yz: '硬座', wz: '无座' }; return map[code] || '二等座'; } 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) { const seatName = getSeatNameByCode(seatCode); const stock = info.tickets[seatName]; if (hasTicket(stock)) { 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; } }; })(); // ========================================== // 3. OrderLogicModule (下单逻辑层 - 保持不变) // ========================================== const OrderLogicModule = (() => { const REGEX_TOKEN = /globalRepeatSubmitToken\s*=\s*'(\w+)'/; const REGEX_KEY_CHECK = /'key_check_isChange':'(\w+)'/; const REGEX_LEFT_TICKET = /'leftTicketStr'\s*:\s*'([^']+)'/; const SEAT_TYPE_CODE = { '商务座': '9', '特等座': 'P', '一等座': 'M', '二等座': 'O', '高级软卧': '6', '软卧': '4', '硬卧': '3', '硬座': '1', '无座': '1' }; const TICKET_TYPE_CODE = { '成人': '1', '儿童': '2', '学生': '3', '残军': '4' }; function buildPassengerStrings(passengers, seatCode) { let passengerTicketList = [], oldPassengerList = []; passengers.forEach(p => { const isStudent = String(p.passenger_type) === '3' || p.passenger_type_name === '学生'; const ticketType = isStudent ? (p.isStudentTicket ? '3' : '1') : (p.passenger_type || TICKET_TYPE_CODE[p.passenger_type_name] || '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); 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) throw new Error('Failed to parse Token/Key/LeftTicket'); const token = tokenMatch[1], keyCheckIsChange = keyMatch[1], 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'}`); const dateStr = trainInfo.trainDate; const dateObj = new Date(`${dateStr.substring(0,4)}-${dateStr.substring(4,6)}-${dateStr.substring(6,8)}`); await NetworkModule.getQueueCount(dateObj, trainInfo.trainNo, trainInfo.trainCode, seatCode, trainInfo.fromStation, trainInfo.toStation, token); 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 }; } } }; })(); // ========================================== // 4. UIModule (全新 UI 面板 + 配置持久化) // ========================================== let checkInterval = null, countdownInterval = null, isChecking = false; let statusEl = null, logContainer = null, startBtn = null, stopBtn = null; // 席别映射(用于 UI 显示) const SEAT_MAP_UI = { swz: { name: '商务/特等' }, zy: { name: '一等座' }, ze: { name: '二等座' }, gr: { name: '高级软卧' }, rw: { name: '软卧' }, yw: { name: '硬卧' }, rz: { name: '软座' }, yz: { name: '硬座' }, wz: { name: '无座' } }; // UI 样式 (融合优化) GM_addStyle(` #grab-panel { position: fixed; top: 10px; right: 10px; z-index: 99999; width: 340px; background: linear-gradient(135deg,#1a1a2e 0%,#16213e 100%); color: #fff; border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,.4); font-family: "Microsoft YaHei", sans-serif; font-size: 13px; border: 1px solid #e74c3c; overflow: hidden; } #grab-panel.minimized { width: 120px; height: 40px; } #grab-panel.minimized .grab-body { display: none; } #grab-panel .grab-header { background: #e74c3c; padding: 8px 12px; cursor: move; display: flex; justify-content: space-between; align-items: center; font-weight: bold; font-size: 14px; } #grab-panel .grab-header .grab-toggle { cursor: pointer; padding: 0 6px; } #grab-panel .grab-body { padding: 10px 12px; } #grab-panel label { display: block; margin: 6px 0 2px; color: #aaa; font-size: 11px; } #grab-panel input, #grab-panel select, #grab-panel textarea { width: 100%; padding: 5px 7px; border: 1px solid #444; border-radius: 4px; background: #0f3460; color: #fff; font-size: 12px; box-sizing: border-box; outline: none; } #grab-panel input:focus, #grab-panel select:focus { border-color: #e74c3c; } #grab-panel .grab-row { display: flex; gap: 6px; } #grab-panel .grab-row > * { flex: 1; } #grab-panel .grab-btns { display: flex; gap: 6px; margin-top: 10px; } #grab-panel button { flex: 1; padding: 7px 0; border: none; border-radius: 5px; font-size: 13px; font-weight: bold; cursor: pointer; transition: .2s; color: #fff; } #grab-panel .btn-start { background: #27ae60; } #grab-panel .btn-start:hover { background: #2ecc71; } #grab-panel .btn-stop { background: #e74c3c; } #grab-panel .btn-stop:hover { background: #c0392b; } #grab-panel .btn-test { background: #3498db; } #grab-panel .btn-test:hover { background: #2980b9; } #grab-panel button:disabled { opacity: .5; cursor: not-allowed; } #grab-panel #grab-log { margin-top: 8px; height: 150px; overflow-y: auto; background: #0a0a1a; border-radius: 4px; padding: 6px; font-family: Consolas, monospace; border: 1px solid #333; } #grab-panel #grab-status { margin-top: 6px; padding: 4px 8px; border-radius: 4px; text-align: center; font-weight: bold; font-size: 12px; background: #2c3e50; } #grab-panel .grab-status-running { background: #27ae60 !important; animation: pulse 1.5s infinite; } #grab-panel .grab-status-waiting { background: #f39c12 !important; } #grab-panel .grab-status-stopped { background: #7f8c8d !important; } #grab-panel .grab-status-found { background: #e74c3c !important; animation: pulse 0.5s infinite; } @keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.6; } } #grab-panel .seat-checkboxes { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 3px; } #grab-panel .seat-checkboxes label { display: inline-flex; align-items: center; margin: 0; padding: 2px 6px; background: #0f3460; border-radius: 3px; cursor: pointer; font-size: 11px; color: #ddd; } #grab-panel .seat-checkboxes input { width: auto; margin-right: 3px; } #grab-panel .grab-count { font-size: 11px; color: #f1c40f; margin-top: 4px; } `); // 声音提示 function playBeep() { try { const ctx = new (window.AudioContext || window.webkitAudioContext)(); const beep = (freq, start, dur) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); osc.connect(gain); gain.connect(ctx.destination); osc.frequency.value = freq; osc.type = 'sine'; gain.gain.setValueAtTime(0.3, ctx.currentTime + start); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + start + dur); osc.start(ctx.currentTime + start); osc.stop(ctx.currentTime + start + dur); }; beep(880, 0, 0.15); beep(880, 0.25, 0.15); beep(880, 0.5, 0.15); beep(1100, 0.75, 0.3); } catch (e) { console.warn('声音提醒失败:', e); } } function showNotification(title, body) { if (cfg.soundAlert) playBeep(); if (cfg.desktopNotify && 'Notification' in window) { if (Notification.permission === 'granted') { GM_notification({ title, text: body, highlight: true }); } else if (Notification.permission !== 'denied') { Notification.requestPermission().then(p => { if (p === 'granted') GM_notification({ title, text: body, highlight: true }); }); } } try { window.focus(); } catch(e){} } function log(msg, type = 'info') { const colors = { info: '#3498db', success: '#27ae60', warn: '#f39c12', error: '#e74c3c', grab: '#e74c3c' }; if (logContainer) { const line = document.createElement('div'); line.style.cssText = `color:${colors[type] || '#fff'};font-size:12px;line-height:1.6;`; line.innerHTML = `[${new Date().toTimeString().slice(0,8)}] ${msg}`; logContainer.appendChild(line); logContainer.scrollTop = logContainer.scrollHeight; } console.log(`[12306]`, msg); } function setStatus(text, cls) { if (!statusEl) return; statusEl.textContent = text; statusEl.className = cls || ''; } function createPanel() { if (document.getElementById('grab-panel')) return; const panel = document.createElement('div'); panel.id = 'grab-panel'; panel.innerHTML = `
🚄 12306 抢票助手 Pro
${Object.entries(SEAT_MAP_UI).map(([k, v]) => ` `).join('')}
● 未开始
`; document.body.appendChild(panel); // 绑定 UI 事件 $('#grab-toggle').onclick = () => panel.classList.toggle('minimized'); statusEl = $('#grab-status'); logContainer = $('#grab-log-content'); startBtn = $('#btn-start'); stopBtn = $('#btn-stop'); // 拖拽功能 const header = $('#grab-header'); let offX = 0, offY = 0, dragging = false; header.addEventListener('mousedown', (e) => { if (e.target.id === 'grab-toggle') return; dragging = true; const rect = panel.getBoundingClientRect(); offX = e.clientX - rect.left; offY = e.clientY - rect.top; panel.style.right = 'auto'; }); document.addEventListener('mousemove', (e) => { if (!dragging) return; panel.style.left = Math.max(0, e.clientX - offX) + 'px'; panel.style.top = Math.max(0, e.clientY - offY) + 'px'; }); document.addEventListener('mouseup', () => dragging = false); // 按钮绑定 $('#btn-start').onclick = () => startGrabbing(); $('#btn-stop').onclick = () => stopGrabbing(); $('#btn-test').onclick = () => { playBeep(); log('🔔 测试提醒音已播放', 'info'); }; log('✅ 面板就绪,请确认设置后点击开始抢票', 'success'); } // 读取面板配置并保存 function readConfigFromPanel() { const trains = $('#cfg-trains').value.trim().toUpperCase().split(/[,,\s]+/).filter(Boolean); const seats = document.querySelectorAll('.cfg-seat:checked'); const seatList = Array.from(seats).map(cb => cb.value); const passengers = $('#cfg-passengers').value.trim().split(/[,,\s]+/).filter(Boolean); // 站点简码模糊匹配 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)) { console.log(`[模糊匹配] ${input} -> ${name}`); return stationMap[name]; } } return null; }; const fromStation = $('#cfg-from').value.trim() || cfg.fromStation; const toStation = $('#cfg-to').value.trim() || cfg.toStation; const from = findStationCode(fromStation); const to = findStationCode(toStation); if (!from || !to) { log(`❌ 站点简码转换失败: ${fromStation} / ${toStation}`, 'error'); throw new Error('站点简码无效'); } const newCfg = { targetTrains: trains.length ? trains : DEFAULT_CONFIG.targetTrains, seatTypes: seatList.length ? seatList : DEFAULT_CONFIG.seatTypes, passengers: passengers, trainDate: $('#cfg-date').value || DEFAULT_CONFIG.trainDate, startTime: $('#cfg-time').value || '', refreshInterval: Math.max(1500, parseInt($('#cfg-interval').value) || 4500), autoSubmit: $('#cfg-autosubmit').checked, soundAlert: $('#cfg-sound').checked, fromStation: fromStation, toStation: toStation, fromCode: from, toCode: to }; cfg = newCfg; saveConfig(cfg); return { ...newCfg, fromStation: from, toStation: to }; } // ========================================== // 5. Main Logic (核心业务逻辑 - 保持不变) // ========================================== async function startGrabbing() { try { const config = readConfigFromPanel(); if (config.targetTrains.length === 0) return log('⚠️ 请输入目标车次', 'warn'); if (config.passengers.length === 0) return log('⚠️ 请选择至少一位乘车人', 'warn'); startBtn.disabled = true; stopBtn.disabled = false; setStatus('● 正在启动...', 'grab-status-running'); log('🚀 开始抢票任务!', 'success'); if (Notification.permission === 'default') Notification.requestPermission(); startTask(config); } catch (e) { log(`❌ 配置错误: ${e.message}`, 'error'); setStatus('● 配置错误', 'grab-status-stopped'); } } function stopGrabbing() { if (checkInterval) { clearInterval(checkInterval); checkInterval = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } isChecking = false; startBtn.disabled = false; stopBtn.disabled = true; setStatus('● 已停止', 'grab-status-stopped'); log('⏸ 已停止抢票', 'warn'); } async function keepAliveDelay() { return Math.floor(Math.random() * (60000 - 30000 + 1)) + 30000; } async function sendKeepAliveRequest(config) { try { await NetworkModule.queryTickets(config.trainDate, config.fromStation, config.toStation); console.log('[KeepAlive] Silent query'); } catch (e) { console.error('[KeepAlive] Error:', e); const logged = await NetworkModule.checkLoginStatus(); if (!logged) log('⚠️ 保活发现未登录!', 'error'); } } async function startTask(config) { if (isChecking) return; const { trainDate, fromStation, toStation, trainCodes, seatTypes, passengers, startTime } = config; if (startTime) { const now = new Date(); const [h, m, s] = startTime.split(':').map(Number); const targetTime = new Date(); targetTime.setHours(h, m, s || 0, 0); if (targetTime > now) { isChecking = true; setStatus('● 等待开抢...', 'grab-status-waiting'); log(`⏰ 定时抢票: ${startTime},等待中...`, 'info'); let nextKeepAliveTime = Date.now() + await keepAliveDelay(); countdownInterval = setInterval(async () => { if (!startBtn.disabled) { clearInterval(countdownInterval); isChecking = false; return; } const diff = targetTime - new Date(); if (diff <= 0) { clearInterval(countdownInterval); setStatus('● 时间到,开始抢票', 'grab-status-running'); log('⏰ 时间到,开始抢票!', 'success'); isChecking = false; executeTask(config); } else { if (diff > 120000 && Date.now() >= nextKeepAliveTime) { await sendKeepAliveRequest(config); nextKeepAliveTime = Date.now() + await keepAliveDelay(); } if (diff < 10000 || diff % 10000 < 1000) { const seconds = Math.floor(diff / 1000); log(`⏳ 倒计时: ${Math.floor(seconds/60)}分${seconds%60}秒`, 'info'); } } }, 1000); return; } else { log('设定时间已过,立即开始', 'warn'); } } executeTask(config); } async function executeTask(config) { if (isChecking) return; isChecking = true; const { trainDate, fromStation, toStation, trainCodes, seatTypes, passengers } = config; log(`目标: ${trainDate} ${fromStation}→${toStation} [${trainCodes.join(',')}]`, 'info'); try { const logged = await NetworkModule.checkLoginStatus(); if (!logged) { log('❌ 未登录,请先登录!', 'error'); isChecking = false; stopGrabbing(); return; } } catch (e) { log('检查登录状态异常', 'error'); } const doCheck = async () => { if (!startBtn.disabled) { clearInterval(checkInterval); isChecking = false; return; } try { log('正在查票...', 'info'); const queryRes = await NetworkModule.queryTickets(trainDate, fromStation, toStation); if (!queryRes.status || !queryRes.data?.result) return; let targetTrain = null; for (const code of trainCodes) { const train = TicketLogicModule.findTargetTrain(queryRes.data.result, code, seatTypes); if (train) { targetTrain = train; break; } } if (targetTrain) { log(`🎉 有票: ${targetTrain.trainCode} ${targetTrain.seatName}`, 'grab'); clearInterval(checkInterval); isChecking = false; setStatus('● 发现余票!', 'grab-status-found'); const orderResult = await OrderLogicModule.executeOrderSequence(targetTrain, passengers); if (orderResult.success) { log('✅ 下单成功!5秒后自动跳转支付页...', 'success'); showNotification('抢票成功', `车次 ${targetTrain.trainCode},请及时支付`); let payUrl = 'https://kyfw.12306.cn/otn/pay/init'; if (orderResult.orderId) payUrl += `?orderId=${orderResult.orderId}`; setTimeout(() => { window.location.href = payUrl; }, 5000); } else { log(`❌ 下单失败: ${orderResult.error}`, 'error'); if (orderResult.error.includes('验证码') || orderResult.error.includes('captcha')) { log('⚠️ 可能需要验证码,暂不支持自动处理,请手动操作', 'warn'); stopGrabbing(); return; } log('3秒后自动重试...', 'warn'); setTimeout(() => { if (!startBtn.disabled) { isChecking = false; executeTask(config); } }, 3000); } } } catch (e) { log(`查票出错: ${e.message}`, 'error'); } }; doCheck(); const randomInterval = Math.floor(Math.random() * 2000) + 4000; checkInterval = setInterval(doCheck, randomInterval); } // ========================================== // 6. 入口 Init // ========================================== function init() { createPanel(); // 统一加载面板 fetchStationMap(); // 加载站点地图 // 如果在确认订单页面,提供兜底提交 if (location.href.includes('confirmPassenger/initDc')) { log('📍 当前在订单确认页,等待自动操作...', 'info'); if (cfg.autoSubmit) { setTimeout(() => { const submitBtn = document.getElementById('submitOrder_id') || document.getElementById('submitOrder'); if (submitBtn) { log('📤 自动点击提交订单...', 'grab'); submitBtn.click(); setTimeout(() => { const confirmBtn = document.querySelector('.dhtmlx_button_primary, .okButton, .layui-layer-btn0'); confirmBtn?.click(); log('✅ 订单已提交,请尽快支付!', 'grab'); showNotification('🎉 抢票成功!', '订单已提交,请尽快支付'); }, 800); } }, 2000); } } } if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(init, 800); } else { window.addEventListener('DOMContentLoaded', () => setTimeout(init, 800)); } })();