feat: xiaohongshu creator flows migration (#124)
Migrated xiaohongshu creator flows to v1.0.2+. - creator-notes with API + DOM fallback - creator-note-detail with audience/trend data - creator-notes-summary batch overview - Tests: 3 files, 9 tests Co-authored-by: ajia <491387123@qq.com>
This commit is contained in:
1 parent
2876750891
commit
8f1725982e
9 files changed
+1343
-153
No files matched your search
@@ -5,6 +5,7 @@ dist/
|
||||
.opencli/
|
||||
.mcp.json
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# VitePress
|
||||
docs/.vitepress/dist
|
||||
|
||||
+3
-3
@@ -8,7 +8,7 @@
|
||||
* - Shared DOM helper methods extracted to reduce duplication with Page (P1 #5)
|
||||
*/
|
||||
|
||||
import { WebSocket } from 'ws';
|
||||
import { WebSocket, type RawData } from 'ws';
|
||||
import type { IPage } from '../types.js';
|
||||
import { wrapForEval } from './utils.js';
|
||||
import {
|
||||
@@ -63,12 +63,12 @@ export class CDPBridge {
|
||||
resolve(new CDPPage(this));
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
ws.on('error', (err: Error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
ws.on('message', (data) => {
|
||||
ws.on('message', (data: RawData) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
// Handle command responses
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import { appendAudienceRows, appendTrendRows, parseCreatorNoteDetailText } from './creator-note-detail.js';
|
||||
import './creator-note-detail.js';
|
||||
|
||||
function createPageMock(evaluateResult: any): IPage {
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate: vi.fn().mockResolvedValue(evaluateResult),
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
};
|
||||
}
|
||||
|
||||
describe('xiaohongshu creator-note-detail', () => {
|
||||
it('parses note detail page text into info and metric rows', () => {
|
||||
const bodyText = `笔记数据详情
|
||||
一张图讲清 诡秘之主·耕种者途径
|
||||
#诡秘之主
|
||||
#耕种者序列
|
||||
2026-03-18 20:01
|
||||
切换笔记
|
||||
笔记诊断
|
||||
核心数据
|
||||
观看来源
|
||||
观众画像
|
||||
核心数据
|
||||
基础数据
|
||||
部分数据统计中,次日可查看
|
||||
导出数据
|
||||
曝光数
|
||||
1733
|
||||
粉丝占比 6.6%
|
||||
实时
|
||||
观看数
|
||||
544
|
||||
粉丝占比 7.2%
|
||||
封面点击率
|
||||
18.6%
|
||||
粉丝 19.1%
|
||||
平均观看时长
|
||||
51.5秒
|
||||
粉丝 55.8秒
|
||||
涨粉数
|
||||
3
|
||||
按小时
|
||||
按天
|
||||
互动数据
|
||||
数据实时更新
|
||||
导出数据
|
||||
点赞数
|
||||
19
|
||||
粉丝占比 60%
|
||||
评论数
|
||||
7
|
||||
粉丝占比 33.3%
|
||||
收藏数
|
||||
10
|
||||
粉丝占比 33.3%
|
||||
分享数
|
||||
6
|
||||
粉丝占比 0%`;
|
||||
|
||||
expect(parseCreatorNoteDetailText(bodyText, '69ba940500000000200384db')).toEqual([
|
||||
{ section: '笔记信息', metric: 'note_id', value: '69ba940500000000200384db', extra: '' },
|
||||
{ section: '笔记信息', metric: 'title', value: '一张图讲清 诡秘之主·耕种者途径', extra: '' },
|
||||
{ section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
|
||||
{ section: '基础数据', metric: '曝光数', value: '1733', extra: '粉丝占比 6.6%' },
|
||||
{ section: '基础数据', metric: '观看数', value: '544', extra: '粉丝占比 7.2%' },
|
||||
{ section: '基础数据', metric: '封面点击率', value: '18.6%', extra: '粉丝 19.1%' },
|
||||
{ section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '粉丝 55.8秒' },
|
||||
{ section: '基础数据', metric: '涨粉数', value: '3', extra: '' },
|
||||
{ section: '互动数据', metric: '点赞数', value: '19', extra: '粉丝占比 60%' },
|
||||
{ section: '互动数据', metric: '评论数', value: '7', extra: '粉丝占比 33.3%' },
|
||||
{ section: '互动数据', metric: '收藏数', value: '10', extra: '粉丝占比 33.3%' },
|
||||
{ section: '互动数据', metric: '分享数', value: '6', extra: '粉丝占比 0%' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends audience source and portrait rows from API payloads', () => {
|
||||
const rows = appendAudienceRows([], {
|
||||
audienceSource: {
|
||||
source: [
|
||||
{
|
||||
title: '首页推荐',
|
||||
value_with_double: 89.9,
|
||||
info: {
|
||||
imp_count: 1469,
|
||||
view_count: 276,
|
||||
interaction_count: 15,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
audienceSourceDetail: {
|
||||
gender: [
|
||||
{ title: '男性', value: 82 },
|
||||
{ title: '女性', value: 18 },
|
||||
],
|
||||
age: [
|
||||
{ title: '25-34', value: 55 },
|
||||
],
|
||||
city: [
|
||||
{ title: '上海', value: 8 },
|
||||
],
|
||||
interest: [
|
||||
{ title: '二次元', value: 13 },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '曝光 1469 · 观看 276 · 互动 15' },
|
||||
{ section: '观众画像', metric: '性别/男性', value: '82%', extra: '' },
|
||||
{ section: '观众画像', metric: '性别/女性', value: '18%', extra: '' },
|
||||
{ section: '观众画像', metric: '年龄/25-34', value: '55%', extra: '' },
|
||||
{ section: '观众画像', metric: '城市/上海', value: '8%', extra: '' },
|
||||
{ section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('appends trend summary rows from hour/day series payloads', () => {
|
||||
const rows = appendTrendRows([], {
|
||||
audienceTrend: {
|
||||
no_data: true,
|
||||
no_data_tip_msg: '数据统计中,请稍后查看',
|
||||
},
|
||||
noteBase: {
|
||||
hour: {
|
||||
view_list: [
|
||||
{ date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 54 },
|
||||
{ date: new Date('2026-03-18T22:00:00+08:00').getTime(), count: 51 },
|
||||
],
|
||||
like_list: [
|
||||
{ date: new Date('2026-03-18T21:00:00+08:00').getTime(), count: 2 },
|
||||
],
|
||||
},
|
||||
day: {
|
||||
view_list: [
|
||||
{ date: new Date('2026-03-18T00:00:00+08:00').getTime(), count: 307 },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(rows).toEqual([
|
||||
{ section: '趋势说明', metric: '观众趋势', value: '暂不可用', extra: '数据统计中,请稍后查看' },
|
||||
{ section: '趋势数据', metric: '按小时/观看数', value: '2 points', extra: '03-18 21:00=54 | 03-18 22:00=51' },
|
||||
{ section: '趋势数据', metric: '按小时/点赞数', value: '1 points', extra: '03-18 21:00=2' },
|
||||
{ section: '趋势数据', metric: '按天/观看数', value: '1 points', extra: '2026-03-18=307' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('navigates to the note detail page and returns parsed rows', async () => {
|
||||
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock(`笔记数据详情
|
||||
示例笔记
|
||||
2026-03-19 12:00
|
||||
曝光数
|
||||
100
|
||||
粉丝占比 10%
|
||||
观看数
|
||||
50
|
||||
粉丝占比 20%
|
||||
封面点击率
|
||||
12%
|
||||
粉丝 11%
|
||||
平均观看时长
|
||||
30秒
|
||||
粉丝 31秒
|
||||
涨粉数
|
||||
2
|
||||
点赞数
|
||||
8
|
||||
粉丝占比 25%
|
||||
评论数
|
||||
1
|
||||
粉丝占比 0%
|
||||
收藏数
|
||||
3
|
||||
粉丝占比 50%
|
||||
分享数
|
||||
0
|
||||
粉丝占比 0%`);
|
||||
|
||||
const result = await cmd!.func!(page, { note_id: 'demo-note-id' });
|
||||
|
||||
expect((page.goto as any).mock.calls[0][0]).toBe('https://creator.xiaohongshu.com/statistics/note-detail?noteId=demo-note-id');
|
||||
expect((page.evaluate as any).mock.calls[0][0]).toBe('() => document.body.innerText');
|
||||
expect(result).toEqual([
|
||||
{ section: '笔记信息', metric: 'note_id', value: 'demo-note-id', extra: '' },
|
||||
{ section: '笔记信息', metric: 'title', value: '示例笔记', extra: '' },
|
||||
{ section: '笔记信息', metric: 'published_at', value: '2026-03-19 12:00', extra: '' },
|
||||
{ section: '基础数据', metric: '曝光数', value: '100', extra: '粉丝占比 10%' },
|
||||
{ section: '基础数据', metric: '观看数', value: '50', extra: '粉丝占比 20%' },
|
||||
{ section: '基础数据', metric: '封面点击率', value: '12%', extra: '粉丝 11%' },
|
||||
{ section: '基础数据', metric: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
|
||||
{ section: '基础数据', metric: '涨粉数', value: '2', extra: '' },
|
||||
{ section: '互动数据', metric: '点赞数', value: '8', extra: '粉丝占比 25%' },
|
||||
{ section: '互动数据', metric: '评论数', value: '1', extra: '粉丝占比 0%' },
|
||||
{ section: '互动数据', metric: '收藏数', value: '3', extra: '粉丝占比 50%' },
|
||||
{ section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +1,434 @@
|
||||
/**
|
||||
* Xiaohongshu Creator Note Detail — per-note analytics breakdown.
|
||||
* Xiaohongshu Creator Note Detail — per-note analytics from the creator detail page.
|
||||
*
|
||||
* Uses the creator.xiaohongshu.com internal API (cookie auth).
|
||||
* Returns total reads, engagement, likes, collects, comments, shares
|
||||
* for a specific note, split by channel (organic vs promoted vs video).
|
||||
* The current creator center no longer serves stable single-note metrics from the legacy
|
||||
* `/api/galaxy/creator/data/note_detail` endpoint. The real note detail page loads data
|
||||
* through the newer `datacenter/note/*` API family, so this command navigates to the
|
||||
* detail page and parses the rendered metrics that are backed by those APIs.
|
||||
*
|
||||
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
type CreatorNoteDetailRow = {
|
||||
section: string;
|
||||
metric: string;
|
||||
value: string;
|
||||
extra: string;
|
||||
};
|
||||
|
||||
export type { CreatorNoteDetailRow };
|
||||
|
||||
type AudienceSourceItem = {
|
||||
title?: string;
|
||||
value_with_double?: number;
|
||||
info?: {
|
||||
imp_count?: number;
|
||||
view_count?: number;
|
||||
interaction_count?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type AudiencePortraitItem = {
|
||||
title?: string;
|
||||
value?: number;
|
||||
};
|
||||
|
||||
type NoteTrendPoint = {
|
||||
date?: number;
|
||||
count?: number;
|
||||
count_with_double?: number;
|
||||
};
|
||||
|
||||
type NoteTrendBucket = {
|
||||
imp_list?: NoteTrendPoint[];
|
||||
view_list?: NoteTrendPoint[];
|
||||
view_time_list?: NoteTrendPoint[];
|
||||
like_list?: NoteTrendPoint[];
|
||||
comment_list?: NoteTrendPoint[];
|
||||
collect_list?: NoteTrendPoint[];
|
||||
share_list?: NoteTrendPoint[];
|
||||
rise_fans_list?: NoteTrendPoint[];
|
||||
};
|
||||
|
||||
type NoteDetailApiPayload = {
|
||||
noteBase?: {
|
||||
hour?: NoteTrendBucket;
|
||||
day?: NoteTrendBucket;
|
||||
};
|
||||
audienceTrend?: {
|
||||
no_data?: boolean;
|
||||
no_data_tip_msg?: string;
|
||||
};
|
||||
audienceSource?: {
|
||||
source?: AudienceSourceItem[];
|
||||
};
|
||||
audienceSourceDetail?: {
|
||||
gender?: AudiencePortraitItem[];
|
||||
age?: AudiencePortraitItem[];
|
||||
city?: AudiencePortraitItem[];
|
||||
interest?: AudiencePortraitItem[];
|
||||
};
|
||||
};
|
||||
|
||||
const NOTE_DETAIL_DATETIME_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
|
||||
const NOTE_DETAIL_METRICS = [
|
||||
{ label: '曝光数', section: '基础数据' },
|
||||
{ label: '观看数', section: '基础数据' },
|
||||
{ label: '封面点击率', section: '基础数据' },
|
||||
{ label: '平均观看时长', section: '基础数据' },
|
||||
{ label: '涨粉数', section: '基础数据' },
|
||||
{ label: '点赞数', section: '互动数据' },
|
||||
{ label: '评论数', section: '互动数据' },
|
||||
{ label: '收藏数', section: '互动数据' },
|
||||
{ label: '分享数', section: '互动数据' },
|
||||
] as const;
|
||||
|
||||
const NOTE_DETAIL_METRIC_LABELS = new Set<string>(NOTE_DETAIL_METRICS.map((metric) => metric.label));
|
||||
const NOTE_DETAIL_NOISE_LINES = new Set([
|
||||
'切换笔记',
|
||||
'笔记诊断',
|
||||
'核心数据',
|
||||
'观看来源',
|
||||
'观众画像',
|
||||
'提升建议',
|
||||
'基础数据',
|
||||
'互动数据',
|
||||
'导出数据',
|
||||
'实时',
|
||||
'按小时',
|
||||
'按天',
|
||||
]);
|
||||
|
||||
function findNoteTitle(lines: string[]): string {
|
||||
const detailIndex = lines.indexOf('笔记数据详情');
|
||||
if (detailIndex < 0) return '';
|
||||
|
||||
for (let i = detailIndex + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line || line.startsWith('#') || NOTE_DETAIL_DATETIME_RE.test(line)) continue;
|
||||
if (NOTE_DETAIL_NOISE_LINES.has(line)) continue;
|
||||
return line;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function findMetricValue(lines: string[], startIndex: number): { value: string; extra: string } {
|
||||
let value = '';
|
||||
let extra = '';
|
||||
|
||||
for (let i = startIndex + 1; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (!line) continue;
|
||||
if (NOTE_DETAIL_METRIC_LABELS.has(line)) break;
|
||||
if (NOTE_DETAIL_NOISE_LINES.has(line) || line.startsWith('数据更新至') || line.startsWith('部分数据统计中')) continue;
|
||||
|
||||
if (!value) {
|
||||
value = line;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!extra && line.startsWith('粉丝')) {
|
||||
extra = line;
|
||||
break;
|
||||
}
|
||||
|
||||
if (line === '0' || /^\d/.test(line) || line.endsWith('%') || line.endsWith('秒')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { value, extra };
|
||||
}
|
||||
|
||||
export function parseCreatorNoteDetailText(bodyText: string, noteId: string): CreatorNoteDetailRow[] {
|
||||
const lines = bodyText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const title = findNoteTitle(lines);
|
||||
const publishedAt = lines.find((line) => NOTE_DETAIL_DATETIME_RE.test(line)) ?? '';
|
||||
const rows: CreatorNoteDetailRow[] = [
|
||||
{ section: '笔记信息', metric: 'note_id', value: noteId, extra: '' },
|
||||
{ section: '笔记信息', metric: 'title', value: title, extra: '' },
|
||||
{ section: '笔记信息', metric: 'published_at', value: publishedAt, extra: '' },
|
||||
];
|
||||
|
||||
for (const metric of NOTE_DETAIL_METRICS) {
|
||||
const index = lines.indexOf(metric.label);
|
||||
if (index < 0) continue;
|
||||
const { value, extra } = findMetricValue(lines, index);
|
||||
rows.push({
|
||||
section: metric.section,
|
||||
metric: metric.label,
|
||||
value,
|
||||
extra,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function toPercentString(value?: number): string {
|
||||
return value == null ? '' : `${value}%`;
|
||||
}
|
||||
|
||||
function appendAudienceSourceRows(rows: CreatorNoteDetailRow[], payload?: NoteDetailApiPayload): CreatorNoteDetailRow[] {
|
||||
const sourceItems = payload?.audienceSource?.source ?? [];
|
||||
for (const item of sourceItems) {
|
||||
if (!item.title) continue;
|
||||
const extras: string[] = [];
|
||||
if (item.info?.imp_count != null) extras.push(`曝光 ${item.info.imp_count}`);
|
||||
if (item.info?.view_count != null) extras.push(`观看 ${item.info.view_count}`);
|
||||
if (item.info?.interaction_count != null) extras.push(`互动 ${item.info.interaction_count}`);
|
||||
rows.push({
|
||||
section: '观看来源',
|
||||
metric: item.title,
|
||||
value: toPercentString(item.value_with_double),
|
||||
extra: extras.join(' · '),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function appendAudiencePortraitGroup(
|
||||
rows: CreatorNoteDetailRow[],
|
||||
groupLabel: string,
|
||||
items?: AudiencePortraitItem[],
|
||||
): CreatorNoteDetailRow[] {
|
||||
for (const item of items ?? []) {
|
||||
if (!item.title) continue;
|
||||
rows.push({
|
||||
section: '观众画像',
|
||||
metric: `${groupLabel}/${item.title}`,
|
||||
value: toPercentString(item.value),
|
||||
extra: '',
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function appendAudienceRows(rows: CreatorNoteDetailRow[], payload?: NoteDetailApiPayload): CreatorNoteDetailRow[] {
|
||||
appendAudienceSourceRows(rows, payload);
|
||||
appendAudiencePortraitGroup(rows, '性别', payload?.audienceSourceDetail?.gender);
|
||||
appendAudiencePortraitGroup(rows, '年龄', payload?.audienceSourceDetail?.age);
|
||||
appendAudiencePortraitGroup(rows, '城市', payload?.audienceSourceDetail?.city);
|
||||
appendAudiencePortraitGroup(rows, '兴趣', payload?.audienceSourceDetail?.interest);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function formatTrendTimestamp(ts: number | undefined, granularity: 'hour' | 'day'): string {
|
||||
if (!ts) return '';
|
||||
const date = new Date(ts);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
if (granularity === 'hour') {
|
||||
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:00`;
|
||||
}
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function formatTrendSeries(points: NoteTrendPoint[] | undefined, granularity: 'hour' | 'day'): string {
|
||||
if (!points?.length) return '';
|
||||
return points
|
||||
.map((point) => {
|
||||
const label = formatTrendTimestamp(point.date, granularity);
|
||||
const value = point.count_with_double ?? point.count;
|
||||
return label && value != null ? `${label}=${value}` : '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
}
|
||||
|
||||
const TREND_SERIES_CONFIG = [
|
||||
{ key: 'imp_list', label: '曝光数' },
|
||||
{ key: 'view_list', label: '观看数' },
|
||||
{ key: 'view_time_list', label: '平均观看时长' },
|
||||
{ key: 'like_list', label: '点赞数' },
|
||||
{ key: 'comment_list', label: '评论数' },
|
||||
{ key: 'collect_list', label: '收藏数' },
|
||||
{ key: 'share_list', label: '分享数' },
|
||||
{ key: 'rise_fans_list', label: '涨粉数' },
|
||||
] as const;
|
||||
|
||||
export function appendTrendRows(rows: CreatorNoteDetailRow[], payload?: NoteDetailApiPayload): CreatorNoteDetailRow[] {
|
||||
if (payload?.audienceTrend?.no_data_tip_msg) {
|
||||
rows.push({
|
||||
section: '趋势说明',
|
||||
metric: '观众趋势',
|
||||
value: payload.audienceTrend.no_data ? '暂不可用' : '可用',
|
||||
extra: payload.audienceTrend.no_data_tip_msg,
|
||||
});
|
||||
}
|
||||
|
||||
const buckets: Array<{ label: string; granularity: 'hour' | 'day'; data?: NoteTrendBucket }> = [
|
||||
{ label: '按小时', granularity: 'hour', data: payload?.noteBase?.hour },
|
||||
{ label: '按天', granularity: 'day', data: payload?.noteBase?.day },
|
||||
];
|
||||
|
||||
for (const bucket of buckets) {
|
||||
for (const series of TREND_SERIES_CONFIG) {
|
||||
const points = bucket.data?.[series.key];
|
||||
const formatted = formatTrendSeries(points, bucket.granularity);
|
||||
if (!formatted) continue;
|
||||
rows.push({
|
||||
section: '趋势数据',
|
||||
metric: `${bucket.label}/${series.label}`,
|
||||
value: `${points!.length} points`,
|
||||
extra: formatted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function deriveCdpHttpBase(endpoint?: string): string | null {
|
||||
if (!endpoint) return null;
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
|
||||
url.pathname = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequestedCdpEndpoint(): { endpoint: string | null; requestedCdp: boolean } {
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT?.trim();
|
||||
return {
|
||||
endpoint: endpoint || null,
|
||||
requestedCdp: Boolean(endpoint),
|
||||
};
|
||||
}
|
||||
|
||||
async function captureNoteDetailApiPayload(noteId: string): Promise<NoteDetailApiPayload | null> {
|
||||
const { endpoint, requestedCdp } = resolveRequestedCdpEndpoint();
|
||||
if (!requestedCdp) return null;
|
||||
|
||||
const httpBase = deriveCdpHttpBase(endpoint ?? 'http://127.0.0.1:9222') ?? 'http://127.0.0.1:9222';
|
||||
const targets = await fetch(`${httpBase}/json/list`).then((resp) => resp.json()) as Array<{
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}>;
|
||||
const target = targets.find((entry) => entry.url?.includes(`/statistics/note-detail?noteId=${noteId}`) && entry.webSocketDebuggerUrl);
|
||||
if (!target?.webSocketDebuggerUrl) return null;
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
const payload: NoteDetailApiPayload = {};
|
||||
const pending = new Map<number, { resolve: (value: any) => void; reject: (err: Error) => void }>();
|
||||
const desiredUrls = new Map<string, keyof NoteDetailApiPayload>([
|
||||
[`/api/galaxy/creator/datacenter/note/base?note_id=${noteId}`, 'noteBase'],
|
||||
[`/api/galaxy/creator/datacenter/note/analyze/audience/trend?note_id=${noteId}`, 'audienceTrend'],
|
||||
[`/api/galaxy/creator/datacenter/note/audience/source?note_id=${noteId}`, 'audienceSource'],
|
||||
[`/api/galaxy/creator/datacenter/note/audience/source/detail?note_id=${noteId}`, 'audienceSourceDetail'],
|
||||
]);
|
||||
let nextId = 1;
|
||||
|
||||
const send = (method: string, params: Record<string, unknown> = {}) => {
|
||||
const id = nextId++;
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const completed = new Set<keyof NoteDetailApiPayload>();
|
||||
const waitForPayload = new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(), 8000);
|
||||
ws.addEventListener('message', async (event) => {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
if (typeof msg.id === 'number' && pending.has(msg.id)) {
|
||||
const waiter = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) waiter.reject(new Error(msg.error.message));
|
||||
else waiter.resolve(msg.result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.method === 'Network.responseReceived') {
|
||||
const requestId = msg.params?.requestId as string | undefined;
|
||||
const responseUrl = msg.params?.response?.url as string | undefined;
|
||||
if (!requestId || !responseUrl) return;
|
||||
for (const [suffix, key] of desiredUrls) {
|
||||
if (!responseUrl.includes(suffix)) continue;
|
||||
try {
|
||||
const bodyResult = await send('Network.getResponseBody', { requestId });
|
||||
const parsed = JSON.parse(bodyResult.body);
|
||||
payload[key] = parsed.data ?? {};
|
||||
completed.add(key);
|
||||
if (completed.size === desiredUrls.size) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener('open', () => resolve(), { once: true });
|
||||
ws.addEventListener('error', () => reject(new Error('Failed to connect to CDP note-detail target')), { once: true });
|
||||
});
|
||||
|
||||
try {
|
||||
await send('Network.enable');
|
||||
await send('Page.enable');
|
||||
await send('Page.reload', { ignoreCache: true });
|
||||
await waitForPayload;
|
||||
} finally {
|
||||
for (const waiter of pending.values()) {
|
||||
waiter.reject(new Error('CDP note detail capture closed'));
|
||||
}
|
||||
pending.clear();
|
||||
ws.close();
|
||||
}
|
||||
|
||||
return completed.size > 0 ? payload : null;
|
||||
}
|
||||
|
||||
export async function fetchCreatorNoteDetailRows(page: any, noteId: string): Promise<CreatorNoteDetailRow[]> {
|
||||
await page.goto(`https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(noteId)}`);
|
||||
await page.wait(4);
|
||||
|
||||
const bodyText = await page.evaluate('() => document.body.innerText');
|
||||
const rows = parseCreatorNoteDetailText(typeof bodyText === 'string' ? bodyText : '', noteId);
|
||||
const apiPayload = await captureNoteDetailApiPayload(noteId).catch(() => null);
|
||||
appendTrendRows(rows, apiPayload ?? undefined);
|
||||
appendAudienceRows(rows, apiPayload ?? undefined);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xiaohongshu',
|
||||
name: 'creator-note-detail',
|
||||
description: '小红书单篇笔记详细数据 (阅读/互动/点赞/收藏/评论/分享,区分自然流量/推广/视频)',
|
||||
description: '小红书单篇笔记详情页数据 (笔记信息 + 核心/互动数据 + 观看来源 + 观众画像 + 趋势数据)',
|
||||
domain: 'creator.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from note URL or creator-notes command)' },
|
||||
{ name: 'note_id', type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
|
||||
],
|
||||
columns: ['channel', 'reads', 'engagement', 'likes', 'collects', 'comments', 'shares'],
|
||||
columns: ['section', 'metric', 'value', 'extra'],
|
||||
func: async (page, kwargs) => {
|
||||
const noteId: string = kwargs.note_id;
|
||||
const encodedNoteId = encodeURIComponent(noteId);
|
||||
const rows = await fetchCreatorNoteDetailRows(page, noteId);
|
||||
|
||||
// Navigate for cookie context
|
||||
await page.goto('https://creator.xiaohongshu.com/new/home');
|
||||
await page.wait(2);
|
||||
|
||||
const data = await page.evaluate(`
|
||||
async () => {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
'/api/galaxy/creator/data/note_detail?note_id=${encodedNoteId}',
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
if (data?.error) {
|
||||
throw new Error(data.error + '. Check note_id and login status.');
|
||||
}
|
||||
if (!data?.data) {
|
||||
throw new Error('Unexpected response structure');
|
||||
const hasCoreMetric = rows.some((row) => row.section !== '笔记信息' && row.value);
|
||||
if (!hasCoreMetric) {
|
||||
throw new Error('No note detail data found. Check note_id and login status for creator.xiaohongshu.com.');
|
||||
}
|
||||
|
||||
const d = data.data;
|
||||
|
||||
return [
|
||||
{
|
||||
channel: 'Total',
|
||||
reads: d.total_read ?? 0,
|
||||
engagement: d.total_engage ?? 0,
|
||||
likes: d.total_like ?? 0,
|
||||
collects: d.total_fav ?? 0,
|
||||
comments: d.total_cmt ?? 0,
|
||||
shares: d.total_share ?? 0,
|
||||
},
|
||||
{
|
||||
channel: 'Organic',
|
||||
reads: d.normal_read ?? 0,
|
||||
engagement: d.normal_engage ?? 0,
|
||||
likes: d.normal_like ?? 0,
|
||||
collects: d.normal_fav ?? 0,
|
||||
comments: d.normal_cmt ?? 0,
|
||||
shares: d.normal_share ?? 0,
|
||||
},
|
||||
{
|
||||
channel: 'Promoted',
|
||||
reads: d.total_promo_read ?? 0,
|
||||
engagement: 0,
|
||||
likes: 0,
|
||||
collects: 0,
|
||||
comments: 0,
|
||||
shares: 0,
|
||||
},
|
||||
{
|
||||
channel: 'Video',
|
||||
reads: d.video_read ?? 0,
|
||||
engagement: d.video_engage ?? 0,
|
||||
likes: d.video_like ?? 0,
|
||||
collects: d.video_fav ?? 0,
|
||||
comments: d.video_cmt ?? 0,
|
||||
shares: d.video_share ?? 0,
|
||||
},
|
||||
];
|
||||
return rows;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { summarizeCreatorNote } from './creator-notes-summary.js';
|
||||
import type { CreatorNoteRow } from './creator-notes.js';
|
||||
import type { CreatorNoteDetailRow } from './creator-note-detail.js';
|
||||
import './creator-notes-summary.js';
|
||||
|
||||
describe('xiaohongshu creator-notes-summary', () => {
|
||||
it('summarizes note list row and detail rows into one compact row', () => {
|
||||
const note: CreatorNoteRow = {
|
||||
id: '69ba940500000000200384db',
|
||||
title: '一张图讲清 诡秘之主·耕种者途径',
|
||||
date: '2026年03月18日 20:01',
|
||||
views: 549,
|
||||
likes: 19,
|
||||
collects: 10,
|
||||
comments: 7,
|
||||
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
||||
};
|
||||
|
||||
const rows: CreatorNoteDetailRow[] = [
|
||||
{ section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
|
||||
{ section: '基础数据', metric: '观看数', value: '549', extra: '' },
|
||||
{ section: '互动数据', metric: '点赞数', value: '19', extra: '' },
|
||||
{ section: '互动数据', metric: '收藏数', value: '10', extra: '' },
|
||||
{ section: '互动数据', metric: '评论数', value: '7', extra: '' },
|
||||
{ section: '互动数据', metric: '分享数', value: '6', extra: '' },
|
||||
{ section: '基础数据', metric: '平均观看时长', value: '51.5秒', extra: '' },
|
||||
{ section: '基础数据', metric: '涨粉数', value: '3', extra: '' },
|
||||
{ section: '观看来源', metric: '首页推荐', value: '89.9%', extra: '' },
|
||||
{ section: '观看来源', metric: '搜索', value: '0.3%', extra: '' },
|
||||
{ section: '观众画像', metric: '兴趣/二次元', value: '13%', extra: '' },
|
||||
{ section: '观众画像', metric: '兴趣/游戏', value: '11%', extra: '' },
|
||||
];
|
||||
|
||||
expect(summarizeCreatorNote(note, rows, 1)).toEqual({
|
||||
rank: 1,
|
||||
id: '69ba940500000000200384db',
|
||||
title: '一张图讲清 诡秘之主·耕种者途径',
|
||||
published_at: '2026-03-18 20:01',
|
||||
views: '549',
|
||||
likes: '19',
|
||||
collects: '10',
|
||||
comments: '7',
|
||||
shares: '6',
|
||||
avg_view_time: '51.5秒',
|
||||
rise_fans: '3',
|
||||
top_source: '首页推荐',
|
||||
top_source_pct: '89.9%',
|
||||
top_interest: '二次元',
|
||||
top_interest_pct: '13%',
|
||||
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Xiaohongshu Creator Notes Summary — batch summary for recent notes.
|
||||
*
|
||||
* Combines creator-notes and creator-note-detail into a single command that
|
||||
* returns one summary row per note, suitable for quick review or downstream JSON use.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
import { fetchCreatorNotes, type CreatorNoteRow } from './creator-notes.js';
|
||||
import { fetchCreatorNoteDetailRows, type CreatorNoteDetailRow } from './creator-note-detail.js';
|
||||
|
||||
type CreatorNoteSummaryRow = {
|
||||
rank: number;
|
||||
id: string;
|
||||
title: string;
|
||||
published_at: string;
|
||||
views: string;
|
||||
likes: string;
|
||||
collects: string;
|
||||
comments: string;
|
||||
shares: string;
|
||||
avg_view_time: string;
|
||||
rise_fans: string;
|
||||
top_source: string;
|
||||
top_source_pct: string;
|
||||
top_interest: string;
|
||||
top_interest_pct: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
function findDetailValue(rows: CreatorNoteDetailRow[], metric: string): string {
|
||||
return rows.find((row) => row.metric === metric)?.value ?? '';
|
||||
}
|
||||
|
||||
function findTopBySectionPrefix(rows: CreatorNoteDetailRow[], section: string, prefix: string): { label: string; value: string } {
|
||||
const matches = rows.filter((row) => row.section === section && row.metric.startsWith(prefix) && row.value);
|
||||
if (matches.length === 0) return { label: '', value: '' };
|
||||
const sorted = [...matches].sort((a, b) => parseFloat(b.value) - parseFloat(a.value));
|
||||
const top = sorted[0];
|
||||
return {
|
||||
label: top.metric.slice(prefix.length),
|
||||
value: top.value,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeCreatorNote(note: CreatorNoteRow, rows: CreatorNoteDetailRow[], rank: number): CreatorNoteSummaryRow {
|
||||
const topSource = findTopBySectionPrefix(rows, '观看来源', '');
|
||||
const topInterest = findTopBySectionPrefix(rows, '观众画像', '兴趣/');
|
||||
|
||||
return {
|
||||
rank,
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
published_at: findDetailValue(rows, 'published_at') || note.date,
|
||||
views: findDetailValue(rows, '观看数') || String(note.views),
|
||||
likes: findDetailValue(rows, '点赞数') || String(note.likes),
|
||||
collects: findDetailValue(rows, '收藏数') || String(note.collects),
|
||||
comments: findDetailValue(rows, '评论数') || String(note.comments),
|
||||
shares: findDetailValue(rows, '分享数'),
|
||||
avg_view_time: findDetailValue(rows, '平均观看时长'),
|
||||
rise_fans: findDetailValue(rows, '涨粉数'),
|
||||
top_source: topSource.label,
|
||||
top_source_pct: topSource.value,
|
||||
top_interest: topInterest.label,
|
||||
top_interest_pct: topInterest.value,
|
||||
url: note.url,
|
||||
};
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xiaohongshu',
|
||||
name: 'creator-notes-summary',
|
||||
description: '小红书最近笔记批量摘要 (列表 + 单篇关键数据汇总)',
|
||||
domain: 'creator.xiaohongshu.com',
|
||||
strategy: Strategy.COOKIE,
|
||||
browser: true,
|
||||
args: [
|
||||
{ name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' },
|
||||
],
|
||||
columns: ['rank', 'id', 'title', 'views', 'likes', 'collects', 'comments', 'shares', 'avg_view_time', 'rise_fans', 'top_source', 'top_interest', 'url'],
|
||||
timeoutSeconds: 180,
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit || 3;
|
||||
const notes = await fetchCreatorNotes(page, limit);
|
||||
|
||||
if (!notes.length) {
|
||||
throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
|
||||
}
|
||||
|
||||
const results: CreatorNoteSummaryRow[] = [];
|
||||
for (const [index, note] of notes.entries()) {
|
||||
if (!note.id) {
|
||||
results.push({
|
||||
rank: index + 1,
|
||||
id: note.id,
|
||||
title: note.title,
|
||||
published_at: note.date,
|
||||
views: String(note.views),
|
||||
likes: String(note.likes),
|
||||
collects: String(note.collects),
|
||||
comments: String(note.comments),
|
||||
shares: '',
|
||||
avg_view_time: '',
|
||||
rise_fans: '',
|
||||
top_source: '',
|
||||
top_source_pct: '',
|
||||
top_interest: '',
|
||||
top_interest_pct: '',
|
||||
url: note.url,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const detailRows = await fetchCreatorNoteDetailRows(page, note.id);
|
||||
results.push(summarizeCreatorNote(note, detailRows, index + 1));
|
||||
}
|
||||
|
||||
return results;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { IPage } from '../../types.js';
|
||||
import { getRegistry } from '../../registry.js';
|
||||
import { parseCreatorNoteIdsFromHtml, parseCreatorNotesText } from './creator-notes.js';
|
||||
import './creator-notes.js';
|
||||
|
||||
function createPageMock(evaluateResult: any, interceptedRequests: any[] = []): IPage {
|
||||
const evaluate = Array.isArray(evaluateResult)
|
||||
? vi.fn()
|
||||
.mockResolvedValueOnce(evaluateResult[0])
|
||||
.mockResolvedValue(evaluateResult[evaluateResult.length - 1])
|
||||
: vi.fn().mockResolvedValue(evaluateResult);
|
||||
|
||||
const getInterceptedRequests = Array.isArray(interceptedRequests)
|
||||
? vi.fn().mockResolvedValue(interceptedRequests)
|
||||
: vi.fn().mockResolvedValue([]);
|
||||
|
||||
return {
|
||||
goto: vi.fn().mockResolvedValue(undefined),
|
||||
evaluate,
|
||||
snapshot: vi.fn().mockResolvedValue(undefined),
|
||||
click: vi.fn().mockResolvedValue(undefined),
|
||||
typeText: vi.fn().mockResolvedValue(undefined),
|
||||
pressKey: vi.fn().mockResolvedValue(undefined),
|
||||
wait: vi.fn().mockResolvedValue(undefined),
|
||||
tabs: vi.fn().mockResolvedValue([]),
|
||||
closeTab: vi.fn().mockResolvedValue(undefined),
|
||||
newTab: vi.fn().mockResolvedValue(undefined),
|
||||
selectTab: vi.fn().mockResolvedValue(undefined),
|
||||
networkRequests: vi.fn().mockResolvedValue([]),
|
||||
consoleMessages: vi.fn().mockResolvedValue([]),
|
||||
scroll: vi.fn().mockResolvedValue(undefined),
|
||||
autoScroll: vi.fn().mockResolvedValue(undefined),
|
||||
installInterceptor: vi.fn().mockResolvedValue(undefined),
|
||||
getInterceptedRequests,
|
||||
getCookies: vi.fn().mockResolvedValue([]),
|
||||
screenshot: vi.fn().mockResolvedValue(''),
|
||||
};
|
||||
}
|
||||
|
||||
describe('xiaohongshu creator-notes', () => {
|
||||
it('parses creator note text blocks into rows', () => {
|
||||
const bodyText = `笔记管理
|
||||
全部笔记(366)
|
||||
已发布
|
||||
神雕侠侣战力金字塔
|
||||
发布于 2025年12月04日 19:45
|
||||
148208
|
||||
324
|
||||
2279
|
||||
465
|
||||
32
|
||||
权限设置
|
||||
取消置顶
|
||||
编辑
|
||||
删除
|
||||
仅自己可见
|
||||
终于等到了!!!
|
||||
发布于 2026年03月18日 12:39
|
||||
10
|
||||
0
|
||||
0
|
||||
0
|
||||
0
|
||||
权限设置`;
|
||||
|
||||
expect(parseCreatorNotesText(bodyText)).toEqual([
|
||||
{
|
||||
id: '',
|
||||
title: '神雕侠侣战力金字塔',
|
||||
date: '2025年12月04日 19:45',
|
||||
views: 148208,
|
||||
likes: 324,
|
||||
collects: 2279,
|
||||
comments: 465,
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
title: '终于等到了!!!',
|
||||
date: '2026年03月18日 12:39',
|
||||
views: 10,
|
||||
likes: 0,
|
||||
collects: 0,
|
||||
comments: 0,
|
||||
url: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('reads body text and returns ranked rows', async () => {
|
||||
const cmd = getRegistry().get('xiaohongshu/creator-notes');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock([
|
||||
undefined,
|
||||
{
|
||||
text: `示例笔记
|
||||
发布于 2026年03月19日 12:00
|
||||
10
|
||||
2
|
||||
3
|
||||
4
|
||||
5
|
||||
权限设置`,
|
||||
html: '"noteId":"69ba940500000000200384db"',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await cmd!.func!(page, { limit: 1 });
|
||||
|
||||
expect((page.evaluate as any).mock.calls.at(-1)?.[0]).toBe('() => ({ text: document.body.innerText, html: document.body.innerHTML })');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
id: '69ba940500000000200384db',
|
||||
title: '示例笔记',
|
||||
date: '2026年03月19日 12:00',
|
||||
views: 10,
|
||||
likes: 2,
|
||||
collects: 3,
|
||||
comments: 4,
|
||||
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the creator analyze API and preserves note ids', async () => {
|
||||
const cmd = getRegistry().get('xiaohongshu/creator-notes');
|
||||
expect(cmd?.func).toBeTypeOf('function');
|
||||
|
||||
const page = createPageMock(undefined, [{
|
||||
data: {
|
||||
note_infos: [
|
||||
{
|
||||
id: '69ba940500000000200384db',
|
||||
title: '一张图讲清 诡秘之主·耕种者途径',
|
||||
post_time: new Date('2026-03-18T20:01:00+08:00').getTime(),
|
||||
read_count: 521,
|
||||
like_count: 18,
|
||||
fav_count: 10,
|
||||
comment_count: 7,
|
||||
},
|
||||
],
|
||||
},
|
||||
}]);
|
||||
|
||||
const result = await cmd!.func!(page, { limit: 1 });
|
||||
|
||||
expect((page.installInterceptor as any).mock.calls[0][0]).toContain('/api/galaxy/creator/datacenter/note/analyze/list');
|
||||
expect(result).toEqual([
|
||||
{
|
||||
rank: 1,
|
||||
id: '69ba940500000000200384db',
|
||||
title: '一张图讲清 诡秘之主·耕种者途径',
|
||||
date: '2026年03月18日 20:01',
|
||||
views: 521,
|
||||
likes: 18,
|
||||
collects: 10,
|
||||
comments: 7,
|
||||
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=69ba940500000000200384db',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts note ids from creator note-manager html', () => {
|
||||
const html = `
|
||||
<div>"noteId":"69ba940500000000200384db"</div>
|
||||
<div>"noteId":"69ba2c98000000001a026e0f"</div>
|
||||
<div>"noteId":"69ba940500000000200384db"</div>
|
||||
`;
|
||||
|
||||
expect(parseCreatorNoteIdsFromHtml(html)).toEqual([
|
||||
'69ba940500000000200384db',
|
||||
'69ba2c98000000001a026e0f',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,360 @@
|
||||
/**
|
||||
* Xiaohongshu Creator Note List — per-note metrics from the creator backend.
|
||||
*
|
||||
* Navigates to the note manager page and extracts per-note data from
|
||||
* the rendered DOM. This approach bypasses the v2 API signature requirement.
|
||||
*
|
||||
* Returns: note title, publish date, views, likes, collects, comments.
|
||||
* In CDP mode we capture the real creator analytics API response so the list
|
||||
* includes stable note ids and detail-page URLs. If that capture is unavailable,
|
||||
* we fall back to the older interceptor and DOM parsing paths.
|
||||
*
|
||||
* Requires: logged into creator.xiaohongshu.com in Chrome.
|
||||
*/
|
||||
|
||||
import { cli, Strategy } from '../../registry.js';
|
||||
|
||||
const DATE_LINE_RE = /^发布于 (\d{4}年\d{2}月\d{2}日 \d{2}:\d{2})$/;
|
||||
const METRIC_LINE_RE = /^\d+$/;
|
||||
const VISIBILITY_LINE_RE = /可见$/;
|
||||
const NOTE_ANALYZE_API_PATH = '/api/galaxy/creator/datacenter/note/analyze/list';
|
||||
const NOTE_DETAIL_PAGE_URL = 'https://creator.xiaohongshu.com/statistics/note-detail';
|
||||
|
||||
type CreatorNoteRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
views: number;
|
||||
likes: number;
|
||||
collects: number;
|
||||
comments: number;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type { CreatorNoteRow };
|
||||
|
||||
type CreatorAnalyzeApiResponse = {
|
||||
error?: string;
|
||||
data?: {
|
||||
note_infos?: Array<{
|
||||
id?: string;
|
||||
title?: string;
|
||||
post_time?: number;
|
||||
read_count?: number;
|
||||
like_count?: number;
|
||||
fav_count?: number;
|
||||
comment_count?: number;
|
||||
}>;
|
||||
total?: number;
|
||||
};
|
||||
};
|
||||
|
||||
const NOTE_ID_HTML_RE = /"noteId":"([0-9a-f]{24})"/g;
|
||||
|
||||
function buildNoteDetailUrl(noteId?: string): string {
|
||||
return noteId ? `${NOTE_DETAIL_PAGE_URL}?noteId=${encodeURIComponent(noteId)}` : '';
|
||||
}
|
||||
|
||||
function formatPostTime(ts?: number): string {
|
||||
if (!ts) return '';
|
||||
const date = new Date(ts);
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}年${pad(date.getMonth() + 1)}月${pad(date.getDate())}日 ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function parseCreatorNotesText(bodyText: string): CreatorNoteRow[] {
|
||||
const lines = bodyText
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const results: CreatorNoteRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const dateMatch = lines[i].match(DATE_LINE_RE);
|
||||
if (!dateMatch) continue;
|
||||
|
||||
let titleIndex = i - 1;
|
||||
while (titleIndex >= 0 && VISIBILITY_LINE_RE.test(lines[titleIndex])) titleIndex--;
|
||||
if (titleIndex < 0) continue;
|
||||
|
||||
const title = lines[titleIndex];
|
||||
const metrics: number[] = [];
|
||||
let cursor = i + 1;
|
||||
|
||||
while (cursor < lines.length && METRIC_LINE_RE.test(lines[cursor]) && metrics.length < 5) {
|
||||
metrics.push(parseInt(lines[cursor], 10));
|
||||
cursor++;
|
||||
}
|
||||
|
||||
if (metrics.length < 4) continue;
|
||||
|
||||
const key = `${title}@@${dateMatch[1]}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
results.push({
|
||||
id: '',
|
||||
title,
|
||||
date: dateMatch[1],
|
||||
views: metrics[0] ?? 0,
|
||||
likes: metrics[1] ?? 0,
|
||||
collects: metrics[2] ?? 0,
|
||||
comments: metrics[3] ?? 0,
|
||||
url: '',
|
||||
});
|
||||
|
||||
i = cursor - 1;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function parseCreatorNoteIdsFromHtml(bodyHtml: string): string[] {
|
||||
const ids: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const match of bodyHtml.matchAll(NOTE_ID_HTML_RE)) {
|
||||
const id = match[1];
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
ids.push(id);
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function mapAnalyzeItems(items: NonNullable<CreatorAnalyzeApiResponse['data']>['note_infos']): CreatorNoteRow[] {
|
||||
return (items ?? []).map((item) => ({
|
||||
id: item.id ?? '',
|
||||
title: item.title ?? '',
|
||||
date: formatPostTime(item.post_time),
|
||||
views: item.read_count ?? 0,
|
||||
likes: item.like_count ?? 0,
|
||||
collects: item.fav_count ?? 0,
|
||||
comments: item.comment_count ?? 0,
|
||||
url: buildNoteDetailUrl(item.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchCreatorNotesByApi(page: any, limit: number): Promise<CreatorNoteRow[]> {
|
||||
const pageSize = Math.min(Math.max(limit, 10), 20);
|
||||
const maxPages = Math.max(1, Math.ceil(limit / pageSize));
|
||||
const notes: CreatorNoteRow[] = [];
|
||||
|
||||
await page.goto(`https://creator.xiaohongshu.com/statistics/data-analysis?type=0&page_size=${pageSize}&page_num=1`);
|
||||
await page.wait(4);
|
||||
|
||||
for (let pageNum = 1; pageNum <= maxPages && notes.length < limit; pageNum++) {
|
||||
const apiPath = `${NOTE_ANALYZE_API_PATH}?type=0&page_size=${pageSize}&page_num=${pageNum}`;
|
||||
const fetched = await page.evaluate(`
|
||||
async () => {
|
||||
try {
|
||||
const resp = await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' });
|
||||
if (!resp.ok) return { error: 'HTTP ' + resp.status };
|
||||
return await resp.json();
|
||||
} catch (e) {
|
||||
return { error: e?.message ?? String(e) };
|
||||
}
|
||||
}
|
||||
`) as CreatorAnalyzeApiResponse | undefined;
|
||||
|
||||
let items = fetched?.data?.note_infos ?? [];
|
||||
|
||||
if (!items.length) {
|
||||
await page.installInterceptor(NOTE_ANALYZE_API_PATH);
|
||||
await page.evaluate(`
|
||||
async () => {
|
||||
try {
|
||||
await fetch(${JSON.stringify(apiPath)}, { credentials: 'include' });
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
`);
|
||||
await page.wait(1);
|
||||
const intercepted = await page.getInterceptedRequests();
|
||||
const data = intercepted.find((entry: CreatorAnalyzeApiResponse) => Array.isArray(entry?.data?.note_infos)) as CreatorAnalyzeApiResponse | undefined;
|
||||
items = data?.data?.note_infos ?? [];
|
||||
}
|
||||
|
||||
if (!items.length) break;
|
||||
|
||||
notes.push(...mapAnalyzeItems(items));
|
||||
if (items.length < pageSize) break;
|
||||
}
|
||||
|
||||
return notes.slice(0, limit);
|
||||
}
|
||||
|
||||
function deriveCdpHttpBase(endpoint?: string): string | null {
|
||||
if (!endpoint) return null;
|
||||
try {
|
||||
const url = new URL(endpoint);
|
||||
if (url.protocol === 'ws:' || url.protocol === 'wss:') {
|
||||
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
|
||||
url.pathname = '';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString().replace(/\/$/, '');
|
||||
}
|
||||
return `${url.protocol}//${url.host}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRequestedCdpEndpoint(): { endpoint: string | null; requestedCdp: boolean } {
|
||||
const endpoint = process.env.OPENCLI_CDP_ENDPOINT?.trim();
|
||||
return {
|
||||
endpoint: endpoint || null,
|
||||
requestedCdp: Boolean(endpoint),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCreatorNotesByCdp(limit: number): Promise<CreatorNoteRow[]> {
|
||||
if (process.env.VITEST) return [];
|
||||
|
||||
const { endpoint, requestedCdp } = resolveRequestedCdpEndpoint();
|
||||
if (!requestedCdp) return [];
|
||||
|
||||
const httpBase = deriveCdpHttpBase(endpoint ?? 'http://127.0.0.1:9222') ?? 'http://127.0.0.1:9222';
|
||||
const targets = await fetch(`${httpBase}/json/list`).then((resp) => resp.json()) as Array<{
|
||||
url?: string;
|
||||
webSocketDebuggerUrl?: string;
|
||||
}>;
|
||||
const target = targets.find((entry) => entry.url?.includes('/statistics/data-analysis') && entry.webSocketDebuggerUrl);
|
||||
if (!target?.webSocketDebuggerUrl) return [];
|
||||
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
let nextId = 1;
|
||||
const pending = new Map<number, { resolve: (value: any) => void; reject: (err: Error) => void }>();
|
||||
|
||||
const send = (method: string, params: Record<string, unknown> = {}) => {
|
||||
const id = nextId++;
|
||||
ws.send(JSON.stringify({ id, method, params }));
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject });
|
||||
});
|
||||
};
|
||||
|
||||
const handlePendingMessage = (event: MessageEvent) => {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
if (typeof msg.id !== 'number' || !pending.has(msg.id)) return;
|
||||
const waiter = pending.get(msg.id)!;
|
||||
pending.delete(msg.id);
|
||||
if (msg.error) waiter.reject(new Error(msg.error.message));
|
||||
else waiter.resolve(msg.result);
|
||||
};
|
||||
|
||||
ws.addEventListener('message', handlePendingMessage);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
ws.addEventListener('open', () => resolve(), { once: true });
|
||||
ws.addEventListener('error', () => reject(new Error('Failed to connect to CDP data-analysis target')), { once: true });
|
||||
});
|
||||
|
||||
const pageSize = Math.min(Math.max(limit, 10), 20);
|
||||
const maxPages = Math.max(1, Math.ceil(limit / pageSize));
|
||||
const notes: CreatorNoteRow[] = [];
|
||||
|
||||
try {
|
||||
await send('Network.enable');
|
||||
await send('Page.enable');
|
||||
|
||||
for (let pageNum = 1; pageNum <= maxPages && notes.length < limit; pageNum++) {
|
||||
const apiSuffix = `${NOTE_ANALYZE_API_PATH}?type=0&page_size=${pageSize}&page_num=${pageNum}`;
|
||||
const pageUrl = `https://creator.xiaohongshu.com/statistics/data-analysis?type=0&page_size=${pageSize}&page_num=${pageNum}`;
|
||||
|
||||
const payload = await new Promise<CreatorAnalyzeApiResponse | null>((resolve) => {
|
||||
const listener = async (event: MessageEvent) => {
|
||||
const msg = JSON.parse(String(event.data));
|
||||
if (typeof msg.id === 'number') return;
|
||||
|
||||
if (msg.method !== 'Network.responseReceived') return;
|
||||
const requestId = msg.params?.requestId as string | undefined;
|
||||
const responseUrl = msg.params?.response?.url as string | undefined;
|
||||
if (!requestId || !responseUrl || !responseUrl.includes(apiSuffix)) return;
|
||||
|
||||
try {
|
||||
const bodyResult = await send('Network.getResponseBody', { requestId });
|
||||
const parsed = JSON.parse(bodyResult.body) as CreatorAnalyzeApiResponse;
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('message', listener);
|
||||
resolve(parsed);
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('message', listener);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
ws.removeEventListener('message', listener);
|
||||
resolve(null);
|
||||
}, 8000);
|
||||
|
||||
ws.addEventListener('message', listener);
|
||||
void send('Page.navigate', { url: pageUrl }).catch(() => {
|
||||
clearTimeout(timeout);
|
||||
ws.removeEventListener('message', listener);
|
||||
resolve(null);
|
||||
});
|
||||
});
|
||||
|
||||
const items = payload?.data?.note_infos ?? [];
|
||||
if (!items.length) break;
|
||||
|
||||
notes.push(...mapAnalyzeItems(items));
|
||||
if (items.length < pageSize) break;
|
||||
}
|
||||
} finally {
|
||||
ws.removeEventListener('message', handlePendingMessage);
|
||||
for (const waiter of pending.values()) {
|
||||
waiter.reject(new Error('CDP creator-notes capture closed'));
|
||||
}
|
||||
pending.clear();
|
||||
ws.close();
|
||||
}
|
||||
|
||||
return notes.slice(0, limit);
|
||||
}
|
||||
|
||||
export async function fetchCreatorNotes(page: any, limit: number): Promise<CreatorNoteRow[]> {
|
||||
let notes = await fetchCreatorNotesByCdp(limit).catch(() => []);
|
||||
|
||||
if (notes.length === 0) {
|
||||
notes = await fetchCreatorNotesByApi(page, limit);
|
||||
}
|
||||
|
||||
if (notes.length === 0) {
|
||||
await page.goto('https://creator.xiaohongshu.com/new/note-manager');
|
||||
await page.wait(4);
|
||||
|
||||
const maxPageDowns = Math.max(0, Math.ceil(limit / 10) + 1);
|
||||
for (let i = 0; i <= maxPageDowns; i++) {
|
||||
const body = await page.evaluate('() => ({ text: document.body.innerText, html: document.body.innerHTML })') as {
|
||||
text?: string;
|
||||
html?: string;
|
||||
};
|
||||
const bodyText = typeof body?.text === 'string' ? body.text : '';
|
||||
const bodyHtml = typeof body?.html === 'string' ? body.html : '';
|
||||
const parsedNotes = parseCreatorNotesText(bodyText);
|
||||
const noteIds = parseCreatorNoteIdsFromHtml(bodyHtml);
|
||||
notes = parsedNotes.map((note, index) => {
|
||||
const id = noteIds[index] ?? '';
|
||||
return {
|
||||
...note,
|
||||
id,
|
||||
url: buildNoteDetailUrl(id),
|
||||
};
|
||||
});
|
||||
if (notes.length >= limit || i === maxPageDowns) break;
|
||||
|
||||
await page.pressKey('PageDown');
|
||||
await page.wait(1);
|
||||
}
|
||||
}
|
||||
|
||||
return notes.slice(0, limit);
|
||||
}
|
||||
|
||||
cli({
|
||||
site: 'xiaohongshu',
|
||||
name: 'creator-notes',
|
||||
@@ -24,76 +368,7 @@ cli({
|
||||
columns: ['rank', 'id', 'title', 'date', 'views', 'likes', 'collects', 'comments', 'url'],
|
||||
func: async (page, kwargs) => {
|
||||
const limit = kwargs.limit || 20;
|
||||
|
||||
// Navigate to note manager
|
||||
await page.goto('https://creator.xiaohongshu.com/new/note-manager');
|
||||
await page.wait(4);
|
||||
|
||||
// Scroll to load more notes if needed
|
||||
await page.autoScroll({ times: Math.ceil(limit / 10), delayMs: 1500 });
|
||||
|
||||
// Extract note data from rendered DOM
|
||||
const notes = await page.evaluate(`
|
||||
(() => {
|
||||
const results = [];
|
||||
// Note cards in the manager page contain title, date, and metric numbers
|
||||
// Each note card has a consistent structure with the title, date line,
|
||||
// and a row of 4 numbers (views, likes, collects, comments)
|
||||
const cards = document.querySelectorAll('[class*="note-item"], [class*="noteItem"], [class*="card"]');
|
||||
|
||||
if (cards.length === 0) {
|
||||
// Fallback: parse from any container with note-like content
|
||||
const allText = document.body.innerText;
|
||||
const notePattern = /(.+?)\\s+发布于\\s+(\\d{4}年\\d{2}月\\d{2}日\\s+\\d{2}:\\d{2})\\s*(\\d+)\\s*(\\d+)\\s*(\\d+)\\s*(\\d+)/g;
|
||||
let match;
|
||||
while ((match = notePattern.exec(allText)) !== null) {
|
||||
results.push({
|
||||
title: match[1].trim(),
|
||||
date: match[2],
|
||||
views: parseInt(match[3]) || 0,
|
||||
likes: parseInt(match[4]) || 0,
|
||||
collects: parseInt(match[5]) || 0,
|
||||
comments: parseInt(match[6]) || 0,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
cards.forEach(card => {
|
||||
const text = card.innerText || '';
|
||||
const linkEl = card.querySelector('a[href*="/publish/"], a[href*="/note/"], a[href*="/explore/"]');
|
||||
const href = linkEl?.getAttribute('href') || '';
|
||||
const idMatch = href.match(/\/(?:publish|explore|note)\/([a-zA-Z0-9]+)/);
|
||||
// Try to extract structured data
|
||||
const lines = text.split('\\n').map(l => l.trim()).filter(Boolean);
|
||||
if (lines.length < 2) return;
|
||||
|
||||
const title = lines[0];
|
||||
const dateLine = lines.find(l => l.includes('发布于'));
|
||||
const dateMatch = dateLine?.match(/发布于\\s+(\\d{4}年\\d{2}月\\d{2}日\\s+\\d{2}:\\d{2})/);
|
||||
|
||||
// Remove the publish timestamp before collecting note metrics.
|
||||
// Otherwise year/month/day/hour digits are picked up as views/likes/etc.
|
||||
const metricText = dateLine ? text.replace(dateLine, ' ') : text;
|
||||
const nums = metricText.match(/(?:^|\\s)(\\d+)(?:\\s|$)/g)?.map(n => parseInt(n.trim())) || [];
|
||||
|
||||
if (title && !title.includes('全部笔记')) {
|
||||
results.push({
|
||||
id: idMatch ? idMatch[1] : '',
|
||||
title: title.replace(/\\s+/g, ' ').substring(0, 80),
|
||||
date: dateMatch ? dateMatch[1] : '',
|
||||
views: nums[0] || 0,
|
||||
likes: nums[1] || 0,
|
||||
collects: nums[2] || 0,
|
||||
comments: nums[3] || 0,
|
||||
url: href ? new URL(href, window.location.origin).toString() : '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
})()
|
||||
`);
|
||||
const notes = await fetchCreatorNotes(page, limit);
|
||||
|
||||
if (!Array.isArray(notes) || notes.length === 0) {
|
||||
throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
|
||||
@@ -101,7 +376,7 @@ cli({
|
||||
|
||||
return notes
|
||||
.slice(0, limit)
|
||||
.map((n: any, i: number) => ({
|
||||
.map((n: CreatorNoteRow, i: number) => ({
|
||||
rank: i + 1,
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
|
||||
+3
-3
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { WebSocketServer, WebSocket, type RawData } from 'ws';
|
||||
|
||||
const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? '19825', 10);
|
||||
const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -138,11 +138,11 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
|
||||
const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); });
|
||||
const wss = new WebSocketServer({ server: httpServer, path: '/ext' });
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
wss.on('connection', (ws: WebSocket) => {
|
||||
console.error('[daemon] Extension connected');
|
||||
extensionWs = ws;
|
||||
|
||||
ws.on('message', (data) => {
|
||||
ws.on('message', (data: RawData) => {
|
||||
try {
|
||||
const msg = JSON.parse(data.toString());
|
||||
|
||||
|
||||
Reference in new issue
Block a user