Просмотр исходного кода

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

Ethanfly 14 часов назад
Родитель
Сommit
9c00df7de0

BIN
server/python/platforms/__pycache__/base.cpython-313.pyc


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


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


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

@@ -454,6 +454,141 @@ class BasePublisher(ABC):
                 "notes": f"AI 分析异常: {e}",
             }
 
+    async def sync_cookies_to_node(self, cookies: list) -> bool:
+        import os
+        import json
+        import requests
+
+        if not self.user_id or not self.publish_account_id:
+            return False
+
+    async def ai_suggest_playwright_selector(self, goal: str, screenshot_base64: str = None) -> dict:
+        import os
+        import requests
+        import json
+        import re
+
+        try:
+            if not screenshot_base64:
+                screenshot_base64 = await self.capture_screenshot()
+
+            if not screenshot_base64:
+                return {"has_selector": False, "selector": "", "confidence": 0, "notes": "无法获取截图"}
+
+            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_vision_model = os.environ.get('AI_VISION_MODEL', 'qwen-vl-plus')
+
+            if not ai_api_key:
+                return {"has_selector": False, "selector": "", "confidence": 0, "notes": "未配置 AI API Key"}
+
+            prompt = f"""请分析这张网页截图,给出一个 Playwright Python 可用的 selector(用于 page.locator(selector))来完成目标操作。
+
+目标:{goal}
+
+要求:
+1) selector 尽量稳定(优先 role/text/aria,其次 class,避免过度依赖随机 class)
+2) selector 必须是 Playwright 支持的选择器语法(如:text="发布"、button:has-text("发布")、[role="button"]:has-text("发布") 等)
+3) 只返回一个最优 selector
+
+以 JSON 返回:
+```json
+{{
+  "has_selector": true,
+  "selector": "button:has-text(\\"发布\\")",
+  "confidence": 0-100,
+  "notes": "你依据的页面证据"
+}}
+```"""
+
+            headers = {
+                'Authorization': f'Bearer {ai_api_key}',
+                'Content-Type': 'application/json'
+            }
+
+            payload = {
+                "model": ai_vision_model,
+                "messages": [
+                    {
+                        "role": "user",
+                        "content": [
+                            {
+                                "type": "image_url",
+                                "image_url": {
+                                    "url": f"data:image/jpeg;base64,{screenshot_base64}"
+                                }
+                            },
+                            {
+                                "type": "text",
+                                "text": prompt
+                            }
+                        ]
+                    }
+                ],
+                "max_tokens": 300
+            }
+
+            response = requests.post(
+                f"{ai_base_url}/chat/completions",
+                headers=headers,
+                json=payload,
+                timeout=30
+            )
+            if response.status_code != 200:
+                return {"has_selector": False, "selector": "", "confidence": 0, "notes": f"AI API 错误 {response.status_code}"}
+
+            result = response.json()
+            ai_response = result.get('choices', [{}])[0].get('message', {}).get('content', '')
+
+            json_match = re.search(r'```json\\s*([\\s\\S]*?)\\s*```', ai_response)
+            if json_match:
+                json_str = json_match.group(1)
+            else:
+                json_match = re.search(r'\\{[\\s\\S]*\\}', ai_response)
+                json_str = json_match.group(0) if json_match else '{}'
+
+            try:
+                data = json.loads(json_str)
+            except Exception:
+                data = {}
+
+            selector = str(data.get("selector", "") or "").strip()
+            has_selector = bool(data.get("has_selector", False)) and bool(selector)
+            confidence = int(data.get("confidence", 0) or 0)
+            notes = str(data.get("notes", "") or "")
+
+            if not has_selector:
+                return {"has_selector": False, "selector": "", "confidence": confidence, "notes": notes or "未给出 selector"}
+
+            return {"has_selector": True, "selector": selector, "confidence": confidence, "notes": notes}
+        except Exception as e:
+            return {"has_selector": False, "selector": "", "confidence": 0, "notes": f"AI selector 异常: {e}"}
+
+        node_api_url = os.environ.get('NODEJS_API_URL', 'http://localhost:3000').rstrip('/')
+        internal_api_key = os.environ.get('INTERNAL_API_KEY', 'internal-api-key-default')
+
+        try:
+            payload = {
+                "user_id": int(self.user_id),
+                "account_id": int(self.publish_account_id),
+                "cookies": json.dumps(cookies, ensure_ascii=False),
+            }
+            resp = requests.post(
+                f"{node_api_url}/api/internal/accounts/update-cookies",
+                headers={
+                    "Content-Type": "application/json",
+                    "X-Internal-API-Key": internal_api_key,
+                },
+                json=payload,
+                timeout=30,
+            )
+            if resp.status_code >= 400:
+                return False
+            data = resp.json() if resp.content else {}
+            return bool(data.get("success", True))
+        except Exception:
+            return False
+
     async def ai_check_captcha(self, screenshot_base64: str = None) -> dict:
         """
         使用 AI 分析截图检测验证码

+ 385 - 11
server/python/platforms/douyin.py

@@ -335,20 +335,67 @@ class DouyinPublisher(BasePublisher):
         # 检查当前 URL 和页面状态
         current_url = self.page.url
         print(f"[{self.platform_name}] 当前 URL: {current_url}")
+
+        async def wait_for_manual_login(timeout_seconds: int = 300) -> bool:
+            if not self.page:
+                return False
+            self.report_progress(8, "检测到需要登录,请在浏览器窗口完成登录...")
+            try:
+                await self.page.bring_to_front()
+            except:
+                pass
+            waited = 0
+            while waited < timeout_seconds:
+                try:
+                    url = self.page.url
+                    if "login" not in url and "passport" not in url:
+                        if "creator.douyin.com" in url:
+                            return True
+                    await asyncio.sleep(2)
+                    waited += 2
+                except:
+                    await asyncio.sleep(2)
+                    waited += 2
+            return False
         
         # 检查是否在登录页面或需要登录
         if "login" in current_url or "passport" in current_url:
-            screenshot_base64 = await self.capture_screenshot()
-            return PublishResult(
-                success=False,
-                platform=self.platform_name,
-                error="Cookie 已过期,需要重新登录",
-                need_captcha=True,
-                captcha_type='login',
-                screenshot_base64=screenshot_base64,
-                page_url=current_url,
-                status='need_captcha'
-            )
+            if not self.headless:
+                logged_in = await wait_for_manual_login()
+                if logged_in:
+                    try:
+                        if self.context:
+                            cookies_after = await self.context.cookies()
+                            await self.sync_cookies_to_node(cookies_after)
+                    except:
+                        pass
+                    await self.page.goto("https://creator.douyin.com/creator-micro/content/upload")
+                    await asyncio.sleep(3)
+                    current_url = self.page.url
+                else:
+                    screenshot_base64 = await self.capture_screenshot()
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error="需要登录:请在浏览器窗口完成登录后重试",
+                        need_captcha=True,
+                        captcha_type='login',
+                        screenshot_base64=screenshot_base64,
+                        page_url=current_url,
+                        status='need_captcha'
+                    )
+            else:
+                screenshot_base64 = await self.capture_screenshot()
+                return PublishResult(
+                    success=False,
+                    platform=self.platform_name,
+                    error="Cookie 已过期,需要重新登录",
+                    need_captcha=True,
+                    captcha_type='login',
+                    screenshot_base64=screenshot_base64,
+                    page_url=current_url,
+                    status='need_captcha'
+                )
         
         # 使用 AI 检测验证码
         ai_captcha_result = await self.ai_check_captcha()
@@ -1070,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),
+            }
+        }

+ 120 - 22
server/python/platforms/xiaohongshu.py

@@ -276,36 +276,117 @@ class XiaohongshuPublisher(BasePublisher):
         
         current_url = self.page.url
         print(f"[{self.platform_name}] 当前 URL: {current_url}")
+
+        async def wait_for_manual_login(timeout_seconds: int = 300) -> bool:
+            if not self.page:
+                return False
+            self.report_progress(12, "检测到需要登录,请在浏览器窗口完成登录...")
+            try:
+                await self.page.bring_to_front()
+            except:
+                pass
+            waited = 0
+            while waited < timeout_seconds:
+                try:
+                    url = self.page.url
+                    if "login" not in url and "passport" not in url and "creator.xiaohongshu.com" in url:
+                        return True
+                    await asyncio.sleep(2)
+                    waited += 2
+                except:
+                    await asyncio.sleep(2)
+                    waited += 2
+            return False
+
+        async def wait_for_manual_captcha(timeout_seconds: int = 180) -> bool:
+            waited = 0
+            while waited < timeout_seconds:
+                try:
+                    ai_captcha = await self.ai_check_captcha()
+                    if not ai_captcha.get("has_captcha"):
+                        return True
+                except:
+                    pass
+                await asyncio.sleep(3)
+                waited += 3
+            return False
         
         # 检查登录状态
         if "login" in current_url or "passport" in current_url:
-            screenshot_base64 = await self.capture_screenshot()
-            return PublishResult(
-                success=False,
-                platform=self.platform_name,
-                error="登录已过期,请重新登录",
-                screenshot_base64=screenshot_base64,
-                page_url=current_url,
-                status='need_captcha',
-                need_captcha=True,
-                captcha_type='login'
-            )
+            if not self.headless:
+                logged_in = await wait_for_manual_login()
+                if logged_in:
+                    try:
+                        if self.context:
+                            cookies_after = await self.context.cookies()
+                            await self.sync_cookies_to_node(cookies_after)
+                    except:
+                        pass
+                    await self.page.goto(publish_url)
+                    await asyncio.sleep(3)
+                    current_url = self.page.url
+                else:
+                    screenshot_base64 = await self.capture_screenshot()
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error="需要登录:请在浏览器窗口完成登录后重试",
+                        screenshot_base64=screenshot_base64,
+                        page_url=current_url,
+                        status='need_captcha',
+                        need_captcha=True,
+                        captcha_type='login'
+                    )
+            else:
+                screenshot_base64 = await self.capture_screenshot()
+                return PublishResult(
+                    success=False,
+                    platform=self.platform_name,
+                    error="登录已过期,请重新登录",
+                    screenshot_base64=screenshot_base64,
+                    page_url=current_url,
+                    status='need_captcha',
+                    need_captcha=True,
+                    captcha_type='login'
+                )
         
         # 使用 AI 检查验证码
         ai_captcha = await self.ai_check_captcha()
         if ai_captcha['has_captcha']:
             print(f"[{self.platform_name}] AI检测到验证码: {ai_captcha['captcha_type']}", flush=True)
-            screenshot_base64 = await self.capture_screenshot()
-            return PublishResult(
-                success=False,
-                platform=self.platform_name,
-                error=f"检测到{ai_captcha['captcha_type']}验证码,需要使用有头浏览器完成验证",
-                screenshot_base64=screenshot_base64,
-                page_url=current_url,
-                status='need_captcha',
-                need_captcha=True,
-                captcha_type=ai_captcha['captcha_type']
-            )
+            if not self.headless:
+                solved = await wait_for_manual_captcha()
+                if solved:
+                    try:
+                        if self.context:
+                            cookies_after = await self.context.cookies()
+                            await self.sync_cookies_to_node(cookies_after)
+                    except:
+                        pass
+                else:
+                    screenshot_base64 = await self.capture_screenshot()
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error=f"需要验证码:请在浏览器窗口完成验证后重试",
+                        screenshot_base64=screenshot_base64,
+                        page_url=current_url,
+                        status='need_captcha',
+                        need_captcha=True,
+                        captcha_type=ai_captcha['captcha_type']
+                    )
+            else:
+                screenshot_base64 = await self.capture_screenshot()
+                return PublishResult(
+                    success=False,
+                    platform=self.platform_name,
+                    error=f"检测到{ai_captcha['captcha_type']}验证码,需要使用有头浏览器完成验证",
+                    screenshot_base64=screenshot_base64,
+                    page_url=current_url,
+                    status='need_captcha',
+                    need_captcha=True,
+                    captcha_type=ai_captcha['captcha_type']
+                )
         
         self.report_progress(20, "正在上传视频...")
         
@@ -495,6 +576,23 @@ class XiaohongshuPublisher(BasePublisher):
                 print(f"[{self.platform_name}] 选择器 {selector} 错误: {e}")
         
         if not publish_clicked:
+            try:
+                suggest = await self.ai_suggest_playwright_selector("点击小红书发布按钮")
+                if suggest.get("has_selector") and suggest.get("selector"):
+                    sel = suggest.get("selector")
+                    btn = self.page.locator(sel).first
+                    if await btn.count() > 0 and await btn.is_visible() and await btn.is_enabled():
+                        try:
+                            await btn.click()
+                        except:
+                            box = await btn.bounding_box()
+                            if box:
+                                await self.page.mouse.click(box['x'] + box['width']/2, box['y'] + box['height']/2)
+                        publish_clicked = True
+            except Exception as e:
+                print(f"[{self.platform_name}] AI 点击发布按钮失败: {e}", flush=True)
+
+        if not publish_clicked:
             # 保存截图用于调试
             screenshot_path = f"debug_publish_failed_{self.platform_name}.png"
             await self.page.screenshot(path=screenshot_path, full_page=True)

+ 3 - 0
server/src/automation/platforms/xiaohongshu.ts

@@ -508,6 +508,9 @@ export class XiaohongshuAdapter extends BasePlatformAdapter {
         },
         body: JSON.stringify({
           ...requestBody,
+          user_id: (params.extra as any)?.userId,
+          publish_task_id: (params.extra as any)?.publishTaskId,
+          publish_account_id: (params.extra as any)?.publishAccountId,
           return_screenshot: true,
         }),
         signal: AbortSignal.timeout(300000), // 5分钟超时

+ 41 - 2
server/src/routes/internal.ts

@@ -11,6 +11,8 @@ import { validateRequest } from '../middleware/validate.js';
 import { config } from '../config/index.js';
 import { wsManager } from '../websocket/index.js';
 import { WS_EVENTS } from '@media-manager/shared';
+import { AppDataSource, PlatformAccount } from '../models/index.js';
+import { CookieManager } from '../automation/cookie.js';
 
 const router = Router();
 const workDayStatisticsService = new WorkDayStatisticsService();
@@ -19,14 +21,14 @@ const workDayStatisticsService = new WorkDayStatisticsService();
 const validateInternalApiKey = (req: Request, res: Response, next: NextFunction) => {
   const apiKey = req.headers['x-internal-api-key'] as string;
   const expectedKey = config.internalApiKey || 'internal-api-key-default';
-  
+
   if (!apiKey || apiKey !== expectedKey) {
     return res.status(401).json({
       success: false,
       error: 'Invalid internal API key',
     });
   }
-  
+
   next();
 };
 
@@ -225,4 +227,41 @@ router.post(
   })
 );
 
+/**
+ * POST /api/internal/accounts/update-cookies
+ * Python 发布服务回写账号 Cookie(例如:有头浏览器手动登录后)
+ */
+router.post(
+  '/accounts/update-cookies',
+  [
+    body('user_id').isInt().withMessage('user_id 必须是整数'),
+    body('account_id').isInt().withMessage('account_id 必须是整数'),
+    body('cookies').notEmpty().withMessage('cookies 不能为空'),
+    validateRequest,
+  ],
+  asyncHandler(async (req, res) => {
+    const userId = Number(req.body.user_id);
+    const accountId = Number(req.body.account_id);
+    const cookies = String(req.body.cookies || '');
+
+    const accountRepository = AppDataSource.getRepository(PlatformAccount);
+    const account = await accountRepository.findOne({ where: { id: accountId, userId } });
+    if (!account) {
+      return res.status(404).json({ success: false, error: '账号不存在' });
+    }
+
+    const encrypted = CookieManager.encrypt(cookies);
+    await accountRepository.update(accountId, {
+      cookieData: encrypted,
+      status: 'active',
+      updatedAt: new Date(),
+    });
+
+    const updated = await accountRepository.findOne({ where: { id: accountId } });
+    wsManager.sendToUser(userId, WS_EVENTS.ACCOUNT_UPDATED, { account: updated });
+
+    res.json({ success: true });
+  })
+);
+
 export default router;

+ 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分钟超时