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:
1 parent
8a355dfd2d
commit
c370bd0582
8 files changed
+597
-56
No files matched your search
@@ -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": [
|
||||
|
||||
@@ -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))],
|
||||
);
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -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, ' | ') },
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -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) },
|
||||
];
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user