@jackwener/opencli 1.0.5 → 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.
- package/CHANGELOG.md +8 -0
- package/README.md +36 -10
- package/README.zh-CN.md +3 -0
- package/SKILL.md +7 -2
- package/dist/bilibili.js +4 -2
- package/dist/cli-manifest.json +506 -6
- package/dist/cli.js +51 -1
- package/dist/clis/antigravity/serve.js +296 -47
- package/dist/clis/arxiv/paper.d.ts +1 -0
- package/dist/clis/arxiv/paper.js +21 -0
- package/dist/clis/arxiv/search.d.ts +1 -0
- package/dist/clis/arxiv/search.js +24 -0
- package/dist/clis/arxiv/utils.d.ts +18 -0
- package/dist/clis/arxiv/utils.js +49 -0
- package/dist/clis/boss/batchgreet.d.ts +1 -0
- package/dist/clis/boss/batchgreet.js +147 -0
- package/dist/clis/boss/exchange.d.ts +1 -0
- package/dist/clis/boss/exchange.js +111 -0
- package/dist/clis/boss/greet.d.ts +1 -0
- package/dist/clis/boss/greet.js +175 -0
- package/dist/clis/boss/invite.d.ts +1 -0
- package/dist/clis/boss/invite.js +158 -0
- package/dist/clis/boss/joblist.d.ts +1 -0
- package/dist/clis/boss/joblist.js +55 -0
- package/dist/clis/boss/mark.d.ts +1 -0
- package/dist/clis/boss/mark.js +141 -0
- package/dist/clis/boss/recommend.d.ts +1 -0
- package/dist/clis/boss/recommend.js +83 -0
- package/dist/clis/boss/stats.d.ts +1 -0
- package/dist/clis/boss/stats.js +116 -0
- package/dist/clis/sinafinance/news.d.ts +7 -0
- package/dist/clis/sinafinance/news.js +61 -0
- package/dist/clis/wikipedia/search.d.ts +1 -0
- package/dist/clis/wikipedia/search.js +30 -0
- package/dist/clis/wikipedia/summary.d.ts +1 -0
- package/dist/clis/wikipedia/summary.js +28 -0
- package/dist/clis/wikipedia/utils.d.ts +8 -0
- package/dist/clis/wikipedia/utils.js +18 -0
- package/dist/clis/xiaohongshu/creator-note-detail.d.ts +64 -5
- package/dist/clis/xiaohongshu/creator-note-detail.js +258 -69
- package/dist/clis/xiaohongshu/creator-note-detail.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +211 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.d.ts +28 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.js +92 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-notes-summary.test.js +49 -0
- package/dist/clis/xiaohongshu/creator-notes.d.ts +18 -5
- package/dist/clis/xiaohongshu/creator-notes.js +159 -71
- package/dist/clis/xiaohongshu/creator-notes.test.d.ts +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +162 -0
- package/dist/external.d.ts +20 -0
- package/dist/external.js +159 -0
- package/docs/.vitepress/config.mts +1 -0
- package/docs/public/CNAME +1 -0
- package/package.json +1 -1
- package/src/bilibili.ts +4 -2
- package/src/browser/cdp.ts +3 -3
- package/src/cli.ts +56 -1
- package/src/clis/antigravity/README.md +3 -46
- package/src/clis/antigravity/serve.ts +323 -50
- package/src/clis/arxiv/paper.ts +21 -0
- package/src/clis/arxiv/search.ts +24 -0
- package/src/clis/arxiv/utils.ts +63 -0
- package/src/clis/boss/batchgreet.ts +167 -0
- package/src/clis/boss/exchange.ts +126 -0
- package/src/clis/boss/greet.ts +198 -0
- package/src/clis/boss/invite.ts +177 -0
- package/src/clis/boss/joblist.ts +63 -0
- package/src/clis/boss/mark.ts +155 -0
- package/src/clis/boss/recommend.ts +94 -0
- package/src/clis/boss/stats.ts +130 -0
- package/src/clis/chaoxing/README.md +2 -24
- package/src/clis/chatgpt/README.md +3 -42
- package/src/clis/chatwise/README.md +3 -36
- package/src/clis/codex/README.md +3 -32
- package/src/clis/cursor/README.md +3 -31
- package/src/clis/discord-app/README.md +2 -25
- package/src/clis/feishu/README.md +2 -17
- package/src/clis/neteasemusic/README.md +3 -29
- package/src/clis/notion/README.md +2 -26
- package/src/clis/sinafinance/news.ts +76 -0
- package/src/clis/wechat/README.md +2 -25
- package/src/clis/wikipedia/search.ts +32 -0
- package/src/clis/wikipedia/summary.ts +28 -0
- package/src/clis/wikipedia/utils.ts +20 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +223 -0
- package/src/clis/xiaohongshu/creator-note-detail.ts +340 -72
- package/src/clis/xiaohongshu/creator-notes-summary.test.ts +54 -0
- package/src/clis/xiaohongshu/creator-notes-summary.ts +120 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +178 -0
- package/src/clis/xiaohongshu/creator-notes.ts +215 -75
- package/src/daemon.ts +3 -3
- package/src/external-clis.yaml +39 -0
- package/src/external.ts +182 -0
- package/CDP.md +0 -103
- package/CDP.zh-CN.md +0 -103
- package/CLI-ELECTRON.md +0 -125
|
@@ -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
|
+
});
|
|
@@ -1,95 +1,363 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Xiaohongshu Creator Note Detail — per-note analytics
|
|
2
|
+
* Xiaohongshu Creator Note Detail — per-note analytics from the creator detail page.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* The current creator center no longer serves stable single-note metrics from the legacy
|
|
5
|
+
* `/api/galaxy/creator/data/note_detail` endpoint. The real note detail page loads data
|
|
6
|
+
* through the newer `datacenter/note/*` API family, so this command navigates to the
|
|
7
|
+
* detail page and parses the rendered metrics that are backed by those APIs.
|
|
7
8
|
*
|
|
8
9
|
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { cli, Strategy } from '../../registry.js';
|
|
13
|
+
import type { IPage } from '../../types.js';
|
|
14
|
+
|
|
15
|
+
type CreatorNoteDetailRow = {
|
|
16
|
+
section: string;
|
|
17
|
+
metric: string;
|
|
18
|
+
value: string;
|
|
19
|
+
extra: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type { CreatorNoteDetailRow };
|
|
23
|
+
|
|
24
|
+
type AudienceSourceItem = {
|
|
25
|
+
title?: string;
|
|
26
|
+
value_with_double?: number;
|
|
27
|
+
info?: {
|
|
28
|
+
imp_count?: number;
|
|
29
|
+
view_count?: number;
|
|
30
|
+
interaction_count?: number;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type AudiencePortraitItem = {
|
|
35
|
+
title?: string;
|
|
36
|
+
value?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type NoteTrendPoint = {
|
|
40
|
+
date?: number;
|
|
41
|
+
count?: number;
|
|
42
|
+
count_with_double?: number;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type NoteTrendBucket = {
|
|
46
|
+
imp_list?: NoteTrendPoint[];
|
|
47
|
+
view_list?: NoteTrendPoint[];
|
|
48
|
+
view_time_list?: NoteTrendPoint[];
|
|
49
|
+
like_list?: NoteTrendPoint[];
|
|
50
|
+
comment_list?: NoteTrendPoint[];
|
|
51
|
+
collect_list?: NoteTrendPoint[];
|
|
52
|
+
share_list?: NoteTrendPoint[];
|
|
53
|
+
rise_fans_list?: NoteTrendPoint[];
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type NoteDetailApiPayload = {
|
|
57
|
+
noteBase?: {
|
|
58
|
+
hour?: NoteTrendBucket;
|
|
59
|
+
day?: NoteTrendBucket;
|
|
60
|
+
};
|
|
61
|
+
audienceTrend?: {
|
|
62
|
+
no_data?: boolean;
|
|
63
|
+
no_data_tip_msg?: string;
|
|
64
|
+
};
|
|
65
|
+
audienceSource?: {
|
|
66
|
+
source?: AudienceSourceItem[];
|
|
67
|
+
};
|
|
68
|
+
audienceSourceDetail?: {
|
|
69
|
+
gender?: AudiencePortraitItem[];
|
|
70
|
+
age?: AudiencePortraitItem[];
|
|
71
|
+
city?: AudiencePortraitItem[];
|
|
72
|
+
interest?: AudiencePortraitItem[];
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
|
|
77
|
+
const NOTE_DETAIL_METRICS = [
|
|
78
|
+
{ label: '曝光数', section: '基础数据' },
|
|
79
|
+
{ label: '观看数', section: '基础数据' },
|
|
80
|
+
{ label: '封面点击率', section: '基础数据' },
|
|
81
|
+
{ label: '平均观看时长', section: '基础数据' },
|
|
82
|
+
{ label: '涨粉数', section: '基础数据' },
|
|
83
|
+
{ label: '点赞数', section: '互动数据' },
|
|
84
|
+
{ label: '评论数', section: '互动数据' },
|
|
85
|
+
{ label: '收藏数', section: '互动数据' },
|
|
86
|
+
{ label: '分享数', section: '互动数据' },
|
|
87
|
+
] as const;
|
|
88
|
+
|
|
89
|
+
const NOTE_DETAIL_METRIC_LABELS = new Set<string>(NOTE_DETAIL_METRICS.map((metric) => metric.label));
|
|
90
|
+
const NOTE_DETAIL_NOISE_LINES = new Set([
|
|
91
|
+
'切换笔记',
|
|
92
|
+
'笔记诊断',
|
|
93
|
+
'核心数据',
|
|
94
|
+
'观看来源',
|
|
95
|
+
'观众画像',
|
|
96
|
+
'提升建议',
|
|
97
|
+
'基础数据',
|
|
98
|
+
'互动数据',
|
|
99
|
+
'导出数据',
|
|
100
|
+
'实时',
|
|
101
|
+
'按小时',
|
|
102
|
+
'按天',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function findNoteTitle(lines: string[]): string {
|
|
106
|
+
const detailIndex = lines.indexOf('笔记数据详情');
|
|
107
|
+
if (detailIndex < 0) return '';
|
|
108
|
+
|
|
109
|
+
for (let i = detailIndex + 1; i < lines.length; i++) {
|
|
110
|
+
const line = lines[i];
|
|
111
|
+
if (!line || line.startsWith('#') || NOTE_DETAIL_DATETIME_RE.test(line)) continue;
|
|
112
|
+
if (NOTE_DETAIL_NOISE_LINES.has(line)) continue;
|
|
113
|
+
return line;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findMetricValue(lines: string[], startIndex: number): { value: string; extra: string } {
|
|
120
|
+
let value = '';
|
|
121
|
+
let extra = '';
|
|
122
|
+
|
|
123
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
124
|
+
const line = lines[i];
|
|
125
|
+
if (!line) continue;
|
|
126
|
+
if (NOTE_DETAIL_METRIC_LABELS.has(line)) break;
|
|
127
|
+
if (NOTE_DETAIL_NOISE_LINES.has(line) || line.startsWith('数据更新至') || line.startsWith('部分数据统计中')) continue;
|
|
128
|
+
|
|
129
|
+
if (!value) {
|
|
130
|
+
value = line;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!extra && line.startsWith('粉丝')) {
|
|
135
|
+
extra = line;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (line === '0' || /^\d/.test(line) || line.endsWith('%') || line.endsWith('秒')) {
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { value, extra };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function parseCreatorNoteDetailText(bodyText: string, noteId: string): CreatorNoteDetailRow[] {
|
|
148
|
+
const lines = bodyText
|
|
149
|
+
.split('\n')
|
|
150
|
+
.map((line) => line.trim())
|
|
151
|
+
.filter(Boolean);
|
|
152
|
+
|
|
153
|
+
const title = findNoteTitle(lines);
|
|
154
|
+
const publishedAt = lines.find((line) => NOTE_DETAIL_DATETIME_RE.test(line)) ?? '';
|
|
155
|
+
const rows: CreatorNoteDetailRow[] = [
|
|
156
|
+
{ section: '笔记信息', metric: 'note_id', value: noteId, extra: '' },
|
|
157
|
+
{ section: '笔记信息', metric: 'title', value: title, extra: '' },
|
|
158
|
+
{ section: '笔记信息', metric: 'published_at', value: publishedAt, extra: '' },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
for (const metric of NOTE_DETAIL_METRICS) {
|
|
162
|
+
const index = lines.indexOf(metric.label);
|
|
163
|
+
if (index < 0) continue;
|
|
164
|
+
const { value, extra } = findMetricValue(lines, index);
|
|
165
|
+
rows.push({
|
|
166
|
+
section: metric.section,
|
|
167
|
+
metric: metric.label,
|
|
168
|
+
value,
|
|
169
|
+
extra,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return rows;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toPercentString(value?: number): string {
|
|
177
|
+
return value == null ? '' : `${value}%`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function appendAudienceSourceRows(rows: CreatorNoteDetailRow[], payload?: NoteDetailApiPayload): CreatorNoteDetailRow[] {
|
|
181
|
+
const sourceItems = payload?.audienceSource?.source ?? [];
|
|
182
|
+
for (const item of sourceItems) {
|
|
183
|
+
if (!item.title) continue;
|
|
184
|
+
const extras: string[] = [];
|
|
185
|
+
if (item.info?.imp_count != null) extras.push(`曝光 ${item.info.imp_count}`);
|
|
186
|
+
if (item.info?.view_count != null) extras.push(`观看 ${item.info.view_count}`);
|
|
187
|
+
if (item.info?.interaction_count != null) extras.push(`互动 ${item.info.interaction_count}`);
|
|
188
|
+
rows.push({
|
|
189
|
+
section: '观看来源',
|
|
190
|
+
metric: item.title,
|
|
191
|
+
value: toPercentString(item.value_with_double),
|
|
192
|
+
extra: extras.join(' · '),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
return rows;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function appendAudiencePortraitGroup(
|
|
199
|
+
rows: CreatorNoteDetailRow[],
|
|
200
|
+
groupLabel: string,
|
|
201
|
+
items?: AudiencePortraitItem[],
|
|
202
|
+
): CreatorNoteDetailRow[] {
|
|
203
|
+
for (const item of items ?? []) {
|
|
204
|
+
if (!item.title) continue;
|
|
205
|
+
rows.push({
|
|
206
|
+
section: '观众画像',
|
|
207
|
+
metric: `${groupLabel}/${item.title}`,
|
|
208
|
+
value: toPercentString(item.value),
|
|
209
|
+
extra: '',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
return rows;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function appendAudienceRows(rows: CreatorNoteDetailRow[], payload?: NoteDetailApiPayload): CreatorNoteDetailRow[] {
|
|
216
|
+
appendAudienceSourceRows(rows, payload);
|
|
217
|
+
appendAudiencePortraitGroup(rows, '性别', payload?.audienceSourceDetail?.gender);
|
|
218
|
+
appendAudiencePortraitGroup(rows, '年龄', payload?.audienceSourceDetail?.age);
|
|
219
|
+
appendAudiencePortraitGroup(rows, '城市', payload?.audienceSourceDetail?.city);
|
|
220
|
+
appendAudiencePortraitGroup(rows, '兴趣', payload?.audienceSourceDetail?.interest);
|
|
221
|
+
return rows;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatTrendTimestamp(ts: number | undefined, granularity: 'hour' | 'day'): string {
|
|
225
|
+
if (!ts) return '';
|
|
226
|
+
// Use fixed UTC+8 offset to ensure consistent output regardless of CI server timezone.
|
|
227
|
+
const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
228
|
+
const cstDate = new Date(ts + CST_OFFSET_MS);
|
|
229
|
+
const pad = (value: number) => String(value).padStart(2, '0');
|
|
230
|
+
if (granularity === 'hour') {
|
|
231
|
+
return `${pad(cstDate.getUTCMonth() + 1)}-${pad(cstDate.getUTCDate())} ${pad(cstDate.getUTCHours())}:00`;
|
|
232
|
+
}
|
|
233
|
+
return `${cstDate.getUTCFullYear()}-${pad(cstDate.getUTCMonth() + 1)}-${pad(cstDate.getUTCDate())}`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function formatTrendSeries(points: NoteTrendPoint[] | undefined, granularity: 'hour' | 'day'): string {
|
|
237
|
+
if (!points?.length) return '';
|
|
238
|
+
return points
|
|
239
|
+
.map((point) => {
|
|
240
|
+
const label = formatTrendTimestamp(point.date, granularity);
|
|
241
|
+
const value = point.count_with_double ?? point.count;
|
|
242
|
+
return label && value != null ? `${label}=${value}` : '';
|
|
243
|
+
})
|
|
244
|
+
.filter(Boolean)
|
|
245
|
+
.join(' | ');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const TREND_SERIES_CONFIG = [
|
|
249
|
+
{ key: 'imp_list', label: '曝光数' },
|
|
250
|
+
{ key: 'view_list', label: '观看数' },
|
|
251
|
+
{ key: 'view_time_list', label: '平均观看时长' },
|
|
252
|
+
{ key: 'like_list', label: '点赞数' },
|
|
253
|
+
{ key: 'comment_list', label: '评论数' },
|
|
254
|
+
{ key: 'collect_list', label: '收藏数' },
|
|
255
|
+
{ key: 'share_list', label: '分享数' },
|
|
256
|
+
{ key: 'rise_fans_list', label: '涨粉数' },
|
|
257
|
+
] as const;
|
|
258
|
+
|
|
259
|
+
export function appendTrendRows(rows: CreatorNoteDetailRow[], payload?: NoteDetailApiPayload): CreatorNoteDetailRow[] {
|
|
260
|
+
if (payload?.audienceTrend?.no_data_tip_msg) {
|
|
261
|
+
rows.push({
|
|
262
|
+
section: '趋势说明',
|
|
263
|
+
metric: '观众趋势',
|
|
264
|
+
value: payload.audienceTrend.no_data ? '暂不可用' : '可用',
|
|
265
|
+
extra: payload.audienceTrend.no_data_tip_msg,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const buckets: Array<{ label: string; granularity: 'hour' | 'day'; data?: NoteTrendBucket }> = [
|
|
270
|
+
{ label: '按小时', granularity: 'hour', data: payload?.noteBase?.hour },
|
|
271
|
+
{ label: '按天', granularity: 'day', data: payload?.noteBase?.day },
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
for (const bucket of buckets) {
|
|
275
|
+
for (const series of TREND_SERIES_CONFIG) {
|
|
276
|
+
const points = bucket.data?.[series.key];
|
|
277
|
+
const formatted = formatTrendSeries(points, bucket.granularity);
|
|
278
|
+
if (!formatted) continue;
|
|
279
|
+
rows.push({
|
|
280
|
+
section: '趋势数据',
|
|
281
|
+
metric: `${bucket.label}/${series.label}`,
|
|
282
|
+
value: `${points!.length} points`,
|
|
283
|
+
extra: formatted,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return rows;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const DETAIL_API_ENDPOINTS: Array<{ suffix: string; key: keyof NoteDetailApiPayload }> = [
|
|
292
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' },
|
|
293
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' },
|
|
294
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' },
|
|
295
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' },
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
async function captureNoteDetailPayload(page: IPage, noteId: string): Promise<NoteDetailApiPayload | null> {
|
|
299
|
+
const payload: NoteDetailApiPayload = {};
|
|
300
|
+
let captured = 0;
|
|
301
|
+
|
|
302
|
+
// Try to fetch each API endpoint through the page context (uses the browser's cookies)
|
|
303
|
+
for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
|
|
304
|
+
const apiUrl = `${suffix}?note_id=${noteId}`;
|
|
305
|
+
try {
|
|
306
|
+
const data = await page.evaluate(`
|
|
307
|
+
async () => {
|
|
308
|
+
try {
|
|
309
|
+
const resp = await fetch(${JSON.stringify(apiUrl)}, { credentials: 'include' });
|
|
310
|
+
if (!resp.ok) return null;
|
|
311
|
+
const json = await resp.json();
|
|
312
|
+
return JSON.stringify(json.data ?? {});
|
|
313
|
+
} catch { return null; }
|
|
314
|
+
}
|
|
315
|
+
`);
|
|
316
|
+
if (data && typeof data === 'string') {
|
|
317
|
+
try {
|
|
318
|
+
payload[key] = JSON.parse(data);
|
|
319
|
+
captured++;
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
322
|
+
} catch {}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return captured > 0 ? payload : null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export async function fetchCreatorNoteDetailRows(page: IPage, noteId: string): Promise<CreatorNoteDetailRow[]> {
|
|
329
|
+
await page.goto(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(noteId)}`);
|
|
330
|
+
await page.wait(4);
|
|
331
|
+
|
|
332
|
+
const bodyText = await page.evaluate('() => document.body.innerText');
|
|
333
|
+
const rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
|
|
334
|
+
const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null);
|
|
335
|
+
appendTrendRows(rows, apiPayload ?? undefined);
|
|
336
|
+
appendAudienceRows(rows, apiPayload ?? undefined);
|
|
337
|
+
|
|
338
|
+
return rows;
|
|
339
|
+
}
|
|
12
340
|
|
|
13
341
|
cli({
|
|
14
342
|
site: 'xiaohongshu',
|
|
15
343
|
name: 'creator-note-detail',
|
|
16
|
-
description: '
|
|
344
|
+
description: '小红书单篇笔记详情页数据 (笔记信息 + 核心/互动数据 + 观看来源 + 观众画像 + 趋势数据)',
|
|
17
345
|
domain: 'creator.xiaohongshu.com',
|
|
18
346
|
strategy: Strategy.COOKIE,
|
|
19
347
|
browser: true,
|
|
20
348
|
args: [
|
|
21
|
-
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from
|
|
349
|
+
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
|
|
22
350
|
],
|
|
23
|
-
columns: ['
|
|
351
|
+
columns: ['section', 'metric', 'value', 'extra'],
|
|
24
352
|
func: async (page, kwargs) => {
|
|
25
353
|
const noteId: string = kwargs.note_id;
|
|
26
|
-
const
|
|
354
|
+
const rows = await fetchCreatorNoteDetailRows(page, noteId);
|
|
27
355
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const data = await page.evaluate(`
|
|
33
|
-
async () => {
|
|
34
|
-
try {
|
|
35
|
-
const resp = await fetch(
|
|
36
|
-
'/api/galaxy/creator/data/note_detail?note_id=${encodedNoteId}',
|
|
37
|
-
{ credentials: 'include' }
|
|
38
|
-
);
|
|
39
|
-
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
|
40
|
-
return await resp.json();
|
|
41
|
-
} catch (e) {
|
|
42
|
-
return { error: e.message };
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
`);
|
|
46
|
-
|
|
47
|
-
if (data?.error) {
|
|
48
|
-
throw new Error(data.error + '. Check note_id and login status.');
|
|
49
|
-
}
|
|
50
|
-
if (!data?.data) {
|
|
51
|
-
throw new Error('Unexpected response structure');
|
|
356
|
+
const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value);
|
|
357
|
+
if (!hasCoreMetric) {
|
|
358
|
+
throw new Error('No note detail data found. Check note_id and login status for creator.xiaohongshu.com.');
|
|
52
359
|
}
|
|
53
360
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return [
|
|
57
|
-
{
|
|
58
|
-
channel: 'Total',
|
|
59
|
-
reads: d.total_read ?? 0,
|
|
60
|
-
engagement: d.total_engage ?? 0,
|
|
61
|
-
likes: d.total_like ?? 0,
|
|
62
|
-
collects: d.total_fav ?? 0,
|
|
63
|
-
comments: d.total_cmt ?? 0,
|
|
64
|
-
shares: d.total_share ?? 0,
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
channel: 'Organic',
|
|
68
|
-
reads: d.normal_read ?? 0,
|
|
69
|
-
engagement: d.normal_engage ?? 0,
|
|
70
|
-
likes: d.normal_like ?? 0,
|
|
71
|
-
collects: d.normal_fav ?? 0,
|
|
72
|
-
comments: d.normal_cmt ?? 0,
|
|
73
|
-
shares: d.normal_share ?? 0,
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
channel: 'Promoted',
|
|
77
|
-
reads: d.total_promo_read ?? 0,
|
|
78
|
-
engagement: 0,
|
|
79
|
-
likes: 0,
|
|
80
|
-
collects: 0,
|
|
81
|
-
comments: 0,
|
|
82
|
-
shares: 0,
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
channel: 'Video',
|
|
86
|
-
reads: d.video_read ?? 0,
|
|
87
|
-
engagement: d.video_engage ?? 0,
|
|
88
|
-
likes: d.video_like ?? 0,
|
|
89
|
-
collects: d.video_fav ?? 0,
|
|
90
|
-
comments: d.video_cmt ?? 0,
|
|
91
|
-
shares: d.video_share ?? 0,
|
|
92
|
-
},
|
|
93
|
-
];
|
|
361
|
+
return rows;
|
|
94
362
|
},
|
|
95
363
|
});
|