灵猴市集Linghou Market

12306 余票车次查询

12306railway-train-search

12306 余票车次查询 用途 查询 12306 公开余票接口中指定出发站、到达站和日期的车次列表,返回车次、出发到达时间、历时、是否可预订以及常见席别余票字段。 执行前提 请先打开 https://kyfw.12306.cn/otn/leftTicket/init 或同域的 12306 余票查询页面。脚本使用公开页面正常创建的匿名 Cookie/cred…

车次余票查询
版本1.0.0
扫描passed
更新2026-06-28
超时30000ms

匹配范围

*://kyfw.12306.cn/*

排除范围

未声明

能力声明

dom.read

代码

源码已展开
1const 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;