
12306
- 583 installs
- 9 repo stars
- Updated March 5, 2026
- kirorab/12306-skill
12306 is a Node-based agent skill that queries China Railway 12306 for train schedules, remaining tickets, and station filters for developers who need terminal or agent access to China high-speed and conventional rail av
About
12306 is a kirorab agent skill that wraps China Railway's 12306 service with a Node script at scripts/query.mjs. Developers pass origin and destination station names—such as 北京 to 上海—with optional flags to retrieve schedules and remaining ticket counts for the current day by default. Output modes include HTML file generation that prints a saved path, or Markdown tables via the -f md flag printed directly to stdout. The skill metadata requires a node binary and targets agent workflows where users ask about 火车, 高铁, tickets, or schedules within China. Reach for 12306 when building travel assistants, internal trip planners, or automation that must surface live China rail inventory without writing scraper plumbing from scratch.
- Node `query.mjs` CLI: from/to stations plus rich filters (date, train type, time windows, seats)
- Output modes: HTML file path, markdown table to stdout, or JSON for downstream automation
- Filters for bookable-only trains, max duration, depart/arrive ranges, and seat classes (e.g. second class `ze`)
- Defaults to today’s date when `--date` is omitted
- Requires `node` on PATH per skill metadata
12306 by the numbers
- 583 all-time installs (skills.sh)
- +5 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #111 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/kirorab/12306-skill --skill 12306Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 583 |
|---|---|
| repo stars | ★ 9 |
| Security audit | 3 / 3 scanners passed |
| Last updated | March 5, 2026 |
| Repository | kirorab/12306-skill ↗ |
How do you query China 12306 train tickets from CLI?
Query China Railway 12306 schedules, remaining tickets, and station filters from the terminal when planning travel or building China rail features into an agent workflow.
Who is it for?
Developers building China rail-aware CLI tools or agents who need scripted 12306 schedule and availability lookups.
Skip if: International rail systems outside China or workflows that cannot install Node or execute bundled query scripts.
When should I use this skill?
A user asks about China train, 高铁, or 12306 tickets, schedules, stations, or availability in terminal or agent context.
What you get
HTML ticket report files or Markdown schedule tables with station filters and remaining seat counts.
- HTML schedule report file
- Markdown ticket availability table
Files
12306 Train Query
Query train schedules and remaining tickets from China Railway 12306.
Query Tickets
node {baseDir}/scripts/query.mjs <from> <to> [options]- HTML mode (default): writes file, prints path to stdout
- Markdown mode (
-f md): prints table to stdout
Examples
# All trains from Beijing to Shanghai (defaults to today)
node {baseDir}/scripts/query.mjs 北京 上海
# Markdown table output (to stdout, good for chat)
node {baseDir}/scripts/query.mjs 北京 上海 -t G -f md
# Morning departures, 2h max, with second class available
node {baseDir}/scripts/query.mjs 上海 杭州 -t G --depart 06:00-12:00 --max-duration 1h --seat ze
# Only bookable trains arriving before 6pm
node {baseDir}/scripts/query.mjs 深圳 长沙 --available --arrive -18:00
# Custom output path
node {baseDir}/scripts/query.mjs 广州 武汉 -o /tmp/tickets.html
# JSON output (to stdout)
node {baseDir}/scripts/query.mjs 广州 武汉 --jsonOptions
-d, --date <YYYY-MM-DD>: Travel date (default: today)-t, --type <G|D|Z|T|K>: Filter train types (combinable, e.g.GD)--depart <HH:MM-HH:MM>: Depart time range (e.g.08:00-12:00,18:00-)--arrive <HH:MM-HH:MM>: Arrive time range (e.g.-18:00,14:00-20:00)--max-duration <duration>: Max travel time (e.g.2h,90m,1h30m)--available: Only show bookable trains--seat <types>: Only show trains with tickets for given seat types (comma-separated:swz,zy,ze,rw,dw,yw,yz,wz)-f, --format <html|md>: Output format —html(default, saves file) ormd(markdown table to stdout)-o, --output <path>: Output file path, html mode only (default:{baseDir}/data/<from>-<to>-<date>.html)--json: Output raw JSON to stdout
Output Columns
| Column | Meaning |
|---|---|
| 商务/特等 | Business class / Premium (swz) |
| 一等座 | First class (zy) |
| 二等座 | Second class (ze) |
| 软卧/动卧 | Soft sleeper / Bullet sleeper (rw/dw) |
| 硬卧 | Hard sleeper (yw) |
| 硬座 | Hard seat (yz) |
| 无座 | Standing (wz) |
Values: number = remaining seats, 有 = available (qty unknown), — = not applicable
Station Lookup
node {baseDir}/scripts/stations.mjs 杭州
node {baseDir}/scripts/stations.mjs 香港西九龙Important Notes for AI Assistant
⚠️ Station Name Resolution Warning
CRITICAL: When querying by city name (e.g., "武汉", "上海", "深圳", "广州"), the API may return trains from/to ANY station in that city, not just the main station.
Common Pitfalls:
- 武汉 includes: 武汉站 (main), 汉口站 (Hankou), 武昌站 (Wuchang), 武汉东站
- 上海 includes: 上海虹桥 (Hongqiao), 上海站 (main), 上海南站, 上海松江站
- 深圳 includes: 深圳北站 (main), 深圳站 (Luohu), 福田站, 深圳东站
- 广州 includes: 广州南站 (main), 广州站, 广州东站, 广州北站
Best Practice - Always verify exact stations: 1. First, use stations.mjs to list all stations in the city:
node {baseDir}/scripts/stations.mjs 武汉2. Then, query with exact station names for accurate results:
node {baseDir}/scripts/query.mjs 武汉 上海虹桥 -f md🔄 Transfer/Connection Guidelines
When planning transfers (中转):
- Use JSON output (
--json) to verify exact station names - Ensure both segments use the SAME station (e.g., both use 武汉站, not 武汉→汉口)
- Recommended minimum transfer time: 20-30 minutes for same station
- Different stations in same city require additional travel time (e.g., 武汉→汉口 = 30+ min by subway)
📋 Query Workflow Recommendation
For accurate results, follow this workflow:
1. List stations in departure city:
node {baseDir}/scripts/stations.mjs 北京2. List stations in arrival city:
node {baseDir}/scripts/stations.mjs 上海3. Query with exact station names (e.g., 北京南 → 上海虹桥):
node {baseDir}/scripts/query.mjs 北京南 上海虹桥 -d 2026-03-05 -f md4. For transfers: Always verify both segments use the same station by checking fromStation and toStation in JSON output.
Technical Notes
- Data comes directly from 12306 official API (no key needed)
- Station data is cached for 7 days in
{baseDir}/data/stations.json - Works for all train types: G (高铁), D (动车), Z (直达), T (特快), K (快速)
data/stations.json
data/*.html
12306 火车票查询
Clawhub Skill — 查询中国铁路 12306 列车时刻表和余票信息。
安装
npx skills add kirorab/12306-skill功能
- 查询任意两站间的列车时刻表和余票
- 输出为 HTML 页面(Apple 风格)或 Markdown 表格
- 丰富的筛选条件:车次类型、出发/到达时间、耗时、可购票状态、座位类型
- 站点数据使用 12306 官方数据源,自动缓存 7 天
- 支持城市名(自动解析到主站)或精确站名
依赖
- Node.js >= 18
用法
# 查询北京到上海的所有列车(默认今天)
node scripts/query.mjs 北京 上海
# Markdown 表格输出(适合终端/聊天)
node scripts/query.mjs 北京 上海 -t G -f md
# 上午出发的高铁,1小时内,二等座有票
node scripts/query.mjs 上海 杭州 -t G --depart 06:00-12:00 --max-duration 1h --seat ze
# 仅可购票,18点前到达
node scripts/query.mjs 深圳 长沙 --available --arrive -18:00
# JSON 输出
node scripts/query.mjs 广州 武汉 --json参数
| 参数 | 说明 |
|---|---|
-d, --date <YYYY-MM-DD> | 出行日期(默认今天) |
| `-t, --type <G\ | D\ |
--depart <HH:MM-HH:MM> | 出发时间范围 |
--arrive <HH:MM-HH:MM> | 到达时间范围 |
--max-duration <duration> | 最长耗时(如 2h、90m、1h30m) |
--available | 仅显示可购票车次 |
--seat <types> | 按座位类型有票筛选(swz,zy,ze,rw,dw,yw,yz,wz) |
| `-f, --format <html\ | md>` |
-o, --output <path> | 输出文件路径(仅 html 模式) |
--json | JSON 输出 |
座位类型
| 缩写 | 含义 |
|---|---|
| swz | 商务座/特等座 |
| zy | 一等座 |
| ze | 二等座 |
| rw/dw | 软卧/动卧 |
| yw | 硬卧 |
| yz | 硬座 |
| wz | 无座 |
站点查询
node scripts/stations.mjs 杭州
node scripts/stations.mjs 香港西九龙数据来源
直接调用 12306 官方 API,无需任何 API Key。
#!/usr/bin/env node
// Query 12306 train tickets: schedule, remaining tickets, prices
import { parseArgs } from 'node:util';
import { writeFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadStations, resolveStation } from './stations.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
Referer: 'https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc',
};
// 12306 API returns pipe-delimited fields; index mapping:
// ref: https://blog.csdn.net/a460550542/article/details/86302597
const F = {
trainNo: 2, trainCode: 3, fromCode: 6, toCode: 7,
departTime: 8, arriveTime: 9, duration: 10, canBuy: 11, date: 13,
gr: 21, rw: 23, rz: 24, tz: 25, wz: 26, yw: 28, yz: 29,
ze: 30, zy: 31, swz: 32, dw: 33,
};
// --- Argument parsing ---
const { values, positionals } = parseArgs({
options: {
date: { type: 'string', short: 'd' },
type: { type: 'string', short: 't', default: '' },
depart: { type: 'string' }, // e.g. 08:00-12:00
arrive: { type: 'string' }, // e.g. -18:00
'max-duration': { type: 'string' }, // e.g. 2h, 90m, 1h30m
available: { type: 'boolean', default: false }, // only bookable
seat: { type: 'string' }, // e.g. ze,zy (has tickets for these seat types)
format: { type: 'string', short: 'f', default: 'html' }, // html or md
output: { type: 'string', short: 'o' }, // output file path
json: { type: 'boolean', default: false },
},
allowPositionals: true,
});
const [fromName, toName] = positionals;
if (!fromName || !toName) {
console.error(`Usage: query.mjs <from> <to> [options]
Options:
-d, --date <YYYY-MM-DD> Travel date (default: today)
-t, --type <G|D|Z|T|K> Filter train types (combinable, e.g. GD)
--depart <HH:MM-HH:MM> Depart time range (e.g. 08:00-12:00, 18:00-)
--arrive <HH:MM-HH:MM> Arrive time range (e.g. -18:00, 14:00-20:00)
--max-duration <duration> Max travel time (e.g. 2h, 90m, 1h30m)
--available Only show bookable trains
--seat <types> Only show trains with tickets for given seats
(comma-separated: swz,zy,ze,rw,dw,yw,yz,wz)
-f, --format <html|md> Output format (default: html)
-o, --output <path> Output file path (html mode only)
--json Output raw JSON`);
process.exit(1);
}
const date = values.date || new Date().toLocaleDateString('sv-SE', { timeZone: 'Asia/Shanghai' });
const trainTypeFilter = (values.type || '').toUpperCase();
// --- Time & duration helpers ---
function parseTime(s) {
const [h, m] = s.split(':').map(Number);
return h * 60 + m;
}
function parseTimeRange(s) {
if (!s) return null;
const [lo, hi] = s.split('-');
return { lo: lo ? parseTime(lo) : 0, hi: hi ? parseTime(hi) : 24 * 60 };
}
function parseDurationLimit(s) {
if (!s) return null;
const match = s.match(/^(?:(\d+)h)?(?:(\d+)m)?$/i);
if (!match) return null;
return (parseInt(match[1] || 0) * 60) + parseInt(match[2] || 0);
}
function formatDuration(raw) {
// raw from 12306: "01:30" or "00:45"
const [h, m] = raw.split(':').map(Number);
if (isNaN(h) || isNaN(m)) return raw;
return h > 0 ? `${h}h${m.toString().padStart(2, '0')}m` : `${m}m`;
}
function durationMinutes(raw) {
const [h, m] = raw.split(':').map(Number);
return h * 60 + m;
}
// --- API ---
async function getCookie() {
const res = await fetch('https://kyfw.12306.cn/otn/leftTicket/init?linktypeid=dc', {
headers: HEADERS,
redirect: 'manual',
});
const cookies = res.headers.getSetCookie?.() || [];
return cookies.map(c => c.split(';')[0]).join('; ');
}
async function queryTickets(from, to, travelDate) {
const cookie = await getCookie();
const params = new URLSearchParams({
'leftTicketDTO.train_date': travelDate,
'leftTicketDTO.from_station': from.station_code,
'leftTicketDTO.to_station': to.station_code,
purpose_codes: 'ADULT',
});
const res = await fetch(`https://kyfw.12306.cn/otn/leftTicket/query?${params}`, {
headers: { ...HEADERS, Cookie: cookie },
});
const json = await res.json();
if (!json.data?.result) {
console.error('No data returned:', JSON.stringify(json).slice(0, 500));
process.exit(1);
}
return json.data;
}
// --- Parsing ---
function parseTicket(raw, stationMap) {
const f = raw.split('|');
const v = (key) => f[F[key]] || '--';
return {
trainNo: v('trainNo'), trainCode: v('trainCode'),
fromStation: stationMap[v('fromCode')]?.station_name || v('fromCode'),
toStation: stationMap[v('toCode')]?.station_name || v('toCode'),
departTime: v('departTime'), arriveTime: v('arriveTime'),
duration: v('duration'), canBuy: v('canBuy'), date: v('date'),
swz: v('swz'), tz: v('tz'), zy: v('zy'), ze: v('ze'),
gr: v('gr'), rw: v('rw'), dw: v('dw'),
yw: v('yw'), rz: v('rz'), yz: v('yz'), wz: v('wz'),
};
}
function hasSeat(val) {
return val && val !== '--' && val !== '' && val !== '无';
}
// --- Filtering ---
function applyFilters(tickets) {
let result = tickets;
if (trainTypeFilter) {
const chars = [...trainTypeFilter];
result = result.filter(t => chars.some(ch => t.trainCode.startsWith(ch)));
}
const departRange = parseTimeRange(values.depart);
if (departRange) {
result = result.filter(t => {
const m = parseTime(t.departTime);
return m >= departRange.lo && m <= departRange.hi;
});
}
const arriveRange = parseTimeRange(values.arrive);
if (arriveRange) {
result = result.filter(t => {
const m = parseTime(t.arriveTime);
return m >= arriveRange.lo && m <= arriveRange.hi;
});
}
const maxDur = parseDurationLimit(values['max-duration']);
if (maxDur) {
result = result.filter(t => durationMinutes(t.duration) <= maxDur);
}
if (values.available) {
result = result.filter(t => t.canBuy === 'Y');
}
if (values.seat) {
const seatTypes = values.seat.split(',').map(s => s.trim().toLowerCase());
result = result.filter(t => seatTypes.every(s => hasSeat(t[s])));
}
return result;
}
// --- HTML output ---
function seatCell(val) {
if (!val || val === '--' || val === '') return '<td class="na">\u2014</td>';
if (val === '无') return '<td class="sold-out">\u65E0</td>';
if (val === '有') return '<td class="available">\u6709</td>';
return `<td class="count">${val}</td>`;
}
function buildHTML(tickets, from, to, travelDate, filterDesc) {
const e = (s) => s.replace(/&/g, '&').replace(/</g, '<');
const fn = e(from.station_name), tn = e(to.station_name);
const rows = tickets.map(t => {
const swz = t.swz !== '--' ? t.swz : t.tz !== '--' ? t.tz : '--';
const rw = t.rw !== '--' ? t.rw : t.dw !== '--' ? t.dw : '--';
const typeClass = t.trainCode[0]?.toLowerCase() || '';
const buyClass = t.canBuy === 'Y' ? 'yes' : 'no';
return ` <tr>
<td class="train-code type-${typeClass}">${e(t.trainCode)}</td>
<td class="time"><span class="depart">${e(t.departTime)}</span><span class="arrow">\u2192</span><span class="arrive">${e(t.arriveTime)}</span></td>
<td class="duration">${formatDuration(t.duration)}</td>
${seatCell(swz)}${seatCell(t.zy)}${seatCell(t.ze)}${seatCell(rw)}${seatCell(t.yw)}${seatCell(t.yz)}${seatCell(t.wz)}
<td class="buy-${buyClass}">${t.canBuy === 'Y' ? '\u53EF\u8D2D' : '\u552E\u7F44'}</td>
</tr>`;
}).join('\n');
const filterTag = filterDesc
? `<div class="filters">${e(filterDesc)}</div>`
: '';
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${fn} \u2192 ${tn} \u5217\u8F66\u65F6\u523B\u8868</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, "SF Pro Text", "Helvetica Neue", sans-serif; background: #f5f5f7; color: #1d1d1f; }
.container { max-width: 1100px; margin: 0 auto; padding: 40px 20px; }
header { text-align: center; margin-bottom: 32px; }
h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.5px; }
h1 .arrow { margin: 0 12px; color: #86868b; font-weight: 300; }
.meta { margin-top: 8px; color: #86868b; font-size: 15px; }
.meta span + span::before { content: "\\00b7"; margin: 0 8px; }
.filters { margin-top: 6px; color: #0071e3; font-size: 13px; }
.table-wrap { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,.08); }
.empty { padding: 60px 20px; text-align: center; color: #86868b; font-size: 15px; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
thead { background: #fafafa; }
th { padding: 12px 10px; font-weight: 500; color: #86868b; font-size: 12px; letter-spacing: 0.5px; border-bottom: 1px solid #f0f0f0; white-space: nowrap; }
td { padding: 11px 10px; border-bottom: 1px solid #f5f5f5; text-align: center; white-space: nowrap; }
tr:last-child td { border-bottom: none; }
tr:hover { background: #fafbff; }
.train-code { font-weight: 600; text-align: left; padding-left: 16px; }
.type-g { color: #0071e3; }
.type-d { color: #34c759; }
.type-z { color: #af52de; }
.type-t { color: #ff9500; }
.type-k { color: #86868b; }
.time { font-variant-numeric: tabular-nums; }
.depart { font-weight: 600; }
.arrow { margin: 0 4px; color: #c0c0c0; }
.arrive { color: #6e6e73; }
.duration { color: #86868b; font-variant-numeric: tabular-nums; }
.na { color: #d2d2d7; }
.available { color: #34c759; font-weight: 500; }
.sold-out { color: #ff3b30; }
.count { font-weight: 600; font-variant-numeric: tabular-nums; }
.buy-yes { color: #34c759; font-weight: 500; }
.buy-no { color: #ff3b30; font-weight: 500; }
footer { text-align: center; margin-top: 24px; color: #c0c0c0; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>${fn}<span class="arrow">\u2192</span>${tn}</h1>
<div class="meta"><span>${e(travelDate)}</span><span>${tickets.length} \u8D9F\u5217\u8F66</span></div>
${filterTag}
</header>
<div class="table-wrap">${tickets.length === 0
? '\n <div class="empty">\u6CA1\u6709\u627E\u5230\u7B26\u5408\u6761\u4EF6\u7684\u5217\u8F66</div>'
: `
<table>
<thead><tr>
<th style="text-align:left;padding-left:16px">\u8F66\u6B21</th><th>\u65F6\u95F4</th><th>\u8017\u65F6</th>
<th>\u5546\u52A1/\u7279\u7B49</th><th>\u4E00\u7B49\u5EA7</th><th>\u4E8C\u7B49\u5EA7</th><th>\u8F6F\u5367/\u52A8\u5367</th><th>\u786C\u5367</th><th>\u786C\u5EA7</th><th>\u65E0\u5EA7</th><th>\u72B6\u6001</th>
</tr></thead>
<tbody>
${rows}
</tbody>
</table>`}
</div>
<footer>\u6570\u636E\u6765\u6E90 12306 \u00b7 ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}</footer>
</div>
</body>
</html>`;
}
// --- Markdown output ---
function buildMarkdown(tickets, from, to, travelDate, filterDesc) {
const lines = [];
lines.push(`## ${from.station_name} \u2192 ${to.station_name} | ${travelDate} | ${tickets.length} \u8D9F\u5217\u8F66`);
if (filterDesc) lines.push(`> ${filterDesc}`);
lines.push('');
if (tickets.length === 0) {
lines.push('\u6CA1\u6709\u627E\u5230\u7B26\u5408\u6761\u4EF6\u7684\u5217\u8F66');
return lines.join('\n');
}
lines.push('| \u8F66\u6B21 | \u51FA\u53D1\u2192\u5230\u8FBE | \u8017\u65F6 | \u5546\u52A1/\u7279\u7B49 | \u4E00\u7B49\u5EA7 | \u4E8C\u7B49\u5EA7 | \u8F6F\u5367/\u52A8\u5367 | \u786C\u5367 | \u786C\u5EA7 | \u65E0\u5EA7 | \u72B6\u6001 |');
lines.push('|------|-----------|------|-----------|--------|--------|-----------|------|------|------|------|');
for (const t of tickets) {
const swz = t.swz !== '--' ? t.swz : t.tz !== '--' ? t.tz : '--';
const rw = t.rw !== '--' ? t.rw : t.dw !== '--' ? t.dw : '--';
const buy = t.canBuy === 'Y' ? '\u2705' : '\u274C';
lines.push(`| ${t.trainCode} | ${t.departTime}\u2192${t.arriveTime} | ${formatDuration(t.duration)} | ${swz} | ${t.zy} | ${t.ze} | ${rw} | ${t.yw} | ${t.yz} | ${t.wz} | ${buy} |`);
}
return lines.join('\n');
}
function buildFilterDesc() {
const parts = [];
if (trainTypeFilter) parts.push(`${trainTypeFilter} \u5B57\u5934`);
if (values.depart) parts.push(`\u51FA\u53D1 ${values.depart}`);
if (values.arrive) parts.push(`\u5230\u8FBE ${values.arrive}`);
if (values['max-duration']) parts.push(`\u8017\u65F6 \u2264 ${values['max-duration']}`);
if (values.available) parts.push('\u4EC5\u53EF\u8D2D');
if (values.seat) parts.push(`\u6709\u7968: ${values.seat}`);
return parts.length ? parts.join(' | ') : '';
}
// --- Main ---
const stationData = await loadStations();
const fromStation = resolveStation(stationData, fromName);
const toStation = resolveStation(stationData, toName);
if (!fromStation) { console.error(`Station not found: ${fromName}`); process.exit(1); }
if (!toStation) { console.error(`Station not found: ${toName}`); process.exit(1); }
console.error(`Querying: ${fromStation.station_name}(${fromStation.station_code}) \u2192 ${toStation.station_name}(${toStation.station_code}) on ${date}`);
const data = await queryTickets(fromStation, toStation, date);
const tickets = data.result.map(r => parseTicket(r, stationData.STATIONS));
const filtered = applyFilters(tickets);
const fmt = values.format?.toLowerCase() || 'html';
const filterDesc = buildFilterDesc();
if (values.json) {
console.log(JSON.stringify(filtered, null, 2));
} else if (fmt === 'md') {
console.error(`${filtered.length}/${tickets.length} trains matched.`);
console.log(buildMarkdown(filtered, fromStation, toStation, date, filterDesc));
} else {
const html = buildHTML(filtered, fromStation, toStation, date, filterDesc);
const outPath = values.output || join(__dirname, '..', 'data',
`${fromStation.station_name}-${toStation.station_name}-${date}.html`);
writeFileSync(outPath, html);
console.error(`${filtered.length}/${tickets.length} trains matched. Saved to ${outPath}`);
console.log(outPath);
}
#!/usr/bin/env node
// Fetch and cache 12306 station data
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const CACHE_FILE = join(__dirname, '..', 'data', 'stations.json');
const CACHE_TTL = 7 * 24 * 3600 * 1000;
const HEADERS = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
};
// --- Core functions ---
export async function loadStations(forceRefresh = false) {
if (!forceRefresh && existsSync(CACHE_FILE)) {
const cached = JSON.parse(readFileSync(CACHE_FILE, 'utf-8'));
if (Date.now() - cached.ts < CACHE_TTL) return cached.data;
}
console.error('Fetching station data from 12306...');
const raw = await fetchStationScript();
const data = parseStationData(raw);
mkdirSync(dirname(CACHE_FILE), { recursive: true });
writeFileSync(CACHE_FILE, JSON.stringify({ ts: Date.now(), data }));
console.error(`Cached ${Object.keys(data.STATIONS).length} stations`);
return data;
}
export function resolveStation(data, name) {
if (data.NAME_STATIONS[name]) return data.NAME_STATIONS[name];
if (data.CITY_CODES[name]) return data.CITY_CODES[name];
if (data.CITY_STATIONS[name]) return data.CITY_STATIONS[name][0];
const trimmed = name.replace(/[市站]$/, '');
if (data.CITY_CODES[trimmed]) return data.CITY_CODES[trimmed];
if (data.CITY_STATIONS[trimmed]) return data.CITY_STATIONS[trimmed][0];
return null;
}
// --- Internal helpers ---
async function fetchStationScript() {
const homeRes = await fetch('https://www.12306.cn/index/', { headers: HEADERS });
const homeHtml = await homeRes.text();
const versionMatch = homeHtml.match(/station_name\.js\?station_version=([\d.]+)/);
const jsUrl = versionMatch
? `https://www.12306.cn/index/script/station_name.js?station_version=${versionMatch[1]}`
: 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
const jsRes = await fetch(jsUrl, { headers: HEADERS });
return jsRes.text();
}
function parseStationData(jsText) {
// Format: @bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||
// [0] [1] [2] [3] [4] [5] [6] [7] [8][9][10]
const raw = jsText.match(/'([^']+)'/)?.[1] || '';
const entries = raw.split('@').filter(Boolean);
const STATIONS = {};
const CITY_STATIONS = {};
const NAME_STATIONS = {};
const CITY_CODES = {};
for (const entry of entries) {
const parts = entry.split('|');
const name = parts[1], code = parts[2], pinyin = parts[3], shortPy = parts[4];
const city = parts[7] || name; // field [7] = city name from 12306
if (!name || !code) continue;
STATIONS[code] = { station_name: name, station_code: code, station_pinyin: pinyin, station_short: shortPy, city };
NAME_STATIONS[name] = { station_code: code, station_name: name };
(CITY_STATIONS[city] ??= []).push({ station_code: code, station_name: name });
if (name === city) CITY_CODES[city] = { station_code: code, station_name: name };
}
return { STATIONS, CITY_STATIONS, NAME_STATIONS, CITY_CODES };
}
// --- CLI ---
if (process.argv[1]?.includes('stations.mjs') && process.argv[2]) {
const data = await loadStations();
const name = process.argv[2];
const result = resolveStation(data, name);
if (!result) {
console.error(`Station not found: ${name}`);
process.exit(1);
}
console.log(JSON.stringify(result));
const city = data.STATIONS[result.station_code]?.city || name;
if (data.CITY_STATIONS[city]) {
console.log(`\nAll stations in ${city}:`);
for (const s of data.CITY_STATIONS[city]) console.log(` ${s.station_name} (${s.station_code})`);
}
}
Related skills
FAQ
How do you run the 12306 ticket query script?
The 12306 skill runs node {baseDir}/scripts/query.mjs with origin and destination station arguments, defaulting to today's trains, and optional -f md for Markdown table output instead of HTML file generation.
What runtime does the 12306 skill require?
The 12306 skill metadata lists node as a required binary under openclaw requires.bins, so agents must have Node available before executing scripts/query.mjs.
Is 12306 safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.