# 12306 (中国铁路 China Railway) **Mode**: 🌐 Public (`stations`, `trains`, `train`, `price`) · 🖥️ Browser + Cookie (`me`, `passengers`, `orders`) **Domain**: `kyfw.12306.cn` Read-only adapter against the 12306 (China Railway) site. Anonymous queries hit the public ticket-search endpoints; authenticated queries reuse the user's existing browser login state. No CAPTCHA / slider / SMS / anti-abuse bypass is performed, no credentials are stored, and no booking / payment / ticket-sniping is implemented. ## Commands | Command | Mode | Description | |---------|------|-------------| | `opencli 12306 stations` | Public | Search the station bundle by Chinese name, telecode, full pinyin, or short alias | | `opencli 12306 trains` | Public | Trains between two stations on a given date with per-seat-class availability | | `opencli 12306 train` | Public | Stop list (arrive / depart / stopover time) for one train segment | | `opencli 12306 price` | Public | Ticket prices by seat class for one train segment + date | | `opencli 12306 me` | Browser (cookie) | Logged-in account summary (sensitive fields masked by default) | | `opencli 12306 passengers` | Browser (cookie) | Saved passenger list (sensitive fields masked by default) | | `opencli 12306 orders` | Browser (cookie) | In-progress orders (not yet ridden / refunded / completed) | ## Usage Examples ```bash # Search stations opencli 12306 stations 上海 --limit 5 opencli 12306 stations beijing opencli 12306 stations AOH # by telecode # List trains between two stations opencli 12306 trains 北京 上海 --date 2026-05-22 --limit 20 opencli 12306 trains BJP AOH --date 2026-05-22 # Show stops for one train opencli 12306 train 24000000G10L --from 北京南 --to 上海虹桥 --date 2026-05-22 # Ticket prices by seat class opencli 12306 price 24000000G10L --from 北京南 --to 上海虹桥 --date 2026-05-22 # Authenticated commands (require login on kyfw.12306.cn first) opencli 12306 me opencli 12306 me --include-sensitive opencli 12306 passengers --limit 10 opencli 12306 orders opencli 12306 orders --include-sensitive # JSON output opencli 12306 trains 北京 上海 --date 2026-05-22 -f json ``` ## Station Resolution The `` and `` arguments on `trains`, `train`, and `price` accept any of the following forms; lookup is anchored against the public `station_name.js` bundle 12306 ships: - **Chinese name** (`上海虹桥`) - **Telecode** (`AOH`, 3-4 uppercase letters, the wire format used in 12306's own API) - **Full pinyin** (`shanghaihongqiao`, case-insensitive) - **Short alias / abbr** (`shhq`) Anything else raises `ArgumentError` with a hint pointing to the accepted forms. Match order is exact-name first, so `北京` does not accidentally resolve to `北京北` by substring. ## Columns ### `stations` | Column | Notes | |--------|-------| | `name` | Chinese station name | | `code` | 3-4 letter telecode (use in API URLs) | | `pinyin` | Full pinyin | | `abbr` | Short alias | | `city` | Chinese city name | ### `trains` | Column | Notes | |--------|-------| | `code` | Public train code (`G1`, `D301`, ...) | | `from_station` / `to_station` | Chinese station names | | `from_code` / `to_code` | Telecodes | | `start_time` / `arrive_time` / `duration` | `HH:MM` format | | `available` | `true` when 12306 shows 预订 status | | `business_seat` / `first_seat` / `second_seat` | Availability for 商务座 / 一等座 / 二等座 (有 / 无 / number string) | | `soft_sleeper` / `hard_sleeper` / `hard_seat` / `no_seat` | Same shape for 软卧 / 硬卧 / 硬座 / 无座 | | `train_no` | Internal id used by `train` and `price` commands | ### `train` | Column | Notes | |--------|-------| | `station_no` | 1-based stop index within this train's route | | `station_name` | Chinese name | | `arrive_time` / `start_time` / `stopover_time` | Empty string for the origin (no arrive) and destination (no stopover) | ### `price` | Column | Notes | |--------|-------| | `seat_code` | 12306 seat letter (`A9` 商务座, `M` 一等座, `O` 二等座, `WZ` 无座, `A1` 硬座, `A3` 硬卧, `A4` 软卧, `F` 动卧, `P` 特等座) | | `seat_name` | Localised Chinese label | | `price` | Decimal string (CNY) | | `currency` | Always `CNY` | Rows are sorted by descending price. 12306 double-encodes some prices as bare numeric keys (e.g. `"9": "21580"` mirroring `"A9": "¥2158.0"` in no-decimal form); the bare-numeric duplicates are filtered out so each seat class appears exactly once. ### `me` | Column | Notes | |--------|-------| | `username` | 12306 login name (`loginUserDTO.user_name`) | | `real_name` | Masked by default; `--include-sensitive` to opt in | | `email` | Local-part masked by default | | `mobile` | 12306 already masks server-side (`xxx****xxxx`) and the adapter preserves that | | `birth_date` | Year only by default | | `sex`, `country`, `user_type` | `男` / `女`, ISO-3, e.g. `成人` | | `member`, `active` | Boolean flags | ### `passengers` | Column | Notes | |--------|-------| | `name` | Masked by default to `*<...>` | | `sex` | `男` / `女` | | `born_year` | Year only by default | | `id_type` | e.g. `居民身份证` | | `id_no` | 12306 masks server-side; the adapter never decodes | | `mobile` | Same as `me` | | `passenger_type` | `成人`, `儿童`, `学生`, `残军` | | `country` | ISO-3 | ### `orders` | Column | Notes | |--------|-------| | `order_id` | 12306 sequence_no | | `order_date` | Order placement timestamp | | `train_code` | Public train code | | `from_station` / `to_station` | Chinese names | | `departure` | Departure timestamp | | `passengers` | Comma-separated passenger names from the order; masked by default, `--include-sensitive` to opt in | | `status` | 12306 ticket / order status name | | `amount` | Total price (CNY) | ## Login The three authenticated commands (`me`, `passengers`, `orders`) require a logged-in 12306 session in the same Chrome instance that OpenCLI's bridge talks to. Sign in once at `https://kyfw.12306.cn`; the adapter then reads the resulting `tk` / `JSESSIONID` cookies on subsequent runs. Login is detected via `document.cookie` rather than `page.getCookies({url})`, because 12306 sets the auth cookies with `Path=/otn` and CDP `Network.getCookies` filters by URL path - a bare `https://kyfw.12306.cn` URL filter would otherwise hide them. If `tk` or `JSESSIONID` is missing, the adapter raises `AuthRequiredError`. ## Privacy Authenticated commands mask sensitive fields by default: - **Email**: local part is `***@` - **Mobile**: 12306's own mask is preserved (the adapter never tries to decode it) - **Real name / passenger/order passenger name**: Chinese names are masked to `*` (or `*` for two-character names) - **Birth date**: year only Pass `--include-sensitive` on `me`, `passengers`, or `orders` to opt back into the unmasked fields the user is entitled to see on their own account. The 12306-side ID number mask is server-side and is never decoded. ## Limit Validation All `--limit` arguments use a strict validator that throws `ArgumentError` on non-integer / out-of-range input rather than silently clamping. The caps are: - `stations`: 1-50 (default 20) - `trains`: 1-100 (default 50) - `passengers`: 1-50 (default 20) ## Endpoint Notes - `/otn/leftTicket/init` is hit first to mint anonymous session cookies (`JSESSIONID`, `route`, `BIGipServerotn`). - 12306 rotates the train-query endpoint name (`queryO`, `queryZ`, `queryA`, `queryG`, ...) every few weeks. The adapter walks a list of known names; when the server responds with `{c_url: "leftTicket/queryX"}` it captures the suggested name and retries. - The `|`-separated train wire record includes a base64 booking-handshake `secret` token at position 0. This adapter parses but does not surface that field (a unit test asserts it cannot leak via the row shape). ## Non-Goals - No ticket sniping - No order submission / payment - No CAPTCHA / slider / SMS / anti-abuse bypass - No password storage - No order history beyond the in-progress slice (left as a follow-up)