Files
HeXi/tests/test_video_group_file.py
T
sansenhoshiandClaude Code a789a44f5d feat(video-analysis): 抖音免浏览器解析链路 + 快手支持 + 小红书加固 + 发送防重复
- 抖音(2026-10-13):删除 SSR + playwright 旧链路(douyin_ssr.py 移除,
  douyin_api.py 精简为下载工具),新增 douyin_web.py(x-tt-argus 网关头 +
  web detail 接口 + 最高码率梯/多 CDN 回退下载)与 douyin_sign.py
  (纯 Python a_bogus 签名,vendor 自 f2(Apache-2.0),依赖 gmssl SM3);
  实测 10/10 成功率、单作品 <1s(旧浏览器链路 5-15s 且随机撞验证码)
- 快手(2026-10-08):kuaishou_content.py 分享页 INIT_STATE 静态解析(无登录态),
  清晰度梯最佳档 + 图集 URL 组装,入口/策略/Web 面板同步接入
- 小红书:按链接来源定国际/国内站优先序、视频 backupUrls 逐候选回退、
  图片 urlDefault→infoList WB_DFT→url 三档回退
- 长视频防重复发送(2026-10-13):媒体发送带 per-call _timeout(默认 300s),
  超时=结果未知不再降级重试;auto/active 两条 handler 互斥
- Web 解析封面抽帧 ffmpeg 加 CREATE_NO_WINDOW(pythonw 下不再弹 cmd 黑窗)
- 依赖:+gmssl;测试:新增 test_douyin_web / test_kuaishou_parse / test_video_cleanup,
  扩充 test_video_policy / test_video_sender_link / test_video_group_file / test_video_media_naming

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:17 +08:00

370 lines
14 KiB
Python

"""群文件打包/投递 (group_file.py) 单元测试
group_file.py 只依赖标准库 + nonebot.logger,故用 importlib 按文件路径裸加载
(与 test_video_policy.py 同法)。build_archive 一律显式传 out_dir —— 默认目录
要经相对导入取 utils.get_temp_root(),裸加载模块没有包上下文。
"""
import importlib.util
import sys
import zipfile
from pathlib import Path
import pytest
from nonebot.adapters.onebot.v11.exception import NetworkError
_MODULE_PATH = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_video_analysis"
/ "services"
/ "storage"
/ "group_file.py"
)
@pytest.fixture(scope="module")
def gf():
"""以独立模块名加载 group_file.py,避免与插件包 __init__ 冲突"""
spec = importlib.util.spec_from_file_location(
"video_group_file_under_test", _MODULE_PATH
)
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
yield module
sys.modules.pop(spec.name, None)
def _make_files(directory: Path, names: list[str]) -> list[Path]:
directory.mkdir(parents=True, exist_ok=True)
made = []
for name in names:
path = directory / name
path.write_bytes(f"hello {name}".encode())
made.append(path)
return made
def test_plain_archive(gf, tmp_path):
files = _make_files(tmp_path / "src", ["a.txt", "b.txt"])
archive = gf.build_archive(files, "我的作品", out_dir=tmp_path / "out")
assert archive.exists() and archive.suffix == ".zip"
assert "我的作品" in archive.name
with zipfile.ZipFile(archive) as zf:
assert sorted(zf.namelist()) == ["a.txt", "b.txt"]
assert zf.read("a.txt") == b"hello a.txt"
def test_encrypted_archive_requires_password(gf, tmp_path):
pyzipper = pytest.importorskip("pyzipper")
files = _make_files(tmp_path / "src", ["v.mp4"])
archive = gf.build_archive(
files, "加密作品", password="pw123", out_dir=tmp_path / "out"
)
# 标准库打不开加密包
with zipfile.ZipFile(archive) as zf:
with pytest.raises(RuntimeError):
zf.read("v.mp4")
# 正确密码可读,错误密码不行
with pyzipper.AESZipFile(archive) as zf:
zf.setpassword(b"pw123")
assert zf.read("v.mp4") == b"hello v.mp4"
with pyzipper.AESZipFile(archive) as zf:
zf.setpassword(b"wrong")
with pytest.raises(RuntimeError):
zf.read("v.mp4")
def test_duplicate_names_indexed(gf, tmp_path):
first = _make_files(tmp_path / "p1", ["same.txt"])[0]
second = _make_files(tmp_path / "p2", ["same.txt"])[0]
archive = gf.build_archive([first, second], "t", out_dir=tmp_path / "out")
with zipfile.ZipFile(archive) as zf:
assert sorted(zf.namelist()) == ["same.txt", "same_2.txt"]
assert zf.read("same_2.txt") == b"hello same.txt"
def test_output_path_is_unique(gf, tmp_path):
files = _make_files(tmp_path / "src", ["a.txt"])
out = tmp_path / "out"
one = gf.build_archive(files, "t", out_dir=out)
two = gf.build_archive(files, "t", out_dir=out)
assert one != two and one.exists() and two.exists()
def test_placeholder_title_falls_back(gf, tmp_path):
"""universal.py 传的 title 是占位符 "title",不该出现在文件名里"""
files = _make_files(tmp_path / "src", ["a.txt"])
archive = gf.build_archive(files, "title", out_dir=tmp_path / "out")
assert archive.name.startswith("群文件_")
assert "群文件_群文件" not in archive.name
def test_title_with_path_separators_is_sanitized(gf, tmp_path):
"""原始标题(抖音文案/YouTube 标题)可能带 /、换行、#话题,不能进文件名"""
files = _make_files(tmp_path / "src", ["a.txt"])
archive = gf.build_archive(files, "和/或 #话题\n测试", out_dir=tmp_path / "out")
assert "/" not in archive.name and "\\" not in archive.name
assert "\n" not in archive.name and "#" not in archive.name
assert archive.name.startswith("和_或_测试")
def test_title_starting_with_archive_prefix_falls_back(gf, tmp_path):
"""标题本身以「群文件」开头时不要产出 群文件_xxx_群文件_yyy.zip"""
files = _make_files(tmp_path / "src", ["a.txt"])
archive = gf.build_archive(files, "群文件_120606", out_dir=tmp_path / "out")
assert archive.name.startswith("群文件_")
assert "群文件_120606_群文件" not in archive.name
def test_empty_or_missing_input(gf, tmp_path):
with pytest.raises(ValueError):
gf.build_archive([], "t", out_dir=tmp_path / "out")
with pytest.raises(FileNotFoundError):
gf.build_archive([tmp_path / "nope.mp4"], "t", out_dir=tmp_path / "out")
def test_password_without_pyzipper_fails_loud(gf, tmp_path, monkeypatch):
"""设了密码但加密库不可用 → 报错(调用方会放弃上传,绝不能退化传明文)"""
monkeypatch.setattr(gf, "pyzipper", None)
assert gf.encryption_available() is False
files = _make_files(tmp_path / "src", ["a.txt"])
with pytest.raises(RuntimeError):
gf.build_archive(files, "t", password="pw", out_dir=tmp_path / "out")
# 不加密那一路不受影响
assert gf.build_archive(files, "t", out_dir=tmp_path / "out").exists()
# ───────────────────── 上传:URI 直传 + S3 降级 ─────────────────────
def test_file_uri_keeps_unicode_and_uses_slashes(gf, tmp_path):
target = tmp_path / "dir with space" / "群文件_120606.zip"
target.parent.mkdir(parents=True)
target.write_bytes(b"x")
uri = gf._file_uri(target)
assert uri.startswith("file:///")
assert "\\" not in uri
# 中文/空格保持原样(不做百分号转义,NapCat 侧直接当路径解析)
assert uri.endswith("/dir with space/群文件_120606.zip")
async def test_s3_link_is_the_primary_channel(gf, tmp_path, monkeypatch):
"""默认通道就是 S3 链接(本环境本地直传必挂,不先浪费一次调用)"""
calls: list = []
link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1"
async def fake_call(file_value, group_id, name, folder_id=None):
calls.append((file_value, group_id, name))
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link)
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert calls == [(link, 123, "a.zip")]
async def test_s3_call_failure_falls_back_to_local(gf, tmp_path, monkeypatch):
seen: list[str] = []
link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1"
async def fake_call(file_value, group_id, name, folder_id=None):
seen.append(file_value)
if file_value.startswith("http"):
raise RuntimeError("链接不可达")
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link)
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert seen[0] == link
assert seen[1] == gf._file_uri(file)
async def test_no_s3_link_falls_back_to_local(gf, tmp_path, monkeypatch):
seen: list[str] = []
async def fake_call(file_value, group_id, name, folder_id=None):
seen.append(file_value)
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "")
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert seen == [gf._file_uri(file)]
async def test_both_transports_fail(gf, tmp_path, monkeypatch):
async def always_fail(*args, **kwargs):
raise RuntimeError("nope")
monkeypatch.setattr(gf, "_call_upload", always_fail)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "http://x/y.zip")
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is False
async def test_missing_file_short_circuits(gf, tmp_path, monkeypatch):
called = []
monkeypatch.setattr(gf, "_call_upload", lambda *a, **kw: called.append(1))
assert await gf.upload_group_file(tmp_path / "nope.zip", 123) is False
assert called == []
# ────────── 传输超时=结果未知:不降级直传(防两份群文件) ──────────
# 与 handlers/sender.py 同一物理事实:超时只是 bot 不再等待,协议端会把这次
# 上传做完(大文件远超默认 30s);此时再走本地直传就是重复投递。
def test_send_timeout_default_and_env(gf, monkeypatch):
monkeypatch.delenv("video_analysis_send_timeout", raising=False)
monkeypatch.delenv("VIDEO_ANALYSIS_SEND_TIMEOUT", raising=False)
assert gf._send_timeout() == 300.0
monkeypatch.setenv("video_analysis_send_timeout", "77")
assert gf._send_timeout() == 77.0
monkeypatch.setenv("video_analysis_send_timeout", "bad")
assert gf._send_timeout() == 300.0
async def test_upload_call_carries_per_call_timeout(gf, monkeypatch):
"""upload_group_file API 带 `_timeout`(适配器 per-call 超时)。"""
monkeypatch.setenv("video_analysis_send_timeout", "77")
calls: list[dict] = []
class _Bot:
async def call_api(self, api, **kwargs):
calls.append({"api": api, **kwargs})
monkeypatch.setattr(gf, "get_bot", lambda: _Bot())
await gf._call_upload("file:///D:/x.zip", 123, "x.zip")
assert calls == [
{
"api": "upload_group_file",
"group_id": 123,
"file": "file:///D:/x.zip",
"name": "x.zip",
"_timeout": 77.0,
}
]
async def test_s3_timeout_does_not_fall_back_to_local(gf, tmp_path, monkeypatch):
"""S3 链接上传超时=结果未知(协议端可能还在传)→ 不再直传,避免两份。"""
seen: list[str] = []
link = "http://192.168.2.15:5246/PLANA/x.zip?sig=1"
async def fake_call(file_value, group_id, name, folder_id=None):
seen.append(file_value)
raise NetworkError("WebSocket call api upload_group_file timeout")
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: link)
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
assert seen == [link] # 只有 S3 那一次,没有第二次 file:// 直传
async def test_local_upload_timeout_reports_unknown(gf, tmp_path, monkeypatch):
"""本地直传超时同样按「可能已送达」处理(不误报失败即可)。"""
async def fake_call(*args, **kwargs):
raise NetworkError("timeout")
monkeypatch.setattr(gf, "_call_upload", fake_call)
monkeypatch.setattr(gf, "_s3_url", lambda path, policy: "")
file = _make_files(tmp_path / "src", ["a.zip"])[0]
assert await gf.upload_group_file(file, 123) is True
# ───────────────────────── 投递编排 ─────────────────────────
def _record_uploads(gf, monkeypatch, path: Path, ok: bool = True) -> list:
calls: list = []
async def fake_upload(file_path, group_id, **kwargs):
calls.append((str(file_path), group_id))
return ok
monkeypatch.setattr(gf, "upload_group_file", fake_upload)
return calls
async def test_zip_mode_uploads_single_archive(gf, tmp_path, monkeypatch):
archive = tmp_path / "pack.zip"
archive.write_bytes(b"zip")
calls = _record_uploads(gf, monkeypatch, archive)
monkeypatch.setattr(gf, "build_archive", lambda *a, **kw: archive)
files = _make_files(tmp_path / "src", ["a.txt", "b.txt"])
ok = await gf.upload_group_files(files, 123, title="t", password="pw")
assert ok is True
assert calls == [(str(archive), 123)]
async def test_raw_mode_uploads_each_file(gf, tmp_path, monkeypatch):
calls = _record_uploads(gf, monkeypatch, tmp_path)
files = _make_files(tmp_path / "src", ["a.txt", "b.txt"])
ok = await gf.upload_group_files(files, 123, zip_files=False)
assert ok is True
assert [c[0] for c in calls] == [str(p) for p in files]
async def test_raw_mode_prefixes_work_name_for_multi_files(gf, tmp_path, monkeypatch):
"""多图作品逐个传时,001.jpg 这类成员名要带上作品名(单文件不加)"""
seen: list = []
async def fake_upload(file_path, group_id, **kwargs):
seen.append(kwargs.get("name"))
return True
monkeypatch.setattr(gf, "upload_group_file", fake_upload)
images = _make_files(tmp_path / "src", ["001.jpg", "002.jpg"])
await gf.upload_group_files(images, 123, zip_files=False, rel_dir="作者_9/海边日落")
assert seen == ["海边日落_001.jpg", "海边日落_002.jpg"]
seen.clear()
single = _make_files(tmp_path / "src2", ["作品.mp4"])
await gf.upload_group_files(single, 123, zip_files=False, rel_dir="作者_9")
assert seen == ["作品.mp4"] # 单文件时文件名本身就是作品名,不再加前缀
async def test_pack_failure_skips_upload_entirely(gf, tmp_path, monkeypatch):
"""打包失败(例如缺 pyzipper)时不能退化成上传原文件"""
calls = _record_uploads(gf, monkeypatch, tmp_path)
def boom(*args, **kwargs):
raise RuntimeError("pyzipper 未安装")
monkeypatch.setattr(gf, "build_archive", boom)
files = _make_files(tmp_path / "src", ["a.txt"])
ok = await gf.upload_group_files(files, 123, title="t", password="pw")
assert ok is False
assert calls == []
async def test_empty_file_list(gf, tmp_path, monkeypatch):
calls = _record_uploads(gf, monkeypatch, tmp_path)
assert await gf.upload_group_files([], 123) is False
assert calls == []