@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
@@ -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
  });
@@ -2,44 +2,41 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './follow.js';
4
4
  describe('zhihu follow', () => {
5
- it('rejects missing --execute before any browser write path', async () => {
5
+ it('registers as a cookie browser command', () => {
6
6
  const cmd = getRegistry().get('zhihu/follow');
7
- expect(cmd?.func).toBeTypeOf('function');
8
- const page = { goto: vi.fn(), evaluate: vi.fn() };
9
- await expect(cmd.func(page, { target: 'question:123' })).rejects.toMatchObject({ code: 'INVALID_INPUT' });
10
- expect(page.goto).not.toHaveBeenCalled();
11
- expect(page.evaluate).not.toHaveBeenCalled();
7
+ expect(cmd).toBeDefined();
8
+ expect(cmd.strategy).toBe('cookie');
12
9
  });
13
- it('rejects user pages where the primary follow control is not uniquely anchored', async () => {
10
+ it('follows via API and returns result', async () => {
14
11
  const cmd = getRegistry().get('zhihu/follow');
15
12
  const page = {
16
13
  goto: vi.fn().mockResolvedValue(undefined),
17
- evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_user_follow' }),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
18
16
  };
19
- await expect(cmd.func(page, { target: 'user:alice', execute: true })).rejects.toMatchObject({
20
- code: 'ACTION_NOT_AVAILABLE',
21
- });
17
+ const rows = await cmd.func(page, { target: 'question:123', execute: true });
18
+ expect(rows).toEqual([expect.objectContaining({ outcome: 'applied' })]);
22
19
  });
23
- it('returns already_applied when already following', async () => {
20
+ it('uses the parsed user slug for user follow API calls', async () => {
24
21
  const cmd = getRegistry().get('zhihu/follow');
25
22
  const page = {
26
23
  goto: vi.fn().mockResolvedValue(undefined),
27
- evaluate: vi.fn().mockResolvedValue({ state: 'already_following' }),
24
+ wait: vi.fn().mockResolvedValue(undefined),
25
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: true }),
28
26
  };
29
- await expect(cmd.func(page, { target: 'question:123', execute: true })).resolves.toEqual([
30
- expect.objectContaining({ outcome: 'already_applied', target_type: 'question', target: 'question:123' }),
31
- ]);
27
+ await cmd.func(page, { target: 'user:alice', execute: true });
28
+ expect(page.evaluate.mock.calls[0][0]).toContain("'https://www.zhihu.com/api/v4/members/' + targetId + '/followers'");
29
+ expect(page.evaluate.mock.calls[0][0]).toContain('var targetId = "alice"');
30
+ expect(page.evaluate.mock.calls[0][0]).not.toContain('undefined');
32
31
  });
33
- it('rejects question pages where the question follow control is not uniquely anchored', async () => {
32
+ it('throws on API error', async () => {
34
33
  const cmd = getRegistry().get('zhihu/follow');
35
34
  const page = {
36
35
  goto: vi.fn().mockResolvedValue(undefined),
37
- evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_question_follow' }),
36
+ wait: vi.fn().mockResolvedValue(undefined),
37
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'already following' }),
38
38
  };
39
- await expect(cmd.func(page, { target: 'question:123', execute: true })).rejects.toMatchObject({
40
- code: 'ACTION_NOT_AVAILABLE',
41
- });
42
- expect(page.evaluate.mock.calls[0][0]).toContain('QuestionHeader');
43
- expect(page.evaluate.mock.calls[0][0]).toContain('new Set(');
39
+ await expect(cmd.func(page, { target: 'question:123', execute: true }))
40
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
44
41
  });
45
42
  });
@@ -7,7 +7,7 @@ cli({
7
7
  name: 'like',
8
8
  description: 'Like a Zhihu answer or article',
9
9
  domain: '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,72 +20,27 @@ cli({
20
20
  requireExecute(kwargs);
21
21
  const rawTarget = String(kwargs.target);
22
22
  const target = assertAllowedKinds('like', parseTarget(rawTarget));
23
- await page.goto(target.url);
24
- const result = await page.evaluate(`(async () => {
25
- const targetKind = ${JSON.stringify(target.kind)};
26
- const targetQuestionId = ${JSON.stringify(target.kind === 'answer' ? target.questionId : null)};
27
- const targetAnswerId = ${JSON.stringify(target.kind === 'answer' ? target.id : null)};
28
-
29
- let btn = null;
30
- if (targetKind === 'answer') {
31
- const block = Array.from(document.querySelectorAll('article, .AnswerItem, [data-zop-question-answer]')).find((node) => {
32
- const dataAnswerId = node.getAttribute('data-answerid') || node.getAttribute('data-zop-question-answer') || '';
33
- if (dataAnswerId && dataAnswerId.includes(targetAnswerId)) return true;
34
- return Array.from(node.querySelectorAll('a[href*="/answer/"]')).some((link) => {
35
- const href = link.getAttribute('href') || '';
36
- return href.includes('/question/' + targetQuestionId + '/answer/' + targetAnswerId);
37
- });
38
- });
39
- if (!block) return { state: 'wrong_answer' };
40
- const candidates = Array.from(block?.querySelectorAll('button') || []).filter((node) => {
41
- const text = (node.textContent || '').trim();
42
- const inCommentItem = Boolean(node.closest('[data-comment-id], .CommentItem'));
43
- return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed') && !inCommentItem;
44
- });
45
- if (candidates.length !== 1) return { state: 'ambiguous_answer_like' };
46
- btn = candidates[0];
47
- } else {
48
- const articleRoot =
49
- document.querySelector('article')
50
- || document.querySelector('.Post-Main')
51
- || document.querySelector('[itemprop="articleBody"]')
52
- || document;
53
- const candidates = Array.from(articleRoot.querySelectorAll('button')).filter((node) => {
54
- const text = (node.textContent || '').trim();
55
- return /赞同|赞/.test(text) && node.hasAttribute('aria-pressed');
56
- });
57
- if (candidates.length !== 1) return { state: 'ambiguous_article_like' };
58
- btn = candidates[0];
59
- }
60
-
61
- if (!btn) return { state: 'missing' };
62
- if (btn.getAttribute('aria-pressed') === 'true') return { state: 'already_liked' };
63
-
64
- btn.click();
65
- await new Promise((resolve) => setTimeout(resolve, 1200));
66
-
67
- return btn.getAttribute('aria-pressed') === 'true'
68
- ? { state: 'liked' }
69
- : { state: 'unknown' };
70
- })()`);
71
- if (result?.state === 'wrong_answer') {
72
- throw new CliError('TARGET_NOT_FOUND', 'Resolved answer target no longer matches the requested answer:<questionId>:<answerId>');
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.id)};
28
+ var resourceType = targetKind === 'answer' ? 'answers' : 'articles';
29
+ var url = 'https://www.zhihu.com/api/v4/' + resourceType + '/' + targetId + '/voters';
30
+ var resp = await fetch(url, {
31
+ method: 'POST',
32
+ credentials: 'include',
33
+ headers: { 'Content-Type': 'application/json' },
34
+ body: JSON.stringify({ type: 'up' }),
35
+ });
36
+ var data = await resp.json();
37
+ if (!resp.ok) return { ok: false, message: data.error ? data.error.message : 'unknown error' };
38
+ if (data && data.success === false) return { ok: false, message: 'Zhihu like API reported success=false' };
39
+ return { ok: true, success: data.success };
40
+ })()`);
41
+ if (!apiResult?.ok) {
42
+ throw new CliError('COMMAND_EXEC', apiResult?.message || 'Failed to like');
73
43
  }
74
- if (result?.state === 'already_liked') {
75
- return buildResultRow(`Already liked ${target.kind}`, target.kind, rawTarget, 'already_applied');
76
- }
77
- if (result?.state === 'ambiguous_answer_like') {
78
- throw new CliError('ACTION_NOT_AVAILABLE', 'Answer like control was not uniquely anchored on the requested answer');
79
- }
80
- if (result?.state === 'ambiguous_article_like') {
81
- throw new CliError('ACTION_NOT_AVAILABLE', 'Article like control was not uniquely anchored on the requested target');
82
- }
83
- if (result?.state === 'missing') {
84
- throw new CliError('ACTION_FAILED', 'Zhihu like control was missing before any write was dispatched');
85
- }
86
- if (result?.state !== 'liked') {
87
- throw new CliError('OUTCOME_UNKNOWN', 'Zhihu like click was dispatched, but the final state could not be verified safely');
88
- }
89
- return buildResultRow(`Liked ${target.kind}`, target.kind, rawTarget, 'applied');
44
+ return buildResultRow(`Liked ${target.kind} ${target.id}`, target.kind, rawTarget, 'applied');
90
45
  },
91
46
  });
@@ -2,63 +2,40 @@ import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
3
  import './like.js';
4
4
  describe('zhihu like', () => {
5
- it('rejects article pages where the like control is not uniquely anchored', async () => {
5
+ it('registers as a cookie browser command', () => {
6
6
  const cmd = getRegistry().get('zhihu/like');
7
- expect(cmd?.func).toBeTypeOf('function');
8
- const page = {
9
- goto: vi.fn().mockResolvedValue(undefined),
10
- evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_article_like' }),
11
- };
12
- await expect(cmd.func(page, { target: 'article:9', execute: true })).rejects.toMatchObject({
13
- code: 'ACTION_NOT_AVAILABLE',
14
- });
15
- });
16
- it('returns already_applied for an already-liked article target', async () => {
17
- const cmd = getRegistry().get('zhihu/like');
18
- const page = {
19
- goto: vi.fn().mockResolvedValue(undefined),
20
- evaluate: vi.fn().mockResolvedValue({ state: 'already_liked' }),
21
- };
22
- await expect(cmd.func(page, { target: 'article:9', execute: true })).resolves.toEqual([
23
- expect.objectContaining({ outcome: 'already_applied', target_type: 'article', target: 'article:9' }),
24
- ]);
7
+ expect(cmd).toBeDefined();
8
+ expect(cmd.strategy).toBe('cookie');
25
9
  });
26
- it('anchors to the requested answer block before clicking like', async () => {
10
+ it('likes via API and returns result', async () => {
27
11
  const cmd = getRegistry().get('zhihu/like');
28
12
  const page = {
29
13
  goto: vi.fn().mockResolvedValue(undefined),
30
- evaluate: vi.fn().mockResolvedValue({ state: 'liked' }),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: true, success: true }),
31
16
  };
32
- await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).resolves.toEqual([
33
- expect.objectContaining({ outcome: 'applied', target_type: 'answer', target: 'answer:123:456' }),
34
- ]);
35
- expect(page.goto).toHaveBeenCalledWith('https://www.zhihu.com/question/123/answer/456');
36
- expect(page.evaluate).toHaveBeenCalledTimes(1);
37
- expect(page.evaluate.mock.calls[0][0]).toContain('targetQuestionId');
38
- expect(page.evaluate.mock.calls[0][0]).toContain('"123"');
39
- expect(page.evaluate.mock.calls[0][0]).toContain('"456"');
40
- expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-answerid')");
41
- expect(page.evaluate.mock.calls[0][0]).toContain("node.getAttribute('data-zop-question-answer')");
17
+ const rows = await cmd.func(page, { target: 'answer:1:2', execute: true });
18
+ expect(rows).toEqual([expect.objectContaining({ outcome: 'applied' })]);
42
19
  });
43
- it('rejects answer targets when the answer-level like control is not unique', async () => {
20
+ it('throws on API error', async () => {
44
21
  const cmd = getRegistry().get('zhihu/like');
45
22
  const page = {
46
23
  goto: vi.fn().mockResolvedValue(undefined),
47
- evaluate: vi.fn().mockResolvedValue({ state: 'ambiguous_answer_like' }),
24
+ wait: vi.fn().mockResolvedValue(undefined),
25
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'rate limited' }),
48
26
  };
49
- await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({
50
- code: 'ACTION_NOT_AVAILABLE',
51
- });
27
+ await expect(cmd.func(page, { target: 'answer:1:2', execute: true }))
28
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
52
29
  });
53
- it('maps missing answer blocks to TARGET_NOT_FOUND', async () => {
30
+ it('does not treat success=false API responses as a successful like', async () => {
54
31
  const cmd = getRegistry().get('zhihu/like');
55
32
  const page = {
56
33
  goto: vi.fn().mockResolvedValue(undefined),
57
- evaluate: vi.fn().mockResolvedValue({ state: 'wrong_answer' }),
34
+ wait: vi.fn().mockResolvedValue(undefined),
35
+ evaluate: vi.fn().mockResolvedValueOnce({ ok: false, message: 'Zhihu like API reported success=false' }),
58
36
  };
59
- await expect(cmd.func(page, { target: 'answer:123:456', execute: true })).rejects.toMatchObject({
60
- code: 'TARGET_NOT_FOUND',
61
- });
62
- expect(page.evaluate.mock.calls[0][0]).toContain("if (!block) return { state: 'wrong_answer' }");
37
+ await expect(cmd.func(page, { target: 'answer:1:2', execute: true }))
38
+ .rejects.toMatchObject({ code: 'COMMAND_EXEC' });
39
+ expect(page.evaluate.mock.calls[0][0]).toContain('data.success === false');
63
40
  });
64
41
  });
@@ -15,12 +15,13 @@ cli({
15
15
  const strip = (html) => (html || '').replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').replace(/<em>/g, '').replace(/<\\/em>/g, '').trim();
16
16
  const keyword = \${{ args.query | json }};
17
17
  const limit = \${{ args.limit }};
18
- const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + limit, {
18
+ var fetchLimit = Math.max(limit * 3, 30);
19
+ const res = await fetch('https://www.zhihu.com/api/v4/search_v3?q=' + encodeURIComponent(keyword) + '&t=general&offset=0&limit=' + fetchLimit, {
19
20
  credentials: 'include'
20
21
  });
21
22
  const d = await res.json();
22
23
  return (d?.data || [])
23
- .filter(item => item.type === 'search_result')
24
+ .filter(item => item.object && (item.object.type === 'answer' || item.object.type === 'article' || item.object.type === 'question'))
24
25
  .map(item => {
25
26
  const obj = item.object || {};
26
27
  const q = obj.question || {};
@@ -194,7 +194,14 @@ function buildResolveCurrentUserIdentityJs() {
194
194
 
195
195
  const navScopes = Array.from(document.querySelectorAll(navScopeSelector));
196
196
  const slug = findCurrentUserSlugFromRoots(navScopes, true) || findCurrentUserSlugFromRoots([document], false);
197
- return slug ? { slug } : null;
197
+ if (slug) return { slug };
198
+
199
+ var avatarImgs = document.querySelectorAll('header img[alt*="\\u4e3b\\u9875"]');
200
+ for (var ai = 0; ai < avatarImgs.length; ai++) {
201
+ var altMatch = (avatarImgs[ai].alt || '').match(/\\u70b9\\u51fb\\u6253\\u5f00(.+?)\\u7684\\u4e3b\\u9875/);
202
+ if (altMatch) return { slug: altMatch[1] };
203
+ }
204
+ return null;
198
205
  })()`;
199
206
  }
200
207
  export async function resolveCurrentUserIdentity(page) {
@@ -163,6 +163,7 @@ describe('zhihu write shared helpers', () => {
163
163
  const documentRoot = new FakeRoot({
164
164
  'header, nav, [role="banner"], [role="navigation"]': [],
165
165
  'a[href^="/people/"]': [],
166
+ 'header img[alt*="\u4e3b\u9875"]': [],
166
167
  });
167
168
  const page = createPageForDom(documentRoot);
168
169
  await expect(__test__.resolveCurrentUserIdentity(page)).rejects.toMatchObject({
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { ArgumentError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import {
5
+ buildSearchUrl,
6
+ normalizeZlibraryBookUrl,
7
+ } from './utils.js';
8
+ import './search.js';
9
+ import './info.js';
10
+
11
+ function createPageMock(evaluateResults = []) {
12
+ const evaluate = vi.fn();
13
+ for (const result of evaluateResults) {
14
+ evaluate.mockResolvedValueOnce(result);
15
+ }
16
+ return {
17
+ goto: vi.fn().mockResolvedValue(undefined),
18
+ wait: vi.fn().mockResolvedValue(undefined),
19
+ evaluate,
20
+ };
21
+ }
22
+
23
+ describe('zlibrary commands', () => {
24
+ it('registers search and info commands', () => {
25
+ expect(getRegistry().get('zlibrary/search')).toBeDefined();
26
+ expect(getRegistry().get('zlibrary/info')).toBeDefined();
27
+ });
28
+
29
+ it('normalizes search query and rejects empty searches', () => {
30
+ expect(buildSearchUrl(' test book ')).toBe('https://z-library.im/s/test%20book');
31
+ expect(() => buildSearchUrl(' ')).toThrow(ArgumentError);
32
+ });
33
+
34
+ it('restricts info URLs to the configured zlibrary host', () => {
35
+ expect(normalizeZlibraryBookUrl('https://z-library.im/book/demo')).toBe('https://z-library.im/book/demo');
36
+ expect(normalizeZlibraryBookUrl('https://www.z-library.im/book/demo')).toBe('https://www.z-library.im/book/demo');
37
+ expect(() => normalizeZlibraryBookUrl('https://example.com/book/demo')).toThrow(ArgumentError);
38
+ });
39
+
40
+ it('search fails fast on empty extraction results', async () => {
41
+ const command = getRegistry().get('zlibrary/search');
42
+ const page = createPageMock(['[]']);
43
+
44
+ await expect(command.func(page, { query: 'missing', limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
45
+ });
46
+
47
+ it('info waits seconds, not milliseconds-as-seconds, before extracting formats', async () => {
48
+ const command = getRegistry().get('zlibrary/info');
49
+ const page = createPageMock([
50
+ 'Demo Book',
51
+ undefined,
52
+ JSON.stringify({ pdf: 'https://z-library.im/dl/pdf', epub: '' }),
53
+ ]);
54
+
55
+ await expect(command.func(page, { url: 'https://z-library.im/book/demo' })).resolves.toEqual([{
56
+ title: 'Demo Book',
57
+ pdf: 'https://z-library.im/dl/pdf',
58
+ epub: '',
59
+ url: 'https://z-library.im/book/demo',
60
+ }]);
61
+ expect(page.wait).toHaveBeenCalledWith({ time: 5 });
62
+ expect(page.wait).toHaveBeenCalledWith({ time: 3 });
63
+ });
64
+
65
+ it('info fails fast when formats are missing', async () => {
66
+ const command = getRegistry().get('zlibrary/info');
67
+ const page = createPageMock([
68
+ 'Login Required',
69
+ undefined,
70
+ JSON.stringify({ pdf: '', epub: '' }),
71
+ ]);
72
+
73
+ await expect(command.func(page, { url: 'https://z-library.im/book/demo' })).rejects.toBeInstanceOf(EmptyResultError);
74
+ });
75
+ });
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { ZLIBRARY_DOMAIN, extractBookTitle, extractFormats, normalizeZlibraryBookUrl } from './utils.js';
4
+
5
+ cli({
6
+ site: 'zlibrary',
7
+ name: 'info',
8
+ description: 'Get book details and available download formats from a Z-Library book page',
9
+ domain: ZLIBRARY_DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ {
15
+ name: 'url',
16
+ positional: true,
17
+ required: true,
18
+ help: 'Z-Library book page URL (e.g. https://z-library.im/book/...)',
19
+ },
20
+ ],
21
+ columns: ['title', 'pdf', 'epub', 'url'],
22
+ func: async (page, args) => {
23
+ const url = normalizeZlibraryBookUrl(args.url);
24
+
25
+ await page.goto(url, { waitUntil: 'load', settleMs: 3000 });
26
+ await page.wait({ time: 5 });
27
+
28
+ const title = await extractBookTitle(page);
29
+ const formats = await extractFormats(page);
30
+
31
+ if (!title || (!formats.pdf && !formats.epub)) {
32
+ throw new EmptyResultError(
33
+ 'zlibrary info',
34
+ 'Could not extract a book title and download formats. Check the URL, login state, and whether Z-Library changed its page layout.',
35
+ );
36
+ }
37
+
38
+ return [
39
+ {
40
+ title,
41
+ pdf: formats.pdf || '',
42
+ epub: formats.epub || '',
43
+ url,
44
+ },
45
+ ];
46
+ },
47
+ });
@@ -0,0 +1,46 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { ZLIBRARY_DOMAIN, buildSearchUrl, extractSearchResults } from './utils.js';
4
+
5
+ cli({
6
+ site: 'zlibrary',
7
+ name: 'search',
8
+ description: 'Search Z-Library for books by title, author, ISBN, or keyword',
9
+ domain: ZLIBRARY_DOMAIN,
10
+ strategy: Strategy.COOKIE,
11
+ browser: true,
12
+ navigateBefore: false,
13
+ args: [
14
+ {
15
+ name: 'query',
16
+ positional: true,
17
+ required: true,
18
+ help: 'Search keyword (title, author, ISBN, etc.)',
19
+ },
20
+ {
21
+ name: 'limit',
22
+ type: 'int',
23
+ default: 10,
24
+ help: 'Max results (1–25)',
25
+ },
26
+ ],
27
+ columns: ['rank', 'title', 'author', 'url'],
28
+ func: async (page, args) => {
29
+ const limit = Math.max(1, Math.min(Number(args.limit) || 10, 25));
30
+ const searchUrl = buildSearchUrl(args.query);
31
+
32
+ await page.goto(searchUrl, { waitUntil: 'load', settleMs: 3000 });
33
+ await page.wait({ time: 5 });
34
+
35
+ const results = await extractSearchResults(page, limit);
36
+
37
+ if (!results.length) {
38
+ throw new EmptyResultError(
39
+ 'zlibrary search',
40
+ 'No books found. Try a different keyword or check that you are logged into Z-Library.',
41
+ );
42
+ }
43
+
44
+ return results;
45
+ },
46
+ });