# 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 `<from>` and `<to>` 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 `<surname>*<...>` |
| `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 `<first>***<last>@<domain>`
- **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
  `<first-char>*<last-char>` (or `<first-char>*` 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)
