Skill: danihe001/12306-backup (version 1.0.0)

--- name: "12306" description: Query China Railway 12306 for train schedules, remaining tickets, and station info. Use when user asks about train/高铁/火车 tickets, schedules, or availability within China. metadata: {"openclaw":{"emoji":"🚄","requires":{"bins":["node"]}}} --- # 12306 Train Query Query train schedules and remaining tickets from China Railway 12306. ## Query Tickets ```bash 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 ```bash # 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 广州 武汉 --json ``` ### 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 seat types (comma-separated: `swz,zy,ze,rw,dw,yw,yz,wz`) - `-f, --format <html|md>`: Output format — `html` (default, saves file) or `md` (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 ```bash node {baseDir}/scripts/stations.mjs 杭州 node {baseDir}/scripts/stations.mjs 香港西九龙 ``` ## Notes - Data comes directly from 12306 official API (no key needed) - Station data is cached for 7 days in `{baseDir}/data/stations.json` - Supports city names (resolves to main station) or exact station names - Works for all train types: G (高铁), D (动车), Z (直达), T (特快), K (快速)

path: README.md
content: # 12306 火车票查询

[OpenClaw](https://github.com/openclaw/openclaw) Skill — 查询中国铁路 12306 列车时刻表和余票信息。

## 安装

```bash
npx skills add kirorab/12306-skill
```

## 功能

- 查询任意两站间的列车时刻表和余票
- 输出为 HTML 页面（Apple 风格）或 Markdown 表格
- 丰富的筛选条件：车次类型、出发/到达时间、耗时、可购票状态、座位类型
- 站点数据使用 12306 官方数据源，自动缓存 7 天
- 支持城市名（自动解析到主站）或精确站名

## 依赖

- Node.js >= 18

## 用法

```bash
# 查询北京到上海的所有列车（默认今天）
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\|Z\|T\|K>` | 车次类型筛选，可组合（如 `GD`） |
| `--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>` | 输出格式：`html`（默认，保存文件）或 `md`（Markdown 表格） |
| `-o, --output <path>` | 输出文件路径（仅 html 模式） |
| `--json` | JSON 输出 |

## 座位类型

| 缩写 | 含义 |
|------|------|
| swz | 商务座/特等座 |
| zy | 一等座 |
| ze | 二等座 |
| rw/dw | 软卧/动卧 |
| yw | 硬卧 |
| yz | 硬座 |
| wz | 无座 |

## 站点查询

```bash
node scripts/stations.mjs 杭州
node scripts/stations.mjs 香港西九龙
```

## 数据来源

直接调用 12306 官方 API，无需任何 API Key。


path: _meta.json
content: ownerId: kn789g3wef6qqydf58qzx199e981qw7r
slug: 12306
version: 1.0.2
publishedAt: 1772727590813

path: scripts/query.mjs
content: #!/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, '&amp;').replace(/</g, '&lt;');
  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);
}


path: scripts/stations.mjs
content: #!/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})`);
  }
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)