diff --git a/sau_cli.py b/sau_cli.py index 7f75a61..7820a7c 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -172,6 +172,7 @@ class TencentVideoUploadRequest: publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE debug: bool = True headless: bool = True + collection_name: str | None = None @dataclass(slots=True) @@ -564,6 +565,7 @@ async def upload_tencent_video(request: TencentVideoUploadRequest) -> Path: publish_strategy=request.publish_strategy, debug=request.debug, headless=request.headless, + collection_name=request.collection_name, ) await app.tencent_upload_video() return account_file @@ -770,6 +772,7 @@ def build_parser() -> argparse.ArgumentParser: tencent_upload_video_parser.add_argument("--short-title", help="Optional WeChat Channels short title") tencent_upload_video_parser.add_argument("--category", help="Optional original content category") tencent_upload_video_parser.add_argument("--draft", action="store_true", help="Save as draft instead of publishing") + tencent_upload_video_parser.add_argument("--collection", default=None, help="Optional collection name to add the work into (must already exist)") add_runtime_flags(tencent_upload_video_parser) youtube_parser = platform_parsers.add_parser("youtube", help="YouTube operations") @@ -1054,6 +1057,7 @@ async def dispatch(args: argparse.Namespace) -> int: publish_strategy=publish_strategy, debug=args.debug, headless=args.headless, + collection_name=args.collection, ) await upload_tencent_video(request) print(f"Tencent/WeChat Channels video upload submitted: {request.video_file}") diff --git a/uploader/tencent_uploader/main.py b/uploader/tencent_uploader/main.py index 6336281..d0b0e5d 100644 --- a/uploader/tencent_uploader/main.py +++ b/uploader/tencent_uploader/main.py @@ -20,6 +20,7 @@ from utils.base_social_media import set_init_script from utils.log import tencent_logger TENCENT_LOGIN_URL = "https://channels.weixin.qq.com" +TENCENT_HOME_URL = "https://channels.weixin.qq.com/platform" TENCENT_UPLOAD_URL = "https://channels.weixin.qq.com/platform/post/create" TENCENT_MANAGE_URL = "https://channels.weixin.qq.com/platform/post/list" TENCENT_PUBLISH_STRATEGY_IMMEDIATE = "immediate" @@ -98,10 +99,14 @@ def format_str_for_short_title(origin_title: str) -> str: filtered_chars = [char if char.isalnum() or char in allowed_special_chars else " " if char == "," else "" for char in origin_title] formatted_string = "".join(filtered_chars) - if len(formatted_string) > 16: - formatted_string = formatted_string[:16] - elif len(formatted_string) < 6: - formatted_string += " " * (6 - len(formatted_string)) + # 视频号「短标题」要求 6~16 个字符/汉字;本项目按 >6 且 <16 从严控制在 7~15。 + formatted_string = formatted_string.strip() + if len(formatted_string) > 15: + formatted_string = formatted_string[:15] + if len(formatted_string) < 7: + # 不足下限时补足到 7;不能用尾部空格(会被平台 trim 掉导致仍不达标) + filler = ",精彩内容分享" + formatted_string = (formatted_string + filler)[:7] if formatted_string else "精彩视频内容分享" return formatted_string @@ -545,17 +550,39 @@ class TencentBaseUploader(BaseVideoUploader): await page.keyboard.press("Escape") async def open_upload_page(self, page: Page) -> None: - await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded") + # 视频号已改版:直接全页加载 /platform/post/create 会被跳回 /platform 首页, + # 发布表单 iframe 只加载空壳(Vue 不挂载),页面上没有任何 input[type=file]。 + # 正确入口:先进首页,再点可见的「发表视频」按钮做客户端跳转,表单才会真正挂载。 + await page.goto(TENCENT_HOME_URL, timeout=120000, wait_until="domcontentloaded") # cookie 失效时前端 JS 会跳转到登录页, 提前发现并报明确的错误 - redirected = True try: await page.wait_for_url("**/login.html**", timeout=8000) - except Exception: - redirected = False # 8 秒内未跳转, 正常 - if redirected or any( - "open.weixin.qq.com/connect/qrconnect" in fr.url for fr in page.frames - ): raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布") + except TimeoutError: + pass # 8 秒内未跳转, 正常 + except RuntimeError: + raise + except Exception: + pass + if any("open.weixin.qq.com/connect/qrconnect" in fr.url for fr in page.frames): + raise RuntimeError("视频号 cookie 已失效(被跳转到登录页),请重新扫码登录后再发布") + try: + await page.wait_for_load_state("networkidle", timeout=15000) + except Exception: + pass + # 注意:get_by_text("发表视频") 会命中一个隐藏的说明
(不可点); + # 首页真正可点的入口是 button.weui-desktop-btn。 + publish_entry = page.locator("button.weui-desktop-btn", has_text="发表视频").first + try: + await publish_entry.wait_for(state="visible", timeout=30000) + await publish_entry.click() + except Exception: + # 兜底:按钮没点到时退回老逻辑直接跳转(可能仍是空壳,但保持向后兼容) + await page.goto(TENCENT_UPLOAD_URL, timeout=120000, wait_until="domcontentloaded") + try: + await page.wait_for_url("**/platform/post/create", timeout=120000) + except Exception: + pass # 上传表单在 micro/content/post/create 这个 iframe 里,domcontentloaded 时它还是空的。 # 不等网络静默就去找 input[type=file],会误报「未找到视频号文件上传框」—— @@ -578,12 +605,16 @@ class TencentBaseUploader(BaseVideoUploader): fi = await find_file_input() if fi is None: - # 助手落在首页:先点「发表视频」唤出编辑器与上传控件 - publish_btn = page.get_by_text("发表视频").first - if await publish_btn.count(): - await publish_btn.click() - await asyncio.sleep(3) - # 60 秒:实测这个 iframe 挂上 input 要几十秒,20 秒会误报「找不到上传框」 + # 助手落在首页:点可见的「发表视频」按钮(button.weui-desktop-btn)唤出发布表单。 + # 不能用 get_by_text("发表视频")——它会命中隐藏的说明
,点了无效。 + publish_btn = page.locator("button.weui-desktop-btn", has_text="发表视频").first + try: + if await publish_btn.count() and await publish_btn.is_visible(): + await publish_btn.click() + await asyncio.sleep(3) + except Exception: + pass + # 60 秒:实测这个 iframe 挂上 input 要几十秒,20 秒会误报「找不到上传框」。 for _ in range(60): fi = await find_file_input() if fi is not None: @@ -606,17 +637,54 @@ class TencentBaseUploader(BaseVideoUploader): await fi.set_input_files(file_path) async def set_short_title(self, page: Page, title: str, short_title: str | None = None) -> None: - short_title_element = ( - page.get_by_text("短标题", exact=True) - .locator("..") - .locator("xpath=following-sibling::div") - .locator('span input[type="text"]') - ) - if await short_title_element.count(): - await short_title_element.fill(short_title or format_str_for_short_title(title)) + # 视频号「短标题」即界面上要求填写的“标题”(那个大编辑区其实是“视频描述”)。 + # 走 format_str_for_short_title 保证长度落在 7~15,避免发布时被校验拦下。 + value = format_str_for_short_title(short_title or title) + # 优先用 placeholder 定位(已 dump 验证更稳),兜底旧的“短标题”相邻 input。 + field = page.locator('input[placeholder="填写短标题有机会获得更多流量"]').first + if not await field.count(): + field = ( + page.get_by_text("短标题", exact=True) + .locator("..") + .locator("xpath=following-sibling::div") + .locator('span input[type="text"]') + ) + if await field.count(): + await field.fill(value) + tencent_logger.info(_msg("🏷️", f"短标题已填写({len(value)}字):{value}")) + else: + tencent_logger.info(_msg("🧾", "未找到短标题输入框,跳过短标题")) + + async def _dismiss_switch_account_dialog(self, page: Page) -> None: + # 视频号上传后偶发弹出「切换视频号」对话框(.changeAccount-dialog / .common-dialog)遮挡发布表单。 + # 它带「取消」按钮、非强制,点「取消」/ 右上角 × / Esc 跳过即可,用当前账号继续发布。 + cancel = page.locator('.changeAccount-dialog button:has-text("取消")').first + closeb = page.locator('.changeAccount-dialog .weui-desktop-dialog__close-btn').first + for cand in (cancel, closeb): + try: + if await cand.count() and await cand.is_visible(): + await cand.click(timeout=2000) + await page.wait_for_timeout(600) + return + except Exception: + continue + try: + await page.keyboard.press("Escape") + await page.wait_for_timeout(600) + except Exception: + pass async def fill_title_and_tags(self, page: Page) -> None: - await page.locator("div.input-editor").click() + # 上传后偶发「切换视频号」弹窗遮挡描述框,点不动就关弹窗重试(以能点中描述框为成功标志)。 + for _ in range(4): + try: + await page.locator("div.input-editor").click(timeout=5000) + break + except Exception: + await self._dismiss_switch_account_dialog(page) + await page.wait_for_timeout(500) + else: + await page.locator("div.input-editor").click(timeout=8000) await page.keyboard.type(self.title) await page.keyboard.press("Enter") for tag in self.tags: @@ -630,113 +698,77 @@ class TencentBaseUploader(BaseVideoUploader): tencent_logger.info(_msg("🏷️", f"成功添加 desc: {len(self.desc)}")) async def apply_collection(self, page: Page) -> None: - collection_elements = ( - page.get_by_text("添加到合集") - .locator("xpath=following-sibling::div") - .locator(".option-list-wrap > div") - ) - if await collection_elements.count() > 1: - await page.get_by_text("添加到合集").locator("xpath=following-sibling::div").click() - await collection_elements.first.click() + """在发布表单页"添加到合集"下拉框按合集名精确选中(页面结构:option-item > .item > .name/.desc)。 + + 找不到匹配名字的合集时不展开/不选(保持未选状态直接发布,界面允许留空, + 不阻断主发布流程)。旧实现是"下拉项数>1就选第一项",等价于随机选,已改为精确匹配。 + """ + if not self.collection_name: + return + try: + trigger = page.get_by_text("添加到合集").first + if await trigger.count() == 0: + tencent_logger.info(_msg("🧾", "当前页面未发现「添加到合集」入口,跳过归集")) + return + dropdown = trigger.locator("xpath=following-sibling::div").first + await dropdown.click(timeout=8000) + await page.wait_for_timeout(800) + + option = dropdown.locator(".option-list-wrap .option-item").filter( + has=page.locator(f'.name:text-is("{self.collection_name}")') + ) + if await option.count() == 0: + tencent_logger.warning( + _msg("😵", f"合集下拉框未找到「{self.collection_name}」,跳过归集,保持未选状态") + ) + await page.keyboard.press("Escape") + await page.wait_for_timeout(300) + return + + # headless 下 option 常报 "element is not visible":下拉列表开在视口外/ + # 在 .option-list-wrap 滚动容器内,headful(大窗口)时在视野里能直接点中, + # headless 默认视口小就点不中。先把目标 option 滚进视野再点;普通 click + # 仍被判不可见时 → force 点击(跳过可见性 actionability)→ 派发原生 click 兜底。 + target = option.first + try: + await target.scroll_into_view_if_needed(timeout=3000) + except Exception: + pass + try: + await target.click(timeout=4000) + except Exception: + try: + await target.click(force=True, timeout=4000) + except Exception: + await target.dispatch_event("click") + await page.wait_for_timeout(500) + tencent_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}")) + except Exception as exc: + tencent_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}")) + try: + await page.keyboard.press("Escape") + except Exception: + pass async def apply_original_statement(self, page: Page) -> None: - original_set = False - if await page.get_by_label("视频为原创").count(): - await page.get_by_label("视频为原创").check() - original_set = True - + # 视频号「视频标注」下拉:本项目成片经 AI 处理(TTS 配音、AI 字幕、AI 前贴片), + # 依平台合规要求如实选「含AI生成内容」(与「内容为转载」等并列,选定即可、无需填写来源)。 + # 注意:这与上方独立的「声明原创」复选框是两个不同字段,本项目走 AI 标注、不勾原创声明。 + label_text = getattr(self, "content_label", None) or "含AI生成内容" try: - label_locator = await page.locator('label:has-text("我已阅读并同意 《视频号原创声明使用条款》")').is_visible() - except Exception: - label_locator = False - - if label_locator: - await page.get_by_label("我已阅读并同意 《视频号原创声明使用条款》").check() - await page.get_by_role("button", name="声明原创").click() - original_set = True - - declaration_entry = page.locator( - 'div.label span:has-text("声明原创"), ' - 'div:has-text("声明原创"):has(input.ant-checkbox-input), ' - 'div:has-text("原创声明"):has(input.ant-checkbox-input)' - ).first - if await declaration_entry.count(): - original_checkbox = page.locator("div.declare-original-checkbox input.ant-checkbox-input").first - if await original_checkbox.count() and not await original_checkbox.is_disabled(): - await original_checkbox.click() - await page.wait_for_timeout(500) - checked_locator = page.locator( - "div.declare-original-dialog " - "label.ant-checkbox-wrapper.ant-checkbox-wrapper-checked:visible" - ) - if not await checked_locator.count(): - await page.locator("div.declare-original-dialog input.ant-checkbox-input:visible").first.click() - - original_type_form = page.locator('div.original-type-form > div.form-label:has-text("原创类型"):visible') - if await original_type_form.count(): - category = getattr(self, "category", None) - await page.locator("div.form-content:visible").click() - option = None - if category: - option = page.locator( - "ul.weui-desktop-dropdown__list " - f'li.weui-desktop-dropdown__list-ele:has-text("{category}")' - ).first - if not await option.count(): - option = None - if option is None: - option = page.locator( - "ul.weui-desktop-dropdown__list " - "li.weui-desktop-dropdown__list-ele:visible" - ).first - if await option.count(): - await option.click() - await page.wait_for_timeout(1000) - - declare_button = page.locator('button:has-text("声明原创"):visible') - if await declare_button.count(): - await declare_button.first.click() - original_set = True - await page.wait_for_timeout(1000) - - if not original_set: - for original_text in ("声明原创", "原创声明", "视频为原创"): - try: - modern_original = page.locator(f'text="{original_text}"').first - if await modern_original.count() and await modern_original.is_visible(): - await modern_original.click() - original_set = True - await page.wait_for_timeout(1000) - break - except Exception: - continue - - content_declaration = page.locator('text="内容声明"').first - try: - if await content_declaration.count() and await content_declaration.is_visible(): - await content_declaration.click() - for option_text in ("无需声明", "不声明", "无"): - option = page.locator(f'text="{option_text}"').first - if await option.count() and await option.is_visible(): - await option.click() - tencent_logger.info(_msg("🧾", f"内容声明已选择: {option_text}")) - break - else: - tencent_logger.info(_msg("🧾", "当前页面未发现内容声明字段")) + entry = page.get_by_text("选择视频标注", exact=True).first + if not await entry.count(): + tencent_logger.info(_msg("🧾", "当前页面未发现「视频标注」入口,跳过标注继续发布")) + return + await entry.click() + await page.wait_for_timeout(800) + option = page.get_by_text(label_text, exact=True).first + await option.wait_for(state="visible", timeout=5000) + await option.click() + await page.wait_for_timeout(500) + tencent_logger.success(_msg("🏷️", f"视频标注已选择:{label_text}")) except Exception as exc: - tencent_logger.warning(_msg("😵", f"内容声明设置失败,继续前先人工确认页面: {exc}")) - - if not original_set: - try: - diagnostic_path = Path(BASE_DIR) / "debug_tencent_original_missing.png" - await page.screenshot(path=str(diagnostic_path), full_page=True) - visible_text = (await page.locator("body").first.inner_text())[-4000:] - tencent_logger.warning(_msg("😵", f"未确认声明原创,诊断截图: {diagnostic_path}")) - tencent_logger.warning(_msg("🧾", f"页面末尾文本: {visible_text}")) - except Exception as exc: - tencent_logger.warning(_msg("😵", f"生成原创声明诊断信息失败: {exc}")) - # 视频号「声明原创」为可选项:页面无对应入口时跳过并继续发布,而非中止。 - tencent_logger.warning(_msg("📭", "本视频未声明原创(页面无入口或为可选项),跳过并继续发布")) + tencent_logger.warning(_msg("😵", f"设置视频标注「{label_text}」失败,跳过继续发布:{exc}")) async def wait_for_upload_complete( self, page: Page, timeout_seconds: int = 3600, max_retries: int = 3 @@ -807,34 +839,61 @@ class TencentBaseUploader(BaseVideoUploader): await asyncio.sleep(2) async def submit_publish(self, page: Page) -> None: - while True: + is_draft = getattr(self, "is_draft", False) + # 先等待并清理遮罩/弹窗,再等发表按钮出现 + for wait_round in range(60): + await self._dismiss_switch_account_dialog(page) try: - if getattr(self, "is_draft", False): - draft_button = page.locator('div.form-btns button:has-text("保存草稿")') - if await draft_button.count(): - await draft_button.click() + await page.evaluate("""() => document.querySelectorAll('.mask, .changeAccount-dialog, .common-dialog').forEach(e => e.remove())""") + except Exception: + pass + publish_btn = page.get_by_role("button", name="发表", exact=True).first if not is_draft else page.get_by_role("button", name="保存草稿").first + try: + if await publish_btn.count() and await publish_btn.is_visible(): + break + except Exception: + pass + await asyncio.sleep(1) + else: + tencent_logger.warning(_msg("😵", "60s 内未找到可见的发表/草稿按钮,尝试强制继续")) + # 点发表/草稿 + for attempt in range(20): + try: + if await publish_btn.count(): + try: + await publish_btn.click(timeout=4000) + except Exception: + await publish_btn.evaluate("el => el.click()") + if is_draft: await page.wait_for_url("**/post/list**", timeout=5000) tencent_logger.success(_msg("🥳", "视频草稿保存成功")) else: - publish_button = page.locator('div.form-btns button:has-text("发表")') - if await publish_button.count(): - await publish_button.click() - await page.wait_for_url(TENCENT_MANAGE_URL, timeout=5000) - tencent_logger.success(_msg("🥳", "视频发布成功")) - break + # 发表成功后视频号可能跳 /platform(首页)、/post/list 或留在 create 页但按钮消失。 + # 综合判断:URL 离开 /post/create 或 发表按钮不再存在。 + for _ in range(10): + await asyncio.sleep(1) + cur = page.url + if "/post/create" not in cur: + tencent_logger.success(_msg("🥳", "视频发布成功")) + return + if not await publish_btn.count(): + tencent_logger.success(_msg("🥳", "视频发布成功(按钮已消失)")) + return + raise Exception("发表后 10s 页面未变化") + return except Exception as exc: current_url = page.url - if getattr(self, "is_draft", False): - if "post/list" in current_url or "draft" in current_url: - tencent_logger.success(_msg("🥳", "视频草稿保存成功")) - break - else: - if TENCENT_MANAGE_URL in current_url: - tencent_logger.success(_msg("🥳", "视频发布成功")) - break - tencent_logger.exception(f" [-] Exception: {exc}") + if is_draft and ("post/list" in current_url or "draft" in current_url): + tencent_logger.success(_msg("🥳", "视频草稿保存成功")) + return + if (not is_draft) and "/post/create" not in current_url: + tencent_logger.success(_msg("🥳", "视频发布成功")) + return + if attempt and attempt % 5 == 0: + tencent_logger.warning(_msg("😵", f"发布仍未完成(第{attempt}次),异常: {str(exc)[:60]}")) tencent_logger.info(_msg("🏃", "视频正在发布中...")) - await asyncio.sleep(0.5) + await asyncio.sleep(1) + raise RuntimeError("发布未在预期时间内完成,请检查发布页面") class TencentVideo(TencentBaseUploader): @@ -931,8 +990,7 @@ class TencentVideo(TencentBaseUploader): file_input = cover_dialog.locator('.single-cover-uploader-wrap input[type="file"]').first await file_input.wait_for(state="attached", timeout=10000) await file_input.set_input_files(thumbnail_path) - await page.wait_for_timeout(1000) - await self.confirm_thumbnail_crop(page) + await page.wait_for_timeout(2000) confirm_button = cover_dialog.locator( 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")' @@ -997,7 +1055,9 @@ class TencentVideo(TencentBaseUploader): async def prepare_video_for_publish(self, page: Page) -> None: await self.fill_title_and_tags(page) await self.fill_description(page) - await self.apply_collection(page) + # 合集不在这里选:此时视频还在上传,上传完成后表单会刷新, + # 上传中选的合集会被重置/不绑定("日志说选了、后台没加"的根因)。 + # 改到 wait_for_upload_complete 之后再选,见 upload()。 async def upload(self, playwright: Playwright) -> None: tencent_logger.info(_msg("🧍", "小人先检查 cookie、视频文件和发布时间")) @@ -1015,6 +1075,8 @@ class TencentVideo(TencentBaseUploader): await self.upload_video_file(page, self.file_path) await self.prepare_video_for_publish(page) await self.wait_for_upload_complete(page) + # 上传完成、表单稳定后再选合集(否则上传中选的会被重置) + await self.apply_collection(page) await self.apply_original_statement(page) await self.set_thumbnail(page)