@jackwener/opencli 1.7.7 → 1.7.9

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 (280) hide show
  1. package/README.md +49 -14
  2. package/README.zh-CN.md +30 -10
  3. package/cli-manifest.json +782 -55
  4. package/clis/36kr/news.js +1 -1
  5. package/clis/amazon/discussion.js +37 -6
  6. package/clis/amazon/discussion.test.js +147 -32
  7. package/clis/apple-podcasts/commands.test.js +4 -4
  8. package/clis/apple-podcasts/episodes.js +1 -1
  9. package/clis/apple-podcasts/search.js +1 -1
  10. package/clis/apple-podcasts/top.js +1 -1
  11. package/clis/arxiv/paper.js +1 -1
  12. package/clis/arxiv/search.js +1 -1
  13. package/clis/band/mentions.js +3 -3
  14. package/clis/bbc/news.js +1 -1
  15. package/clis/bilibili/subtitle.js +2 -2
  16. package/clis/bloomberg/businessweek.js +1 -1
  17. package/clis/bloomberg/economics.js +1 -1
  18. package/clis/bloomberg/industries.js +1 -1
  19. package/clis/bloomberg/main.js +1 -1
  20. package/clis/bloomberg/markets.js +1 -1
  21. package/clis/bloomberg/opinions.js +1 -1
  22. package/clis/bloomberg/politics.js +1 -1
  23. package/clis/bloomberg/tech.js +1 -1
  24. package/clis/boss/search.js +49 -8
  25. package/clis/boss/search.test.js +78 -0
  26. package/clis/boss/send.js +3 -3
  27. package/clis/chatgpt/image.js +37 -8
  28. package/clis/chatgpt/image.test.js +92 -0
  29. package/clis/chatgpt/utils.js +39 -6
  30. package/clis/chatgpt/utils.test.js +63 -0
  31. package/clis/chatgpt-app/ask.js +4 -20
  32. package/clis/chatgpt-app/ax.js +135 -2
  33. package/clis/chatgpt-app/ax.test.js +35 -0
  34. package/clis/chatgpt-app/model.js +1 -1
  35. package/clis/chatgpt-app/new.js +1 -1
  36. package/clis/chatgpt-app/read.js +1 -1
  37. package/clis/chatgpt-app/send.js +3 -22
  38. package/clis/chatgpt-app/status.js +1 -1
  39. package/clis/chatwise/ask.js +2 -2
  40. package/clis/chatwise/model.js +2 -2
  41. package/clis/chatwise/send.js +2 -2
  42. package/clis/claude/ask.js +128 -0
  43. package/clis/claude/ask.test.js +338 -0
  44. package/clis/claude/commands.test.js +118 -0
  45. package/clis/claude/detail.js +29 -0
  46. package/clis/claude/history.js +31 -0
  47. package/clis/claude/new.js +21 -0
  48. package/clis/claude/read.js +24 -0
  49. package/clis/claude/send.js +41 -0
  50. package/clis/claude/status.js +24 -0
  51. package/clis/claude/utils.js +440 -0
  52. package/clis/claude/utils.test.js +148 -0
  53. package/clis/codex/ask.js +2 -2
  54. package/clis/codex/send.js +2 -2
  55. package/clis/ctrip/search.js +1 -1
  56. package/clis/ctrip/search.test.js +4 -4
  57. package/clis/cursor/ask.js +2 -2
  58. package/clis/cursor/composer.js +2 -2
  59. package/clis/cursor/send.js +2 -2
  60. package/clis/deepseek/ask.js +49 -10
  61. package/clis/deepseek/ask.test.js +150 -3
  62. package/clis/deepseek/utils.js +60 -22
  63. package/clis/deepseek/utils.test.js +124 -5
  64. package/clis/doubao/utils.js +53 -11
  65. package/clis/doubao/utils.test.js +22 -2
  66. package/clis/eastmoney/announcement.js +1 -1
  67. package/clis/eastmoney/convertible.js +1 -1
  68. package/clis/eastmoney/etf.js +1 -1
  69. package/clis/eastmoney/holders.js +1 -1
  70. package/clis/eastmoney/index-board.js +1 -1
  71. package/clis/eastmoney/kline.js +1 -1
  72. package/clis/eastmoney/kuaixun.js +1 -1
  73. package/clis/eastmoney/longhu.js +1 -1
  74. package/clis/eastmoney/money-flow.js +1 -1
  75. package/clis/eastmoney/northbound.js +1 -1
  76. package/clis/eastmoney/quote.js +1 -1
  77. package/clis/eastmoney/rank.js +1 -1
  78. package/clis/eastmoney/sectors.js +1 -1
  79. package/clis/facebook/marketplace-inbox.js +83 -0
  80. package/clis/facebook/marketplace-listings.js +83 -0
  81. package/clis/facebook/marketplace.test.js +91 -0
  82. package/clis/google/news.js +1 -1
  83. package/clis/google/suggest.js +1 -1
  84. package/clis/google/trends.js +1 -1
  85. package/clis/google-scholar/cite.js +74 -0
  86. package/clis/google-scholar/cite.test.js +47 -0
  87. package/clis/google-scholar/profile.js +92 -0
  88. package/clis/google-scholar/profile.test.js +49 -0
  89. package/clis/google-scholar/search.js +1 -1
  90. package/clis/google-scholar/search.test.js +15 -0
  91. package/clis/hf/top.js +1 -1
  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/powerchina/search.js +250 -0
  115. package/clis/powerchina/search.test.js +67 -0
  116. package/clis/producthunt/posts.js +1 -1
  117. package/clis/producthunt/today.js +1 -1
  118. package/clis/sinablog/search.js +1 -1
  119. package/clis/sinafinance/news.js +1 -1
  120. package/clis/sinafinance/stock.js +6 -3
  121. package/clis/sinafinance/stock.test.js +59 -0
  122. package/clis/spotify/spotify.js +6 -6
  123. package/clis/substack/search.js +1 -1
  124. package/clis/toutiao/articles.js +80 -0
  125. package/clis/toutiao/articles.test.js +30 -0
  126. package/clis/twitter/followers.js +2 -2
  127. package/clis/twitter/following.js +224 -73
  128. package/clis/twitter/following.test.js +277 -0
  129. package/clis/twitter/post.js +184 -47
  130. package/clis/twitter/post.test.js +114 -34
  131. package/clis/uiverse/_shared.js +63 -4
  132. package/clis/uiverse/_shared.test.js +7 -0
  133. package/clis/uiverse/code.js +1 -0
  134. package/clis/uiverse/navigation.test.js +12 -0
  135. package/clis/uiverse/preview.js +1 -0
  136. package/clis/web/read.js +319 -81
  137. package/clis/web/read.test.js +221 -5
  138. package/clis/weibo/favorites.js +169 -0
  139. package/clis/weibo/favorites.test.js +114 -0
  140. package/clis/weibo/publish.js +282 -0
  141. package/clis/weibo/publish.test.js +183 -0
  142. package/clis/weixin/create-draft.js +225 -0
  143. package/clis/weixin/drafts.js +65 -0
  144. package/clis/weixin/drafts.test.js +65 -0
  145. package/clis/weread/ranking.js +1 -1
  146. package/clis/weread/search-regression.test.js +8 -8
  147. package/clis/weread/search.js +1 -1
  148. package/clis/wikipedia/random.js +1 -1
  149. package/clis/wikipedia/search.js +1 -1
  150. package/clis/wikipedia/summary.js +1 -1
  151. package/clis/wikipedia/trending.js +1 -1
  152. package/clis/xianyu/chat.js +3 -3
  153. package/clis/xianyu/item.js +2 -2
  154. package/clis/xianyu/item.test.js +3 -3
  155. package/clis/xiaohongshu/search.js +17 -2
  156. package/clis/xiaohongshu/search.test.js +37 -1
  157. package/clis/xiaoyuzhou/download.js +1 -1
  158. package/clis/xiaoyuzhou/download.test.js +3 -3
  159. package/clis/xiaoyuzhou/episode.js +1 -1
  160. package/clis/xiaoyuzhou/podcast-episodes.js +1 -1
  161. package/clis/xiaoyuzhou/podcast-episodes.test.js +2 -2
  162. package/clis/xiaoyuzhou/podcast.js +1 -1
  163. package/clis/xiaoyuzhou/transcript.js +1 -1
  164. package/clis/xiaoyuzhou/transcript.test.js +5 -5
  165. package/clis/yollomi/models.js +1 -1
  166. package/clis/youtube/channel.js +24 -1
  167. package/clis/youtube/channel.test.js +59 -0
  168. package/clis/zhihu/answer.js +21 -162
  169. package/clis/zhihu/answer.test.js +26 -53
  170. package/clis/zhihu/collection.js +197 -0
  171. package/clis/zhihu/collection.test.js +290 -0
  172. package/clis/zhihu/collections.js +127 -0
  173. package/clis/zhihu/collections.test.js +182 -0
  174. package/clis/zhihu/comment.js +24 -305
  175. package/clis/zhihu/comment.test.js +31 -35
  176. package/clis/zhihu/favorite.js +44 -182
  177. package/clis/zhihu/favorite.test.js +30 -167
  178. package/clis/zhihu/follow.js +25 -56
  179. package/clis/zhihu/follow.test.js +20 -23
  180. package/clis/zhihu/like.js +22 -67
  181. package/clis/zhihu/like.test.js +19 -42
  182. package/clis/zhihu/search.js +3 -2
  183. package/clis/zhihu/write-shared.js +8 -1
  184. package/clis/zhihu/write-shared.test.js +1 -0
  185. package/clis/zlibrary/commands.test.js +75 -0
  186. package/clis/zlibrary/info.js +47 -0
  187. package/clis/zlibrary/search.js +46 -0
  188. package/clis/zlibrary/utils.js +136 -0
  189. package/dist/src/adapter-source.d.ts +11 -0
  190. package/dist/src/adapter-source.js +24 -0
  191. package/dist/src/adapter-source.test.js +29 -0
  192. package/dist/src/browser/base-page.d.ts +3 -1
  193. package/dist/src/browser/base-page.js +76 -1
  194. package/dist/src/browser/base-page.test.d.ts +1 -0
  195. package/dist/src/browser/base-page.test.js +74 -0
  196. package/dist/src/browser/bridge.d.ts +1 -0
  197. package/dist/src/browser/bridge.js +36 -9
  198. package/dist/src/browser/cdp.d.ts +1 -0
  199. package/dist/src/browser/cdp.js +3 -3
  200. package/dist/src/browser/daemon-client.d.ts +38 -4
  201. package/dist/src/browser/daemon-client.js +24 -7
  202. package/dist/src/browser/daemon-client.test.js +49 -0
  203. package/dist/src/browser/errors.js +3 -0
  204. package/dist/src/browser/errors.test.js +3 -0
  205. package/dist/src/browser/network-cache.d.ts +1 -0
  206. package/dist/src/browser/page.d.ts +3 -1
  207. package/dist/src/browser/page.js +10 -2
  208. package/dist/src/browser/profile.d.ts +14 -0
  209. package/dist/src/browser/profile.js +85 -0
  210. package/dist/src/build-manifest.d.ts +2 -0
  211. package/dist/src/build-manifest.js +13 -3
  212. package/dist/src/build-manifest.test.js +20 -2
  213. package/dist/src/cli.d.ts +6 -0
  214. package/dist/src/cli.js +462 -32
  215. package/dist/src/cli.test.js +209 -2
  216. package/dist/src/commanderAdapter.js +29 -9
  217. package/dist/src/commanderAdapter.test.js +78 -2
  218. package/dist/src/commands/daemon.js +6 -0
  219. package/dist/src/completion-shared.js +1 -2
  220. package/dist/src/completion.test.js +3 -2
  221. package/dist/src/daemon.js +125 -41
  222. package/dist/src/doctor.d.ts +4 -6
  223. package/dist/src/doctor.js +80 -22
  224. package/dist/src/doctor.test.js +82 -0
  225. package/dist/src/engine.test.js +6 -5
  226. package/dist/src/errors.d.ts +14 -8
  227. package/dist/src/errors.js +36 -30
  228. package/dist/src/errors.test.js +5 -5
  229. package/dist/src/execution.d.ts +4 -0
  230. package/dist/src/execution.js +173 -25
  231. package/dist/src/execution.test.js +171 -1
  232. package/dist/src/main.js +10 -0
  233. package/dist/src/observation/artifact.d.ts +16 -0
  234. package/dist/src/observation/artifact.js +260 -0
  235. package/dist/src/observation/artifact.test.d.ts +1 -0
  236. package/dist/src/observation/artifact.test.js +121 -0
  237. package/dist/src/observation/events.d.ts +89 -0
  238. package/dist/src/observation/events.js +1 -0
  239. package/dist/src/observation/index.d.ts +7 -0
  240. package/dist/src/observation/index.js +7 -0
  241. package/dist/src/observation/manager.d.ts +9 -0
  242. package/dist/src/observation/manager.js +27 -0
  243. package/dist/src/observation/manager.test.d.ts +1 -0
  244. package/dist/src/observation/manager.test.js +13 -0
  245. package/dist/src/observation/redaction.d.ts +11 -0
  246. package/dist/src/observation/redaction.js +81 -0
  247. package/dist/src/observation/redaction.test.d.ts +1 -0
  248. package/dist/src/observation/redaction.test.js +32 -0
  249. package/dist/src/observation/retention.d.ts +32 -0
  250. package/dist/src/observation/retention.js +160 -0
  251. package/dist/src/observation/retention.test.d.ts +1 -0
  252. package/dist/src/observation/retention.test.js +118 -0
  253. package/dist/src/observation/ring-buffer.d.ts +22 -0
  254. package/dist/src/observation/ring-buffer.js +45 -0
  255. package/dist/src/observation/ring-buffer.test.d.ts +1 -0
  256. package/dist/src/observation/ring-buffer.test.js +22 -0
  257. package/dist/src/observation/session.d.ts +25 -0
  258. package/dist/src/observation/session.js +50 -0
  259. package/dist/src/pipeline/executor.test.js +1 -0
  260. package/dist/src/pipeline/steps/download.test.js +1 -0
  261. package/dist/src/pipeline/steps/fetch.js +1 -21
  262. package/dist/src/pipeline/steps/fetch.test.js +6 -12
  263. package/dist/src/plugin-scaffold.js +1 -1
  264. package/dist/src/plugin-scaffold.test.js +1 -1
  265. package/dist/src/registry.d.ts +40 -9
  266. package/dist/src/registry.js +3 -1
  267. package/dist/src/runtime-detect.d.ts +10 -0
  268. package/dist/src/runtime-detect.js +19 -0
  269. package/dist/src/runtime-detect.test.js +12 -1
  270. package/dist/src/runtime.d.ts +2 -0
  271. package/dist/src/runtime.js +1 -0
  272. package/dist/src/types.d.ts +22 -0
  273. package/dist/src/update-check.d.ts +31 -1
  274. package/dist/src/update-check.js +62 -16
  275. package/dist/src/update-check.test.js +86 -1
  276. package/package.json +1 -1
  277. package/dist/src/diagnostic.d.ts +0 -63
  278. package/dist/src/diagnostic.js +0 -292
  279. package/dist/src/diagnostic.test.js +0 -302
  280. /package/dist/src/{diagnostic.test.d.ts → adapter-source.test.d.ts} +0 -0
@@ -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
+ });
@@ -0,0 +1,127 @@
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 validatePositiveInt(value, name) {
6
+ const n = Number(value);
7
+ if (!Number.isInteger(n) || n <= 0) {
8
+ throw new ArgumentError(`zhihu collections --${name} must be a positive integer`, 'Example: opencli zhihu collections --limit 20');
9
+ }
10
+ return n;
11
+ }
12
+
13
+ async function fetchJson(page, url, errorLabel) {
14
+ const data = await page.evaluate(`
15
+ (async () => {
16
+ const r = await fetch(${JSON.stringify(url)}, { credentials: 'include' });
17
+ if (!r.ok) return { __httpError: r.status };
18
+ return await r.json();
19
+ })()
20
+ `);
21
+
22
+ if (!data || data.__httpError) {
23
+ const status = data?.__httpError;
24
+ if (status === 401 || status === 403) {
25
+ throw new AuthRequiredError('www.zhihu.com', `${errorLabel} from Zhihu failed. Please ensure you are logged in.`);
26
+ }
27
+ throw new CommandExecutionError(
28
+ status ? `${errorLabel} from Zhihu failed (HTTP ${status})` : `${errorLabel} from Zhihu failed`,
29
+ 'Try again later or rerun with -v for more detail',
30
+ );
31
+ }
32
+ return data;
33
+ }
34
+
35
+ function collectionKey(item) {
36
+ return String(item?.id || item?.url || item?.title || '');
37
+ }
38
+
39
+ cli({
40
+ site: 'zhihu',
41
+ name: 'collections',
42
+ description: '知乎收藏夹列表(需要登录)',
43
+ domain: 'www.zhihu.com',
44
+ strategy: Strategy.COOKIE,
45
+ browser: true,
46
+ args: [
47
+ { name: 'limit', type: 'int', default: 20, help: '每页数量(最大 20)' },
48
+ ],
49
+ columns: ['rank', 'title', 'item_count', 'description', 'collection_id'],
50
+ func: async (page, kwargs) => {
51
+ const { limit = 20 } = kwargs;
52
+ const requestedLimit = validatePositiveInt(limit, 'limit');
53
+
54
+ // 先访问知乎主页建立 session
55
+ await page.goto('https://www.zhihu.com');
56
+ // 获取当前用户的 url_token
57
+ const meData = await fetchJson(page, 'https://www.zhihu.com/api/v4/me?include=url_token', 'Zhihu user info request');
58
+
59
+ const urlToken = meData.url_token;
60
+ if (!urlToken) {
61
+ throw new CommandExecutionError('Failed to get user url_token from Zhihu', 'Please ensure you are logged in.');
62
+ }
63
+
64
+ const collected = [];
65
+ const seen = new Set();
66
+ let totals = 0;
67
+ let offset = 0;
68
+ const pageLimit = Math.min(requestedLimit, 20);
69
+ const maxPages = Math.ceil(requestedLimit / pageLimit) + 2;
70
+
71
+ for (let pageIndex = 0; pageIndex < maxPages && collected.length < requestedLimit; pageIndex += 1) {
72
+ const currentFetchLimit = Math.min(pageLimit, requestedLimit - collected.length);
73
+ const url = `https://www.zhihu.com/api/v4/people/${urlToken}/collections?include=data%5B*%5D.updated_time&offset=${offset}&limit=${currentFetchLimit}`;
74
+ const data = await fetchJson(page, url, 'Zhihu favorite collections request');
75
+ const items = Array.isArray(data.data) ? data.data : [];
76
+ const paging = data.paging || {};
77
+ totals = Number(paging.totals || totals || 0);
78
+
79
+ for (const item of items) {
80
+ const key = collectionKey(item);
81
+ if (key && !seen.has(key)) {
82
+ seen.add(key);
83
+ collected.push(item);
84
+ }
85
+ if (collected.length >= requestedLimit) break;
86
+ }
87
+
88
+ if (items.length === 0 || paging.is_end || collected.length >= requestedLimit) break;
89
+ if (typeof paging.next === 'string') {
90
+ try {
91
+ const nextUrl = new URL(paging.next);
92
+ const parsedOffset = Number(nextUrl.searchParams.get('offset'));
93
+ if (Number.isInteger(parsedOffset) && parsedOffset > offset) {
94
+ offset = parsedOffset;
95
+ continue;
96
+ }
97
+ } catch {}
98
+ }
99
+ if (items.length < currentFetchLimit) break;
100
+ const fallbackOffset = offset + items.length;
101
+ if (fallbackOffset <= offset) break;
102
+ offset = fallbackOffset;
103
+ if (totals && offset >= totals) break;
104
+ }
105
+
106
+ if (totals > 0) {
107
+ log.info(`共有 ${totals} 个收藏夹`);
108
+ }
109
+
110
+ if (collected.length === 0) {
111
+ throw new EmptyResultError('zhihu collections', 'No favorite collections were returned for the logged-in user.');
112
+ }
113
+
114
+ return collected.slice(0, requestedLimit).map((item, i) => ({
115
+ rank: i + 1,
116
+ title: item.title || '未命名',
117
+ item_count: item.item_count ?? item.answer_count ?? 0,
118
+ description: item.description || '',
119
+ collection_id: String(item.id || ''),
120
+ }));
121
+ },
122
+ });
123
+
124
+ export const __test__ = {
125
+ validatePositiveInt,
126
+ collectionKey,
127
+ };
@@ -0,0 +1,182 @@
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 './collections.js';
21
+
22
+ describe('zhihu collections', () => {
23
+ it('returns list of collections', async () => {
24
+ const cmd = getRegistry().get('zhihu/collections');
25
+ expect(cmd?.func).toBeTypeOf('function');
26
+
27
+ const goto = vi.fn().mockResolvedValue(undefined);
28
+ let callCount = 0;
29
+ const evaluate = vi.fn().mockImplementation(async (js) => {
30
+ callCount++;
31
+ if (callCount === 1) {
32
+ expect(js).toContain('api/v4/me');
33
+ return { url_token: 'testuser', id: 'abc123' };
34
+ }
35
+ expect(js).toContain('people/testuser/collections');
36
+ return {
37
+ data: [
38
+ { id: 123456, title: '我的收藏夹', item_count: 42, description: '待读' },
39
+ { id: 789012, title: '技术文章', item_count: 100, description: '' },
40
+ ],
41
+ paging: { totals: 2 },
42
+ };
43
+ });
44
+
45
+ const page = { goto, evaluate };
46
+ const result = await cmd.func(page, { limit: 20 });
47
+
48
+ expect(result).toHaveLength(2);
49
+ expect(result[0]).toMatchObject({
50
+ rank: 1,
51
+ title: '我的收藏夹',
52
+ item_count: 42,
53
+ description: '待读',
54
+ collection_id: '123456',
55
+ });
56
+ expect(result[1]).toMatchObject({
57
+ rank: 2,
58
+ title: '技术文章',
59
+ item_count: 100,
60
+ description: '',
61
+ collection_id: '789012',
62
+ });
63
+ expect(evaluate).toHaveBeenCalledTimes(2);
64
+ });
65
+
66
+ it('returns list of collections with answer_count fallback', async () => {
67
+ const cmd = getRegistry().get('zhihu/collections');
68
+ let callCount = 0;
69
+ const evaluate = vi.fn().mockImplementation(async () => {
70
+ callCount++;
71
+ if (callCount === 1) {
72
+ return { url_token: 'testuser', id: 'abc123' };
73
+ }
74
+ return {
75
+ data: [{ id: 111, title: '默认收藏夹', answer_count: 15, description: 'test desc' }],
76
+ paging: { totals: 1 },
77
+ };
78
+ });
79
+
80
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
81
+ const result = await cmd.func(page, { limit: 20 });
82
+
83
+ expect(result).toHaveLength(1);
84
+ expect(result[0]).toMatchObject({
85
+ title: '默认收藏夹',
86
+ item_count: 15,
87
+ description: 'test desc',
88
+ collection_id: '111',
89
+ });
90
+ });
91
+
92
+ it('maps auth failures to AuthRequiredError', async () => {
93
+ const cmd = getRegistry().get('zhihu/collections');
94
+ const page = {
95
+ goto: vi.fn().mockResolvedValue(undefined),
96
+ evaluate: vi.fn().mockResolvedValue({ __httpError: 401 }),
97
+ };
98
+
99
+ await expect(cmd.func(page, { limit: 20 }))
100
+ .rejects.toBeInstanceOf(AuthRequiredError);
101
+ });
102
+
103
+ it('handles missing url_token', async () => {
104
+ const cmd = getRegistry().get('zhihu/collections');
105
+ let callCount = 0;
106
+ const page = {
107
+ goto: vi.fn().mockResolvedValue(undefined),
108
+ evaluate: vi.fn().mockImplementation(async () => {
109
+ callCount++;
110
+ if (callCount === 1) return { id: 'abc123' };
111
+ return {};
112
+ }),
113
+ };
114
+
115
+ await expect(cmd.func(page, { limit: 20 }))
116
+ .rejects.toBeInstanceOf(CommandExecutionError);
117
+ });
118
+
119
+ it('respects limit parameter', async () => {
120
+ const cmd = getRegistry().get('zhihu/collections');
121
+ let callCount = 0;
122
+ const evaluate = vi.fn().mockImplementation(async (js) => {
123
+ callCount++;
124
+ if (callCount === 1) {
125
+ return { url_token: 'testuser', id: 'abc123' };
126
+ }
127
+ expect(js).toContain('limit=10');
128
+ return {
129
+ data: [{ id: 1, title: 'Test', answer_count: 0, description: '' }],
130
+ paging: { totals: 1 },
131
+ };
132
+ });
133
+
134
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
135
+ const result = await cmd.func(page, { limit: 10 });
136
+
137
+ expect(result).toHaveLength(1);
138
+ expect(evaluate).toHaveBeenCalledTimes(2);
139
+ });
140
+
141
+ it('rejects invalid limits before navigation', async () => {
142
+ const cmd = getRegistry().get('zhihu/collections');
143
+ const page = { goto: vi.fn(), evaluate: vi.fn() };
144
+
145
+ await expect(cmd.func(page, { limit: 0 })).rejects.toBeInstanceOf(ArgumentError);
146
+ expect(page.goto).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it('paginates collection list and deduplicates by collection id', async () => {
150
+ const cmd = getRegistry().get('zhihu/collections');
151
+ const evaluate = vi.fn()
152
+ .mockResolvedValueOnce({ url_token: 'testuser', id: 'abc123' })
153
+ .mockResolvedValueOnce({
154
+ data: [{ id: 1, title: 'A', item_count: 1, description: '' }],
155
+ paging: { totals: 3, is_end: false, next: 'https://www.zhihu.com/api/v4/people/testuser/collections?offset=1&limit=1' },
156
+ })
157
+ .mockResolvedValueOnce({
158
+ data: [
159
+ { id: 1, title: 'A duplicate', item_count: 1, description: '' },
160
+ { id: 2, title: 'B', item_count: 2, description: '' },
161
+ ],
162
+ paging: { totals: 3, is_end: true },
163
+ });
164
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
165
+
166
+ const result = await cmd.func(page, { limit: 2 });
167
+
168
+ expect(result.map((row) => row.title)).toEqual(['A', 'B']);
169
+ expect(evaluate).toHaveBeenCalledTimes(3);
170
+ expect(evaluate.mock.calls[2][0]).toContain('offset=1');
171
+ });
172
+
173
+ it('throws EmptyResultError when no collections are returned', async () => {
174
+ const cmd = getRegistry().get('zhihu/collections');
175
+ const evaluate = vi.fn()
176
+ .mockResolvedValueOnce({ url_token: 'testuser', id: 'abc123' })
177
+ .mockResolvedValueOnce({ data: [], paging: { totals: 0, is_end: true } });
178
+ const page = { goto: vi.fn().mockResolvedValue(undefined), evaluate };
179
+
180
+ await expect(cmd.func(page, { limit: 20 })).rejects.toBeInstanceOf(EmptyResultError);
181
+ });
182
+ });