匹配范围
*://kyfw.12306.cn/*railway-train-search12306 余票车次查询 用途 查询 12306 公开余票接口中指定出发站、到达站和日期的车次列表,返回车次、出发到达时间、历时、是否可预订以及常见席别余票字段。 执行前提 请先打开 https://kyfw.12306.cn/otn/leftTicket/init 或同域的 12306 余票查询页面。脚本使用公开页面正常创建的匿名 Cookie/cred…
*://kyfw.12306.cn/*dom.read1const rawParams = params && typeof params === "object" ? params : {};2const STATION_BUNDLE_URL = "https://kyfw.12306.cn/otn/resources/js/framework/station_name.js";3const INIT_URL = "https://kyfw.12306.cn/otn/leftTicket/init";4const QUERY_ENDPOINTS = ["queryG", "queryO", "queryZ", "queryA"];5const QUERY_ENDPOINT_RE = /^query[A-Z]$/;6const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;7const STATION_CODE_RE = /^[A-Z]{2,4}$/;8 9function requiredTextParam(name) {10 const value = String(rawParams[name] ?? "").trim();11 if (!value) throw new Error(`${name} is required`);12 return value;13}14 15function integerParam(name, fallback, min, max) {16 const raw = rawParams[name];17 const value = raw === undefined || raw === null || raw === "" ? fallback : Number(raw);18 if (!Number.isInteger(value) || value < min || value > max) {19 throw new Error(`${name} must be an integer between ${min} and ${max}`);20 }21 return value;22}23 24function validateDate(value) {25 const text = String(value ?? "").trim();26 if (!DATE_RE.test(text)) throw new Error("date must use YYYY-MM-DD");27 const [year, month, day] = text.split("-").map(Number);28 const date = new Date(Date.UTC(year, month - 1, day));29 if (30 date.getUTCFullYear() !== year ||31 date.getUTCMonth() !== month - 1 ||32 date.getUTCDate() !== day33 ) {34 throw new Error(`date is not a real calendar date: ${text}`);35 }36 return text;37}38 39function assertKyfwPage() {40 if (location.hostname !== "kyfw.12306.cn") {41 throw new Error("Open https://kyfw.12306.cn/otn/leftTicket/init first, then run this script");42 }43}44 45function parseStationBundle(text) {46 const match = String(text || "").match(/'([^']+)'/);47 if (!match) throw new Error("Failed to parse station bundle");48 49 const stations = match[1]50 .split("@")51 .filter(Boolean)52 .map((record) => {53 const parts = record.split("|");54 return {55 short: parts[0] || "",56 name: parts[1] || "",57 code: parts[2] || "",58 pinyin: parts[3] || "",59 abbr: parts[4] || "",60 city: parts[7] || "",61 };62 })63 .filter((station) => station.name && station.code);64 65 if (stations.length === 0) throw new Error("No station records found");66 return stations;67}68 69async function fetchStations() {70 const response = await fetch(STATION_BUNDLE_URL, {71 credentials: "include",72 cache: "no-store",73 });74 if (!response.ok) {75 throw new Error(`Failed to fetch station bundle: HTTP ${response.status}`);76 }77 return parseStationBundle(await response.text());78}79 80function resolveStation(stations, input) {81 const text = String(input ?? "").trim();82 if (!text) throw new Error("station must not be empty");83 84 if (STATION_CODE_RE.test(text)) {85 const exactCode = stations.find((station) => station.code === text);86 if (exactCode) return exactCode;87 throw new Error(`Unknown station code: ${text}`);88 }89 90 const lowerText = text.toLowerCase();91 const exactName = stations.find((station) => station.name === text);92 if (exactName) return exactName;93 94 const exactPinyin = stations.find((station) => station.pinyin === lowerText);95 if (exactPinyin) return exactPinyin;96 97 const exactAbbr = stations.find(98 (station) => station.abbr === lowerText || station.short === lowerText99 );100 if (exactAbbr) return exactAbbr;101 102 throw new Error(`Unknown station: ${text}`);103}104 105async function mintAnonymousSession() {106 const response = await fetch(INIT_URL, {107 credentials: "include",108 cache: "no-store",109 });110 if (!response.ok) {111 throw new Error(`Failed to create anonymous session: HTTP ${response.status}`);112 }113}114 115function extractQueryEndpoint(value) {116 const raw = String(value ?? "").trim();117 if (!raw) return "";118 const direct = raw.replace(/^leftTicket\//, "").trim();119 if (QUERY_ENDPOINT_RE.test(direct)) return direct;120 121 try {122 const url = new URL(raw, "https://kyfw.12306.cn");123 if (url.hostname !== "kyfw.12306.cn") return "";124 const match = url.pathname.match(/\/leftTicket\/(query[A-Z])$/);125 return match ? match[1] : "";126 } catch {127 return "";128 }129}130 131async function requestTicketEndpoint(endpoint, queryString) {132 const response = await fetch(`/otn/leftTicket/${endpoint}?${queryString}`, {133 credentials: "include",134 cache: "no-store",135 headers: {136 Accept: "application/json, text/plain, */*",137 },138 });139 const text = await response.text();140 141 let json = null;142 try {143 json = JSON.parse(text);144 } catch {145 if (!response.ok) {146 throw new Error(`Endpoint ${endpoint} returned HTTP ${response.status}`);147 }148 return {149 retry: "",150 result: null,151 error: `Endpoint ${endpoint} returned non-JSON response`,152 };153 }154 155 const rotated = extractQueryEndpoint(json?.c_url);156 if (rotated) return { retry: rotated, result: null, error: "" };157 158 if (!response.ok) {159 throw new Error(`Endpoint ${endpoint} returned HTTP ${response.status}`);160 }161 162 if (Array.isArray(json?.data?.result)) {163 return { retry: "", result: json.data.result, error: "" };164 }165 166 const message = [167 ...(Array.isArray(json?.messages) ? json.messages : []),168 json?.message,169 json?.msg,170 ]171 .filter(Boolean)172 .join(" ");173 174 return {175 retry: "",176 result: null,177 error: message || `Endpoint ${endpoint} returned an unexpected payload`,178 };179}180 181async function queryLeftTickets(fromCode, toCode, date) {182 const search = new URLSearchParams({183 "leftTicketDTO.train_date": date,184 "leftTicketDTO.from_station": fromCode,185 "leftTicketDTO.to_station": toCode,186 purpose_codes: "ADULT",187 });188 const queue = [...QUERY_ENDPOINTS];189 const tried = new Set();190 const errors = [];191 192 while (queue.length > 0) {193 const endpoint = queue.shift();194 if (!endpoint || tried.has(endpoint)) continue;195 tried.add(endpoint);196 197 const payload = await requestTicketEndpoint(endpoint, search.toString());198 if (payload.result) return payload.result;199 if (payload.retry && !tried.has(payload.retry)) {200 queue.unshift(payload.retry);201 }202 if (payload.error) errors.push(`${endpoint}: ${payload.error}`);203 }204 205 throw new Error(`No usable ticket query endpoint found. ${errors.join("; ")}`);206}207 208function decodeTrainLine(line) {209 const text = String(line || "").replace(/%0A/g, "");210 try {211 return decodeURIComponent(text);212 } catch {213 return text;214 }215}216 217function parseTrainRecord(line, stationByCode) {218 const fields = decodeTrainLine(line).split("|");219 if (fields.length < 33) return null;220 221 const fromCode = fields[6] || "";222 const toCode = fields[7] || "";223 return {224 train_no: fields[2] || "",225 code: fields[3] || "",226 from_station: stationByCode.get(fromCode)?.name || fromCode,227 to_station: stationByCode.get(toCode)?.name || toCode,228 from_code: fromCode,229 to_code: toCode,230 start_time: fields[8] || "",231 arrive_time: fields[9] || "",232 duration: fields[10] || "",233 available: String(fields[1] || "").trim() === "预订" || String(fields[11] || "").trim() === "Y",234 business_seat: fields[32] || "",235 first_seat: fields[31] || "",236 second_seat: fields[30] || "",237 soft_sleeper: fields[23] || "",238 hard_sleeper: fields[28] || "",239 hard_seat: fields[29] || "",240 no_seat: fields[26] || "",241 };242}243 244const fromInput = requiredTextParam("from");245const toInput = requiredTextParam("to");246const date = validateDate(rawParams.date);247const limit = integerParam("limit", 50, 1, 100);248 249assertKyfwPage();250 251const stations = await fetchStations();252const fromStation = resolveStation(stations, fromInput);253const toStation = resolveStation(stations, toInput);254 255if (fromStation.code === toStation.code) {256 throw new Error(`from and to must differ: ${fromStation.name}`);257}258 259await mintAnonymousSession();260 261const stationByCode = new Map(stations.map((station) => [station.code, station]));262const rows = await queryLeftTickets(fromStation.code, toStation.code, date);263const trains = rows264 .map((row) => parseTrainRecord(row, stationByCode))265 .filter(Boolean)266 .slice(0, limit)267 .map((train, index) => ({268 rank: index + 1,269 ...train,270 }));271 272if (trains.length === 0) {273 throw new Error(`No trains found from ${fromStation.name} to ${toStation.name} on ${date}`);274}275 276return trains;