mirror of
https://github.com/dreammis/social-auto-upload.git
synced 2026-08-28 17:43:28 +08:00
feat(cli): 支持双比例封面参数
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user