Merge pull request #250 from mechanic-Q/feat/douyin-explicit-declaration
fix(douyin): make self-declaration explicit and fail-closed (follow-up to #213)
This commit is contained in:
@@ -66,6 +66,7 @@ class DouyinVideoUploadRequest:
|
|||||||
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE
|
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE
|
||||||
debug: bool = True
|
debug: bool = True
|
||||||
headless: bool = True
|
headless: bool = True
|
||||||
|
declaration: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
@@ -354,6 +355,7 @@ async def upload_video(request: DouyinVideoUploadRequest) -> Path:
|
|||||||
) if request.thumbnail_portrait_file or request.thumbnail_file else None,
|
) if request.thumbnail_portrait_file or request.thumbnail_file else None,
|
||||||
productLink=request.product_link,
|
productLink=request.product_link,
|
||||||
productTitle=request.product_title,
|
productTitle=request.product_title,
|
||||||
|
declaration=request.declaration,
|
||||||
publish_strategy=request.publish_strategy,
|
publish_strategy=request.publish_strategy,
|
||||||
debug=request.debug,
|
debug=request.debug,
|
||||||
headless=request.headless,
|
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("--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-link", default="", help="Optional product link")
|
||||||
upload_video_parser.add_argument("--product-title", default="", help="Optional product title")
|
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)
|
add_runtime_flags(upload_video_parser)
|
||||||
|
|
||||||
upload_note_parser = douyin_actions.add_parser("upload-note", help="Upload one note to Douyin")
|
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,
|
thumbnail_portrait_file=args.thumbnail_portrait,
|
||||||
product_link=args.product_link,
|
product_link=args.product_link,
|
||||||
product_title=args.product_title,
|
product_title=args.product_title,
|
||||||
|
declaration=args.declaration,
|
||||||
publish_strategy=publish_strategy,
|
publish_strategy=publish_strategy,
|
||||||
debug=args.debug,
|
debug=args.debug,
|
||||||
headless=args.headless,
|
headless=args.headless,
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -68,6 +68,38 @@ class BrowserCliParserTests(unittest.TestCase):
|
|||||||
self.assertEqual(args.thumbnail_landscape, landscape_path)
|
self.assertEqual(args.thumbnail_landscape, landscape_path)
|
||||||
self.assertEqual(args.thumbnail_portrait, portrait_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_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):
|
def test_tencent_upload_video_accepts_dual_thumbnail_aspects(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp_dir:
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
video_path = Path(tmp_dir) / "demo.mp4"
|
video_path = Path(tmp_dir) / "demo.mp4"
|
||||||
@@ -210,6 +242,7 @@ class BrowserCliDispatchTests(unittest.TestCase):
|
|||||||
thumbnail_portrait=Path("portrait.png"),
|
thumbnail_portrait=Path("portrait.png"),
|
||||||
product_link="",
|
product_link="",
|
||||||
product_title="",
|
product_title="",
|
||||||
|
declaration="已确认声明原文",
|
||||||
debug=False,
|
debug=False,
|
||||||
headless=True,
|
headless=True,
|
||||||
)
|
)
|
||||||
@@ -219,6 +252,7 @@ class BrowserCliDispatchTests(unittest.TestCase):
|
|||||||
request = mock_upload.await_args.args[0]
|
request = mock_upload.await_args.args[0]
|
||||||
self.assertEqual(request.thumbnail_landscape_file, Path("landscape.png"))
|
self.assertEqual(request.thumbnail_landscape_file, Path("landscape.png"))
|
||||||
self.assertEqual(request.thumbnail_portrait_file, Path("portrait.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):
|
def test_dispatch_tencent_upload_video_uses_dual_thumbnail_request_fields(self):
|
||||||
args = Namespace(
|
args = Namespace(
|
||||||
|
|||||||
@@ -420,12 +420,8 @@ class DouYinBaseUploader(BaseVideoUploader):
|
|||||||
douyin_logger.error(_msg("😢", f"设置商品链接时出错: {str(e)}"))
|
douyin_logger.error(_msg("😢", f"设置商品链接时出错: {str(e)}"))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def set_self_declaration(self, page: Page, declaration: str = "内容为个人观点或见解") -> None:
|
async def set_self_declaration(self, page: Page, declaration: str) -> bool:
|
||||||
"""抖音「自主声明」为发布必选项:打开声明弹窗 → 选指定类型 → 确定。
|
"""按调用方给出的平台原文选择自主声明;失败返回 False。"""
|
||||||
|
|
||||||
入口和弹窗都是异步渲染,等不到就记 warning 跳过、继续发布,绝不因此中断
|
|
||||||
(与小红书话题、视频号声明原创的容错策略保持一致)。
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# 发布页底部「自主声明」行,未选时显示占位文案「请选择自主声明」
|
# 发布页底部「自主声明」行,未选时显示占位文案「请选择自主声明」
|
||||||
entry = page.get_by_text("请选择自主声明").first
|
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.get_by_role("button", name="确定").click(timeout=6000)
|
||||||
await dialog.wait_for(state="hidden", timeout=6000)
|
await dialog.wait_for(state="hidden", timeout=6000)
|
||||||
douyin_logger.info(_msg("🧾", f"自主声明已选择「{declaration}」"))
|
douyin_logger.info(_msg("🧾", f"自主声明已选择「{declaration}」"))
|
||||||
|
return True
|
||||||
except Exception as exc:
|
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:
|
async def select_bgm(self, page: Page, bgm_name: str) -> bool:
|
||||||
"""为图文发布选择 BGM:可选增强功能,搜索无结果或异常均跳过不中断发布。"""
|
"""为图文发布选择 BGM:可选增强功能,搜索无结果或异常均跳过不中断发布。"""
|
||||||
@@ -532,6 +530,7 @@ class DouYinVideo(DouYinBaseUploader):
|
|||||||
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
publish_strategy: str = DOUYIN_PUBLISH_STRATEGY_IMMEDIATE,
|
||||||
debug: bool = DEBUG_MODE,
|
debug: bool = DEBUG_MODE,
|
||||||
headless: bool = LOCAL_CHROME_HEADLESS,
|
headless: bool = LOCAL_CHROME_HEADLESS,
|
||||||
|
declaration: str | None = None,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
publish_date=publish_date,
|
publish_date=publish_date,
|
||||||
@@ -548,6 +547,13 @@ class DouYinVideo(DouYinBaseUploader):
|
|||||||
self.productLink = productLink
|
self.productLink = productLink
|
||||||
self.productTitle = productTitle
|
self.productTitle = productTitle
|
||||||
self.desc = desc or ""
|
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:
|
async def _submit_sms_verify_code(self, page: Page, sms_input, code: str, code_file: str) -> bool:
|
||||||
douyin_logger.info(_msg("✍️", f"已获取验证码,准备填入: {code}"))
|
douyin_logger.info(_msg("✍️", f"已获取验证码,准备填入: {code}"))
|
||||||
@@ -731,7 +737,18 @@ class DouYinVideo(DouYinBaseUploader):
|
|||||||
|
|
||||||
await self.set_thumbnail(page)
|
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'
|
third_part_element = '[class^="info"] > [class^="first-part"] div div.semi-switch'
|
||||||
if await page.locator(third_part_element).count():
|
if await page.locator(third_part_element).count():
|
||||||
|
|||||||
Reference in New Issue
Block a user