Bläddra i källkod

Merge branch 'main' of http://gitlab.pubdata.cn/hlm/multi-platform-media-manage

Ethanfly 15 timmar sedan
förälder
incheckning
794990ec67

+ 54 - 12
server/src/scheduler/index.ts

@@ -8,6 +8,8 @@ import { LessThanOrEqual, In } from 'typeorm';
 import { taskQueueService } from '../services/TaskQueueService.js';
 import { XiaohongshuAccountOverviewImportService } from '../services/XiaohongshuAccountOverviewImportService.js';
 import { DouyinAccountOverviewImportService } from '../services/DouyinAccountOverviewImportService.js';
+import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoContentOverviewImportService.js';
+import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
 
 /**
  * 定时任务调度器
@@ -17,7 +19,8 @@ export class TaskScheduler {
   private isRefreshingAccounts = false; // 账号刷新锁,防止任务重叠执行
   private isXhsImportRunning = false; // 小红书导入锁,防止任务重叠执行
   private isDyImportRunning = false; // 抖音导入锁,防止任务重叠执行
-  private isRefreshingAccounts = false;
+  private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
+  private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
   private isAutoReplying = false; // 私信回复锁,防止任务重叠执行
   /**
    * 启动调度器
@@ -32,12 +35,18 @@ export class TaskScheduler {
     // 每分钟检查定时发布任务(只处理到期的定时发布任务)
     this.scheduleJob('check-publish-tasks', '* * * * *', this.checkPublishTasks.bind(this));
 
-    // 每天早上 7 点:批量导出小红书“账号概览-笔记数据-观看数据-近30日”,导入 user_day_statistics
+    // 每天中午 12 点:批量导出小红书“账号概览-笔记数据-观看数据-近30日”,导入 user_day_statistics
     // 注意:node-schedule 使用服务器本地时区
-    this.scheduleJob('xhs-account-overview-import', '0 7 * * *', this.importXhsAccountOverviewLast30Days.bind(this));
+    this.scheduleJob('xhs-account-overview-import', '0 12 * * *', this.importXhsAccountOverviewLast30Days.bind(this));
 
-    // 每天早上 7:10:批量导出抖音“数据中心-账号总览-短视频-数据表现-近30天”,导入 user_day_statistics
-    this.scheduleJob('dy-account-overview-import', '10 7 * * *', this.importDyAccountOverviewLast30Days.bind(this));
+    // 每天 12:10:批量导出抖音“数据中心-账号总览-短视频-数据表现-近30天”,导入 user_day_statistics
+    this.scheduleJob('dy-account-overview-import', '10 12 * * *', this.importDyAccountOverviewLast30Days.bind(this));
+
+    // 每天 12:20:批量导出百家号“数据中心-内容分析-基础数据-近30天”,导入 user_day_statistics
+    this.scheduleJob('bj-content-overview-import', '20 12 * * *', this.importBaijiahaoContentOverviewLast30Days.bind(this));
+
+    // 每天 12:30:批量导出视频号“数据中心-各子菜单-增长详情(数据详情)-近30天-下载表格”,导入 user_day_statistics
+    this.scheduleJob('wx-video-data-center-import', '30 12 * * *', this.importWeixinVideoDataCenterLast30Days.bind(this));
     
     this.scheduleJob('auto-reply-messages', '* * * * *', this.autoReplyMessages.bind(this));
     // 注意:账号刷新由客户端定时触发,不在服务端自动执行
@@ -48,8 +57,10 @@ export class TaskScheduler {
     
     logger.info('[Scheduler] Scheduled jobs:');
     logger.info('[Scheduler]   - check-publish-tasks: every minute (* * * * *)');
-    logger.info('[Scheduler]   - xhs-account-overview-import: daily at 07:00 (0 7 * * *)');
-    logger.info('[Scheduler]   - dy-account-overview-import:  daily at 07:10 (10 7 * * *)');
+    logger.info('[Scheduler]   - xhs-account-overview-import: daily at 12:00 (0 12 * * *)');
+    logger.info('[Scheduler]   - dy-account-overview-import:  daily at 12:10 (10 12 * * *)');
+    logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
+    logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
     logger.info('[Scheduler]   - auto-reply-messages: every minute (* * * * *)');
     logger.info('[Scheduler] Note: Account refresh is triggered by client, not server');
     logger.info('[Scheduler] ========================================');
@@ -425,11 +436,42 @@ export class TaskScheduler {
       logger.debug('[Scheduler] Released auto reply lock');
     }
   }
-  
-  
-  
-  
-  
+
+  /**
+   * 百家号:内容分析-基础数据导出(近30天)→ 导入 user_day_statistics
+   */
+  private async importBaijiahaoContentOverviewLast30Days(): Promise<void> {
+    if (this.isBjImportRunning) {
+      logger.info('[Scheduler] Baijiahao import is already running, skipping this cycle...');
+      return;
+    }
+
+    this.isBjImportRunning = true;
+    try {
+      const svc = new BaijiahaoContentOverviewImportService();
+      await svc.runDailyImportForAllBaijiahaoAccounts();
+    } finally {
+      this.isBjImportRunning = false;
+    }
+  }
+
+  /**
+   * 视频号:数据中心-关注者/视频/图文 的增长详情(近30天)→ 导入 user_day_statistics
+   */
+  private async importWeixinVideoDataCenterLast30Days(): Promise<void> {
+    if (this.isWxImportRunning) {
+      logger.info('[Scheduler] Weixin video import is already running, skipping this cycle...');
+      return;
+    }
+
+    this.isWxImportRunning = true;
+    try {
+      const svc = new WeixinVideoDataCenterImportService();
+      await svc.runDailyImportForAllWeixinVideoAccounts();
+    } finally {
+      this.isWxImportRunning = false;
+    }
+  }
 }
 
 export const taskScheduler = new TaskScheduler();

+ 20 - 0
server/src/scripts/run-baijiahao-import.ts

@@ -0,0 +1,20 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoContentOverviewImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[BJ Import] Manual run start...');
+    const svc = new BaijiahaoContentOverviewImportService();
+    await svc.runDailyImportForAllBaijiahaoAccounts();
+    logger.info('[BJ Import] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[BJ Import] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 45 - 0
server/src/scripts/run-weixin-video-import-single.ts

@@ -0,0 +1,45 @@
+import { initDatabase, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+
+    const accountName = '凝光AI';
+    const accountRepository = (await import('../models/index.js')).AppDataSource.getRepository(
+      PlatformAccount
+    );
+
+    const account = await accountRepository.findOne({
+      where: {
+        platform: 'weixin_video' as any,
+        accountName,
+      },
+    });
+
+    if (!account) {
+      logger.error(`[WX Import] Weixin video account not found for name=${accountName}`);
+      process.exit(1);
+      return;
+    }
+
+    logger.info(
+      `[WX Import] Single-account run start. accountId=${account.id} name=${account.accountName}`
+    );
+
+    const svc = new WeixinVideoDataCenterImportService();
+    await svc.importAccountLast30Days(account);
+
+    logger.info(
+      `[WX Import] Single-account run done. accountId=${account.id} name=${account.accountName}`
+    );
+    process.exit(0);
+  } catch (e) {
+    logger.error('[WX Import] Single-account run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 20 - 0
server/src/scripts/run-weixin-video-import.ts

@@ -0,0 +1,20 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[WX Import] Manual run start...');
+    const svc = new WeixinVideoDataCenterImportService();
+    await svc.runDailyImportForAllWeixinVideoAccounts();
+    logger.info('[WX Import] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[WX Import] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 145 - 0
server/src/scripts/run-weixin-video-open-with-account.ts

@@ -0,0 +1,145 @@
+import { initDatabase, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { BrowserManager } from '../automation/browser.js';
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
+
+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://channels.weixin.qq.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://channels.weixin.qq.com' });
+  }
+  return cookies;
+}
+
+async function main() {
+  try {
+    await initDatabase();
+
+    const accountRepository = (await import('../models/index.js')).AppDataSource.getRepository(
+      PlatformAccount
+    );
+
+    const accountName = '嗯麦威欧洲古董';
+    const account = await accountRepository.findOne({
+      where: {
+        platform: 'weixin_video' as any,
+        accountName,
+      },
+    });
+
+    if (!account) {
+      logger.error(`[WX Video] Account not found for name=${accountName}`);
+      process.exit(1);
+      return;
+    }
+
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
+      logger.error('[WX Video] cookieData 为空或无法解析,无法带登录态打开页面');
+      process.exit(1);
+      return;
+    }
+
+    logger.info(
+      `[WX Video] Opening with account. id=${account.id} name=${account.accountName ?? ''}`
+    );
+
+    const browser = await BrowserManager.getBrowser({ headless: false });
+    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();
+    const url = 'https://channels.weixin.qq.com/platform';
+    logger.info(`[WX Video] Opening page with cookies: ${url}`);
+    await page.goto(url, { waitUntil: 'domcontentloaded' });
+    logger.info('[WX Video] Page opened with account cookies. 你可以在浏览器里操作该账号。');
+
+    // 等页面真正进入平台(避免太早保存到“登录页”的 storageState)
+    await page
+      .waitForFunction(() => {
+        const t = document.body?.innerText || '';
+        return t.includes('数据中心') || t.includes('视频数据') || t.includes('关注者数据');
+      }, { timeout: 60_000 })
+      .catch(() => undefined);
+
+    // 保存 storageState,供后台 headless 同步复用(避免 cookie-only 在 headless 下跳登录)
+    const stateDir = path.resolve(process.cwd(), 'tmp', 'weixin-video-storage-state');
+    await fs.mkdir(stateDir, { recursive: true });
+    const statePath = path.join(stateDir, `${account.id}.json`);
+    await context.storageState({ path: statePath });
+    logger.info(`[WX Video] storageState saved: ${statePath}`);
+    // 不主动关闭浏览器,方便你手动操作
+  } catch (e) {
+    logger.error('[WX Video] Failed to open page with account:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 26 - 0
server/src/scripts/run-weixin-video-open.ts

@@ -0,0 +1,26 @@
+import { logger } from '../utils/logger.js';
+import { BrowserManager } from '../automation/browser.js';
+
+async function main() {
+  try {
+    logger.info('[WX Video] Launching headful browser...');
+    const browser = await BrowserManager.getBrowser({ headless: false });
+    const context = await browser.newContext({
+      viewport: { width: 1920, height: 1080 },
+      locale: 'zh-CN',
+      timezoneId: 'Asia/Shanghai',
+    });
+    const page = await context.newPage();
+    const url = 'https://channels.weixin.qq.com/platform';
+    logger.info(`[WX Video] Opening page: ${url}`);
+    await page.goto(url, { waitUntil: 'domcontentloaded' });
+    logger.info('[WX Video] Page opened. You can now operate in the browser window.');
+    // 不主动关闭,让你在浏览器里手动操作;进程保持运行
+  } catch (e) {
+    logger.error('[WX Video] Failed to open page:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

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

@@ -18,6 +18,10 @@ import { logger } from '../utils/logger.js';
 import { headlessBrowserService } from './HeadlessBrowserService.js';
 import { aiService } from '../ai/index.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
+import { XiaohongshuAccountOverviewImportService } from './XiaohongshuAccountOverviewImportService.js';
+import { DouyinAccountOverviewImportService } from './DouyinAccountOverviewImportService.js';
+import { BaijiahaoContentOverviewImportService } from './BaijiahaoContentOverviewImportService.js';
+import { WeixinVideoDataCenterImportService } from './WeixinVideoDataCenterImportService.js';
 
 interface GetAccountsParams {
   platform?: string;
@@ -280,6 +284,14 @@ export class AccountService {
       logger.warn(`[addAccount] Background refresh failed for account ${account.id}:`, err);
     });
 
+    // 新增账号后,按平台触发一次“近30天数据同步”,用于初始化 user_day_statistics
+    this.initStatisticsForNewAccountAsync(account).catch(err => {
+      logger.warn(
+        `[addAccount] Initial statistics sync failed for account ${account.id} (${platform}):`,
+        err
+      );
+    });
+
     return this.formatAccount(account);
   }
 
@@ -300,6 +312,52 @@ export class AccountService {
     }
   }
 
+  /**
+   * 新增账号后,按平台触发一次“近30天数据同步”,用于初始化 user_day_statistics
+   * 仿照定时任务里跑的导入服务,但只针对当前账号
+   */
+  private async initStatisticsForNewAccountAsync(account: PlatformAccount): Promise<void> {
+    const platform = account.platform as PlatformType;
+
+    // 延迟几秒,避免和前端后续操作/账号刷新抢占浏览器资源
+    await new Promise((resolve) => setTimeout(resolve, 3000));
+
+    logger.info(
+      `[addAccount] Starting initial statistics import for account ${account.id} (${platform})`
+    );
+
+    try {
+      if (platform === 'xiaohongshu') {
+        const svc = new XiaohongshuAccountOverviewImportService();
+        await svc.importAccountLast30Days(account);
+      } else if (platform === 'douyin') {
+        const svc = new DouyinAccountOverviewImportService();
+        await svc.importAccountLast30Days(account);
+      } else if (platform === 'baijiahao') {
+        const svc = new BaijiahaoContentOverviewImportService();
+        await svc.importAccountLast30Days(account);
+      } else if (platform === 'weixin_video') {
+        const svc = new WeixinVideoDataCenterImportService();
+        await svc.importAccountLast30Days(account);
+      } else {
+        logger.info(
+          `[addAccount] Initial statistics import skipped for unsupported platform ${platform}`
+        );
+        return;
+      }
+
+      logger.info(
+        `[addAccount] Initial statistics import completed for account ${account.id} (${platform})`
+      );
+    } catch (error) {
+      logger.warn(
+        `[addAccount] Initial statistics import encountered error for account ${account.id} (${platform}):`,
+        error
+      );
+      // 出错时不抛出,让前端添加账号流程不受影响
+    }
+  }
+
   async updateAccount(
     userId: number,
     accountId: number,

+ 655 - 0
server/src/services/BaijiahaoContentOverviewImportService.ts

@@ -0,0 +1,655 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { chromium, type Browser } 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 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';
+};
+
+function ensureDir(p: string) {
+  return fs.mkdir(p, { recursive: true });
+}
+
+function normalizeDateText(input: unknown): Date | null {
+  if (!input) return null;
+  const s = String(input).trim();
+  if (!s) return null;
+
+  // 20260115 / 2026-01-15 / 2026/01/15
+  const mCompact = s.match(/^(\d{4})(\d{2})(\d{2})$/);
+  if (mCompact) {
+    const yyyy = Number(mCompact[1]);
+    const mm = Number(mCompact[2]);
+    const dd = Number(mCompact[3]);
+    if (!yyyy || !mm || !dd) return null;
+    const d = new Date(yyyy, mm - 1, dd);
+    d.setHours(0, 0, 0, 0);
+    return d;
+  }
+
+  const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})/);
+  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;
+  }
+
+  return null;
+}
+
+function parseChineseNumberLike(input: unknown): number | null {
+  if (input === null || input === undefined) return null;
+  const s = String(input).trim();
+  if (!s) return null;
+  const plain = s.replace(/,/g, '');
+  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 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://baijiahao.baidu.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://baijiahao.baidu.com' });
+  }
+  return cookies;
+}
+
+async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
+  // 静默同步:默认一律 headless,不弹窗
+  // 只有在“引导登录/验证”时(BJ_STORAGE_STATE_BOOTSTRAP=1 且 BJ_IMPORT_HEADLESS=0)才允许 headful
+  const allowHeadfulForBootstrap =
+    process.env.BJ_STORAGE_STATE_BOOTSTRAP === '1' && process.env.BJ_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 };
+}
+
+function parseBaijiahaoExcel(
+  filePath: string
+): Map<string, { recordDate: Date } & Record<string, any>> {
+  const wb = XLSX.readFile(filePath);
+  const result = new Map<string, { recordDate: Date } & Record<string, any>>();
+
+  logger.info(
+    `[BJ Import] Excel loaded. file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`
+  );
+
+  for (const sheetName of wb.SheetNames) {
+    const sheet = wb.Sheets[sheetName];
+    const rows = XLSX.utils.sheet_to_json<Record<string, any>>(sheet, { defval: '' });
+
+    if (!rows.length) {
+      logger.warn(`[BJ Import] Sheet empty. name=${sheetName}`);
+      continue;
+    }
+
+    const keys = Object.keys(rows[0] || {});
+    logger.info(
+      `[BJ Import] Sheet parsed. name=${sheetName} rows=${rows.length} keys=${keys.join(',')}`
+    );
+
+    // 百家号 Excel 为 GBK 编码,列名在 node 环境下会变成乱码(但列顺序稳定),所以这里按“列位置”做映射:
+    // 0: 日期(形如 20260115)
+    // 1: 阅读量
+    // 2: 点击率
+    // 3: 互动率
+    // 4: 评论量
+    // 5: 评论率(%)
+    // 6: 点赞量
+    // 7: 点赞率(%)
+    // 8: 收藏量
+    // 9: 收藏率(%)
+    // 10: 分享量
+    // 11: 分享率(%)
+    // 12: 作品涨粉量
+    // 13: 作品涨粉率
+    // 14: 作品脱粉量
+    // ... 其余列暂不入库
+
+    for (const row of rows) {
+      const cols = Object.keys(row || {});
+      if (!cols.length) continue;
+
+      const dateVal = (row as any)[cols[0]];
+      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)!;
+
+      const safeGet = (idx: number): any =>
+        idx >= 0 && idx < cols.length ? (row as any)[cols[idx]] : undefined;
+
+      // 阅读量 → playCount
+      const readCount = parseChineseNumberLike(safeGet(1));
+      if (typeof readCount === 'number') (obj as any).playCount = readCount;
+
+      // 点赞量 → likeCount
+      const likeCount = parseChineseNumberLike(safeGet(6));
+      if (typeof likeCount === 'number') (obj as any).likeCount = likeCount;
+
+      // 评论量 → commentCount
+      const commentCount = parseChineseNumberLike(safeGet(4));
+      if (typeof commentCount === 'number') (obj as any).commentCount = commentCount;
+
+      // 收藏量 → collectCount
+      const collectCount = parseChineseNumberLike(safeGet(8));
+      if (typeof collectCount === 'number') (obj as any).collectCount = collectCount;
+
+      // 分享量 → shareCount
+      const shareCount = parseChineseNumberLike(safeGet(10));
+      if (typeof shareCount === 'number') (obj as any).shareCount = shareCount;
+
+      // 点击率 → cover_click_rate(通常是百分比字符串,原样入库)
+      const clickRateRaw = safeGet(2);
+      if (clickRateRaw !== undefined && clickRateRaw !== null) {
+        const s = String(clickRateRaw).trim();
+        if (s) (obj as any).coverClickRate = s;
+      }
+
+      // fans_increase 只看作品涨粉量(不再扣除作品脱粉量)
+      const inc = parseChineseNumberLike(safeGet(12));
+      if (typeof inc === 'number') {
+        (obj as any).fansIncrease = inc;
+      }
+    }
+  }
+
+  return result;
+}
+
+function formatPercentString(input: unknown): string | null {
+  if (input === null || input === undefined) return null;
+  const s = String(input).trim();
+  if (!s) return null;
+  if (s.includes('%')) return s;
+  const n = Number(s);
+  if (!Number.isFinite(n)) return null;
+  // 0.0423 -> 4.23%
+  if (n >= 0 && n <= 1) return `${(n * 100).toFixed(2)}%`;
+  // 4.23 -> 4.23%
+  return `${n.toFixed(2)}%`;
+}
+
+function findArrayWithDateLikeField(root: any): { arr: any[]; dateKey: string } | null {
+  const seen = new Set<any>();
+  const queue: any[] = [root];
+  const isDateLike = (v: any) => {
+    if (v === null || v === undefined) return false;
+    if (typeof v === 'number') return String(v).match(/^\d{8}$/);
+    const s = String(v).trim();
+    return /^\d{8}$/.test(s) || /^\d{4}[-/]\d{1,2}[-/]\d{1,2}$/.test(s);
+  };
+  const dateKeyCandidates = ['day', 'date', 'stat_day', 'statDay', 'dt', 'time', 'the_day'];
+  const candidates: Array<{ arr: any[]; dateKey: string }> = [];
+
+  while (queue.length) {
+    const cur = queue.shift();
+    if (!cur || typeof cur !== 'object') continue;
+    if (seen.has(cur)) continue;
+    seen.add(cur);
+
+    if (Array.isArray(cur)) {
+      // 数组元素为对象且含日期字段
+      for (const item of cur) {
+        if (!item || typeof item !== 'object') continue;
+        const keys = Object.keys(item);
+        for (const dk of dateKeyCandidates) {
+          if (keys.includes(dk) && isDateLike((item as any)[dk])) {
+            candidates.push({ arr: cur, dateKey: dk });
+            break;
+          }
+        }
+        // 兜底:任意字段像日期
+        for (const k of keys) {
+          if (isDateLike((item as any)[k])) {
+            candidates.push({ arr: cur, dateKey: k });
+            break;
+          }
+        }
+      }
+    } else {
+      for (const v of Object.values(cur)) {
+        if (v && typeof v === 'object') queue.push(v);
+      }
+    }
+  }
+  if (!candidates.length) return null;
+  candidates.sort((a, b) => (b.arr?.length ?? 0) - (a.arr?.length ?? 0));
+  return candidates[0]!;
+}
+
+function parseBaijiahaoAppStatisticV3(json: any): Map<string, { recordDate: Date } & Record<string, any>> {
+  const result = new Map<string, { recordDate: Date } & Record<string, any>>();
+  const found = findArrayWithDateLikeField(json);
+  if (!found) return result;
+  const { arr, dateKey } = found;
+
+  const pickNumber = (obj: any, keys: string[]): number | null => {
+    for (const k of keys) {
+      if (obj?.[k] === undefined || obj?.[k] === null) continue;
+      const n = parseChineseNumberLike(obj[k]);
+      if (typeof n === 'number') return n;
+    }
+    return null;
+  };
+
+  const pickString = (obj: any, keys: string[]): string | null => {
+    for (const k of keys) {
+      if (obj?.[k] === undefined || obj?.[k] === null) continue;
+      const s = String(obj[k]).trim();
+      if (s) return s;
+    }
+    return null;
+  };
+
+  for (const item of arr) {
+    if (!item || typeof item !== 'object') continue;
+    const d = normalizeDateText(item[dateKey]);
+    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)!;
+
+    // 阅读量 → playCount
+    const play = pickNumber(item, ['read_cnt', 'readCount', 'read', 'pv', 'view_cnt', 'viewCount', 'views']);
+    if (typeof play === 'number') (obj as any).playCount = play;
+
+    // 点赞量 → likeCount
+    const like = pickNumber(item, ['like_cnt', 'praise_cnt', 'praise', 'likeCount', 'likes']);
+    if (typeof like === 'number') (obj as any).likeCount = like;
+
+    // 评论量 → commentCount
+    const comment = pickNumber(item, ['comment_cnt', 'commentCount', 'comments']);
+    if (typeof comment === 'number') (obj as any).commentCount = comment;
+
+    // 收藏量 → collectCount
+    const collect = pickNumber(item, ['collect_cnt', 'favorite_cnt', 'fav_cnt', 'collectCount', 'favorites']);
+    if (typeof collect === 'number') (obj as any).collectCount = collect;
+
+    // 分享量 → shareCount
+    const share = pickNumber(item, ['share_cnt', 'shareCount', 'shares']);
+    if (typeof share === 'number') (obj as any).shareCount = share;
+
+    // 点击率 → coverClickRate
+    const clickRateRaw =
+      pickString(item, ['click_rate', 'ctr', 'clickRate']) ??
+      (typeof pickNumber(item, ['click_rate', 'ctr', 'clickRate']) === 'number'
+        ? String(pickNumber(item, ['click_rate', 'ctr', 'clickRate']))
+        : null);
+    const clickRate = formatPercentString(clickRateRaw);
+    if (clickRate) (obj as any).coverClickRate = clickRate;
+
+    // 作品涨粉量 → fansIncrease(只取涨粉)
+    const fansInc = pickNumber(item, ['works_fans_inc', 'worksFansInc', 'content_fans_inc', 'fans_inc', 'fansIncrease']);
+    if (typeof fansInc === 'number') (obj as any).fansIncrease = fansInc;
+  }
+
+  return result;
+}
+
+export class BaijiahaoContentOverviewImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private userDayStatisticsService = new UserDayStatisticsService();
+
+  private downloadDir = path.resolve(process.cwd(), 'tmp', 'baijiahao-content-overview');
+  private stateDir = path.resolve(process.cwd(), 'tmp', 'baijiahao-storage-state');
+
+  private getStatePath(accountId: number) {
+    return path.join(this.stateDir, `${accountId}.json`);
+  }
+
+  private async ensureStorageState(
+    account: PlatformAccount,
+    cookies: PlaywrightCookie[]
+  ): Promise<string | null> {
+    const statePath = this.getStatePath(account.id);
+    try {
+      await fs.access(statePath);
+      return statePath;
+    } catch {
+      // no state
+    }
+
+    // 需要你在弹出的浏览器里完成一次登录/验证,然后脚本会自动保存 storageState
+    // 启用方式:BJ_IMPORT_HEADLESS=0 且 BJ_STORAGE_STATE_BOOTSTRAP=1
+    if (!(process.env.BJ_IMPORT_HEADLESS === '0' && process.env.BJ_STORAGE_STATE_BOOTSTRAP === '1')) {
+      return null;
+    }
+
+    await ensureDir(this.stateDir);
+    logger.warn(
+      `[BJ 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://baijiahao.baidu.com/builder/rc/analysiscontent', {
+        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(`[BJ Import] storageState saved: ${statePath}`);
+      await context.close();
+      return statePath;
+    } finally {
+      if (shouldClose) await browser.close().catch(() => undefined);
+    }
+  }
+
+  /**
+   * 为所有百家号账号导出“数据中心-内容分析-基础数据-近30天”并导入 user_day_statistics
+   */
+  async runDailyImportForAllBaijiahaoAccounts(): Promise<void> {
+    await ensureDir(this.downloadDir);
+
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'baijiahao' as any },
+    });
+
+    logger.info(`[BJ Import] Start. total_accounts=${accounts.length}`);
+
+    for (const account of accounts) {
+      try {
+        await this.importAccountLast30Days(account);
+      } catch (e) {
+        logger.error(
+          `[BJ Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+      }
+    }
+
+    logger.info('[BJ Import] Done.');
+  }
+
+  /**
+   * 单账号:导出 Excel → 解析 → 入库 → 删除文件
+   */
+  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+    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);
+      if (!statePath) {
+        await context.addCookies(cookies as any);
+      }
+
+      const page = await context.newPage();
+      await page.goto('https://baijiahao.baidu.com/builder/rc/analysiscontent', {
+        waitUntil: 'domcontentloaded',
+      });
+      await page.waitForTimeout(1500);
+
+      if (page.url().includes('passport') || page.url().includes('login')) {
+        throw new Error('未登录/需要重新登录(跳转到登录页)');
+      }
+
+      const bodyText = (await page.textContent('body').catch(() => '')) || '';
+      if (bodyText.includes('暂无数据') || bodyText.includes('无权访问')) {
+        await this.accountRepository.update(account.id, { status: 'expired' as any });
+        wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
+          account: { id: account.id, status: 'expired', platform: 'baijiahao' },
+        });
+        wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
+          level: 'warning',
+          message: `百家号账号「${account.accountName || account.accountId || account.id}」暂无数据看板访问权限,请到百家号后台检查数据权限。`,
+          platform: 'baijiahao',
+          accountId: account.id,
+        });
+        throw new Error('百家号数据看板暂无访问权限/暂无数据,已标记 expired 并通知用户');
+      }
+
+      // 统一入口:数据中心 -> 内容分析 -> 基础数据
+      await page.getByText('数据中心', { exact: false }).first().click().catch(() => undefined);
+      await page.getByText('内容分析', { exact: false }).first().click().catch(() => undefined);
+      await page.getByText('基础数据', { exact: false }).first().click().catch(() => undefined);
+
+      // 切换“近30天”(容错:有些账号默认就是近30天,或文案略有差异)
+      try {
+        const trigger = page.getByText(/近\d+天?/, { exact: false }).first();
+        const hasTrigger = await trigger.count();
+        if (hasTrigger > 0) {
+          await trigger.click().catch(() => undefined);
+        }
+
+        const thirtyDay =
+          (await page.getByText('近30天', { exact: true }).first().count()) > 0
+            ? page.getByText('近30天', { exact: true }).first()
+            : page.getByText('近30日', { exact: false }).first();
+
+        await thirtyDay.click().catch(() => undefined);
+
+        // 等页面后端刷新统计数据和日期范围(百家号这里比较慢)
+        await page.waitForTimeout(5000);
+      } catch (e) {
+        logger.warn(
+          `[BJ Import] Unable to explicitly switch to last 30 days, continue with default range. accountId=${account.id}`,
+          e
+        );
+      }
+
+      // 优先抓取接口:/author/eco/statistics/appStatisticV3?type=all&start_day=YYYYMMDD&end_day=YYYYMMDD&stat=0&special_filter_days=30
+      // 这样可以拿到完整 30 天数据(避免导出 Excel 只有 7 天 / GBK 乱码)
+      const end = new Date();
+      end.setHours(0, 0, 0, 0);
+      end.setDate(end.getDate() - 1); // 默认取昨天
+      const start = new Date(end);
+      start.setDate(start.getDate() - 29);
+      const fmt = (d: Date) =>
+        `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`;
+      const start_day = fmt(start);
+      const end_day = fmt(end);
+
+      let perDay = new Map<string, { recordDate: Date } & Record<string, any>>();
+      let inserted = 0;
+      let updated = 0;
+
+      const tryFetchApi = async () => {
+        const apiUrl = `https://baijiahao.baidu.com/author/eco/statistics/appStatisticV3?type=all&start_day=${start_day}&end_day=${end_day}&stat=0&special_filter_days=30`;
+        // 使用 browser context 的 request(带 cookie)
+        const res = await (context as any).request.get(apiUrl, {
+          headers: {
+            Referer: 'https://baijiahao.baidu.com/builder/rc/analysiscontent',
+          },
+        });
+        if (!res.ok()) {
+          throw new Error(`appStatisticV3 http ${res.status()}`);
+        }
+        const json = await res.json().catch(() => null);
+        if (!json) throw new Error('appStatisticV3 json parse failed');
+        const map = parseBaijiahaoAppStatisticV3(json);
+        logger.info(`[BJ Import] appStatisticV3 fetched. accountId=${account.id} days=${map.size} range=${start_day}-${end_day}`);
+        return map;
+      };
+
+      try {
+        perDay = await tryFetchApi();
+      } catch (e) {
+        logger.warn(`[BJ Import] appStatisticV3 fetch failed, fallback to Excel export. accountId=${account.id}`, e);
+      }
+
+      // 兜底:如果接口抓不到,则退回导出 Excel;如果接口抓到但天数偏少,则“合并 Excel 补齐空缺”
+      let filePath: string | null = null;
+      if (perDay.size === 0) {
+        const [download] = await Promise.all([
+          page.waitForEvent('download', { timeout: 60_000 }),
+          page.getByText('导出数据', { exact: true }).first().click(),
+        ]);
+
+        const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
+        filePath = path.join(this.downloadDir, filename);
+        await download.saveAs(filePath);
+        perDay = parseBaijiahaoExcel(filePath);
+      } else if (perDay.size < 20) {
+        const [download] = await Promise.all([
+          page.waitForEvent('download', { timeout: 60_000 }),
+          page.getByText('导出数据', { exact: true }).first().click(),
+        ]);
+
+        const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
+        filePath = path.join(this.downloadDir, filename);
+        await download.saveAs(filePath);
+        const excelMap = parseBaijiahaoExcel(filePath);
+        for (const [k, v] of excelMap.entries()) {
+          if (!perDay.has(k)) perDay.set(k, v);
+        }
+      }
+
+      try {
+        for (const v of perDay.values()) {
+          const { recordDate, ...patch } = v;
+          const r = await this.userDayStatisticsService.saveStatisticsForDate(
+            account.id,
+            recordDate,
+            patch
+          );
+          inserted += r.inserted;
+          updated += r.updated;
+        }
+
+        logger.info(
+          `[BJ Import] basic-data imported. accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
+        );
+      } finally {
+        if (filePath) {
+          if (process.env.KEEP_BJ_XLSX === 'true') {
+            logger.warn(`[BJ Import] KEEP_BJ_XLSX=true, keep file: ${filePath}`);
+          } else {
+            await fs.unlink(filePath).catch(() => undefined);
+          }
+        }
+      }
+
+      await context.close();
+    } finally {
+      if (shouldClose) {
+        await browser.close().catch(() => undefined);
+      }
+    }
+  }
+}
+

+ 558 - 0
server/src/services/WeixinVideoDataCenterImportService.ts

@@ -0,0 +1,558 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { chromium, type Browser } 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 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';
+};
+
+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();
+  if (!s) return null;
+
+  // 2026/1/27 or 2026-01-27
+  const m1 = s.match(/(\d{4})\D(\d{1,2})\D(\d{1,2})/);
+  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;
+  }
+
+  // 20260127
+  const m2 = s.match(/^(\d{4})(\d{2})(\d{2})$/);
+  if (m2) {
+    const yyyy = Number(m2[1]);
+    const mm = Number(m2[2]);
+    const dd = Number(m2[3]);
+    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;
+  const plain = s.replace(/,/g, '');
+  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 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://channels.weixin.qq.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://channels.weixin.qq.com' });
+  }
+  return cookies;
+}
+
+async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ browser: Browser; shouldClose: boolean }> {
+  // 默认 headless;但视频号在 headless 下经常会强制跳登录/风控,
+  // 因此允许通过 WX_IMPORT_HEADLESS=0 强制用有头浏览器跑导入。
+  const headless = process.env.WX_IMPORT_HEADLESS === '0' ? false : true;
+  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 };
+}
+
+type WxSection = '关注者数据' | '视频数据' | '图文数据';
+
+function parseCsvLine(line: string): string[] {
+  // 简单 CSV 解析(处理双引号包裹与转义)
+  const out: string[] = [];
+  let cur = '';
+  let inQuotes = false;
+  for (let i = 0; i < line.length; i++) {
+    const ch = line[i]!;
+    if (ch === '"') {
+      const next = line[i + 1];
+      if (inQuotes && next === '"') {
+        cur += '"';
+        i++;
+      } else {
+        inQuotes = !inQuotes;
+      }
+      continue;
+    }
+    if (ch === ',' && !inQuotes) {
+      out.push(cur);
+      cur = '';
+      continue;
+    }
+    cur += ch;
+  }
+  out.push(cur);
+  return out.map((s) => s.trim());
+}
+
+async function parseWeixinVideoFile(filePath: string): Promise<Map<string, { recordDate: Date } & Record<string, any>>> {
+  const ext = path.extname(filePath).toLowerCase();
+  if (ext === '.csv') {
+    const text = await fs.readFile(filePath, 'utf8');
+    const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter((l) => l.trim().length > 0);
+    const result = new Map<string, { recordDate: Date } & Record<string, any>>();
+
+    logger.info(`[WX Import] CSV loaded. file=${path.basename(filePath)} lines=${lines.length}`);
+
+    // 找表头行(含“时间”或“日期”)
+    const headerLineIdx = lines.findIndex((l) => l.includes('"时间"') || l.includes('"日期"') || l.startsWith('时间,') || l.startsWith('日期,'));
+    if (headerLineIdx < 0) return result;
+    const header = parseCsvLine(lines[headerLineIdx]!).map((c) => c.replace(/^"|"$/g, '').trim());
+    logger.info(`[WX Import] Header detected. headerRow=${headerLineIdx + 1} headers=${header.join('|')}`);
+
+    const colIndex = (names: string[]) => {
+      for (const n of names) {
+        const idx = header.findIndex((h) => h === n);
+        if (idx >= 0) return idx;
+      }
+      for (const n of names) {
+        const idx = header.findIndex((h) => h.includes(n));
+        if (idx >= 0) return idx;
+      }
+      return -1;
+    };
+
+    const dateCol = colIndex(['时间', '日期']);
+    const playCol = colIndex(['播放', '播放量', '曝光量', '阅读/播放量', '阅读量']);
+    const likeCol = colIndex(['喜欢', '点赞', '点赞量']);
+    const commentCol = colIndex(['评论', '评论量']);
+    const shareCol = colIndex(['分享', '分享量']);
+    const fansIncCol = colIndex(['净增关注', '新增关注']);
+    const fansTotalCol = colIndex(['关注者总数', '关注者总量', '粉丝总数', '粉丝总量']);
+
+    for (let i = headerLineIdx + 1; i < lines.length; i++) {
+      const cols = parseCsvLine(lines[i]!).map((c) => c.replace(/^"|"$/g, '').trim());
+      if (dateCol < 0 || cols.length <= dateCol) continue;
+      const d = normalizeDateText(cols[dateCol]);
+      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 (playCol >= 0 && cols.length > playCol) {
+        const n = parseChineseNumberLike(cols[playCol]);
+        if (typeof n === 'number') (obj as any).playCount = n;
+      }
+      if (likeCol >= 0 && cols.length > likeCol) {
+        const n = parseChineseNumberLike(cols[likeCol]);
+        if (typeof n === 'number') (obj as any).likeCount = n;
+      }
+      if (commentCol >= 0 && cols.length > commentCol) {
+        const n = parseChineseNumberLike(cols[commentCol]);
+        if (typeof n === 'number') (obj as any).commentCount = n;
+      }
+      if (shareCol >= 0 && cols.length > shareCol) {
+        const n = parseChineseNumberLike(cols[shareCol]);
+        if (typeof n === 'number') (obj as any).shareCount = n;
+      }
+      if (fansIncCol >= 0 && cols.length > fansIncCol) {
+        const n = parseChineseNumberLike(cols[fansIncCol]);
+        if (typeof n === 'number') (obj as any).fansIncrease = n;
+      }
+      if (fansTotalCol >= 0 && cols.length > fansTotalCol) {
+        const n = parseChineseNumberLike(cols[fansTotalCol]);
+        if (typeof n === 'number') (obj as any).fansCount = n;
+      }
+    }
+
+    return result;
+  }
+
+  // xlsx/xls:走 xlsx 解析
+  const wb = XLSX.readFile(filePath);
+  const result = new Map<string, { recordDate: Date } & Record<string, any>>();
+  logger.info(`[WX Import] Excel loaded. file=${path.basename(filePath)} sheets=${wb.SheetNames.join(' | ')}`);
+
+  for (const sheetName of wb.SheetNames) {
+    const sheet = wb.Sheets[sheetName];
+    const rows: any[][] = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' });
+    if (!rows.length) continue;
+
+    let headerIdx = rows.findIndex(
+      (r) => Array.isArray(r) && r.some((c) => ['时间', '日期'].includes(String(c).trim()))
+    );
+    if (headerIdx < 0) continue;
+    const header = rows[headerIdx].map((c) => String(c).trim());
+    logger.info(`[WX Import] Header detected. sheet=${sheetName} headerRow=${headerIdx + 1} headers=${header.join('|')}`);
+
+    const colIndex = (names: string[]) => {
+      for (const n of names) {
+        const idx = header.findIndex((h) => h === n);
+        if (idx >= 0) return idx;
+      }
+      for (const n of names) {
+        const idx = header.findIndex((h) => h.includes(n));
+        if (idx >= 0) return idx;
+      }
+      return -1;
+    };
+
+    const dateCol = colIndex(['时间', '日期']);
+    if (dateCol < 0) continue;
+    const playCol = colIndex(['播放', '播放量', '曝光量', '阅读/播放量', '阅读量']);
+    const likeCol = colIndex(['喜欢', '点赞', '点赞量']);
+    const commentCol = colIndex(['评论', '评论量']);
+    const shareCol = colIndex(['分享', '分享量']);
+    const fansIncCol = colIndex(['净增关注', '新增关注']);
+    const fansTotalCol = colIndex(['关注者总数', '关注者总量', '粉丝总数', '粉丝总量']);
+
+    for (let i = headerIdx + 1; i < rows.length; i++) {
+      const r = rows[i];
+      if (!r || !Array.isArray(r) || r.length <= dateCol) continue;
+      const d = normalizeDateText(r[dateCol]);
+      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 (playCol >= 0) {
+        const n = parseChineseNumberLike(r[playCol]);
+        if (typeof n === 'number') (obj as any).playCount = n;
+      }
+      if (likeCol >= 0) {
+        const n = parseChineseNumberLike(r[likeCol]);
+        if (typeof n === 'number') (obj as any).likeCount = n;
+      }
+      if (commentCol >= 0) {
+        const n = parseChineseNumberLike(r[commentCol]);
+        if (typeof n === 'number') (obj as any).commentCount = n;
+      }
+      if (shareCol >= 0) {
+        const n = parseChineseNumberLike(r[shareCol]);
+        if (typeof n === 'number') (obj as any).shareCount = n;
+      }
+      if (fansIncCol >= 0) {
+        const n = parseChineseNumberLike(r[fansIncCol]);
+        if (typeof n === 'number') (obj as any).fansIncrease = n;
+      }
+      if (fansTotalCol >= 0) {
+        const n = parseChineseNumberLike(r[fansTotalCol]);
+        if (typeof n === 'number') (obj as any).fansCount = n;
+      }
+    }
+  }
+
+  return result;
+}
+
+export class WeixinVideoDataCenterImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private userDayStatisticsService = new UserDayStatisticsService();
+
+  // 兼容 monorepo 从根目录/从 server 目录启动
+  private baseDir =
+    path.basename(process.cwd()).toLowerCase() === 'server'
+      ? process.cwd()
+      : path.resolve(process.cwd(), 'server');
+
+  private downloadDir = path.resolve(this.baseDir, 'tmp', 'weixin-video-data-center');
+  private stateDir = path.resolve(this.baseDir, 'tmp', 'weixin-video-storage-state');
+
+  private getStatePath(accountId: number) {
+    return path.join(this.stateDir, `${accountId}.json`);
+  }
+
+  private async ensureStorageState(account: PlatformAccount, cookies: PlaywrightCookie[]): Promise<string | null> {
+    const statePath = this.getStatePath(account.id);
+    try {
+      await fs.access(statePath);
+      return statePath;
+    } catch {
+      // no state
+    }
+
+    if (!(process.env.WX_IMPORT_HEADLESS === '0' && process.env.WX_STORAGE_STATE_BOOTSTRAP === '1')) {
+      return null;
+    }
+
+    await ensureDir(this.stateDir);
+    logger.warn(`[WX 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://channels.weixin.qq.com/platform', { waitUntil: 'domcontentloaded' });
+
+      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(`[WX Import] storageState saved: ${statePath}`);
+      await context.close();
+      return statePath;
+    } finally {
+      if (shouldClose) await browser.close().catch(() => undefined);
+    }
+  }
+
+  async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
+    await ensureDir(this.downloadDir);
+    const accounts = await this.accountRepository.find({ where: { platform: 'weixin_video' as any } });
+    logger.info(`[WX Import] Start. total_accounts=${accounts.length}`);
+    for (const account of accounts) {
+      try {
+        await this.importAccountLast30Days(account);
+      } catch (e) {
+        logger.error(`[WX Import] Account failed. accountId=${account.id} name=${account.accountName || ''}`, e);
+      }
+    }
+    logger.info('[WX Import] Done.');
+  }
+
+  async importAccountLast30Days(account: PlatformAccount): Promise<void> {
+    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);
+      logger.info(
+        `[WX Import] Context init. accountId=${account.id} storageState=${statePath ? statePath : 'none'}`
+      );
+      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);
+      if (!statePath) await context.addCookies(cookies as any);
+
+      const page = await context.newPage();
+      await page.goto('https://channels.weixin.qq.com/platform', { waitUntil: 'domcontentloaded' });
+      await page.waitForTimeout(1500);
+
+      if (page.url().includes('login') || page.url().includes('passport')) {
+        throw new Error('未登录/需要重新登录(跳转到登录页)');
+      }
+
+      // 进入 数据中心
+      await page.getByText('数据中心', { exact: false }).first().click();
+      await page.waitForTimeout(800);
+
+      // 目前只需要关注者数据 + 视频数据,图文数据暂不采集
+      const sections: WxSection[] = ['关注者数据', '视频数据'];
+      let mergedDays = new Map<string, { recordDate: Date } & Record<string, any>>();
+
+      const tryClick = async (texts: string[]) => {
+        for (const t of texts) {
+          const loc = page.getByText(t, { exact: true }).first();
+          if ((await loc.count().catch(() => 0)) > 0) {
+            await loc.click().catch(() => undefined);
+            return true;
+          }
+        }
+        for (const t of texts) {
+          const loc = page.getByText(t, { exact: false }).first();
+          if ((await loc.count().catch(() => 0)) > 0) {
+            await loc.click().catch(() => undefined);
+            return true;
+          }
+        }
+        return false;
+      };
+
+      const exportSection = async (section: WxSection) => {
+        const ok = await tryClick([section]);
+        if (!ok) {
+          logger.warn(`[WX Import] Section not found, skip. accountId=${account.id} section=${section}`);
+          return;
+        }
+        await page.waitForTimeout(1200);
+
+        // 进入 增长详情/数据详情(页面上可能显示“增长详情”或“数据详情”)
+        await tryClick(['增长详情', '数据详情']);
+        await page.waitForTimeout(800);
+
+        // 日期范围:点击「近30天」
+        try {
+          if (section === '关注者数据') {
+            const loc = page.locator(
+              '#container-wrap > div.container-center > div > div > div.follower-growth-wrap > div:nth-child(4) > div > div > div.card-body > div.filter-wrap > div > div.filter-content > div > div > div.weui-desktop-radio-group.radio-group > label:nth-child(2)'
+            );
+            if ((await loc.count().catch(() => 0)) > 0) {
+              await loc.click().catch(() => undefined);
+            } else {
+              await tryClick(['近30天', '近30日', '近30']);
+            }
+          } else if (section === '视频数据') {
+            const loc = page.locator(
+              '#container-wrap > div.container-center > div > div > div > div.post-total-wrap > div.post-statistic-common > div:nth-child(3) > div > div > div.card-body > div.filter-wrap > div:nth-child(2) > div.filter-content > div > div > div.weui-desktop-radio-group.radio-group > label:nth-child(2)'
+            );
+            if ((await loc.count().catch(() => 0)) > 0) {
+              await loc.click().catch(() => undefined);
+            } else {
+              await tryClick(['近30天', '近30日', '近30']);
+            }
+          } else {
+            await tryClick(['近30天', '近30日', '近30']);
+          }
+        } catch {
+          await tryClick(['近30天', '近30日', '近30']);
+        }
+        await page.waitForTimeout(4000);
+
+        // 下载表格
+        const [download] = await Promise.all([
+          page.waitForEvent('download', { timeout: 60_000 }),
+          tryClick(['下载表格', '下载', '导出数据']),
+        ]);
+
+        const filename = `${account.id}_${Date.now()}_${download.suggestedFilename()}`;
+        const filePath = path.join(this.downloadDir, filename);
+        await download.saveAs(filePath);
+
+        try {
+          const perDay = await parseWeixinVideoFile(filePath);
+          for (const [k, v] of perDay.entries()) {
+            if (!mergedDays.has(k)) mergedDays.set(k, { recordDate: v.recordDate });
+            Object.assign(mergedDays.get(k)!, v);
+          }
+          logger.info(`[WX Import] Section parsed. accountId=${account.id} section=${section} days=${perDay.size}`);
+        } finally {
+          if (process.env.KEEP_WX_XLSX === 'true') {
+            logger.warn(`[WX Import] KEEP_WX_XLSX=true, keep file: ${filePath}`);
+          } else {
+            await fs.unlink(filePath).catch(() => undefined);
+          }
+        }
+      };
+
+      for (const s of sections) {
+        await exportSection(s);
+      }
+
+      let inserted = 0;
+      let updated = 0;
+      for (const v of mergedDays.values()) {
+        const { recordDate, ...patch } = v;
+        const r = await this.userDayStatisticsService.saveStatisticsForDate(account.id, recordDate, patch);
+        inserted += r.inserted;
+        updated += r.updated;
+      }
+
+      logger.info(`[WX Import] Account imported. accountId=${account.id} days=${mergedDays.size} inserted=${inserted} updated=${updated}`);
+
+      await context.close();
+    } finally {
+      if (shouldClose) await browser.close().catch(() => undefined);
+    }
+  }
+}
+

+ 71 - 0
server/tmp/weixin-video-storage-state/41.json

@@ -0,0 +1,71 @@
+{
+  "cookies": [
+    {
+      "name": "sessionid",
+      "value": "BgAALYWWMysMevRNf12PJx4CWniwBTsUKO6god9LKuaHrgSW3ncAEWImV3zu5YcJZXbwVa3T58pJVffU2j97y3bB3ftxvLBN1OFpuawvKKrB",
+      "domain": "channels.weixin.qq.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    },
+    {
+      "name": "wxuin",
+      "value": "3445722064",
+      "domain": "channels.weixin.qq.com",
+      "path": "/",
+      "expires": -1,
+      "httpOnly": false,
+      "secure": true,
+      "sameSite": "Lax"
+    }
+  ],
+  "origins": [
+    {
+      "origin": "https://channels.weixin.qq.com",
+      "localStorage": [
+        {
+          "name": "__ml::page_51d8b24c-eecb-4a87-9ff4-4e702cc7b7ef",
+          "value": "{\"pageId\":\"LoginForIframe\",\"accessId\":\"147efbd3-0310-41c2-869a-5c69b19d917f\",\"step\":1}"
+        },
+        {
+          "name": "__ml::hb_ts",
+          "value": "1769652088196"
+        },
+        {
+          "name": "_finger_print_device_id",
+          "value": "02e55ca7206f752ee9220947d0b714ad"
+        },
+        {
+          "name": "__ml::aid",
+          "value": "\"0a3af824-fe47-43ed-b2f2-b5b0d3530f3d\""
+        },
+        {
+          "name": "__rx::aid",
+          "value": "\"0a3af824-fe47-43ed-b2f2-b5b0d3530f3d\""
+        },
+        {
+          "name": "UvFirstReportLocalKey",
+          "value": "1769616000000"
+        },
+        {
+          "name": "__ml::page",
+          "value": "[\"51d8b24c-eecb-4a87-9ff4-4e702cc7b7ef\"]"
+        },
+        {
+          "name": "FINDER_HELPER_REDIRECT_PATH",
+          "value": "/platform"
+        },
+        {
+          "name": "finder_ua_report_data",
+          "value": "{\"browser\":\"Chrome\",\"browserVersion\":\"143.0.0.0\",\"engine\":\"Webkit\",\"engineVersion\":\"537.36\",\"os\":\"Windows\",\"osVersion\":\"10\",\"device\":\"desktop\",\"darkmode\":0}"
+        },
+        {
+          "name": "finder_route_meta",
+          "value": "platform.login-for-iframe;index;2;1769652089694"
+        }
+      ]
+    }
+  ]
+}