@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
@@ -1,95 +1,363 @@
1
1
  /**
2
- * Xiaohongshu Creator Note Detail — per-note analytics breakdown.
2
+ * Xiaohongshu Creator Note Detail — per-note analytics from the creator detail page.
3
3
  *
4
- * Uses the creator.xiaohongshu.com internal API (cookie auth).
5
- * Returns total reads, engagement, likes, collects, comments, shares
6
- * for a specific note, split by channel (organic vs promoted vs video).
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 note URL or creator-notes command)' },
349
+ { name: 'note_id', type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
22
350
  ],
23
- columns: ['channel', 'reads', 'engagement', 'likes', 'collects', 'comments', 'shares'],
351
+ columns: ['section', 'metric', 'value', 'extra'],
24
352
  func: async (page, kwargs) => {
25
353
  const noteId: string = kwargs.note_id;
26
- const encodedNoteId = encodeURIComponent(noteId);
354
+ const rows = await fetchCreatorNoteDetailRows(page, noteId);
27
355
 
28
- // Navigate for cookie context
29
- await page.goto('https://creator.xiaohongshu.com/new/home');
30
- await page.wait(2);
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
- const d = data.data;
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
  });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { summarizeCreatorNote } from './creator-notes-summary.js';
3
+ import type { CreatorNoteRow } from './creator-notes.js';
4
+ import type { CreatorNoteDetailRow } from './creator-note-detail.js';
5
+ import './creator-notes-summary.js';
6
+
7
+ describe('xiaohongshu creator-notes-summary', () => {
8
+ it('summarizes note list row and detail rows into one compact row', () => {
9
+ const note: CreatorNoteRow = {
10
+ id: '69ba940500000000200384db',
11
+ title: '一张图讲清 诡秘之主·耕种者途径',
12
+ date: '2026年03月18日 20:01',
13
+ views: 549,
14
+ likes: 19,
15
+ collects: 10,
16
+ comments: 7,
17
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
18
+ };
19
+
20
+ const rows: CreatorNoteDetailRow[] = [
21
+ { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
22
+ { section: '基础数据', metric: '观看数', value: '549', extra: '' },
23
+ { section: '互动数据', metric: '点赞数', value: '19', extra: '' },
24
+ { section: '互动数据', metric: '收藏数', value: '10', extra: '' },
25
+ { section: '互动数据', metric: '评论数', value: '7', extra: '' },
26
+ { section: '互动数据', metric: '分享数', value: '6', extra: '' },
27
+ { section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '' },
28
+ { section: '基础数据', metric: '涨粉数', value: '3', extra: '' },
29
+ { section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '' },
30
+ { section: '观看来源', metric: '搜索', value: '0.3%', extra: '' },
31
+ { section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' },
32
+ { section: '观众画像', metric: '兴趣/游戏', value: '11%', extra: '' },
33
+ ];
34
+
35
+ expect(summarizeCreatorNote(note, rows, 1)).toEqual({
36
+ rank: 1,
37
+ id: '69ba940500000000200384db',
38
+ title: '一张图讲清 诡秘之主·耕种者途径',
39
+ published_at: '2026-03-18 20:01',
40
+ views: '549',
41
+ likes: '19',
42
+ collects: '10',
43
+ comments: '7',
44
+ shares: '6',
45
+ avg_view_time: '51.5秒',
46
+ rise_fans: '3',
47
+ top_source: '首页推荐',
48
+ top_source_pct: '89.9%',
49
+ top_interest: '二次元',
50
+ top_interest_pct: '13%',
51
+ url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
52
+ });
53
+ });
54
+ });
@@ -0,0 +1,120 @@
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
+
8
+ import { cli, Strategy } from '../../registry.js';
9
+ import { fetchCreatorNotes, type CreatorNoteRow } from './creator-notes.js';
10
+ import { fetchCreatorNoteDetailRows, type CreatorNoteDetailRow } from './creator-note-detail.js';
11
+
12
+ type CreatorNoteSummaryRow = {
13
+ rank: number;
14
+ id: string;
15
+ title: string;
16
+ published_at: string;
17
+ views: string;
18
+ likes: string;
19
+ collects: string;
20
+ comments: string;
21
+ shares: string;
22
+ avg_view_time: string;
23
+ rise_fans: string;
24
+ top_source: string;
25
+ top_source_pct: string;
26
+ top_interest: string;
27
+ top_interest_pct: string;
28
+ url: string;
29
+ };
30
+
31
+ function findDetailValue(rows: CreatorNoteDetailRow[], metric: string): string {
32
+ return rows.find((row) => row.metric === metric)?.value ?? '';
33
+ }
34
+
35
+ function findTopBySectionPrefix(rows: CreatorNoteDetailRow[], section: string, prefix: string): { label: string; value: string } {
36
+ const matches = rows.filter((row) => row.section === section && row.metric.startsWith(prefix) && row.value);
37
+ if (matches.length === 0) return { label: '', value: '' };
38
+ const sorted = [...matches].sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
39
+ const top = sorted[0];
40
+ return {
41
+ label: top.metric.slice(prefix.length),
42
+ value: top.value,
43
+ };
44
+ }
45
+
46
+ export function summarizeCreatorNote(note: CreatorNoteRow, rows: CreatorNoteDetailRow[], rank: number): CreatorNoteSummaryRow {
47
+ const topSource = findTopBySectionPrefix(rows, '观看来源', '');
48
+ const topInterest = findTopBySectionPrefix(rows, '观众画像', '兴趣/');
49
+
50
+ return {
51
+ rank,
52
+ id: note.id,
53
+ title: note.title,
54
+ published_at: findDetailValue(rows, 'published_at') || note.date,
55
+ views: findDetailValue(rows, '观看数') || String(note.views),
56
+ likes: findDetailValue(rows, '点赞数') || String(note.likes),
57
+ collects: findDetailValue(rows, '收藏数') || String(note.collects),
58
+ comments: findDetailValue(rows, '评论数') || String(note.comments),
59
+ shares: findDetailValue(rows, '分享数'),
60
+ avg_view_time: findDetailValue(rows, '平均观看时长'),
61
+ rise_fans: findDetailValue(rows, '涨粉数'),
62
+ top_source: topSource.label,
63
+ top_source_pct: topSource.value,
64
+ top_interest: topInterest.label,
65
+ top_interest_pct: topInterest.value,
66
+ url: note.url,
67
+ };
68
+ }
69
+
70
+ cli({
71
+ site: 'xiaohongshu',
72
+ name: 'creator-notes-summary',
73
+ description: '小红书最近笔记批量摘要 (列表 + 单篇关键数据汇总)',
74
+ domain: 'creator.xiaohongshu.com',
75
+ strategy: Strategy.COOKIE,
76
+ browser: true,
77
+ args: [
78
+ { name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' },
79
+ ],
80
+ columns: ['rank', 'id', 'title', 'views', 'likes', 'collects', 'comments', 'shares', 'avg_view_time', 'rise_fans', 'top_source', 'top_interest', 'url'],
81
+ timeoutSeconds: 180,
82
+ func: async (page, kwargs) => {
83
+ const limit = kwargs.limit || 3;
84
+ const notes = await fetchCreatorNotes(page, limit);
85
+
86
+ if (!notes.length) {
87
+ throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
88
+ }
89
+
90
+ const results: CreatorNoteSummaryRow[] = [];
91
+ for (const [index, note] of notes.entries()) {
92
+ if (!note.id) {
93
+ results.push({
94
+ rank: index + 1,
95
+ id: note.id,
96
+ title: note.title,
97
+ published_at: note.date,
98
+ views: String(note.views),
99
+ likes: String(note.likes),
100
+ collects: String(note.collects),
101
+ comments: String(note.comments),
102
+ shares: '',
103
+ avg_view_time: '',
104
+ rise_fans: '',
105
+ top_source: '',
106
+ top_source_pct: '',
107
+ top_interest: '',
108
+ top_interest_pct: '',
109
+ url: note.url,
110
+ });
111
+ continue;
112
+ }
113
+
114
+ const detailRows = await fetchCreatorNoteDetailRows(page, note.id);
115
+ results.push(summarizeCreatorNote(note, detailRows, index + 1));
116
+ }
117
+
118
+ return results;
119
+ },
120
+ });