@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
package/clis/12306/me.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 account summary for the logged-in user.
|
|
3
|
+
*
|
|
4
|
+
* Returns non-sensitive identity fields plus masked email / mobile.
|
|
5
|
+
* Use `--include-sensitive` to surface unmasked values from 12306's
|
|
6
|
+
* own response (12306 already masks the ID number server-side; this
|
|
7
|
+
* adapter never decodes that mask).
|
|
8
|
+
*/
|
|
9
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
10
|
+
import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
11
|
+
import { isAuthLikePayload, maskEmail, maskMobile, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
|
|
12
|
+
|
|
13
|
+
const ACCOUNT_INFO_URL = 'https://kyfw.12306.cn/otn/modifyUser/initQueryUserInfoApi';
|
|
14
|
+
|
|
15
|
+
cli({
|
|
16
|
+
site: '12306',
|
|
17
|
+
name: 'me',
|
|
18
|
+
access: 'read',
|
|
19
|
+
description: 'Show the logged-in 12306 account summary. Sensitive fields (real name, email, mobile, birth date) are masked by default; pass --include-sensitive to opt in.',
|
|
20
|
+
domain: 'kyfw.12306.cn',
|
|
21
|
+
strategy: Strategy.COOKIE,
|
|
22
|
+
browser: true,
|
|
23
|
+
args: [
|
|
24
|
+
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real name / email / mobile / birth date. The 12306 ID-number mask is server-side and never decoded.' },
|
|
25
|
+
],
|
|
26
|
+
columns: ['username', 'real_name', 'email', 'mobile', 'birth_date', 'sex', 'country', 'user_type', 'member', 'active'],
|
|
27
|
+
func: async (page, kwargs) => {
|
|
28
|
+
if (!page) throw new CommandExecutionError('Browser session required for 12306 me');
|
|
29
|
+
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
|
30
|
+
await require12306Login(page, AuthRequiredError);
|
|
31
|
+
const json = requireEvaluateObject(await page.evaluate(`async () => {
|
|
32
|
+
const r = await fetch(${JSON.stringify(ACCOUNT_INFO_URL)}, { credentials: 'include' });
|
|
33
|
+
if (!r.ok) return { __http: r.status };
|
|
34
|
+
try {
|
|
35
|
+
return await r.json();
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return { __parse: String(err && err.message || err) };
|
|
38
|
+
}
|
|
39
|
+
}`), 'account info');
|
|
40
|
+
if (json?.__http) {
|
|
41
|
+
if ([401, 403].includes(Number(json.__http))) {
|
|
42
|
+
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
|
|
43
|
+
}
|
|
44
|
+
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for account info`);
|
|
45
|
+
}
|
|
46
|
+
if (json?.__parse) {
|
|
47
|
+
throw new CommandExecutionError(`12306 account info returned non-JSON body: ${json.__parse}`);
|
|
48
|
+
}
|
|
49
|
+
if (isAuthLikePayload(json)) {
|
|
50
|
+
throw new AuthRequiredError('kyfw.12306.cn', '12306 account info requires a valid login session');
|
|
51
|
+
}
|
|
52
|
+
if (json?.status !== true || !json?.data?.userDTO) {
|
|
53
|
+
throw new CommandExecutionError('12306 account info payload missing userDTO');
|
|
54
|
+
}
|
|
55
|
+
const dto = json.data.userDTO;
|
|
56
|
+
const loginDto = dto.loginUserDTO || {};
|
|
57
|
+
const username = loginDto.user_name || loginDto.name || '';
|
|
58
|
+
const realName = loginDto.real_name || loginDto.realname || '';
|
|
59
|
+
const include = kwargs['include-sensitive'] === true;
|
|
60
|
+
return [{
|
|
61
|
+
username,
|
|
62
|
+
real_name: include ? realName : maskChineseName(realName),
|
|
63
|
+
email: include ? (dto.email || '') : maskEmail(dto.email || ''),
|
|
64
|
+
mobile: include ? (dto.mobile_no || '') : maskMobile(dto.mobile_no || ''),
|
|
65
|
+
birth_date: include ? (dto.born_date || '') : (dto.born_date || '').slice(0, 4),
|
|
66
|
+
sex: dto.sex_code === 'M' ? '男' : (dto.sex_code === 'F' ? '女' : ''),
|
|
67
|
+
country: dto.country_code || '',
|
|
68
|
+
user_type: json.data.userTypeName || '',
|
|
69
|
+
member: dto.flag_member === '1',
|
|
70
|
+
active: dto.is_active === '1',
|
|
71
|
+
}];
|
|
72
|
+
},
|
|
73
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 in-progress orders for the logged-in user.
|
|
3
|
+
*
|
|
4
|
+
* Returns orders that have not yet been ridden / refunded / completed
|
|
5
|
+
* (the `noComplete` slice). Order history covering completed and
|
|
6
|
+
* refunded tickets uses a separate endpoint that requires extra
|
|
7
|
+
* referer / page-state handshakes and is left for a follow-up so this
|
|
8
|
+
* command can ship reliably.
|
|
9
|
+
*/
|
|
10
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
11
|
+
import { AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
12
|
+
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
|
|
13
|
+
|
|
14
|
+
const NO_COMPLETE_URL = 'https://kyfw.12306.cn/otn/queryOrder/queryMyOrderNoComplete';
|
|
15
|
+
|
|
16
|
+
cli({
|
|
17
|
+
site: '12306',
|
|
18
|
+
name: 'orders',
|
|
19
|
+
access: 'read',
|
|
20
|
+
description: 'List in-progress 12306 orders (not yet ridden, refunded, or completed) for the logged-in user',
|
|
21
|
+
domain: 'kyfw.12306.cn',
|
|
22
|
+
strategy: Strategy.COOKIE,
|
|
23
|
+
browser: true,
|
|
24
|
+
args: [
|
|
25
|
+
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked passenger names in order rows. Masked by default.' },
|
|
26
|
+
],
|
|
27
|
+
columns: ['order_id', 'order_date', 'train_code', 'from_station', 'to_station', 'departure', 'passengers', 'status', 'amount'],
|
|
28
|
+
func: async (page, kwargs) => {
|
|
29
|
+
if (!page) throw new CommandExecutionError('Browser session required for 12306 orders');
|
|
30
|
+
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
|
31
|
+
await require12306Login(page, AuthRequiredError);
|
|
32
|
+
const include = kwargs['include-sensitive'] === true;
|
|
33
|
+
const json = requireEvaluateObject(await page.evaluate(`async () => {
|
|
34
|
+
const r = await fetch(${JSON.stringify(NO_COMPLETE_URL)}, {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
37
|
+
body: '_json_att=', credentials: 'include',
|
|
38
|
+
});
|
|
39
|
+
if (!r.ok) return { __http: r.status };
|
|
40
|
+
try {
|
|
41
|
+
return await r.json();
|
|
42
|
+
} catch (err) {
|
|
43
|
+
return { __parse: String(err && err.message || err) };
|
|
44
|
+
}
|
|
45
|
+
}`), 'orders');
|
|
46
|
+
if (json?.__http) {
|
|
47
|
+
if ([401, 403].includes(Number(json.__http))) {
|
|
48
|
+
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
|
|
49
|
+
}
|
|
50
|
+
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for queryMyOrderNoComplete`);
|
|
51
|
+
}
|
|
52
|
+
if (json?.__parse) {
|
|
53
|
+
throw new CommandExecutionError(`12306 orders returned non-JSON body: ${json.__parse}`);
|
|
54
|
+
}
|
|
55
|
+
if (isAuthLikePayload(json)) {
|
|
56
|
+
throw new AuthRequiredError('kyfw.12306.cn', '12306 orders requires a valid login session');
|
|
57
|
+
}
|
|
58
|
+
if (json?.status !== true) {
|
|
59
|
+
throw new CommandExecutionError('12306 queryMyOrderNoComplete returned a failure status');
|
|
60
|
+
}
|
|
61
|
+
let orders;
|
|
62
|
+
if (Array.isArray(json?.data?.orderDBList)) {
|
|
63
|
+
orders = json.data.orderDBList;
|
|
64
|
+
} else if (Array.isArray(json?.data?.orderDTODataList)) {
|
|
65
|
+
orders = json.data.orderDTODataList;
|
|
66
|
+
} else if (Array.isArray(json?.data?.orders)) {
|
|
67
|
+
orders = json.data.orders;
|
|
68
|
+
} else if (Array.isArray(json?.data)) {
|
|
69
|
+
orders = json.data;
|
|
70
|
+
} else {
|
|
71
|
+
throw new CommandExecutionError('12306 queryMyOrderNoComplete payload missing order list array');
|
|
72
|
+
}
|
|
73
|
+
if (orders.length === 0) {
|
|
74
|
+
throw new EmptyResultError('No in-progress 12306 orders on this account');
|
|
75
|
+
}
|
|
76
|
+
return orders.map((o) => {
|
|
77
|
+
const tickets = Array.isArray(o.tickets) ? o.tickets : [];
|
|
78
|
+
const passengerNames = tickets
|
|
79
|
+
.map((t) => t.passenger_name || '')
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.map((name) => include ? name : maskChineseName(name))
|
|
82
|
+
.join(', ');
|
|
83
|
+
return {
|
|
84
|
+
order_id: o.sequence_no || o.order_id || o.sequenceNo || '',
|
|
85
|
+
order_date: o.order_date || '',
|
|
86
|
+
train_code: o.train_code_page || o.station_train_code || o.train_code || '',
|
|
87
|
+
from_station: o.from_station_name_page || o.from_station_name || '',
|
|
88
|
+
to_station: o.to_station_name_page || o.to_station_name || '',
|
|
89
|
+
departure: o.start_train_date_page || o.start_train_date || '',
|
|
90
|
+
passengers: passengerNames,
|
|
91
|
+
status: o.ticket_status_name || o.order_status_name || o.statusName || '',
|
|
92
|
+
amount: o.ticket_total_price_page || o.ticket_total_price || '',
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 saved passenger list for the logged-in user.
|
|
3
|
+
*
|
|
4
|
+
* 12306 already masks ID numbers (`xxxx***********xxx`) and mobile
|
|
5
|
+
* numbers (`138****xxxx`) server-side. This adapter further masks the
|
|
6
|
+
* passenger's Chinese real name and birth date by default; pass
|
|
7
|
+
* `--include-sensitive` to surface the unmasked-by-12306 fields.
|
|
8
|
+
*/
|
|
9
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
10
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
11
|
+
import { isAuthLikePayload, maskChineseName, require12306Login, requireEvaluateObject } from './utils.js';
|
|
12
|
+
|
|
13
|
+
const PASSENGER_QUERY_URL = 'https://kyfw.12306.cn/otn/passengers/query';
|
|
14
|
+
const MAX_PAGE_SIZE = 50;
|
|
15
|
+
|
|
16
|
+
function normalizeLimit(value, defaultValue, max) {
|
|
17
|
+
if (value === undefined || value === null || value === '') return defaultValue;
|
|
18
|
+
const n = Number(value);
|
|
19
|
+
if (!Number.isInteger(n) || n < 1) throw new ArgumentError(`limit must be a positive integer (1-${max})`);
|
|
20
|
+
if (n > max) throw new ArgumentError(`limit must be <= ${max}`);
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
cli({
|
|
25
|
+
site: '12306',
|
|
26
|
+
name: 'passengers',
|
|
27
|
+
access: 'read',
|
|
28
|
+
description: 'List the logged-in user\'s saved 12306 passengers. Sensitive fields are masked by default; pass --include-sensitive to opt in.',
|
|
29
|
+
domain: 'kyfw.12306.cn',
|
|
30
|
+
strategy: Strategy.COOKIE,
|
|
31
|
+
browser: true,
|
|
32
|
+
args: [
|
|
33
|
+
{ name: 'limit', type: 'int', default: 20, help: `Max passengers to return (1-${MAX_PAGE_SIZE})` },
|
|
34
|
+
{ name: 'include-sensitive', type: 'boolean', default: false, help: 'Reveal unmasked real names and birth dates. The 12306 ID-number / mobile masks are server-side and never decoded.' },
|
|
35
|
+
],
|
|
36
|
+
columns: ['name', 'sex', 'born_year', 'id_type', 'id_no', 'mobile', 'passenger_type', 'country'],
|
|
37
|
+
func: async (page, kwargs) => {
|
|
38
|
+
if (!page) throw new CommandExecutionError('Browser session required for 12306 passengers');
|
|
39
|
+
const limit = normalizeLimit(kwargs.limit, 20, MAX_PAGE_SIZE);
|
|
40
|
+
const include = kwargs['include-sensitive'] === true;
|
|
41
|
+
|
|
42
|
+
await page.goto('https://kyfw.12306.cn/otn/view/index.html');
|
|
43
|
+
await require12306Login(page, AuthRequiredError);
|
|
44
|
+
const json = requireEvaluateObject(await page.evaluate(`async () => {
|
|
45
|
+
const body = "pageIndex=1&pageSize=${MAX_PAGE_SIZE}";
|
|
46
|
+
const r = await fetch(${JSON.stringify(PASSENGER_QUERY_URL)}, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
49
|
+
body, credentials: 'include',
|
|
50
|
+
});
|
|
51
|
+
if (!r.ok) return { __http: r.status };
|
|
52
|
+
try {
|
|
53
|
+
return await r.json();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return { __parse: String(err && err.message || err) };
|
|
56
|
+
}
|
|
57
|
+
}`), 'passengers');
|
|
58
|
+
if (json?.__http) {
|
|
59
|
+
if ([401, 403].includes(Number(json.__http))) {
|
|
60
|
+
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
|
|
61
|
+
}
|
|
62
|
+
throw new CommandExecutionError(`12306 returned HTTP ${json.__http} for passengers/query`);
|
|
63
|
+
}
|
|
64
|
+
if (json?.__parse) {
|
|
65
|
+
throw new CommandExecutionError(`12306 passengers returned non-JSON body: ${json.__parse}`);
|
|
66
|
+
}
|
|
67
|
+
if (isAuthLikePayload(json)) {
|
|
68
|
+
throw new AuthRequiredError('kyfw.12306.cn', '12306 passengers requires a valid login session');
|
|
69
|
+
}
|
|
70
|
+
if (json?.status !== true || !Array.isArray(json?.data?.datas)) {
|
|
71
|
+
throw new CommandExecutionError('12306 passengers payload missing data.datas array');
|
|
72
|
+
}
|
|
73
|
+
const datas = json.data.datas;
|
|
74
|
+
if (datas.length === 0) {
|
|
75
|
+
throw new EmptyResultError('No saved passengers on this 12306 account');
|
|
76
|
+
}
|
|
77
|
+
return datas.slice(0, limit).map((p) => ({
|
|
78
|
+
name: include ? (p.passenger_name || '') : maskChineseName(p.passenger_name || ''),
|
|
79
|
+
sex: p.sex_name || '',
|
|
80
|
+
born_year: (p.born_date || '').slice(0, 4),
|
|
81
|
+
id_type: p.passenger_id_type_name || '',
|
|
82
|
+
id_no: p.passenger_id_no || '',
|
|
83
|
+
mobile: p.mobile_no || '',
|
|
84
|
+
passenger_type: p.passenger_type_name || '',
|
|
85
|
+
country: p.country_code || '',
|
|
86
|
+
}));
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
export const __test__ = { normalizeLimit };
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 ticket price lookup for a single train + segment.
|
|
3
|
+
*
|
|
4
|
+
* Cascades three anonymous API calls:
|
|
5
|
+
* 1. /otn/leftTicket/init: mint session cookies
|
|
6
|
+
* 2. /otn/czxx/queryByTrainNo: resolve from/to station_no within the
|
|
7
|
+
* train route (price endpoint addresses stops by station_no, not
|
|
8
|
+
* telecode)
|
|
9
|
+
* 3. /otn/leftTicket/queryTicketPrice: ticket prices keyed by seat
|
|
10
|
+
* letter (M=一等座, O=二等座, A9=商务座, A1=硬座, A3=硬卧,
|
|
11
|
+
* A4=软卧, F=动卧, P=特等座, WZ=无座, etc.)
|
|
12
|
+
*/
|
|
13
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
14
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
15
|
+
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
|
|
16
|
+
|
|
17
|
+
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';
|
|
18
|
+
const TRAIN_NO_RE = /^[0-9A-Z]{8,18}$/;
|
|
19
|
+
const SEAT_TYPES_RE = /^[A-Z0-9]{1,32}$/;
|
|
20
|
+
|
|
21
|
+
const SEAT_LETTERS = {
|
|
22
|
+
'A9': '商务座',
|
|
23
|
+
'P': '特等座',
|
|
24
|
+
'M': '一等座',
|
|
25
|
+
'O': '二等座',
|
|
26
|
+
'A1': '硬座',
|
|
27
|
+
'A3': '硬卧',
|
|
28
|
+
'A4': '软卧',
|
|
29
|
+
'F': '动卧',
|
|
30
|
+
'WZ': '无座',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function queryStopsForPrice(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
|
|
34
|
+
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
|
|
35
|
+
const resp = await fetchImpl(url, {
|
|
36
|
+
headers: {
|
|
37
|
+
'User-Agent': UA,
|
|
38
|
+
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
|
39
|
+
'Cookie': cookieHeader,
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
if (!resp.ok) throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
|
|
43
|
+
let json;
|
|
44
|
+
try {
|
|
45
|
+
json = await resp.json();
|
|
46
|
+
} catch {
|
|
47
|
+
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
|
|
48
|
+
}
|
|
49
|
+
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
|
|
50
|
+
throw new CommandExecutionError('12306 queryByTrainNo returned an unexpected payload shape');
|
|
51
|
+
}
|
|
52
|
+
return json.data.data;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function pickStationNos(stops, fromCode, toCode, fromName, toName) {
|
|
56
|
+
const matches = (s, code, name) => (s.station_name && name && s.station_name === name);
|
|
57
|
+
const fromStop = stops.find((s) => matches(s, fromCode, fromName));
|
|
58
|
+
const toStop = stops.find((s) => matches(s, toCode, toName));
|
|
59
|
+
if (!fromStop) throw new CommandExecutionError(`Train does not stop at ${fromName}`);
|
|
60
|
+
if (!toStop) throw new CommandExecutionError(`Train does not stop at ${toName}`);
|
|
61
|
+
return { fromNo: fromStop.station_no, toNo: toStop.station_no };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date, fetchImpl = fetch) {
|
|
65
|
+
const url = `https://kyfw.12306.cn/otn/leftTicket/queryTicketPrice?train_no=${trainNo}&from_station_no=${fromNo}&to_station_no=${toNo}&seat_types=${seatTypes}&train_date=${date}`;
|
|
66
|
+
const resp = await fetchImpl(url, {
|
|
67
|
+
headers: {
|
|
68
|
+
'User-Agent': UA,
|
|
69
|
+
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
|
70
|
+
'Cookie': cookieHeader,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
if (!resp.ok) throw new CommandExecutionError(`12306 queryTicketPrice returned HTTP ${resp.status}`);
|
|
74
|
+
let json;
|
|
75
|
+
try {
|
|
76
|
+
json = await resp.json();
|
|
77
|
+
} catch {
|
|
78
|
+
throw new CommandExecutionError('12306 queryTicketPrice returned non-JSON body');
|
|
79
|
+
}
|
|
80
|
+
if (json?.status !== true || !json?.data) {
|
|
81
|
+
throw new CommandExecutionError('12306 queryTicketPrice returned an unexpected payload shape');
|
|
82
|
+
}
|
|
83
|
+
return json.data;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parsePriceData(priceData) {
|
|
87
|
+
const rows = [];
|
|
88
|
+
for (const [letter, value] of Object.entries(priceData)) {
|
|
89
|
+
if (letter === 'train_no' || letter === 'OT') continue;
|
|
90
|
+
if (typeof value !== 'string' || !value) continue;
|
|
91
|
+
// 12306 doubles up some prices as bare numerics ("9": "21580"), which
|
|
92
|
+
// mirror their letter sibling ("A9": "¥2158.0") in cents/no-decimal
|
|
93
|
+
// form. Skip the bare numeric letter codes to avoid duplicates.
|
|
94
|
+
if (/^\d+$/.test(letter)) continue;
|
|
95
|
+
if (!/^[A-Z]/.test(letter)) continue;
|
|
96
|
+
const numeric = value.replace(/^¥/, '');
|
|
97
|
+
if (!/^[\d.]+$/.test(numeric)) continue;
|
|
98
|
+
rows.push({
|
|
99
|
+
seat_code: letter,
|
|
100
|
+
seat_name: SEAT_LETTERS[letter] || letter,
|
|
101
|
+
price: numeric,
|
|
102
|
+
currency: 'CNY',
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
rows.sort((a, b) => Number(b.price) - Number(a.price));
|
|
106
|
+
return rows;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
cli({
|
|
110
|
+
site: '12306',
|
|
111
|
+
name: 'price',
|
|
112
|
+
access: 'read',
|
|
113
|
+
description: 'Look up 12306 ticket prices by seat class for one train on a given date and segment (anonymous, no login required)',
|
|
114
|
+
domain: 'kyfw.12306.cn',
|
|
115
|
+
strategy: Strategy.PUBLIC,
|
|
116
|
+
browser: false,
|
|
117
|
+
args: [
|
|
118
|
+
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L)' },
|
|
119
|
+
{ name: 'from', required: true, help: 'Origin station (Chinese name, telecode, or pinyin) - must be a stop of this train' },
|
|
120
|
+
{ name: 'to', required: true, help: 'Destination station - must be a stop of this train' },
|
|
121
|
+
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
|
122
|
+
{ name: 'seat-types', default: 'OM9PA1A3A4FWZ', help: 'Seat-type letters to query (default covers the common classes). Examples: OM9 (二等/一等/商务), A1A3A4 (硬座/硬卧/软卧).' },
|
|
123
|
+
],
|
|
124
|
+
columns: ['seat_code', 'seat_name', 'price', 'currency'],
|
|
125
|
+
func: async (kwargs) => {
|
|
126
|
+
const trainNo = String(kwargs['train-no'] ?? '').trim();
|
|
127
|
+
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
|
|
128
|
+
if (!TRAIN_NO_RE.test(trainNo)) {
|
|
129
|
+
throw new ArgumentError(
|
|
130
|
+
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
|
|
131
|
+
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const fromArg = String(kwargs.from ?? '').trim();
|
|
135
|
+
const toArg = String(kwargs.to ?? '').trim();
|
|
136
|
+
if (!fromArg) throw new ArgumentError('--from station must not be empty');
|
|
137
|
+
if (!toArg) throw new ArgumentError('--to station must not be empty');
|
|
138
|
+
const date = validateDate(kwargs.date);
|
|
139
|
+
const seatTypes = String(kwargs['seat-types'] ?? '').trim() || 'OM9PA1A3A4FWZ';
|
|
140
|
+
if (!SEAT_TYPES_RE.test(seatTypes)) {
|
|
141
|
+
throw new ArgumentError('--seat-types must contain only 12306 seat letters/digits (A-Z, 0-9)');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const stations = await fetchStationBundle();
|
|
145
|
+
const fromStation = resolveStation(stations, fromArg);
|
|
146
|
+
const toStation = resolveStation(stations, toArg);
|
|
147
|
+
if (fromStation.code === toStation.code) {
|
|
148
|
+
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const cookieHeader = await mintSession();
|
|
152
|
+
const stops = await queryStopsForPrice(cookieHeader, trainNo, fromStation.code, toStation.code, date);
|
|
153
|
+
const { fromNo, toNo } = pickStationNos(stops, fromStation.code, toStation.code, fromStation.name, toStation.name);
|
|
154
|
+
const priceData = await queryPrice(cookieHeader, trainNo, fromNo, toNo, seatTypes, date);
|
|
155
|
+
const rows = parsePriceData(priceData);
|
|
156
|
+
if (rows.length === 0) {
|
|
157
|
+
throw new EmptyResultError(
|
|
158
|
+
`No prices returned for train_no=${trainNo} ${fromStation.name} -> ${toStation.name} on ${date}`,
|
|
159
|
+
'Try a different seat-types letter set, or check that this train operates on the date.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return rows;
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
export const __test__ = { parsePriceData, pickStationNos, queryStopsForPrice, queryPrice, SEAT_LETTERS };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 station search.
|
|
3
|
+
*
|
|
4
|
+
* Queries the public `station_name.js` bundle and filters by the user's
|
|
5
|
+
* keyword. Anonymous, no session needed.
|
|
6
|
+
*/
|
|
7
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
|
+
import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
9
|
+
import { fetchStationBundle } from './utils.js';
|
|
10
|
+
|
|
11
|
+
const MAX_LIMIT = 50;
|
|
12
|
+
|
|
13
|
+
function normalizeLimit(value, defaultValue, max) {
|
|
14
|
+
if (value === undefined || value === null || value === '') return defaultValue;
|
|
15
|
+
const n = Number(value);
|
|
16
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
17
|
+
throw new ArgumentError(`limit must be a positive integer (1-${max})`);
|
|
18
|
+
}
|
|
19
|
+
if (n > max) {
|
|
20
|
+
throw new ArgumentError(`limit must be <= ${max}`);
|
|
21
|
+
}
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
cli({
|
|
26
|
+
site: '12306',
|
|
27
|
+
name: 'stations',
|
|
28
|
+
access: 'read',
|
|
29
|
+
description: 'Search 12306 (China Railway) stations by Chinese name, telecode, or pinyin keyword',
|
|
30
|
+
domain: 'kyfw.12306.cn',
|
|
31
|
+
strategy: Strategy.PUBLIC,
|
|
32
|
+
browser: false,
|
|
33
|
+
args: [
|
|
34
|
+
{ name: 'keyword', positional: true, required: true, help: 'Chinese substring (上海), telecode (AOH), or pinyin (shanghai)' },
|
|
35
|
+
{ name: 'limit', type: 'int', default: 20, help: `Maximum results (1-${MAX_LIMIT})` },
|
|
36
|
+
],
|
|
37
|
+
columns: ['name', 'code', 'pinyin', 'abbr', 'city'],
|
|
38
|
+
func: async (kwargs) => {
|
|
39
|
+
const keyword = String(kwargs.keyword ?? '').trim();
|
|
40
|
+
if (!keyword) throw new ArgumentError('keyword must not be empty');
|
|
41
|
+
const limit = normalizeLimit(kwargs.limit, 20, MAX_LIMIT);
|
|
42
|
+
|
|
43
|
+
const stations = await fetchStationBundle();
|
|
44
|
+
const lower = keyword.toLowerCase();
|
|
45
|
+
const matches = stations.filter((s) =>
|
|
46
|
+
s.name.includes(keyword)
|
|
47
|
+
|| s.code === keyword.toUpperCase()
|
|
48
|
+
|| s.pinyin.includes(lower)
|
|
49
|
+
|| s.abbr.includes(lower)
|
|
50
|
+
|| s.short.includes(lower)
|
|
51
|
+
|| s.city.includes(keyword),
|
|
52
|
+
);
|
|
53
|
+
if (matches.length === 0) {
|
|
54
|
+
throw new EmptyResultError(`No 12306 stations match "${keyword}"`);
|
|
55
|
+
}
|
|
56
|
+
return matches.slice(0, limit).map((s) => ({
|
|
57
|
+
name: s.name,
|
|
58
|
+
code: s.code,
|
|
59
|
+
pinyin: s.pinyin,
|
|
60
|
+
abbr: s.abbr,
|
|
61
|
+
city: s.city,
|
|
62
|
+
}));
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
export const __test__ = { normalizeLimit };
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 12306 train stop details - list every station a train calls at,
|
|
3
|
+
* with arrival / departure / stopover time.
|
|
4
|
+
*
|
|
5
|
+
* Requires the internal `train_no` returned by `12306 trains`
|
|
6
|
+
* (`24000000G10L`), not the public train code (`G1`).
|
|
7
|
+
*/
|
|
8
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
9
|
+
import { ArgumentError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
10
|
+
import { fetchStationBundle, mintSession, resolveStation, validateDate } from './utils.js';
|
|
11
|
+
|
|
12
|
+
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';
|
|
13
|
+
const TRAIN_NO_RE = /^[0-9A-Z]{8,18}$/;
|
|
14
|
+
|
|
15
|
+
async function queryStops(cookieHeader, trainNo, fromCode, toCode, date, fetchImpl = fetch) {
|
|
16
|
+
const url = `https://kyfw.12306.cn/otn/czxx/queryByTrainNo?train_no=${trainNo}&from_station_telecode=${fromCode}&to_station_telecode=${toCode}&depart_date=${date}`;
|
|
17
|
+
const resp = await fetchImpl(url, {
|
|
18
|
+
headers: {
|
|
19
|
+
'User-Agent': UA,
|
|
20
|
+
'Referer': 'https://kyfw.12306.cn/otn/leftTicket/init',
|
|
21
|
+
'Cookie': cookieHeader,
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (!resp.ok) {
|
|
25
|
+
throw new CommandExecutionError(`12306 queryByTrainNo returned HTTP ${resp.status}`);
|
|
26
|
+
}
|
|
27
|
+
let json;
|
|
28
|
+
try {
|
|
29
|
+
json = await resp.json();
|
|
30
|
+
} catch {
|
|
31
|
+
throw new CommandExecutionError('12306 queryByTrainNo returned non-JSON body');
|
|
32
|
+
}
|
|
33
|
+
if (json?.status !== true || !Array.isArray(json?.data?.data)) {
|
|
34
|
+
throw new CommandExecutionError(`12306 queryByTrainNo returned an unexpected payload shape`);
|
|
35
|
+
}
|
|
36
|
+
return json.data.data;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
cli({
|
|
40
|
+
site: '12306',
|
|
41
|
+
name: 'train',
|
|
42
|
+
access: 'read',
|
|
43
|
+
description: 'List every station a 12306 train calls at, with arrival / departure / stopover time (anonymous, no login required)',
|
|
44
|
+
domain: 'kyfw.12306.cn',
|
|
45
|
+
strategy: Strategy.PUBLIC,
|
|
46
|
+
browser: false,
|
|
47
|
+
args: [
|
|
48
|
+
{ name: 'train-no', positional: true, required: true, help: 'Internal train_no from `12306 trains` (e.g. 24000000G10L), not the public code (G1)' },
|
|
49
|
+
{ name: 'from', required: true, help: 'Origin station for the segment: Chinese name, telecode, or pinyin' },
|
|
50
|
+
{ name: 'to', required: true, help: 'Destination station for the segment' },
|
|
51
|
+
{ name: 'date', required: true, help: 'Departure date in YYYY-MM-DD' },
|
|
52
|
+
],
|
|
53
|
+
columns: ['station_no', 'station_name', 'arrive_time', 'start_time', 'stopover_time'],
|
|
54
|
+
func: async (kwargs) => {
|
|
55
|
+
const trainNo = String(kwargs['train-no'] ?? '').trim();
|
|
56
|
+
if (!trainNo) throw new ArgumentError('<train-no> must not be empty');
|
|
57
|
+
if (!TRAIN_NO_RE.test(trainNo)) {
|
|
58
|
+
throw new ArgumentError(
|
|
59
|
+
`<train-no> "${trainNo}" does not look like a 12306 internal train_no`,
|
|
60
|
+
'Use the train_no field from `12306 trains` output (e.g. 24000000G10L), not the public code (G1).',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const fromArg = String(kwargs.from ?? '').trim();
|
|
64
|
+
const toArg = String(kwargs.to ?? '').trim();
|
|
65
|
+
if (!fromArg) throw new ArgumentError('--from station must not be empty');
|
|
66
|
+
if (!toArg) throw new ArgumentError('--to station must not be empty');
|
|
67
|
+
const date = validateDate(kwargs.date);
|
|
68
|
+
|
|
69
|
+
const stations = await fetchStationBundle();
|
|
70
|
+
const fromStation = resolveStation(stations, fromArg);
|
|
71
|
+
const toStation = resolveStation(stations, toArg);
|
|
72
|
+
if (fromStation.code === toStation.code) {
|
|
73
|
+
throw new ArgumentError(`--from and --to must differ; both resolved to ${fromStation.name} (${fromStation.code})`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const cookieHeader = await mintSession();
|
|
77
|
+
const stops = await queryStops(cookieHeader, trainNo, fromStation.code, toStation.code, date);
|
|
78
|
+
if (stops.length === 0) {
|
|
79
|
+
throw new EmptyResultError(`No stops returned for train_no=${trainNo} on ${date}`);
|
|
80
|
+
}
|
|
81
|
+
return stops.map((s) => ({
|
|
82
|
+
station_no: s.station_no || '',
|
|
83
|
+
station_name: s.station_name || '',
|
|
84
|
+
arrive_time: s.arrive_time === '----' ? '' : (s.arrive_time || ''),
|
|
85
|
+
start_time: s.start_time === '----' ? '' : (s.start_time || ''),
|
|
86
|
+
stopover_time: s.stopover_time === '----' ? '' : (s.stopover_time || ''),
|
|
87
|
+
}));
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
export const __test__ = { queryStops, TRAIN_NO_RE };
|