@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,53 +2,49 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './comment.js';
4
4
  describe('zhihu comment', () => {
5
- it('rejects composer paths that are not proven side-effect free', async () => {
5
+ it('registers as a cookie browser command', () => {
6
+ const cmd = getRegistry().get('zhihu/comment');
7
+ expect(cmd).toBeDefined();
8
+ expect(cmd.strategy).toBe('cookie');
9
+ expect(cmd.browser).toBe(true);
10
+ });
11
+ it('creates a comment via API and returns result', async () => {
6
12
  const cmd = getRegistry().get('zhihu/comment');
7
- expect(cmd?.func).toBeTypeOf('function');
8
13
  const page = {
9
14
  goto: vi.fn().mockResolvedValue(undefined),
15
+ wait: vi.fn().mockResolvedValue(undefined),
10
16
  evaluate: vi.fn()
11
17
  .mockResolvedValueOnce({ slug: 'alice' })
12
- .mockResolvedValueOnce({ entryPathSafe: false })
13
- .mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] }),
18
+ .mockResolvedValueOnce({ ok: true, id: 99, url: 'https://www.zhihu.com/api/v4/comments/99' }),
14
19
  };
15
- await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
20
+ const rows = await cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true });
21
+ expect(rows).toEqual([
22
+ expect.objectContaining({ outcome: 'created', author_identity: 'alice' }),
23
+ ]);
16
24
  });
17
- it('requires exact editor replacement before accepting fallback proof', async () => {
25
+ it('throws on API error', async () => {
18
26
  const cmd = getRegistry().get('zhihu/comment');
19
27
  const page = {
20
28
  goto: vi.fn().mockResolvedValue(undefined),
29
+ wait: vi.fn().mockResolvedValue(undefined),
21
30
  evaluate: vi.fn()
22
31
  .mockResolvedValueOnce({ slug: 'alice' })
23
- .mockResolvedValueOnce({ entryPathSafe: true })
24
- .mockResolvedValueOnce({ wrongAnswer: false, rows: [], commentLinks: [] })
25
- .mockResolvedValueOnce({ composerState: 'fresh_top_level' })
26
- .mockResolvedValueOnce({ editorContent: 'hello', mode: 'top_level' })
27
- .mockResolvedValueOnce({
28
- proofType: 'fallback',
29
- createdProof: {
30
- proof_type: 'comment_fallback',
31
- author_scope: 'current_user',
32
- target_scope: 'requested_target',
33
- comment_scope: 'top_level_only',
34
- content_match: 'exact_normalized',
35
- observed_after_submit: true,
36
- present_in_pre_submit_snapshot: false,
37
- new_matching_entries: 1,
38
- post_submit_matching_entries: 1,
39
- snapshot_scope: 'stabilized_expanded_target_comment_list',
40
- },
41
- }),
32
+ .mockResolvedValueOnce({ ok: false, status: 403, message: 'forbidden' }),
42
33
  };
43
- await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true })).resolves.toEqual([
44
- expect.objectContaining({ outcome: 'created', author_identity: 'alice', created_proof: expect.any(Object) }),
45
- ]);
46
- expect(page.evaluate.mock.calls[1][0]).toContain('topLevelCandidates.length === 1');
47
- expect(page.evaluate.mock.calls[1][0]).not.toContain('commentTrigger');
48
- expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-answerid')");
49
- expect(page.evaluate.mock.calls[2][0]).toContain("node.getAttribute('data-zop-question-answer')");
50
- expect(page.evaluate.mock.calls[5][0]).toContain('const readCommentAuthorSlug = (node) =>');
51
- expect(page.evaluate.mock.calls[5][0]).toContain('const commentAuthorScopeSelector = ".CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop=\\"author\\"]"');
52
- expect(page.evaluate.mock.calls[5][0]).not.toContain("card?.querySelector('a[href^=\"/people/\"]')");
34
+ await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true }))
35
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
36
+ });
37
+ it('requires the comment API response to include the created id', async () => {
38
+ const cmd = getRegistry().get('zhihu/comment');
39
+ const page = {
40
+ goto: vi.fn().mockResolvedValue(undefined),
41
+ wait: vi.fn().mockResolvedValue(undefined),
42
+ evaluate: vi.fn()
43
+ .mockResolvedValueOnce({ slug: 'alice' })
44
+ .mockResolvedValueOnce({ ok: false, status: 200, message: 'Comment API response did not include a created comment id' }),
45
+ };
46
+ await expect(cmd.func(page, { target: 'answer:1:2', text: 'hello', execute: true }))
47
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
48
+ expect(page.evaluate.mock.calls[1][0]).toContain('Comment API response did not include a created comment id');
53
49
  });
54
50
  });
@@ -2,9 +2,6 @@ import { CliError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { assertAllowedKinds, parseTarget } from './target.js';
4
4
  import { buildResultRow, requireExecute } from './write-shared.js';
5
- function rowKey(row) {
6
- return row.id || `name:${normalizeCollectionName(row.name)}`;
7
- }
8
5
  function normalizeCollectionName(value) {
9
6
  return value
10
7
  .replace(/\s+/g, ' ')
@@ -17,7 +14,7 @@ cli({
17
14
  name: 'favorite',
18
15
  description: 'Favorite a Zhihu answer or article into a specific collection',
19
16
  domain: 'zhihu.com',
20
- strategy: Strategy.UI,
17
+ strategy: Strategy.COOKIE,
21
18
  browser: true,
22
19
  args: [
23
20
  { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
@@ -37,188 +34,53 @@ cli({
37
34
  if ((collectionName ? 1 : 0) + (collectionId ? 1 : 0) !== 1) {
38
35
  throw new CliError('INVALID_INPUT', 'Use exactly one of --collection or --collection-id');
39
36
  }
40
- await page.goto(target.url);
41
- const preflight = await page.evaluate(`(async () => {
42
- const targetKind = ${JSON.stringify(target.kind)};
43
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
44
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
45
- const wantedName = ${JSON.stringify(collectionName ?? null)};
46
- const wantedId = ${JSON.stringify(collectionId ?? null)};
47
-
48
- let scope = document;
49
- if (targetKind === 'answer') {
50
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
51
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
52
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
53
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
54
- const href = link.getAttribute('href') || '';
55
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
56
- });
57
- });
58
- if (!block) return { wrongAnswer: true, chooserRows: [] };
59
- scope = block;
60
- } else {
61
- scope =
62
- document.querySelector('article')
63
- || document.querySelector('.Post-Main')
64
- || document.querySelector('[itemprop="articleBody"]')
65
- || document;
66
- }
67
-
68
- const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
69
- if (!favoriteButton) return { wrongAnswer: false, missingChooser: true, chooserRows: [] };
70
- favoriteButton.click();
71
- await new Promise((resolve) => setTimeout(resolve, 600));
72
-
73
- const chooserRows = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button'))
74
- .map((node) => {
75
- const text = (node.textContent || '').trim();
76
- const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
77
- const selected = node.getAttribute('aria-checked') === 'true'
78
- || node.getAttribute('aria-pressed') === 'true'
79
- || /已选|已收藏/.test(text);
80
- return text ? { id, name: text, selected } : null;
81
- })
82
- .filter(Boolean);
37
+ await page.goto('https://www.zhihu.com');
38
+ await page.wait(2);
39
+ const apiResult = await page.evaluate(`(async () => {
40
+ var collectionId = ${JSON.stringify(collectionId || null)};
41
+ var collectionName = ${JSON.stringify(collectionName || null)};
42
+ var targetKind = ${JSON.stringify(target.kind)};
43
+ var targetId = ${JSON.stringify(target.id)};
44
+ var normalizeCollectionName = function(value) {
45
+ return String(value || '')
46
+ .replace(/\\s+/g, ' ')
47
+ .replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '')
48
+ .replace(/\\s+(公开|私密|默认)$/i, '')
49
+ .trim()
50
+ .toLowerCase();
51
+ };
83
52
 
84
- return {
85
- wrongAnswer: false,
86
- missingChooser: chooserRows.length === 0,
87
- chooserRows,
88
- targetRowId: wantedId,
89
- targetRowName: wantedName,
90
- };
91
- })()`);
92
- if (preflight.wrongAnswer) {
93
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
94
- }
95
- if (preflight.missingChooser) {
96
- throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser did not open on the requested target');
97
- }
98
- const matchingRows = preflight.chooserRows.filter((row) => (collectionId
99
- ? row.id === collectionId
100
- : normalizeCollectionName(row.name) === normalizeCollectionName(collectionName || '')));
101
- if (collectionId && !matchingRows.some((row) => row.id === collectionId)) {
102
- throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not confirm the requested stable collection id');
103
- }
104
- if (!collectionId && matchingRows.length !== 1) {
105
- throw new CliError('ACTION_NOT_AVAILABLE', 'Favorite chooser could not prove that the requested collection name is globally unique');
106
- }
107
- const targetRow = matchingRows[0];
108
- const targetRowKey = rowKey(targetRow);
109
- const selectedBefore = preflight.chooserRows.filter((row) => row.selected).map(rowKey);
110
- const verify = await page.evaluate(`(async () => {
111
- const targetKind = ${JSON.stringify(target.kind)};
112
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
113
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
114
- const targetWasSelected = ${JSON.stringify(targetRow.selected)};
115
- const wantedName = ${JSON.stringify(collectionName ?? null)};
116
- const wantedId = ${JSON.stringify(collectionId ?? null)};
117
- const normalizeCollectionName = (value) => String(value || '')
118
- .replace(/\\s+/g, ' ')
119
- .replace(/\\s+\\d+\\s*(条内容|个内容|items?)$/i, '')
120
- .replace(/\\s+(公开|私密|默认)$/i, '')
121
- .trim();
122
- const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);
53
+ if (!collectionId && collectionName) {
54
+ var listResp = await fetch('https://www.zhihu.com/api/v4/people/self/collections?limit=50', { credentials: 'include' });
55
+ if (!listResp.ok) return { ok: false, message: 'Failed to list collections: HTTP ' + listResp.status };
56
+ var listData = {};
57
+ try { listData = await listResp.json(); } catch(e) {
58
+ return { ok: false, message: 'Failed to parse collection list' };
59
+ }
60
+ var needle = normalizeCollectionName(collectionName);
61
+ var matches = (listData.data || []).filter(function(c) { return normalizeCollectionName(c.title) === needle; });
62
+ if (matches.length === 0) return { ok: false, message: 'Collection not found: ' + collectionName };
63
+ if (matches.length > 1) return { ok: false, message: 'Collection name is ambiguous: ' + collectionName };
64
+ collectionId = String(matches[0].id);
65
+ }
123
66
 
124
- const chooserSelector = '[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button';
125
- const readChooserRows = () => Array.from(document.querySelectorAll(chooserSelector))
126
- .map((node) => {
127
- const text = (node.textContent || '').trim();
128
- const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
129
- const selected = node.getAttribute('aria-checked') === 'true'
130
- || node.getAttribute('aria-pressed') === 'true'
131
- || /已选|已收藏/.test(text);
132
- return text ? { id, name: text, selected } : null;
133
- })
134
- .filter(Boolean);
135
- const waitForChooserRows = async (expectedPresent) => {
136
- for (let attempt = 0; attempt < 10; attempt += 1) {
137
- const rows = readChooserRows();
138
- if (expectedPresent ? rows.length > 0 : rows.length === 0) return rows;
139
- await new Promise((resolve) => setTimeout(resolve, 150));
140
- }
141
- return readChooserRows();
142
- };
143
- const closeChooser = async () => {
144
- const closeButton = Array.from(document.querySelectorAll('[role="dialog"] button, [role="dialog"] [role="button"]')).find((node) => {
145
- const text = (node.textContent || '').trim();
146
- const aria = node.getAttribute('aria-label') || '';
147
- return /关闭|取消|收起/.test(text) || /关闭|cancel|close/i.test(aria);
148
- });
149
- closeButton && closeButton.click();
150
- return waitForChooserRows(false);
151
- };
152
- const reopenChooser = async () => {
153
- let scope = document;
154
- if (targetKind === 'answer') {
155
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
156
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
157
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
158
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
159
- const href = link.getAttribute('href') || '';
160
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
67
+ var resp = await fetch('https://www.zhihu.com/api/v4/favlists/' + collectionId + '/items', {
68
+ method: 'POST',
69
+ credentials: 'include',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ item_id: targetId, item_type: targetKind }),
161
72
  });
162
- });
163
- if (!block) return [];
164
- scope = block;
165
- } else {
166
- scope =
167
- document.querySelector('article')
168
- || document.querySelector('.Post-Main')
169
- || document.querySelector('[itemprop="articleBody"]')
170
- || document;
171
- }
172
- const favoriteButton = Array.from(scope.querySelectorAll('button')).find((node) => /收藏/.test(node.textContent || ''));
173
- favoriteButton && favoriteButton.click();
174
- return waitForChooserRows(true);
175
- };
176
-
177
- let chooserRows = readChooserRows();
178
- let sawChooserClose = false;
179
- if (!targetWasSelected) {
180
- const row = Array.from(document.querySelectorAll('[role="dialog"] li, [role="dialog"] [role="checkbox"], [role="dialog"] button')).find((node) => {
181
- const text = (node.textContent || '').trim();
182
- const id = node.getAttribute('data-id') || node.getAttribute('data-collection-id') || '';
183
- return wantedId ? id === wantedId : normalizeCollectionName(text) === normalizeCollectionName(wantedName);
184
- });
185
- row && row.click();
186
- await new Promise((resolve) => setTimeout(resolve, 300));
187
- const submit = Array.from(document.querySelectorAll('[role="dialog"] button')).find((node) => /完成|确定|保存/.test(node.textContent || ''));
188
- submit && submit.click();
189
- chooserRows = await waitForChooserRows(false);
190
- sawChooserClose = chooserRows.length === 0;
191
- } else {
192
- chooserRows = await closeChooser();
193
- sawChooserClose = chooserRows.length === 0;
194
- }
195
- if (sawChooserClose) {
196
- chooserRows = await reopenChooser();
197
- }
198
-
199
- return {
200
- persisted: sawChooserClose && chooserRows.length > 0,
201
- readbackSource: sawChooserClose && chooserRows.length > 0 ? 'reopened_chooser' : (chooserRows.length > 0 ? 'same_modal' : 'missing'),
202
- selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey),
203
- targetSelected: chooserRows.some((row) => rowKey(row) === ${JSON.stringify(targetRowKey)} && row.selected),
204
- };
205
- })()`);
206
- if (!verify.persisted) {
207
- throw new CliError('OUTCOME_UNKNOWN', 'Favorite action may have been applied, but persisted read-back was unavailable');
208
- }
209
- if (verify.readbackSource !== 'reopened_chooser') {
210
- throw new CliError('OUTCOME_UNKNOWN', 'Favorite state was not re-read from a reopened chooser after submit');
211
- }
212
- if (!verify.targetSelected) {
213
- throw new CliError('OUTCOME_UNKNOWN', 'Favorite chooser remained readable, but the requested collection was not confirmed as selected');
214
- }
215
- if (!selectedBefore.every((row) => verify.selectedAfter.includes(row))) {
216
- throw new CliError('OUTCOME_UNKNOWN', `Favorite action changed unrelated collection membership: before=${JSON.stringify(selectedBefore)} after=${JSON.stringify(verify.selectedAfter)}`);
73
+ if (resp.ok || resp.status === 204) return { ok: true, collectionId: collectionId };
74
+ var data = {};
75
+ try { data = await resp.json(); } catch(e) {}
76
+ return { ok: false, message: data.error ? data.error.message : 'HTTP ' + resp.status };
77
+ })()`);
78
+ if (!apiResult?.ok) {
79
+ throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to favorite');
217
80
  }
218
- const outcome = targetRow.selected ? 'already_applied' : 'applied';
219
- return buildResultRow(targetRow.selected ? `Already favorited ${target.kind}` : `Favorited ${target.kind}`, target.kind, rawTarget, outcome, {
220
- collection_name: collectionName ?? targetRow.name,
221
- ...(targetRow.id ? { collection_id: targetRow.id } : {}),
81
+ return buildResultRow(`Favorited ${target.kind} ${target.id}`, target.kind, rawTarget, 'applied', {
82
+ collection_name: collectionName || '',
83
+ collection_id: apiResult.collectionId || collectionId || '',
222
84
  });
223
85
  },
224
86
  });
@@ -2,195 +2,58 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './favorite.js';
4
4
  describe('zhihu favorite', () => {
5
- it('rejects missing collection selectors before opening the chooser', async () => {
5
+ it('registers as a cookie browser command', () => {
6
6
  const cmd = getRegistry().get('zhihu/favorite');
7
- expect(cmd?.func).toBeTypeOf('function');
8
- const page = { goto: vi.fn(), evaluate: vi.fn() };
9
- await expect(cmd.func(page, { target: 'article:1', execute: true })).rejects.toMatchObject({
10
- code: 'INVALID_INPUT',
11
- });
12
- expect(page.goto).not.toHaveBeenCalled();
13
- expect(page.evaluate).not.toHaveBeenCalled();
7
+ expect(cmd).toBeDefined();
8
+ expect(cmd.strategy).toBe('cookie');
14
9
  });
15
- it('requires persisted read-back and preserves previously selected collections', async () => {
10
+ it('favorites via API with collection-id', async () => {
16
11
  const cmd = getRegistry().get('zhihu/favorite');
17
12
  const page = {
18
13
  goto: vi.fn().mockResolvedValue(undefined),
19
- evaluate: vi.fn()
20
- .mockResolvedValueOnce({
21
- chooserRows: [
22
- { id: 'fav-a', name: '已存在', selected: true },
23
- { id: 'fav-b', name: '默认收藏夹', selected: false },
24
- ],
25
- targetRowId: 'fav-b',
26
- targetRowName: '默认收藏夹',
27
- })
28
- .mockResolvedValueOnce({
29
- persisted: true,
30
- readbackSource: 'reopened_chooser',
31
- selectedBefore: ['fav-a'],
32
- selectedAfter: ['fav-a', 'fav-b'],
33
- targetSelected: true,
34
- }),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: true, collectionId: '123' }),
35
16
  };
36
- await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
37
- expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹', target: 'article:1' }),
38
- ]);
39
- expect(page.evaluate.mock.calls[1][0]).toContain('waitForChooserRows(false)');
40
- expect(page.evaluate.mock.calls[1][0]).toContain("readbackSource");
17
+ const rows = await cmd.func(page, { target: 'answer:1:2', 'collection-id': '123', execute: true });
18
+ expect(rows).toEqual([expect.objectContaining({ outcome: 'applied', collection_id: '123' })]);
41
19
  });
42
- it('requires persisted read-back before returning already_applied', async () => {
20
+ it('throws on API error', async () => {
43
21
  const cmd = getRegistry().get('zhihu/favorite');
44
22
  const page = {
45
23
  goto: vi.fn().mockResolvedValue(undefined),
46
- evaluate: vi.fn()
47
- .mockResolvedValueOnce({
48
- chooserRows: [{ id: 'fav-a', name: '默认收藏夹', selected: true }],
49
- targetRowId: 'fav-a',
50
- targetRowName: '默认收藏夹',
51
- })
52
- .mockResolvedValueOnce({
53
- persisted: true,
54
- readbackSource: 'reopened_chooser',
55
- selectedAfter: ['fav-a'],
56
- targetSelected: true,
57
- }),
24
+ wait: vi.fn().mockResolvedValue(undefined),
25
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'collection not found' }),
58
26
  };
59
- await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
60
- expect.objectContaining({ outcome: 'already_applied', collection_name: '默认收藏夹' }),
61
- ]);
27
+ await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': '123', execute: true }))
28
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
62
29
  });
63
- it('accepts --collection-id as the stable selector path', async () => {
30
+ it('requires exact normalized collection-name matches', async () => {
64
31
  const cmd = getRegistry().get('zhihu/favorite');
65
32
  const page = {
66
33
  goto: vi.fn().mockResolvedValue(undefined),
67
- evaluate: vi.fn()
68
- .mockResolvedValueOnce({
69
- chooserRows: [
70
- { id: 'fav-a', name: '默认收藏夹', selected: false },
71
- { id: 'fav-b', name: '同名收藏夹', selected: false },
72
- ],
73
- targetRowId: 'fav-b',
74
- targetRowName: null,
75
- })
76
- .mockResolvedValueOnce({
77
- persisted: true,
78
- readbackSource: 'reopened_chooser',
79
- selectedAfter: ['fav-b'],
80
- targetSelected: true,
81
- }),
34
+ wait: vi.fn().mockResolvedValue(undefined),
35
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Collection not found: AI' }),
82
36
  };
83
- await expect(cmd.func(page, { target: 'article:1', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([
84
- expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b' }),
85
- ]);
37
+ await expect(cmd.func(page, { target: 'answer:1:2', collection: 'AI', execute: true }))
38
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
39
+ expect(page.evaluate.mock.calls[0][0]).toContain('normalizeCollectionName(c.title) === needle');
40
+ expect(page.evaluate.mock.calls[0][0]).not.toContain('.includes(needle)');
86
41
  });
87
- it('rejects duplicate collection names before selecting any row', async () => {
42
+ it('fails fast on ambiguous collection-name matches', async () => {
88
43
  const cmd = getRegistry().get('zhihu/favorite');
89
44
  const page = {
90
45
  goto: vi.fn().mockResolvedValue(undefined),
91
- evaluate: vi.fn().mockResolvedValue({
92
- chooserRows: [
93
- { id: 'fav-a', name: '默认收藏夹', selected: false },
94
- { id: 'fav-b', name: '默认收藏夹', selected: false },
95
- ],
96
- }),
46
+ wait: vi.fn().mockResolvedValue(undefined),
47
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Collection name is ambiguous: 默认收藏夹' }),
97
48
  };
98
- await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'ACTION_NOT_AVAILABLE' });
49
+ await expect(cmd.func(page, { target: 'answer:1:2', collection: '默认收藏夹', execute: true }))
50
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
51
+ expect(page.evaluate.mock.calls[0][0]).toContain('matches.length > 1');
99
52
  });
100
- it('rejects optimistic chooser state that was not re-read from a reopened chooser', async () => {
53
+ it('requires exactly one of --collection or --collection-id', async () => {
101
54
  const cmd = getRegistry().get('zhihu/favorite');
102
- const page = {
103
- goto: vi.fn().mockResolvedValue(undefined),
104
- evaluate: vi.fn()
105
- .mockResolvedValueOnce({
106
- chooserRows: [
107
- { id: 'fav-a', name: '已存在', selected: true },
108
- { id: 'fav-b', name: '默认收藏夹', selected: false },
109
- ],
110
- targetRowId: 'fav-b',
111
- targetRowName: '默认收藏夹',
112
- })
113
- .mockResolvedValueOnce({
114
- persisted: true,
115
- readbackSource: 'same_modal',
116
- selectedAfter: ['fav-a', 'fav-b'],
117
- targetSelected: true,
118
- }),
119
- };
120
- await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).rejects.toMatchObject({ code: 'OUTCOME_UNKNOWN' });
121
- });
122
- it('matches unique collection names even when chooser rows include extra UI text', async () => {
123
- const cmd = getRegistry().get('zhihu/favorite');
124
- const page = {
125
- goto: vi.fn().mockResolvedValue(undefined),
126
- evaluate: vi.fn()
127
- .mockResolvedValueOnce({
128
- chooserRows: [
129
- { id: 'fav-b', name: '默认收藏夹 12 条内容', selected: false },
130
- ],
131
- targetRowId: null,
132
- targetRowName: '默认收藏夹',
133
- })
134
- .mockResolvedValueOnce({
135
- persisted: true,
136
- readbackSource: 'reopened_chooser',
137
- selectedAfter: ['fav-b'],
138
- targetSelected: true,
139
- }),
140
- };
141
- await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
142
- expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }),
143
- ]);
144
- expect(page.evaluate.mock.calls[1][0]).toContain('normalizeCollectionName');
145
- });
146
- it('normalizes id-less row keys during reopened chooser verification', async () => {
147
- const cmd = getRegistry().get('zhihu/favorite');
148
- const page = {
149
- goto: vi.fn().mockResolvedValue(undefined),
150
- evaluate: vi.fn()
151
- .mockResolvedValueOnce({
152
- chooserRows: [
153
- { id: '', name: '默认收藏夹 12 条内容', selected: false },
154
- ],
155
- targetRowId: null,
156
- targetRowName: '默认收藏夹',
157
- })
158
- .mockResolvedValueOnce({
159
- persisted: true,
160
- readbackSource: 'reopened_chooser',
161
- selectedAfter: ['name:默认收藏夹'],
162
- targetSelected: true,
163
- }),
164
- };
165
- await expect(cmd.func(page, { target: 'article:1', collection: '默认收藏夹', execute: true })).resolves.toEqual([
166
- expect.objectContaining({ outcome: 'applied', collection_name: '默认收藏夹' }),
167
- ]);
168
- expect(page.evaluate.mock.calls[1][0]).toContain("const rowKey = (row) => row.id || 'name:' + normalizeCollectionName(row.name);");
169
- expect(page.evaluate.mock.calls[1][0]).toContain('selectedAfter: chooserRows.filter((row) => row.selected).map(rowKey)');
170
- });
171
- it('reuses data-attribute answer anchoring during reopened chooser verification', async () => {
172
- const cmd = getRegistry().get('zhihu/favorite');
173
- const page = {
174
- goto: vi.fn().mockResolvedValue(undefined),
175
- evaluate: vi.fn()
176
- .mockResolvedValueOnce({
177
- chooserRows: [
178
- { id: 'fav-b', name: '默认收藏夹', selected: false },
179
- ],
180
- targetRowId: 'fav-b',
181
- targetRowName: null,
182
- })
183
- .mockResolvedValueOnce({
184
- persisted: true,
185
- readbackSource: 'reopened_chooser',
186
- selectedAfter: ['fav-b'],
187
- targetSelected: true,
188
- }),
189
- };
190
- await expect(cmd.func(page, { target: 'answer:1:2', 'collection-id': 'fav-b', execute: true })).resolves.toEqual([
191
- expect.objectContaining({ outcome: 'applied', collection_id: 'fav-b', target: 'answer:1:2' }),
192
- ]);
193
- expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-answerid')");
194
- expect(page.evaluate.mock.calls[1][0]).toContain("node.getAttribute('data-zop-question-answer')");
55
+ const page = { goto: vi.fn(), wait: vi.fn(), evaluate: vi.fn() };
56
+ await expect(cmd.func(page, { target: 'answer:1:2', execute: true }))
57
+ .rejects.toMatchObject({ code: 'INVALID_INPUT' });
195
58
  });
196
59
  });