Ethanfly 1 неделя назад
Родитель
Сommit
3a046ea43e

+ 1 - 0
client/electron/local-services.ts

@@ -207,6 +207,7 @@ function startNodeServer(): void {
     PORT: String(NODE_PORT),
     HOST: '127.0.0.1',
     PYTHON_PUBLISH_SERVICE_URL: `http://127.0.0.1:${PYTHON_PORT}`,
+    USE_REDIS_QUEUE: 'false',
   };
 
   if (useElectronAsNode) {

+ 27 - 9
server/python/platforms/base.py

@@ -327,21 +327,14 @@ class BasePublisher(ABC):
         if has_proxy:
             print(f"[{self.platform_name}] 使用代理: {proxy.get('server')}", flush=True)
 
-        # 浏览器启动参数
+        # 浏览器启动参数 — 尽量模拟真实用户浏览器
         launch_args = {
             "headless": self.headless,
             "args": [
                 "--disable-blink-features=AutomationControlled",
-                "--disable-features=IsolateOrigins,site-per-process",
-                "--disable-site-isolation-trials",
                 "--no-sandbox",
-                "--disable-setuid-sandbox",
                 "--disable-dev-shm-usage",
-                "--disable-web-security",
-                "--disable-features=VizDisplayCompositor",
                 "--disable-infobars",
-                "--disable-extensions",
-                "--disable-gpu",
                 "--window-size=1920,1080",
             ],
         }
@@ -349,7 +342,32 @@ class BasePublisher(ABC):
         if has_proxy:
             launch_args["proxy"] = proxy
 
-        self.browser = await playwright.chromium.launch(**launch_args)
+        # 优先使用系统 Chrome(与用户手动操作一致,不容易被反爬检测)
+        # 回退到 Playwright 自带 Chromium
+        chrome_launched = False
+        for channel in ("chrome", "msedge"):
+            try:
+                self.browser = await playwright.chromium.launch(
+                    channel=channel, **launch_args
+                )
+                print(
+                    f"[{self.platform_name}] 使用系统浏览器: {channel}",
+                    flush=True,
+                )
+                chrome_launched = True
+                break
+            except Exception as e:
+                print(
+                    f"[{self.platform_name}] 系统 {channel} 不可用: {e}",
+                    flush=True,
+                )
+
+        if not chrome_launched:
+            print(
+                f"[{self.platform_name}] 回退到 Playwright Chromium(注意: 更容易被平台检测)",
+                flush=True,
+            )
+            self.browser = await playwright.chromium.launch(**launch_args)
 
         # 生成浏览器上下文参数(带反检测配置)
         if STEALTH_AVAILABLE:

+ 142 - 37
server/python/platforms/douyin.py

@@ -66,8 +66,10 @@ class DouyinPublisher(BasePublisher):
             phone_captcha_selectors = [
                 'text="请输入验证码"',
                 'text="输入手机验证码"',
+                'text="接收短信验证码"',
                 'text="获取验证码"',
                 'text="手机号验证"',
+                'text="短信验证"',
                 '[class*="captcha"][class*="phone"]',
                 '[class*="verify"][class*="phone"]',
                 '[class*="sms-code"]',
@@ -155,6 +157,15 @@ class DouyinPublisher(BasePublisher):
                     self.page.locator('button:has-text("发送验证码")'),
                     self.page.locator('[role="button"]:has-text("获取验证码")'),
                     self.page.locator('[role="button"]:has-text("发送验证码")'),
+                    # 非 button 元素(链接、span、div 等)
+                    self.page.locator('a:has-text("获取验证码")'),
+                    self.page.locator('span:has-text("获取验证码")'),
+                    self.page.locator('div:has-text("获取验证码"):not(:has(div:has-text("获取验证码")))'),
+                    self.page.locator('a:has-text("发送验证码")'),
+                    self.page.locator('span:has-text("发送验证码")'),
+                    self.page.locator('[class*="send"]:has-text("验证码")'),
+                    self.page.locator('[class*="code"]:has-text("获取")'),
+                    self.page.locator('[class*="code"]:has-text("发送")'),
                 ]
                 for c in candidates:
                     try:
@@ -194,13 +205,30 @@ class DouyinPublisher(BasePublisher):
             btn = await _get_send_button()
             if btn:
                 try:
-                    if await btn.is_enabled():
-                        await btn.click(timeout=5000)
-                        did_click_send = True
-                        print(f"[{self.platform_name}] 已点击发送短信验证码", flush=True)
+                    await btn.click(timeout=5000)
+                    did_click_send = True
+                    print(f"[{self.platform_name}] 已点击发送短信验证码", flush=True)
                 except Exception as e:
                     print(f"[{self.platform_name}] 点击发送验证码按钮失败: {e}", flush=True)
 
+            # 常规选择器找不到时,用 AI 识别并点击
+            if not did_click_send:
+                print(f"[{self.platform_name}] 常规选择器未命中,使用 AI 查找发送验证码按钮...", flush=True)
+                try:
+                    suggest = await self.ai_suggest_playwright_selector(
+                        "点击弹窗中的'获取验证码'或'发送验证码'链接/按钮,用于获取短信验证码"
+                    )
+                    if suggest.get("has_selector") and suggest.get("selector"):
+                        sel = suggest["selector"]
+                        print(f"[{self.platform_name}] AI 建议选择器: {sel} (置信度: {suggest.get('confidence')})", flush=True)
+                        ai_btn = self.page.locator(sel).first
+                        if await ai_btn.count() > 0 and await ai_btn.is_visible():
+                            await ai_btn.click(timeout=5000)
+                            did_click_send = True
+                            print(f"[{self.platform_name}] AI 方式点击发送验证码成功", flush=True)
+                except Exception as e:
+                    print(f"[{self.platform_name}] AI 查找发送验证码失败: {e}", flush=True)
+
             if did_click_send:
                 try:
                     await self.page.wait_for_timeout(800)
@@ -219,14 +247,13 @@ class DouyinPublisher(BasePublisher):
                 btn2 = await _get_send_button()
                 if btn2:
                     try:
-                        if await btn2.is_enabled():
-                            await btn2.click(timeout=5000)
-                            did_click_send = True
-                            await self.page.wait_for_timeout(800)
-                            sent_confirmed = await _confirm_sent()
-                            ai_state = await self.ai_analyze_sms_send_state()
-                            if ai_state.get("sent_likely"):
-                                sent_confirmed = True
+                        await btn2.click(timeout=5000)
+                        did_click_send = True
+                        await self.page.wait_for_timeout(800)
+                        sent_confirmed = await _confirm_sent()
+                        ai_state = await self.ai_analyze_sms_send_state()
+                        if ai_state.get("sent_likely"):
+                            sent_confirmed = True
                     except:
                         pass
 
@@ -263,26 +290,93 @@ class DouyinPublisher(BasePublisher):
             if not filled:
                 raise Exception("未找到验证码输入框")
 
+            # 点击提交/验证按钮
+            submit_clicked = False
+
+            # 方法1: 常规选择器(button 和非 button 元素)
             submit_selectors = [
+                'button:has-text("验证"):not(:has-text("验证码"))',
                 'button:has-text("确定")',
                 'button:has-text("确认")',
                 'button:has-text("提交")',
                 'button:has-text("完成")',
+                # 非 button 元素
+                '[role="button"]:has-text("验证"):not(:has-text("验证码"))',
+                'div:has-text("验证"):not(:has(div:has-text("验证"))):not(:has-text("验证码")):not(:has-text("短信"))',
+                'span:has-text("验证"):not(:has-text("验证码"))',
+                'a:has-text("验证"):not(:has-text("验证码"))',
             ]
             for selector in submit_selectors:
                 try:
                     btn = self.page.locator(selector).first
-                    if await btn.count() > 0:
-                        await btn.click()
+                    if await btn.count() > 0 and await btn.is_visible():
+                        print(f"[{self.platform_name}] 找到验证按钮: {selector}", flush=True)
+                        await btn.click(timeout=5000)
+                        submit_clicked = True
                         break
-                except:
+                except Exception as e:
+                    print(f"[{self.platform_name}] 选择器 {selector} 失败: {e}", flush=True)
                     continue
 
+            # 方法2: 遍历弹窗内的所有可见按钮/可点击元素
+            if not submit_clicked:
+                print(f"[{self.platform_name}] 常规选择器未命中,遍历弹窗按钮...", flush=True)
+                try:
+                    # 在弹窗/对话框中查找
+                    dialog_selectors = [
+                        '[class*="modal"] button, [class*="modal"] [role="button"]',
+                        '[class*="dialog"] button, [class*="dialog"] [role="button"]',
+                        '[class*="popup"] button, [class*="popup"] [role="button"]',
+                        'button',
+                    ]
+                    for dialog_sel in dialog_selectors:
+                        btns = self.page.locator(dialog_sel)
+                        count = await btns.count()
+                        for idx in range(count):
+                            b = btns.nth(idx)
+                            try:
+                                text = (await b.text_content() or "").strip()
+                                if text == "验证" and await b.is_visible():
+                                    print(f"[{self.platform_name}] 遍历找到验证按钮: '{text}'", flush=True)
+                                    await b.click(timeout=5000)
+                                    submit_clicked = True
+                                    break
+                            except:
+                                continue
+                        if submit_clicked:
+                            break
+                except Exception as e:
+                    print(f"[{self.platform_name}] 遍历按钮失败: {e}", flush=True)
+
+            # 方法3: AI 识别并点击
+            if not submit_clicked:
+                print(f"[{self.platform_name}] 使用 AI 查找验证按钮...", flush=True)
+                try:
+                    suggest = await self.ai_suggest_playwright_selector(
+                        "点击弹窗中红色的'验证'确认按钮(不是'取消'按钮),用于提交短信验证码"
+                    )
+                    if suggest.get("has_selector") and suggest.get("selector"):
+                        sel = suggest["selector"]
+                        print(f"[{self.platform_name}] AI 建议验证按钮选择器: {sel} (置信度: {suggest.get('confidence')})", flush=True)
+                        ai_btn = self.page.locator(sel).first
+                        if await ai_btn.count() > 0 and await ai_btn.is_visible():
+                            await ai_btn.click(timeout=5000)
+                            submit_clicked = True
+                            print(f"[{self.platform_name}] AI 方式点击验证按钮成功", flush=True)
+                except Exception as e:
+                    print(f"[{self.platform_name}] AI 查找验证按钮失败: {e}", flush=True)
+
+            if not submit_clicked:
+                print(f"[{self.platform_name}] ⚠ 未能点击验证按钮", flush=True)
+
             try:
-                await self.page.wait_for_timeout(1000)
-                await self.page.wait_for_selector('text="请输入验证码"', state="hidden", timeout=15000)
+                await self.page.wait_for_timeout(1500)
+                await self.page.wait_for_selector('text="接收短信验证码"', state="hidden", timeout=15000)
             except:
-                pass
+                try:
+                    await self.page.wait_for_selector('text="请输入验证码"', state="hidden", timeout=5000)
+                except:
+                    pass
 
             print(f"[{self.platform_name}] 短信验证码已提交,继续执行发布流程", flush=True)
             return True
@@ -596,27 +690,38 @@ class DouyinPublisher(BasePublisher):
         # 点击发布 - 参考 matrix
         for i in range(30):
             try:
-                # 检查验证码(不要在每次循环都调 AI,太慢)
-                if i % 5 == 0:
+                # 先用快速方式检查验证码弹窗(每次都检测,不需要 AI)
+                captcha_detected = False
+                captcha_type = ''
+                fast_captcha = await self.check_captcha()
+                if fast_captcha['need_captcha']:
+                    captcha_detected = True
+                    captcha_type = fast_captcha['captcha_type']
+                    print(f"[{self.platform_name}] 快速检测到验证码: {captcha_type}", flush=True)
+                elif i > 0 and i % 5 == 0:
                     ai_captcha = await self.ai_check_captcha()
                     if ai_captcha['has_captcha']:
-                        print(f"[{self.platform_name}] AI检测到发布过程中需要验证码: {ai_captcha['captcha_type']}", flush=True)
-                        if ai_captcha['captcha_type'] == 'phone':
-                            handled = await self.handle_phone_captcha()
-                            if handled:
-                                continue
-                        screenshot_base64 = await self.capture_screenshot()
-                        page_url = await self.get_page_url()
-                        return PublishResult(
-                            success=False,
-                            platform=self.platform_name,
-                            error=f"发布过程中需要{ai_captcha['captcha_type']}验证码,请使用有头浏览器完成验证",
-                            need_captcha=True,
-                            captcha_type=ai_captcha['captcha_type'],
-                            screenshot_base64=screenshot_base64,
-                            page_url=page_url,
-                            status='need_captcha'
-                        )
+                        captcha_detected = True
+                        captcha_type = ai_captcha['captcha_type']
+                        print(f"[{self.platform_name}] AI检测到发布过程中需要验证码: {captcha_type}", flush=True)
+
+                if captcha_detected:
+                    if captcha_type == 'phone':
+                        handled = await self.handle_phone_captcha()
+                        if handled:
+                            continue
+                    screenshot_base64 = await self.capture_screenshot()
+                    page_url = await self.get_page_url()
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error=f"发布过程中需要{captcha_type}验证码,请使用有头浏览器完成验证",
+                        need_captcha=True,
+                        captcha_type=captcha_type,
+                        screenshot_base64=screenshot_base64,
+                        page_url=page_url,
+                        status='need_captcha'
+                    )
                 
                 publish_btn = self.page.get_by_role('button', name="发布", exact=True)
                 btn_count = await publish_btn.count()

+ 506 - 89
server/python/platforms/xiaohongshu.py

@@ -62,7 +62,10 @@ class XiaohongshuPublisher(BasePublisher):
 
         try:
             async with async_playwright() as playwright:
-                browser = await playwright.chromium.launch(headless=True)
+                try:
+                    browser = await playwright.chromium.launch(headless=True, channel="chrome")
+                except Exception:
+                    browser = await playwright.chromium.launch(headless=True)
                 browser_context = await browser.new_context()
 
                 if STEALTH_JS_PATH.exists():
@@ -488,47 +491,128 @@ class XiaohongshuPublisher(BasePublisher):
 
         self.report_progress(20, "正在上传视频...")
 
+        # 验证视频文件存在
+        if not os.path.isfile(params.video_path):
+            return PublishResult(
+                success=False,
+                platform=self.platform_name,
+                error=f"视频文件不存在: {params.video_path}",
+                status="failed",
+            )
+        file_size_mb = os.path.getsize(params.video_path) / (1024 * 1024)
+        print(f"[{self.platform_name}] 视频文件: {params.video_path} ({file_size_mb:.1f} MB)")
+
         # 等待页面加载
         await asyncio.sleep(2)
 
-        # 上传视频
+        # 打印页面结构辅助调试
+        try:
+            file_inputs = self.page.locator('input[type="file"]')
+            input_count = await file_inputs.count()
+            print(f"[{self.platform_name}] 页面 file input 数量: {input_count}")
+            for i in range(input_count):
+                accept = await file_inputs.nth(i).get_attribute("accept") or ""
+                print(f"[{self.platform_name}]   input[{i}] accept='{accept}'")
+            all_btns = self.page.locator("button")
+            btn_count = await all_btns.count()
+            print(f"[{self.platform_name}] 页面 button 数量: {btn_count}")
+            for i in range(min(btn_count, 15)):
+                text = (await all_btns.nth(i).text_content() or "").strip()
+                vis = await all_btns.nth(i).is_visible()
+                if text:
+                    print(f"[{self.platform_name}]   button[{i}] text='{text[:30]}' visible={vis}")
+        except Exception as e:
+            print(f"[{self.platform_name}] 页面结构诊断失败: {e}")
+
         upload_triggered = False
 
-        # 方法1: 直接设置隐藏的 file input
-        print(f"[{self.platform_name}] 尝试方法1: 设置 file input")
-        file_inputs = self.page.locator('input[type="file"]')
-        input_count = await file_inputs.count()
-        print(f"[{self.platform_name}] 找到 {input_count} 个 file input")
+        # 辅助函数:点击元素并通过 file_chooser 上传
+        async def _click_and_upload(locator, label: str) -> bool:
+            try:
+                if await locator.count() == 0 or not await locator.is_visible():
+                    return False
+                print(f"[{self.platform_name}] 尝试点击: {label}")
+                async with self.page.expect_file_chooser(timeout=10000) as fc_info:
+                    await locator.click()
+                file_chooser = await fc_info.value
+                await file_chooser.set_files(params.video_path)
+                print(f"[{self.platform_name}] 通过 {label} 上传成功")
+                return True
+            except Exception as e:
+                print(f"[{self.platform_name}] {label} 失败: {e}")
+                return False
 
-        if input_count > 0:
-            # 找到接受视频的 input
-            for i in range(input_count):
-                input_el = file_inputs.nth(i)
-                accept = await input_el.get_attribute("accept") or ""
-                print(f"[{self.platform_name}] Input {i} accept: {accept}")
-                if "video" in accept or "*" in accept or not accept:
-                    await input_el.set_input_files(params.video_path)
-                    upload_triggered = True
-                    print(f"[{self.platform_name}] 视频文件已设置到 input {i}")
-                    break
+        # 方法1: 点击页面上的"上传视频"红色按钮或上传区域(最可靠)
+        print(f"[{self.platform_name}] 方法1: 点击上传按钮/区域")
+        click_selectors = [
+            # 红色"上传视频"按钮(排除顶部 tab)
+            ('button:has-text("上传视频")', "上传视频 button"),
+            ('[class*="upload-btn"]:has-text("上传")', "upload-btn"),
+            ('[class*="btn"]:has-text("上传视频")', "btn 上传视频"),
+            # 上传拖拽区域
+            ('[class*="drag"]', "拖拽区域"),
+            ('[class*="upload-area"]', "upload-area"),
+            ('[class*="upload-wrapper"]', "upload-wrapper"),
+            ('[class*="upload-container"]', "upload-container"),
+        ]
+        for sel, label in click_selectors:
+            el = self.page.locator(sel).first
+            if await _click_and_upload(el, f"方法1-{label}"):
+                upload_triggered = True
+                break
 
-        # 方法2: 点击上传区域触发文件选择器
+        # 方法2: 遍历所有按钮,精确匹配文本"上传视频"
         if not upload_triggered:
-            print(f"[{self.platform_name}] 尝试方法2: 点击上传区域")
+            print(f"[{self.platform_name}] 方法2: 遍历按钮匹配文本")
             try:
-                upload_area = self.page.locator(
-                    '[class*="upload-wrapper"], [class*="upload-area"], .upload-input'
-                ).first
-                if await upload_area.count() > 0:
-                    async with self.page.expect_file_chooser(timeout=5000) as fc_info:
-                        await upload_area.click()
-                    file_chooser = await fc_info.value
-                    await file_chooser.set_files(params.video_path)
-                    upload_triggered = True
-                    print(f"[{self.platform_name}] 通过点击上传区域上传成功")
+                all_btns = self.page.locator("button")
+                count = await all_btns.count()
+                for i in range(count):
+                    btn = all_btns.nth(i)
+                    try:
+                        text = (await btn.text_content() or "").strip()
+                        if "上传视频" in text and await btn.is_visible():
+                            if await _click_and_upload(btn, f"方法2-button[{i}]({text})"):
+                                upload_triggered = True
+                                break
+                    except:
+                        continue
             except Exception as e:
                 print(f"[{self.platform_name}] 方法2失败: {e}")
 
+        # 方法3: 直接 set_input_files(某些页面有效)
+        if not upload_triggered:
+            print(f"[{self.platform_name}] 方法3: 直接 set_input_files")
+            try:
+                file_inputs = self.page.locator('input[type="file"]')
+                input_count = await file_inputs.count()
+                for i in range(input_count):
+                    input_el = file_inputs.nth(i)
+                    accept = await input_el.get_attribute("accept") or ""
+                    if "video" in accept or "*" in accept or not accept:
+                        await input_el.set_input_files(params.video_path)
+                        upload_triggered = True
+                        print(f"[{self.platform_name}] set_input_files 到 input[{i}] accept='{accept}'")
+                        break
+            except Exception as e:
+                print(f"[{self.platform_name}] 方法3失败: {e}")
+
+        # 方法4: AI 识别上传按钮
+        if not upload_triggered:
+            print(f"[{self.platform_name}] 方法4: AI 识别上传按钮")
+            try:
+                suggest = await self.ai_suggest_playwright_selector(
+                    "点击小红书视频上传页面中间区域的红色'上传视频'按钮(不是顶部的'上传视频'标签页),该按钮用于打开文件选择器选择视频文件"
+                )
+                if suggest.get("has_selector") and suggest.get("selector"):
+                    sel = suggest["selector"]
+                    print(f"[{self.platform_name}] AI 建议选择器: {sel} (置信度: {suggest.get('confidence')})")
+                    el = self.page.locator(sel).first
+                    if await _click_and_upload(el, f"方法4-AI({sel})"):
+                        upload_triggered = True
+            except Exception as e:
+                print(f"[{self.platform_name}] 方法4失败: {e}")
+
         if not upload_triggered:
             screenshot_base64 = await self.capture_screenshot()
             page_url = await self.get_page_url()
@@ -544,40 +628,184 @@ class XiaohongshuPublisher(BasePublisher):
         self.report_progress(40, "等待视频上传完成...")
         print(f"[{self.platform_name}] 等待视频上传和处理...")
 
-        # 等待上传完成(检测页面变化)
+        # 辅助:检查页面是否处于上传选择页(即跳回了)
+        async def _is_on_upload_page() -> bool:
+            """检测页面是否回到了视频选择/上传页面"""
+            try:
+                drag_area = await self.page.locator('[class*="drag"], [class*="upload-area"], [class*="upload-wrapper"]').count()
+                upload_btn_visible = False
+                for sel in ['span.d-text:has-text("上传视频")', 'button:has-text("上传视频")']:
+                    loc = self.page.locator(sel).first
+                    if await loc.count() > 0:
+                        try:
+                            if await loc.is_visible():
+                                upload_btn_visible = True
+                                break
+                        except:
+                            pass
+                title_count = await self.page.locator('input[placeholder*="标题"], input[placeholder*="填写标题"]').count()
+                if (drag_area > 0 or upload_btn_visible) and title_count == 0:
+                    return True
+            except:
+                pass
+            return False
+
+        # 辅助:抓取页面上的 toast / 错误提示
+        async def _get_page_error() -> str:
+            """尝试获取页面上的错误提示文本"""
+            error_selectors = [
+                '.toast', '.el-message', '[class*="toast"]', '[class*="Toast"]',
+                '[class*="message"]', '[class*="Message"]', '[class*="notice"]',
+                '[class*="error"]', '[class*="Error"]', '[class*="alert"]',
+                '[class*="tip"]', '[class*="warn"]', '.ant-message',
+                '[role="alert"]', '[role="status"]',
+            ]
+            errors = []
+            for sel in error_selectors:
+                try:
+                    els = self.page.locator(sel)
+                    count = await els.count()
+                    for j in range(min(count, 5)):
+                        el = els.nth(j)
+                        if await el.is_visible():
+                            text = (await el.text_content() or "").strip()
+                            if text and len(text) < 200:
+                                errors.append(text)
+                except:
+                    continue
+            return "; ".join(errors[:3]) if errors else ""
+
+        # 辅助:执行一次上传操作(用于重试)
+        async def _do_upload() -> bool:
+            """触发文件上传,返回是否成功触发"""
+            for sel, label in [
+                ('[class*="drag"]', "drag-area"),
+                ('span.d-text:has-text("上传视频")', "span上传视频"),
+                ('button:has-text("上传视频")', "btn上传视频"),
+            ]:
+                try:
+                    el = self.page.locator(sel).first
+                    if await el.count() > 0 and await el.is_visible():
+                        async with self.page.expect_file_chooser(timeout=8000) as fc_info:
+                            await el.click()
+                        fc = await fc_info.value
+                        await fc.set_files(params.video_path)
+                        print(f"[{self.platform_name}] 重试上传成功: {label}")
+                        return True
+                except Exception as e:
+                    print(f"[{self.platform_name}] 重试上传-{label}失败: {e}")
+            # set_input_files 兜底
+            try:
+                file_inputs = self.page.locator('input[type="file"]')
+                for k in range(await file_inputs.count()):
+                    inp = file_inputs.nth(k)
+                    accept = await inp.get_attribute("accept") or ""
+                    if "video" in accept or "*" in accept or not accept:
+                        await inp.set_input_files(params.video_path)
+                        print(f"[{self.platform_name}] 重试 set_input_files 成功")
+                        return True
+            except:
+                pass
+            return False
+
         upload_complete = False
-        for i in range(60):  # 最多等待3分钟
+        redirect_retry_count = 0
+        max_redirect_retries = 2
+
+        for i in range(90):  # 最多等待 ~4.5 分钟
             await asyncio.sleep(3)
 
+            current_url = self.page.url
+
+            # 检测是否跳转到登录页
+            if "login" in current_url or "passport" in current_url:
+                print(f"[{self.platform_name}] ⚠ 检测到跳转到登录页: {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 await _is_on_upload_page():
+                error_msg = await _get_page_error()
+                redirect_retry_count += 1
+                print(
+                    f"[{self.platform_name}] ⚠ 页面跳回上传选择页 (第 {redirect_retry_count} 次), "
+                    f"URL={current_url}, 页面错误: {error_msg or '无'}"
+                )
+
+                if redirect_retry_count > max_redirect_retries:
+                    screenshot_base64 = await self.capture_screenshot()
+                    page_url = await self.get_page_url()
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error=f"视频上传失败(页面反复跳回上传页){': ' + error_msg if error_msg else ',可能是登录过期或视频格式不支持'}",
+                        screenshot_base64=screenshot_base64,
+                        page_url=page_url,
+                        status="need_action",
+                    )
+
+                # 等一下再重试上传
+                print(f"[{self.platform_name}] 等待 3 秒后重试上传...")
+                await asyncio.sleep(3)
+                retry_ok = await _do_upload()
+                if not retry_ok:
+                    screenshot_base64 = await self.capture_screenshot()
+                    page_url = await self.get_page_url()
+                    return PublishResult(
+                        success=False,
+                        platform=self.platform_name,
+                        error=f"重试上传失败{': ' + error_msg if error_msg else ''}",
+                        screenshot_base64=screenshot_base64,
+                        page_url=page_url,
+                        status="need_action",
+                    )
+                continue
+
             # 检查是否有标题输入框(上传完成后出现)
             title_input_count = await self.page.locator(
                 'input[placeholder*="标题"], input[placeholder*="填写标题"]'
             ).count()
-            # 或者检查编辑器区域
             editor_count = await self.page.locator(
                 '[class*="ql-editor"], [contenteditable="true"]'
             ).count()
-            # 检查发布按钮是否可见
             publish_btn_count = await self.page.locator(
                 '.publishBtn, button:has-text("发布")'
             ).count()
 
-            print(
-                f"[{self.platform_name}] 检测 {i + 1}: 标题框={title_input_count}, 编辑器={editor_count}, 发布按钮={publish_btn_count}"
-            )
+            if i % 5 == 0 or title_input_count > 0 or editor_count > 0:
+                print(
+                    f"[{self.platform_name}] 检测 {i + 1}: 标题框={title_input_count}, "
+                    f"编辑器={editor_count}, 发布按钮={publish_btn_count}, URL={current_url}"
+                )
 
             if title_input_count > 0 or (editor_count > 0 and publish_btn_count > 0):
                 upload_complete = True
                 print(f"[{self.platform_name}] 视频上传完成!")
                 break
 
+            # 每 30 秒抓一次页面错误提示
+            if i > 0 and i % 10 == 0:
+                err = await _get_page_error()
+                if err:
+                    print(f"[{self.platform_name}] 页面提示: {err}")
+
         if not upload_complete:
             screenshot_base64 = await self.capture_screenshot()
             page_url = await self.get_page_url()
+            error_msg = await _get_page_error()
             return PublishResult(
                 success=False,
                 platform=self.platform_name,
-                error="视频上传超时",
+                error=f"视频上传超时{': ' + error_msg if error_msg else ''}",
                 screenshot_base64=screenshot_base64,
                 page_url=page_url,
                 status="need_action",
@@ -646,78 +874,273 @@ class XiaohongshuPublisher(BasePublisher):
         await asyncio.sleep(2)
         self.report_progress(80, "正在发布...")
 
+        # ========== 等待视频处理完成 ==========
+        # 小红书上传视频后需要转码处理,发布按钮在处理完前可能是禁用状态
+        print(f"[{self.platform_name}] 等待视频处理完成...")
+        for wait_i in range(40):  # 最多等待 ~2 分钟
+            await asyncio.sleep(3)
+            # 检查是否有 "处理中"、"上传中"、进度条等
+            processing_indicators = [
+                '[class*="progress"]',
+                '[class*="loading"]',
+                '[class*="uploading"]',
+                ':text("上传中")',
+                ':text("处理中")',
+                ':text("转码")',
+            ]
+            still_processing = False
+            for ind_sel in processing_indicators:
+                try:
+                    loc = self.page.locator(ind_sel).first
+                    if await loc.count() > 0 and await loc.is_visible():
+                        still_processing = True
+                        if wait_i % 5 == 0:
+                            text = (await loc.text_content() or "").strip()[:40]
+                            print(f"[{self.platform_name}] 视频仍在处理: {ind_sel} -> '{text}'")
+                        break
+                except:
+                    continue
+
+            # 检查发布按钮是否已可用
+            try:
+                pub_btn = self.page.locator(
+                    'button:has-text("发布"):not(:has-text("定时发布")):not(:has-text("定时"))'
+                ).first
+                if await pub_btn.count() > 0 and await pub_btn.is_visible():
+                    if await pub_btn.is_enabled():
+                        print(f"[{self.platform_name}] 发布按钮已可用(等待了 {wait_i * 3} 秒)")
+                        break
+                    else:
+                        if wait_i % 5 == 0:
+                            print(f"[{self.platform_name}] 发布按钮存在但尚未启用,继续等待...")
+            except:
+                pass
+
+            if not still_processing and wait_i > 3:
+                print(f"[{self.platform_name}] 未检测到处理指示器,继续尝试发布")
+                break
+
         await asyncio.sleep(2)
 
         # 滚动到页面底部确保发布按钮可见
         await self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
         await asyncio.sleep(1)
 
+        # ========== 诊断页面上所有按钮 ==========
+        print(f"[{self.platform_name}] === 页面按钮诊断 ===")
+        try:
+            all_page_btns = self.page.locator("button")
+            btn_total = await all_page_btns.count()
+            print(f"[{self.platform_name}] 页面共有 {btn_total} 个 <button> 元素")
+            for di in range(min(btn_total, 15)):
+                try:
+                    b = all_page_btns.nth(di)
+                    t = (await b.text_content() or "").strip().replace("\n", " ")[:40]
+                    c = await b.get_attribute("class") or ""
+                    v = await b.is_visible()
+                    e = await b.is_enabled()
+                    print(f"[{self.platform_name}]   button[{di}]: text='{t}' class='{c[:60]}' visible={v} enabled={e}")
+                except:
+                    continue
+        except Exception as ex:
+            print(f"[{self.platform_name}] 按钮诊断失败: {ex}")
+
+        # 同时诊断可能不是 button 的发布元素
+        try:
+            non_btn_publish = self.page.locator(
+                'div:has-text("发布"), span:has-text("发布"), a:has-text("发布")'
+            )
+            nb_count = await non_btn_publish.count()
+            print(f"[{self.platform_name}] 含'发布'文本的非button元素数量: {nb_count}")
+            for di in range(min(nb_count, 10)):
+                try:
+                    el = non_btn_publish.nth(di)
+                    t = (await el.text_content() or "").strip().replace("\n", " ")[:40]
+                    tag = await el.evaluate("e => e.tagName")
+                    c = await el.evaluate("e => e.className") or ""
+                    v = await el.is_visible()
+                    if v and ("发布" in t and len(t) < 15):
+                        print(f"[{self.platform_name}]   ** {tag}[{di}]: text='{t}' class='{str(c)[:60]}' visible={v}")
+                except:
+                    continue
+        except:
+            pass
+        print(f"[{self.platform_name}] === 诊断结束 ===")
+
         print(f"[{self.platform_name}] 查找发布按钮...")
 
-        # 点击发布
+        # ========== 点击发布 ==========
         publish_selectors = [
             "button.publishBtn",
             ".publishBtn",
+            "button.css-k0qbsp",
             "button.d-button.red",
-            'button:has-text("发布"):not(:has-text("定时发布"))',
+            'button.el-button--primary:has-text("发布")',
+            'button[class*="red"]:has-text("发布")',
             '[class*="publish"][class*="btn"]',
         ]
 
         publish_clicked = False
-        for selector in publish_selectors:
+
+        # 辅助:安全地点击元素
+        async def _safe_click_publish(locator, label: str) -> bool:
             try:
-                btn = self.page.locator(selector).first
-                if await btn.count() > 0:
-                    is_visible = await btn.is_visible()
-                    is_enabled = await btn.is_enabled()
-                    print(
-                        f"[{self.platform_name}] 按钮 {selector}: visible={is_visible}, enabled={is_enabled}"
+                if await locator.count() == 0:
+                    return False
+                if not await locator.is_visible():
+                    return False
+                box = await locator.bounding_box()
+                if box:
+                    print(f"[{self.platform_name}] 点击发布按钮: {label}, 位置: ({box['x']:.0f}, {box['y']:.0f})")
+                    await self.page.mouse.click(
+                        box["x"] + box["width"] / 2,
+                        box["y"] + box["height"] / 2,
                     )
+                    return True
+                else:
+                    await locator.click(timeout=5000)
+                    print(f"[{self.platform_name}] 直接click发布按钮: {label}")
+                    return True
+            except Exception as e:
+                print(f"[{self.platform_name}] 点击 {label} 失败: {e}")
+                return False
 
-                    if is_visible and is_enabled:
-                        box = await btn.bounding_box()
-                        if box:
-                            print(
-                                f"[{self.platform_name}] 点击发布按钮: {selector}, 位置: ({box['x']}, {box['y']})"
-                            )
-                            # 使用真实鼠标点击
-                            await self.page.mouse.click(
-                                box["x"] + box["width"] / 2,
-                                box["y"] + box["height"] / 2,
-                            )
-                            publish_clicked = True
-                            break
+        # 策略1: 预定义选择器
+        for selector in publish_selectors:
+            btn = self.page.locator(selector).first
+            if await _safe_click_publish(btn, f"策略1-{selector}"):
+                publish_clicked = True
+                break
+
+        # 策略2: 精确匹配 button 文本为"发布"(排除"定时发布"等)
+        if not publish_clicked:
+            print(f"[{self.platform_name}] 策略2: 遍历button匹配文本'发布'...")
+            try:
+                all_btns = self.page.locator("button")
+                count = await all_btns.count()
+                for i in range(count):
+                    btn = all_btns.nth(i)
+                    try:
+                        text = (await btn.text_content() or "").strip()
+                        if not text:
+                            continue
+                        # 宽松匹配:text 包含"发布"且不含"定时"
+                        if "发布" in text and "定时" not in text and len(text) <= 10:
+                            if await btn.is_visible():
+                                if await _safe_click_publish(btn, f"策略2-button[{i}]('{text}')"):
+                                    publish_clicked = True
+                                    break
+                    except:
+                        continue
             except Exception as e:
-                print(f"[{self.platform_name}] 选择器 {selector} 错误: {e}")
+                print(f"[{self.platform_name}] 策略2失败: {e}")
 
+        # 策略3: 非 button 元素(div/span/a)包含"发布"文本
         if not publish_clicked:
+            print(f"[{self.platform_name}] 策略3: 查找非button的发布元素...")
+            try:
+                for tag in ["div", "span", "a"]:
+                    candidates = self.page.locator(f'{tag}:has-text("发布")')
+                    cnt = await candidates.count()
+                    for i in range(cnt):
+                        el = candidates.nth(i)
+                        try:
+                            text = (await el.text_content() or "").strip()
+                            if "发布" in text and "定时" not in text and len(text) <= 10:
+                                if await el.is_visible():
+                                    # 排除包含子元素的大容器
+                                    children = await el.evaluate("e => e.children.length")
+                                    if children <= 2:
+                                        if await _safe_click_publish(el, f"策略3-{tag}[{i}]('{text}')"):
+                                            publish_clicked = True
+                                            break
+                        except:
+                            continue
+                    if publish_clicked:
+                        break
+            except Exception as e:
+                print(f"[{self.platform_name}] 策略3失败: {e}")
+
+        # 策略4: 通过 CSS 类名模糊匹配(小红书常用 d-button red 类名)
+        if not publish_clicked:
+            print(f"[{self.platform_name}] 策略4: CSS类名模糊匹配...")
+            css_selectors = [
+                '[class*="submit"]',
+                '[class*="publish"]',
+                '.red.d-button',
+                'button.red',
+                '[class*="btn-publish"]',
+                '[class*="btn_publish"]',
+            ]
+            for sel in css_selectors:
+                try:
+                    el = self.page.locator(sel).first
+                    if await el.count() > 0 and await el.is_visible():
+                        text = (await el.text_content() or "").strip()
+                        if "发布" in text and "定时" not in text:
+                            if await _safe_click_publish(el, f"策略4-{sel}('{text}')"):
+                                publish_clicked = True
+                                break
+                except:
+                    continue
+
+        # 策略5: AI 兜底
+        if not publish_clicked:
+            print(f"[{self.platform_name}] 策略5: AI 识别发布按钮...")
             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,
-                                )
+                    print(f"[{self.platform_name}] AI 建议: {sel} (置信度: {suggest.get('confidence')})")
+                    el = self.page.locator(sel).first
+                    if await _safe_click_publish(el, f"策略5-AI({sel})"):
                         publish_clicked = True
             except Exception as e:
-                print(f"[{self.platform_name}] AI 点击发布按钮失败: {e}", flush=True)
+                print(f"[{self.platform_name}] 策略5失败: {e}", flush=True)
+
+        # 策略6: JavaScript 直接查找并点击
+        if not publish_clicked:
+            print(f"[{self.platform_name}] 策略6: JavaScript 查找并点击...")
+            try:
+                clicked_by_js = await self.page.evaluate("""() => {
+                    // 查找所有可见的包含"发布"的元素
+                    const candidates = [];
+                    const all = document.querySelectorAll('button, [role="button"], a, span, div');
+                    for (const el of all) {
+                        const text = (el.textContent || '').trim();
+                        if (text === '发布' || (text.includes('发布') && !text.includes('定时') && text.length <= 8)) {
+                            const rect = el.getBoundingClientRect();
+                            if (rect.width > 0 && rect.height > 0 && rect.top >= 0) {
+                                const style = window.getComputedStyle(el);
+                                if (style.display !== 'none' && style.visibility !== 'hidden') {
+                                    candidates.push({
+                                        el: el,
+                                        text: text,
+                                        tag: el.tagName,
+                                        area: rect.width * rect.height,
+                                        y: rect.top
+                                    });
+                                }
+                            }
+                        }
+                    }
+                    if (candidates.length === 0) return null;
+                    // 优先选择面积适中(像按钮)且在页面下方的元素
+                    candidates.sort((a, b) => b.y - a.y);
+                    const target = candidates.find(c => c.area > 500 && c.area < 50000) || candidates[0];
+                    target.el.click();
+                    return { tag: target.tag, text: target.text, y: target.y };
+                }""")
+                if clicked_by_js:
+                    print(f"[{self.platform_name}] JS点击成功: {clicked_by_js}")
+                    publish_clicked = True
+            except Exception as e:
+                print(f"[{self.platform_name}] 策略6失败: {e}")
 
         if not publish_clicked:
-            # 保存截图用于调试
             screenshot_dir = os.path.join(
                 os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
                 "screenshots",
@@ -730,15 +1153,6 @@ class XiaohongshuPublisher(BasePublisher):
             print(
                 f"[{self.platform_name}] 未找到发布按钮,截图保存到: {screenshot_path}"
             )
-
-            # 打印页面 HTML 结构用于调试
-            buttons = await self.page.query_selector_all("button")
-            print(f"[{self.platform_name}] 页面上共有 {len(buttons)} 个按钮")
-            for i, btn in enumerate(buttons[:10]):
-                text = await btn.text_content() or ""
-                cls = await btn.get_attribute("class") or ""
-                print(f"  按钮 {i}: text='{text.strip()[:30]}', class='{cls[:50]}'")
-
             raise Exception("未找到发布按钮")
 
         print(f"[{self.platform_name}] 已点击发布按钮,等待发布完成...")
@@ -2095,7 +2509,10 @@ class XiaohongshuPublisher(BasePublisher):
             # --- Step 1: 初始化浏览器和 Cookie ---
             cookie_list = self.parse_cookies(cookies)
             playwright = await async_playwright().start()
-            browser = await playwright.chromium.launch(headless=False)
+            try:
+                browser = await playwright.chromium.launch(headless=False, channel="chrome")
+            except Exception:
+                browser = await playwright.chromium.launch(headless=False)
             context = await browser.new_context(
                 viewport={"width": 1400, "height": 900},
                 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",

+ 4 - 1
server/src/app.ts

@@ -16,7 +16,7 @@ import { initRedis } from './config/redis.js';
 import { logger } from './utils/logger.js';
 import { taskScheduler } from './scheduler/index.js';
 import { registerTaskExecutors } from './services/taskExecutors.js';
-import { taskQueueService } from './services/TaskQueueService.js';
+import { taskQueueService, initTaskQueue } from './services/TaskQueueService.js';
 import { loginServiceManager } from './services/login/index.js';
 import { wsManager } from './websocket/index.js';
 
@@ -224,6 +224,9 @@ async function bootstrap() {
     logger.warn('Redis connection failed - some features may not work');
   }
 
+  // 初始化任务队列(必须在注册执行器和启动 Worker 之前)
+  await initTaskQueue();
+
   // 只有在数据库连接成功时才启动调度器和注册任务执行器
   if (dbConnected) {
     registerTaskExecutors();

+ 16 - 13
server/src/services/TaskQueueService.ts

@@ -302,23 +302,26 @@ class TaskQueueService {
 // 内存队列单例
 const memoryTaskQueueService = new TaskQueueService();
 
-// 根据配置选择队列实现
-let taskQueueService: TaskQueueService;
+// 根据配置选择队列实现(默认使用内存队列)
+let taskQueueService: TaskQueueService = memoryTaskQueueService;
 
-if (USE_REDIS) {
-  // 动态导入 Redis 队列
-  import('./RedisTaskQueue.js').then(({ redisTaskQueueService }) => {
-    (taskQueueService as unknown) = redisTaskQueueService;
+/**
+ * 初始化任务队列(必须在 registerTaskExecutors / startWorker 之前 await)
+ */
+export async function initTaskQueue(): Promise<void> {
+  if (!USE_REDIS) {
+    logger.info('Using Memory Task Queue');
+    return;
+  }
+
+  try {
+    const { redisTaskQueueService } = await import('./RedisTaskQueue.js');
+    taskQueueService = redisTaskQueueService as unknown as TaskQueueService;
     logger.info('Using Redis Task Queue');
-  }).catch((err) => {
+  } catch (err: any) {
     logger.warn('Failed to load Redis Task Queue, falling back to memory queue:', err.message);
     taskQueueService = memoryTaskQueueService;
-  });
-  // 初始设置为内存队列(在 Redis 加载完成前使用)
-  taskQueueService = memoryTaskQueueService;
-} else {
-  taskQueueService = memoryTaskQueueService;
-  logger.info('Using Memory Task Queue');
+  }
 }
 
 export { taskQueueService };