Explorar el Código

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

Ethanfly hace 1 día
padre
commit
24aa88e1ed

+ 8 - 0
database/migrations/alter_works_video_url_to_text.sql

@@ -0,0 +1,8 @@
+-- 将 works.video_url 扩展为 TEXT,避免抖音 play_addr.url_list[0] 过长导致写库失败
+-- 执行日期: 2026-02-03
+
+USE media_manager;
+
+ALTER TABLE works
+  MODIFY COLUMN video_url TEXT NULL;
+

+ 1 - 1
database/schema.sql

@@ -195,7 +195,7 @@ CREATE TABLE IF NOT EXISTS works (
     title VARCHAR(200) DEFAULT '',
     description TEXT,
     cover_url VARCHAR(500) DEFAULT '',
-    video_url VARCHAR(500),
+    video_url TEXT,
     duration VARCHAR(20) DEFAULT '00:00',
     status VARCHAR(20) DEFAULT 'published',
     publish_time DATETIME,

+ 4 - 0
server/package.json

@@ -8,8 +8,12 @@
     "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",
+    "dy:work-stats": "tsx src/scripts/run-dy-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",
+    "check:douyin-account": "tsx src/scripts/check-douyin-account.ts",
+    "list:works-for-account": "tsx src/scripts/list-works-for-account.ts",
+    "sync:works-for-account": "tsx src/scripts/sync-works-for-account.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",

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


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


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

@@ -84,6 +84,7 @@ class WorkItem:
 class CommentItem:
     """评论数据"""
     comment_id: str
+    parent_comment_id: str
     work_id: str
     content: str
     author_id: str = ""
@@ -98,6 +99,7 @@ class CommentItem:
     def to_dict(self) -> Dict[str, Any]:
         return {
             "comment_id": self.comment_id,
+            "parent_comment_id": self.parent_comment_id,
             "work_id": self.work_id,
             "content": self.content,
             "author_id": self.author_id,

+ 4 - 1
server/python/platforms/douyin.py

@@ -777,7 +777,9 @@ 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 ""
+                # 入库 video_url 使用 play_addr.url_list 的第一项,无则用分享页链接
+                url_list = (aweme.get('video') or {}).get('play_addr', {}).get('url_list') or []
+                video_url = url_list[0] if url_list else (f"https://www.douyin.com/video/{aweme_id}" if aweme_id else "")
                 works.append(WorkItem(
                     work_id=aweme_id,
                     title=title,
@@ -790,6 +792,7 @@ class DouyinPublisher(BasePublisher):
                     like_count=int(statistics.get('digg_count', 0)),
                     comment_count=int(statistics.get('comment_count', 0)),
                     share_count=int(statistics.get('share_count', 0)),
+                    collect_count=int(statistics.get('collect_count', 0)),
                 ))
             
             if total == 0:

+ 231 - 116
server/python/platforms/weixin.py

@@ -326,7 +326,8 @@ class WeixinPublisher(BasePublisher):
         # 如果没有安装 Chrome,则使用默认 Chromium
         try:
             self.browser = await playwright.chromium.launch(
-                headless=self.headless,
+                # headless=self.headless,
+                headless=False,
                 channel="chrome"  # 使用系统 Chrome
             )
             print(f"[{self.platform_name}] 使用系统 Chrome 浏览器")
@@ -1070,12 +1071,12 @@ class WeixinPublisher(BasePublisher):
             if not self.page:
                 raise Exception("Page not initialized")
             
-            await self.page.goto("https://channels.weixin.qq.com/micro/content/post/list", timeout=30000)
+            await self.page.goto("https://channels.weixin.qq.com/platform/post/list", timeout=30000)
             await asyncio.sleep(3)
             
             current_url = self.page.url
             if "login" in current_url:
-                raise Exception("Cookie 已过期,请重新登录")
+                raise Exception("Cookie 已过期,请重新登录") 
             
             api_url = "https://channels.weixin.qq.com/micro/content/cgi-bin/mmfinderassistant-bin/post/post_list"
             req_body = {
@@ -1102,7 +1103,7 @@ class WeixinPublisher(BasePublisher):
                             headers: {
                                 'Content-Type': 'application/json',
                                 'Accept': '*/*',
-                                'Referer': 'https://channels.weixin.qq.com/micro/content/post/list'
+                                'Referer': 'https://channels.weixin.qq.com/platform/post/list'
                             },
                             body: bodyStr
                         });
@@ -1201,8 +1202,12 @@ class WeixinPublisher(BasePublisher):
         
         return WorksResult(success=True, platform=self.platform_name, works=works, total=total, has_more=has_more, next_page=next_page)
     
+
     async def get_comments(self, cookies: str, work_id: str, cursor: str = "") -> CommentsResult:
-        """获取视频号作品评论"""
+        """
+        获取视频号作品评论(完全参考 get_weixin_work_comments.py 的接口监听逻辑)
+        支持递归提取二级评论,正确处理 parent_comment_id
+        """
         print(f"\n{'='*60}")
         print(f"[{self.platform_name}] 获取作品评论")
         print(f"[{self.platform_name}] work_id={work_id}")
@@ -1221,142 +1226,252 @@ class WeixinPublisher(BasePublisher):
                 raise Exception("Page not initialized")
             
             # 访问评论管理页面
-            await self.page.goto("https://channels.weixin.qq.com/platform/interaction/comment")
-            await asyncio.sleep(3)
+            print(f"[{self.platform_name}] 正在打开评论页面...")
+            await self.page.goto("https://channels.weixin.qq.com/platform/interaction/comment", timeout=30000)
+            await asyncio.sleep(2)
             
             # 检查登录状态
             current_url = self.page.url
             if "login" in current_url:
                 raise Exception("Cookie 已过期,请重新登录")
             
-            # 等待左侧作品列表加载
+            # === 步骤1: 监听 post_list 接口获取作品列表 ===
+            posts = []
             try:
-                await self.page.wait_for_selector('div.comment-feed-wrap', timeout=15000)
-            except:
-                print(f"[{self.platform_name}] 未找到作品列表")
-                return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=[], total=0, has_more=False)
-            
-            print(f"[{self.platform_name}] 查找 work_id={work_id} 对应的作品")
-            
-            # 点击左侧作品项(根据 work_id 匹配)
-            feed_items = self.page.locator('div.comment-feed-wrap')
-            item_count = await feed_items.count()
-            print(f"[{self.platform_name}] 左侧共 {item_count} 个作品")
-            
-            clicked = False
-            for i in range(item_count):
-                feed = feed_items.nth(i)
-                title_el = feed.locator('div.feed-title').first
-                if await title_el.count() > 0:
-                    title_text = await title_el.text_content() or ''
-                    title_text = title_text.strip()
-                    
-                    # 检查是否包含 work_id(标题)
-                    if work_id in title_text or title_text in work_id:
-                        print(f"[{self.platform_name}] 找到匹配作品: {title_text}")
-                        await feed.click()
-                        await asyncio.sleep(2)
-                        clicked = True
-                        break
-            
-            if not clicked:
-                # 如果没找到匹配的,点击第一个
-                print(f"[{self.platform_name}] 未找到匹配作品,点击第一个")
-                if item_count > 0:
-                    await feed_items.nth(0).click()
-                    await asyncio.sleep(2)
+                async with self.page.expect_response(
+                    lambda res: "/post/post_list" in res.url,
+                    timeout=20000
+                ) as post_resp_info:
+                    await self.page.wait_for_selector('.scroll-list .comment-feed-wrap', timeout=15000)
+                
+                post_resp = await post_resp_info.value
+                post_data = await post_resp.json()
+                
+                if post_data.get("errCode") == 0:
+                    posts = post_data.get("data", {}).get("list", [])
+                    print(f"[{self.platform_name}] ✅ 获取 {len(posts)} 个作品")
                 else:
-                    return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=[], total=0, has_more=False)
+                    err_msg = post_data.get("errMsg", "未知错误")
+                    print(f"[{self.platform_name}] ❌ post_list 业务错误: {err_msg}")
+                    return CommentsResult(
+                        success=False, 
+                        platform=self.platform_name, 
+                        work_id=work_id,
+                        error=f"post_list 业务错误: {err_msg}"
+                    )
+            except Exception as e:
+                print(f"[{self.platform_name}] ❌ 获取 post_list 失败: {e}")
+                return CommentsResult(
+                    success=False, 
+                    platform=self.platform_name, 
+                    work_id=work_id,
+                    error=f"获取 post_list 失败: {e}"
+                )
             
-            # 等待右侧评论详情加载
-            try:
-                await self.page.wait_for_selector('div.comment-item', timeout=5000)
-            except:
-                print(f"[{self.platform_name}] 该作品暂无评论")
-                return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=[], total=0, has_more=False)
+            # === 步骤2: 在 DOM 中查找目标作品 ===
+            feed_wraps = await self.page.query_selector_all('.scroll-list .comment-feed-wrap')
+            target_feed = None
+            target_post = None
+            target_index = -1
             
-            # 获取评论总数
-            total_text_el = self.page.locator('div.comment-count__tips')
-            if await total_text_el.count() > 0:
-                total_text = await total_text_el.text_content() or ''
-                # 提取数字(如 "共 1 条评论")
-                import re
-                match = re.search(r'(\d+)', total_text)
-                if match:
-                    total = int(match.group(1))
+            for i, feed in enumerate(feed_wraps):
+                if i >= len(posts):
+                    break
+                
+                post = posts[i]
+                object_nonce = post.get("objectNonce", "")
+                post_work_id = post.get("objectId", "") or object_nonce
+                
+                # 匹配 work_id(支持 objectId 或 objectNonce 匹配)
+                if work_id in [post_work_id, object_nonce] or post_work_id in work_id or object_nonce in work_id:
+                    target_feed = feed
+                    target_post = post
+                    target_index = i
+                    work_title = post.get("desc", {}).get("description", "无标题")
+                    print(f"[{self.platform_name}] ✅ 找到目标作品: {work_title}")
+                    break
             
-            print(f"[{self.platform_name}] 评论总数: {total}")
+            if not target_feed or not target_post:
+                print(f"[{self.platform_name}] ❌ 未找到 work_id={work_id} 对应的作品")
+                return CommentsResult(
+                    success=True, 
+                    platform=self.platform_name, 
+                    work_id=work_id,
+                    comments=[],
+                    total=0,
+                    has_more=False
+                )
             
-            # 获取右侧评论列表
-            comment_items = self.page.locator('div.comment-item')
-            item_count = await comment_items.count()
+            # 准备作品信息(用于递归函数)
+            object_nonce = target_post.get("objectNonce", f"nonce_{target_index}")
+            work_title = target_post.get("desc", {}).get("description", f"作品{target_index+1}")
             
-            print(f"[{self.platform_name}] 当前加载 {item_count} 条评论")
+            work_info = {
+                "work_id": object_nonce,
+                "work_title": work_title
+            }
             
-            for i in range(item_count):
-                try:
-                    item = comment_items.nth(i)
-                    
-                    # 获取作者昵称(加 .first 防 strict mode)
-                    author_name = ''
-                    name_el = item.locator('span.comment-user-name').first
-                    if await name_el.count() > 0:
-                        author_name = await name_el.text_content() or ''
-                        author_name = author_name.strip()
-                    
-                    # 获取头像
-                    author_avatar = ''
-                    avatar_el = item.locator('img.comment-avatar').first
-                    if await avatar_el.count() > 0:
-                        author_avatar = await avatar_el.get_attribute('src') or ''
-                    
-                    # 获取评论内容(加 .first 防 strict mode)
-                    content = ''
-                    content_el = item.locator('span.comment-content').first
-                    if await content_el.count() > 0:
-                        content = await content_el.text_content() or ''
-                        content = content.strip()
-                    
-                    # 获取评论时间(加 .first 防 strict mode)
-                    create_time = ''
-                    time_el = item.locator('span.comment-time').first
-                    if await time_el.count() > 0:
-                        create_time = await time_el.text_content() or ''
-                        create_time = create_time.strip()
-                    
-                    if not content:
-                        continue
-                    
-                    # 生成评论 ID
-                    comment_id = f"weixin_comment_{i}_{abs(hash(content))}"
+            # === 步骤3: 点击作品触发 comment_list 接口 ===
+            content_wrap = await target_feed.query_selector('.feed-content') or target_feed
+            
+            try:
+                async with self.page.expect_response(
+                    lambda res: "/comment/comment_list" in res.url,
+                    timeout=15000
+                ) as comment_resp_info:
+                    await content_wrap.click()
+                    await asyncio.sleep(0.8)
+                
+                comment_resp = await comment_resp_info.value
+                comment_data = await comment_resp.json()
+                
+                if comment_data.get("errCode") != 0:
+                    err_msg = comment_data.get("errMsg", "未知错误")
+                    print(f"[{self.platform_name}] ❌ 评论接口错误: {err_msg}")
+                    return CommentsResult(
+                        success=False, 
+                        platform=self.platform_name, 
+                        work_id=work_id,
+                        error=f"评论接口错误: {err_msg}"
+                    )
+                
+                raw_comments = comment_data.get("data", {}).get("comment", [])
+                total = comment_data.get("data", {}).get("totalCount", len(raw_comments))
+                
+                print(f"[{self.platform_name}] 📊 原始评论数: {len(raw_comments)}, 总数: {total}")
+                
+                # === 步骤4: 递归提取所有评论(含子评论)===
+                extracted = self._extract_comments(raw_comments, parent_id="", work_info=work_info)
+                
+                # === 步骤5: 转换为 CommentItem 列表(保留 weixin.py 的数据结构)===
+                for c in extracted:
+                    # 使用接口返回的 comment_id
+                    comment_id = c.get("comment_id", "")
+                    parent_comment_id = c.get("parent_comment_id", "")
                     
-                    comments.append(CommentItem(
+                    # 构建 CommentItem(保留原有数据结构用于数据库入库)
+                    comment_item = CommentItem(
                         comment_id=comment_id,
+                        parent_comment_id=parent_comment_id,
                         work_id=work_id,
-                        content=content,
-                        author_id='',
-                        author_name=author_name,
-                        author_avatar=author_avatar,
-                        like_count=0,
+                        content=c.get("content", ""),
+                        author_id=c.get("username", ""),  # 使用 username 作为 author_id
+                        author_name=c.get("nickname", ""),
+                        author_avatar=c.get("avatar", ""),
+                        like_count=c.get("like_count", 0),
                         reply_count=0,
-                        create_time=create_time,
-                    ))
+                        create_time=c.get("create_time", ""),
+                    )
                     
-                    print(f"[{self.platform_name}] 评论 {i+1}: {author_name} - {content[:20]}...")
+                    # 添加扩展字段(用于数据库存储和后续处理)
+                    # comment_item.parent_comment_id = c.get("parent_comment_id", "")
+                    comment_item.is_author = c.get("is_author", False)
+                    comment_item.create_time_unix = c.get("create_time_unix", 0)
+                    comment_item.work_title = c.get("work_title", "")
+                    print(comment_item)
+                    comments.append(comment_item) 
                     
-                except Exception as e:
-                    print(f"[{self.platform_name}] 解析评论 {i} 失败: {e}")
-                    continue
-            
-            print(f"[{self.platform_name}] 成功获取 {len(comments)} 条评论")
+                    # 打印日志
+                    author_tag = " 👤(作者)" if c.get("is_author") else ""
+                    parent_tag = f" [回复: {c.get('parent_comment_id', '')}]" if c.get("parent_comment_id") else ""
+                    print(f"[{self.platform_name}]   - [{c.get('nickname', '')}] {c.get('content', '')[:30]}... "
+                          f"({c.get('create_time', '')}){author_tag}{parent_tag}")
+                
+                # 判断是否还有更多(优先使用接口返回的 continueFlag,否则根据数量判断)
+                has_more = comment_data.get("data", {}).get("continueFlag", False) or len(extracted) < total
+                
+                print(f"[{self.platform_name}] ✅ 共提取 {len(comments)} 条评论(含子评论)")
+                
+            except Exception as e:
+                print(f"[{self.platform_name}] ❌ 获取评论失败: {e}")
+                import traceback
+                traceback.print_exc()
+                return CommentsResult(
+                    success=False, 
+                    platform=self.platform_name, 
+                    work_id=work_id,
+                    error=f"获取评论失败: {e}"
+                )
             
         except Exception as e:
             import traceback
             traceback.print_exc()
-            return CommentsResult(success=False, platform=self.platform_name, work_id=work_id, error=str(e))
+            return CommentsResult(
+                success=False, 
+                platform=self.platform_name, 
+                work_id=work_id, 
+                error=str(e)
+            )
         
-        return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=comments, total=total, has_more=has_more)
-    
+        return CommentsResult(
+            success=True, 
+            platform=self.platform_name, 
+            work_id=work_id, 
+            comments=comments, 
+            total=total, 
+            has_more=has_more
+        )
+
+    def _extract_comments(self, comment_list: list, parent_id: str = "", work_info: dict = None) -> list:
+        """
+        递归提取一级和二级评论(完全参考 get_weixin_work_comments.py 的 extract_comments 函数)
+        
+        Args:
+            comment_list: 评论列表(原始接口数据)
+            parent_id: 父评论ID(一级评论为空字符串"",二级评论为父级评论ID)
+            work_info: 作品信息字典
+        
+        Returns:
+            list: 扁平化的评论列表,包含一级和二级评论
+        """
+        result = []
+        
+        # 获取当前用户 username(用于判断是否为作者)
+        # 优先从环境变量获取,也可通过其他方式配置
+        my_username = getattr(self, 'my_username', '') or os.environ.get('WEIXIN_MY_USERNAME', '')
+        
+        for cmt in comment_list:
+            # 处理时间戳
+            create_ts = int(cmt.get("commentCreatetime", 0) or 0)
+            readable_time = (
+                datetime.fromtimestamp(create_ts).strftime('%Y-%m-%d %H:%M:%S')
+                if create_ts > 0 else ""
+            )
+            
+            # 判断是否作者(如果配置了 my_username)
+            username = cmt.get("username", "") or ""
+            is_author = (my_username != "") and (username == my_username)
+            
+            # 构建评论条目 - 完全参考 get_weixin_work_comments.py 的字段
+            entry = {
+                "work_id": work_info.get("work_id", "") if work_info else "",
+                "work_title": work_info.get("work_title", "") if work_info else "",
+                "comment_id": cmt.get("commentId"),
+                "parent_comment_id": parent_id,  # 关键:一级评论为空字符串"",二级评论为父评论ID
+                "username": username,
+                "nickname": cmt.get("commentNickname", ""),
+                "avatar": cmt.get("commentHeadurl", ""),
+                "content": cmt.get("commentContent", ""),
+                "create_time_unix": create_ts,
+                "create_time": readable_time,
+                "is_author": is_author,
+                "like_count": cmt.get("commentLikeCount", 0) or 0
+            }
+            result.append(entry)
+            
+            # 递归处理二级评论(levelTwoComment)
+            # 关键:二级评论的 parent_id 应该是当前这条评论的 comment_id
+            level_two = cmt.get("levelTwoComment", []) or []
+            if level_two and isinstance(level_two, list) and len(level_two) > 0:
+                # 当前评论的 ID 作为其子评论的 parent_id
+                current_comment_id = cmt.get("commentId", "")
+                result.extend(
+                    self._extract_comments(level_two, parent_id=current_comment_id, work_info=work_info)
+                )
+        
+        return result
+
+
     async def auto_reply_private_messages(self, cookies: str) -> dict:
         """自动回复私信 - 集成自 pw3.py"""
         print(f"\n{'='*60}")

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

@@ -30,7 +30,8 @@ export class Work {
   @Column({ name: 'cover_url', type: 'varchar', length: 500, default: '' })
   coverUrl!: string;
 
-  @Column({ name: 'video_url', type: 'varchar', length: 500, nullable: true })
+  // 抖音 play_addr.url_list[0] 等直链可能非常长,使用 TEXT 避免长度不足导致同步失败
+  @Column({ name: 'video_url', type: 'text', nullable: true })
   videoUrl!: string | null;
 
   @Column({ type: 'varchar', length: 20, default: '00:00' })

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

@@ -11,6 +11,7 @@ import { DouyinAccountOverviewImportService } from '../services/DouyinAccountOve
 import { BaijiahaoContentOverviewImportService } from '../services/BaijiahaoContentOverviewImportService.js';
 import { WeixinVideoDataCenterImportService } from '../services/WeixinVideoDataCenterImportService.js';
 import { XiaohongshuWorkNoteStatisticsImportService } from '../services/XiaohongshuWorkNoteStatisticsImportService.js';
+import { DouyinWorkStatisticsImportService } from '../services/DouyinWorkStatisticsImportService.js';
 
 /**
  * 定时任务调度器
@@ -21,6 +22,7 @@ export class TaskScheduler {
   private isXhsImportRunning = false; // 小红书导入锁,防止任务重叠执行
   private isXhsWorkImportRunning = false; // 小红书作品日统计导入锁
   private isDyImportRunning = false; // 抖音导入锁,防止任务重叠执行
+  private isDyWorkImportRunning = false; // 抖音作品日统计导入锁
   private isBjImportRunning = false; // 百家号导入锁,防止任务重叠执行
   private isWxImportRunning = false; // 视频号导入锁,防止任务重叠执行
   private isAutoReplying = false; // 私信回复锁,防止任务重叠执行
@@ -51,6 +53,9 @@ export class TaskScheduler {
     // 每天 12:10:批量导出抖音“数据中心-账号总览-短视频-数据表现-近30天”,导入 user_day_statistics
     this.scheduleJob('dy-account-overview-import', '10 12 * * *', this.importDyAccountOverviewLast30Days.bind(this));
 
+    // 每天 12:50:同步抖音作品维度的「作品详情-按天」数据,写入 work_day_statistics
+    this.scheduleJob('dy-work-statistics-import', '50 12 * * *', this.importDyWorkStatistics.bind(this));
+
     // 每天 12:20:批量导出百家号“数据中心-内容分析-基础数据-近30天”,导入 user_day_statistics
     this.scheduleJob('bj-content-overview-import', '20 12 * * *', this.importBaijiahaoContentOverviewLast30Days.bind(this));
 
@@ -71,6 +76,7 @@ export class TaskScheduler {
       '[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]   - dy-work-statistics-import:  daily at 12:50 (50 12 * * *)');
     logger.info('[Scheduler]   - bj-content-overview-import: daily at 12:20 (20 12 * * *)');
     logger.info('[Scheduler]   - wx-video-data-center-import: daily at 12:30 (30 12 * * *)');
     logger.info('[Scheduler]   - auto-reply-messages: every minute (* * * * *)');
@@ -381,6 +387,23 @@ export class TaskScheduler {
       this.isDyImportRunning = false;
     }
   }
+
+  /**
+   * 抖音:作品维度「作品详情-按天」→ 导入 work_day_statistics
+   */
+  private async importDyWorkStatistics(): Promise<void> {
+    if (this.isDyWorkImportRunning) {
+      logger.info('[Scheduler] Douyin work statistics import is already running, skipping...');
+      return;
+    }
+
+    this.isDyWorkImportRunning = true;
+    try {
+      await DouyinWorkStatisticsImportService.runDailyImport();
+    } finally {
+      this.isDyWorkImportRunning = false;
+    }
+  }
   
   /**
    * 自动回复私信(每5分钟执行一次)

+ 196 - 0
server/src/scripts/check-douyin-account.ts

@@ -0,0 +1,196 @@
+/**
+ * 检查抖音账号同步问题 - 诊断脚本
+ * 用法: tsx src/scripts/check-douyin-account.ts <accountId或account_id>
+ */
+import { initDatabase, AppDataSource, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { headlessBrowserService } from '../services/HeadlessBrowserService.js';
+import { CookieManager } from '../automation/cookie.js';
+
+async function main() {
+  const accountIdOrAccountId = process.argv[2];
+  if (!accountIdOrAccountId) {
+    logger.error('请提供账号ID或account_id');
+    logger.info('用法: tsx src/scripts/check-douyin-account.ts <accountId或account_id>');
+    process.exit(1);
+  }
+
+  try {
+    await initDatabase();
+    const accountRepository = AppDataSource.getRepository(PlatformAccount);
+
+    // 先尝试作为数据库主键ID查询(仅当参数是纯数字时)
+    const maybeId = Number(accountIdOrAccountId);
+    let account: PlatformAccount | null = null;
+    if (!Number.isNaN(maybeId) && Number.isInteger(maybeId) && maybeId > 0) {
+      account = await accountRepository.findOne({
+        where: { id: maybeId },
+      });
+    }
+
+    // 如果没找到,尝试作为account_id(字符串)查询
+    if (!account) {
+      account = await accountRepository.findOne({
+        where: { accountId: accountIdOrAccountId },
+      });
+    }
+
+    // 如果还是没找到,尝试模糊匹配(dy_开头)
+    if (!account && accountIdOrAccountId.startsWith('dy_')) {
+      const accounts = await accountRepository.find({
+        where: { platform: 'douyin' },
+      });
+      account = accounts.find(
+        (a) =>
+          a.accountId?.includes(accountIdOrAccountId.replace('dy_', '')) ||
+          a.accountId === accountIdOrAccountId
+      );
+    }
+
+    if (!account) {
+      logger.error(`未找到账号: ${accountIdOrAccountId}`);
+      logger.info('可用的抖音账号:');
+      const allAccounts = await accountRepository.find({
+        where: { platform: 'douyin' },
+      });
+      allAccounts.forEach((a) => {
+        logger.info(
+          `  ID=${a.id}, accountId=${a.accountId}, name=${a.accountName}, status=${a.status}`
+        );
+      });
+      process.exit(1);
+    }
+
+    logger.info(`找到账号: ID=${account.id}, accountId=${account.accountId}, name=${account.accountName}, status=${account.status}`);
+
+    if (!account.cookieData) {
+      logger.error('账号没有 cookie 数据');
+      process.exit(1);
+    }
+
+    // 解密 Cookie
+    let decryptedCookies: string;
+    try {
+      decryptedCookies = CookieManager.decrypt(account.cookieData);
+      logger.info('Cookie 解密成功');
+    } catch {
+      decryptedCookies = account.cookieData;
+      logger.info('使用原始 cookie 数据');
+    }
+
+    // 解析 Cookie(支持 JSON 和 \"name=value; name2=value2\" 两种格式)
+    let cookieList: { name: string; value: string; domain: string; path: string }[];
+    try {
+      cookieList = JSON.parse(decryptedCookies);
+      logger.info(`从 JSON 解析了 ${cookieList.length} 个 cookie`);
+    } catch {
+      // 回退解析字符串格式的 cookie(与 WorkService.parseCookieString 保持一致)
+      logger.warn('Cookie 不是 JSON,尝试按字符串格式解析...');
+      const domain = '.douyin.com';
+      const cookies: { name: string; value: string; domain: string; path: string }[] = [];
+      const pairs = decryptedCookies.split(';');
+      for (const pair of pairs) {
+        const trimmed = pair.trim();
+        if (!trimmed) continue;
+        const eqIndex = trimmed.indexOf('=');
+        if (eqIndex === -1) continue;
+        const name = trimmed.substring(0, eqIndex).trim();
+        const value = trimmed.substring(eqIndex + 1).trim();
+        if (!name || !value) continue;
+        cookies.push({ name, value, domain, path: '/' });
+      }
+      cookieList = cookies;
+      logger.info(`从字符串解析了 ${cookieList.length} 个 cookie`);
+      if (cookieList.length === 0) {
+        logger.error('Cookie 字符串解析失败,未能获取任何 cookie');
+        logger.info('Cookie 前100字符:', decryptedCookies.substring(0, 100));
+        process.exit(1);
+      }
+    }
+
+    // 调用 fetchAccountInfo
+    logger.info('开始获取账号信息和作品列表...');
+    logger.info('注意:这可能需要一些时间,请耐心等待...');
+    
+    let accountInfo;
+    try {
+      accountInfo = await headlessBrowserService.fetchAccountInfo('douyin', cookieList, {
+        onWorksFetchProgress: (info) => {
+          logger.info(
+            `[进度] 已获取 ${info.totalSoFar} 个作品,总计: ${info.declaredTotal || '?'}, 当前页: ${info.page}`
+          );
+        },
+      });
+    } catch (error) {
+      logger.error('获取账号信息时出错:', error);
+      throw error;
+    }
+
+    logger.info('\n=== 账号信息 ===');
+    logger.info(`accountId: ${accountInfo.accountId}`);
+    logger.info(`accountName: ${accountInfo.accountName}`);
+    logger.info(`avatarUrl: ${accountInfo.avatarUrl ? '有' : '无'}`);
+    logger.info(`fansCount: ${accountInfo.fansCount}`);
+    logger.info(`worksCount: ${accountInfo.worksCount}`);
+    logger.info(`worksList.length: ${accountInfo.worksList?.length || 0}`);
+    logger.info(`source: ${accountInfo.source || 'unknown'}`);
+    logger.info(`pythonAvailable: ${accountInfo.pythonAvailable ? 'yes' : 'no'}`);
+    
+    if (accountInfo.worksCount > 0 && (!accountInfo.worksList || accountInfo.worksList.length === 0)) {
+      logger.warn('\n⚠️  警告:worksCount > 0 但 worksList 为空!');
+      logger.warn('这可能表示:');
+      logger.warn('1. API 返回了总数,但实际列表为空');
+      logger.warn('2. 作品列表在解析过程中丢失');
+      logger.warn('3. 分页逻辑有问题,没有正确获取所有作品');
+    }
+
+    if (accountInfo.worksList && accountInfo.worksList.length > 0) {
+      logger.info('\n=== 作品列表 ===');
+      accountInfo.worksList.forEach((work, idx) => {
+        logger.info(
+          `${idx + 1}. ${work.title} (videoId: ${work.videoId}, playCount: ${work.playCount}, likeCount: ${work.likeCount})`
+        );
+      });
+    } else {
+      logger.warn('\n=== 未获取到作品列表 ===');
+      logger.warn(`worksCount: ${accountInfo.worksCount}`);
+      logger.warn(`worksList.length: ${accountInfo.worksList?.length || 0}`);
+      logger.warn(`source: ${accountInfo.source || 'unknown'}`);
+      logger.warn(`pythonAvailable: ${accountInfo.pythonAvailable ? 'yes' : 'no'}`);
+      
+      if (accountInfo.worksCount > 0 && (!accountInfo.worksList || accountInfo.worksList.length === 0)) {
+        logger.error('\n⚠️  严重问题:worksCount > 0 但 worksList 为空!');
+        logger.error('这表示 API 返回了作品总数,但实际列表为空');
+        logger.error('可能的原因:');
+        logger.error('1. API 分页逻辑有问题');
+        logger.error('2. 作品列表在解析过程中丢失');
+        logger.error('3. API 返回格式变化');
+      } else if (accountInfo.worksCount === 0) {
+        logger.warn('\n可能的原因:');
+        logger.warn('1. API 调用失败或超时(检查上面的错误日志)');
+        logger.warn('2. 账号确实没有作品');
+        logger.warn('3. Cookie 已过期(检查是否跳转到登录页)');
+        logger.warn('4. 账号权限不足(无法访问作品列表)');
+        logger.warn('5. Python API 返回空列表,Playwright 也失败');
+      }
+    }
+
+    // 检查账号ID匹配
+    logger.info('\n=== 账号ID匹配检查 ===');
+    logger.info(`数据库 accountId: ${account.accountId}`);
+    logger.info(`API 返回 accountId: ${accountInfo.accountId}`);
+    if (account.accountId !== accountInfo.accountId) {
+      logger.warn('⚠️  账号ID不匹配!这可能导致同步时无法正确匹配账号');
+      logger.warn('建议: 更新数据库中的 accountId 为 API 返回的值');
+    } else {
+      logger.info('✓ 账号ID匹配');
+    }
+
+    process.exit(0);
+  } catch (e) {
+    logger.error('执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();

+ 53 - 0
server/src/scripts/list-works-for-account.ts

@@ -0,0 +1,53 @@
+import { initDatabase, AppDataSource, Work, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+
+async function main() {
+  const arg = process.argv[2];
+  if (!arg) {
+    logger.error('请提供账号ID(数据库主键)');
+    logger.info('用法: tsx src/scripts/list-works-for-account.ts <accountId>');
+    process.exit(1);
+  }
+
+  const accountId = Number(arg);
+  if (!Number.isInteger(accountId) || accountId <= 0) {
+    logger.error(`无效的账号ID: ${arg}`);
+    process.exit(1);
+  }
+
+  try {
+    await initDatabase();
+    const accountRepo = AppDataSource.getRepository(PlatformAccount);
+    const workRepo = AppDataSource.getRepository(Work);
+
+    const account = await accountRepo.findOne({ where: { id: accountId } });
+    if (!account) {
+      logger.error(`未找到账号 ID=${accountId}`);
+      process.exit(1);
+    }
+
+    logger.info(
+      `账号: ID=${account.id}, platform=${account.platform}, accountId=${account.accountId}, name=${account.accountName}`
+    );
+
+    const works = await workRepo.find({
+      where: { accountId: account.id },
+      order: { publishTime: 'DESC' },
+    });
+
+    logger.info(`共找到 ${works.length} 条作品记录`);
+    works.forEach((w, idx) => {
+      logger.info(
+        `${idx + 1}. id=${w.id}, platformVideoId=${w.platformVideoId}, title=${w.title}, play=${w.playCount}, like=${w.likeCount}`
+      );
+    });
+
+    process.exit(0);
+  } catch (e) {
+    logger.error('执行失败:', e);
+    process.exit(1);
+  }
+}
+
+void main();
+

+ 19 - 0
server/src/scripts/run-dy-work-stats-import.ts

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

+ 49 - 0
server/src/scripts/sync-works-for-account.ts

@@ -0,0 +1,49 @@
+import { initDatabase, AppDataSource, PlatformAccount } from '../models/index.js';
+import { logger } from '../utils/logger.js';
+import { WorkService } from '../services/WorkService.js';
+
+async function main() {
+  const arg = process.argv[2];
+  if (!arg) {
+    logger.error('请提供账号(platform_accounts.id 或 account_id 字符串)');
+    logger.info('用法: tsx src/scripts/sync-works-for-account.ts <6|dy_1742363409>');
+    process.exit(1);
+  }
+
+  await initDatabase();
+  const accountRepo = AppDataSource.getRepository(PlatformAccount);
+
+  const maybeId = Number(arg);
+  let account: PlatformAccount | null = null;
+  if (!Number.isNaN(maybeId) && Number.isInteger(maybeId) && maybeId > 0) {
+    account = await accountRepo.findOne({ where: { id: maybeId } });
+  }
+  if (!account) {
+    account = await accountRepo.findOne({ where: { accountId: arg } });
+  }
+
+  if (!account) {
+    logger.error(`未找到账号: ${arg}`);
+    process.exit(1);
+  }
+
+  logger.info(`准备同步账号: id=${account.id}, userId=${account.userId}, platform=${account.platform}, accountId=${account.accountId}`);
+
+  const workService = new WorkService();
+  const result = await workService.syncWorks(
+    account.userId,
+    account.id,
+    undefined,
+    (p, step) => logger.info(`[进度] ${p}% ${step}`)
+  );
+
+  logger.info(`同步完成: synced=${result.synced}, accounts=${result.accounts}`);
+  logger.info(`账号摘要: ${JSON.stringify(result.accountSummaries, null, 2)}`);
+  process.exit(0);
+}
+
+void main().catch((e) => {
+  logger.error('执行失败:', e);
+  process.exit(1);
+});
+

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

@@ -283,6 +283,8 @@ export class CommentService {
         } else if (account.platform === 'weixin_video') {
           workComments = await headlessBrowserService.fetchWeixinVideoCommentsViaApi(cookies);
         }
+
+
         
         // 获取该账号的所有作品,用于关联
         const workRepository = AppDataSource.getRepository(Work);
@@ -447,6 +449,7 @@ export class CommentService {
                   platform: account.platform as PlatformType,
                   videoId: workComment.videoId,
                   commentId: comment.commentId,
+                  parentCommentId: comment.parentCommentId,
                   authorId: comment.authorId,
                   authorName: comment.authorName,
                   authorAvatar: comment.authorAvatar,

+ 598 - 0
server/src/services/DouyinWorkStatisticsImportService.ts

@@ -0,0 +1,598 @@
+/// <reference lib="dom" />
+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 { BrowserManager } from '../automation/browser.js';
+import type { ProxyConfig } from '@media-manager/shared';
+import { CookieManager } from '../automation/cookie.js';
+import { WS_EVENTS } from '@media-manager/shared';
+import { wsManager } from '../websocket/index.js';
+import type { PlatformType } from '@media-manager/shared';
+
+/** 抖音 metrics_trend 返回 user not match / 未登录时抛出,用于触发「先刷新账号、再决定是否账号失效」 */
+export class DouyinLoginExpiredError extends Error {
+  constructor(message = 'DOUYIN_LOGIN_EXPIRED') {
+    super(message);
+    this.name = 'DouyinLoginExpiredError';
+  }
+}
+
+type PlaywrightCookie = {
+  name: string;
+  value: string;
+  domain?: string;
+  path?: string;
+  url?: string;
+  expires?: number;
+  httpOnly?: boolean;
+  secure?: boolean;
+  sameSite?: 'Lax' | 'None' | 'Strict';
+};
+
+type TrendPoint = { date_time?: string; value?: string | number };
+type MetricsTrendResponse = {
+  status_code: number;
+  status_msg?: string;
+  trend_map?: Record<
+    string,
+    Record<string, TrendPoint[]>
+  >;
+};
+
+interface DailyWorkStatPatch {
+  workId: number;
+  recordDate: Date;
+  playCount?: number;
+  likeCount?: number;
+  commentCount?: number;
+  shareCount?: number;
+  collectCount?: number;
+  fansIncrease?: number;
+  completionRate?: string;
+  twoSecondExitRate?: string;
+}
+
+function tryDecryptCookieData(cookieData: string | null): string | null {
+  if (!cookieData) return null;
+  const raw = cookieData.trim();
+  if (!raw) return null;
+  try {
+    return CookieManager.decrypt(raw);
+  } catch {
+    return raw;
+  }
+}
+
+function parseCookiesFromAccount(cookieData: string | null): PlaywrightCookie[] {
+  const rawOrDecrypted = tryDecryptCookieData(cookieData);
+  if (!rawOrDecrypted) return [];
+  const raw = rawOrDecrypted.trim();
+  if (!raw) return [];
+
+  // 1) JSON array(最常见)
+  if (raw.startsWith('[') || raw.startsWith('{')) {
+    try {
+      const parsed = JSON.parse(raw);
+      const arr = Array.isArray(parsed) ? parsed : (parsed?.cookies ? parsed.cookies : []);
+      if (!Array.isArray(arr)) return [];
+      return arr
+        .map((c: any) => {
+          const name = String(c?.name ?? '').trim();
+          const value = String(c?.value ?? '').trim();
+          if (!name) return null;
+          const domain = c?.domain ? String(c.domain) : undefined;
+          const pathVal = c?.path ? String(c.path) : '/';
+          const url = !domain ? 'https://creator.douyin.com' : undefined;
+          const sameSiteRaw = c?.sameSite;
+          const sameSite =
+            sameSiteRaw === 'Lax' || sameSiteRaw === 'None' || sameSiteRaw === 'Strict'
+              ? sameSiteRaw
+              : undefined;
+          return {
+            name,
+            value,
+            domain,
+            path: pathVal,
+            url,
+            expires: typeof c?.expires === 'number' ? c.expires : undefined,
+            httpOnly: typeof c?.httpOnly === 'boolean' ? c.httpOnly : undefined,
+            secure: typeof c?.secure === 'boolean' ? c.secure : undefined,
+            sameSite,
+          } satisfies PlaywrightCookie;
+        })
+        .filter(Boolean) as PlaywrightCookie[];
+    } catch {
+      // fallthrough
+    }
+  }
+
+  // 2) "a=b; c=d" 拼接格式
+  const pairs = raw.split(';').map((p) => p.trim()).filter(Boolean);
+  const cookies: PlaywrightCookie[] = [];
+  for (const p of pairs) {
+    const idx = p.indexOf('=');
+    if (idx <= 0) continue;
+    const name = p.slice(0, idx).trim();
+    const value = p.slice(idx + 1).trim();
+    if (!name) continue;
+    cookies.push({ name, value, url: 'https://creator.douyin.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 parseChinaDateFromDateTimeString(dateTime: unknown): Date | null {
+  if (!dateTime) return null;
+  const s = String(dateTime).trim();
+  if (s.length < 10) return null;
+  const ymd = s.slice(0, 10); // YYYY-MM-DD
+  const m = ymd.match(/^(\d{4})-(\d{2})-(\d{2})$/);
+  if (!m) return null;
+  const yyyy = Number(m[1]);
+  const mm = Number(m[2]);
+  const dd = Number(m[3]);
+  if (!yyyy || !mm || !dd) return null;
+  const d = new Date(yyyy, mm - 1, dd, 0, 0, 0, 0);
+  return d;
+}
+
+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 toInt(val: unknown, defaultValue = 0): number {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return defaultValue;
+  return Math.round(n);
+}
+
+function normalizePercentString(val: unknown): string | undefined {
+  const n = toNumber(val, NaN);
+  if (!Number.isFinite(n)) return undefined;
+  // 去掉多余的 0:48.730000 -> 48.73
+  const s = n.toString();
+  return `${s}%`;
+}
+
+function isDouyinLoginExpiredByApi(body: any): boolean {
+  const code = Number(body?.status_code);
+  const msg = String(body?.status_msg || '');
+  if (code === 20001) return true; // user not match
+  if (msg.includes('user not match')) return true;
+  if (msg.includes('登录') && msg.includes('失效')) return true;
+  return false;
+}
+
+class DouyinMetricsTrendClient {
+  private capturedHeaders: Record<string, string> | null = null;
+
+  private buildTrendUrl(itemId: string, metric: string): string {
+    const base = 'https://creator.douyin.com/janus/douyin/creator/data/item_analysis/metrics_trend';
+    const params = new URLSearchParams({
+      aid: '2906',
+      app_name: 'aweme_creator_platform',
+      device_platform: 'web',
+      // referer/user_agent 等埋点参数保留,尽量贴近浏览器请求
+      referer: 'https://creator.douyin.com/creator-micro/data-center/content',
+      user_agent:
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
+      cookie_enabled: 'true',
+      screen_width: '1920',
+      screen_height: '1080',
+      browser_language: 'zh-CN',
+      browser_platform: 'Win32',
+      browser_name: 'Mozilla',
+      browser_version:
+        '5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
+      browser_online: 'true',
+      timezone_name: 'Asia/Shanghai',
+      item_id: itemId,
+      trend_type: '1',
+      time_unit: '1',
+      metrics_group: '0,1,3',
+      metrics: metric,
+    });
+    return `${base}?${params.toString()}`;
+  }
+
+  private filterCapturedHeaders(h: Record<string, string>): Record<string, string> {
+    const out: Record<string, string> = {};
+    const allowList = new Set([
+      'accept',
+      'accept-language',
+      'agw-js-conv',
+      'user-agent',
+      'x-secsdk-csrf-token',
+    ]);
+    for (const [k, v] of Object.entries(h || {})) {
+      const key = k.toLowerCase();
+      if (key === 'cookie') continue;
+      if (key === 'host') continue;
+      if (key === 'authority') continue;
+      if (key === 'content-length') continue;
+      if (key === 'referer') continue; // 每次按作品详情页动态设置
+      if (allowList.has(key)) out[key] = v;
+      // 保留所有 x- 前缀的 header(有些风控字段会放在 x- 里)
+      else if (key.startsWith('x-')) out[key] = v;
+    }
+    return out;
+  }
+
+  private async captureHeadersFromRealRequest(page: Page, metricLabel: string, itemId: string, metric: string): Promise<void> {
+    // 通过点击 UI 触发一次真实请求,抓取其 headers(尤其是 x-secsdk-csrf-token)
+    const apiPattern = /\/janus\/douyin\/creator\/data\/item_analysis\/metrics_trend/i;
+
+    const wait = page.waitForResponse((res) => {
+      if (res.request().method() !== 'GET') return false;
+      const u = res.url();
+      return apiPattern.test(u) && u.includes(`item_id=${itemId}`) && u.includes(`metrics=${metric}`);
+    }, { timeout: 25_000 });
+
+    // 指标卡片(如:点赞量/播放量/评论量...)
+    // 允许用 "|" 提供多个候选文案(适配 UI 文案差异)
+    const labels = metricLabel
+      .split('|')
+      .map((s) => s.trim())
+      .filter(Boolean);
+
+    let clicked = false;
+    for (const label of labels.length ? labels : [metricLabel]) {
+      const loc = page.getByText(label, { exact: false }).first();
+      const cnt = await loc.count().catch(() => 0);
+      if (!cnt) continue;
+      await loc.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => undefined);
+      await loc.click().catch(() => undefined);
+      clicked = true;
+      break;
+    }
+
+    if (!clicked) {
+      // 不强制失败:有些情况下 UI 不可点击,但直连请求仍可能成功
+      logger.warn(`[DY WorkStats] Could not click metric label on page for header capture. label=${metricLabel}`);
+    }
+
+    const res = await wait;
+    const headers = res.request().headers();
+    this.capturedHeaders = this.filterCapturedHeaders(headers);
+    logger.info(`[DY WorkStats] Captured request headers for metrics_trend. keys=${Object.keys(this.capturedHeaders).join(',')}`);
+  }
+
+  async fetchTrend(
+    ctx: BrowserContext,
+    page: Page,
+    itemId: string,
+    metric: string,
+    metricLabelForFallback: string,
+    refererUrl: string
+  ): Promise<MetricsTrendResponse> {
+    const url = this.buildTrendUrl(itemId, metric);
+
+    const headers: Record<string, string> = {
+      accept: 'application/json, text/plain, */*',
+      'accept-language': 'zh-CN,zh;q=0.9',
+      referer: refererUrl,
+      'user-agent':
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
+      ...(this.capturedHeaders || {}),
+    };
+
+    // 1) 先尝试直接请求(最快)
+    try {
+      const res = await ctx.request.get(url, {
+        headers,
+        timeout: 25_000,
+      });
+      const json = (await res.json().catch(() => null)) as MetricsTrendResponse | null;
+      if (json && typeof json === 'object') return json;
+    } catch {
+      // fallthrough
+    }
+
+    // 2) 如果直连失败,抓一次真实请求 header 后重试
+    if (!this.capturedHeaders) {
+      await this.captureHeadersFromRealRequest(page, metricLabelForFallback, itemId, metric).catch(() => undefined);
+    }
+
+    const headers2: Record<string, string> = {
+      accept: 'application/json, text/plain, */*',
+      'accept-language': 'zh-CN,zh;q=0.9',
+      referer: refererUrl,
+      'user-agent':
+        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36',
+      ...(this.capturedHeaders || {}),
+    };
+
+    const res2 = await ctx.request.get(url, {
+      headers: headers2,
+      timeout: 25_000,
+    });
+    const json2 = (await res2.json().catch(() => null)) as MetricsTrendResponse | null;
+    if (!json2) throw new Error('metrics_trend 响应不是 JSON');
+    return json2;
+  }
+}
+
+export class DouyinWorkStatisticsImportService {
+  private accountRepository = AppDataSource.getRepository(PlatformAccount);
+  private workRepository = AppDataSource.getRepository(Work);
+  private workDayStatisticsService = new WorkDayStatisticsService();
+
+  static async runDailyImport(): Promise<void> {
+    const svc = new DouyinWorkStatisticsImportService();
+    await svc.runDailyImportForAllDouyinAccounts();
+  }
+
+  async runDailyImportForAllDouyinAccounts(): Promise<void> {
+    const accounts = await this.accountRepository.find({
+      where: { platform: 'douyin' as any },
+    });
+
+    logger.info(`[DY WorkStats] Start import for ${accounts.length} accounts`);
+
+    for (const account of accounts) {
+      try {
+        await this.importAccountWorksStatistics(account);
+      } catch (e) {
+        logger.error(
+          `[DY WorkStats] Account failed. accountId=${account.id} name=${account.accountName || ''}`,
+          e
+        );
+        // 单账号失败仅记录日志,不中断循环,其他账号照常同步
+      }
+    }
+
+    logger.info('[DY WorkStats] All accounts done');
+  }
+
+  /**
+   * 按账号同步作品日统计。检测到 cookie 失效时:先尝试同步/刷新账号一次;刷新仍失效则标记账号 expired。
+   * @param isRetry 是否为「刷新账号后的重试」,避免无限递归
+   */
+  private async importAccountWorksStatistics(account: PlatformAccount, isRetry = false): Promise<void> {
+    const cookies = parseCookiesFromAccount(account.cookieData);
+    if (!cookies.length) {
+      logger.warn(`[DY WorkStats] accountId=${account.id} cookieData 为空或无法解析,跳过`);
+      return;
+    }
+
+    const works = await this.workRepository.find({
+      where: {
+        accountId: account.id,
+        platform: 'douyin' as any,
+      },
+    });
+
+    if (!works.length) {
+      logger.info(`[DY 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/144.0.0.0 Safari/537.36',
+      });
+      await context.addCookies(cookies as any);
+      context.setDefaultTimeout(60_000);
+
+      if (!context) {
+        throw new Error('BrowserContext 初始化失败');
+      }
+      const ctx = context;
+
+      const page = await context.newPage();
+      const client = new DouyinMetricsTrendClient();
+
+      let totalInserted = 0;
+      let totalUpdated = 0;
+
+      for (const work of works) {
+        const itemId = (work.platformVideoId || '').trim();
+        if (!itemId) continue;
+
+        const detailUrl = `https://creator.douyin.com/creator-micro/work-management/work-detail/${encodeURIComponent(
+          itemId
+        )}?enter_from=item_data`;
+
+        try {
+          await page.goto(detailUrl, { waitUntil: 'domcontentloaded' }).catch(() => undefined);
+          await page.waitForTimeout(1200);
+
+          if (page.url().includes('login') || page.url().includes('passport')) {
+            throw new DouyinLoginExpiredError('work-detail 页面跳转登录,cookie 可能失效');
+          }
+
+          // metrics -> 入库字段映射
+          const metricsPlan: Array<{
+            metric: string;
+            label: string;
+            apply: (patch: DailyWorkStatPatch, v: unknown) => void;
+          }> = [
+            { metric: 'view_count', label: '播放量', apply: (p, v) => (p.playCount = toInt(v, 0)) },
+            { metric: 'like_count', label: '点赞量', apply: (p, v) => (p.likeCount = toInt(v, 0)) },
+            { metric: 'comment_count', label: '评论量', apply: (p, v) => (p.commentCount = toInt(v, 0)) },
+            { metric: 'share_count', label: '分享量', apply: (p, v) => (p.shareCount = toInt(v, 0)) },
+            { metric: 'favorite_count', label: '收藏量|收藏数', apply: (p, v) => (p.collectCount = toInt(v, 0)) },
+            { metric: 'subscribe_count', label: '涨粉量|涨粉数', apply: (p, v) => (p.fansIncrease = toInt(v, 0)) },
+            {
+              metric: 'completion_rate',
+              label: '完播率',
+              apply: (p, v) => {
+                const s = normalizePercentString(v);
+                if (s != null) p.completionRate = s;
+              },
+            },
+            {
+              metric: 'bounce_rate_2s',
+              label: '2s退出率|2s跳出率|2s跳出',
+              apply: (p, v) => {
+                const s = normalizePercentString(v);
+                if (s != null) p.twoSecondExitRate = s;
+              },
+            },
+          ];
+
+          const dayMap = new Map<number, DailyWorkStatPatch>();
+
+          for (const m of metricsPlan) {
+            const body = await client.fetchTrend(ctx, page, itemId, m.metric, m.label, detailUrl);
+            if (!body || typeof body !== 'object') continue;
+
+            if (isDouyinLoginExpiredByApi(body)) {
+              throw new DouyinLoginExpiredError(body.status_msg || 'metrics_trend: user not match');
+            }
+            if (Number(body.status_code) !== 0) {
+              logger.warn(
+                `[DY WorkStats] metrics_trend 非成功返回. accountId=${account.id} workId=${work.id} itemId=${itemId} metric=${m.metric} code=${body.status_code} msg=${body.status_msg || ''}`
+              );
+              continue;
+            }
+
+            const trendMap = body.trend_map || {};
+            const metricMap = (trendMap as any)[m.metric] as Record<string, TrendPoint[]> | undefined;
+            if (!metricMap) continue;
+
+            // 优先取 group "0"(一般为“总计/全部”),否则兜底合并全部 group
+            const points = Array.isArray(metricMap['0'])
+              ? metricMap['0']
+              : Object.values(metricMap).flatMap((arr) => (Array.isArray(arr) ? arr : []));
+
+            for (const pt of points) {
+              const d = parseChinaDateFromDateTimeString(pt?.date_time);
+              if (!d) continue;
+              const key = d.getTime();
+              let entry = dayMap.get(key);
+              if (!entry) {
+                entry = { workId: work.id, recordDate: d };
+                dayMap.set(key, entry);
+              }
+              m.apply(entry, pt?.value);
+            }
+          }
+
+          const patches = Array.from(dayMap.values()).sort(
+            (a, b) => a.recordDate.getTime() - b.recordDate.getTime()
+          );
+          if (!patches.length) continue;
+
+          const result = await this.workDayStatisticsService.saveStatisticsForDateBatch(
+            patches.map((p) => ({
+              workId: p.workId,
+              recordDate: p.recordDate,
+              playCount: p.playCount,
+              likeCount: p.likeCount,
+              commentCount: p.commentCount,
+              shareCount: p.shareCount,
+              collectCount: p.collectCount,
+              fansIncrease: p.fansIncrease,
+              completionRate: p.completionRate,
+              twoSecondExitRate: p.twoSecondExitRate,
+            }))
+          );
+
+          totalInserted += result.inserted;
+          totalUpdated += result.updated;
+        } catch (e) {
+          if (e instanceof DouyinLoginExpiredError) {
+            closedDueToLoginExpired = true;
+            if (context) {
+              await context.close().catch(() => undefined);
+              context = null;
+            }
+            if (shouldClose) {
+              await browser.close().catch(() => undefined);
+            }
+
+            // cookie 过期处理:先刷新一次账号,再决定是否标记 expired
+            if (!isRetry) {
+              logger.info(`[DY WorkStats] accountId=${account.id} 登录失效,尝试同步账号后重试...`);
+              try {
+                const accountService = new AccountService();
+                const refreshResult = await accountService.refreshAccount(account.userId, account.id);
+                if (refreshResult.needReLogin) {
+                  await this.markAccountExpired(account, 'cookie 过期,需要重新登录');
+                  return;
+                }
+                const refreshed = await this.accountRepository.findOne({ where: { id: account.id } });
+                if (refreshed) {
+                  logger.info(`[DY WorkStats] accountId=${account.id} 同步账号成功,重新拉取作品数据`);
+                  return this.importAccountWorksStatistics(refreshed, true);
+                }
+              } catch (refreshErr) {
+                logger.error(`[DY WorkStats] accountId=${account.id} 同步账号失败`, refreshErr);
+                await this.markAccountExpired(account, '同步账号失败,已标记过期');
+                return;
+              }
+            } else {
+              await this.markAccountExpired(account, '同步后仍失效,已标记过期');
+              return;
+            }
+          }
+
+          logger.error(
+            `[DY WorkStats] Failed to import work stats. accountId=${account.id} workId=${work.id} itemId=${itemId}`,
+            e
+          );
+        }
+      }
+
+      logger.info(
+        `[DY 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 markAccountExpired(account: PlatformAccount, reason: string): Promise<void> {
+    await this.accountRepository.update(account.id, { status: 'expired' as any });
+    wsManager.sendToUser(account.userId, WS_EVENTS.ACCOUNT_UPDATED, {
+      account: { id: account.id, status: 'expired', platform: 'douyin' as PlatformType },
+    });
+    wsManager.sendToUser(account.userId, WS_EVENTS.SYSTEM_MESSAGE, {
+      level: 'warning',
+      message: `抖音账号「${account.accountName || account.accountId || account.id}」登录已失效:${reason}`,
+      platform: 'douyin',
+      accountId: account.id,
+    });
+  }
+}
+

+ 80 - 15
server/src/services/HeadlessBrowserService.ts

@@ -1005,9 +1005,15 @@ class HeadlessBrowserService {
           }
 
           // 作品列表为空,尝试用 Playwright 获取账号信息
-          logger.info(`[Python API] Got empty works list for ${platform}, trying Playwright`);
+          if (worksTotal > 0 && worksList.length === 0) {
+            logger.warn(`[Python API] Warning: API reported ${worksTotal} works but returned empty list for ${platform}`);
+            logger.warn(`[Python API] This may indicate a bug in Python API or API format change`);
+          } else {
+            logger.info(`[Python API] Got empty works list for ${platform} (total=${worksTotal}), trying Playwright`);
+          }
         } catch (pythonError) {
           logger.warn(`[Python API] Failed to fetch works for ${platform}:`, pythonError);
+          logger.warn(`[Python API] Error details:`, pythonError instanceof Error ? pythonError.message : String(pythonError));
         }
       } else {
         logger.info(`[Python API] Service not available for ${platform}`);
@@ -1108,7 +1114,7 @@ class HeadlessBrowserService {
         coverUrl: string;
         duration: number;
         createTime: number;
-        statistics: { play_count: number; digg_count: number; comment_count: number; share_count: number };
+        statistics: { play_count: number; digg_count: number; comment_count: number; share_count: number; collect_count: number };
       }>;
       total?: number;
     } = {};
@@ -1153,16 +1159,19 @@ class HeadlessBrowserService {
                   capturedData.total = (capturedData.total || 0) + data.aweme_list.length;
                 }
               }
-              // 解析作品列表
+              // 解析作品列表;video_url 使用 video.play_addr.url_list 的第一项
               capturedData.worksList = data.aweme_list.map((aweme: Record<string, unknown>) => {
                 const statistics = aweme.statistics as Record<string, unknown> || {};
                 const cover = aweme.Cover as { url_list?: string[] } || aweme.video as { cover?: { url_list?: string[] } };
                 const coverUrl = cover?.url_list?.[0] || (cover as { cover?: { url_list?: string[] } })?.cover?.url_list?.[0] || '';
+                const video = aweme.video as { play_addr?: { url_list?: string[] } } | undefined;
+                const videoUrl = video?.play_addr?.url_list?.[0] || '';
 
                 return {
                   awemeId: String(aweme.aweme_id || ''),
                   title: String(aweme.item_title || aweme.desc || '').split('\n')[0].slice(0, 50) || '无标题',
                   coverUrl,
+                  videoUrl,
                   duration: Number(aweme.duration || 0),
                   createTime: Number(aweme.create_time || 0),
                   statistics: {
@@ -1170,6 +1179,7 @@ class HeadlessBrowserService {
                     digg_count: Number(statistics.digg_count || 0),
                     comment_count: Number(statistics.comment_count || 0),
                     share_count: Number(statistics.share_count || 0),
+                    collect_count: Number((statistics as any).collect_count || 0),
                   },
                 };
               });
@@ -1421,6 +1431,8 @@ class HeadlessBrowserService {
       logger.info('[Douyin] Fetching works via API...');
       const apiResult = await this.fetchWorksDirectApi(page);
 
+      logger.info(`[Douyin] fetchWorksDirectApi returned: works.length=${apiResult.works.length}, total=${apiResult.total}`);
+      
       if (apiResult.works.length > 0) {
         // 使用 items 累计数量作为作品数(apiResult.total 现在是累计的 items.length)
         // 如果 total 为 0,则使用 works 列表长度
@@ -1429,24 +1441,28 @@ class HeadlessBrowserService {
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
-          videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
+          videoUrl: (w as { videoUrl?: string }).videoUrl || (w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : ''),
           duration: '00:00',
           publishTime: w.createTime ? new Date(w.createTime * 1000).toISOString() : '',
           status: 'published',
-          playCount: 0,
-          likeCount: 0,
+          playCount: w.playCount,
+          likeCount: w.likeCount,
           commentCount: w.commentCount,
-          shareCount: 0,
+          shareCount: w.shareCount,
+          collectCount: w.collectCount,
         }));
         logger.info(`[Douyin] Got ${apiResult.works.length} works from API, total count: ${worksCount}`);
-      } else if (capturedData.worksList && capturedData.worksList.length > 0) {
-        // 如果直接 API 调用失败,使用监听到的数据
-        worksCount = capturedData.total || capturedData.worksList.length;
+      } else {
+        logger.warn(`[Douyin] fetchWorksDirectApi returned 0 works`);
+        if (capturedData.worksList && capturedData.worksList.length > 0) {
+          // 如果直接 API 调用失败,使用监听到的数据
+          logger.info(`[Douyin] Falling back to intercepted API data: ${capturedData.worksList.length} works`);
+          worksCount = capturedData.total || capturedData.worksList.length;
         worksList = capturedData.worksList.map(w => ({
           videoId: w.awemeId,
           title: w.title,
           coverUrl: w.coverUrl,
-          videoUrl: w.awemeId ? `https://www.douyin.com/video/${w.awemeId}` : '',
+          videoUrl: (w as { videoUrl?: string }).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',
@@ -1454,12 +1470,18 @@ class HeadlessBrowserService {
           likeCount: w.statistics.digg_count,
           commentCount: w.statistics.comment_count,
           shareCount: w.statistics.share_count,
+          collectCount: w.statistics.collect_count,
         }));
-        logger.info(`[Douyin] Got ${worksCount} works from intercepted API data`);
+          logger.info(`[Douyin] Got ${worksCount} works from intercepted API data`);
+        } else {
+          logger.warn(`[Douyin] No works found: fetchWorksDirectApi returned 0, intercepted data also empty`);
+          logger.warn(`[Douyin] This may indicate: cookie expired, API error, or account has no works`);
+        }
       }
 
     } catch (error) {
-      logger.warn('Failed to fetch Douyin account info:', error);
+      logger.error('Failed to fetch Douyin account info:', error);
+      logger.error('Error details:', error instanceof Error ? error.stack : String(error));
     }
 
     return { accountId, accountName, avatarUrl, fansCount, worksCount, worksList };
@@ -3761,7 +3783,12 @@ class HeadlessBrowserService {
       awemeId: string;
       title: string;
       coverUrl: string;
+      videoUrl?: string;
+      playCount: number;
+      likeCount: number;
       commentCount: number;
+      shareCount: number;
+      collectCount: number;
       createTime?: number;
     }>;
     total: number;
@@ -3770,7 +3797,12 @@ class HeadlessBrowserService {
       awemeId: string;
       title: string;
       coverUrl: string;
+      videoUrl?: string;
+      playCount: number;
+      likeCount: number;
       commentCount: number;
+      shareCount: number;
+      collectCount: number;
       createTime?: number;
     }> = [];
     let totalCount = 0; // 从 API 获取的总作品数
@@ -3828,12 +3860,24 @@ class HeadlessBrowserService {
         // 检查 API 返回状态
         if (data?.status_code !== 0 && data?.status_code !== undefined) {
           logger.warn(`[DirectAPI] API returned error status_code: ${data.status_code}`);
+          logger.warn(`[DirectAPI] Error message: ${data?.err_msg || data?.errMsg || 'unknown'}`);
           // status_code: 8 表示未授权,可能需要重新登录
           if (data.status_code === 8) {
             logger.warn('[DirectAPI] status_code 8: Not authorized, may need re-login');
           }
+          // 如果是第一页就出错,记录更详细的错误信息
+          if (pageCount === 1) {
+            logger.error(`[DirectAPI] First page failed with status_code ${data.status_code}, cannot fetch works`);
+            logger.error(`[DirectAPI] Response data: ${JSON.stringify(data).substring(0, 500)}`);
+          }
           break;
         }
+        
+        // 如果 status_code 是 0 但 aweme_list 为空,记录警告
+        if (data?.status_code === 0 && awemeList.length === 0 && pageCount === 1) {
+          logger.warn(`[DirectAPI] API returned success but aweme_list is empty on first page`);
+          logger.warn(`[DirectAPI] Response data: ${JSON.stringify(data).substring(0, 500)}`);
+        }
 
         // 优先从第一个作品的 author.aweme_count 获取真实作品数(只在第一页获取)
         if (pageCount === 1 && awemeList.length > 0) {
@@ -3852,9 +3896,13 @@ class HeadlessBrowserService {
           const awemeId = String(aweme.aweme_id || '');
           if (!awemeId) continue;
 
-          // 从 statistics 中获取评论数
+          // 从 statistics 中获取所有统计字段
           const statistics = aweme.statistics || {};
+          const playCount = parseInt(String(statistics.play_count || '0'), 10);
+          const likeCount = parseInt(String(statistics.digg_count || '0'), 10); // 抖音用 digg_count 表示点赞
           const commentCount = parseInt(String(statistics.comment_count || '0'), 10);
+          const shareCount = parseInt(String(statistics.share_count || '0'), 10);
+          const collectCount = parseInt(String(statistics.collect_count || '0'), 10);
 
           // 获取标题:优先使用 item_title,其次使用 desc(描述)
           let title = aweme.item_title || '';
@@ -3872,11 +3920,19 @@ class HeadlessBrowserService {
             coverUrl = aweme.video.cover.url_list[0];
           }
 
+          // 入库 video_url 使用 play_addr.url_list 的第一项
+          const videoUrl = aweme.video?.play_addr?.url_list?.[0] || '';
+
           works.push({
             awemeId,
             title,
             coverUrl,
+            videoUrl,
+            playCount,
+            likeCount,
             commentCount,
+            shareCount,
+            collectCount,
             createTime: aweme.create_time,
           });
         }
@@ -3923,8 +3979,15 @@ class HeadlessBrowserService {
       }
 
       logger.info(`[DirectAPI] Total fetched ${works.length} works from ${pageCount} pages, items count: ${totalCount}`);
+      
+      // 如果总作品数 > 0 但实际获取到的作品数为 0,记录警告
+      if (totalCount > 0 && works.length === 0) {
+        logger.warn(`[DirectAPI] Warning: API reported ${totalCount} works but fetched 0 works`);
+        logger.warn(`[DirectAPI] This may indicate: API error, cookie expired, or permission issue`);
+      }
     } catch (e) {
-      logger.warn('[DirectAPI] Failed to fetch works:', e);
+      logger.error('[DirectAPI] Failed to fetch works:', e);
+      logger.error('[DirectAPI] Error details:', e instanceof Error ? e.stack : String(e));
     }
 
     return { works, total: totalCount };
@@ -3992,6 +4055,7 @@ class HeadlessBrowserService {
 
         const comments: CommentItem[] = (commentsResult.comments || []).map((c: {
           comment_id: string;
+          parent_comment_id: string;
           author_id: string;
           author_name: string;
           author_avatar: string;
@@ -4001,6 +4065,7 @@ class HeadlessBrowserService {
           reply_count?: number;
         }) => ({
           commentId: c.comment_id,
+          parentCommentId: c.parent_comment_id,
           authorId: c.author_id,
           authorName: c.author_name,
           authorAvatar: c.author_avatar,