Переглянути джерело

百家号每日用户数据

Ethanfly 2 днів тому
батько
коміт
6d7f01bd03

+ 0 - 8
client/src/components.d.ts

@@ -15,15 +15,10 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
-    ElCascader: typeof import('element-plus/es')['ElCascader']
     ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
-    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
-    ElCol: typeof import('element-plus/es')['ElCol']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
-    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
-    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -43,7 +38,6 @@ declare module 'vue' {
     ElProgress: typeof import('element-plus/es')['ElProgress']
     ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
     ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
-    ElRow: typeof import('element-plus/es')['ElRow']
     ElSelect: typeof import('element-plus/es')['ElSelect']
     ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
     ElSwitch: typeof import('element-plus/es')['ElSwitch']
@@ -52,8 +46,6 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
-    ElText: typeof import('element-plus/es')['ElText']
-    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

+ 58 - 0
server/python/app.py

@@ -1678,6 +1678,64 @@ def baijiahao_trend_data():
         return jsonify({"success": False, "error": str(e)}), 500
 
 
+@app.route("/baijiahao/app_statistic_v3", methods=["POST"])
+def baijiahao_app_statistic_v3():
+    """
+    百家号用户每日数据:代理调用 appStatisticV3(账号近30天基础数据)。
+    登录模式与打开后台一致:使用账号已存 Cookie。
+    请求体: { "cookie": "...", "start_day": "YYYYMMDD", "end_day": "YYYYMMDD" }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        start_day = data.get("start_day", "")
+        end_day = data.get("end_day", "")
+
+        if not cookie_str:
+            return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
+        if not start_day or not end_day:
+            return jsonify({"success": False, "error": "缺少 start_day 或 end_day 参数"}), 400
+
+        PublisherClass = get_publisher("baijiahao")
+        publisher = PublisherClass(headless=HEADLESS_MODE)
+        result = asyncio.run(
+            publisher.get_app_statistic_v3(cookie_str, start_day=start_day, end_day=end_day)
+        )
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e), "errno": -1}), 500
+
+
+@app.route("/baijiahao/fans_basic_info", methods=["POST"])
+def baijiahao_fans_basic_info():
+    """
+    百家号用户每日数据:代理调用 getFansBasicInfo(近30天粉丝数据)。
+    登录模式与打开后台一致:使用账号已存 Cookie。
+    请求体: { "cookie": "...", "start": "YYYYMMDD", "end": "YYYYMMDD" }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        start_str = data.get("start", "")
+        end_str = data.get("end", "")
+
+        if not cookie_str:
+            return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
+        if not start_str or not end_str:
+            return jsonify({"success": False, "error": "缺少 start 或 end 参数"}), 400
+
+        PublisherClass = get_publisher("baijiahao")
+        publisher = PublisherClass(headless=HEADLESS_MODE)
+        result = asyncio.run(
+            publisher.get_fans_basic_info(cookie_str, start=start_str, end=end_str)
+        )
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e), "errno": -1}), 500
+
+
 # ==================== 健康检查 ====================
 
 @app.route("/health", methods=["GET"])

+ 134 - 1
server/python/platforms/baijiahao.py

@@ -3168,7 +3168,140 @@ class BaijiahaoPublisher(BasePublisher):
             "errmsg": errmsg,
             "data": data.get('data') if isinstance(data, dict) else None,
         }
-    
+
+    async def get_app_statistic_v3(
+        self,
+        cookies: str,
+        start_day: str,
+        end_day: str,
+    ) -> dict:
+        """
+        调用百家号 appStatisticV3(账号维度近30天基础数据),用于用户每日数据同步。
+        登录模式与打开后台一致:使用账号已存 Cookie,不启浏览器。
+        """
+        import aiohttp
+
+        print(f"[{self.platform_name}] get_app_statistic_v3: {start_day}-{end_day}")
+
+        cookie_list = self.parse_cookies(cookies)
+        cookie_dict = {c['name']: c['value'] for c in cookie_list}
+
+        session_headers = {
+            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+            'Accept-Encoding': 'gzip, deflate, br',
+            'Connection': 'keep-alive',
+        }
+        headers = {
+            'Accept': 'application/json, text/plain, */*',
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+            'Referer': 'https://baijiahao.baidu.com/builder/rc/analysiscontent',
+            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+            'Accept-Encoding': 'gzip, deflate, br',
+            'Connection': 'keep-alive',
+        }
+
+        async with aiohttp.ClientSession(cookies=cookie_dict) as session:
+            # warmup:与打开后台一致,先访问后台页面建立会话
+            try:
+                await session.get(
+                    'https://baijiahao.baidu.com/builder/rc/analysiscontent',
+                    headers=session_headers,
+                    timeout=aiohttp.ClientTimeout(total=20),
+                )
+            except Exception as e:
+                print(f"[{self.platform_name}] warmup analysiscontent failed (non-fatal): {e}")
+
+            api_url = (
+                "https://baijiahao.baidu.com/author/eco/statistics/appStatisticV3"
+                f"?type=all&start_day={start_day}&end_day={end_day}&stat=0&special_filter_days=30"
+            )
+            async with session.get(
+                api_url,
+                headers=headers,
+                timeout=aiohttp.ClientTimeout(total=30),
+            ) as resp:
+                status = resp.status
+                try:
+                    data = await resp.json()
+                except Exception:
+                    text = await resp.text()
+                    print(f"[{self.platform_name}] appStatisticV3 non-JSON: {text[:1000]}")
+                    raise
+
+        errno = data.get('errno') if isinstance(data, dict) else None
+        errmsg = data.get('errmsg') if isinstance(data, dict) else None
+        print(f"[{self.platform_name}] appStatisticV3: http={status}, errno={errno}, msg={errmsg}")
+
+        return data if isinstance(data, dict) else {"errno": -1, "errmsg": "invalid response", "data": None}
+
+    async def get_fans_basic_info(
+        self,
+        cookies: str,
+        start: str,
+        end: str,
+    ) -> dict:
+        """
+        调用百家号 getFansBasicInfo(近30天粉丝数据),用于用户每日数据同步。
+        登录模式与打开后台一致:使用账号已存 Cookie。
+        """
+        import aiohttp
+
+        print(f"[{self.platform_name}] get_fans_basic_info: {start}-{end}")
+
+        cookie_list = self.parse_cookies(cookies)
+        cookie_dict = {c['name']: c['value'] for c in cookie_list}
+
+        session_headers = {
+            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+            'Accept-Encoding': 'gzip, deflate, br',
+            'Connection': 'keep-alive',
+        }
+        headers = {
+            'Accept': 'application/json, text/plain, */*',
+            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+            'Referer': 'https://baijiahao.baidu.com/builder/rc/analysisfans/basedata',
+            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
+            'Accept-Encoding': 'gzip, deflate, br',
+            'Connection': 'keep-alive',
+        }
+
+        async with aiohttp.ClientSession(cookies=cookie_dict) as session:
+            try:
+                await session.get(
+                    'https://baijiahao.baidu.com/builder/rc/analysisfans/basedata',
+                    headers=session_headers,
+                    timeout=aiohttp.ClientTimeout(total=20),
+                )
+            except Exception as e:
+                print(f"[{self.platform_name}] warmup analysisfans/basedata failed (non-fatal): {e}")
+
+            api_url = (
+                "https://baijiahao.baidu.com/author/eco/statistics/getFansBasicInfo"
+                f"?start={start}&end={end}&fans_type=new%2Csum&sort=asc&is_page=0&show_type=chart"
+            )
+            async with session.get(
+                api_url,
+                headers=headers,
+                timeout=aiohttp.ClientTimeout(total=30),
+            ) as resp:
+                status = resp.status
+                try:
+                    data = await resp.json()
+                except Exception:
+                    text = await resp.text()
+                    print(f"[{self.platform_name}] getFansBasicInfo non-JSON: {text[:1000]}")
+                    raise
+
+        errno = data.get('errno') if isinstance(data, dict) else None
+        errmsg = data.get('errmsg') if isinstance(data, dict) else None
+        print(f"[{self.platform_name}] getFansBasicInfo: http={status}, errno={errno}, msg={errmsg}")
+
+        return data if isinstance(data, dict) else {"errno": -1, "errmsg": "invalid response", "data": None}
+
     async def check_login_status(self, cookies: str) -> dict:
         """
         检查百家号 Cookie 登录状态

+ 190 - 108
server/src/services/BaijiahaoContentOverviewImportService.ts

@@ -7,6 +7,7 @@ import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { UserDayStatisticsService } from './UserDayStatisticsService.js';
 import { AccountService } from './AccountService.js';
+import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
 import type { ProxyConfig } from '@media-manager/shared';
 import { WS_EVENTS } from '@media-manager/shared';
 import { wsManager } from '../websocket/index.js';
@@ -458,6 +459,74 @@ export class BaijiahaoContentOverviewImportService {
   }
 
   /**
+   * 通过 Python 调用 appStatisticV3(登录模式与打开后台一致:使用账号已存 Cookie)
+   */
+  private async fetchAppStatisticV3ViaPython(
+    account: PlatformAccount,
+    startDay: string,
+    endDay: string
+  ): Promise<Record<string, unknown>> {
+    const base = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
+    const url = `${base}/baijiahao/app_statistic_v3`;
+    const cookie = String(account.cookieData || '').trim();
+    if (!cookie) throw new Error('百家号账号 cookie 为空,无法调用 Python app_statistic_v3');
+
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30_000);
+    try {
+      const res = await fetch(url, {
+        method: 'POST',
+        signal: controller.signal,
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ cookie, start_day: startDay, end_day: endDay }),
+      });
+      const text = await res.text();
+      const data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
+      if (!res.ok) {
+        const msg = String(data?.errmsg || data?.error || '').trim() || `HTTP ${res.status}`;
+        throw new Error(`Python app_statistic_v3 调用失败: ${msg}`);
+      }
+      return data;
+    } finally {
+      clearTimeout(timeoutId);
+    }
+  }
+
+  /**
+   * 通过 Python 调用 getFansBasicInfo(登录模式与打开后台一致)
+   */
+  private async fetchFansBasicInfoViaPython(
+    account: PlatformAccount,
+    start: string,
+    end: string
+  ): Promise<Record<string, unknown>> {
+    const base = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
+    const url = `${base}/baijiahao/fans_basic_info`;
+    const cookie = String(account.cookieData || '').trim();
+    if (!cookie) throw new Error('百家号账号 cookie 为空,无法调用 Python fans_basic_info');
+
+    const controller = new AbortController();
+    const timeoutId = setTimeout(() => controller.abort(), 30_000);
+    try {
+      const res = await fetch(url, {
+        method: 'POST',
+        signal: controller.signal,
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ cookie, start, end }),
+      });
+      const text = await res.text();
+      const data = text ? (JSON.parse(text) as Record<string, unknown>) : {};
+      if (!res.ok) {
+        const msg = String(data?.errmsg || data?.error || '').trim() || `HTTP ${res.status}`;
+        throw new Error(`Python fans_basic_info 调用失败: ${msg}`);
+      }
+      return data;
+    } finally {
+      clearTimeout(timeoutId);
+    }
+  }
+
+  /**
    * 统一入口:定时任务与添加账号均调用此方法,执行“内容分析-基础数据-近30天 + 粉丝 getFansBasicInfo”
    */
   static async runDailyImport(): Promise<void> {
@@ -492,14 +561,114 @@ export class BaijiahaoContentOverviewImportService {
   }
 
   /**
-   * 单账号:导出 Excel → 解析 → 入库 → 删除文件
+   * 单账号:优先 Python+Node(登录与打开后台一致,使用账号已存 Cookie);失败则刷新重试一次,再失败则浏览器兜底
    */
   async importAccountLast30Days(account: PlatformAccount, isRetry = false): Promise<void> {
     const cookies = parseCookiesFromAccount(account.cookieData);
-    if (!cookies.length) {
-      throw new Error('cookieData 为空或无法解析');
+    if (!cookies.length) throw new Error('cookieData 为空或无法解析');
+
+    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);
+
+    const chinaTz = 'Asia/Shanghai';
+    const toChinaYMD = (date: Date): { y: number; m: number; d: number } => {
+      const formatter = new Intl.DateTimeFormat('en-CA', {
+        timeZone: chinaTz,
+        year: 'numeric',
+        month: '2-digit',
+        day: '2-digit',
+      });
+      const parts = formatter.formatToParts(date);
+      const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+      return {
+        y: parseInt(get('year'), 10),
+        m: parseInt(get('month'), 10),
+        d: parseInt(get('day'), 10),
+      };
+    };
+    const now = new Date();
+    const today = toChinaYMD(now);
+    const yesterdayDate = new Date(
+      Date.UTC(today.y, today.m - 1, today.d, 0, 0, 0, 0) - 24 * 60 * 60 * 1000
+    );
+    const startDate = new Date(yesterdayDate.getTime() - 29 * 24 * 60 * 60 * 1000);
+    const endYMD = toChinaYMD(yesterdayDate);
+    const startYMD = toChinaYMD(startDate);
+    const pad = (n: number) => String(n).padStart(2, '0');
+    const startStr = `${startYMD.y}${pad(startYMD.m)}${pad(startYMD.d)}`;
+    const endStr = `${endYMD.y}${pad(endYMD.m)}${pad(endYMD.d)}`;
+
+    // 优先 Python(登录与打开后台一致:仅用账号已存 Cookie,不启浏览器)
+    try {
+      const data = await this.fetchAppStatisticV3ViaPython(account, start_day, end_day);
+      const errno = typeof data?.errno === 'number' ? data.errno : Number(data?.errno ?? -1);
+      if (errno !== 0) throw new Error(data?.errmsg ? String(data.errmsg) : 'appStatisticV3 errno !== 0');
+      const perDay = parseBaijiahaoAppStatisticV3(data);
+      if (perDay.size === 0) throw new Error('appStatisticV3 解析后无数据');
+
+      let inserted = 0;
+      let updated = 0;
+      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 (via Python). accountId=${account.id} days=${perDay.size} inserted=${inserted} updated=${updated}`
+      );
+
+      try {
+        const fansBody = await this.fetchFansBasicInfoViaPython(account, startStr, endStr);
+        const fansErrno = (fansBody as any).errno;
+        if (fansErrno === 0 || fansErrno === undefined) {
+          const list = this.parseGetFansBasicInfoResponse(fansBody as Record<string, unknown>);
+          let fansUpdated = 0;
+          for (const { recordDate, fansCount, fansIncrease } of list) {
+            const r = await this.userDayStatisticsService.saveStatisticsForDate(
+              account.id,
+              recordDate,
+              { fansCount, fansIncrease }
+            );
+            fansUpdated += r.inserted + r.updated;
+          }
+          logger.info(`[BJ Import] Fans data (via Python). accountId=${account.id} days=${list.length} updated=${fansUpdated}`);
+        }
+      } catch (e) {
+        logger.warn(`[BJ Import] Fans via Python failed (non-fatal). accountId=${account.id}`, e instanceof Error ? e.message : e);
+      }
+      return;
+    } catch (pythonError) {
+      logger.warn(
+        `[BJ Import] Python path failed, fallback to browser. accountId=${account.id}`,
+        pythonError instanceof Error ? pythonError.message : pythonError
+      );
+    }
+
+    if (!isRetry) {
+      try {
+        const accountService = new AccountService();
+        const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+        if (!refreshResult.needReLogin) {
+          const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
+          if (refreshedAccount) {
+            logger.info(`[BJ Import] Account ${account.id} refreshed, retrying import...`);
+            return await this.importAccountLast30Days(refreshedAccount, true);
+          }
+        }
+      } catch (refreshError) {
+        logger.error(`[BJ Import] Account ${account.id} refresh failed:`, refreshError);
+      }
     }
 
+    // 浏览器兜底:原有逻辑不变
     const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
     try {
       const statePath = await this.ensureStorageState(account, cookies);
@@ -513,50 +682,33 @@ export class BaijiahaoContentOverviewImportService {
         ...(statePath ? { storageState: statePath } : {}),
       });
       context.setDefaultTimeout(60_000);
-      if (!statePath) {
-        await context.addCookies(cookies as any);
-      }
+      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.goto('https://baijiahao.baidu.com/builder/rc/analysiscontent', { waitUntil: 'domcontentloaded' });
       await page.waitForTimeout(1500);
 
       if (page.url().includes('passport') || page.url().includes('login')) {
-        // 第一次检测到登录失效时,尝试刷新账号
         if (!isRetry) {
-          logger.info(`[BJ Import] Login expired detected for account ${account.id}, attempting to refresh...`);
+          logger.info(`[BJ Import] Login expired for account ${account.id}, attempting 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(`[BJ Import] Account ${account.id} refresh failed, still needs re-login`);
               throw new Error('未登录/需要重新登录(跳转到登录页)');
             }
-            
-            // 刷新成功,重新获取账号信息并重试导入
-            logger.info(`[BJ Import] Account ${account.id} refreshed successfully, retrying import...`);
             const refreshedAccount = await this.accountRepository.findOne({ where: { id: account.id } });
-            if (!refreshedAccount) {
-              throw new Error('账号刷新后未找到');
-            }
-            
-            // 递归调用,标记为重试
+            if (!refreshedAccount) throw new Error('账号刷新后未找到');
             return await this.importAccountLast30Days(refreshedAccount, true);
           } catch (refreshError) {
             logger.error(`[BJ Import] Account ${account.id} refresh failed:`, refreshError);
             throw new Error('未登录/需要重新登录(跳转到登录页)');
           }
-        } else {
-          // 已经是重试了,不再尝试刷新
-          throw new Error('未登录/需要重新登录(跳转到登录页)');
         }
+        throw new Error('未登录/需要重新登录(跳转到登录页)');
       }
 
       const bodyText = (await page.textContent('body').catch(() => '')) || '';
@@ -574,103 +726,54 @@ export class BaijiahaoContentOverviewImportService {
         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);
-        }
-
+        if ((await trigger.count()) > 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
-        );
+        logger.warn(`[BJ Import] Unable to switch to 近30天. 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',
-          },
+          headers: { Referer: 'https://baijiahao.baidu.com/builder/rc/analysiscontent' },
         });
-        if (!res.ok()) {
-          throw new Error(`appStatisticV3 http ${res.status()}`);
-        }
+        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');
-
-        // 调试:BJ_IMPORT_DEBUG=1 时把接口原始返回写入文件,便于对比
         if (process.env.BJ_IMPORT_DEBUG === '1') {
           const debugPath = path.join(this.downloadDir, `appStatisticV3_response_${account.id}_${Date.now()}.json`);
           await ensureDir(this.downloadDir);
           await fs.writeFile(debugPath, JSON.stringify(json, null, 2), 'utf-8');
           logger.info(`[BJ Import] DEBUG: appStatisticV3 原始响应已写入 ${debugPath}`);
         }
-
-        const map = parseBaijiahaoAppStatisticV3(json);
-        logger.info(`[BJ Import] appStatisticV3 fetched. accountId=${account.id} days=${map.size} range=${start_day}-${end_day}`);
-
-        // 调试:打印解析后指定日期的数据(如 2026-02-02)便于对比
-        if (process.env.BJ_IMPORT_DEBUG === '1' && map.size > 0) {
-          const sampleKeys = ['2026-02-02', '2026-02-01', '2026-01-16'];
-          for (const k of sampleKeys) {
-            const v = map.get(k);
-            if (v) logger.info(`[BJ Import] DEBUG: 解析后 ${k} => ${JSON.stringify(v)}`);
-          }
-        }
-        return map;
+        return parseBaijiahaoAppStatisticV3(json);
       };
-
       try {
         perDay = await tryFetchApi();
       } catch (e) {
-        logger.warn(`[BJ Import] appStatisticV3 fetch failed, fallback to Excel export. accountId=${account.id}`, e);
+        logger.warn(`[BJ Import] appStatisticV3 failed, fallback to Excel. 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);
+        filePath = path.join(this.downloadDir, `${account.id}_${Date.now()}_${download.suggestedFilename()}`);
         await download.saveAs(filePath);
         perDay = parseBaijiahaoExcel(filePath);
       } else if (perDay.size < 20) {
@@ -678,9 +781,7 @@ export class BaijiahaoContentOverviewImportService {
           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);
+        filePath = path.join(this.downloadDir, `${account.id}_${Date.now()}_${download.suggestedFilename()}`);
         await download.saveAs(filePath);
         const excelMap = parseBaijiahaoExcel(filePath);
         for (const [k, v] of excelMap.entries()) {
@@ -691,43 +792,24 @@ export class BaijiahaoContentOverviewImportService {
       try {
         for (const v of perDay.values()) {
           const { recordDate, ...patch } = v;
-          const r = await this.userDayStatisticsService.saveStatisticsForDate(
-            account.id,
-            recordDate,
-            patch
-          );
+          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}`
-        );
+        logger.info(`[BJ Import] basic-data (browser). 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);
-          }
-        }
+        if (filePath && process.env.KEEP_BJ_XLSX !== 'true') await fs.unlink(filePath).catch(() => undefined);
       }
 
-      // 粉丝数据:直接请求 getFansBasicInfo(近30天:昨天为结束,往前推30天),不打开页面、不等待点击
       try {
         await this.importFansDataByApi(context, account);
       } catch (e) {
-        logger.warn(
-          `[BJ Import] Fans data import failed (non-fatal). accountId=${account.id}`,
-          e instanceof Error ? e.message : e
-        );
+        logger.warn(`[BJ Import] Fans import failed (non-fatal). accountId=${account.id}`, e instanceof Error ? e.message : e);
       }
 
       await context.close();
     } finally {
-      if (shouldClose) {
-        await browser.close().catch(() => undefined);
-      }
+      if (shouldClose) await browser.close().catch(() => undefined);
     }
   }
 

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

@@ -144,6 +144,7 @@ export class UserDayStatisticsService {
         avgWatchDuration: patch.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
         totalWatchDuration: patch.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
         completionRate: patch.completionRate ?? existing.completionRate ?? '0',
+        updatedAt: new Date(),
       });
       return { inserted: 0, updated: 1 };
     }