Commit e82e32a
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
- clis/12306
- docs/adapters/browser
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
2 | 299 | | |
3 | 300 | | |
4 | 301 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
0 commit comments