@jackwener/opencli 1.0.6 → 1.1.0

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 (80) hide show
  1. package/README.md +26 -0
  2. package/README.zh-CN.md +3 -0
  3. package/SKILL.md +7 -2
  4. package/dist/cli-manifest.json +506 -6
  5. package/dist/cli.js +51 -1
  6. package/dist/clis/antigravity/serve.js +296 -47
  7. package/dist/clis/arxiv/paper.d.ts +1 -0
  8. package/dist/clis/arxiv/paper.js +21 -0
  9. package/dist/clis/arxiv/search.d.ts +1 -0
  10. package/dist/clis/arxiv/search.js +24 -0
  11. package/dist/clis/arxiv/utils.d.ts +18 -0
  12. package/dist/clis/arxiv/utils.js +49 -0
  13. package/dist/clis/boss/batchgreet.d.ts +1 -0
  14. package/dist/clis/boss/batchgreet.js +147 -0
  15. package/dist/clis/boss/exchange.d.ts +1 -0
  16. package/dist/clis/boss/exchange.js +111 -0
  17. package/dist/clis/boss/greet.d.ts +1 -0
  18. package/dist/clis/boss/greet.js +175 -0
  19. package/dist/clis/boss/invite.d.ts +1 -0
  20. package/dist/clis/boss/invite.js +158 -0
  21. package/dist/clis/boss/joblist.d.ts +1 -0
  22. package/dist/clis/boss/joblist.js +55 -0
  23. package/dist/clis/boss/mark.d.ts +1 -0
  24. package/dist/clis/boss/mark.js +141 -0
  25. package/dist/clis/boss/recommend.d.ts +1 -0
  26. package/dist/clis/boss/recommend.js +83 -0
  27. package/dist/clis/boss/stats.d.ts +1 -0
  28. package/dist/clis/boss/stats.js +116 -0
  29. package/dist/clis/sinafinance/news.d.ts +7 -0
  30. package/dist/clis/sinafinance/news.js +61 -0
  31. package/dist/clis/wikipedia/search.d.ts +1 -0
  32. package/dist/clis/wikipedia/search.js +30 -0
  33. package/dist/clis/wikipedia/summary.d.ts +1 -0
  34. package/dist/clis/wikipedia/summary.js +28 -0
  35. package/dist/clis/wikipedia/utils.d.ts +8 -0
  36. package/dist/clis/wikipedia/utils.js +18 -0
  37. package/dist/clis/xiaohongshu/creator-note-detail.d.ts +64 -5
  38. package/dist/clis/xiaohongshu/creator-note-detail.js +258 -69
  39. package/dist/clis/xiaohongshu/creator-note-detail.test.d.ts +1 -0
  40. package/dist/clis/xiaohongshu/creator-note-detail.test.js +211 -0
  41. package/dist/clis/xiaohongshu/creator-notes-summary.d.ts +28 -0
  42. package/dist/clis/xiaohongshu/creator-notes-summary.js +92 -0
  43. package/dist/clis/xiaohongshu/creator-notes-summary.test.d.ts +1 -0
  44. package/dist/clis/xiaohongshu/creator-notes-summary.test.js +49 -0
  45. package/dist/clis/xiaohongshu/creator-notes.d.ts +18 -5
  46. package/dist/clis/xiaohongshu/creator-notes.js +159 -71
  47. package/dist/clis/xiaohongshu/creator-notes.test.d.ts +1 -0
  48. package/dist/clis/xiaohongshu/creator-notes.test.js +162 -0
  49. package/dist/external.d.ts +20 -0
  50. package/dist/external.js +159 -0
  51. package/docs/.vitepress/config.mts +1 -1
  52. package/docs/public/CNAME +1 -0
  53. package/package.json +1 -1
  54. package/src/browser/cdp.ts +3 -3
  55. package/src/cli.ts +56 -1
  56. package/src/clis/antigravity/serve.ts +323 -50
  57. package/src/clis/arxiv/paper.ts +21 -0
  58. package/src/clis/arxiv/search.ts +24 -0
  59. package/src/clis/arxiv/utils.ts +63 -0
  60. package/src/clis/boss/batchgreet.ts +167 -0
  61. package/src/clis/boss/exchange.ts +126 -0
  62. package/src/clis/boss/greet.ts +198 -0
  63. package/src/clis/boss/invite.ts +177 -0
  64. package/src/clis/boss/joblist.ts +63 -0
  65. package/src/clis/boss/mark.ts +155 -0
  66. package/src/clis/boss/recommend.ts +94 -0
  67. package/src/clis/boss/stats.ts +130 -0
  68. package/src/clis/sinafinance/news.ts +76 -0
  69. package/src/clis/wikipedia/search.ts +32 -0
  70. package/src/clis/wikipedia/summary.ts +28 -0
  71. package/src/clis/wikipedia/utils.ts +20 -0
  72. package/src/clis/xiaohongshu/creator-note-detail.test.ts +223 -0
  73. package/src/clis/xiaohongshu/creator-note-detail.ts +340 -72
  74. package/src/clis/xiaohongshu/creator-notes-summary.test.ts +54 -0
  75. package/src/clis/xiaohongshu/creator-notes-summary.ts +120 -0
  76. package/src/clis/xiaohongshu/creator-notes.test.ts +178 -0
  77. package/src/clis/xiaohongshu/creator-notes.ts +215 -75
  78. package/src/daemon.ts +3 -3
  79. package/src/external-clis.yaml +39 -0
  80. package/src/external.ts +182 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Sina Finance 7x24 live news feed.
3
+ *
4
+ * Uses the public CJ API — no key or browser required.
5
+ * https://app.cj.sina.com.cn/api/news/pc
6
+ */
7
+
8
+ import { cli, Strategy } from '../../registry.js';
9
+ import { CliError } from '../../errors.js';
10
+
11
+ // User-facing type (0-9) → Sina API tag ID
12
+ const TYPE_MAP = [
13
+ 0, // 0: 全部
14
+ 10, // 1: A股
15
+ 1, // 2: 宏观
16
+ 3, // 3: 公司
17
+ 4, // 4: 数据
18
+ 5, // 5: 市场
19
+ 102, // 6: 国际
20
+ 6, // 7: 观点
21
+ 6, // 8: 央行
22
+ 8, // 9: 其它
23
+ ] as const;
24
+
25
+ interface SinaNewsItem {
26
+ id?: string;
27
+ create_time?: string;
28
+ rich_text?: string;
29
+ view_num?: number;
30
+ }
31
+
32
+ function stripHtml(html: string): string {
33
+ return html.replace(/<[^>]+>/g, '').trim();
34
+ }
35
+
36
+ cli({
37
+ site: 'sinafinance',
38
+ name: 'news',
39
+ description: '新浪财经 7x24 小时实时快讯',
40
+ domain: 'app.cj.sina.com.cn',
41
+ strategy: Strategy.PUBLIC,
42
+ browser: false,
43
+ args: [
44
+ { name: 'limit', type: 'int', default: 20, help: 'Max results (max 50)' },
45
+ { name: 'type', type: 'int', default: 0, help: 'News type: 0=全部 1=A股 2=宏观 3=公司 4=数据 5=市场 6=国际 7=观点 8=央行 9=其它' },
46
+ ],
47
+ columns: ['id', 'time', 'content', 'views'],
48
+ func: async (_page, args) => {
49
+ const limit = Math.max(1, Math.min(Number(args.limit), 50));
50
+ const apiTag = TYPE_MAP[args.type as number] ?? 0;
51
+
52
+ const params = new URLSearchParams({
53
+ page: '1',
54
+ size: String(limit),
55
+ tag: String(apiTag),
56
+ });
57
+
58
+ const res = await fetch(`https://app.cj.sina.com.cn/api/news/pc?${params}`);
59
+ if (!res.ok) {
60
+ throw new CliError('FETCH_ERROR', `Sina Finance API HTTP ${res.status}`, 'Check your network connection');
61
+ }
62
+ const json = await res.json() as { result?: { data?: { feed?: { list?: SinaNewsItem[] } } } };
63
+ const list = json?.result?.data?.feed?.list ?? [];
64
+
65
+ if (!list.length) {
66
+ throw new CliError('NOT_FOUND', 'No news found', 'Try a different type or increase limit');
67
+ }
68
+
69
+ return list.map((item) => ({
70
+ id: item.id ?? '',
71
+ time: item.create_time ?? '',
72
+ content: stripHtml(item.rich_text ?? ''),
73
+ views: item.view_num ?? 0,
74
+ }));
75
+ },
76
+ });
@@ -0,0 +1,32 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { wikiFetch } from './utils.js';
4
+
5
+ interface WikiSearchResult { title: string; snippet: string; }
6
+
7
+ cli({
8
+ site: 'wikipedia',
9
+ name: 'search',
10
+ description: 'Search Wikipedia articles',
11
+ strategy: Strategy.PUBLIC,
12
+ browser: false,
13
+ args: [
14
+ { name: 'keyword', positional: true, required: true, help: 'Search keyword' },
15
+ { name: 'limit', type: 'int', default: 10, help: 'Max results' },
16
+ { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
17
+ ],
18
+ columns: ['title', 'snippet', 'url'],
19
+ func: async (_page, args) => {
20
+ const limit = Math.max(1, Math.min(Number(args.limit), 50));
21
+ const lang = args.lang || 'en';
22
+ const q = encodeURIComponent(args.keyword);
23
+ const data = await wikiFetch(lang, `/w/api.php?action=query&list=search&srsearch=${q}&srlimit=${limit}&format=json&utf8=1`) as { query?: { search?: WikiSearchResult[] } };
24
+ const results = data?.query?.search;
25
+ if (!results?.length) throw new CliError('NOT_FOUND', 'No articles found', 'Try a different keyword');
26
+ return results.map((r) => ({
27
+ title: r.title,
28
+ snippet: r.snippet.replace(/<[^>]+>/g, '').slice(0, 120),
29
+ url: `https://${lang}.wikipedia.org/wiki/${encodeURIComponent(r.title.replace(/ /g, '_'))}`,
30
+ }));
31
+ },
32
+ });
@@ -0,0 +1,28 @@
1
+ import { cli, Strategy } from '../../registry.js';
2
+ import { CliError } from '../../errors.js';
3
+ import { wikiFetch } from './utils.js';
4
+
5
+ cli({
6
+ site: 'wikipedia',
7
+ name: 'summary',
8
+ description: 'Get Wikipedia article summary',
9
+ strategy: Strategy.PUBLIC,
10
+ browser: false,
11
+ args: [
12
+ { name: 'title', positional: true, required: true, help: 'Article title (e.g. "Transformer (machine learning model)")' },
13
+ { name: 'lang', default: 'en', help: 'Language code (e.g. en, zh, ja)' },
14
+ ],
15
+ columns: ['title', 'description', 'extract', 'url'],
16
+ func: async (_page, args) => {
17
+ const lang = args.lang || 'en';
18
+ const title = encodeURIComponent(args.title.replace(/ /g, '_'));
19
+ const data = await wikiFetch(lang, `/api/rest_v1/page/summary/${title}`) as { title?: string; description?: string; extract?: string; content_urls?: { desktop?: { page?: string } } };
20
+ if (!data?.title) throw new CliError('NOT_FOUND', `Article "${args.title}" not found`, 'Try searching first: opencli wikipedia search <keyword>');
21
+ return [{
22
+ title: data.title,
23
+ description: data.description ?? '-',
24
+ extract: (data.extract ?? '').slice(0, 300),
25
+ url: data.content_urls?.desktop?.page ?? `https://${lang}.wikipedia.org/wiki/${title}`,
26
+ }];
27
+ },
28
+ });
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Wikipedia adapter utilities.
3
+ *
4
+ * Uses the public MediaWiki REST API and Action API — no key required.
5
+ * REST API: https://en.wikipedia.org/api/rest_v1/
6
+ * Action API: https://en.wikipedia.org/w/api.php
7
+ */
8
+
9
+ import { CliError } from '../../errors.js';
10
+
11
+ export async function wikiFetch(lang: string, path: string): Promise<unknown> {
12
+ const url = `https://${lang}.wikipedia.org${path}`;
13
+ const resp = await fetch(url, {
14
+ headers: { 'User-Agent': 'opencli/1.0 (https://github.com/jackwener/opencli)' },
15
+ });
16
+ if (!resp.ok) {
17
+ throw new CliError('FETCH_ERROR', `Wikipedia API HTTP ${resp.status}`, `Check your title or search term`);
18
+ }
19
+ return resp.json();
20
+ }
@@ -0,0 +1,223 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import type { IPage } from '../../types.js';
3
+ import { getRegistry } from '../../registry.js';
4
+ import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailText } from './creator-note-detail.js';
5
+ import './creator-note-detail.js';
6
+
7
+ function createPageMock(evaluateResult: any): IPage {
8
+ return {
9
+ goto: vi.fn().mockResolvedValue(undefined),
10
+ evaluate: vi.fn().mockResolvedValue(evaluateResult),
11
+ snapshot: vi.fn().mockResolvedValue(undefined),
12
+ click: vi.fn().mockResolvedValue(undefined),
13
+ typeText: vi.fn().mockResolvedValue(undefined),
14
+ pressKey: vi.fn().mockResolvedValue(undefined),
15
+ wait: vi.fn().mockResolvedValue(undefined),
16
+ tabs: vi.fn().mockResolvedValue([]),
17
+ closeTab: vi.fn().mockResolvedValue(undefined),
18
+ newTab: vi.fn().mockResolvedValue(undefined),
19
+ selectTab: vi.fn().mockResolvedValue(undefined),
20
+ networkRequests: vi.fn().mockResolvedValue([]),
21
+ consoleMessages: vi.fn().mockResolvedValue([]),
22
+ scroll: vi.fn().mockResolvedValue(undefined),
23
+ autoScroll: vi.fn().mockResolvedValue(undefined),
24
+ installInterceptor: vi.fn().mockResolvedValue(undefined),
25
+ getInterceptedRequests: vi.fn().mockResolvedValue([]),
26
+ getCookies: vi.fn().mockResolvedValue([]),
27
+ screenshot: vi.fn().mockResolvedValue(''),
28
+ };
29
+ }
30
+
31
+ describe('xiaohongshu creator-note-detail', () => {
32
+ it('parses note detail page text into info and metric rows', () => {
33
+ const bodyText = `笔记数据详情
34
+ 一张图讲清 诡秘之主·耕种者途径
35
+ #诡秘之主
36
+ #耕种者序列
37
+ 2026-03-18 20:01
38
+ 切换笔记
39
+ 笔记诊断
40
+ 核心数据
41
+ 观看来源
42
+ 观众画像
43
+ 核心数据
44
+ 基础数据
45
+ 部分数据统计中,次日可查看
46
+ 导出数据
47
+ 曝光数
48
+ 1733
49
+ 粉丝占比 6.6%
50
+ 实时
51
+ 观看数
52
+ 544
53
+ 粉丝占比 7.2%
54
+ 封面点击率
55
+ 18.6%
56
+ 粉丝 19.1%
57
+ 平均观看时长
58
+ 51.5秒
59
+ 粉丝 55.8秒
60
+ 涨粉数
61
+ 3
62
+ 按小时
63
+ 按天
64
+ 互动数据
65
+ 数据实时更新
66
+ 导出数据
67
+ 点赞数
68
+ 19
69
+ 粉丝占比 60%
70
+ 评论数
71
+ 7
72
+ 粉丝占比 33.3%
73
+ 收藏数
74
+ 10
75
+ 粉丝占比 33.3%
76
+ 分享数
77
+ 6
78
+ 粉丝占比 0%`;
79
+
80
+ expect(parseCreatorNoteDetailText(bodyText, '69ba940500000000200384db')).toEqual([
81
+ { section: '笔记信息', metric: 'note_id', value: '69ba940500000000200384db', extra: '' },
82
+ { section: '笔记信息', metric: 'title', value: '一张图讲清 诡秘之主·耕种者途径', extra: '' },
83
+ { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
84
+ { section: '基础数据', metric: '曝光数', value: '1733', extra: '粉丝占比 6.6%' },
85
+ { section: '基础数据', metric: '观看数', value: '544', extra: '粉丝占比 7.2%' },
86
+ { section: '基础数据', metric: '封面点击率', value: '18.6%', extra: '粉丝 19.1%' },
87
+ { section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '粉丝 55.8秒' },
88
+ { section: '基础数据', metric: '涨粉数', value: '3', extra: '' },
89
+ { section: '互动数据', metric: '点赞数', value: '19', extra: '粉丝占比 60%' },
90
+ { section: '互动数据', metric: '评论数', value: '7', extra: '粉丝占比 33.3%' },
91
+ { section: '互动数据', metric: '收藏数', value: '10', extra: '粉丝占比 33.3%' },
92
+ { section: '互动数据', metric: '分享数', value: '6', extra: '粉丝占比 0%' },
93
+ ]);
94
+ });
95
+
96
+ it('appends audience source and portrait rows from API payloads', () => {
97
+ const rows = appendAudienceRows([], {
98
+ audienceSource: {
99
+ source: [
100
+ {
101
+ title: '首页推荐',
102
+ value_with_double: 89.9,
103
+ info: {
104
+ imp_count: 1469,
105
+ view_count: 276,
106
+ interaction_count: 15,
107
+ },
108
+ },
109
+ ],
110
+ },
111
+ audienceSourceDetail: {
112
+ gender: [
113
+ { title: '男性', value: 82 },
114
+ { title: '女性', value: 18 },
115
+ ],
116
+ age: [
117
+ { title: '25-34', value: 55 },
118
+ ],
119
+ city: [
120
+ { title: '上海', value: 8 },
121
+ ],
122
+ interest: [
123
+ { title: '二次元', value: 13 },
124
+ ],
125
+ },
126
+ });
127
+
128
+ expect(rows).toEqual([
129
+ { section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '曝光 1469 · 观看 276 · 互动 15' },
130
+ { section: '观众画像', metric: '性别/男性', value: '82%', extra: '' },
131
+ { section: '观众画像', metric: '性别/女性', value: '18%', extra: '' },
132
+ { section: '观众画像', metric: '年龄/25-34', value: '55%', extra: '' },
133
+ { section: '观众画像', metric: '城市/上海', value: '8%', extra: '' },
134
+ { section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' },
135
+ ]);
136
+ });
137
+
138
+ it('appends trend summary rows from hour/day series payloads', () => {
139
+ const rows = appendTrendRows([], {
140
+ audienceTrend: {
141
+ no_data: true,
142
+ no_data_tip_msg: '数据统计中,请稍后查看',
143
+ },
144
+ noteBase: {
145
+ hour: {
146
+ view_list: [
147
+ { date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 54 },
148
+ { date: new Date('2026-03-18T22:00:00+08:00').getTime(), count: 51 },
149
+ ],
150
+ like_list: [
151
+ { date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 2 },
152
+ ],
153
+ },
154
+ day: {
155
+ view_list: [
156
+ { date: new Date('2026-03-18T00:00:00+08:00').getTime(), count: 307 },
157
+ ],
158
+ },
159
+ },
160
+ });
161
+
162
+ expect(rows).toEqual([
163
+ { section: '趋势说明', metric: '观众趋势', value: '暂不可用', extra: '数据统计中,请稍后查看' },
164
+ { section: '趋势数据', metric: '按小时/观看数', value: '2 points', extra: '03-18 21:00=54 | 03-18 22:00=51' },
165
+ { section: '趋势数据', metric: '按小时/点赞数', value: '1 points', extra: '03-18 21:00=2' },
166
+ { section: '趋势数据', metric: '按天/观看数', value: '1 points', extra: '2026-03-18=307' },
167
+ ]);
168
+ });
169
+
170
+ it('navigates to the note detail page and returns parsed rows', async () => {
171
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
172
+ expect(cmd?.func).toBeTypeOf('function');
173
+
174
+ const page = createPageMock(`笔记数据详情
175
+ 示例笔记
176
+ 2026-03-19 12:00
177
+ 曝光数
178
+ 100
179
+ 粉丝占比 10%
180
+ 观看数
181
+ 50
182
+ 粉丝占比 20%
183
+ 封面点击率
184
+ 12%
185
+ 粉丝 11%
186
+ 平均观看时长
187
+ 30秒
188
+ 粉丝 31秒
189
+ 涨粉数
190
+ 2
191
+ 点赞数
192
+ 8
193
+ 粉丝占比 25%
194
+ 评论数
195
+ 1
196
+ 粉丝占比 0%
197
+ 收藏数
198
+ 3
199
+ 粉丝占比 50%
200
+ 分享数
201
+ 0
202
+ 粉丝占比 0%`);
203
+
204
+ const result = await cmd!.func!(page, { note_id: 'demo-note-id' });
205
+
206
+ expect((page.goto as any).mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics/note-detail?noteId=demo-note-id');
207
+ expect((page.evaluate as any).mock.calls[0][0]).toBe('() => document.body.innerText');
208
+ expect(result).toEqual([
209
+ { section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
210
+ { section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
211
+ { section: '笔记信息', metric: 'published_at', value: '2026-03-19 12:00', extra: '' },
212
+ { section: '基础数据', metric: '曝光数', value: '100', extra: '粉丝占比 10%' },
213
+ { section: '基础数据', metric: '观看数', value: '50', extra: '粉丝占比 20%' },
214
+ { section: '基础数据', metric: '封面点击率', value: '12%', extra: '粉丝 11%' },
215
+ { section: '基础数据', metric: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
216
+ { section: '基础数据', metric: '涨粉数', value: '2', extra: '' },
217
+ { section: '互动数据', metric: '点赞数', value: '8', extra: '粉丝占比 25%' },
218
+ { section: '互动数据', metric: '评论数', value: '1', extra: '粉丝占比 0%' },
219
+ { section: '互动数据', metric: '收藏数', value: '3', extra: '粉丝占比 50%' },
220
+ { section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
221
+ ]);
222
+ });
223
+ });