@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,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
+ });
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Z-Library adapter utilities.
3
+ */
4
+
5
+ import { ArgumentError } from '@jackwener/opencli/errors';
6
+
7
+ const ZLIBRARY_DOMAIN = 'z-library.im';
8
+ const ZLIBRARY_ORIGIN = `https://${ZLIBRARY_DOMAIN}`;
9
+ const ZLIBRARY_ALLOWED_HOSTS = new Set([
10
+ ZLIBRARY_DOMAIN,
11
+ `www.${ZLIBRARY_DOMAIN}`,
12
+ ]);
13
+
14
+ export function normalizeZlibraryBookUrl(input) {
15
+ const raw = String(input || '').trim();
16
+ let url;
17
+ try {
18
+ url = new URL(raw);
19
+ } catch {
20
+ throw new ArgumentError('Z-Library book URL must be a valid http(s) URL', `Example: ${ZLIBRARY_ORIGIN}/book/...`);
21
+ }
22
+ if (!['http:', 'https:'].includes(url.protocol) || !ZLIBRARY_ALLOWED_HOSTS.has(url.hostname)) {
23
+ throw new ArgumentError(
24
+ `Unsupported Z-Library URL host: ${url.hostname}`,
25
+ `Pass a book URL under ${ZLIBRARY_DOMAIN}, for example ${ZLIBRARY_ORIGIN}/book/...`,
26
+ );
27
+ }
28
+ return url.toString();
29
+ }
30
+
31
+ /**
32
+ * Build a Z-Library search URL.
33
+ * Z-Library uses /s/<url-encoded-query> for search.
34
+ */
35
+ export function buildSearchUrl(query) {
36
+ const normalized = String(query || '').trim();
37
+ if (!normalized) {
38
+ throw new ArgumentError('zlibrary search query cannot be empty');
39
+ }
40
+ return `${ZLIBRARY_ORIGIN}/s/${encodeURIComponent(normalized)}`;
41
+ }
42
+
43
+ /**
44
+ * Extract book title from page context.
45
+ * Tries z-bookcard shadow DOM first, then falls back to page title.
46
+ */
47
+ export async function extractBookTitle(page) {
48
+ const title = await page.evaluate(`
49
+ (() => {
50
+ const card = document.querySelector('z-bookcard');
51
+ if (card && card.shadowRoot) {
52
+ const el = card.shadowRoot.querySelector('[class*="title"], h1, a');
53
+ if (el) return el.textContent.trim().split('\\n')[0].trim();
54
+ }
55
+ return document.title.replace(/\\s*[-|].*$/, '').trim();
56
+ })()
57
+ `);
58
+ return String(title || '').trim();
59
+ }
60
+
61
+ /**
62
+ * Extract available download formats from book page.
63
+ * Clicks the three-dot menu to reveal download options.
64
+ * NOTE: Z-Library download links redirect through /dl/<hash> URLs.
65
+ * These require browser cookies and may not produce direct file downloads
66
+ * in OpenCLI's browser automation. For actual file downloading,
67
+ * consider using Playwright's download event handling instead.
68
+ */
69
+ export async function extractFormats(page) {
70
+ // Click three-dot menu if present
71
+ await page.evaluate(`
72
+ (() => {
73
+ const btn = document.querySelector(
74
+ 'button[aria-label*="more" i], [class*="dots" i], [class*="more" i]'
75
+ );
76
+ if (btn) btn.click();
77
+ })()
78
+ `);
79
+ // Wait for menu
80
+ await page.wait({ time: 3 });
81
+
82
+ const formats = await page.evaluate(`
83
+ JSON.stringify((() => {
84
+ const res = { pdf: '', epub: '' };
85
+ document.querySelectorAll('a[href]').forEach(a => {
86
+ const h = a.href || '';
87
+ const t = (a.textContent || '').toUpperCase();
88
+ if (h.includes('/dl/') && t.includes('PDF')) res.pdf = h;
89
+ if (h.includes('/dl/') && t.includes('EPUB')) res.epub = h;
90
+ });
91
+ return res;
92
+ })())
93
+ `);
94
+ return JSON.parse(formats);
95
+ }
96
+
97
+ /**
98
+ * Extract book cards from search results page.
99
+ *
100
+ * Z-Library renders search results as <z-bookcard> custom elements.
101
+ * Each card contains the book title, author, and a link to the book page.
102
+ * The link is inside a shadow DOM that can be queried with card.shadowRoot.
103
+ *
104
+ * This approach was validated on 2026-04-28 against z-library.im.
105
+ */
106
+ export async function extractSearchResults(page, limit) {
107
+ const raw = await page.evaluate(`
108
+ JSON.stringify(
109
+ Array.from(document.querySelectorAll('z-bookcard'))
110
+ .slice(0, ${limit})
111
+ .map((card, index) => {
112
+ const text = card.textContent.trim();
113
+ const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
114
+ const title = lines[0] || '';
115
+ const author = lines[1] || '';
116
+ let url = '';
117
+ try {
118
+ if (card.shadowRoot) {
119
+ const link = card.shadowRoot.querySelector('a');
120
+ if (link) url = link.href || '';
121
+ }
122
+ } catch(e) {}
123
+ return { rank: index + 1, title, author, url };
124
+ })
125
+ .filter(item => item.url && item.title)
126
+ )
127
+ `);
128
+
129
+ try {
130
+ return JSON.parse(raw);
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ export { ZLIBRARY_DOMAIN, ZLIBRARY_ORIGIN };
@@ -0,0 +1,11 @@
1
+ import type { InternalCliCommand } from './registry.js';
2
+ /**
3
+ * Resolve the editable source file path for an adapter.
4
+ *
5
+ * Priority:
6
+ * 1. cmd.source (set for FS-scanned JS and manifest lazy-loaded JS)
7
+ * 2. cmd._modulePath (set for manifest lazy-loaded JS)
8
+ *
9
+ * Skip manifest: prefixed pseudo-paths (YAML commands inlined in manifest).
10
+ */
11
+ export declare function resolveAdapterSourcePath(cmd: InternalCliCommand): string | undefined;
@@ -0,0 +1,24 @@
1
+ import * as fs from 'node:fs';
2
+ /**
3
+ * Resolve the editable source file path for an adapter.
4
+ *
5
+ * Priority:
6
+ * 1. cmd.source (set for FS-scanned JS and manifest lazy-loaded JS)
7
+ * 2. cmd._modulePath (set for manifest lazy-loaded JS)
8
+ *
9
+ * Skip manifest: prefixed pseudo-paths (YAML commands inlined in manifest).
10
+ */
11
+ export function resolveAdapterSourcePath(cmd) {
12
+ const candidates = [];
13
+ if (cmd.source && !cmd.source.startsWith('manifest:')) {
14
+ candidates.push(cmd.source);
15
+ }
16
+ if (cmd._modulePath) {
17
+ candidates.push(cmd._modulePath);
18
+ }
19
+ for (const candidate of candidates) {
20
+ if (fs.existsSync(candidate))
21
+ return candidate;
22
+ }
23
+ return candidates[0];
24
+ }