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>
97 lines
4.5 KiB
JavaScript
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 || '',
|
|
};
|
|
});
|
|
},
|
|
});
|