'use strict'; (function () { // ==UserScript== // @name 12306余票全信息增强:局属·车型车号·席位图鉴·票价折扣 // @namespace https://github.com/joelin818818/12306-yupiao-toushi // @version 1.2.7.3 // @description 12306 余票全信息增强:车次局属(路局,如成都局)、车型与车号(含和谐号/复兴号类别)、席位图鉴(右侧停靠浮窗、多图完整展示)、票价折扣;左侧信息面板 + Excel 式行高亮,数据接口复用 12306 官方。 // @author LBCN // @license MIT // @homepageURL https://github.com/joelin818818/12306-yupiao-toushi // @supportURL https://github.com/joelin818818/12306-yupiao-toushi/issues // @icon https://www.12306.cn/index/images/logo.png // @match https://kyfw.12306.cn/otn/leftTicket/init* // @match https://kyfw.12306.cn/otn/leftTicketPrice/init* // @match https://kyfw.12306.cn/otn/leftTicketPrice/initPublicPrice* // @run-at document-idle // @grant GM_addStyle // ==/UserScript== // api.js — 12306 官方接口封装(局属 / 席位图鉴 / 车型) // 数据接口复用自 galaxy-sea/12306-seat-viewer(Apache-2.0, 作者 魏昌进 / wcj.plus)。 // 接口均为 12306 官方开放接口,无需任何第三方服务,最为稳健。 const API = (() => { const metaCache = new Map(); const metaRequests = new Map(); const seatCache = new Map(); const seatRequests = new Map(); // 带超时的 fetch:防止个别请求挂起导致自动获取队列永久卡死 function fetchWithTimeout(url, options = {}, timeout = 8000) { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeout); return fetch(url, { ...options, signal: ctrl.signal }).finally(() => clearTimeout(timer)); } // 取页面查询日期,格式 YYYYMMDD function getRunningDay() { const raw = document.getElementById('train_date')?.value?.trim(); if (!raw) return null; const m = raw.match(/\d{4}-\d{2}-\d{2}/); if (!m) return null; return m[0].replace(/-/g, ''); } // 局属 + 车型:部门接口(信息最全,首选) async function fetchDeptMeta(trainCode) { const url = 'https://kyfw.12306.cn/wxxcx/openplatform-inner/miniprogram/wifiapps/appFrontEnd/v2/lounge/open-smooth-common/qrCode/getDeptByTrainCode?trainCode=' + encodeURIComponent(trainCode) + '&reqType=form'; const resp = await fetchWithTimeout(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' }, body: '', }); if (!resp.ok) throw new Error('dept request failed'); const data = await resp.json(); const d = data?.content?.data; return { bureauName: d?.bureauName || null, deptName: d?.deptName || null, carType: d?.carInfo?.carType || null, trainStyle: d?.carInfo?.trainStyle || null, perHourSpeed: d?.carInfo?.perHourSpeed ?? null, }; } // 局属:大屏接口(部门接口失败时的兜底) async function fetchBureauMeta(bureauTrainCode) { const queryDate = getRunningDay(); const url = 'https://kyfw.12306.cn/wxxcx/wechat/bigScreen/queryTrainBureau?queryDate=' + encodeURIComponent(queryDate) + '&trainCode=' + encodeURIComponent(bureauTrainCode); const resp = await fetchWithTimeout(url, { method: 'GET' }); if (!resp.ok) throw new Error('bureau request failed'); const data = await resp.json(); return { bureauName: data?.data?.bureau_code_name || null, bureauCode: data?.data?.bureau_code || null, }; } // 车型详情 + 席位图鉴 同源接口:响应 content.data 同时含 trainStyle/carType 与 coachDetailPicList。 // 抽成单一 fetcher 并做内存 + 在途去重,避免「车型」与「席位图」两次重复请求同一 URL。 const carDetailCache = new Map(); const carDetailReqs = new Map(); function getCarDetail(trainCode, runningDay) { if (!trainCode || !runningDay) return Promise.resolve(null); const key = runningDay + '|' + trainCode; if (carDetailCache.has(key)) return Promise.resolve(carDetailCache.get(key)); if (carDetailReqs.has(key)) return carDetailReqs.get(key); const url = 'https://kyfw.12306.cn/wxxcx/openplatform-inner/miniprogram/wifiapps/appFrontEnd/v2/lounge/open-smooth-common/trainStyleBatch/getCarDetail?carCode=&trainCode=' + encodeURIComponent(trainCode) + '&runningDay=' + encodeURIComponent(runningDay) + '&reqType=form'; const req = fetchWithTimeout(url, { method: 'GET' }) .then(async (resp) => { if (!resp.ok) throw new Error('car detail request failed'); const data = await resp.json(); return data?.content?.data || null; }) .catch(() => null) .finally(() => carDetailReqs.delete(key)); carDetailReqs.set(key, req); req.then((d) => carDetailCache.set(key, d)).catch(() => {}); // 失败也缓存 null,避免反复重试 return req; } // 车型详情:从共享 getCarDetail 取 trainStyle/carType(部门接口失败时兜底) async function fetchCarDetailMeta(trainCode, runningDay) { const d = await getCarDetail(trainCode, runningDay); if (!d) return null; return { carType: d.carType || null, trainStyle: d.trainStyle || null, perHourSpeed: null, }; } // —— 持久化缓存(localStorage):按 日期|车次 存,跨查询/刷新复用,免去重复网络请求 —— const META_TTL = 7 * 24 * 3600 * 1000; // 7 天 const META_PREFIX = 'sv_meta_v1_'; function metaPersistentKey(date, code) { return META_PREFIX + date + '|' + code; } function readStoredMeta(trainCode) { const date = getRunningDay(); if (!date || !trainCode) return null; try { const raw = localStorage.getItem(metaPersistentKey(date, trainCode)); if (!raw) return null; const obj = JSON.parse(raw); if (!obj || typeof obj.ts !== 'number') return null; if (Date.now() - obj.ts > META_TTL) return null; return obj.meta || null; } catch (e) { return null; } } function writeStoredMeta(trainCode, meta) { if (!meta) return; const date = getRunningDay(); if (!date || !trainCode) return; try { localStorage.setItem(metaPersistentKey(date, trainCode), JSON.stringify({ ts: Date.now(), meta })); } catch (e) { /* 容量超限忽略 */ } } function fetchTrainMeta(trainCode, rawTrainCode) { if (!trainCode) return Promise.resolve(null); const queryDate = getRunningDay(); if (!queryDate) return Promise.resolve(null); const bureauTrainCode = rawTrainCode || trainCode; const key = queryDate + '|' + trainCode; if (metaCache.has(key)) return Promise.resolve(metaCache.get(key)); if (metaRequests.has(key)) return metaRequests.get(key); // 部门接口与车型详情接口并行:两者都能给出 trainStyle/carType,合并去重; // 局属若仍缺失,再补大屏接口。并行相比原串行三连显著缩短首查延迟。 const request = (async () => { const [dept, detail] = await Promise.all([ fetchDeptMeta(trainCode).catch(() => null), fetchCarDetailMeta(trainCode, queryDate).catch(() => null), ]); const meta = {}; if (dept) { meta.bureauName = dept.bureauName || null; meta.deptName = dept.deptName || null; meta.carType = dept.carType || null; meta.trainStyle = dept.trainStyle || null; meta.perHourSpeed = dept.perHourSpeed ?? null; } if (detail) { meta.carType = meta.carType || detail.carType || null; meta.trainStyle = meta.trainStyle || detail.trainStyle || null; meta.perHourSpeed = meta.perHourSpeed ?? detail.perHourSpeed ?? null; } if (!meta.bureauName) { try { const b = await fetchBureauMeta(bureauTrainCode); meta.bureauName = meta.bureauName || b.bureauName || null; meta.bureauCode = b.bureauCode || null; } catch (e) { /* 忽略 */ } } writeStoredMeta(trainCode, meta); metaCache.set(key, meta); return meta; })().finally(() => metaRequests.delete(key)); metaRequests.set(key, request); return request; } // 席位图鉴:复用共享 getCarDetail 的同一响应(同一次请求已含 coachDetailPicList) function fetchSeatPics(trainCode, runningDay) { if (!trainCode || !runningDay) return Promise.resolve(null); const key = trainCode + '|' + runningDay; if (seatCache.has(key)) return Promise.resolve(seatCache.get(key)); if (seatRequests.has(key)) return seatRequests.get(key); const request = getCarDetail(trainCode, runningDay).then((d) => { const pics = d?.coachDetailPicList || null; seatCache.set(key, pics); return pics; }); seatRequests.set(key, request); return request; } return { getRunningDay, fetchTrainMeta, fetchSeatPics, readStoredMeta }; })(); // dom.js — DOM 选择 / 车次号解析(复用 galaxy-sea/12306-seat-viewer 逻辑) const DOM = (() => { function getTrainAnchor(tr) { return ( tr.querySelector('[id^="train_num_"] > div.train > div > a') || tr.querySelector('.train > div > a') ); } function getRawTrainCode(tr) { const a = getTrainAnchor(tr); return a?.textContent?.trim() || ''; } // 车次号可能藏在 onclick 的 open(...) 编码串里,需解析 function getTrainCode(tr) { const anchor = getTrainAnchor(tr); if (!anchor) return ''; const rawText = getRawTrainCode(tr); const onclick = anchor.getAttribute('onclick') || ''; if (!onclick || onclick.includes(rawText)) return rawText; // onclick 形如 myStopStation.open('58','5e0000G4420G','NGH','HGH','20251126','3'); const m = onclick.match(/open\(\s*'[^']*'\s*,\s*'([^']*)'/); const encoded = m?.[1]; if (!encoded || encoded.length < 3) return rawText; const withoutTail = encoded.slice(0, -2); // 去掉末尾随机两字符 for (let i = withoutTail.length - 1; i >= 0; i -= 1) { if (/[A-Za-z]/.test(withoutTail[i])) { return withoutTail.slice(i); } } return rawText; } function findRows(tbody) { return Array.from(tbody.querySelectorAll('tr')).filter((tr) => { if (tr.id?.startsWith('price_')) return false; const hasCells = tr.querySelectorAll('td').length > 0; const text = tr.innerText.trim(); return hasCells && text.length > 0; }); } // 席别单元格 id 前缀(用于城际车型推断) function getCoachClassId(tr) { const tds = tr.querySelectorAll('td'); for (const td of tds) { const id = td.id || ''; if (/^(SWZ|TZ)_/.test(id)) return id; } return null; } // 城际列车(天津 C2x)按席别推断车型 function getIntercityModel(code, coachClassId) { if (/^C2[0-6]/.test(code)) { if (coachClassId && /^SWZ_/.test(coachClassId)) return 'CR400AF/BF型'; if (coachClassId && /^TZ_/.test(coachClassId)) return 'CRH3C型'; } return null; } // 18 个路局的完整城市名(徽标展示用) const BUREAU_CITY = [ '哈尔滨', '沈阳', '北京', '呼和浩特', '太原', '上海', '济南', '南昌', '广州', '南宁', '武汉', '郑州', '成都', '昆明', '青藏', '兰州', '乌鲁木齐', '西安', ]; // 从 API 返回的原始路局名(如「中国铁路北京局集团有限公司」)提取完整城市名 function bureauCityName(raw) { if (!raw) return null; for (const c of BUREAU_CITY) if (raw.indexOf(c) !== -1) return c; return raw; // 兜底:未知格式时直接展示原值 } // 面板展示用的短标签:城市名 + 「局」(如 成都局);无法识别时退回原值 function bureauShortLabel(raw) { if (!raw) return null; const city = bureauCityName(raw); return BUREAU_CITY.indexOf(city) !== -1 ? `${city}局` : city; } // 车次等级(普速按车次首字母推断;动车组由 main 结合车型数据显示) const trainClassMap = { G: '高速', D: '动车', C: '城际', Z: '直特', T: '特快', K: '快速', Y: '旅游', L: '临客', S: '市郊', }; function getTrainClass(code) { if (!code) return ''; const first = code[0].toUpperCase(); if (trainClassMap[first]) return trainClassMap[first]; if (/[0-9]/.test(first)) return code.length >= 4 ? '普快' : '普客'; return ''; } // 车次号所在单元格(用于在其后插入「等级」列) function getTrainCell(tr) { const anchor = getTrainAnchor(tr); if (anchor) { const td = anchor.closest('td'); if (td) return td; } return Array.from(tr.querySelectorAll('td')).find((c) => c.id?.startsWith('train_num_')) || null; } return { getTrainAnchor, getRawTrainCode, getTrainCode, findRows, getCoachClassId, bureauCityName, bureauShortLabel, getTrainClass, getTrainCell, getIntercityModel, }; })(); // ui.js — 界面渲染:悬浮提示、左侧车次信息面板(局属+车型)、票价角标 const UI = (() => { const SEAT_PIC_PREFIX = 'https://wifi.12306.cn/resourcecenter/cateringimages/'; function escapeHtml(value) { return String(value || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function addStyles() { GM_addStyle(` .seat-viewer-tooltip { position: fixed; z-index: 2147483647; max-width: 1080px; width: fit-content; padding: 10px 12px; background: rgba(15, 23, 42, 0.92); color: #f8fafc; border-radius: 10px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); font-family: "Segoe UI", "PingFang SC", sans-serif; font-size: 13px; line-height: 1.5; pointer-events: none; transition: opacity 120ms ease; } .sv-meta { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 6px; align-items: center; } .sv-meta-item { white-space: nowrap; } .sv-seats { display: grid; grid-template-columns: repeat(3, minmax(288px, 1fr)); gap: 8px; margin-top: 4px; align-items: start; } .sv-seats.cols-1 { grid-template-columns: repeat(1, minmax(288px, 1fr)); max-width: 336px; } .sv-seats.cols-2 { grid-template-columns: repeat(2, minmax(288px, 1fr)); max-width: 672px; } .sv-seats.cols-3 { grid-template-columns: repeat(3, minmax(288px, 1fr)); max-width: 1008px; } .sv-seat { margin: 0; text-align: center; color: #e2e8f0; font-size: 12px; } .sv-seat img { width: 100%; max-height: 288px; object-fit: contain; border-radius: 8px; background: #0f172a; } .sv-price { margin-top: 2px; color: #fb7403; font-size: 12px; font-weight: 400; line-height: 1.2; white-space: nowrap; } /* 右侧停靠模式:固定宽度(不随图片加载抖动);不限高、不内部滚动,多张图一次性完整展示 */ .seat-viewer-tooltip.sv-dock { width: 320px; max-width: 320px; box-sizing: border-box; padding: 8px 10px; } .sv-dock .sv-seats { grid-template-columns: minmax(0, 1fr) !important; max-width: none !important; gap: 6px !important; } .sv-dock .sv-seat img { display: block; } /* 不强制宽高比:按图片原生比例显示,避免 object-fit 留黑边 */ .sv-dock .sv-seat figcaption { font-size: 11px; line-height: 1.4; margin-top: 2px; } .sv-dock .sv-seat figcaption { font-size: 11px; } /* 左侧车次信息面板:与官方表格结构完全解耦,绝不影响官方布局 */ /* 经停弹窗打开期间官方样式会给页面文本挂下划线,面板/悬浮窗文字强制去除(分隔线在 .sv-side-row 上,不受影响) */ .sv-side-panel, .sv-side-panel div, .seat-viewer-tooltip, .seat-viewer-tooltip div { text-decoration: none !important; } .sv-side-bureau, .sv-side-model { border-bottom: none !important; } .sv-side-panel { position: absolute; top: 0; left: 0; width: 0; height: 0; z-index: 50; pointer-events: none; } .sv-side-row { position: absolute; pointer-events: auto; box-sizing: border-box; display: flex; flex-direction: column; justify-content: center; gap: 2px; padding: 2px 6px; font-family: "Segoe UI", "PingFang SC", sans-serif; font-size: 11px; line-height: 14px; border-right: 2px solid #cbd5e1; border-bottom: 1px solid #dbe3ec; cursor: pointer; color: #334155; } .sv-side-row:last-child { border-bottom: none; } .sv-side-row:hover, .sv-side-row.sv-active { background: #eff6ff; } /* 官方表格行悬停淡蓝底色(Excel 式行高亮,方便左右对照) */ #queryLeftTable tr[id^="ticket_"]:hover td { background-color: #f0f7ff !important; } .sv-side-bureau, .sv-side-model { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sv-side-bureau { color: #7c3aed; } .sv-side-model { color: #0ea5e9; } .sv-side-row .sv-empty { color: #94a3b8; } `); } // —— 浮窗停靠开关(恢复预案)—— // true = 停靠页面右侧空白、竖向排列(当前默认); // false = 恢复原「页面居中悬浮」样式,仅需改这一个值,其余逻辑完全复用。 const DOCK_TOOLTIP_RIGHT = true; function createTooltip() { const tip = document.createElement('div'); tip.className = 'seat-viewer-tooltip'; if (DOCK_TOOLTIP_RIGHT) tip.classList.add('sv-dock'); tip.style.display = 'none'; document.body.appendChild(tip); return tip; } function renderTooltip(trainCode, meta, seatPics) { const safeTrain = escapeHtml(trainCode); const metaParts = [ ``, ]; // 车型:trainStyle 为具体型号(CRH380BL),carType 为类别(和谐号/复兴号) const modelText = [meta?.trainStyle, meta?.carType].filter(Boolean).join(' '); if (modelText) { metaParts.push(``); } if (meta?.bureauName || meta?.deptName) { const bureauText = [meta.bureauName, meta.deptName].filter(Boolean).join('-'); metaParts.push(``); } if (meta?.perHourSpeed) { metaParts.push(``); } const parts = [`
`]; if (seatPics && seatPics.length) { const cols = Math.min(3, seatPics.length); const images = seatPics .slice() .sort((a, b) => (a?.picOrder || 0) - (b?.picOrder || 0)) .map((pic) => { const url = pic?.pictureUrl?.startsWith('http') ? pic.pictureUrl : `${SEAT_PIC_PREFIX}${pic?.pictureUrl || ''}`; const rawName = pic?.pictureName || '座位'; const name = escapeHtml((rawName.split('#')[0] || rawName)); return `