@jackwener/opencli 1.7.18 → 1.7.19

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.
Files changed (95) hide show
  1. package/README.md +7 -8
  2. package/README.zh-CN.md +7 -8
  3. package/cli-manifest.json +305 -9
  4. package/clis/ctrip/ctrip.test.js +486 -1
  5. package/clis/ctrip/flight.js +136 -0
  6. package/clis/ctrip/hotel-search.js +132 -0
  7. package/clis/ctrip/utils.js +298 -0
  8. package/clis/google/search.js +16 -6
  9. package/clis/google-scholar/search.js +20 -5
  10. package/clis/google-scholar/search.test.js +35 -2
  11. package/clis/reddit/home.js +117 -0
  12. package/clis/reddit/home.test.js +127 -0
  13. package/clis/reddit/read.js +400 -54
  14. package/clis/reddit/read.test.js +315 -12
  15. package/clis/reddit/subreddit-info.js +117 -0
  16. package/clis/reddit/subreddit-info.test.js +163 -0
  17. package/clis/reddit/whoami.js +84 -0
  18. package/clis/reddit/whoami.test.js +105 -0
  19. package/clis/rednote/search.js +6 -2
  20. package/clis/twitter/bookmark-folder.js +3 -1
  21. package/clis/twitter/bookmarks.js +3 -1
  22. package/clis/twitter/followers.js +20 -5
  23. package/clis/twitter/followers.test.js +44 -0
  24. package/clis/twitter/following.js +36 -20
  25. package/clis/twitter/following.test.js +60 -8
  26. package/clis/twitter/likes.js +28 -13
  27. package/clis/twitter/likes.test.js +111 -1
  28. package/clis/twitter/list-add.js +128 -204
  29. package/clis/twitter/list-add.test.js +97 -1
  30. package/clis/twitter/list-tweets.js +13 -4
  31. package/clis/twitter/list-tweets.test.js +48 -0
  32. package/clis/twitter/lists.js +5 -2
  33. package/clis/twitter/post.js +23 -4
  34. package/clis/twitter/post.test.js +30 -0
  35. package/clis/twitter/profile.js +16 -8
  36. package/clis/twitter/profile.test.js +39 -0
  37. package/clis/twitter/reply.js +133 -10
  38. package/clis/twitter/reply.test.js +55 -0
  39. package/clis/twitter/search.js +188 -170
  40. package/clis/twitter/search.test.js +96 -258
  41. package/clis/twitter/shared.js +167 -16
  42. package/clis/twitter/shared.test.js +102 -1
  43. package/clis/twitter/timeline.js +3 -1
  44. package/clis/twitter/tweets.js +147 -51
  45. package/clis/twitter/tweets.test.js +238 -1
  46. package/clis/xiaohongshu/comments.js +23 -2
  47. package/clis/xiaohongshu/comments.test.js +63 -1
  48. package/clis/xiaohongshu/search.js +168 -13
  49. package/clis/xiaohongshu/search.test.js +82 -8
  50. package/clis/xueqiu/earnings-date.js +2 -2
  51. package/clis/xueqiu/kline.js +2 -2
  52. package/clis/xueqiu/utils.js +19 -0
  53. package/clis/xueqiu/utils.test.js +26 -0
  54. package/clis/zhihu/answer-detail.js +233 -0
  55. package/clis/zhihu/answer-detail.test.js +330 -0
  56. package/clis/zhihu/question.js +44 -10
  57. package/clis/zhihu/question.test.js +78 -1
  58. package/clis/zhihu/recommend.js +103 -0
  59. package/clis/zhihu/recommend.test.js +143 -0
  60. package/dist/src/browser/base-page.d.ts +3 -2
  61. package/dist/src/browser/base-page.test.js +2 -2
  62. package/dist/src/browser/cdp.js +3 -3
  63. package/dist/src/browser/page.d.ts +3 -2
  64. package/dist/src/browser/page.js +4 -4
  65. package/dist/src/browser/page.test.js +31 -0
  66. package/dist/src/browser/utils.d.ts +10 -0
  67. package/dist/src/browser/utils.js +37 -0
  68. package/dist/src/browser/utils.test.d.ts +1 -0
  69. package/dist/src/browser/utils.test.js +29 -0
  70. package/dist/src/cli-argv-preprocess.d.ts +37 -0
  71. package/dist/src/cli-argv-preprocess.js +131 -0
  72. package/dist/src/cli-argv-preprocess.test.d.ts +1 -0
  73. package/dist/src/cli-argv-preprocess.test.js +130 -0
  74. package/dist/src/cli.js +123 -86
  75. package/dist/src/cli.test.js +33 -28
  76. package/dist/src/commands/daemon.js +6 -7
  77. package/dist/src/doctor.js +15 -16
  78. package/dist/src/download/progress.js +15 -11
  79. package/dist/src/download/progress.test.d.ts +1 -0
  80. package/dist/src/download/progress.test.js +25 -0
  81. package/dist/src/execution.js +1 -3
  82. package/dist/src/execution.test.js +4 -16
  83. package/dist/src/help.d.ts +11 -0
  84. package/dist/src/help.js +46 -5
  85. package/dist/src/logger.js +8 -9
  86. package/dist/src/main.js +16 -0
  87. package/dist/src/output.js +4 -5
  88. package/dist/src/runtime-detect.d.ts +1 -1
  89. package/dist/src/runtime-detect.js +1 -1
  90. package/dist/src/runtime-detect.test.js +3 -2
  91. package/dist/src/tui.d.ts +0 -1
  92. package/dist/src/tui.js +9 -22
  93. package/dist/src/types.d.ts +3 -1
  94. package/dist/src/update-check.js +4 -5
  95. package/package.json +5 -4
@@ -0,0 +1,330 @@
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-detail.js';
5
+ import { __test__ as helpers } from './answer-detail.js';
6
+
7
+ describe('zhihu answer-detail', () => {
8
+ it('registers as a cookie read command', () => {
9
+ const cmd = getRegistry().get('zhihu/answer-detail');
10
+ expect(cmd).toBeDefined();
11
+ expect(cmd.access).toBe('read');
12
+ expect(cmd.strategy).toBe('cookie');
13
+ });
14
+
15
+ it('fetches a single answer by numeric id and returns one row', async () => {
16
+ const cmd = getRegistry().get('zhihu/answer-detail');
17
+ const goto = vi.fn().mockResolvedValue(undefined);
18
+ const evaluate = vi.fn().mockImplementation(async (js) => {
19
+ // The adapter must call the `/api/v4/answers/<id>` endpoint
20
+ // (not the question→answers listing) and request the rich
21
+ // include set so the row carries content + counts + question.
22
+ expect(js).toContain('/api/v4/answers/1937205528846655537?include=content');
23
+ expect(js).toContain('voteup_count');
24
+ expect(js).toContain('comment_count');
25
+ expect(js).toContain('question');
26
+ expect(js).toContain("credentials: 'include'");
27
+ return {
28
+ // Real Zhihu API returns `id` as a JSON number, which
29
+ // *loses precision* in browser JSON.parse for ids
30
+ // above 2^53 (Number.MAX_SAFE_INTEGER). The adapter
31
+ // must not trust this field for the canonical id —
32
+ // it must anchor the row id to the parsed input
33
+ // instead. We pass a deliberately wrong value below
34
+ // to lock that contract in.
35
+ id: 0,
36
+ author: { name: 'Ricky' },
37
+ voteup_count: 1234,
38
+ comment_count: 56,
39
+ created_time: 1700000000,
40
+ updated_time: 1700001000,
41
+ content: '<p>这是<strong>第一段</strong></p><br/><p>第二段。</p>',
42
+ question: { id: 630517537, title: '回想自己的人生阅历,你最想教给孩子们的一个道理是什么?' },
43
+ };
44
+ });
45
+ const page = { goto, evaluate };
46
+ const rows = await cmd.func(page, { id: '1937205528846655537', 'max-content': 0 });
47
+ expect(rows).toHaveLength(1);
48
+ expect(rows[0]).toMatchObject({
49
+ id: '1937205528846655537',
50
+ author: 'Ricky',
51
+ votes: 1234,
52
+ comments: 56,
53
+ question_id: '630517537',
54
+ question_title: '回想自己的人生阅历,你最想教给孩子们的一个道理是什么?',
55
+ url: 'https://www.zhihu.com/question/630517537/answer/1937205528846655537',
56
+ created_at: '2023-11-14T22:13:20.000Z',
57
+ updated_at: '2023-11-14T22:30:00.000Z',
58
+ });
59
+ // Block-level tags should become real newlines, not be collapsed flat.
60
+ expect(rows[0].content).toBe('这是第一段\n\n第二段。');
61
+ expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/answer/1937205528846655537');
62
+ });
63
+
64
+ it('accepts a full Zhihu answer URL as id, preserving full id precision', async () => {
65
+ const cmd = getRegistry().get('zhihu/answer-detail');
66
+ const evaluate = vi.fn().mockResolvedValue({
67
+ // Same precision-loss trap as above: `data.id` from the
68
+ // real API would round to `1937205528846655500`. Pass a
69
+ // wrong value here to assert the adapter ignores it and
70
+ // anchors to the parsed URL instead.
71
+ id: 0,
72
+ author: { name: 'Ricky' },
73
+ voteup_count: 1,
74
+ comment_count: 0,
75
+ content: '<p>hello</p>',
76
+ // The input question id is the string-safe source of truth
77
+ // when API JSON numeric ids have already lost precision.
78
+ question: { id: 2021881398772981800, title: 'Q' },
79
+ });
80
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
81
+ const rows = await cmd.func(page, {
82
+ id: 'https://www.zhihu.com/question/2021881398772981878/answer/1937205528846655537',
83
+ 'max-content': 0,
84
+ });
85
+ expect(rows[0].id).toBe('1937205528846655537');
86
+ expect(rows[0].question_id).toBe('2021881398772981878');
87
+ expect(rows[0].url).toBe('https://www.zhihu.com/question/2021881398772981878/answer/1937205528846655537');
88
+ expect(evaluate.mock.calls[0][0]).toContain('/api/v4/answers/1937205528846655537?');
89
+ });
90
+
91
+ it('accepts the typed-target form answer:<qid>:<aid>', async () => {
92
+ const cmd = getRegistry().get('zhihu/answer-detail');
93
+ const evaluate = vi.fn().mockResolvedValue({
94
+ id: 999,
95
+ author: { name: 'bob' },
96
+ voteup_count: 0,
97
+ comment_count: 0,
98
+ content: '<p>x</p>',
99
+ question: { id: 0, title: 'Q' },
100
+ });
101
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
102
+ const rows = await cmd.func(page, { id: 'answer:2021881398772981878:999', 'max-content': 0 });
103
+ expect(rows[0].id).toBe('999');
104
+ expect(rows[0].question_id).toBe('2021881398772981878');
105
+ expect(evaluate.mock.calls[0][0]).toContain('/api/v4/answers/999?');
106
+ });
107
+
108
+ it('uses the redirected canonical URL as question id source for bare answer ids', async () => {
109
+ const cmd = getRegistry().get('zhihu/answer-detail');
110
+ const page = {
111
+ goto: vi.fn().mockResolvedValue(undefined),
112
+ getCurrentUrl: vi.fn().mockResolvedValue('https://www.zhihu.com/question/2021881398772981878/answer/999'),
113
+ evaluate: vi.fn().mockResolvedValue({
114
+ id: 999,
115
+ author: { name: 'bob' },
116
+ voteup_count: 0,
117
+ comment_count: 0,
118
+ content: '<p>x</p>',
119
+ question: { id: 2021881398772981800, title: 'Q' },
120
+ }),
121
+ };
122
+ const rows = await cmd.func(page, { id: '999', 'max-content': 0 });
123
+ expect(rows[0].question_id).toBe('2021881398772981878');
124
+ expect(rows[0].url).toBe('https://www.zhihu.com/question/2021881398772981878/answer/999');
125
+ });
126
+
127
+ it('uses API question url as a string-safe fallback when the browser URL is unavailable', async () => {
128
+ const cmd = getRegistry().get('zhihu/answer-detail');
129
+ const page = {
130
+ goto: vi.fn().mockResolvedValue(undefined),
131
+ evaluate: vi.fn().mockResolvedValue({
132
+ id: 999,
133
+ author: { name: 'bob' },
134
+ voteup_count: 0,
135
+ comment_count: 0,
136
+ content: '<p>x</p>',
137
+ question: {
138
+ id: 2021881398772981800,
139
+ url: 'https://www.zhihu.com/api/v4/questions/2021881398772981878',
140
+ title: 'Q',
141
+ },
142
+ }),
143
+ };
144
+ const rows = await cmd.func(page, { id: '999', 'max-content': 0 });
145
+ expect(rows[0].question_id).toBe('2021881398772981878');
146
+ });
147
+
148
+ it('returns the full stripped body when --max-content is 0 (default)', async () => {
149
+ const cmd = getRegistry().get('zhihu/answer-detail');
150
+ const longBody = 'x'.repeat(5000);
151
+ const evaluate = vi.fn().mockResolvedValue({
152
+ id: 1,
153
+ author: { name: 'a' },
154
+ voteup_count: 0,
155
+ comment_count: 0,
156
+ content: `<p>${longBody}</p>`,
157
+ question: { id: 2, title: 'Q' },
158
+ });
159
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
160
+ const rows = await cmd.func(page, { id: '1', 'max-content': 0 });
161
+ expect(rows[0].content.length).toBe(5000);
162
+ });
163
+
164
+ it('respects --max-content as an opt-in cap', async () => {
165
+ const cmd = getRegistry().get('zhihu/answer-detail');
166
+ const longBody = 'x'.repeat(5000);
167
+ const evaluate = vi.fn().mockResolvedValue({
168
+ id: 1,
169
+ author: { name: 'a' },
170
+ voteup_count: 0,
171
+ comment_count: 0,
172
+ content: `<p>${longBody}</p>`,
173
+ question: { id: 2, title: 'Q' },
174
+ });
175
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
176
+ const rows = await cmd.func(page, { id: '1', 'max-content': 100 });
177
+ expect(rows[0].content.length).toBe(100);
178
+ });
179
+
180
+ it('falls back to bare /answer/<id> URL when the response is missing question metadata', async () => {
181
+ const cmd = getRegistry().get('zhihu/answer-detail');
182
+ const evaluate = vi.fn().mockResolvedValue({
183
+ id: 42,
184
+ author: { name: 'alice' },
185
+ voteup_count: 0,
186
+ comment_count: 0,
187
+ content: '<p>orphan answer</p>',
188
+ // no `question` field at all
189
+ });
190
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
191
+ const rows = await cmd.func(page, { id: '42', 'max-content': 0 });
192
+ expect(rows[0].question_id).toBe('');
193
+ expect(rows[0].question_title).toBe('');
194
+ expect(rows[0].url).toBe('https://www.zhihu.com/answer/42');
195
+ });
196
+
197
+ it('maps 401/403 to AuthRequiredError', async () => {
198
+ const cmd = getRegistry().get('zhihu/answer-detail');
199
+ const page = {
200
+ goto: vi.fn().mockResolvedValue(undefined),
201
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
202
+ };
203
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(AuthRequiredError);
204
+ });
205
+
206
+ it('maps 404 to EmptyResultError', async () => {
207
+ const cmd = getRegistry().get('zhihu/answer-detail');
208
+ const page = {
209
+ goto: vi.fn().mockResolvedValue(undefined),
210
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 404 }),
211
+ };
212
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(EmptyResultError);
213
+ });
214
+
215
+ it('maps other HTTP failures to CommandExecutionError', async () => {
216
+ const cmd = getRegistry().get('zhihu/answer-detail');
217
+ const page = {
218
+ goto: vi.fn().mockResolvedValue(undefined),
219
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 500 }),
220
+ };
221
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
222
+ });
223
+
224
+ it('treats a null evaluate response as a fetch error', async () => {
225
+ const cmd = getRegistry().get('zhihu/answer-detail');
226
+ const page = {
227
+ goto: vi.fn().mockResolvedValue(undefined),
228
+ evaluate: vi.fn().mockResolvedValue(null),
229
+ };
230
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
231
+ });
232
+
233
+ it('wraps browser navigation failures as CommandExecutionError', async () => {
234
+ const cmd = getRegistry().get('zhihu/answer-detail');
235
+ const page = {
236
+ goto: vi.fn().mockRejectedValue(new Error('navigation failed')),
237
+ evaluate: vi.fn(),
238
+ };
239
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
240
+ expect(page.evaluate).not.toHaveBeenCalled();
241
+ });
242
+
243
+ it('wraps malformed JSON responses as CommandExecutionError', async () => {
244
+ const cmd = getRegistry().get('zhihu/answer-detail');
245
+ const page = {
246
+ goto: vi.fn().mockResolvedValue(undefined),
247
+ evaluate: vi.fn().mockResolvedValue({ __malformedJson: 'Unexpected token <' }),
248
+ };
249
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
250
+ });
251
+
252
+ it('rejects in-band error payloads instead of returning empty success rows', async () => {
253
+ const cmd = getRegistry().get('zhihu/answer-detail');
254
+ const page = {
255
+ goto: vi.fn().mockResolvedValue(undefined),
256
+ evaluate: vi.fn().mockResolvedValue({ error: { message: 'not found' } }),
257
+ };
258
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
259
+ });
260
+
261
+ it('rejects payloads missing answer content instead of fabricating a row', async () => {
262
+ const cmd = getRegistry().get('zhihu/answer-detail');
263
+ const page = {
264
+ goto: vi.fn().mockResolvedValue(undefined),
265
+ evaluate: vi.fn().mockResolvedValue({ id: 1, author: { name: 'ghost' } }),
266
+ };
267
+ await expect(cmd.func(page, { id: '1', 'max-content': 0 })).rejects.toBeInstanceOf(CommandExecutionError);
268
+ });
269
+
270
+ it('rejects non-numeric answer ids before navigation', async () => {
271
+ const cmd = getRegistry().get('zhihu/answer-detail');
272
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
273
+ await expect(cmd.func(page, { id: "abc'; alert(1); //", 'max-content': 0 })).rejects.toBeInstanceOf(ArgumentError);
274
+ expect(page.goto).not.toHaveBeenCalled();
275
+ expect(page.evaluate).not.toHaveBeenCalled();
276
+ });
277
+
278
+ it('rejects negative --max-content before navigation', async () => {
279
+ const cmd = getRegistry().get('zhihu/answer-detail');
280
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
281
+ await expect(cmd.func(page, { id: '1', 'max-content': -5 })).rejects.toBeInstanceOf(ArgumentError);
282
+ expect(page.goto).not.toHaveBeenCalled();
283
+ });
284
+
285
+ it('rejects invalid URL identities before navigation', async () => {
286
+ const cmd = getRegistry().get('zhihu/answer-detail');
287
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
288
+ for (const id of [
289
+ 'https://example.com/foo/bar',
290
+ 'http://www.zhihu.com/question/10/answer/123',
291
+ 'https://www.zhihu.com/question/10/answer/123/extra',
292
+ 'https://www.zhihu.com.evil.com/question/10/answer/123',
293
+ 'https://user:pass@www.zhihu.com/question/10/answer/123',
294
+ ]) {
295
+ await expect(cmd.func(page, { id, 'max-content': 0 })).rejects.toBeInstanceOf(ArgumentError);
296
+ }
297
+ expect(page.goto).not.toHaveBeenCalled();
298
+ });
299
+ });
300
+
301
+ describe('zhihu answer-detail helpers', () => {
302
+ it('stripHtml drops tags and decodes common entities', () => {
303
+ const out = helpers.stripHtml('<p>hi&nbsp;there &amp; you</p><p>second</p>');
304
+ expect(out).toBe('hi there & you\n\nsecond');
305
+ });
306
+
307
+ it('stripHtml maps <br> to single newline', () => {
308
+ expect(helpers.stripHtml('a<br>b<br/>c')).toBe('a\nb\nc');
309
+ });
310
+
311
+ it('parseAnswerTarget handles exact input shapes', () => {
312
+ expect(helpers.parseAnswerTarget('123')).toEqual({ answerId: '123', questionId: '' });
313
+ expect(helpers.parseAnswerTarget('answer:10:123')).toEqual({ answerId: '123', questionId: '10' });
314
+ expect(helpers.parseAnswerTarget('https://www.zhihu.com/question/10/answer/123')).toEqual({ answerId: '123', questionId: '10' });
315
+ expect(helpers.parseAnswerTarget('https://zhihu.com/answer/123?utm=1#x')).toEqual({ answerId: '123', questionId: '' });
316
+ expect(helpers.parseAnswerTarget('http://www.zhihu.com/question/10/answer/123')).toBeNull();
317
+ expect(helpers.parseAnswerTarget('https://www.zhihu.com/question/10/answer/123/extra')).toBeNull();
318
+ });
319
+
320
+ it('extractAnswerId keeps the legacy helper contract for tests', () => {
321
+ expect(helpers.extractAnswerId('123')).toBe('123');
322
+ expect(helpers.extractAnswerId('answer:10:123')).toBe('123');
323
+ expect(helpers.extractAnswerId('https://www.zhihu.com/question/10/answer/123')).toBe('123');
324
+ expect(helpers.extractAnswerId('https://www.zhihu.com/answer/123')).toBe('123');
325
+ expect(helpers.extractAnswerId(' 123 ')).toBe('123');
326
+ expect(helpers.extractAnswerId('')).toBeNull();
327
+ expect(helpers.extractAnswerId('not-an-id')).toBeNull();
328
+ expect(helpers.extractAnswerId('https://example.com/answer/123')).toBeNull();
329
+ });
330
+ });
@@ -9,6 +9,9 @@ function stripHtml(html) {
9
9
  .replace(/&amp;/g, '&')
10
10
  .trim();
11
11
  }
12
+
13
+ const MAX_LIMIT = 1000;
14
+
12
15
  cli({
13
16
  site: 'zhihu',
14
17
  name: 'question',
@@ -18,7 +21,8 @@ cli({
18
21
  strategy: Strategy.COOKIE,
19
22
  args: [
20
23
  { name: 'id', required: true, positional: true, help: 'Question ID (numeric)' },
21
- { name: 'limit', type: 'int', default: 5, help: 'Number of answers' },
24
+ { name: 'limit', type: 'int', default: 5, help: 'Number of answers (max 1000; use normal-sized requests)' },
25
+ { name: 'sort', default: 'default', choices: ['default', 'created'], help: 'Answer order: default or created' },
22
26
  ],
23
27
  columns: ['rank', 'author', 'votes', 'content'],
24
28
  func: async (page, kwargs) => {
@@ -28,23 +32,53 @@ cli({
28
32
  throw new CliError('INVALID_INPUT', 'Question ID must be numeric', 'Example: opencli zhihu question 123456789');
29
33
  }
30
34
  const answerLimit = Number(limit);
31
- await page.goto(`https://www.zhihu.com/question/${questionId}`);
32
- const url = `https://www.zhihu.com/api/v4/questions/${questionId}/answers?limit=${answerLimit}&offset=0&sort_by=default&include=data[*].content,voteup_count,comment_count,author`;
33
- const data = await page.evaluate(`
35
+ if (!Number.isInteger(answerLimit) || answerLimit <= 0 || answerLimit > MAX_LIMIT) {
36
+ throw new CliError('INVALID_INPUT', `Limit must be a positive integer no greater than ${MAX_LIMIT}`, 'Use a normal-sized limit to avoid slow requests or Zhihu risk controls');
37
+ }
38
+ const sort = String(kwargs.sort || 'default');
39
+ if (sort !== 'default' && sort !== 'created') {
40
+ throw new CliError('INVALID_INPUT', 'Sort must be one of: default, created', 'Example: opencli zhihu question 123456789 --sort created');
41
+ }
42
+ await page.goto(sort === 'created'
43
+ ? `https://www.zhihu.com/question/${questionId}/answers/updated`
44
+ : `https://www.zhihu.com/question/${questionId}`);
45
+ // Zhihu caps `limit` at 20 per request, so always ask for the API
46
+ // maximum. The pagination loop below trims to `answerLimit` via the
47
+ // `answers.length >= answerLimit` break, so a smaller --limit only
48
+ // costs one over-fetched page worth of bandwidth and never silently
49
+ // clamps the user-requested count.
50
+ 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`;
52
+ const answers = [];
53
+ const seen = new Set();
54
+ const visited = new Set();
55
+ while (url && answers.length < answerLimit && !visited.has(url)) {
56
+ visited.add(url);
57
+ const data = await page.evaluate(`
34
58
  (async () => {
35
59
  const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
36
60
  if (!r.ok) return { __httpError: r.status };
37
61
  return await r.json();
38
62
  })()
39
63
  `);
40
- if (!data || data.__httpError) {
41
- const status = data?.__httpError;
42
- if (status === 401 || status === 403) {
43
- throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
64
+ if (!data || data.__httpError) {
65
+ const status = data?.__httpError;
66
+ if (status === 401 || status === 403) {
67
+ throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch question data from Zhihu');
68
+ }
69
+ 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
+ }
71
+ for (const item of data.data || []) {
72
+ const key = item.id == null ? `${item.author?.name || 'anonymous'}:${item.content || ''}` : String(item.id);
73
+ if (seen.has(key)) continue;
74
+ seen.add(key);
75
+ answers.push(item);
76
+ if (answers.length >= answerLimit) break;
44
77
  }
45
- 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');
78
+ if (data.paging?.is_end) break;
79
+ url = typeof data.paging?.next === 'string' ? data.paging.next : '';
46
80
  }
47
- return (data.data || []).map((item, i) => ({
81
+ return answers.map((item, i) => ({
48
82
  rank: i + 1,
49
83
  author: item.author?.name || 'anonymous',
50
84
  votes: item.voteup_count || 0,
@@ -8,7 +8,10 @@ describe('zhihu question', () => {
8
8
  expect(cmd?.func).toBeTypeOf('function');
9
9
  const goto = vi.fn().mockResolvedValue(undefined);
10
10
  const evaluate = vi.fn().mockImplementation(async (js) => {
11
- expect(js).toContain('questions/2021881398772981878/answers?limit=3');
11
+ // Per-request page size is the Zhihu API maximum (20). The
12
+ // user-requested `--limit 3` is enforced by the dedup loop's
13
+ // `answers.length >= answerLimit` break, not by the fetch URL.
14
+ expect(js).toContain('questions/2021881398772981878/answers?limit=20');
12
15
  expect(js).toContain("credentials: 'include'");
13
16
  return {
14
17
  data: [
@@ -32,6 +35,59 @@ describe('zhihu question', () => {
32
35
  expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878');
33
36
  expect(evaluate).toHaveBeenCalledTimes(1);
34
37
  });
38
+ it('follows paging.next until the requested limit is reached', async () => {
39
+ const cmd = getRegistry().get('zhihu/question');
40
+ const goto = vi.fn().mockResolvedValue(undefined);
41
+ const evaluate = vi.fn()
42
+ .mockResolvedValueOnce({
43
+ data: [
44
+ { id: 'a1', author: { name: 'alice' }, voteup_count: 12, content: '<p>first</p>' },
45
+ { id: 'a2', author: { name: 'bob' }, voteup_count: 8, content: '<p>second</p>' },
46
+ ],
47
+ paging: {
48
+ is_end: false,
49
+ next: 'https://www.zhihu.com/api/v4/questions/2021881398772981878/answers?limit=2&offset=80&sort_by=default',
50
+ },
51
+ })
52
+ .mockResolvedValueOnce({
53
+ data: [
54
+ { id: 'a2', author: { name: 'bob duplicate' }, voteup_count: 8, content: '<p>duplicate</p>' },
55
+ { id: 'a3', author: { name: 'carol' }, voteup_count: 5, content: '<p>third</p>' },
56
+ ],
57
+ paging: { is_end: true },
58
+ });
59
+ const page = { goto, evaluate };
60
+ 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' },
64
+ ]);
65
+ expect(evaluate).toHaveBeenCalledTimes(2);
66
+ expect(evaluate.mock.calls[1][0]).toContain('offset=80');
67
+ });
68
+ it('supports created-time sorting', async () => {
69
+ const cmd = getRegistry().get('zhihu/question');
70
+ const goto = vi.fn().mockResolvedValue(undefined);
71
+ const evaluate = vi.fn().mockImplementation(async (js) => {
72
+ expect(js).toContain('sort_by=created');
73
+ return {
74
+ data: [
75
+ {
76
+ id: 'a1',
77
+ author: { name: 'newest' },
78
+ voteup_count: 1,
79
+ content: '<p>created order</p>',
80
+ },
81
+ ],
82
+ paging: { is_end: true },
83
+ };
84
+ });
85
+ const page = { goto, evaluate };
86
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 1, sort: 'created' })).resolves.toEqual([
87
+ { rank: 1, author: 'newest', votes: 1, content: 'created order' },
88
+ ]);
89
+ expect(goto).toHaveBeenCalledWith('https://www.zhihu.com/question/2021881398772981878/answers/updated');
90
+ });
35
91
  it('maps auth-like answer failures to AuthRequiredError', async () => {
36
92
  const cmd = getRegistry().get('zhihu/question');
37
93
  const page = {
@@ -69,4 +125,25 @@ describe('zhihu question', () => {
69
125
  expect(page.goto).not.toHaveBeenCalled();
70
126
  expect(page.evaluate).not.toHaveBeenCalled();
71
127
  });
128
+ it('rejects invalid limits before navigation', async () => {
129
+ const cmd = getRegistry().get('zhihu/question');
130
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
131
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 0 })).rejects.toBeInstanceOf(CliError);
132
+ expect(page.goto).not.toHaveBeenCalled();
133
+ expect(page.evaluate).not.toHaveBeenCalled();
134
+ });
135
+ it('rejects excessive limits before navigation', async () => {
136
+ const cmd = getRegistry().get('zhihu/question');
137
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
138
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 1001 })).rejects.toBeInstanceOf(CliError);
139
+ expect(page.goto).not.toHaveBeenCalled();
140
+ expect(page.evaluate).not.toHaveBeenCalled();
141
+ });
142
+ it('rejects invalid sort before navigation', async () => {
143
+ const cmd = getRegistry().get('zhihu/question');
144
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
145
+ await expect(cmd.func(page, { id: '2021881398772981878', limit: 1, sort: 'unknown' })).rejects.toBeInstanceOf(CliError);
146
+ expect(page.goto).not.toHaveBeenCalled();
147
+ expect(page.evaluate).not.toHaveBeenCalled();
148
+ });
72
149
  });
@@ -0,0 +1,103 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { AuthRequiredError, CliError } from '@jackwener/opencli/errors';
3
+
4
+ function normalizeUrl(item) {
5
+ const target = item.target || {};
6
+ const id = target.id == null ? '' : String(target.id);
7
+ if (target.type === 'answer') {
8
+ const questionId = target.question?.id == null ? '' : String(target.question.id);
9
+ return questionId && id ? `https://www.zhihu.com/question/${questionId}/answer/${id}` : '';
10
+ }
11
+ if (target.type === 'article') {
12
+ return id ? `https://zhuanlan.zhihu.com/p/${id}` : '';
13
+ }
14
+ if (target.type === 'question') {
15
+ return id ? `https://www.zhihu.com/question/${id}` : '';
16
+ }
17
+ return '';
18
+ }
19
+
20
+ function normalizeTitle(item) {
21
+ const target = item.target || {};
22
+ if (target.type === 'answer') return target.question?.title || '';
23
+ return target.title || target.question?.title || '';
24
+ }
25
+
26
+ const MAX_LIMIT = 1000;
27
+
28
+ cli({
29
+ site: 'zhihu',
30
+ name: 'recommend',
31
+ access: 'read',
32
+ description: '知乎首页推荐',
33
+ domain: 'www.zhihu.com',
34
+ strategy: Strategy.COOKIE,
35
+ args: [
36
+ { name: 'limit', type: 'int', default: 20, help: 'Number of items to return (max 1000; use normal-sized requests)' },
37
+ ],
38
+ columns: ['rank', 'type', 'title', 'author', 'votes', 'url'],
39
+ func: async (page, kwargs) => {
40
+ const itemLimit = Number(kwargs.limit ?? 20);
41
+ if (!Number.isInteger(itemLimit) || itemLimit <= 0 || itemLimit > MAX_LIMIT) {
42
+ throw new CliError('INVALID_INPUT', `Limit must be a positive integer no greater than ${MAX_LIMIT}`, 'Use a normal-sized limit to avoid slow requests or Zhihu risk controls');
43
+ }
44
+ await page.goto('https://www.zhihu.com');
45
+ let url = 'https://www.zhihu.com/api/v3/feed/topstory/recommend?limit=10&desktop=true';
46
+ const items = [];
47
+ const seen = new Set();
48
+ const visited = new Set();
49
+ while (url && items.length < itemLimit && !visited.has(url)) {
50
+ visited.add(url);
51
+ const data = await page.evaluate(`
52
+ (async () => {
53
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
54
+ if (!r.ok) return { __httpError: r.status };
55
+ return await r.json();
56
+ })()
57
+ `);
58
+ if (!data || data.__httpError) {
59
+ const status = data?.__httpError;
60
+ if (status === 401 || status === 403) {
61
+ throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch Zhihu recommendations');
62
+ }
63
+ throw new CliError('FETCH_ERROR', status ? `Zhihu recommendations request failed (HTTP ${status})` : 'Zhihu recommendations request failed', 'Try again later or rerun with -v for more detail');
64
+ }
65
+ for (const item of data.data || []) {
66
+ const target = item.target || {};
67
+ // Dedup key uses semantic identity (type:targetId) and falls
68
+ // back to the feed cursor id when no target id exists. We avoid
69
+ // synthesizing a sentinel like 'unknown' for missing type
70
+ // because that would collapse distinct typed items into the
71
+ // same bucket. When no id is available at all we keep the row
72
+ // and skip dedup — surfacing potentially-duplicate items beats
73
+ // silently dropping them.
74
+ const targetId = target.id;
75
+ let key = null;
76
+ if (targetId != null) {
77
+ key = `${target.type ?? ''}:${targetId}`;
78
+ } else if (item.id != null) {
79
+ key = `__feed:${item.id}`;
80
+ }
81
+ if (key != null) {
82
+ if (seen.has(key)) continue;
83
+ seen.add(key);
84
+ }
85
+ items.push(item);
86
+ if (items.length >= itemLimit) break;
87
+ }
88
+ if (data.paging?.is_end) break;
89
+ url = typeof data.paging?.next === 'string' ? data.paging.next : '';
90
+ }
91
+ return items.map((item, i) => {
92
+ const target = item.target || {};
93
+ return {
94
+ rank: i + 1,
95
+ type: target.type || item.type || '',
96
+ title: normalizeTitle(item),
97
+ author: target.author?.name || '',
98
+ votes: target.voteup_count ?? target.reaction?.statistics?.like_count ?? 0,
99
+ url: normalizeUrl(item),
100
+ };
101
+ });
102
+ },
103
+ });