diff --git a/package.json b/package.json index 96a98cc..127bae4 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/scripts/test-site.mjs b/scripts/test-site.mjs new file mode 100644 index 0000000..f164605 --- /dev/null +++ b/scripts/test-site.mjs @@ -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 -- '); + 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))], +); diff --git a/src/build-manifest.test.ts b/src/build-manifest.test.ts new file mode 100644 index 0000000..d2adae7 --- /dev/null +++ b/src/build-manifest.test.ts @@ -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'], + }, + ]); + }); +}); diff --git a/src/build-manifest.ts b/src/build-manifest.ts index bd2d6b1..1fcaf98 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -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[] = []; - -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)); +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)); + } } } } + + 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(); +} diff --git a/src/clis/xiaohongshu/creator-note-detail.ts b/src/clis/xiaohongshu/creator-note-detail.ts new file mode 100644 index 0000000..787c351 --- /dev/null +++ b/src/clis/xiaohongshu/creator-note-detail.ts @@ -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, + }, + ]; + }, +}); diff --git a/src/clis/xiaohongshu/creator-notes.ts b/src/clis/xiaohongshu/creator-notes.ts new file mode 100644 index 0000000..fcbf9c0 --- /dev/null +++ b/src/clis/xiaohongshu/creator-notes.ts @@ -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, + })); + }, +}); diff --git a/src/clis/xiaohongshu/creator-profile.ts b/src/clis/xiaohongshu/creator-profile.ts new file mode 100644 index 0000000..6ed0dcf --- /dev/null +++ b/src/clis/xiaohongshu/creator-profile.ts @@ -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, ' | ') }, + ]; + }, +}); diff --git a/src/clis/xiaohongshu/creator-stats.ts b/src/clis/xiaohongshu/creator-stats.ts new file mode 100644 index 0000000..5875b16 --- /dev/null +++ b/src/clis/xiaohongshu/creator-stats.ts @@ -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) }, + ]; + }, +});