@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,287 @@
|
|
|
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 './answer-comments.js';
|
|
5
|
+
import { __test__ as helpers } from './answer-comments.js';
|
|
6
|
+
|
|
7
|
+
describe('zhihu answer-comments', () => {
|
|
8
|
+
it('registers as a cookie read command', () => {
|
|
9
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
10
|
+
expect(cmd).toBeDefined();
|
|
11
|
+
expect(cmd.access).toBe('read');
|
|
12
|
+
expect(cmd.strategy).toBe('cookie');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('returns flattened comments while limiting only top-level comments', async () => {
|
|
16
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
17
|
+
const goto = vi.fn().mockResolvedValue(undefined);
|
|
18
|
+
const evaluate = vi.fn().mockImplementation(async (js) => {
|
|
19
|
+
expect(js).toContain('/api/v4/answers/2036567240334653053/comments?order=normal&limit=20');
|
|
20
|
+
expect(js).toContain("credentials: 'include'");
|
|
21
|
+
return {
|
|
22
|
+
data: [
|
|
23
|
+
{
|
|
24
|
+
id: 'c1',
|
|
25
|
+
author: { member: { id: 'u1', name: 'alice' } },
|
|
26
|
+
vote_count: 3,
|
|
27
|
+
created_time: 1700000000,
|
|
28
|
+
content: '<p>top "one"</p>',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: 'r1',
|
|
32
|
+
author: { member: { id: 'u2', name: 'bob' } },
|
|
33
|
+
reply_to_author: { member: { id: 'u1', name: 'alice' } },
|
|
34
|
+
vote_count: 1,
|
|
35
|
+
created_time: 1700000100,
|
|
36
|
+
content: '<p>reply one</p>',
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'r2',
|
|
40
|
+
author: { member: { id: 'u3', name: 'carol' } },
|
|
41
|
+
reply_to_author: { member: { id: 'u1', name: 'alice' } },
|
|
42
|
+
vote_count: 2,
|
|
43
|
+
created_time: 1700000200,
|
|
44
|
+
content: '<p>reply two should be capped</p>',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'c2',
|
|
48
|
+
author: { member: { id: 'u4', name: 'dave' } },
|
|
49
|
+
vote_count: 4,
|
|
50
|
+
created_time: 1700000300,
|
|
51
|
+
content: '<p>top two</p>',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'c3',
|
|
55
|
+
author: { member: { id: 'u5', name: 'erin' } },
|
|
56
|
+
vote_count: 5,
|
|
57
|
+
content: '<p>top three should stop the page walk</p>',
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
paging: { is_end: false, next: 'https://www.zhihu.com/api/v4/answers/2036567240334653053/comments?offset=20' },
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const page = {
|
|
64
|
+
goto,
|
|
65
|
+
getCurrentUrl: vi.fn().mockResolvedValue('https://www.zhihu.com/question/2022852734622114542/answer/2036567240334653053'),
|
|
66
|
+
evaluate,
|
|
67
|
+
};
|
|
68
|
+
await expect(cmd.func(page, {
|
|
69
|
+
id: 'https://www.zhihu.com/question/2022852734622114542/answer/2036567240334653053',
|
|
70
|
+
limit: 2,
|
|
71
|
+
'replies-limit': 1,
|
|
72
|
+
})).resolves.toEqual([
|
|
73
|
+
{
|
|
74
|
+
rank: 1,
|
|
75
|
+
comment_rank: 1,
|
|
76
|
+
reply_rank: 0,
|
|
77
|
+
depth: 0,
|
|
78
|
+
id: 'c1',
|
|
79
|
+
parent_id: '',
|
|
80
|
+
author: 'alice',
|
|
81
|
+
reply_to: '',
|
|
82
|
+
likes: 3,
|
|
83
|
+
created_at: '2023-11-14T22:13:20.000Z',
|
|
84
|
+
url: 'https://www.zhihu.com/question/2022852734622114542/answer/2036567240334653053#comment-c1',
|
|
85
|
+
content: 'top "one"',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
rank: 2,
|
|
89
|
+
comment_rank: 1,
|
|
90
|
+
reply_rank: 1,
|
|
91
|
+
depth: 0,
|
|
92
|
+
id: 'r1',
|
|
93
|
+
parent_id: '',
|
|
94
|
+
author: 'bob',
|
|
95
|
+
reply_to: 'alice',
|
|
96
|
+
likes: 1,
|
|
97
|
+
created_at: '2023-11-14T22:15:00.000Z',
|
|
98
|
+
url: 'https://www.zhihu.com/question/2022852734622114542/answer/2036567240334653053#comment-r1',
|
|
99
|
+
content: 'reply one',
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
rank: 3,
|
|
103
|
+
comment_rank: 2,
|
|
104
|
+
reply_rank: 0,
|
|
105
|
+
depth: 0,
|
|
106
|
+
id: 'c2',
|
|
107
|
+
parent_id: '',
|
|
108
|
+
author: 'dave',
|
|
109
|
+
reply_to: '',
|
|
110
|
+
likes: 4,
|
|
111
|
+
created_at: '2023-11-14T22:18:20.000Z',
|
|
112
|
+
url: 'https://www.zhihu.com/question/2022852734622114542/answer/2036567240334653053#comment-c2',
|
|
113
|
+
content: 'top two',
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/answer/2036567240334653053');
|
|
117
|
+
expect(evaluate).toHaveBeenCalledTimes(1);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('follows paging.next until enough top-level comments are collected', async () => {
|
|
121
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
122
|
+
const evaluate = vi.fn()
|
|
123
|
+
.mockResolvedValueOnce({
|
|
124
|
+
data: [
|
|
125
|
+
{ id: 'c1', author: { member: { id: 'u1', name: 'alice' } }, content: 'first' },
|
|
126
|
+
{ id: 'r1', author: { member: { id: 'u2', name: 'bob' } }, reply_to_author: { member: { id: 'u1', name: 'alice' } }, content: 'reply' },
|
|
127
|
+
],
|
|
128
|
+
paging: { is_end: false, next: 'https://www.zhihu.com/api/v4/answers/1/comments?offset=20' },
|
|
129
|
+
})
|
|
130
|
+
.mockResolvedValueOnce({
|
|
131
|
+
data: [
|
|
132
|
+
{ id: 'c2', author: { member: { id: 'u3', name: 'carol' } }, content: 'second' },
|
|
133
|
+
],
|
|
134
|
+
paging: { is_end: true },
|
|
135
|
+
});
|
|
136
|
+
const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
|
|
137
|
+
const rows = await cmd.func(page, { id: '1', limit: 2, 'replies-limit': 0 });
|
|
138
|
+
expect(rows.map((row) => row.id)).toEqual(['c1', 'c2']);
|
|
139
|
+
expect(evaluate).toHaveBeenCalledTimes(2);
|
|
140
|
+
expect(evaluate.mock.calls[1][0]).toContain('offset=20');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('supports typed answer targets', async () => {
|
|
144
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
145
|
+
const page = {
|
|
146
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
147
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
148
|
+
data: [
|
|
149
|
+
{ id: 'c1', author: { member: { id: 'u1', name: 'alice' } }, content: 'typed target comment' },
|
|
150
|
+
],
|
|
151
|
+
paging: { is_end: true },
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
await expect(cmd.func(page, { id: 'answer:2022852734622114542:2036567240334653053', limit: 1, 'replies-limit': 0 }))
|
|
155
|
+
.resolves.toMatchObject([{ id: 'c1', url: 'https://www.zhihu.com/question/2022852734622114542/answer/2036567240334653053#comment-c1' }]);
|
|
156
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.zhihu.com/answer/2036567240334653053');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('maps auth failures to AuthRequiredError', async () => {
|
|
160
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
161
|
+
const page = {
|
|
162
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
163
|
+
evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
|
|
164
|
+
};
|
|
165
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': 0 })).rejects.toBeInstanceOf(AuthRequiredError);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('maps 404 not found to EmptyResultError', async () => {
|
|
169
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
170
|
+
const page = {
|
|
171
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
172
|
+
evaluate: vi.fn().mockResolvedValue({ __httpError: 404 }),
|
|
173
|
+
};
|
|
174
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': 0 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('maps malformed responses to CommandExecutionError', async () => {
|
|
178
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
179
|
+
const page = {
|
|
180
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
181
|
+
evaluate: vi.fn().mockResolvedValue({ data: {} }),
|
|
182
|
+
};
|
|
183
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('maps valid empty comments to EmptyResultError', async () => {
|
|
187
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
188
|
+
const page = {
|
|
189
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
190
|
+
evaluate: vi.fn().mockResolvedValue({ data: [], paging: { is_end: true } }),
|
|
191
|
+
};
|
|
192
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': 0 })).rejects.toBeInstanceOf(EmptyResultError);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('rejects malformed pagination next URLs and repeated next URLs', async () => {
|
|
196
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
197
|
+
const malformedNextPage = {
|
|
198
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
199
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
200
|
+
data: [{ id: 'c1', author: { member: { id: 'u1', name: 'alice' } }, content: 'first' }],
|
|
201
|
+
paging: { is_end: false, next: 'https://evil.example/api/v4/answers/1/comments?offset=20' },
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
await expect(cmd.func(malformedNextPage, { id: '1', limit: 2, 'replies-limit': 0 }))
|
|
205
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
206
|
+
|
|
207
|
+
const repeatedNextPage = {
|
|
208
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
209
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
210
|
+
data: [{ id: 'c1', author: { member: { id: 'u1', name: 'alice' } }, content: 'first' }],
|
|
211
|
+
paging: { is_end: false, next: 'https://www.zhihu.com/api/v4/answers/1/comments?order=normal&limit=20&offset=0&status=open' },
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
await expect(cmd.func(repeatedNextPage, { id: '1', limit: 2, 'replies-limit': 0 }))
|
|
215
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('rejects comment rows without stable comment id anchors', async () => {
|
|
219
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
220
|
+
const page = {
|
|
221
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
222
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
223
|
+
data: [{ author: { member: { id: 'u1', name: 'alice' } }, content: 'missing id' }],
|
|
224
|
+
paging: { is_end: true },
|
|
225
|
+
}),
|
|
226
|
+
};
|
|
227
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('rejects null comment items and non-primitive comment ids', async () => {
|
|
231
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
232
|
+
const basePage = (data) => ({
|
|
233
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
234
|
+
evaluate: vi.fn().mockResolvedValue({ data, paging: { is_end: true } }),
|
|
235
|
+
});
|
|
236
|
+
await expect(cmd.func(basePage([null]), { id: '1', limit: 1, 'replies-limit': 0 }))
|
|
237
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
238
|
+
await expect(cmd.func(basePage([{ id: { value: 'c1' }, content: 'object id' }]), { id: '1', limit: 1, 'replies-limit': 0 }))
|
|
239
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
240
|
+
await expect(cmd.func(basePage([{ id: true, content: 'boolean id' }]), { id: '1', limit: 1, 'replies-limit': 0 }))
|
|
241
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('rejects invalid inputs before navigation', async () => {
|
|
245
|
+
const cmd = getRegistry().get('zhihu/answer-comments');
|
|
246
|
+
const page = { goto: vi.fn(), evaluate: vi.fn() };
|
|
247
|
+
await expect(cmd.func(page, { id: 'not-an-answer', limit: 1, 'replies-limit': 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
248
|
+
await expect(cmd.func(page, { id: '1', limit: 0, 'replies-limit': 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
249
|
+
await expect(cmd.func(page, { id: '1', limit: 1001, 'replies-limit': 0 })).rejects.toBeInstanceOf(ArgumentError);
|
|
250
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': -1 })).rejects.toBeInstanceOf(ArgumentError);
|
|
251
|
+
await expect(cmd.func(page, { id: '1', limit: 1, 'replies-limit': 101 })).rejects.toBeInstanceOf(ArgumentError);
|
|
252
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
253
|
+
expect(page.evaluate).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('zhihu answer-comments helpers', () => {
|
|
258
|
+
it('parseAnswerTarget handles exact input shapes', () => {
|
|
259
|
+
expect(helpers.parseAnswerTarget('123')).toEqual({ answerId: '123', questionId: '' });
|
|
260
|
+
expect(helpers.parseAnswerTarget('answer:10:123')).toEqual({ answerId: '123', questionId: '10' });
|
|
261
|
+
expect(helpers.parseAnswerTarget('https://www.zhihu.com/question/10/answer/123')).toEqual({ answerId: '123', questionId: '10' });
|
|
262
|
+
expect(helpers.parseAnswerTarget('https://zhihu.com/answer/123?utm=1')).toEqual({ answerId: '123', questionId: '' });
|
|
263
|
+
expect(helpers.parseAnswerTarget('https://example.com/question/10/answer/123')).toBeNull();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('normalizeCommentsApiUrl only accepts same-answer Zhihu comments API URLs', () => {
|
|
267
|
+
expect(helpers.normalizeCommentsApiUrl('https://www.zhihu.com/api/v4/answers/123/comments?offset=20', '123'))
|
|
268
|
+
.toBe('https://www.zhihu.com/api/v4/answers/123/comments?offset=20');
|
|
269
|
+
expect(helpers.normalizeCommentsApiUrl('https://api.zhihu.com/answers/123/comments?offset=20', '123'))
|
|
270
|
+
.toBe('https://www.zhihu.com/api/v4/answers/123/comments?offset=20');
|
|
271
|
+
expect(helpers.normalizeCommentsApiUrl('https://www.zhihu.com/api/v4/answers/999/comments?offset=20', '123')).toBe('');
|
|
272
|
+
expect(helpers.normalizeCommentsApiUrl('https://evil.example/api/v4/answers/123/comments?offset=20', '123')).toBe('');
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('buildRows keeps replies flat without guessing parent comment ids', () => {
|
|
276
|
+
const rows = helpers.buildRows([
|
|
277
|
+
{ id: 'c1', author: { member: { id: 'u1', name: 'alice' } }, content: 'top' },
|
|
278
|
+
{ id: 'r1', author: { member: { id: 'u2', name: 'bob' } }, reply_to_author: { member: { id: 'u1', name: 'alice' } }, content: 'reply' },
|
|
279
|
+
{ id: 'r2', author: { member: { id: 'u3', name: 'carol' } }, reply_to_author: { member: { id: 'u2', name: 'bob' } }, content: 'nested' },
|
|
280
|
+
], { answerId: 'a1', questionId: 'q1', topLevelLimit: 1, repliesLimit: 5 }).rows;
|
|
281
|
+
expect(rows.map((row) => [row.id, row.parent_id, row.depth, row.comment_rank, row.reply_rank])).toEqual([
|
|
282
|
+
['c1', '', 0, 1, 0],
|
|
283
|
+
['r1', '', 0, 1, 1],
|
|
284
|
+
['r2', '', 0, 1, 2],
|
|
285
|
+
]);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -19,6 +19,18 @@ function stripHtml(html) {
|
|
|
19
19
|
.replace(/&/g, '&')
|
|
20
20
|
.replace(/"/g, '"')
|
|
21
21
|
.replace(/'/g, "'")
|
|
22
|
+
.replace(/&#(\d+);/g, (_, value) => {
|
|
23
|
+
const codePoint = Number(value);
|
|
24
|
+
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF
|
|
25
|
+
? String.fromCodePoint(codePoint)
|
|
26
|
+
: _;
|
|
27
|
+
})
|
|
28
|
+
.replace(/&#x([0-9a-f]+);/gi, (_, value) => {
|
|
29
|
+
const codePoint = Number.parseInt(value, 16);
|
|
30
|
+
return Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10FFFF
|
|
31
|
+
? String.fromCodePoint(codePoint)
|
|
32
|
+
: _;
|
|
33
|
+
})
|
|
22
34
|
.replace(/\n{3,}/g, '\n\n')
|
|
23
35
|
.trim();
|
|
24
36
|
}
|
|
@@ -304,6 +304,14 @@ describe('zhihu answer-detail helpers', () => {
|
|
|
304
304
|
expect(out).toBe('hi there & you\n\nsecond');
|
|
305
305
|
});
|
|
306
306
|
|
|
307
|
+
it('stripHtml decodes numeric entities', () => {
|
|
308
|
+
expect(helpers.stripHtml('"中文" & 'test'')).toBe('"中文" & \'test\'');
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('stripHtml keeps invalid numeric entities unchanged', () => {
|
|
312
|
+
expect(helpers.stripHtml('bad � entity')).toBe('bad � entity');
|
|
313
|
+
});
|
|
314
|
+
|
|
307
315
|
it('stripHtml maps <br> to single newline', () => {
|
|
308
316
|
expect(helpers.stripHtml('a<br>b<br/>c')).toBe('a\nb\nc');
|
|
309
317
|
});
|
package/clis/zhihu/collection.js
CHANGED
|
@@ -56,12 +56,18 @@ async function fetchCollectionPage(page, collectionId, offset, limit) {
|
|
|
56
56
|
|
|
57
57
|
function itemKey(item) {
|
|
58
58
|
const content = item?.content || {};
|
|
59
|
-
return `${content.type || '
|
|
59
|
+
return `${content.type || ''}:${content.id || content.url || JSON.stringify(content).slice(0, 80)}`;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function mapCollectionItem(item, rank) {
|
|
63
63
|
const content = item.content || {};
|
|
64
|
-
const type = content.type || '
|
|
64
|
+
const type = content.type || '';
|
|
65
|
+
if (!['answer', 'article', 'pin'].includes(type)) {
|
|
66
|
+
throw new CommandExecutionError(
|
|
67
|
+
`Zhihu collection returned unsupported content type: ${type || 'missing'}`,
|
|
68
|
+
'Collection items require a supported content.type so the row identity, title, and URL are not silently blank.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
65
71
|
|
|
66
72
|
let title = '';
|
|
67
73
|
let excerpt = '';
|
|
@@ -90,6 +96,13 @@ function mapCollectionItem(item, rank) {
|
|
|
90
96
|
votes = content.reaction_count || 0;
|
|
91
97
|
}
|
|
92
98
|
|
|
99
|
+
if (!String(title || '').trim() || !String(url || '').trim() || url.includes('undefined')) {
|
|
100
|
+
throw new CommandExecutionError(
|
|
101
|
+
'Zhihu collection returned a malformed item without title or URL identity',
|
|
102
|
+
'Collection item rows require type, title, and URL so malformed payloads do not become blank listing rows.',
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
93
106
|
return {
|
|
94
107
|
rank,
|
|
95
108
|
type,
|
|
@@ -287,4 +287,50 @@ describe('zhihu collection', () => {
|
|
|
287
287
|
await expect(cmd.func(page, { id: '83283292', offset: 0, limit: 20 }))
|
|
288
288
|
.rejects.toBeInstanceOf(EmptyResultError);
|
|
289
289
|
});
|
|
290
|
+
|
|
291
|
+
it('fails typed for missing content.type instead of emitting a blank identity row', async () => {
|
|
292
|
+
const cmd = getRegistry().get('zhihu/collection');
|
|
293
|
+
const evaluate = vi.fn().mockResolvedValue({
|
|
294
|
+
data: [
|
|
295
|
+
{
|
|
296
|
+
content: {
|
|
297
|
+
id: 555,
|
|
298
|
+
question: { id: 666, title: 'No-type Question' },
|
|
299
|
+
author: { name: 'a' },
|
|
300
|
+
voteup_count: 1,
|
|
301
|
+
content: '<p>x</p>',
|
|
302
|
+
url: 'https://www.zhihu.com/question/666',
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
],
|
|
306
|
+
paging: { totals: 1 },
|
|
307
|
+
});
|
|
308
|
+
const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
|
|
309
|
+
await expect(cmd.func(page, { id: '83283292', offset: 0, limit: 20 }))
|
|
310
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('fails typed for supported collection items missing title/url identity', async () => {
|
|
314
|
+
const cmd = getRegistry().get('zhihu/collection');
|
|
315
|
+
const page = {
|
|
316
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
317
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
318
|
+
data: [
|
|
319
|
+
{
|
|
320
|
+
content: {
|
|
321
|
+
type: 'answer',
|
|
322
|
+
id: 555,
|
|
323
|
+
question: { id: 666, title: '' },
|
|
324
|
+
author: { name: 'a' },
|
|
325
|
+
voteup_count: 1,
|
|
326
|
+
content: '<p>x</p>',
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
paging: { totals: 1 },
|
|
331
|
+
}),
|
|
332
|
+
};
|
|
333
|
+
await expect(cmd.func(page, { id: '83283292', offset: 0, limit: 20 }))
|
|
334
|
+
.rejects.toBeInstanceOf(CommandExecutionError);
|
|
335
|
+
});
|
|
290
336
|
});
|
package/clis/zhihu/download.js
CHANGED
|
@@ -41,7 +41,7 @@ cli({
|
|
|
41
41
|
|
|
42
42
|
// Get author
|
|
43
43
|
const authorEl = document.querySelector('.AuthorInfo-name, .UserLink-link');
|
|
44
|
-
result.author = authorEl?.textContent?.trim() || '
|
|
44
|
+
result.author = authorEl?.textContent?.trim() || '';
|
|
45
45
|
|
|
46
46
|
// Get publish time
|
|
47
47
|
const timeEl = document.querySelector('.ContentItem-time, .Post-Time');
|
package/clis/zhihu/question.js
CHANGED
|
@@ -10,6 +10,34 @@ function stripHtml(html) {
|
|
|
10
10
|
.trim();
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function answerIdFromUrl(url) {
|
|
14
|
+
if (typeof url !== 'string') return '';
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(url);
|
|
17
|
+
if (parsed.hostname !== 'www.zhihu.com' && parsed.hostname !== 'zhihu.com') return '';
|
|
18
|
+
return parsed.pathname.match(/^\/question\/\d+\/answer\/(\d+)\/?$/)?.[1]
|
|
19
|
+
|| parsed.pathname.match(/^\/api\/v4\/answers\/(\d+)\/?$/)?.[1]
|
|
20
|
+
|| parsed.pathname.match(/^\/answer\/(\d+)\/?$/)?.[1]
|
|
21
|
+
|| '';
|
|
22
|
+
} catch {
|
|
23
|
+
return '';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function answerId(item) {
|
|
28
|
+
const fromUrl = answerIdFromUrl(item.url);
|
|
29
|
+
if (fromUrl) return fromUrl;
|
|
30
|
+
if (typeof item.id === 'string' && /^\d+$/.test(item.id)) return item.id;
|
|
31
|
+
if (typeof item.id === 'number' && Number.isSafeInteger(item.id) && item.id > 0) return String(item.id);
|
|
32
|
+
return '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function answerDedupeKey(item) {
|
|
36
|
+
const id = answerId(item);
|
|
37
|
+
if (id) return `id:${id}`;
|
|
38
|
+
return `fallback:${item.author?.name || 'anonymous'}:${item.content || ''}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
13
41
|
const MAX_LIMIT = 1000;
|
|
14
42
|
|
|
15
43
|
cli({
|
|
@@ -24,7 +52,7 @@ cli({
|
|
|
24
52
|
{ name: 'limit', type: 'int', default: 5, help: 'Number of answers (max 1000; use normal-sized requests)' },
|
|
25
53
|
{ name: 'sort', default: 'default', choices: ['default', 'created'], help: 'Answer order: default or created' },
|
|
26
54
|
],
|
|
27
|
-
columns: ['rank', 'author', 'votes', 'content'],
|
|
55
|
+
columns: ['rank', 'id', 'author', 'votes', 'url', 'content'],
|
|
28
56
|
func: async (page, kwargs) => {
|
|
29
57
|
const { id, limit = 5 } = kwargs;
|
|
30
58
|
const questionId = String(id);
|
|
@@ -48,7 +76,7 @@ cli({
|
|
|
48
76
|
// costs one over-fetched page worth of bandwidth and never silently
|
|
49
77
|
// clamps the user-requested count.
|
|
50
78
|
const ZHIHU_PAGE_SIZE = 20;
|
|
51
|
-
let url = `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${ZHIHU_PAGE_SIZE}&offset=0&sort_by=${sort}&include=data[*].content,voteup_count,comment_count,author`;
|
|
79
|
+
let url = `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${ZHIHU_PAGE_SIZE}&offset=0&sort_by=${sort}&include=data[*].content,url,voteup_count,comment_count,author`;
|
|
52
80
|
const answers = [];
|
|
53
81
|
const seen = new Set();
|
|
54
82
|
const visited = new Set();
|
|
@@ -69,7 +97,7 @@ cli({
|
|
|
69
97
|
throw new CliError('FETCH_ERROR', status ? `Zhihu question answers request failed (HTTP ${status})` : 'Zhihu question answers request failed', 'Try again later or rerun with -v for more detail');
|
|
70
98
|
}
|
|
71
99
|
for (const item of data.data || []) {
|
|
72
|
-
const key =
|
|
100
|
+
const key = answerDedupeKey(item);
|
|
73
101
|
if (seen.has(key)) continue;
|
|
74
102
|
seen.add(key);
|
|
75
103
|
answers.push(item);
|
|
@@ -78,11 +106,16 @@ cli({
|
|
|
78
106
|
if (data.paging?.is_end) break;
|
|
79
107
|
url = typeof data.paging?.next === 'string' ? data.paging.next : '';
|
|
80
108
|
}
|
|
81
|
-
return answers.map((item, i) =>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
109
|
+
return answers.map((item, i) => {
|
|
110
|
+
const id = answerId(item);
|
|
111
|
+
return {
|
|
112
|
+
rank: i + 1,
|
|
113
|
+
id,
|
|
114
|
+
author: item.author?.name || 'anonymous',
|
|
115
|
+
votes: item.voteup_count || 0,
|
|
116
|
+
url: id ? `https://www.zhihu.com/question/${questionId}/answer/${id}` : '',
|
|
117
|
+
content: stripHtml(item.content || '').substring(0, 200),
|
|
118
|
+
};
|
|
119
|
+
});
|
|
87
120
|
},
|
|
88
121
|
});
|