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

feat(platforms): 支持有头浏览器手动登录并同步Cookie到Node服务

- 为抖音和小红书平台添加有头浏览器手动登录等待逻辑
- 新增Python到Node服务的Cookie同步机制
- 添加AI辅助选择器功能用于识别发布按钮
- 实现内部API端点用于更新账号Cookie数据
- 在有头模式下允许用户手动完成登录和验证码验证
Ethanfly 15 часов назад
Родитель
Сommit
ec70216f62

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

@@ -452,6 +452,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 分析截图检测验证码

+ 58 - 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()

+ 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;