@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,331 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import { __test__ } from './utils.js';
|
|
5
|
+
import { __test__ as priceTest } from './price.js';
|
|
6
|
+
import { __test__ as trainTest } from './train.js';
|
|
7
|
+
import './orders.js';
|
|
8
|
+
|
|
9
|
+
const { parseStationBundle, resolveStation, validateDate, buildCookieHeader, parseTrainRecord, maskEmail, maskMobile, maskChineseName, unwrapEvaluateResult, requireEvaluateObject, isAuthLikePayload } = __test__;
|
|
10
|
+
const { parsePriceData, queryStopsForPrice, queryPrice } = priceTest;
|
|
11
|
+
const { queryStops } = trainTest;
|
|
12
|
+
|
|
13
|
+
describe('12306 utils - parseStationBundle', () => {
|
|
14
|
+
it('parses the `@`-delimited station bundle into structured records', () => {
|
|
15
|
+
const bundle = "var station_names ='@bjb|北京北|VAP|beijingbei|bjb|0|0357|北京|||@bji|北京|BJP|beijing|bj|2|0357|北京|||@aoh|上海虹桥|AOH|shanghaihongqiao|shhq|10|7600|上海|||';";
|
|
16
|
+
const stations = parseStationBundle(bundle);
|
|
17
|
+
expect(stations).toHaveLength(3);
|
|
18
|
+
expect(stations[1]).toEqual({
|
|
19
|
+
short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京',
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('skips records that lack a telecode', () => {
|
|
24
|
+
const bundle = "var station_names ='@xxx|||||||||@bji|北京|BJP|beijing|bj|2|0357|北京|||';";
|
|
25
|
+
const stations = parseStationBundle(bundle);
|
|
26
|
+
expect(stations).toHaveLength(1);
|
|
27
|
+
expect(stations[0].code).toBe('BJP');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('throws CommandExecutionError when the bundle has no parseable station rows', () => {
|
|
31
|
+
expect(() => parseStationBundle("var station_names ='@xxx|||||||||';")).toThrow(CommandExecutionError);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('12306 utils - resolveStation', () => {
|
|
36
|
+
const stations = [
|
|
37
|
+
{ short: 'bjb', name: '北京北', code: 'VAP', pinyin: 'beijingbei', abbr: 'bjb', city: '北京' },
|
|
38
|
+
{ short: 'bji', name: '北京', code: 'BJP', pinyin: 'beijing', abbr: 'bj', city: '北京' },
|
|
39
|
+
{ short: 'aoh', name: '上海虹桥', code: 'AOH', pinyin: 'shanghaihongqiao', abbr: 'shhq', city: '上海' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
it('matches by exact Chinese name', () => {
|
|
43
|
+
expect(resolveStation(stations, '上海虹桥').code).toBe('AOH');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('matches by uppercase telecode', () => {
|
|
47
|
+
expect(resolveStation(stations, 'BJP').code).toBe('BJP');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('matches by full pinyin (case-insensitive)', () => {
|
|
51
|
+
expect(resolveStation(stations, 'Beijing').code).toBe('BJP');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('matches by short alias / abbr', () => {
|
|
55
|
+
expect(resolveStation(stations, 'shhq').code).toBe('AOH');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('throws ArgumentError for empty input', () => {
|
|
59
|
+
expect(() => resolveStation(stations, ' ')).toThrow(ArgumentError);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('throws ArgumentError for unknown station', () => {
|
|
63
|
+
expect(() => resolveStation(stations, '某不存在站')).toThrow(ArgumentError);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('throws ArgumentError for telecode-shaped but unknown input', () => {
|
|
67
|
+
expect(() => resolveStation(stations, 'XYZ')).toThrow(ArgumentError);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('12306 utils - validateDate', () => {
|
|
72
|
+
it('accepts valid YYYY-MM-DD', () => {
|
|
73
|
+
expect(validateDate('2026-05-22')).toBe('2026-05-22');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('throws ArgumentError on wrong format', () => {
|
|
77
|
+
expect(() => validateDate('2026/05/22')).toThrow(ArgumentError);
|
|
78
|
+
expect(() => validateDate('26-05-22')).toThrow(ArgumentError);
|
|
79
|
+
expect(() => validateDate('today')).toThrow(ArgumentError);
|
|
80
|
+
expect(() => validateDate('')).toThrow(ArgumentError);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('throws ArgumentError on impossible calendar dates', () => {
|
|
84
|
+
expect(() => validateDate('2026-02-30')).toThrow(ArgumentError);
|
|
85
|
+
expect(() => validateDate('2026-13-01')).toThrow(ArgumentError);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('12306 utils - buildCookieHeader', () => {
|
|
90
|
+
it('joins set-cookie lines into a single Cookie header', () => {
|
|
91
|
+
const headers = [
|
|
92
|
+
'JSESSIONID=ABC123; Path=/otn',
|
|
93
|
+
'BIGipServerotn=xxx.yyy; Path=/',
|
|
94
|
+
'route=zzz; Expires=Sat, 01 Jan 2027 00:00:00 GMT',
|
|
95
|
+
];
|
|
96
|
+
expect(buildCookieHeader(headers)).toBe('JSESSIONID=ABC123; BIGipServerotn=xxx.yyy; route=zzz');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns empty string for empty input', () => {
|
|
100
|
+
expect(buildCookieHeader([])).toBe('');
|
|
101
|
+
expect(buildCookieHeader(undefined)).toBe('');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe('12306 utils - parseTrainRecord', () => {
|
|
106
|
+
const stationByCode = new Map([
|
|
107
|
+
['VNP', { name: '北京南', code: 'VNP' }],
|
|
108
|
+
['AOH', { name: '上海虹桥', code: 'AOH' }],
|
|
109
|
+
]);
|
|
110
|
+
|
|
111
|
+
it('extracts the canonical train fields from a wire record', () => {
|
|
112
|
+
// 33 `|`-separated fields, with positions used by parseTrainRecord populated.
|
|
113
|
+
const fields = new Array(36).fill('');
|
|
114
|
+
fields[0] = 'SECRET_TOKEN';
|
|
115
|
+
fields[1] = '预订';
|
|
116
|
+
fields[2] = '240000G54700';
|
|
117
|
+
fields[3] = 'G547';
|
|
118
|
+
fields[6] = 'VNP';
|
|
119
|
+
fields[7] = 'AOH';
|
|
120
|
+
fields[8] = '06:18';
|
|
121
|
+
fields[9] = '12:11';
|
|
122
|
+
fields[10] = '05:53';
|
|
123
|
+
fields[11] = 'Y';
|
|
124
|
+
fields[23] = ''; // soft sleeper
|
|
125
|
+
fields[26] = '无'; // no seat
|
|
126
|
+
fields[28] = ''; // hard sleeper
|
|
127
|
+
fields[29] = ''; // hard seat
|
|
128
|
+
fields[30] = '有'; // second seat
|
|
129
|
+
fields[31] = '有'; // first seat
|
|
130
|
+
fields[32] = '无'; // business seat
|
|
131
|
+
const row = parseTrainRecord(fields.join('|'), stationByCode);
|
|
132
|
+
expect(row).toEqual({
|
|
133
|
+
train_no: '240000G54700',
|
|
134
|
+
code: 'G547',
|
|
135
|
+
from_station: '北京南',
|
|
136
|
+
to_station: '上海虹桥',
|
|
137
|
+
from_code: 'VNP',
|
|
138
|
+
to_code: 'AOH',
|
|
139
|
+
start_time: '06:18',
|
|
140
|
+
arrive_time: '12:11',
|
|
141
|
+
duration: '05:53',
|
|
142
|
+
available: true,
|
|
143
|
+
business_seat: '无',
|
|
144
|
+
first_seat: '有',
|
|
145
|
+
second_seat: '有',
|
|
146
|
+
soft_sleeper: '',
|
|
147
|
+
hard_sleeper: '',
|
|
148
|
+
hard_seat: '',
|
|
149
|
+
no_seat: '无',
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('does not expose the booking-handshake secret token', () => {
|
|
154
|
+
const fields = new Array(36).fill('');
|
|
155
|
+
fields[0] = 'SECRET_TOKEN_DO_NOT_LEAK';
|
|
156
|
+
fields[2] = 't_no'; fields[3] = 'X1'; fields[6] = 'VNP'; fields[7] = 'AOH';
|
|
157
|
+
const row = parseTrainRecord(fields.join('|'), stationByCode);
|
|
158
|
+
expect(Object.values(row)).not.toContain('SECRET_TOKEN_DO_NOT_LEAK');
|
|
159
|
+
expect('secret' in row).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('falls back to the telecode when the station bundle has no name', () => {
|
|
163
|
+
const fields = new Array(36).fill('');
|
|
164
|
+
fields[2] = 'X'; fields[3] = 'X'; fields[6] = 'ZZZ'; fields[7] = 'YYY';
|
|
165
|
+
const row = parseTrainRecord(fields.join('|'), stationByCode);
|
|
166
|
+
expect(row.from_station).toBe('ZZZ');
|
|
167
|
+
expect(row.to_station).toBe('YYY');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('returns null for short records', () => {
|
|
171
|
+
expect(parseTrainRecord('a|b|c', stationByCode)).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('12306 utils - mask helpers', () => {
|
|
176
|
+
it('masks the local-part of an email', () => {
|
|
177
|
+
expect(maskEmail('hello@example.com')).toBe('h***o@example.com');
|
|
178
|
+
expect(maskEmail('ab@x.cn')).toBe('a*@x.cn');
|
|
179
|
+
expect(maskEmail('a@x.cn')).toBe('a*@x.cn');
|
|
180
|
+
expect(maskEmail('')).toBe('');
|
|
181
|
+
expect(maskEmail('not-an-email')).toBe('not-an-email');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('masks Chinese mobile numbers while preserving 12306-side masks', () => {
|
|
185
|
+
expect(maskMobile('13800001234')).toBe('138****1234');
|
|
186
|
+
expect(maskMobile('138****1234')).toBe('138****1234');
|
|
187
|
+
expect(maskMobile('')).toBe('');
|
|
188
|
+
expect(maskMobile('123')).toBe('**3');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('masks Chinese real names', () => {
|
|
192
|
+
expect(maskChineseName('张三')).toBe('张*');
|
|
193
|
+
expect(maskChineseName('李四明')).toBe('李*明');
|
|
194
|
+
expect(maskChineseName('欧阳锋')).toBe('欧*锋');
|
|
195
|
+
expect(maskChineseName('张')).toBe('张');
|
|
196
|
+
expect(maskChineseName('')).toBe('');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('12306 price - parsePriceData', () => {
|
|
201
|
+
it('returns seat rows sorted by descending price and drops dup numeric codes', () => {
|
|
202
|
+
const data = {
|
|
203
|
+
train_no: '24000000G10L',
|
|
204
|
+
'OT': [],
|
|
205
|
+
'A9': '¥2158.0',
|
|
206
|
+
'9': '21580',
|
|
207
|
+
'P': '¥1163.0',
|
|
208
|
+
'M': '¥1035.0',
|
|
209
|
+
'O': '¥626.0',
|
|
210
|
+
'WZ': '¥626.0',
|
|
211
|
+
'INVALID': 'not-a-price',
|
|
212
|
+
};
|
|
213
|
+
const rows = parsePriceData(data);
|
|
214
|
+
const codes = rows.map((r) => r.seat_code);
|
|
215
|
+
expect(codes).not.toContain('9');
|
|
216
|
+
expect(codes).not.toContain('OT');
|
|
217
|
+
expect(codes).not.toContain('train_no');
|
|
218
|
+
expect(codes).not.toContain('INVALID');
|
|
219
|
+
expect(codes).toEqual(['A9', 'P', 'M', 'O', 'WZ']);
|
|
220
|
+
expect(rows[0]).toEqual({ seat_code: 'A9', seat_name: '商务座', price: '2158.0', currency: 'CNY' });
|
|
221
|
+
expect(rows[4]).toEqual({ seat_code: 'WZ', seat_name: '无座', price: '626.0', currency: 'CNY' });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('keeps unknown letter codes with the letter as the name', () => {
|
|
225
|
+
const data = { 'A9': '¥100.0', 'ZZ': '¥50.0' };
|
|
226
|
+
const rows = parsePriceData(data);
|
|
227
|
+
const zz = rows.find((r) => r.seat_code === 'ZZ');
|
|
228
|
+
expect(zz?.seat_name).toBe('ZZ');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('12306 public API typed boundaries', () => {
|
|
233
|
+
const nonJsonFetch = async () => ({
|
|
234
|
+
ok: true,
|
|
235
|
+
json: async () => {
|
|
236
|
+
throw new SyntaxError('Unexpected token <');
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('wraps non-JSON train stop bodies as CommandExecutionError', async () => {
|
|
241
|
+
await expect(queryStops('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
|
|
242
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('wraps non-JSON price helper bodies as CommandExecutionError', async () => {
|
|
246
|
+
await expect(queryStopsForPrice('cookie=1', '24000000G10L', 'BJP', 'AOH', '2026-05-22', nonJsonFetch))
|
|
247
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
248
|
+
await expect(queryPrice('cookie=1', '24000000G10L', '01', '02', 'OM9', '2026-05-22', nonJsonFetch))
|
|
249
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('12306 browser evaluate boundaries', () => {
|
|
254
|
+
it('unwraps Browser Bridge {session,data} evaluate envelopes only at the boundary', () => {
|
|
255
|
+
expect(unwrapEvaluateResult({ session: 's1', data: 'JSESSIONID=1; tk=2' })).toBe('JSESSIONID=1; tk=2');
|
|
256
|
+
expect(unwrapEvaluateResult({ status: true, data: { value: 1 } })).toEqual({ status: true, data: { value: 1 } });
|
|
257
|
+
expect(requireEvaluateObject({ session: 's1', data: { status: true } }, 'test')).toEqual({ status: true });
|
|
258
|
+
expect(() => requireEvaluateObject({ session: 's1', data: null }, 'test')).toThrow(CommandExecutionError);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('classifies 12306 login-like API envelopes as auth failures', () => {
|
|
262
|
+
expect(isAuthLikePayload({ status: false, messages: ['用户未登录'] })).toBe(true);
|
|
263
|
+
expect(isAuthLikePayload({ status: false, validateMessages: { global: ['请登录后再试'] } })).toBe(true);
|
|
264
|
+
expect(isAuthLikePayload({ status: false, messages: ['系统繁忙'] })).toBe(false);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('masks passenger names in orders by default and supports explicit sensitive opt-in', async () => {
|
|
268
|
+
const command = getRegistry().get('12306/orders');
|
|
269
|
+
const makePage = () => ({
|
|
270
|
+
goto: async () => {},
|
|
271
|
+
evaluate: async (script) => {
|
|
272
|
+
if (script === "document.cookie || ''") return { session: 'browser', data: 'JSESSIONID=abc; tk=def' };
|
|
273
|
+
return {
|
|
274
|
+
session: 'browser',
|
|
275
|
+
data: {
|
|
276
|
+
status: true,
|
|
277
|
+
data: {
|
|
278
|
+
orderDBList: [{
|
|
279
|
+
sequence_no: 'E123',
|
|
280
|
+
order_date: '2026-05-18 10:00',
|
|
281
|
+
train_code_page: 'G1',
|
|
282
|
+
from_station_name_page: '北京南',
|
|
283
|
+
to_station_name_page: '上海虹桥',
|
|
284
|
+
start_train_date_page: '2026-05-22 07:00',
|
|
285
|
+
ticket_status_name: '未出行',
|
|
286
|
+
ticket_total_price_page: '626.0',
|
|
287
|
+
tickets: [{ passenger_name: '张三' }, { passenger_name: '李四明' }],
|
|
288
|
+
}],
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
await expect(command.func(makePage(), {})).resolves.toMatchObject([
|
|
296
|
+
{ order_id: 'E123', passengers: '张*, 李*明' },
|
|
297
|
+
]);
|
|
298
|
+
await expect(command.func(makePage(), { 'include-sensitive': true })).resolves.toMatchObject([
|
|
299
|
+
{ order_id: 'E123', passengers: '张三, 李四明' },
|
|
300
|
+
]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('maps login-like order payloads to AuthRequiredError instead of parser drift', async () => {
|
|
304
|
+
const command = getRegistry().get('12306/orders');
|
|
305
|
+
const page = {
|
|
306
|
+
goto: async () => {},
|
|
307
|
+
evaluate: async (script) => {
|
|
308
|
+
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
|
|
309
|
+
return { status: false, messages: ['用户未登录'] };
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
await expect(command.func(page, {})).rejects.toBeInstanceOf(AuthRequiredError);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('treats missing order list shape as parser drift but known empty arrays as empty result', async () => {
|
|
317
|
+
const command = getRegistry().get('12306/orders');
|
|
318
|
+
const makePage = (payload) => ({
|
|
319
|
+
goto: async () => {},
|
|
320
|
+
evaluate: async (script) => {
|
|
321
|
+
if (script === "document.cookie || ''") return 'JSESSIONID=abc; tk=def';
|
|
322
|
+
return payload;
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
await expect(command.func(makePage({ status: true, data: {} }), {}))
|
|
327
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
328
|
+
await expect(command.func(makePage({ status: true, data: { orderDBList: [] } }), {}))
|
|
329
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
330
|
+
});
|
|
331
|
+
});
|
package/clis/36kr/article.js
CHANGED
|
@@ -52,12 +52,15 @@ cli({
|
|
|
52
52
|
if (!data?.title) {
|
|
53
53
|
throw new CliError('NOT_FOUND', 'Article not found or failed to load', 'Check the article ID');
|
|
54
54
|
}
|
|
55
|
+
if (!data.body) {
|
|
56
|
+
throw new CliError('PARSE_ERROR', 'Article body not found', '36kr page loaded but no article body paragraphs were extracted');
|
|
57
|
+
}
|
|
55
58
|
return [
|
|
56
59
|
{ field: 'title', value: data.title },
|
|
57
|
-
{ field: 'author', value: data.author || '
|
|
58
|
-
{ field: 'date', value: data.date || '
|
|
60
|
+
{ field: 'author', value: data.author || '' },
|
|
61
|
+
{ field: 'date', value: data.date || '' },
|
|
59
62
|
{ field: 'url', value: `https://36kr.com/p/${articleId}` },
|
|
60
|
-
{ field: 'body', value: data.body || '
|
|
63
|
+
{ field: 'body', value: data.body || '' },
|
|
61
64
|
];
|
|
62
65
|
},
|
|
63
66
|
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { CliError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './article.js';
|
|
5
|
+
|
|
6
|
+
function makePage(evaluateResult) {
|
|
7
|
+
return {
|
|
8
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('36kr article', () => {
|
|
16
|
+
it('emits empty-string for missing optional author / date instead of a sentinel', async () => {
|
|
17
|
+
const command = getRegistry().get('36kr/article');
|
|
18
|
+
expect(command?.func).toBeDefined();
|
|
19
|
+
const page = makePage({ title: 'Real Title', author: '', date: '', body: 'Real article body' });
|
|
20
|
+
const rows = await command.func(page, { id: '1234567' });
|
|
21
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
22
|
+
expect(byField.title).toBe('Real Title');
|
|
23
|
+
expect(byField.author).toBe('');
|
|
24
|
+
expect(byField.date).toBe('');
|
|
25
|
+
expect(byField.body).toBe('Real article body');
|
|
26
|
+
expect(byField.url).toBe('https://36kr.com/p/1234567');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('throws CliError NOT_FOUND when the page exposes no title', async () => {
|
|
30
|
+
const command = getRegistry().get('36kr/article');
|
|
31
|
+
const page = makePage({ title: '', author: 'x', date: 'y', body: 'z' });
|
|
32
|
+
await expect(command.func(page, { id: '1234567' })).rejects.toBeInstanceOf(CliError);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('throws CliError PARSE_ERROR when the page exposes title but no body', async () => {
|
|
36
|
+
const command = getRegistry().get('36kr/article');
|
|
37
|
+
const page = makePage({ title: 'Real Title', author: 'x', date: 'y', body: '' });
|
|
38
|
+
await expect(command.func(page, { id: '1234567' })).rejects.toMatchObject({ code: 'PARSE_ERROR' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('throws CliError INVALID_ARGUMENT when no numeric id can be parsed', async () => {
|
|
42
|
+
const command = getRegistry().get('36kr/article');
|
|
43
|
+
const page = makePage({});
|
|
44
|
+
await expect(command.func(page, { id: 'not-a-url' })).rejects.toBeInstanceOf(CliError);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -41,6 +41,26 @@ describe('apple-podcasts search command', () => {
|
|
|
41
41
|
}),
|
|
42
42
|
]);
|
|
43
43
|
});
|
|
44
|
+
it('emits empty-string for missing trackCount and primaryGenreName instead of a sentinel', async () => {
|
|
45
|
+
const cmd = getRegistry().get('apple-podcasts/search');
|
|
46
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
47
|
+
ok: true,
|
|
48
|
+
json: () => Promise.resolve({
|
|
49
|
+
results: [
|
|
50
|
+
{
|
|
51
|
+
collectionId: 99,
|
|
52
|
+
collectionName: 'No-Meta Show',
|
|
53
|
+
artistName: 'Anon Host',
|
|
54
|
+
collectionViewUrl: 'https://example.com/p/99',
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
});
|
|
59
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
60
|
+
const result = await cmd.func({ query: 'no-meta', limit: 1 });
|
|
61
|
+
expect(result[0].episodes).toBe('');
|
|
62
|
+
expect(result[0].genre).toBe('');
|
|
63
|
+
});
|
|
44
64
|
});
|
|
45
65
|
describe('apple-podcasts top command', () => {
|
|
46
66
|
beforeEach(() => {
|
|
@@ -23,8 +23,8 @@ cli({
|
|
|
23
23
|
id: p.collectionId,
|
|
24
24
|
title: p.collectionName,
|
|
25
25
|
author: p.artistName,
|
|
26
|
-
episodes: p.trackCount ?? '
|
|
27
|
-
genre: p.primaryGenreName ?? '
|
|
26
|
+
episodes: p.trackCount ?? '',
|
|
27
|
+
genre: p.primaryGenreName ?? '',
|
|
28
28
|
url: p.collectionViewUrl || '',
|
|
29
29
|
}));
|
|
30
30
|
},
|