// ==UserScript== // @name 12306 抢票助手 // @namespace http://tampermonkey.net/ // @version 2.0.0 // @description 12306 自动刷票/抢票脚本:自动查询余票、自动点击预订、自动选择乘客、自动提交订单。支持多车次、多席别、声音提醒、定时抢票。 // @author 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 GM_playSound // @grant window.focus // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 配置区域 - 默认值(可通过面板修改) // ============================================================ const DEFAULT_CONFIG = { // 目标车次列表(填入你要抢的车次号,支持多个) targetTrains: ['D7763'], // 目标座位类型: swz=商务座, zy=一等座, ze=二等座, gr=高级软卧, rw=软卧, yw=硬卧, rz=软座, yz=硬座, wz=无座 seatTypes: ['ze', 'zy', 'yz', 'wz'], // 乘客姓名(已在12306联系人列表中的姓名,模糊匹配) passengers: [], // 查询刷新间隔(毫秒),太快容易被封,建议 >= 2000 refreshInterval: 2500, // 开抢时间,格式 "HH:MM:SS",例如 "06:00:00";为空则立即开始 startTime: '', // 是否开启自动提交订单(确认乘客页面) autoSubmit: true, // 是否开启声音提醒 soundAlert: true, // 是否开启桌面通知 desktopNotify: true, // 出发日期(自动填充用) trainDate: '2026-08-23', // 出发站编码 fromStation: 'PVD', // 盘锦 // 到达站编码 toStation: 'SYT', // 沈阳南 }; // ============================================================ // 席别名称映射(12306接口返回的字段顺序) // ============================================================ // queryLeftTable 中 secretString 后面各字段含义(截取常用部分): // 序号(0-based from secretString after split): // 1: 列车号 3: 车次号 5: 出发站编码 6: 到达站编码 // 8: 出发时间 9: 到达时间 10: 历时 // 26: 无座 28: 硬卧 29: 硬座 30: 软卧 31: 高级软卧 // 32: 二等座 33: 一等座 35: 商务座/特等座 // 具体偏移可能随12306更新变化,脚本会优先用DOM中显示的内容判断 const SEAT_FIELD_MAP = { swz: { name: '商务座/特等座', idx: 32, color: '#e74c3c' }, zy: { name: '一等座', idx: 31, color: '#e67e22' }, ze: { name: '二等座', idx: 30, color: '#27ae60' }, gr: { name: '高级软卧', idx: 21, color: '#9b59b6' }, rw: { name: '软卧', idx: 23, color: '#8e44ad' }, yw: { name: '硬卧', idx: 28, color: '#3498db' }, rz: { name: '软座', idx: 24, color: '#16a085' }, yz: { name: '硬座', idx: 29, color: '#2980b9' }, wz: { name: '无座', idx: 26, color: '#7f8c8d' }, }; // ============================================================ // 工具函数 // ============================================================ const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel)); function getConfig() { const saved = GM_getValue('config', null); return Object.assign({}, DEFAULT_CONFIG, saved || {}); } function saveConfig(cfg) { GM_setValue('config', cfg); } function nowStr() { const d = new Date(); return d.toTimeString().slice(0, 8); } function log(msg, type = 'info') { const colors = { info: '#3498db', success: '#27ae60', warn: '#f39c12', error: '#e74c3c', grab: '#e74c3c' }; const panel = $('#grab-log-content'); if (panel) { const line = document.createElement('div'); line.style.cssText = `color:${colors[type] || '#fff'};font-size:12px;line-height:1.6;`; line.innerHTML = `[${nowStr()}] ${msg}`; panel.appendChild(line); panel.scrollTop = panel.scrollHeight; } console.log(`[12306抢票 ${nowStr()}]`, msg); } function playAlert() { 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 notify(title, body) { const cfg = getConfig(); if (cfg.soundAlert) playAlert(); 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 }); }); } } // 弹出alert确保用户注意到 try { window.focus(); } catch(e){} } // ============================================================ // 样式注入 // ============================================================ 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: 2px 8px; background: rgba(255,255,255,.2); border-radius: 4px; font-size: 12px; } #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; } .train-highlight { background: #ffeaa7 !important; } `); // ============================================================ // 控制面板 UI // ============================================================ let grabbing = false; let timer = null; let queryCount = 0; let dragged = false; function buildPanel() { if ($('#grab-panel')) return; const cfg = getConfig(); const panel = document.createElement('div'); panel.id = 'grab-panel'; panel.innerHTML = `
🚄 12306 抢票助手
${Object.entries(SEAT_FIELD_MAP).map(([k, v]) => ` `).join('')}
● 未开始
查询次数: 0
`; document.body.appendChild(panel); // 最小化切换 $('#grab-toggle').onclick = () => panel.classList.toggle('minimized'); // 拖拽 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 = () => { playAlert(); log('🔔 测试提醒音已播放', 'info'); }; // 保存配置到面板实时读取 log('✅ 抢票面板已加载,请确认设置后点击"开始抢票"', 'success'); log(`📍 默认目标: 盘锦(PVD) → 沈阳南(SYT) ${cfg.trainDate}`, 'info'); } function readConfigFromPanel() { const trains = $('#cfg-trains').value.trim().toUpperCase().split(/[,,\s]+/).filter(Boolean); const seats = $$('.cfg-seat:checked').map(cb => cb.value); const passengers = $('#cfg-passengers').value.trim().split(/[,,\s]+/).filter(Boolean); const cfg = { targetTrains: trains.length ? trains : DEFAULT_CONFIG.targetTrains, seatTypes: seats.length ? seats : DEFAULT_CONFIG.seatTypes, passengers: passengers, trainDate: $('#cfg-date').value || DEFAULT_CONFIG.trainDate, startTime: $('#cfg-time').value || '', refreshInterval: Math.max(1500, parseInt($('#cfg-interval').value) || 2500), autoSubmit: $('#cfg-autosubmit').checked, soundAlert: $('#cfg-sound').checked, desktopNotify: true, fromStation: DEFAULT_CONFIG.fromStation, toStation: DEFAULT_CONFIG.toStation, }; saveConfig(cfg); return cfg; } function setStatus(text, cls) { const el = $('#grab-status'); if (!el) return; el.textContent = text; el.className = cls || ''; } // ============================================================ // 余票查询页逻辑 // ============================================================ // 确保页面上日期/车站正确 function ensureQueryParams(cfg) { // 日期输入框 const dateInput = $('#train_date'); if (dateInput && dateInput.value !== cfg.trainDate) { dateInput.value = cfg.trainDate; // 触发 change 事件让JS拾取 dateInput.dispatchEvent(new Event('change', { bubbles: true })); } // 12306 使用 fromStation / toStation 的隐藏input以及查询按钮 const fs = $('#fromStation'); const ts = $('#toStation'); if (fs && fs.value !== cfg.fromStation) fs.value = cfg.fromStation; if (ts && ts.value !== cfg.toStation) ts.value = cfg.toStation; } // 触发查询 function triggerQuery() { const btn = $('#query_ticket') || $('#a_search_ticket'); if (btn && !btn.disabled) { btn.click(); queryCount++; const cnt = $('#grab-count'); if (cnt) cnt.textContent = `查询次数: ${queryCount}`; } else { log('⚠️ 查询按钮不可用,稍后重试', 'warn'); } } // 解析余票表格,找目标车次 function checkTrainList(cfg) { const rows = $$('#queryLeftTable tr:not(.line)'); // 12306 用 成对:datatran 的 是数据行,下一行是详情 // 预订按钮: class="btn72" let found = null; for (const tr of rows) { // 有些行是 分隔行 / 表头,跳过 const trainNoEl = tr.querySelector('.number a, .train a, td:nth-child(1) .number'); // 兼容多种DOM布局:优先查找车次号 let trainNumber = ''; const numLink = tr.querySelector('a.number, .train-num a, td a[href*="javascript:"]'); if (numLink) { trainNumber = numLink.textContent.trim().toUpperCase(); } // 另一种:车次在class="cdz"里的 strong if (!trainNumber) { const cdz = tr.querySelector('.cdz strong, .t-station strong'); // cdz 一般是站名,不对。换个思路:整行文本匹配 const match = tr.textContent.match(/\b([GDTCKZSYL]\d{1,5})\b/); if (match) trainNumber = match[1]; } // 也可以从"预订"按钮的onclick里拿 trainNo const bookBtn = tr.querySelector('.btn72, a.btn72, a[onclick*="book"]'); let onclickInfo = ''; if (bookBtn) onclickInfo = bookBtn.getAttribute('onclick') || ''; // 合并判断 const matches = cfg.targetTrains.some(t => { t = t.toUpperCase(); return trainNumber === t || onclickInfo.includes(t); }); if (!matches) { // 清理旧高亮 tr.classList.remove('train-highlight'); continue; } // 这是目标车次,检查余票 // 余票单元格在各 中,class一般是 "yz", "yw", "ze" 等或包含对应席别的 // 先遍历所有td,看席别字段文本 const tds = $$('td', tr); let hasTicket = false; let seatInfo = []; // 12306余票表格 td 索引(常见布局): // 0: 车次 1: 出发站 2: 到达站 3: 出发时间 4: 到达时间 5: 历时 // 6: 商务座 7: 一等座 8: 二等座 9: 高级软卧 10: 软卧 11: 硬卧 // 12: 软座 13: 硬座 14: 无座 (实际索引随版本变动,用class/key判断更稳) const seatKeyMap = { swz: ['商务', '特等'], zy: ['一等'], ze: ['二等'], gr: ['高软'], rw: ['软卧'], yw: ['硬卧'], rz: ['软座'], yz: ['硬座'], wz: ['无座'], }; // 每个可点击/显示的票额td通常带有 data-[seat] 属性或包含席别字段 // 最稳妥:查询所有带余票数字或"有"/"*" 的td并与席别列对应 // 先找 class 中包含席位关键字的 for (const td of tds) { const cls = td.className || ''; const text = td.textContent.trim(); for (const [seatKey, keywords] of Object.entries(seatKeyMap)) { if (!cfg.seatTypes.includes(seatKey)) continue; // td的class一般会包含席位key if (cls.includes(seatKey) || keywords.some(kw => td.previousElementSibling && td.previousElementSibling.textContent.includes(kw))) { if (isAvailable(text)) { hasTicket = true; seatInfo.push(SEAT_FIELD_MAP[seatKey].name); } } } } // 如果上面没找到(页面结构变了),再用兜底:找预订按钮可用 if (!hasTicket && bookBtn && bookBtn.textContent.includes('预')) { // 如果按钮可点且不是"无",可能有票 // 进一步检查是否所有席别都显示"--"或"无" const txt = tr.textContent; const anyAvail = cfg.seatTypes.some(k => { // 利用 queryLeftTable 每行 dataset 不一定存在,看strong数字 // 最原始判断:有"预订"按钮通常代表该车可以买(不一定目标席别有票) // 若所有目标席别td都显示"无"/"--"才认为无票 return true; // 保守策略:只要有预订按钮就尝试 }); if (anyAvail) { hasTicket = true; seatInfo.push('(有预订按钮)'); } } tr.classList.add('train-highlight'); log(`🎯 找到 ${trainNumber} 次列车`, hasTicket ? 'success' : 'warn'); log(` 可用席别: ${seatInfo.length ? seatInfo.join(', ') : '无'}`, hasTicket ? 'grab' : 'warn'); if (hasTicket && bookBtn && !bookBtn.classList.contains('disabled') && !bookBtn.disabled) { found = { tr, trainNumber, bookBtn, seatInfo }; break; } } return found; } function isAvailable(text) { if (!text) return false; const t = text.trim(); if (t === '有' || t === '*') return true; // 数字 > 0 const n = parseInt(t); if (!isNaN(n) && n > 0) return true; return false; } function clickBookAndGo(found) { log(`🚀 点击预订按钮: ${found.trainNumber}`, 'grab'); notify('🎫 发现余票!', `${found.trainNumber} 有票!正在提交...`); setStatus(`● 发现 ${found.trainNumber} 有票!`, 'grab-status-found'); found.bookBtn.click(); // 停止自动刷新 stopGrabbing(false); } function startGrabbing() { const cfg = readConfigFromPanel(); if (!cfg.targetTrains.length) { log('❌ 请填写目标车次', 'error'); return; } grabbing = true; queryCount = 0; $('#btn-start').disabled = true; $('#btn-stop').disabled = false; log(`🚀 抢票启动!目标: ${cfg.targetTrains.join(', ')}`, 'success'); log(`💺 席别: ${cfg.seatTypes.map(s => SEAT_FIELD_MAP[s].name).join(', ')}`, 'info'); if (cfg.passengers.length) log(`👤 乘客: ${cfg.passengers.join(', ')}`, 'info'); log(`🔄 刷新间隔: ${cfg.refreshInterval}ms`, 'info'); ensureQueryParams(cfg); // 等待开抢时间 const now = new Date(); const nowSecs = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds(); let waitMs = 0; if (cfg.startTime) { const [h, m, s] = cfg.startTime.split(':').map(Number); const targetSecs = h * 3600 + m * 60 + (s || 0); waitMs = (targetSecs - nowSecs) * 1000; if (waitMs < 0) waitMs = 0; log(`⏰ 等待开抢时间 ${cfg.startTime},还有 ${(waitMs/1000).toFixed(0)} 秒`, 'waiting'); setStatus(`● 等待中 ${cfg.startTime}`, 'grab-status-waiting'); } setTimeout(() => { if (!grabbing) return; setStatus('● 正在刷票...', 'grab-status-running'); // 立即查询一次 triggerQuery(); timer = setInterval(() => { if (!grabbing) return; // 先检查结果 const found = checkTrainList(cfg); if (found) { clickBookAndGo(found); return; } // 再次查询 ensureQueryParams(cfg); triggerQuery(); }, cfg.refreshInterval); }, waitMs); } function stopGrabbing(resetStatus = true) { grabbing = false; if (timer) { clearInterval(timer); timer = null; } const start = $('#btn-start'), stop = $('#btn-stop'); if (start) start.disabled = false; if (stop) stop.disabled = true; if (resetStatus) { setStatus('● 已停止', 'grab-status-stopped'); log('⏸ 抢票已停止', 'warn'); } } // ============================================================ // 确认订单页(乘客选择)逻辑 // ============================================================ function runConfirmPage() { log('📍 进入确认订单页', 'success'); const cfg = readConfigFromPanel(); // 等乘客列表渲染完成 let attempts = 0; const maxAttempts = 60; // 30秒 const waitPassenger = setInterval(() => { attempts++; // 乘客列表在 #normal_passenger_id 或 .normal-passenger-item 中 // 新版12306: 乘客列表通过JS渲染,每个乘客一个