From aa526b6ca8400a570f85f8351f12fc63c43d1364 Mon Sep 17 00:00:00 2001 From: yoqu Date: Tue, 2 Jun 2026 16:30:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(cli):=20=E6=94=AF=E6=8C=81=E5=8F=8C?= =?UTF-8?q?=E6=AF=94=E4=BE=8B=E5=B0=81=E9=9D=A2=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/CLI.md | 8 ++ sau_cli.py | 29 ++++++- tests/test_sau_browser_cli.py | 111 ++++++++++++++++++++++++++ uploader/tencent_uploader/main.py | 124 +++++++++++++++++++++--------- 4 files changed, 232 insertions(+), 40 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index b6fa5f8..bff5e2e 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -141,8 +141,16 @@ CLI 将 `debug` 和 `headless` 拆成了两个独立维度: --desc "示例简介" --tags 运动,训练 --thumbnail videos/demo.png +--thumbnail-landscape videos/cover-4x3.png +--thumbnail-portrait videos/cover-3x4.png ``` +抖音和视频号支持同时设置两种比例的封面图: + +- `--thumbnail-landscape`: 4:3 横版封面 +- `--thumbnail-portrait`: 3:4 竖版封面 +- `--thumbnail`: 兼容旧参数,等同于 3:4 竖版封面 + 抖音额外支持: ```bash diff --git a/sau_cli.py b/sau_cli.py index 4d74d45..5a867be 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -54,6 +54,8 @@ class DouyinVideoUploadRequest: tags: list[str] publish_date: datetime | int thumbnail_file: Path | None = None + thumbnail_landscape_file: Path | None = None + thumbnail_portrait_file: Path | None = None product_link: str = "" product_title: str = "" publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE @@ -148,6 +150,8 @@ class TencentVideoUploadRequest: tags: list[str] publish_date: datetime | int thumbnail_file: Path | None = None + thumbnail_landscape_file: Path | None = None + thumbnail_portrait_file: Path | None = None short_title: str | None = None category: str | None = None is_draft: bool = False @@ -285,7 +289,12 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path: request.publish_date, str(account_file), desc=request.description, - thumbnail_portrait_path=str(request.thumbnail_file) if request.thumbnail_file else None, + thumbnail_landscape_path=( + str(request.thumbnail_landscape_file) if request.thumbnail_landscape_file else None + ), + thumbnail_portrait_path=str( + request.thumbnail_portrait_file or request.thumbnail_file + ) if request.thumbnail_portrait_file or request.thumbnail_file else None, productLink=request.product_link, productTitle=request.product_title, publish_strategy=request.publish_strategy, @@ -463,6 +472,12 @@ async def upload_tencent_video(request: TencentVideoUploadRequest) -> Path: is_draft=request.is_draft, desc=request.description, thumbnail_path=str(request.thumbnail_file) if request.thumbnail_file else None, + thumbnail_landscape_path=( + str(request.thumbnail_landscape_file) if request.thumbnail_landscape_file else None + ), + thumbnail_portrait_path=( + str(request.thumbnail_portrait_file) if request.thumbnail_portrait_file else None + ), short_title=request.short_title, publish_strategy=request.publish_strategy, debug=request.debug, @@ -520,7 +535,9 @@ def build_parser() -> argparse.ArgumentParser: upload_video_parser.add_argument("--desc", default="", help="Optional video description") upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") - upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") + upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional 3:4 portrait thumbnail path") + upload_video_parser.add_argument("--thumbnail-landscape", type=existing_file_path, help="Optional 4:3 landscape thumbnail path") + upload_video_parser.add_argument("--thumbnail-portrait", type=existing_file_path, help="Optional 3:4 portrait thumbnail path") upload_video_parser.add_argument("--product-link", default="", help="Optional product link") upload_video_parser.add_argument("--product-title", default="", help="Optional product title") add_runtime_flags(upload_video_parser) @@ -622,7 +639,9 @@ def build_parser() -> argparse.ArgumentParser: tencent_upload_video_parser.add_argument("--desc", default="", help="Optional video description") tencent_upload_video_parser.add_argument("--tags", default="", help="Comma-separated tags, such as tag1,tag2") tencent_upload_video_parser.add_argument("--schedule", type=schedule_value, help=f"Schedule time in {schedule_help}") - tencent_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional thumbnail path") + tencent_upload_video_parser.add_argument("--thumbnail", type=existing_file_path, help="Optional 3:4 portrait thumbnail path") + tencent_upload_video_parser.add_argument("--thumbnail-landscape", type=existing_file_path, help="Optional 4:3 landscape thumbnail path") + tencent_upload_video_parser.add_argument("--thumbnail-portrait", type=existing_file_path, help="Optional 3:4 portrait thumbnail path") 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") @@ -655,6 +674,8 @@ async def dispatch(args: argparse.Namespace) -> int: tags=parse_tags(args.tags), publish_date=args.schedule or 0, thumbnail_file=args.thumbnail, + thumbnail_landscape_file=args.thumbnail_landscape, + thumbnail_portrait_file=args.thumbnail_portrait, product_link=args.product_link, product_title=args.product_title, publish_strategy=publish_strategy, @@ -838,6 +859,8 @@ async def dispatch(args: argparse.Namespace) -> int: tags=parse_tags(args.tags), publish_date=args.schedule or 0, thumbnail_file=args.thumbnail, + thumbnail_landscape_file=args.thumbnail_landscape, + thumbnail_portrait_file=args.thumbnail_portrait, short_title=args.short_title, category=args.category, is_draft=args.draft, diff --git a/tests/test_sau_browser_cli.py b/tests/test_sau_browser_cli.py index b3d2c65..c006b29 100644 --- a/tests/test_sau_browser_cli.py +++ b/tests/test_sau_browser_cli.py @@ -38,6 +38,66 @@ class BrowserCliParserTests(unittest.TestCase): self.assertEqual(args.desc, "视频简介") + def test_douyin_upload_video_accepts_dual_thumbnail_aspects(self): + with tempfile.TemporaryDirectory() as tmp_dir: + video_path = Path(tmp_dir) / "demo.mp4" + landscape_path = Path(tmp_dir) / "landscape.png" + portrait_path = Path(tmp_dir) / "portrait.png" + video_path.write_bytes(b"video") + landscape_path.write_bytes(b"image") + portrait_path.write_bytes(b"image") + + parser = sau_cli.build_parser() + args = parser.parse_args( + [ + "douyin", + "upload-video", + "--account", + "creator", + "--file", + str(video_path), + "--title", + "标题", + "--thumbnail-landscape", + str(landscape_path), + "--thumbnail-portrait", + str(portrait_path), + ] + ) + + self.assertEqual(args.thumbnail_landscape, landscape_path) + self.assertEqual(args.thumbnail_portrait, portrait_path) + + def test_tencent_upload_video_accepts_dual_thumbnail_aspects(self): + with tempfile.TemporaryDirectory() as tmp_dir: + video_path = Path(tmp_dir) / "demo.mp4" + landscape_path = Path(tmp_dir) / "landscape.png" + portrait_path = Path(tmp_dir) / "portrait.png" + video_path.write_bytes(b"video") + landscape_path.write_bytes(b"image") + portrait_path.write_bytes(b"image") + + parser = sau_cli.build_parser() + args = parser.parse_args( + [ + "tencent", + "upload-video", + "--account", + "creator", + "--file", + str(video_path), + "--title", + "标题", + "--thumbnail-landscape", + str(landscape_path), + "--thumbnail-portrait", + str(portrait_path), + ] + ) + + self.assertEqual(args.thumbnail_landscape, landscape_path) + self.assertEqual(args.thumbnail_portrait, portrait_path) + def test_kuaishou_upload_note_accepts_title_and_note(self): with tempfile.TemporaryDirectory() as tmp_dir: image_path = Path(tmp_dir) / "1.png" @@ -135,6 +195,57 @@ class BrowserCliDispatchTests(unittest.TestCase): self.assertEqual(request.title, "图文标题") self.assertEqual(request.note, "图文正文") + def test_dispatch_douyin_upload_video_uses_dual_thumbnail_request_fields(self): + args = Namespace( + platform="douyin", + action="upload-video", + account="creator", + file=Path("demo.mp4"), + title="视频标题", + desc="视频简介", + tags="测试,视频", + schedule=0, + thumbnail=None, + thumbnail_landscape=Path("landscape.png"), + thumbnail_portrait=Path("portrait.png"), + product_link="", + product_title="", + debug=False, + headless=True, + ) + with patch("sau_cli.upload_video", new=AsyncMock()) as mock_upload: + asyncio.run(sau_cli.dispatch(args)) + + request = mock_upload.await_args.args[0] + self.assertEqual(request.thumbnail_landscape_file, Path("landscape.png")) + self.assertEqual(request.thumbnail_portrait_file, Path("portrait.png")) + + def test_dispatch_tencent_upload_video_uses_dual_thumbnail_request_fields(self): + args = Namespace( + platform="tencent", + action="upload-video", + account="creator", + file=Path("demo.mp4"), + title="视频标题", + desc="视频简介", + tags="测试,视频", + schedule=0, + thumbnail=None, + thumbnail_landscape=Path("landscape.png"), + thumbnail_portrait=Path("portrait.png"), + short_title=None, + category=None, + draft=False, + debug=False, + headless=True, + ) + with patch("sau_cli.upload_tencent_video", new=AsyncMock()) as mock_upload: + asyncio.run(sau_cli.dispatch(args)) + + request = mock_upload.await_args.args[0] + self.assertEqual(request.thumbnail_landscape_file, Path("landscape.png")) + self.assertEqual(request.thumbnail_portrait_file, Path("portrait.png")) + def test_dispatch_xiaohongshu_upload_video_uses_headed_request(self): args = Namespace( platform="xiaohongshu", diff --git a/uploader/tencent_uploader/main.py b/uploader/tencent_uploader/main.py index 1f35c03..54dc5e1 100644 --- a/uploader/tencent_uploader/main.py +++ b/uploader/tencent_uploader/main.py @@ -698,6 +698,8 @@ class TencentVideo(TencentBaseUploader): is_draft=False, desc: str | None = None, thumbnail_path: str | None = None, + thumbnail_landscape_path: str | None = None, + thumbnail_portrait_path: str | None = None, short_title: str | None = None, publish_strategy: str = TENCENT_PUBLISH_STRATEGY_IMMEDIATE, debug: bool = DEBUG_MODE, @@ -717,6 +719,8 @@ class TencentVideo(TencentBaseUploader): self.is_draft = is_draft self.desc = desc or "" self.thumbnail_path = thumbnail_path + self.thumbnail_landscape_path = thumbnail_landscape_path + self.thumbnail_portrait_path = thumbnail_portrait_path or thumbnail_path self.short_title = short_title async def validate_upload_args(self): @@ -724,8 +728,10 @@ class TencentVideo(TencentBaseUploader): if not self.title or not str(self.title).strip(): raise ValueError("视频模式下,title 是必须的") self.file_path = str(self.validate_video_file(self.file_path)) - if self.thumbnail_path: - self.thumbnail_path = str(self.validate_image_file(self.thumbnail_path)) + if self.thumbnail_landscape_path: + self.thumbnail_landscape_path = str(self.validate_image_file(self.thumbnail_landscape_path)) + if self.thumbnail_portrait_path: + self.thumbnail_portrait_path = str(self.validate_image_file(self.thumbnail_portrait_path)) async def handle_upload_error(self, page: Page) -> None: tencent_logger.info(_msg("😵", "视频出错了,重新上传中")) @@ -733,18 +739,8 @@ class TencentVideo(TencentBaseUploader): await page.get_by_role("button", name="删除", exact=True).click() await self.upload_video_file(page, self.file_path) - async def set_thumbnail(self, page: Page) -> None: - if not self.thumbnail_path: - return - - tencent_logger.info(_msg("🖼️", "小人准备设置封面")) - - cover_entry_selectors = [ - 'div.vertical-cover-wrap:has-text("个人主页卡片"):has-text("3:4")', - 'div.vertical-cover-wrap:has-text("3:4")', - 'div.vertical-cover-wrap:has-text("个人主页卡片")', - ] - for selector in cover_entry_selectors: + async def open_thumbnail_dialog(self, page: Page, selectors: list[str], dialog_titles: list[str]): + for selector in selectors: cover_entry = page.locator(selector).first try: if not await cover_entry.count(): @@ -756,42 +752,96 @@ class TencentVideo(TencentBaseUploader): except Exception: continue - cover_dialog = page.locator("div.weui-desktop-dialog").filter(has_text="编辑个人主页卡片").first - if not await cover_dialog.count(): - tencent_logger.info(_msg("🧍", "当前页面没有出现封面编辑弹窗,小人先跳过自定义封面")) + for title in dialog_titles: + cover_dialog = page.locator("div.weui-desktop-dialog").filter(has_text=title).first + if await cover_dialog.count(): + return cover_dialog + return None + + async def confirm_thumbnail_crop(self, page: Page) -> None: + crop_dialog = page.locator("div.weui-desktop-dialog").filter(has_text="裁剪封面图").first + if not await crop_dialog.count(): return try: - await cover_dialog.wait_for(state="visible", timeout=5000) - except Exception: - tencent_logger.warning(_msg("😵", "封面编辑弹窗暂时不可见,这次先跳过自定义封面")) - return + await crop_dialog.wait_for(state="visible", timeout=10000) + crop_confirm_button = crop_dialog.locator( + 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确定")' + ).first + if await crop_confirm_button.count(): + await crop_confirm_button.wait_for(state="visible", timeout=5000) + await crop_confirm_button.click() + await page.wait_for_timeout(1000) + except Exception as exc: + tencent_logger.warning(_msg("😵", f"封面裁剪确认时出错,小人继续尝试保存主弹窗: {exc}")) + async def upload_thumbnail_in_dialog(self, page: Page, cover_dialog, thumbnail_path: str) -> None: + await cover_dialog.wait_for(state="visible", timeout=5000) 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(self.thumbnail_path) + await file_input.set_input_files(thumbnail_path) await page.wait_for_timeout(1000) - - crop_dialog = page.locator("div.weui-desktop-dialog").filter(has_text="裁剪封面图").first - if await crop_dialog.count(): - try: - await crop_dialog.wait_for(state="visible", timeout=10000) - crop_confirm_button = crop_dialog.locator( - 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确定")' - ).first - if await crop_confirm_button.count(): - await crop_confirm_button.wait_for(state="visible", timeout=5000) - await crop_confirm_button.click() - await page.wait_for_timeout(1000) - except Exception as exc: - tencent_logger.warning(_msg("😵", f"封面裁剪确认时出错,小人继续尝试保存主弹窗: {exc}")) + await self.confirm_thumbnail_crop(page) confirm_button = cover_dialog.locator( 'div.weui-desktop-dialog__ft button.weui-desktop-btn_primary:has-text("确认")' ).first await confirm_button.wait_for(state="visible", timeout=10000) await confirm_button.click() - tencent_logger.success(_msg("🥳", "封面已经设置完成")) + + async def set_single_thumbnail( + self, + page: Page, + thumbnail_path: str, + selectors: list[str], + dialog_titles: list[str], + label: str, + ) -> None: + cover_dialog = await self.open_thumbnail_dialog(page, selectors, dialog_titles) + if not cover_dialog: + tencent_logger.info(_msg("🧍", f"当前页面没有出现{label}封面编辑弹窗,小人先跳过")) + return + + try: + await self.upload_thumbnail_in_dialog(page, cover_dialog, thumbnail_path) + tencent_logger.success(_msg("🥳", f"{label}封面已经设置完成")) + except Exception as exc: + tencent_logger.warning(_msg("😵", f"{label}封面设置失败,这次先跳过: {exc}")) + + async def set_thumbnail(self, page: Page) -> None: + if not self.thumbnail_landscape_path and not self.thumbnail_portrait_path: + return + + tencent_logger.info(_msg("🖼️", "小人准备设置封面")) + + landscape_selectors = [ + 'div.horizontal-cover-wrap:has-text("4:3")', + 'div[class*="cover-wrap"]:has-text("4:3"):has-text("动态")', + 'div:has-text("视频号动态"):has-text("4:3")', + 'div:has-text("横版封面"):has-text("4:3")', + ] + portrait_selectors = [ + 'div.vertical-cover-wrap:has-text("个人主页卡片"):has-text("3:4")', + 'div.vertical-cover-wrap:has-text("3:4")', + 'div.vertical-cover-wrap:has-text("个人主页卡片")', + ] + + if self.thumbnail_landscape_path: + await self.set_single_thumbnail( + page, + self.thumbnail_landscape_path, + landscape_selectors, + ["编辑视频号动态封面", "编辑动态封面", "编辑封面"], + "4:3 横版", + ) + if self.thumbnail_portrait_path: + await self.set_single_thumbnail( + page, + self.thumbnail_portrait_path, + portrait_selectors, + ["编辑个人主页卡片", "编辑封面"], + "3:4 竖版", + ) async def prepare_video_for_publish(self, page: Page) -> None: await self.fill_title_and_tags(page)