From e89d3a875a120b13d4870f9c1f12fca69adcbe87 Mon Sep 17 00:00:00 2001 From: lmr Date: Sat, 18 Jul 2026 02:07:55 +0800 Subject: [PATCH 1/2] fix(douyin): require explicit self-declaration --- sau_cli.py | 7 +++ tests/test_douyin_declaration.py | 98 ++++++++++++++++++++++++++++++++ tests/test_sau_browser_cli.py | 24 ++++++++ uploader/douyin_uploader/main.py | 33 ++++++++--- 4 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 tests/test_douyin_declaration.py diff --git a/sau_cli.py b/sau_cli.py index 4cbe854..584e7e1 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -63,6 +63,7 @@ class DouyinVideoUploadRequest: thumbnail_portrait_file: Path | None = None product_link: str = "" product_title: str = "" + declaration: str | None = None publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE debug: bool = True headless: bool = True @@ -354,6 +355,7 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path: ) if request.thumbnail_portrait_file or request.thumbnail_file else None, productLink=request.product_link, productTitle=request.product_title, + declaration=request.declaration, publish_strategy=request.publish_strategy, debug=request.debug, headless=request.headless, @@ -600,6 +602,10 @@ def build_parser() -> argparse.ArgumentParser: 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") + upload_video_parser.add_argument( + "--declaration", + help="Exact Douyin self-declaration option text; omitted means do not set one", + ) add_runtime_flags(upload_video_parser) upload_note_parser = douyin_actions.add_parser("upload-note", help="Upload one note to Douyin") @@ -762,6 +768,7 @@ async def dispatch(args: argparse.Namespace) -> int: thumbnail_portrait_file=args.thumbnail_portrait, product_link=args.product_link, product_title=args.product_title, + declaration=args.declaration, publish_strategy=publish_strategy, debug=args.debug, headless=args.headless, diff --git a/tests/test_douyin_declaration.py b/tests/test_douyin_declaration.py new file mode 100644 index 0000000..efd8a24 --- /dev/null +++ b/tests/test_douyin_declaration.py @@ -0,0 +1,98 @@ +# Language: 中文 +import asyncio +import unittest +from unittest.mock import AsyncMock, MagicMock, patch + +from uploader.douyin_uploader import main as douyin_main +from uploader.douyin_uploader.main import DouYinVideo + + +class DouyinDeclarationTests(unittest.TestCase): + def test_upload_only_sets_explicit_declaration(self): + video = DouYinVideo( + "标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json", + declaration="已确认声明原文", + ) + self.assertEqual(video.declaration, "已确认声明原文") + + def test_missing_declaration_does_not_fall_back_to_personal_opinion(self): + video = DouYinVideo("标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json") + self.assertIsNone(video.declaration) + + def test_legacy_positional_runtime_flags_keep_their_meaning(self): + video = DouYinVideo( + "标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json", + None, "", "", None, "", + "scheduled", False, False, + ) + self.assertEqual(video.publish_strategy, "scheduled") + self.assertFalse(video.debug) + self.assertFalse(video.headless) + self.assertIsNone(video.declaration) + + def test_apply_declaration_skips_when_unspecified(self): + video = DouYinVideo("标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json") + video.set_self_declaration = AsyncMock() + asyncio.run(video.apply_self_declaration(object())) + video.set_self_declaration.assert_not_awaited() + + def test_apply_declaration_uses_exact_explicit_text(self): + page = object() + video = DouYinVideo( + "标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json", + declaration="已确认声明原文", + ) + video.set_self_declaration = AsyncMock(return_value=True) + asyncio.run(video.apply_self_declaration(page)) + video.set_self_declaration.assert_awaited_once_with(page, "已确认声明原文") + + def test_explicit_declaration_failure_blocks_publish(self): + video = DouYinVideo( + "标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json", + declaration="已确认声明原文", + ) + video.set_self_declaration = AsyncMock(return_value=False) + with self.assertRaisesRegex(RuntimeError, "自主声明"): + asyncio.run(video.apply_self_declaration(object())) + + def test_declaration_failure_closes_browser_resources(self): + video = DouYinVideo( + "标题", "/tmp/demo.mp4", [], 0, "/tmp/cookie.json", + declaration="已确认声明原文", + ) + video.validate_upload_args = AsyncMock() + video.fill_title_and_description = AsyncMock() + video.set_thumbnail = AsyncMock() + video.apply_self_declaration = AsyncMock(side_effect=RuntimeError("抖音自主声明设置失败")) + + locator = MagicMock() + locator.set_input_files = AsyncMock() + locator.count = AsyncMock(return_value=1) + page = MagicMock() + page.goto = AsyncMock() + page.wait_for_url = AsyncMock() + page.wait_for_selector = AsyncMock() + page.locator.return_value = locator + + context = MagicMock() + context.new_page = AsyncMock(return_value=page) + context.close = AsyncMock(side_effect=OSError("close failed")) + browser = MagicMock() + browser.new_context = AsyncMock(return_value=context) + browser.close = AsyncMock() + playwright = MagicMock() + playwright.chromium.launch = AsyncMock(return_value=browser) + + with ( + patch.object(douyin_main, "set_init_script", AsyncMock(return_value=context)), + patch.object(douyin_main.asyncio, "sleep", AsyncMock()), + self.assertRaisesRegex(RuntimeError, "自主声明"), + ): + asyncio.run(video.upload(playwright)) + + context.close.assert_awaited_once() + browser.close.assert_awaited_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sau_browser_cli.py b/tests/test_sau_browser_cli.py index c006b29..22bc885 100644 --- a/tests/test_sau_browser_cli.py +++ b/tests/test_sau_browser_cli.py @@ -68,6 +68,28 @@ class BrowserCliParserTests(unittest.TestCase): self.assertEqual(args.thumbnail_landscape, landscape_path) self.assertEqual(args.thumbnail_portrait, portrait_path) + def test_douyin_upload_video_accepts_explicit_declaration(self): + with tempfile.TemporaryDirectory() as tmp_dir: + video_path = Path(tmp_dir) / "demo.mp4" + video_path.write_bytes(b"video") + parser = sau_cli.build_parser() + args = parser.parse_args([ + "douyin", "upload-video", "--account", "creator", + "--file", str(video_path), "--title", "标题", + "--declaration", "已确认声明原文", + ]) + self.assertEqual(args.declaration, "已确认声明原文") + + def test_douyin_upload_video_has_no_implicit_declaration(self): + with tempfile.TemporaryDirectory() as tmp_dir: + video_path = Path(tmp_dir) / "demo.mp4" + video_path.write_bytes(b"video") + args = sau_cli.build_parser().parse_args([ + "douyin", "upload-video", "--account", "creator", + "--file", str(video_path), "--title", "标题", + ]) + self.assertIsNone(args.declaration) + def test_tencent_upload_video_accepts_dual_thumbnail_aspects(self): with tempfile.TemporaryDirectory() as tmp_dir: video_path = Path(tmp_dir) / "demo.mp4" @@ -210,6 +232,7 @@ class BrowserCliDispatchTests(unittest.TestCase): thumbnail_portrait=Path("portrait.png"), product_link="", product_title="", + declaration="已确认声明原文", debug=False, headless=True, ) @@ -219,6 +242,7 @@ class BrowserCliDispatchTests(unittest.TestCase): 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")) + self.assertEqual(request.declaration, "已确认声明原文") def test_dispatch_tencent_upload_video_uses_dual_thumbnail_request_fields(self): args = Namespace( diff --git a/uploader/douyin_uploader/main.py b/uploader/douyin_uploader/main.py index 9109809..a4f8e8b 100644 --- a/uploader/douyin_uploader/main.py +++ b/uploader/douyin_uploader/main.py @@ -420,12 +420,8 @@ class DouYinBaseUploader(BaseVideoUploader): douyin_logger.error(_msg("😢", f"设置商品链接时出错: {str(e)}")) return False - async def set_self_declaration(self, page: Page, declaration: str = "内容为个人观点或见解") -> None: - """抖音「自主声明」为发布必选项:打开声明弹窗 → 选指定类型 → 确定。 - - 入口和弹窗都是异步渲染,等不到就记 warning 跳过、继续发布,绝不因此中断 - (与小红书话题、视频号声明原创的容错策略保持一致)。 - """ + async def set_self_declaration(self, page: Page, declaration: str) -> bool: + """按调用方给出的平台原文选择自主声明;失败返回 False。""" try: # 发布页底部「自主声明」行,未选时显示占位文案「请选择自主声明」 entry = page.get_by_text("请选择自主声明").first @@ -446,8 +442,10 @@ class DouYinBaseUploader(BaseVideoUploader): await dialog.get_by_role("button", name="确定").click(timeout=6000) await dialog.wait_for(state="hidden", timeout=6000) douyin_logger.info(_msg("🧾", f"自主声明已选择「{declaration}」")) + return True except Exception as exc: - douyin_logger.warning(_msg("🧾", f"自主声明设置失败,跳过该步骤继续发布:{exc}")) + douyin_logger.warning(_msg("🧾", f"自主声明设置失败:{exc}")) + return False async def select_bgm(self, page: Page, bgm_name: str) -> bool: """为图文发布选择 BGM:可选增强功能,搜索无结果或异常均跳过不中断发布。""" @@ -532,6 +530,7 @@ class DouYinVideo(DouYinBaseUploader): publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE, debug: bool = DEBUG_MODE, headless: bool = LOCAL_CHROME_HEADLESS, + declaration: str | None = None, ): super().__init__( publish_date=publish_date, @@ -548,6 +547,13 @@ class DouYinVideo(DouYinBaseUploader): self.productLink = productLink self.productTitle = productTitle self.desc = desc or "" + self.declaration = declaration.strip() if declaration and declaration.strip() else None + + async def apply_self_declaration(self, page: Page) -> None: + if not self.declaration: + return + if not await self.set_self_declaration(page, self.declaration): + raise RuntimeError(f"自主声明「{self.declaration}」设置失败,拒绝继续发布") async def _submit_sms_verify_code(self, page: Page, sms_input, code: str, code_file: str) -> bool: douyin_logger.info(_msg("✍️", f"已获取验证码,准备填入: {code}")) @@ -731,7 +737,18 @@ class DouYinVideo(DouYinBaseUploader): await self.set_thumbnail(page) - await self.set_self_declaration(page) + try: + await self.apply_self_declaration(page) + except Exception: + try: + await context.close() + except Exception: + pass + try: + await browser.close() + except Exception: + pass + raise third_part_element = '[class^="info"] > [class^="first-part"] div div.semi-switch' if await page.locator(third_part_element).count(): From f83225c76911ca5dcecc4fdc50164552344f5f87 Mon Sep 17 00:00:00 2001 From: lmr Date: Sat, 18 Jul 2026 14:19:42 +0800 Subject: [PATCH 2/2] fix: move declaration to after runtime flags for positional compat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DouyinVideoUploadRequest dataclass 声明字段位置 被独立审查者(deleg_61d33c80)标记为P1兼容问题: 旧位置调用 ('scheduled', False, False) 会被错位解析。 把 declaration 移到最后,保留旧位置契约。 Previous-Review: deleg_61d33c80 Ref: dreammis/social-auto-upload#213(安全后续修正) --- sau_cli.py | 2 +- tests/test_sau_browser_cli.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/sau_cli.py b/sau_cli.py index 584e7e1..a896718 100644 --- a/sau_cli.py +++ b/sau_cli.py @@ -63,10 +63,10 @@ class DouyinVideoUploadRequest: thumbnail_portrait_file: Path | None = None product_link: str = "" product_title: str = "" - declaration: str | None = None publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE debug: bool = True headless: bool = True + declaration: str | None = None @dataclass(slots=True) diff --git a/tests/test_sau_browser_cli.py b/tests/test_sau_browser_cli.py index 22bc885..4da58c6 100644 --- a/tests/test_sau_browser_cli.py +++ b/tests/test_sau_browser_cli.py @@ -90,6 +90,16 @@ class BrowserCliParserTests(unittest.TestCase): ]) self.assertIsNone(args.declaration) + def test_douyin_request_legacy_positional_runtime_flags_keep_their_meaning(self): + request = sau_cli.DouyinVideoUploadRequest( + "creator", Path("demo.mp4"), "标题", "简介", [], 0, + None, None, None, "", "", "scheduled", False, False, + ) + self.assertEqual(request.publish_strategy, "scheduled") + self.assertFalse(request.debug) + self.assertFalse(request.headless) + self.assertIsNone(request.declaration) + def test_tencent_upload_video_accepts_dual_thumbnail_aspects(self): with tempfile.TemporaryDirectory() as tmp_dir: video_path = Path(tmp_dir) / "demo.mp4"