Forráskód Böngészése

抖音私信回复

swortect 14 órája
szülő
commit
a7b96dc0e0
2 módosított fájl, 335 hozzáadás és 4 törlés
  1. 327 0
      server/python/platforms/douyin.py
  2. 8 4
      server/src/scheduler/index.ts

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

@@ -1117,3 +1117,330 @@ class DouyinPublisher(BasePublisher):
             'work_comments': all_work_comments,
             'total': len(all_work_comments)
         }
+
+
+
+    async def auto_reply_private_messages(self, cookies: str) -> dict:
+        """自动回复抖音私信 - 适配新页面结构"""
+        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://creator.douyin.com/creator-micro/data/following/chat", timeout=30000)
+            await asyncio.sleep(3)
+
+            # 检查登录状态
+            current_url = self.page.url
+            print(f"[{self.platform_name}] 当前 URL: {current_url}")
+            if "login" in current_url or "passport" in current_url:
+                raise Exception("Cookie 已过期,请重新登录")
+
+            replied_count = 0
+                        
+            # 处理两个tab: 陌生人私信 和 朋友私信
+            for tab_name in ["陌生人私信", "朋友私信"]:
+                print(f"\n{'='*50}")
+                print(f"[{self.platform_name}] 处理 {tab_name} ...")
+                print(f"{'='*50}")
+                            
+                # 点击对应tab
+                tab_locator = self.page.locator(f'div.semi-tabs-tab:text-is("{tab_name}")')
+                if await tab_locator.count() > 0:
+                    await tab_locator.click()
+                    await asyncio.sleep(2)
+                else:
+                    print(f"⚠️ 未找到 {tab_name} 标签,跳过")
+                    continue
+            
+                # 获取私信列表
+                session_items = self.page.locator('.semi-list-item')
+                session_count = await session_items.count()
+                print(f"[{self.platform_name}] {tab_name} 共找到 {session_count} 条会话")
+            
+                if session_count == 0:
+                    print(f"[{self.platform_name}] {tab_name} 无新私信")
+                    continue
+            
+                for idx in range(session_count):
+                    try:
+                        # 重新获取列表(防止 DOM 变化)
+                        current_sessions = self.page.locator('.semi-list-item')
+                        if idx >= await current_sessions.count():
+                            break
+            
+                        session = current_sessions.nth(idx)
+                        user_name = await session.locator('.item-header-name-vL_79m').inner_text()
+                        last_msg = await session.locator('.text-whxV9A').inner_text()
+                        print(f"\n ➤ [{idx+1}/{session_count}] 处理用户: {user_name} | 最后消息: {last_msg[:30]}...")
+            
+                        # 检查会话预览消息是否包含非文字内容
+                        if "分享" in last_msg and ("视频" in last_msg or "图片" in last_msg or "链接" in last_msg):
+                            print(" ➤ 会话预览为非文字消息,跳过")
+                            continue
+                        
+                        # 点击进入聊天
+                        await session.click()
+                        await asyncio.sleep(2)
+                        
+                        # 提取聊天历史(判断最后一条是否是自己发的)
+                        chat_messages = self.page.locator('.box-item-dSA1TJ:not(.time-Za5gKL)')
+                        msg_count = await chat_messages.count()
+                        should_reply = True
+                                                
+                        if msg_count > 0:
+                            # 最后一条消息
+                            last_msg_el = chat_messages.nth(msg_count - 1)
+                            # 获取元素的 class 属性判断是否是自己发的
+                            classes = await last_msg_el.get_attribute('class') or ''
+                            is_my_message = 'is-me-' in classes  # 包含 is-me- 表示是自己发的
+                            should_reply = not is_my_message  # 如果是自己发的就不回复
+                        
+                        if should_reply:
+                            # 提取完整聊天历史
+                            chat_history = await self._extract_chat_history()
+                                                    
+                            if chat_history:
+                                # 生成回复
+                                reply_text = await self._generate_reply_with_ai(chat_history)
+                                if not reply_text:
+                                    reply_text = self._generate_reply(chat_history)
+                        
+                                if reply_text:
+                                    print(f" 📝 回复内容: {reply_text}")
+                        
+                                    # 填充输入框
+                                    input_box = self.page.locator('div.chat-input-dccKiL[contenteditable="true"]')
+                                    send_btn = self.page.locator('button:has-text("发送")')
+                        
+                                    if await input_box.is_visible() and await send_btn.is_visible():
+                                        await input_box.fill(reply_text)
+                                        await asyncio.sleep(0.5)
+                                        await send_btn.click()
+                                        print(" ✅ 已发送")
+                                        replied_count += 1
+                                        await asyncio.sleep(2)
+                                    else:
+                                        print(" ❌ 输入框或发送按钮不可见")
+                                else:
+                                    print(" ➤ 无需回复")
+                            else:
+                                print(" ➤ 聊天历史为空,跳过")
+                        else:
+                            print(" ➤ 最后一条是我发的,跳过")
+            
+                    except Exception as e:
+                        print(f" ❌ 处理会话 {idx+1} 时出错: {e}")
+                        continue
+            
+            print(f"[{self.platform_name}] 自动回复完成,共回复 {replied_count} 条消息")
+            return {
+                'success': True,
+                'platform': self.platform_name,
+                'replied_count': replied_count,
+                'message': f'成功回复 {replied_count} 条私信'
+            }
+
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            return {
+                'success': False,
+                'platform': self.platform_name,
+                'error': str(e)
+            }
+        finally:
+            await self.close_browser()
+
+
+    # 辅助方法保持兼容(可复用)
+    def _generate_reply(self, chat_history: list) -> str:
+        """规则回复"""
+        if not chat_history:
+            return "你好!感谢联系~"
+        last_msg = chat_history[-1]["content"]
+        if "谢谢" in last_msg or "感谢" in last_msg:
+            return "不客气!欢迎常来交流~"
+        elif "你好" in last_msg or "在吗" in last_msg:
+            return "你好!请问有什么可以帮您的?"
+        elif "视频" in last_msg or "怎么拍" in last_msg:
+            return "视频是用手机拍摄的,注意光线和稳定哦!"
+        else:
+            return "收到!我会认真阅读您的留言~"
+
+    async def _extract_chat_history(self) -> list:
+        """精准提取聊天记录,区分作者(自己)和用户"""
+        if not self.page:
+            return []
+        
+        history = []
+        # 获取所有聊天消息(排除时间戳元素)
+        message_wrappers = self.page.locator('.box-item-dSA1TJ:not(.time-Za5gKL)')
+        count = await message_wrappers.count()
+        
+        for i in range(count):
+            try:
+                wrapper = message_wrappers.nth(i)
+                # 检查是否为自己发送的消息
+                classes = await wrapper.get_attribute('class') or ''
+                is_author = 'is-me-' in classes  # 包含 is-me- 表示是自己发的
+                
+                # 获取消息文本内容
+                text_element = wrapper.locator('.text-X2d7fS')
+                if await text_element.count() > 0:
+                    content = await text_element.inner_text()
+                    content = content.strip()
+                    
+                    if content:  # 只添加非空消息
+                        # 获取用户名(如果是对方消息)
+                        author_name = ''
+                        if not is_author:
+                            # 尝试获取对方用户名
+                            name_elements = wrapper.locator('.aweme-author-name-m8uoXU')
+                            if await name_elements.count() > 0:
+                                author_name = await name_elements.nth(0).inner_text()
+                            else:
+                                author_name = '用户'
+                        else:
+                            author_name = '我'
+                        
+                        history.append({
+                            "author": author_name,
+                            "content": content,
+                            "is_author": is_author,
+                        })
+            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 os, requests, json
+        try:
+            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:
+                return self._generate_reply(chat_history)
+
+            messages = [{"role": "system", "content": "你是一个友好的抖音创作者助手,负责回复粉丝私信。请保持简洁、友好、专业的语气。回复长度不超过20字。"}]
+            for msg in chat_history:
+                role = "assistant" if msg.get("is_author", False) else "user"
+                messages.append({"role": role, "content": msg["content"]})
+
+            headers = {'Authorization': f'Bearer {ai_api_key}', 'Content-Type': 'application/json'}
+            payload = {"model": ai_model, "messages": messages, "max_tokens": 150, "temperature": 0.8}
+            response = requests.post(f"{ai_base_url}/chat/completions", headers=headers, json=payload, timeout=30)
+            if response.status_code == 200:
+                ai_reply = response.json().get('choices', [{}])[0].get('message', {}).get('content', '').strip()
+                return ai_reply if ai_reply else self._generate_reply(chat_history)
+            else:
+                return self._generate_reply(chat_history)
+        except:
+            return self._generate_reply(chat_history)
+
+
+    async def get_work_comments_mapping(self, cookies: str) -> dict:
+        """获取所有作品及其评论的对应关系
+        
+        Args:
+            cookies: 抖音创作者平台的cookies
+            
+        Returns:
+            dict: 包含作品和评论对应关系的JSON数据
+        """
+        print(f"\n{'='*60}")
+        print(f"[{self.platform_name}] 获取作品和评论对应关系")
+        print(f"{'='*60}")
+        
+        work_comments_mapping = []
+        
+        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://creator.douyin.com/creator-micro/home", timeout=30000)
+            await asyncio.sleep(3)
+            
+            # 检查登录状态
+            current_url = self.page.url
+            if "login" in current_url or "passport" in current_url:
+                raise Exception("Cookie 已过期,请重新登录")
+            
+            # 访问内容管理页面获取作品列表
+            print(f"[{self.platform_name}] 访问内容管理页面...")
+            await self.page.goto("https://creator.douyin.com/creator-micro/content/manage", timeout=30000)
+            await asyncio.sleep(5)
+            
+            # 获取作品列表
+            works_result = await self.get_works(cookies, page=0, page_size=20)
+            if not works_result.success:
+                print(f"[{self.platform_name}] 获取作品列表失败: {works_result.error}")
+                return {
+                    'success': False,
+                    'platform': self.platform_name,
+                    'error': works_result.error,
+                    'work_comments': []
+                }
+            
+            print(f"[{self.platform_name}] 获取到 {len(works_result.works)} 个作品")
+            
+            # 对每个作品获取评论
+            for i, work in enumerate(works_result.works):
+                print(f"[{self.platform_name}] 正在获取作品 {i+1}/{len(works_result.works)} 的评论: {work.title[:20]}...")
+                
+                # 获取单个作品的评论
+                comments_result = await self.get_comments(cookies, work.work_id)
+                if comments_result.success:
+                    work_comments_mapping.append({
+                        'work_info': work.to_dict(),
+                        'comments': [comment.to_dict() for comment in comments_result.comments]
+                    })
+                    print(f"[{self.platform_name}] 作品 '{work.title[:20]}...' 获取到 {len(comments_result.comments)} 条评论")
+                else:
+                    print(f"[{self.platform_name}] 获取作品 '{work.title[:20]}...' 评论失败: {comments_result.error}")
+                    work_comments_mapping.append({
+                        'work_info': work.to_dict(),
+                        'comments': [],
+                        'error': comments_result.error
+                    })
+                
+                # 添加延时避免请求过于频繁
+                await asyncio.sleep(2)
+            
+            print(f"[{self.platform_name}] 所有作品评论获取完成")
+            
+        except Exception as e:
+            import traceback
+            traceback.print_exc()
+            return {
+                'success': False,
+                'platform': self.platform_name,
+                'error': str(e),
+                'work_comments': []
+            }
+        finally:
+            await self.close_browser()
+        
+        return {
+            'success': True,
+            'platform': self.platform_name,
+            'work_comments': work_comments_mapping,
+            'summary': {
+                'total_works': len(work_comments_mapping),
+                'total_comments': sum(len(item['comments']) for item in work_comments_mapping),
+            }
+        }

+ 8 - 4
server/src/scheduler/index.ts

@@ -374,17 +374,18 @@ export class TaskScheduler {
       // 只获取微信视频号的活跃账号
       const accounts = await accountRepository.find({
         where: {
-          platform: 'weixin_video',
+          // platform: 'weixin_video',
+          userId: 2,
           status: 'active',
         },
       });
       
       if (accounts.length === 0) {
-        logger.info('[Scheduler] No active weixin accounts for auto reply');
+        logger.info('[Scheduler] No active accounts for auto reply');
         return;
       }
       
-      logger.info(`[Scheduler] Starting auto reply for ${accounts.length} weixin accounts...`);
+      logger.info(`[Scheduler] Starting auto reply for ${accounts.length} accounts...`);
       
       let successCount = 0;
       let failCount = 0;
@@ -394,6 +395,9 @@ export class TaskScheduler {
         try {
           logger.info(`[Scheduler] Auto replying for account: ${account.accountName} (${account.id})`);
           
+          // Python 服务端使用 weixin,不是 weixin_video
+          const pythonPlatform = account.platform === 'weixin_video' ? 'weixin' : account.platform;
+          
           // 调用 Python 服务执行自动回复
           const response = await fetch('http://localhost:5005/auto-reply', {
             method: 'POST',
@@ -401,7 +405,7 @@ export class TaskScheduler {
               'Content-Type': 'application/json',
             },
             body: JSON.stringify({
-              platform: 'weixin',
+              platform: pythonPlatform,
               cookie: account.cookieData || '',
             }),
             signal: AbortSignal.timeout(120000), // 2分钟超时