// ==UserScript== // @name 12306 抢票助手 Pro (优化版) // @namespace http://tampermonkey.net/ // @version 1.3 // @description 自动查票、下单,成功后自动跳转支付页 // @author kl2 (optimized) // @match https://kyfw.12306.cn/otn/* // @grant GM_notification // @grant window.focus // @license MIT // ==/UserScript== (function() { 'use strict'; console.log('>>> 12306 抢票助手 Pro (优化版) 已加载 <<<'); // ========================================== // 0. Configuration // ========================================== let stationMap = {}; 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 // ========================================== 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; } 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 seatName of targetSeats) { 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 => { // 修复学生票判断:严格对比字符串 '3' 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!'); // 提取订单号(返回字段可能为 orderId 或 order_id) 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:500px; 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-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; } `; 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; // 声音提示 (使用 Web Audio 生成蜂鸣) function playBeep() { try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value = 800; oscillator.type = 'square'; gainNode.gain.setValueAtTime(0.5, audioCtx.currentTime); oscillator.start(audioCtx.currentTime); oscillator.stop(audioCtx.currentTime + 0.5); } catch(e) { console.log('声音播放失败:', e); } } function showNotification(title, body) { 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 }); }); } // 无论如何都播放声音 playBeep(); } 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 = `