import fs from 'node:fs/promises'; import path from 'node:path'; import { chromium, type Browser, type Page, type BrowserContext } from 'playwright'; import * as XLSXNS from 'xlsx'; import { AppDataSource, PlatformAccount } from '../models/index.js'; import { BrowserManager } from '../automation/browser.js'; import { logger } from '../utils/logger.js'; import { UserDayStatisticsService } from './UserDayStatisticsService.js'; import { AccountService } from './AccountService.js'; import type { ProxyConfig } from '@media-manager/shared'; import { WS_EVENTS } from '@media-manager/shared'; import { wsManager } from '../websocket/index.js'; // xlsx 在 ESM 下可能挂在 default 上;这里做一次兼容兜底 // eslint-disable-next-line @typescript-eslint/no-explicit-any const XLSX: any = (XLSXNS as any).default ?? (XLSXNS as any); type PlaywrightCookie = { name: string; value: string; domain?: string; path?: string; url?: string; expires?: number; httpOnly?: boolean; secure?: boolean; sameSite?: 'Lax' | 'None' | 'Strict'; }; type MetricKind = | 'playCount' | 'exposureCount' | 'likeCount' | 'commentCount' | 'shareCount' | 'collectCount' | 'fansIncrease' | 'worksCount' | 'coverClickRate' | 'avgWatchDuration' | 'totalWatchDuration' | 'completionRate'; type ExportMode = 'watch' | 'interaction' | 'fans' | 'publish'; function ensureDir(p: string) { return fs.mkdir(p, { recursive: true }); } function normalizeDateText(input: unknown): Date | null { if (!input) return null; if (input instanceof Date && !Number.isNaN(input.getTime())) { const d = new Date(input); d.setHours(0, 0, 0, 0); return d; } const s = String(input).trim(); // 2026年01月27日 const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})\D?/); if (m1) { const yyyy = Number(m1[1]); const mm = Number(m1[2]); const dd = Number(m1[3]); if (!yyyy || !mm || !dd) return null; const d = new Date(yyyy, mm - 1, dd); d.setHours(0, 0, 0, 0); return d; } // 01-27(兜底:用当前年份) const m2 = s.match(/^(\d{1,2})[-/](\d{1,2})$/); if (m2) { const yyyy = new Date().getFullYear(); const mm = Number(m2[1]); const dd = Number(m2[2]); const d = new Date(yyyy, mm - 1, dd); d.setHours(0, 0, 0, 0); return d; } return null; } function parseChineseNumberLike(input: unknown): number | null { if (input === null || input === undefined) return null; const s = String(input).trim(); if (!s) return null; // 8,077 const plain = s.replace(/,/g, ''); // 4.8万 const wan = plain.match(/^(\d+(\.\d+)?)\s*万$/); if (wan) return Math.round(Number(wan[1]) * 10000); const yi = plain.match(/^(\d+(\.\d+)?)\s*亿$/); if (yi) return Math.round(Number(yi[1]) * 100000000); const n = Number(plain.replace(/[^\d.-]/g, '')); if (Number.isFinite(n)) return Math.round(n); return null; } function detectMetricKind(sheetName: string): MetricKind | null { const n = sheetName.trim(); // 观看数据:子表命名可能是「观看趋势」或「观看数趋势」 if (n.includes('观看趋势') || n.includes('观看数')) return 'playCount'; if (n.includes('曝光趋势')) return 'exposureCount'; if (n.includes('封面点击率')) return 'coverClickRate'; if (n.includes('平均观看时长')) return 'avgWatchDuration'; if (n.includes('观看总时长')) return 'totalWatchDuration'; if (n.includes('完播率')) return 'completionRate'; // 互动数据 if (n.includes('点赞') && n.includes('趋势')) return 'likeCount'; if (n.includes('评论') && n.includes('趋势')) return 'commentCount'; if (n.includes('分享') && n.includes('趋势')) return 'shareCount'; if (n.includes('收藏') && n.includes('趋势')) return 'collectCount'; // 涨粉数据(只取净涨粉趋势) if (n.includes('净涨粉') && n.includes('趋势')) return 'fansIncrease'; // 发布数据:总发布趋势 → 每日发布数,入库 works_count if (n.includes('总发布趋势')) return 'worksCount'; return null; } function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] { if (!cookieData) return []; const raw = cookieData.trim(); if (!raw) return []; // 1) JSON array(最常见:浏览器插件导出/前端保存) if (raw.startsWith('[') || raw.startsWith('{')) { try { const parsed = JSON.parse(raw); const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []); if (!Array.isArray(arr)) return []; return arr .map((c: any) => { const name = String(c?.name ?? '').trim(); const value = String(c?.value ?? '').trim(); if (!name) return null; const domain = c?.domain ? String(c.domain) : undefined; const pathVal = c?.path ? String(c.path) : '/'; const url = !domain ? 'https://creator.xiaohongshu.com' : undefined; const sameSiteRaw = c?.sameSite; const sameSite = sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict' ? sameSiteRaw : undefined; return { name, value, domain, path: pathVal, url, expires: typeof c?.expires === 'number' ? c.expires : undefined, httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined, secure: typeof c?.secure === 'boolean' ? c.secure : undefined, sameSite, } satisfies PlaywrightCookie; }) .filter(Boolean) as PlaywrightCookie[]; } catch { // fallthrough } } // 2) "a=b; c=d" 拼接格式 const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean); const cookies: PlaywrightCookie[] = []; for (const p of pairs) { const idx = p.indexOf('='); if (idx <= 0) continue; const name = p.slice(0, idx).trim(); const value = p.slice(idx + 1).trim(); if (!name) continue; cookies.push({ name, value, url: 'https://creator.xiaohongshu.com' }); } return cookies; } async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> { // 静默同步:默认一律 headless,不弹窗 // 只有在“引导登录/验证”时(XHS_STORAGE_STATE_BOOTSTRAP=1 且 XHS_IMPORT_HEADLESS=0)才允许 headful const allowHeadfulForBootstrap = process.env.XHS_STORAGE_STATE_BOOTSTRAP === '1' && process.env.XHS_IMPORT_HEADLESS === '0'; const headless = !allowHeadfulForBootstrap; if (proxy?.enabled) { const server = `${proxy.type}://${proxy.host}:${proxy.port}`; const browser = await chromium.launch({ headless, proxy: { server, username: proxy.username, password: proxy.password, }, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'], }); return { browser, shouldClose: true }; } const browser = await BrowserManager.getBrowser({ headless }); return { browser, shouldClose: false }; } export function parseXhsExcel( filePath: string, mode: ExportMode ): Map> { const wb = XLSX.readFile(filePath); const result = new Map>(); logger.info( `[XHS Import] Excel loaded. mode=${mode} file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}` ); for (const sheetName of wb.SheetNames) { const kind = detectMetricKind(sheetName); if (!kind) continue; // 按导出类型过滤不相关子表,避免误写字段 if ( (mode === 'watch' && !['playCount', 'exposureCount', 'coverClickRate', 'avgWatchDuration', 'totalWatchDuration', 'completionRate'].includes(kind)) || (mode === 'interaction' && !['likeCount', 'commentCount', 'shareCount', 'collectCount'].includes(kind)) || (mode === 'fans' && kind !== 'fansIncrease') || (mode === 'publish' && kind !== 'worksCount') ) { continue; } const sheet = wb.Sheets[sheetName]; const rows = (XLSX.utils.sheet_to_json(sheet, { defval: '' }) as Record[]); if (rows.length) { const keys = Object.keys(rows[0] || {}); logger.info(`[XHS Import] Sheet parsed. name=${sheetName} kind=${kind} rows=${rows.length} keys=${keys.join(',')}`); } else { logger.warn(`[XHS Import] Sheet empty. name=${sheetName} kind=${kind}`); } for (const row of rows) { const dateVal = row['日期'] ?? row['date'] ?? row['Date'] ?? row[Object.keys(row)[0] ?? '']; const valueVal = row['数值'] ?? row['value'] ?? row['Value'] ?? row[Object.keys(row)[1] ?? '']; const d = normalizeDateText(dateVal); if (!d) continue; const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; if (!result.has(key)) result.set(key, { recordDate: d }); const obj = result.get(key)!; if (kind === 'playCount' || kind === 'exposureCount' || kind === 'likeCount' || kind === 'commentCount' || kind === 'shareCount' || kind === 'collectCount' || kind === 'fansIncrease' || kind === 'worksCount') { const n = parseChineseNumberLike(valueVal); if (typeof n === 'number') { if (kind === 'playCount') obj.playCount = n; if (kind === 'exposureCount') obj.exposureCount = n; if (kind === 'likeCount') obj.likeCount = n; if (kind === 'worksCount') obj.worksCount = n; if (kind === 'commentCount') obj.commentCount = n; if (kind === 'shareCount') obj.shareCount = n; if (kind === 'collectCount') obj.collectCount = n; if (kind === 'fansIncrease') obj.fansIncrease = n; // 允许负数 } } else { const s = String(valueVal ?? '').trim(); if (kind === 'coverClickRate') obj.coverClickRate = s || '0'; if (kind === 'avgWatchDuration') obj.avgWatchDuration = s || '0'; if (kind === 'totalWatchDuration') obj.totalWatchDuration = s || '0'; if (kind === 'completionRate') obj.completionRate = s || '0'; } } } return result; } export { parseCookiesFromAccount, createBrowserForAccount }; export class XiaohongshuAccountOverviewImportService { private accountRepository = AppDataSource.getRepository(PlatformAccount); private userDayStatisticsService = new UserDayStatisticsService(); private downloadDir = path.resolve(process.cwd(), 'tmp', 'xhs-account-overview'); private stateDir = path.resolve(process.cwd(), 'tmp', 'xhs-storage-state'); private getStatePath(accountId: number) { return path.join(this.stateDir, `${accountId}.json`); } private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise { const statePath = this.getStatePath(account.id); try { await fs.access(statePath); return statePath; } catch { // no state } // 需要你在弹出的浏览器里完成一次登录/验证,然后脚本会自动保存 storageState // 启用方式:XHS_IMPORT_HEADLESS=0 且 XHS_STORAGE_STATE_BOOTSTRAP=1 if (!(process.env.XHS_IMPORT_HEADLESS === '0' && process.env.XHS_STORAGE_STATE_BOOTSTRAP === '1')) { return null; } await ensureDir(this.stateDir); logger.warn(`[XHS Import] No storageState for accountId=${account.id}. Bootstrapping... 请在弹出的浏览器中完成登录/验证。`); const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig); try { const context = await browser.newContext({ viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', }); await context.addCookies(cookies as any); const page = await context.newPage(); await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' }); // 最长等 5 分钟:让你手动完成登录/滑块/短信等 await page .waitForFunction(() => { const t = document.body?.innerText || ''; return t.includes('账号概览') || t.includes('数据总览') || t.includes('观看数据'); }, { timeout: 5 * 60_000 }) .catch(() => undefined); await context.storageState({ path: statePath }); logger.info(`[XHS Import] storageState saved: ${statePath}`); await context.close(); return statePath; } finally { if (shouldClose) await browser.close().catch(() => undefined); } } /** * 统一入口:定时任务与添加账号均调用此方法,执行“账号概览-观看/互动/涨粉-近30日 + 粉丝 overall_new” */ static async runDailyImport(): Promise { const svc = new XiaohongshuAccountOverviewImportService(); await svc.runDailyImportForAllXhsAccounts(); } /** * 为所有小红书账号导出“观看数据-近30日”并导入 user_day_statistics */ async runDailyImportForAllXhsAccounts(): Promise { await ensureDir(this.downloadDir); const accounts = await this.accountRepository.find({ where: { platform: 'xiaohongshu' as any }, }); logger.info(`[XHS Import] Start. total_accounts=${accounts.length}`); for (const account of accounts) { try { await this.importAccountLast30Days(account); } catch (e) { logger.error(`[XHS Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e); } } logger.info('[XHS Import] Done.'); } /** * 单账号:导出 Excel → 解析 → 入库 → 删除文件 */ async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise { const cookies = parseCookiesFromAccount(account.cookieData); if (!cookies.length) { throw new Error('cookieData 为空或无法解析'); } const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig); try { const statePath = await this.ensureStorageState(account, cookies); const context = await browser.newContext({ acceptDownloads: true, viewport: { width: 1920, height: 1080 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', ...(statePath ? { storageState: statePath } : {}), }); context.setDefaultTimeout(60_000); // 如果没 state,就退回 cookie-only(可能导出为 0) if (!statePath) { await context.addCookies(cookies as any); } const page = await context.newPage(); // account/base 在页面加载时自动请求,先挂监听再访问 const accountBasePattern = /\/api\/galaxy\/v2\/creator\/datacenter\/account\/base/i; const responsePromise = page.waitForResponse( (r) => r.url().match(accountBasePattern) != null && r.request().method() === 'GET', { timeout: 30_000 } ); await page.goto('https://creator.xiaohongshu.com/statistics/account/v2', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(3000); // 等几秒,让页面发起 account/base 请求 if (page.url().includes('login')) { // 第一次检测到登录失效时,尝试刷新账号 if (!isRetry) { logger.info(`[XHS Import] Login expired detected for account ${account.id}, attempting to refresh...`); await context.close(); if (shouldClose) await browser.close(); try { const accountService = new AccountService(); const refreshResult = await accountService.refreshAccount(account.userId, account.id); if (refreshResult.needReLogin) { // 刷新后仍需要重新登录,走原先的失效流程 logger.warn(`[XHS Import] Account ${account.id} refresh failed, still needs re-login`); throw new Error('未登录/需要重新登录(跳转到 login)'); } // 刷新成功,重新获取账号信息并重试导入 logger.info(`[XHS Import] Account ${account.id} refreshed successfully, retrying import...`); const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } }); if (!refreshedAccount) { throw new Error('账号刷新后未找到'); } // 递归调用,标记为重试 return await this.importAccountLast30Days(refreshedAccount, true); } catch (refreshError) { logger.error(`[XHS Import] Account ${account.id} refresh failed:`, refreshError); throw new Error('未登录/需要重新登录(跳转到 login)'); } } else { // 已经是重试了,不再尝试刷新 throw new Error('未登录/需要重新登录(跳转到 login)'); } } // 检测“暂无访问权限 / 权限申请中”提示:仅推送提示,不修改账号状态(避免误判或用户不想自动变更) const bodyText = (await page.textContent('body').catch(() => '')) || ''; if (bodyText.includes('暂无访问权限') || bodyText.includes('数据权限申请中') || bodyText.includes('次日再来查看')) { // await this.accountRepository.update(account.id, { status: 'expired' as any }); wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, { level: 'warning', message: `小红书账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到小红书创作服务平台申请数据权限(通过后一般次日生效)。`, platform: 'xiaohongshu', accountId: account.id, }); throw new Error('小红书数据看板暂无访问权限/申请中,已通知用户'); } // 直接监听 account/base,无需点击 账号概览/笔记数据 await this.importFromAccountBaseApi(responsePromise, page, account); // 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count await this.importFansDataTrendFromPage(context, page, account); logger.info(`[XHS Import] Account all tabs done. accountId=${account.id}`); await context.close(); } finally { if (shouldClose) { await browser.close().catch(() => undefined); } } } /** * 等待 account/base 接口响应,解析 data.thirty 各 *_list 按 date 合并为按日数据并写入 user_day_statistics * 字段映射:view_list→playCount, impl_count_list→exposureCount, comment_list→commentCount, * like_list→likeCount, share_list→shareCount, collect_list→collectCount, * net_rise_fans_count_list→fansIncrease, cover_click_rate_list→coverClickRate(格式化为"14%"), * avg_view_time_list→avgWatchDuration("12秒"), view_time_list→totalWatchDuration("1866秒"), * video_full_view_rate_list→completionRate("15%"), publish_note_num_list→worksCount */ private async importFromAccountBaseApi( responsePromise: Promise, _page: Page, account: PlatformAccount ): Promise { let res: import('playwright').Response; try { res = await responsePromise; } catch { logger.warn(`[XHS Import] account/base response not captured, skip. accountId=${account.id}`); return; } const body = await res.json().catch(() => null); if (!body || typeof body !== 'object') { logger.warn(`[XHS Import] account/base not valid JSON. accountId=${account.id}`); return; } const data = (body as Record).data as Record | undefined; const thirty = data?.thirty as Record | undefined; if (!thirty || typeof thirty !== 'object') { logger.warn(`[XHS Import] account/base data.thirty missing. accountId=${account.id}`); return; } const perDay = this.parseAccountBaseThirty(thirty); if (perDay.size === 0) { logger.info(`[XHS Import] account/base no days parsed. accountId=${account.id}`); return; } let inserted = 0; let updated = 0; const today = new Date(); today.setHours(0, 0, 0, 0); for (const v of perDay.values()) { const { recordDate, ...patch } = v; if (recordDate.getTime() === today.getTime() && patch.fansCount === undefined && account.fansCount != null && account.fansCount > 0) { (patch as Record).fansCount = account.fansCount; } const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch); inserted += r.inserted; updated += r.updated; } logger.info( `[XHS Import] account/base imported. accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}` ); } /** * 解析 data.thirty:各 *_list 每项 { date: 毫秒, count[, count_with_double] },按 date 合并为按日一条 * 注意:接口返回的 date 是「中国时区(Asia/Shanghai)该日 0 点」的 UTC 时间戳,需按中国时区解析日期 */ private parseAccountBaseThirty(thirty: Record): Map> { const map = new Map>(); // 使用 Intl.DateTimeFormat 获取中国时区的年月日 const cstFormatter = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }); const toKey = (ms: number): string => { // 将 UTC 时间戳转成中国时区的日期字符串 YYYY-MM-DD return cstFormatter.format(new Date(ms)); }; const toRecordDate = (ms: number): Date => { // 获取中国时区的年月日 const parts = cstFormatter.formatToParts(new Date(ms)); const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0'; const y = parseInt(get('year'), 10); const m = parseInt(get('month'), 10) - 1; // month 是 1-12,Date 构造函数需要 0-11 const d = parseInt(get('day'), 10); // 构造本地时区的该日 0 点(如果服务器在中国时区,就是中国时区的 0 点) return new Date(y, m, d, 0, 0, 0, 0); }; const setFromList = ( listKey: string, field: string, formatter?: (n: number) => string | number ) => { const arr = thirty[listKey]; if (!Array.isArray(arr)) return; for (const item of arr) { if (!item || typeof item !== 'object') continue; const o = item as Record; const dateMs = o.date; const countRaw = o.count; if (dateMs == null || countRaw == null) continue; const ts = typeof dateMs === 'number' ? dateMs : Number(dateMs); if (!Number.isFinite(ts)) continue; const key = toKey(ts); if (!map.has(key)) { map.set(key, { recordDate: toRecordDate(ts) }); } else { (map.get(key)!.recordDate as Date) = toRecordDate(ts); } const rec = map.get(key)!; const n = typeof countRaw === 'number' ? countRaw : Number(countRaw); if (!Number.isFinite(n)) continue; const val = formatter ? formatter(n) : n; (rec as Record)[field] = val; } }; setFromList('view_list', 'playCount'); setFromList('impl_count_list', 'exposureCount'); setFromList('comment_list', 'commentCount'); setFromList('like_list', 'likeCount'); setFromList('share_list', 'shareCount'); setFromList('collect_list', 'collectCount'); setFromList('net_rise_fans_count_list', 'fansIncrease'); setFromList('cover_click_rate_list', 'coverClickRate', (n) => `${Math.round(n)}%`); setFromList('avg_view_time_list', 'avgWatchDuration', (n) => `${Math.round(n)}秒`); setFromList('view_time_list', 'totalWatchDuration', (n) => `${Math.round(n)}秒`); setFromList('video_full_view_rate_list', 'completionRate', (n) => `${typeof n === 'number' ? Math.round(n) : n}%`); setFromList('publish_note_num_list', 'worksCount'); return map; } /** * 粉丝数据页:打开粉丝数据、点击「粉丝数据概览」近30天,监听 overall_new 接口响应,解析每日粉丝总数并写入 user_day_statistics.fans_count */ private async importFansDataTrendFromPage( _context: BrowserContext, page: Page, account: PlatformAccount ): Promise { const fansDataUrl = 'https://creator.xiaohongshu.com/statistics/fans-data'; const overallNewPattern = /\/api\/galaxy\/creator\/data\/fans\/overall_new/i; const near30ButtonSelector = '#content-area > main > div:nth-child(3) > div > div.content > div.css-12s9z8c.fans-data-container > div.title-container > div.extra-box > div > label:nth-child(2)'; await page.goto(fansDataUrl, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2000); if (page.url().includes('login')) { logger.warn(`[XHS Import] Fans data page redirected to login, skip fans trend. accountId=${account.id}`); return; } const responsePromise = page.waitForResponse( (res) => res.url().match(overallNewPattern) != null && res.request().method() === 'GET', { timeout: 30_000 } ); const btn = page.locator(near30ButtonSelector).or(page.locator('.fans-data-container').getByText('近30天').first()); await btn.click().catch(() => undefined); await page.waitForTimeout(1500); let res; try { res = await responsePromise; } catch { try { res = await page.waitForResponse( (r) => r.url().match(overallNewPattern) != null && r.request().method() === 'GET', { timeout: 15_000 } ); } catch { logger.warn(`[XHS Import] No overall_new response captured, skip fans trend. accountId=${account.id}`); return; } } const body = await res.json().catch(() => null); if (!body || typeof body !== 'object') { logger.warn(`[XHS Import] overall_new response not valid JSON, skip. accountId=${account.id}`); return; } const list = this.parseFansOverallNewResponse(body); if (!list.length) { logger.info(`[XHS Import] No fans trend items from overall_new. accountId=${account.id}`); return; } let updated = 0; for (const { recordDate, fansCount } of list) { const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, { fansCount }); updated += r.inserted + r.updated; } logger.info(`[XHS Import] Fans trend imported. accountId=${account.id} days=${list.length} updated=${updated}`); } /** * 解析 overall_new 接口返回的 JSON,提取 (recordDate, fansCount) 列表 * 接口格式:data.thirty.fans_list(或 fans_list_iterator),每项 { date: 毫秒时间戳, count: 粉丝数 } */ private parseFansOverallNewResponse(body: Record): Array<{ recordDate: Date; fansCount: number }> { const list: Array<{ recordDate: Date; fansCount: number }> = []; const data = body.data as Record | undefined; if (!data || typeof data !== 'object') return list; const thirty = data.thirty as Record | undefined; if (!thirty || typeof thirty !== 'object') return list; const arr = (thirty.fans_list as unknown[]) ?? (thirty.fans_list_iterator as unknown[]) ?? []; if (!Array.isArray(arr)) return list; for (const item of arr) { if (!item || typeof item !== 'object') continue; const o = item as Record; const dateMs = o.date; const countRaw = o.count; if (dateMs == null || countRaw == null) continue; const ts = typeof dateMs === 'number' ? dateMs : Number(dateMs); if (!Number.isFinite(ts)) continue; const d = new Date(ts); d.setHours(0, 0, 0, 0); const n = typeof countRaw === 'number' ? countRaw : Number(countRaw); if (!Number.isFinite(n) || n < 0) continue; list.push({ recordDate: d, fansCount: Math.round(n) }); } return list; } }