Browse Source

微信号作品同步改造

Ethanfly 1 day ago
parent
commit
7b2dc0e778

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

@@ -15,8 +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']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
+    ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']

+ 13 - 59
server/src/routes/workDayStatistics.ts

@@ -7,10 +7,9 @@ import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
 import { validateRequest } from '../middleware/validate.js';
 import { WorkDayStatisticsService } from '../services/WorkDayStatisticsService.js';
-import { getPythonServiceBaseUrl } from '../services/PythonServiceConfigService.js';
+import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
 import { AppDataSource, Work, PlatformAccount } from '../models/index.js';
 import { logger } from '../utils/logger.js';
-import { CookieManager } from '../automation/cookie.js';
 
 /**
  * Work day statistics(原 Python 统计接口的 Node 版本)
@@ -540,7 +539,7 @@ router.get(
 
 /**
  * POST /api/work-day-statistics/sync-weixin-video/:workId
- * 同步视频号作品的每日数据(浏览器自动化 + CSV 导入
+ * 同步视频号作品的每日数据(Node 端 Playwright 浏览器自动化,不依赖 Python
  */
 router.post(
   '/sync-weixin-video/:workId',
@@ -568,68 +567,23 @@ router.post(
       return res.status(400).json({ success: false, message: '账号未配置 Cookie' });
     }
 
-    let cookieStr: string;
     try {
-      cookieStr = CookieManager.decrypt(account.cookieData);
-    } catch {
-      cookieStr = account.cookieData;
-    }
-
-    let cookieForPython: string;
-    try {
-      JSON.parse(cookieStr);
-      cookieForPython = cookieStr;
-    } catch {
-      cookieForPython = JSON.stringify(
-        cookieStr.split(';').filter(Boolean).map((s) => {
-          const idx = s.trim().indexOf('=');
-          const name = idx >= 0 ? s.trim().slice(0, idx) : s.trim();
-          const value = idx >= 0 ? s.trim().slice(idx + 1) : '';
-          return { name, value, domain: '.weixin.qq.com', path: '/' };
-        })
+      const result = await WeixinVideoWorkStatisticsImportService.runDailyImportForWork(
+        workId,
+        true // 显示浏览器窗口,便于观察
       );
-    }
-
-    const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
-    const pyRes = await fetch(`${pythonUrl}/sync_weixin_work_daily_stats`, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({
-        work_id: workId,
-        platform_video_id: work.platformVideoId,
-        cookie: cookieForPython,
-        show_browser: true, // 显示浏览器窗口,便于观察点击操作
-      }),
-      signal: AbortSignal.timeout(120000),
-    });
-
-    const data = (await pyRes.json().catch(() => ({}))) as {
-      success?: boolean;
-      error?: string;
-      message?: string;
-      inserted?: number;
-      updated?: number;
-    };
-
-    if (!pyRes.ok) {
-      return res.status(500).json({
-        success: false,
-        message: data.error || 'Python 服务请求失败',
-      });
-    }
-
-    if (!data.success) {
       return res.json({
+        success: true,
+        message: `同步成功: 新增 ${result.inserted} 条, 更新 ${result.updated} 条`,
+        data: { inserted: result.inserted, updated: result.updated },
+      });
+    } catch (err) {
+      logger.error('[WorkDayStatistics] sync-weixin-video failed', err);
+      return res.status(500).json({
         success: false,
-        message: data.error || '同步失败',
+        message: err instanceof Error ? err.message : '同步失败',
       });
     }
-
-    return res.json({
-      success: true,
-      message: data.message || '同步成功',
-      data: { inserted: data.inserted ?? 0, updated: data.updated ?? 0 },
-    });
   })
 );
 

+ 12 - 72
server/src/scripts/test-weixin-work-daily-sync.ts

@@ -1,89 +1,29 @@
 #!/usr/bin/env tsx
 /**
- * 测试视频号作品每日数据同步(work_id=906
+ * 测试视频号作品每日数据同步(不依赖 Python,Node 端 Playwright
  * 用法: cd server && pnpm exec tsx src/scripts/test-weixin-work-daily-sync.ts [workId]
  */
-import { initDatabase, AppDataSource, Work, PlatformAccount } from '../models/index.js';
+import { initDatabase } from '../models/index.js';
 import { logger } from '../utils/logger.js';
-import { CookieManager } from '../automation/cookie.js';
-
-const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || 'http://localhost:5005';
+import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
 
 async function main() {
   const workId = Number(process.argv[2] || 906);
   await initDatabase();
 
-  const workRepo = AppDataSource.getRepository(Work);
-  const work = await workRepo.findOne({
-    where: { id: workId },
-    relations: ['account'],
-  });
-
-  if (!work) {
-    logger.error(`作品 ${workId} 不存在`);
-    process.exit(1);
-  }
-  if (work.platform !== 'weixin_video') {
-    logger.error(`作品 ${workId} 不是视频号,platform=${work.platform}`);
-    process.exit(1);
-  }
-
-  const account = work.account as PlatformAccount;
-  if (!account.cookieData) {
-    logger.error('账号未配置 Cookie');
-    process.exit(1);
-  }
-
-  let cookieStr: string;
-  try {
-    cookieStr = CookieManager.decrypt(account.cookieData);
-  } catch {
-    cookieStr = account.cookieData;
-  }
+  logger.info(`测试同步 work_id=${workId}(Node Playwright,showBrowser=true)`);
 
-  let cookieForPython: string;
   try {
-    JSON.parse(cookieStr);
-    cookieForPython = cookieStr;
-  } catch {
-    cookieForPython = JSON.stringify(
-      cookieStr
-        .split(';')
-        .filter(Boolean)
-        .map((s) => {
-          const idx = s.trim().indexOf('=');
-          const name = idx >= 0 ? s.trim().slice(0, idx) : s.trim();
-          const value = idx >= 0 ? s.trim().slice(idx + 1) : '';
-          return { name, value, domain: '.weixin.qq.com', path: '/' };
-        })
+    const result = await WeixinVideoWorkStatisticsImportService.runDailyImportForWork(
+      workId,
+      true
     );
+    logger.info(`成功: inserted=${result.inserted}, updated=${result.updated}`);
+    process.exit(0);
+  } catch (e) {
+    logger.error('失败:', e);
+    process.exit(1);
   }
-
-  logger.info(`测试同步 work_id=${workId}, platform_video_id=${work.platformVideoId}`);
-  logger.info(`调用 Python: ${PYTHON_SERVICE_URL}/sync_weixin_work_daily_stats`);
-
-  const res = await fetch(`${PYTHON_SERVICE_URL}/sync_weixin_work_daily_stats`, {
-    method: 'POST',
-    headers: { 'Content-Type': 'application/json' },
-    body: JSON.stringify({
-      work_id: workId,
-      platform_video_id: work.platformVideoId,
-      cookie: cookieForPython,
-      show_browser: true,
-    }),
-    signal: AbortSignal.timeout(120000),
-  });
-
-  const data = (await res.json().catch(() => ({}))) as any;
-  logger.info('Python 响应:', JSON.stringify(data, null, 2));
-
-  if (data.success) {
-    logger.info(`成功: ${data.message}, inserted=${data.inserted}, updated=${data.updated}`);
-  } else {
-    logger.error(`失败: ${data.error}`);
-  }
-  await AppDataSource.destroy();
-  process.exit(data.success ? 0 : 1);
 }
 
 main().catch((e) => {

+ 493 - 68
server/src/services/WeixinVideoWorkStatisticsImportService.ts

@@ -1,17 +1,32 @@
 /**
  * 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics
  *
- * 流程:调用 Python 纯浏览器接口,由 Python 完成
+ * 流程:Node 端 Playwright 完成(不依赖 Python)
  * 1. 打开 statistic/post → 点击单篇视频 → 点击近30天
- * 2. 监听 post_list 获取 exportId->objectId
- * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页近30天 → 下载表格
- * 4. 解析 CSV 存入 work_day_statistics
+ * 2. 监听 post_list 获取 exportId->objectId,可选更新 works 表 yesterday_*
+ * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页监听 feed_aggreagate_data_by_tab_type
+ * 4. 解析「全部」tab 的 browse/like/comment/forward/fav/follow,写入 work_day_statistics
  */
 
+import { chromium, type Browser } from 'playwright';
 import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
+import { BrowserManager } from '../automation/browser.js';
 import { logger } from '../utils/logger.js';
 import { CookieManager } from '../automation/cookie.js';
-import { getPythonServiceBaseUrl } from './PythonServiceConfigService.js';
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import type { ProxyConfig } from '@media-manager/shared';
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
 
 function tryDecryptCookieData(cookieData: string | null): string | null {
   if (!cookieData) return null;
@@ -24,33 +39,152 @@ function tryDecryptCookieData(cookieData: string | null): string | null {
   }
 }
 
-/** 将 cookie 转为 Python 接口所需格式(JSON 数组或原始字符串) */
-function getCookieForPython(cookieData: string | null): string {
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
   const raw = tryDecryptCookieData(cookieData);
-  if (!raw) return '';
+  if (!raw || !raw.trim()) return [];
+
   const s = raw.trim();
-  if (!s) return '';
-  try {
-    JSON.parse(s);
-    return s; // 已是 JSON
-  } catch {
-    return JSON.stringify(
-      s
-        .split(';')
-        .filter(Boolean)
-        .map((part) => {
-          const idx = part.trim().indexOf('=');
-          const name = idx >= 0 ? part.trim().slice(0, idx) : part.trim();
-          const value = idx >= 0 ? part.trim().slice(idx + 1) : '';
-          return { name, value, domain: '.weixin.qq.com', path: '/' };
+  if (s.startsWith('[') || s.startsWith('{')) {
+    try {
+      const parsed = JSON.parse(s);
+      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
+    }
+  }
+
+  const pairs = s.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, headless: boolean): Promise<{
+  browser: Browser;
+  shouldClose: boolean;
+}> {
+  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 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;
+}
+
+/** 从昨天往前推 N 天的日期数组(index 0 = 最早),与接口数组顺序一致 */
+function getRecentChinaDates(days: number): Date[] {
+  const formatter = new Intl.DateTimeFormat('en-CA', {
+    timeZone: 'Asia/Shanghai',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  });
+  const parts = formatter.formatToParts(new Date());
+  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+  const y = parseInt(get('year'), 10);
+  const m = parseInt(get('month'), 10) - 1;
+  const d = parseInt(get('day'), 10);
+  const today = new Date(y, m, d, 0, 0, 0, 0);
+  const end = new Date(today);
+  end.setDate(end.getDate() - 1);
+  const start = new Date(end);
+  start.setDate(end.getDate() - (days - 1));
+  const dates: Date[] = [];
+  for (let i = 0; i < days; i++) {
+    const dt = new Date(start);
+    dt.setDate(start.getDate() + i);
+    dt.setHours(0, 0, 0, 0);
+    dates.push(dt);
   }
+  return dates;
 }
 
+const STATISTIC_POST_URL = 'https://channels.weixin.qq.com/platform/statistic/post';
+const TAB_SINGLE_VIDEO_SELECTORS = [
+  'div.weui-desktop-tab__navs ul li:nth-child(2) a',
+  'a:has-text("单篇视频")',
+];
+const NEAR_30_DAYS_SELECTORS = [
+  'div.post-single-wrap div.weui-desktop-radio-group.radio-group label:has-text("近30天")',
+  'div.post-single-wrap div.filter-wrap div.weui-desktop-radio-group label:nth-child(2)',
+  'div.post-single-wrap label:has-text("近30天")',
+  'div.weui-desktop-radio-group label:has-text("近30天")',
+  'label:has-text("近30天")',
+];
+const VIEW_BTN_SELECTORS = (objectId: string) => [
+  `div.ant-table-fixed-right tr[data-row-key="${objectId}"] a.detail-wrap`,
+  `tr[data-row-key="${objectId}"] a.detail-wrap`,
+  `tr[data-row-key="${objectId}"] a:has-text("查看")`,
+  `tr[data-row-key="${objectId}"] a`,
+];
+
 export class WeixinVideoWorkStatisticsImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
 
   static async runDailyImport(): Promise<void> {
     const svc = new WeixinVideoWorkStatisticsImportService();
@@ -70,6 +204,23 @@ export class WeixinVideoWorkStatisticsImportService {
     await svc.importAccountWorksStatistics(account, showBrowser);
   }
 
+  /** 仅同步指定作品(用于接口/测试),showBrowser=true 时显示浏览器窗口;返回 inserted/updated */
+  static async runDailyImportForWork(
+    workId: number,
+    showBrowser = false
+  ): Promise<{ inserted: number; updated: number }> {
+    const svc = new WeixinVideoWorkStatisticsImportService();
+    const work = await svc.workRepository.findOne({
+      where: { id: workId, platform: 'weixin_video' as any },
+      relations: ['account'],
+    });
+    if (!work) throw new Error(`未找到视频号作品 id=${workId}`);
+    const account = work.account as PlatformAccount;
+    if (!account) throw new Error(`作品 ${workId} 无关联账号`);
+    logger.info(`[WX WorkStats] 单作品同步 workId=${workId} accountId=${account.id} showBrowser=${showBrowser}`);
+    return svc.importAccountWorksStatistics(account, showBrowser, { onlyWorkIds: [workId] });
+  }
+
   async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
     const accounts = await this.accountRepository.find({
       where: { platform: 'weixin_video' as any },
@@ -88,76 +239,350 @@ export class WeixinVideoWorkStatisticsImportService {
     logger.info('[WX WorkStats] All accounts done');
   }
 
-  private async importAccountWorksStatistics(account: PlatformAccount, showBrowser = false): Promise<void> {
-    const cookieForPython = getCookieForPython(account.cookieData);
-    if (!cookieForPython) {
+  private async importAccountWorksStatistics(
+    account: PlatformAccount,
+    showBrowser = false,
+    options?: { onlyWorkIds?: number[] }
+  ): Promise<{ inserted: number; updated: number }> {
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
       logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
-      return;
+      return { inserted: 0, updated: 0 };
     }
 
-    const works = await this.workRepository.find({
+    let works = await this.workRepository.find({
       where: { accountId: account.id, platform: 'weixin_video' as any },
     });
+    if (options?.onlyWorkIds?.length) {
+      const set = new Set(options.onlyWorkIds);
+      works = works.filter((w) => set.has(w.id));
+    }
     if (!works.length) {
       logger.info(`[WX WorkStats] accountId=${account.id} 没有作品,跳过`);
-      return;
+      return { inserted: 0, updated: 0 };
     }
 
-    const worksPayload = works
-      .filter((w) => (w.platformVideoId ?? '').trim())
-      .map((w) => ({ work_id: w.id, platform_video_id: (w.platformVideoId ?? '').trim() }));
-
-    if (!worksPayload.length) {
+    const exportIdToWorkId = new Map<string, number>();
+    for (const w of works) {
+      const pvid = (w.platformVideoId ?? '').trim();
+      if (!pvid) continue;
+      exportIdToWorkId.set(pvid, w.id);
+      if (pvid.includes('/')) exportIdToWorkId.set(pvid.split('/').pop()!, w.id);
+    }
+    if (exportIdToWorkId.size === 0) {
       logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`);
-      return;
+      return { inserted: 0, updated: 0 };
     }
 
-    logger.info(
-      `[WX WorkStats] accountId=${account.id} 调用 Python 纯浏览器同步,共 ${worksPayload.length} 个作品`
+    const headless =
+      process.env.WX_IMPORT_HEADLESS === '0' ? false : !showBrowser;
+    const { browser, shouldClose } = await createBrowserForAccount(
+      account.proxyConfig as ProxyConfig | null,
+      headless
     );
 
+    let totalProcessed = 0;
+    let totalSkipped = 0;
+    let inserted = 0;
+    let updated = 0;
+    let worksUpdated = 0;
+
     try {
-      const pythonUrl = (await getPythonServiceBaseUrl()).replace(/\/$/, '');
-      const pyRes = await fetch(`${pythonUrl}/sync_weixin_account_works_daily_stats`, {
-        method: 'POST',
-        headers: { 'Content-Type': 'application/json' },
-        body: JSON.stringify({
-          works: worksPayload,
-          cookie: cookieForPython,
-          show_browser: showBrowser,
-        }),
-        signal: AbortSignal.timeout(600_000), // 10 分钟,批量可能较久
+      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',
       });
+      context.setDefaultTimeout(60_000);
+      await context.addCookies(cookies as any);
 
-      const data = (await pyRes.json().catch(() => ({}))) as {
-        success?: boolean;
-        error?: string;
-        message?: string;
-        total_processed?: number;
-        total_skipped?: number;
-        inserted?: number;
-        updated?: number;
-        works_updated?: number;
-      };
+      const page = await context.newPage();
 
-      if (!pyRes.ok) {
-        logger.warn(`[WX WorkStats] accountId=${account.id} Python 请求失败: ${pyRes.status} ${data.error || ''}`);
-        return;
+      const postListData = { list: [] as Array<{ exportId?: string; objectId?: string; [k: string]: any }> };
+
+      page.on('response', async (response) => {
+        const url = response.url();
+        try {
+          if (url.includes('statistic/post_list') && response.request().method() === 'POST') {
+            const body = await response.json().catch(() => ({}));
+            if (body?.errCode === 0 && body?.data?.list) {
+              postListData.list = body.data.list;
+            }
+          }
+        } catch {
+          // ignore
+        }
+      });
+
+      await page.goto(STATISTIC_POST_URL, { waitUntil: 'domcontentloaded', timeout: 30_000 });
+      await page.waitForTimeout(showBrowser ? 5000 : 3000);
+
+      if (page.url().includes('login') || page.url().includes('passport')) {
+        throw new Error('Cookie 已过期,请重新登录');
+      }
+
+      for (const sel of TAB_SINGLE_VIDEO_SELECTORS) {
+        const loc = page.locator(sel).first;
+        if ((await loc.count()) > 0) {
+          await loc.click().catch(() => undefined);
+          break;
+        }
+      }
+      await page.waitForTimeout(2000);
+
+      postListData.list = [];
+      for (const sel of NEAR_30_DAYS_SELECTORS) {
+        const loc = page.locator(sel).first;
+        if ((await loc.count()) > 0) {
+          await loc.click().catch(() => undefined);
+          break;
+        }
+      }
+      await page.waitForTimeout(5000);
+
+      const items = postListData.list;
+      if (!items.length) {
+        logger.warn(`[WX WorkStats] accountId=${account.id} 未监听到 post_list 或列表为空`);
+        return { inserted: 0, updated: 0 };
       }
 
-      if (!data.success) {
-        logger.warn(`[WX WorkStats] accountId=${account.id} 同步失败: ${data.error || ''}`);
-        return;
+      // 首次:用 post_list 更新 works 表 yesterday_*
+      const workUpdates: Array<{
+        work_id: number;
+        yesterday_play_count: number;
+        yesterday_like_count: number;
+        yesterday_recommend_count: number;
+        yesterday_comment_count: number;
+        yesterday_share_count: number;
+        yesterday_follow_count: number;
+        yesterday_completion_rate: string;
+        yesterday_avg_watch_duration: string;
+      }> = [];
+      for (const it of items) {
+        const eid = (it.exportId ?? '').trim();
+        if (!eid) continue;
+        let workId = exportIdToWorkId.get(eid);
+        if (workId == null) {
+          for (const [k, v] of exportIdToWorkId) {
+            if (eid.includes(k) || k.includes(eid)) {
+              workId = v;
+              break;
+            }
+          }
+        }
+        if (workId == null) continue;
+        const readCount = Number(it.readCount) || 0;
+        const recommendCount = Number(it.likeCount) || 0;
+        const likeCount = Number(it.favCount) || 0;
+        const commentCount = Number(it.commentCount) || 0;
+        const forwardCount = Number(it.forwardCount) || 0;
+        const followCount = Number(it.followCount) || 0;
+        const fullPlayRate = it.fullPlayRate;
+        const compRate = fullPlayRate != null ? `${Number(fullPlayRate) * 100}%` : '0';
+        const avgSec = it.avgPlayTimeSec;
+        const avgDur = avgSec != null ? `${Number(avgSec)}秒` : '0';
+        workUpdates.push({
+          work_id: workId,
+          yesterday_play_count: readCount,
+          yesterday_like_count: likeCount,
+          yesterday_recommend_count: recommendCount,
+          yesterday_comment_count: commentCount,
+          yesterday_share_count: forwardCount,
+          yesterday_follow_count: followCount,
+          yesterday_completion_rate: compRate,
+          yesterday_avg_watch_duration: avgDur,
+        });
+      }
+      if (workUpdates.length > 0) {
+        for (const u of workUpdates) {
+          const result = await this.workRepository.update(u.work_id, {
+            yesterdayPlayCount: u.yesterday_play_count,
+            yesterdayLikeCount: u.yesterday_like_count,
+            yesterdayRecommendCount: u.yesterday_recommend_count,
+            yesterdayCommentCount: u.yesterday_comment_count,
+            yesterdayShareCount: u.yesterday_share_count,
+            yesterdayFollowCount: u.yesterday_follow_count,
+            yesterdayCompletionRate: u.yesterday_completion_rate,
+            yesterdayAvgWatchDuration: u.yesterday_avg_watch_duration,
+          });
+          if (result.affected && result.affected > 0) worksUpdated += result.affected;
+        }
+      }
+
+      const processedExportIds = new Set<string>();
+
+      const ensureSingleVideoNear30 = async () => {
+        for (const sel of TAB_SINGLE_VIDEO_SELECTORS) {
+          const loc = page.locator(sel).first;
+          if ((await loc.count()) > 0) {
+            await loc.click().catch(() => undefined);
+            break;
+          }
+        }
+        await page.waitForTimeout(2000);
+        for (const sel of NEAR_30_DAYS_SELECTORS) {
+          const loc = page.locator(sel).first;
+          if ((await loc.count()) > 0) {
+            await loc.click().catch(() => undefined);
+            break;
+          }
+        }
+        await page.waitForTimeout(3000);
+      };
+
+      for (let idx = 0; idx < items.length; idx++) {
+        const item = items[idx]!;
+        const eid = (item.exportId ?? '').trim();
+        const oid = (item.objectId ?? '').trim();
+        if (!oid) continue;
+        if (processedExportIds.has(eid)) continue;
+
+        if (idx > 0) {
+          await ensureSingleVideoNear30();
+        }
+
+        let workId = exportIdToWorkId.get(eid);
+        if (workId == null) {
+          for (const [k, v] of exportIdToWorkId) {
+            if (eid.includes(k) || k.includes(eid)) {
+              workId = v;
+              break;
+            }
+          }
+        }
+        if (workId == null) {
+          totalSkipped += 1;
+          continue;
+        }
+
+        let viewClicked = false;
+        for (const sel of VIEW_BTN_SELECTORS(oid)) {
+          const viewBtn = page.locator(sel);
+          if ((await viewBtn.count()) > 0) {
+            try {
+              await viewBtn.first.waitFor({ timeout: 3000 });
+              await viewBtn.first.click();
+              viewClicked = true;
+              break;
+            } catch {
+              // next selector
+            }
+        }
+        if (!viewClicked) {
+          totalSkipped += 1;
+          continue;
+        }
+        await page.waitForTimeout(2000);
+
+        let body: { data?: { dataByFanstype?: Array<{ dataByTabtype?: Array<{ tabTypeName?: string; tabType?: number; data?: any }> }>; feedData?: Array<{ totalData?: any }> } } | null = null;
+        try {
+          const res = await page.waitForResponse(
+            (r) => r.url().includes('feed_aggreagate_data_by_tab_type'),
+            { timeout: 12_000 }
+          );
+          const json = await res.json().catch(() => null);
+          if (json?.errCode === 0 && json?.data) body = json;
+        } catch {
+          // timeout or no response
+        }
+
+        if (!body?.data) {
+          await page.goBack().catch(() => undefined);
+          await page.waitForTimeout(2000);
+          continue;
+        }
+
+        let tabAll: Record<string, unknown[] | undefined> | undefined;
+        for (const fanItem of body.data.dataByFanstype ?? []) {
+          for (const tabItem of fanItem.dataByTabtype ?? []) {
+            if (tabItem.tabTypeName === '全部' || tabItem.tabType === 999) {
+              tabAll = tabItem.data as Record<string, unknown[] | undefined>;
+              break;
+            }
+          }
+          if (tabAll) break;
+        }
+        if (!tabAll) {
+          const feedData = body.data.feedData;
+          tabAll = feedData?.[0]?.totalData as Record<string, unknown[] | undefined> | undefined;
+        }
+        if (!tabAll) {
+          await page.goBack().catch(() => undefined);
+          await page.waitForTimeout(2000);
+          continue;
+        }
+
+        const browse = Array.isArray(tabAll.browse) ? tabAll.browse : [];
+        const n = browse.length;
+        if (n === 0) {
+          await page.goBack().catch(() => undefined);
+          await page.waitForTimeout(2000);
+          continue;
+        }
+
+        const likeArr = Array.isArray(tabAll.like) ? tabAll.like : [];
+        const commentArr = Array.isArray(tabAll.comment) ? tabAll.comment : [];
+        const forwardArr = Array.isArray(tabAll.forward) ? tabAll.forward : [];
+        const favArr = Array.isArray(tabAll.fav) ? tabAll.fav : [];
+        const followArr = Array.isArray(tabAll.follow) ? tabAll.follow : [];
+        const dates = getRecentChinaDates(n);
+
+        const batchItems: Array<{
+          workId: number;
+          recordDate: Date;
+          playCount: number;
+          likeCount: number;
+          recommendCount: number;
+          commentCount: number;
+          shareCount: number;
+          collectCount: number;
+          followCount: number;
+          completionRate: string;
+          avgWatchDuration: string;
+        }> = [];
+        for (let i = 0; i < n; i++) {
+          const recDate = dates[i]!;
+          const play = parseChineseNumberLike(browse[i]) ?? 0;
+          const recommend = parseChineseNumberLike(likeArr[i]) ?? 0;
+          const like = parseChineseNumberLike(favArr[i]) ?? 0;
+          const comment = parseChineseNumberLike(commentArr[i]) ?? 0;
+          const share = parseChineseNumberLike(forwardArr[i]) ?? 0;
+          const follow = parseChineseNumberLike(followArr[i]) ?? 0;
+          batchItems.push({
+            workId,
+            recordDate: recDate,
+            playCount: play,
+            likeCount: like,
+            recommendCount: recommend,
+            commentCount: comment,
+            shareCount: share,
+            collectCount: 0,
+            followCount: follow,
+            completionRate: '0',
+            avgWatchDuration: '0',
+          });
+        }
+
+        const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(batchItems);
+        inserted += result.inserted;
+        updated += result.updated;
+        totalProcessed += 1;
+        processedExportIds.add(eid);
+
+        await page.goBack().catch(() => undefined);
+        await page.waitForTimeout(2000);
       }
 
-      const worksUpdated = data.works_updated ?? 0;
       logger.info(
-        `[WX WorkStats] accountId=${account.id} 完成: 处理 ${data.total_processed ?? 0} 个, 跳过 ${data.total_skipped ?? 0} 个, 新增 ${data.inserted ?? 0} 条, 更新 ${data.updated ?? 0} 条` +
+        `[WX WorkStats] accountId=${account.id} 完成: 处理 ${totalProcessed} 个, 跳过 ${totalSkipped} 个, 新增 ${inserted} 条, 更新 ${updated} 条` +
           (worksUpdated > 0 ? `, works 表更新 ${worksUpdated} 条` : '')
       );
-    } catch (e) {
-      logger.error(`[WX WorkStats] accountId=${account.id} 调用 Python 失败:`, e);
-      throw e;
+      return { inserted, updated };
+    } finally {
+      if (shouldClose) await browser.close().catch(() => undefined);
     }
   }
 }