Browse Source

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

Ethanfly 11 giờ trước cách đây
mục cha
commit
e886db8c5c
35 tập tin đã thay đổi với 2835 bổ sung543 xóa
  1. 13 12
      client/package.json
  2. 42 2
      client/src/views/Analytics/Account/index.vue
  3. 42 2
      client/src/views/Analytics/PlatformDetail/index.vue
  4. 94 0
      client/src/views/Analytics/Work/index.vue
  5. 7 0
      database/migrations/add_exposure_count_to_user_day_statistics.sql
  6. 38 0
      database/migrations/fix_platform_accounts_timestamp_format.sql
  7. 3 2
      database/schema.sql
  8. 3 0
      pnpm-lock.yaml
  9. 212 0
      server/python/app.py
  10. 150 124
      server/python/platforms/baijiahao.py
  11. 598 12
      server/python/platforms/weixin.py
  12. 2 2
      server/src/models/entities/PlatformAccount.ts
  13. 3 0
      server/src/models/entities/UserDayStatistics.ts
  14. 6 0
      server/src/models/entities/Work.ts
  15. 6 0
      server/src/models/entities/WorkDayStatistics.ts
  16. 17 1
      server/src/routes/auth.ts
  17. 110 1
      server/src/routes/internal.ts
  18. 99 2
      server/src/routes/workDayStatistics.ts
  19. 29 7
      server/src/scheduler/index.ts
  20. 77 0
      server/src/scripts/fix-platform-accounts-timestamp.ts
  21. 35 0
      server/src/scripts/run-baijiahao-work-daily-import.ts
  22. 20 2
      server/src/scripts/run-weixin-video-work-stats-import.ts
  23. 92 0
      server/src/scripts/test-weixin-work-daily-sync.ts
  24. 15 5
      server/src/services/BaijiahaoContentOverviewImportService.ts
  25. 645 0
      server/src/services/BaijiahaoWorkDailyStatisticsImportService.ts
  26. 5 0
      server/src/services/UserDayStatisticsService.ts
  27. 80 363
      server/src/services/WeixinVideoWorkStatisticsImportService.ts
  28. 8 0
      server/src/services/WorkDayStatisticsService.ts
  29. 1 0
      server/src/services/WorkService.ts
  30. 18 6
      server/src/services/XiaohongshuAccountOverviewImportService.ts
  31. 323 0
      server/tmp/baijiahao-storage-state/10.json
  32. 4 0
      server/tmp/feed_aggreagate_account61_work906.json
  33. 4 0
      server/tmp/feed_aggreagate_account61_work907.json
  34. 32 0
      server/tmp/post_list_params.json
  35. 2 0
      shared/src/types/work.ts

+ 13 - 12
client/package.json

@@ -14,37 +14,38 @@
     "generate-icons": "node scripts/generate-icons.js"
   },
   "dependencies": {
-    "@media-manager/shared": "workspace:*",
-    "vue": "^3.4.15",
-    "vue-router": "^4.2.5",
-    "pinia": "^2.1.7",
-    "element-plus": "^2.5.3",
     "@element-plus/icons-vue": "^2.3.1",
+    "@media-manager/shared": "workspace:*",
     "axios": "^1.6.5",
     "dayjs": "^1.11.10",
     "echarts": "^5.4.3",
+    "element-plus": "^2.5.3",
+    "pinia": "^2.1.7",
+    "vue": "^3.4.15",
     "vue-echarts": "^6.6.8",
-    "vue-i18n": "^9.9.0"
+    "vue-i18n": "^9.9.0",
+    "vue-router": "^4.2.5",
+    "xlsx": "^0.18.5"
   },
   "devDependencies": {
     "@types/node": "^20.10.6",
+    "@typescript-eslint/eslint-plugin": "^6.18.0",
+    "@typescript-eslint/parser": "^6.18.0",
     "@vitejs/plugin-vue": "^5.0.3",
     "@vue/tsconfig": "^0.5.1",
     "electron": "^28.1.3",
     "electron-builder": "^24.9.1",
+    "eslint": "^8.56.0",
+    "eslint-plugin-vue": "^9.20.0",
     "sass": "^1.69.7",
+    "sharp": "^0.34.5",
     "typescript": "^5.3.3",
     "unplugin-auto-import": "^0.17.3",
     "unplugin-vue-components": "^0.26.0",
     "vite": "^5.0.11",
     "vite-plugin-electron": "^0.28.0",
     "vite-plugin-electron-renderer": "^0.14.5",
-    "vue-tsc": "^1.8.27",
-    "eslint": "^8.56.0",
-    "@typescript-eslint/eslint-plugin": "^6.18.0",
-    "@typescript-eslint/parser": "^6.18.0",
-    "eslint-plugin-vue": "^9.20.0",
-    "sharp": "^0.34.5"
+    "vue-tsc": "^1.8.27"
   },
   "build": {
     "appId": "com.media-manager.app",

+ 42 - 2
client/src/views/Analytics/Account/index.vue

@@ -216,6 +216,11 @@
               {{ btn.label }}
             </el-button>
           </div>
+          <div class="detail-export-wrap">
+            <el-button type="primary" plain size="small" :disabled="!detailDailyData.length" @click="exportDetailDailyData">
+              导出数据
+            </el-button>
+          </div>
         </div>
 
         <!-- 详情 Tab -->
@@ -241,9 +246,10 @@
               </div>
             </div>
 
-            <!-- 每日数据表格 -->
+            <!-- 每日数据表格:时间倒序;收益、推荐量暂未接入先注释 -->
             <el-table :data="detailDailyData" v-loading="detailLoading" stripe>
               <el-table-column prop="date" label="时间" width="120" align="center" />
+              <!-- 收益与推荐量暂未接入,先隐藏
               <el-table-column prop="income" label="收益" width="90" align="center">
                 <template #default="{ row }">
                   <span>{{ row.income ?? 0 }}</span>
@@ -254,6 +260,7 @@
                   <span>{{ row.recommendationCount ?? 0 }}</span>
                 </template>
               </el-table-column>
+              -->
               <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
                 <template #default="{ row }">
                   <span>{{ row.viewsCount ?? 0 }}</span>
@@ -345,6 +352,7 @@ import { Search, User, View, ChatDotRound, Star, TrendCharts } from '@element-pl
 import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { ElMessage } from 'element-plus';
+import * as XLSX from 'xlsx';
 import dayjs from 'dayjs';
 import request from '@/api/request';
 import iconDefaultUrl from '@/assets/platforms/default.svg?url';
@@ -738,6 +746,33 @@ function handleDetailQuickDate(type: string) {
   loadAccountDetailData();
 }
 
+// 导出当前时间范围内的每日数据为 xlsx(表头与列表一致)
+function exportDetailDailyData() {
+  if (!detailDailyData.value.length) return;
+  const headers = ['时间', '播放(阅读)量', '评论量', '点赞量', '涨粉量'];
+  const rows = detailDailyData.value.map((row) => [
+    row.date ?? '',
+    row.viewsCount ?? 0,
+    row.commentsCount ?? 0,
+    row.likesCount ?? 0,
+    row.fansIncrease ?? 0,
+  ]);
+  const data = [headers, ...rows];
+  const ws = XLSX.utils.aoa_to_sheet(data);
+  const wb = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(wb, ws, '数据详情');
+  const arrayBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
+  const blob = new Blob([arrayBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+  const name = (selectedAccount.value?.nickname || '账号').replace(/[/\\?*:"|]/g, '_');
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = `${name}_数据详情_${detailStartDate.value}_${detailEndDate.value}.xlsx`;
+  a.click();
+  URL.revokeObjectURL(url);
+  ElMessage.success('导出成功');
+}
+
 // 加载账号详情(汇总 + 每日 + 作品)
 async function loadAccountDetailData() {
   if (!selectedAccount.value) return;
@@ -762,7 +797,8 @@ async function loadAccountDetailData() {
         fansIncrease: data.summary?.fansIncrease ?? 0,
       };
 
-      detailDailyData.value = Array.isArray(data.dailyData) ? data.dailyData : [];
+      const rawDaily = Array.isArray(data.dailyData) ? data.dailyData : [];
+      detailDailyData.value = [...rawDaily].sort((a, b) => (b.date || '').localeCompare(a.date || ''));
       detailWorks.value = Array.isArray(data.works) ? data.works : [];
       // 重新加载详情数据时,重置作品分页
       detailWorksPage.value = 1;
@@ -1068,6 +1104,10 @@ onMounted(() => {
     }
   }
 
+  .detail-export-wrap {
+    margin-left: 12px;
+  }
+
   .work-title-cell {
     .work-title-text {
       font-weight: 500;

+ 42 - 2
client/src/views/Analytics/PlatformDetail/index.vue

@@ -216,6 +216,11 @@
               {{ btn.label }}
             </el-button>
           </div>
+          <div class="detail-export-wrap">
+            <el-button type="primary" plain size="small" :disabled="!detailDailyData.length" @click="exportDetailDailyData">
+              导出数据
+            </el-button>
+          </div>
         </div>
 
         <!-- 详情 Tab -->
@@ -241,9 +246,10 @@
               </div>
             </div>
 
-            <!-- 每日数据表格 -->
+            <!-- 每日数据表格:时间倒序;收益、推荐量暂未接入先注释 -->
             <el-table :data="detailDailyData" v-loading="detailLoading" stripe>
               <el-table-column prop="date" label="时间" width="120" align="center" />
+              <!-- 收益与推荐量暂未接入,先隐藏
               <el-table-column prop="income" label="收益" width="90" align="center">
                 <template #default="{ row }">
                   <span>{{ row.income ?? 0 }}</span>
@@ -254,6 +260,7 @@
                   <span>{{ row.recommendationCount ?? 0 }}</span>
                 </template>
               </el-table-column>
+              -->
               <el-table-column prop="viewsCount" label="播放(阅读)量" width="130" align="center">
                 <template #default="{ row }">
                   <span>{{ row.viewsCount ?? 0 }}</span>
@@ -333,6 +340,7 @@ import { useRoute, useRouter } from 'vue-router';
 import { PLATFORMS, AVAILABLE_PLATFORM_TYPES } from '@media-manager/shared';
 import type { PlatformType } from '@media-manager/shared';
 import { ElMessage } from 'element-plus';
+import * as XLSX from 'xlsx';
 import dayjs from 'dayjs';
 import request from '@/api/request';
 
@@ -678,6 +686,33 @@ function handleDetailQuickDate(type: string) {
   loadAccountDetailData();
 }
 
+// 导出当前时间范围内的每日数据为 xlsx(表头与列表一致)
+function exportDetailDailyData() {
+  if (!detailDailyData.value.length) return;
+  const headers = ['时间', '播放(阅读)量', '评论量', '点赞量', '涨粉量'];
+  const rows = detailDailyData.value.map((row) => [
+    row.date ?? '',
+    row.viewsCount ?? 0,
+    row.commentsCount ?? 0,
+    row.likesCount ?? 0,
+    row.fansIncrease ?? 0,
+  ]);
+  const data = [headers, ...rows];
+  const ws = XLSX.utils.aoa_to_sheet(data);
+  const wb = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(wb, ws, '数据详情');
+  const arrayBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
+  const blob = new Blob([arrayBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+  const name = (selectedAccountDetail.value?.nickname || selectedAccountDetail.value?.username || '账号').replace(/[/\\?*:"|]/g, '_');
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = `${name}_数据详情_${detailStartDate.value}_${detailEndDate.value}.xlsx`;
+  a.click();
+  URL.revokeObjectURL(url);
+  ElMessage.success('导出成功');
+}
+
 // 加载账号详情(汇总 + 每日 + 作品)
 async function loadAccountDetailData() {
   if (!selectedAccountDetail.value) return;
@@ -702,7 +737,8 @@ async function loadAccountDetailData() {
         fansIncrease: data.summary?.fansIncrease ?? 0,
       };
 
-      detailDailyData.value = Array.isArray(data.dailyData) ? data.dailyData : [];
+      const rawDaily = Array.isArray(data.dailyData) ? data.dailyData : [];
+      detailDailyData.value = [...rawDaily].sort((a, b) => (b.date || '').localeCompare(a.date || ''));
       detailWorks.value = Array.isArray(data.works) ? data.works : [];
     }
   } catch (error) {
@@ -948,6 +984,10 @@ onMounted(() => {
       }
     }
 
+    .detail-export-wrap {
+      margin-left: 12px;
+    }
+
     .work-title-cell {
       .work-title-text {
         font-weight: 500;

+ 94 - 0
client/src/views/Analytics/Work/index.vue

@@ -243,6 +243,40 @@
                     </div>
                   </template>
 
+                  <!-- 视频号:播放、点赞、评论、分享、收藏、关注数、涨粉量、平均观看时长、完播率、2s退出率 -->
+                  <template v-else-if="selectedWork.platform === 'weixin_video'">
+                    <div
+                      v-for="item in weixinMetricCards"
+                      :key="item.label"
+                      class="data-card"
+                      :class="{ highlight: item.key && activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="item.key && setTrendMetric(item.key)"
+                      @keyup.enter="item.key && setTrendMetric(item.key)"
+                    >
+                      <div class="card-label">{{ item.label }}</div>
+                      <div class="card-value">{{ item.value }}</div>
+                    </div>
+                  </template>
+
+                  <!-- 百家号:推荐量、播放(阅读)量、评论量、点赞量、收藏量、分享量、完播率、涨粉量 -->
+                  <template v-else-if="selectedWork.platform === 'baijiahao'">
+                    <div
+                      v-for="item in baijiahaoMetricCards"
+                      :key="item.key || item.label"
+                      class="data-card"
+                      :class="{ highlight: item.key && activeTrendMetric === item.key }"
+                      role="button"
+                      tabindex="0"
+                      @click="item.key && setTrendMetric(item.key)"
+                      @keyup.enter="item.key && setTrendMetric(item.key)"
+                    >
+                      <div class="card-label">{{ item.label }}</div>
+                      <div class="card-value">{{ item.value }}</div>
+                    </div>
+                  </template>
+
                   <!-- 其他平台:保持原口径 -->
                   <template v-else>
                     <div
@@ -454,6 +488,8 @@ interface WorkDetailData {
   collectCount: number;
   shareCount: number;
   fansIncrease: number;
+  followCount: number; // 视频号:关注数
+  recommendCount: number; // 百家号等:推荐量
   coverClickRate: string;
   avgWatchDuration: string;
   completionRate: string;
@@ -471,6 +507,8 @@ const workDetailData = ref<WorkDetailData>({
   collectCount: 0,
   shareCount: 0,
   fansIncrease: 0,
+  followCount: 0,
+  recommendCount: 0,
   coverClickRate: '0',
   avgWatchDuration: '0',
   completionRate: '0',
@@ -494,6 +532,7 @@ type TrendMetricKey =
   | 'completionRate'
   | 'twoSecondExitRate'
   | 'fansIncrease'
+  | 'followCount'
   | 'totalWatchDuration';
 
 const activeTrendMetric = ref<TrendMetricKey>('playCount');
@@ -516,6 +555,7 @@ const trendTitle = computed(() => {
       collectCount: '收藏量趋势',
       shareCount: '分享量趋势',
       fansIncrease: '涨粉量趋势',
+      followCount: '关注量趋势',
       exposureCount: '曝光量趋势',
       coverClickRate: '封面点击率趋势',
       avgWatchDuration: '平均观看时长趋势',
@@ -536,6 +576,7 @@ const trendTitle = computed(() => {
     completionRate: '完播率趋势',
     twoSecondExitRate: '2s退出率趋势',
     fansIncrease: '涨粉量趋势',
+    followCount: '关注量趋势',
     totalWatchDuration: '播放总时长趋势',
   };
   return map[activeTrendMetric.value] || '趋势';
@@ -797,6 +838,40 @@ const douyinMetricCards = computed<MetricCardConfig[]>(() => {
   ];
 });
 
+// 视频号:播放、点赞、评论、分享、收藏、关注数、涨粉量、平均观看时长、完播率、2s退出率
+const weixinMetricCards = computed<MetricCardConfig[]>(() => {
+  const d = workDetailData.value;
+  const base = selectedWork.value;
+  return [
+    { key: 'playCount', label: '播放量', value: formatNumber(d.playCount || base?.viewsCount || 0) },
+    { key: 'likeCount', label: '点赞量', value: formatNumber(d.likeCount || base?.likesCount || 0) },
+    { key: 'commentCount', label: '评论量', value: formatNumber(d.commentCount || base?.commentsCount || 0) },
+    { key: 'shareCount', label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
+    { key: 'collectCount', label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
+    { key: 'followCount', label: '关注数', value: formatNumber(d.followCount || 0) },
+    // { key: 'fansIncrease', label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+    { label: '平均观看时长', value: formatAvgWatchDurationSeconds(d.avgWatchDuration) },
+    { label: '完播率', value: formatRate(d.completionRate) },
+    // { key: 'twoSecondExitRate', label: '2s退出率', value: formatRate(d.twoSecondExitRate) },
+  ];
+});
+
+// 百家号:推荐量、播放(阅读)量、评论量、点赞量、收藏量、分享量、完播率、涨粉量
+const baijiahaoMetricCards = computed<MetricCardConfig[]>(() => {
+  const d = workDetailData.value;
+  const base = selectedWork.value;
+  return [
+    { label: '推荐量', value: formatNumber(d.recommendCount || 0) },
+    { key: 'playCount' as const, label: '播放(阅读)量', value: formatNumber(d.playCount || base?.viewsCount || 0) },
+    { key: 'commentCount' as const, label: '评论量', value: formatNumber(d.commentCount || base?.commentsCount || 0) },
+    { key: 'likeCount' as const, label: '点赞量', value: formatNumber(d.likeCount || base?.likesCount || 0) },
+    { key: 'collectCount' as const, label: '收藏量', value: formatNumber(d.collectCount || base?.collectsCount || 0) },
+    { key: 'shareCount' as const, label: '分享量', value: formatNumber(d.shareCount || base?.sharesCount || 0) },
+    { key: 'completionRate' as const, label: '完播率', value: formatRate(d.completionRate) },
+    { key: 'fansIncrease' as const, label: '涨粉量', value: formatNumber(d.fansIncrease || 0) },
+  ];
+});
+
 // 查看详情
 async function handleView(row: WorkData) {
   selectedWork.value = row;
@@ -805,6 +880,21 @@ async function handleView(row: WorkData) {
   workStatsHistory.value = [];
   drawerVisible.value = true;
   activeTab.value = 'core';
+
+  // [已注释] 视频号:点击详情时先同步每日数据(浏览器自动化 + CSV 导入)
+  // if (row.platform === 'weixin_video') {
+  //   detailLoading.value = true;
+  //   try {
+  //     const syncRes = await request.post(`/api/work-day-statistics/sync-weixin-video/${row.id}`);
+  //     if (syncRes?.success && (syncRes?.data?.inserted > 0 || syncRes?.data?.updated > 0)) {
+  //       ElMessage.success(syncRes.message || `已同步 ${(syncRes.data?.inserted || 0) + (syncRes.data?.updated || 0)} 条日数据`);
+  //     }
+  //   } catch (e) {
+  //     console.warn('视频号日数据同步失败(可忽略):', e);
+  //   } finally {
+  //     detailLoading.value = false;
+  //   }
+  // }
   
   // 先用列表行做“瞬时占位”(列表来自区间汇总,可能不等于 works 表累计值)
   workDetailData.value = {
@@ -816,6 +906,8 @@ async function handleView(row: WorkData) {
     collectCount: row.collectsCount || 0,
     shareCount: row.sharesCount || 0,
     fansIncrease: 0,
+    followCount: 0,
+    recommendCount: 0,
     coverClickRate: '0',
     avgWatchDuration: '0',
     completionRate: '0',
@@ -855,6 +947,8 @@ async function loadWorkBase(workId: number) {
       collectCount: toIntSafe(data.yesterdayCollectCount ?? 0),
       shareCount: toIntSafe(data.yesterdayShareCount ?? 0),
       fansIncrease: toIntSafe(data.yesterdayFansIncrease ?? 0),
+      followCount: toIntSafe(data.yesterdayFollowCount ?? 0),
+      recommendCount: toIntSafe(data.yesterdayRecommendCount ?? 0),
       coverClickRate: String(data.yesterdayCoverClickRate ?? '0'),
       avgWatchDuration: String(data.yesterdayAvgWatchDuration ?? '0'),
       completionRate: String(data.yesterdayCompletionRate ?? '0'),

+ 7 - 0
database/migrations/add_exposure_count_to_user_day_statistics.sql

@@ -0,0 +1,7 @@
+-- 为 user_day_statistics 表添加 exposure_count(曝光数/展现量)
+-- 数据来源:小红书创作者中心 - 账号概览 - 笔记数据 - 观看数据 - 曝光趋势
+
+USE media_manager;
+
+ALTER TABLE user_day_statistics
+ADD COLUMN exposure_count INT NOT NULL DEFAULT 0 COMMENT '曝光数/展现量' AFTER play_count;

+ 38 - 0
database/migrations/fix_platform_accounts_timestamp_format.sql

@@ -0,0 +1,38 @@
+-- 修复 platform_accounts 表的 created_at 和 updated_at 时间格式
+-- 将 TIMESTAMP 类型改为 DATETIME 类型,确保时间格式为 2026-02-05 12:22:22
+-- 执行日期: 2026-02-05
+
+USE media_manager;
+
+-- 步骤1: 设置会话时区为东八区(确保时间转换正确)
+SET time_zone = '+08:00';
+
+-- 步骤2: 修改 created_at 字段类型为 DATETIME
+-- MySQL 会自动将 TIMESTAMP 转换为 DATETIME,保持时间值不变
+ALTER TABLE platform_accounts 
+MODIFY COLUMN created_at DATETIME NULL;
+
+ALTER TABLE platform_accounts 
+MODIFY COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP;
+
+-- 步骤3: 修改 updated_at 字段类型为 DATETIME
+ALTER TABLE platform_accounts 
+MODIFY COLUMN updated_at DATETIME NULL;
+
+ALTER TABLE platform_accounts 
+MODIFY COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP;
+
+-- 步骤4: 验证修改结果
+DESCRIBE platform_accounts;
+
+SELECT 
+    id, 
+    account_name, 
+    platform,
+    DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at_formatted,
+    DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at_formatted,
+    created_at,
+    updated_at
+FROM platform_accounts 
+ORDER BY id DESC 
+LIMIT 10;

+ 3 - 2
database/schema.sql

@@ -80,8 +80,8 @@ CREATE TABLE IF NOT EXISTS platform_accounts (
     status ENUM('active','expired','disabled') DEFAULT 'active',
     proxy_config JSON,
     group_id INT,
-    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
     FOREIGN KEY (group_id) REFERENCES account_groups(id) ON DELETE SET NULL,
     UNIQUE KEY uk_user_platform_account (user_id, platform, account_id)
@@ -244,6 +244,7 @@ CREATE TABLE IF NOT EXISTS user_day_statistics (
     fans_count INT DEFAULT 0 COMMENT '粉丝数',
     works_count INT DEFAULT 0 COMMENT '作品数',
     play_count INT DEFAULT 0 COMMENT '播放数',
+    exposure_count INT DEFAULT 0 COMMENT '曝光数/展现量',
     comment_count INT DEFAULT 0 COMMENT '评论数',
     fans_increase INT DEFAULT 0 COMMENT '涨粉数',
     like_count INT DEFAULT 0 COMMENT '点赞数',

+ 3 - 0
pnpm-lock.yaml

@@ -50,6 +50,9 @@ importers:
       vue-router:
         specifier: ^4.2.5
         version: 4.6.4(vue@3.5.26(typescript@5.9.3))
+      xlsx:
+        specifier: ^0.18.5
+        version: 0.18.5
     devDependencies:
       '@types/node':
         specifier: ^20.10.6

+ 212 - 0
server/python/app.py

@@ -927,6 +927,134 @@ def get_works():
         return jsonify({"success": False, "error": str(e)}), 500
 
 
+# ==================== 视频号同步作品每日数据(浏览器自动化) ====================
+
+@app.route("/sync_weixin_account_works_daily_stats", methods=["POST"])
+def sync_weixin_account_works_daily_stats():
+    """
+    纯浏览器批量同步视频号账号下所有(在库)作品的每日数据到 work_day_statistics。
+    流程:打开 statistic/post → 单篇视频 → 近30天 → 遍历列表,按 exportId 匹配作品,
+    匹配则点击查看 → 详情页近30天 → 下载表格 → 解析 CSV 存入 work_day_statistics。
+    请求体: {
+        "works": [{"work_id": 906, "platform_video_id": "export/xxx"}, ...],
+        "cookie": "...",
+        "show_browser": false
+    }
+    """
+    try:
+        data = request.json or {}
+        works = data.get("works", [])
+        cookie_str = data.get("cookie", "")
+
+        if not works or not cookie_str:
+            return jsonify({
+                "success": False,
+                "error": "缺少 works 或 cookie 参数",
+            }), 400
+
+        show_browser = data.get("show_browser", False)
+        if isinstance(show_browser, str):
+            show_browser = show_browser.lower() in ("true", "1", "yes")
+        headless = not show_browser
+
+        def save_fn(stats_list):
+            if not stats_list:
+                return {"inserted": 0, "updated": 0}
+            return call_nodejs_api("POST", "/work-day-statistics/batch-dates", {"statistics": stats_list})
+
+        def update_works_fn(updates):
+            if not updates:
+                return {"updated": 0}
+            return call_nodejs_api("POST", "/works/batch-update-from-csv", {"updates": updates})
+
+        publisher = WeixinPublisher(headless=headless)
+        result = asyncio.run(publisher.sync_account_works_daily_stats_via_browser(
+            cookie_str, works, save_fn=save_fn, update_works_fn=update_works_fn, headless=headless
+        ))
+
+        if not result.get("success"):
+            return jsonify({
+                "success": False,
+                "error": result.get("error", "同步失败"),
+            }), 200
+
+        works_updated = result.get("works_updated", 0)
+        msg = f"批量同步完成: 处理 {result.get('total_processed', 0)} 个作品, 跳过 {result.get('total_skipped', 0)} 个, 新增 {result.get('inserted', 0)} 条, 更新 {result.get('updated', 0)} 条"
+        if works_updated > 0:
+            msg += f", works 表更新 {works_updated} 条"
+        return jsonify({
+            "success": True,
+            "message": msg,
+            "total_processed": result.get("total_processed", 0),
+            "total_skipped": result.get("total_skipped", 0),
+            "inserted": result.get("inserted", 0),
+            "updated": result.get("updated", 0),
+            "works_updated": works_updated,
+        })
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
+@app.route("/sync_weixin_work_daily_stats", methods=["POST"])
+def sync_weixin_work_daily_stats():
+    """
+    通过浏览器自动化同步单个视频号作品的每日数据到 work_day_statistics。
+    请求体: { "work_id": 906, "platform_video_id": "export/xxx", "cookie": "..." }
+    """
+    try:
+        data = request.json or {}
+        work_id = data.get("work_id")
+        platform_video_id = (data.get("platform_video_id") or "").strip()
+        cookie_str = data.get("cookie", "")
+
+        if not work_id or not platform_video_id or not cookie_str:
+            return jsonify({
+                "success": False,
+                "error": "缺少 work_id、platform_video_id 或 cookie 参数",
+            }), 400
+
+        work_id = int(work_id)
+        # show_browser=True 时显示浏览器窗口,便于观察点击操作
+        show_browser = data.get("show_browser", True)
+        if isinstance(show_browser, str):
+            show_browser = show_browser.lower() in ("true", "1", "yes")
+        headless = not show_browser
+        print(f"[SyncWXDailyStats] headless={headless} (show_browser={show_browser})", flush=True)
+        publisher = WeixinPublisher(headless=headless)
+        result = asyncio.run(publisher.sync_work_daily_stats_via_browser(
+            cookie_str, work_id, platform_video_id
+        ))
+
+        if not result.get("success"):
+            return jsonify({
+                "success": False,
+                "error": result.get("error", "同步失败"),
+            }), 200
+
+        stats = result.get("statistics") or []
+        if not stats:
+            return jsonify({
+                "success": True,
+                "message": "无新数据需要保存",
+                "inserted": 0,
+                "updated": 0,
+            })
+
+        save_result = call_nodejs_api("POST", "/work-day-statistics/batch-dates", {
+            "statistics": stats,
+        })
+        return jsonify({
+            "success": True,
+            "message": f"同步成功: 新增 {save_result.get('inserted', 0)} 条, 更新 {save_result.get('updated', 0)} 条",
+            "inserted": save_result.get("inserted", 0),
+            "updated": save_result.get("updated", 0),
+        })
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
 # ==================== 保存作品日统计数据接口 ====================
 
 @app.route("/work_day_statistics", methods=["POST"])
@@ -1466,6 +1594,90 @@ def get_account_info():
         return jsonify({"success": False, "error": str(e)}), 500
 
 
+# ==================== 百家号作品每日数据辅助接口 ====================
+
+@app.route("/baijiahao/article_stats", methods=["POST"])
+def baijiahao_article_stats():
+    """
+    百家号:代理调用 /author/eco/statistics/articleListStatistic
+
+    请求体:
+    {
+        "cookie": "...",
+        "start_day": "YYYYMMDD",
+        "end_day": "YYYYMMDD",
+        "type": "small_video_v2|video|news",
+        "num": 1,
+        "count": 10
+    }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        start_day = data.get("start_day", "")
+        end_day = data.get("end_day", "")
+        stat_type = data.get("type", "video")
+        num = int(data.get("num", 1) or 1)
+        count = int(data.get("count", 10) or 10)
+
+        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_article_stats(
+                cookie_str,
+                start_day=start_day,
+                end_day=end_day,
+                stat_type=stat_type,
+                num=num,
+                count=count,
+            )
+        )
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
+@app.route("/baijiahao/trend_data", methods=["POST"])
+def baijiahao_trend_data():
+    """
+    百家号:代理调用 /author/eco/statistic/gettrenddata
+
+    请求体:
+    {
+        "cookie": "...",
+        "nid": "文章/视频 nid 或 article_id"
+    }
+    """
+    try:
+        data = request.json or {}
+        cookie_str = data.get("cookie", "")
+        nid = data.get("nid", "")
+
+        if not cookie_str:
+            return jsonify({"success": False, "error": "缺少 cookie 参数"}), 400
+        if not nid:
+            return jsonify({"success": False, "error": "缺少 nid 参数"}), 400
+
+        PublisherClass = get_publisher("baijiahao")
+        publisher = PublisherClass(headless=HEADLESS_MODE)
+        result = asyncio.run(
+            publisher.get_trend_data(
+                cookie_str,
+                nid=str(nid),
+            )
+        )
+        return jsonify(result)
+    except Exception as e:
+        traceback.print_exc()
+        return jsonify({"success": False, "error": str(e)}), 500
+
+
 # ==================== 健康检查 ====================
 
 @app.route("/health", methods=["GET"])

+ 150 - 124
server/python/platforms/baijiahao.py

@@ -1067,141 +1067,167 @@ class BaijiahaoPublisher(BasePublisher):
             next_page=next_page
         )
     
-    async def check_login_status(self, cookies: str) -> dict:
+    async def get_article_stats(
+        self,
+        cookies: str,
+        start_day: str,
+        end_day: str,
+        stat_type: str,
+        num: int,
+        count: int,
+    ) -> dict:
         """
-        检查百家号 Cookie 登录状态
-        使用直接 HTTP API 调用,不使用浏览器
+        调用百家号 /author/eco/statistics/articleListStatistic 接口(不依赖浏览器 token),用于作品列表维度的每日数据。
         """
         import aiohttp
         
-        print(f"[{self.platform_name}] 检查登录状态 (使用 API)")
+        print(f"[{self.platform_name}] get_article_stats: {start_day}-{end_day}, type={stat_type}, num={num}, count={count}")
         
-        try:
-            # 解析 cookies
-            cookie_list = self.parse_cookies(cookies)
-            cookie_dict = {c['name']: c['value'] for c in cookie_list}
+        # 解析 cookies
+        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,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+            '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',
+            'Upgrade-Insecure-Requests': '1',
+        }
+        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/single',
+            '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:
+            # 0) 先访问 single 页面建立会话上下文(与 Node 端 UI 打开的页面一致)
+            try:
+                await session.get(
+                    'https://baijiahao.baidu.com/builder/rc/analysiscontent/single',
+                    headers=session_headers,
+                    timeout=aiohttp.ClientTimeout(total=20),
+                )
+            except Exception as e:
+                print(f"[{self.platform_name}] warmup single page failed (non-fatal): {e}")
             
-            # 重要:百家号需要先访问主页建立会话上下文
-            session_headers = {
-                'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
-                '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',
-                # Cookie 由 session 管理
-                'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
-                'Accept-Encoding': 'gzip, deflate, br',
-                'Connection': 'keep-alive',
-                'Upgrade-Insecure-Requests': '1',
-                'Sec-Fetch-Dest': 'document',
-                'Sec-Fetch-Mode': 'navigate',
-                'Sec-Fetch-Site': 'none',
-                'Sec-Fetch-User': '?1',
-                'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
-                'sec-ch-ua-mobile': '?0',
-                'sec-ch-ua-platform': '"Windows"'
-            }
+            # 1) 调用 articleListStatistic
+            api_url = (
+                "https://baijiahao.baidu.com/author/eco/statistics/articleListStatistic"
+                f"?start_day={start_day}&end_day={end_day}&type={stat_type}&num={num}&count={count}"
+            )
+            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}] articleListStatistic non-JSON response: {text[:1000]}")
+                    raise
             
-            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',
-                # Cookie 由 session 管理
-                'Referer': 'https://baijiahao.baidu.com/builder/rc/home',
-                'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
-                'Accept-Encoding': 'gzip, deflate, br',
-                'Connection': 'keep-alive',
-                'Sec-Fetch-Dest': 'empty',
-                'Sec-Fetch-Mode': 'cors',
-                'Sec-Fetch-Site': 'same-origin',
-                'sec-ch-ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"',
-                'sec-ch-ua-mobile': '?0',
-                'sec-ch-ua-platform': '"Windows"'
-            }
+            errno = data.get('errno')
+            errmsg = data.get('errmsg')
+            print(f"[{self.platform_name}] articleListStatistic: http={status}, errno={errno}, msg={errmsg}")
             
-            async with aiohttp.ClientSession(cookies=cookie_dict) as session:
-                # 步骤 0: 先访问主页建立会话上下文(关键步骤!)
-                print(f"[{self.platform_name}] [0/2] 访问主页建立会话上下文...")
-                async with session.get(
-                    'https://baijiahao.baidu.com/builder/rc/home',
-                    headers=session_headers,
-                    timeout=aiohttp.ClientTimeout(total=30)
-                ) as home_response:
-                    home_status = home_response.status
-                    print(f"[{self.platform_name}] 主页访问状态: {home_status}")
-                
-                # 短暂等待确保会话建立
-                await asyncio.sleep(1)
-                
-                # 步骤 1: 调用 API 检查登录状态
-                print(f"[{self.platform_name}] [1/2] 调用 appinfo API 检查登录状态...")
-                
-                async with session.get(
-                    'https://baijiahao.baidu.com/builder/app/appinfo',
-                    headers=headers,
-                    timeout=aiohttp.ClientTimeout(total=30)
-                ) as response:
-                    api_result = await response.json()
-                
-                errno = api_result.get('errno')
-                print(f"[{self.platform_name}] API 完整响应: {json.dumps(api_result, ensure_ascii=False)[:500]}")
-                print(f"[{self.platform_name}] API 响应: errno={errno}")
-                
-                # errno 为 0 表示请求成功
-                if errno == 0:
-                    # 检查是否有用户数据
-                    user_data = api_result.get('data', {}).get('user', {})
-                    if user_data:
-                        # 检查账号状态
-                        status = user_data.get('status', '')
-                        account_name = user_data.get('name') or user_data.get('uname', '')
-                        
-                        # 有效的账号状态:audit(审核中), pass(已通过), normal(正常), newbie(新手)
-                        valid_statuses = ['audit', 'pass', 'normal', 'newbie']
-                        
-                        if status in valid_statuses and account_name:
-                            print(f"[{self.platform_name}] ✓ 登录状态有效: {account_name} (status={status})")
-                            return {
-                                "success": True,
-                                "valid": True,
-                                "need_login": False,
-                                "message": "登录状态有效"
-                            }
-                        else:
-                            print(f"[{self.platform_name}] 账号状态异常: status={status}, name={account_name}")
-                            return {
-                                "success": True,
-                                "valid": False,
-                                "need_login": True,
-                                "message": f"账号状态异常: {status}"
-                            }
-                    else:
-                        print(f"[{self.platform_name}] 无用户数据,Cookie 可能无效")
-                        return {
-                            "success": True,
-                            "valid": False,
-                            "need_login": True,
-                            "message": "无用户数据"
-                        }
-                
-                # errno 非 0 表示请求失败
-                # 常见错误码:110 = 未登录
-                error_msg = api_result.get('errmsg', '未知错误')
-                print(f"[{self.platform_name}] Cookie 无效: errno={errno}, msg={error_msg}")
-                
-                return {
-                    "success": True,
-                    "valid": False,
-                    "need_login": True,
-                    "message": error_msg
-                }
-            
-        except Exception as e:
-            import traceback
-            traceback.print_exc()
             return {
-                "success": False,
-                "valid": False,
-                "need_login": True,
-                "error": str(e)
+                "success": status == 200 and errno == 0,
+                "status": status,
+                "errno": errno,
+                "errmsg": errmsg,
+                "data": data.get('data') if isinstance(data, dict) else None,
             }
     
+    async def get_trend_data(
+        self,
+        cookies: str,
+        nid: str,
+    ) -> dict:
+        """
+        调用百家号 /author/eco/statistic/gettrenddata 接口,获取单作品的按日统计数据(basic_list)。
+        """
+        import aiohttp
+        
+        print(f"[{self.platform_name}] get_trend_data: nid={nid}")
+        
+        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,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7',
+            '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',
+            'Upgrade-Insecure-Requests': '1',
+        }
+        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/single',
+            '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:
+            # 0) warmup
+            try:
+                await session.get(
+                    'https://baijiahao.baidu.com/builder/rc/analysiscontent/single',
+                    headers=session_headers,
+                    timeout=aiohttp.ClientTimeout(total=20),
+                )
+            except Exception as e:
+                print(f"[{self.platform_name}] warmup single page (trend) failed (non-fatal): {e}")
+            
+            api_url = (
+                "https://baijiahao.baidu.com/author/eco/statistic/gettrenddata"
+                f"?nid={nid}&trend_type=all&data_type=addition"
+            )
+            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}] gettrenddata non-JSON response: {text[:1000]}")
+                    raise
+        
+        errno = data.get('errno')
+        errmsg = data.get('errmsg')
+        print(f"[{self.platform_name}] gettrenddata: http={status}, errno={errno}, msg={errmsg}")
+        
+        return {
+            "success": status == 200 and errno == 0,
+            "status": status,
+            "errno": errno,
+            "errmsg": errmsg,
+            "data": data.get('data') if isinstance(data, dict) else None,
+        }
+    
+    async def check_login_status(self, cookies: str) -> dict:
+        """
+        检查百家号 Cookie 登录状态
+        现在与其他平台保持一致,直接复用 BasePublisher 的浏览器检测逻辑:
+        - 使用 Playwright 打开后台页面
+        - 根据是否跳转到登录页 / 是否出现登录弹窗或风控提示,判断登录是否有效
+        """
+        print(f"[{self.platform_name}] 检查登录状态 (使用通用浏览器逻辑)")
+        # 直接调用父类的实现,保持与抖音/小红书/视频号一致
+        return await super().check_login_status(cookies)
+    
     async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
         """获取百家号作品评论"""
         # TODO: 实现评论获取逻辑

+ 598 - 12
server/python/platforms/weixin.py

@@ -325,20 +325,22 @@ class WeixinPublisher(BasePublisher):
             print(f"[{self.platform_name}] 使用代理: {proxy.get('server')}", flush=True)
         
         # 参考 matrix: 使用系统内的 Chrome 浏览器,避免 H264 编码错误
-        # 如果没有安装 Chrome,则使用默认 Chromium
+        # 非 headless 时添加 slow_mo 便于观察点击操作
+        launch_opts = {"headless": self.headless}
+        if not self.headless:
+            launch_opts["slow_mo"] = 400  # 每个操作间隔 400ms,便于观看
+            print(f"[{self.platform_name}] 有头模式 + slow_mo=400ms,浏览器将可见", flush=True)
         try:
-            self.browser = await playwright.chromium.launch(
-                headless=self.headless,
-                channel="chrome",  # 使用系统 Chrome
-                proxy=proxy if proxy and proxy.get('server') else None
-            )
-            print(f"[{self.platform_name}] 使用系统 Chrome 浏览器")
+            launch_opts["channel"] = "chrome"
+            if proxy and proxy.get("server"):
+                launch_opts["proxy"] = proxy
+            self.browser = await playwright.chromium.launch(**launch_opts)
+            print(f"[{self.platform_name}] 使用系统 Chrome 浏览器", flush=True)
         except Exception as e:
-            print(f"[{self.platform_name}] Chrome 不可用,使用 Chromium: {e}")
-            self.browser = await playwright.chromium.launch(
-                headless=self.headless,
-                proxy=proxy if proxy and proxy.get('server') else None
-            )
+            print(f"[{self.platform_name}] Chrome 不可用,使用 Chromium: {e}", flush=True)
+            if "channel" in launch_opts:
+                del launch_opts["channel"]
+            self.browser = await playwright.chromium.launch(**launch_opts)
         
         # 设置 HTTP Headers 防止重定向
         headers = {
@@ -1209,6 +1211,590 @@ class WeixinPublisher(BasePublisher):
         return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
     
 
+    async def sync_work_daily_stats_via_browser(
+        self, cookies: str, work_id: int, platform_video_id: str
+    ) -> dict:
+        """
+        通过浏览器自动化同步单个作品的每日数据到 work_day_statistics。
+        流程:
+        1. 打开 statistic/post 页,点击单篇视频 tab,点击近30天
+        2. 监听 post_list 接口,根据 exportId 匹配 platform_video_id 得到 objectId
+        3. 找到 data-row-key=objectId 的行,点击「查看」
+        4. 进入详情页,点击数据详情的近30天,点击下载表格
+        5. 解析 CSV 并返回 statistics 列表(供 Node 保存)
+        """
+        import csv
+        import tempfile
+        from pathlib import Path
+
+        result = {"success": False, "error": "", "statistics": [], "inserted": 0, "updated": 0}
+        post_list_data = {"list": []}
+
+        async def handle_response(response):
+            try:
+                if "statistic/post_list" in response.url and response.request.method == "POST":
+                    try:
+                        body = await response.json()
+                        if body.get("errCode") == 0 and body.get("data"):
+                            post_list_data["list"] = body.get("data", {}).get("list", [])
+                    except Exception:
+                        pass
+            except Exception:
+                pass
+
+        try:
+            await self.init_browser()
+            cookie_list = self.parse_cookies(cookies)
+            await self.set_cookies(cookie_list)
+            if not self.page:
+                raise Exception("Page not initialized")
+
+            self.page.on("response", handle_response)
+
+            # 1. 打开数据分析-作品数据页
+            print(f"[{self.platform_name}] 打开数据分析页...", flush=True)
+            await self.page.goto("https://channels.weixin.qq.com/platform/statistic/post", timeout=30000)
+            if not self.headless:
+                print(f"[{self.platform_name}] 浏览器已打开,请将窗口置于前台观看操作(等待 5 秒)...", flush=True)
+                await asyncio.sleep(5)
+            else:
+                await asyncio.sleep(3)
+            if "login" in self.page.url:
+                raise Exception("Cookie 已过期,请重新登录")
+
+            # 2. 点击「单篇视频」tab
+            tab_sel = "div.weui-desktop-tab__navs ul li:nth-child(2) a"
+            try:
+                await self.page.wait_for_selector(tab_sel, timeout=8000)
+                await self.page.click(tab_sel)
+            except Exception:
+                tab_sel = "a:has-text('单篇视频')"
+                await self.page.click(tab_sel)
+            await asyncio.sleep(2)
+
+            # 3. 点击「近30天」(单篇视频页的日期范围筛选)
+            # 选择器优先级:精确匹配单篇视频区域内的日期范围 radio 组
+            radio_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 div.card-body div.filter-wrap div:nth-child(2) 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天')",
+            ]
+            clicked = False
+            for sel in radio_selectors:
+                try:
+                    el = self.page.locator(sel).first
+                    if await el.count() > 0:
+                        await el.click()
+                        clicked = True
+                        print(f"[{self.platform_name}] 已点击近30天按钮 (selector: {sel[:50]}...)", flush=True)
+                        break
+                except Exception as e:
+                    continue
+            if not clicked:
+                print(f"[{self.platform_name}] 警告: 未找到近30天按钮,继续尝试...", flush=True)
+            await asyncio.sleep(3)
+
+            # 4. 从 post_list 响应中找 exportId -> objectId
+            export_id_to_object = {}
+            for item in post_list_data["list"]:
+                eid = (item.get("exportId") or "").strip()
+                oid = (item.get("objectId") or "").strip()
+                if eid and oid:
+                    export_id_to_object[eid] = oid
+
+            object_id = export_id_to_object.get(platform_video_id) or export_id_to_object.get(
+                platform_video_id.strip()
+            )
+            if not object_id:
+                # 尝试宽松匹配(platform_video_id 可能带前缀)
+                for eid, oid in export_id_to_object.items():
+                    if platform_video_id in eid or eid in platform_video_id:
+                        object_id = oid
+                        break
+            if not object_id:
+                result["error"] = f"未在 post_list 中匹配到 exportId={platform_video_id}"
+                print(f"[{self.platform_name}] {result['error']}", flush=True)
+                return result
+
+            # 5. 找到 data-row-key=objectId 的行,点击「查看」
+            view_btn = self.page.locator(f'tr[data-row-key="{object_id}"] a.detail-wrap, tr[data-row-key="{object_id}"] a:has-text("查看")')
+            try:
+                await view_btn.first.wait_for(timeout=5000)
+                await view_btn.first.click()
+            except Exception as e:
+                view_btn = self.page.locator(f'tr[data-row-key="{object_id}"] a')
+                if await view_btn.count() > 0:
+                    await view_btn.first.click()
+                else:
+                    raise Exception(f"未找到 objectId={object_id} 的查看按钮: {e}")
+            await asyncio.sleep(3)
+
+            # 6. 详情页:点击数据详情的「近30天」,再点击「下载表格」
+            detail_radio = "div.post-statistic-common div.filter-wrap label:nth-child(2)"
+            for sel in [detail_radio, "div.main-body label:has-text('近30天')"]:
+                try:
+                    el = self.page.locator(sel).first
+                    if await el.count() > 0:
+                        await el.click()
+                        break
+                except Exception:
+                    continue
+            await asyncio.sleep(2)
+
+            # 保存到 server/tmp 目录
+            download_dir = Path(__file__).resolve().parent.parent.parent / "tmp"
+            download_dir.mkdir(parents=True, exist_ok=True)
+
+            async with self.page.expect_download(timeout=15000) as download_info:
+                download_btn = self.page.locator("div.post-statistic-common div.filter-extra a, a:has-text('下载表格')")
+                if await download_btn.count() == 0:
+                    raise Exception("未找到「下载表格」按钮")
+                await download_btn.first.click()
+            download = await download_info.value
+            save_path = download_dir / f"work_{work_id}_{int(time.time())}.csv"
+            await download.save_as(save_path)
+
+            # 7. 解析 CSV -> statistics
+            stats_list = []
+            with open(save_path, "r", encoding="utf-8-sig", errors="replace") as f:
+                reader = csv.DictReader(f)
+                rows = list(reader)
+            for row in rows:
+                date_val = (
+                    row.get("日期")
+                    or row.get("date")
+                    or row.get("时间")
+                    or row.get("时间周期", "")
+                ).strip()
+                if not date_val:
+                    continue
+                dt = None
+                norm = date_val[:10].replace("年", "-").replace("月", "-").replace("日", "-").replace("/", "-")
+                if len(norm) >= 8 and norm.count("-") >= 2:
+                    parts = norm.split("-")
+                    if len(parts) == 3:
+                        try:
+                            y, m, d = int(parts[0]), int(parts[1]), int(parts[2])
+                            if 2000 <= y <= 2100 and 1 <= m <= 12 and 1 <= d <= 31:
+                                dt = datetime(y, m, d)
+                        except (ValueError, IndexError):
+                            pass
+                if not dt:
+                    for fmt in ["%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y"]:
+                        try:
+                            dt = datetime.strptime((date_val.split()[0] if date_val else "")[:10], fmt)
+                            break
+                        except (ValueError, IndexError):
+                            dt = None
+                if not dt:
+                    continue
+                rec_date = dt.strftime("%Y-%m-%d")
+                play = self._parse_count(row.get("播放", "") or row.get("播放量", "") or row.get("play_count", "0"))
+                like = self._parse_count(row.get("点赞", "") or row.get("like_count", "0"))
+                comment = self._parse_count(row.get("评论", "") or row.get("comment_count", "0"))
+                share = self._parse_count(row.get("分享", "") or row.get("share_count", "0"))
+                collect = self._parse_count(row.get("收藏", "") or row.get("collect_count", "0"))
+                comp_rate = (row.get("完播率", "") or row.get("completion_rate", "0")).strip().rstrip("%") or "0"
+                avg_dur = (row.get("平均播放时长", "") or row.get("avg_watch_duration", "0")).strip()
+                stats_list.append({
+                    "work_id": work_id,
+                    "record_date": rec_date,
+                    "play_count": play,
+                    "like_count": like,
+                    "comment_count": comment,
+                    "share_count": share,
+                    "collect_count": collect,
+                    "completion_rate": comp_rate,
+                    "avg_watch_duration": avg_dur,
+                })
+            result["statistics"] = stats_list
+            result["success"] = True
+            try:
+                os.remove(save_path)
+            except Exception:
+                pass
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            result["error"] = str(e)
+        finally:
+            try:
+                await self.close_browser()
+            except Exception:
+                pass
+        return result
+
+    async def sync_account_works_daily_stats_via_browser(
+        self,
+        cookies: str,
+        works: List[dict],
+        save_fn=None,
+        update_works_fn=None,
+        headless: bool = True,
+    ) -> dict:
+        """
+        纯浏览器批量同步账号下所有作品(在库的)的每日数据到 work_day_statistics。
+        流程:
+        1. 打开 statistic/post → 点击单篇视频 → 点击近30天
+        2. 【首次】监听 post_list 接口 → 解析响应更新 works 表 yesterday_* 字段
+        3. 监听 post_list 获取 exportId->objectId 映射
+        4. 遍历 post_list 的每一条:
+           - 若 exportId 在 works 的 platform_video_id 中无匹配 → 跳过
+           - 若匹配 → 找到 data-row-key=objectId 的行,点击「查看」
+           - 详情页:默认近7天,直接监听 feed_aggreagate_data_by_tab_type 接口
+           - 从「全部」tab 解析 browse/like/comment/forward/fav/follow,日期从昨天往前推
+           - 通过 save_fn 存入 work_day_statistics
+           - 返回列表页,继续下一条
+        works: [{"work_id": int, "platform_video_id": str}, ...]
+        save_fn: (stats_list: List[dict]) -> {inserted, updated},由调用方传入,用于调用 Node batch-dates
+        update_works_fn: (updates: List[dict]) -> {updated},由调用方传入,用于将 post_list 解析数据更新到 works 表(仅首次调用)
+        """
+        from pathlib import Path
+        from datetime import timedelta
+
+        result = {
+            "success": True,
+            "error": "",
+            "total_processed": 0,
+            "total_skipped": 0,
+            "inserted": 0,
+            "updated": 0,
+            "works_updated": 0,
+        }
+        # platform_video_id(exportId) -> work_id
+        export_id_to_work = {}
+        for w in works:
+            pvid = (w.get("platform_video_id") or w.get("platformVideoId") or "").strip()
+            wid = w.get("work_id") or w.get("workId")
+            if pvid and wid is not None:
+                export_id_to_work[pvid] = int(wid)
+                # 兼容可能带/不带前缀(如 export/xxx vs xxx)
+                if "/" in pvid:
+                    export_id_to_work[pvid.split("/")[-1]] = int(wid)
+
+        post_list_data = {"list": []}
+        feed_aggreagate_data = {"body": None}
+
+        async def handle_response(response):
+            try:
+                url = response.url
+                if "statistic/post_list" in url:
+                    try:
+                        body = await response.json()
+                        if body.get("errCode") == 0 and body.get("data"):
+                            post_list_data["list"] = body.get("data", {}).get("list", [])
+                    except Exception:
+                        pass
+                elif "feed_aggreagate_data_by_tab_type" in url:
+                    try:
+                        body = await response.json()
+                        if body.get("errCode") == 0 and body.get("data"):
+                            feed_aggreagate_data["body"] = body
+                    except Exception:
+                        pass
+            except Exception:
+                pass
+
+        try:
+            await self.init_browser()
+            cookie_list = self.parse_cookies(cookies)
+            await self.set_cookies(cookie_list)
+            if not self.page:
+                raise Exception("Page not initialized")
+
+            self.page.on("response", handle_response)
+
+            # 1. 打开数据分析-作品数据页
+            print(f"[{self.platform_name}] 打开数据分析页...", flush=True)
+            await self.page.goto("https://channels.weixin.qq.com/platform/statistic/post", timeout=30000)
+            if not headless:
+                print(f"[{self.platform_name}] 浏览器已打开,请将窗口置于前台观看操作(等待 5 秒)...", flush=True)
+                await asyncio.sleep(5)
+            else:
+                await asyncio.sleep(3)
+            if "login" in self.page.url:
+                raise Exception("Cookie 已过期,请重新登录")
+
+            # 2. 点击「单篇视频」tab
+            tab_sel = "div.weui-desktop-tab__navs ul li:nth-child(2) a"
+            try:
+                await self.page.wait_for_selector(tab_sel, timeout=8000)
+                await self.page.click(tab_sel)
+            except Exception:
+                tab_sel = "a:has-text('单篇视频')"
+                await self.page.click(tab_sel)
+            await asyncio.sleep(2)
+
+            # 3. 点击「近30天」前清空 list,点击后等待 handler 捕获带 fullPlayRate 的 post_list
+            post_list_data["list"] = []
+            radio_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天')",
+            ]
+            clicked = False
+            for sel in radio_selectors:
+                try:
+                    el = self.page.locator(sel).first
+                    if await el.count() > 0:
+                        await el.click()
+                        clicked = True
+                        print(f"[{self.platform_name}] 已点击近30天 (selector: {sel[:40]}...)", flush=True)
+                        break
+                except Exception:
+                    continue
+            if not clicked:
+                print(f"[{self.platform_name}] 警告: 未找到近30天按钮", flush=True)
+            await asyncio.sleep(5)
+
+            # 4. 从 post_list 获取列表
+            items = post_list_data["list"]
+            if not items:
+                result["error"] = "未监听到 post_list 或列表为空"
+                print(f"[{self.platform_name}] {result['error']}", flush=True)
+                return result
+
+            # 4.5 【仅首次】从 post_list 接口响应解析数据 → 更新 works 表(不再下载 CSV)
+            # post_list 返回字段映射: readCount->播放量, likeCount->点赞, commentCount->评论, forwardCount->分享,
+            # fullPlayRate->完播率(0-1小数), avgPlayTimeSec->平均播放时长(秒), exportId->匹配 work_id
+            if update_works_fn and items:
+                try:
+                    updates = []
+                    for it in items:
+                        eid = (it.get("exportId") or "").strip()
+                        if not eid:
+                            continue
+                        work_id = export_id_to_work.get(eid)
+                        if work_id is None:
+                            for k, v in export_id_to_work.items():
+                                if eid in k or k in eid:
+                                    work_id = v
+                                    break
+                        if work_id is None:
+                            continue
+                        read_count = int(it.get("readCount") or 0)
+                        like_count = int(it.get("likeCount") or 0)
+                        comment_count = int(it.get("commentCount") or 0)
+                        forward_count = int(it.get("forwardCount") or 0)
+                        follow_count = int(it.get("followCount") or 0)
+                        full_play_rate = it.get("fullPlayRate")
+                        if full_play_rate is not None:
+                            comp_rate = f"{float(full_play_rate) * 100:.2f}%"
+                        else:
+                            comp_rate = "0"
+                        avg_sec = it.get("avgPlayTimeSec")
+                        if avg_sec is not None:
+                            avg_dur = f"{float(avg_sec):.2f}秒"
+                        else:
+                            avg_dur = "0"
+                        updates.append({
+                            "work_id": work_id,
+                            "yesterday_play_count": read_count,
+                            "yesterday_like_count": like_count,
+                            "yesterday_comment_count": comment_count,
+                            "yesterday_share_count": forward_count,
+                            "yesterday_follow_count": follow_count,
+                            "yesterday_completion_rate": comp_rate,
+                            "yesterday_avg_watch_duration": avg_dur,
+                        })
+                    if updates:
+                        try:
+                            save_result = update_works_fn(updates)
+                            result["works_updated"] = save_result.get("updated", 0)
+                        except Exception as api_err:
+                            import traceback
+                            traceback.print_exc()
+                except Exception as e:
+                    import traceback
+                    traceback.print_exc()
+                    print(f"[{self.platform_name}] 解析 post_list 更新 works 失败: {e}", flush=True)
+
+            # 辅助:点击单篇视频 + 近30天,恢复列表视图(go_back 后会回到全部视频页)
+            async def ensure_single_video_near30():
+                tab_sel = "div.weui-desktop-tab__navs ul li:nth-child(2) a"
+                try:
+                    await self.page.wait_for_selector(tab_sel, timeout=8000)
+                    await self.page.click(tab_sel)
+                except Exception:
+                    await self.page.click("a:has-text('单篇视频')")
+                await asyncio.sleep(2)
+                for sel in [
+                    "div.post-single-wrap div.weui-desktop-radio-group.radio-group label:has-text('近30天')",
+                    "div.post-single-wrap label:has-text('近30天')",
+                    "div.weui-desktop-radio-group label:has-text('近30天')",
+                    "label:has-text('近30天')",
+                ]:
+                    try:
+                        el = self.page.locator(sel).first
+                        if await el.count() > 0:
+                            await el.click()
+                            break
+                    except Exception:
+                        continue
+                await asyncio.sleep(3)
+
+            # 5. 遍历每一条,按 exportId 匹配作品
+            processed_export_ids = set()
+
+            for idx, item in enumerate(items):
+                eid = (item.get("exportId") or "").strip()
+                oid = (item.get("objectId") or "").strip()
+                if not oid:
+                    continue
+
+                # 已处理过的跳过(理论上循环顺序即处理顺序,此处做双重保险)
+                if eid in processed_export_ids:
+                    print(f"[{self.platform_name}] 跳过 [{idx+1}] exportId={eid} (已处理)", flush=True)
+                    continue
+
+                # go_back 后回到全部视频页,需重新点击单篇视频+近30天
+                if idx > 0:
+                    await ensure_single_video_near30()
+
+                # 匹配 work_id
+                work_id = export_id_to_work.get(eid)
+                if work_id is None:
+                    for k, v in export_id_to_work.items():
+                        if eid in k or k in eid:
+                            work_id = v
+                            break
+                if work_id is None:
+                    result["total_skipped"] += 1
+                    print(f"[{self.platform_name}] 跳过 [{idx+1}] exportId={eid} (库中无对应作品)", flush=True)
+                    continue
+
+                # 点击「查看」:Ant Design 表格 tr[data-row-key] > td > div.slot-wrap > a.detail-wrap
+                # 操作列可能在 ant-table-fixed-right 内,优先尝试
+                view_selectors = [
+                    f'div.ant-table-fixed-right tr[data-row-key="{oid}"] a.detail-wrap',
+                    f'tr[data-row-key="{oid}"] a.detail-wrap',
+                    f'tr[data-row-key="{oid}"] td a.detail-wrap',
+                    f'tr[data-row-key="{oid}"] a:has-text("查看")',
+                    f'tr[data-row-key="{oid}"] a',
+                ]
+                clicked = False
+                for sel in view_selectors:
+                    view_btn = self.page.locator(sel)
+                    if await view_btn.count() > 0:
+                        try:
+                            await view_btn.first.wait_for(timeout=3000)
+                            await view_btn.first.click()
+                            clicked = True
+                            print(f"[{self.platform_name}] 已点击查看 (selector: {sel[:40]}...)", flush=True)
+                            break
+                        except Exception as e:
+                            continue
+                if not clicked:
+                    print(f"[{self.platform_name}] 未找到 objectId={oid} 的查看按钮", flush=True)
+                    result["total_skipped"] += 1
+                    continue
+                await asyncio.sleep(3)
+
+                # 详情页:默认展示近7天,页面加载时自动请求 feed_aggreagate,不清空 body 避免覆盖已监听到的响应
+                await asyncio.sleep(4)
+
+                # 从 feed_aggreagate 响应解析「全部」数据
+                # 数据结构: data.dataByFanstype[].dataByTabtype[] 中 tabTypeName="全部" 或 tabType=999
+                # 日期:从昨天往前推 N 天(含昨天),数组从最早到最晚排列
+                body = feed_aggreagate_data.get("body")
+                if not body or not body.get("data"):
+                    print(f"[{self.platform_name}] work_id={work_id} 未监听到 feed_aggreagate 有效响应", flush=True)
+                    await self.page.go_back()
+                    await asyncio.sleep(2)
+                    continue
+
+                tab_all = None
+                for fan_item in body.get("data", {}).get("dataByFanstype", []):
+                    for tab_item in fan_item.get("dataByTabtype", []):
+                        if tab_item.get("tabTypeName") == "全部" or tab_item.get("tabType") == 999:
+                            tab_all = tab_item.get("data")
+                            break
+                    if tab_all is not None:
+                        break
+                if not tab_all:
+                    tab_all = body.get("data", {}).get("feedData", [{}])[0].get("totalData")
+                if not tab_all:
+                    print(f"[{self.platform_name}] work_id={work_id} 未找到「全部」数据", flush=True)
+                    await self.page.go_back()
+                    await asyncio.sleep(2)
+                    continue
+
+                browse = tab_all.get("browse", [])
+                n = len(browse)
+                if n == 0:
+                    print(f"[{self.platform_name}] work_id={work_id} browse 为空", flush=True)
+                    await self.page.go_back()
+                    await asyncio.sleep(2)
+                    continue
+
+                # 日期:昨天往前推 n 天,index 0 = 最早日
+                today = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
+                yesterday = today - timedelta(days=1)
+                start_date = yesterday - timedelta(days=n - 1)
+
+                like_arr = tab_all.get("like", [])
+                comment_arr = tab_all.get("comment", [])
+                forward_arr = tab_all.get("forward", [])
+                fav_arr = tab_all.get("fav", [])
+                follow_arr = tab_all.get("follow", [])
+
+                stats_list = []
+                for i in range(n):
+                    rec_dt = start_date + timedelta(days=i)
+                    rec_date = rec_dt.strftime("%Y-%m-%d")
+                    play = self._parse_count(browse[i] if i < len(browse) else "0")
+                    like = self._parse_count(like_arr[i] if i < len(like_arr) else "0")
+                    comment = self._parse_count(comment_arr[i] if i < len(comment_arr) else "0")
+                    share = self._parse_count(forward_arr[i] if i < len(forward_arr) else "0")
+                    follow = self._parse_count(follow_arr[i] if i < len(follow_arr) else "0")
+                    # fav[i] 不入库,follow[i] 入 follow_count
+                    stats_list.append({
+                        "work_id": work_id,
+                        "record_date": rec_date,
+                        "play_count": play,
+                        "like_count": like,
+                        "comment_count": comment,
+                        "share_count": share,
+                        "collect_count": 0,
+                        "follow_count": follow,
+                        "completion_rate": "0",
+                        "avg_watch_duration": "0",
+                    })
+                print(f"[{self.platform_name}] work_id={work_id} 从 feed_aggreagate 解析得到 {len(stats_list)} 条日统计", flush=True)
+
+                # 存入 work_day_statistics(通过 save_fn 调用 Node)
+                if save_fn and stats_list:
+                    try:
+                        save_result = save_fn(stats_list)
+                        result["inserted"] += save_result.get("inserted", 0)
+                        result["updated"] += save_result.get("updated", 0)
+                    except Exception as e:
+                        print(f"[{self.platform_name}] work_id={work_id} 保存失败: {e}", flush=True)
+
+                result["total_processed"] += 1
+                processed_export_ids.add(eid)
+
+                # 返回列表页,继续下一条(会回到全部视频页,下次循环会重新点击单篇视频+近30天)
+                await self.page.go_back()
+                await asyncio.sleep(2)
+            print(f"[{self.platform_name}] 批量同步完成: 处理 {result['total_processed']} 个作品, 跳过 {result['total_skipped']} 个", flush=True)
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            result["success"] = False
+            result["error"] = str(e)
+        finally:
+            try:
+                await self.close_browser()
+            except Exception:
+                pass
+        return result
+
     async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
         """
         获取视频号作品评论(完全参考 get_weixin_work_comments.py 的接口监听逻辑)

+ 2 - 2
server/src/models/entities/PlatformAccount.ts

@@ -63,11 +63,11 @@ export class PlatformAccount {
   @Column({ type: 'int', nullable: true, name: 'group_id' })
   groupId!: number | null;
 
-  @Column({ type: 'timestamp', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
+  @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;
 
   @Column({
-    type: 'timestamp',
+    type: 'datetime',
     name: 'updated_at',
     default: () => 'CURRENT_TIMESTAMP',
     onUpdate: 'CURRENT_TIMESTAMP',

+ 3 - 0
server/src/models/entities/UserDayStatistics.ts

@@ -21,6 +21,9 @@ export class UserDayStatistics {
   @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
   playCount!: number;
 
+  @Column({ name: 'exposure_count', type: 'int', default: 0, comment: '曝光数/展现量' })
+  exposureCount!: number;
+
   @Column({ name: 'comment_count', type: 'int', default: 0, comment: '评论数' })
   commentCount!: number;
 

+ 6 - 0
server/src/models/entities/Work.ts

@@ -75,9 +75,15 @@ export class Work {
   @Column({ name: 'yesterday_collect_count', type: 'int', default: 0 })
   yesterdayCollectCount!: number;
 
+  @Column({ name: 'yesterday_recommend_count', type: 'int', default: 0 })
+  yesterdayRecommendCount!: number;
+
   @Column({ name: 'yesterday_fans_increase', type: 'int', default: 0 })
   yesterdayFansIncrease!: number;
 
+  @Column({ name: 'yesterday_follow_count', type: 'int', default: 0 })
+  yesterdayFollowCount!: number;
+
   @Column({ name: 'yesterday_cover_click_rate', type: 'varchar', length: 50, default: '0' })
   yesterdayCoverClickRate!: string;
 

+ 6 - 0
server/src/models/entities/WorkDayStatistics.ts

@@ -30,9 +30,15 @@ export class WorkDayStatistics {
   @Column({ name: 'collect_count', type: 'int', default: 0, comment: '收藏数' })
   collectCount!: number;
 
+  @Column({ name: 'recommend_count', type: 'int', default: 0, comment: '推荐量' })
+  recommendCount!: number;
+
   @Column({ name: 'fans_increase', type: 'int', default: 0, comment: '涨粉数' })
   fansIncrease!: number;
 
+  @Column({ name: 'follow_count', type: 'int', default: 0, comment: '关注数' })
+  followCount!: number;
+
   @Column({ name: 'cover_click_rate', type: 'varchar', length: 50, default: '0', comment: '封面点击率' })
   coverClickRate!: string;
 

+ 17 - 1
server/src/routes/auth.ts

@@ -1,5 +1,6 @@
-import { Router } from 'express';
+import { Router, type Request, type Response, type NextFunction } from 'express';
 import { body } from 'express-validator';
+import { AppDataSource } from '../models/index.js';
 import { AuthService } from '../services/AuthService.js';
 import { authenticate } from '../middleware/auth.js';
 import { asyncHandler } from '../middleware/error.js';
@@ -8,6 +9,18 @@ import { validateRequest } from '../middleware/validate.js';
 const router = Router();
 const authService = new AuthService();
 
+/** 数据库未连接时返回 503,避免 TypeORM "No metadata for 'User'" 等报错 */
+function requireDatabase(_req: Request, res: Response, next: NextFunction) {
+  if (!AppDataSource.isInitialized) {
+    res.status(503).json({
+      success: false,
+      message: '数据库未连接,请检查后端启动日志是否出现 "Database connected",并确认 .env 中 DB_HOST/DB_PORT/DB_USERNAME/DB_PASSWORD/DB_DATABASE 与当前 MySQL 一致',
+    });
+    return;
+  }
+  next();
+}
+
 // 获取注册配置(公开接口)
 router.get('/config', (_req, res) => {
   // 环境变量优先,必须明确设置为 'true' 才开放注册
@@ -23,6 +36,7 @@ router.get('/config', (_req, res) => {
 // 登录
 router.post(
   '/login',
+  requireDatabase,
   [
     body('username').notEmpty().withMessage('用户名不能为空'),
     body('password').notEmpty().withMessage('密码不能为空'),
@@ -45,6 +59,7 @@ router.post(
 // 注册
 router.post(
   '/register',
+  requireDatabase,
   [
     body('username')
       .notEmpty().withMessage('用户名不能为空')
@@ -70,6 +85,7 @@ router.post(
 // 刷新 Token
 router.post(
   '/refresh',
+  requireDatabase,
   [
     body('refreshToken').notEmpty().withMessage('刷新令牌不能为空'),
     validateRequest,

+ 110 - 1
server/src/routes/internal.ts

@@ -11,7 +11,7 @@ import { validateRequest } from '../middleware/validate.js';
 import { config } from '../config/index.js';
 import { wsManager } from '../websocket/index.js';
 import { WS_EVENTS } from '@media-manager/shared';
-import { AppDataSource, PlatformAccount } from '../models/index.js';
+import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
 import { CookieManager } from '../automation/cookie.js';
 
 const router = Router();
@@ -88,6 +88,56 @@ router.post(
 );
 
 /**
+ * POST /api/internal/work-day-statistics/batch-dates
+ * 按日期批量保存作品日统计数据(用于视频号 CSV 导入等)
+ */
+router.post(
+  '/work-day-statistics/batch-dates',
+  [
+    body('statistics').isArray().withMessage('statistics 必须是数组'),
+    body('statistics.*.workId').optional().isNumeric().withMessage('workId 必须是数字'),
+    body('statistics.*.work_id').optional().isNumeric().withMessage('work_id 必须是数字'),
+    body('statistics.*.recordDate').optional().isString().withMessage('recordDate 必须是字符串'),
+    body('statistics.*.record_date').optional().isString().withMessage('record_date 必须是字符串'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const items = req.body.statistics.map((item: any) => {
+      const workId = item.workId ?? item.work_id;
+      const recordDateStr = item.recordDate ?? item.record_date ?? '';
+      const recordDate = recordDateStr ? new Date(recordDateStr) : new Date();
+      recordDate.setHours(0, 0, 0, 0);
+      return {
+        workId: Number(workId),
+        recordDate,
+        playCount: item.playCount ?? item.play_count ?? 0,
+        exposureCount: item.exposureCount ?? item.exposure_count ?? 0,
+        likeCount: item.likeCount ?? item.like_count ?? 0,
+        commentCount: item.commentCount ?? item.comment_count ?? 0,
+        shareCount: item.shareCount ?? item.share_count ?? 0,
+        collectCount: item.collectCount ?? item.collect_count ?? 0,
+        fansIncrease: item.fansIncrease ?? item.fans_increase ?? 0,
+        followCount: item.followCount ?? item.follow_count ?? 0,
+        coverClickRate: String(item.coverClickRate ?? item.cover_click_rate ?? '0'),
+        avgWatchDuration: String(item.avgWatchDuration ?? item.avg_watch_duration ?? '0'),
+        totalWatchDuration: String(item.totalWatchDuration ?? item.total_watch_duration ?? '0'),
+        completionRate: String(item.completionRate ?? item.completion_rate ?? '0'),
+        twoSecondExitRate: String(item.twoSecondExitRate ?? item.two_second_exit_rate ?? '0'),
+      };
+    });
+
+    const result = await workDayStatisticsService.saveStatisticsForDateBatch(items);
+
+    res.json({
+      success: true,
+      inserted: result.inserted,
+      updated: result.updated,
+      message: `保存成功: 新增 ${result.inserted} 条, 更新 ${result.updated} 条`,
+    });
+  })
+);
+
+/**
  * GET /api/internal/work-day-statistics/trend
  * 获取数据趋势
  */
@@ -196,6 +246,65 @@ router.get(
 );
 
 /**
+ * POST /api/internal/works/batch-update-from-csv
+ * 根据视频号 CSV 明细批量更新 works 表 yesterday_* 字段
+ * 请求体: { "updates": [{ work_id, yesterday_play_count, yesterday_like_count, yesterday_comment_count, yesterday_share_count, yesterday_completion_rate, yesterday_avg_watch_duration }, ...] }
+ */
+router.post(
+  '/works/batch-update-from-csv',
+  [
+    body('updates').isArray().withMessage('updates 必须是数组'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const raw = req.body.updates as Array<any>;
+    const updates = raw
+      .map((item) => ({
+        work_id: item.work_id ?? item.workId,
+        yesterday_play_count: item.yesterday_play_count ?? item.yesterdayPlayCount,
+        yesterday_like_count: item.yesterday_like_count ?? item.yesterdayLikeCount,
+        yesterday_comment_count: item.yesterday_comment_count ?? item.yesterdayCommentCount,
+        yesterday_share_count: item.yesterday_share_count ?? item.yesterdayShareCount,
+        yesterday_follow_count: item.yesterday_follow_count ?? item.yesterdayFollowCount,
+        yesterday_completion_rate: item.yesterday_completion_rate ?? item.yesterdayCompletionRate,
+        yesterday_avg_watch_duration: item.yesterday_avg_watch_duration ?? item.yesterdayAvgWatchDuration,
+      }))
+      .filter((item) => item.work_id != null);
+
+    const workRepository = AppDataSource.getRepository(Work);
+    let updated = 0;
+    for (const item of updates) {
+      const patch: Partial<{
+        yesterdayPlayCount: number;
+        yesterdayLikeCount: number;
+        yesterdayCommentCount: number;
+        yesterdayShareCount: number;
+        yesterdayFollowCount: number;
+        yesterdayCompletionRate: string;
+        yesterdayAvgWatchDuration: string;
+      }> = {};
+      if (item.yesterday_play_count !== undefined) patch.yesterdayPlayCount = item.yesterday_play_count;
+      if (item.yesterday_like_count !== undefined) patch.yesterdayLikeCount = item.yesterday_like_count;
+      if (item.yesterday_comment_count !== undefined) patch.yesterdayCommentCount = item.yesterday_comment_count;
+      if (item.yesterday_share_count !== undefined) patch.yesterdayShareCount = item.yesterday_share_count;
+      if (item.yesterday_follow_count !== undefined) patch.yesterdayFollowCount = item.yesterday_follow_count;
+      if (item.yesterday_completion_rate !== undefined) patch.yesterdayCompletionRate = String(item.yesterday_completion_rate);
+      if (item.yesterday_avg_watch_duration !== undefined) patch.yesterdayAvgWatchDuration = String(item.yesterday_avg_watch_duration);
+      if (Object.keys(patch).length === 0) continue;
+
+      const result = await workRepository.update(item.work_id, patch);
+      if (result.affected && result.affected > 0) updated += result.affected;
+    }
+
+    res.json({
+      success: true,
+      updated,
+      message: `成功更新 ${updated} 条 works 记录`,
+    });
+  })
+);
+
+/**
  * POST /api/internal/captcha/request
  * Python 发布服务请求前端输入验证码(短信/图形)
  */

+ 99 - 2
server/src/routes/workDayStatistics.ts

@@ -1,5 +1,5 @@
 import { Router } from 'express';
-import { query } from 'express-validator';
+import { query, param } from 'express-validator';
 import { spawn } from 'child_process';
 import path from 'path';
 import { fileURLToPath } from 'url';
@@ -7,8 +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 { AppDataSource, Work } from '../models/index.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 版本)
@@ -536,6 +537,102 @@ router.get(
   })
 );
 
+const PYTHON_SERVICE_URL = process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005';
+
+/**
+ * POST /api/work-day-statistics/sync-weixin-video/:workId
+ * 同步视频号作品的每日数据(浏览器自动化 + CSV 导入)
+ */
+router.post(
+  '/sync-weixin-video/:workId',
+  [param('workId').isInt().withMessage('workId 必须是整数'), validateRequest],
+  asyncHandler(async (req, res) => {
+    const workId = Number(req.params.workId);
+    const workRepository = AppDataSource.getRepository(Work);
+    const work = await workRepository.findOne({
+      where: { id: workId },
+      relations: ['account'],
+    });
+
+    if (!work) {
+      return res.status(404).json({ success: false, message: '作品不存在' });
+    }
+    if (work.account.userId !== req.user!.userId) {
+      return res.status(403).json({ success: false, message: '无权访问该作品' });
+    }
+    if (work.platform !== 'weixin_video') {
+      return res.status(400).json({ success: false, message: '仅支持视频号作品' });
+    }
+
+    const account = work.account as PlatformAccount;
+    if (!account.cookieData) {
+      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 pyRes = 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 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: false,
+        message: data.error || '同步失败',
+      });
+    }
+
+    return res.json({
+      success: true,
+      message: data.message || '同步成功',
+      data: { inserted: data.inserted ?? 0, updated: data.updated ?? 0 },
+    });
+  })
+);
+
 /**
  * GET /api/work-day-statistics/work/:workId
  * 获取单个作品的历史统计数据(用于作品详情页)

+ 29 - 7
server/src/scheduler/index.ts

@@ -13,6 +13,7 @@ import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataC
 import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
 import { DouyinWorkStatisticsImportService } from '../services/DouyinWorkStatisticsImportService.js';
 import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
+import { BaijiahaoWorkDailyStatisticsImportService } from '../services/BaijiahaoWorkDailyStatisticsImportService.js';
 
 /**
  * 定时任务调度器
@@ -25,6 +26,7 @@ export class TaskScheduler {
   private isDyImportRunning = false; // 抖音导入锁,防止任务重叠执行
   private isDyWorkImportRunning = false; // 抖音作品日统计导入锁
   private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
+  private isBjWorkImportRunning = false; // 百家号作品日统计导入锁
   private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
   private isWxWorkImportRunning = false; // 视频号作品日统计导入锁
   private isAutoReplying = false; // 私信回复锁,防止任务重叠执行
@@ -61,16 +63,18 @@ export class TaskScheduler {
     // 每天 12:20:批量导出百家号“数据中心-内容分析-基础数据-近30天”,导入 user_day_statistics
     this.scheduleJob('bj-content-overview-import', '20 12 * * *', this.importBaijiahaoContentOverviewLast30Days.bind(this));
 
+    // 每天 12:25:百家号作品维度「每日数据」同步(列表分页 + 逐条趋势),写入 works.yesterday_* 与 work_day_statistics
+    this.scheduleJob('bj-work-daily-import', '25 12 * * *', this.importBaijiahaoWorkDailyStatistics.bind(this));
+
     // 每天 12:30:批量导出视频号“数据中心-各子菜单-增长详情(数据详情)-近30天-下载表格”,导入 user_day_statistics
     this.scheduleJob('wx-video-data-center-import', '30 12 * * *', this.importWeixinVideoDataCenterLast30Days.bind(this));
 
     // 每天 12:35:同步视频号作品维度的「作品列表 + 按天聚合-全部」数据,写入 work_day_statistics
-    // [已中止] 暂时禁用,等待接口问题解决
-    // this.scheduleJob(
-    //   'wx-video-work-statistics-import',
-    //   '35 12 * * *',
-    //   this.importWeixinVideoWorkStatistics.bind(this)
-    // );
+    this.scheduleJob(
+      'wx-video-work-statistics-import',
+      '35 12 * * *',
+      this.importWeixinVideoWorkStatistics.bind(this)
+    );
 
     this.scheduleJob('auto-reply-messages', '* * * * *', this.autoReplyMessages.bind(this));
     // 注意:账号刷新由客户端定时触发,不在服务端自动执行
@@ -88,8 +92,9 @@ export class TaskScheduler {
     logger.info('[Scheduler]   - dy-account-overview-import:  daily at 12:10 (10 12 * * *)');
     logger.info('[Scheduler]   - dy-work-statistics-import:  daily at 12:50 (50 12 * * *)');
     logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
+    logger.info('[Scheduler]   - bj-work-daily-import:       daily at 12:25 (25 12 * * *)');
     logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
-    // logger.info('[Scheduler]   - wx-video-work-statistics-import: daily at 12:35 (35 12 * * *)');
+    logger.info('[Scheduler]   - wx-video-work-statistics-import: daily at 12:35 (35 12 * * *)');
     logger.info('[Scheduler]   - auto-reply-messages: every minute (* * * * *)');
     logger.info('[Scheduler] Note: Account refresh is triggered by client, not server');
     logger.info('[Scheduler] ========================================');
@@ -520,6 +525,23 @@ export class TaskScheduler {
   }
 
   /**
+   * 百家号:作品维度「每日作品数据」→ 写入 works.yesterday_* 与 work_day_statistics
+   */
+  private async importBaijiahaoWorkDailyStatistics(): Promise<void> {
+    if (this.isBjWorkImportRunning) {
+      logger.info('[Scheduler] Baijiahao work daily import is already running, skipping...');
+      return;
+    }
+
+    this.isBjWorkImportRunning = true;
+    try {
+      await BaijiahaoWorkDailyStatisticsImportService.runDailyImport();
+    } finally {
+      this.isBjWorkImportRunning = false;
+    }
+  }
+
+  /**
    * 视频号:数据中心-关注者/视频/图文 的增长详情(近30天)→ 导入 user_day_statistics
    */
   private async importWeixinVideoDataCenterLast30Days(): Promise<void> {

+ 77 - 0
server/src/scripts/fix-platform-accounts-timestamp.ts

@@ -0,0 +1,77 @@
+#!/usr/bin/env tsx
+/**
+ * 修复 platform_accounts 表的 created_at 和 updated_at 字段格式
+ * 将 timestamp 类型改为 datetime 类型,时间格式为 2026-02-05 12:22:22
+ *
+ * 运行: cd server && pnpm exec tsx src/scripts/fix-platform-accounts-timestamp.ts
+ */
+import { initDatabase, AppDataSource } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+
+async function fixPlatformAccountsTimestamp() {
+  try {
+    await initDatabase();
+    logger.info('数据库连接已初始化');
+
+    const queryRunner = AppDataSource.createQueryRunner();
+    await queryRunner.connect();
+
+    try {
+      logger.info('\n========================================');
+      logger.info('修复 platform_accounts 时间字段格式...');
+      logger.info('========================================\n');
+
+      await queryRunner.query(`SET time_zone = '+08:00'`);
+
+      logger.info('1. 修改 created_at 字段类型...');
+      await queryRunner.query(`
+        ALTER TABLE platform_accounts 
+        MODIFY COLUMN created_at DATETIME NULL
+      `);
+      await queryRunner.query(`
+        ALTER TABLE platform_accounts 
+        MODIFY COLUMN created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+      `);
+      logger.info('   ✓ created_at 修改完成');
+
+      logger.info('2. 修改 updated_at 字段类型...');
+      await queryRunner.query(`
+        ALTER TABLE platform_accounts 
+        MODIFY COLUMN updated_at DATETIME NULL
+      `);
+      await queryRunner.query(`
+        ALTER TABLE platform_accounts 
+        MODIFY COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+      `);
+      logger.info('   ✓ updated_at 修改完成');
+
+      logger.info('\n3. 验证修复结果...');
+      const rows = await queryRunner.query(`
+        SELECT id, account_name, created_at, updated_at 
+        FROM platform_accounts 
+        ORDER BY id DESC 
+        LIMIT 5
+      `);
+      rows.forEach((row: any) => {
+        logger.info(`   ID ${row.id}: created_at=${row.created_at}, updated_at=${row.updated_at}`);
+      });
+
+      logger.info('\n========================================');
+      logger.info('platform_accounts 时间字段修复完成!');
+      logger.info('========================================\n');
+    } catch (error: any) {
+      logger.error('修复过程中出错:', error);
+      throw error;
+    } finally {
+      await queryRunner.release();
+    }
+  } catch (error: any) {
+    logger.error('修复失败:', error);
+    process.exit(1);
+  } finally {
+    await AppDataSource.destroy();
+    process.exit(0);
+  }
+}
+
+fixPlatformAccountsTimestamp().catch(console.error);

+ 35 - 0
server/src/scripts/run-baijiahao-work-daily-import.ts

@@ -0,0 +1,35 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { BaijiahaoWorkDailyStatisticsImportService } from '../services/BaijiahaoWorkDailyStatisticsImportService.js';
+
+/**
+ * 用法:
+ * - 全量:cd server && pnpm exec tsx src/scripts/run-baijiahao-work-daily-import.ts
+ * - 单账号:cd server && pnpm exec tsx src/scripts/run-baijiahao-work-daily-import.ts <accountId>
+ */
+async function main() {
+  try {
+    await initDatabase();
+    const accountIdArg = process.argv[2];
+
+    logger.info('[BJ WorkDaily] Manual run start...');
+    if (accountIdArg) {
+      const accountId = parseInt(accountIdArg, 10);
+      if (isNaN(accountId)) {
+        logger.error('[BJ WorkDaily] accountId 必须是数字');
+        process.exit(1);
+      }
+      await BaijiahaoWorkDailyStatisticsImportService.runDailyImportForAccount(accountId);
+    } else {
+      await BaijiahaoWorkDailyStatisticsImportService.runDailyImport();
+    }
+    logger.info('[BJ WorkDaily] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[BJ WorkDaily] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 20 - 2
server/src/scripts/run-weixin-video-work-stats-import.ts

@@ -2,11 +2,29 @@ import { initDatabase } from '../models/index.js';
 import { logger } from '../utils/logger.js';
 import { WeixinVideoWorkStatisticsImportService } from '../services/WeixinVideoWorkStatisticsImportService.js';
 
+/**
+ * 用法: pnpm exec tsx server/src/scripts/run-weixin-video-work-stats-import.ts [accountId] [show]
+ * 不传 accountId 则同步所有视频号账号
+ * 第二个参数传 show 则打开浏览器可视模式,便于观察点击操作
+ */
 async function main() {
   try {
     await initDatabase();
-    logger.info('[WX WorkStats] Manual run start...');
-    await WeixinVideoWorkStatisticsImportService.runDailyImport();
+    const accountIdArg = process.argv[2];
+    const showArg = process.argv[3];
+    const showBrowser = (showArg || '').toLowerCase() === 'show';
+    if (accountIdArg) {
+      const accountId = parseInt(accountIdArg, 10);
+      if (isNaN(accountId)) {
+        logger.error('[WX WorkStats] accountId 必须是数字');
+        process.exit(1);
+      }
+      logger.info(`[WX WorkStats] 单账号同步 accountId=${accountId}${showBrowser ? ' (可视模式)' : ''} start...`);
+      await WeixinVideoWorkStatisticsImportService.runDailyImportForAccount(accountId, showBrowser);
+    } else {
+      logger.info('[WX WorkStats] 全量同步 start...');
+      await WeixinVideoWorkStatisticsImportService.runDailyImport();
+    }
     logger.info('[WX WorkStats] Manual run done.');
     process.exit(0);
   } catch (e) {

+ 92 - 0
server/src/scripts/test-weixin-work-daily-sync.ts

@@ -0,0 +1,92 @@
+#!/usr/bin/env tsx
+/**
+ * 测试视频号作品每日数据同步(work_id=906)
+ * 用法: cd server && pnpm exec tsx src/scripts/test-weixin-work-daily-sync.ts [workId]
+ */
+import { initDatabase, AppDataSource, Work, PlatformAccount } 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';
+
+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;
+  }
+
+  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: '/' };
+        })
+    );
+  }
+
+  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) => {
+  logger.error(e);
+  process.exit(1);
+});

+ 15 - 5
server/src/services/BaijiahaoContentOverviewImportService.ts

@@ -235,12 +235,10 @@ function parseBaijiahaoExcel(
       const shareCount = parseChineseNumberLike(safeGet(10));
       if (typeof shareCount === 'number') (obj as any).shareCount = shareCount;
 
-      // 点击率 → cover_click_rate(通常是百分比字符串,原样入库
+      // 点击率 → coverClickRate(不为 0 时加 %
       const clickRateRaw = safeGet(2);
-      if (clickRateRaw !== undefined && clickRateRaw !== null) {
-        const s = String(clickRateRaw).trim();
-        if (s) (obj as any).coverClickRate = s;
-      }
+      const coverClickRate = formatRateWithPercent(clickRateRaw);
+      if (coverClickRate !== '0') (obj as any).coverClickRate = coverClickRate;
 
       // fans_increase 只看作品涨粉量(不再扣除作品脱粉量)
       const inc = parseChineseNumberLike(safeGet(12));
@@ -253,6 +251,18 @@ function parseBaijiahaoExcel(
   return result;
 }
 
+/** 比率:不为 0 时加上 %,为 0 或空返回 '0' */
+function formatRateWithPercent(v: unknown): string {
+  if (v === null || v === undefined) return '0';
+  const s = String(v).trim();
+  if (!s) return '0';
+  const n = Number(s.replace(/,/g, ''));
+  if (!Number.isFinite(n) || n === 0) return '0';
+  if (s.includes('%')) return s;
+  if (n > 0 && n <= 1) return `${(n * 100).toFixed(2)}%`;
+  return `${Number(n.toFixed(2))}%`;
+}
+
 function formatPercentString(input: unknown): string | null {
   if (input === null || input === undefined) return null;
   const s = String(input).trim();

+ 645 - 0
server/src/services/BaijiahaoWorkDailyStatisticsImportService.ts

@@ -0,0 +1,645 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
+import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
+import type { ProxyConfig } from '@media-manager/shared';
+import { AccountService } from './AccountService.js';
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
+
+type BjhListType = 'small_video_v2' | 'video' | 'news';
+
+type ArticleListStatisticItem = {
+  article_id?: string;
+  nid?: string;
+  id?: string;
+  title?: string;
+  type?: string;
+  view_count?: number;
+  comment_count?: number;
+  likes_count?: number;
+  collect_count?: number;
+  share_count?: number;
+  rec_count?: number;
+};
+
+type ArticleListStatisticResponse = {
+  errno?: number;
+  errmsg?: string;
+  data?: {
+    count?: string | number;
+    list?: ArticleListStatisticItem[];
+  };
+};
+
+type TrendItem = {
+  event_day?: string; // YYYYMMDD
+  view_count?: string | number;
+  disp_pv?: string | number;
+  likes_count?: string | number;
+  comment_count?: string | number;
+  collect_count?: string | number;
+  share_count?: string | number;
+  cover_ctr?: string | number;
+  completion_ratio?: string | number;
+  avg_duration?: string | number;
+  view_duration?: string | number;
+  fans_add_cnt?: string | number;
+};
+
+type GetTrendDataResponse = {
+  errno?: number;
+  errmsg?: string;
+  data?: {
+    basic_list?: TrendItem[];
+  };
+};
+
+function ensureDir(p: string) {
+  return fs.mkdir(p, { recursive: true });
+}
+
+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 }> {
+  const headless = 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 chromium.launch({
+    headless,
+    args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu', '--window-size=1920,1080'],
+  });
+  return { browser, shouldClose: true };
+}
+
+function isJwtLike(v: unknown): v is string {
+  if (!v || typeof v !== 'string') return false;
+  const s = v.trim();
+  if (s.length < 60) return false;
+  const parts = s.split('.');
+  if (parts.length !== 3) return false;
+  return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 10);
+}
+
+async function extractTokenFromPage(page: Page): Promise<string> {
+  const token = await page
+    .evaluate(() => {
+      const isJwtLikeInner = (v: any) => {
+        if (!v || typeof v !== 'string') return false;
+        const s = v.trim();
+        if (s.length < 60) return false;
+        const parts = s.split('.');
+        if (parts.length !== 3) return false;
+        return parts.every((p) => /^[A-Za-z0-9_-]+$/.test(p) && p.length > 10);
+      };
+      const pickFromStorage = (storage: Storage) => {
+        try {
+          const keys = Object.keys(storage || {});
+          for (const k of keys) {
+            const v = storage.getItem(k);
+            if (isJwtLikeInner(v)) return v;
+          }
+        } catch {
+          // ignore
+        }
+        return '';
+      };
+
+      let t = pickFromStorage(window.localStorage);
+      if (t) return t;
+      t = pickFromStorage(window.sessionStorage);
+      if (t) return t;
+
+      const meta = document.querySelector('meta[name="token"], meta[name="bjh-token"]');
+      const metaToken = meta && meta.getAttribute('content');
+      if (isJwtLikeInner(metaToken)) return metaToken;
+
+      const candidates = [
+        ((window as any).__INITIAL_STATE__ && (window as any).__INITIAL_STATE__.token) || '',
+        ((window as any).__PRELOADED_STATE__ && (window as any).__PRELOADED_STATE__.token) || '',
+        ((window as any).__NUXT__ && (window as any).__NUXT__.state && (window as any).__NUXT__.state.token) || '',
+      ];
+      for (const c of candidates) {
+        if (isJwtLikeInner(c)) return c;
+      }
+
+      return '';
+    })
+    .catch(() => '');
+
+  if (token && isJwtLike(token)) return token;
+
+  // HTML 兜底
+  const html = await page.content().catch(() => '');
+  const m = html.match(/([A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})/);
+  if (m?.[1] && isJwtLike(m[1])) return m[1];
+
+  return '';
+}
+
+function toYmd(date: Date): string {
+  const yyyy = date.getFullYear();
+  const mm = String(date.getMonth() + 1).padStart(2, '0');
+  const dd = String(date.getDate()).padStart(2, '0');
+  return `${yyyy}${mm}${dd}`;
+}
+
+function parseYyyyMmDdCompactToDate(day: string): Date | null {
+  const s = String(day || '').trim();
+  const m = s.match(/^(\d{4})(\d{2})(\d{2})$/);
+  if (!m) return null;
+  const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
+  d.setHours(0, 0, 0, 0);
+  return d;
+}
+
+function toInt(v: unknown): number {
+  if (v === null || v === undefined) return 0;
+  if (typeof v === 'number' && Number.isFinite(v)) return Math.floor(v);
+  const s = String(v).trim();
+  if (!s) return 0;
+  const n = Number(s.replace(/,/g, ''));
+  return Number.isFinite(n) ? Math.floor(n) : 0;
+}
+
+function toStr(v: unknown): string {
+  if (v === null || v === undefined) return '0';
+  const s = String(v).trim();
+  return s || '0';
+}
+
+/** 比率:不为 0 时加上 %,为 0 或空返回 '0' */
+function formatRateWithPercent(v: unknown): string {
+  if (v === null || v === undefined) return '0';
+  const s = String(v).trim();
+  if (!s) return '0';
+  const n = Number(s.replace(/,/g, ''));
+  if (!Number.isFinite(n) || n === 0) return '0';
+  if (s.includes('%')) return s;
+  if (n > 0 && n <= 1) return `${(n * 100).toFixed(2)}%`;
+  return `${Number(n.toFixed(2))}%`;
+}
+
+/** 观看时长:保留两位小数 */
+function formatDurationTwoDecimals(v: unknown): string {
+  if (v === null || v === undefined) return '0';
+  const n = Number(String(v).trim().replace(/,/g, ''));
+  if (!Number.isFinite(n)) return '0';
+  return n.toFixed(2);
+}
+
+export class BaijiahaoWorkDailyStatisticsImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
+  private accountService = new AccountService();
+
+  private stateDir = path.resolve(process.cwd(), 'tmp', 'baijiahao-storage-state');
+
+  static async runDailyImport(): Promise<void> {
+    const svc = new BaijiahaoWorkDailyStatisticsImportService();
+    await svc.runDailyImportForAllBaijiahaoAccounts();
+  }
+
+  static async runDailyImportForAccount(accountId: number): Promise<void> {
+    const svc = new BaijiahaoWorkDailyStatisticsImportService();
+    const account = await svc.accountRepository.findOne({
+      where: { id: accountId, platform: 'baijiahao' as any },
+    });
+    if (!account) throw new Error(`未找到百家号账号 id=${accountId}`);
+    await svc.importAccountWorkDaily(account);
+  }
+
+  async runDailyImportForAllBaijiahaoAccounts(): Promise<void> {
+    await ensureDir(this.stateDir);
+    const accounts = await this.accountRepository.find({ where: { platform: 'baijiahao' as any } });
+    logger.info(`[BJ WorkDaily] Start. total_accounts=${accounts.length}`);
+    for (const account of accounts) {
+      try {
+        await this.importAccountWorkDaily(account);
+      } catch (e) {
+        logger.error(
+          `[BJ WorkDaily] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+      }
+    }
+    logger.info('[BJ WorkDaily] Done.');
+  }
+
+  private getStatePath(accountId: number) {
+    return path.join(this.stateDir, `${accountId}.json`);
+  }
+
+  private async createContext(
+    account: PlatformAccount,
+    cookies: PlaywrightCookie[]
+  ): Promise<{ context: BrowserContext; browser: Browser; shouldClose: boolean; token: string }> {
+    const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
+
+    const statePath = this.getStatePath(account.id);
+    let hasState = false;
+    try {
+      await fs.access(statePath);
+      hasState = true;
+    } catch {
+      hasState = false;
+    }
+
+    const context = await browser.newContext({
+      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/144.0.0.0 Safari/537.36 Edg/144.0.0.0',
+      ...(hasState ? { storageState: statePath } : {}),
+    });
+    context.setDefaultTimeout(60_000);
+    if (!hasState) {
+      await context.addCookies(cookies as any);
+    }
+
+    const page = await context.newPage();
+    await page.goto('https://baijiahao.baidu.com/builder/rc/analysiscontent/single', {
+      waitUntil: 'domcontentloaded',
+    });
+    await page.waitForTimeout(1500);
+
+    const token = await extractTokenFromPage(page);
+    if (token) {
+      try {
+        await ensureDir(this.stateDir);
+        await context.storageState({ path: statePath });
+      } catch {
+        // ignore
+      }
+    }
+
+    await page.close().catch(() => undefined);
+    return { context, browser, shouldClose, token };
+  }
+
+  private buildCommonHeaders(token: string): Record<string, string> {
+    const headers: Record<string, string> = {
+      accept: 'application/json, text/plain, */*',
+      'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
+      referer: 'https://baijiahao.baidu.com/builder/rc/analysiscontent/single',
+    };
+    if (token) headers.token = token;
+    return headers;
+  }
+
+  private async fetchArticleListStatisticPage(
+    context: BrowserContext,
+    token: string,
+    params: {
+      startDay: string; // YYYYMMDD
+      endDay: string; // YYYYMMDD
+      type: BjhListType;
+      num: number;
+      count: number;
+    }
+  ): Promise<ArticleListStatisticResponse> {
+    const { startDay, endDay, type, num, count } = params;
+    const url = `https://baijiahao.baidu.com/author/eco/statistics/articleListStatistic?start_day=${startDay}&end_day=${endDay}&type=${type}&num=${num}&count=${count}`;
+    const res = await (context as any).request.get(url, {
+      headers: this.buildCommonHeaders(token),
+    });
+    const json = (await res.json().catch(() => null)) as ArticleListStatisticResponse | null;
+    if (!json) throw new Error(`articleListStatistic json parse failed (http=${res.status()})`);
+    return json;
+  }
+
+  private async fetchTrendData(
+    context: BrowserContext,
+    token: string,
+    nid: string
+  ): Promise<GetTrendDataResponse> {
+    const url = `https://baijiahao.baidu.com/author/eco/statistic/gettrenddata?nid=${encodeURIComponent(
+      nid
+    )}&trend_type=all&data_type=addition`;
+    const res = await (context as any).request.get(url, {
+      headers: this.buildCommonHeaders(token),
+    });
+    const json = (await res.json().catch(() => null)) as GetTrendDataResponse | null;
+    if (!json) throw new Error(`gettrenddata json parse failed (http=${res.status()})`);
+    return json;
+  }
+
+  private isNotLoggedInErrno(errno: unknown): boolean {
+    const n = typeof errno === 'number' ? errno : Number(errno);
+    // 110: 未登录;20040001: 当前用户未登录(你示例里的 errno)
+    return n === 110 || n === 20040001;
+  }
+
+  private isNotLoggedInError(e: unknown): boolean {
+    const err = e as any;
+    if (!err) return false;
+    if (err.code === 'BJH_NOT_LOGGED_IN') return true;
+    const msg = String(err.message || '').toLowerCase();
+    return msg.includes('未登录') || msg.includes('not logged in');
+  }
+
+  private async importAccountWorkDaily(account: PlatformAccount, isRetry = false): Promise<void> {
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
+      logger.warn(
+        `[BJ WorkDaily] accountId=${account.id} cookieData 为空或无法解析,跳过`
+      );
+      return;
+    }
+
+    const works = await this.workRepository.find({
+      where: { accountId: account.id, platform: 'baijiahao' as any },
+      select: ['id', 'platformVideoId'],
+    });
+    if (!works.length) {
+      logger.info(
+        `[BJ WorkDaily] accountId=${account.id} 没有 baijiahao 作品,跳过`
+      );
+      return;
+    }
+    const idMap = new Map<string, number>();
+    for (const w of works) {
+      const k = String(w.platformVideoId || '').trim();
+      if (k) idMap.set(k, w.id);
+    }
+
+    let context: BrowserContext | null = null;
+    let browser: Browser | null = null;
+    let shouldClose = false;
+    let token = '';
+
+    try {
+      const created = await this.createContext(account, cookies);
+      context = created.context;
+      browser = created.browser;
+      shouldClose = created.shouldClose;
+      token = created.token;
+
+      if (!token) {
+        throw Object.assign(
+          new Error('未能提取百家号 token(可能未登录)'),
+          { code: 'BJH_NOT_LOGGED_IN' }
+        );
+      }
+
+      // 默认取昨天(中国时区)
+      const now = new Date();
+      const chinaNow = new Date(now.getTime() + 8 * 60 * 60 * 1000);
+      const chinaYesterday = new Date(chinaNow.getTime() - 24 * 60 * 60 * 1000);
+      const endDay = toYmd(chinaYesterday);
+      const startDayDate = new Date(chinaYesterday);
+      startDayDate.setDate(startDayDate.getDate() - 6);
+      const startDay = toYmd(startDayDate);
+
+      const types: BjhListType[] = ['small_video_v2', 'video', 'news'];
+      const pageSize = 10;
+
+      let worksUpdated = 0;
+      let wdsInserted = 0;
+      let wdsUpdated = 0;
+
+      for (const t of types) {
+        let num = 1;
+        let total = 0;
+        while (true) {
+          const body = await this.fetchArticleListStatisticPage(context!, token, {
+            startDay,
+            endDay,
+            type: t,
+            num,
+            count: pageSize,
+          });
+
+          if (this.isNotLoggedInErrno(body.errno)) {
+            const err = new Error(
+              `articleListStatistic errno=${body.errno} 未登录/会话失效`
+            );
+            (err as any).code = 'BJH_NOT_LOGGED_IN';
+            throw err;
+          }
+          if (body.errno !== 0) {
+            throw new Error(
+              `articleListStatistic errno=${body.errno} errmsg=${body.errmsg || ''}`
+            );
+          }
+
+          const list = body.data?.list || [];
+          const countRaw = body.data?.count;
+          total = typeof countRaw === 'string' ? toInt(countRaw) : toInt(countRaw);
+
+          if (!list.length) break;
+
+          // 1) 先把列表汇总写入 works.yesterday_*
+          for (const it of list) {
+            const articleId = String(it.article_id || '').trim();
+            if (!articleId) continue;
+            const workId = idMap.get(articleId);
+            if (!workId) continue;
+
+            const patch: Partial<Work> = {
+              yesterdayPlayCount: toInt(it.view_count),
+              yesterdayCommentCount: toInt(it.comment_count),
+              yesterdayLikeCount: toInt(it.likes_count),
+              yesterdayCollectCount: toInt(it.collect_count),
+              yesterdayShareCount: toInt(it.share_count),
+              // 百家号列表 rec_count → 推荐量
+              yesterdayRecommendCount: toInt(it.rec_count),
+            };
+
+            const r = await this.workRepository.update(workId, patch);
+            if (r.affected && r.affected > 0) worksUpdated += r.affected;
+          }
+
+          // 2) 再逐条拉趋势,把 basic_list 写入 work_day_statistics
+          for (const it of list) {
+            const articleId = String(it.article_id || '').trim();
+            if (!articleId) continue;
+            const workId = idMap.get(articleId);
+            if (!workId) continue;
+
+            const trend = await this.fetchTrendData(context!, token, articleId);
+            if (this.isNotLoggedInErrno(trend.errno)) {
+              const err = new Error(
+                `gettrenddata errno=${trend.errno} 未登录/会话失效`
+              );
+              (err as any).code = 'BJH_NOT_LOGGED_IN';
+              throw err;
+            }
+            if (trend.errno !== 0) {
+              logger.warn(
+                `[BJ WorkDaily] gettrenddata errno=${trend.errno} nid=${articleId} errmsg=${trend.errmsg || ''}`
+              );
+              continue;
+            }
+            const basic = trend.data?.basic_list || [];
+            for (const day of basic) {
+              const d = parseYyyyMmDdCompactToDate(String(day.event_day || ''));
+              if (!d) continue;
+
+              const save = await this.workDayStatisticsService.saveStatisticsForDate(
+                workId,
+                d,
+                {
+                  playCount: toInt(day.view_count),
+                  likeCount: toInt(day.likes_count),
+                  commentCount: toInt(day.comment_count),
+                  collectCount: toInt(day.collect_count),
+                  shareCount: toInt(day.share_count),
+                  // basic_list 目前没有推荐量字段;如果后续有再映射到 recommendCount
+                  fansIncrease: toInt(day.fans_add_cnt),
+                  coverClickRate: formatRateWithPercent(day.cover_ctr),
+                  completionRate: formatRateWithPercent(day.completion_ratio),
+                  avgWatchDuration: formatDurationTwoDecimals(day.avg_duration),
+                  totalWatchDuration: formatDurationTwoDecimals(day.view_duration),
+                }
+              );
+              wdsInserted += save.inserted;
+              wdsUpdated += save.updated;
+            }
+          }
+
+          const fetched = num * pageSize;
+          if (total > 0 && fetched >= total) break;
+          num += 1;
+          if (num > 200) break;
+        }
+      }
+
+      logger.info(
+        `[BJ WorkDaily] accountId=${account.id} done. worksUpdated=${worksUpdated} wdsInserted=${wdsInserted} wdsUpdated=${wdsUpdated} range=${startDay}-${endDay}`
+      );
+    } catch (e) {
+      if (!isRetry && this.isNotLoggedInError(e)) {
+        logger.info(
+          `[BJ WorkDaily] Login expired detected for account ${account.id}, attempting to refresh account...`
+        );
+        try {
+          const refreshResult = await this.accountService.refreshAccount(
+            account.userId,
+            account.id
+          );
+          if (refreshResult.needReLogin) {
+            logger.warn(
+              `[BJ WorkDaily] Account ${account.id} refresh finished but still need re-login, mark as expired.`
+            );
+            await this.accountRepository.update(account.id, {
+              status: 'expired' as any,
+            });
+            return;
+          }
+
+          const refreshed = await this.accountRepository.findOne({
+            where: { id: account.id },
+          });
+          if (!refreshed) {
+            throw new Error('账号刷新后未找到');
+          }
+
+          logger.info(
+            `[BJ WorkDaily] Account ${account.id} refresh success, retry work daily import once...`
+          );
+          await this.importAccountWorkDaily(refreshed, true);
+          return;
+        } catch (refreshError) {
+          logger.error(
+            `[BJ WorkDaily] Account ${account.id} refresh failed:`,
+            refreshError
+          );
+          await this.accountRepository.update(account.id, {
+            status: 'expired' as any,
+          });
+          return;
+        }
+      }
+
+      throw e;
+    } finally {
+      await context?.close().catch(() => undefined);
+      if (shouldClose && browser) {
+        await browser.close().catch(() => undefined);
+      }
+    }
+  }
+}
+

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

@@ -6,6 +6,7 @@ export interface UserDayStatisticsItem {
   fansCount?: number;
   worksCount?: number;
   playCount?: number;
+  exposureCount?: number;
   commentCount?: number;
   fansIncrease?: number;
   likeCount?: number;
@@ -66,6 +67,7 @@ export class UserDayStatisticsService {
         fansCount: item.fansCount ?? existing.fansCount,
         worksCount: item.worksCount ?? existing.worksCount,
         playCount: item.playCount ?? existing.playCount,
+        exposureCount: item.exposureCount ?? existing.exposureCount,
         commentCount: item.commentCount ?? existing.commentCount,
         fansIncrease: item.fansIncrease ?? existing.fansIncrease,
         likeCount: item.likeCount ?? existing.likeCount,
@@ -86,6 +88,7 @@ export class UserDayStatisticsService {
         fansCount: item.fansCount ?? 0,
         worksCount: item.worksCount ?? 0,
         playCount: item.playCount ?? 0,
+        exposureCount: item.exposureCount ?? 0,
         commentCount: item.commentCount ?? 0,
         fansIncrease: item.fansIncrease ?? 0,
         likeCount: item.likeCount ?? 0,
@@ -123,6 +126,7 @@ export class UserDayStatisticsService {
         fansCount: patch.fansCount ?? existing.fansCount,
         worksCount: patch.worksCount ?? existing.worksCount,
         playCount: patch.playCount ?? existing.playCount,
+        exposureCount: patch.exposureCount ?? existing.exposureCount,
         commentCount: patch.commentCount ?? existing.commentCount,
         fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
         likeCount: patch.likeCount ?? existing.likeCount,
@@ -142,6 +146,7 @@ export class UserDayStatisticsService {
       fansCount: patch.fansCount ?? 0,
       worksCount: patch.worksCount ?? 0,
       playCount: patch.playCount ?? 0,
+      exposureCount: patch.exposureCount ?? 0,
       commentCount: patch.commentCount ?? 0,
       fansIncrease: patch.fansIncrease ?? 0,
       likeCount: patch.likeCount ?? 0,

+ 80 - 363
server/src/services/WeixinVideoWorkStatisticsImportService.ts

@@ -1,56 +1,19 @@
 /**
- * 视频号:作品维度「作品列表 + 按天聚合数据」→ 导入 work_day_statistics
+ * 视频号:作品维度「纯浏览器自动化」→ 导入 work_day_statistics
  *
- * 流程:
- * 1. 获取 works 表中 platform=weixin_video 的作品(platform_video_id 存的是 exportId)
- * 2. 调用 post_list 接口获取作品列表,通过 exportId 匹配得到 objectId
- * 3. 对每个作品调用 feed_aggreagate_data_by_tab_type,取「全部」tab 的按天数据
- * 4. 将 browse→播放、like→点赞、comment→评论 写入 work_day_statistics(follow=关注、fav/forward 暂不入库)
+ * 流程:调用 Python 纯浏览器接口,由 Python 完成:
+ * 1. 打开 statistic/post → 点击单篇视频 → 点击近30天
+ * 2. 监听 post_list 获取 exportId->objectId
+ * 3. 遍历列表,按 exportId 匹配 DB 作品,匹配则点击查看 → 详情页近30天 → 下载表格
+ * 4. 解析 CSV 存入 work_day_statistics
  */
 
-import crypto from 'crypto';
 import { AppDataSource, PlatformAccount, Work } from '../models/index.js';
 import { logger } from '../utils/logger.js';
-import { WorkDayStatisticsService } from './WorkDayStatisticsService.js';
 import { CookieManager } from '../automation/cookie.js';
 
-const POST_LIST_BASE =
-  'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/post_list';
-const FEED_AGGREGATE_BASE =
-  'https://channels.weixin.qq.com/micro/statistic/cgi-bin/mmfinderassistant-bin/statistic/feed_aggreagate_data_by_tab_type';
-
-/** 列表页 _pageUrl(与浏览器「数据统计-作品」列表一致) */
-const POST_LIST_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/post';
-/** 详情页 _pageUrl(与浏览器 postDetail 一致,feed_aggreagate 用) */
-const POST_DETAIL_PAGE_URL = 'https://channels.weixin.qq.com/micro/statistic/postDetail';
-
-/** 生成随机 _rid(格式如 6982df69-ff6e46a5,8hex-8hex) */
-function generateRandomRid(): string {
-  const a = crypto.randomBytes(4).toString('hex');
-  const b = crypto.randomBytes(4).toString('hex');
-  return `${a}-${b}`;
-}
-
-/**
- * 构建带 _aid、_rid、_pageUrl 的 URL。
- * 若传入 sessionAid/sessionRid 则优先使用(本账号 post_list 生成的,复用于 feed_aggreagate);
- * 否则读环境变量 WX_VIDEO_AID、WX_VIDEO_RID。
- */
-function buildUrlWithAidRid(
-  base: string,
-  pageUrl: string,
-  sessionAid?: string,
-  sessionRid?: string
-): string {
-  const aid = sessionAid ?? process.env.WX_VIDEO_AID?.trim() ?? '';
-  const rid = sessionRid ?? process.env.WX_VIDEO_RID?.trim() ?? '';
-  const params = new URLSearchParams();
-  if (aid) params.set('_aid', aid);
-  if (rid) params.set('_rid', rid);
-  params.set('_pageUrl', pageUrl);
-  const qs = params.toString();
-  return qs ? `${base}?${qs}` : base;
-}
+const PYTHON_SERVICE_URL =
+  process.env.PYTHON_PUBLISH_SERVICE_URL || process.env.XHS_SERVICE_URL || 'http://localhost:5005';
 
 function tryDecryptCookieData(cookieData: string | null): string | null {
   if (!cookieData) return null;
@@ -63,107 +26,52 @@ function tryDecryptCookieData(cookieData: string | null): string | null {
   }
 }
 
-/** 将账号 cookie_data 转为 HTTP Cookie 头字符串 */
-function getCookieHeaderString(cookieData: string | null): string {
+/** 将 cookie 转为 Python 接口所需格式(JSON 数组或原始字符串) */
+function getCookieForPython(cookieData: string | null): string {
   const raw = tryDecryptCookieData(cookieData);
   if (!raw) return '';
   const s = raw.trim();
   if (!s) return '';
-  if (s.startsWith('[') || s.startsWith('{')) {
-    try {
-      const parsed = JSON.parse(s);
-      const arr = Array.isArray(parsed) ? parsed : parsed?.cookies ?? [];
-      if (!Array.isArray(arr)) return '';
-      return arr
-        .map((c: { name?: string; value?: string }) => {
-          const name = String(c?.name ?? '').trim();
-          const value = String(c?.value ?? '').trim();
-          return name ? `${name}=${value}` : '';
-        })
+  try {
+    JSON.parse(s);
+    return s; // 已是 JSON
+  } catch {
+    return JSON.stringify(
+      s
+        .split(';')
         .filter(Boolean)
-        .join('; ');
-    } catch {
-      return s;
-    }
-  }
-  return s;
-}
-
-/** 从 Cookie 字符串中解析 x-wechat-uin(可选) */
-function getXWechatUinFromCookie(cookieHeader: string): string | undefined {
-  const match = cookieHeader.match(/\bwxuin=(\d+)/i);
-  return match ? match[1] : undefined;
-}
-
-/** 从账号 account_id 得到 _log_finder_id(去掉 weixin_video_ 前缀,保证以 @finder 结尾) */
-function getLogFinderId(accountId: string | null): string {
-  if (!accountId) return '';
-  const s = String(accountId).trim();
-  const prefix = 'weixin_video_';
-  const id = s.startsWith(prefix) ? s.slice(prefix.length) : s;
-  if (!id) return '';
-  return id.endsWith('@finder') ? id : `${id}@finder`;
-}
-
-function buildPostListUrl(sessionAid?: string, sessionRid?: string): string {
-  return buildUrlWithAidRid(POST_LIST_BASE, POST_LIST_PAGE_URL, sessionAid, sessionRid);
-}
-
-function buildFeedAggregateUrl(sessionAid?: string, sessionRid?: string): string {
-  return buildUrlWithAidRid(FEED_AGGREGATE_BASE, POST_DETAIL_PAGE_URL, sessionAid, sessionRid);
-}
-
-/** 近30天到昨天:返回 [startTime, endTime] Unix 秒(中国时间 00:00:00 起算) */
-function getLast30DaysRange(): { startTime: number; endTime: number; startDate: Date; endDate: Date } {
-  const now = new Date();
-  const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1);
-  const startDate = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate() - 30);
-  startDate.setHours(0, 0, 0, 0);
-  yesterday.setHours(23, 59, 59, 999);
-  const startTime = Math.floor(startDate.getTime() / 1000);
-  const endTime = Math.floor(yesterday.getTime() / 1000);
-  const endDateNorm = new Date(yesterday.getFullYear(), yesterday.getMonth(), yesterday.getDate());
-  endDateNorm.setHours(0, 0, 0, 0);
-  return { startTime, endTime, startDate, endDate: endDateNorm };
-}
-
-function toInt(val: unknown, defaultVal = 0): number {
-  if (typeof val === 'number') return Number.isFinite(val) ? Math.round(val) : defaultVal;
-  if (typeof val === 'string') {
-    const n = parseInt(val, 10);
-    return Number.isFinite(n) ? n : defaultVal;
+        .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: '/' };
+        })
+    );
   }
-  return defaultVal;
-}
-
-interface PostListItem {
-  objectId?: string;
-  exportId?: string;
-}
-
-interface FeedAggregateDataByTabType {
-  tabType?: number;
-  tabTypeName?: string;
-  data?: {
-    browse?: string[];
-    like?: string[];
-    comment?: string[];
-    forward?: string[];
-    fav?: string[];
-    follow?: string[];
-  };
 }
 
 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();
     await svc.runDailyImportForAllWeixinVideoAccounts();
   }
 
+  /** 仅同步指定账号(用于测试),showBrowser=true 时显示浏览器窗口 */
+  static async runDailyImportForAccount(accountId: number, showBrowser = false): Promise<void> {
+    const svc = new WeixinVideoWorkStatisticsImportService();
+    const account = await svc.accountRepository.findOne({
+      where: { id: accountId, platform: 'weixin_video' as any },
+    });
+    if (!account) {
+      throw new Error(`未找到视频号账号 id=${accountId}`);
+    }
+    logger.info(`[WX WorkStats] 单账号同步 accountId=${accountId} showBrowser=${showBrowser}`);
+    await svc.importAccountWorksStatistics(account, showBrowser);
+  }
+
   async runDailyImportForAllWeixinVideoAccounts(): Promise<void> {
     const accounts = await this.accountRepository.find({
       where: { platform: 'weixin_video' as any },
@@ -182,9 +90,9 @@ export class WeixinVideoWorkStatisticsImportService {
     logger.info('[WX WorkStats] All accounts done');
   }
 
-  private async importAccountWorksStatistics(account: PlatformAccount): Promise<void> {
-    const cookieHeader = getCookieHeaderString(account.cookieData);
-    if (!cookieHeader) {
+  private async importAccountWorksStatistics(account: PlatformAccount, showBrowser = false): Promise<void> {
+    const cookieForPython = getCookieForPython(account.cookieData);
+    if (!cookieForPython) {
       logger.warn(`[WX WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
       return;
     }
@@ -197,251 +105,60 @@ export class WeixinVideoWorkStatisticsImportService {
       return;
     }
 
-    const { startTime, endTime, startDate, endDate } = getLast30DaysRange();
-    const logFinderId = getLogFinderId(account.accountId);
-    const xWechatUin = getXWechatUinFromCookie(cookieHeader);
+    const worksPayload = works
+      .filter((w) => (w.platformVideoId ?? '').trim())
+      .map((w) => ({ work_id: w.id, platform_video_id: (w.platformVideoId ?? '').trim() }));
 
-    // _aid:post_list 时生成一次,本批次请求数据接口(feed_aggreagate)时复用;_rid 每次请求随机
-    const sessionAid =
-      process.env.WX_VIDEO_AID?.trim() || crypto.randomUUID();
-    logger.info(`[WX WorkStats] accountId=${account.id} post_list 生成 aid=${sessionAid},数据接口复用此 aid,rid 每次随机`);
-
-    const headers: Record<string, string> = {
-      accept: '*/*',
-      'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
-      'content-type': 'application/json',
-      cookie: cookieHeader,
-      origin: 'https://channels.weixin.qq.com',
-      referer: 'https://channels.weixin.qq.com/micro/statistic/post',
-      'user-agent':
-        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36 Edg/144.0.0.0',
-    };
-    if (xWechatUin) headers['x-wechat-uin'] = xWechatUin;
-
-    const postListBody = {
-      pageSize: 100,
-      currentPage: 1,
-      sort: 0,
-      order: 0,
-      startTime,
-      endTime,
-      timestamp: String(Date.now()),
-      _log_finder_uin: '',
-      _log_finder_id: logFinderId,
-      rawKeyBuff: null,
-      pluginSessionId: null,
-      scene: 7,
-      reqScene: 7,
-    };
-
-    const postListUrl = buildPostListUrl(sessionAid, generateRandomRid());
-    let res: Response;
-    try {
-      res = await fetch(postListUrl, {
-        method: 'POST',
-        headers,
-        body: JSON.stringify(postListBody),
-        signal: AbortSignal.timeout(30_000),
-      });
-    } catch (e) {
-      logger.error(`[WX WorkStats] post_list request failed. accountId=${account.id}`, e);
-      throw e;
-    }
-
-    if (!res.ok) {
-      logger.warn(`[WX WorkStats] post_list HTTP ${res.status}. accountId=${account.id}`);
+    if (!worksPayload.length) {
+      logger.info(`[WX WorkStats] accountId=${account.id} 无有效 platform_video_id,跳过`);
       return;
     }
 
-    const postListJson = (await res.json().catch(() => null)) as {
-      errCode?: number;
-      errMsg?: string;
-      data?: { list?: PostListItem[]; totalCount?: number };
-    } | null;
-
-    if (!postListJson || postListJson.errCode !== 0) {
-      logger.warn(
-        `[WX WorkStats] post_list errCode=${postListJson?.errCode} errMsg=${postListJson?.errMsg}. accountId=${account.id}`
-      );
-      return;
-    }
-
-    const list = postListJson.data?.list ?? [];
-    const totalCount = postListJson.data?.totalCount ?? list.length;
-    const exportIdToObjectId = new Map<string, string>();
-    for (const item of list) {
-      const exportId = item.exportId ?? '';
-      const objectId = item.objectId ?? '';
-      if (exportId && objectId) exportIdToObjectId.set(exportId, objectId);
-    }
-
-    // 日志:对比 API 与 DB 的 exportId
     logger.info(
-      `[WX WorkStats] accountId=${account.id} post_list 返回 totalCount=${totalCount} list.length=${list.length}`
+      `[WX WorkStats] accountId=${account.id} 调用 Python 纯浏览器同步,共 ${worksPayload.length} 个作品`
     );
-    const apiExportIds: string[] = [];
-    for (let i = 0; i < list.length; i++) {
-      const item = list[i];
-      const eid = (item.exportId ?? '').trim();
-      const oid = (item.objectId ?? '').trim();
-      apiExportIds.push(eid);
-      logger.info(`[WX WorkStats] post_list[${i}] exportId=${eid} objectId=${oid}`);
-    }
-    for (const work of works) {
-      const dbExportId = (work.platformVideoId ?? '').trim();
-      if (!dbExportId) continue;
-      const matched = exportIdToObjectId.has(dbExportId);
-      logger.info(
-        `[WX WorkStats] DB workId=${work.id} platform_video_id(exportId)=${dbExportId} 匹配post_list=${matched}`
-      );
-      if (!matched && apiExportIds.length > 0) {
-        const sameLength = apiExportIds.filter((e) => e.length === dbExportId.length).length;
-        const containsDb = apiExportIds.some((e) => e === dbExportId || e.includes(dbExportId) || dbExportId.includes(e));
-        logger.info(
-          `[WX WorkStats] 对比: DB长度=${dbExportId.length} API条数=${apiExportIds.length} 同长API条数=${sameLength} 是否包含关系=${containsDb}`
-        );
-      }
-    }
 
-    let totalInserted = 0;
-    let totalUpdated = 0;
-
-    const feedHeaders: Record<string, string> = {
-      ...headers,
-      referer: 'https://channels.weixin.qq.com/micro/statistic/postDetail?isImageMode=0',
-      'finger-print-device-id':
-        process.env.WX_VIDEO_FINGERPRINT_DEVICE_ID?.trim() ||
-        '4605bc28ad3962eb9ee791897b199217',
-    };
-
-    for (const work of works) {
-      const exportId = (work.platformVideoId ?? '').trim();
-      if (!exportId) continue;
-
-      const objectId = exportIdToObjectId.get(exportId);
-      if (!objectId) {
-        logger.debug(`[WX WorkStats] workId=${work.id} exportId=${exportId} 未在 post_list 中匹配到 objectId,跳过`);
-        continue;
-      }
+    try {
+      const pyRes = await fetch(`${PYTHON_SERVICE_URL}/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 feedBody = {
-        startTs: String(startTime),
-        endTs: String(endTime),
-        interval: 3,
-        feedId: objectId,
-        timestamp: String(Date.now()),
-        _log_finder_uin: '',
-        _log_finder_id: logFinderId,
-        rawKeyBuff: null,
-        pluginSessionId: null,
-        scene: 7,
-        reqScene: 7,
+      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 feedUrl = buildFeedAggregateUrl(sessionAid, generateRandomRid());
-      let feedRes: Response;
-      try {
-        feedRes = await fetch(feedUrl, {
-          method: 'POST',
-          headers: feedHeaders,
-          body: JSON.stringify(feedBody),
-          signal: AbortSignal.timeout(30_000),
-        });
-      } catch (e) {
-        logger.error(`[WX WorkStats] feed_aggreagate request failed. workId=${work.id} feedId=${objectId}`, e);
-        continue;
-      }
-
-      if (!feedRes.ok) {
-        logger.warn(`[WX WorkStats] feed_aggreagate HTTP ${feedRes.status}. workId=${work.id}`);
-        continue;
+      if (!pyRes.ok) {
+        logger.warn(`[WX WorkStats] accountId=${account.id} Python 请求失败: ${pyRes.status} ${data.error || ''}`);
+        return;
       }
 
-      const feedJson = (await feedRes.json().catch(() => null)) as {
-        errCode?: number;
-        data?: {
-          dataByFanstype?: { dataByTabtype?: FeedAggregateDataByTabType[] }[];
-          feedData?: { dataByTabtype?: FeedAggregateDataByTabType[] }[];
-        };
-      } | null;
-
-      const isTestWork = work.id === 866 || work.id === 867 || work.id === 902 || work.id === 903;
-      if (isTestWork) {
-        logger.info(`[WX WorkStats] feed_aggreagate 原始响应 workId=${work.id} errCode=${feedJson?.errCode} errMsg=${(feedJson as any)?.errMsg} 完整body=${JSON.stringify(feedJson ?? null)}`);
-      }
-
-      if (!feedJson || feedJson.errCode !== 0) {
-        if (isTestWork) {
-          logger.warn(`[WX WorkStats] workId=${work.id} feed_aggreagate 非成功 errCode=${feedJson?.errCode} 跳过`);
-        }
-        continue;
-      }
-
-      const dataByFanstype = feedJson.data?.dataByFanstype ?? [];
-      const firstFans = dataByFanstype[0];
-      const dataByTabtype = firstFans?.dataByTabtype ?? feedJson.data?.feedData?.[0]?.dataByTabtype ?? [];
-      const tabAll = dataByTabtype.find((t) => t.tabTypeName === '全部' || t.tabType === 999);
-      if (isTestWork) {
-        logger.info(
-          `[WX WorkStats] workId=${work.id} dataByTabtype.length=${dataByTabtype.length} tabAll=${!!tabAll} tabAll.tabTypeName=${tabAll?.tabTypeName}`
-        );
+      if (!data.success) {
+        logger.warn(`[WX WorkStats] accountId=${account.id} 同步失败: ${data.error || ''}`);
+        return;
       }
-      if (!tabAll?.data) continue;
-
-      const data = tabAll.data;
-      const browse = data.browse ?? [];
-      const like = data.like ?? [];
-      const comment = data.comment ?? [];
-      if (isTestWork) {
-        logger.info(
-          `[WX WorkStats] workId=${work.id} 「全部」data: browse.length=${browse.length} like.length=${like.length} comment.length=${comment.length}`
-        );
-        logger.info(`[WX WorkStats] workId=${work.id} browse=${JSON.stringify(browse)}`);
-        logger.info(`[WX WorkStats] workId=${work.id} like=${JSON.stringify(like)}`);
-        logger.info(`[WX WorkStats] workId=${work.id} comment=${JSON.stringify(comment)}`);
-      }
-
-      const len = Math.max(browse.length, like.length, comment.length);
-      if (len === 0) continue;
-
-      const patches: Array<{
-        workId: number;
-        recordDate: Date;
-        playCount?: number;
-        likeCount?: number;
-        commentCount?: number;
-      }> = [];
-
-      for (let i = 0; i < len; i++) {
-        const recordDate = new Date(startDate);
-        recordDate.setDate(recordDate.getDate() + i);
-        recordDate.setHours(0, 0, 0, 0);
-        if (recordDate > endDate) break;
 
-        patches.push({
-          workId: work.id,
-          recordDate,
-          playCount: toInt(browse[i], 0),
-          likeCount: toInt(like[i], 0),
-          commentCount: toInt(comment[i], 0),
-        });
-      }
-
-      if (isTestWork) {
-        logger.info(`[WX WorkStats] workId=${work.id} 生成 patches.length=${patches.length} 前3条=${JSON.stringify(patches.slice(0, 3))}`);
-      }
-      if (patches.length) {
-        const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(patches);
-        if (isTestWork) {
-          logger.info(`[WX WorkStats] workId=${work.id} saveStatisticsForDateBatch inserted=${result.inserted} updated=${result.updated}`);
-        }
-        totalInserted += result.inserted;
-        totalUpdated += result.updated;
-      }
+      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} 条` +
+          (worksUpdated > 0 ? `, works 表更新 ${worksUpdated} 条` : '')
+      );
+    } catch (e) {
+      logger.error(`[WX WorkStats] accountId=${account.id} 调用 Python 失败:`, e);
+      throw e;
     }
-
-    logger.info(
-      `[WX WorkStats] accountId=${account.id} completed. inserted=${totalInserted} updated=${totalUpdated}`
-    );
   }
 }

+ 8 - 0
server/src/services/WorkDayStatisticsService.ts

@@ -11,6 +11,7 @@ interface StatisticsItem {
   shareCount?: number;
   collectCount?: number;
   fansIncrease?: number;
+  followCount?: number;
   coverClickRate?: string;
   avgWatchDuration?: string;
   totalWatchDuration?: string;
@@ -59,6 +60,7 @@ interface WorkStatisticsItem {
   shareCount: number;
   collectCount: number;
   fansIncrease?: number;
+  followCount?: number; // 视频号:关注数
   totalWatchDuration?: string;
   avgWatchDuration?: string;
   coverClickRate?: string;
@@ -161,6 +163,7 @@ export class WorkDayStatisticsService {
           shareCount: stat.shareCount ?? existing.shareCount,
           collectCount: stat.collectCount ?? existing.collectCount,
           fansIncrease: stat.fansIncrease ?? existing.fansIncrease,
+          followCount: stat.followCount ?? existing.followCount,
           coverClickRate: stat.coverClickRate ?? existing.coverClickRate ?? '0',
           avgWatchDuration: stat.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
           totalWatchDuration: stat.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
@@ -180,6 +183,7 @@ export class WorkDayStatisticsService {
           shareCount: stat.shareCount ?? 0,
           collectCount: stat.collectCount ?? 0,
           fansIncrease: stat.fansIncrease ?? 0,
+          followCount: stat.followCount ?? 0,
           coverClickRate: stat.coverClickRate ?? '0',
           avgWatchDuration: stat.avgWatchDuration ?? '0',
           totalWatchDuration: stat.totalWatchDuration ?? '0',
@@ -238,6 +242,7 @@ export class WorkDayStatisticsService {
         shareCount: patch.shareCount ?? existing.shareCount,
         collectCount: patch.collectCount ?? existing.collectCount,
         fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
+        followCount: patch.followCount ?? existing.followCount,
         coverClickRate: patch.coverClickRate ?? existing.coverClickRate ?? '0',
         avgWatchDuration: patch.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
         totalWatchDuration: patch.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
@@ -257,6 +262,7 @@ export class WorkDayStatisticsService {
       shareCount: patch.shareCount ?? 0,
       collectCount: patch.collectCount ?? 0,
       fansIncrease: patch.fansIncrease ?? 0,
+      followCount: patch.followCount ?? 0,
       coverClickRate: patch.coverClickRate ?? '0',
       avgWatchDuration: patch.avgWatchDuration ?? '0',
       totalWatchDuration: patch.totalWatchDuration ?? '0',
@@ -609,6 +615,7 @@ export class WorkDayStatisticsService {
       .addSelect('wds.share_count', 'shareCount')
       .addSelect('wds.collect_count', 'collectCount')
       .addSelect('wds.fans_increase', 'fansIncrease')
+      .addSelect('wds.follow_count', 'followCount')
       .addSelect('wds.total_watch_duration', 'totalWatchDuration')
       .addSelect('wds.avg_watch_duration', 'avgWatchDuration')
       .addSelect('wds.cover_click_rate', 'coverClickRate')
@@ -649,6 +656,7 @@ export class WorkDayStatisticsService {
         shareCount: parseInt(row.shareCount) || 0,
         collectCount: parseInt(row.collectCount) || 0,
         fansIncrease: parseInt(row.fansIncrease) || 0,
+        followCount: parseInt(row.followCount) || 0,
         totalWatchDuration: row.totalWatchDuration || '0',
         avgWatchDuration: row.avgWatchDuration || '0',
         coverClickRate: row.coverClickRate || '0',

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

@@ -746,6 +746,7 @@ export class WorkService {
       yesterdayCommentCount: work.yesterdayCommentCount,
       yesterdayShareCount: work.yesterdayShareCount,
       yesterdayCollectCount: work.yesterdayCollectCount,
+      yesterdayRecommendCount: (work as any).yesterdayRecommendCount,
       yesterdayFansIncrease: work.yesterdayFansIncrease,
       yesterdayCoverClickRate: work.yesterdayCoverClickRate,
       yesterdayAvgWatchDuration: work.yesterdayAvgWatchDuration,

+ 18 - 6
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -29,17 +29,19 @@ type PlaywrightCookie = {
 
 type MetricKind =
   | 'playCount'
+  | 'exposureCount'
   | 'likeCount'
   | 'commentCount'
   | 'shareCount'
   | 'collectCount'
   | 'fansIncrease'
+  | 'worksCount'
   | 'coverClickRate'
   | 'avgWatchDuration'
   | 'totalWatchDuration'
   | 'completionRate';
 
-type ExportMode = 'watch' | 'interaction' | 'fans';
+type ExportMode = 'watch' | 'interaction' | 'fans' | 'publish';
 
 function ensureDir(p: string) {
   return fs.mkdir(p, { recursive: true });
@@ -97,6 +99,7 @@ function detectMetricKind(sheetName: string): MetricKind | null {
   const n = sheetName.trim();
   // 观看数据:子表命名可能是「观看趋势」或「观看数趋势」
   if (n.includes('观看趋势') || n.includes('观看数')) return 'playCount';
+  if (n.includes('曝光趋势')) return 'exposureCount';
   if (n.includes('封面点击率')) return 'coverClickRate';
   if (n.includes('平均观看时长')) return 'avgWatchDuration';
   if (n.includes('观看总时长')) return 'totalWatchDuration';
@@ -110,6 +113,9 @@ function detectMetricKind(sheetName: string): MetricKind | null {
 
   // 涨粉数据(只取净涨粉趋势)
   if (n.includes('净涨粉') && n.includes('趋势')) return 'fansIncrease';
+
+  // 发布数据:总发布趋势 → 每日发布数,入库 works_count
+  if (n.includes('总发布趋势')) return 'worksCount';
   return null;
 }
 
@@ -210,9 +216,10 @@ export function parseXhsExcel(
     // 按导出类型过滤不相关子表,避免误写字段
     if (
       (mode === 'watch' &&
-        !['playCount', 'coverClickRate', 'avgWatchDuration', 'totalWatchDuration', 'completionRate'].includes(kind)) ||
+        !['playCount', 'exposureCount', 'coverClickRate', 'avgWatchDuration', 'totalWatchDuration', 'completionRate'].includes(kind)) ||
       (mode === 'interaction' && !['likeCount', 'commentCount', 'shareCount', 'collectCount'].includes(kind)) ||
-      (mode === 'fans' && kind !== 'fansIncrease')
+      (mode === 'fans' && kind !== 'fansIncrease') ||
+      (mode === 'publish' && kind !== 'worksCount')
     ) {
       continue;
     }
@@ -236,11 +243,13 @@ export function parseXhsExcel(
       if (!result.has(key)) result.set(key, { recordDate: d });
       const obj = result.get(key)!;
 
-      if (kind === 'playCount' || kind === 'likeCount' || kind === 'commentCount' || kind === 'shareCount' || kind === 'collectCount' || kind === 'fansIncrease') {
+      if (kind === 'playCount' || kind === 'exposureCount' || kind === 'likeCount' || kind === 'commentCount' || kind === 'shareCount' || kind === 'collectCount' || kind === 'fansIncrease' || kind === 'worksCount') {
         const n = parseChineseNumberLike(valueVal);
         if (typeof n === 'number') {
           if (kind === 'playCount') obj.playCount = n;
+          if (kind === 'exposureCount') obj.exposureCount = n;
           if (kind === 'likeCount') obj.likeCount = n;
+          if (kind === 'worksCount') obj.worksCount = n;
           if (kind === 'commentCount') obj.commentCount = n;
           if (kind === 'shareCount') obj.shareCount = n;
           if (kind === 'collectCount') obj.collectCount = n;
@@ -433,7 +442,7 @@ export class XiaohongshuAccountOverviewImportService {
       await page.getByText('账号概览', { exact: true }).first().click().catch(() => undefined);
       await page.getByText('笔记数据', { exact: true }).first().click();
 
-      const exportAndImport = async (tabText: '观看数据' | '互动数据' | '涨粉数据', mode: ExportMode) => {
+      const exportAndImport = async (tabText: '观看数据' | '互动数据' | '涨粉数据' | '发布数据', mode: ExportMode) => {
         await page.getByText(tabText, { exact: true }).first().click();
         await page.getByText(/近\d+日/).first().click().catch(() => undefined);
         await page.getByText('近30日', { exact: true }).click();
@@ -496,7 +505,10 @@ export class XiaohongshuAccountOverviewImportService {
       // 3) 涨粉数据:只取“净涨粉趋势”(解析器已过滤)
       await exportAndImport('涨粉数据', 'fans');
 
-      // 4) 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
+      // 4) 发布数据:近30日导出,解析「总发布趋势」→ user_day_statistics.works_count
+      await exportAndImport('发布数据', 'publish');
+
+      // 5) 粉丝数据页:打开粉丝数据、点击近30天,解析 overall_new 接口,将每日粉丝总数写入 user_day_statistics.fans_count
       await this.importFansDataTrendFromPage(context, page, account);
 
       logger.info(`[XHS Import] Account all tabs done. accountId=${account.id}`);

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 323 - 0
server/tmp/baijiahao-storage-state/10.json


+ 4 - 0
server/tmp/feed_aggreagate_account61_work906.json

@@ -0,0 +1,4 @@
+{
+  "errCode": 300800,
+  "errMsg": "request failed"
+}

+ 4 - 0
server/tmp/feed_aggreagate_account61_work907.json

@@ -0,0 +1,4 @@
+{
+  "errCode": 300800,
+  "errMsg": "request failed"
+}

+ 32 - 0
server/tmp/post_list_params.json

@@ -0,0 +1,32 @@
+[
+  {
+    "index": 0,
+    "exportId": "export/UzFfAgtgekIEAQAAAAAA7t0VvbweUgAAAAstQy6ubaLX4KHWvLEZgBPEuaBYKCQxUe6NzNPgMJpPX5kDlbwQURlqNnMbvAgw",
+    "objectId": "14850108409273518573",
+    "createTime": 1770270873,
+    "desc": "雪落下的声音",
+    "readCount": 149,
+    "likeCount": 0,
+    "commentCount": 0,
+    "forwardCount": 0,
+    "favCount": 0,
+    "followCount": 0,
+    "fullPlayRate": 0.20945945945945946,
+    "avgPlayTimeSec": 5.5675675675675675
+  },
+  {
+    "index": 1,
+    "exportId": "export/UzFfAgtgekIEAQAAAAAA2kAUwIZSfQAAAAstQy6ubaLX4KHWvLEZgBPEg6BYPH5pROKNzNPgMJpFgSGS9JNqdISSEBUW5Tvu",
+    "objectId": "14847937164213881303",
+    "createTime": 1770012040,
+    "desc": "",
+    "readCount": 173,
+    "likeCount": 0,
+    "commentCount": 0,
+    "forwardCount": 0,
+    "favCount": 1,
+    "followCount": 0,
+    "fullPlayRate": 0.023121387283236993,
+    "avgPlayTimeSec": 3.2254335260115607
+  }
+]

+ 2 - 0
shared/src/types/work.ts

@@ -32,6 +32,8 @@ export interface Work {
   yesterdayCommentCount?: number;
   yesterdayShareCount?: number;
   yesterdayCollectCount?: number;
+  /** 昨日推荐量 */
+  yesterdayRecommendCount?: number;
   yesterdayFansIncrease?: number;
   yesterdayCoverClickRate?: string;
   yesterdayAvgWatchDuration?: string;

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác