@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
|
@@ -1,88 +1,277 @@
|
|
|
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
|
import { cli, Strategy } from '../../registry.js';
|
|
12
|
+
const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
|
|
13
|
+
const NOTE_DETAIL_METRICS = [
|
|
14
|
+
{ label: '曝光数', section: '基础数据' },
|
|
15
|
+
{ label: '观看数', section: '基础数据' },
|
|
16
|
+
{ label: '封面点击率', section: '基础数据' },
|
|
17
|
+
{ label: '平均观看时长', section: '基础数据' },
|
|
18
|
+
{ label: '涨粉数', section: '基础数据' },
|
|
19
|
+
{ label: '点赞数', section: '互动数据' },
|
|
20
|
+
{ label: '评论数', section: '互动数据' },
|
|
21
|
+
{ label: '收藏数', section: '互动数据' },
|
|
22
|
+
{ label: '分享数', section: '互动数据' },
|
|
23
|
+
];
|
|
24
|
+
const NOTE_DETAIL_METRIC_LABELS = new Set(NOTE_DETAIL_METRICS.map((metric) => metric.label));
|
|
25
|
+
const NOTE_DETAIL_NOISE_LINES = new Set([
|
|
26
|
+
'切换笔记',
|
|
27
|
+
'笔记诊断',
|
|
28
|
+
'核心数据',
|
|
29
|
+
'观看来源',
|
|
30
|
+
'观众画像',
|
|
31
|
+
'提升建议',
|
|
32
|
+
'基础数据',
|
|
33
|
+
'互动数据',
|
|
34
|
+
'导出数据',
|
|
35
|
+
'实时',
|
|
36
|
+
'按小时',
|
|
37
|
+
'按天',
|
|
38
|
+
]);
|
|
39
|
+
function findNoteTitle(lines) {
|
|
40
|
+
const detailIndex = lines.indexOf('笔记数据详情');
|
|
41
|
+
if (detailIndex < 0)
|
|
42
|
+
return '';
|
|
43
|
+
for (let i = detailIndex + 1; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
if (!line || line.startsWith('#') || NOTE_DETAIL_DATETIME_RE.test(line))
|
|
46
|
+
continue;
|
|
47
|
+
if (NOTE_DETAIL_NOISE_LINES.has(line))
|
|
48
|
+
continue;
|
|
49
|
+
return line;
|
|
50
|
+
}
|
|
51
|
+
return '';
|
|
52
|
+
}
|
|
53
|
+
function findMetricValue(lines, startIndex) {
|
|
54
|
+
let value = '';
|
|
55
|
+
let extra = '';
|
|
56
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
57
|
+
const line = lines[i];
|
|
58
|
+
if (!line)
|
|
59
|
+
continue;
|
|
60
|
+
if (NOTE_DETAIL_METRIC_LABELS.has(line))
|
|
61
|
+
break;
|
|
62
|
+
if (NOTE_DETAIL_NOISE_LINES.has(line) || line.startsWith('数据更新至') || line.startsWith('部分数据统计中'))
|
|
63
|
+
continue;
|
|
64
|
+
if (!value) {
|
|
65
|
+
value = line;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (!extra && line.startsWith('粉丝')) {
|
|
69
|
+
extra = line;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
if (line === '0' || /^\d/.test(line) || line.endsWith('%') || line.endsWith('秒')) {
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { value, extra };
|
|
77
|
+
}
|
|
78
|
+
export function parseCreatorNoteDetailText(bodyText, noteId) {
|
|
79
|
+
const lines = bodyText
|
|
80
|
+
.split('\n')
|
|
81
|
+
.map((line) => line.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
const title = findNoteTitle(lines);
|
|
84
|
+
const publishedAt = lines.find((line) => NOTE_DETAIL_DATETIME_RE.test(line)) ?? '';
|
|
85
|
+
const rows = [
|
|
86
|
+
{ section: '笔记信息', metric: 'note_id', value: noteId, extra: '' },
|
|
87
|
+
{ section: '笔记信息', metric: 'title', value: title, extra: '' },
|
|
88
|
+
{ section: '笔记信息', metric: 'published_at', value: publishedAt, extra: '' },
|
|
89
|
+
];
|
|
90
|
+
for (const metric of NOTE_DETAIL_METRICS) {
|
|
91
|
+
const index = lines.indexOf(metric.label);
|
|
92
|
+
if (index < 0)
|
|
93
|
+
continue;
|
|
94
|
+
const { value, extra } = findMetricValue(lines, index);
|
|
95
|
+
rows.push({
|
|
96
|
+
section: metric.section,
|
|
97
|
+
metric: metric.label,
|
|
98
|
+
value,
|
|
99
|
+
extra,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
function toPercentString(value) {
|
|
105
|
+
return value == null ? '' : `${value}%`;
|
|
106
|
+
}
|
|
107
|
+
function appendAudienceSourceRows(rows, payload) {
|
|
108
|
+
const sourceItems = payload?.audienceSource?.source ?? [];
|
|
109
|
+
for (const item of sourceItems) {
|
|
110
|
+
if (!item.title)
|
|
111
|
+
continue;
|
|
112
|
+
const extras = [];
|
|
113
|
+
if (item.info?.imp_count != null)
|
|
114
|
+
extras.push(`曝光 ${item.info.imp_count}`);
|
|
115
|
+
if (item.info?.view_count != null)
|
|
116
|
+
extras.push(`观看 ${item.info.view_count}`);
|
|
117
|
+
if (item.info?.interaction_count != null)
|
|
118
|
+
extras.push(`互动 ${item.info.interaction_count}`);
|
|
119
|
+
rows.push({
|
|
120
|
+
section: '观看来源',
|
|
121
|
+
metric: item.title,
|
|
122
|
+
value: toPercentString(item.value_with_double),
|
|
123
|
+
extra: extras.join(' · '),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return rows;
|
|
127
|
+
}
|
|
128
|
+
function appendAudiencePortraitGroup(rows, groupLabel, items) {
|
|
129
|
+
for (const item of items ?? []) {
|
|
130
|
+
if (!item.title)
|
|
131
|
+
continue;
|
|
132
|
+
rows.push({
|
|
133
|
+
section: '观众画像',
|
|
134
|
+
metric: `${groupLabel}/${item.title}`,
|
|
135
|
+
value: toPercentString(item.value),
|
|
136
|
+
extra: '',
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return rows;
|
|
140
|
+
}
|
|
141
|
+
export function appendAudienceRows(rows, payload) {
|
|
142
|
+
appendAudienceSourceRows(rows, payload);
|
|
143
|
+
appendAudiencePortraitGroup(rows, '性别', payload?.audienceSourceDetail?.gender);
|
|
144
|
+
appendAudiencePortraitGroup(rows, '年龄', payload?.audienceSourceDetail?.age);
|
|
145
|
+
appendAudiencePortraitGroup(rows, '城市', payload?.audienceSourceDetail?.city);
|
|
146
|
+
appendAudiencePortraitGroup(rows, '兴趣', payload?.audienceSourceDetail?.interest);
|
|
147
|
+
return rows;
|
|
148
|
+
}
|
|
149
|
+
function formatTrendTimestamp(ts, granularity) {
|
|
150
|
+
if (!ts)
|
|
151
|
+
return '';
|
|
152
|
+
// Use fixed UTC+8 offset to ensure consistent output regardless of CI server timezone.
|
|
153
|
+
const CST_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
154
|
+
const cstDate = new Date(ts + CST_OFFSET_MS);
|
|
155
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
156
|
+
if (granularity === 'hour') {
|
|
157
|
+
return `${pad(cstDate.getUTCMonth() + 1)}-${pad(cstDate.getUTCDate())} ${pad(cstDate.getUTCHours())}:00`;
|
|
158
|
+
}
|
|
159
|
+
return `${cstDate.getUTCFullYear()}-${pad(cstDate.getUTCMonth() + 1)}-${pad(cstDate.getUTCDate())}`;
|
|
160
|
+
}
|
|
161
|
+
function formatTrendSeries(points, granularity) {
|
|
162
|
+
if (!points?.length)
|
|
163
|
+
return '';
|
|
164
|
+
return points
|
|
165
|
+
.map((point) => {
|
|
166
|
+
const label = formatTrendTimestamp(point.date, granularity);
|
|
167
|
+
const value = point.count_with_double ?? point.count;
|
|
168
|
+
return label && value != null ? `${label}=${value}` : '';
|
|
169
|
+
})
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.join(' | ');
|
|
172
|
+
}
|
|
173
|
+
const TREND_SERIES_CONFIG = [
|
|
174
|
+
{ key: 'imp_list', label: '曝光数' },
|
|
175
|
+
{ key: 'view_list', label: '观看数' },
|
|
176
|
+
{ key: 'view_time_list', label: '平均观看时长' },
|
|
177
|
+
{ key: 'like_list', label: '点赞数' },
|
|
178
|
+
{ key: 'comment_list', label: '评论数' },
|
|
179
|
+
{ key: 'collect_list', label: '收藏数' },
|
|
180
|
+
{ key: 'share_list', label: '分享数' },
|
|
181
|
+
{ key: 'rise_fans_list', label: '涨粉数' },
|
|
182
|
+
];
|
|
183
|
+
export function appendTrendRows(rows, payload) {
|
|
184
|
+
if (payload?.audienceTrend?.no_data_tip_msg) {
|
|
185
|
+
rows.push({
|
|
186
|
+
section: '趋势说明',
|
|
187
|
+
metric: '观众趋势',
|
|
188
|
+
value: payload.audienceTrend.no_data ? '暂不可用' : '可用',
|
|
189
|
+
extra: payload.audienceTrend.no_data_tip_msg,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const buckets = [
|
|
193
|
+
{ label: '按小时', granularity: 'hour', data: payload?.noteBase?.hour },
|
|
194
|
+
{ label: '按天', granularity: 'day', data: payload?.noteBase?.day },
|
|
195
|
+
];
|
|
196
|
+
for (const bucket of buckets) {
|
|
197
|
+
for (const series of TREND_SERIES_CONFIG) {
|
|
198
|
+
const points = bucket.data?.[series.key];
|
|
199
|
+
const formatted = formatTrendSeries(points, bucket.granularity);
|
|
200
|
+
if (!formatted)
|
|
201
|
+
continue;
|
|
202
|
+
rows.push({
|
|
203
|
+
section: '趋势数据',
|
|
204
|
+
metric: `${bucket.label}/${series.label}`,
|
|
205
|
+
value: `${points.length} points`,
|
|
206
|
+
extra: formatted,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return rows;
|
|
211
|
+
}
|
|
212
|
+
const DETAIL_API_ENDPOINTS = [
|
|
213
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/base', key: 'noteBase' },
|
|
214
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/analyze/audience/trend', key: 'audienceTrend' },
|
|
215
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/audience/source/detail', key: 'audienceSourceDetail' },
|
|
216
|
+
{ suffix: '/api/galaxy/creator/datacenter/note/audience', key: 'audienceSource' },
|
|
217
|
+
];
|
|
218
|
+
async function captureNoteDetailPayload(page, noteId) {
|
|
219
|
+
const payload = {};
|
|
220
|
+
let captured = 0;
|
|
221
|
+
// Try to fetch each API endpoint through the page context (uses the browser's cookies)
|
|
222
|
+
for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
|
|
223
|
+
const apiUrl = `${suffix}?note_id=${noteId}`;
|
|
224
|
+
try {
|
|
225
|
+
const data = await page.evaluate(`
|
|
226
|
+
async () => {
|
|
227
|
+
try {
|
|
228
|
+
const resp = await fetch(${JSON.stringify(apiUrl)}, { credentials: 'include' });
|
|
229
|
+
if (!resp.ok) return null;
|
|
230
|
+
const json = await resp.json();
|
|
231
|
+
return JSON.stringify(json.data ?? {});
|
|
232
|
+
} catch { return null; }
|
|
233
|
+
}
|
|
234
|
+
`);
|
|
235
|
+
if (data && typeof data === 'string') {
|
|
236
|
+
try {
|
|
237
|
+
payload[key] = JSON.parse(data);
|
|
238
|
+
captured++;
|
|
239
|
+
}
|
|
240
|
+
catch { }
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch { }
|
|
244
|
+
}
|
|
245
|
+
return captured > 0 ? payload : null;
|
|
246
|
+
}
|
|
247
|
+
export async function fetchCreatorNoteDetailRows(page, noteId) {
|
|
248
|
+
await page.goto(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(noteId)}`);
|
|
249
|
+
await page.wait(4);
|
|
250
|
+
const bodyText = await page.evaluate('() => document.body.innerText');
|
|
251
|
+
const rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
|
|
252
|
+
const apiPayload = await captureNoteDetailPayload(page, noteId).catch(() => null);
|
|
253
|
+
appendTrendRows(rows, apiPayload ?? undefined);
|
|
254
|
+
appendAudienceRows(rows, apiPayload ?? undefined);
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
11
257
|
cli({
|
|
12
258
|
site: 'xiaohongshu',
|
|
13
259
|
name: 'creator-note-detail',
|
|
14
|
-
description: '
|
|
260
|
+
description: '小红书单篇笔记详情页数据 (笔记信息 + 核心/互动数据 + 观看来源 + 观众画像 + 趋势数据)',
|
|
15
261
|
domain: 'creator.xiaohongshu.com',
|
|
16
262
|
strategy: Strategy.COOKIE,
|
|
17
263
|
browser: true,
|
|
18
264
|
args: [
|
|
19
|
-
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from
|
|
265
|
+
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
|
|
20
266
|
],
|
|
21
|
-
columns: ['
|
|
267
|
+
columns: ['section', 'metric', 'value', 'extra'],
|
|
22
268
|
func: async (page, kwargs) => {
|
|
23
269
|
const noteId = kwargs.note_id;
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const data = await page.evaluate(`
|
|
29
|
-
async () => {
|
|
30
|
-
try {
|
|
31
|
-
const resp = await fetch(
|
|
32
|
-
'/api/galaxy/creator/data/note_detail?note_id=${encodedNoteId}',
|
|
33
|
-
{ credentials: 'include' }
|
|
34
|
-
);
|
|
35
|
-
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
|
36
|
-
return await resp.json();
|
|
37
|
-
} catch (e) {
|
|
38
|
-
return { error: e.message };
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
`);
|
|
42
|
-
if (data?.error) {
|
|
43
|
-
throw new Error(data.error + '. Check note_id and login status.');
|
|
44
|
-
}
|
|
45
|
-
if (!data?.data) {
|
|
46
|
-
throw new Error('Unexpected response structure');
|
|
270
|
+
const rows = await fetchCreatorNoteDetailRows(page, noteId);
|
|
271
|
+
const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value);
|
|
272
|
+
if (!hasCoreMetric) {
|
|
273
|
+
throw new Error('No note detail data found. Check note_id and login status for creator.xiaohongshu.com.');
|
|
47
274
|
}
|
|
48
|
-
|
|
49
|
-
return [
|
|
50
|
-
{
|
|
51
|
-
channel: 'Total',
|
|
52
|
-
reads: d.total_read ?? 0,
|
|
53
|
-
engagement: d.total_engage ?? 0,
|
|
54
|
-
likes: d.total_like ?? 0,
|
|
55
|
-
collects: d.total_fav ?? 0,
|
|
56
|
-
comments: d.total_cmt ?? 0,
|
|
57
|
-
shares: d.total_share ?? 0,
|
|
58
|
-
},
|
|
59
|
-
{
|
|
60
|
-
channel: 'Organic',
|
|
61
|
-
reads: d.normal_read ?? 0,
|
|
62
|
-
engagement: d.normal_engage ?? 0,
|
|
63
|
-
likes: d.normal_like ?? 0,
|
|
64
|
-
collects: d.normal_fav ?? 0,
|
|
65
|
-
comments: d.normal_cmt ?? 0,
|
|
66
|
-
shares: d.normal_share ?? 0,
|
|
67
|
-
},
|
|
68
|
-
{
|
|
69
|
-
channel: 'Promoted',
|
|
70
|
-
reads: d.total_promo_read ?? 0,
|
|
71
|
-
engagement: 0,
|
|
72
|
-
likes: 0,
|
|
73
|
-
collects: 0,
|
|
74
|
-
comments: 0,
|
|
75
|
-
shares: 0,
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
channel: 'Video',
|
|
79
|
-
reads: d.video_read ?? 0,
|
|
80
|
-
engagement: d.video_engage ?? 0,
|
|
81
|
-
likes: d.video_like ?? 0,
|
|
82
|
-
collects: d.video_fav ?? 0,
|
|
83
|
-
comments: d.video_cmt ?? 0,
|
|
84
|
-
shares: d.video_share ?? 0,
|
|
85
|
-
},
|
|
86
|
-
];
|
|
275
|
+
return rows;
|
|
87
276
|
},
|
|
88
277
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './creator-note-detail.js';
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '../../registry.js';
|
|
3
|
+
import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailText } from './creator-note-detail.js';
|
|
4
|
+
import './creator-note-detail.js';
|
|
5
|
+
function createPageMock(evaluateResult) {
|
|
6
|
+
return {
|
|
7
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
8
|
+
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
|
9
|
+
snapshot: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
click: vi.fn().mockResolvedValue(undefined),
|
|
11
|
+
typeText: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
pressKey: vi.fn().mockResolvedValue(undefined),
|
|
13
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
14
|
+
tabs: vi.fn().mockResolvedValue([]),
|
|
15
|
+
closeTab: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
newTab: vi.fn().mockResolvedValue(undefined),
|
|
17
|
+
selectTab: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
networkRequests: vi.fn().mockResolvedValue([]),
|
|
19
|
+
consoleMessages: vi.fn().mockResolvedValue([]),
|
|
20
|
+
scroll: vi.fn().mockResolvedValue(undefined),
|
|
21
|
+
autoScroll: vi.fn().mockResolvedValue(undefined),
|
|
22
|
+
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
|
23
|
+
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
24
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
25
|
+
screenshot: vi.fn().mockResolvedValue(''),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
describe('xiaohongshu creator-note-detail', () => {
|
|
29
|
+
it('parses note detail page text into info and metric rows', () => {
|
|
30
|
+
const bodyText = `笔记数据详情
|
|
31
|
+
一张图讲清 诡秘之主·耕种者途径
|
|
32
|
+
#诡秘之主
|
|
33
|
+
#耕种者序列
|
|
34
|
+
2026-03-18 20:01
|
|
35
|
+
切换笔记
|
|
36
|
+
笔记诊断
|
|
37
|
+
核心数据
|
|
38
|
+
观看来源
|
|
39
|
+
观众画像
|
|
40
|
+
核心数据
|
|
41
|
+
基础数据
|
|
42
|
+
部分数据统计中,次日可查看
|
|
43
|
+
导出数据
|
|
44
|
+
曝光数
|
|
45
|
+
1733
|
|
46
|
+
粉丝占比 6.6%
|
|
47
|
+
实时
|
|
48
|
+
观看数
|
|
49
|
+
544
|
|
50
|
+
粉丝占比 7.2%
|
|
51
|
+
封面点击率
|
|
52
|
+
18.6%
|
|
53
|
+
粉丝 19.1%
|
|
54
|
+
平均观看时长
|
|
55
|
+
51.5秒
|
|
56
|
+
粉丝 55.8秒
|
|
57
|
+
涨粉数
|
|
58
|
+
3
|
|
59
|
+
按小时
|
|
60
|
+
按天
|
|
61
|
+
互动数据
|
|
62
|
+
数据实时更新
|
|
63
|
+
导出数据
|
|
64
|
+
点赞数
|
|
65
|
+
19
|
|
66
|
+
粉丝占比 60%
|
|
67
|
+
评论数
|
|
68
|
+
7
|
|
69
|
+
粉丝占比 33.3%
|
|
70
|
+
收藏数
|
|
71
|
+
10
|
|
72
|
+
粉丝占比 33.3%
|
|
73
|
+
分享数
|
|
74
|
+
6
|
|
75
|
+
粉丝占比 0%`;
|
|
76
|
+
expect(parseCreatorNoteDetailText(bodyText, '69ba940500000000200384db')).toEqual([
|
|
77
|
+
{ section: '笔记信息', metric: 'note_id', value: '69ba940500000000200384db', extra: '' },
|
|
78
|
+
{ section: '笔记信息', metric: 'title', value: '一张图讲清 诡秘之主·耕种者途径', extra: '' },
|
|
79
|
+
{ section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
|
|
80
|
+
{ section: '基础数据', metric: '曝光数', value: '1733', extra: '粉丝占比 6.6%' },
|
|
81
|
+
{ section: '基础数据', metric: '观看数', value: '544', extra: '粉丝占比 7.2%' },
|
|
82
|
+
{ section: '基础数据', metric: '封面点击率', value: '18.6%', extra: '粉丝 19.1%' },
|
|
83
|
+
{ section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '粉丝 55.8秒' },
|
|
84
|
+
{ section: '基础数据', metric: '涨粉数', value: '3', extra: '' },
|
|
85
|
+
{ section: '互动数据', metric: '点赞数', value: '19', extra: '粉丝占比 60%' },
|
|
86
|
+
{ section: '互动数据', metric: '评论数', value: '7', extra: '粉丝占比 33.3%' },
|
|
87
|
+
{ section: '互动数据', metric: '收藏数', value: '10', extra: '粉丝占比 33.3%' },
|
|
88
|
+
{ section: '互动数据', metric: '分享数', value: '6', extra: '粉丝占比 0%' },
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
it('appends audience source and portrait rows from API payloads', () => {
|
|
92
|
+
const rows = appendAudienceRows([], {
|
|
93
|
+
audienceSource: {
|
|
94
|
+
source: [
|
|
95
|
+
{
|
|
96
|
+
title: '首页推荐',
|
|
97
|
+
value_with_double: 89.9,
|
|
98
|
+
info: {
|
|
99
|
+
imp_count: 1469,
|
|
100
|
+
view_count: 276,
|
|
101
|
+
interaction_count: 15,
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
},
|
|
106
|
+
audienceSourceDetail: {
|
|
107
|
+
gender: [
|
|
108
|
+
{ title: '男性', value: 82 },
|
|
109
|
+
{ title: '女性', value: 18 },
|
|
110
|
+
],
|
|
111
|
+
age: [
|
|
112
|
+
{ title: '25-34', value: 55 },
|
|
113
|
+
],
|
|
114
|
+
city: [
|
|
115
|
+
{ title: '上海', value: 8 },
|
|
116
|
+
],
|
|
117
|
+
interest: [
|
|
118
|
+
{ title: '二次元', value: 13 },
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
expect(rows).toEqual([
|
|
123
|
+
{ section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '曝光 1469 · 观看 276 · 互动 15' },
|
|
124
|
+
{ section: '观众画像', metric: '性别/男性', value: '82%', extra: '' },
|
|
125
|
+
{ section: '观众画像', metric: '性别/女性', value: '18%', extra: '' },
|
|
126
|
+
{ section: '观众画像', metric: '年龄/25-34', value: '55%', extra: '' },
|
|
127
|
+
{ section: '观众画像', metric: '城市/上海', value: '8%', extra: '' },
|
|
128
|
+
{ section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' },
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
it('appends trend summary rows from hour/day series payloads', () => {
|
|
132
|
+
const rows = appendTrendRows([], {
|
|
133
|
+
audienceTrend: {
|
|
134
|
+
no_data: true,
|
|
135
|
+
no_data_tip_msg: '数据统计中,请稍后查看',
|
|
136
|
+
},
|
|
137
|
+
noteBase: {
|
|
138
|
+
hour: {
|
|
139
|
+
view_list: [
|
|
140
|
+
{ date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 54 },
|
|
141
|
+
{ date: new Date('2026-03-18T22:00:00+08:00').getTime(), count: 51 },
|
|
142
|
+
],
|
|
143
|
+
like_list: [
|
|
144
|
+
{ date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 2 },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
day: {
|
|
148
|
+
view_list: [
|
|
149
|
+
{ date: new Date('2026-03-18T00:00:00+08:00').getTime(), count: 307 },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
expect(rows).toEqual([
|
|
155
|
+
{ section: '趋势说明', metric: '观众趋势', value: '暂不可用', extra: '数据统计中,请稍后查看' },
|
|
156
|
+
{ section: '趋势数据', metric: '按小时/观看数', value: '2 points', extra: '03-18 21:00=54 | 03-18 22:00=51' },
|
|
157
|
+
{ section: '趋势数据', metric: '按小时/点赞数', value: '1 points', extra: '03-18 21:00=2' },
|
|
158
|
+
{ section: '趋势数据', metric: '按天/观看数', value: '1 points', extra: '2026-03-18=307' },
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
it('navigates to the note detail page and returns parsed rows', async () => {
|
|
162
|
+
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
163
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
164
|
+
const page = createPageMock(`笔记数据详情
|
|
165
|
+
示例笔记
|
|
166
|
+
2026-03-19 12:00
|
|
167
|
+
曝光数
|
|
168
|
+
100
|
|
169
|
+
粉丝占比 10%
|
|
170
|
+
观看数
|
|
171
|
+
50
|
|
172
|
+
粉丝占比 20%
|
|
173
|
+
封面点击率
|
|
174
|
+
12%
|
|
175
|
+
粉丝 11%
|
|
176
|
+
平均观看时长
|
|
177
|
+
30秒
|
|
178
|
+
粉丝 31秒
|
|
179
|
+
涨粉数
|
|
180
|
+
2
|
|
181
|
+
点赞数
|
|
182
|
+
8
|
|
183
|
+
粉丝占比 25%
|
|
184
|
+
评论数
|
|
185
|
+
1
|
|
186
|
+
粉丝占比 0%
|
|
187
|
+
收藏数
|
|
188
|
+
3
|
|
189
|
+
粉丝占比 50%
|
|
190
|
+
分享数
|
|
191
|
+
0
|
|
192
|
+
粉丝占比 0%`);
|
|
193
|
+
const result = await cmd.func(page, { note_id: 'demo-note-id' });
|
|
194
|
+
expect(page.goto.mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics/note-detail?noteId=demo-note-id');
|
|
195
|
+
expect(page.evaluate.mock.calls[0][0]).toBe('() => document.body.innerText');
|
|
196
|
+
expect(result).toEqual([
|
|
197
|
+
{ section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
|
|
198
|
+
{ section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
|
|
199
|
+
{ section: '笔记信息', metric: 'published_at', value: '2026-03-19 12:00', extra: '' },
|
|
200
|
+
{ section: '基础数据', metric: '曝光数', value: '100', extra: '粉丝占比 10%' },
|
|
201
|
+
{ section: '基础数据', metric: '观看数', value: '50', extra: '粉丝占比 20%' },
|
|
202
|
+
{ section: '基础数据', metric: '封面点击率', value: '12%', extra: '粉丝 11%' },
|
|
203
|
+
{ section: '基础数据', metric: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
|
|
204
|
+
{ section: '基础数据', metric: '涨粉数', value: '2', extra: '' },
|
|
205
|
+
{ section: '互动数据', metric: '点赞数', value: '8', extra: '粉丝占比 25%' },
|
|
206
|
+
{ section: '互动数据', metric: '评论数', value: '1', extra: '粉丝占比 0%' },
|
|
207
|
+
{ section: '互动数据', metric: '收藏数', value: '3', extra: '粉丝占比 50%' },
|
|
208
|
+
{ section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
|
|
209
|
+
]);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu Creator Notes Summary — batch summary for recent notes.
|
|
3
|
+
*
|
|
4
|
+
* Combines creator-notes and creator-note-detail into a single command that
|
|
5
|
+
* returns one summary row per note, suitable for quick review or downstream JSON use.
|
|
6
|
+
*/
|
|
7
|
+
import { type CreatorNoteRow } from './creator-notes.js';
|
|
8
|
+
import { type CreatorNoteDetailRow } from './creator-note-detail.js';
|
|
9
|
+
type CreatorNoteSummaryRow = {
|
|
10
|
+
rank: number;
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
published_at: string;
|
|
14
|
+
views: string;
|
|
15
|
+
likes: string;
|
|
16
|
+
collects: string;
|
|
17
|
+
comments: string;
|
|
18
|
+
shares: string;
|
|
19
|
+
avg_view_time: string;
|
|
20
|
+
rise_fans: string;
|
|
21
|
+
top_source: string;
|
|
22
|
+
top_source_pct: string;
|
|
23
|
+
top_interest: string;
|
|
24
|
+
top_interest_pct: string;
|
|
25
|
+
url: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function summarizeCreatorNote(note: CreatorNoteRow, rows: CreatorNoteDetailRow[], rank: number): CreatorNoteSummaryRow;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaohongshu Creator Notes Summary — batch summary for recent notes.
|
|
3
|
+
*
|
|
4
|
+
* Combines creator-notes and creator-note-detail into a single command that
|
|
5
|
+
* returns one summary row per note, suitable for quick review or downstream JSON use.
|
|
6
|
+
*/
|
|
7
|
+
import { cli, Strategy } from '../../registry.js';
|
|
8
|
+
import { fetchCreatorNotes } from './creator-notes.js';
|
|
9
|
+
import { fetchCreatorNoteDetailRows } from './creator-note-detail.js';
|
|
10
|
+
function findDetailValue(rows, metric) {
|
|
11
|
+
return rows.find((row) => row.metric === metric)?.value ?? '';
|
|
12
|
+
}
|
|
13
|
+
function findTopBySectionPrefix(rows, section, prefix) {
|
|
14
|
+
const matches = rows.filter((row) => row.section === section && row.metric.startsWith(prefix) && row.value);
|
|
15
|
+
if (matches.length === 0)
|
|
16
|
+
return { label: '', value: '' };
|
|
17
|
+
const sorted = [...matches].sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
|
|
18
|
+
const top = sorted[0];
|
|
19
|
+
return {
|
|
20
|
+
label: top.metric.slice(prefix.length),
|
|
21
|
+
value: top.value,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function summarizeCreatorNote(note, rows, rank) {
|
|
25
|
+
const topSource = findTopBySectionPrefix(rows, '观看来源', '');
|
|
26
|
+
const topInterest = findTopBySectionPrefix(rows, '观众画像', '兴趣/');
|
|
27
|
+
return {
|
|
28
|
+
rank,
|
|
29
|
+
id: note.id,
|
|
30
|
+
title: note.title,
|
|
31
|
+
published_at: findDetailValue(rows, 'published_at') || note.date,
|
|
32
|
+
views: findDetailValue(rows, '观看数') || String(note.views),
|
|
33
|
+
likes: findDetailValue(rows, '点赞数') || String(note.likes),
|
|
34
|
+
collects: findDetailValue(rows, '收藏数') || String(note.collects),
|
|
35
|
+
comments: findDetailValue(rows, '评论数') || String(note.comments),
|
|
36
|
+
shares: findDetailValue(rows, '分享数'),
|
|
37
|
+
avg_view_time: findDetailValue(rows, '平均观看时长'),
|
|
38
|
+
rise_fans: findDetailValue(rows, '涨粉数'),
|
|
39
|
+
top_source: topSource.label,
|
|
40
|
+
top_source_pct: topSource.value,
|
|
41
|
+
top_interest: topInterest.label,
|
|
42
|
+
top_interest_pct: topInterest.value,
|
|
43
|
+
url: note.url,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
cli({
|
|
47
|
+
site: 'xiaohongshu',
|
|
48
|
+
name: 'creator-notes-summary',
|
|
49
|
+
description: '小红书最近笔记批量摘要 (列表 + 单篇关键数据汇总)',
|
|
50
|
+
domain: 'creator.xiaohongshu.com',
|
|
51
|
+
strategy: Strategy.COOKIE,
|
|
52
|
+
browser: true,
|
|
53
|
+
args: [
|
|
54
|
+
{ name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' },
|
|
55
|
+
],
|
|
56
|
+
columns: ['rank', 'id', 'title', 'views', 'likes', 'collects', 'comments', 'shares', 'avg_view_time', 'rise_fans', 'top_source', 'top_interest', 'url'],
|
|
57
|
+
timeoutSeconds: 180,
|
|
58
|
+
func: async (page, kwargs) => {
|
|
59
|
+
const limit = kwargs.limit || 3;
|
|
60
|
+
const notes = await fetchCreatorNotes(page, limit);
|
|
61
|
+
if (!notes.length) {
|
|
62
|
+
throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
|
|
63
|
+
}
|
|
64
|
+
const results = [];
|
|
65
|
+
for (const [index, note] of notes.entries()) {
|
|
66
|
+
if (!note.id) {
|
|
67
|
+
results.push({
|
|
68
|
+
rank: index + 1,
|
|
69
|
+
id: note.id,
|
|
70
|
+
title: note.title,
|
|
71
|
+
published_at: note.date,
|
|
72
|
+
views: String(note.views),
|
|
73
|
+
likes: String(note.likes),
|
|
74
|
+
collects: String(note.collects),
|
|
75
|
+
comments: String(note.comments),
|
|
76
|
+
shares: '',
|
|
77
|
+
avg_view_time: '',
|
|
78
|
+
rise_fans: '',
|
|
79
|
+
top_source: '',
|
|
80
|
+
top_source_pct: '',
|
|
81
|
+
top_interest: '',
|
|
82
|
+
top_interest_pct: '',
|
|
83
|
+
url: note.url,
|
|
84
|
+
});
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const detailRows = await fetchCreatorNoteDetailRows(page, note.id);
|
|
88
|
+
results.push(summarizeCreatorNote(note, detailRows, index + 1));
|
|
89
|
+
}
|
|
90
|
+
return results;
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './creator-notes-summary.js';
|