- 抖音(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>
370 lines
14 KiB
Python
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 == []
|