@jackwener/opencli 1.7.22 → 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 +30 -148
- package/README.zh-CN.md +37 -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/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/delete.js +172 -0
- package/clis/weibo/delete.test.js +94 -0
- package/clis/weibo/publish.js +37 -14
- package/clis/weibo/publish.test.js +14 -5
- package/clis/weibo/user-posts.js +234 -0
- package/clis/weibo/user-posts.test.js +92 -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/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 +8 -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
|
@@ -12,10 +12,12 @@ describe('zhihu question', () => {
|
|
|
12
12
|
// user-requested `--limit 3` is enforced by the dedup loop's
|
|
13
13
|
// `answers.length >= answerLimit` break, not by the fetch URL.
|
|
14
14
|
expect(js).toContain('questions/2021881398772981878/answers?limit=20');
|
|
15
|
+
expect(js).toContain('content,url,voteup_count');
|
|
15
16
|
expect(js).toContain("credentials: 'include'");
|
|
16
17
|
return {
|
|
17
18
|
data: [
|
|
18
19
|
{
|
|
20
|
+
id: '2036567240334653053',
|
|
19
21
|
author: { name: 'alice' },
|
|
20
22
|
voteup_count: 12,
|
|
21
23
|
content: 'Hello Zhihu',
|
|
@@ -27,22 +29,95 @@ describe('zhihu question', () => {
|
|
|
27
29
|
await expect(cmd.func(page, { id: '2021881398772981878', limit: 3 })).resolves.toEqual([
|
|
28
30
|
{
|
|
29
31
|
rank: 1,
|
|
32
|
+
id: '2036567240334653053',
|
|
30
33
|
author: 'alice',
|
|
31
34
|
votes: 12,
|
|
35
|
+
url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653053',
|
|
32
36
|
content: 'Hello Zhihu',
|
|
33
37
|
},
|
|
34
38
|
]);
|
|
35
39
|
expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878');
|
|
36
40
|
expect(evaluate).toHaveBeenCalledTimes(1);
|
|
37
41
|
});
|
|
42
|
+
it('prefers the answer URL when extracting large answer IDs', async () => {
|
|
43
|
+
const cmd = getRegistry().get('zhihu/question');
|
|
44
|
+
const page = {
|
|
45
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
46
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
47
|
+
data: [
|
|
48
|
+
{
|
|
49
|
+
id: 2036567240334653000,
|
|
50
|
+
url: 'https://www.zhihu.com/api/v4/answers/2036567240334653053',
|
|
51
|
+
author: { name: 'alice' },
|
|
52
|
+
voteup_count: 12,
|
|
53
|
+
content: '<p>precise id</p>',
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
paging: { is_end: true },
|
|
57
|
+
}),
|
|
58
|
+
};
|
|
59
|
+
await expect(cmd.func(page, { id: '2021881398772981878', limit: 1 })).resolves.toEqual([
|
|
60
|
+
{
|
|
61
|
+
rank: 1,
|
|
62
|
+
id: '2036567240334653053',
|
|
63
|
+
author: 'alice',
|
|
64
|
+
votes: 12,
|
|
65
|
+
url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653053',
|
|
66
|
+
content: 'precise id',
|
|
67
|
+
},
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
it('deduplicates paginated answers by precise answer URL identity', async () => {
|
|
71
|
+
const cmd = getRegistry().get('zhihu/question');
|
|
72
|
+
const page = {
|
|
73
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
74
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
75
|
+
data: [
|
|
76
|
+
{
|
|
77
|
+
id: 2036567240334653000,
|
|
78
|
+
url: 'https://www.zhihu.com/api/v4/answers/2036567240334653053',
|
|
79
|
+
author: { name: 'alice' },
|
|
80
|
+
voteup_count: 12,
|
|
81
|
+
content: '<p>first precise id</p>',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 2036567240334653000,
|
|
85
|
+
url: 'https://www.zhihu.com/api/v4/answers/2036567240334653054',
|
|
86
|
+
author: { name: 'bob' },
|
|
87
|
+
voteup_count: 8,
|
|
88
|
+
content: '<p>second precise id</p>',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
paging: { is_end: true },
|
|
92
|
+
}),
|
|
93
|
+
};
|
|
94
|
+
await expect(cmd.func(page, { id: '2021881398772981878', limit: 2 })).resolves.toEqual([
|
|
95
|
+
{
|
|
96
|
+
rank: 1,
|
|
97
|
+
id: '2036567240334653053',
|
|
98
|
+
author: 'alice',
|
|
99
|
+
votes: 12,
|
|
100
|
+
url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653053',
|
|
101
|
+
content: 'first precise id',
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
rank: 2,
|
|
105
|
+
id: '2036567240334653054',
|
|
106
|
+
author: 'bob',
|
|
107
|
+
votes: 8,
|
|
108
|
+
url: 'https://www.zhihu.com/question/2021881398772981878/answer/2036567240334653054',
|
|
109
|
+
content: 'second precise id',
|
|
110
|
+
},
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
38
113
|
it('follows paging.next until the requested limit is reached', async () => {
|
|
39
114
|
const cmd = getRegistry().get('zhihu/question');
|
|
40
115
|
const goto = vi.fn().mockResolvedValue(undefined);
|
|
41
116
|
const evaluate = vi.fn()
|
|
42
117
|
.mockResolvedValueOnce({
|
|
43
118
|
data: [
|
|
44
|
-
{ id: '
|
|
45
|
-
{ id: '
|
|
119
|
+
{ id: '101', author: { name: 'alice' }, voteup_count: 12, content: '<p>first</p>' },
|
|
120
|
+
{ id: '102', author: { name: 'bob' }, voteup_count: 8, content: '<p>second</p>' },
|
|
46
121
|
],
|
|
47
122
|
paging: {
|
|
48
123
|
is_end: false,
|
|
@@ -51,16 +126,16 @@ describe('zhihu question', () => {
|
|
|
51
126
|
})
|
|
52
127
|
.mockResolvedValueOnce({
|
|
53
128
|
data: [
|
|
54
|
-
{ id: '
|
|
55
|
-
{ id: '
|
|
129
|
+
{ id: '102', author: { name: 'bob duplicate' }, voteup_count: 8, content: '<p>duplicate</p>' },
|
|
130
|
+
{ id: '103', author: { name: 'carol' }, voteup_count: 5, content: '<p>third</p>' },
|
|
56
131
|
],
|
|
57
132
|
paging: { is_end: true },
|
|
58
133
|
});
|
|
59
134
|
const page = { goto, evaluate };
|
|
60
135
|
await expect(cmd.func(page, { id: '2021881398772981878', limit: 3 })).resolves.toEqual([
|
|
61
|
-
{ rank: 1, author: 'alice', votes: 12, content: 'first' },
|
|
62
|
-
{ rank: 2, author: 'bob', votes: 8, content: 'second' },
|
|
63
|
-
{ rank: 3, author: 'carol', votes: 5, content: 'third' },
|
|
136
|
+
{ rank: 1, id: '101', author: 'alice', votes: 12, url: 'https://www.zhihu.com/question/2021881398772981878/answer/101', content: 'first' },
|
|
137
|
+
{ rank: 2, id: '102', author: 'bob', votes: 8, url: 'https://www.zhihu.com/question/2021881398772981878/answer/102', content: 'second' },
|
|
138
|
+
{ rank: 3, id: '103', author: 'carol', votes: 5, url: 'https://www.zhihu.com/question/2021881398772981878/answer/103', content: 'third' },
|
|
64
139
|
]);
|
|
65
140
|
expect(evaluate).toHaveBeenCalledTimes(2);
|
|
66
141
|
expect(evaluate.mock.calls[1][0]).toContain('offset=80');
|
|
@@ -73,7 +148,7 @@ describe('zhihu question', () => {
|
|
|
73
148
|
return {
|
|
74
149
|
data: [
|
|
75
150
|
{
|
|
76
|
-
id: '
|
|
151
|
+
id: '101',
|
|
77
152
|
author: { name: 'newest' },
|
|
78
153
|
voteup_count: 1,
|
|
79
154
|
content: '<p>created order</p>',
|
|
@@ -84,10 +159,37 @@ describe('zhihu question', () => {
|
|
|
84
159
|
});
|
|
85
160
|
const page = { goto, evaluate };
|
|
86
161
|
await expect(cmd.func(page, { id: '2021881398772981878', limit: 1, sort: 'created' })).resolves.toEqual([
|
|
87
|
-
{ rank: 1, author: 'newest', votes: 1, content: 'created order' },
|
|
162
|
+
{ rank: 1, id: '101', author: 'newest', votes: 1, url: 'https://www.zhihu.com/question/2021881398772981878/answer/101', content: 'created order' },
|
|
88
163
|
]);
|
|
89
164
|
expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878/answers/updated');
|
|
90
165
|
});
|
|
166
|
+
it('does not emit a fake answer URL for malformed answer IDs', async () => {
|
|
167
|
+
const cmd = getRegistry().get('zhihu/question');
|
|
168
|
+
const page = {
|
|
169
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
170
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
171
|
+
data: [
|
|
172
|
+
{
|
|
173
|
+
id: 'not-an-id',
|
|
174
|
+
author: { name: 'alice' },
|
|
175
|
+
voteup_count: 12,
|
|
176
|
+
content: '<p>malformed id</p>',
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
paging: { is_end: true },
|
|
180
|
+
}),
|
|
181
|
+
};
|
|
182
|
+
await expect(cmd.func(page, { id: '2021881398772981878', limit: 1 })).resolves.toEqual([
|
|
183
|
+
{
|
|
184
|
+
rank: 1,
|
|
185
|
+
id: '',
|
|
186
|
+
author: 'alice',
|
|
187
|
+
votes: 12,
|
|
188
|
+
url: '',
|
|
189
|
+
content: 'malformed id',
|
|
190
|
+
},
|
|
191
|
+
]);
|
|
192
|
+
});
|
|
91
193
|
it('maps auth-like answer failures to AuthRequiredError', async () => {
|
|
92
194
|
const cmd = getRegistry().get('zhihu/question');
|
|
93
195
|
const page = {
|
package/clis/zhihu/search.js
CHANGED
|
@@ -1,54 +1,217 @@
|
|
|
1
|
-
import { cli } from '@jackwener/opencli/registry';
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
function stripHtml(html) {
|
|
5
|
+
return (html || '')
|
|
6
|
+
.replace(/<[^>]+>/g, '')
|
|
7
|
+
.replace(/ /g, ' ')
|
|
8
|
+
.replace(/</g, '<')
|
|
9
|
+
.replace(/>/g, '>')
|
|
10
|
+
.replace(/&/g, '&')
|
|
11
|
+
.replace(/<em>/g, '')
|
|
12
|
+
.replace(/<\/em>/g, '')
|
|
13
|
+
.trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function itemKey(item) {
|
|
17
|
+
const obj = item.object || {};
|
|
18
|
+
if (obj.id != null) return `${obj.type || ''}:${obj.id}`;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function itemUrl(obj) {
|
|
23
|
+
const id = obj.id == null ? '' : String(obj.id);
|
|
24
|
+
if (obj.type === 'answer') {
|
|
25
|
+
const questionId = obj.question?.id == null ? '' : String(obj.question.id);
|
|
26
|
+
return questionId && id ? `https://www.zhihu.com/question/${questionId}/answer/${id}` : '';
|
|
27
|
+
}
|
|
28
|
+
if (obj.type === 'article') {
|
|
29
|
+
return id ? `https://zhuanlan.zhihu.com/p/${id}` : '';
|
|
30
|
+
}
|
|
31
|
+
if (obj.type === 'question') {
|
|
32
|
+
return id ? `https://www.zhihu.com/question/${id}` : '';
|
|
33
|
+
}
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeSearchUrl(url) {
|
|
38
|
+
if (typeof url !== 'string' || !url) return '';
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
if (parsed.hostname === 'api.zhihu.com' && parsed.pathname === '/search_v3') {
|
|
42
|
+
return `https://www.zhihu.com/api/v4/search_v3${parsed.search}`;
|
|
43
|
+
}
|
|
44
|
+
if (parsed.hostname === 'www.zhihu.com' && parsed.pathname === '/api/v4/search_v3') {
|
|
45
|
+
return parsed.toString();
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const MAX_LIMIT = 1000;
|
|
54
|
+
const PAGE_SIZE = 20;
|
|
55
|
+
const TYPES = ['all', 'answer', 'article', 'question'];
|
|
56
|
+
|
|
57
|
+
function parseLimit(value) {
|
|
58
|
+
const limit = Number(value ?? 10);
|
|
59
|
+
if (!Number.isInteger(limit) || limit <= 0 || limit > MAX_LIMIT) {
|
|
60
|
+
throw new ArgumentError(`zhihu search --limit must be a positive integer no greater than ${MAX_LIMIT}`, 'Use a normal-sized limit to avoid slow requests or Zhihu risk controls');
|
|
61
|
+
}
|
|
62
|
+
return limit;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function requireQuery(value) {
|
|
66
|
+
const query = String(value || '').trim();
|
|
67
|
+
if (!query) {
|
|
68
|
+
throw new ArgumentError('zhihu search query must not be empty', 'Example: opencli zhihu search codex');
|
|
69
|
+
}
|
|
70
|
+
return query;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function requireType(value) {
|
|
74
|
+
const type = String(value || 'all');
|
|
75
|
+
if (!TYPES.includes(type)) {
|
|
76
|
+
throw new ArgumentError(`zhihu search --type must be one of: ${TYPES.join(', ')}`, 'Example: opencli zhihu search codex --type answer');
|
|
77
|
+
}
|
|
78
|
+
return type;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function unwrapEvaluateResult(payload) {
|
|
82
|
+
if (payload && typeof payload === 'object' && 'data' in payload && 'session' in payload) return payload.data;
|
|
83
|
+
return payload;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function requireSearchPayload(data, url) {
|
|
87
|
+
const payload = unwrapEvaluateResult(data);
|
|
88
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
89
|
+
throw new CommandExecutionError('Zhihu search returned malformed payload');
|
|
90
|
+
}
|
|
91
|
+
if (payload.__httpError) {
|
|
92
|
+
const status = payload.__httpError;
|
|
93
|
+
if (status === 401 || status === 403) {
|
|
94
|
+
throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch search results from Zhihu');
|
|
95
|
+
}
|
|
96
|
+
throw new CommandExecutionError(`Zhihu search request failed${status ? ` (HTTP ${status})` : ''}`, 'Try again later or rerun with -v for more detail');
|
|
97
|
+
}
|
|
98
|
+
if (payload.__fetchError) {
|
|
99
|
+
throw new CommandExecutionError('Zhihu search request failed', String(payload.__fetchError));
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(payload.data)) {
|
|
102
|
+
throw new CommandExecutionError('Zhihu search returned malformed data list', `URL: ${url}`);
|
|
103
|
+
}
|
|
104
|
+
if (!payload.paging || typeof payload.paging !== 'object') {
|
|
105
|
+
throw new CommandExecutionError('Zhihu search returned malformed paging data', `URL: ${url}`);
|
|
106
|
+
}
|
|
107
|
+
return payload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function normalizeResultItem(item) {
|
|
111
|
+
if (!item || typeof item !== 'object' || item.type !== 'search_result' || !item.object || typeof item.object !== 'object') {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const obj = item.object;
|
|
115
|
+
if (obj.type !== 'answer' && obj.type !== 'article' && obj.type !== 'question') return null;
|
|
116
|
+
const key = itemKey(item);
|
|
117
|
+
const url = itemUrl(obj);
|
|
118
|
+
const question = obj.question || {};
|
|
119
|
+
const title = stripHtml(obj.title || question.name || question.title || '');
|
|
120
|
+
if (!key || !url || !title) {
|
|
121
|
+
throw new CommandExecutionError('Zhihu search returned malformed result row identity');
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
item,
|
|
125
|
+
key,
|
|
126
|
+
row: {
|
|
127
|
+
title,
|
|
128
|
+
type: obj.type,
|
|
129
|
+
author: obj.author?.name || '',
|
|
130
|
+
votes: obj.voteup_count || 0,
|
|
131
|
+
url,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
2
136
|
cli({
|
|
3
137
|
site: 'zhihu',
|
|
4
138
|
name: 'search',
|
|
5
139
|
access: 'read',
|
|
6
140
|
description: '知乎搜索',
|
|
7
141
|
domain: 'www.zhihu.com',
|
|
142
|
+
strategy: Strategy.COOKIE,
|
|
8
143
|
args: [
|
|
9
144
|
{ name: 'query', required: true, positional: true, help: 'Search query' },
|
|
10
|
-
{ name: 'limit', type: 'int', default: 10, help: 'Number of results' },
|
|
145
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Number of results (max 1000; use normal-sized requests)' },
|
|
146
|
+
{ name: 'type', default: 'all', choices: TYPES, help: 'Result type: all, answer, article, or question' },
|
|
11
147
|
],
|
|
12
148
|
columns: ['rank', 'title', 'type', 'author', 'votes', 'url'],
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
149
|
+
func: async (page, kwargs) => {
|
|
150
|
+
const query = requireQuery(kwargs.query);
|
|
151
|
+
const resultLimit = parseLimit(kwargs.limit);
|
|
152
|
+
const type = requireType(kwargs.type);
|
|
153
|
+
await page.goto('https://www.zhihu.com');
|
|
154
|
+
let url = 'https://www.zhihu.com/api/v4/search_v3'
|
|
155
|
+
+ `?q=${encodeURIComponent(query)}&t=general&offset=0&limit=${PAGE_SIZE}`;
|
|
156
|
+
const results = [];
|
|
157
|
+
const seen = new Set();
|
|
158
|
+
const visited = new Set();
|
|
159
|
+
while (url && results.length < resultLimit && !visited.has(url)) {
|
|
160
|
+
visited.add(url);
|
|
161
|
+
const data = requireSearchPayload(await page.evaluate(`
|
|
162
|
+
(async () => {
|
|
163
|
+
try {
|
|
164
|
+
const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
|
|
165
|
+
if (!r.ok) return { __httpError: r.status };
|
|
166
|
+
return await r.json();
|
|
167
|
+
} catch (err) {
|
|
168
|
+
return { __fetchError: err?.message || String(err) };
|
|
169
|
+
}
|
|
170
|
+
})()
|
|
171
|
+
`), url);
|
|
172
|
+
for (const item of data.data) {
|
|
173
|
+
const rawType = item?.object?.type;
|
|
174
|
+
if (type !== 'all' && rawType && rawType !== type) continue;
|
|
175
|
+
const normalized = normalizeResultItem(item);
|
|
176
|
+
if (!normalized) continue;
|
|
177
|
+
if (type !== 'all' && normalized.row.type !== type) continue;
|
|
178
|
+
if (seen.has(normalized.key)) continue;
|
|
179
|
+
seen.add(normalized.key);
|
|
180
|
+
results.push(normalized.row);
|
|
181
|
+
if (results.length >= resultLimit) break;
|
|
182
|
+
}
|
|
183
|
+
if (results.length >= resultLimit) break;
|
|
184
|
+
if (data.paging?.is_end) break;
|
|
185
|
+
const next = normalizeSearchUrl(data.paging?.next);
|
|
186
|
+
if (!next) {
|
|
187
|
+
throw new CommandExecutionError('Zhihu search pagination returned malformed next URL');
|
|
188
|
+
}
|
|
189
|
+
if (visited.has(next)) {
|
|
190
|
+
throw new CommandExecutionError('Zhihu search pagination returned a repeated next URL');
|
|
191
|
+
}
|
|
192
|
+
url = next;
|
|
193
|
+
}
|
|
194
|
+
if (results.length === 0) {
|
|
195
|
+
throw new EmptyResultError('zhihu search', `No ${type === 'all' ? '' : `${type} `}results found for "${query}"`);
|
|
196
|
+
}
|
|
197
|
+
return results.map((row, i) => {
|
|
198
|
+
return {
|
|
199
|
+
rank: i + 1,
|
|
200
|
+
...row,
|
|
201
|
+
};
|
|
202
|
+
});
|
|
203
|
+
},
|
|
54
204
|
});
|
|
205
|
+
|
|
206
|
+
export const __test__ = {
|
|
207
|
+
stripHtml,
|
|
208
|
+
itemKey,
|
|
209
|
+
itemUrl,
|
|
210
|
+
normalizeSearchUrl,
|
|
211
|
+
parseLimit,
|
|
212
|
+
requireQuery,
|
|
213
|
+
requireType,
|
|
214
|
+
unwrapEvaluateResult,
|
|
215
|
+
requireSearchPayload,
|
|
216
|
+
normalizeResultItem,
|
|
217
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
4
|
+
import './search.js';
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
normalizeSearchUrl,
|
|
8
|
+
requireSearchPayload,
|
|
9
|
+
normalizeResultItem,
|
|
10
|
+
} = await import('./search.js').then((m) => m.__test__);
|
|
11
|
+
|
|
12
|
+
describe('zhihu search', () => {
|
|
13
|
+
it('returns search_result entries from the Zhihu search API', async () => {
|
|
14
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
15
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
16
|
+
const goto = vi.fn().mockResolvedValue(undefined);
|
|
17
|
+
const evaluate = vi.fn().mockImplementation(async (js) => {
|
|
18
|
+
expect(js).toContain('/api/v4/search_v3');
|
|
19
|
+
expect(js).toContain('limit=20');
|
|
20
|
+
expect(js).toContain("credentials: 'include'");
|
|
21
|
+
return {
|
|
22
|
+
data: [
|
|
23
|
+
{
|
|
24
|
+
type: 'hot_timing',
|
|
25
|
+
object: {
|
|
26
|
+
type: 'hot_timing',
|
|
27
|
+
content_items: [
|
|
28
|
+
{ object: { id: 'discussion-1', type: 'article', title: 'discussion' } },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: 'search_result',
|
|
34
|
+
object: {
|
|
35
|
+
id: 'a1',
|
|
36
|
+
type: 'answer',
|
|
37
|
+
author: { name: 'alice' },
|
|
38
|
+
voteup_count: 12,
|
|
39
|
+
question: { id: 'q1', name: '<em>Codex</em> question' },
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
type: 'search_result',
|
|
44
|
+
object: {
|
|
45
|
+
id: 'p1',
|
|
46
|
+
type: 'article',
|
|
47
|
+
title: '<em>Codex</em> article',
|
|
48
|
+
author: { name: 'bob' },
|
|
49
|
+
voteup_count: 7,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
paging: { is_end: true },
|
|
54
|
+
};
|
|
55
|
+
});
|
|
56
|
+
const page = { goto, evaluate };
|
|
57
|
+
await expect(cmd.func(page, { query: 'codex', limit: 2 })).resolves.toEqual([
|
|
58
|
+
{
|
|
59
|
+
rank: 1,
|
|
60
|
+
title: 'Codex question',
|
|
61
|
+
type: 'answer',
|
|
62
|
+
author: 'alice',
|
|
63
|
+
votes: 12,
|
|
64
|
+
url: 'https://www.zhihu.com/question/q1/answer/a1',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
rank: 2,
|
|
68
|
+
title: 'Codex article',
|
|
69
|
+
type: 'article',
|
|
70
|
+
author: 'bob',
|
|
71
|
+
votes: 7,
|
|
72
|
+
url: 'https://zhuanlan.zhihu.com/p/p1',
|
|
73
|
+
},
|
|
74
|
+
]);
|
|
75
|
+
expect(goto).toHaveBeenCalledWith('https://www.zhihu.com');
|
|
76
|
+
expect(evaluate).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('follows paging.next until the requested limit is reached', async () => {
|
|
80
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
81
|
+
const page = {
|
|
82
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
83
|
+
evaluate: vi.fn()
|
|
84
|
+
.mockResolvedValueOnce({
|
|
85
|
+
data: [
|
|
86
|
+
{ type: 'search_result', object: { id: 'a1', type: 'answer', question: { id: 'q1', name: 'first' } } },
|
|
87
|
+
{ type: 'search_result', object: { id: 'a2', type: 'answer', question: { id: 'q2', name: 'second' } } },
|
|
88
|
+
],
|
|
89
|
+
paging: {
|
|
90
|
+
is_end: false,
|
|
91
|
+
next: 'https://api.zhihu.com/search_v3?offset=20&q=codex',
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
.mockResolvedValueOnce({
|
|
95
|
+
data: [
|
|
96
|
+
{ type: 'search_result', object: { id: 'a2', type: 'answer', question: { id: 'q2', name: 'duplicate' } } },
|
|
97
|
+
{ type: 'search_result', object: { id: 'q3', type: 'question', title: 'third' } },
|
|
98
|
+
],
|
|
99
|
+
paging: { is_end: true },
|
|
100
|
+
}),
|
|
101
|
+
};
|
|
102
|
+
await expect(cmd.func(page, { query: 'codex', limit: 3 })).resolves.toEqual([
|
|
103
|
+
{ rank: 1, title: 'first', type: 'answer', author: '', votes: 0, url: 'https://www.zhihu.com/question/q1/answer/a1' },
|
|
104
|
+
{ rank: 2, title: 'second', type: 'answer', author: '', votes: 0, url: 'https://www.zhihu.com/question/q2/answer/a2' },
|
|
105
|
+
{ rank: 3, title: 'third', type: 'question', author: '', votes: 0, url: 'https://www.zhihu.com/question/q3' },
|
|
106
|
+
]);
|
|
107
|
+
expect(page.evaluate).toHaveBeenCalledTimes(2);
|
|
108
|
+
expect(page.evaluate.mock.calls[1][0]).toContain('https://www.zhihu.com/api/v4/search_v3?offset=20&q=codex');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('filters by result type', async () => {
|
|
112
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
113
|
+
const page = {
|
|
114
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
115
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
116
|
+
data: [
|
|
117
|
+
{ type: 'search_result', object: { id: 'a1', type: 'answer' } },
|
|
118
|
+
{ type: 'search_result', object: { id: 'p1', type: 'article', title: 'article' } },
|
|
119
|
+
],
|
|
120
|
+
paging: { is_end: true },
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
await expect(cmd.func(page, { query: 'codex', limit: 2, type: 'article' })).resolves.toEqual([
|
|
124
|
+
{ rank: 1, title: 'article', type: 'article', author: '', votes: 0, url: 'https://zhuanlan.zhihu.com/p/p1' },
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('maps auth-like failures to AuthRequiredError', async () => {
|
|
129
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
130
|
+
const page = {
|
|
131
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
132
|
+
evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
|
|
133
|
+
};
|
|
134
|
+
await expect(cmd.func(page, { query: 'codex', limit: 3 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('preserves non-auth fetch failures as typed execution errors', async () => {
|
|
138
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
139
|
+
const page = {
|
|
140
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
141
|
+
evaluate: vi.fn().mockResolvedValue({ __httpError: 500 }),
|
|
142
|
+
};
|
|
143
|
+
await expect(cmd.func(page, { query: 'codex', limit: 3 }))
|
|
144
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects invalid input before navigation', async () => {
|
|
148
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
149
|
+
const page = { goto: vi.fn(), evaluate: vi.fn() };
|
|
150
|
+
await expect(cmd.func(page, { query: '', limit: 1 })).rejects.toBeInstanceOf(ArgumentError);
|
|
151
|
+
await expect(cmd.func(page, { query: 'codex', limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
152
|
+
await expect(cmd.func(page, { query: 'codex', limit: 1001 })).rejects.toBeInstanceOf(ArgumentError);
|
|
153
|
+
await expect(cmd.func(page, { query: 'codex', limit: 1, type: 'video' })).rejects.toBeInstanceOf(ArgumentError);
|
|
154
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
155
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('unwraps Browser Bridge envelopes and fails typed on malformed payloads', () => {
|
|
159
|
+
const payload = { data: [], paging: { is_end: true } };
|
|
160
|
+
expect(requireSearchPayload({ session: {}, data: payload }, 'https://www.zhihu.com/api/v4/search_v3')).toBe(payload);
|
|
161
|
+
expect(() => requireSearchPayload(null, 'url')).toThrow(CommandExecutionError);
|
|
162
|
+
expect(() => requireSearchPayload({ data: null, paging: { is_end: true } }, 'url')).toThrow(CommandExecutionError);
|
|
163
|
+
expect(() => requireSearchPayload({ data: [], paging: null }, 'url')).toThrow(CommandExecutionError);
|
|
164
|
+
expect(() => requireSearchPayload({ __fetchError: 'network down' }, 'url')).toThrow(CommandExecutionError);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('fails typed on malformed supported result rows instead of emitting blank identity rows', () => {
|
|
168
|
+
expect(() => normalizeResultItem({ type: 'search_result', object: { type: 'answer', id: 'a1', question: { name: 'missing question id' } } }))
|
|
169
|
+
.toThrow(CommandExecutionError);
|
|
170
|
+
expect(() => normalizeResultItem({ type: 'search_result', object: { type: 'article', id: 'p1' } }))
|
|
171
|
+
.toThrow(CommandExecutionError);
|
|
172
|
+
expect(normalizeResultItem({ type: 'hot_timing', object: { type: 'article', id: 'p1' } })).toBe(null);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rejects malformed pagination next URLs and reports valid empty result separately', async () => {
|
|
176
|
+
expect(normalizeSearchUrl('https://api.zhihu.com/search_v3?offset=20&q=codex'))
|
|
177
|
+
.toBe('https://www.zhihu.com/api/v4/search_v3?offset=20&q=codex');
|
|
178
|
+
expect(normalizeSearchUrl('https://evil.example/search_v3?offset=20')).toBe('');
|
|
179
|
+
|
|
180
|
+
const cmd = getRegistry().get('zhihu/search');
|
|
181
|
+
const malformedNextPage = {
|
|
182
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
183
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
184
|
+
data: [],
|
|
185
|
+
paging: { is_end: false, next: 'https://evil.example/search_v3?offset=20' },
|
|
186
|
+
}),
|
|
187
|
+
};
|
|
188
|
+
await expect(cmd.func(malformedNextPage, { query: 'codex', limit: 3 }))
|
|
189
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
190
|
+
|
|
191
|
+
const emptyPage = {
|
|
192
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
193
|
+
evaluate: vi.fn().mockResolvedValue({ data: [], paging: { is_end: true } }),
|
|
194
|
+
};
|
|
195
|
+
await expect(cmd.func(emptyPage, { query: 'codex', limit: 3 }))
|
|
196
|
+
.rejects.toBeInstanceOf(EmptyResultError);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
@@ -49,8 +49,10 @@ export function classifyBrowserError(err) {
|
|
|
49
49
|
if (TARGET_NAVIGATION_PATTERNS.some(p => msg.includes(p))) {
|
|
50
50
|
return { kind: 'target-navigation', retryable: true, delayMs: 200 };
|
|
51
51
|
}
|
|
52
|
-
// CDP protocol error with target
|
|
53
|
-
|
|
52
|
+
// CDP protocol error with target/context invalidation (e.g., -32000 "target closed" or
|
|
53
|
+
// -32000 "Cannot find default execution context" — both indicate the inspected target
|
|
54
|
+
// went away and a fresh attach should recover).
|
|
55
|
+
if (msg.includes('-32000') && /target|context/i.test(msg)) {
|
|
54
56
|
return { kind: 'target-navigation', retryable: true, delayMs: 200 };
|
|
55
57
|
}
|
|
56
58
|
return { kind: 'non-retryable', retryable: false, delayMs: 0 };
|