// ==UserScript== // @name 12306 抢票助手 Pro // @namespace http://tampermonkey.net/ // @icon https://raw.githubusercontent.com/shiyi312/12306-/main/12306helper.jpg // @version 2.0.4 // @description 自动查票、下单,成功后自动跳转支付页。支持多车次、多席别、配置自动保存、动态状态提醒。 // @author kl2 // @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.4) 已加载 <<<'); // ========================================== // 0. Configuration (配置与持久化) // ========================================== let stationMap = {}; const DEFAULT_CONFIG = { targetTrains: ['G1234'], seatTypes: ['ze', 'zy'], passengers: [], trainDate: new Date().toISOString().split('T')[0], startTime: '', soundAlert: true, autoSubmit: true, fromStation: '上海', toStation: '杭州' }; // 加载和保存配置 function loadConfig() { const saved = GM_getValue('grab_config_pro', null); return saved ? Object.assign({}, DEFAULT_CONFIG, saved) : DEFAULT_CONFIG; } function saveConfig(cfg) { GM_setValue('grab_config_pro', 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} 个站点`); UIModule.log('站点简码表加载完成', 'success'); } } catch (e) { console.error('获取站点简码表失败:', e); UIModule.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 = (() => { 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 = []; let 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]; 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'}`); } const dateStr = trainInfo.trainDate; const y = dateStr.substring(0,4), m = dateStr.substring(4,6), d = dateStr.substring(6,8); const dateObj = new Date(`${y}-${m}-${d}`); 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 SEAT_MAP_UI = { swz: { name: '商务/特等' }, zy: { name: '一等座' }, ze: { name: '二等座' }, gr: { name: '高级软卧' }, rw: { name: '软卧' }, yw: { name: '硬卧' }, rz: { name: '软座' }, yz: { name: '硬座' }, wz: { name: '无座' } }; // UI 样式 (原始蓝白风格 + 新增勾选框、状态灯、测试按钮) 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-test { width:100%; padding:10px; background:#f39c12; margin-top:8px; border:none; border-radius:4px; color:white; font-weight:bold; cursor:pointer; transition:background 0.2s; } .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; } } `; let state = { isRunning: false, config: { fromStation: '上海', toStation: '杭州', trainDate: new Date().toISOString().split('T')[0], trainCodes: [], seatTypes: [], passengers: [] }, passengersList: [], notificationEnabled: false }; 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 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'); } 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', async () => { 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'); } else { log('未获取到乘客,请确认已登录', 'error'); } } catch (e) { log('获取乘客失败: ' + e.message, 'error'); } }); } function renderPassengers(list) { const container = document.getElementById('th-passenger-list'); container.innerHTML = ''; list.forEach(p => { const isStudent = String(p.passenger_type) === '3' || p.passenger_type_name === '学生'; const div = document.createElement('div'); div.style.marginBottom = '4px'; let html = ``; if (isStudent) { html += ``; } div.innerHTML = html; container.appendChild(div); }); } 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(); // 加强站点简码校验:若 stationMap 为空或无法解析,提示并抛出 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)) { console.log(`[模糊匹配] ${input} -> ${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 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 selectedPassengers = []; document.querySelectorAll('#th-passenger-list .th-p-check:checked').forEach(checkbox => { const passengerData = JSON.parse(checkbox.dataset.full); const parentDiv = checkbox.closest('div'); const studentCheck = parentDiv.querySelector('.th-p-student-check'); if (studentCheck && studentCheck.checked) { passengerData.isStudentTicket = true; } selectedPassengers.push(passengerData); }); // 保存配置 cfg = { targetTrains: trains, seatTypes: seatList, passengers: selectedPassengers.map(p => p.passenger_name), trainDate: date, startTime: startTime, soundAlert: cfg.soundAlert, autoSubmit: cfg.autoSubmit, fromStation: fromName, toStation: toName, fromCode: from, toCode: to }; saveConfig(cfg); return { trainDate: date, fromStation: from, toStation: to, trainCodes: trains, seatTypes: seatList, passengers: selectedPassengers, startTime }; } function start() { try { 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; 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; startBtn.textContent = '开始抢票'; startBtn.className = 'th-btn th-btn-primary'; setStatus('● 已停止', 'grab-status-stopped'); log('任务已停止', 'warn'); if (onStopCallback) onStopCallback(); } function log(msg, type = 'info') { if (!logContainer) return; 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); logContainer.scrollTop = logContainer.scrollHeight; } function makeDraggable(element) { const header = element.querySelector('.th-header'); let isDragging = false, startX, startY, initialLeft, initialTop; header.addEventListener('mousedown', (e) => { 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); } return { init: (startCb, stopCb) => { createPanel(); onStartCallback = startCb; onStopCallback = stopCb; log('抢票助手 UI 已初始化', 'success'); }, log: log, getIsRunning: () => state.isRunning, showNotification: showNotification, playBeep: playBeep, setStatus: setStatus }; })(); // ========================================== // 5. Main Logic (核心业务逻辑) // ========================================== let checkInterval = null; let isChecking = false; let countdownInterval = null; async function keepAliveDelay() { return Math.floor(Math.random() * (60000 - 30000 + 1)) + 30000; } async function sendKeepAliveRequest(config) { try { // 仅保留安全的查票保活,移除 risky 的 initDc 请求 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) UIModule.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; UIModule.setStatus('● 等待开抢', 'grab-status-waiting'); UIModule.log(`定时抢票: ${startTime},等待中...`, 'info'); let nextKeepAliveTime = Date.now() + await keepAliveDelay(); countdownInterval = setInterval(async () => { if (!UIModule.getIsRunning()) { clearInterval(countdownInterval); isChecking = false; return; } const currentNow = new Date(); const diff = targetTime - currentNow; if (diff <= 0) { clearInterval(countdownInterval); UIModule.setStatus('● 时间到', 'grab-status-running'); UIModule.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 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'); } } }, 1000); return; } else { UIModule.log('设定时间已过,立即开始', 'warn'); } } executeTask(config); } async function executeTask(config) { if (isChecking) return; isChecking = true; const { trainDate, fromStation, toStation, trainCodes, seatTypes, passengers } = config; UIModule.log(`目标: ${trainDate} ${fromStation}→${toStation} [${trainCodes.join(',')}]`, 'info'); try { const logged = await NetworkModule.checkLoginStatus(); if (!logged) { UIModule.log('未登录,请先登录!', 'error'); isChecking = false; UIModule.stop(); return; } } catch (e) { UIModule.log('检查登录状态异常', 'error'); } const doCheck = async () => { if (!UIModule.getIsRunning()) { clearInterval(checkInterval); isChecking = false; return; } try { UIModule.log('正在查票...', 'info'); const queryRes = await NetworkModule.queryTickets(trainDate, fromStation, toStation); if (!queryRes.status || !queryRes.data?.result) { UIModule.log('查票接口异常', 'warn'); 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) { UIModule.log(`🎉 有票: ${targetTrain.trainCode} ${targetTrain.seatName}`, 'success'); clearInterval(checkInterval); isChecking = false; UIModule.setStatus('● 发现余票!', 'grab-status-found'); UIModule.playBeep(); const orderResult = await OrderLogicModule.executeOrderSequence(targetTrain, passengers); if (orderResult.success) { UIModule.log('✅ 下单成功!5秒后自动跳转支付页...', 'success'); UIModule.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 { UIModule.log(`❌ 下单失败: ${orderResult.error}`, 'error'); if (orderResult.error.includes('验证码') || orderResult.error.includes('captcha')) { UIModule.log('可能需要验证码,暂不支持自动处理,请手动操作', 'warn'); isChecking = false; UIModule.stop(); return; } UIModule.log('3秒后自动重试...', 'warn'); setTimeout(() => { if (UIModule.getIsRunning()) { isChecking = false; executeTask(config); } }, 3000); } } } catch (e) { UIModule.log(`查票出错: ${e.message}`, 'error'); } }; doCheck(); // 移除用户配置,改为硬编码 4~6 秒随机安全间隔 const randomInterval = Math.floor(Math.random() * 2000) + 4000; checkInterval = setInterval(doCheck, randomInterval); } function stopTask() { if (checkInterval) { clearInterval(checkInterval); checkInterval = null; } if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } isChecking = false; UIModule.log('已停止刷票', 'warn'); } setTimeout(() => { UIModule.init(startTask, stopTask); fetchStationMap(); }, 1000); })();