@jackwener/opencli 1.7.8 → 1.7.10

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 (281) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +646 -30
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/apple-podcasts/commands.test.js +4 -4
  6. package/clis/apple-podcasts/episodes.js +1 -1
  7. package/clis/apple-podcasts/search.js +1 -1
  8. package/clis/apple-podcasts/top.js +1 -1
  9. package/clis/arxiv/paper.js +1 -1
  10. package/clis/arxiv/search.js +1 -1
  11. package/clis/band/mentions.js +3 -3
  12. package/clis/bbc/news.js +1 -1
  13. package/clis/bilibili/subtitle.js +2 -2
  14. package/clis/bloomberg/businessweek.js +1 -1
  15. package/clis/bloomberg/economics.js +1 -1
  16. package/clis/bloomberg/industries.js +1 -1
  17. package/clis/bloomberg/main.js +1 -1
  18. package/clis/bloomberg/markets.js +1 -1
  19. package/clis/bloomberg/opinions.js +1 -1
  20. package/clis/bloomberg/politics.js +1 -1
  21. package/clis/bloomberg/tech.js +1 -1
  22. package/clis/boss/search.js +49 -8
  23. package/clis/boss/search.test.js +78 -0
  24. package/clis/boss/send.js +3 -3
  25. package/clis/chatgpt/image.js +37 -8
  26. package/clis/chatgpt/image.test.js +92 -0
  27. package/clis/chatgpt/utils.js +39 -6
  28. package/clis/chatgpt/utils.test.js +63 -0
  29. package/clis/chatgpt-app/ask.js +1 -1
  30. package/clis/chatgpt-app/ax.js +4 -2
  31. package/clis/chatgpt-app/ax.test.js +12 -0
  32. package/clis/chatgpt-app/model.js +1 -1
  33. package/clis/chatgpt-app/new.js +1 -1
  34. package/clis/chatgpt-app/read.js +1 -1
  35. package/clis/chatgpt-app/send.js +1 -1
  36. package/clis/chatgpt-app/status.js +1 -1
  37. package/clis/chatwise/ask.js +2 -2
  38. package/clis/chatwise/model.js +2 -2
  39. package/clis/chatwise/send.js +2 -2
  40. package/clis/claude/ask.js +128 -0
  41. package/clis/claude/ask.test.js +338 -0
  42. package/clis/claude/commands.test.js +118 -0
  43. package/clis/claude/detail.js +29 -0
  44. package/clis/claude/history.js +31 -0
  45. package/clis/claude/new.js +21 -0
  46. package/clis/claude/read.js +24 -0
  47. package/clis/claude/send.js +41 -0
  48. package/clis/claude/status.js +24 -0
  49. package/clis/claude/utils.js +440 -0
  50. package/clis/claude/utils.test.js +148 -0
  51. package/clis/codex/ask.js +2 -2
  52. package/clis/codex/send.js +2 -2
  53. package/clis/ctrip/search.js +1 -1
  54. package/clis/ctrip/search.test.js +4 -4
  55. package/clis/cursor/ask.js +2 -2
  56. package/clis/cursor/composer.js +2 -2
  57. package/clis/cursor/send.js +2 -2
  58. package/clis/deepseek/ask.js +17 -4
  59. package/clis/deepseek/ask.test.js +46 -0
  60. package/clis/deepseek/utils.js +55 -16
  61. package/clis/deepseek/utils.test.js +124 -5
  62. package/clis/doubao/utils.js +53 -11
  63. package/clis/doubao/utils.test.js +22 -2
  64. package/clis/eastmoney/announcement.js +1 -1
  65. package/clis/eastmoney/convertible.js +1 -1
  66. package/clis/eastmoney/etf.js +1 -1
  67. package/clis/eastmoney/holders.js +1 -1
  68. package/clis/eastmoney/index-board.js +1 -1
  69. package/clis/eastmoney/kline.js +1 -1
  70. package/clis/eastmoney/kuaixun.js +1 -1
  71. package/clis/eastmoney/longhu.js +1 -1
  72. package/clis/eastmoney/money-flow.js +1 -1
  73. package/clis/eastmoney/northbound.js +1 -1
  74. package/clis/eastmoney/quote.js +1 -1
  75. package/clis/eastmoney/rank.js +1 -1
  76. package/clis/eastmoney/sectors.js +1 -1
  77. package/clis/facebook/marketplace-inbox.js +83 -0
  78. package/clis/facebook/marketplace-listings.js +83 -0
  79. package/clis/facebook/marketplace.test.js +91 -0
  80. package/clis/google/news.js +1 -1
  81. package/clis/google/suggest.js +1 -1
  82. package/clis/google/trends.js +1 -1
  83. package/clis/google-scholar/cite.js +74 -0
  84. package/clis/google-scholar/cite.test.js +47 -0
  85. package/clis/google-scholar/profile.js +92 -0
  86. package/clis/google-scholar/profile.test.js +49 -0
  87. package/clis/google-scholar/search.js +1 -1
  88. package/clis/google-scholar/search.test.js +15 -0
  89. package/clis/hf/top.js +1 -1
  90. package/clis/instagram/collection-create.js +57 -0
  91. package/clis/instagram/saved.js +21 -7
  92. package/clis/jd/item.js +679 -47
  93. package/clis/jd/item.test.js +318 -7
  94. package/clis/jd/item.test.ts +517 -0
  95. package/clis/lesswrong/comments.js +1 -1
  96. package/clis/lesswrong/curated.js +1 -1
  97. package/clis/lesswrong/frontpage.js +1 -1
  98. package/clis/lesswrong/new.js +1 -1
  99. package/clis/lesswrong/read.js +1 -1
  100. package/clis/lesswrong/sequences.js +1 -1
  101. package/clis/lesswrong/shortform.js +1 -1
  102. package/clis/lesswrong/tag.js +1 -1
  103. package/clis/lesswrong/tags.js +1 -1
  104. package/clis/lesswrong/top-month.js +1 -1
  105. package/clis/lesswrong/top-week.js +1 -1
  106. package/clis/lesswrong/top-year.js +1 -1
  107. package/clis/lesswrong/top.js +1 -1
  108. package/clis/lesswrong/user-posts.js +1 -1
  109. package/clis/lesswrong/user.js +1 -1
  110. package/clis/paperreview/commands.test.js +6 -6
  111. package/clis/paperreview/feedback.js +1 -1
  112. package/clis/paperreview/review.js +1 -1
  113. package/clis/paperreview/submit.js +1 -1
  114. package/clis/producthunt/posts.js +1 -1
  115. package/clis/producthunt/today.js +1 -1
  116. package/clis/sinablog/search.js +1 -1
  117. package/clis/sinafinance/news.js +1 -1
  118. package/clis/sinafinance/stock.js +1 -1
  119. package/clis/sinafinance/stock.test.js +2 -2
  120. package/clis/spotify/spotify.js +6 -6
  121. package/clis/substack/search.js +1 -1
  122. package/clis/toutiao/articles.js +5 -6
  123. package/clis/toutiao/articles.test.js +22 -15
  124. package/clis/twitter/followers.js +2 -2
  125. package/clis/twitter/following.js +224 -73
  126. package/clis/twitter/following.test.js +277 -0
  127. package/clis/twitter/post.js +184 -47
  128. package/clis/twitter/post.test.js +114 -34
  129. package/clis/uiverse/_shared.js +63 -4
  130. package/clis/uiverse/_shared.test.js +7 -0
  131. package/clis/uiverse/code.js +1 -0
  132. package/clis/uiverse/navigation.test.js +12 -0
  133. package/clis/uiverse/preview.js +1 -0
  134. package/clis/web/read.js +319 -81
  135. package/clis/web/read.test.js +221 -5
  136. package/clis/weibo/favorites.js +169 -0
  137. package/clis/weibo/favorites.test.js +114 -0
  138. package/clis/weibo/publish.js +282 -0
  139. package/clis/weibo/publish.test.js +183 -0
  140. package/clis/weread/ranking.js +1 -1
  141. package/clis/weread/search-regression.test.js +8 -8
  142. package/clis/weread/search.js +1 -1
  143. package/clis/wikipedia/random.js +1 -1
  144. package/clis/wikipedia/search.js +1 -1
  145. package/clis/wikipedia/summary.js +1 -1
  146. package/clis/wikipedia/trending.js +1 -1
  147. package/clis/xianyu/chat.js +3 -3
  148. package/clis/xianyu/item.js +2 -2
  149. package/clis/xianyu/item.test.js +3 -3
  150. package/clis/xiaohongshu/search.js +17 -2
  151. package/clis/xiaohongshu/search.test.js +37 -1
  152. package/clis/xiaoyuzhou/download.js +1 -1
  153. package/clis/xiaoyuzhou/download.test.js +3 -3
  154. package/clis/xiaoyuzhou/episode.js +1 -1
  155. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  156. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  157. package/clis/xiaoyuzhou/podcast.js +1 -1
  158. package/clis/xiaoyuzhou/transcript.js +1 -1
  159. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  160. package/clis/yollomi/models.js +1 -1
  161. package/clis/youtube/channel.js +24 -1
  162. package/clis/youtube/channel.test.js +59 -0
  163. package/clis/zhihu/answer.js +21 -162
  164. package/clis/zhihu/answer.test.js +26 -53
  165. package/clis/zhihu/collection.js +197 -0
  166. package/clis/zhihu/collection.test.js +290 -0
  167. package/clis/zhihu/collections.js +127 -0
  168. package/clis/zhihu/collections.test.js +182 -0
  169. package/clis/zhihu/comment.js +24 -305
  170. package/clis/zhihu/comment.test.js +31 -35
  171. package/clis/zhihu/favorite.js +44 -182
  172. package/clis/zhihu/favorite.test.js +30 -167
  173. package/clis/zhihu/follow.js +25 -56
  174. package/clis/zhihu/follow.test.js +20 -23
  175. package/clis/zhihu/like.js +22 -67
  176. package/clis/zhihu/like.test.js +19 -42
  177. package/clis/zhihu/search.js +3 -2
  178. package/clis/zhihu/write-shared.js +8 -1
  179. package/clis/zhihu/write-shared.test.js +1 -0
  180. package/clis/zlibrary/commands.test.js +75 -0
  181. package/clis/zlibrary/info.js +47 -0
  182. package/clis/zlibrary/search.js +46 -0
  183. package/clis/zlibrary/utils.js +136 -0
  184. package/dist/src/adapter-source.d.ts +11 -0
  185. package/dist/src/adapter-source.js +24 -0
  186. package/dist/src/adapter-source.test.js +29 -0
  187. package/dist/src/browser/base-page.d.ts +3 -1
  188. package/dist/src/browser/base-page.js +76 -1
  189. package/dist/src/browser/base-page.test.d.ts +1 -0
  190. package/dist/src/browser/base-page.test.js +74 -0
  191. package/dist/src/browser/bridge.d.ts +1 -2
  192. package/dist/src/browser/bridge.js +40 -41
  193. package/dist/src/browser/cdp.d.ts +1 -0
  194. package/dist/src/browser/cdp.js +3 -3
  195. package/dist/src/browser/daemon-client.d.ts +38 -4
  196. package/dist/src/browser/daemon-client.js +24 -7
  197. package/dist/src/browser/daemon-client.test.js +49 -0
  198. package/dist/src/browser/daemon-lifecycle.d.ts +23 -0
  199. package/dist/src/browser/daemon-lifecycle.js +67 -0
  200. package/dist/src/browser/daemon-version.d.ts +4 -0
  201. package/dist/src/browser/daemon-version.js +12 -0
  202. package/dist/src/browser/errors.js +3 -0
  203. package/dist/src/browser/errors.test.js +3 -0
  204. package/dist/src/browser/network-cache.d.ts +1 -0
  205. package/dist/src/browser/page.d.ts +3 -1
  206. package/dist/src/browser/page.js +10 -2
  207. package/dist/src/browser/profile.d.ts +14 -0
  208. package/dist/src/browser/profile.js +85 -0
  209. package/dist/src/build-manifest.d.ts +2 -0
  210. package/dist/src/build-manifest.js +13 -3
  211. package/dist/src/build-manifest.test.js +20 -2
  212. package/dist/src/cli.d.ts +6 -0
  213. package/dist/src/cli.js +477 -35
  214. package/dist/src/cli.test.js +303 -2
  215. package/dist/src/commanderAdapter.js +17 -9
  216. package/dist/src/commanderAdapter.test.js +67 -2
  217. package/dist/src/commands/daemon.d.ts +2 -0
  218. package/dist/src/commands/daemon.js +42 -1
  219. package/dist/src/commands/daemon.test.js +103 -2
  220. package/dist/src/completion-shared.js +1 -2
  221. package/dist/src/completion.test.js +3 -2
  222. package/dist/src/daemon.js +125 -41
  223. package/dist/src/doctor.d.ts +5 -6
  224. package/dist/src/doctor.js +77 -19
  225. package/dist/src/doctor.test.js +117 -0
  226. package/dist/src/engine.test.js +6 -5
  227. package/dist/src/errors.d.ts +14 -8
  228. package/dist/src/errors.js +36 -30
  229. package/dist/src/errors.test.js +5 -5
  230. package/dist/src/execution.d.ts +4 -0
  231. package/dist/src/execution.js +173 -25
  232. package/dist/src/execution.test.js +171 -1
  233. package/dist/src/main.js +10 -0
  234. package/dist/src/observation/artifact.d.ts +16 -0
  235. package/dist/src/observation/artifact.js +260 -0
  236. package/dist/src/observation/artifact.test.d.ts +1 -0
  237. package/dist/src/observation/artifact.test.js +121 -0
  238. package/dist/src/observation/events.d.ts +89 -0
  239. package/dist/src/observation/events.js +1 -0
  240. package/dist/src/observation/index.d.ts +7 -0
  241. package/dist/src/observation/index.js +7 -0
  242. package/dist/src/observation/manager.d.ts +9 -0
  243. package/dist/src/observation/manager.js +27 -0
  244. package/dist/src/observation/manager.test.d.ts +1 -0
  245. package/dist/src/observation/manager.test.js +13 -0
  246. package/dist/src/observation/redaction.d.ts +11 -0
  247. package/dist/src/observation/redaction.js +81 -0
  248. package/dist/src/observation/redaction.test.d.ts +1 -0
  249. package/dist/src/observation/redaction.test.js +32 -0
  250. package/dist/src/observation/retention.d.ts +32 -0
  251. package/dist/src/observation/retention.js +160 -0
  252. package/dist/src/observation/retention.test.d.ts +1 -0
  253. package/dist/src/observation/retention.test.js +118 -0
  254. package/dist/src/observation/ring-buffer.d.ts +22 -0
  255. package/dist/src/observation/ring-buffer.js +45 -0
  256. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  257. package/dist/src/observation/ring-buffer.test.js +22 -0
  258. package/dist/src/observation/session.d.ts +25 -0
  259. package/dist/src/observation/session.js +50 -0
  260. package/dist/src/pipeline/executor.test.js +1 -0
  261. package/dist/src/pipeline/steps/download.test.js +1 -0
  262. package/dist/src/pipeline/steps/fetch.js +1 -21
  263. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  264. package/dist/src/plugin-scaffold.js +1 -1
  265. package/dist/src/plugin-scaffold.test.js +1 -1
  266. package/dist/src/registry.d.ts +40 -9
  267. package/dist/src/registry.js +3 -1
  268. package/dist/src/runtime-detect.d.ts +10 -0
  269. package/dist/src/runtime-detect.js +19 -0
  270. package/dist/src/runtime-detect.test.js +12 -1
  271. package/dist/src/runtime.d.ts +2 -0
  272. package/dist/src/runtime.js +1 -0
  273. package/dist/src/types.d.ts +22 -0
  274. package/dist/src/update-check.d.ts +31 -1
  275. package/dist/src/update-check.js +62 -16
  276. package/dist/src/update-check.test.js +86 -1
  277. package/package.json +1 -1
  278. package/dist/src/diagnostic.d.ts +0 -63
  279. package/dist/src/diagnostic.js +0 -292
  280. package/dist/src/diagnostic.test.js +0 -302
  281. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -2,80 +2,53 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './answer.js';
4
4
  describe('zhihu answer', () => {
5
- it('rejects create mode when the current user already answered the question', async () => {
5
+ it('registers as a cookie browser command', () => {
6
6
  const cmd = getRegistry().get('zhihu/answer');
7
- expect(cmd?.func).toBeTypeOf('function');
8
- const page = {
9
- goto: vi.fn().mockResolvedValue(undefined),
10
- evaluate: vi.fn()
11
- .mockResolvedValueOnce({ slug: 'alice' })
12
- .mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: true }),
13
- };
14
- await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
15
- });
16
- it('rejects anonymous mode instead of toggling it', async () => {
17
- const cmd = getRegistry().get('zhihu/answer');
18
- const page = {
19
- goto: vi.fn().mockResolvedValue(undefined),
20
- evaluate: vi.fn()
21
- .mockResolvedValueOnce({ slug: 'alice' })
22
- .mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
23
- .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'on' }),
24
- };
25
- await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
7
+ expect(cmd).toBeDefined();
8
+ expect(cmd.strategy).toBe('cookie');
9
+ expect(cmd.browser).toBe(true);
26
10
  });
27
- it('rejects when a unique safe answer composer cannot be proven', async () => {
11
+ it('creates an answer via API and returns result', async () => {
28
12
  const cmd = getRegistry().get('zhihu/answer');
29
13
  const page = {
30
14
  goto: vi.fn().mockResolvedValue(undefined),
15
+ wait: vi.fn().mockResolvedValue(undefined),
31
16
  evaluate: vi.fn()
32
17
  .mockResolvedValueOnce({ slug: 'alice' })
33
- .mockResolvedValueOnce({ entryPathSafe: false, hasExistingAnswerByCurrentUser: false }),
18
+ .mockResolvedValueOnce({ ok: true, id: '42', url: 'https://www.zhihu.com/question/1/answer/42' }),
34
19
  };
35
- await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
20
+ const rows = await cmd.func(page, { target: 'question:1', text: 'hello', execute: true });
21
+ expect(rows).toEqual([
22
+ expect.objectContaining({
23
+ outcome: 'created',
24
+ created_target: 'answer:1:42',
25
+ author_identity: 'alice',
26
+ }),
27
+ ]);
36
28
  });
37
- it('rejects when anonymous mode cannot be proven off', async () => {
29
+ it('throws on API error', async () => {
38
30
  const cmd = getRegistry().get('zhihu/answer');
39
31
  const page = {
40
32
  goto: vi.fn().mockResolvedValue(undefined),
33
+ wait: vi.fn().mockResolvedValue(undefined),
41
34
  evaluate: vi.fn()
42
35
  .mockResolvedValueOnce({ slug: 'alice' })
43
- .mockResolvedValueOnce({ entryPathSafe: true, hasExistingAnswerByCurrentUser: false })
44
- .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'unknown' }),
36
+ .mockResolvedValueOnce({ ok: false, status: 400, message: 'already answered' }),
45
37
  };
46
- await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
38
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true }))
39
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
47
40
  });
48
- it('requires a side-effect-free entry path and exact editor content before publish', async () => {
41
+ it('requires the answer API response to include the created id', async () => {
49
42
  const cmd = getRegistry().get('zhihu/answer');
50
43
  const page = {
51
44
  goto: vi.fn().mockResolvedValue(undefined),
45
+ wait: vi.fn().mockResolvedValue(undefined),
52
46
  evaluate: vi.fn()
53
47
  .mockResolvedValueOnce({ slug: 'alice' })
54
- .mockResolvedValueOnce({ entryPathSafe: true })
55
- .mockResolvedValueOnce({ editorState: 'fresh_empty', anonymousMode: 'off' })
56
- .mockResolvedValueOnce({ editorContent: 'hello', bodyMatches: true })
57
- .mockResolvedValueOnce({
58
- createdTarget: 'answer:1:2',
59
- createdUrl: 'https://www.zhihu.com/question/1/answer/2',
60
- authorIdentity: 'alice',
61
- bodyMatches: true,
62
- }),
48
+ .mockResolvedValueOnce({ ok: false, status: 200, message: 'Answer API response did not include a created answer id' }),
63
49
  };
64
- await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true })).resolves.toEqual([
65
- expect.objectContaining({
66
- outcome: 'created',
67
- created_target: 'answer:1:2',
68
- created_url: 'https://www.zhihu.com/question/1/answer/2',
69
- author_identity: 'alice',
70
- }),
71
- ]);
72
- expect(page.evaluate.mock.calls[1][0]).toContain('composerCandidates.length === 1');
73
- expect(page.evaluate.mock.calls[1][0]).not.toContain('writeAnswerButton');
74
- expect(page.evaluate.mock.calls[1][0]).toContain('const readAnswerAuthorSlug = (node) =>');
75
- expect(page.evaluate.mock.calls[1][0]).toContain('const answerAuthorScopeSelector = ".AuthorInfo, .AnswerItem-authorInfo, .ContentItem-meta, [itemprop=\\"author\\"]"');
76
- expect(page.evaluate.mock.calls[1][0]).not.toContain("node.querySelector('a[href^=\"/people/\"]')");
77
- expect(page.evaluate.mock.calls[3][0]).toContain('composerCandidates.length !== 1');
78
- expect(page.evaluate.mock.calls[4][0]).toContain('const readAnswerAuthorSlug = (node) =>');
79
- expect(page.evaluate.mock.calls[4][0]).not.toContain("answerContainer?.querySelector('a[href^=\"/people/\"]')");
50
+ await expect(cmd.func(page, { target: 'question:1', text: 'hello', execute: true }))
51
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
52
+ expect(page.evaluate.mock.calls[1][0]).toContain('Answer API response did not include a created answer id');
80
53
  });
81
54
  });
@@ -0,0 +1,197 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { ArgumentError, AuthRequiredError, CommandExecutionError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { log } from '@jackwener/opencli/logger';
4
+
5
+ function stripHtml(html) {
6
+ return html
7
+ .replace(/<[^>]+>/g, '')
8
+ .replace(/&nbsp;/g, ' ')
9
+ .replace(/&lt;/g, '<')
10
+ .replace(/&gt;/g, '>')
11
+ .replace(/&amp;/g, '&')
12
+ .replace(/&quot;/g, '"')
13
+ .replace(/<em>/g, '')
14
+ .replace(/<\/em>/g, '')
15
+ .trim();
16
+ }
17
+
18
+ function validatePositiveInt(value, name) {
19
+ const n = Number(value);
20
+ if (!Number.isInteger(n) || n <= 0) {
21
+ throw new ArgumentError(`zhihu collection --${name} must be a positive integer`, 'Example: opencli zhihu collection 83283292 --limit 20');
22
+ }
23
+ return n;
24
+ }
25
+
26
+ function validateNonNegativeInt(value, name) {
27
+ const n = Number(value);
28
+ if (!Number.isInteger(n) || n < 0) {
29
+ throw new ArgumentError(`zhihu collection --${name} must be a non-negative integer`, 'Example: opencli zhihu collection 83283292 --offset 0');
30
+ }
31
+ return n;
32
+ }
33
+
34
+ async function fetchCollectionPage(page, collectionId, offset, limit) {
35
+ const url = `https://www.zhihu.com/api/v4/collections/${collectionId}/items?offset=${offset}&limit=${limit}`;
36
+ const data = await page.evaluate(`
37
+ (async () => {
38
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
39
+ if (!r.ok) return { __httpError: r.status };
40
+ return await r.json();
41
+ })()
42
+ `);
43
+
44
+ if (!data || data.__httpError) {
45
+ const status = data?.__httpError;
46
+ if (status === 401 || status === 403) {
47
+ throw new AuthRequiredError('www.zhihu.com', 'Failed to fetch collection data from Zhihu. Please ensure you are logged in.');
48
+ }
49
+ throw new CommandExecutionError(
50
+ status ? `Zhihu collection request failed (HTTP ${status})` : 'Zhihu collection request failed',
51
+ 'Try again later or rerun with -v for more detail',
52
+ );
53
+ }
54
+ return data;
55
+ }
56
+
57
+ function itemKey(item) {
58
+ const content = item?.content || {};
59
+ return `${content.type || 'unknown'}:${content.id || content.url || JSON.stringify(content).slice(0, 80)}`;
60
+ }
61
+
62
+ function mapCollectionItem(item, rank) {
63
+ const content = item.content || {};
64
+ const type = content.type || 'unknown';
65
+
66
+ let title = '';
67
+ let excerpt = '';
68
+ let url = '';
69
+ let author = '';
70
+ let votes = 0;
71
+
72
+ if (type === 'answer') {
73
+ const question = content.question || {};
74
+ title = question.title || '';
75
+ excerpt = stripHtml(content.content || '').substring(0, 150);
76
+ url = content.url || `https://www.zhihu.com/question/${question.id}/answer/${content.id}`;
77
+ author = content.author?.name || '匿名用户';
78
+ votes = content.voteup_count || 0;
79
+ } else if (type === 'article') {
80
+ title = content.title || '';
81
+ excerpt = stripHtml(content.content || '').substring(0, 150);
82
+ url = content.url || `https://zhuanlan.zhihu.com/p/${content.id}`;
83
+ author = content.author?.name || '匿名用户';
84
+ votes = content.voteup_count || 0;
85
+ } else if (type === 'pin') {
86
+ title = '想法';
87
+ excerpt = stripHtml((content.content || []).map((c) => c.content || '').join(' ')).substring(0, 150);
88
+ url = content.url || `https://www.zhihu.com/pin/${content.id}`;
89
+ author = content.author?.name || '匿名用户';
90
+ votes = content.reaction_count || 0;
91
+ }
92
+
93
+ return {
94
+ rank,
95
+ type,
96
+ title: title.substring(0, 100),
97
+ author,
98
+ votes,
99
+ excerpt,
100
+ url,
101
+ };
102
+ }
103
+
104
+ cli({
105
+ site: 'zhihu',
106
+ name: 'collection',
107
+ description: '知乎收藏夹内容列表(需要登录)',
108
+ domain: 'www.zhihu.com',
109
+ strategy: Strategy.COOKIE,
110
+ browser: true,
111
+ args: [
112
+ { name: 'id', positional: true, required: true, help: '收藏夹 ID (数字,可从收藏夹 URL 中获取)' },
113
+ { name: 'offset', type: 'int', default: 0, help: '起始偏移量(用于分页)' },
114
+ { name: 'limit', type: 'int', default: 20, help: '每页数量(最大 20)' },
115
+ ],
116
+ columns: ['rank', 'type', 'title', 'author', 'votes', 'excerpt', 'url'],
117
+ func: async (page, kwargs) => {
118
+ const { id, offset = 0, limit = 20 } = kwargs;
119
+
120
+ const collectionId = String(id);
121
+
122
+ // 验证收藏夹 ID 为数字
123
+ if (!/^\d+$/.test(collectionId)) {
124
+ throw new ArgumentError('Collection ID must be numeric', 'Example: opencli zhihu collection 83283292');
125
+ }
126
+
127
+ const pageOffset = validateNonNegativeInt(offset, 'offset');
128
+ const requestedLimit = validatePositiveInt(limit, 'limit');
129
+ const pageLimit = Math.min(requestedLimit, 20); // 知乎 API 限制每页最大 20
130
+
131
+ // 先访问知乎主页建立 session
132
+ await page.goto('https://www.zhihu.com');
133
+
134
+ const collected = [];
135
+ const seen = new Set();
136
+ let totals = 0;
137
+ let nextOffset = pageOffset;
138
+ const maxPages = Math.ceil(requestedLimit / pageLimit) + 2;
139
+ for (let pageIndex = 0; pageIndex < maxPages && collected.length < requestedLimit; pageIndex += 1) {
140
+ const currentFetchLimit = Math.min(pageLimit, requestedLimit - collected.length);
141
+ const data = await fetchCollectionPage(page, collectionId, nextOffset, currentFetchLimit);
142
+ const items = Array.isArray(data.data) ? data.data : [];
143
+ const paging = data.paging || {};
144
+ totals = Number(paging.totals || totals || 0);
145
+
146
+ for (const item of items) {
147
+ const key = itemKey(item);
148
+ if (!seen.has(key)) {
149
+ seen.add(key);
150
+ collected.push(item);
151
+ }
152
+ if (collected.length >= requestedLimit) break;
153
+ }
154
+
155
+ if (items.length === 0 || paging.is_end || collected.length >= requestedLimit) break;
156
+ if (typeof paging.next === 'string') {
157
+ try {
158
+ const nextUrl = new URL(paging.next);
159
+ const parsedOffset = Number(nextUrl.searchParams.get('offset'));
160
+ if (Number.isInteger(parsedOffset) && parsedOffset > nextOffset) {
161
+ nextOffset = parsedOffset;
162
+ continue;
163
+ }
164
+ } catch {}
165
+ }
166
+ if (items.length < currentFetchLimit) break;
167
+ const fallbackOffset = nextOffset + items.length;
168
+ if (fallbackOffset <= nextOffset) break;
169
+ nextOffset = fallbackOffset;
170
+ if (totals && nextOffset >= totals) break;
171
+ }
172
+
173
+ // 计算总页数
174
+ const totalPages = Math.ceil(totals / pageLimit);
175
+ const currentPage = Math.floor(pageOffset / pageLimit) + 1;
176
+
177
+ // 输出统计信息
178
+ if (totals > 0) {
179
+ log.info(`收藏夹共有 ${totals} 条内容,共 ${totalPages} 页`);
180
+ log.info(`当前第 ${currentPage} 页,显示第 ${pageOffset + 1} - ${Math.min(pageOffset + collected.length, totals)} 条`);
181
+ }
182
+
183
+ if (collected.length === 0) {
184
+ throw new EmptyResultError('zhihu collection', `No items found for collection ${collectionId}. The collection may be empty, private, or the offset may be out of range.`);
185
+ }
186
+
187
+ return collected.slice(0, requestedLimit).map((item, i) => mapCollectionItem(item, pageOffset + i + 1));
188
+ },
189
+ });
190
+
191
+ export const __test__ = {
192
+ stripHtml,
193
+ validatePositiveInt,
194
+ validateNonNegativeInt,
195
+ itemKey,
196
+ mapCollectionItem,
197
+ };
@@ -0,0 +1,290 @@
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
+
5
+ // Mock logger
6
+ vi.mock('@jackwener/opencli/logger', () => ({
7
+ log: {
8
+ info: vi.fn(),
9
+ status: vi.fn(),
10
+ success: vi.fn(),
11
+ warn: vi.fn(),
12
+ error: vi.fn(),
13
+ verbose: vi.fn(),
14
+ debug: vi.fn(),
15
+ step: vi.fn(),
16
+ stepResult: vi.fn(),
17
+ },
18
+ }));
19
+
20
+ import './collection.js';
21
+
22
+ describe('zhihu collection', () => {
23
+ it('returns collection items from the Zhihu API', async () => {
24
+ const cmd = getRegistry().get('zhihu/collection');
25
+ expect(cmd?.func).toBeTypeOf('function');
26
+
27
+ const goto = vi.fn().mockResolvedValue(undefined);
28
+ const evaluate = vi.fn().mockImplementation(async (js) => {
29
+ expect(js).toContain('collections/83283292/items');
30
+ expect(js).toContain("credentials: 'include'");
31
+ return {
32
+ data: [
33
+ {
34
+ content: {
35
+ type: 'answer',
36
+ id: 123456,
37
+ question: { id: 789012, title: 'Test Question' },
38
+ author: { name: 'test_author' },
39
+ voteup_count: 42,
40
+ content: '<p>Test answer content</p>',
41
+ url: 'https://www.zhihu.com/question/789012/answer/123456',
42
+ },
43
+ },
44
+ ],
45
+ paging: { totals: 100 },
46
+ };
47
+ });
48
+
49
+ const page = { goto, evaluate };
50
+
51
+ const result = await cmd.func(page, { id: '83283292', offset: 0, limit: 20 });
52
+
53
+ expect(result).toHaveLength(1);
54
+ expect(result[0]).toMatchObject({
55
+ rank: 1,
56
+ type: 'answer',
57
+ title: 'Test Question',
58
+ author: 'test_author',
59
+ votes: 42,
60
+ url: 'https://www.zhihu.com/question/789012/answer/123456',
61
+ });
62
+
63
+ expect(goto).toHaveBeenCalledWith('https://www.zhihu.com');
64
+ expect(evaluate).toHaveBeenCalledTimes(1);
65
+ });
66
+
67
+ it('handles article type items', async () => {
68
+ const cmd = getRegistry().get('zhihu/collection');
69
+ const evaluate = vi.fn().mockResolvedValue({
70
+ data: [
71
+ {
72
+ content: {
73
+ type: 'article',
74
+ id: 987654,
75
+ title: 'Test Article',
76
+ author: { name: 'article_author' },
77
+ voteup_count: 100,
78
+ content: '<p>Article content</p>',
79
+ url: 'https://zhuanlan.zhihu.com/p/987654',
80
+ },
81
+ },
82
+ ],
83
+ paging: { totals: 50 },
84
+ });
85
+
86
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
87
+
88
+ const result = await cmd.func(page, { id: '83283292', offset: 0, limit: 20 });
89
+
90
+ expect(result[0]).toMatchObject({
91
+ type: 'article',
92
+ title: 'Test Article',
93
+ author: 'article_author',
94
+ votes: 100,
95
+ });
96
+ });
97
+
98
+ it('handles pin type items', async () => {
99
+ const cmd = getRegistry().get('zhihu/collection');
100
+ const evaluate = vi.fn().mockResolvedValue({
101
+ data: [
102
+ {
103
+ content: {
104
+ type: 'pin',
105
+ id: 111222,
106
+ author: { name: 'pin_author' },
107
+ reaction_count: 25,
108
+ content: [{ content: 'Pin content here' }],
109
+ url: 'https://www.zhihu.com/pin/111222',
110
+ },
111
+ },
112
+ ],
113
+ paging: { totals: 30 },
114
+ });
115
+
116
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
117
+
118
+ const result = await cmd.func(page, { id: '83283292', offset: 0, limit: 20 });
119
+
120
+ expect(result[0]).toMatchObject({
121
+ type: 'pin',
122
+ title: '想法',
123
+ author: 'pin_author',
124
+ votes: 25,
125
+ });
126
+ });
127
+
128
+ it('maps auth failures to AuthRequiredError', async () => {
129
+ const cmd = getRegistry().get('zhihu/collection');
130
+ const page = {
131
+ goto: vi.fn().mockResolvedValue(undefined),
132
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 401 }),
133
+ };
134
+
135
+ await expect(
136
+ cmd.func(page, { id: '83283292', offset: 0, limit: 20 }),
137
+ ).rejects.toBeInstanceOf(AuthRequiredError);
138
+ });
139
+
140
+ it('maps 403 errors to AuthRequiredError', async () => {
141
+ const cmd = getRegistry().get('zhihu/collection');
142
+ const page = {
143
+ goto: vi.fn().mockResolvedValue(undefined),
144
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 403 }),
145
+ };
146
+
147
+ await expect(
148
+ cmd.func(page, { id: '83283292', offset: 0, limit: 20 }),
149
+ ).rejects.toBeInstanceOf(AuthRequiredError);
150
+ });
151
+
152
+ it('preserves non-auth fetch failures as CommandExecutionError', async () => {
153
+ const cmd = getRegistry().get('zhihu/collection');
154
+ const page = {
155
+ goto: vi.fn().mockResolvedValue(undefined),
156
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 500 }),
157
+ };
158
+
159
+ await expect(
160
+ cmd.func(page, { id: '83283292', offset: 0, limit: 20 }),
161
+ ).rejects.toBeInstanceOf(CommandExecutionError);
162
+ });
163
+
164
+ it('handles null evaluate response as fetch error', async () => {
165
+ const cmd = getRegistry().get('zhihu/collection');
166
+ const page = {
167
+ goto: vi.fn().mockResolvedValue(undefined),
168
+ evaluate: vi.fn().mockResolvedValue(null),
169
+ };
170
+
171
+ await expect(
172
+ cmd.func(page, { id: '83283292', offset: 0, limit: 20 }),
173
+ ).rejects.toBeInstanceOf(CommandExecutionError);
174
+ });
175
+
176
+ it('rejects non-numeric collection IDs', async () => {
177
+ const cmd = getRegistry().get('zhihu/collection');
178
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
179
+
180
+ await expect(
181
+ cmd.func(page, { id: "abc'; alert(1); //", offset: 0, limit: 20 }),
182
+ ).rejects.toBeInstanceOf(ArgumentError);
183
+
184
+ expect(page.goto).not.toHaveBeenCalled();
185
+ expect(page.evaluate).not.toHaveBeenCalled();
186
+ });
187
+
188
+ it('respects pagination offset', async () => {
189
+ const cmd = getRegistry().get('zhihu/collection');
190
+ const evaluate = vi.fn().mockResolvedValue({
191
+ data: [
192
+ {
193
+ content: {
194
+ type: 'answer',
195
+ id: 1,
196
+ question: { id: 1, title: 'Test' },
197
+ author: { name: 'author' },
198
+ voteup_count: 10,
199
+ content: 'Content',
200
+ },
201
+ },
202
+ ],
203
+ paging: { totals: 100 },
204
+ });
205
+
206
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
207
+
208
+ const result = await cmd.func(page, { id: '83283292', offset: 40, limit: 20 });
209
+
210
+ expect(result[0].rank).toBe(41); // offset 40 + index 0 + 1
211
+ expect(evaluate).toHaveBeenCalledWith(
212
+ expect.stringContaining('offset=40'),
213
+ );
214
+ });
215
+
216
+ it('rejects invalid offset and limit before navigation', async () => {
217
+ const cmd = getRegistry().get('zhihu/collection');
218
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
219
+
220
+ await expect(cmd.func(page, { id: '83283292', offset: -1, limit: 20 }))
221
+ .rejects.toBeInstanceOf(ArgumentError);
222
+ await expect(cmd.func(page, { id: '83283292', offset: 0, limit: 0 }))
223
+ .rejects.toBeInstanceOf(ArgumentError);
224
+ expect(page.goto).not.toHaveBeenCalled();
225
+ });
226
+
227
+ it('paginates until requested limit and deduplicates items', async () => {
228
+ const cmd = getRegistry().get('zhihu/collection');
229
+ const evaluate = vi.fn()
230
+ .mockResolvedValueOnce({
231
+ data: [
232
+ {
233
+ content: {
234
+ type: 'answer',
235
+ id: 1,
236
+ question: { id: 1, title: 'A' },
237
+ author: { name: 'alice' },
238
+ content: 'A',
239
+ },
240
+ },
241
+ ],
242
+ paging: { totals: 3, is_end: false, next: 'https://www.zhihu.com/api/v4/collections/83283292/items?offset=1&limit=1' },
243
+ })
244
+ .mockResolvedValueOnce({
245
+ data: [
246
+ {
247
+ content: {
248
+ type: 'answer',
249
+ id: 1,
250
+ question: { id: 1, title: 'A duplicate' },
251
+ author: { name: 'alice' },
252
+ content: 'A',
253
+ },
254
+ },
255
+ {
256
+ content: {
257
+ type: 'answer',
258
+ id: 2,
259
+ question: { id: 2, title: 'B' },
260
+ author: { name: 'bob' },
261
+ content: 'B',
262
+ },
263
+ },
264
+ ],
265
+ paging: { totals: 3, is_end: true },
266
+ });
267
+
268
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
269
+
270
+ const result = await cmd.func(page, { id: '83283292', offset: 0, limit: 2 });
271
+
272
+ expect(result.map((row) => row.title)).toEqual(['A', 'B']);
273
+ expect(evaluate).toHaveBeenCalledTimes(2);
274
+ expect(evaluate.mock.calls[1][0]).toContain('offset=1');
275
+ });
276
+
277
+ it('throws EmptyResultError for empty collection', async () => {
278
+ const cmd = getRegistry().get('zhihu/collection');
279
+ const page = {
280
+ goto: vi.fn().mockResolvedValue(undefined),
281
+ evaluate: vi.fn().mockResolvedValue({
282
+ data: [],
283
+ paging: { totals: 0 },
284
+ }),
285
+ };
286
+
287
+ await expect(cmd.func(page, { id: '83283292', offset: 0, limit: 20 }))
288
+ .rejects.toBeInstanceOf(EmptyResultError);
289
+ });
290
+ });