From 8f1725982ec06d121d7c15b5cf3cda2f5941c32a Mon Sep 17 00:00:00 2001 From: ajia1206 <91619746+ajia1206@users.noreply.github.com> Date: Fri, 20 Mar 2026 16:33:48 +0800 Subject: [PATCH] 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> --- .gitignore | 1 + src/browser/cdp.ts | 6 +- .../xiaohongshu/creator-note-detail.test.ts | 223 ++++++++ src/clis/xiaohongshu/creator-note-detail.ts | 483 +++++++++++++++--- .../xiaohongshu/creator-notes-summary.test.ts | 54 ++ src/clis/xiaohongshu/creator-notes-summary.ts | 120 +++++ src/clis/xiaohongshu/creator-notes.test.ts | 178 +++++++ src/clis/xiaohongshu/creator-notes.ts | 425 ++++++++++++--- src/daemon.ts | 6 +- 9 files changed, 1343 insertions(+), 153 deletions(-) create mode 100644 src/clis/xiaohongshu/creator-note-detail.test.ts create mode 100644 src/clis/xiaohongshu/creator-notes-summary.test.ts create mode 100644 src/clis/xiaohongshu/creator-notes-summary.ts create mode 100644 src/clis/xiaohongshu/creator-notes.test.ts diff --git a/.gitignore b/.gitignore index 3e317f1..bf6125f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ .opencli/ .mcp.json *.log +.DS_Store # VitePress docs/.vitepress/dist diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 512c1e5..84add0d 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -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 diff --git a/src/clis/xiaohongshu/creator-note-detail.test.ts b/src/clis/xiaohongshu/creator-note-detail.test.ts new file mode 100644 index 0000000..f5e2131 --- /dev/null +++ b/src/clis/xiaohongshu/creator-note-detail.test.ts @@ -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%' }, + ]); + }); +}); diff --git a/src/clis/xiaohongshu/creator-note-detail.ts b/src/clis/xiaohongshu/creator-note-detail.ts index 787c351..047d962 100644 --- a/src/clis/xiaohongshu/creator-note-detail.ts +++ b/src/clis/xiaohongshu/creator-note-detail.ts @@ -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(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 { + 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 void; reject: (err: Error) => void }>(); + const desiredUrls = new Map([ + [`/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 = {}) => { + const id = nextId++; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + }); + }; + + const completed = new Set(); + const waitForPayload = new Promise((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((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 { + 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; }, }); diff --git a/src/clis/xiaohongshu/creator-notes-summary.test.ts b/src/clis/xiaohongshu/creator-notes-summary.test.ts new file mode 100644 index 0000000..90cc6e0 --- /dev/null +++ b/src/clis/xiaohongshu/creator-notes-summary.test.ts @@ -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', + }); + }); +}); diff --git a/src/clis/xiaohongshu/creator-notes-summary.ts b/src/clis/xiaohongshu/creator-notes-summary.ts new file mode 100644 index 0000000..bb24b4a --- /dev/null +++ b/src/clis/xiaohongshu/creator-notes-summary.ts @@ -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; + }, +}); diff --git a/src/clis/xiaohongshu/creator-notes.test.ts b/src/clis/xiaohongshu/creator-notes.test.ts new file mode 100644 index 0000000..f06e42d --- /dev/null +++ b/src/clis/xiaohongshu/creator-notes.test.ts @@ -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 = ` +
"noteId":"69ba940500000000200384db"
+
"noteId":"69ba2c98000000001a026e0f"
+
"noteId":"69ba940500000000200384db"
+ `; + + expect(parseCreatorNoteIdsFromHtml(html)).toEqual([ + '69ba940500000000200384db', + '69ba2c98000000001a026e0f', + ]); + }); +}); diff --git a/src/clis/xiaohongshu/creator-notes.ts b/src/clis/xiaohongshu/creator-notes.ts index fcbf9c0..4e2d4a1 100644 --- a/src/clis/xiaohongshu/creator-notes.ts +++ b/src/clis/xiaohongshu/creator-notes.ts @@ -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(); + + 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(); + + 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['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 { + 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 { + 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 void; reject: (err: Error) => void }>(); + + const send = (method: string, params: Record = {}) => { + const id = nextId++; + ws.send(JSON.stringify({ id, method, params })); + return new Promise((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((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((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 { + 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, diff --git a/src/daemon.ts b/src/daemon.ts index 36b4e72..8e07a95 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -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());