@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
@@ -2,13 +2,12 @@ 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, resolveCurrentUserIdentity, resolvePayload } from './write-shared.js';
5
- const COMMENT_AUTHOR_SCOPE_SELECTOR = '.CommentItemV2-head, .CommentItem-head, .CommentItemV2-meta, .CommentItem-meta, .CommentItemV2-metaSibling, [data-comment-author], [itemprop="author"]';
6
5
  cli({
7
6
  site: 'zhihu',
8
7
  name: 'comment',
9
8
  description: 'Create a top-level comment on a Zhihu answer or article',
10
9
  domain: 'zhihu.com',
11
- strategy: Strategy.UI,
10
+ strategy: Strategy.COOKIE,
12
11
  browser: true,
13
12
  args: [
14
13
  { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
@@ -16,7 +15,7 @@ cli({
16
15
  { name: 'file', help: 'Comment text file path' },
17
16
  { name: 'execute', type: 'boolean', help: 'Actually perform the write action' },
18
17
  ],
19
- columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url', 'created_proof'],
18
+ columns: ['status', 'outcome', 'message', 'target_type', 'target', 'author_identity', 'created_url'],
20
19
  func: async (page, kwargs) => {
21
20
  if (!page)
22
21
  throw new CommandExecutionError('Browser session required for zhihu comment');
@@ -25,311 +24,31 @@ cli({
25
24
  const target = assertAllowedKinds('comment', parseTarget(rawTarget));
26
25
  const payload = await resolvePayload(kwargs);
27
26
  await page.goto(target.url);
27
+ await page.wait(3);
28
28
  const authorIdentity = await resolveCurrentUserIdentity(page);
29
- const entryPath = await page.evaluate(`(() => {
30
- const targetKind = ${JSON.stringify(target.kind)};
31
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
32
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
33
- const restoredDraft = !!document.querySelector('[contenteditable="true"][data-draft-restored], textarea[data-draft-restored]');
34
- let scope = document;
35
- if (targetKind === 'answer') {
36
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
37
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
38
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
39
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
40
- const href = link.getAttribute('href') || '';
41
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
42
- });
43
- });
44
- if (!block) return { entryPathSafe: false, wrongAnswer: true };
45
- scope = block;
46
- } else {
47
- scope =
48
- document.querySelector('article')
49
- || document.querySelector('.Post-Main')
50
- || document.querySelector('[itemprop="articleBody"]')
51
- || document;
52
- }
53
- const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
54
- const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
55
- const replyHint = editor.getAttribute('data-reply-to') || '';
56
- const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
57
- const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
58
- return { editor, container, replyHint, text, nestedReply };
59
- }).filter((candidate) => candidate.container && !candidate.nestedReply);
60
- return {
61
- entryPathSafe: topLevelCandidates.length === 1
62
- && !restoredDraft
63
- && !topLevelCandidates[0].replyHint
64
- && !String(topLevelCandidates[0].text || '').trim(),
65
- wrongAnswer: false,
66
- };
67
- })()`);
68
- if (entryPath.wrongAnswer) {
69
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
70
- }
71
- if (!entryPath.entryPathSafe) {
72
- throw new CliError('ACTION_NOT_AVAILABLE', 'Comment entry path was not proven side-effect free');
73
- }
74
- const beforeSubmitSnapshot = await page.evaluate(`(() => {
75
- const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
76
- const targetKind = ${JSON.stringify(target.kind)};
77
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
78
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
79
- let scope = document;
80
- if (targetKind === 'answer') {
81
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
82
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
83
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
84
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
85
- const href = link.getAttribute('href') || '';
86
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
87
- });
88
- });
89
- if (!block) return { wrongAnswer: true, rows: [], commentLinks: [] };
90
- scope = block;
91
- } else {
92
- scope =
93
- document.querySelector('article')
94
- || document.querySelector('.Post-Main')
95
- || document.querySelector('[itemprop="articleBody"]')
96
- || document;
97
- }
98
- return {
99
- wrongAnswer: false,
100
- rows: Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => ({
101
- id: node.getAttribute('data-comment-id') || '',
102
- text: normalize(node.textContent || ''),
103
- })),
104
- commentLinks: Array.from(scope.querySelectorAll('a[href*="/comment/"]'))
105
- .map((node) => node.getAttribute('href') || '')
106
- .filter(Boolean),
107
- };
108
- })()`);
109
- if (beforeSubmitSnapshot.wrongAnswer) {
110
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
111
- }
112
- const composer = await page.evaluate(`(async () => {
113
- const targetKind = ${JSON.stringify(target.kind)};
114
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
115
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
116
- let scope = document;
117
- if (targetKind === 'answer') {
118
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
119
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
120
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
121
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
122
- const href = link.getAttribute('href') || '';
123
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
124
- });
125
- });
126
- if (!block) return { composerState: 'wrong_answer' };
127
- scope = block;
128
- } else {
129
- scope =
130
- document.querySelector('article')
131
- || document.querySelector('.Post-Main')
132
- || document.querySelector('[itemprop="articleBody"]')
133
- || document;
134
- }
135
- const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
136
- const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
137
- const replyHint = editor.getAttribute('data-reply-to') || '';
138
- const text = 'value' in editor ? editor.value || '' : (editor.textContent || '');
139
- const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
140
- return { editor, container, replyHint, text, nestedReply };
141
- }).filter((candidate) => candidate.container && !candidate.nestedReply);
142
- if (topLevelCandidates.length !== 1) return { composerState: 'unsafe' };
143
- return {
144
- composerState: !topLevelCandidates[0].replyHint && !topLevelCandidates[0].text.trim() ? 'fresh_top_level' : 'unsafe',
145
- };
146
- })()`);
147
- if (composer.composerState === 'wrong_answer') {
148
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
149
- }
150
- if (composer.composerState !== 'fresh_top_level') {
151
- throw new CliError('ACTION_NOT_AVAILABLE', 'Comment composer was not a fresh top-level composer');
152
- }
153
- const editorCheck = await page.evaluate(`(async () => {
154
- const textToInsert = ${JSON.stringify(payload)};
155
- const targetKind = ${JSON.stringify(target.kind)};
156
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
157
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
158
- let scope = document;
159
- if (targetKind === 'answer') {
160
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
161
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
162
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
163
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
164
- const href = link.getAttribute('href') || '';
165
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
166
- });
167
- });
168
- if (!block) return { editorContent: '', mode: 'wrong_answer' };
169
- scope = block;
170
- } else {
171
- scope =
172
- document.querySelector('article')
173
- || document.querySelector('.Post-Main')
174
- || document.querySelector('[itemprop="articleBody"]')
175
- || document;
176
- }
177
- const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
178
- const container = editor.closest('form, .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
179
- const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
180
- return { editor, container, nestedReply };
181
- }).filter((candidate) => candidate.container && !candidate.nestedReply);
182
- if (topLevelCandidates.length !== 1) return { editorContent: '', mode: 'missing' };
183
- const { editor } = topLevelCandidates[0];
184
- editor.focus();
185
- if ('value' in editor) {
186
- editor.value = '';
187
- editor.dispatchEvent(new Event('input', { bubbles: true }));
188
- editor.value = textToInsert;
189
- editor.dispatchEvent(new Event('input', { bubbles: true }));
190
- } else {
191
- editor.textContent = '';
192
- document.execCommand('insertText', false, textToInsert);
193
- editor.dispatchEvent(new InputEvent('input', { bubbles: true, data: textToInsert, inputType: 'insertText' }));
194
- }
195
- await new Promise((resolve) => setTimeout(resolve, 200));
196
- const content = 'value' in editor ? editor.value : (editor.textContent || '');
197
- const replyHint = editor.getAttribute('data-reply-to') || '';
198
- return { editorContent: content, mode: replyHint ? 'reply' : 'top_level' };
199
- })()`);
200
- if (editorCheck.mode === 'wrong_answer') {
201
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
202
- }
203
- if (editorCheck.mode !== 'top_level' || editorCheck.editorContent !== payload) {
204
- throw new CliError('OUTCOME_UNKNOWN', 'Comment editor content did not exactly match the requested payload before submit');
205
- }
206
- const proof = await page.evaluate(`(async () => {
207
- const normalize = (value) => value.replace(/\\s+/g, ' ').trim();
208
- const commentAuthorScopeSelector = ${JSON.stringify(COMMENT_AUTHOR_SCOPE_SELECTOR)};
209
- const readCommentAuthorSlug = (node) => {
210
- const authorScopes = Array.from(node.querySelectorAll(commentAuthorScopeSelector));
211
- const slugs = Array.from(new Set(authorScopes
212
- .flatMap((scope) => Array.from(scope.querySelectorAll('a[href^="/people/"]')))
213
- .map((link) => (link.getAttribute('href') || '').match(/^\\/people\\/([A-Za-z0-9_-]+)/)?.[1] || null)
214
- .filter(Boolean)));
215
- return slugs.length === 1 ? slugs[0] : null;
216
- };
217
- const targetKind = ${JSON.stringify(target.kind)};
218
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
219
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
220
- let scope = document;
221
- if (targetKind === 'answer') {
222
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
223
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
224
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
225
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
226
- const href = link.getAttribute('href') || '';
227
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
228
- });
229
- });
230
- if (!block) return { proofType: 'wrong_answer' };
231
- scope = block;
232
- } else {
233
- scope =
234
- document.querySelector('article')
235
- || document.querySelector('.Post-Main')
236
- || document.querySelector('[itemprop="articleBody"]')
237
- || document;
238
- }
239
-
240
- const topLevelCandidates = Array.from(scope.querySelectorAll('[contenteditable="true"], textarea')).map((editor) => {
241
- const container = editor.closest('form, [role="dialog"], .CommentEditor, .CommentForm, .CommentsV2-footer, [data-comment-editor]') || editor.parentElement;
242
- const nestedReply = Boolean(container?.closest('[data-comment-id], .CommentItem'));
243
- return { editor, container, nestedReply };
244
- }).filter((candidate) => candidate.container && !candidate.nestedReply);
245
- if (topLevelCandidates.length !== 1) return { proofType: 'unknown' };
246
- const submitScope = topLevelCandidates[0].container || scope;
247
- const submit = Array.from(submitScope.querySelectorAll('button')).find((node) => /发布|评论|发送/.test(node.textContent || ''));
248
- submit && submit.click();
249
- await new Promise((resolve) => setTimeout(resolve, 1200));
250
- const createdLink = Array.from(scope.querySelectorAll('a[href*="/comment/"]')).find((node) => {
251
- const href = node.getAttribute('href') || '';
252
- return href.includes('/comment/') && !${JSON.stringify(beforeSubmitSnapshot.commentLinks ?? [])}.includes(href);
253
- });
254
-
255
- if (createdLink) {
256
- const card = createdLink.closest('[data-comment-id], .CommentItem, li');
257
- const authorSlug = card ? readCommentAuthorSlug(card) : null;
258
- const contentNode =
259
- card?.querySelector('[data-comment-content], .RichContent-inner, .CommentItemV2-content, .CommentContent')
260
- || card;
261
- const text = normalize(contentNode?.textContent || '');
262
- const nestedReply = Boolean(card?.closest('ul ul, ol ol, li li') || card?.parentElement?.closest('[data-comment-id], .CommentItem'));
263
- return {
264
- proofType: 'stable_url',
265
- createdUrl: new URL(createdLink.getAttribute('href') || '', location.origin).href,
266
- commentScope: nestedReply ? 'nested_reply' : 'top_level_only',
267
- authorIdentity: authorSlug,
268
- targetMatches: text === normalize(${JSON.stringify(payload)}),
269
- };
270
- }
271
-
272
- const currentUserSlug = ${JSON.stringify(authorIdentity)};
273
- const beforeIds = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.id).filter(Boolean))});
274
- const beforeTexts = new Set(${JSON.stringify((beforeSubmitSnapshot.rows ?? []).map((row) => row.text).filter(Boolean))});
275
- const normalizedPayload = normalize(${JSON.stringify(payload)});
276
- const after = Array.from(scope.querySelectorAll('[data-comment-id], .CommentItem')).map((node) => {
277
- return {
278
- id: node.getAttribute('data-comment-id') || '',
279
- text: normalize(node.textContent || ''),
280
- authorSlug: readCommentAuthorSlug(node),
281
- topLevel: !node.closest('ul ul, ol ol, li li') && !node.parentElement?.closest('[data-comment-id], .CommentItem'),
282
- };
283
- });
284
-
285
- const matching = after.filter((row) =>
286
- !beforeIds.has(row.id)
287
- && row.authorSlug === currentUserSlug
288
- && row.topLevel
289
- && row.text === normalizedPayload
290
- && !beforeTexts.has(row.text)
291
- );
292
-
293
- return matching.length === 1
294
- ? {
295
- proofType: 'fallback',
296
- createdProof: {
297
- proof_type: 'comment_fallback',
298
- author_scope: 'current_user',
299
- target_scope: 'requested_target',
300
- comment_scope: 'top_level_only',
301
- content_match: 'exact_normalized',
302
- observed_after_submit: true,
303
- present_in_pre_submit_snapshot: false,
304
- new_matching_entries: 1,
305
- post_submit_matching_entries: after.filter((row) =>
306
- row.authorSlug === currentUserSlug && row.topLevel && row.text === normalizedPayload
307
- ).length,
308
- snapshot_scope: ${JSON.stringify(target.kind === 'answer'
309
- ? 'stabilized_expanded_target_answer_comment_list'
310
- : 'stabilized_expanded_target_article_comment_list')},
311
- },
312
- }
313
- : { proofType: 'unknown' };
314
- })()`);
315
- if (proof.proofType === 'wrong_answer') {
316
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
317
- }
318
- if (proof.proofType === 'fallback') {
319
- return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
320
- author_identity: authorIdentity,
321
- created_proof: proof.createdProof,
29
+ const apiResult = await page.evaluate(`(async () => {
30
+ var targetKind = ${JSON.stringify(target.kind)};
31
+ var targetId = ${JSON.stringify(target.id)};
32
+ var content = ${JSON.stringify(payload)};
33
+ var resourceType = targetKind === 'answer' ? 'answers' : 'articles';
34
+ var url = 'https://www.zhihu.com/api/v4/' + resourceType + '/' + targetId + '/comments';
35
+ var resp = await fetch(url, {
36
+ method: 'POST',
37
+ credentials: 'include',
38
+ headers: { 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ content: content }),
322
40
  });
323
- }
324
- if (proof.proofType !== 'stable_url') {
325
- throw new CliError('OUTCOME_UNKNOWN', 'Comment submit was dispatched, but the created object could not be proven safely');
326
- }
327
- if (proof.commentScope !== 'top_level_only' || proof.authorIdentity !== authorIdentity || !proof.targetMatches) {
328
- throw new CliError('OUTCOME_UNKNOWN', 'Stable comment URL was found, but authorship or top-level scope could not be proven safely');
329
- }
330
- return buildResultRow(`Commented on ${target.kind}`, target.kind, rawTarget, 'created', {
41
+ var data = await resp.json();
42
+ if (!resp.ok) return { ok: false, status: resp.status, message: data.error ? data.error.message : 'unknown error' };
43
+ if (!data || !data.id) return { ok: false, status: resp.status, message: 'Comment API response did not include a created comment id' };
44
+ return { ok: true, id: data.id, url: data.url };
45
+ })()`);
46
+ if (!apiResult?.ok) {
47
+ throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to create comment');
48
+ }
49
+ return buildResultRow(`Commented on ${target.kind} ${target.id}`, target.kind, rawTarget, 'created', {
331
50
  author_identity: authorIdentity,
332
- created_url: proof.createdUrl,
51
+ created_url: apiResult.url || '',
333
52
  });
334
53
  },
335
54
  });
@@ -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
  });