285 lines
9.1 KiB
Python
285 lines
9.1 KiB
Python
import asyncio
|
|
import os
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import uploader.xiaohongshu_uploader.main as xhs_main
|
|
|
|
|
|
class FakeLocator:
|
|
def __init__(self, name, count=0, src=None, children=None):
|
|
self.name = name
|
|
self._count = count
|
|
self._src = src
|
|
self._children = children or {}
|
|
|
|
@property
|
|
def first(self):
|
|
return self
|
|
|
|
def locator(self, selector):
|
|
return self._children.get(selector, FakeLocator(selector))
|
|
|
|
def get_by_text(self, text, exact=False):
|
|
return self._children.get(f"text:{text}", FakeLocator(text))
|
|
|
|
def filter(self, **kwargs):
|
|
return self
|
|
|
|
def nth(self, index):
|
|
return self
|
|
|
|
async def count(self):
|
|
return self._count
|
|
|
|
async def wait_for(self, **kwargs):
|
|
return None
|
|
|
|
async def get_attribute(self, name):
|
|
if name == "src":
|
|
return self._src
|
|
return None
|
|
|
|
async def fill(self, value):
|
|
return None
|
|
|
|
async def click(self):
|
|
return None
|
|
|
|
|
|
class RecordingKeyboard:
|
|
def __init__(self):
|
|
self.actions = []
|
|
|
|
async def press(self, key):
|
|
self.actions.append(("press", key))
|
|
|
|
async def type(self, text, delay=None):
|
|
self.actions.append(("type", text, delay))
|
|
|
|
|
|
class RecordingLocator(FakeLocator):
|
|
def __init__(self, name):
|
|
super().__init__(name, count=1)
|
|
self.actions = []
|
|
|
|
async def fill(self, value):
|
|
self.actions.append(("fill", value))
|
|
|
|
async def click(self):
|
|
self.actions.append(("click",))
|
|
|
|
async def wait_for(self, **kwargs):
|
|
self.actions.append(("wait_for", kwargs))
|
|
|
|
|
|
class RecordingPage:
|
|
def __init__(self):
|
|
self.keyboard = RecordingKeyboard()
|
|
self.locators = {
|
|
'input[placeholder*="填写标题"]': RecordingLocator("title"),
|
|
'p[data-placeholder*="输入正文描述"]': RecordingLocator("desc"),
|
|
'#creator-editor-topic-container': RecordingLocator("topic-container"),
|
|
'#creator-editor-topic-container .item': RecordingLocator("topic-item"),
|
|
}
|
|
|
|
def locator(self, selector):
|
|
return self.locators[selector]
|
|
|
|
|
|
class XiaohongshuUploaderTests(unittest.TestCase):
|
|
def test_creator_urls_keep_xiaohongshu_domain_by_default(self):
|
|
with patch.dict(os.environ, {"SAU_XHS_CREATOR_BASE_URL": ""}):
|
|
self.assertEqual(
|
|
xhs_main._build_xhs_creator_url("/login"),
|
|
"https://creator.xiaohongshu.com/login",
|
|
)
|
|
|
|
def test_creator_urls_use_configured_rednote_domain(self):
|
|
with patch.dict(
|
|
os.environ,
|
|
{"SAU_XHS_CREATOR_BASE_URL": "https://creator.rednote.com/"},
|
|
):
|
|
self.assertEqual(
|
|
xhs_main._build_xhs_creator_url("/login"),
|
|
"https://creator.rednote.com/login",
|
|
)
|
|
self.assertEqual(
|
|
xhs_main._build_xhs_creator_url(
|
|
"/publish/publish?from=homepage&target=video"
|
|
),
|
|
"https://creator.rednote.com/publish/publish?from=homepage&target=video",
|
|
)
|
|
|
|
def test_find_xhs_qrcode_locator_prefers_scan_sibling_inside_login_box(self):
|
|
qrcode_locator = FakeLocator("qrcode", count=1, src="data:image/png;base64,abc")
|
|
scan_text_locator = FakeLocator(
|
|
"scan-text",
|
|
count=1,
|
|
children={
|
|
"xpath=..//following-sibling::div//img": qrcode_locator,
|
|
},
|
|
)
|
|
login_box_locator = FakeLocator(
|
|
"login-box",
|
|
count=1,
|
|
children={
|
|
"div:has-text('扫一扫')": scan_text_locator,
|
|
"text:APP扫一扫登录": scan_text_locator,
|
|
},
|
|
)
|
|
page = FakeLocator(
|
|
"page",
|
|
children={
|
|
"div[class*='login-box']": login_box_locator,
|
|
".login-box-container": login_box_locator,
|
|
},
|
|
)
|
|
|
|
locator = asyncio.run(xhs_main._find_xhs_qrcode_locator(page))
|
|
self.assertIs(locator, qrcode_locator)
|
|
|
|
def test_setup_returns_detail_when_cookie_invalid_without_handle(self):
|
|
with patch("uploader.xiaohongshu_uploader.main.os.path.exists", return_value=False):
|
|
result = asyncio.run(
|
|
xhs_main.xiaohongshu_setup(
|
|
"missing.json",
|
|
handle=False,
|
|
return_detail=True,
|
|
)
|
|
)
|
|
self.assertFalse(result["success"])
|
|
self.assertEqual(result["status"], "cookie_invalid")
|
|
|
|
def test_setup_uses_login_flow_when_handle_is_true(self):
|
|
login_result = {
|
|
"success": True,
|
|
"status": "success",
|
|
"message": "ok",
|
|
"account_file": "account.json",
|
|
"qrcode": {"image_path": "qrcode.png"},
|
|
"current_url": "https://creator.xiaohongshu.com/",
|
|
}
|
|
with patch("uploader.xiaohongshu_uploader.main.os.path.exists", return_value=False):
|
|
with patch(
|
|
"uploader.xiaohongshu_uploader.main.xiaohongshu_cookie_gen",
|
|
new=AsyncMock(return_value=login_result),
|
|
) as mock_login:
|
|
result = asyncio.run(
|
|
xhs_main.xiaohongshu_setup(
|
|
"account.json",
|
|
handle=True,
|
|
return_detail=True,
|
|
)
|
|
)
|
|
self.assertTrue(result["success"])
|
|
mock_login.assert_awaited_once()
|
|
|
|
def test_video_validate_upload_args_normalizes_video_and_thumbnail(self):
|
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
video_path = Path(tmp_dir) / "demo.mp4"
|
|
thumbnail_path = Path(tmp_dir) / "demo.png"
|
|
cookie_path = Path(tmp_dir) / "account.json"
|
|
video_path.write_bytes(b"video")
|
|
thumbnail_path.write_bytes(b"image")
|
|
cookie_path.write_text("{}")
|
|
|
|
app = xhs_main.XiaoHongShuVideo(
|
|
title="demo",
|
|
file_path=str(video_path),
|
|
tags=["xhs"],
|
|
publish_date=0,
|
|
account_file=str(cookie_path),
|
|
thumbnail_path=str(thumbnail_path),
|
|
)
|
|
|
|
with patch(
|
|
"uploader.xiaohongshu_uploader.main.cookie_auth",
|
|
new=AsyncMock(return_value=True),
|
|
):
|
|
asyncio.run(app.validate_upload_args())
|
|
|
|
self.assertTrue(app.file_path.endswith("demo.mp4"))
|
|
self.assertTrue(app.thumbnail_path.endswith("demo.png"))
|
|
|
|
def test_note_uploader_exists_and_validates_required_fields(self):
|
|
note_cls = getattr(xhs_main, "XiaoHongShuNote")
|
|
app = note_cls(
|
|
image_paths=[],
|
|
note="",
|
|
tags=[],
|
|
publish_date=0,
|
|
account_file="account.json",
|
|
)
|
|
|
|
with patch.object(app, "validate_base_args", new=AsyncMock(return_value=None)):
|
|
with self.assertRaises(ValueError):
|
|
asyncio.run(app.validate_upload_args())
|
|
|
|
def test_video_fill_meta_uses_desc_then_first_tag(self):
|
|
app = xhs_main.XiaoHongShuVideo(
|
|
title="标题内容",
|
|
file_path="demo.mp4",
|
|
tags=["话题1"],
|
|
publish_date=0,
|
|
account_file="account.json",
|
|
desc="描述内容",
|
|
)
|
|
page = RecordingPage()
|
|
|
|
asyncio.run(app.fill_meta(page))
|
|
|
|
self.assertEqual(
|
|
page.locators['input[placeholder*="填写标题"]'].actions,
|
|
[("fill", "标题内容")],
|
|
)
|
|
self.assertEqual(
|
|
page.locators['p[data-placeholder*="输入正文描述"]'].actions,
|
|
[("click",)],
|
|
)
|
|
self.assertIn(("type", "描述内容", None), page.keyboard.actions)
|
|
self.assertIn(("type", "#话题1", 30), page.keyboard.actions)
|
|
self.assertEqual(
|
|
page.locators['#creator-editor-topic-container .item'].actions,
|
|
[("wait_for", {"state": "visible", "timeout": 2000}), ("click",)],
|
|
)
|
|
|
|
def test_video_fill_meta_can_fill_first_tag_without_desc(self):
|
|
app = xhs_main.XiaoHongShuVideo(
|
|
title="标题内容",
|
|
file_path="demo.mp4",
|
|
tags=["话题1"],
|
|
publish_date=0,
|
|
account_file="account.json",
|
|
)
|
|
page = RecordingPage()
|
|
|
|
asyncio.run(app.fill_meta(page))
|
|
|
|
self.assertEqual(
|
|
page.locators['p[data-placeholder*="输入正文描述"]'].actions,
|
|
[("click",)],
|
|
)
|
|
self.assertNotIn(("type", "", None), page.keyboard.actions)
|
|
self.assertIn(("type", "#话题1", 30), page.keyboard.actions)
|
|
|
|
def test_note_title_defaults_do_not_override_explicit_title(self):
|
|
app = xhs_main.XiaoHongShuNote(
|
|
image_paths=["a.png"],
|
|
note="正文",
|
|
tags=[],
|
|
publish_date=0,
|
|
account_file="account.json",
|
|
title="显式标题",
|
|
desc="图文正文",
|
|
)
|
|
|
|
self.assertEqual(app.title, "显式标题")
|
|
self.assertEqual(app.desc, "图文正文")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|