@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,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
  });
@@ -7,7 +7,7 @@ cli({
7
7
  name: 'follow',
8
8
  description: 'Follow a Zhihu user or question',
9
9
  domain: 'www.zhihu.com',
10
- strategy: Strategy.UI,
10
+ strategy: Strategy.COOKIE,
11
11
  browser: true,
12
12
  args: [
13
13
  { name: 'target', positional: true, required: true, help: 'Zhihu target URL or typed target' },
@@ -20,61 +20,30 @@ cli({
20
20
  requireExecute(kwargs);
21
21
  const rawTarget = String(kwargs.target);
22
22
  const target = assertAllowedKinds('follow', parseTarget(rawTarget));
23
- await page.goto(target.url);
24
- const result = await page.evaluate(`(async () => {
25
- const targetKind = ${JSON.stringify(target.kind)};
26
- const mainRoot = document.querySelector('main') || document;
27
- let followBtn = null;
28
-
29
- if (targetKind === 'question') {
30
- const questionRoots = Array.from(mainRoot.querySelectorAll('.QuestionHeader, .Question-main, [data-zop-question-id], [class*="QuestionHeader"]'));
31
- const scopedRoots = questionRoots.length ? questionRoots : [mainRoot];
32
- const candidates = Array.from(new Set(scopedRoots.flatMap((root) => Array.from(root.querySelectorAll('button, a'))))).filter((node) => {
33
- const text = (node.textContent || '').trim();
34
- const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations'));
35
- const inAnswerBlock = Boolean(node.closest('article, .AnswerItem, [data-zop-question-answer]'));
36
- return /关注问题|已关注/.test(text) && !inAside && !inAnswerBlock;
37
- });
38
- if (candidates.length !== 1) return { state: 'ambiguous_question_follow' };
39
- followBtn = candidates[0];
40
- } else {
41
- const candidates = Array.from(mainRoot.querySelectorAll('button, a')).filter((node) => {
42
- const text = (node.textContent || '').trim();
43
- const inAside = Boolean(node.closest('aside, [data-testid*="recommend"], .Recommendations'));
44
- return /关注|已关注/.test(text) && !/邀请|收藏|评论/.test(text) && !inAside;
45
- });
46
-
47
- if (candidates.length !== 1) return { state: 'ambiguous_user_follow' };
48
- followBtn = candidates[0];
49
- }
50
-
51
- if (!followBtn) return { state: 'missing' };
52
- if ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true') {
53
- return { state: 'already_following' };
54
- }
55
-
56
- followBtn.click();
57
- await new Promise((resolve) => setTimeout(resolve, 1000));
58
-
59
- return ((followBtn.textContent || '').includes('已关注') || followBtn.getAttribute('aria-pressed') === 'true')
60
- ? { state: 'followed' }
61
- : { state: 'unknown' };
62
- })()`);
63
- if (result?.state === 'already_following') {
64
- return buildResultRow(`Already followed ${target.kind}`, target.kind, rawTarget, 'already_applied');
23
+ await page.goto('https://www.zhihu.com');
24
+ await page.wait(2);
25
+ const apiResult = await page.evaluate(`(async () => {
26
+ var targetKind = ${JSON.stringify(target.kind)};
27
+ var targetId = ${JSON.stringify(target.kind === 'user' ? target.slug : target.id)};
28
+ var url;
29
+ if (targetKind === 'question') {
30
+ url = 'https://www.zhihu.com/api/v4/questions/' + targetId + '/followers';
31
+ } else if (targetKind === 'user') {
32
+ url = 'https://www.zhihu.com/api/v4/members/' + targetId + '/followers';
33
+ } else {
34
+ return { ok: false, message: 'unsupported target type: ' + targetKind };
35
+ }
36
+ var resp = await fetch(url, { method: 'POST', credentials: 'include' });
37
+ if (!resp.ok) {
38
+ var data = {};
39
+ try { data = await resp.json(); } catch(e) {}
40
+ return { ok: false, message: data.error ? data.error.message : 'HTTP ' + resp.status };
41
+ }
42
+ return { ok: true };
43
+ })()`);
44
+ if (!apiResult?.ok) {
45
+ throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to follow');
65
46
  }
66
- if (result?.state === 'ambiguous_question_follow') {
67
- throw new CliError('ACTION_NOT_AVAILABLE', 'Question follow control was not uniquely anchored on the requested question page');
68
- }
69
- if (result?.state === 'ambiguous_user_follow') {
70
- throw new CliError('ACTION_NOT_AVAILABLE', 'User follow control was not uniquely anchored on the requested profile page');
71
- }
72
- if (result?.state === 'missing') {
73
- throw new CliError('ACTION_FAILED', 'Zhihu follow control was missing before any write was dispatched');
74
- }
75
- if (result?.state !== 'followed') {
76
- throw new CliError('OUTCOME_UNKNOWN', 'Zhihu follow click was dispatched, but the final state could not be verified safely');
77
- }
78
- return buildResultRow(`Followed ${target.kind}`, target.kind, rawTarget, 'applied');
47
+ return buildResultRow(`Followed ${target.kind} ${target.kind === 'user' ? target.slug : target.id}`, target.kind, rawTarget, 'applied');
79
48
  },
80
49
  });