feat(xiaohongshu): add 4 creator analytics commands (creator-profile, creator-stats, creator-notes, creator-note-detail) (#49)

* feat(xiaohongshu): add 4 creator analytics commands

Add creator backend support for Xiaohongshu (小红书), enabling
creators to access their analytics data from the command line.

New commands:
- creator-profile: account info (followers, likes, creator level)
- creator-stats: 7-day/30-day overview (views, likes, collects,
  comments, shares, new followers) with daily trend data
- creator-notes: note list with per-note metrics from note manager
- creator-note-detail: single note analytics breakdown
  (organic vs promoted vs video traffic)

API discovery:
- /api/galaxy/creator/home/personal_info (cookie auth, 200 OK)
- /api/galaxy/creator/data/note_detail_new (cookie auth, 200 OK)
- /api/galaxy/creator/data/note_detail?note_id=xxx (cookie auth, 200 OK)
- Note manager DOM extraction for note list (bypasses v2 signature)

All endpoints verified working with real creator account.
Screenshots (redacted) included in docs/screenshots/.

Requires: Chrome logged into creator.xiaohongshu.com

* chore: remove screenshots from repo (will host externally for PR)

* review: fix creator analytics CLI integration

Co-authored-by: stone16 <stone2paul@gmail.com>

* test: add site-scoped test runner

Co-authored-by: stone16 <stone2paul@gmail.com>

* review: ignore publish timestamps in creator note metrics

---------

Co-authored-by: jackwener <jakevingoo@gmail.com>
This commit is contained in:
stometaverseandjackwener authored and GitHub committed 2026-03-18 22:27:25 +08:00
1 parent 8a355dfd2d
commit c370bd0582
8 files changed
+597 -56

No files matched your search

+1
View File
@@ -25,6 +25,7 @@
"lint": "tsc --noEmit",
"prepublishOnly": "npm run build",
"test": "vitest run",
"test:site": "node scripts/test-site.mjs",
"test:watch": "vitest"
},
"keywords": [
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
const site = process.argv[2]?.trim();
if (!site) {
console.error('Usage: npm run test:site -- <site>');
process.exit(1);
}
const repoRoot = path.resolve(new URL('..', import.meta.url).pathname);
const srcDir = path.join(repoRoot, 'src');
function runStep(label, command, args) {
console.log(`\n==> ${label}`);
const result = spawnSync(command, args, {
cwd: repoRoot,
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
function walk(dir) {
const files = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walk(fullPath));
} else {
files.push(fullPath);
}
}
return files;
}
function toPosix(filePath) {
return filePath.split(path.sep).join('/');
}
function findSiteTests() {
return walk(srcDir)
.filter(filePath => filePath.endsWith('.test.ts'))
.filter(filePath => {
const normalized = toPosix(path.relative(repoRoot, filePath));
return normalized.includes(`/clis/${site}/`) || normalized.includes(`/${site}.test.ts`);
})
.sort();
}
runStep('Typecheck', 'npm', ['run', 'typecheck']);
runStep('Targeted verify', 'npx', ['tsx', 'src/main.ts', 'verify', site]);
const testFiles = findSiteTests();
if (testFiles.length === 0) {
console.log(`\nNo site-specific vitest files found for "${site}". Skipping full vitest run.`);
process.exit(0);
}
runStep(
`Site tests (${site})`,
'npx',
['vitest', 'run', ...testFiles.map(filePath => path.relative(repoRoot, filePath))],
);
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { parseTsArgsBlock } from './build-manifest.js';
describe('parseTsArgsBlock', () => {
it('keeps args with nested choices arrays', () => {
const args = parseTsArgsBlock(`
{
name: 'period',
type: 'string',
default: 'seven',
help: 'Stats period: seven or thirty',
choices: ['seven', 'thirty'],
},
`);
expect(args).toEqual([
{
name: 'period',
type: 'string',
default: 'seven',
required: false,
positional: undefined,
help: 'Stats period: seven or thirty',
choices: ['seven', 'thirty'],
},
]);
});
});
+146 -56
View File
@@ -11,7 +11,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import yaml from 'js-yaml';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -43,6 +43,116 @@ interface ManifestEntry {
modulePath?: string;
}
function extractBalancedBlock(
source: string,
startIndex: number,
openChar: string,
closeChar: string,
): string | null {
let depth = 0;
let quote: string | null = null;
let escaped = false;
for (let i = startIndex; i < source.length; i++) {
const ch = source[i];
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (ch === quote) quote = null;
continue;
}
if (ch === '"' || ch === '\'' || ch === '`') {
quote = ch;
continue;
}
if (ch === openChar) {
depth++;
} else if (ch === closeChar) {
depth--;
if (depth === 0) {
return source.slice(startIndex + 1, i);
}
}
}
return null;
}
function extractTsArgsBlock(source: string): string | null {
const argsMatch = source.match(/args\s*:/);
if (!argsMatch || argsMatch.index === undefined) return null;
const bracketIndex = source.indexOf('[', argsMatch.index);
if (bracketIndex === -1) return null;
return extractBalancedBlock(source, bracketIndex, '[', ']');
}
function parseInlineChoices(body: string): string[] | undefined {
const choicesMatch = body.match(/choices\s*:\s*\[([^\]]*)\]/);
if (!choicesMatch) return undefined;
const values = choicesMatch[1]
.split(',')
.map(s => s.trim().replace(/^['"`]|['"`]$/g, ''))
.filter(Boolean);
return values.length > 0 ? values : undefined;
}
export function parseTsArgsBlock(argsBlock: string): ManifestEntry['args'] {
const args: ManifestEntry['args'] = [];
let cursor = 0;
while (cursor < argsBlock.length) {
const nameMatch = argsBlock.slice(cursor).match(/\{\s*name\s*:\s*['"`](\w+)['"`]/);
if (!nameMatch || nameMatch.index === undefined) break;
const objectStart = cursor + nameMatch.index;
const body = extractBalancedBlock(argsBlock, objectStart, '{', '}');
if (body == null) break;
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
let defaultVal: any = undefined;
if (defaultMatch) {
const raw = defaultMatch[1].trim();
if (raw === 'true') defaultVal = true;
else if (raw === 'false') defaultVal = false;
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
}
args.push({
name: nameMatch[1],
type: typeMatch?.[1] ?? 'str',
default: defaultVal,
required: requiredMatch?.[1] === 'true',
positional: positionalMatch?.[1] === 'true' || undefined,
help: helpMatch?.[1] ?? '',
choices: parseInlineChoices(body),
});
cursor = objectStart + body.length + 2;
}
return args;
}
function scanYaml(filePath: string, site: string): ManifestEntry | null {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
@@ -129,39 +239,9 @@ function scanTs(filePath: string, site: string): ManifestEntry {
}
// Extract args array items: { name: '...', ... }
const argsBlockMatch = src.match(/args\s*:\s*\[([\s\S]*?)\]\s*,/);
if (argsBlockMatch) {
const argsBlock = argsBlockMatch[1];
const argRegex = /\{\s*name\s*:\s*['"`](\w+)['"`]([^}]*)\}/g;
let m;
while ((m = argRegex.exec(argsBlock)) !== null) {
const argName = m[1];
const body = m[2];
const typeMatch = body.match(/type\s*:\s*['"`](\w+)['"`]/);
const defaultMatch = body.match(/default\s*:\s*([^,}]+)/);
const requiredMatch = body.match(/required\s*:\s*(true|false)/);
const helpMatch = body.match(/help\s*:\s*['"`]([^'"`]*)['"`]/);
const positionalMatch = body.match(/positional\s*:\s*(true|false)/);
let defaultVal: any = undefined;
if (defaultMatch) {
const raw = defaultMatch[1].trim();
if (raw === 'true') defaultVal = true;
else if (raw === 'false') defaultVal = false;
else if (/^\d+$/.test(raw)) defaultVal = parseInt(raw, 10);
else if (/^\d+\.\d+$/.test(raw)) defaultVal = parseFloat(raw);
else defaultVal = raw.replace(/^['"`]|['"`]$/g, '');
}
entry.args.push({
name: argName,
type: typeMatch?.[1] ?? 'str',
default: defaultVal,
required: requiredMatch?.[1] === 'true',
positional: positionalMatch?.[1] === 'true' || undefined,
help: helpMatch?.[1] ?? '',
});
}
const argsBlock = extractTsArgsBlock(src);
if (argsBlock) {
entry.args = parseTsArgsBlock(argsBlock);
}
} catch {
// If parsing fails, fall back to empty metadata — module will self-register at runtime
@@ -170,32 +250,42 @@ function scanTs(filePath: string, site: string): ManifestEntry {
return entry;
}
// Main
const manifest: ManifestEntry[] = [];
export function buildManifest(): ManifestEntry[] {
const manifest: ManifestEntry[] = [];
if (fs.existsSync(CLIS_DIR)) {
for (const site of fs.readdirSync(CLIS_DIR)) {
const siteDir = path.join(CLIS_DIR, site);
if (!fs.statSync(siteDir).isDirectory()) continue;
for (const file of fs.readdirSync(siteDir)) {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
const entry = scanYaml(filePath, site);
if (entry) manifest.push(entry);
} else if (
(file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')
) {
manifest.push(scanTs(filePath, site));
if (fs.existsSync(CLIS_DIR)) {
for (const site of fs.readdirSync(CLIS_DIR)) {
const siteDir = path.join(CLIS_DIR, site);
if (!fs.statSync(siteDir).isDirectory()) continue;
for (const file of fs.readdirSync(siteDir)) {
const filePath = path.join(siteDir, file);
if (file.endsWith('.yaml') || file.endsWith('.yml')) {
const entry = scanYaml(filePath, site);
if (entry) manifest.push(entry);
} else if (
(file.endsWith('.ts') && !file.endsWith('.d.ts') && file !== 'index.ts') ||
(file.endsWith('.js') && !file.endsWith('.d.js') && file !== 'index.js')
) {
manifest.push(scanTs(filePath, site));
}
}
}
}
return manifest;
}
// Ensure output directory exists
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
function main(): void {
const manifest = buildManifest();
fs.mkdirSync(path.dirname(OUTPUT), { recursive: true });
fs.writeFileSync(OUTPUT, JSON.stringify(manifest, null, 2));
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
const tsCount = manifest.filter(e => e.type === 'ts').length;
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
const yamlCount = manifest.filter(e => e.type === 'yaml').length;
const tsCount = manifest.filter(e => e.type === 'ts').length;
console.log(`✅ Manifest compiled: ${manifest.length} entries (${yamlCount} YAML, ${tsCount} TS) → ${OUTPUT}`);
}
const entrypoint = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
if (entrypoint === import.meta.url) {
main();
}
@@ -0,0 +1,95 @@
/**
* Xiaohongshu Creator Note Detail — per-note analytics breakdown.
*
* 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).
*
* Requires: logged into creator.xiaohongshu.com in Chrome.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'xiaohongshu',
name: 'creator-note-detail',
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)' },
],
columns: ['channel', 'reads', 'engagement', 'likes', 'collects', 'comments', 'shares'],
func: async (page, kwargs) => {
const noteId: string = kwargs.note_id;
const encodedNoteId = encodeURIComponent(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 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,
},
];
},
});
+116
View File
@@ -0,0 +1,116 @@
/**
* 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.
*
* Requires: logged into creator.xiaohongshu.com in Chrome.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'xiaohongshu',
name: 'creator-notes',
description: '小红书创作者笔记列表 + 每篇数据 (标题/日期/观看/点赞/收藏/评论)',
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{ name: 'limit', type: 'int', default: 20, help: 'Number of notes to return' },
],
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;
})()
`);
if (!Array.isArray(notes) || notes.length === 0) {
throw new Error('No notes found. Are you logged into creator.xiaohongshu.com?');
}
return notes
.slice(0, limit)
.map((n: any, i: number) => ({
rank: i + 1,
id: n.id,
title: n.title,
date: n.date,
views: n.views,
likes: n.likes,
collects: n.collects,
comments: n.comments,
url: n.url,
}));
},
});
+60
View File
@@ -0,0 +1,60 @@
/**
* Xiaohongshu Creator Profile — creator account info and growth status.
*
* Uses the creator.xiaohongshu.com internal API (cookie auth).
* Returns follower/following counts, total likes+collects, and
* creator level growth info.
*
* Requires: logged into creator.xiaohongshu.com in Chrome.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'xiaohongshu',
name: 'creator-profile',
description: '小红书创作者账号信息 (粉丝/关注/获赞/成长等级)',
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
args: [],
columns: ['field', 'value'],
func: async (page, _kwargs) => {
await page.goto('https://creator.xiaohongshu.com/new/home');
await page.wait(3);
const data = await page.evaluate(`
async () => {
try {
const resp = await fetch('/api/galaxy/creator/home/personal_info', {
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 + '. Are you logged into creator.xiaohongshu.com?');
}
if (!data?.data) {
throw new Error('Unexpected response structure');
}
const d = data.data;
const grow = d.grow_info || {};
return [
{ field: 'Name', value: d.name ?? '' },
{ field: 'Followers', value: d.fans_count ?? 0 },
{ field: 'Following', value: d.follow_count ?? 0 },
{ field: 'Likes & Collects', value: d.faved_count ?? 0 },
{ field: 'Creator Level', value: grow.level ?? 0 },
{ field: 'Level Progress', value: `${grow.fans_count ?? 0}/${grow.max_fans_count ?? 0} fans` },
{ field: 'Bio', value: (d.personal_desc ?? '').replace(/\\n/g, ' | ') },
];
},
});
+81
View File
@@ -0,0 +1,81 @@
/**
* Xiaohongshu Creator Analytics — account-level metrics overview.
*
* Uses the creator.xiaohongshu.com internal API (cookie auth).
* Returns 7-day and 30-day aggregate stats: views, likes, collects,
* comments, shares, new followers, and daily trend data.
*
* Requires: logged into creator.xiaohongshu.com in Chrome.
*/
import { cli, Strategy } from '../../registry.js';
cli({
site: 'xiaohongshu',
name: 'creator-stats',
description: '小红书创作者数据总览 (观看/点赞/收藏/评论/分享/涨粉,含每日趋势)',
domain: 'creator.xiaohongshu.com',
strategy: Strategy.COOKIE,
browser: true,
args: [
{
name: 'period',
type: 'string',
default: 'seven',
help: 'Stats period: seven or thirty',
choices: ['seven', 'thirty'],
},
],
columns: ['metric', 'total', 'trend'],
func: async (page, kwargs) => {
const period: string = kwargs.period || 'seven';
// Navigate to creator center for cookie context
await page.goto('https://creator.xiaohongshu.com/new/home');
await page.wait(3);
const data = await page.evaluate(`
async () => {
try {
const resp = await fetch('/api/galaxy/creator/data/note_detail_new', {
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 + '. Are you logged into creator.xiaohongshu.com?');
}
if (!data?.data) {
throw new Error('Unexpected response structure');
}
const stats = data.data[period];
if (!stats) {
throw new Error(`No data for period "${period}". Available: ${Object.keys(data.data).join(', ')}`);
}
// Format daily trend as sparkline-like summary
const formatTrend = (list: any[]): string => {
if (!list || !list.length) return '-';
return list.map((d: any) => d.count).join(' → ');
};
return [
{ metric: '观看数 (views)', total: stats.view_count ?? 0, trend: formatTrend(stats.view_list) },
{ metric: '平均观看时长 (avg view time ms)', total: stats.view_time_avg ?? 0, trend: formatTrend(stats.view_time_list) },
{ metric: '主页访问 (home views)', total: stats.home_view_count ?? 0, trend: formatTrend(stats.home_view_list) },
{ metric: '点赞数 (likes)', total: stats.like_count ?? 0, trend: formatTrend(stats.like_list) },
{ metric: '收藏数 (collects)', total: stats.collect_count ?? 0, trend: formatTrend(stats.collect_list) },
{ metric: '评论数 (comments)', total: stats.comment_count ?? 0, trend: formatTrend(stats.comment_list) },
{ metric: '弹幕数 (danmaku)', total: stats.danmaku_count ?? 0, trend: '-' },
{ metric: '分享数 (shares)', total: stats.share_count ?? 0, trend: formatTrend(stats.share_list) },
{ metric: '涨粉数 (new followers)', total: stats.rise_fans_count ?? 0, trend: formatTrend(stats.rise_fans_list) },
];
},
});