@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
@@ -0,0 +1,65 @@
1
+ import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
2
+ import { cli, Strategy } from '@jackwener/opencli/registry';
3
+
4
+ const WEIXIN_DOMAIN = 'mp.weixin.qq.com';
5
+
6
+ export const draftsCommand = cli({
7
+ site: 'weixin',
8
+ name: 'drafts',
9
+ description: '列出微信公众号草稿箱',
10
+ domain: WEIXIN_DOMAIN,
11
+ strategy: Strategy.COOKIE,
12
+ browser: true,
13
+ navigateBefore: false,
14
+ timeoutSeconds: 60,
15
+ args: [
16
+ { name: 'limit', type: 'int', default: 10, help: '最多显示条数' },
17
+ ],
18
+ columns: ['Index', 'Title', 'Time'],
19
+
20
+ func: async (page, kwargs) => {
21
+ await page.goto('https://mp.weixin.qq.com/');
22
+ await page.wait(3);
23
+ const token = await page.evaluate(`(window.location.href.match(/token=(\\d+)/)||[])[1]`);
24
+ if (!token) {
25
+ throw new AuthRequiredError(WEIXIN_DOMAIN, '微信公众号草稿箱需要已登录的 mp.weixin.qq.com 会话');
26
+ }
27
+
28
+ await page.goto(`https://mp.weixin.qq.com/cgi-bin/appmsg?begin=0&count=${kwargs.limit}&type=77&action=list_card&token=${token}&lang=zh_CN`);
29
+ await page.wait(4);
30
+
31
+ const drafts = await page.evaluate(`(() => {
32
+ var results = [];
33
+ var idx = 0;
34
+
35
+ var cards = document.querySelectorAll('.weui-desktop-card');
36
+ for (var i = 0; i < cards.length; i++) {
37
+ if (cards[i].className.includes('card_new')) continue;
38
+ var titleEl = cards[i].querySelector('[class*=title]');
39
+ var timeEl = cards[i].querySelector('[class*=tips]');
40
+ var title = titleEl ? titleEl.textContent.trim() : '';
41
+ var time = timeEl ? timeEl.textContent.trim().replace(/\\s+/g, ' ') : '';
42
+ if (title) results.push({ Index: ++idx, Title: title, Time: time });
43
+ }
44
+ if (results.length > 0) return results;
45
+
46
+ var rows = document.querySelectorAll('tr, [class*=appmsg_item], [class*=list_item]');
47
+ rows.forEach(function(row) {
48
+ var titleEl = row.querySelector('[class*=title] a, [class*=title], h4');
49
+ var timeEl = row.querySelector('[class*=time], td:nth-child(2)');
50
+ var title = titleEl ? titleEl.textContent.trim() : '';
51
+ var time = timeEl ? timeEl.textContent.trim() : '';
52
+ if (title && title !== '内容' && title.length < 80) {
53
+ results.push({ Index: ++idx, Title: title, Time: time });
54
+ }
55
+ });
56
+ return results;
57
+ })()`);
58
+
59
+ if (!drafts || drafts.length === 0) {
60
+ throw new EmptyResultError('weixin drafts', 'No structured drafts found in the current Weixin Official Account backend');
61
+ }
62
+
63
+ return drafts.slice(0, kwargs.limit);
64
+ },
65
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import './create-draft.js';
5
+ import './drafts.js';
6
+
7
+ function createPageMock(overrides = {}) {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ wait: vi.fn().mockResolvedValue(undefined),
11
+ evaluate: overrides.evaluate ?? vi.fn().mockResolvedValue(undefined),
12
+ setFileInput: vi.fn().mockResolvedValue(undefined),
13
+ };
14
+ }
15
+
16
+ describe('weixin command registration', () => {
17
+ it('registers create-draft and drafts commands', () => {
18
+ const registry = getRegistry();
19
+ const values = [...registry.values()];
20
+ expect(values.find(c => c.site === 'weixin' && c.name === 'create-draft')).toBeDefined();
21
+ expect(values.find(c => c.site === 'weixin' && c.name === 'drafts')).toBeDefined();
22
+ });
23
+ });
24
+
25
+ describe('weixin drafts command', () => {
26
+ it('throws AuthRequiredError when no session token is available', async () => {
27
+ const command = getRegistry().get('weixin/drafts');
28
+ const page = createPageMock({
29
+ evaluate: vi.fn().mockResolvedValueOnce(undefined),
30
+ });
31
+
32
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(AuthRequiredError);
33
+ });
34
+
35
+ it('fails instead of scraping arbitrary body text when structured selectors miss', async () => {
36
+ const command = getRegistry().get('weixin/drafts');
37
+ const evaluate = vi.fn()
38
+ .mockResolvedValueOnce('123456')
39
+ .mockImplementationOnce(async (script) => {
40
+ expect(script).not.toContain('document.body.innerText');
41
+ return [];
42
+ });
43
+ const page = createPageMock({ evaluate });
44
+
45
+ await expect(command.func(page, { limit: 10 })).rejects.toBeInstanceOf(EmptyResultError);
46
+ });
47
+
48
+ it('returns structured drafts and respects the requested limit', async () => {
49
+ const command = getRegistry().get('weixin/drafts');
50
+ const page = createPageMock({
51
+ evaluate: vi.fn()
52
+ .mockResolvedValueOnce('123456')
53
+ .mockResolvedValueOnce([
54
+ { Index: 1, Title: '第一篇草稿', Time: '2026-04-24 10:00' },
55
+ { Index: 2, Title: '第二篇草稿', Time: '2026-04-24 11:00' },
56
+ ]),
57
+ });
58
+
59
+ const result = await command.func(page, { limit: 1 });
60
+
61
+ expect(result).toEqual([
62
+ { Index: 1, Title: '第一篇草稿', Time: '2026-04-24 10:00' },
63
+ ]);
64
+ });
65
+ });
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'limit', type: 'int', default: 20, help: 'Max results' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'author', 'category', 'readingCount', 'bookId'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const cat = encodeURIComponent(args.category ?? 'all');
17
17
  const data = await fetchWebApi(`/bookListInCategory/${cat}`, { rank: '1' });
18
18
  const books = data?.books ?? [];
@@ -35,7 +35,7 @@ describe('weread/search regression', () => {
35
35
  `),
36
36
  });
37
37
  vi.stubGlobal('fetch', fetchMock);
38
- const result = await command.func(null, { query: 'deep work', limit: 5 });
38
+ const result = await command.func({ query: 'deep work', limit: 5 });
39
39
  expect(fetchMock).toHaveBeenCalledTimes(2);
40
40
  expect(String(fetchMock.mock.calls[0][0])).toContain('keyword=deep+work');
41
41
  expect(String(fetchMock.mock.calls[1][0])).toContain('/web/search/books?keyword=deep+work');
@@ -72,7 +72,7 @@ describe('weread/search regression', () => {
72
72
  text: () => Promise.resolve('<html><body><p>no search cards</p></body></html>'),
73
73
  });
74
74
  vi.stubGlobal('fetch', fetchMock);
75
- const result = await command.func(null, { query: 'deep work', limit: 5 });
75
+ const result = await command.func({ query: 'deep work', limit: 5 });
76
76
  expect(result).toEqual([
77
77
  {
78
78
  rank: 1,
@@ -128,7 +128,7 @@ describe('weread/search regression', () => {
128
128
  `),
129
129
  });
130
130
  vi.stubGlobal('fetch', fetchMock);
131
- const result = await command.func(null, { query: 'cal newport', limit: 5 });
131
+ const result = await command.func({ query: 'cal newport', limit: 5 });
132
132
  expect(result).toEqual([
133
133
  {
134
134
  rank: 1,
@@ -166,7 +166,7 @@ describe('weread/search regression', () => {
166
166
  })
167
167
  .mockRejectedValueOnce(new Error('network timeout'));
168
168
  vi.stubGlobal('fetch', fetchMock);
169
- const result = await command.func(null, { query: 'deep work', limit: 5 });
169
+ const result = await command.func({ query: 'deep work', limit: 5 });
170
170
  expect(result).toEqual([
171
171
  {
172
172
  rank: 1,
@@ -220,7 +220,7 @@ describe('weread/search regression', () => {
220
220
  `),
221
221
  });
222
222
  vi.stubGlobal('fetch', fetchMock);
223
- const result = await command.func(null, { query: '文明', limit: 5 });
223
+ const result = await command.func({ query: '文明', limit: 5 });
224
224
  expect(result).toEqual([
225
225
  {
226
226
  rank: 1,
@@ -279,7 +279,7 @@ describe('weread/search regression', () => {
279
279
  `),
280
280
  });
281
281
  vi.stubGlobal('fetch', fetchMock);
282
- const result = await command.func(null, { query: '文明', limit: 5 });
282
+ const result = await command.func({ query: '文明', limit: 5 });
283
283
  expect(result).toEqual([
284
284
  {
285
285
  rank: 1,
@@ -332,7 +332,7 @@ describe('weread/search regression', () => {
332
332
  `),
333
333
  });
334
334
  vi.stubGlobal('fetch', fetchMock);
335
- const result = await command.func(null, { query: '文明', limit: 5 });
335
+ const result = await command.func({ query: '文明', limit: 5 });
336
336
  expect(result).toEqual([
337
337
  {
338
338
  rank: 1,
@@ -386,7 +386,7 @@ describe('weread/search regression', () => {
386
386
  `),
387
387
  });
388
388
  vi.stubGlobal('fetch', fetchMock);
389
- const result = await command.func(null, { query: '文明', limit: 5 });
389
+ const result = await command.func({ query: '文明', limit: 5 });
390
390
  expect(result).toEqual([
391
391
  {
392
392
  rank: 1,
@@ -124,7 +124,7 @@ cli({
124
124
  { name: 'limit', type: 'int', default: 10, help: 'Max results' },
125
125
  ],
126
126
  columns: ['rank', 'title', 'author', 'bookId', 'url'],
127
- func: async (_page, args) => {
127
+ func: async (args) => {
128
128
  const [data, htmlEntries] = await Promise.all([
129
129
  fetchWebApi('/search/global', { keyword: args.query }),
130
130
  loadSearchHtmlEntries(String(args.query ?? '')),
@@ -9,7 +9,7 @@ cli({
9
9
  browser: false,
10
10
  args: [{ name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' }],
11
11
  columns: ['title', 'description', 'extract', 'url'],
12
- func: async (_page, args) => {
12
+ func: async (args) => {
13
13
  const lang = args.lang || 'en';
14
14
  const data = (await wikiFetch(lang, '/api/rest_v1/page/random/summary'));
15
15
  if (!data?.title)
@@ -13,7 +13,7 @@ cli({
13
13
  { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
14
14
  ],
15
15
  columns: ['title', 'snippet', 'url'],
16
- func: async (_page, args) => {
16
+ func: async (args) => {
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 50));
18
18
  const lang = args.lang || 'en';
19
19
  const q = encodeURIComponent(args.query);
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
13
13
  ],
14
14
  columns: ['title', 'description', 'extract', 'url'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const lang = args.lang || 'en';
17
17
  const title = encodeURIComponent(args.title.replace(/ /g, '_'));
18
18
  const data = (await wikiFetch(lang, `/api/rest_v1/page/summary/${title}`));
@@ -12,7 +12,7 @@ cli({
12
12
  { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
13
13
  ],
14
14
  columns: ['rank', 'title', 'description', 'views'],
15
- func: async (_page, args) => {
15
+ func: async (args) => {
16
16
  const lang = args.lang || 'en';
17
17
  const limit = Math.max(1, Math.min(Number(args.limit), 50));
18
18
  // Use yesterday's UTC date — Wikipedia API expects UTC and yesterday
@@ -1,4 +1,4 @@
1
- import { AuthRequiredError, SelectorError } from '@jackwener/opencli/errors';
1
+ import { AuthRequiredError, selectorError } from '@jackwener/opencli/errors';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { normalizeNumericId } from './utils.js';
4
4
  function buildChatUrl(itemId, peerUserId) {
@@ -105,7 +105,7 @@ cli({
105
105
  throw new AuthRequiredError('www.goofish.com', 'Xianyu chat requires a logged-in browser session');
106
106
  }
107
107
  if (!state?.can_input) {
108
- throw new SelectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
108
+ throw selectorError('闲鱼聊天输入框', '未找到可用的聊天输入框,请确认该会话页已正确加载');
109
109
  }
110
110
  if (!text) {
111
111
  return [{
@@ -123,7 +123,7 @@ cli({
123
123
  }
124
124
  const sent = await page.evaluate(buildSendMessageEvaluate(text));
125
125
  if (!sent?.ok) {
126
- throw new SelectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
126
+ throw selectorError('闲鱼发送按钮', `消息发送失败:${sent?.reason || 'unknown-reason'}`);
127
127
  }
128
128
  await page.wait(1);
129
129
  return [{
@@ -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
  import { normalizeNumericId } from './utils.js';
4
4
  function buildItemUrl(itemId) {
@@ -127,7 +127,7 @@ cli({
127
127
  throw new EmptyResultError('xianyu item', 'Xianyu item detail is blocked by verification or risk control');
128
128
  }
129
129
  if (result?.error === 'mtop-not-ready') {
130
- throw new SelectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
130
+ throw selectorError('window.lib.mtop', '闲鱼页面未完成初始化,无法调用商品详情接口');
131
131
  }
132
132
  if (!result || typeof result !== 'object') {
133
133
  throw new EmptyResultError('xianyu item', '闲鱼商品详情接口未返回有效数据');
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { AuthRequiredError, EmptyResultError, SelectorError } from '@jackwener/opencli/errors';
2
+ import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
3
3
  import { getRegistry } from '@jackwener/opencli/registry';
4
4
  import { __test__ } from './item.js';
5
5
  import './item.js';
@@ -49,8 +49,8 @@ describe('xianyu item command', () => {
49
49
  const page = createPageMock({ error: 'blocked' });
50
50
  await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(EmptyResultError);
51
51
  });
52
- it('keeps SelectorError for true mtop initialization failures', async () => {
52
+ it('keeps SELECTOR code for true mtop initialization failures', async () => {
53
53
  const page = createPageMock({ error: 'mtop-not-ready' });
54
- await expect(command.func(page, { item_id: '1040754408976' })).rejects.toBeInstanceOf(SelectorError);
54
+ await expect(command.func(page, { item_id: '1040754408976' })).rejects.toMatchObject({ code: 'SELECTOR' });
55
55
  });
56
56
  });
@@ -47,6 +47,11 @@ export function noteIdToDate(url) {
47
47
  // Offset by UTC+8 (China Standard Time) so the date matches what XHS users see
48
48
  return new Date((ts + 8 * 3600) * 1000).toISOString().slice(0, 10);
49
49
  }
50
+ export function stripXhsAuthorDateSuffix(value) {
51
+ const text = (value || '').replace(/\s+/g, ' ').trim();
52
+ const stripped = text.replace(/\s*(?:\d{1,2}天前|\d+小时前|\d+分钟前|\d+秒前|刚刚|昨天|前天|\d+周前|\d+个月前|\d{1,2}-\d{1,2}|\d{4}-\d{1,2}-\d{1,2})$/u, '').trim();
53
+ return stripped || text;
54
+ }
50
55
  cli({
51
56
  site: 'xiaohongshu',
52
57
  name: 'search',
@@ -81,6 +86,7 @@ cli({
81
86
  };
82
87
 
83
88
  const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
89
+ const stripXhsAuthorDateSuffix = ${stripXhsAuthorDateSuffix.toString()};
84
90
 
85
91
  const results = [];
86
92
  const seen = new Set();
@@ -90,7 +96,13 @@ cli({
90
96
  if (el.classList.contains('query-note-item')) return;
91
97
 
92
98
  const titleEl = el.querySelector('.title, .note-title, a.title, .footer .title span');
93
- const nameEl = el.querySelector('a.author .name, .name, .author-name, .nick-name, a.author');
99
+ const nameEl = el.querySelector('a.author .name, .author-name, .nick-name, .name');
100
+ const authorWrapEl = el.querySelector('a.author');
101
+ let author = cleanText(nameEl?.textContent || '');
102
+ if (!author && authorWrapEl) {
103
+ const nameChild = authorWrapEl.querySelector('.name');
104
+ author = nameChild ? cleanText(nameChild.textContent || '') : stripXhsAuthorDateSuffix(authorWrapEl.textContent || '');
105
+ }
94
106
  const likesEl = el.querySelector('.count, .like-count, .like-wrapper .count');
95
107
  // Prefer search_result link (preserves xsec_token) over generic /explore/ link
96
108
  const detailLinkEl =
@@ -109,7 +121,7 @@ cli({
109
121
 
110
122
  results.push({
111
123
  title: cleanText(titleEl?.textContent || ''),
112
- author: cleanText(nameEl?.textContent || ''),
124
+ author,
113
125
  likes: cleanText(likesEl?.textContent || '0'),
114
126
  url,
115
127
  author_url: normalizeUrl(authorLinkEl?.getAttribute('href') || ''),
@@ -130,3 +142,6 @@ cli({
130
142
  }));
131
143
  },
132
144
  });
145
+ export const __test__ = {
146
+ stripXhsAuthorDateSuffix,
147
+ };
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { getRegistry } from '@jackwener/opencli/registry';
3
- import { noteIdToDate } from './search.js';
3
+ import { JSDOM } from 'jsdom';
4
+ import { __test__, noteIdToDate } from './search.js';
4
5
  function createPageMock(evaluateResults) {
5
6
  const evaluate = vi.fn();
6
7
  for (const result of evaluateResults) {
@@ -127,6 +128,41 @@ describe('xiaohongshu search', () => {
127
128
  // Two evaluate calls: wait + extraction
128
129
  expect(page.evaluate).toHaveBeenCalledTimes(2);
129
130
  });
131
+ it('separates fallback author text from appended relative date', async () => {
132
+ const cmd = getRegistry().get('xiaohongshu/search');
133
+ expect(cmd?.func).toBeTypeOf('function');
134
+ const dom = new JSDOM(`
135
+ <section class="note-item">
136
+ <a class="cover mask" href="/search_result/68e90be80000000004022e66?xsec_token=test-token"></a>
137
+ <div class="title">数字作者测试</div>
138
+ <a class="author" href="/user/profile/author123">
139
+ <span>数字3天前端</span><span>3天前</span>
140
+ </a>
141
+ <span class="count">8</span>
142
+ </section>
143
+ `, { url: 'https://www.xiaohongshu.com/search_result?keyword=test' });
144
+ const page = createPageMock([]);
145
+ page.evaluate.mockImplementationOnce(async () => 'content');
146
+ page.evaluate.mockImplementationOnce(async (script) => Function('document', `return (${script})`)(dom.window.document));
147
+
148
+ const result = await cmd.func(page, { query: '测试', limit: 1 });
149
+
150
+ expect(result[0]).toMatchObject({
151
+ title: '数字作者测试',
152
+ author: '数字3天前端',
153
+ likes: '8',
154
+ author_url: 'https://www.xiaohongshu.com/user/profile/author123',
155
+ });
156
+ });
157
+ });
158
+ describe('stripXhsAuthorDateSuffix', () => {
159
+ it('only strips trailing date suffixes and preserves date-like author text', () => {
160
+ expect(__test__.stripXhsAuthorDateSuffix('作者名 3天前')).toBe('作者名');
161
+ expect(__test__.stripXhsAuthorDateSuffix('作者名2026-04-01')).toBe('作者名');
162
+ expect(__test__.stripXhsAuthorDateSuffix('3天前端工程师')).toBe('3天前端工程师');
163
+ expect(__test__.stripXhsAuthorDateSuffix('刚刚好')).toBe('刚刚好');
164
+ expect(__test__.stripXhsAuthorDateSuffix('刚刚')).toBe('刚刚');
165
+ });
130
166
  });
131
167
  describe('noteIdToDate (ObjectID timestamp parsing)', () => {
132
168
  it('parses a known note ID to the correct China-timezone date', () => {
@@ -18,7 +18,7 @@ cli({
18
18
  { name: 'output', default: './xiaoyuzhou-downloads', help: 'Output directory' },
19
19
  ],
20
20
  columns: ['title', 'podcast', 'status', 'size', 'file'],
21
- func: async (_page, args) => {
21
+ func: async (args) => {
22
22
  const credentials = loadXiaoyuzhouCredentials();
23
23
  const response = await requestXiaoyuzhouJson('/v1/episode/get', {
24
24
  query: { eid: args.id },
@@ -68,7 +68,7 @@ describe('xiaoyuzhou download', () => {
68
68
  });
69
69
  mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
70
70
 
71
- const result = await cmd.func(null, {
71
+ const result = await cmd.func({
72
72
  id: 'ep123',
73
73
  output: '/tmp/xiaoyuzhou-test',
74
74
  });
@@ -106,7 +106,7 @@ describe('xiaoyuzhou download', () => {
106
106
  });
107
107
  mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
108
108
 
109
- const result = await cmd.func(null, {
109
+ const result = await cmd.func({
110
110
  id: 'ep456',
111
111
  output: '/tmp/xiaoyuzhou-test',
112
112
  });
@@ -125,7 +125,7 @@ describe('xiaoyuzhou download', () => {
125
125
  },
126
126
  });
127
127
 
128
- await expect(cmd.func(null, { id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
128
+ await expect(cmd.func({ id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
129
129
  code: 'PARSE_ERROR',
130
130
  message: 'Audio URL not found in episode payload',
131
131
  hint: 'Episode payload does not expose media.source.url',
@@ -11,7 +11,7 @@ cli({
11
11
  browser: false,
12
12
  args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
13
13
  columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
14
- func: async (_page, args) => {
14
+ func: async (args) => {
15
15
  const credentials = loadXiaoyuzhouCredentials();
16
16
  const response = await requestXiaoyuzhouJson('/v1/episode/get', {
17
17
  query: { eid: args.id },
@@ -14,7 +14,7 @@ cli({
14
14
  { name: 'limit', type: 'int', default: 20, help: 'Max episodes to show' },
15
15
  ],
16
16
  columns: ['eid', 'title', 'duration', 'plays', 'date'],
17
- func: async (_page, args) => {
17
+ func: async (args) => {
18
18
  const requestedLimit = Number(args.limit);
19
19
  if (!Number.isInteger(requestedLimit) || requestedLimit < 1) {
20
20
  throw new CliError('INVALID_ARGUMENT', 'limit must be a positive integer', 'Example: --limit 5');
@@ -44,7 +44,7 @@ describe('xiaoyuzhou podcast-episodes', () => {
44
44
  ],
45
45
  });
46
46
 
47
- const result = await cmd.func(null, {
47
+ const result = await cmd.func({
48
48
  id: 'podcast-1',
49
49
  limit: 3,
50
50
  });
@@ -66,7 +66,7 @@ describe('xiaoyuzhou podcast-episodes', () => {
66
66
  });
67
67
 
68
68
  it('rejects non-positive limits before hitting the API', async () => {
69
- await expect(cmd.func(null, {
69
+ await expect(cmd.func({
70
70
  id: 'podcast-1',
71
71
  limit: 0,
72
72
  })).rejects.toMatchObject({
@@ -11,7 +11,7 @@ cli({
11
11
  browser: false,
12
12
  args: [{ name: 'id', positional: true, required: true, help: 'Podcast ID (from xiaoyuzhoufm.com URL)' }],
13
13
  columns: ['title', 'author', 'description', 'subscribers', 'episodes', 'updated'],
14
- func: async (_page, args) => {
14
+ func: async (args) => {
15
15
  const credentials = loadXiaoyuzhouCredentials();
16
16
  const response = await requestXiaoyuzhouJson('/v1/podcast/get', {
17
17
  query: { pid: args.id },
@@ -18,7 +18,7 @@ cli({
18
18
  { name: 'text', type: 'boolean', default: true, help: 'Save extracted transcript text file' },
19
19
  ],
20
20
  columns: ['title', 'podcast', 'status', 'segments', 'json_file', 'text_file'],
21
- func: async (_page, kwargs) => {
21
+ func: async (kwargs) => {
22
22
  if (kwargs.json === false && kwargs.text === false) {
23
23
  throw new ArgumentError('At least one of --json or --text must be enabled', 'Example: opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --text true');
24
24
  }
@@ -67,7 +67,7 @@ describe('xiaoyuzhou transcript', () => {
67
67
  mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
68
68
  segments: [{ text: 'hello' }, { text: 'world' }],
69
69
  }));
70
- const result = await cmd.func(null, {
70
+ const result = await cmd.func({
71
71
  id: 'ep123',
72
72
  output: '/tmp/xiaoyuzhou-transcripts',
73
73
  json: true,
@@ -112,7 +112,7 @@ describe('xiaoyuzhou transcript', () => {
112
112
  },
113
113
  });
114
114
  mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({ text: 'hello' }));
115
- await cmd.func(null, {
115
+ await cmd.func({
116
116
  id: 'ep456',
117
117
  output: '/tmp/xiaoyuzhou-transcripts',
118
118
  json: false,
@@ -137,7 +137,7 @@ describe('xiaoyuzhou transcript', () => {
137
137
  credentials: { access_token: 'access-1', refresh_token: 'refresh-1' },
138
138
  data: {},
139
139
  });
140
- await expect(cmd.func(null, {
140
+ await expect(cmd.func({
141
141
  id: 'ep123',
142
142
  output: '/tmp/xiaoyuzhou-transcripts',
143
143
  json: true,
@@ -168,7 +168,7 @@ describe('xiaoyuzhou transcript', () => {
168
168
  mockFetchTranscriptBody.mockResolvedValue(JSON.stringify({
169
169
  segments: [{ startAt: 0, endAt: 1 }],
170
170
  }));
171
- await expect(cmd.func(null, {
171
+ await expect(cmd.func({
172
172
  id: 'ep123',
173
173
  output: '/tmp/xiaoyuzhou-transcripts',
174
174
  json: true,
@@ -181,7 +181,7 @@ describe('xiaoyuzhou transcript', () => {
181
181
  });
182
182
 
183
183
  it('rejects disabling both json and text outputs', async () => {
184
- await expect(cmd.func(null, {
184
+ await expect(cmd.func({
185
185
  id: 'ep123',
186
186
  output: '/tmp/xiaoyuzhou-transcripts',
187
187
  json: false,
@@ -10,7 +10,7 @@ cli({
10
10
  { name: 'type', default: 'all', choices: ['all', 'image', 'video', 'tool'], help: 'Filter by model type' },
11
11
  ],
12
12
  columns: ['type', 'model', 'credits', 'description'],
13
- func: async (_page, kwargs) => {
13
+ func: async (kwargs) => {
14
14
  const filter = kwargs.type;
15
15
  const rows = [];
16
16
  if (filter === 'all' || filter === 'image') {
@@ -3,6 +3,21 @@
3
3
  */
4
4
  import { cli, Strategy } from '@jackwener/opencli/registry';
5
5
  import { CommandExecutionError } from '@jackwener/opencli/errors';
6
+
7
+ export function extractSelectedRichGridContents(browseData) {
8
+ const tabs = browseData?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
9
+ const readRichGrid = (tab) => tab?.tabRenderer?.content?.richGridRenderer?.contents;
10
+ const selectedTab = tabs.find(t => t?.tabRenderer?.selected);
11
+ const selectedContents = readRichGrid(selectedTab);
12
+ if (Array.isArray(selectedContents))
13
+ return selectedContents;
14
+ const fallbackContents = readRichGrid(tabs.find(t => {
15
+ const contents = readRichGrid(t);
16
+ return Array.isArray(contents) && contents.length > 0;
17
+ })) || readRichGrid(tabs.find(t => Array.isArray(readRichGrid(t))));
18
+ return Array.isArray(fallbackContents) ? fallbackContents : [];
19
+ }
20
+
6
21
  cli({
7
22
  site: 'youtube',
8
23
  name: 'channel',
@@ -27,6 +42,7 @@ cli({
27
42
  const apiKey = cfg.INNERTUBE_API_KEY;
28
43
  const context = cfg.INNERTUBE_CONTEXT;
29
44
  if (!apiKey || !context) return {error: 'YouTube config not found'};
45
+ const extractSelectedRichGridContents = ${extractSelectedRichGridContents.toString()};
30
46
 
31
47
  // Resolve handle to browseId if needed
32
48
  let browseId = channelId;
@@ -133,7 +149,10 @@ cli({
133
149
  });
134
150
  if (videosResp.ok) {
135
151
  const videosData = await videosResp.json();
136
- const richGrid = videosData.contents?.twoColumnBrowseResultsRenderer?.tabs?.[0]?.tabRenderer?.content?.richGridRenderer?.contents || [];
152
+ // The InnerTube response includes ALL tabs (Home/Videos/Shorts/...),
153
+ // not just the requested one. Prefer the selected tab, but keep
154
+ // older single-tab responses working when YouTube omits selected.
155
+ const richGrid = extractSelectedRichGridContents(videosData);
137
156
  for (const item of richGrid) {
138
157
  if (recentVideos.length >= limit) break;
139
158
  const v = item.richItemRenderer?.content?.videoRenderer;
@@ -183,3 +202,7 @@ cli({
183
202
  return rows;
184
203
  },
185
204
  });
205
+
206
+ export const __test__ = {
207
+ extractSelectedRichGridContents,
208
+ };