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:
ajia1206andajia authored and GitHub committed 2026-03-20 16:33:48 +08:00
1 parent 2876750891
commit 8f1725982e
9 files changed
+1343 -153

No files matched your search

+1
View File
@@ -5,6 +5,7 @@ dist/
.opencli/
.mcp.json
*.log
.DS_Store
# VitePress
docs/.vitepress/dist
+3 -3
View File
@@ -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%' },
]);
});
});
+411 -72
View File
@@ -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;
},
});
+178
View File
@@ -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: '&quot;noteId&quot;:&quot;69ba940500000000200384db&quot;',
},
]);
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>&quot;noteId&quot;:&quot;69ba940500000000200384db&quot;</div>
<div>&quot;noteId&quot;:&quot;69ba2c98000000001a026e0f&quot;</div>
<div>&quot;noteId&quot;:&quot;69ba940500000000200384db&quot;</div>
`;
expect(parseCreatorNoteIdsFromHtml(html)).toEqual([
'69ba940500000000200384db',
'69ba2c98000000001a026e0f',
]);
});
});
+350 -75
View File
@@ -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 = /&quot;noteId&quot;:&quot;([0-9a-f]{24})&quot;/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
View File
@@ -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());