فهرست منبع

Enhance Weixin video upload functionality by adding AI-based selector recognition and allowing manual selector configuration via environment variable. Update upload logic to handle various DOM structures and improve error handling for iframe scenarios. Also, extend TypeScript definitions for Element Plus components by adding ElCheckbox, ElCheckboxGroup, ElDescriptions, ElDescriptionsItem, ElText, and ElUpload.

Ethanfly 21 ساعت پیش
والد
کامیت
fb798c4838
3فایلهای تغییر یافته به همراه518 افزوده شده و 48 حذف شده
  1. 6 0
      client/src/components.d.ts
  2. BIN
      server/python/platforms/__pycache__/weixin.cpython-313.pyc
  3. 512 48
      server/python/platforms/weixin.py

+ 6 - 0
client/src/components.d.ts

@@ -15,9 +15,13 @@ declare module 'vue' {
     ElBadge: typeof import('element-plus/es')['ElBadge']
     ElButton: typeof import('element-plus/es')['ElButton']
     ElButtonGroup: typeof import('element-plus/es')['ElButtonGroup']
+    ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
+    ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
     ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
     ElContainer: typeof import('element-plus/es')['ElContainer']
     ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
+    ElDescriptions: typeof import('element-plus/es')['ElDescriptions']
+    ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem']
     ElDialog: typeof import('element-plus/es')['ElDialog']
     ElDivider: typeof import('element-plus/es')['ElDivider']
     ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -45,6 +49,8 @@ declare module 'vue' {
     ElTabPane: typeof import('element-plus/es')['ElTabPane']
     ElTabs: typeof import('element-plus/es')['ElTabs']
     ElTag: typeof import('element-plus/es')['ElTag']
+    ElText: typeof import('element-plus/es')['ElText']
+    ElUpload: typeof import('element-plus/es')['ElUpload']
     Icons: typeof import('./components/icons/index.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']

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


+ 512 - 48
server/python/platforms/weixin.py

@@ -13,6 +13,9 @@ from .base import (
     WorkItem, WorksResult, CommentItem, CommentsResult
 )
 
+# 允许通过环境变量手动指定“上传视频入口”的选择器,便于在页面结构频繁变更时快速调整
+WEIXIN_UPLOAD_SELECTOR = os.environ.get("WEIXIN_UPLOAD_SELECTOR", "").strip()
+
 
 def format_short_title(origin_title: str) -> str:
     """
@@ -50,6 +53,257 @@ class WeixinPublisher(BasePublisher):
     # 视频号域名为 channels.weixin.qq.com,cookie 常见 domain 为 .qq.com / .weixin.qq.com 等
     # 这里默认用更宽泛的 .qq.com,避免“字符串 cookie”场景下 domain 兜底不生效
     cookie_domain = ".qq.com"
+
+    async def ai_find_upload_selector(self, frame_html: str, frame_name: str = "main") -> str:
+        """
+        使用 AI 从 HTML 中识别“上传视频/选择文件”相关元素的 CSS 选择器。
+        
+        设计思路:
+        - 仅在常规 DOM 选择器都失败时调用,避免频繁占用 AI 配额;
+        - 通过 DashScope 文本模型(与验证码识别同一套配置)分析 HTML;
+        - 返回一个适合用于 frame.locator(selector) 的 CSS 选择器。
+        """
+        import json
+        import re
+        import requests
+        import os
+
+        # 避免 HTML 过长导致 token 超限,只截取前 N 字符
+        if not frame_html:
+            return ""
+        max_len = 20000
+        if len(frame_html) > max_len:
+            frame_html = frame_html[:max_len]
+
+        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_text_model = os.environ.get("AI_TEXT_MODEL", "qwen-plus")
+
+        if not ai_api_key:
+            print(f"[{self.platform_name}] AI上传入口识别: 未配置 AI API Key,跳过")
+            return ""
+
+        prompt = f"""
+你是熟悉微信视频号后台的前端工程师,现在需要在一段 HTML 中找到“上传视频文件”的入口。
+
+页面说明:
+- 平台:微信视频号(channels.weixin.qq.com)
+- 目标:用于上传视频文件的按钮或 input(一般会触发文件选择框)
+- 你会收到某个 frame 的完整 HTML 片段(不包含截图)。
+
+请你根据下面的 HTML,推断最适合用于上传视频文件的元素,并输出一个可以被 Playwright 使用的 CSS 选择器。
+
+要求:
+1. 只考虑“上传/选择视频文件”的入口,不要返回“发布/发表/下一步”等按钮;
+2. 选择器需要尽量稳定,不要使用自动生成的随机类名(例如带很多随机字母/数字的类名可以用前缀匹配);
+3. 选择器必须是 CSS 选择器(不要返回 XPath);
+4. 如果确实找不到合理的上传入口,返回 selector 为空字符串。
+
+请以 JSON 格式输出,严格遵守以下结构(不要添加任何解释文字):
+```json
+{{
+  "selector": "CSS 选择器字符串,比如:input[type='file'] 或 div.upload-content input[type='file']"
+}}
+```
+
+下面是 frame=\"{frame_name}\" 的 HTML:
+```html
+{frame_html}
+```"""
+
+        payload = {
+            "model": ai_text_model,
+            "messages": [
+                {
+                    "role": "user",
+                    "content": prompt,
+                }
+            ],
+            "max_tokens": 600,
+        }
+
+        headers = {
+            "Authorization": f"Bearer {ai_api_key}",
+            "Content-Type": "application/json",
+        }
+
+        try:
+            print(f"[{self.platform_name}] AI上传入口识别: 正在分析 frame={frame_name} HTML...")
+            resp = requests.post(
+                f"{ai_base_url}/chat/completions",
+                headers=headers,
+                json=payload,
+                timeout=40,
+            )
+            if resp.status_code != 200:
+                print(f"[{self.platform_name}] AI上传入口识别: API 返回错误 {resp.status_code}")
+                return ""
+
+            data = resp.json()
+            content = data.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
+
+            # 尝试从 ```json``` 代码块中解析
+            json_match = re.search(r"```json\\s*([\\s\\S]*?)\\s*```", content)
+            if json_match:
+                json_str = json_match.group(1)
+            else:
+                json_match = re.search(r"\\{[\\s\\S]*\\}", content)
+                json_str = json_match.group(0) if json_match else "{}"
+
+            try:
+                result = json.loads(json_str)
+            except Exception:
+                result = {}
+
+            selector = (result.get("selector") or "").strip()
+            print(f"[{self.platform_name}] AI上传入口识别结果: selector='{selector}'")
+            return selector
+        except Exception as e:
+            print(f"[{self.platform_name}] AI上传入口识别异常: {e}")
+            return ""
+
+    async def ai_pick_selector_from_candidates(self, candidates: list, goal: str, frame_name: str = "main") -> str:
+        """
+        将“候选元素列表(包含 css selector + 文本/属性)”发给 AI,让 AI 直接挑选最符合 goal 的元素。
+        适用于:HTML 里看不出上传入口、或页面大量动态渲染时。
+        """
+        import json
+        import re
+        import requests
+        import os
+
+        if not candidates:
+            return ""
+
+        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_text_model = os.environ.get("AI_TEXT_MODEL", "qwen-plus")
+
+        if not ai_api_key:
+            print(f"[{self.platform_name}] AI候选选择器: 未配置 AI API Key,跳过")
+            return ""
+
+        # 控制长度,最多取前 120 个候选
+        candidates = candidates[:120]
+
+        prompt = f"""
+你是自动化发布工程师。现在要在微信视频号(channels.weixin.qq.com)发布页面里找到“{goal}”相关的入口元素。
+
+我会给你一组候选元素,每个候选都包含:
+- css: 可直接用于 Playwright 的 CSS 选择器
+- tag / type / role / ariaLabel / text / id / className(部分字段可能为空)
+
+你的任务:
+- 从候选中选出最可能用于“{goal}”的元素,返回它的 css 选择器;
+- 如果没有任何候选符合,返回空字符串。
+
+注意:
+- 如果 goal 是“上传视频入口”,优先选择 input[type=file] 或看起来会触发选择文件/上传的区域;
+- 不要选择“发布/发表/下一步”等按钮(除非 goal 明确是发布按钮)。
+
+请严格按 JSON 输出(不要解释):
+```json
+{{ "selector": "..." }}
+```
+
+候选列表(frame={frame_name}):
+```json
+{json.dumps(candidates, ensure_ascii=False)}
+```"""
+
+        payload = {
+            "model": ai_text_model,
+            "messages": [{"role": "user", "content": prompt}],
+            "max_tokens": 400,
+        }
+
+        headers = {
+            "Authorization": f"Bearer {ai_api_key}",
+            "Content-Type": "application/json",
+        }
+
+        try:
+            print(f"[{self.platform_name}] AI候选选择器: 正在分析 frame={frame_name}, goal={goal} ...")
+            resp = requests.post(
+                f"{ai_base_url}/chat/completions",
+                headers=headers,
+                json=payload,
+                timeout=40,
+            )
+            if resp.status_code != 200:
+                print(f"[{self.platform_name}] AI候选选择器: API 返回错误 {resp.status_code}")
+                return ""
+
+            data = resp.json()
+            content = data.get("choices", [{}])[0].get("message", {}).get("content", "") or ""
+            json_match = re.search(r"```json\\s*([\\s\\S]*?)\\s*```", content)
+            if json_match:
+                json_str = json_match.group(1)
+            else:
+                json_match = re.search(r"\\{[\\s\\S]*\\}", content)
+                json_str = json_match.group(0) if json_match else "{}"
+
+            try:
+                result = json.loads(json_str)
+            except Exception:
+                result = {}
+
+            selector = (result.get("selector") or "").strip()
+            print(f"[{self.platform_name}] AI候选选择器结果: selector='{selector}'")
+            return selector
+        except Exception as e:
+            print(f"[{self.platform_name}] AI候选选择器异常: {e}")
+            return ""
+
+    async def _extract_relevant_html_snippets(self, html: str) -> str:
+        """
+        从 HTML 中抽取与上传相关的片段,减少 token,提升 AI 命中率。
+        - 优先抓取包含 upload/上传/file/input 等关键词的窗口片段
+        - 若未命中关键词,返回“开头 + 结尾”的拼接
+        """
+        import re
+
+        if not html:
+            return ""
+
+        patterns = [
+            r"upload",
+            r"uploader",
+            r"file",
+            r"type\\s*=\\s*['\\\"]file['\\\"]",
+            r"input",
+            r"drag",
+            r"drop",
+            r"选择",
+            r"上传",
+            r"添加",
+            r"视频",
+        ]
+        regex = re.compile("|".join(patterns), re.IGNORECASE)
+
+        snippets = []
+        for m in regex.finditer(html):
+            start = max(0, m.start() - 350)
+            end = min(len(html), m.end() + 350)
+            snippets.append(html[start:end])
+            if len(snippets) >= 18:
+                break
+
+        if snippets:
+            # 去重(粗略)
+            unique = []
+            seen = set()
+            for s in snippets:
+                key = hash(s)
+                if key not in seen:
+                    seen.add(key)
+                    unique.append(s)
+            return "\n\n<!-- SNIPPET -->\n\n".join(unique)[:20000]
+
+        # fallback: head + tail
+        head = html[:9000]
+        tail = html[-9000:] if len(html) > 9000 else ""
+        return (head + "\n\n<!-- TAIL -->\n\n" + tail)[:20000]
     
     async def init_browser(self, storage_state: str = None):
         """初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
@@ -318,67 +572,277 @@ class WeixinPublisher(BasePublisher):
         
         self.report_progress(15, "正在选择视频文件...")
         
-        # 上传视频 - 参考 matrix/tencent_uploader/main.py
-        # matrix 使用: div.upload-content 点击后触发文件选择器
+        # 上传视频
+        # 说明:视频号发布页在不同账号/地区/灰度下 DOM 结构差异较大,且上传组件可能在 iframe 中。
+        # 因此这里按 matrix 的思路“点击触发 file chooser”,同时增加“遍历全部 frame + 精确挑选 video input”的兜底。
         upload_success = False
-        
-        # 方法1: 参考 matrix - 点击 div.upload-content
+
+        if not self.page:
+            raise Exception("Page not initialized")
+
+        # 等待页面把上传区域渲染出来(避免过早判断)
         try:
-            upload_div = self.page.locator("div.upload-content")
-            if await upload_div.count() > 0:
-                print(f"[{self.platform_name}] 找到 upload-content 上传区域")
-                async with self.page.expect_file_chooser(timeout=10000) as fc_info:
-                    await upload_div.click()
-                file_chooser = await fc_info.value
-                await file_chooser.set_files(params.video_path)
-                upload_success = True
-                print(f"[{self.platform_name}] 通过 upload-content 上传成功")
-        except Exception as e:
-            print(f"[{self.platform_name}] upload-content 上传失败: {e}")
-        
-        # 方法2: 尝试其他选择器
-        if not upload_success:
-            upload_selectors = [
-                'div[class*="upload-area"]',
-                'div[class*="drag-upload"]',
-                'div.add-wrap',
-                '[class*="uploader"]',
-            ]
-            
-            for selector in upload_selectors:
-                if upload_success:
-                    break
+            await self.page.wait_for_selector("div.upload-content, input[type='file'], iframe", timeout=20000)
+        except Exception:
+            pass
+
+        async def _try_set_files_in_frame(frame, frame_name: str) -> bool:
+            """在指定 frame 中尝试触发上传"""
+            nonlocal upload_success
+            if upload_success:
+                return True
+
+            # 方法0:如果用户通过环境变量显式配置了选择器,优先尝试这个
+            if WEIXIN_UPLOAD_SELECTOR:
                 try:
-                    upload_area = self.page.locator(selector).first
-                    if await upload_area.count() > 0:
-                        print(f"[{self.platform_name}] 尝试点击上传区域: {selector}")
-                        async with self.page.expect_file_chooser(timeout=10000) as fc_info:
-                            await upload_area.click()
-                        file_chooser = await fc_info.value
-                        await file_chooser.set_files(params.video_path)
-                        upload_success = True
-                        print(f"[{self.platform_name}] 通过点击上传区域成功")
-                        break
+                    el = frame.locator(WEIXIN_UPLOAD_SELECTOR).first
+                    if await el.count() > 0 and await el.is_visible():
+                        print(f"[{self.platform_name}] [{frame_name}] 使用环境变量 WEIXIN_UPLOAD_SELECTOR: {WEIXIN_UPLOAD_SELECTOR}")
+                        try:
+                            async with self.page.expect_file_chooser(timeout=5000) as fc_info:
+                                await el.click()
+                            chooser = await fc_info.value
+                            await chooser.set_files(params.video_path)
+                            upload_success = True
+                            print(f"[{self.platform_name}] [{frame_name}] 通过环境变量选择器上传成功")
+                            return True
+                        except Exception as e:
+                            print(f"[{self.platform_name}] [{frame_name}] 环境变量选择器点击失败,尝试直接 set_input_files: {e}")
+                            try:
+                                await el.set_input_files(params.video_path)
+                                upload_success = True
+                                print(f"[{self.platform_name}] [{frame_name}] 环境变量选择器 set_input_files 成功")
+                                return True
+                            except Exception as e2:
+                                print(f"[{self.platform_name}] [{frame_name}] 环境变量选择器 set_input_files 仍失败: {e2}")
                 except Exception as e:
-                    print(f"[{self.platform_name}] 选择器 {selector} 失败: {e}")
-        
-        # 方法3: 直接设置 file input
-        if not upload_success:
+                    print(f"[{self.platform_name}] [{frame_name}] 使用环境变量选择器定位元素失败: {e}")
+
+            # 先尝试点击上传区域触发 chooser(最贴近 matrix)
+            click_selectors = [
+                "div.upload-content",
+                "div[class*='upload-content']",
+                "div[class*='upload']",
+                "div.add-wrap",
+                "[class*='uploader']",
+                "text=点击上传",
+                "text=上传视频",
+                "text=选择视频",
+            ]
+            for selector in click_selectors:
+                try:
+                    el = frame.locator(selector).first
+                    if await el.count() > 0 and await el.is_visible():
+                        print(f"[{self.platform_name}] [{frame_name}] 找到可点击上传区域: {selector}")
+                        try:
+                            async with self.page.expect_file_chooser(timeout=5000) as fc_info:
+                                await el.click()
+                            chooser = await fc_info.value
+                            await chooser.set_files(params.video_path)
+                            upload_success = True
+                            print(f"[{self.platform_name}] [{frame_name}] 通过 file chooser 上传成功")
+                            return True
+                        except Exception as e:
+                            print(f"[{self.platform_name}] [{frame_name}] 点击触发 chooser 失败: {e}")
+                except Exception:
+                    pass
+
+            # 再尝试直接设置 input[type=file](iframe/隐藏 input 常见)
             try:
-                file_input = self.page.locator('input[type="file"]')
-                if await file_input.count() > 0:
-                    await file_input.first.set_input_files(params.video_path)
+                inputs = frame.locator("input[type='file']")
+                cnt = await inputs.count()
+                if cnt > 0:
+                    best_idx = 0
+                    best_score = -1
+                    for i in range(cnt):
+                        try:
+                            inp = inputs.nth(i)
+                            accept = (await inp.get_attribute("accept")) or ""
+                            multiple = (await inp.get_attribute("multiple")) or ""
+                            score = 0
+                            if "video" in accept:
+                                score += 10
+                            if "mp4" in accept:
+                                score += 3
+                            if multiple:
+                                score += 1
+                            if score > best_score:
+                                best_score = score
+                                best_idx = i
+                        except Exception:
+                            continue
+
+                    target = inputs.nth(best_idx)
+                    print(f"[{self.platform_name}] [{frame_name}] 尝试对 input[{best_idx}] set_input_files (score={best_score})")
+                    await target.set_input_files(params.video_path)
                     upload_success = True
-                    print(f"[{self.platform_name}] 通过 file input 上传成功")
+                    print(f"[{self.platform_name}] [{frame_name}] 通过 file input 上传成功")
+                    return True
+            except Exception as e:
+                print(f"[{self.platform_name}] [{frame_name}] file input 上传失败: {e}")
+                # 不直接返回,让后面的 AI 兜底有机会执行
+
+            # 方法4: 兜底使用 AI 分析 HTML,猜测上传入口
+            try:
+                frame_url = getattr(frame, "url", "")
+                html_full = await frame.content()
+                html_for_ai = await self._extract_relevant_html_snippets(html_full)
+                print(f"[{self.platform_name}] [{frame_name}] frame_url={frame_url}, html_len={len(html_full)}, html_for_ai_len={len(html_for_ai)}")
+
+                ai_selector = await self.ai_find_upload_selector(html_for_ai, frame_name=frame_name)
+                if ai_selector:
+                    try:
+                        el = frame.locator(ai_selector).first
+                        if await el.count() > 0:
+                            print(f"[{self.platform_name}] [{frame_name}] 使用 AI 选择器点击上传入口: {ai_selector}")
+                            try:
+                                async with self.page.expect_file_chooser(timeout=5000) as fc_info:
+                                    await el.click()
+                                chooser = await fc_info.value
+                                await chooser.set_files(params.video_path)
+                                upload_success = True
+                                print(f"[{self.platform_name}] [{frame_name}] 通过 AI 选择器上传成功")
+                                return True
+                            except Exception as e:
+                                print(f"[{self.platform_name}] [{frame_name}] AI 选择器点击失败,改为直接 set_input_files: {e}")
+                                try:
+                                    await el.set_input_files(params.video_path)
+                                    upload_success = True
+                                    print(f"[{self.platform_name}] [{frame_name}] AI 选择器直接 set_input_files 成功")
+                                    return True
+                                except Exception as e2:
+                                    print(f"[{self.platform_name}] [{frame_name}] AI 选择器 set_input_files 仍失败: {e2}")
+                    except Exception as e:
+                        print(f"[{self.platform_name}] [{frame_name}] 使用 AI 选择器定位元素失败: {e}")
+                else:
+                    # 如果 AI 无法从 HTML 推断,退一步:构造候选元素列表交给 AI 选择
+                    try:
+                        candidates = await frame.evaluate("""
+() => {
+  function cssEscape(s) {
+    try { return CSS.escape(s); } catch (e) { return s.replace(/[^a-zA-Z0-9_-]/g, '\\\\$&'); }
+  }
+  function buildSelector(el) {
+    if (!el || el.nodeType !== 1) return '';
+    if (el.id) return `#${cssEscape(el.id)}`;
+    let parts = [];
+    let cur = el;
+    for (let depth = 0; cur && cur.nodeType === 1 && depth < 5; depth++) {
+      let part = cur.tagName.toLowerCase();
+      const role = cur.getAttribute('role');
+      const type = cur.getAttribute('type');
+      if (type) part += `[type="${type}"]`;
+      if (role) part += `[role="${role}"]`;
+      const cls = (cur.className || '').toString().trim().split(/\\s+/).filter(Boolean);
+      if (cls.length) part += '.' + cls.slice(0, 2).map(cssEscape).join('.');
+      // nth-of-type
+      let idx = 1;
+      let sib = cur;
+      while (sib && (sib = sib.previousElementSibling)) {
+        if (sib.tagName === cur.tagName) idx++;
+      }
+      part += `:nth-of-type(${idx})`;
+      parts.unshift(part);
+      cur = cur.parentElement;
+    }
+    return parts.join(' > ');
+  }
+
+  const nodes = Array.from(document.querySelectorAll('input, button, a, div, span'))
+    .filter(el => {
+      const tag = el.tagName.toLowerCase();
+      const type = (el.getAttribute('type') || '').toLowerCase();
+      const role = (el.getAttribute('role') || '').toLowerCase();
+      const aria = (el.getAttribute('aria-label') || '').toLowerCase();
+      const txt = (el.innerText || '').trim().slice(0, 60);
+      const cls = (el.className || '').toString().toLowerCase();
+      const isFile = tag === 'input' && type === 'file';
+      const looksClickable =
+        tag === 'button' || tag === 'a' || role === 'button' || el.onclick ||
+        cls.includes('upload') || cls.includes('uploader') || cls.includes('drag') ||
+        aria.includes('上传') || aria.includes('选择') || aria.includes('添加') ||
+        txt.includes('上传') || txt.includes('选择') || txt.includes('添加') || txt.includes('点击上传');
+      if (!isFile && !looksClickable) return false;
+      const r = el.getBoundingClientRect();
+      const visible = r.width > 5 && r.height > 5;
+      return visible;
+    });
+
+  const limited = nodes.slice(0, 120).map(el => ({
+    css: buildSelector(el),
+    tag: el.tagName.toLowerCase(),
+    type: el.getAttribute('type') || '',
+    role: el.getAttribute('role') || '',
+    ariaLabel: el.getAttribute('aria-label') || '',
+    text: (el.innerText || '').trim().slice(0, 80),
+    id: el.id || '',
+    className: (el.className || '').toString().slice(0, 120),
+    accept: el.getAttribute('accept') || '',
+  }));
+  return limited;
+}
+""")
+                        ai_selector2 = await self.ai_pick_selector_from_candidates(
+                            candidates=candidates,
+                            goal="上传视频入口",
+                            frame_name=frame_name
+                        )
+                        if ai_selector2:
+                            el2 = frame.locator(ai_selector2).first
+                            if await el2.count() > 0:
+                                print(f"[{self.platform_name}] [{frame_name}] 使用 AI 候选选择器点击上传入口: {ai_selector2}")
+                                try:
+                                    async with self.page.expect_file_chooser(timeout=5000) as fc_info:
+                                        await el2.click()
+                                    chooser2 = await fc_info.value
+                                    await chooser2.set_files(params.video_path)
+                                    upload_success = True
+                                    print(f"[{self.platform_name}] [{frame_name}] 通过 AI 候选选择器上传成功")
+                                    return True
+                                except Exception as e:
+                                    print(f"[{self.platform_name}] [{frame_name}] AI 候选选择器点击失败,尝试 set_input_files: {e}")
+                                    try:
+                                        await el2.set_input_files(params.video_path)
+                                        upload_success = True
+                                        print(f"[{self.platform_name}] [{frame_name}] AI 候选选择器 set_input_files 成功")
+                                        return True
+                                    except Exception as e2:
+                                        print(f"[{self.platform_name}] [{frame_name}] AI 候选选择器 set_input_files 仍失败: {e2}")
+                    except Exception as e:
+                        print(f"[{self.platform_name}] [{frame_name}] 构造候选并交给 AI 失败: {e}")
+            except Exception as e:
+                print(f"[{self.platform_name}] [{frame_name}] AI 上传入口识别整体失败: {e}")
+
+            return False
+
+        # 先尝试主 frame
+        try:
+            await _try_set_files_in_frame(self.page.main_frame, "main")
+        except Exception as e:
+            print(f"[{self.platform_name}] main frame 上传尝试异常: {e}")
+
+        # 再遍历所有子 frame
+        if not upload_success:
+            try:
+                frames = self.page.frames
+                print(f"[{self.platform_name}] 发现 frames: {len(frames)}")
+                for idx, fr in enumerate(frames):
+                    if upload_success:
+                        break
+                    # main_frame 已尝试过
+                    if fr == self.page.main_frame:
+                        continue
+                    name = fr.name or f"frame-{idx}"
+                    await _try_set_files_in_frame(fr, name)
             except Exception as e:
-                print(f"[{self.platform_name}] file input 上传失败: {e}")
+                print(f"[{self.platform_name}] 遍历 frames 异常: {e}")
         
         if not upload_success:
             screenshot_base64 = await self.capture_screenshot()
             return PublishResult(
                 success=False,
                 platform=self.platform_name,
-                error="未找到上传入口",
+                error="未找到上传入口(可能在 iframe 中或页面结构已变更)",
                 screenshot_base64=screenshot_base64,
                 page_url=await self.get_page_url(),
                 status='failed'