@jackwener/opencli 1.7.21 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -148
- package/README.zh-CN.md +38 -211
- package/cli-manifest.json +6423 -4260
- package/clis/12306/me.js +73 -0
- package/clis/12306/orders.js +96 -0
- package/clis/12306/passengers.js +90 -0
- package/clis/12306/price.js +166 -0
- package/clis/12306/stations.js +66 -0
- package/clis/12306/train.js +91 -0
- package/clis/12306/trains.js +119 -0
- package/clis/12306/utils.js +272 -0
- package/clis/12306/utils.test.js +331 -0
- package/clis/36kr/article.js +6 -3
- package/clis/36kr/article.test.js +46 -0
- package/clis/apple-podcasts/commands.test.js +20 -0
- package/clis/apple-podcasts/search.js +2 -2
- package/clis/barchart/greeks.js +144 -56
- package/clis/barchart/greeks.test.js +138 -0
- package/clis/bilibili/summary.js +167 -0
- package/clis/bilibili/summary.test.js +210 -0
- package/clis/booking/booking.test.js +356 -0
- package/clis/booking/search.js +351 -0
- package/clis/boss/utils.js +17 -1
- package/clis/boss/utils.test.js +34 -0
- package/clis/chatgpt/envelope.test.js +108 -0
- package/clis/chatgpt/image.js +2 -2
- package/clis/chatgpt/image.test.js +6 -0
- package/clis/chatgpt/utils.js +148 -41
- package/clis/chatgpt/utils.test.js +92 -2
- package/clis/douyin/_shared/browser-fetch.js +44 -20
- package/clis/douyin/_shared/browser-fetch.test.js +22 -1
- package/clis/douyin/_shared/evaluate-result.js +16 -0
- package/clis/douyin/_shared/tos-upload.js +105 -69
- package/clis/douyin/_shared/vod-upload.js +212 -0
- package/clis/douyin/_shared/vod-upload.test.js +38 -0
- package/clis/douyin/delete.js +137 -4
- package/clis/douyin/delete.test.js +90 -1
- package/clis/douyin/publish-upload-id.test.js +170 -0
- package/clis/douyin/publish.js +88 -42
- package/clis/douyin/user-videos.js +9 -2
- package/clis/douyin/user-videos.test.js +43 -0
- package/clis/flomo/memos.js +228 -0
- package/clis/flomo/memos.test.js +144 -0
- package/clis/gitee/search.js +2 -2
- package/clis/gitee/search.test.js +65 -0
- package/clis/jike/post.js +27 -17
- package/clis/jike/read.test.js +86 -0
- package/clis/jike/topic.js +32 -19
- package/clis/jike/user.js +33 -20
- package/clis/lesswrong/comments.js +1 -1
- package/clis/lesswrong/curated.js +1 -1
- package/clis/lesswrong/frontpage.js +1 -1
- package/clis/lesswrong/frontpage.test.js +37 -0
- package/clis/lesswrong/new.js +1 -1
- package/clis/lesswrong/read.js +1 -1
- package/clis/lesswrong/sequences.js +1 -1
- package/clis/lesswrong/shortform.js +1 -1
- package/clis/lesswrong/tag.js +1 -1
- package/clis/lesswrong/top-month.js +1 -1
- package/clis/lesswrong/top-week.js +1 -1
- package/clis/lesswrong/top-year.js +1 -1
- package/clis/lesswrong/top.js +1 -1
- package/clis/linkedin/connect.js +401 -0
- package/clis/linkedin/connect.test.js +213 -0
- package/clis/linkedin/inbox.js +234 -0
- package/clis/linkedin/inbox.test.js +152 -0
- package/clis/linkedin/people-search.js +262 -0
- package/clis/linkedin/people-search.test.js +216 -0
- package/clis/linkedin/safe-send.js +357 -0
- package/clis/linkedin/safe-send.test.js +204 -0
- package/clis/linkedin/salesnav-inbox.js +210 -0
- package/clis/linkedin/salesnav-inbox.test.js +113 -0
- package/clis/linkedin/salesnav-message.js +360 -0
- package/clis/linkedin/salesnav-message.test.js +172 -0
- package/clis/linkedin/salesnav-search.js +186 -0
- package/clis/linkedin/salesnav-search.test.js +76 -0
- package/clis/linkedin/salesnav-thread.js +212 -0
- package/clis/linkedin/salesnav-thread.test.js +79 -0
- package/clis/linkedin/sent-invitations.js +92 -0
- package/clis/linkedin/sent-invitations.test.js +62 -0
- package/clis/linkedin/thread-snapshot.js +214 -0
- package/clis/linkedin/thread-snapshot.test.js +89 -0
- package/clis/linkedin-learning/course.js +138 -0
- package/clis/linkedin-learning/course.test.js +114 -0
- package/clis/linkedin-learning/search.js +155 -0
- package/clis/linkedin-learning/search.test.js +144 -0
- package/clis/linkedin-learning/trending.js +133 -0
- package/clis/linkedin-learning/trending.test.js +123 -0
- package/clis/powerchina/search.js +3 -3
- package/clis/powerchina/search.test.js +27 -1
- package/clis/reddit/extract-media.test.js +149 -0
- package/clis/reddit/frontpage.js +47 -9
- package/clis/reddit/frontpage.test.js +34 -0
- package/clis/reddit/home.js +31 -1
- package/clis/reddit/home.test.js +46 -3
- package/clis/reddit/hot.js +32 -1
- package/clis/reddit/hot.test.js +15 -1
- package/clis/reddit/popular.js +39 -1
- package/clis/reddit/popular.test.js +26 -0
- package/clis/reddit/saved.js +1 -1
- package/clis/reddit/search.js +38 -1
- package/clis/reddit/search.test.js +26 -0
- package/clis/reddit/subreddit.js +52 -7
- package/clis/reddit/subreddit.test.js +31 -0
- package/clis/reddit/subscribed.js +165 -0
- package/clis/reddit/subscribed.test.js +168 -0
- package/clis/reddit/upvoted.js +1 -1
- package/clis/suno/commands.test.js +188 -0
- package/clis/suno/download.js +140 -0
- package/clis/suno/download.test.js +151 -0
- package/clis/suno/generate.js +226 -0
- package/clis/suno/generate.test.js +243 -0
- package/clis/suno/list.js +79 -0
- package/clis/suno/status.js +62 -0
- package/clis/suno/utils.js +540 -0
- package/clis/suno/utils.test.js +223 -0
- package/clis/twitter/device-follow.js +193 -0
- package/clis/twitter/device-follow.test.js +287 -0
- package/clis/twitter/download.js +443 -73
- package/clis/twitter/download.test.js +457 -0
- package/clis/twitter/list-create.js +155 -0
- package/clis/twitter/list-create.test.js +169 -0
- package/clis/twitter/list-remove.js +12 -5
- package/clis/twitter/list-remove.test.js +74 -0
- package/clis/twitter/list-tweets.js +6 -2
- package/clis/twitter/list-tweets.test.js +41 -1
- package/clis/twitter/lists.js +31 -4
- package/clis/twitter/lists.test.js +152 -16
- package/clis/twitter/search.js +6 -2
- package/clis/twitter/search.test.js +6 -0
- package/clis/twitter/shared.js +144 -0
- package/clis/twitter/shared.test.js +429 -1
- package/clis/twitter/thread.js +10 -2
- package/clis/twitter/thread.test.js +58 -0
- package/clis/twitter/timeline.js +6 -2
- package/clis/twitter/timeline.test.js +2 -0
- package/clis/twitter/tweets.js +3 -2
- package/clis/twitter/tweets.test.js +1 -1
- package/clis/weibo/comments.js +3 -4
- package/clis/weibo/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/envelope.test.js +85 -0
- package/clis/weibo/favorites.js +4 -4
- package/clis/weibo/feed.js +3 -5
- package/clis/weibo/hot.js +3 -4
- package/clis/weibo/me.js +3 -5
- package/clis/weibo/post.js +3 -4
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/search.js +4 -3
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -0
- package/clis/weibo/user.js +3 -4
- package/clis/weibo/utils.js +34 -5
- package/clis/weibo/utils.test.js +36 -0
- package/clis/weread/search-regression.test.js +18 -11
- package/clis/weread/search.js +15 -7
- package/clis/weread-official/book.js +135 -0
- package/clis/weread-official/commands.test.js +385 -0
- package/clis/weread-official/discover.js +107 -0
- package/clis/weread-official/list-apis.js +95 -0
- package/clis/weread-official/notes.js +171 -0
- package/clis/weread-official/readdata.js +158 -0
- package/clis/weread-official/review.js +93 -0
- package/clis/weread-official/search.js +106 -0
- package/clis/weread-official/shelf.js +97 -0
- package/clis/weread-official/utils.js +293 -0
- package/clis/weread-official/utils.test.js +242 -0
- package/clis/wikipedia/trending.js +7 -3
- package/clis/wikipedia/trending.test.js +57 -0
- package/clis/xianyu/chat.js +24 -109
- package/clis/xianyu/chat.test.js +5 -0
- package/clis/xianyu/im.js +322 -0
- package/clis/xianyu/im.test.js +253 -0
- package/clis/xianyu/inbox.js +96 -0
- package/clis/xianyu/messages.js +91 -0
- package/clis/xianyu/reply.js +82 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -1
- package/clis/xiaohongshu/creator-note-detail.test.js +11 -0
- package/clis/xiaohongshu/creator-notes-summary.js +2 -1
- package/clis/xiaohongshu/creator-notes-summary.test.js +7 -0
- package/clis/xiaohongshu/creator-notes.js +2 -1
- package/clis/xiaohongshu/creator-notes.test.js +12 -0
- package/clis/xiaohongshu/creator-stats.js +2 -1
- package/clis/xiaohongshu/creator-stats.test.js +24 -0
- package/clis/xiaohongshu/delete-note.js +260 -0
- package/clis/xiaohongshu/delete-note.test.js +172 -0
- package/clis/xiaohongshu/publish.js +48 -8
- package/clis/xiaohongshu/publish.test.js +65 -10
- package/clis/xiaohongshu/user-helpers.test.js +41 -0
- package/clis/xiaohongshu/user.js +27 -4
- package/clis/xiaoyuzhou/download.js +1 -1
- package/clis/xiaoyuzhou/transcript.js +1 -1
- package/clis/youdao/note.js +258 -0
- package/clis/youdao/note.test.js +99 -0
- package/clis/youtube/transcript.js +397 -24
- package/clis/youtube/transcript.test.js +196 -6
- package/clis/zhihu/answer-comments.js +299 -0
- package/clis/zhihu/answer-comments.test.js +287 -0
- package/clis/zhihu/answer-detail.js +12 -0
- package/clis/zhihu/answer-detail.test.js +8 -0
- package/clis/zhihu/collection.js +15 -2
- package/clis/zhihu/collection.test.js +46 -0
- package/clis/zhihu/download.js +1 -1
- package/clis/zhihu/question.js +42 -9
- package/clis/zhihu/question.test.js +111 -9
- package/clis/zhihu/search.js +206 -43
- package/clis/zhihu/search.test.js +198 -0
- package/dist/src/browser/errors.js +4 -2
- package/dist/src/browser/errors.test.js +6 -0
- package/dist/src/browser/page.js +30 -4
- package/dist/src/browser/page.test.js +42 -0
- package/dist/src/browser/utils.d.ts +1 -1
- package/dist/src/cli-argv-preprocess.d.ts +26 -0
- package/dist/src/cli-argv-preprocess.js +138 -0
- package/dist/src/cli-argv-preprocess.test.js +79 -0
- package/dist/src/cli.js +1 -1
- package/dist/src/convention-audit.js +15 -8
- package/dist/src/convention-audit.test.js +21 -0
- package/dist/src/download/media-download.js +15 -2
- package/dist/src/download/media-download.test.d.ts +1 -0
- package/dist/src/download/media-download.test.js +110 -0
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/electron-apps.test.js +7 -2
- package/dist/src/errors.d.ts +17 -0
- package/dist/src/errors.js +22 -0
- package/dist/src/external-clis.yaml +20 -0
- package/dist/src/external.d.ts +6 -1
- package/dist/src/external.test.js +19 -0
- package/dist/src/main.js +14 -2
- package/dist/src/utils.d.ts +43 -0
- package/dist/src/utils.js +97 -0
- package/dist/src/utils.test.d.ts +1 -0
- package/dist/src/utils.test.js +155 -0
- package/package.json +8 -2
- package/scripts/silent-column-drop-baseline.json +0 -52
- package/scripts/typed-error-lint-baseline.json +28 -380
- package/clis/slock/_utils.js +0 -12
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 train availability between two stations on a given date.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Fetch the station bundle (cached implicitly via per-process module state).
|
|
6
|
+
* 2. Mint anonymous session cookies via /otn/leftTicket/init.
|
|
7
|
+
* 3. Query /otn/leftTicket/queryG; if 12306 returns
|
|
8
|
+
* `{c_url: "leftTicket/queryX"}` (endpoint rotation), retry once
|
|
9
|
+
* against the suggested name.
|
|
10
|
+
* 4. Parse the `|`-separated train records.
|
|
11
|
+
*/
|
|
12
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
13
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
14
|
+
import { fetchStationBundle, mintSession, resolveStation, validateDate, parseTrainRecord } from './utils.js';
|
|
15
|
+
|
|
16
|
+
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';
|
|
17
|
+
const QUERY_ENDPOINTS = ['queryG', 'queryO', 'queryZ', 'queryA'];
|
|
18
|
+
const MAX_LIMIT = 100;
|
|
19
|
+
|
|
20
|
+
function normalizeLimit(value, defaultValue, max) {
|
|
21
|
+
if (value === undefined || value === null || value === '') return defaultValue;
|
|
22
|
+
const n = Number(value);
|
|
23
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
24
|
+
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
|
|
25
|
+
}
|
|
26
|
+
if (n > max) {
|
|
27
|
+
throw new ArgumentError(`limit must be <= ${max}`);
|
|
28
|
+
}
|
|
29
|
+
return n;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function queryLeftTickets(cookieHeader, fromCode, toCode, date) {
|
|
33
|
+
const headers = {
|
|
34
|
+
'User-Agent': UA,
|
|
35
|
+
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
|
36
|
+
'Cookie': cookieHeader,
|
|
37
|
+
};
|
|
38
|
+
const queryParams = `leftTicketDTO.train_date=${date}&leftTicketDTO.from_station=${fromCode}&leftTicketDTO.to_station=${toCode}&purpose_codes=ADULT`;
|
|
39
|
+
let lastResponseText = '';
|
|
40
|
+
for (const endpoint of QUERY_ENDPOINTS) {
|
|
41
|
+
const url = `https://kyfw.12306.cn/otn/leftTicket/${endpoint}?${queryParams}`;
|
|
42
|
+
const resp = await fetch(url, { headers });
|
|
43
|
+
if (!resp.ok) {
|
|
44
|
+
if (resp.status === 302) continue;
|
|
45
|
+
throw new CommandExecutionError(`12306 ${endpoint} returned HTTP ${resp.status}`);
|
|
46
|
+
}
|
|
47
|
+
const text = await resp.text();
|
|
48
|
+
lastResponseText = text;
|
|
49
|
+
let json;
|
|
50
|
+
try { json = JSON.parse(text); } catch {
|
|
51
|
+
throw new CommandExecutionError(`12306 ${endpoint} returned non-JSON body`);
|
|
52
|
+
}
|
|
53
|
+
if (json?.c_url && typeof json.c_url === 'string') {
|
|
54
|
+
const rotated = json.c_url.replace('leftTicket/', '').trim();
|
|
55
|
+
if (rotated && !QUERY_ENDPOINTS.includes(rotated)) {
|
|
56
|
+
QUERY_ENDPOINTS.unshift(rotated);
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(json?.data?.result)) {
|
|
61
|
+
return json.data.result;
|
|
62
|
+
}
|
|
63
|
+
throw new CommandExecutionError(`12306 ${endpoint} returned an unexpected payload shape`);
|
|
64
|
+
}
|
|
65
|
+
throw new CommandExecutionError(`12306 rejected every known query endpoint name (${QUERY_ENDPOINTS.join(', ')}); the wire protocol may have changed. Last body: ${lastResponseText.slice(0, 200)}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
cli({
|
|
69
|
+
site: '12306',
|
|
70
|
+
name: 'trains',
|
|
71
|
+
access: 'read',
|
|
72
|
+
description: 'List trains between two 12306 stations on a given date (anonymous, no login required)',
|
|
73
|
+
domain: 'kyfw.12306.cn',
|
|
74
|
+
strategy: Strategy.PUBLIC,
|
|
75
|
+
browser: false,
|
|
76
|
+
args: [
|
|
77
|
+
{ name: 'from', positional: true, required: true, help: 'Origin station: Chinese name (北京), telecode (BJP), or pinyin (beijing)' },
|
|
78
|
+
{ name: 'to', positional: true, required: true, help: 'Destination station: same forms as <from>' },
|
|
79
|
+
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
|
80
|
+
{ name: 'limit', type: 'int', default: 50, help: `Maximum rows (1-${MAX_LIMIT})` },
|
|
81
|
+
],
|
|
82
|
+
columns: [
|
|
83
|
+
'code', 'from_station', 'to_station', 'start_time', 'arrive_time',
|
|
84
|
+
'duration', 'available', 'business_seat', 'first_seat', 'second_seat',
|
|
85
|
+
'soft_sleeper', 'hard_sleeper', 'hard_seat', 'no_seat', 'train_no',
|
|
86
|
+
],
|
|
87
|
+
func: async (kwargs) => {
|
|
88
|
+
const fromArg = String(kwargs.from ?? '').trim();
|
|
89
|
+
const toArg = String(kwargs.to ?? '').trim();
|
|
90
|
+
if (!fromArg) throw new ArgumentError('<from> station must not be empty');
|
|
91
|
+
if (!toArg) throw new ArgumentError('<to> station must not be empty');
|
|
92
|
+
const date = validateDate(kwargs.date);
|
|
93
|
+
const limit = normalizeLimit(kwargs.limit, 50, MAX_LIMIT);
|
|
94
|
+
|
|
95
|
+
const stations = await fetchStationBundle();
|
|
96
|
+
const fromStation = resolveStation(stations, fromArg);
|
|
97
|
+
const toStation = resolveStation(stations, toArg);
|
|
98
|
+
if (fromStation.code === toStation.code) {
|
|
99
|
+
throw new ArgumentError(`<from> and <to> must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
|
100
|
+
}
|
|
101
|
+
const stationByCode = new Map(stations.map((s) => [s.code, s]));
|
|
102
|
+
|
|
103
|
+
const cookieHeader = await mintSession();
|
|
104
|
+
const rawRows = await queryLeftTickets(cookieHeader, fromStation.code, toStation.code, date);
|
|
105
|
+
const decoded = rawRows
|
|
106
|
+
.map((line) => parseTrainRecord(decodeURIComponent(line.replace(/%0A/g, '')), stationByCode))
|
|
107
|
+
.filter(Boolean);
|
|
108
|
+
|
|
109
|
+
if (decoded.length === 0) {
|
|
110
|
+
throw new EmptyResultError(
|
|
111
|
+
`No trains found from ${fromStation.name} to ${toStation.name} on ${date}`,
|
|
112
|
+
'Try a different date or check whether the route is operated by 12306.',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return decoded.slice(0, limit);
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const __test__ = { normalizeLimit, queryLeftTickets };
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 (中国铁路) shared helpers.
|
|
3
|
+
*
|
|
4
|
+
* - Station lookup: parses the public `station_name.js` bundle into
|
|
5
|
+
* structured records.
|
|
6
|
+
* - Cookie session: 12306's query endpoints reject anonymous requests
|
|
7
|
+
* with `HTTP 302 -> error.html`, so callers must hit `/otn/leftTicket/init`
|
|
8
|
+
* first to mint the JSESSIONID / route / BIGipServerotn cookies.
|
|
9
|
+
* - Query endpoint rotation: 12306 rotates the train-query endpoint
|
|
10
|
+
* name (queryO / queryZ / queryA / queryG / ...) every few weeks.
|
|
11
|
+
* When the wrong name is hit, the server returns
|
|
12
|
+
* `{"c_url":"leftTicket/queryG","c_name":"CLeftTicketUrl","status":false}`
|
|
13
|
+
* pointing to the current correct name; retry once with that name.
|
|
14
|
+
*/
|
|
15
|
+
import { ArgumentError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
16
|
+
|
|
17
|
+
const STATION_BUNDLE_URL = 'https://kyfw.12306.cn/otn/resources/js/framework/station_name.js';
|
|
18
|
+
const INIT_URL = 'https://kyfw.12306.cn/otn/leftTicket/init';
|
|
19
|
+
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';
|
|
20
|
+
|
|
21
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
22
|
+
const STATION_CODE_RE = /^[A-Z]{2,4}$/;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Parse the `station_name.js` bundle into a station record array.
|
|
26
|
+
*
|
|
27
|
+
* Bundle format (single line, `@`-delimited records, each `|`-delimited):
|
|
28
|
+
* `var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||...';`
|
|
29
|
+
*
|
|
30
|
+
* Per-record fields (positional):
|
|
31
|
+
* [0] short pinyin alias (e.g. `bjb`)
|
|
32
|
+
* [1] Chinese station name (e.g. `北京北`)
|
|
33
|
+
* [2] telecode (3-4 uppercase letters, e.g. `VAP`) - this is the
|
|
34
|
+
* wire format 12306 uses for `from_station` / `to_station`.
|
|
35
|
+
* [3] full pinyin (e.g. `beijingbei`)
|
|
36
|
+
* [4] short alias (duplicate of [0] usually)
|
|
37
|
+
* [5] index/rank
|
|
38
|
+
* [6] city code
|
|
39
|
+
* [7] city name (e.g. `北京`)
|
|
40
|
+
*/
|
|
41
|
+
export function parseStationBundle(text) {
|
|
42
|
+
const match = text.match(/'([^']+)'/);
|
|
43
|
+
if (!match) {
|
|
44
|
+
throw new CommandExecutionError('Failed to parse 12306 station_name.js: source string not found');
|
|
45
|
+
}
|
|
46
|
+
const raw = match[1];
|
|
47
|
+
const records = raw.split('@').filter(Boolean);
|
|
48
|
+
const stations = [];
|
|
49
|
+
for (const r of records) {
|
|
50
|
+
const parts = r.split('|');
|
|
51
|
+
if (parts.length < 8 || !parts[2]) continue;
|
|
52
|
+
stations.push({
|
|
53
|
+
short: parts[0] || '',
|
|
54
|
+
name: parts[1] || '',
|
|
55
|
+
code: parts[2] || '',
|
|
56
|
+
pinyin: parts[3] || '',
|
|
57
|
+
abbr: parts[4] || '',
|
|
58
|
+
city: parts[7] || '',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
if (stations.length === 0) {
|
|
62
|
+
throw new CommandExecutionError('Failed to parse 12306 station_name.js: no station records found');
|
|
63
|
+
}
|
|
64
|
+
return stations;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a user-supplied station identifier to a telecode.
|
|
69
|
+
*
|
|
70
|
+
* Accepts Chinese name (`上海虹桥`), telecode (`AOH`), pinyin
|
|
71
|
+
* (`shanghaihongqiao`), short alias (`shh`), or city name with a
|
|
72
|
+
* preference for the city's main station.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveStation(stations, input) {
|
|
75
|
+
const trimmed = String(input ?? '').trim();
|
|
76
|
+
if (!trimmed) throw new ArgumentError('station must not be empty');
|
|
77
|
+
if (STATION_CODE_RE.test(trimmed)) {
|
|
78
|
+
const exact = stations.find((s) => s.code === trimmed);
|
|
79
|
+
if (exact) return exact;
|
|
80
|
+
throw new ArgumentError(`Unknown 12306 station telecode "${trimmed}"`);
|
|
81
|
+
}
|
|
82
|
+
const lower = trimmed.toLowerCase();
|
|
83
|
+
const exactName = stations.find((s) => s.name === trimmed);
|
|
84
|
+
if (exactName) return exactName;
|
|
85
|
+
const exactPinyin = stations.find((s) => s.pinyin === lower);
|
|
86
|
+
if (exactPinyin) return exactPinyin;
|
|
87
|
+
const exactAbbr = stations.find((s) => s.abbr === lower || s.short === lower);
|
|
88
|
+
if (exactAbbr) return exactAbbr;
|
|
89
|
+
throw new ArgumentError(`Unknown 12306 station "${trimmed}"`, 'Try the Chinese name (上海虹桥), the 3-4 letter telecode (AOH), or full pinyin (shanghaihongqiao).');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function validateDate(value) {
|
|
93
|
+
if (!DATE_RE.test(String(value ?? ''))) {
|
|
94
|
+
throw new ArgumentError(`date must be YYYY-MM-DD, got "${value}"`);
|
|
95
|
+
}
|
|
96
|
+
const [y, m, d] = value.split('-').map(Number);
|
|
97
|
+
const date = new Date(Date.UTC(y, m - 1, d));
|
|
98
|
+
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== m - 1 || date.getUTCDate() !== d) {
|
|
99
|
+
throw new ArgumentError(`date "${value}" is not a real calendar date`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Extract Set-Cookie header values into a single `Cookie:` header string. */
|
|
105
|
+
export function buildCookieHeader(setCookieHeaders) {
|
|
106
|
+
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return '';
|
|
107
|
+
return setCookieHeaders
|
|
108
|
+
.map((line) => line.split(';')[0])
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join('; ');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function fetchStationBundle(fetchImpl = fetch) {
|
|
114
|
+
const resp = await fetchImpl(STATION_BUNDLE_URL, {
|
|
115
|
+
headers: { 'User-Agent': UA },
|
|
116
|
+
});
|
|
117
|
+
if (!resp.ok) {
|
|
118
|
+
throw new CommandExecutionError(`Failed to fetch 12306 station bundle: HTTP ${resp.status}`);
|
|
119
|
+
}
|
|
120
|
+
return parseStationBundle(await resp.text());
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Mint a 12306 anonymous session by hitting /otn/leftTicket/init. */
|
|
124
|
+
export async function mintSession(fetchImpl = fetch) {
|
|
125
|
+
const resp = await fetchImpl(INIT_URL, {
|
|
126
|
+
headers: { 'User-Agent': UA },
|
|
127
|
+
redirect: 'follow',
|
|
128
|
+
});
|
|
129
|
+
if (!resp.ok) {
|
|
130
|
+
throw new CommandExecutionError(`Failed to mint 12306 session: HTTP ${resp.status}`);
|
|
131
|
+
}
|
|
132
|
+
const setCookies = typeof resp.headers.getSetCookie === 'function'
|
|
133
|
+
? resp.headers.getSetCookie()
|
|
134
|
+
: resp.headers.raw?.()['set-cookie'] || [];
|
|
135
|
+
const cookieHeader = buildCookieHeader(setCookies);
|
|
136
|
+
if (!cookieHeader) {
|
|
137
|
+
throw new CommandExecutionError('12306 init returned no session cookies');
|
|
138
|
+
}
|
|
139
|
+
return cookieHeader;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Twelve-row train query record (LEFT_TICKET_DTO).
|
|
144
|
+
*
|
|
145
|
+
* 12306 returns each train as a `|`-separated string with ~36 fields.
|
|
146
|
+
* Positions used here come from the public web client; unused
|
|
147
|
+
* positions are documented inline so future maintainers can extend
|
|
148
|
+
* the row shape without re-reverse-engineering.
|
|
149
|
+
*/
|
|
150
|
+
export function parseTrainRecord(line, stationByCode) {
|
|
151
|
+
const f = line.split('|');
|
|
152
|
+
if (f.length < 33) return null;
|
|
153
|
+
return {
|
|
154
|
+
train_no: f[2] || '',
|
|
155
|
+
code: f[3] || '',
|
|
156
|
+
from_station: stationByCode.get(f[6])?.name || f[6] || '',
|
|
157
|
+
to_station: stationByCode.get(f[7])?.name || f[7] || '',
|
|
158
|
+
from_code: f[6] || '',
|
|
159
|
+
to_code: f[7] || '',
|
|
160
|
+
start_time: f[8] || '',
|
|
161
|
+
arrive_time: f[9] || '',
|
|
162
|
+
duration: f[10] || '',
|
|
163
|
+
available: (f[1] || '').trim() === '预订' || (f[11] || '').trim() === 'Y',
|
|
164
|
+
business_seat: f[32] || '',
|
|
165
|
+
first_seat: f[31] || '',
|
|
166
|
+
second_seat: f[30] || '',
|
|
167
|
+
soft_sleeper: f[23] || '',
|
|
168
|
+
hard_sleeper: f[28] || '',
|
|
169
|
+
hard_seat: f[29] || '',
|
|
170
|
+
no_seat: f[26] || '',
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Mask helpers for sensitive identity fields rendered by 12306.
|
|
176
|
+
*
|
|
177
|
+
* 12306 already masks ID numbers and mobile numbers server-side
|
|
178
|
+
* (`xxxx***********xxx` / `138****xxxx`); these helpers handle the
|
|
179
|
+
* remaining fields (email, real Chinese name) so the adapter never
|
|
180
|
+
* leaks unmasked PII without an explicit `--include-sensitive` opt-in.
|
|
181
|
+
*/
|
|
182
|
+
export function maskEmail(value) {
|
|
183
|
+
const v = String(value || '').trim();
|
|
184
|
+
if (!v) return '';
|
|
185
|
+
const at = v.indexOf('@');
|
|
186
|
+
if (at <= 0) return v;
|
|
187
|
+
const local = v.slice(0, at);
|
|
188
|
+
const domain = v.slice(at);
|
|
189
|
+
if (local.length <= 2) return local[0] + '*' + domain;
|
|
190
|
+
return local[0] + '*'.repeat(Math.max(1, local.length - 2)) + local.slice(-1) + domain;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function maskMobile(value) {
|
|
194
|
+
const v = String(value || '').trim();
|
|
195
|
+
if (!v) return '';
|
|
196
|
+
if (/\*/.test(v)) return v;
|
|
197
|
+
if (v.length < 7) return v.replace(/.(?=.)/g, '*');
|
|
198
|
+
return v.slice(0, 3) + '*'.repeat(v.length - 7) + v.slice(-4);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function maskChineseName(value) {
|
|
202
|
+
const v = String(value || '').trim();
|
|
203
|
+
if (!v) return '';
|
|
204
|
+
if (v.length === 1) return v;
|
|
205
|
+
if (v.length === 2) return v[0] + '*';
|
|
206
|
+
return v[0] + '*'.repeat(v.length - 2) + v.slice(-1);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function unwrapEvaluateResult(value) {
|
|
210
|
+
if (
|
|
211
|
+
value
|
|
212
|
+
&& typeof value === 'object'
|
|
213
|
+
&& !Array.isArray(value)
|
|
214
|
+
&& Object.prototype.hasOwnProperty.call(value, 'session')
|
|
215
|
+
&& Object.prototype.hasOwnProperty.call(value, 'data')
|
|
216
|
+
) {
|
|
217
|
+
return value.data;
|
|
218
|
+
}
|
|
219
|
+
return value;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function requireEvaluateObject(value, label) {
|
|
223
|
+
const payload = unwrapEvaluateResult(value);
|
|
224
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
225
|
+
throw new CommandExecutionError(`12306 ${label} returned a malformed browser payload`);
|
|
226
|
+
}
|
|
227
|
+
return payload;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function isAuthLikePayload(payload) {
|
|
231
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
232
|
+
const parts = [];
|
|
233
|
+
if (Array.isArray(payload.messages)) parts.push(...payload.messages);
|
|
234
|
+
if (payload.message) parts.push(payload.message);
|
|
235
|
+
if (payload.msg) parts.push(payload.msg);
|
|
236
|
+
if (payload.validateMessages && typeof payload.validateMessages === 'object') {
|
|
237
|
+
parts.push(...Object.values(payload.validateMessages).flat());
|
|
238
|
+
}
|
|
239
|
+
const text = parts.map((item) => String(item ?? '')).join(' ');
|
|
240
|
+
return /未登录|登录|请登录|身份|认证|session|Session|login/i.test(text);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Detect the 12306 login marker by reading `document.cookie` from the
|
|
245
|
+
* current adapter page. Cannot use `page.getCookies({url})` here:
|
|
246
|
+
* 12306 sets the auth cookie `tk` and `JSESSIONID` with `Path=/otn`,
|
|
247
|
+
* and CDP `Network.getCookies` with a bare URL filter excludes
|
|
248
|
+
* cookies whose path does not match the URL path. `document.cookie`
|
|
249
|
+
* returns all non-httponly cookies visible to the current page
|
|
250
|
+
* regardless of path, which is what we need to confirm login.
|
|
251
|
+
*/
|
|
252
|
+
export async function require12306Login(page, AuthRequiredErrorClass) {
|
|
253
|
+
const docCookie = unwrapEvaluateResult(await page.evaluate(`document.cookie || ''`));
|
|
254
|
+
const cookieStr = typeof docCookie === 'string' ? docCookie : '';
|
|
255
|
+
if (!/\btk=/.test(cookieStr) || !/JSESSIONID=/.test(cookieStr)) {
|
|
256
|
+
throw new AuthRequiredErrorClass('kyfw.12306.cn', 'Not logged into 12306. Sign in at https://kyfw.12306.cn first.');
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export const __test__ = {
|
|
261
|
+
parseStationBundle,
|
|
262
|
+
resolveStation,
|
|
263
|
+
validateDate,
|
|
264
|
+
buildCookieHeader,
|
|
265
|
+
parseTrainRecord,
|
|
266
|
+
maskEmail,
|
|
267
|
+
maskMobile,
|
|
268
|
+
maskChineseName,
|
|
269
|
+
unwrapEvaluateResult,
|
|
270
|
+
requireEvaluateObject,
|
|
271
|
+
isAuthLikePayload,
|
|
272
|
+
};
|