Преглед изворни кода

视频号自动回复私信

swortect пре 15 часа
родитељ
комит
bbcd835c5f
3 измењених фајлова са 721 додато и 65 уклоњено
  1. 52 0
      server/python/app.py
  2. 578 64
      server/python/platforms/weixin.py
  3. 91 1
      server/src/scheduler/index.ts

+ 52 - 0
server/python/app.py

@@ -70,6 +70,7 @@ from flask_cors import CORS
 
 from platforms import get_publisher, PLATFORM_MAP
 from platforms.base import PublishParams
+from platforms.weixin import WeixinPublisher
 
 
 def parse_datetime(date_str: str):
@@ -1316,5 +1317,56 @@ def main():
     app.run(host=args.host, port=args.port, debug=True, threaded=True, use_reloader=False)
 
 
+@app.route('/auto-reply', methods=['POST'])
+def auto_reply():
+    """
+    微信视频号自动回复私信
+    POST /auto-reply
+    Body: {
+        "platform": "weixin",
+        "cookie": "..."
+    }
+    """
+    try:
+        data = request.json
+        platform = data.get('platform', '').lower()
+        cookie = data.get('cookie', '')
+        
+        if platform != 'weixin':
+            return jsonify({
+                'success': False,
+                'error': '只支持微信视频号平台'
+            }), 400
+        
+        if not cookie:
+            return jsonify({
+                'success': False,
+                'error': '缺少 Cookie'
+            }), 400
+        
+        print(f"[API] 接收自动回复请求: platform={platform}")
+        
+        # 创建 Publisher 实例
+        publisher = WeixinPublisher(headless=HEADLESS_MODE)
+        
+        # 执行自动回复
+        loop = asyncio.new_event_loop()
+        asyncio.set_event_loop(loop)
+        result = loop.run_until_complete(publisher.auto_reply_private_messages(cookie))
+        loop.close()
+        
+        print(f"[API] 自动回复结果: {result}")
+        
+        return jsonify(result)
+    
+    except Exception as e:
+        print(f"[API] 自动回复异常: {e}")
+        traceback.print_exc()
+        return jsonify({
+            'success': False,
+            'error': str(e)
+        }), 500
+
+
 if __name__ == '__main__':
     main()

+ 578 - 64
server/python/platforms/weixin.py

@@ -12,6 +12,8 @@ from .base import (
     BasePublisher, PublishParams, PublishResult,
     WorkItem, WorksResult, CommentItem, CommentsResult
 )
+import os
+import time
 
 
 def format_short_title(origin_title: str) -> str:
@@ -47,9 +49,17 @@ class WeixinPublisher(BasePublisher):
     platform_name = "weixin"
     login_url = "https://channels.weixin.qq.com/platform"
     publish_url = "https://channels.weixin.qq.com/platform/post/create"
-    # 视频号域名为 channels.weixin.qq.com,cookie 常见 domain 为 .qq.com / .weixin.qq.com 等
-    # 这里默认用更宽泛的 .qq.com,避免“字符串 cookie”场景下 domain 兜底不生效
-    cookie_domain = ".qq.com"
+    cookie_domain = ".weixin.qq.com"
+    
+    def _parse_count(self, count_str: str) -> int:
+        """解析数字(支持带'万'的格式)"""
+        try:
+            count_str = count_str.strip()
+            if '万' in count_str:
+                return int(float(count_str.replace('万', '')) * 10000)
+            return int(count_str)
+        except:
+            return 0
     
     async def init_browser(self, storage_state: str = None):
         """初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
@@ -69,10 +79,18 @@ class WeixinPublisher(BasePublisher):
             print(f"[{self.platform_name}] Chrome 不可用,使用 Chromium: {e}")
             self.browser = await playwright.chromium.launch(headless=self.headless)
         
-        if storage_state and os.path.exists(storage_state):
-            self.context = await self.browser.new_context(storage_state=storage_state)
-        else:
-            self.context = await self.browser.new_context()
+        # 设置 HTTP Headers 防止重定向
+        headers = {
+            "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+            "Referer": "https://channels.weixin.qq.com/platform/post/list",
+        }
+        
+        self.context = await self.browser.new_context(
+            extra_http_headers=headers,
+            ignore_https_errors=True,
+            viewport={"width": 1920, "height": 1080},
+            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" 
+        )
         
         self.page = await self.context.new_page()
         return self.page
@@ -250,6 +268,7 @@ class WeixinPublisher(BasePublisher):
         
         # 解析并设置 cookies
         cookie_list = self.parse_cookies(cookies)
+        print(cookie_list)
         print(f"[{self.platform_name}] 解析到 {len(cookie_list)} 个 cookies")
         await self.set_cookies(cookie_list)
         
@@ -263,10 +282,100 @@ class WeixinPublisher(BasePublisher):
         print(f"[{self.platform_name}] 视频文件存在,大小: {os.path.getsize(params.video_path)} bytes")
         
         self.report_progress(10, "正在打开上传页面...")
-        
+        print(f"[{self.platform_name}] 当前 发布URL: {self.publish_url}")
         # 访问上传页面
-        await self.page.goto(self.publish_url, wait_until="domcontentloaded", timeout=60000)
-        await asyncio.sleep(3)
+        await self.page.goto(self.publish_url, wait_until="networkidle", timeout=60000)
+        await asyncio.sleep(10)
+
+        # 打印页面HTML调试
+        print(f"[{self.platform_name}] 当前 URL: {self.page.url}")
+        html_content = await self.page.content()
+        print(f"[{self.platform_name}] 页面HTML长度: {len(html_content)}")
+        
+        # 截图调试
+        screenshot_path = f"weixin_publish_{int(asyncio.get_event_loop().time())}.png"
+        await self.page.screenshot(path=screenshot_path)
+        print(f"[{self.platform_name}] 截图已保存: {screenshot_path}")
+        
+        # 检查 input[type='file'] 是否存在
+        file_input = self.page.locator("input[type='file']")
+        count = await file_input.count()
+        print(f"[{self.platform_name}] 找到 {count} 个 file input")
+        
+        if count == 0:
+            raise Exception("页面中未找到 input[type='file'] 元素")
+        
+        # 直接设置文件,不触发click
+        print("上传文件...")
+        file_path = params.video_path
+        await file_input.first.set_input_files(file_path)
+        print(f"[{self.platform_name}] 文件已设置: {file_path}")
+        
+        # 等待上传进度
+        await asyncio.sleep(5)
+        
+        # 等待删除标签弹窗可见(可选,设置超时)
+        try:
+            await self.page.wait_for_selector(".weui-desktop-popover__wrp.finder-popover-dialog-wrap .finder-tag-wrap", state="visible", timeout=20000)
+            print("删除标签弹窗已显示")
+        except:
+            print("删除标签弹窗未出现,继续执行")
+        
+        # 主动关闭系统文件选择窗口(如果还存在)
+        try:
+            # 获取所有窗口
+            context_pages = self.page.context.pages
+            for p in context_pages:
+                if p != self.page and "打开" in await p.title():
+                    print(f"关闭系统文件选择窗口: {await p.title()}")
+                    await p.close()
+        except Exception as e:
+            print(f"关闭文件选择窗口异常: {e}")
+
+
+        
+        
+
+        # 填写多个输入框
+        print("填写输入框...")
+        # 描述输入框
+        await self.page.locator("div.input-editor[contenteditable][data-placeholder='添加描述']").fill("智能拍照机来啦")
+        
+        # 短标题输入框
+        await self.page.fill("input.weui-desktop-form__input[placeholder*='概括视频主要内容']", "解放双手的智能拍照机")
+        await self.page.wait_for_timeout(1000)
+
+
+        # 点击最下方的发布按钮
+        print("点击发布按钮...")
+        await self.page.click("button.weui-desktop-btn.weui-desktop-btn_primary:has-text('发表')")
+
+        
+        # 监控是否出现"直接发表"按钮
+        try:
+            direct_publish_btn = self.page.locator("button.weui-desktop-btn.weui-desktop-btn_default:has-text('直接发表')")
+            await direct_publish_btn.wait_for(state="visible", timeout=3000)
+            print("检测到'直接发表'按钮,点击...")
+            await direct_publish_btn.click()
+        except:
+            print("未检测到'直接发表'按钮,继续...")
+
+        
+        
+        # 等待发布完成
+        await self.page.wait_for_timeout(3000)
+        print("发布完成!")
+
+        return PublishResult(
+            success=True,
+            platform=self.platform_name,
+            message="发布成功",
+            screenshot_base64="",
+            page_url=self.publish_url,
+            status='success'
+        )
+
+
         
         # 检查是否跳转到登录页
         current_url = self.page.url
@@ -487,6 +596,9 @@ class WeixinPublisher(BasePublisher):
         )
 
     async def get_works(self, cookies: str, page: int = 0, page_size: int = 20) -> WorksResult:
+
+
+        print(f"1111111111111111111")
         """获取视频号作品列表"""
         print(f"\n{'='*60}")
         print(f"[{self.platform_name}] 获取作品列表")
@@ -506,17 +618,34 @@ class WeixinPublisher(BasePublisher):
                 raise Exception("Page not initialized")
             
             # 访问视频号创作者中心
-            await self.page.goto("https://channels.weixin.qq.com/platform/post/list")
+            await self.page.goto("https://channels.weixin.qq.com/platform/post/list") 
             await asyncio.sleep(5)
-            
+            print(f"1111111111111111")
             # 检查登录状态
             current_url = self.page.url
             if "login" in current_url:
-                raise Exception("Cookie 已过期,请重新登录")
+                print(f"2111111111111111")
+                raise Exception("Cookie 已过期,请重新登录") 
             
             # 视频号使用页面爬取方式获取作品列表
-            # 等待作品列表加载
-            await self.page.wait_for_selector('div.post-feed-wrap', timeout=10000)
+            # 等待作品列表加载(增加等待时间,并添加截图调试)
+            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}")
+            
+            # 打印 DOM 结构
+            page_html = await self.page.content()
+            print(f"[{self.platform_name}] ========== 页面 DOM 开始 ==========")
+            print(page_html[:5000])  # 打印前5000个字符
+            print(f"[{self.platform_name}] ========== 页面 DOM 结束 ==========")
             
             # 获取所有作品项
             post_items = self.page.locator('div.post-feed-item')
@@ -529,53 +658,60 @@ class WeixinPublisher(BasePublisher):
                     item = post_items.nth(i)
                     
                     # 获取封面
-                    cover_el = item.locator('div.cover-wrap img').first
+                    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 ''
                     
                     # 获取标题
-                    title_el = item.locator('div.content').first
+                    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()[:50]
+                        title = title.strip()
+                    
+                    # 获取发布时间
+                    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()
                     
                     # 获取统计数据
-                    stats_el = item.locator('div.post-data')
+                    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
                     
-                    if await stats_el.count() > 0:
-                        stats_text = await stats_el.text_content() or ''
-                        # 解析统计数据(格式可能是: 播放 100 点赞 50 评论 10)
-                        import re
-                        play_match = re.search(r'播放[\s]*([\d.]+[万]?)', stats_text)
-                        like_match = re.search(r'点赞[\s]*([\d.]+[万]?)', stats_text)
-                        comment_match = re.search(r'评论[\s]*([\d.]+[万]?)', stats_text)
-                        
-                        def parse_count(match):
-                            if not match:
-                                return 0
-                            val = match.group(1)
-                            if '万' in val:
-                                return int(float(val.replace('万', '')) * 10000)
-                            return int(val)
+                    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()
                         
-                        play_count = parse_count(play_match)
-                        like_count = parse_count(like_match)
-                        comment_count = parse_count(comment_match)
+                        # 判断图标类型
+                        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)
                     
-                    # 获取发布时间
-                    time_el = item.locator('div.time')
-                    publish_time = ''
-                    if await time_el.count() > 0:
-                        publish_time = await time_el.text_content() or ''
-                        publish_time = publish_time.strip()
-                    
-                    # 生成临时 work_id(视频号可能需要从详情页获取)
-                    work_id = f"weixin_{i}_{hash(title)}"
+                    # 生成临时 work_id
+                    work_id = f"weixin_{i}_{hash(title)}_{hash(publish_time)}"
                     
                     works.append(WorkItem(
                         work_id=work_id,
@@ -587,9 +723,13 @@ class WeixinPublisher(BasePublisher):
                         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}] 解析作品 {i} 失败: {e}")
+                    import traceback
+                    traceback.print_exc()
                     continue
             
             total = len(works)
@@ -623,59 +763,114 @@ class WeixinPublisher(BasePublisher):
                 raise Exception("Page not initialized")
             
             # 访问评论管理页面
-            await self.page.goto("https://channels.weixin.qq.com/platform/comment/index")
-            await asyncio.sleep(5)
+            await self.page.goto("https://channels.weixin.qq.com/platform/interaction/comment")
+            await asyncio.sleep(3)
             
             # 检查登录状态
             current_url = self.page.url
             if "login" in current_url:
                 raise Exception("Cookie 已过期,请重新登录")
             
-            # 等待评论列表加载
+            # 等待左侧作品列表加载
             try:
-                await self.page.wait_for_selector('div.comment-list', timeout=10000)
+                await self.page.wait_for_selector('div.comment-feed-wrap', timeout=15000)
             except:
-                print(f"[{self.platform_name}] 未找到评论列表")
+                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)
+                else:
+                    return CommentsResult(success=True, platform=self.platform_name, work_id=work_id, comments=[], total=0, has_more=False)
+            
+            # 等待右侧评论详情加载
+            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)
+            
+            # 获取评论总数
+            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))
+            
+            print(f"[{self.platform_name}] 评论总数: {total}")
+            
+            # 获取右侧评论列表
             comment_items = self.page.locator('div.comment-item')
             item_count = await comment_items.count()
             
-            print(f"[{self.platform_name}] 找到 {item_count} 个评论项")
+            print(f"[{self.platform_name}] 当前加载 {item_count} 条评论")
             
             for i in range(item_count):
                 try:
                     item = comment_items.nth(i)
                     
-                    # 获取作者信息
+                    # 获取作者昵称(加 .first 防 strict mode)
                     author_name = ''
-                    author_avatar = ''
-                    name_el = item.locator('div.nick-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()
                     
-                    avatar_el = item.locator('img.avatar')
+                    # 获取头像
+                    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('div.comment-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('div.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}_{hash(content)}"
+                    comment_id = f"weixin_comment_{i}_{abs(hash(content))}"
                     
                     comments.append(CommentItem(
                         comment_id=comment_id,
@@ -688,12 +883,14 @@ class WeixinPublisher(BasePublisher):
                         reply_count=0,
                         create_time=create_time,
                     ))
+                    
+                    print(f"[{self.platform_name}] 评论 {i+1}: {author_name} - {content[:20]}...")
+                    
                 except Exception as e:
                     print(f"[{self.platform_name}] 解析评论 {i} 失败: {e}")
                     continue
             
-            total = len(comments)
-            print(f"[{self.platform_name}] 获取到 {total} 条评论")
+            print(f"[{self.platform_name}] 成功获取 {len(comments)} 条评论")
             
         except Exception as e:
             import traceback
@@ -701,3 +898,320 @@ class WeixinPublisher(BasePublisher):
             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)
+    
+    async def auto_reply_private_messages(self, cookies: str) -> dict:
+        """自动回复私信 - 集成自 pw3.py"""
+        print(f"\n{'='*60}")
+        print(f"[{self.platform_name}] 开始自动回复私信")
+        print(f"{'='*60}")
+        
+        try:
+            await self.init_browser()
+            cookie_list = self.parse_cookies(cookies)
+            await self.set_cookies(cookie_list)
+            
+            if not self.page:
+                raise Exception("Page not initialized")
+            
+            # 访问私信页面
+            await self.page.goto("https://channels.weixin.qq.com/platform/private_msg", timeout=30000)
+            await asyncio.sleep(3)
+            
+            # 检查登录状态
+            current_url = self.page.url
+            print(f"[{self.platform_name}] 当前 URL: {current_url}")
+            
+            if "login" in current_url:
+                raise Exception("Cookie 已过期,请重新登录")
+            
+            # 等待私信页面加载(使用多个选择器容错)
+            try:
+                await self.page.wait_for_selector('.private-msg-list-header', timeout=15000)
+            except:
+                # 尝试其他选择器
+                try:
+                    await self.page.wait_for_selector('.weui-desktop-tab__navs__inner', timeout=10000)
+                    print(f"[{self.platform_name}] 使用备用选择器加载成功")
+                except:
+                    # 截图调试
+                    screenshot_path = f"weixin_private_msg_{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}")
+            
+            print(f"[{self.platform_name}] 私信页面加载完成")
+            
+            # 处理两个 tab
+            total_replied = 0
+            for tab_name in ["打招呼消息", "私信"]:
+                replied_count = await self._process_tab_sessions(tab_name)
+                total_replied += replied_count
+            
+            print(f"[{self.platform_name}] 自动回复完成,共回复 {total_replied} 条消息")
+            
+            return {
+                'success': True,
+                'platform': self.platform_name,
+                'replied_count': total_replied,
+                'message': f'成功回复 {total_replied} 条私信'
+            }
+            
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            return {
+                'success': False,
+                'platform': self.platform_name,
+                'error': str(e)
+            }
+    
+    async def _process_tab_sessions(self, tab_name: str) -> int:
+        """处理指定 tab 下的所有会话"""
+        print(f"\n🔄 正在处理「{tab_name}」中的所有会话...")
+        
+        if not self.page:
+            return 0
+        
+        replied_count = 0
+        
+        try:
+            # 点击 tab
+            if tab_name == "私信":
+                tab_link = self.page.locator('.weui-desktop-tab__navs__inner li').first.locator('a')
+            elif tab_name == "打招呼消息":
+                tab_link = self.page.locator('.weui-desktop-tab__navs__inner li').nth(1).locator('a')
+            else:
+                return 0
+            
+            if await tab_link.is_visible():
+                await tab_link.click()
+                print(f"  ➤ 已点击「{tab_name}」tab")
+            else:
+                print(f"  ❌ 「{tab_name}」tab 不可见")
+                return 0
+            
+            # 等待会话列表加载
+            try:
+                await self.page.wait_for_function("""
+                    () => {
+                        const hasSession = document.querySelectorAll('.session-wrap').length > 0;
+                        const hasEmpty = !!document.querySelector('.empty-text');
+                        return hasSession || hasEmpty;
+                    }
+                """, timeout=8000)
+                print("  ✅ 会话列表区域已加载")
+            except:
+                print("  ⚠️ 等待会话列表超时,继续尝试读取...")
+            
+            # 获取会话
+            session_wraps = self.page.locator('.session-wrap')
+            session_count = await session_wraps.count()
+            print(f"  💬 共找到 {session_count} 个会话")
+            
+            if session_count == 0:
+                return 0
+            
+            # 遍历每个会话
+            for idx in range(session_count):
+                try:
+                    current_sessions = self.page.locator('.session-wrap')
+                    if idx >= await current_sessions.count():
+                        break
+                    
+                    session = current_sessions.nth(idx)
+                    user_name = await session.locator('.name').inner_text()
+                    last_preview = await session.locator('.feed-info').inner_text()
+                    print(f"\n  ➤ [{idx+1}/{session_count}] 正在处理: {user_name} | 最后消息: {last_preview}")
+                    
+                    await session.click()
+                    await asyncio.sleep(2)
+                    
+                    # 提取聊天历史
+                    history = await self._extract_chat_history()
+                    need_reply = (not history) or (not history[-1]["is_author"])
+                    
+                    if need_reply:
+                        reply_text = await self._generate_reply_with_ai(history)
+                        if reply_text=="":
+                            reply_text = self._generate_reply(history)
+                        # # 生成回复
+                        # if history and history[-1]["is_author"]:
+                        #     reply_text = await self._generate_reply_with_ai(history)
+                        # else:
+                        #     reply_text = self._generate_reply(history)
+                        
+                        if reply_text:
+                            print(f"    📝 回复内容: {reply_text}")
+                            try:
+                                textarea = self.page.locator('.edit_area').first
+                                send_btn = self.page.locator('button:has-text("发送")').first
+                                if await textarea.is_visible() and await send_btn.is_visible():
+                                    await textarea.fill(reply_text)
+                                    await asyncio.sleep(0.5)
+                                    await send_btn.click()
+                                    print("    ✅ 已发送")
+                                    replied_count += 1
+                                    await asyncio.sleep(1.5)
+                                else:
+                                    print("    ❌ 输入框或发送按钮不可见")
+                            except Exception as e:
+                                print(f"    ❌ 发送失败: {e}")
+                        else:
+                            print("    ➤ 无需回复")
+                    else:
+                        print("    ➤ 最后一条是我发的,跳过回复")
+                    
+                except Exception as e:
+                    print(f"  ❌ 处理会话 {idx+1} 时出错: {e}")
+                    continue
+        
+        except Exception as e:
+            print(f"❌ 处理「{tab_name}」失败: {e}")
+        
+        return replied_count
+    
+    async def _extract_chat_history(self) -> list:
+        """精准提取聊天记录,区分作者(自己)和用户"""
+        if not self.page:
+            return []
+        
+        history = []
+        message_wrappers = self.page.locator('.session-content-wrapper > div:not(.footer) > .text-wrapper')
+        count = await message_wrappers.count()
+        
+        for i in range(count):
+            try:
+                wrapper = message_wrappers.nth(i)
+                # 判断方向
+                is_right = await wrapper.locator('.content-right').count() > 0
+                is_left = await wrapper.locator('.content-left').count() > 0
+                
+                if not (is_left or is_right):
+                    continue
+                
+                # 提取消息文本
+                pre_el = wrapper.locator('pre.message-plain')
+                content = ''
+                if await pre_el.count() > 0:
+                    content = await pre_el.inner_text()
+                    content = content.strip()
+                
+                if not content:
+                    continue
+                
+                # 获取头像
+                avatar_img = wrapper.locator('.avatar').first
+                avatar_src = ''
+                if await avatar_img.count() > 0:
+                    avatar_src = await avatar_img.get_attribute("src") or ''
+                
+                # 右侧 = 作者(自己)
+                is_author = is_right
+                
+                # 获取用户名
+                if is_left:
+                    name_el = wrapper.locator('.profile .name')
+                    author_name = '用户'
+                    if await name_el.count() > 0:
+                        author_name = await name_el.inner_text()
+                else:
+                    author_name = "我"
+                
+                history.append({
+                    "author": author_name,
+                    "content": content,
+                    "is_author": is_author,
+                    "avatar": avatar_src
+                })
+            except Exception as e:
+                print(f"  ⚠️ 解析第 {i+1} 条消息失败: {e}")
+                continue
+        
+        return history
+    
+    async def _generate_reply_with_ai(self, chat_history: list) -> str:
+        """使用 AI 生成智能回复"""
+        import requests
+        import json
+        
+        try:
+            # 获取 AI 配置
+            ai_api_key = os.environ.get('DASHSCOPE_API_KEY', '')
+            ai_base_url = os.environ.get('DASHSCOPE_BASE_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1')
+            ai_model = os.environ.get('AI_MODEL', 'qwen-plus')
+            
+            
+            if not ai_api_key:
+                print("⚠️ 未配置 AI API Key,使用规则回复")
+                return self._generate_reply(chat_history)
+            
+            # 构建对话上下文
+            messages = [{"role": "system", "content": "你是一个友好的微信视频号创作者助手,负责回复粉丝私信。请保持简洁、友好、专业的语气。回复长度不超过20字。"}]
+            
+            for msg in chat_history:
+                role = "assistant" if msg["is_author"] else "user"
+                messages.append({
+                    "role": role,
+                    "content": msg["content"]
+                })
+            
+            # 调用 AI API
+            headers = {
+                'Authorization': f'Bearer {ai_api_key}',
+                'Content-Type': 'application/json'
+            }
+            
+            payload = {
+                "model": ai_model,
+                "messages": messages,
+                "max_tokens": 150,
+                "temperature": 0.8
+            }
+            
+            print("  🤖 正在调用 AI 生成回复...")
+            response = requests.post(
+                f"{ai_base_url}/chat/completions",
+                headers=headers,
+                json=payload,
+                timeout=30
+            )
+            
+            if response.status_code != 200:
+                print(f"  ⚠️ AI API 返回错误 {response.status_code},使用规则回复")
+                return self._generate_reply(chat_history)
+            
+            result = response.json()
+            ai_reply = result.get('choices', [{}])[0].get('message', {}).get('content', '').strip()
+            
+            if ai_reply:
+                print(f"  ✅ AI 生成回复: {ai_reply}")
+                return ai_reply
+            else:
+                print("  ⚠️ AI 返回空内容,使用规则回复")
+                return self._generate_reply(chat_history)
+                
+        except Exception as e:
+            print(f"  ⚠️ AI 回复生成失败: {e},使用规则回复")
+            return self._generate_reply(chat_history)
+    
+    def _generate_reply(self, chat_history: list) -> str:
+        """根据完整聊天历史生成回复(规则回复方式)"""
+        if not chat_history:
+            return "你好!感谢联系~"
+        
+        # 检查最后一条是否是作者发的
+        if chat_history[-1]["is_author"]:
+            return ""  # 不回复
+        
+        # 找最后一条用户消息
+        last_user_msg = chat_history[-1]["content"]
+        
+        # 简单规则回复
+        if "谢谢" in last_user_msg or "感谢" in last_user_msg:
+            return "不客气!欢迎常来交流~"
+        elif "你好" in last_user_msg or "在吗" in last_user_msg:
+            return "你好!请问有什么可以帮您的?"
+        elif "视频" in last_user_msg or "怎么拍" in last_user_msg:
+            return "视频是用手机拍摄的,注意光线和稳定哦!"
+        else:
+            return "收到!我会认真阅读您的留言~"

+ 91 - 1
server/src/scheduler/index.ts

@@ -17,7 +17,8 @@ export class TaskScheduler {
   private isRefreshingAccounts = false; // 账号刷新锁,防止任务重叠执行
   private isXhsImportRunning = false; // 小红书导入锁,防止任务重叠执行
   private isDyImportRunning = false; // 抖音导入锁,防止任务重叠执行
-  
+  private isRefreshingAccounts = false;
+  private isAutoReplying = false; // 私信回复锁,防止任务重叠执行
   /**
    * 启动调度器
    * 
@@ -38,6 +39,7 @@ export class TaskScheduler {
     // 每天早上 7:10:批量导出抖音“数据中心-账号总览-短视频-数据表现-近30天”,导入 user_day_statistics
     this.scheduleJob('dy-account-overview-import', '10 7 * * *', this.importDyAccountOverviewLast30Days.bind(this));
     
+    this.scheduleJob('auto-reply-messages', '* * * * *', this.autoReplyMessages.bind(this));
     // 注意:账号刷新由客户端定时触发,不在服务端自动执行
     // 这样可以确保只刷新当前登录用户的账号,避免处理其他用户的数据
     
@@ -48,6 +50,7 @@ export class TaskScheduler {
     logger.info('[Scheduler]   - check-publish-tasks: every minute (* * * * *)');
     logger.info('[Scheduler]   - xhs-account-overview-import: daily at 07:00 (0 7 * * *)');
     logger.info('[Scheduler]   - dy-account-overview-import:  daily at 07:10 (10 7 * * *)');
+    logger.info('[Scheduler]   - auto-reply-messages: every minute (* * * * *)');
     logger.info('[Scheduler] Note: Account refresh is triggered by client, not server');
     logger.info('[Scheduler] ========================================');
     
@@ -340,6 +343,93 @@ export class TaskScheduler {
       this.isDyImportRunning = false;
     }
   }
+  
+  /**
+   * 自动回复私信(每5分钟执行一次)
+   * 只处理微信视频号平台的账号
+   */
+  private async autoReplyMessages(): Promise<void> {
+    // 检查是否正在执行回复任务
+    if (this.isAutoReplying) {
+      logger.info('[Scheduler] Auto reply is already running, skipping this cycle...');
+      return;
+    }
+    
+    // 获取锁
+    this.isAutoReplying = true;
+    logger.debug('[Scheduler] Acquired auto reply lock');
+    
+    try {
+      const accountRepository = AppDataSource.getRepository(PlatformAccount);
+      
+      // 只获取微信视频号的活跃账号
+      const accounts = await accountRepository.find({
+        where: {
+          platform: 'weixin_video',
+          status: 'active',
+        },
+      });
+      
+      if (accounts.length === 0) {
+        logger.info('[Scheduler] No active weixin accounts for auto reply');
+        return;
+      }
+      
+      logger.info(`[Scheduler] Starting auto reply for ${accounts.length} weixin accounts...`);
+      
+      let successCount = 0;
+      let failCount = 0;
+      
+      // 为每个账号执行自动回复
+      for (const account of accounts) {
+        try {
+          logger.info(`[Scheduler] Auto replying for account: ${account.accountName} (${account.id})`);
+          
+          // 调用 Python 服务执行自动回复
+          const response = await fetch('http://localhost:5005/auto-reply', {
+            method: 'POST',
+            headers: {
+              'Content-Type': 'application/json',
+            },
+            body: JSON.stringify({
+              platform: 'weixin',
+              cookie: account.cookieData || '',
+            }),
+            signal: AbortSignal.timeout(120000), // 2分钟超时
+          });
+          
+          if (!response.ok) {
+            throw new Error(`HTTP ${response.status}`);
+          }
+          
+          const result = await response.json();
+          
+          if (result.success) {
+            successCount++;
+            logger.info(`[Scheduler] Auto reply success for ${account.accountName}: ${result.replied_count} messages`);
+          } else {
+            failCount++;
+            logger.error(`[Scheduler] Auto reply failed for ${account.accountName}: ${result.error}`);
+          }
+          
+        } catch (error) {
+          failCount++;
+          logger.error(`[Scheduler] Auto reply error for account ${account.id}:`, error);
+        }
+      }
+      
+      logger.info(`[Scheduler] Auto reply completed: ${successCount} success, ${failCount} failed`);
+    } finally {
+      // 释放锁
+      this.isAutoReplying = false;
+      logger.debug('[Scheduler] Released auto reply lock');
+    }
+  }
+  
+  
+  
+  
+  
 }
 
 export const taskScheduler = new TaskScheduler();