9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
92 lines
4.3 KiB
JavaScript
92 lines
4.3 KiB
JavaScript
/**
|
|
* 12306 train stop details - list every station a train calls at,
|
|
* with arrival / departure / stopover time.
|
|
*
|
|
* Requires the internal `train_no` returned by `12306 trains`
|
|
* (`24000000G10L`), not the public train code (`G1`).
|
|
*/
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
|
|
|
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0 Safari/537.36';
|
|
const TRAIN_NO_RE = /^[0-9A-Za-z]{8,18}$/;
|
|
|
|
async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
|
|
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
|
|
const resp = await fetchImpl(url, {
|
|
headers: {
|
|
'User-Agent': UA,
|
|
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
|
'Cookie': cookieHeader,
|
|
},
|
|
});
|
|
if (!resp.ok) {
|
|
throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
|
|
}
|
|
let json;
|
|
try {
|
|
json = await resp.json();
|
|
} catch {
|
|
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
|
|
}
|
|
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
|
|
throw new CommandExecutionError(`12306 queryByTrainNo returned an unexpected payload shape`);
|
|
}
|
|
return json.data.data;
|
|
}
|
|
|
|
cli({
|
|
site: '12306',
|
|
name: 'train',
|
|
access: 'read',
|
|
description: 'List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)',
|
|
domain: 'kyfw.12306.cn',
|
|
strategy: Strategy.PUBLIC,
|
|
browser: false,
|
|
args: [
|
|
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)' },
|
|
{ name: 'from', required: true, help: 'Origin station for the segment: Chinese name, telecode, or pinyin' },
|
|
{ name: 'to', required: true, help: 'Destination station for the segment' },
|
|
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
|
],
|
|
columns: ['station_no', 'station_name', 'arrive_time', 'start_time', 'stopover_time'],
|
|
func: async (kwargs) => {
|
|
const trainNo = String(kwargs['train-no'] ?? '').trim();
|
|
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
|
|
if (!TRAIN_NO_RE.test(trainNo)) {
|
|
throw new ArgumentError(
|
|
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
|
|
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
|
|
);
|
|
}
|
|
const fromArg = String(kwargs.from ?? '').trim();
|
|
const toArg = String(kwargs.to ?? '').trim();
|
|
if (!fromArg) throw new ArgumentError('--from station must not be empty');
|
|
if (!toArg) throw new ArgumentError('--to station must not be empty');
|
|
const date = validateDate(kwargs.date);
|
|
|
|
const stations = await fetchStationBundle();
|
|
const fromStation = resolveStation(stations, fromArg);
|
|
const toStation = resolveStation(stations, toArg);
|
|
if (fromStation.code === toStation.code) {
|
|
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
|
}
|
|
|
|
const cookieHeader = await mintSession();
|
|
const stops = await queryStops(cookieHeader, trainNo, fromStation.code, toStation.code, date);
|
|
if (stops.length === 0) {
|
|
throw new EmptyResultError(`No stops returned for train_no=${trainNo} on ${date}`);
|
|
}
|
|
return stops.map((s) => ({
|
|
station_no: s.station_no || '',
|
|
station_name: s.station_name || '',
|
|
arrive_time: s.arrive_time === '----' ? '' : (s.arrive_time || ''),
|
|
start_time: s.start_time === '----' ? '' : (s.start_time || ''),
|
|
stopover_time: s.stopover_time === '----' ? '' : (s.stopover_time || ''),
|
|
}));
|
|
},
|
|
});
|
|
|
|
export const __test__ = { queryStops, TRAIN_NO_RE };
|