|
|
@@ -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;
|
|
|
+ }
|
|
|
}
|
|
|
|