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
273 lines
10 KiB
JavaScript
273 lines
10 KiB
JavaScript
/**
|
|
* 12306 (中国铁路) shared helpers.
|
|
*
|
|
* - Station lookup: parses the public `station_name.js` bundle into
|
|
* structured records.
|
|
* - Cookie session: 12306's query endpoints reject anonymous requests
|
|
* with `HTTP 302 -> error.html`, so callers must hit `/otn/leftTicket/init`
|
|
* first to mint the JSESSIONID / route / BIGipServerotn cookies.
|
|
* - Query endpoint rotation: 12306 rotates the train-query endpoint
|
|
* name (queryO / queryZ / queryA / queryG / ...) every few weeks.
|
|
* When the wrong name is hit, the server returns
|
|
* `{"c_url":"leftTicket/queryG","c_name":"CLeftTicketUrl","status":false}`
|
|
* pointing to the current correct name; retry once with that name.
|
|
*/
|
|
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
|
|
const STATION_BUNDLE_URL = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
|
|
const INIT_URL = 'https://kyfw.12306.cn/otn/leftTicket/init';
|
|
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 DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
const STATION_CODE_RE = /^[A-Z]{2,4}$/;
|
|
|
|
/**
|
|
* Parse the `station_name.js` bundle into a station record array.
|
|
*
|
|
* Bundle format (single line, `@`-delimited records, each `|`-delimited):
|
|
* `var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||...';`
|
|
*
|
|
* Per-record fields (positional):
|
|
* [0] short pinyin alias (e.g. `bjb`)
|
|
* [1] Chinese station name (e.g. `北京北`)
|
|
* [2] telecode (3-4 uppercase letters, e.g. `VAP`) - this is the
|
|
* wire format 12306 uses for `from_station` / `to_station`.
|
|
* [3] full pinyin (e.g. `beijingbei`)
|
|
* [4] short alias (duplicate of [0] usually)
|
|
* [5] index/rank
|
|
* [6] city code
|
|
* [7] city name (e.g. `北京`)
|
|
*/
|
|
export function parseStationBundle(text) {
|
|
const match = text.match(/'([^']+)'/);
|
|
if (!match) {
|
|
throw new CommandExecutionError('Failed to parse 12306 station_name.js: source string not found');
|
|
}
|
|
const raw = match[1];
|
|
const records = raw.split('@').filter(Boolean);
|
|
const stations = [];
|
|
for (const r of records) {
|
|
const parts = r.split('|');
|
|
if (parts.length < 8 || !parts[2]) continue;
|
|
stations.push({
|
|
short: parts[0] || '',
|
|
name: parts[1] || '',
|
|
code: parts[2] || '',
|
|
pinyin: parts[3] || '',
|
|
abbr: parts[4] || '',
|
|
city: parts[7] || '',
|
|
});
|
|
}
|
|
if (stations.length === 0) {
|
|
throw new CommandExecutionError('Failed to parse 12306 station_name.js: no station records found');
|
|
}
|
|
return stations;
|
|
}
|
|
|
|
/**
|
|
* Resolve a user-supplied station identifier to a telecode.
|
|
*
|
|
* Accepts Chinese name (`上海虹桥`), telecode (`AOH`), pinyin
|
|
* (`shanghaihongqiao`), short alias (`shh`), or city name with a
|
|
* preference for the city's main station.
|
|
*/
|
|
export function resolveStation(stations, input) {
|
|
const trimmed = String(input ?? '').trim();
|
|
if (!trimmed) throw new ArgumentError('station must not be empty');
|
|
if (STATION_CODE_RE.test(trimmed)) {
|
|
const exact = stations.find((s) => s.code === trimmed);
|
|
if (exact) return exact;
|
|
throw new ArgumentError(`Unknown 12306 station telecode "${trimmed}"`);
|
|
}
|
|
const lower = trimmed.toLowerCase();
|
|
const exactName = stations.find((s) => s.name === trimmed);
|
|
if (exactName) return exactName;
|
|
const exactPinyin = stations.find((s) => s.pinyin === lower);
|
|
if (exactPinyin) return exactPinyin;
|
|
const exactAbbr = stations.find((s) => s.abbr === lower || s.short === lower);
|
|
if (exactAbbr) return exactAbbr;
|
|
throw new ArgumentError(`Unknown 12306 station "${trimmed}"`, 'Try the Chinese name (上海虹桥), the 3-4 letter telecode (AOH), or full pinyin (shanghaihongqiao).');
|
|
}
|
|
|
|
export function validateDate(value) {
|
|
if (!DATE_RE.test(String(value ?? ''))) {
|
|
throw new ArgumentError(`date must be YYYY-MM-DD, got "${value}"`);
|
|
}
|
|
const [y, m, d] = value.split('-').map(Number);
|
|
const date = new Date(Date.UTC(y, m - 1, d));
|
|
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
|
|
throw new ArgumentError(`date "${value}" is not a real calendar date`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
|
|
export function buildCookieHeader(setCookieHeaders) {
|
|
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
|
|
return setCookieHeaders
|
|
.map((line) => line.split(';')[0])
|
|
.filter(Boolean)
|
|
.join('; ');
|
|
}
|
|
|
|
export async function fetchStationBundle(fetchImpl = fetch) {
|
|
const resp = await fetchImpl(STATION_BUNDLE_URL, {
|
|
headers: { 'User-Agent': UA },
|
|
});
|
|
if (!resp.ok) {
|
|
throw new CommandExecutionError(`Failed to fetch 12306 station bundle: HTTP ${resp.status}`);
|
|
}
|
|
return parseStationBundle(await resp.text());
|
|
}
|
|
|
|
/** Mint a 12306 anonymous session by hitting /otn/leftTicket/init. */
|
|
export async function mintSession(fetchImpl = fetch) {
|
|
const resp = await fetchImpl(INIT_URL, {
|
|
headers: { 'User-Agent': UA },
|
|
redirect: 'follow',
|
|
});
|
|
if (!resp.ok) {
|
|
throw new CommandExecutionError(`Failed to mint 12306 session: HTTP ${resp.status}`);
|
|
}
|
|
const setCookies = typeof resp.headers.getSetCookie === 'function'
|
|
? resp.headers.getSetCookie()
|
|
: resp.headers.raw?.()['set-cookie'] || [];
|
|
const cookieHeader = buildCookieHeader(setCookies);
|
|
if (!cookieHeader) {
|
|
throw new CommandExecutionError('12306 init returned no session cookies');
|
|
}
|
|
return cookieHeader;
|
|
}
|
|
|
|
/**
|
|
* Twelve-row train query record (LEFT_TICKET_DTO).
|
|
*
|
|
* 12306 returns each train as a `|`-separated string with ~36 fields.
|
|
* Positions used here come from the public web client; unused
|
|
* positions are documented inline so future maintainers can extend
|
|
* the row shape without re-reverse-engineering.
|
|
*/
|
|
export function parseTrainRecord(line, stationByCode) {
|
|
const f = line.split('|');
|
|
if (f.length < 33) return null;
|
|
return {
|
|
train_no: f[2] || '',
|
|
code: f[3] || '',
|
|
from_station: stationByCode.get(f[6])?.name || f[6] || '',
|
|
to_station: stationByCode.get(f[7])?.name || f[7] || '',
|
|
from_code: f[6] || '',
|
|
to_code: f[7] || '',
|
|
start_time: f[8] || '',
|
|
arrive_time: f[9] || '',
|
|
duration: f[10] || '',
|
|
available: (f[1] || '').trim() === '预订' || (f[11] || '').trim() === 'Y',
|
|
business_seat: f[32] || '',
|
|
first_seat: f[31] || '',
|
|
second_seat: f[30] || '',
|
|
soft_sleeper: f[23] || '',
|
|
hard_sleeper: f[28] || '',
|
|
hard_seat: f[29] || '',
|
|
no_seat: f[26] || '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Mask helpers for sensitive identity fields rendered by 12306.
|
|
*
|
|
* 12306 already masks ID numbers and mobile numbers server-side
|
|
* (`xxxx***********xxx` / `138****xxxx`); these helpers handle the
|
|
* remaining fields (email, real Chinese name) so the adapter never
|
|
* leaks unmasked PII without an explicit `--include-sensitive` opt-in.
|
|
*/
|
|
export function maskEmail(value) {
|
|
const v = String(value || '').trim();
|
|
if (!v) return '';
|
|
const at = v.indexOf('@');
|
|
if (at <= 0) return v;
|
|
const local = v.slice(0, at);
|
|
const domain = v.slice(at);
|
|
if (local.length <= 2) return local[0] + '*' + domain;
|
|
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local.slice(-1) + domain;
|
|
}
|
|
|
|
export function maskMobile(value) {
|
|
const v = String(value || '').trim();
|
|
if (!v) return '';
|
|
if (/\*/.test(v)) return v;
|
|
if (v.length < 7) return v.replace(/.(?=.)/g, '*');
|
|
return v.slice(0, 3) + '*'.repeat(v.length - 7) + v.slice(-4);
|
|
}
|
|
|
|
export function maskChineseName(value) {
|
|
const v = String(value || '').trim();
|
|
if (!v) return '';
|
|
if (v.length === 1) return v;
|
|
if (v.length === 2) return v[0] + '*';
|
|
return v[0] + '*'.repeat(v.length - 2) + v.slice(-1);
|
|
}
|
|
|
|
export function unwrapEvaluateResult(value) {
|
|
if (
|
|
value
|
|
&& typeof value === 'object'
|
|
&& !Array.isArray(value)
|
|
&& Object.prototype.hasOwnProperty.call(value, 'session')
|
|
&& Object.prototype.hasOwnProperty.call(value, 'data')
|
|
) {
|
|
return value.data;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function requireEvaluateObject(value, label) {
|
|
const payload = unwrapEvaluateResult(value);
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
throw new CommandExecutionError(`12306 ${label} returned a malformed browser payload`);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
export function isAuthLikePayload(payload) {
|
|
if (!payload || typeof payload !== 'object') return false;
|
|
const parts = [];
|
|
if (Array.isArray(payload.messages)) parts.push(...payload.messages);
|
|
if (payload.message) parts.push(payload.message);
|
|
if (payload.msg) parts.push(payload.msg);
|
|
if (payload.validateMessages && typeof payload.validateMessages === 'object') {
|
|
parts.push(...Object.values(payload.validateMessages).flat());
|
|
}
|
|
const text = parts.map((item) => String(item ?? '')).join(' ');
|
|
return /未登录|登录|请登录|身份|认证|session|Session|login/i.test(text);
|
|
}
|
|
|
|
/**
|
|
* Detect the 12306 login marker by reading `document.cookie` from the
|
|
* current adapter page. Cannot use `page.getCookies({url})` here:
|
|
* 12306 sets the auth cookie `tk` and `JSESSIONID` with `Path=/otn`,
|
|
* and CDP `Network.getCookies` with a bare URL filter excludes
|
|
* cookies whose path does not match the URL path. `document.cookie`
|
|
* returns all non-httponly cookies visible to the current page
|
|
* regardless of path, which is what we need to confirm login.
|
|
*/
|
|
export async function require12306Login(page, AuthRequiredErrorClass) {
|
|
const docCookie = unwrapEvaluateResult(await page.evaluate(`document.cookie || ''`));
|
|
const cookieStr = typeof docCookie === 'string' ? docCookie : '';
|
|
if (!/\btk=/.test(cookieStr) || !/JSESSIONID=/.test(cookieStr)) {
|
|
throw new AuthRequiredErrorClass('kyfw.12306.cn', 'Not logged into 12306. Sign in at https://kyfw.12306.cn first.');
|
|
}
|
|
}
|
|
|
|
export const __test__ = {
|
|
parseStationBundle,
|
|
resolveStation,
|
|
validateDate,
|
|
buildCookieHeader,
|
|
parseTrainRecord,
|
|
maskEmail,
|
|
maskMobile,
|
|
maskChineseName,
|
|
unwrapEvaluateResult,
|
|
requireEvaluateObject,
|
|
isAuthLikePayload,
|
|
};
|