12306 (中国铁路 China Railway)

May 18, 2026 · View on GitHub

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

CommandModeDescription
opencli 12306 stationsPublicSearch the station bundle by Chinese name, telecode, full pinyin, or short alias
opencli 12306 trainsPublicTrains between two stations on a given date with per-seat-class availability
opencli 12306 trainPublicStop list (arrive / depart / stopover time) for one train segment
opencli 12306 pricePublicTicket prices by seat class for one train segment + date
opencli 12306 meBrowser (cookie)Logged-in account summary (sensitive fields masked by default)
opencli 12306 passengersBrowser (cookie)Saved passenger list (sensitive fields masked by default)
opencli 12306 ordersBrowser (cookie)In-progress orders (not yet ridden / refunded / completed)

Usage Examples

# 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

ColumnNotes
nameChinese station name
code3-4 letter telecode (use in API URLs)
pinyinFull pinyin
abbrShort alias
cityChinese city name

trains

ColumnNotes
codePublic train code (G1, D301, ...)
from_station / to_stationChinese station names
from_code / to_codeTelecodes
start_time / arrive_time / durationHH:MM format
availabletrue when 12306 shows 预订 status
business_seat / first_seat / second_seatAvailability for 商务座 / 一等座 / 二等座 (有 / 无 / number string)
soft_sleeper / hard_sleeper / hard_seat / no_seatSame shape for 软卧 / 硬卧 / 硬座 / 无座
train_noInternal id used by train and price commands

train

ColumnNotes
station_no1-based stop index within this train's route
station_nameChinese name
arrive_time / start_time / stopover_timeEmpty string for the origin (no arrive) and destination (no stopover)

price

ColumnNotes
seat_code12306 seat letter (A9 商务座, M 一等座, O 二等座, WZ 无座, A1 硬座, A3 硬卧, A4 软卧, F 动卧, P 特等座)
seat_nameLocalised Chinese label
priceDecimal string (CNY)
currencyAlways 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

ColumnNotes
username12306 login name (loginUserDTO.user_name)
real_nameMasked by default; --include-sensitive to opt in
emailLocal-part masked by default
mobile12306 already masks server-side (xxx****xxxx) and the adapter preserves that
birth_dateYear only by default
sex, country, user_type / , ISO-3, e.g. 成人
member, activeBoolean flags

passengers

ColumnNotes
nameMasked by default to <surname>*<...>
sex /
born_yearYear only by default
id_typee.g. 居民身份证
id_no12306 masks server-side; the adapter never decodes
mobileSame as me
passenger_type成人, 儿童, 学生, 残军
countryISO-3

orders

ColumnNotes
order_id12306 sequence_no
order_dateOrder placement timestamp
train_codePublic train code
from_station / to_stationChinese names
departureDeparture timestamp
passengersComma-separated passenger names from the order; masked by default, --include-sensitive to opt in
status12306 ticket / order status name
amountTotal 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)