|
@@ -13,6 +13,9 @@ from .base import (
|
|
|
WorkItem, WorksResult, CommentItem, CommentsResult
|
|
WorkItem, WorksResult, CommentItem, CommentsResult
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+# 允许通过环境变量手动指定“上传视频入口”的选择器,便于在页面结构频繁变更时快速调整
|
|
|
|
|
+WEIXIN_UPLOAD_SELECTOR = os.environ.get("WEIXIN_UPLOAD_SELECTOR", "").strip()
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def format_short_title(origin_title: str) -> str:
|
|
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 等
|
|
# 视频号域名为 channels.weixin.qq.com,cookie 常见 domain 为 .qq.com / .weixin.qq.com 等
|
|
|
# 这里默认用更宽泛的 .qq.com,避免“字符串 cookie”场景下 domain 兜底不生效
|
|
# 这里默认用更宽泛的 .qq.com,避免“字符串 cookie”场景下 domain 兜底不生效
|
|
|
cookie_domain = ".qq.com"
|
|
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):
|
|
async def init_browser(self, storage_state: str = None):
|
|
|
"""初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
|
|
"""初始化浏览器 - 参考 matrix 使用 channel=chrome 避免 H264 编码错误"""
|
|
@@ -318,67 +572,277 @@ class WeixinPublisher(BasePublisher):
|
|
|
|
|
|
|
|
self.report_progress(15, "正在选择视频文件...")
|
|
self.report_progress(15, "正在选择视频文件...")
|
|
|
|
|
|
|
|
- # 上传视频 - 参考 matrix/tencent_uploader/main.py
|
|
|
|
|
- # matrix 使用: div.upload-content 点击后触发文件选择器
|
|
|
|
|
|
|
+ # 上传视频
|
|
|
|
|
+ # 说明:视频号发布页在不同账号/地区/灰度下 DOM 结构差异较大,且上传组件可能在 iframe 中。
|
|
|
|
|
+ # 因此这里按 matrix 的思路“点击触发 file chooser”,同时增加“遍历全部 frame + 精确挑选 video input”的兜底。
|
|
|
upload_success = False
|
|
upload_success = False
|
|
|
-
|
|
|
|
|
- # 方法1: 参考 matrix - 点击 div.upload-content
|
|
|
|
|
|
|
+
|
|
|
|
|
+ if not self.page:
|
|
|
|
|
+ raise Exception("Page not initialized")
|
|
|
|
|
+
|
|
|
|
|
+ # 等待页面把上传区域渲染出来(避免过早判断)
|
|
|
try:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
except Exception as e:
|
|
|
- print(f"[{self.platform_name}] file input 上传失败: {e}")
|
|
|
|
|
|
|
+ print(f"[{self.platform_name}] 遍历 frames 异常: {e}")
|
|
|
|
|
|
|
|
if not upload_success:
|
|
if not upload_success:
|
|
|
screenshot_base64 = await self.capture_screenshot()
|
|
screenshot_base64 = await self.capture_screenshot()
|
|
|
return PublishResult(
|
|
return PublishResult(
|
|
|
success=False,
|
|
success=False,
|
|
|
platform=self.platform_name,
|
|
platform=self.platform_name,
|
|
|
- error="未找到上传入口",
|
|
|
|
|
|
|
+ error="未找到上传入口(可能在 iframe 中或页面结构已变更)",
|
|
|
screenshot_base64=screenshot_base64,
|
|
screenshot_base64=screenshot_base64,
|
|
|
page_url=await self.get_page_url(),
|
|
page_url=await self.get_page_url(),
|
|
|
status='failed'
|
|
status='failed'
|