@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
package/clis/36kr/news.js CHANGED
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 20, help: 'Number of articles (max 50)' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'summary', 'date', 'url'],
15
- func: async (_page, kwargs) => {
15
+ func: async (kwargs) => {
16
16
  const count = Math.min(kwargs.limit || 20, 50);
17
17
  const resp = await fetch('https://www.36kr.com/feed', {
18
18
  headers: { 'User-Agent': 'Mozilla/5.0 (compatible; opencli/1.0)' },
@@ -1,6 +1,6 @@
1
- import { CommandExecutionError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
- import { buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
3
+ import { buildProductUrl, buildDiscussionUrl, buildProvenance, cleanText, extractAsin, normalizeProductUrl, parseRatingValue, parseReviewCount, trimRatingPrefix, uniqueNonEmpty, assertUsableState, gotoAndReadState, } from './shared.js';
4
4
  function normalizeDiscussionPayload(payload) {
5
5
  const sourceUrl = cleanText(payload.href) || buildDiscussionUrl(payload.href ?? '');
6
6
  const asin = extractAsin(payload.href ?? '') ?? null;
@@ -28,10 +28,16 @@ function normalizeDiscussionPayload(payload) {
28
28
  })),
29
29
  };
30
30
  }
31
- async function readDiscussionPayload(page, input, limit) {
32
- const url = buildDiscussionUrl(input);
33
- const state = await gotoAndReadState(page, url, 2500, 'discussion');
34
- assertUsableState(state, 'discussion');
31
+ function hasDiscussionSummary(payload) {
32
+ return Boolean(cleanText(payload.average_rating_text) || cleanText(payload.total_review_count_text));
33
+ }
34
+ function isSignInState(state) {
35
+ const href = cleanText(state.href).toLowerCase();
36
+ const title = cleanText(state.title).toLowerCase();
37
+ return href.includes('/ap/signin')
38
+ || title.includes('amazon sign-in');
39
+ }
40
+ async function readCurrentDiscussionPayload(page, limit) {
35
41
  return await page.evaluate(`
36
42
  (() => ({
37
43
  href: window.location.href,
@@ -53,6 +59,29 @@ async function readDiscussionPayload(page, input, limit) {
53
59
  }))()
54
60
  `);
55
61
  }
62
+ async function readDiscussionPayload(page, input, limit) {
63
+ const reviewUrl = buildDiscussionUrl(input);
64
+ const reviewState = await gotoAndReadState(page, reviewUrl, 2500, 'discussion');
65
+ assertUsableState(reviewState, 'discussion');
66
+ const reviewPayload = await readCurrentDiscussionPayload(page, limit);
67
+ if (hasDiscussionSummary(reviewPayload)) {
68
+ return reviewPayload;
69
+ }
70
+ const productUrl = buildProductUrl(input);
71
+ const productState = await gotoAndReadState(page, productUrl, 2500, 'discussion');
72
+ assertUsableState(productState, 'discussion');
73
+ if (isSignInState(reviewState) && isSignInState(productState)) {
74
+ throw new AuthRequiredError('amazon.com', 'Amazon review discussion requires an active signed-in Amazon session in the shared Chrome profile.');
75
+ }
76
+ const productPayload = await readCurrentDiscussionPayload(page, limit);
77
+ if (hasDiscussionSummary(productPayload)) {
78
+ return productPayload;
79
+ }
80
+ if (isSignInState(reviewState)) {
81
+ throw new CommandExecutionError('amazon review page redirected to sign-in and product page fallback did not expose review summary', 'Open the product page in Chrome, verify reviews are visible, and retry.');
82
+ }
83
+ return reviewPayload;
84
+ }
56
85
  cli({
57
86
  site: 'amazon',
58
87
  name: 'discussion',
@@ -88,4 +117,6 @@ cli({
88
117
  });
89
118
  export const __test__ = {
90
119
  normalizeDiscussionPayload,
120
+ hasDiscussionSummary,
121
+ isSignInState,
91
122
  };
@@ -1,36 +1,151 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
2
4
  import { __test__ } from './discussion.js';
5
+ import './discussion.js';
6
+
7
+ function createPageMock(evaluateResults) {
8
+ const evaluate = vi.fn();
9
+ for (const result of evaluateResults) {
10
+ evaluate.mockResolvedValueOnce(result);
11
+ }
12
+ return {
13
+ goto: vi.fn().mockResolvedValue(undefined),
14
+ wait: vi.fn().mockResolvedValue(undefined),
15
+ evaluate,
16
+ snapshot: vi.fn().mockResolvedValue(undefined),
17
+ click: vi.fn().mockResolvedValue(undefined),
18
+ typeText: vi.fn().mockResolvedValue(undefined),
19
+ pressKey: vi.fn().mockResolvedValue(undefined),
20
+ scrollTo: vi.fn().mockResolvedValue(undefined),
21
+ getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }),
22
+ tabs: vi.fn().mockResolvedValue([]),
23
+ selectTab: vi.fn().mockResolvedValue(undefined),
24
+ networkRequests: vi.fn().mockResolvedValue([]),
25
+ consoleMessages: vi.fn().mockResolvedValue([]),
26
+ scroll: vi.fn().mockResolvedValue(undefined),
27
+ autoScroll: vi.fn().mockResolvedValue(undefined),
28
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
29
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
30
+ getCookies: vi.fn().mockResolvedValue([]),
31
+ screenshot: vi.fn().mockResolvedValue(''),
32
+ waitForCapture: vi.fn().mockResolvedValue(undefined),
33
+ };
34
+ }
35
+
3
36
  describe('amazon discussion normalization', () => {
4
- it('normalizes review summary and sample reviews', () => {
5
- const result = __test__.normalizeDiscussionPayload({
6
- href: 'https://www.amazon.com/product-reviews/B0FJS72893',
7
- average_rating_text: '3.9 out of 5',
8
- total_review_count_text: '27 global ratings',
9
- qa_links: [],
10
- review_samples: [
11
- {
12
- title: '5.0 out of 5 stars Great value and quality',
13
- rating_text: '5.0 out of 5 stars',
14
- author: 'GTreader2',
15
- date_text: 'Reviewed in the United States on February 21, 2026',
16
- body: 'Small but mighty.',
17
- verified: true,
18
- },
19
- ],
20
- });
21
- expect(result.asin).toBe('B0FJS72893');
22
- expect(result.average_rating_value).toBe(3.9);
23
- expect(result.total_review_count).toBe(27);
24
- expect(result.review_samples).toEqual([
25
- {
26
- title: 'Great value and quality',
27
- rating_text: '5.0 out of 5 stars',
28
- rating_value: 5,
29
- author: 'GTreader2',
30
- date_text: 'Reviewed in the United States on February 21, 2026',
31
- body: 'Small but mighty.',
32
- verified_purchase: true,
33
- },
34
- ]);
37
+ it('normalizes review summary and sample reviews', () => {
38
+ const result = __test__.normalizeDiscussionPayload({
39
+ href: 'https://www.amazon.com/product-reviews/B0FJS72893',
40
+ average_rating_text: '3.9 out of 5',
41
+ total_review_count_text: '27 global ratings',
42
+ qa_links: [],
43
+ review_samples: [
44
+ {
45
+ title: '5.0 out of 5 stars Great value and quality',
46
+ rating_text: '5.0 out of 5 stars',
47
+ author: 'GTreader2',
48
+ date_text: 'Reviewed in the United States on February 21, 2026',
49
+ body: 'Small but mighty.',
50
+ verified: true,
51
+ },
52
+ ],
35
53
  });
54
+
55
+ expect(result.asin).toBe('B0FJS72893');
56
+ expect(result.average_rating_value).toBe(3.9);
57
+ expect(result.total_review_count).toBe(27);
58
+ expect(result.review_samples).toEqual([
59
+ {
60
+ title: 'Great value and quality',
61
+ rating_text: '5.0 out of 5 stars',
62
+ rating_value: 5,
63
+ author: 'GTreader2',
64
+ date_text: 'Reviewed in the United States on February 21, 2026',
65
+ body: 'Small but mighty.',
66
+ verified_purchase: true,
67
+ },
68
+ ]);
69
+ });
70
+
71
+ it('falls back to the product page when the review page redirects to sign-in', async () => {
72
+ const command = getRegistry().get('amazon/discussion');
73
+ const page = createPageMock([
74
+ {
75
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
76
+ title: 'Amazon Sign-In',
77
+ body_text: 'Sign in Create account',
78
+ },
79
+ {
80
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
81
+ average_rating_text: '',
82
+ total_review_count_text: '',
83
+ review_samples: [],
84
+ },
85
+ {
86
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
87
+ title: 'Amazon.com: Example product',
88
+ body_text: 'Hello, zejia-wu Reviews',
89
+ },
90
+ {
91
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
92
+ average_rating_text: '4.4 out of 5',
93
+ total_review_count_text: '349 global ratings',
94
+ review_samples: [
95
+ {
96
+ title: '5.0 out of 5 stars Perfect for the office',
97
+ rating_text: '5.0 out of 5 stars',
98
+ author: 'Ken',
99
+ date_text: 'Reviewed in the United States on March 19, 2026',
100
+ body: 'Good for the office, no complaints.',
101
+ verified: true,
102
+ },
103
+ ],
104
+ },
105
+ ]);
106
+
107
+ const result = await command.func(page, { input: 'B09HKN2ZRT', limit: 1 });
108
+
109
+ expect(page.goto.mock.calls.map((call) => call[0])).toEqual([
110
+ 'https://www.amazon.com/product-reviews/B09HKN2ZRT',
111
+ 'https://www.amazon.com/dp/B09HKN2ZRT',
112
+ ]);
113
+ expect(result).toEqual([
114
+ expect.objectContaining({
115
+ asin: 'B09HKN2ZRT',
116
+ discussion_url: 'https://www.amazon.com/dp/B09HKN2ZRT',
117
+ average_rating_value: 4.4,
118
+ total_review_count: 349,
119
+ }),
120
+ ]);
121
+ });
122
+
123
+ it('throws AuthRequiredError when both review and product pages are gated', async () => {
124
+ const command = getRegistry().get('amazon/discussion');
125
+ const authState = {
126
+ href: 'https://www.amazon.com/ap/signin?openid.return_to=https%3A%2F%2Fwww.amazon.com%2Fproduct-reviews%2FB09HKN2ZRT',
127
+ title: 'Amazon Sign-In',
128
+ body_text: 'Sign in Create account',
129
+ };
130
+ const page = createPageMock([
131
+ authState,
132
+ {
133
+ href: authState.href,
134
+ average_rating_text: '',
135
+ total_review_count_text: '',
136
+ review_samples: [],
137
+ },
138
+ authState,
139
+ ]);
140
+
141
+ await expect(command.func(page, { input: 'B09HKN2ZRT', limit: 1 })).rejects.toBeInstanceOf(AuthRequiredError);
142
+ });
143
+
144
+ it('does not treat a public product page with sign-in copy as a gated page', () => {
145
+ expect(__test__.isSignInState({
146
+ href: 'https://www.amazon.com/dp/B09HKN2ZRT',
147
+ title: 'Amazon.com: Example product',
148
+ body_text: 'Hello, sign in Account & Lists Create account',
149
+ })).toBe(false);
150
+ });
36
151
  });
@@ -24,7 +24,7 @@ describe('apple-podcasts search command', () => {
24
24
  }),
25
25
  });
26
26
  vi.stubGlobal('fetch', fetchMock);
27
- const result = await cmd.func(null, {
27
+ const result = await cmd.func({
28
28
  query: 'machine learning',
29
29
  keyword: 'sports',
30
30
  limit: 5,
@@ -60,7 +60,7 @@ describe('apple-podcasts top command', () => {
60
60
  }),
61
61
  });
62
62
  vi.stubGlobal('fetch', fetchMock);
63
- await cmd.func(null, { country: 'US', limit: 1 });
63
+ await cmd.func({ country: 'US', limit: 1 });
64
64
  const [, options] = fetchMock.mock.calls[0] ?? [];
65
65
  expect(options).toBeDefined();
66
66
  expect(options.signal).toBeDefined();
@@ -81,7 +81,7 @@ describe('apple-podcasts top command', () => {
81
81
  }),
82
82
  });
83
83
  vi.stubGlobal('fetch', fetchMock);
84
- const result = await cmd.func(null, { country: 'US', limit: 2 });
84
+ const result = await cmd.func({ country: 'US', limit: 2 });
85
85
  expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
86
86
  signal: expect.any(Object),
87
87
  }));
@@ -94,6 +94,6 @@ describe('apple-podcasts top command', () => {
94
94
  const cmd = getRegistry().get('apple-podcasts/top');
95
95
  expect(cmd?.func).toBeTypeOf('function');
96
96
  vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket hang up')));
97
- await expect(cmd.func(null, { country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
97
+ await expect(cmd.func({ country: 'us', limit: 3 })).rejects.toThrow('Unable to reach Apple Podcasts charts for US');
98
98
  });
99
99
  });
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
13
13
  ],
14
14
  columns: ['title', 'duration', 'date'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const limit = Math.max(1, Math.min(Number(args.limit), 200));
17
17
  // results[0] is the podcast itself; the rest are episodes
18
18
  const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Max results' },
13
13
  ],
14
14
  columns: ['id', 'title', 'author', 'episodes', 'genre', 'url'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const term = encodeURIComponent(args.query);
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 25));
18
18
  const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
@@ -14,7 +14,7 @@ cli({
14
14
  { name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
15
15
  ],
16
16
  columns: ['rank', 'title', 'author', 'id'],
17
- func: async (_page, args) => {
17
+ func: async (args) => {
18
18
  const limit = Math.max(1, Math.min(Number(args.limit), 100));
19
19
  const country = String(args.country || 'us').trim().toLowerCase();
20
20
  const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'id', positional: true, required: true, help: 'arXiv paper ID (e.g. 1706.03762)' },
12
12
  ],
13
13
  columns: ['id', 'title', 'authors', 'published', 'abstract', 'url'],
14
- func: async (_page, args) => {
14
+ func: async (args) => {
15
15
  const xml = await arxivFetch(`id_list=${encodeURIComponent(args.id)}`);
16
16
  const entries = parseEntries(xml);
17
17
  if (!entries.length)
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 10, help: 'Max results (max 25)' },
13
13
  ],
14
14
  columns: ['id', 'title', 'authors', 'published', 'url'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const limit = Math.max(1, Math.min(Number(args.limit), 25));
17
17
  const query = encodeURIComponent(`all:${args.query}`);
18
18
  const xml = await arxivFetch(`search_query=${query}&max_results=${limit}&sortBy=relevance`);
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  /**
4
4
  * band mentions — Show Band notifications where you were @mentioned.
@@ -52,7 +52,7 @@ cli({
52
52
  await page.wait(0.5);
53
53
  }
54
54
  if (!bellReady) {
55
- throw new SelectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
55
+ throw selectorError('button._btnWidgetIcon', 'Notification bell not found. The Band.us UI may have changed.');
56
56
  }
57
57
  // Poll until a capture containing result_data.news arrives, up to maxSecs seconds.
58
58
  // getInterceptedRequests() clears the array on each call, so captures are accumulated
@@ -80,7 +80,7 @@ cli({
80
80
  return true;
81
81
  }`);
82
82
  if (!bellClicked) {
83
- throw new SelectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
83
+ throw selectorError('button._btnWidgetIcon', 'Notification bell disappeared before click. The Band.us UI may have changed.');
84
84
  }
85
85
  const requests = await waitForOneCapture();
86
86
  // Find the get_news response (has result_data.news); get_news_count responses do not.
package/clis/bbc/news.js CHANGED
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 20, help: 'Number of headlines (max 50)' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'description', 'url'],
15
- func: async (page, kwargs) => {
15
+ func: async (kwargs) => {
16
16
  const count = Math.min(kwargs.limit || 20, 50);
17
17
  const resp = await fetch('https://feeds.bbci.co.uk/news/rss.xml');
18
18
  if (!resp.ok)
@@ -1,5 +1,5 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
- import { AuthRequiredError, CommandExecutionError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, CommandExecutionError, EmptyResultError, selectorError } from '@jackwener/opencli/errors';
3
3
  import { apiGet, resolveBvid } from './utils.js';
4
4
  cli({
5
5
  site: 'bilibili',
@@ -23,7 +23,7 @@ cli({
23
23
  return state?.videoData?.cid;
24
24
  })()`);
25
25
  if (!cid) {
26
- throw new SelectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
26
+ throw selectorError('videoData.cid', '无法在页面中提取到当前视频的 CID,请检查页面是否正常加载。');
27
27
  }
28
28
  // 3. 在 Node 端使用 apiGet 获取带 Wbi 签名的字幕列表
29
29
  // 之前纯靠 evaluate 里的 fetch 会失败,因为 B 站 /wbi/ 开头的接口强校验 w_rid,未签名直接被风控返回 403 HTML
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('businessweek', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('economics', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('industries', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('main', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('markets', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('opinions', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('politics', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -11,7 +11,7 @@ cli({
11
11
  { name: 'limit', type: 'int', default: 1, help: 'Number of feed items to return (max 20)' },
12
12
  ],
13
13
  columns: ['title', 'summary', 'link', 'mediaLinks'],
14
- func: async (_page, kwargs) => {
14
+ func: async (kwargs) => {
15
15
  return fetchBloombergFeed('tech', kwargs.limit ?? 1);
16
16
  },
17
17
  });
@@ -2,6 +2,7 @@
2
2
  * BOSS直聘 job search — browser cookie API.
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
+ import { ArgumentError } from '@jackwener/opencli/errors';
5
6
  import { requirePage, navigateTo, bossFetch, verbose } from './utils.js';
6
7
  /** City name → BOSS Zhipin city code mapping */
7
8
  const CITY_CODES = {
@@ -22,8 +23,16 @@ const CITY_CODES = {
22
23
  '香港': '101320100',
23
24
  };
24
25
  const EXP_MAP = {
25
- '不限': '0', '在校/应届': '108', '应届': '108', '1年以内': '101',
26
- '1-3年': '102', '3-5年': '103', '5-10年': '104', '10年以上': '105',
26
+ '不限': '0',
27
+ '在校/应届': '108',
28
+ '在校生': '108', '在校': '108',
29
+ '应届生': '102', '应届': '102',
30
+ '经验不限': '101',
31
+ '1年以内': '103',
32
+ '1-3年': '104',
33
+ '3-5年': '105',
34
+ '5-10年': '106',
35
+ '10年以上': '107',
27
36
  };
28
37
  const DEGREE_MAP = {
29
38
  '不限': '0', '初中及以下': '209', '中专/中技': '208', '高中': '206',
@@ -38,6 +47,10 @@ const INDUSTRY_MAP = {
38
47
  '人工智能': '100901', '大数据': '100902', '金融': '100101',
39
48
  '教育培训': '100200', '医疗健康': '100300',
40
49
  };
50
+ const JOB_TYPE_MAP = {
51
+ '不限': '0', '全职': '1901', '实习': '1902', '兼职': '1903',
52
+ };
53
+ const JOB_TYPE_CODES = new Set(Object.values(JOB_TYPE_MAP));
41
54
  function resolveCity(input) {
42
55
  if (!input)
43
56
  return '101010100';
@@ -62,35 +75,54 @@ function resolveMap(input, map) {
62
75
  }
63
76
  return input;
64
77
  }
78
+ function resolveJobType(input) {
79
+ if (!input)
80
+ return '';
81
+ if (JOB_TYPE_MAP[input] !== undefined)
82
+ return JOB_TYPE_MAP[input];
83
+ if (JOB_TYPE_CODES.has(input))
84
+ return input;
85
+ throw new ArgumentError(`Invalid jobType: ${input}`, 'Use one of: 全职, 兼职, 实习, 不限');
86
+ }
87
+ function formatBossOnline(value) {
88
+ if (value === true)
89
+ return 'Y';
90
+ if (value === false)
91
+ return 'N';
92
+ return '';
93
+ }
65
94
  cli({
66
95
  site: 'boss',
67
96
  name: 'search',
68
- description: 'BOSS直聘搜索职位',
97
+ description: 'BOSS直聘搜索职位(不带关键词时返回为你推荐职位)',
69
98
  domain: 'www.zhipin.com',
70
99
  strategy: Strategy.COOKIE,
71
100
  navigateBefore: false,
72
101
  browser: true,
73
102
  args: [
74
- { name: 'query', required: true, positional: true, help: 'Search keyword (e.g. AI agent, 前端)' },
103
+ { name: 'query', positional: true, help: 'Search keyword (optional, empty = recommended jobs)' },
75
104
  { name: 'city', default: '北京', help: 'City name or code (e.g. 杭州, 上海, 101010100)' },
76
- { name: 'experience', default: '', help: 'Experience: 应届/1年以内/1-3年/3-5年/5-10年/10年以上' },
105
+ { name: 'experience', default: '', help: 'Experience: 在校生(实习)/应届生(校招)/经验不限/1年以内/1-3年/3-5年/5-10年/10年以上' },
77
106
  { name: 'degree', default: '', help: 'Degree: 大专/本科/硕士/博士' },
78
107
  { name: 'salary', default: '', help: 'Salary: 3K以下/3-5K/5-10K/10-15K/15-20K/20-30K/30-50K/50K以上' },
79
108
  { name: 'industry', default: '', help: 'Industry code or name (e.g. 100020, 互联网)' },
109
+ { name: 'jobType', default: '', help: 'Job type: 全职/兼职/实习(不传=不限,混合校招与实习)' },
80
110
  { name: 'page', type: 'int', default: 1, help: 'Page number' },
81
111
  { name: 'limit', type: 'int', default: 15, help: 'Number of results' },
82
112
  ],
83
- columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'security_id', 'url'],
113
+ columns: ['name', 'salary', 'company', 'area', 'experience', 'degree', 'skills', 'boss', 'bossOnline', 'security_id', 'url'],
84
114
  func: async (page, kwargs) => {
85
115
  requirePage(page);
116
+ const query = String(kwargs.query ?? '').trim();
86
117
  const cityCode = resolveCity(kwargs.city);
87
118
  verbose('Navigating to set referrer context...');
88
- await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(kwargs.query)}&city=${cityCode}`);
119
+ await navigateTo(page, `https://www.zhipin.com/web/geek/job?query=${encodeURIComponent(query)}&city=${cityCode}`);
89
120
  await new Promise(r => setTimeout(r, 1000));
90
121
  const expVal = resolveMap(kwargs.experience, EXP_MAP);
91
122
  const degreeVal = resolveMap(kwargs.degree, DEGREE_MAP);
92
123
  const salaryVal = resolveMap(kwargs.salary, SALARY_MAP);
93
124
  const industryVal = resolveMap(kwargs.industry, INDUSTRY_MAP);
125
+ const jobTypeVal = resolveJobType(kwargs.jobType);
94
126
  const limit = kwargs.limit || 15;
95
127
  let currentPage = kwargs.page || 1;
96
128
  let allJobs = [];
@@ -101,7 +133,7 @@ cli({
101
133
  }
102
134
  const qs = new URLSearchParams({
103
135
  scene: '1',
104
- query: kwargs.query,
136
+ query,
105
137
  city: cityCode,
106
138
  page: String(currentPage),
107
139
  pageSize: '15',
@@ -114,6 +146,8 @@ cli({
114
146
  qs.set('salary', salaryVal);
115
147
  if (industryVal)
116
148
  qs.set('industry', industryVal);
149
+ if (jobTypeVal)
150
+ qs.set('jobType', jobTypeVal);
117
151
  const targetUrl = `https://www.zhipin.com/wapi/zpgeek/search/joblist.json?${qs.toString()}`;
118
152
  verbose(`Fetching page ${currentPage}... (current jobs: ${allJobs.length})`);
119
153
  const data = await bossFetch(page, targetUrl);
@@ -135,6 +169,7 @@ cli({
135
169
  degree: j.jobDegree,
136
170
  skills: (j.skills || []).join(','),
137
171
  boss: j.bossName + ' · ' + j.bossTitle,
172
+ bossOnline: formatBossOnline(j.bossOnline),
138
173
  security_id: j.securityId || '',
139
174
  url: 'https://www.zhipin.com/job_detail/' + j.encryptJobId + '.html',
140
175
  });
@@ -153,3 +188,9 @@ cli({
153
188
  return allJobs;
154
189
  },
155
190
  });
191
+ export const __test__ = {
192
+ EXP_MAP,
193
+ resolveMap,
194
+ resolveJobType,
195
+ formatBossOnline,
196
+ };