Преглед на файлове

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

Ethanfly преди 1 ден
родител
ревизия
f6d31912d1
променени са 30 файла, в които са добавени 1638 реда и са изтрити 253 реда
  1. 0 0
      client/dist-electron/main.js.map
  2. 0 8
      client/src/components.d.ts
  3. 12 34
      client/src/views/Works/index.vue
  4. 13 0
      database/migrations/add_fields_to_work_day_statistics.sql
  5. 7 0
      database/migrations/add_total_watch_duration_to_work_day_statistics.sql
  6. 16 0
      database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql
  7. 12 0
      database/migrations/rename_work_day_statistics_columns.sql
  8. 35 1
      database/schema.sql
  9. 2 0
      server/package.json
  10. 66 0
      server/python/WECHAT_VIDEO_SYNC_WORKS.md
  11. BIN
      server/python/platforms/__pycache__/baijiahao.cpython-311.pyc
  12. BIN
      server/python/platforms/__pycache__/douyin.cpython-311.pyc
  13. BIN
      server/python/platforms/__pycache__/weixin.cpython-311.pyc
  14. 214 84
      server/python/platforms/baijiahao.py
  15. 2 0
      server/python/platforms/douyin.py
  16. 187 103
      server/python/platforms/weixin.py
  17. 4 0
      server/python/platforms/xiaohongshu.py
  18. 1 0
      server/src/automation/platforms/base.ts
  19. 2 1
      server/src/models/entities/Work.ts
  20. 33 0
      server/src/models/entities/WorkDayStatistics.ts
  21. 17 0
      server/src/routes/internal.ts
  22. 29 0
      server/src/scheduler/index.ts
  23. 51 0
      server/src/scripts/clean-work-day-statistics-orphans.ts
  24. 18 0
      server/src/scripts/run-xhs-work-stats-import.ts
  25. 3 2
      server/src/services/CommentService.ts
  26. 279 13
      server/src/services/HeadlessBrowserService.ts
  27. 130 5
      server/src/services/WorkDayStatisticsService.ts
  28. 19 1
      server/src/services/WorkService.ts
  29. 3 1
      server/src/services/XiaohongshuAccountOverviewImportService.ts
  30. 483 0
      server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

Файловите разлики са ограничени, защото са твърде много
+ 0 - 0
client/dist-electron/main.js.map


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

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

+ 12 - 34
client/src/views/Works/index.vue

@@ -69,22 +69,13 @@
           @click="openWorkDetail(work)"
         >
           <div class="work-cover">
-            <img :src="getSecureCoverUrl(work.coverUrl)" :alt="work.title" @error="handleImageError" />
-            <span class="work-duration">{{ formatDuration(work.duration) }}</span>
-            <el-tag 
-              class="work-status" 
-              :type="getStatusType(work.status)" 
-              size="small"
-            >
-              {{ getStatusText(work.status) }}
-            </el-tag>
+            <img :src="getWorkCoverSrc(work)" :alt="work.title" @error="handleImageError" />
           </div>
           
           <div class="work-info">
             <div class="work-title" :title="work.title">{{ work.title || '无标题' }}</div>
             <div class="work-meta">
               <el-tag size="small" type="info">{{ getPlatformName(work.platform) }}</el-tag>
-              <span class="work-time">{{ formatDate(work.publishTime) }}</span>
             </div>
             <div class="work-stats">
               <span><el-icon><VideoPlay /></el-icon> {{ formatNumber(work.playCount) }}</span>
@@ -193,7 +184,7 @@
       destroy-on-close
     >
       <div class="comments-drawer-header" v-if="commentsWork">
-        <img :src="getSecureCoverUrl(commentsWork.coverUrl)" class="work-thumb" @error="handleImageError" />
+        <img :src="getWorkCoverSrc(commentsWork)" class="work-thumb" @error="handleImageError" />
         <div class="work-brief">
           <div class="work-brief-title">{{ commentsWork.title || '无标题' }}</div>
           <div class="work-brief-meta">
@@ -495,6 +486,15 @@ function formatDuration(seconds: number | string | undefined): string {
   return `${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
 }
 
+// 作品列表/评论等处的封面:兼容 coverUrl / cover_url,无封面时用占位图
+const COVER_PLACEHOLDER = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">无封面</text></svg>';
+
+function getWorkCoverSrc(work: Work): string {
+  const url = (work as { coverUrl?: string; cover_url?: string }).coverUrl ?? (work as { cover_url?: string }).cover_url ?? '';
+  if (!url || typeof url !== 'string' || !url.trim()) return COVER_PLACEHOLDER;
+  return getSecureCoverUrl(url);
+}
+
 // 将 HTTP 图片 URL 转换为 HTTPS(小红书等平台的图片 URL 可能是 HTTP)
 function getSecureCoverUrl(url: string): string {
   if (!url) return '';
@@ -507,7 +507,7 @@ function getSecureCoverUrl(url: string): string {
 
 function handleImageError(e: Event) {
   const img = e.target as HTMLImageElement;
-  img.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><rect fill="%23f0f0f0" width="100" height="100"/><text x="50" y="55" text-anchor="middle" fill="%23999" font-size="12">无封面</text></svg>';
+  img.src = COVER_PLACEHOLDER;
 }
 
 async function loadWorks() {
@@ -936,23 +936,6 @@ onUnmounted(() => {
       height: 100%;
       object-fit: cover;
     }
-    
-    .work-duration {
-      position: absolute;
-      bottom: 8px;
-      right: 8px;
-      padding: 2px 6px;
-      background: rgba(0, 0, 0, 0.7);
-      color: #fff;
-      font-size: 12px;
-      border-radius: 4px;
-    }
-    
-    .work-status {
-      position: absolute;
-      top: 8px;
-      left: 8px;
-    }
   }
   
   .work-info {
@@ -973,11 +956,6 @@ onUnmounted(() => {
       align-items: center;
       gap: 8px;
       margin-bottom: 8px;
-      
-      .work-time {
-        font-size: 12px;
-        color: $text-secondary;
-      }
     }
     
     .work-stats {

+ 13 - 0
database/migrations/add_fields_to_work_day_statistics.sql

@@ -0,0 +1,13 @@
+-- 为 work_day_statistics 表添加扩展字段的迁移脚本
+-- 执行日期: 2026-02-02
+
+USE media_manager;
+
+ALTER TABLE work_day_statistics
+  ADD COLUMN impression_count INT DEFAULT 0 COMMENT '曝光数/展现量' AFTER play_count,
+  ADD COLUMN rise_fans_count INT DEFAULT 0 COMMENT '涨粉数' AFTER collect_count,
+  ADD COLUMN cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率' AFTER rise_fans_count,
+  ADD COLUMN avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)' AFTER cover_click_rate,
+  ADD COLUMN finish_rate VARCHAR(50) DEFAULT '0' COMMENT '完播率' AFTER avg_watch_duration,
+  ADD COLUMN exit_view2s_rate VARCHAR(50) DEFAULT '0' COMMENT '2秒退出率' AFTER finish_rate;
+

+ 7 - 0
database/migrations/add_total_watch_duration_to_work_day_statistics.sql

@@ -0,0 +1,7 @@
+-- 为 work_day_statistics 表添加 total_watch_duration(总观看时长)字段
+-- 执行日期: 2026-02-02
+
+USE media_manager;
+
+ALTER TABLE work_day_statistics
+  ADD COLUMN total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '总观看时长(秒)' AFTER avg_watch_duration;

+ 16 - 0
database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql

@@ -0,0 +1,16 @@
+-- 1. 清理 work_day_statistics 中 work_id 在 works 表不存在的孤儿数据
+-- 2. 为 work_day_statistics.work_id 添加外键,删除 works 时级联删除对应日统计
+-- 执行日期: 2026-02-02
+
+USE media_manager;
+
+-- 删除孤儿数据:work_day_statistics 中 work_id 在 works 里不存在的记录
+DELETE wds
+FROM work_day_statistics wds
+LEFT JOIN works w ON wds.work_id = w.id
+WHERE w.id IS NULL;
+
+-- 添加外键:删除 works 时自动删除对应 work_day_statistics
+ALTER TABLE work_day_statistics
+  ADD CONSTRAINT fk_work_day_statistics_work
+  FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE;

+ 12 - 0
database/migrations/rename_work_day_statistics_columns.sql

@@ -0,0 +1,12 @@
+-- 将 work_day_statistics 表字段名改为与 API 口径一致
+-- 执行日期: 2026-02-02
+-- 若已执行过 add_fields_to_work_day_statistics.sql,用本脚本重命名列
+
+USE media_manager;
+
+-- 若列名为旧名则重命名(MySQL 5.7+ 用 CHANGE COLUMN)
+ALTER TABLE work_day_statistics
+  CHANGE COLUMN impression_count exposure_count INT DEFAULT 0 COMMENT '曝光数/展现量',
+  CHANGE COLUMN rise_fans_count fans_increase INT DEFAULT 0 COMMENT '涨粉数',
+  CHANGE COLUMN finish_rate completion_rate VARCHAR(50) DEFAULT '0' COMMENT '完播率',
+  CHANGE COLUMN exit_view2s_rate two_second_exit_rate VARCHAR(50) DEFAULT '0' COMMENT '2秒退出率';

+ 35 - 1
database/schema.sql

@@ -185,21 +185,55 @@ CREATE TABLE IF NOT EXISTS operation_logs (
     INDEX idx_log_user_time (user_id, created_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
+-- 作品表(关联平台账号)
+CREATE TABLE IF NOT EXISTS works (
+    id INT PRIMARY KEY AUTO_INCREMENT,
+    userId INT NOT NULL,
+    accountId INT NOT NULL,
+    platform VARCHAR(20) NOT NULL,
+    platform_video_id VARCHAR(500) NOT NULL,
+    title VARCHAR(200) DEFAULT '',
+    description TEXT,
+    cover_url VARCHAR(500) DEFAULT '',
+    video_url VARCHAR(500),
+    duration VARCHAR(20) DEFAULT '00:00',
+    status VARCHAR(20) DEFAULT 'published',
+    publish_time DATETIME,
+    play_count INT DEFAULT 0,
+    like_count INT DEFAULT 0,
+    comment_count INT DEFAULT 0,
+    share_count INT DEFAULT 0,
+    collect_count INT DEFAULT 0,
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    INDEX idx_user_platform (userId, platform),
+    UNIQUE KEY uk_account_platform_video (accountId, platform_video_id),
+    FOREIGN KEY (accountId) REFERENCES platform_accounts(id) ON DELETE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
 -- 作品每日统计表(记录每天的数据快照)
 CREATE TABLE IF NOT EXISTS work_day_statistics (
     id INT PRIMARY KEY AUTO_INCREMENT,
     work_id INT NOT NULL,
     record_date DATE NOT NULL,
     play_count INT DEFAULT 0 COMMENT '播放数',
+    exposure_count INT DEFAULT 0 COMMENT '曝光数/展现量',
     like_count INT DEFAULT 0 COMMENT '点赞数',
     comment_count INT DEFAULT 0 COMMENT '评论数',
     share_count INT DEFAULT 0 COMMENT '分享数',
     collect_count INT DEFAULT 0 COMMENT '收藏数',
+    fans_increase INT DEFAULT 0 COMMENT '涨粉数',
+    cover_click_rate VARCHAR(50) DEFAULT '0' COMMENT '封面点击率',
+    avg_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '平均观看时长(秒)',
+    total_watch_duration VARCHAR(50) DEFAULT '0' COMMENT '总观看时长(秒)',
+    completion_rate VARCHAR(50) DEFAULT '0' COMMENT '完播率',
+    two_second_exit_rate VARCHAR(50) DEFAULT '0' COMMENT '2秒退出率',
     created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
     updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
     UNIQUE KEY uk_work_date (work_id, record_date),
     INDEX idx_work_id (work_id),
-    INDEX idx_record_date (record_date)
+    INDEX idx_record_date (record_date),
+    FOREIGN KEY (work_id) REFERENCES works(id) ON DELETE CASCADE
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作品每日统计数据';
 
 -- 账号每日统计表(记录每个平台账号的粉丝数和作品数,不关联作品)

+ 2 - 0
server/package.json

@@ -7,7 +7,9 @@
   "scripts": {
     "dev": "tsx watch src/app.ts",
     "xhs:import": "tsx src/scripts/run-xhs-import.ts",
+    "xhs:work-stats": "tsx src/scripts/run-xhs-work-stats-import.ts",
     "check:trend": "tsx src/scripts/check-trend-data.ts",
+    "clean:work-day-orphans": "tsx src/scripts/clean-work-day-statistics-orphans.ts",
     "xhs:auth": "set XHS_IMPORT_HEADLESS=0&& set XHS_STORAGE_STATE_BOOTSTRAP=1&& tsx src/scripts/run-xhs-import.ts",
     "build": "tsc",
     "start": "node dist/app.js",

+ 66 - 0
server/python/WECHAT_VIDEO_SYNC_WORKS.md

@@ -0,0 +1,66 @@
+# 视频号(weixin_video)同步作品逻辑说明
+
+## 一、整体流程
+
+```
+作品管理「同步作品」 / 任务队列 sync_works
+    → WorkService.syncWorks(userId, accountId?, platform?)
+    → 按账号筛选后,对每个账号调用 HeadlessBrowserService.fetchAccountInfo(platform, cookies, ...)
+    → 若平台在 supportedPlatforms 内(含 weixin_video),优先用 Python 拉作品
+    → fetchWorksViaPython('weixin_video', cookies, onProgress)
+    → 请求 Python 服务 POST /works,platform 映射为 'weixin'
+    → Python platforms/weixin.py WeixinPublisher.get_works(cookies, page, page_size)
+    → 返回 works + total + has_more,Node 据此分页循环
+    → 拿到全量 works 后,再调 fetchAccountInfoWithPlaywright 补账号信息(头像、粉丝等)
+    → WorkService 用返回的 works 与 DB 做比对:新增/更新/删除本地作品
+```
+
+## 二、Node 端(HeadlessBrowserService)
+
+- **平台映射**:`weixin_video` → 请求 Python 时用 `platform: 'weixin'`(`pythonPlatform = platform === 'weixin_video' ? 'weixin' : platform`)。
+- **分页方式**:视频号**不用**游标分页,用**页码**:
+  - `useCursorPagination = platform === 'xiaohongshu' || platform === 'douyin'` → 视频号为 false。
+  - 因此 `pageParam = pageIndex`(0, 1, 2, ...),每次请求带 `page`、`page_size`。
+- **每页条数**:非小红书/非抖音统一 `pageSize = 50`。
+- **停止条件**:`!result.has_more || pageWorks.length === 0 || newCount === 0` 时结束循环。
+
+## 三、Python 端(platforms/weixin.py)
+
+- **入口**:`get_works(cookies, page=0, page_size=20)`,由 `run_get_works` 调用(app.py 的 /works 路由传 page、page_size)。
+- **当前实现要点**:
+  1. 打开固定页面:`https://channels.weixin.qq.com/platform/post/list`,等待 `div.post-feed-item`。
+  2. 用 `self.page.locator('div.post-feed-item')` 取当前 DOM 下所有作品项,`item_count = await post_items.count()`。
+  3. 只遍历前 `min(item_count, page_size)` 条,解析封面、标题、时间、播放/点赞/评论/分享/收藏等。
+  4. **未使用参数 `page`**:没有根据 page 做滚动、点击「加载更多」或请求下一页接口,每次请求都是同一页 DOM。
+  5. 返回值:
+     - `total = len(works)`(当前批数量,非平台总作品数);
+     - `has_more = item_count > page_size`(仅表示当前屏 DOM 条数是否大于 page_size)。
+
+## 四、存在的问题
+
+1. **分页未实现**
+   - Node 会按 `has_more` 继续请求 page=1, 2, …,但 Python 每次都是同一页、同一批 DOM,第二页起通常会返回重复数据,Node 端 `newCount === 0` 后停止。
+   - 实际效果:**只能拿到首屏/首批作品**(约几十条),列表若为虚拟滚动,首屏 DOM 条数有限,总数会更少。
+
+2. **work_id 不稳定**
+   - 使用 `work_id = f"weixin_{i}_{hash(title)}_{hash(publish_time)}"`,同一作品在不同批次或重试中可能 i 不同,且 hash 可能碰撞;若页面有唯一 ID(如接口或 data 属性),应用真实 ID 更稳妥。
+
+3. **total / has_more 含义与 Node 预期不一致**
+   - Node 用 `declaredTotal`、`has_more` 决定是否继续请求;Python 的 `total` 只是本批条数,`has_more` 只反映当前屏是否多于 page_size,不能代表「平台是否还有更多作品」。
+
+4. **调试代码未清理**
+   - 存在 `print("1111111111111111")` 等调试输出,以及大段 DOM 打印,建议移除或改为可配置日志。
+
+## 五、改进方向建议
+
+1. **实现真实分页**
+   - 若视频号创作者后台有「加载更多」或滚动加载:在 `get_works` 里根据 `page` 做多次滚动或点击,再采集当前屏新增的 `div.post-feed-item`,并去重。
+   - 若有列表接口(如类似抖音 work_list):改为请求接口并解析 cursor/offset 分页,返回 `next_page` 供 Node 按游标请求(需同步改 Node 对 weixin_video 的分页策略)。
+2. **使用稳定作品 ID**
+   - 从 DOM 或接口中取作品唯一 ID(若有),作为 `work_id`,便于去重与和本地库一致对应。
+3. **清理调试并规范日志**
+   - 去掉无意义 print,DOM 打印改为 debug 级别或环境变量控制。
+
+---
+
+文档基于当前代码整理,若 weixin.py 或 HeadlessBrowserService 有改动,以实际代码为准。

BIN
server/python/platforms/__pycache__/baijiahao.cpython-311.pyc


BIN
server/python/platforms/__pycache__/douyin.cpython-311.pyc


BIN
server/python/platforms/__pycache__/weixin.cpython-311.pyc


+ 214 - 84
server/python/platforms/baijiahao.py

@@ -823,9 +823,14 @@ class BaijiahaoPublisher(BasePublisher):
     async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
         """
         获取百家号作品列表
-        使用直接 HTTP API 调用,不使用浏览器
+        优先使用内容管理页的接口(pcui/article/lists)。
+
+        说明:
+        - 该接口通常需要自定义请求头 token(JWT),仅靠 Cookie 可能会返回“未登录”
+        - 这里使用 Playwright 打开内容页,从 localStorage/sessionStorage/页面脚本中自动提取 token,
+          再在页面上下文中发起 fetch(携带 cookie + token),以提高成功率
         """
-        import aiohttp
+        import re
         
         print(f"\n{'='*60}")
         print(f"[{self.platform_name}] 获取作品列表 (使用 API)")
@@ -835,90 +840,213 @@ class BaijiahaoPublisher(BasePublisher):
         works: List[WorkItem] = []
         total = 0
         has_more = False
+        next_page = ""
         
         try:
-            # 解析 cookies
+            # 解析并设置 cookies(Playwright)
             cookie_list = self.parse_cookies(cookies)
-            cookie_dict = {c['name']: c['value'] for c in cookie_list}
-            
-            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/content',
-                '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"'
-            }
-            
-            # 计算起始位置
-            start = page * page_size
-            
-            async with aiohttp.ClientSession(cookies=cookie_dict) as session:
-                print(f"[{self.platform_name}] 调用 article/lists API (start={start}, count={page_size})...")
-                
-                async with session.get(
-                    f'https://baijiahao.baidu.com/pcui/article/lists?start={start}&count={page_size}&article_type=video',
-                    headers=headers,
-                    timeout=aiohttp.ClientTimeout(total=30)
-                ) as response:
-                    api_result = await response.json()
-                
-                print(f"[{self.platform_name}] article/lists API 完整响应: {json.dumps(api_result, ensure_ascii=False)[:500]}")
-                print(f"[{self.platform_name}] API 响应: errno={api_result.get('errno')}")
-                
-                # 检查登录状态
-                if api_result.get('errno') != 0:
-                    error_msg = api_result.get('errmsg', '未知错误')
-                    errno = api_result.get('errno')
-                    print(f"[{self.platform_name}] API 返回错误: errno={errno}, msg={error_msg}")
-                    
-                    if errno == 110:
-                        raise Exception("Cookie 已过期,请重新登录")
-                    
-                    raise Exception(error_msg)
-                
-                # 解析作品列表
-                data = api_result.get('data', {})
-                article_list = data.get('article_list', [])
-                has_more = data.get('has_more', False)
-                total = data.get('total', 0)
-                
-                print(f"[{self.platform_name}] 获取到 {len(article_list)} 个作品,总数: {total}")
-                
-                for article in article_list:
-                    work_id = str(article.get('article_id', ''))
-                    if not work_id:
-                        continue
-                    
-                    # 处理封面图
-                    cover_url = ''
-                    cover_images = article.get('cover_images', [])
-                    if cover_images and len(cover_images) > 0:
-                        cover_url = cover_images[0]
-                        if cover_url and cover_url.startswith('//'):
-                            cover_url = 'https:' + cover_url
-                    
-                    works.append(WorkItem(
+            await self.init_browser()
+            await self.set_cookies(cookie_list)
+
+            if not self.page:
+                raise Exception("Page not initialized")
+
+            # 先打开内容管理页,确保本页 Referer/会话就绪
+            # Node 侧传 page=0,1,...;接口 currentPage 为 1,2,...
+            current_page = int(page) + 1
+            page_size = int(page_size)
+            content_url = (
+                "https://baijiahao.baidu.com/builder/rc/content"
+                f"?currentPage={current_page}&pageSize={page_size}"
+                "&search=&type=&collection=&startDate=&endDate="
+            )
+            await self.page.goto(content_url, wait_until="domcontentloaded", timeout=60000)
+            await asyncio.sleep(2)
+
+            # 1) 提取 token(JWT)
+            token = await self.page.evaluate(
+                """
+                () => {
+                  const isJwtLike = (v) => {
+                    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) => {
+                    try {
+                      const keys = Object.keys(storage || {});
+                      for (const k of keys) {
+                        const v = storage.getItem(k);
+                        if (isJwtLike(v)) return v;
+                      }
+                    } catch {}
+                    return "";
+                  };
+
+                  // localStorage / sessionStorage
+                  let t = pickFromStorage(window.localStorage);
+                  if (t) return t;
+                  t = pickFromStorage(window.sessionStorage);
+                  if (t) return t;
+
+                  // meta 标签
+                  const meta = document.querySelector('meta[name="token"], meta[name="bjh-token"]');
+                  const metaToken = meta && meta.getAttribute('content');
+                  if (isJwtLike(metaToken)) return metaToken;
+
+                  // 简单从全局变量里找
+                  const candidates = [
+                    (window.__INITIAL_STATE__ && window.__INITIAL_STATE__.token) || "",
+                    (window.__PRELOADED_STATE__ && window.__PRELOADED_STATE__.token) || "",
+                    (window.__NUXT__ && window.__NUXT__.state && window.__NUXT__.state.token) || "",
+                  ];
+                  for (const c of candidates) {
+                    if (isJwtLike(c)) return c;
+                  }
+
+                  return "";
+                }
+                """
+            )
+
+            # 2) 若仍未取到 token,再从页面 HTML 兜底提取
+            if not token:
+                html = await self.page.content()
+                m = re.search(r'([A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,})', html)
+                if m:
+                    token = m.group(1)
+
+            if not token:
+                raise Exception("未能从页面提取 token(可能未登录或触发风控),请重新登录百家号账号后再试")
+
+            # 3) 调用接口(在页面上下文 fetch,自动携带 cookie)
+            api_url = (
+                "https://baijiahao.baidu.com/pcui/article/lists"
+                f"?currentPage={current_page}"
+                f"&pageSize={page_size}"
+                "&search=&type=&collection=&startDate=&endDate="
+                "&clearBeforeFetch=false"
+                "&dynamic=1"
+            )
+            resp = await self.page.evaluate(
+                """
+                async ({ url, token }) => {
+                  const r = await fetch(url, {
+                    method: 'GET',
+                    credentials: 'include',
+                    headers: {
+                      'accept': 'application/json, text/plain, */*',
+                      ...(token ? { token } : {}),
+                    },
+                  });
+                  const text = await r.text();
+                  return { ok: r.ok, status: r.status, text };
+                }
+                """,
+                {"url": api_url, "token": token},
+            )
+
+            if not resp or not resp.get("ok"):
+                status = resp.get("status") if isinstance(resp, dict) else "unknown"
+                raise Exception(f"百家号接口请求失败: HTTP {status}")
+
+            api_result = json.loads(resp.get("text") or "{}")
+            print(f"[{self.platform_name}] pcui/article/lists 响应: errno={api_result.get('errno')}, errmsg={api_result.get('errmsg')}")
+
+            if api_result.get("errno") != 0:
+                errno = api_result.get("errno")
+                errmsg = api_result.get("errmsg", "unknown error")
+                # 20040001 常见为“未登录”
+                if errno in (110, 20040001):
+                    raise Exception("百家号未登录或 Cookie/token 失效,请重新登录后再同步")
+                raise Exception(f"百家号接口错误: errno={errno}, errmsg={errmsg}")
+
+            data = api_result.get("data", {}) or {}
+            items = data.get("list", []) or []
+            page_info = data.get("page", {}) or {}
+            total = int(page_info.get("totalCount", 0) or 0)
+            total_page = int(page_info.get("totalPage", 0) or 0)
+            cur_page = int(page_info.get("currentPage", current_page) or current_page)
+            has_more = bool(total_page and cur_page < total_page)
+            next_page = cur_page + 1 if has_more else ""
+
+            print(f"[{self.platform_name}] 获取到 {len(items)} 个作品,总数: {total}, currentPage={cur_page}, totalPage={total_page}")
+
+            def _pick_cover(item: dict) -> str:
+                cover = item.get("crosswise_cover") or item.get("vertical_cover") or ""
+                if cover:
+                    return cover
+                raw = item.get("cover_images") or ""
+                try:
+                    # cover_images 可能是 JSON 字符串
+                    parsed = json.loads(raw) if isinstance(raw, str) else raw
+                    if isinstance(parsed, list) and parsed:
+                        first = parsed[0]
+                        if isinstance(first, dict):
+                            return first.get("src") or first.get("ori_src") or ""
+                        if isinstance(first, str):
+                            return first
+                except Exception:
+                    pass
+                return ""
+
+            def _pick_duration(item: dict) -> int:
+                for k in ("rmb_duration", "duration", "long"):
+                    try:
+                        v = int(item.get(k) or 0)
+                        if v > 0:
+                            return v
+                    except Exception:
+                        pass
+                # displaytype_exinfo 里可能有 ugcvideo.video_info.durationInSecond
+                ex = item.get("displaytype_exinfo") or ""
+                try:
+                    exj = json.loads(ex) if isinstance(ex, str) and ex else (ex if isinstance(ex, dict) else {})
+                    ugc = (exj.get("ugcvideo") or {}) if isinstance(exj, dict) else {}
+                    vi = ugc.get("video_info") or {}
+                    v = int(vi.get("durationInSecond") or ugc.get("long") or 0)
+                    return v if v > 0 else 0
+                except Exception:
+                    return 0
+
+            def _pick_status(item: dict) -> str:
+                qs = str(item.get("quality_status") or "").lower()
+                st = str(item.get("status") or "").lower()
+                if qs == "rejected" or "reject" in st:
+                    return "rejected"
+                if st in ("draft", "unpublish", "unpublished"):
+                    return "draft"
+                # 百家号常见 publish
+                return "published"
+
+            for item in items:
+                # 优先使用 nid(builder 预览链接使用这个)
+                work_id = str(item.get("nid") or item.get("feed_id") or item.get("article_id") or item.get("id") or "")
+                if not work_id:
+                    continue
+
+                works.append(
+                    WorkItem(
                         work_id=work_id,
-                        title=article.get('title', ''),
-                        cover_url=cover_url,
-                        duration=0,
-                        status='published',
-                        publish_time=article.get('publish_time', ''),
-                        play_count=int(article.get('read_count', 0)),
-                        like_count=int(article.get('like_count', 0)),
-                        comment_count=int(article.get('comment_count', 0)),
-                        share_count=int(article.get('share_count', 0)),
-                    ))
-                
-                print(f"[{self.platform_name}] ✓ 成功解析 {len(works)} 个作品")
+                        title=str(item.get("title") or ""),
+                        cover_url=_pick_cover(item),
+                        video_url=str(item.get("url") or ""),
+                        duration=_pick_duration(item),
+                        status=_pick_status(item),
+                        publish_time=str(item.get("publish_time") or item.get("publish_at") or item.get("created_at") or ""),
+                        play_count=int(item.get("read_amount") or 0),
+                        like_count=int(item.get("like_amount") or 0),
+                        comment_count=int(item.get("comment_amount") or 0),
+                        share_count=int(item.get("share_amount") or 0),
+                        collect_count=int(item.get("collection_amount") or 0),
+                    )
+                )
+
+            print(f"[{self.platform_name}] ✓ 成功解析 {len(works)} 个作品")
             
         except Exception as e:
             import traceback
@@ -926,7 +1054,8 @@ class BaijiahaoPublisher(BasePublisher):
             return WorksResult(
                 success=False,
                 platform=self.platform_name,
-                error=str(e)
+                error=str(e),
+                debug_info="baijiahao_get_works_failed"
             )
         
         return WorksResult(
@@ -934,7 +1063,8 @@ class BaijiahaoPublisher(BasePublisher):
             platform=self.platform_name,
             works=works,
             total=total,
-            has_more=has_more
+            has_more=has_more,
+            next_page=next_page
         )
     
     async def check_login_status(self, cookies: str) -> dict:

+ 2 - 0
server/python/platforms/douyin.py

@@ -777,10 +777,12 @@ class DouyinPublisher(BasePublisher):
                 create_time = aweme.get('create_time', 0)
                 publish_time = datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S') if create_time else ''
                 
+                video_url = f"https://www.douyin.com/video/{aweme_id}" if aweme_id else ""
                 works.append(WorkItem(
                     work_id=aweme_id,
                     title=title,
                     cover_url=cover_url,
+                    video_url=video_url,
                     duration=duration,
                     status='published',
                     publish_time=publish_time,

+ 187 - 103
server/python/platforms/weixin.py

@@ -5,6 +5,7 @@
 """
 
 import asyncio
+import json
 import os
 from datetime import datetime
 from typing import List
@@ -969,19 +970,97 @@ class WeixinPublisher(BasePublisher):
             status='need_action'
         )
 
+    async def _get_works_fallback_dom(self, page_size: int) -> tuple:
+        """API 失败时从当前页面 DOM 抓取作品列表(兼容新账号/不同入口)"""
+        works: List[WorkItem] = []
+        total = 0
+        has_more = False
+        try:
+            for selector in ["div.post-feed-item", "[class*='post-feed']", "[class*='feed-item']", "div[class*='post']"]:
+                try:
+                    await self.page.wait_for_selector(selector, timeout=8000)
+                    break
+                except Exception:
+                    continue
+            post_items = self.page.locator("div.post-feed-item")
+            item_count = await post_items.count()
+            if item_count == 0:
+                post_items = self.page.locator("[class*='post-feed']")
+                item_count = await post_items.count()
+            for i in range(min(item_count, page_size)):
+                try:
+                    item = post_items.nth(i)
+                    cover_el = item.locator("div.media img.thumb").first
+                    cover_url = await cover_el.get_attribute("src") or "" if await cover_el.count() > 0 else ""
+                    if not cover_url:
+                        cover_el = item.locator("img").first
+                        cover_url = await cover_el.get_attribute("src") or "" if await cover_el.count() > 0 else ""
+                    title_el = item.locator("div.post-title").first
+                    title = (await title_el.text_content() or "").strip() if await title_el.count() > 0 else ""
+                    time_el = item.locator("div.post-time span").first
+                    publish_time = (await time_el.text_content() or "").strip() if await time_el.count() > 0 else ""
+                    play_count = like_count = comment_count = share_count = collect_count = 0
+                    data_items = item.locator("div.post-data div.data-item")
+                    for j in range(await data_items.count()):
+                        data_item = data_items.nth(j)
+                        count_text = (await data_item.locator("span.count").text_content() or "0").strip()
+                        if await data_item.locator("span.weui-icon-outlined-eyes-on").count() > 0:
+                            play_count = self._parse_count(count_text)
+                        elif await data_item.locator("span.weui-icon-outlined-like").count() > 0:
+                            like_count = self._parse_count(count_text)
+                        elif await data_item.locator("span.weui-icon-outlined-comment").count() > 0:
+                            comment_count = self._parse_count(count_text)
+                        elif await data_item.locator("use[xlink\\:href='#icon-share']").count() > 0:
+                            share_count = self._parse_count(count_text)
+                        elif await data_item.locator("use[xlink\\:href='#icon-thumb']").count() > 0:
+                            collect_count = self._parse_count(count_text)
+                    work_id = f"weixin_{i}_{hash(title)}_{hash(publish_time)}"
+                    works.append(WorkItem(
+                        work_id=work_id,
+                        title=title or "无标题",
+                        cover_url=cover_url,
+                        duration=0,
+                        status="published",
+                        publish_time=publish_time,
+                        play_count=play_count,
+                        like_count=like_count,
+                        comment_count=comment_count,
+                        share_count=share_count,
+                        collect_count=collect_count,
+                    ))
+                except Exception as e:
+                    print(f"[{self.platform_name}] DOM 解析作品 {i} 失败: {e}", flush=True)
+                    continue
+            total = len(works)
+            has_more = item_count > page_size
+            print(f"[{self.platform_name}] DOM 回退获取 {len(works)} 条", flush=True)
+        except Exception as e:
+            print(f"[{self.platform_name}] DOM 回退失败: {e}", flush=True)
+        return (works, total, has_more, "")
+    
     async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
-
-
-        print(f"1111111111111111111")
-        """获取视频号作品列表"""
+        """获取视频号作品列表(调用 post_list 接口)
+        page: 页码从 0 开始,或上一页返回的 rawKeyBuff/lastBuff 字符串
+        """
+        # 分页:首页 currentPage=1/rawKeyBuff=null,下一页用 currentPage 递增或 rawKeyBuff
+        if page is None or page == "" or (isinstance(page, int) and page == 0):
+            current_page = 1
+            raw_key_buff = None
+        elif isinstance(page, int):
+            current_page = page + 1
+            raw_key_buff = None
+        else:
+            current_page = 1
+            raw_key_buff = str(page)
+        ts_ms = str(int(time.time() * 1000))
         print(f"\n{'='*60}")
-        print(f"[{self.platform_name}] 获取作品列表")
-        print(f"[{self.platform_name}] page={page}, page_size={page_size}")
+        print(f"[{self.platform_name}] 获取作品列表 currentPage={current_page}, pageSize={page_size}, rawKeyBuff={raw_key_buff[:40] if raw_key_buff else 'null'}...")
         print(f"{'='*60}")
         
         works: List[WorkItem] = []
         total = 0
         has_more = False
+        next_page = ""
         
         try:
             await self.init_browser()
@@ -991,131 +1070,136 @@ class WeixinPublisher(BasePublisher):
             if not self.page:
                 raise Exception("Page not initialized")
             
-            # 访问视频号创作者中心
-            await self.page.goto("https://channels.weixin.qq.com/platform/post/list") 
-            await asyncio.sleep(5)
-            print(f"1111111111111111")
-            # 检查登录状态
+            await self.page.goto("https://channels.weixin.qq.com/micro/content/post/list", timeout=30000)
+            await asyncio.sleep(3)
+            
             current_url = self.page.url
             if "login" in current_url:
-                print(f"2111111111111111")
-                raise Exception("Cookie 已过期,请重新登录") 
+                raise Exception("Cookie 已过期,请重新登录")
             
-            # 视频号使用页面爬取方式获取作品列表
-            # 等待作品列表加载(增加等待时间,并添加截图调试)
-            try:
-                await self.page.wait_for_selector('div.post-feed-item', timeout=15000)
-            except:
-                # 超时后打印当前 URL 和截图
-                current_url = self.page.url
-                print(f"[{self.platform_name}] 等待超时,当前 URL: {current_url}")
-                # 截图保存
-                screenshot_path = f"weixin_timeout_{int(asyncio.get_event_loop().time())}.png"
-                await self.page.screenshot(path=screenshot_path)
-                print(f"[{self.platform_name}] 截图已保存: {screenshot_path}")
-                raise Exception(f"页面加载超时,当前 URL: {current_url}")
+            api_url = "https://channels.weixin.qq.com/micro/content/cgi-bin/mmfinderassistant-bin/post/post_list"
+            req_body = {
+                "pageSize": page_size,
+                "currentPage": current_page,
+                "userpageType": 11,
+                "stickyOrder": True,
+                "timestamp": ts_ms,
+                "_log_finder_uin": "",
+                "_log_finder_id": "",
+                "rawKeyBuff": raw_key_buff,
+                "pluginSessionId": None,
+                "scene": 7,
+                "reqScene": 7,
+            }
+            body_str = json.dumps(req_body)
             
-            # 打印 DOM 结构
-            page_html = await self.page.content()
-            print(f"[{self.platform_name}] ========== 页面 DOM 开始 ==========")
-            print(page_html[:5000])  # 打印前5000个字符
-            print(f"[{self.platform_name}] ========== 页面 DOM 结束 ==========")
+            response = await self.page.evaluate("""
+                async ([url, bodyStr]) => {
+                    try {
+                        const resp = await fetch(url, {
+                            method: 'POST',
+                            credentials: 'include',
+                            headers: {
+                                'Content-Type': 'application/json',
+                                'Accept': '*/*',
+                                'Referer': 'https://channels.weixin.qq.com/micro/content/post/list'
+                            },
+                            body: bodyStr
+                        });
+                        return await resp.json();
+                    } catch (e) {
+                        return { error: e.toString() };
+                    }
+                }
+            """, [api_url, body_str])
             
-            # 获取所有作品项
-            post_items = self.page.locator('div.post-feed-item')
-            item_count = await post_items.count()
+            is_first_page = current_page == 1 and raw_key_buff is None
+            if response.get("error"):
+                print(f"[{self.platform_name}] API 请求失败: {response.get('error')}", flush=True)
+                if is_first_page:
+                    works, total, has_more, next_page = await self._get_works_fallback_dom(page_size)
+                    if works:
+                        return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
+                return WorksResult(success=False, platform=self.platform_name, error=response.get("error", "API 请求失败"))
             
-            print(f"[{self.platform_name}] 找到 {item_count} 个作品项")
+            err_code = response.get("errCode", -1)
+            if err_code != 0:
+                err_msg = response.get("errMsg", "unknown")
+                print(f"[{self.platform_name}] API errCode={err_code}, errMsg={err_msg}, 完整响应(前800字): {json.dumps(response, ensure_ascii=False)[:800]}", flush=True)
+                if is_first_page:
+                    works, total, has_more, next_page = await self._get_works_fallback_dom(page_size)
+                    if works:
+                        return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
+                return WorksResult(success=False, platform=self.platform_name, error=f"errCode={err_code}, errMsg={err_msg}")
             
-            for i in range(min(item_count, page_size)):
+            data = response.get("data") or {}
+            raw_list = data.get("list") or []
+            total = int(data.get("totalCount") or 0)
+            has_more = bool(data.get("continueFlag", False))
+            next_page = (data.get("lastBuff") or "").strip()
+            
+            print(f"[{self.platform_name}] API 响应: list_len={len(raw_list)}, totalCount={total}, continueFlag={has_more}, lastBuff={next_page[:50] if next_page else ''}...")
+            
+            if is_first_page and len(raw_list) == 0:
+                works_fb, total_fb, has_more_fb, _ = await self._get_works_fallback_dom(page_size)
+                if works_fb:
+                    return WorksResult(success=True, platform=self.platform_name, works=works_fb, total=total_fb, has_more=has_more_fb, next_page="")
+            
+            for item in raw_list:
                 try:
-                    item = post_items.nth(i)
-                    
-                    # 获取封面
-                    cover_el = item.locator('div.media img.thumb').first
-                    cover_url = ''
-                    if await cover_el.count() > 0:
-                        cover_url = await cover_el.get_attribute('src') or ''
+                    work_id = str(item.get("objectId") or item.get("id") or "").strip()
+                    if not work_id:
+                        work_id = f"weixin_{hash(item.get('createTime',0))}_{hash(item.get('desc', {}).get('description',''))}"
                     
-                    # 获取标题
-                    title_el = item.locator('div.post-title').first
-                    title = ''
-                    if await title_el.count() > 0:
-                        title = await title_el.text_content() or ''
-                        title = title.strip()
+                    desc = item.get("desc") or {}
+                    title = (desc.get("description") or "").strip() or "无标题"
+                    cover_url = ""
+                    duration = 0
+                    media_list = desc.get("media") or []
+                    if media_list and isinstance(media_list[0], dict):
+                        m = media_list[0]
+                        cover_url = (m.get("coverUrl") or m.get("thumbUrl") or "").strip()
+                        duration = int(m.get("videoPlayLen") or 0)
                     
-                    # 获取发布时间
-                    time_el = item.locator('div.post-time span').first
-                    publish_time = ''
-                    if await time_el.count() > 0:
-                        publish_time = await time_el.text_content() or ''
-                        publish_time = publish_time.strip()
-                    
-                    # 获取统计数据
-                    import re
-                    data_items = item.locator('div.post-data div.data-item')
-                    data_count = await data_items.count()
-                    
-                    play_count = 0
-                    like_count = 0
-                    comment_count = 0
-                    share_count = 0
-                    collect_count = 0
-                    
-                    for j in range(data_count):
-                        data_item = data_items.nth(j)
-                        count_text = await data_item.locator('span.count').text_content() or '0'
-                        count_text = count_text.strip()
-                        
-                        # 判断图标类型
-                        if await data_item.locator('span.weui-icon-outlined-eyes-on').count() > 0:
-                            # 播放量
-                            play_count = self._parse_count(count_text)
-                        elif await data_item.locator('span.weui-icon-outlined-like').count() > 0:
-                            # 点赞
-                            like_count = self._parse_count(count_text)
-                        elif await data_item.locator('span.weui-icon-outlined-comment').count() > 0:
-                            # 评论
-                            comment_count = self._parse_count(count_text)
-                        elif await data_item.locator('use[xlink\\:href="#icon-share"]').count() > 0:
-                            # 分享
-                            share_count = self._parse_count(count_text)
-                        elif await data_item.locator('use[xlink\\:href="#icon-thumb"]').count() > 0:
-                            # 收藏
-                            collect_count = self._parse_count(count_text)
+                    create_ts = item.get("createTime") or 0
+                    if isinstance(create_ts, (int, float)) and create_ts:
+                        publish_time = datetime.fromtimestamp(create_ts).strftime("%Y-%m-%d %H:%M:%S")
+                    else:
+                        publish_time = str(create_ts) if create_ts else ""
                     
-                    # 生成临时 work_id
-                    work_id = f"weixin_{i}_{hash(title)}_{hash(publish_time)}"
+                    read_count = int(item.get("readCount") or 0)
+                    like_count = int(item.get("likeCount") or 0)
+                    comment_count = int(item.get("commentCount") or 0)
+                    forward_count = int(item.get("forwardCount") or 0)
+                    fav_count = int(item.get("favCount") or 0)
                     
                     works.append(WorkItem(
                         work_id=work_id,
-                        title=title or '无标题',
+                        title=title,
                         cover_url=cover_url,
-                        duration=0,
-                        status='published',
+                        duration=duration,
+                        status="published",
                         publish_time=publish_time,
-                        play_count=play_count,
+                        play_count=read_count,
                         like_count=like_count,
                         comment_count=comment_count,
-                        share_count=share_count,
-                        collect_count=collect_count,
+                        share_count=forward_count,
+                        collect_count=fav_count,
                     ))
                 except Exception as e:
-                    print(f"[{self.platform_name}] 解析作品 {i} 失败: {e}")
-                    import traceback
-                    traceback.print_exc()
+                    print(f"[{self.platform_name}] 解析作品项失败: {e}", flush=True)
                     continue
             
-            total = len(works)
-            has_more = item_count > page_size
-            print(f"[{self.platform_name}] 获取到 {total} 个作品")
+            if total == 0 and works:
+                total = len(works)
+            print(f"[{self.platform_name}] 本页获取 {len(works)} 条,totalCount={total}, next_page={bool(next_page)}")
             
         except Exception as e:
             import traceback
             traceback.print_exc()
             return WorksResult(success=False, platform=self.platform_name, error=str(e))
         
-        return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more)
+        return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
     
     async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
         """获取视频号作品评论"""

+ 4 - 0
server/python/platforms/xiaohongshu.py

@@ -913,10 +913,12 @@ class XiaohongshuPublisher(BasePublisher):
                     elif tab_status == 3:
                         status = 'rejected'
                     
+                    video_url = f"https://www.xiaohongshu.com/explore/{note_id}" if note_id else ""
                     parsed.append(WorkItem(
                         work_id=note_id,
                         title=note.get('display_title', '') or '无标题',
                         cover_url=cover_url,
+                        video_url=video_url,
                         duration=duration,
                         status=status,
                         publish_time=note.get('time', ''),
@@ -1179,10 +1181,12 @@ class XiaohongshuPublisher(BasePublisher):
                     elif tab_status == 3:
                         status = 'rejected'
 
+                    video_url = f"https://www.xiaohongshu.com/explore/{note_id}" if note_id else ""
                     parsed.append(WorkItem(
                         work_id=note_id,
                         title=note.get('display_title', '') or '无标题',
                         cover_url=cover_url,
+                        video_url=video_url,
                         duration=duration,
                         status=status,
                         publish_time=note.get('time', ''),

+ 1 - 0
server/src/automation/platforms/base.ts

@@ -13,6 +13,7 @@ export interface WorkItem {
   videoId?: string;
   title: string;
   coverUrl: string;
+  videoUrl?: string;
   duration: string;
   publishTime: string;
   status: string;

+ 2 - 1
server/src/models/entities/Work.ts

@@ -17,7 +17,8 @@ export class Work {
   @Column({ type: 'varchar', length: 20 })
   platform!: string;
 
-  @Column({ name: 'platform_video_id', type: 'varchar', length: 100 })
+  /** 平台作品 ID,视频号 objectId 较长,需 500 字符 */
+  @Column({ name: 'platform_video_id', type: 'varchar', length: 500 })
   platformVideoId!: string;
 
   @Column({ type: 'varchar', length: 200, default: '' })

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

@@ -15,6 +15,9 @@ export class WorkDayStatistics {
   @Column({ name: 'play_count', type: 'int', default: 0, comment: '播放数' })
   playCount!: number;
 
+  @Column({ name: 'exposure_count', type: 'int', default: 0, comment: '曝光数/展现量' })
+  exposureCount!: number;
+
   @Column({ name: 'like_count', type: 'int', default: 0, comment: '点赞数' })
   likeCount!: number;
 
@@ -27,6 +30,36 @@ export class WorkDayStatistics {
   @Column({ name: 'collect_count', type: 'int', default: 0, comment: '收藏数' })
   collectCount!: number;
 
+  @Column({ name: 'fans_increase', type: 'int', default: 0, comment: '涨粉数' })
+  fansIncrease!: number;
+
+  @Column({ name: 'cover_click_rate', type: 'varchar', length: 50, default: '0', comment: '封面点击率' })
+  coverClickRate!: string;
+
+  @Column({
+    name: 'avg_watch_duration',
+    type: 'varchar',
+    length: 50,
+    default: '0',
+    comment: '平均观看时长(秒)',
+  })
+  avgWatchDuration!: string;
+
+  @Column({
+    name: 'total_watch_duration',
+    type: 'varchar',
+    length: 50,
+    default: '0',
+    comment: '总观看时长(秒)',
+  })
+  totalWatchDuration!: string;
+
+  @Column({ name: 'completion_rate', type: 'varchar', length: 50, default: '0', comment: '完播率' })
+  completionRate!: string;
+
+  @Column({ name: 'two_second_exit_rate', type: 'varchar', length: 50, default: '0', comment: '2秒退出率' })
+  twoSecondExitRate!: string;
+
   @Column({ type: 'datetime', name: 'created_at', default: () => 'CURRENT_TIMESTAMP' })
   createdAt!: Date;
 

+ 17 - 0
server/src/routes/internal.ts

@@ -53,10 +53,27 @@ router.post(
       workId: item.workId ?? item.work_id,
       fansCount: item.fansCount ?? item.fans_count ?? 0,
       playCount: item.playCount ?? item.play_count ?? 0,
+      exposureCount:
+        item.exposureCount ??
+        item.exposure_count ??
+        item.impressionCount ??
+        item.impression_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 ??
+        item.riseFansCount ??
+        item.rise_fans_count ??
+        0,
+      coverClickRate: item.coverClickRate ?? item.cover_click_rate,
+      avgWatchDuration: item.avgWatchDuration ?? item.avg_watch_duration,
+      totalWatchDuration: item.totalWatchDuration ?? item.total_watch_duration ?? '0',
+      completionRate: item.completionRate ?? item.completion_rate ?? item.finish_rate ?? item.full_view_rate,
+      twoSecondExitRate: item.twoSecondExitRate ?? item.two_second_exit_rate ?? item.exit_view2s_rate,
     }));
 
     const result = await workDayStatisticsService.saveStatistics(statistics);

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

@@ -10,6 +10,7 @@ import { XiaohongshuAccountOverviewImportService } from '../services/Xiaohongshu
 import { DouyinAccountOverviewImportService } from '../services/DouyinAccountOverviewImportService.js';
 import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoContentOverviewImportService.js';
 import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
 
 /**
  * 定时任务调度器
@@ -18,6 +19,7 @@ export class TaskScheduler {
   private jobs: Map<string, schedule.Job> = new Map();
   private isRefreshingAccounts = false; // 账号刷新锁,防止任务重叠执行
   private isXhsImportRunning = false; // 小红书导入锁,防止任务重叠执行
+  private isXhsWorkImportRunning = false; // 小红书作品日统计导入锁
   private isDyImportRunning = false; // 抖音导入锁,防止任务重叠执行
   private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
   private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
@@ -39,6 +41,13 @@ export class TaskScheduler {
     // 注意:node-schedule 使用服务器本地时区
     this.scheduleJob('xhs-account-overview-import', '0 12 * * *', this.importXhsAccountOverviewLast30Days.bind(this));
 
+    // 每天 12:40:同步小红书作品维度的「笔记详情-按天」数据,写入 work_day_statistics
+    this.scheduleJob(
+      'xhs-work-note-statistics-import',
+      '40 12 * * *',
+      this.importXhsWorkNoteStatistics.bind(this)
+    );
+
     // 每天 12:10:批量导出抖音“数据中心-账号总览-短视频-数据表现-近30天”,导入 user_day_statistics
     this.scheduleJob('dy-account-overview-import', '10 12 * * *', this.importDyAccountOverviewLast30Days.bind(this));
 
@@ -58,6 +67,9 @@ export class TaskScheduler {
     logger.info('[Scheduler] Scheduled jobs:');
     logger.info('[Scheduler]   - check-publish-tasks: every minute (* * * * *)');
     logger.info('[Scheduler]   - xhs-account-overview-import: daily at 12:00 (0 12 * * *)');
+    logger.info(
+      '[Scheduler]   - xhs-work-note-statistics-import: daily at 12:40 (40 12 * * *)'
+    );
     logger.info('[Scheduler]   - dy-account-overview-import:  daily at 12:10 (10 12 * * *)');
     logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
     logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
@@ -337,6 +349,23 @@ export class TaskScheduler {
   }
 
   /**
+   * 小红书:作品维度「笔记详情-按天」→ 导入 work_day_statistics
+   */
+  private async importXhsWorkNoteStatistics(): Promise<void> {
+    if (this.isXhsWorkImportRunning) {
+      logger.info('[Scheduler] XHS work note statistics import is already running, skipping...');
+      return;
+    }
+
+    this.isXhsWorkImportRunning = true;
+    try {
+      await XiaohongshuWorkNoteStatisticsImportService.runDailyImport();
+    } finally {
+      this.isXhsWorkImportRunning = false;
+    }
+  }
+
+  /**
    * 抖音:账号总览-短视频-数据表现导出(近30天)→ 导入 user_day_statistics
    */
   private async importDyAccountOverviewLast30Days(): Promise<void> {

+ 51 - 0
server/src/scripts/clean-work-day-statistics-orphans.ts

@@ -0,0 +1,51 @@
+/**
+ * 清理 work_day_statistics 表中的孤儿数据(work_id 在 works 表中不存在的记录),
+ * 执行后可运行 database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql 添加外键。
+ */
+import { initDatabase, AppDataSource } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    const qr = AppDataSource.createQueryRunner();
+    await qr.connect();
+
+    try {
+      // 统计孤儿数据
+      const countResult = await qr.query(
+        `SELECT COUNT(*) AS cnt FROM work_day_statistics wds
+         LEFT JOIN works w ON wds.work_id = w.id
+         WHERE w.id IS NULL`
+      );
+      const orphanCount = Number(countResult?.[0]?.cnt ?? 0);
+
+      if (orphanCount === 0) {
+        logger.info('[CleanOrphans] work_day_statistics 中无孤儿数据。');
+      } else {
+        logger.info(`[CleanOrphans] 发现 ${orphanCount} 条孤儿数据,正在删除...`);
+        const deleteResult = await qr.query(
+          `DELETE wds FROM work_day_statistics wds
+           LEFT JOIN works w ON wds.work_id = w.id
+           WHERE w.id IS NULL`
+        );
+        const affected = (deleteResult as { affectedRows?: number })?.affectedRows ?? orphanCount;
+        logger.info(`[CleanOrphans] 已删除 ${affected} 条孤儿数据。`);
+      }
+
+      // 提示添加外键
+      logger.info(
+        '[CleanOrphans] 若需为 work_day_statistics.work_id 添加外键(删除 works 时级联删除),请执行:database/migrations/clean_work_day_statistics_orphans_and_add_fk.sql'
+      );
+    } finally {
+      await qr.release();
+    }
+
+    process.exit(0);
+  } catch (e) {
+    logger.error('[CleanOrphans] 执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 18 - 0
server/src/scripts/run-xhs-work-stats-import.ts

@@ -0,0 +1,18 @@
+import { initDatabase } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
+
+async function main() {
+  try {
+    await initDatabase();
+    logger.info('[XHS WorkStats] Manual run start...');
+    await XiaohongshuWorkNoteStatisticsImportService.runDailyImport();
+    logger.info('[XHS WorkStats] Manual run done.');
+    process.exit(0);
+  } catch (e) {
+    logger.error('[XHS WorkStats] Manual run failed:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 3 - 2
server/src/services/CommentService.ts

@@ -227,7 +227,7 @@ export class CommentService {
     for (const account of accounts) {
       try {
         // 只处理支持的平台
-        if (account.platform !== 'douyin' && account.platform !== 'xiaohongshu') {
+        if (account.platform !== 'douyin' && account.platform !== 'xiaohongshu' && account.platform !== 'weixin_video') {
           logger.info(`Skipping unsupported platform: ${account.platform}`);
           continue;
         }
@@ -276,11 +276,12 @@ export class CommentService {
             parentCommentId?: string;
           }>;
         }> = [];
-        
         if (account.platform === 'douyin') {
           workComments = await headlessBrowserService.fetchDouyinCommentsViaApi(cookies);
         } else if (account.platform === 'xiaohongshu') {
           workComments = await headlessBrowserService.fetchXiaohongshuCommentsViaApi(cookies);
+        } else if (account.platform === 'weixin_video') {
+          workComments = await headlessBrowserService.fetchWeixinVideoCommentsViaApi(cookies);
         }
         
         // 获取该账号的所有作品,用于关联

+ 279 - 13
server/src/services/HeadlessBrowserService.ts

@@ -89,6 +89,8 @@ export interface WorkItem {
   videoId?: string;
   title: string;
   coverUrl: string;
+  /** 作品播放/详情页 URL,同步到 works.video_url */
+  videoUrl?: string;
   duration: string;
   publishTime: string;
   status: string;
@@ -96,6 +98,7 @@ export interface WorkItem {
   likeCount: number;
   commentCount: number;
   shareCount: number;
+  collectCount?: number;
 }
 
 export interface CommentItem {
@@ -726,13 +729,13 @@ class HeadlessBrowserService {
 
     let cursor: string | number = 0;
     const seenCursors = new Set<string>();
-    // 抖音和小红书使用 cursor 分页(next_page 作为下一页的 max_cursor),其他平台用 pageIndex
+    // 抖音、小红书使用 cursor 分页;视频号使用 currentPage 页码(pageIndex 0,1,2...)
     const useCursorPagination = platform === 'xiaohongshu' || platform === 'douyin';
     for (let pageIndex = 0; pageIndex < maxPages; pageIndex++) {
-      const pageParam = useCursorPagination ? cursor : pageIndex;
+      const pageParam: number | string = useCursorPagination ? cursor : pageIndex;
       logger.info(`[Python API] Fetching works page=${String(pageParam)}, page_size=${pageSize} for ${platform}`);
 
-      const response = await fetch(`${PYTHON_SERVICE_URL}/works`, {
+      const response: Response = await fetch(`${PYTHON_SERVICE_URL}/works`, {
         method: 'POST',
         headers: {
           'Content-Type': 'application/json',
@@ -750,7 +753,7 @@ class HeadlessBrowserService {
         throw new Error(`Python API returned ${response.status}`);
       }
 
-      const result = await response.json();
+      const result: any = await response.json();
 
       // 记录 Python API 的详细响应(用于调试)
       if (pageIndex === 0) {
@@ -776,6 +779,7 @@ class HeadlessBrowserService {
         work_id: string;
         title: string;
         cover_url: string;
+        video_url?: string;
         duration: number;
         publish_time: string;
         status: string;
@@ -788,6 +792,7 @@ class HeadlessBrowserService {
         videoId: work.work_id,
         title: work.title,
         coverUrl: work.cover_url,
+        videoUrl: work.video_url || '',
         duration: String(work.duration || 0),
         publishTime: work.publish_time,
         status: work.status || 'published',
@@ -795,6 +800,7 @@ class HeadlessBrowserService {
         likeCount: work.like_count || 0,
         commentCount: work.comment_count || 0,
         shareCount: work.share_count || 0,
+        collectCount: work.collect_count ?? 0,
       }));
 
       let newCount = 0;
@@ -841,7 +847,7 @@ class HeadlessBrowserService {
       });
 
       if (useCursorPagination) {
-        const next = result.next_page;
+        const next: any = result.next_page;
         const hasNextCursor = next !== undefined && next !== null && next !== '' && next !== -1 && next !== '-1';
 
         if (hasNextCursor) {
@@ -854,7 +860,7 @@ class HeadlessBrowserService {
           cursor = (typeof cursor === 'number' ? cursor + 1 : pageIndex + 1);
         }
 
-        // 抖音:仅当无下一页游标或本页 0 条时停止(不依赖 has_more/declaredTotal,避免只同步 20 条)
+        // 抖音:仅当无下一页游标或本页 0 条时停止
         if (platform === 'douyin') {
           if (!hasNextCursor || pageWorks.length === 0) break;
         } else {
@@ -896,21 +902,51 @@ class HeadlessBrowserService {
     // 百家号:优先走 Python 的 /account_info(包含粉丝数、作品数),避免 Node 直连分散认证问题
     if (platform === 'baijiahao') {
       pythonAvailable = await this.checkPythonServiceAvailable();
+
+      let info: AccountInfo;
       if (pythonAvailable) {
         logger.info(`[Python API] Service available, fetching account_info for baijiahao`);
         try {
-          return await this.fetchAccountInfoViaPython(platform, cookies);
+          info = await this.fetchAccountInfoViaPython(platform, cookies);
+          info.source = 'python';
+          info.pythonAvailable = true;
         } catch (error) {
-          logger.warn(`[Python API] Failed to fetch account_info for baijiahao, falling back to direct API:`, error);
+          logger.warn(`[Python API] Failed to fetch account_info for baijiahao, will still try /works:`, error);
+          info = this.getDefaultAccountInfo(platform);
+          info.source = 'python';
+          info.pythonAvailable = true;
         }
       } else {
         logger.info(`[Python API] Service not available for baijiahao, falling back to direct API`);
+        // Python 不可用时,回退到 Node 直连 API(可能仍会遇到分散认证问题)
+        info = await this.fetchBaijiahaoAccountInfoDirectApi(cookies);
+        info.source = 'api';
+        info.pythonAvailable = false;
+      }
+
+      // 百家号同步作品需要全量:优先通过 Python /works 自动分页拉取
+      if (pythonAvailable) {
+        try {
+          const { works: worksList, total: worksTotal } = await this.fetchWorksViaPython(
+            platform,
+            cookies,
+            options?.onWorksFetchProgress
+          );
+          info.worksList = worksList;
+          if (worksTotal && worksTotal > 0) {
+            info.worksCount = worksTotal;
+            info.worksListComplete = worksList.length >= worksTotal;
+          } else if (worksList.length > 0) {
+            info.worksCount = Math.max(info.worksCount || 0, worksList.length);
+            info.worksListComplete = undefined;
+          }
+          info.source = 'python';
+          info.pythonAvailable = true;
+        } catch (error) {
+          logger.warn(`[Python API] Failed to fetch works for baijiahao:`, error);
+        }
       }
 
-      // Python 不可用或失败时,回退到 Node 直连 API(可能仍会遇到分散认证问题)
-      const info = await this.fetchBaijiahaoAccountInfoDirectApi(cookies);
-      info.source = 'api';
-      info.pythonAvailable = pythonAvailable;
       return info;
     }
 
@@ -1393,6 +1429,7 @@ class HeadlessBrowserService {
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
+          videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
           duration: '00:00',
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
@@ -1409,6 +1446,7 @@ class HeadlessBrowserService {
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
+          videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
           duration: this.formatDuration(w.duration),
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
@@ -2255,6 +2293,7 @@ class HeadlessBrowserService {
             videoId: note.noteId,
             title: note.title || '无标题',
             coverUrl: note.coverUrl,
+            videoUrl: note.noteId ? `https://www.xiaohongshu.com/explore/${note.noteId}` : '',
             duration: durationStr,
             publishTime: note.publishTime,
             status: statusStr,
@@ -2621,6 +2660,7 @@ class HeadlessBrowserService {
               videoId: item.id || item.article_id || `bjh_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
               title: item.title || '',
               coverUrl: coverUrl,
+              videoUrl: item.url || item.article_url || '',
               duration: '00:00',
               publishTime: item.created_at || item.create_time || new Date().toISOString(),
               status: item.status || 'published',
@@ -3893,7 +3933,7 @@ class HeadlessBrowserService {
   /**
    * 通过 Python API 获取评论 - 分作品逐个获取
    */
-  private async fetchCommentsViaPythonApi(platform: 'douyin' | 'xiaohongshu', cookies: CookieData[]): Promise<WorkComments[]> {
+  private async fetchCommentsViaPythonApi(platform: 'douyin' | 'xiaohongshu' | 'weixin', cookies: CookieData[]): Promise<WorkComments[]> {
     const allWorkComments: WorkComments[] = [];
     const cookieString = JSON.stringify(cookies);
 
@@ -4855,6 +4895,232 @@ class HeadlessBrowserService {
       return allWorkComments;
     }
   }
+
+  /**
+   * 获取微信视频号评论 - 优先使用 Python API
+   */
+  async fetchWeixinVideoCommentsViaApi(cookies: CookieData[]): Promise<WorkComments[]> {
+    // 优先使用 Python API(分作品获取)
+    const pythonAvailable = await this.checkPythonServiceAvailable();
+    if (pythonAvailable) {
+      logger.info('[Weixin Video Comments] Using Python API...');
+      try {
+        const result = await this.fetchCommentsViaPythonApi('weixin', cookies);
+        if (result.length > 0) {
+          return result;
+        }
+        logger.info('[Weixin Video Comments] Python API returned empty, falling back to Playwright...');
+      } catch (pythonError) {
+        logger.warn('[Weixin Video Comments] Python API failed:', pythonError);
+      }
+    }
+
+    // 回退到 Playwright 方式
+    const browser = await chromium.launch({
+      headless: true,
+      args: ['--no-sandbox', '--disable-setuid-sandbox'],
+    });
+
+    const allWorkComments: WorkComments[] = [];
+
+    try {
+      const context = await browser.newContext({
+        viewport: { width: 1920, height: 1080 },
+        userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
+      });
+
+      // 设置 Cookie
+      const playwrightCookies = cookies.map(c => ({
+        name: c.name,
+        value: c.value,
+        domain: c.domain || '.weixin.qq.com',
+        path: c.path || '/',
+      }));
+      await context.addCookies(playwrightCookies);
+      logger.info(`[Weixin Video Comments] Set ${playwrightCookies.length} cookies`);
+
+      const page = await context.newPage();
+
+      // 用于捕获评论数据
+      const capturedComments: Map<string, CommentItem[]> = new Map();
+      const capturedWorks: Array<{
+        workId: string;
+        title: string;
+        coverUrl: string;
+      }> = [];
+
+      // 设置 API 响应监听器
+      page.on('response', async (response) => {
+        const url = response.url();
+        try {
+          // 监听作品列表 API
+          if (url.includes('/mmfinderassistant-bin/post/post_list')) {
+            const data = await response.json();
+            logger.info(`[Weixin Video API] Works list: ${JSON.stringify(data).slice(0, 500)}`);
+            const posts = data?.data?.list || [];
+            for (const post of posts) {
+              capturedWorks.push({
+                workId: post.objectNonce || post.id || '',
+                title: post.title || post.desc || '',
+                coverUrl: post.cover?.url || post.cover || '',
+              });
+            }
+          }
+
+          // 监听评论列表 API
+          if (url.includes('/mmfinderassistant-bin/comment/comment_list')) {
+            const data = await response.json();
+            logger.info(`[Weixin Video API] Comments: ${JSON.stringify(data).slice(0, 500)}`);
+
+            const comments: CommentItem[] = [];
+            const commentList = data?.data?.commentList || data?.comments || [];
+
+            for (const comment of commentList) {
+              comments.push({
+                commentId: comment.commentId || comment.id || `weixin_${Date.now()}`,
+                authorId: comment.commenterInfo?.identifier || comment.authorId || '',
+                authorName: comment.commenterInfo?.nickName || comment.nickname || comment.nick_name || '',
+                authorAvatar: comment.commenterInfo?.headUrl || comment.avatar || '',
+                content: comment.content || '',
+                likeCount: comment.likeCnt || comment.like_count || 0,
+                commentTime: comment.createTime || comment.create_time || '',
+                parentCommentId: comment.parentCommentId || undefined,
+              });
+
+              // 处理子评论
+              const subComments = comment.subCommentList || comment.sub_comments || [];
+              for (const sub of subComments) {
+                comments.push({
+                  commentId: sub.commentId || sub.id || `weixin_sub_${Date.now()}`,
+                  authorId: sub.commenterInfo?.identifier || sub.authorId || '',
+                  authorName: sub.commenterInfo?.nickName || sub.nickname || sub.nick_name || '',
+                  authorAvatar: sub.commenterInfo?.headUrl || sub.avatar || '',
+                  content: sub.content || '',
+                  likeCount: sub.likeCnt || sub.like_count || 0,
+                  commentTime: sub.createTime || sub.create_time || '',
+                  parentCommentId: comment.commentId || comment.id || undefined,
+                });
+              }
+            }
+
+            // 尝试从 URL 获取作品 ID
+            const workIdMatch = url.match(/objectNonce=([^&]+)/) || url.match(/workId=([^&]+)/);
+            const workId = workIdMatch?.[1] || `work_${Date.now()}`;
+
+            if (comments.length > 0) {
+              const existing = capturedComments.get(workId) || [];
+              capturedComments.set(workId, [...existing, ...comments]);
+            }
+          }
+        } catch { }
+      });
+
+      // 导航到评论管理页面
+      logger.info('[Weixin Video Comments] Navigating to comment management...');
+      await page.goto('https://channels.weixin.qq.com/platform/interaction/comment', {
+        waitUntil: 'domcontentloaded',
+        timeout: 60000,
+      });
+
+      await page.waitForTimeout(5000);
+
+      // 检查是否需要登录
+      const currentUrl = page.url();
+      if (currentUrl.includes('login') || currentUrl.includes('passport')) {
+        logger.warn('[Weixin Video Comments] Cookie expired, need re-login');
+        await browser.close();
+        return allWorkComments;
+      }
+
+      // 尝试加载更多评论
+      for (let i = 0; i < 5; i++) {
+        await page.evaluate(() => {
+          window.scrollBy(0, 500);
+        });
+        await page.waitForTimeout(1000);
+      }
+
+      // 等待 API 响应
+      await page.waitForTimeout(3000);
+
+      // 将捕获的评论转换为 WorkComments 格式
+      for (const [workId, comments] of capturedComments) {
+        const workInfo = capturedWorks.find(w => w.workId === workId);
+        allWorkComments.push({
+          videoId: workId,
+          videoTitle: workInfo?.title || `作品 ${workId.slice(0, 10)}`,
+          videoCoverUrl: workInfo?.coverUrl || '',
+          comments,
+        });
+      }
+
+      // 如果没有从 API 获取到评论,尝试从页面提取
+      if (allWorkComments.length === 0) {
+        logger.info('[Weixin Video Comments] No comments from API, extracting from page...');
+
+        const pageComments = await page.evaluate(() => {
+          const result: Array<{
+            commentId: string;
+            authorName: string;
+            authorAvatar: string;
+            content: string;
+            likeCount: number;
+            commentTime: string;
+          }> = [];
+
+          const commentItems = document.querySelectorAll('[class*="comment-item"], [class*="comment-card"]');
+          commentItems.forEach((item, index) => {
+            try {
+              const authorEl = item.querySelector('[class*="author"], [class*="name"]');
+              const avatarEl = item.querySelector('img');
+              const contentEl = item.querySelector('[class*="content"]');
+              const timeEl = item.querySelector('[class*="time"]');
+              const likeEl = item.querySelector('[class*="like"] span');
+
+              result.push({
+                commentId: `weixin_page_${index}`,
+                authorName: authorEl?.textContent?.trim() || '',
+                authorAvatar: avatarEl?.src || '',
+                content: contentEl?.textContent?.trim() || '',
+                likeCount: parseInt(likeEl?.textContent || '0') || 0,
+                commentTime: timeEl?.textContent?.trim() || '',
+              });
+            } catch { }
+          });
+
+          return result;
+        });
+
+        if (pageComments.length > 0) {
+          allWorkComments.push({
+            videoId: 'page_comments',
+            videoTitle: '页面评论',
+            videoCoverUrl: '',
+            comments: pageComments.map(c => ({
+              ...c,
+              authorId: '',
+            })),
+          });
+        }
+      }
+
+      await page.close();
+      await context.close();
+      await browser.close();
+
+      const totalComments = allWorkComments.reduce((sum, w) => sum + w.comments.length, 0);
+      logger.info(`[Weixin Video Comments] Total: fetched ${totalComments} comments from ${allWorkComments.length} works`);
+
+      return allWorkComments;
+
+    } catch (error) {
+      logger.error('[Weixin Video Comments] Error:', error);
+      try {
+        await browser.close();
+      } catch { }
+      return allWorkComments;
+    }
+  }
 }
 
 export const headlessBrowserService = new HeadlessBrowserService();

+ 130 - 5
server/src/services/WorkDayStatisticsService.ts

@@ -5,10 +5,17 @@ import { logger } from '../utils/logger.js';
 interface StatisticsItem {
   workId: number;
   playCount?: number;
+  exposureCount?: number;
   likeCount?: number;
   commentCount?: number;
   shareCount?: number;
   collectCount?: number;
+  fansIncrease?: number;
+  coverClickRate?: string;
+  avgWatchDuration?: string;
+  totalWatchDuration?: string;
+  completionRate?: string;
+  twoSecondExitRate?: string;
 }
 
 interface SaveResult {
@@ -66,6 +73,15 @@ export class WorkDayStatisticsService {
   }
 
   /**
+   * 按作品 ID 删除该作品的所有每日统计(work 被删除时调用,或用于清理孤儿数据)
+   * @returns 被删除的行数
+   */
+  async deleteByWorkId(workId: number): Promise<number> {
+    const result = await this.statisticsRepository.delete({ workId });
+    return result.affected ?? 0;
+  }
+
+  /**
    * 获取某个账号在指定日期(<= targetDate)时各作品的“最新一条”累计数据总和
    * 口径:对该账号所有作品,每个作品取 record_date <= targetDate 的最大日期那条记录,然后把 play/like/comment/collect 求和
    */
@@ -108,12 +124,11 @@ export class WorkDayStatisticsService {
   }
 
   /**
-   * 保存作品日统计数据
+   * 保存作品日统计数据(按「今天」的中国时间日历日)
    * 当天的数据走更新流,日期变化走新增流
    */
   async saveStatistics(statistics: StatisticsItem[]): Promise<SaveResult> {
-    const today = new Date();
-    today.setHours(0, 0, 0, 0);
+    const today = this.getTodayInChina();
 
     let insertedCount = 0;
     let updatedCount = 0;
@@ -130,25 +145,39 @@ export class WorkDayStatisticsService {
       });
 
       if (existing) {
-        // 更新已有记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
+        // 更新已有记录
         await this.statisticsRepository.update(existing.id, {
           playCount: stat.playCount ?? existing.playCount,
+          exposureCount: stat.exposureCount ?? existing.exposureCount,
           likeCount: stat.likeCount ?? existing.likeCount,
           commentCount: stat.commentCount ?? existing.commentCount,
           shareCount: stat.shareCount ?? existing.shareCount,
           collectCount: stat.collectCount ?? existing.collectCount,
+          fansIncrease: stat.fansIncrease ?? existing.fansIncrease,
+          coverClickRate: stat.coverClickRate ?? existing.coverClickRate ?? '0',
+          avgWatchDuration: stat.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
+          totalWatchDuration: stat.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
+          completionRate: stat.completionRate ?? existing.completionRate ?? '0',
+          twoSecondExitRate: stat.twoSecondExitRate ?? existing.twoSecondExitRate ?? '0',
         });
         updatedCount++;
       } else {
-        // 插入新记录(不再包含粉丝数,粉丝数从 user_day_statistics 表获取)
+        // 插入新记录
         const newStat = this.statisticsRepository.create({
           workId: stat.workId,
           recordDate: today,
           playCount: stat.playCount ?? 0,
+          exposureCount: stat.exposureCount ?? 0,
           likeCount: stat.likeCount ?? 0,
           commentCount: stat.commentCount ?? 0,
           shareCount: stat.shareCount ?? 0,
           collectCount: stat.collectCount ?? 0,
+          fansIncrease: stat.fansIncrease ?? 0,
+          coverClickRate: stat.coverClickRate ?? '0',
+          avgWatchDuration: stat.avgWatchDuration ?? '0',
+          totalWatchDuration: stat.totalWatchDuration ?? '0',
+          completionRate: stat.completionRate ?? '0',
+          twoSecondExitRate: stat.twoSecondExitRate ?? '0',
         });
         await this.statisticsRepository.save(newStat);
         insertedCount++;
@@ -158,6 +187,102 @@ export class WorkDayStatisticsService {
     return { inserted: insertedCount, updated: updatedCount };
   }
 
+  /**
+   * 获取「中国时区(Asia/Shanghai)当前日历日」的 Date
+   * 用于存储 record_date,避免服务器时区与业务日期不一致
+   */
+  private getTodayInChina(): Date {
+    const formatter = new Intl.DateTimeFormat('en-CA', {
+      timeZone: 'Asia/Shanghai',
+      year: 'numeric',
+      month: '2-digit',
+      day: '2-digit',
+    });
+    const parts = formatter.formatToParts(new Date());
+    const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+    const y = parseInt(get('year'), 10);
+    const m = parseInt(get('month'), 10) - 1;
+    const d = parseInt(get('day'), 10);
+    return new Date(Date.UTC(y, m, d, 0, 0, 0, 0));
+  }
+
+  /**
+   * 保存指定日期的作品日统计数据(按 workId + recordDate 维度 upsert)
+   * 说明:recordDate 会被归零到当天 00:00:00(本地时间),避免主键冲突
+   */
+  async saveStatisticsForDate(
+    workId: number,
+    recordDate: Date,
+    patch: Omit<StatisticsItem, 'workId'>
+  ): Promise<SaveResult> {
+    const d = new Date(recordDate);
+    d.setHours(0, 0, 0, 0);
+
+    const existing = await this.statisticsRepository.findOne({
+      where: { workId, recordDate: d },
+    });
+
+    if (existing) {
+      await this.statisticsRepository.update(existing.id, {
+        playCount: patch.playCount ?? existing.playCount,
+        exposureCount: patch.exposureCount ?? existing.exposureCount,
+        likeCount: patch.likeCount ?? existing.likeCount,
+        commentCount: patch.commentCount ?? existing.commentCount,
+        shareCount: patch.shareCount ?? existing.shareCount,
+        collectCount: patch.collectCount ?? existing.collectCount,
+        fansIncrease: patch.fansIncrease ?? existing.fansIncrease,
+        coverClickRate: patch.coverClickRate ?? existing.coverClickRate ?? '0',
+        avgWatchDuration: patch.avgWatchDuration ?? existing.avgWatchDuration ?? '0',
+        totalWatchDuration: patch.totalWatchDuration ?? existing.totalWatchDuration ?? '0',
+        completionRate: patch.completionRate ?? existing.completionRate ?? '0',
+        twoSecondExitRate: patch.twoSecondExitRate ?? existing.twoSecondExitRate ?? '0',
+      });
+      return { inserted: 0, updated: 1 };
+    }
+
+    const newStat = this.statisticsRepository.create({
+      workId,
+      recordDate: d,
+      playCount: patch.playCount ?? 0,
+      exposureCount: patch.exposureCount ?? 0,
+      likeCount: patch.likeCount ?? 0,
+      commentCount: patch.commentCount ?? 0,
+      shareCount: patch.shareCount ?? 0,
+      collectCount: patch.collectCount ?? 0,
+      fansIncrease: patch.fansIncrease ?? 0,
+      coverClickRate: patch.coverClickRate ?? '0',
+      avgWatchDuration: patch.avgWatchDuration ?? '0',
+      totalWatchDuration: patch.totalWatchDuration ?? '0',
+      completionRate: patch.completionRate ?? '0',
+      twoSecondExitRate: patch.twoSecondExitRate ?? '0',
+    });
+
+    await this.statisticsRepository.save(newStat);
+    return { inserted: 1, updated: 0 };
+  }
+
+  /**
+   * 批量保存指定日期范围的作品日统计数据(每条记录自带日期)
+   */
+  async saveStatisticsForDateBatch(
+    items: Array<{ workId: number; recordDate: Date } & Omit<StatisticsItem, 'workId'>>
+  ): Promise<SaveResult> {
+    let insertedCount = 0;
+    let updatedCount = 0;
+
+    for (const it of items) {
+      const { workId, recordDate, ...patch } = it;
+      const result = await this.saveStatisticsForDate(workId, recordDate, patch);
+      insertedCount += result.inserted;
+      updatedCount += result.updated;
+    }
+
+    logger.info(
+      `[WorkDayStatistics] Date-batch save completed: inserted=${insertedCount}, updated=${updatedCount}`
+    );
+    return { inserted: insertedCount, updated: updatedCount };
+  }
+
   // 平台名称映射
   private platformNameMap: Record<string, string> = {
     xiaohongshu: '小红书',

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

@@ -11,6 +11,7 @@ export class WorkService {
   private workRepository = AppDataSource.getRepository(Work);
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private commentRepository = AppDataSource.getRepository(Comment);
+  private workDayStatisticsService = new WorkDayStatisticsService();
 
   /**
    * 获取作品列表
@@ -299,6 +300,7 @@ export class WorkService {
               { workId: legacyWork.id },
               { workId: work.id }
             );
+            await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
             await this.workRepository.delete(legacyWork.id);
           }
         }
@@ -318,6 +320,7 @@ export class WorkService {
                 { workId: legacyWork.id },
                 { workId: canonicalWork.id }
               );
+              await this.workDayStatisticsService.deleteByWorkId(legacyWork.id);
               await this.workRepository.delete(legacyWork.id);
               work = canonicalWork;
             } else {
@@ -333,12 +336,14 @@ export class WorkService {
           await this.workRepository.update(work.id, {
             title: workItem.title || work.title,
             coverUrl: workItem.coverUrl || work.coverUrl,
+            videoUrl: workItem.videoUrl !== undefined ? workItem.videoUrl || null : work.videoUrl,
             duration: workItem.duration || work.duration,
             status: workItem.status || work.status,
             playCount: workItem.playCount ?? work.playCount,
             likeCount: workItem.likeCount ?? work.likeCount,
             commentCount: workItem.commentCount ?? work.commentCount,
             shareCount: workItem.shareCount ?? work.shareCount,
+            collectCount: workItem.collectCount ?? work.collectCount,
           });
         } else {
           // 创建新作品
@@ -349,6 +354,7 @@ export class WorkService {
             platformVideoId: canonicalVideoId,
             title: workItem.title || '',
             coverUrl: workItem.coverUrl || '',
+            videoUrl: workItem.videoUrl || null,
             duration: workItem.duration || '00:00',
             status: this.normalizeStatus(workItem.status),
             publishTime: this.parsePublishTime(workItem.publishTime),
@@ -356,6 +362,7 @@ export class WorkService {
             likeCount: workItem.likeCount || 0,
             commentCount: workItem.commentCount || 0,
             shareCount: workItem.shareCount || 0,
+            collectCount: workItem.collectCount || 0,
           });
 
           await this.workRepository.save(work);
@@ -419,6 +426,7 @@ export class WorkService {
         for (const localWork of localWorks) {
           if (!remotePlatformVideoIds.has(localWork.platformVideoId)) {
             await AppDataSource.getRepository(Comment).delete({ workId: localWork.id });
+            await this.workDayStatisticsService.deleteByWorkId(localWork.id);
             await this.workRepository.delete(localWork.id);
             deletedCount++;
             logger.info(`Deleted work ${localWork.id} (${localWork.title}) - no longer exists on platform`);
@@ -451,6 +459,15 @@ export class WorkService {
    * 保存作品每日统计数据
    */
   private async saveWorkDayStatistics(account: PlatformAccount): Promise<void> {
+    // 小红书作品的细分日统计通过 XiaohongshuWorkNoteStatisticsImportService 定时任务单独采集,
+    // 这里的基于「作品当前总量」的快照统计对小红书意义不大,避免口径混乱,直接跳过。
+    if (account.platform === 'xiaohongshu') {
+      logger.info(
+        `[SaveWorkDayStatistics] Skip snapshot-based work_day_statistics for xiaohongshu account ${account.id}, will be filled by dedicated XHS note statistics importer.`
+      );
+      return;
+    }
+
     // 获取该账号下所有作品
     const works = await this.workRepository.find({
       where: { accountId: account.id },
@@ -537,8 +554,9 @@ export class WorkService {
       throw new AppError('作品不存在', HTTP_STATUS.NOT_FOUND, ERROR_CODES.NOT_FOUND);
     }
 
-    // 先删除关联的评论
+    // 先删除关联的评论和作品每日统计
     await AppDataSource.getRepository(Comment).delete({ workId });
+    await this.workDayStatisticsService.deleteByWorkId(workId);
 
     // 删除作品
     await this.workRepository.delete(workId);

+ 3 - 1
server/src/services/XiaohongshuAccountOverviewImportService.ts

@@ -192,7 +192,7 @@ async function createBrowserForAccount(proxy: ProxyConfig | null): Promise<{ bro
   return { browser, shouldClose: false };
 }
 
-function parseXhsExcel(
+export function parseXhsExcel(
   filePath: string,
   mode: ExportMode
 ): Map<string, { recordDate: Date } & Record<string, any>> {
@@ -259,6 +259,8 @@ function parseXhsExcel(
   return result;
 }
 
+export { parseCookiesFromAccount, createBrowserForAccount };
+
 export class XiaohongshuAccountOverviewImportService {
   private accountRepository = AppDataSource.getRepository(PlatformAccount);
   private userDayStatisticsService = new UserDayStatisticsService();

+ 483 - 0
server/src/services/XiaohongshuWorkNoteStatisticsImportService.ts

@@ -0,0 +1,483 @@
+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 { AccountService } from './AccountService.js';
+import type { ProxyConfig } from '@media-manager/shared';
+import { BrowserManager } from '../automation/browser.js';
+
+/** 小红书笔记详情页跳转到登录时抛出,用于触发「先刷新登录、再决定是否账号失效」 */
+export class XhsLoginExpiredError extends Error {
+  constructor() {
+    super('XHS_LOGIN_EXPIRED');
+    this.name = 'XhsLoginExpiredError';
+  }
+}
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
+
+interface NoteTrendItem {
+  date?: number | string;
+  count?: number | string;
+  count_with_double?: number | string;
+  /** API 返回的比率数值字段(用于 cover_click_rate、two_second_exit_rate、completion_rate 等,存时加 "%") */
+  coun?: number | string;
+}
+
+interface NoteDaySection {
+  view_list?: NoteTrendItem[];
+  like_list?: NoteTrendItem[];
+  comment_list?: NoteTrendItem[];
+  share_list?: NoteTrendItem[];
+  collect_list?: NoteTrendItem[];
+  rise_fans_list?: NoteTrendItem[];
+  imp_list?: NoteTrendItem[];
+  cover_click_rate_list?: NoteTrendItem[];
+  exit_view2s_list?: NoteTrendItem[];
+  view_time_list?: NoteTrendItem[];
+  finish_list?: NoteTrendItem[];
+}
+
+interface NoteBaseData {
+  day?: NoteDaySection;
+}
+
+interface DailyWorkStatPatch {
+  workId: number;
+  recordDate: Date;
+  playCount?: number;
+  exposureCount?: number;
+  likeCount?: number;
+  commentCount?: number;
+  shareCount?: number;
+  collectCount?: number;
+  fansIncrease?: number;
+  coverClickRate?: string;
+  avgWatchDuration?: string;
+  totalWatchDuration?: string;
+  completionRate?: string;
+  twoSecondExitRate?: string;
+}
+
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
+  if (!cookieData) return [];
+  const raw = cookieData.trim();
+  if (!raw) return [];
+
+  try {
+    const parsed = JSON.parse(raw) as PlaywrightCookie[];
+    if (Array.isArray(parsed)) {
+      return parsed.map((c) => ({
+        ...c,
+        url: c.url || 'https://creator.xiaohongshu.com',
+      }));
+    }
+  } catch {
+    // fallthrough
+  }
+
+  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://creator.xiaohongshu.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 BrowserManager.getBrowser({ headless });
+  return { browser, shouldClose: false };
+}
+
+function getChinaDateFromTimestamp(ts: number): Date {
+  const d = new Date(ts);
+  const formatter = new Intl.DateTimeFormat('en-CA', {
+    timeZone: 'Asia/Shanghai',
+    year: 'numeric',
+    month: '2-digit',
+    day: '2-digit',
+  });
+  const parts = formatter.formatToParts(d);
+  const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '0';
+  const y = parseInt(get('year'), 10);
+  const m = parseInt(get('month'), 10) - 1;
+  const day = parseInt(get('day'), 10);
+  return new Date(y, m, day, 0, 0, 0, 0);
+}
+
+function toNumber(val: unknown, defaultValue = 0): number {
+  if (typeof val === 'number') return Number.isFinite(val) ? val : defaultValue;
+  if (typeof val === 'string') {
+    const n = Number(val);
+    return Number.isFinite(n) ? n : defaultValue;
+  }
+  return defaultValue;
+}
+
+function toRateString(val: unknown): string | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return n.toString();
+}
+
+/** 从 item 取 coun(或 count)转为字符串并追加 "%",用于比率类字段 */
+function toRatePercentString(item: NoteTrendItem): string | undefined {
+  const raw = item?.coun ?? item?.count;
+  const n = toNumber(raw, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  return `${n}%`;
+}
+
+export class XiaohongshuWorkNoteStatisticsImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
+
+  /**
+   * 统一入口:定时任务调用,批量为所有小红书账号同步作品日统计
+   */
+  static async runDailyImport(): Promise<void> {
+    const svc = new XiaohongshuWorkNoteStatisticsImportService();
+    await svc.runDailyImportForAllXhsAccounts();
+  }
+
+  async runDailyImportForAllXhsAccounts(): Promise<void> {
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'xiaohongshu' as any },
+    });
+
+    logger.info(`[XHS WorkStats] Start import for ${accounts.length} accounts`);
+
+    for (const account of accounts) {
+      try {
+        await this.importAccountWorksStatistics(account);
+      } catch (e) {
+        logger.error(
+          `[XHS WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+        // 单账号失败(含登录失效、刷新失败等)仅记录日志,不中断循环,其他账号照常同步
+      }
+    }
+
+    logger.info('[XHS WorkStats] All accounts done');
+  }
+
+  /**
+   * 按账号同步作品日统计。检测到登录失效时:先尝试刷新登录一次;刷新仍失效则执行账号失效,刷新成功则用新 cookie 重试一次。
+   * @param isRetry 是否为「刷新登录后的重试」,避免无限递归
+   */
+  private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
+      logger.warn(`[XHS WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
+      return;
+    }
+
+    const works = await this.workRepository.find({
+      where: {
+        accountId: account.id,
+        platform: 'xiaohongshu' as any,
+      },
+    });
+
+    if (!works.length) {
+      logger.info(`[XHS WorkStats] accountId=${account.id} 没有作品,跳过`);
+      return;
+    }
+
+    const { browser, shouldClose } = await createBrowserForAccount(account.proxyConfig);
+    let context: BrowserContext | null = null;
+    let closedDueToLoginExpired = false;
+    try {
+      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/120.0.0.0 Safari/537.36',
+      });
+      await context.addCookies(cookies as any);
+      context.setDefaultTimeout(60_000);
+
+      const page = await context.newPage();
+
+      let totalInserted = 0;
+      let totalUpdated = 0;
+
+      for (const work of works) {
+        const noteId = (work.platformVideoId || '').trim();
+        if (!noteId) continue;
+
+        try {
+          const data = await this.fetchNoteBaseData(page, noteId);
+          if (!data) continue;
+
+          const patches = this.buildDailyStatisticsFromNoteData(work.id, data);
+          if (!patches.length) continue;
+
+          const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
+            patches.map((p) => ({
+              workId: p.workId,
+              recordDate: p.recordDate,
+              playCount: p.playCount,
+              exposureCount: p.exposureCount,
+              likeCount: p.likeCount,
+              commentCount: p.commentCount,
+              shareCount: p.shareCount,
+              collectCount: p.collectCount,
+              fansIncrease: p.fansIncrease,
+              coverClickRate: p.coverClickRate,
+              avgWatchDuration: p.avgWatchDuration,
+              totalWatchDuration: p.totalWatchDuration,
+              completionRate: p.completionRate,
+              twoSecondExitRate: p.twoSecondExitRate,
+            }))
+          );
+
+          totalInserted += result.inserted;
+          totalUpdated += result.updated;
+        } catch (e) {
+          if (e instanceof XhsLoginExpiredError) {
+            closedDueToLoginExpired = true;
+            if (context) {
+              await context.close().catch(() => undefined);
+              context = null;
+            }
+            if (shouldClose) {
+              await browser.close().catch(() => undefined);
+            }
+
+            try {
+              if (!isRetry) {
+                logger.info(`[XHS WorkStats] accountId=${account.id} 登录失效,尝试刷新登录...`);
+                try {
+                  const accountService = new AccountService();
+                  const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+                  if (refreshResult.needReLogin) {
+                    await this.accountRepository.update(account.id, { status: 'expired' as any });
+                    logger.warn(`[XHS WorkStats] accountId=${account.id} 刷新后仍需重新登录,已标记账号失效`);
+                    return;
+                  }
+                  const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
+                  if (refreshed) {
+                    logger.info(`[XHS WorkStats] accountId=${account.id} 刷新成功,重新同步数据`);
+                    return this.importAccountWorksStatistics(refreshed, true);
+                  }
+                } catch (refreshErr) {
+                  logger.error(`[XHS WorkStats] accountId=${account.id} 刷新登录失败`, refreshErr);
+                  await this.accountRepository.update(account.id, { status: 'expired' as any });
+                  logger.warn(`[XHS WorkStats] accountId=${account.id} 已标记账号失效`);
+                  return;
+                }
+              } else {
+                await this.accountRepository.update(account.id, { status: 'expired' as any });
+                logger.warn(`[XHS WorkStats] accountId=${account.id} 刷新后仍跳转登录,已标记账号失效`);
+                return;
+              }
+            } catch (expireErr) {
+              logger.error(
+                `[XHS WorkStats] accountId=${account.id} 账号失效处理异常(不影响其他账号)`,
+                expireErr
+              );
+              return;
+            }
+          }
+          logger.error(
+            `[XHS WorkStats] Failed to import note stats. accountId=${account.id} workId=${work.id} noteId=${noteId}`,
+            e
+          );
+        }
+      }
+
+      logger.info(
+        `[XHS WorkStats] accountId=${account.id} completed. inserted=${totalInserted}, updated=${totalUpdated}`
+      );
+    } finally {
+      if (!closedDueToLoginExpired) {
+        if (context) {
+          await context.close().catch(() => undefined);
+        }
+        if (shouldClose) {
+          await browser.close().catch(() => undefined);
+        }
+      }
+    }
+  }
+
+  private async fetchNoteBaseData(page: Page, noteId: string): Promise<NoteBaseData | null> {
+    const noteUrl = `https://creator.xiaohongshu.com/statistics/note-detail?noteId=${encodeURIComponent(
+      noteId
+    )}`;
+    const apiPattern = /\/api\/galaxy\/creator\/datacenter\/note\/base\?note_id=/i;
+
+    const responsePromise = page
+      .waitForResponse(
+        (res) => res.url().match(apiPattern) != null && res.request().method() === 'GET',
+        { timeout: 30_000 }
+      )
+      .catch(() => null);
+
+    await page.goto(noteUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
+    await page.waitForTimeout(1500);
+
+    if (page.url().includes('login')) {
+      logger.warn(
+        `[XHS WorkStats] note-detail 页面跳转到登录,可能 cookie 失效。noteId=${noteId}`
+      );
+      throw new XhsLoginExpiredError();
+    }
+
+    let res = await responsePromise;
+    if (!res) {
+      // 兜底再等一轮
+      try {
+        res = await page.waitForResponse(
+          (r) => r.url().match(apiPattern) != null && r.request().method() === 'GET',
+          { timeout: 15_000 }
+        );
+      } catch {
+        logger.warn(
+          `[XHS WorkStats] 未捕获到 note/base 接口响应,跳过该笔记。noteId=${noteId}`
+        );
+        return null;
+      }
+    }
+
+    const body = await res.json().catch(() => null);
+    if (!body || typeof body !== 'object') {
+      logger.warn(`[XHS WorkStats] note/base 响应不是 JSON,跳过。noteId=${noteId}`);
+      return null;
+    }
+
+    const data = (body as any).data as NoteBaseData | undefined;
+    if (!data || typeof data !== 'object') {
+      logger.warn(`[XHS WorkStats] note/base data 为空,跳过。noteId=${noteId}`);
+      return null;
+    }
+
+    return data;
+  }
+
+  private buildDailyStatisticsFromNoteData(workId: number, data: NoteBaseData): DailyWorkStatPatch[] {
+    const day = data.day;
+    if (!day) return [];
+
+    const map = new Map<number, DailyWorkStatPatch>();
+
+    const addIntMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
+      if (!Array.isArray(items)) return;
+      for (const item of items) {
+        const tsRaw = item?.date;
+        if (tsRaw == null) continue;
+        const ts = toNumber(tsRaw, NaN);
+        if (!Number.isFinite(ts)) continue;
+        const d = getChinaDateFromTimestamp(ts);
+        const key = d.getTime();
+        let entry = map.get(key);
+        if (!entry) {
+          entry = { workId, recordDate: d };
+          map.set(key, entry);
+        }
+        const prev = (entry as any)[field] ?? 0;
+        const inc = toNumber(item.count, 0);
+        (entry as any)[field] = prev + inc;
+      }
+    };
+
+    /** 比率类:使用 item.coun,转为字符串并加 "%" */
+    const addRatePercentMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
+      if (!Array.isArray(items)) return;
+      for (const item of items) {
+        const tsRaw = item?.date;
+        if (tsRaw == null) continue;
+        const ts = toNumber(tsRaw, NaN);
+        if (!Number.isFinite(ts)) continue;
+        const d = getChinaDateFromTimestamp(ts);
+        const key = d.getTime();
+        let entry = map.get(key);
+        if (!entry) {
+          entry = { workId, recordDate: d };
+          map.set(key, entry);
+        }
+        const s = toRatePercentString(item);
+        if (s != null) {
+          (entry as any)[field] = s;
+        }
+      }
+    };
+
+    /** 时长/数值字符串(不加 %) */
+    const addRateMetric = (items: NoteTrendItem[] | undefined, field: keyof DailyWorkStatPatch) => {
+      if (!Array.isArray(items)) return;
+      for (const item of items) {
+        const tsRaw = item?.date;
+        if (tsRaw == null) continue;
+        const ts = toNumber(tsRaw, NaN);
+        if (!Number.isFinite(ts)) continue;
+        const d = getChinaDateFromTimestamp(ts);
+        const key = d.getTime();
+        let entry = map.get(key);
+        if (!entry) {
+          entry = { workId, recordDate: d };
+          map.set(key, entry);
+        }
+        const s = toRateString(item.count_with_double ?? item.count ?? item.coun);
+        if (s != null) {
+          (entry as any)[field] = s;
+        }
+      }
+    };
+
+    // 数值型:每日增量
+    addIntMetric(day.view_list, 'playCount');
+    addIntMetric(day.like_list, 'likeCount');
+    addIntMetric(day.comment_list, 'commentCount');
+    addIntMetric(day.share_list, 'shareCount');
+    addIntMetric(day.collect_list, 'collectCount');
+    addIntMetric(day.imp_list, 'exposureCount');
+    addIntMetric(day.rise_fans_list, 'fansIncrease');
+
+    // 比率类:使用 coun 字段,入库时带 "%"
+    addRatePercentMetric(day.cover_click_rate_list, 'coverClickRate');
+    addRatePercentMetric(day.exit_view2s_list, 'twoSecondExitRate');
+    addRatePercentMetric(day.finish_list, 'completionRate');
+
+    // 平均观看时长:view_time_list → avg_watch_duration(数值字符串,不加 %)
+    addRateMetric(day.view_time_list, 'avgWatchDuration');
+
+    return Array.from(map.values()).sort(
+      (a, b) => a.recordDate.getTime() - b.recordDate.getTime()
+    );
+  }
+}
+

Някои файлове не бяха показани, защото твърде много файлове са промени