Skip to content

Commit e82e32a

Browse files
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>
1 parent 254d518 commit e82e32a

11 files changed

Lines changed: 1812 additions & 0 deletions

File tree

cli-manifest.json

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,301 @@
11
[
2+
{
3+
"site": "12306",
4+
"name": "me",
5+
"description": "Show the logged-in 12306 account summary. Sensitive fields (real name, email, mobile, birth date) are masked by default; pass --include-sensitive to opt in.",
6+
"access": "read",
7+
"domain": "kyfw.12306.cn",
8+
"strategy": "cookie",
9+
"browser": true,
10+
"args": [
11+
{
12+
"name": "include-sensitive",
13+
"type": "boolean",
14+
"default": false,
15+
"required": false,
16+
"help": "Reveal unmasked real name / email / mobile / birth date. The 12306 ID-number mask is server-side and never decoded."
17+
}
18+
],
19+
"columns": [
20+
"username",
21+
"real_name",
22+
"email",
23+
"mobile",
24+
"birth_date",
25+
"sex",
26+
"country",
27+
"user_type",
28+
"member",
29+
"active"
30+
],
31+
"type": "js",
32+
"modulePath": "12306/me.js",
33+
"sourceFile": "12306/me.js",
34+
"navigateBefore": "https://kyfw.12306.cn"
35+
},
36+
{
37+
"site": "12306",
38+
"name": "orders",
39+
"description": "List in-progress 12306 orders (not yet ridden, refunded, or completed) for the logged-in user",
40+
"access": "read",
41+
"domain": "kyfw.12306.cn",
42+
"strategy": "cookie",
43+
"browser": true,
44+
"args": [
45+
{
46+
"name": "include-sensitive",
47+
"type": "boolean",
48+
"default": false,
49+
"required": false,
50+
"help": "Reveal unmasked passenger names in order rows. Masked by default."
51+
}
52+
],
53+
"columns": [
54+
"order_id",
55+
"order_date",
56+
"train_code",
57+
"from_station",
58+
"to_station",
59+
"departure",
60+
"passengers",
61+
"status",
62+
"amount"
63+
],
64+
"type": "js",
65+
"modulePath": "12306/orders.js",
66+
"sourceFile": "12306/orders.js",
67+
"navigateBefore": "https://kyfw.12306.cn"
68+
},
69+
{
70+
"site": "12306",
71+
"name": "passengers",
72+
"description": "List the logged-in user's saved 12306 passengers. Sensitive fields are masked by default; pass --include-sensitive to opt in.",
73+
"access": "read",
74+
"domain": "kyfw.12306.cn",
75+
"strategy": "cookie",
76+
"browser": true,
77+
"args": [
78+
{
79+
"name": "limit",
80+
"type": "int",
81+
"default": 20,
82+
"required": false,
83+
"help": "Max passengers to return (1-50)"
84+
},
85+
{
86+
"name": "include-sensitive",
87+
"type": "boolean",
88+
"default": false,
89+
"required": false,
90+
"help": "Reveal unmasked real names and birth dates. The 12306 ID-number / mobile masks are server-side and never decoded."
91+
}
92+
],
93+
"columns": [
94+
"name",
95+
"sex",
96+
"born_year",
97+
"id_type",
98+
"id_no",
99+
"mobile",
100+
"passenger_type",
101+
"country"
102+
],
103+
"type": "js",
104+
"modulePath": "12306/passengers.js",
105+
"sourceFile": "12306/passengers.js",
106+
"navigateBefore": "https://kyfw.12306.cn"
107+
},
108+
{
109+
"site": "12306",
110+
"name": "price",
111+
"description": "Look up 12306 ticket prices by seat class for one train on a given date and segment (anonymous, no login required)",
112+
"access": "read",
113+
"domain": "kyfw.12306.cn",
114+
"strategy": "public",
115+
"browser": false,
116+
"args": [
117+
{
118+
"name": "train-no",
119+
"type": "str",
120+
"required": true,
121+
"positional": true,
122+
"help": "Internal train_no from `12306 trains` (e.g. 24000000G10L)"
123+
},
124+
{
125+
"name": "from",
126+
"type": "str",
127+
"required": true,
128+
"help": "Origin station (Chinese name, telecode, or pinyin) - must be a stop of this train"
129+
},
130+
{
131+
"name": "to",
132+
"type": "str",
133+
"required": true,
134+
"help": "Destination station - must be a stop of this train"
135+
},
136+
{
137+
"name": "date",
138+
"type": "str",
139+
"required": true,
140+
"help": "Departure date in YYYY-MM-DD"
141+
},
142+
{
143+
"name": "seat-types",
144+
"type": "str",
145+
"default": "OM9PA1A3A4FWZ",
146+
"required": false,
147+
"help": "Seat-type letters to query (default covers the common classes). Examples: OM9 (二等/一等/商务), A1A3A4 (硬座/硬卧/软卧)."
148+
}
149+
],
150+
"columns": [
151+
"seat_code",
152+
"seat_name",
153+
"price",
154+
"currency"
155+
],
156+
"type": "js",
157+
"modulePath": "12306/price.js",
158+
"sourceFile": "12306/price.js"
159+
},
160+
{
161+
"site": "12306",
162+
"name": "stations",
163+
"description": "Search 12306 (China Railway) stations by Chinese name, telecode, or pinyin keyword",
164+
"access": "read",
165+
"domain": "kyfw.12306.cn",
166+
"strategy": "public",
167+
"browser": false,
168+
"args": [
169+
{
170+
"name": "keyword",
171+
"type": "str",
172+
"required": true,
173+
"positional": true,
174+
"help": "Chinese substring (上海), telecode (AOH), or pinyin (shanghai)"
175+
},
176+
{
177+
"name": "limit",
178+
"type": "int",
179+
"default": 20,
180+
"required": false,
181+
"help": "Maximum results (1-50)"
182+
}
183+
],
184+
"columns": [
185+
"name",
186+
"code",
187+
"pinyin",
188+
"abbr",
189+
"city"
190+
],
191+
"type": "js",
192+
"modulePath": "12306/stations.js",
193+
"sourceFile": "12306/stations.js"
194+
},
195+
{
196+
"site": "12306",
197+
"name": "train",
198+
"description": "List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)",
199+
"access": "read",
200+
"domain": "kyfw.12306.cn",
201+
"strategy": "public",
202+
"browser": false,
203+
"args": [
204+
{
205+
"name": "train-no",
206+
"type": "str",
207+
"required": true,
208+
"positional": true,
209+
"help": "Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)"
210+
},
211+
{
212+
"name": "from",
213+
"type": "str",
214+
"required": true,
215+
"help": "Origin station for the segment: Chinese name, telecode, or pinyin"
216+
},
217+
{
218+
"name": "to",
219+
"type": "str",
220+
"required": true,
221+
"help": "Destination station for the segment"
222+
},
223+
{
224+
"name": "date",
225+
"type": "str",
226+
"required": true,
227+
"help": "Departure date in YYYY-MM-DD"
228+
}
229+
],
230+
"columns": [
231+
"station_no",
232+
"station_name",
233+
"arrive_time",
234+
"start_time",
235+
"stopover_time"
236+
],
237+
"type": "js",
238+
"modulePath": "12306/train.js",
239+
"sourceFile": "12306/train.js"
240+
},
241+
{
242+
"site": "12306",
243+
"name": "trains",
244+
"description": "List trains between two 12306 stations on a given date (anonymous, no login required)",
245+
"access": "read",
246+
"domain": "kyfw.12306.cn",
247+
"strategy": "public",
248+
"browser": false,
249+
"args": [
250+
{
251+
"name": "from",
252+
"type": "str",
253+
"required": true,
254+
"positional": true,
255+
"help": "Origin station: Chinese name (北京), telecode (BJP), or pinyin (beijing)"
256+
},
257+
{
258+
"name": "to",
259+
"type": "str",
260+
"required": true,
261+
"positional": true,
262+
"help": "Destination station: same forms as <from>"
263+
},
264+
{
265+
"name": "date",
266+
"type": "str",
267+
"required": true,
268+
"help": "Departure date in YYYY-MM-DD"
269+
},
270+
{
271+
"name": "limit",
272+
"type": "int",
273+
"default": 50,
274+
"required": false,
275+
"help": "Maximum rows (1-100)"
276+
}
277+
],
278+
"columns": [
279+
"code",
280+
"from_station",
281+
"to_station",
282+
"start_time",
283+
"arrive_time",
284+
"duration",
285+
"available",
286+
"business_seat",
287+
"first_seat",
288+
"second_seat",
289+
"soft_sleeper",
290+
"hard_sleeper",
291+
"hard_seat",
292+
"no_seat",
293+
"train_no"
294+
],
295+
"type": "js",
296+
"modulePath": "12306/trains.js",
297+
"sourceFile": "12306/trains.js"
298+
},
2299
{
3300
"site": "1688",
4301
"name": "assets",

clis/12306/me.js

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* 12306 account summary for the logged-in user.
3+
*
4+
* Returns non-sensitive identity fields plus masked email / mobile.
5+
* Use `--include-sensitive` to surface unmasked values from 12306's
6+
* own response (12306 already masks the ID number server-side; this
7+
* adapter never decodes that mask).
8+
*/
9+
import { cli, Strategy } from '@jackwener/opencli/registry';
10+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
11+
import { isAuthLikePayload, maskEmail, maskMobile, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
12+
13+
const ACCOUNT_INFO_URL = 'https://kyfw.12306.cn/otn/modifyUser/initQueryUserInfoApi';
14+
15+
cli({
16+
site: '12306',
17+
name: 'me',
18+
access: 'read',
19+
description: 'Show the logged-in 12306 account summary. Sensitive fields (real name, email, mobile, birth date) are masked by default; pass --include-sensitive to opt in.',
20+
domain: 'kyfw.12306.cn',
21+
strategy: Strategy.COOKIE,
22+
browser: true,
23+
args: [
24+
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real name / email / mobile / birth date. The 12306 ID-number mask is server-side and never decoded.' },
25+
],
26+
columns: ['username', 'real_name', 'email', 'mobile', 'birth_date', 'sex', 'country', 'user_type', 'member', 'active'],
27+
func: async (page, kwargs) => {
28+
if (!page) throw new CommandExecutionError('Browser session required for 12306 me');
29+
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
30+
await require12306Login(page, AuthRequiredError);
31+
const json = requireEvaluateObject(await page.evaluate(`async () => {
32+
const r = await fetch(${JSON.stringify(ACCOUNT_INFO_URL)}, { credentials: 'include' });
33+
if (!r.ok) return { __http: r.status };
34+
try {
35+
return await r.json();
36+
} catch (err) {
37+
return { __parse: String(err && err.message || err) };
38+
}
39+
}`), 'account info');
40+
if (json?.__http) {
41+
if ([401, 403].includes(Number(json.__http))) {
42+
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
43+
}
44+
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for account info`);
45+
}
46+
if (json?.__parse) {
47+
throw new CommandExecutionError(`12306 account info returned non-JSON body: ${json.__parse}`);
48+
}
49+
if (isAuthLikePayload(json)) {
50+
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
51+
}
52+
if (json?.status !== true || !json?.data?.userDTO) {
53+
throw new CommandExecutionError('12306 account info payload missing userDTO');
54+
}
55+
const dto = json.data.userDTO;
56+
const loginDto = dto.loginUserDTO || {};
57+
const username = loginDto.user_name || loginDto.name || '';
58+
const realName = loginDto.real_name || loginDto.realname || '';
59+
const include = kwargs['include-sensitive'] === true;
60+
return [{
61+
username,
62+
real_name: include ? realName : maskChineseName(realName),
63+
email: include ? (dto.email || '') : maskEmail(dto.email || ''),
64+
mobile: include ? (dto.mobile_no || '') : maskMobile(dto.mobile_no || ''),
65+
birth_date: include ? (dto.born_date || '') : (dto.born_date || '').slice(0, 4),
66+
sex: dto.sex_code === 'M' ? '男' : (dto.sex_code === 'F' ? '女' : ''),
67+
country: dto.country_code || '',
68+
user_type: json.data.userTypeName || '',
69+
member: dto.flag_member === '1',
70+
active: dto.is_active === '1',
71+
}];
72+
},
73+
});

0 commit comments

Comments
 (0)