feat(cli): 支持双比例封面参数

This commit is contained in:
yoqu
2026-06-02 16:30:27 +08:00
parent 99a392af2c
commit aa526b6ca8
4 changed files with 232 additions and 40 deletions
+8
View File
@@ -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
+26 -3
View File
@@ -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,
+111
View File
@@ -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",
+87 -37
View File
@@ -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)