Purpose
Return the list of trains running between two stations on a given date on China Railway's official site (12306.cn) — train number, departure / arrival station and time, journey duration, and per-class seat availability. Schedule data only; ticket prices and booking require an authenticated session and are out of scope. Read-only.
When to Use
- "What trains run from Beijing to Shanghai on 2026-05-26?"
- Mainland China rail itinerary planning (Beijing/Shanghai/Guangzhou/ Chengdu / any HSR or conventional rail city).
- Comparing G (high-speed) vs D (动车 EMU) vs Z/T/K (conventional) train options for a corridor.
- Any flow that needs schedule + seat-class availability without booking. Booking, real-time seat counts past 0/1, and ticket prices need a logged-in flow and a different skill.
Workflow
12306 ships a public JSON endpoint at kyfw.12306.cn/otn/leftTicket/queryO
that returns the full schedule for any origin/destination/date — same
data the official web UI renders, no login, no captcha, no rate-limit
in normal use. The English site www.12306.cn/en/ is a marketing /
FAQ landing page only; its "Search" button does nothing useful for
querying schedules. The Chinese-language kyfw.12306.cn is the only
surface that returns real data.
The complication: kyfw.12306.cn is not resolvable from a typical
non-China egress (DNS or TCP block depending on path). Browserbase's
remote browser pool routes through endpoints that do resolve it — so
the cheapest reliable path is:
Use
browserless_agentwith a residential proxy. Passproxy: { proxy: "residential" }as a top-level arg on the call. The proxy is required — without itkyfw.12306.cnoften does not resolve from a non-China egress. The proxy also gives the session a real residential IP so thenc.jsAlibaba anti-bot probe ong.alicdn.comstays quiet (this gives you a stealthed, residential-proxied session). Steps 3–4 below run as the orderedcommandsof onebrowserless_agentcall so they share the same proxied session and its/otn/...cookies; if you split across multiple calls, repeatproxyon every one:{ "rationale": "Querying 12306 train schedule", "proxy": { "proxy": "residential" }, "commands": [/* goto init → waitForTimeout → evaluate, see steps 3–4 */], }If a slider captcha ever appears (over-synthetic session), the
browserless_agentsolvecommand can attempt it before you retry with a fresh call.Resolve from / to station to 12306 telecodes. The station-code dictionary is served as a JS literal at
https://www.12306.cn/en/js/core/framework/station_name.js(~115 KB, no proxy needed —www.12306.cnresolves anywhere). The payload is one big string:var station_names = '@bjb|北京北|VAP|beijingbei|bjb|0@bjd|北京东|BOP|beijingdong|bjd|1@bji|北京|BJP|beijing|bj|2@bjn|北京南|VNP|beijingnan|bjn|3...';Per record:
pinyin_abbr|chinese_name|telecode|full_pinyin|short_pinyin|sort_idx. Use the 3-lettertelecode(BJP, VNP, AOH, SHH, ...) — that's what the query API consumes. Pin city-level codes (BJP=北京, SHH=上海) when the user gives a city name; pin specific-station codes (VNP=北京南, AOH=上海虹桥) when they specify the station. City-level codes return trains from every station in that city (verified: BJP→SHH and VNP→AOH return the same 54-train set for Beijing→Shanghai on 2026-05-26 — the API treats top-N station codes as a city alias).Establish session cookies by opening any kyfw page. The query endpoint needs the
JSESSIONID,BIGipServerotn, androutecookies that any first/otn/...page sets. First two commands of the call — open the init page and let the anti-botg.alicdn.com/sd/ncpc/nc.jsprobe settle:{ "method": "goto", "params": { "url": "https://kyfw.12306.cn/otn/leftTicket/init", "waitUntil": "load", "timeout": 45000 } }, { "method": "waitForTimeout", "params": { "time": 4000 } }Call the schedule API from the page context — the third command, an
evaluate. Running the fetch in the page context auto-attaches the/otn/...cookies and a same-originReferer; no UI interaction needed. Return a JSON string (evaluategives it back under.value):{ "method": "evaluate", "params": { "content": "(async()=>{const r=await fetch('https://kyfw.12306.cn/otn/leftTicket/queryO?leftTicketDTO.train_date=2026-05-26&leftTicketDTO.from_station=VNP&leftTicketDTO.to_station=AOH&purpose_codes=ADULT',{headers:{'X-Requested-With':'XMLHttpRequest'}});return JSON.stringify(await r.json());})()", }, }The endpoint set is
queryO(all train types — preferred default),queryG(high-speed only — G/D/C trains),queryAandqueryE(legacy aliases, behave identically toqueryOas of 2026-05). The page-context fetch automatically attaches the right cookies and a same-originReferer.Parse the response. Top-level shape:
{ "httpstatus": 200, "data": { "result": ["<train1-pipe-string>", "<train2-pipe-string>", ...], "map": { "VNP": "北京南", "AOH": "上海虹桥", "SHH": "上海", ... }, "flag": "1", "level": "...", "sametlc": "..." } }Each entry in
data.result[]is a single|-separated positional string of ~50 fields. Reference field positions (0-indexed, aftersplit('|')):idx field example 0 secret_str(URL-encoded book token)4NMzznPw13...1 button_text(预订=Book /候补=Waitlist /--=N/A)预订2 train_no(internal id)240000G547003 station_train_code(user-visible)G5474 start_station_telecode(line origin)VNP5 end_station_telecode(line terminus)AOH6 from_station_telecode(this query's origin)VNP7 to_station_telecode(this query's destination)AOH8 start_time(HH:MM)06:189 arrive_time(HH:MM)12:1110 lishi(duration HH:MM, may span next day)05:5311 can_web_buy(Y/N)Y12 yp_info(URL-encoded encrypted seat-price block)siXk2hk%2F...13 start_train_date(YYYYMMDD)2026052614 train_seat_feature315 location_codeP316 from_station_no(stop index of from_station on the train's route)0117 to_station_no1318 is_support_card119 controlled_train_flag032 swz_num(商务座 — Business)1/有/无/""33 tz_num(特等座 — Special, on D/Z trains)""34 zy_num(一等座 — First Class)有35 ze_num(二等座 — Second Class)有36 gr_num(高级软卧 — Premier Soft Sleeper)""37 rw_num(软卧 — Soft Sleeper)""38 yw_num(硬卧 — Hard Sleeper)""39 rz_num(软座 — Soft Seat)""40 yz_num(硬座 — Hard Seat)""41 wz_num(无座 — Standing / No Seat)无44 seat_discount_info""45 seat_types(compact class-list — each char = one class)9MOOSeat-count values: an integer (exact remaining count when the railway publishes it — typically only 0–20 are exposed precisely), 有(available, exact count not disclosed),无(sold out), or""(class not offered on this train). The seat_typesenum chars atindex 45 map to: 9=商务座,P=特等座,M=一等座,O=二等座,6=高级软卧,4=软卧,F=动卧,3=硬卧,2=软座,1=硬座,W=无座,D=其他/动卧 variants. Useseat_typesto know whichclasses a given train can offer; cross-check against the per-class fields to know which are sold out / sold-out / available. Map
from_station_telecodeandto_station_telecodeto display names viadata.map— that response sub-object is keyed by telecode and only contains the stations actually referenced in the result set (typically 5–10 entries, not the full dictionary). If a telecode in the result is not indata.map(rare; small or freight stations), fall back to the globalstation_name.jsdictionary.Optional filters. The API returns the full schedule unconditionally — there are no server-side filter params for train class, departure time, or seat class. Filter client-side:
- High-speed only → keep rows where
station_train_codestarts withG,D, orC. (Equivalent to callingqueryGinstead ofqueryO.) - Available only → drop rows where every seat field in 32..41 is
无or""(andbutton_textis not预订).
- High-speed only → keep rows where
No session release step. There is nothing to release — but not because the session dies on return. A
browserless_agentsession persists across separate calls, keyed by theproxyconfig: a later call carrying the sameproxyreconnects to the same warmed browser with its/otn/...cookies intact. To run another query, issue a new call withproxyset again; if you drop or changeproxyyou land in a different, blank session (which re-establishes the/otn/...cookies on its own initgoto).The station-code dictionary (step 2,
station_name.jsonwww.12306.cn, which resolves anywhere) is cheapest to fetch inside the same proxiedbrowserless_agentcall as an extraevaluatestep, or from abrowserless_function— but note that function sandbox runs in a browser page context (no Node, a barefetchhas no egress), sopage.goto('https://www.12306.cn/')first, thenpage.evaluate(() => fetch('/.../station_name.js').then(r=>r.text())). Cache the parsed dictionary; it changes rarely.
Browser fallback
When the JSON API is unavailable (Alibaba probe fails / session denied
mid-query), the same data is fetched into the page's results table at
https://kyfw.12306.cn/otn/leftTicket/init. In a single browserless_agent
call (keep proxy set), seed the four cookies via evaluate, then
navigate and click the search button:
{ "method": "evaluate", "params": { "content":
"(()=>{document.cookie='_jc_save_fromStation='+encodeURIComponent('北京')+'%2CBJP; path=/';document.cookie='_jc_save_toStation='+encodeURIComponent('上海')+'%2CSHH; path=/';document.cookie='_jc_save_fromDate=2026-05-26; path=/';document.cookie='_jc_save_wfdc_flag=dc; path=/';return 'ok';})()" } },
{ "method": "goto", "params": { "url": "https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc", "waitUntil": "load", "timeout": 45000 } },
{ "method": "click", "params": { "selector": "#query_ticket" } },
{ "method": "waitForTimeout", "params": { "time": 3000 } },
{ "method": "html", "params": { "selector": "#queryLeftTable" } }(查询 is #query_ticket, class .btn92s; the results table is
#queryLeftTable.) The same queryO XHR fires from the JS bundle and
populates the table; parse the returned HTML rows. This costs ~5× more
than the direct API path because the table renders progressively and
per-row seat cells use font-icon spans — prefer step 4's page-context
fetch. Note evaluate-set cookies and the subsequent goto must be in
the same call (same session) or the cookies won't be present.
Site-Specific Gotchas
kyfw.12306.cnis geo-IP / DNS restricted from most non-China egress. A plaincurlfrom a non-China network fails withCould not resolve host: kyfw.12306.cn. Thebrowserless_agentresidential proxy (proxy:{proxy:"residential"}) routes through an endpoint that resolves it — that arg is mandatory for any query. If it still won't resolve, pin a China-adjacentproxyCountry. The marketing hostwww.12306.cn(used forstation_name.jsand the English info pages) is reachable everywhere.- English-language site is a decoy for schedule queries.
https://www.12306.cn/en/index.htmlhas aFrom / To / Date / Searchform but its Search button is not wired to the production query API — it dead-ends. Do not waste turns trying to drive the English UI; use the Chinesekyfw.12306.cnJSON API. - City-code = top-N-station alias. Passing the city-level telecode
(
BJPfor Beijing,SHHfor Shanghai,CDUfor Chengdu, ...) returns the same result set as passing the city's primary HSR station (VNP,AOH,IPH, ...). It does not restrict to trains terminating at the small "main" station. Verified 2026-05-19:BJP→SHH,BJP→AOH, andVNP→AOHall returned the identical 54-train set for 2026-05-26. To filter to a specific station, post-filter the result onfrom_station_telecode/to_station_telecode(indices 6 and 7). queryGvsqueryOvsqueryA/queryE. All four endpoints exist and return the same JSON shape.queryGis what the official UI calls when "High-speed only" is checked — but it actually returns the same rows asqueryO(it filters client-side in the JS bundle; the API response is identical). Default toqueryO. The endpoint name appears to flip occasionally during 12306 schedule-version rollovers — if one 404s or 302s tomormhweb/logFiles/error.html, try the next one in[queryO, queryG, queryA, queryE]./mormhweb/logFiles/error.html302 = session missing. CallingqueryOwithout first hitting any/otn/...page in the same browser session returns302 → error.htmlbecause the load-balancer cookies (BIGipServerotn,JSESSIONID,route) are not set. Always openhttps://kyfw.12306.cn/otn/leftTicket/init(or any/otn/path) once per session before calling the API.g.alicdn.com/sd/ncpc/nc.jsruns on every page load. This is Alibaba's anti-bot probe (the samenc.jsthat backs Taobao's slider captcha). It does not gate the schedule API in our trace — but it does run, takes ~2 s, and can stall the page-context fetch if the session is too obviously synthetic. The residential proxy + real Chrome of abrowserless_agentsession keeps the probe quiet; a bare/un-proxied session can see the slider captcha within ~5 page loads — if it appears, try thesolvecommand, then fall back to a fresh call.- Prices are not in the public response. Field 12 (
yp_info) is a URL-encoded base64 blob; decryption requires the per-session AES key that 12306 ships only after login. The unauthenticated/otn/leftTicketPrice/queryAllPublicPrice?...endpoint returns 200 OK withdata: [](verified 2026-05-19) — confirmed dead end. Document price asnullin the schema and tell users to check the app for fares. Booking is a strictly authenticated, captcha-gated flow that this read-only skill does not attempt. - Seat-count semantics are deliberately fuzzy. The Railway
publishes exact remaining seats only when the count is low (commonly
0–20). Above that threshold the field is
有("available, count redacted") regardless of whether 30 or 800 seats remain.无= truly sold out. Empty string = the train does not offer that class. Do not paper over this — surfaceavailable_countasint | "有" | "无" | nullin the JSON output, not as a coerced integer. - Date precision: depart date only. The query has no time-window
filter. Trains crossing midnight are included;
arrive_time's clock rolls paststart_timeandlishi(duration) is the source of truth for overnight detection. - Booking-window cutoff. China Railway opens 15-day forward
booking. Queries for dates beyond
today + 15 daysreturndata.result: []with amessageswarning string. Within the window, even unscheduled days (very early-morning queries on the day-of-opening) can briefly return empty before the daily seat release at 5:00 AM China time. - Station-code dictionary versions. The path
/en/js/core/framework/station_name.jsis stable; the Chinese-language path includes a_v<N>suffix (/index/script/core/common/station_name_v10198.js) that rev-locks and 302s toerror.htmlon a stale version. Always use the un-versioned English-side URL. - Don't bother with
snapshotfor the results table. The<table>populates from JS after the XHR, with per-class seat status rendered as styled<td>text — but the accessibility-tree snapshot returns ~280–400 refs and the table cells aren't reliably enumerated as a list. Read the JSON directly (step 4); only fall back tohtml-scraping#queryLeftTableif the API path itself is blocked (we did not observe a block in 2 iters of testing).
Expected Output
Three distinct outcome shapes:
// Success — schedule returned
{
"success": true,
"from": { "telecode": "VNP", "name": "北京南", "name_en": "Beijing South", "city": "Beijing" },
"to": { "telecode": "AOH", "name": "上海虹桥", "name_en": "Shanghai Hongqiao", "city": "Shanghai" },
"date": "2026-05-26",
"queried_at_utc": "2026-05-19T18:11:30Z",
"train_count": 54,
"trains": [
{
"train_no": "G547",
"train_no_internal": "240000G54700",
"from": { "telecode": "VNP", "name": "北京南" },
"to": { "telecode": "AOH", "name": "上海虹桥" },
"start_time": "06:18",
"arrive_time": "12:11",
"duration": "05:53",
"from_stop_index": 1,
"to_stop_index": 13,
"can_web_buy": true,
"seat_types_offered": ["business", "first_class", "second_class"],
"seats": {
"business": { "status": "available", "count": 1, "price_cny": null },
"first_class": { "status": "available", "count": "有", "price_cny": null },
"second_class": { "status": "available", "count": "有", "price_cny": null }
},
"button_text": "预订"
}
],
"error_reasoning": null
}
// No trains — date out of booking window or no service
{
"success": true,
"from": { "telecode": "VNP", "name": "北京南" },
"to": { "telecode": "AOH", "name": "上海虹桥" },
"date": "2026-07-15",
"train_count": 0,
"trains": [],
"messages": ["请您选择正确的查询日期,您还可预订15天内的车票。"],
"error_reasoning": "Outside 15-day booking window"
}
// Blocked — session denied / anti-bot wall
{
"success": false,
"from": { "telecode": "VNP", "name": "北京南" },
"to": { "telecode": "AOH", "name": "上海虹桥" },
"date": "2026-05-26",
"trains": [],
"error_reasoning": "queryO 302→/mormhweb/logFiles/error.html — session cookies missing or kyfw.12306.cn unreachable (proxy required)"
}Note: per-class price_cny is always null for unauthenticated
queries — the encrypted yp_info blob (field 12) requires a logged-in
AES key to decrypt. Surface null honestly; do not guess.