Files
jackwener__opencli/clis/12306/utils.js
Benjamin Liu e82e32abc6 feat(12306): add full read adapter (stations / trains / train / price / me / passengers / orders) (#1637)
* feat(12306): add stations / trains / train read commands (no login required)

Adds a first-pass 12306 (中国铁路) adapter for the public anonymous
query endpoints. Closes the no-login slice of #1589. The
authenticated `me / passengers / orders` commands the issue
proposes are explicitly left as a follow-up.

Commands:
- 12306 stations <keyword>             search station bundle
- 12306 trains <from> <to> --date YYYY-MM-DD  availability between stations
- 12306 train <train-no> --from <s> --to <s> --date  stop list

All three use Strategy.PUBLIC + browser: false, anonymous, no cookie
storage, no CAPTCHA bypass. Sensitive behaviors the issue rules out
(ticket sniping, order submission, payment, anti-abuse circumvention,
password storage) are not implemented.

Notes worth flagging for review:

- 12306 rejects anonymous query endpoints with HTTP 302 to
  /mormhweb/logFiles/error.html. The adapter first hits
  /otn/leftTicket/init to mint JSESSIONID / route / BIGipServerotn
  cookies, then attaches them to subsequent queries. No CAPTCHA path.

- 12306 rotates the train-query endpoint name (queryO / queryZ /
  queryA / queryG) every few weeks. When the wrong name is hit the
  server returns `{c_url: "leftTicket/queryX", status: false}`
  pointing to the current correct name. The adapter walks a list of
  known names, captures the rotation hint, and retries; the runtime
  list is also mutated so subsequent calls in the same process skip
  the warm-up round trip.

- The `|`-separated train wire format includes a booking-handshake
  `secret` field at position 0. Since this PR is read-only and the
  issue explicitly rules out booking, that field is parsed but not
  surfaced in the returned row, and a unit test asserts it cannot
  leak via the public adapter contract.

- Station resolution accepts Chinese name (`上海虹桥`), telecode
  (`AOH`), full pinyin (`shanghaihongqiao`), or short alias (`shhq`).
  Anything else raises ArgumentError with a hint.

- `limit` arguments use a tight validator that throws ArgumentError
  on non-integer / out-of-range input rather than silently clamping,
  matching the typed-error pattern used in #1397 (grok) and #1370
  (coupang).

Live verified anonymously against kyfw.12306.cn:
- `12306 stations 上海 --limit 5` returns 5 stations including
  上海 (SHH) / 上海南 (SNH) / 上海虹桥 (AOH).
- `12306 trains 北京 上海 --date 2026-05-22 --limit 1` returns
  G547 06:18 -> 12:11 with first / second / business / no-seat
  availability columns populated.
- `12306 train 24000000G10L --from 北京南 --to 上海虹桥 --date 2026-05-22`
  returns the 7-stop G1 route from 北京南 through 沧州西 / 德州东 /
  曲阜东 / 南京南 / 苏州北 to 上海虹桥, with arrival / departure /
  stopover times.

Tests: 18 unit tests covering parseStationBundle, resolveStation
(including ambiguous / case-insensitive cases), validateDate,
buildCookieHeader, parseTrainRecord (including a regression test
asserting the `secret` field cannot leak into the row).

Deliberately deferred to a follow-up: `12306 price`. The
queryTicketPrice endpoint needs train_no + per-stop station_no +
per-train seat-type letters, so an ergonomic `12306 price <code>`
would cascade three API calls (trains -> stops -> price) per
invocation. Wanted to keep this PR's blast radius small. If the
maintainer prefers a Phase 1 that includes price even with the
cascading-call cost, happy to add it.

* feat(12306): add me / passengers / orders / price authenticated + price read commands

Completes the #1589 12306 (中国铁路) adapter on top of the
stations / trains / train slice landed in the prior commit of this
branch. The full command set is now:

  Anonymous (no login):
    12306 stations  search station bundle by Chinese / telecode / pinyin
    12306 trains    list trains between two stations on a date
    12306 train     list stops of one train
    12306 price     ticket prices for one train segment + date

  Authenticated (cookie session):
    12306 me        account summary (sensitive fields masked by default)
    12306 passengers  saved-passenger list (sensitive fields masked)
    12306 orders    in-progress orders (not yet ridden / refunded)

Notes worth flagging for review:

- 12306 sets the auth cookie `tk` and the session cookie `JSESSIONID`
  with `Path=/otn`. CDP `Network.getCookies` filters by URL path, so
  `page.getCookies({ url: 'https://kyfw.12306.cn' })` returns 7
  cookies without `tk` / `JSESSIONID`, even on a freshly-navigated
  logged-in tab. Switched the login check to read `document.cookie`
  via `page.evaluate`, which the current navigated page exposes
  regardless of cookie path. Centralized as `require12306Login` in
  utils.js so all three authenticated commands share the same check.

- All authenticated commands mask sensitive fields by default:
  - `me`: real name (Chinese mask), email, mobile (12306 already
    masks server-side), birth date (year only).
  - `passengers`: name + birth year by default; 12306 already masks
    ID number and mobile server-side and this adapter never decodes
    those.
  - Both expose `--include-sensitive` to opt back into the unmasked
    fields the user is entitled to see on their own account.

- `orders` returns the `queryMyOrderNoComplete` slice (orders that
  have not yet been ridden / refunded / completed). The historical
  `queryMyOrderApi` endpoint requires extra page-state handshakes
  that proved fragile when probed; left as a follow-up so this
  command can ship reliably for the immediate "what's still on my
  account" use case.

- `price` cascades three anonymous API calls per invocation:
  init -> queryByTrainNo (to resolve segment station_no within the
  train route) -> queryTicketPrice. 12306 returns prices keyed by
  one-or-two-letter seat codes (`A9` 商务座 / `M` 一等座 /
  `O` 二等座 / `WZ` 无座 / etc.) and additionally doubles some up
  as bare numeric codes (e.g. `"9": "21580"` mirrors
  `"A9": "¥2158.0"`); the bare-numeric duplicates are filtered out
  so the row set is one-per-seat-class.

- Strictly anonymous queries; no CAPTCHA / slider / SMS bypass, no
  credential storage, no ticket sniping, no order submission, no
  payment - per the issue's Non-goals list.

Live verified anonymously and authenticated against kyfw.12306.cn,
sleeping 15-25 seconds between hits to keep 12306's anti-abuse
throttle gentle:

  - 12306 me: account summary returned with real_name / email /
    mobile / birth date all masked at the adapter level, on top of
    12306's own server-side mobile mask.
  - 12306 passengers: every saved passenger returned with name
    masked to `<surname>*<...>` and 12306-side ID/mobile masks
    preserved verbatim.
  - 12306 orders: empty for this test account (no in-progress
    orders), correct EmptyResultError surface.
  - 12306 price G1 北京南 -> 上海虹桥 2026-05-22: returns
    商务座 ¥2158 / 特等座 ¥1163 / 一等座 ¥1035 / 二等座 ¥626 /
    无座 ¥626, sorted desc.

Tests: 23 unit tests (5 new beyond the prior commit's 18) cover
the mask helpers (email / mobile / Chinese name) plus the
parsePriceData filter that drops the bare-numeric duplicates and
sorts by descending price.

* fix(12306): harden browser auth boundaries

* fix(12306): tighten API drift boundaries

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
2026-05-18 22:58:48 +08:00

273 lines
10 KiB
JavaScript

/**
* 12306 (中国铁路) shared helpers.
*
* - Station lookup: parses the public `station_name.js` bundle into
* structured records.
* - Cookie session: 12306's query endpoints reject anonymous requests
* with `HTTP 302 -> error.html`, so callers must hit `/otn/leftTicket/init`
* first to mint the JSESSIONID / route / BIGipServerotn cookies.
* - Query endpoint rotation: 12306 rotates the train-query endpoint
* name (queryO / queryZ / queryA / queryG / ...) every few weeks.
* When the wrong name is hit, the server returns
* `{"c_url":"leftTicket/queryG","c_name":"CLeftTicketUrl","status":false}`
* pointing to the current correct name; retry once with that name.
*/
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
const STATION_BUNDLE_URL = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
const INIT_URL = 'https://kyfw.12306.cn/otn/leftTicket/init';
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const STATION_CODE_RE = /^[A-Z]{2,4}$/;
/**
* Parse the `station_name.js` bundle into a station record array.
*
* Bundle format (single line, `@`-delimited records, each `|`-delimited):
* `var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||...';`
*
* Per-record fields (positional):
* [0] short pinyin alias (e.g. `bjb`)
* [1] Chinese station name (e.g. `北京北`)
* [2] telecode (3-4 uppercase letters, e.g. `VAP`) - this is the
* wire format 12306 uses for `from_station` / `to_station`.
* [3] full pinyin (e.g. `beijingbei`)
* [4] short alias (duplicate of [0] usually)
* [5] index/rank
* [6] city code
* [7] city name (e.g. `北京`)
*/
export function parseStationBundle(text) {
const match = text.match(/'([^']+)'/);
if (!match) {
throw new CommandExecutionError('Failed to parse 12306 station_name.js: source string not found');
}
const raw = match[1];
const records = raw.split('@').filter(Boolean);
const stations = [];
for (const r of records) {
const parts = r.split('|');
if (parts.length < 8 || !parts[2]) continue;
stations.push({
short: parts[0] || '',
name: parts[1] || '',
code: parts[2] || '',
pinyin: parts[3] || '',
abbr: parts[4] || '',
city: parts[7] || '',
});
}
if (stations.length === 0) {
throw new CommandExecutionError('Failed to parse 12306 station_name.js: no station records found');
}
return stations;
}
/**
* Resolve a user-supplied station identifier to a telecode.
*
* Accepts Chinese name (`上海虹桥`), telecode (`AOH`), pinyin
* (`shanghaihongqiao`), short alias (`shh`), or city name with a
* preference for the city's main station.
*/
export function resolveStation(stations, input) {
const trimmed = String(input ?? '').trim();
if (!trimmed) throw new ArgumentError('station must not be empty');
if (STATION_CODE_RE.test(trimmed)) {
const exact = stations.find((s) => s.code === trimmed);
if (exact) return exact;
throw new ArgumentError(`Unknown 12306 station telecode "${trimmed}"`);
}
const lower = trimmed.toLowerCase();
const exactName = stations.find((s) => s.name === trimmed);
if (exactName) return exactName;
const exactPinyin = stations.find((s) => s.pinyin === lower);
if (exactPinyin) return exactPinyin;
const exactAbbr = stations.find((s) => s.abbr === lower || s.short === lower);
if (exactAbbr) return exactAbbr;
throw new ArgumentError(`Unknown 12306 station "${trimmed}"`, 'Try the Chinese name (上海虹桥), the 3-4 letter telecode (AOH), or full pinyin (shanghaihongqiao).');
}
export function validateDate(value) {
if (!DATE_RE.test(String(value ?? ''))) {
throw new ArgumentError(`date must be YYYY-MM-DD, got "${value}"`);
}
const [y, m, d] = value.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
throw new ArgumentError(`date "${value}" is not a real calendar date`);
}
return value;
}
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
export function buildCookieHeader(setCookieHeaders) {
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
return setCookieHeaders
.map((line) => line.split(';')[0])
.filter(Boolean)
.join('; ');
}
export async function fetchStationBundle(fetchImpl = fetch) {
const resp = await fetchImpl(STATION_BUNDLE_URL, {
headers: { 'User-Agent': UA },
});
if (!resp.ok) {
throw new CommandExecutionError(`Failed to fetch 12306 station bundle: HTTP ${resp.status}`);
}
return parseStationBundle(await resp.text());
}
/** Mint a 12306 anonymous session by hitting /otn/leftTicket/init. */
export async function mintSession(fetchImpl = fetch) {
const resp = await fetchImpl(INIT_URL, {
headers: { 'User-Agent': UA },
redirect: 'follow',
});
if (!resp.ok) {
throw new CommandExecutionError(`Failed to mint 12306 session: HTTP ${resp.status}`);
}
const setCookies = typeof resp.headers.getSetCookie === 'function'
? resp.headers.getSetCookie()
: resp.headers.raw?.()['set-cookie'] || [];
const cookieHeader = buildCookieHeader(setCookies);
if (!cookieHeader) {
throw new CommandExecutionError('12306 init returned no session cookies');
}
return cookieHeader;
}
/**
* Twelve-row train query record (LEFT_TICKET_DTO).
*
* 12306 returns each train as a `|`-separated string with ~36 fields.
* Positions used here come from the public web client; unused
* positions are documented inline so future maintainers can extend
* the row shape without re-reverse-engineering.
*/
export function parseTrainRecord(line, stationByCode) {
const f = line.split('|');
if (f.length < 33) return null;
return {
train_no: f[2] || '',
code: f[3] || '',
from_station: stationByCode.get(f[6])?.name || f[6] || '',
to_station: stationByCode.get(f[7])?.name || f[7] || '',
from_code: f[6] || '',
to_code: f[7] || '',
start_time: f[8] || '',
arrive_time: f[9] || '',
duration: f[10] || '',
available: (f[1] || '').trim() === '预订' || (f[11] || '').trim() === 'Y',
business_seat: f[32] || '',
first_seat: f[31] || '',
second_seat: f[30] || '',
soft_sleeper: f[23] || '',
hard_sleeper: f[28] || '',
hard_seat: f[29] || '',
no_seat: f[26] || '',
};
}
/**
* Mask helpers for sensitive identity fields rendered by 12306.
*
* 12306 already masks ID numbers and mobile numbers server-side
* (`xxxx***********xxx` / `138****xxxx`); these helpers handle the
* remaining fields (email, real Chinese name) so the adapter never
* leaks unmasked PII without an explicit `--include-sensitive` opt-in.
*/
export function maskEmail(value) {
const v = String(value || '').trim();
if (!v) return '';
const at = v.indexOf('@');
if (at <= 0) return v;
const local = v.slice(0, at);
const domain = v.slice(at);
if (local.length <= 2) return local[0] + '*' + domain;
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local.slice(-1) + domain;
}
export function maskMobile(value) {
const v = String(value || '').trim();
if (!v) return '';
if (/\*/.test(v)) return v;
if (v.length < 7) return v.replace(/.(?=.)/g, '*');
return v.slice(0, 3) + '*'.repeat(v.length - 7) + v.slice(-4);
}
export function maskChineseName(value) {
const v = String(value || '').trim();
if (!v) return '';
if (v.length === 1) return v;
if (v.length === 2) return v[0] + '*';
return v[0] + '*'.repeat(v.length - 2) + v.slice(-1);
}
export function unwrapEvaluateResult(value) {
if (
value
&& typeof value === 'object'
&& !Array.isArray(value)
&& Object.prototype.hasOwnProperty.call(value, 'session')
&& Object.prototype.hasOwnProperty.call(value, 'data')
) {
return value.data;
}
return value;
}
export function requireEvaluateObject(value, label) {
const payload = unwrapEvaluateResult(value);
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError(`12306 ${label} returned a malformed browser payload`);
}
return payload;
}
export function isAuthLikePayload(payload) {
if (!payload || typeof payload !== 'object') return false;
const parts = [];
if (Array.isArray(payload.messages)) parts.push(...payload.messages);
if (payload.message) parts.push(payload.message);
if (payload.msg) parts.push(payload.msg);
if (payload.validateMessages && typeof payload.validateMessages === 'object') {
parts.push(...Object.values(payload.validateMessages).flat());
}
const text = parts.map((item) => String(item ?? '')).join(' ');
return /未登录|登录|请登录|身份|认证|session|Session|login/i.test(text);
}
/**
* Detect the 12306 login marker by reading `document.cookie` from the
* current adapter page. Cannot use `page.getCookies({url})` here:
* 12306 sets the auth cookie `tk` and `JSESSIONID` with `Path=/otn`,
* and CDP `Network.getCookies` with a bare URL filter excludes
* cookies whose path does not match the URL path. `document.cookie`
* returns all non-httponly cookies visible to the current page
* regardless of path, which is what we need to confirm login.
*/
export async function require12306Login(page, AuthRequiredErrorClass) {
const docCookie = unwrapEvaluateResult(await page.evaluate(`document.cookie || ''`));
const cookieStr = typeof docCookie === 'string' ? docCookie : '';
if (!/\btk=/.test(cookieStr) || !/JSESSIONID=/.test(cookieStr)) {
throw new AuthRequiredErrorClass('kyfw.12306.cn', 'Not logged into 12306. Sign in at https://kyfw.12306.cn first.');
}
}
export const __test__ = {
parseStationBundle,
resolveStation,
validateDate,
buildCookieHeader,
parseTrainRecord,
maskEmail,
maskMobile,
maskChineseName,
unwrapEvaluateResult,
requireEvaluateObject,
isAuthLikePayload,
};