// ==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 = `
`;
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渲染,每个乘客一个 |