Просмотр исходного кода

小红书统计新增粉丝总数

Ethanfly 21 часов назад
Родитель
Сommit
bed30759a2

+ 1 - 0
server/src/services/AccountService.ts

@@ -328,6 +328,7 @@ export class AccountService {
 
     try {
       if (platform === 'xiaohongshu') {
+        // 与定时任务相同:账号概览(观看/互动/涨粉)导出 + 粉丝数据页近30天 overall_new → user_day_statistics.fans_count
         const svc = new XiaohongshuAccountOverviewImportService();
         await svc.importAccountLast30Days(account);
       } else if (platform === 'douyin') {

+ 101 - 1
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs/promises';
 import path from 'node:path';
-import { chromium, type Browser } from 'playwright';
+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';
@@ -453,6 +453,9 @@ export class XiaohongshuAccountOverviewImportService {
       // 3) 涨粉数据:只取“净涨粉趋势”(解析器已过滤)
       await exportAndImport('涨粉数据', 'fans');
 
+      // 4) 粉丝数据页:打开粉丝数据、点击近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();
@@ -462,5 +465,102 @@ export class XiaohongshuAccountOverviewImportService {
       }
     }
   }
+
+  /**
+   * 粉丝数据页:打开粉丝数据、点击「粉丝数据概览」近30天,监听 overall_new 接口响应,解析每日粉丝总数并写入 user_day_statistics.fans_count
+   */
+  private async importFansDataTrendFromPage(
+    _context: BrowserContext,
+    page: Page,
+    account: PlatformAccount
+  ): Promise<void> {
+    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<string, unknown>): Array<{ recordDate: Date; fansCount: number }> {
+    const list: Array<{ recordDate: Date; fansCount: number }> = [];
+    const data = body.data as Record<string, unknown> | undefined;
+    if (!data || typeof data !== 'object') return list;
+
+    const thirty = data.thirty as Record<string, unknown> | 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<string, unknown>;
+      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;
+  }
 }