Files
jackwener__opencli/clis/12306/orders.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

97 lines
4.5 KiB
JavaScript

/**
* 12306 in-progress orders for the logged-in user.
*
* Returns orders that have not yet been ridden / refunded / completed
* (the `noComplete` slice). Order history covering completed and
* refunded tickets uses a separate endpoint that requires extra
* referer / page-state handshakes and is left for a follow-up so this
* command can ship reliably.
*/
import { cli, Strategy } from '@jackwener/opencli/registry';
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
const NO_COMPLETE_URL = 'https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete';
cli({
site: '12306',
name: 'orders',
access: 'read',
description: 'List in-progress 12306 orders (not yet ridden, refunded, or completed) for the logged-in user',
domain: 'kyfw.12306.cn',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked passenger names in order rows. Masked by default.' },
],
columns: ['order_id', 'order_date', 'train_code', 'from_station', 'to_station', 'departure', 'passengers', 'status', 'amount'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('Browser session required for 12306 orders');
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
await require12306Login(page, AuthRequiredError);
const include = kwargs['include-sensitive'] === true;
const json = requireEvaluateObject(await page.evaluate(`async () => {
const r = await fetch(${JSON.stringify(NO_COMPLETE_URL)}, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: '_json_att=', credentials: 'include',
});
if (!r.ok) return { __http: r.status };
try {
return await r.json();
} catch (err) {
return { __parse: String(err && err.message || err) };
}
}`), 'orders');
if (json?.__http) {
if ([401, 403].includes(Number(json.__http))) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
}
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for queryMyOrderNoComplete`);
}
if (json?.__parse) {
throw new CommandExecutionError(`12306 orders returned non-JSON body: ${json.__parse}`);
}
if (isAuthLikePayload(json)) {
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
}
if (json?.status !== true) {
throw new CommandExecutionError('12306 queryMyOrderNoComplete returned a failure status');
}
let orders;
if (Array.isArray(json?.data?.orderDBList)) {
orders = json.data.orderDBList;
} else if (Array.isArray(json?.data?.orderDTODataList)) {
orders = json.data.orderDTODataList;
} else if (Array.isArray(json?.data?.orders)) {
orders = json.data.orders;
} else if (Array.isArray(json?.data)) {
orders = json.data;
} else {
throw new CommandExecutionError('12306 queryMyOrderNoComplete payload missing order list array');
}
if (orders.length === 0) {
throw new EmptyResultError('No in-progress 12306 orders on this account');
}
return orders.map((o) => {
const tickets = Array.isArray(o.tickets) ? o.tickets : [];
const passengerNames = tickets
.map((t) => t.passenger_name || '')
.filter(Boolean)
.map((name) => include ? name : maskChineseName(name))
.join(', ');
return {
order_id: o.sequence_no || o.order_id || o.sequenceNo || '',
order_date: o.order_date || '',
train_code: o.train_code_page || o.station_train_code || o.train_code || '',
from_station: o.from_station_name_page || o.from_station_name || '',
to_station: o.to_station_name_page || o.to_station_name || '',
departure: o.start_train_date_page || o.start_train_date || '',
passengers: passengerNames,
status: o.ticket_status_name || o.order_status_name || o.statusName || '',
amount: o.ticket_total_price_page || o.ticket_total_price || '',
};
});
},
});