diff --git a/sau_cli.py b/sau_cli.py index a896718..35c3e32 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -95,6 +95,7 @@ class KuaishouVideoUploadRequest: publish_strategy: str = KUAISHOU_PUBLISH_STRATEGY_IMMEDIATE debug: bool = True headless: bool = True + collection_name: str | None = None @dataclass(slots=True) @@ -407,6 +408,7 @@ async def upload_kuaishou_video(request: KuaishouVideoUploadRequest) -> Path: publish_strategy=request.publish_strategy, debug=request.debug, headless=request.headless, + collection_name=request.collection_name, ) await app.main() return account_file @@ -636,6 +638,7 @@ def build_parser() -> argparse.ArgumentParser: kuaishou_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") kuaishou_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") kuaishou_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") + kuaishou_upload_video_parser.add_argument("--collection", default=None, help="Optional collection name to add the work into (must already exist)") add_runtime_flags(kuaishou_upload_video_parser) kuaishou_upload_note_parser = kuaishou_actions.add_parser("upload-note", help="Upload one note to Kuaishou") @@ -832,6 +835,7 @@ async def dispatch(args: argparse.Namespace) -> int: publish_strategy=publish_strategy, debug=args.debug, headless=args.headless, + collection_name=args.collection, ) await upload_kuaishou_video(request) print(f"Kuaishou video upload submitted: {request.video_file}") diff --git a/uploader/ks_uploader/main.py b/uploader/ks_uploader/main.py index c139897..32c6089 100644 --- a/uploader/ks_uploader/main.py +++ b/uploader/ks_uploader/main.py @@ -36,6 +36,62 @@ def _msg(emoji: str, text: str) -> str: return f"{emoji} {text}" +async def _dump_page_debug(page, tag: str) -> str: + """出错时保存整页截图 + 当前 HTML,返回保存目录,便于对照新 DOM 修选择器。""" + import time + base = Path("ks_debug") + base.mkdir(parents=True, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + try: + await page.screenshot(path=str(base / f"{tag}_{ts}.png"), full_page=True) + except Exception: + pass + try: + (base / f"{tag}_{ts}.html").write_text(await page.content(), encoding="utf-8") + except Exception: + pass + return str(base.resolve()) + + +async def _focus_desc_editor(page) -> None: + """定位并聚焦快手发布页的『描述』编辑区。 + + 快手创作者中心 DOM 时有改版,旧的 + get_by_text("描述").locator("xpath=following-sibling::div") + 一旦结构变化就会干等 30s 超时。这里按多种策略依次尝试(都锚定在「描述」 + 标签附近,避免误点到标题框),每种短超时快速失败;全部失败则保存截图/HTML + 供排查后抛出明确错误,而不是无脑超时。 + """ + label = page.get_by_text("描述") # 默认子串匹配,"作品描述" 等也能命中 + strategies = [ + # 旧结构:『描述』相邻 div + lambda: label.locator("xpath=following-sibling::div"), + # 新版描述区通常是紧随其后的富文本可编辑区 + lambda: label.locator("xpath=following::div[@contenteditable='true'][1]"), + # 同容器内的可编辑区 + lambda: label.locator("xpath=ancestor::*[1]//div[@contenteditable='true'][1]"), + # 兜底:其后第一个任意可编辑元素 + lambda: label.locator("xpath=following::*[@contenteditable='true'][1]"), + ] + last_err = None + for i, make in enumerate(strategies): + try: + loc = make().first + await loc.wait_for(state="visible", timeout=8000) + await loc.click(force=True) + if i > 0: + kuaishou_logger.warning(_msg( + "⚠️", f"描述区改用回退策略#{i}定位成功(快手可能已改版,建议核对选择器)")) + return + except Exception as e: # noqa: BLE001 + last_err = e + dbg = await _dump_page_debug(page, "desc_not_found") + raise RuntimeError( + f"未能定位快手『描述』编辑区(疑似发布页改版)。已保存截图/HTML 到 {dbg}," + f"请据此更新选择器。最后错误: {last_err}") + + + def _print_ks_qrcode(qrcode_content: str, qrcode_path: Path) -> None: try: print_terminal_qrcode(qrcode_content, qrcode_path, "快手APP", compact=False, border=2) @@ -161,12 +217,33 @@ async def cookie_auth(account_file): context = await set_init_script(context) page = await context.new_page() await page.goto(KUAISHOU_UPLOAD_URL) - if await _is_ks_cookie_invalid(page): - kuaishou_logger.info(_msg("🥹", "cookie 已失效,得重新登录一下")) + await page.wait_for_timeout(3000) + + # 检查是否被重定向到登录页 + if "passport.kuaishou.com" in page.url: + kuaishou_logger.info(_msg("🥹", "cookie 已失效(跳到登录页)")) return False - kuaishou_logger.success(_msg("🥳", "cookie 有效")) - return True + # 检查是否停留在介绍页(未登录状态显示"立即登录"按钮) + login_btn = page.get_by_text("立即登录") + if await login_btn.count() > 0: + kuaishou_logger.info(_msg("🥹", "cookie 已失效(介绍页)")) + return False + + # 正向证明:上传按钮存在 = 真正已登录 + try: + upload_btn = page.locator("button[class^='_upload-btn']") + await upload_btn.wait_for(state="visible", timeout=10000) + kuaishou_logger.success(_msg("🥳", "cookie 有效")) + return True + except Exception: + # 兜底:旧版检测("机构服务"元素出现在未登录介绍页) + if await _is_ks_cookie_invalid(page): + kuaishou_logger.info(_msg("🥹", "cookie 已失效(机构服务页)")) + return False + # 都没命中:保守判定为失效,避免假阳性 + kuaishou_logger.warning(_msg("😵", "无法确认 cookie 有效性,按失效处理")) + return False except Exception as exc: kuaishou_logger.warning(_msg("😵", f"cookie 校验时出错,按失效处理: {exc}")) return False @@ -359,25 +436,59 @@ class KSBaseUploader(BaseVideoUploader): kuaishou_logger.info(f"✅ 定时发布时间已设置为 {publish_date_str}") async def close_guide_overlay(self, page: Page) -> bool: - joyride_tooltip = page.locator('div[id^="react-joyride-step"] div[role="alertdialog"]') + """关闭快手创作者平台的 Joyride 引导遮罩。 - # 判断是否显示 + Joyride 有两个关键元素: + 1. tooltip (alertdialog) — 引导提示框,有关闭按钮 + 2. spotlight (react-joyride__spotlight) — 聚光灯遮罩层,拦截点击事件 + 两者可能独立存在。必须都关掉才能正常操作页面。 + """ + closed = False + + # 方式1:点击 tooltip 的关闭/跳过按钮 + joyride_tooltip = page.locator('div[id^="react-joyride-step"] div[role="alertdialog"]') if await joyride_tooltip.count() > 0 and await joyride_tooltip.first.is_visible(): print("检测到 Joyride 引导遮罩,正在关闭...") + # 尝试多种关闭按钮 selector + close_selectors = [ + '[aria-label="Skip"], [data-action="skip"], button[title="Skip"]', + 'button:text("跳过")', + 'button:text("我知道了")', + 'button:text("关闭")', + 'button:text("下一步")', # 有时需要多步跳过 + ] + for sel in close_selectors: + btn = page.locator('div[role="alertdialog"]').locator(sel) + if await btn.count() > 0: + await btn.first.click(force=True) + await asyncio.sleep(0.5) + break + closed = True - # 点击关闭按钮(X),使用多个可靠特征 - close_button = page.locator('div[role="alertdialog"]').locator( - '[aria-label="Skip"], [data-action="skip"], button[title="Skip"]' - ) + # 方式2:直接移除 Joyride portal(兜底,确保 spotlight 不再拦截) + joyride_portal = page.locator('div#react-joyride-portal') + if await joyride_portal.count() > 0: + try: + await page.evaluate("document.getElementById('react-joyride-portal')?.remove()") + print("✅ 已移除 Joyride portal 遮罩") + closed = True + except Exception: + pass - await close_button.click(force=True) + # 方式3:移除 spotlight 元素 + spotlight = page.locator('div.react-joyride__spotlight') + if await spotlight.count() > 0: + try: + await page.evaluate("document.querySelectorAll('.react-joyride__spotlight').forEach(e => e.remove())") + print("✅ 已移除 Joyride spotlight") + closed = True + except Exception: + pass - # 等待遮罩消失 - await joyride_tooltip.wait_for(state="hidden", timeout=5000) - - print("✅ 已关闭 Joyride 遮罩") - else: + if not closed: print("未检测到 Joyride 遮罩,继续执行") + else: + await asyncio.sleep(0.5) class KSVideo(KSBaseUploader): @@ -393,6 +504,7 @@ class KSVideo(KSBaseUploader): headless: bool = LOCAL_CHROME_HEADLESS, thumbnail_path=None, desc: str | None = None, + collection_name: str | None = None, ): super().__init__( publish_date=publish_date, @@ -406,6 +518,46 @@ class KSVideo(KSBaseUploader): self.tags = tags or [] self.thumbnail_path = thumbnail_path self.desc = desc or "" + self.collection_name = collection_name + + async def apply_collection(self, page: Page) -> None: + """在发布表单页选择"加入合集"下拉框(Ant Design Select,label 属性=合集名)。 + + 锚点用 label 文字"加入合集"精确定位紧邻的 ant-select 容器,避免误选页面上 + 其它下拉框(服务类型/关联热点/作者声明/添加地点,同页面还有好几个 ant-select)。 + 找不到匹配名字的合集选项时按 Escape 收起下拉,保持未选状态直接发布(界面允许留空, + 不阻断主发布流程)。 + """ + if not self.collection_name: + return + try: + trigger = page.locator( + 'label:text-is("加入合集")' + ).locator("xpath=following-sibling::div[contains(@class,'ant-select')]").first + if await trigger.count() == 0: + kuaishou_logger.warning(_msg("😵", "未找到\"加入合集\"下拉框,跳过归集")) + return + await trigger.locator(".ant-select-selector").click(timeout=8000) + await page.wait_for_timeout(800) + + option = page.locator(f'div.ant-select-item-option[label="{self.collection_name}"]') + if await option.count() == 0: + kuaishou_logger.warning( + _msg("😵", f"合集下拉框未找到「{self.collection_name}」,跳过归集,保持未选状态") + ) + await page.keyboard.press("Escape") + await page.wait_for_timeout(300) + return + + await option.first.click(timeout=8000) + await page.wait_for_timeout(500) + kuaishou_logger.success(_msg("🥳", f"已选择合集:{self.collection_name}")) + except Exception as exc: + kuaishou_logger.warning(_msg("😵", f"选择合集失败,跳过归集继续发布: {exc}")) + try: + await page.keyboard.press("Escape") + except Exception: + pass async def validate_upload_args(self): await self.validate_base_args() @@ -494,7 +646,9 @@ class KSVideo(KSBaseUploader): await self.close_guide_overlay(page) kuaishou_logger.info(_msg("✍️", "小人开始填描述和话题")) - await page.get_by_text("描述").locator("xpath=following-sibling::div").click() + # 再次检查并关闭 Joyride(可能在文件上传后才弹出) + await self.close_guide_overlay(page) + await _focus_desc_editor(page) await page.keyboard.press("Backspace") await page.keyboard.press("Control+KeyA") await page.keyboard.press("Delete") @@ -532,6 +686,8 @@ class KSVideo(KSBaseUploader): await self.set_thumbnail(page) + await self.apply_collection(page) + if self.publish_strategy == KUAISHOU_PUBLISH_STRATEGY_SCHEDULED and self.publish_date != 0: await self.set_schedule_time(page, self.publish_date) @@ -634,7 +790,7 @@ class KSNote(KSBaseUploader): await self.close_guide_overlay(page) kuaishou_logger.info(_msg("✍️", "小人开始填写图文内容和话题")) - await page.get_by_text("描述").locator("xpath=following-sibling::div").click() + await _focus_desc_editor(page) await page.keyboard.press("Backspace") await page.keyboard.press("Control+KeyA") await page.keyboard.press("Delete")