mirror of
https://github.com/jackwener/OpenCLI.git
synced 2026-09-14 18:25:42 +08:00
e82e32abc6
* 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>
273 lines
10 KiB
JavaScript
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,
|
|
};
|