@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -0,0 +1,94 @@
1
+ // eastmoney rank — market mover list across common segments.
2
+ //
3
+ // Data source: push2.eastmoney.com/api/qt/clist/get (Tier 1 public JSON).
4
+ // opencli eastmoney rank
5
+ // opencli eastmoney rank --market cyb --sort turnover --limit 30
6
+
7
+ import { cli, Strategy } from '@jackwener/opencli/registry';
8
+ import { CliError } from '@jackwener/opencli/errors';
9
+
10
+ const MARKETS = {
11
+ 'hs-a': 'm:0+t:6,m:0+t:80,m:1+t:2,m:1+t:23,m:0+t:81+s:2048', // 沪深 A
12
+ 'sh-a': 'm:1+t:2,m:1+t:23', // 沪 A
13
+ 'sz-a': 'm:0+t:6,m:0+t:80', // 深 A
14
+ 'bj-a': 'm:0+t:81+s:2048', // 北证 A
15
+ 'cyb': 'm:0+t:80', // 创业板
16
+ 'kcb': 'm:1+t:23', // 科创板
17
+ 'hk': 'm:116+t:3,m:116+t:4,m:116+t:1,m:116+t:2', // 港股
18
+ 'us': 'm:105,m:106,m:107', // 美股
19
+ };
20
+
21
+ const SORTS = {
22
+ change: { fid: 'f3', order: 'desc' }, // 涨幅榜
23
+ drop: { fid: 'f3', order: 'asc' }, // 跌幅榜
24
+ turnover:{ fid: 'f6', order: 'desc' }, // 成交额
25
+ volume: { fid: 'f5', order: 'desc' }, // 成交量
26
+ amplitude:{ fid:'f7', order: 'desc' }, // 振幅
27
+ rate: { fid: 'f8', order: 'desc' }, // 换手率
28
+ };
29
+
30
+ const FIELDS =
31
+ 'f2,f3,f4,f5,f6,f7,f8,f9,f10,f12,f13,f14,f15,f16,f17,f18,f20,f21,f23';
32
+
33
+ cli({
34
+ site: 'eastmoney',
35
+ name: 'rank',
36
+ description: '东财市场涨跌/成交排行(沪深/北证/创/科/港/美)',
37
+ domain: 'push2.eastmoney.com',
38
+ strategy: Strategy.PUBLIC,
39
+ browser: false,
40
+ args: [
41
+ { name: 'market', type: 'string', default: 'hs-a', help: '市场:hs-a / sh-a / sz-a / bj-a / cyb / kcb / hk / us' },
42
+ { name: 'sort', type: 'string', default: 'change', help: '排序:change / drop / turnover / volume / amplitude / rate' },
43
+ { name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
44
+ ],
45
+ columns: ['rank', 'code', 'name', 'price', 'changePercent', 'change', 'turnover', 'volume', 'turnoverRate', 'peDynamic', 'marketCap'],
46
+ func: async (_page, args) => {
47
+ const market = String(args.market ?? 'hs-a').toLowerCase();
48
+ const sortKey = String(args.sort ?? 'change').toLowerCase();
49
+ const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
50
+
51
+ const fs = MARKETS[market];
52
+ if (!fs) {
53
+ throw new CliError('INVALID_ARGUMENT', `Unknown market "${market}". Valid: ${Object.keys(MARKETS).join(', ')}`);
54
+ }
55
+ const sort = SORTS[sortKey];
56
+ if (!sort) {
57
+ throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
58
+ }
59
+
60
+ const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
61
+ url.searchParams.set('pn', '1');
62
+ url.searchParams.set('pz', String(limit));
63
+ url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
64
+ url.searchParams.set('np', '1');
65
+ url.searchParams.set('fltt', '2');
66
+ url.searchParams.set('invt', '2');
67
+ url.searchParams.set('fid', sort.fid);
68
+ url.searchParams.set('fs', fs);
69
+ url.searchParams.set('fields', FIELDS);
70
+ url.searchParams.set('ut', 'bd1d9ddb04089700cf9c27f6f7426281');
71
+
72
+ const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' } });
73
+ if (!resp.ok) throw new CliError('HTTP_ERROR', `eastmoney rank failed: HTTP ${resp.status}`);
74
+ const data = await resp.json();
75
+ const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
76
+ if (diff.length === 0) {
77
+ throw new CliError('NO_DATA', 'eastmoney returned no rank data', `market=${market} sort=${sortKey}`);
78
+ }
79
+
80
+ return diff.slice(0, limit).map((it, i) => ({
81
+ rank: i + 1,
82
+ code: it.f12,
83
+ name: it.f14,
84
+ price: it.f2,
85
+ changePercent: it.f3,
86
+ change: it.f4,
87
+ turnover: it.f6,
88
+ volume: it.f5,
89
+ turnoverRate: it.f8,
90
+ peDynamic: it.f9,
91
+ marketCap: it.f20,
92
+ }));
93
+ },
94
+ });
@@ -0,0 +1,76 @@
1
+ // eastmoney sectors — industry / concept / region sector board ranking.
2
+ //
3
+ // opencli eastmoney sectors
4
+ // opencli eastmoney sectors --type concept --sort money-flow --limit 30
5
+
6
+ import { cli, Strategy } from '@jackwener/opencli/registry';
7
+ import { CliError } from '@jackwener/opencli/errors';
8
+
9
+ const SECTOR_TYPES = {
10
+ industry: 'm:90+t:2',
11
+ concept: 'm:90+t:3',
12
+ region: 'm:90+t:1',
13
+ };
14
+
15
+ const SORTS = {
16
+ change: { fid: 'f3', order: 'desc' },
17
+ drop: { fid: 'f3', order: 'asc' },
18
+ 'money-flow': { fid: 'f62', order: 'desc' },
19
+ 'out-flow': { fid: 'f62', order: 'asc' },
20
+ turnover: { fid: 'f6', order: 'desc' },
21
+ };
22
+
23
+ cli({
24
+ site: 'eastmoney',
25
+ name: 'sectors',
26
+ description: '板块排行(行业/概念/地域)按涨跌幅、主力资金或成交额排序',
27
+ domain: 'push2.eastmoney.com',
28
+ strategy: Strategy.PUBLIC,
29
+ browser: false,
30
+ args: [
31
+ { name: 'type', type: 'string', default: 'industry', help: '板块类型:industry / concept / region' },
32
+ { name: 'sort', type: 'string', default: 'change', help: '排序:change / drop / money-flow / out-flow / turnover' },
33
+ { name: 'limit', type: 'int', default: 20, help: '返回数量 (max 100)' },
34
+ ],
35
+ columns: ['rank', 'code', 'name', 'price', 'changePercent', 'mainNet', 'leadStock', 'leadChangePercent', 'upCount', 'downCount'],
36
+ func: async (_page, args) => {
37
+ const typeKey = String(args.type ?? 'industry').toLowerCase();
38
+ const fs = SECTOR_TYPES[typeKey];
39
+ if (!fs) throw new CliError('INVALID_ARGUMENT', `Unknown sector type "${typeKey}". Valid: ${Object.keys(SECTOR_TYPES).join(', ')}`);
40
+ const sortKey = String(args.sort ?? 'change').toLowerCase();
41
+ const sort = SORTS[sortKey];
42
+ if (!sort) throw new CliError('INVALID_ARGUMENT', `Unknown sort "${sortKey}". Valid: ${Object.keys(SORTS).join(', ')}`);
43
+ const limit = Math.max(1, Math.min(Number(args.limit) || 20, 100));
44
+
45
+ const url = new URL('https://push2.eastmoney.com/api/qt/clist/get');
46
+ url.searchParams.set('pn', '1');
47
+ url.searchParams.set('pz', String(limit));
48
+ url.searchParams.set('po', sort.order === 'desc' ? '1' : '0');
49
+ url.searchParams.set('np', '1');
50
+ url.searchParams.set('fltt', '2');
51
+ url.searchParams.set('invt', '2');
52
+ url.searchParams.set('fid', sort.fid);
53
+ url.searchParams.set('fs', fs);
54
+ url.searchParams.set('fields', 'f12,f14,f2,f3,f62,f104,f105,f128,f136,f140,f141');
55
+ url.searchParams.set('ut', 'b2884a393a59ad64002292a3e90d46a5');
56
+
57
+ const resp = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } });
58
+ if (!resp.ok) throw new CliError('HTTP_ERROR', `sectors failed: HTTP ${resp.status}`);
59
+ const data = await resp.json();
60
+ const diff = Array.isArray(data?.data?.diff) ? data.data.diff : [];
61
+ if (diff.length === 0) throw new CliError('NO_DATA', 'eastmoney returned no sector data');
62
+
63
+ return diff.slice(0, limit).map((it, i) => ({
64
+ rank: i + 1,
65
+ code: it.f12,
66
+ name: it.f14,
67
+ price: it.f2,
68
+ changePercent: it.f3,
69
+ mainNet: it.f62,
70
+ leadStock: it.f128,
71
+ leadChangePercent: it.f136,
72
+ upCount: it.f104,
73
+ downCount: it.f105,
74
+ }));
75
+ },
76
+ });
@@ -0,0 +1,58 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
3
+
4
+ cli({
5
+ site: 'google-scholar',
6
+ name: 'search',
7
+ description: 'Google Scholar 学术搜索',
8
+ domain: 'scholar.google.com',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: true,
11
+ args: [
12
+ { name: 'query', positional: true, required: true, help: '搜索关键词' },
13
+ { name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
14
+ ],
15
+ columns: ['rank', 'title', 'authors', 'source', 'year', 'cited', 'url'],
16
+ navigateBefore: false,
17
+ func: async (page, kwargs) => {
18
+ const limit = clampInt(kwargs.limit, 10, 1, 20);
19
+ const query = requireNonEmptyQuery(kwargs.query);
20
+ await page.goto(`https://scholar.google.com/scholar?q=${encodeURIComponent(query)}&hl=zh-CN`);
21
+ await page.wait(3);
22
+ const data = await page.evaluate(`
23
+ (() => {
24
+ const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
25
+ const results = [];
26
+ for (const el of document.querySelectorAll('.gs_r.gs_or.gs_scl, .gs_ri')) {
27
+ const container = el.querySelector('.gs_ri') || el;
28
+ const titleEl = container.querySelector('.gs_rt a, h3 a');
29
+ const title = normalize(titleEl?.textContent);
30
+ if (!title) continue;
31
+
32
+ const url = titleEl?.getAttribute('href') || '';
33
+ const infoLine = normalize(container.querySelector('.gs_a')?.textContent);
34
+ const parts = infoLine.split(' - ');
35
+ const authors = (parts[0] || '').trim();
36
+ const sourceParts = (parts[1] || '').split(',');
37
+ const source = sourceParts.slice(0, -1).join(',').trim() || sourceParts[0]?.trim() || '';
38
+ const year = infoLine.match(/(19|20)\\d{2}/)?.[0] || '';
39
+ const citedText = normalize(container.querySelector('.gs_fl a[href*="cites"]')?.textContent);
40
+ const cited = citedText.match(/(\\d+)/)?.[1] || '0';
41
+
42
+ results.push({
43
+ rank: results.length + 1,
44
+ title,
45
+ authors: authors.slice(0, 80),
46
+ source: source.slice(0, 60),
47
+ year,
48
+ cited,
49
+ url,
50
+ });
51
+ if (results.length >= ${limit}) break;
52
+ }
53
+ return results;
54
+ })()
55
+ `);
56
+ return Array.isArray(data) ? data : [];
57
+ },
58
+ });
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './search.js';
4
+
5
+ describe('google-scholar search command', () => {
6
+ const command = getRegistry().get('google-scholar/search');
7
+
8
+ it('registers as a public browser command', () => {
9
+ expect(command).toBeDefined();
10
+ expect(command.site).toBe('google-scholar');
11
+ expect(command.strategy).toBe('public');
12
+ expect(command.browser).toBe(true);
13
+ });
14
+
15
+ it('rejects empty queries before browser navigation', async () => {
16
+ const page = { goto: vi.fn() };
17
+ await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
18
+ name: 'ArgumentError',
19
+ code: 'ARGUMENT',
20
+ });
21
+ expect(page.goto).not.toHaveBeenCalled();
22
+ });
23
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './search.js';
4
+ import './recent.js';
5
+
6
+ describe('gov-law commands', () => {
7
+ const search = getRegistry().get('gov-law/search');
8
+ const recent = getRegistry().get('gov-law/recent');
9
+
10
+ it('registers both commands as public browser commands', () => {
11
+ expect(search).toBeDefined();
12
+ expect(recent).toBeDefined();
13
+ expect(search.strategy).toBe('public');
14
+ expect(recent.strategy).toBe('public');
15
+ expect(search.browser).toBe(true);
16
+ expect(recent.browser).toBe(true);
17
+ });
18
+
19
+ it('rejects empty search queries before browser navigation', async () => {
20
+ const page = { goto: vi.fn() };
21
+ await expect(search.func(page, { query: ' ' })).rejects.toMatchObject({
22
+ name: 'ArgumentError',
23
+ code: 'ARGUMENT',
24
+ });
25
+ expect(page.goto).not.toHaveBeenCalled();
26
+ });
27
+
28
+ it('throws a descriptive error when Vue Router is unavailable', async () => {
29
+ const page = {
30
+ goto: vi.fn(),
31
+ wait: vi.fn(),
32
+ evaluate: vi.fn().mockResolvedValue(false),
33
+ };
34
+ await expect(recent.func(page, { limit: 3 })).rejects.toMatchObject({
35
+ name: 'CliError',
36
+ code: 'FRAMEWORK_CHANGED',
37
+ });
38
+ });
39
+ });
@@ -0,0 +1,22 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { clampInt } from '../_shared/common.js';
3
+ import { extractLawResults, navigateViaVueRouter } from './shared.js';
4
+
5
+ cli({
6
+ site: 'gov-law',
7
+ name: 'recent',
8
+ description: '最新法律法规',
9
+ domain: 'flk.npc.gov.cn',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: true,
12
+ args: [
13
+ { name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
14
+ ],
15
+ columns: ['rank', 'title', 'status', 'publish_date', 'type', 'department'],
16
+ navigateBefore: false,
17
+ func: async (page, kwargs) => {
18
+ const limit = clampInt(kwargs.limit, 10, 1, 20);
19
+ await navigateViaVueRouter(page, {});
20
+ return extractLawResults(page, limit);
21
+ },
22
+ });
@@ -0,0 +1,41 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
3
+ import { extractLawResults, navigateViaVueRouter } from './shared.js';
4
+
5
+ cli({
6
+ site: 'gov-law',
7
+ name: 'search',
8
+ description: '国家法律法规数据库搜索',
9
+ domain: 'flk.npc.gov.cn',
10
+ strategy: Strategy.PUBLIC,
11
+ browser: true,
12
+ args: [
13
+ { name: 'query', positional: true, required: true, help: '搜索关键词' },
14
+ { name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
15
+ ],
16
+ columns: ['rank', 'title', 'status', 'publish_date', 'type', 'department'],
17
+ navigateBefore: false,
18
+ func: async (page, kwargs) => {
19
+ const limit = clampInt(kwargs.limit, 10, 1, 20);
20
+ const query = requireNonEmptyQuery(kwargs.query);
21
+ await navigateViaVueRouter(page, { searchWord: query });
22
+
23
+ const encodedQuery = JSON.stringify(query);
24
+ await page.evaluate(`
25
+ (async () => {
26
+ const input = document.querySelector('.el-input__inner');
27
+ if (input && !input.value) {
28
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set;
29
+ setter.call(input, ${encodedQuery});
30
+ input.dispatchEvent(new Event('input', { bubbles: true }));
31
+ input.dispatchEvent(new Event('change', { bubbles: true }));
32
+ await new Promise(r => setTimeout(r, 300));
33
+ input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true }));
34
+ input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', keyCode: 13, bubbles: true }));
35
+ }
36
+ })()
37
+ `);
38
+ await page.wait(3);
39
+ return extractLawResults(page, limit);
40
+ },
41
+ });
@@ -0,0 +1,51 @@
1
+ import { CliError } from '@jackwener/opencli/errors';
2
+
3
+ export async function navigateViaVueRouter(page, query) {
4
+ await page.goto('https://flk.npc.gov.cn/index.html');
5
+ await page.wait(4);
6
+
7
+ const routerAvailable = await page.evaluate(`
8
+ (async () => {
9
+ const app = document.querySelector('#app');
10
+ const router = app?.__vue_app__?.config?.globalProperties?.$router;
11
+ if (!router) return false;
12
+ await router.push({ path: '/search', query: ${JSON.stringify(query)} });
13
+ return true;
14
+ })()
15
+ `);
16
+
17
+ if (!routerAvailable) {
18
+ throw new CliError(
19
+ 'FRAMEWORK_CHANGED',
20
+ 'Could not access Vue Router on flk.npc.gov.cn — the site may have been restructured.',
21
+ 'Please report this issue so the adapter can be updated.',
22
+ );
23
+ }
24
+
25
+ await page.wait(5);
26
+ }
27
+
28
+ export async function extractLawResults(page, limit) {
29
+ const data = await page.evaluate(`
30
+ (async () => {
31
+ const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
32
+ for (let i = 0; i < 40; i++) {
33
+ if (document.querySelectorAll('.result-item').length > 0) break;
34
+ await new Promise(r => setTimeout(r, 500));
35
+ }
36
+ const results = [];
37
+ for (const el of document.querySelectorAll('.result-item')) {
38
+ const title = normalize(el.querySelector('.title-content')?.textContent);
39
+ if (!title) continue;
40
+ const status = normalize(el.querySelector('[class*="status"]')?.textContent);
41
+ const publish_date = normalize(el.querySelector('.publish-time')?.textContent).replace(/^公布日期[::]\\s*/, '');
42
+ const type = normalize(el.querySelector('.type')?.textContent);
43
+ const department = normalize(el.querySelector('.department')?.textContent);
44
+ results.push({ rank: results.length + 1, title, status, publish_date, type, department });
45
+ if (results.length >= ${limit}) break;
46
+ }
47
+ return results;
48
+ })()
49
+ `);
50
+ return Array.isArray(data) ? data : [];
51
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './search.js';
4
+ import './recent.js';
5
+
6
+ describe('gov-policy commands', () => {
7
+ const search = getRegistry().get('gov-policy/search');
8
+ const recent = getRegistry().get('gov-policy/recent');
9
+
10
+ it('registers both commands as public browser commands', () => {
11
+ expect(search).toBeDefined();
12
+ expect(recent).toBeDefined();
13
+ expect(search.browser).toBe(true);
14
+ expect(recent.browser).toBe(true);
15
+ expect(search.strategy).toBe('public');
16
+ expect(recent.strategy).toBe('public');
17
+ });
18
+
19
+ it('rejects empty search queries before browser navigation', async () => {
20
+ const page = { goto: vi.fn() };
21
+ await expect(search.func(page, { query: ' ' })).rejects.toMatchObject({
22
+ name: 'ArgumentError',
23
+ code: 'ARGUMENT',
24
+ });
25
+ expect(page.goto).not.toHaveBeenCalled();
26
+ });
27
+ });
@@ -0,0 +1,47 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { clampInt } from '../_shared/common.js';
3
+
4
+ cli({
5
+ site: 'gov-policy',
6
+ name: 'recent',
7
+ description: '国务院最新政策文件',
8
+ domain: 'www.gov.cn',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: true,
11
+ args: [
12
+ { name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
13
+ ],
14
+ columns: ['rank', 'title', 'date', 'source', 'url'],
15
+ navigateBefore: false,
16
+ func: async (page, kwargs) => {
17
+ const limit = clampInt(kwargs.limit, 10, 1, 20);
18
+ await page.goto('https://www.gov.cn/zhengce/zuixin/index.htm');
19
+ await page.wait(4);
20
+ const data = await page.evaluate(`
21
+ (async () => {
22
+ const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
23
+ for (let i = 0; i < 20; i++) {
24
+ if (document.querySelector('.news_box li, .list li, .list_item, .news-list li')) break;
25
+ await new Promise(r => setTimeout(r, 500));
26
+ }
27
+ const results = [];
28
+ for (const el of document.querySelectorAll('.news_box li, .list li, .list_item, .news-list li')) {
29
+ const titleEl = el.querySelector('a');
30
+ const title = normalize(titleEl?.textContent);
31
+ if (!title || title.length < 4) continue;
32
+
33
+ let url = titleEl?.getAttribute('href') || '';
34
+ if (url && !url.startsWith('http')) url = 'https://www.gov.cn' + url;
35
+
36
+ const date = (el.textContent || '').match(/(\\d{4}[-./]\\d{1,2}[-./]\\d{1,2})/)?.[1] || '';
37
+ const source = normalize(el.querySelector('.source, .from')?.textContent);
38
+
39
+ results.push({ rank: results.length + 1, title, date, source, url });
40
+ if (results.length >= ${limit}) break;
41
+ }
42
+ return results;
43
+ })()
44
+ `);
45
+ return Array.isArray(data) ? data : [];
46
+ },
47
+ });
@@ -0,0 +1,48 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
3
+
4
+ cli({
5
+ site: 'gov-policy',
6
+ name: 'search',
7
+ description: '中国政府网政策文件搜索',
8
+ domain: 'sousuo.www.gov.cn',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: true,
11
+ args: [
12
+ { name: 'query', positional: true, required: true, help: '搜索关键词' },
13
+ { name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
14
+ ],
15
+ columns: ['rank', 'title', 'description', 'date', 'url'],
16
+ navigateBefore: false,
17
+ func: async (page, kwargs) => {
18
+ const limit = clampInt(kwargs.limit, 10, 1, 20);
19
+ const query = requireNonEmptyQuery(kwargs.query);
20
+ await page.goto(`https://sousuo.www.gov.cn/sousuo/search.shtml?code=17da70961a7&dataTypeId=107&searchWord=${encodeURIComponent(query)}`);
21
+ await page.wait(5);
22
+ const data = await page.evaluate(`
23
+ (async () => {
24
+ const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
25
+ for (let i = 0; i < 30; i++) {
26
+ if (document.querySelectorAll('.basic_result_content .item, .js_basic_result_content .item').length > 0) break;
27
+ await new Promise(r => setTimeout(r, 500));
28
+ }
29
+ const results = [];
30
+ for (const el of document.querySelectorAll('.basic_result_content .item, .js_basic_result_content .item')) {
31
+ const titleEl = el.querySelector('a.title, .title a, a.log-anchor');
32
+ const title = normalize(titleEl?.textContent).replace(/<[^>]+>/g, '');
33
+ if (!title || title.length < 4) continue;
34
+
35
+ let url = titleEl?.getAttribute('href') || '';
36
+ if (url && !url.startsWith('http')) url = 'https://www.gov.cn' + url;
37
+
38
+ const description = normalize(el.querySelector('.description')?.textContent).slice(0, 120);
39
+ const date = (el.textContent || '').match(/(\\d{4}[-./]\\d{1,2}[-./]\\d{1,2})/)?.[1] || '';
40
+ results.push({ rank: results.length + 1, title, description, date, url });
41
+ if (results.length >= ${limit}) break;
42
+ }
43
+ return results;
44
+ })()
45
+ `);
46
+ return Array.isArray(data) ? data : [];
47
+ },
48
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { IPage } from '@jackwener/opencli/types';
3
+ import { __test__ } from './image.js';
4
+
5
+ describe('grok image helpers', () => {
6
+ describe('isOnGrok', () => {
7
+ const fakePage = (url: string | Error): IPage =>
8
+ ({ evaluate: () => url instanceof Error ? Promise.reject(url) : Promise.resolve(url) }) as unknown as IPage;
9
+
10
+ it('returns true for grok.com URLs', async () => {
11
+ expect(await __test__.isOnGrok(fakePage('https://grok.com/'))).toBe(true);
12
+ expect(await __test__.isOnGrok(fakePage('https://grok.com/chat/abc123'))).toBe(true);
13
+ });
14
+
15
+ it('returns true for grok.com subdomains', async () => {
16
+ expect(await __test__.isOnGrok(fakePage('https://assets.grok.com/foo'))).toBe(true);
17
+ });
18
+
19
+ it('returns false for non-grok domains', async () => {
20
+ expect(await __test__.isOnGrok(fakePage('https://fakegrok.com/'))).toBe(false);
21
+ expect(await __test__.isOnGrok(fakePage('about:blank'))).toBe(false);
22
+ });
23
+
24
+ it('returns false when evaluate throws (detached tab)', async () => {
25
+ expect(await __test__.isOnGrok(fakePage(new Error('detached')))).toBe(false);
26
+ });
27
+ });
28
+
29
+ it('normalizes boolean flags', () => {
30
+ expect(__test__.normalizeBooleanFlag(true)).toBe(true);
31
+ expect(__test__.normalizeBooleanFlag('true')).toBe(true);
32
+ expect(__test__.normalizeBooleanFlag('1')).toBe(true);
33
+ expect(__test__.normalizeBooleanFlag('yes')).toBe(true);
34
+ expect(__test__.normalizeBooleanFlag('on')).toBe(true);
35
+
36
+ expect(__test__.normalizeBooleanFlag(false)).toBe(false);
37
+ expect(__test__.normalizeBooleanFlag('false')).toBe(false);
38
+ expect(__test__.normalizeBooleanFlag(undefined)).toBe(false);
39
+ });
40
+
41
+ it('dedupes images by src', () => {
42
+ const deduped = __test__.dedupeBySrc([
43
+ { src: 'https://a.example/1.jpg', w: 500, h: 500 },
44
+ { src: 'https://a.example/1.jpg', w: 500, h: 500 },
45
+ { src: 'https://a.example/2.jpg', w: 500, h: 500 },
46
+ { src: '', w: 500, h: 500 },
47
+ ]);
48
+ expect(deduped.map(i => i.src)).toEqual([
49
+ 'https://a.example/1.jpg',
50
+ 'https://a.example/2.jpg',
51
+ ]);
52
+ });
53
+
54
+ it('builds a deterministic-ish signature order-independent by src', () => {
55
+ const sigA = __test__.imagesSignature([
56
+ { src: 'https://a.example/1.jpg', w: 1, h: 1 },
57
+ { src: 'https://a.example/2.jpg', w: 1, h: 1 },
58
+ ]);
59
+ const sigB = __test__.imagesSignature([
60
+ { src: 'https://a.example/2.jpg', w: 1, h: 1 },
61
+ { src: 'https://a.example/1.jpg', w: 1, h: 1 },
62
+ ]);
63
+ expect(sigA).toBe(sigB);
64
+ });
65
+
66
+ it('maps content-type to sensible image extensions', () => {
67
+ expect(__test__.extFromContentType('image/png')).toBe('png');
68
+ expect(__test__.extFromContentType('image/webp')).toBe('webp');
69
+ expect(__test__.extFromContentType('image/gif')).toBe('gif');
70
+ expect(__test__.extFromContentType('image/jpeg')).toBe('jpg');
71
+ expect(__test__.extFromContentType(undefined)).toBe('jpg');
72
+ expect(__test__.extFromContentType('')).toBe('jpg');
73
+ });
74
+
75
+ it('builds filenames with a stable sha1 slice tied to the src', () => {
76
+ const a1 = __test__.buildFilename('https://a.example/1.jpg', 'image/jpeg');
77
+ const a2 = __test__.buildFilename('https://a.example/1.jpg', 'image/jpeg');
78
+ const b1 = __test__.buildFilename('https://a.example/2.jpg', 'image/png');
79
+ // Same URL → same 12-char hash slice (timestamps may differ).
80
+ expect(a1.split('-')[2].split('.')[0]).toBe(a2.split('-')[2].split('.')[0]);
81
+ expect(a1.split('-')[2].split('.')[0]).not.toBe(b1.split('-')[2].split('.')[0]);
82
+ expect(a1.endsWith('.jpg')).toBe(true);
83
+ expect(b1.endsWith('.png')).toBe(true);
84
+ });
85
+
86
+ it('only accepts image bubbles that appeared after the baseline', () => {
87
+ const candidate = __test__.pickLatestImageCandidate([
88
+ [{ src: 'https://a.example/stale.jpg', w: 512, h: 512 }],
89
+ [],
90
+ [{ src: 'https://a.example/fresh.jpg', w: 1024, h: 1024 }],
91
+ ], 1);
92
+
93
+ expect(candidate).toEqual([
94
+ { src: 'https://a.example/fresh.jpg', w: 1024, h: 1024 },
95
+ ]);
96
+ });
97
+
98
+ it('does not reuse stale images when no new image bubble appears after baseline', () => {
99
+ const candidate = __test__.pickLatestImageCandidate([
100
+ [{ src: 'https://a.example/stale.jpg', w: 512, h: 512 }],
101
+ [],
102
+ [],
103
+ ], 1);
104
+
105
+ expect(candidate).toEqual([]);
106
+ });
107
+ });