Files
HeXi/tests/test_kuaishou_parse.py
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

303 lines
11 KiB
Python

"""快手分享页解析(kuaishou_content.py 纯函数)单元测试
只测不碰网络的解析/命名部分:INIT_STATE 提取、视频/图集 URL 组装、媒体命名。
样本仿自 2026-10-08 实测的分享页(v.kuaishou.com 短链 → v.m.chenzhongtech.com/fw/photo)。
kuaishou_content.py 有插件内相对导入(`...models` / `...utils`),无法裸加载,
故在 sys.modules 里预置父包替身 + 真实加载 utils/models 后按路径加载目标模块
(与 test_video_sender_link.py 同法)。
"""
import importlib.util
import json
import sys
import types
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parents[1]
_PLUGIN = _REPO / "hexi" / "plugins" / "nonebot_plugin_video_analysis"
_PKG = "hexi.plugins.nonebot_plugin_video_analysis"
#: 真实加载的插件内模块(utils 纯标准库、models 只有一个异常类)
_REAL_MODULES = {
f"{_PKG}.models": _PLUGIN / "models.py",
f"{_PKG}.utils": _PLUGIN / "utils.py",
}
#: 只当命名空间用的父包(避免触发插件包 __init__ 的 NoneBot 初始化)
_STUB_PACKAGES = (_PKG, f"{_PKG}.services", f"{_PKG}.services.fetchers")
def _load_real(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def ks():
saved = {name: sys.modules.get(name) for name in _STUB_PACKAGES}
for name in _STUB_PACKAGES:
sys.modules[name] = types.ModuleType(name)
for name, path in _REAL_MODULES.items():
saved[name] = sys.modules.get(name)
_load_real(name, path)
target = f"{_PKG}.services.fetchers.kuaishou_content"
saved[target] = sys.modules.get(target)
module = _load_real(target, _PLUGIN / "services" / "fetchers" / "kuaishou_content.py")
yield module
for name, previous in saved.items():
if previous is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = previous
@pytest.fixture(scope="module")
def u(ks):
"""插件 utils(命名断言用同一套函数,别在测试里手拼)"""
return sys.modules[f"{_PKG}.utils"]
# ───────────────────────── 样本 ─────────────────────────
KEY = "string/rest/wd/ugH5App/photo/simple/info@kpn>KUAISHOU"
def _photo(**over) -> dict:
photo = {
"caption": "欧巴欧巴哦 #蔚蓝档案 #砂狼白子",
"userName": "砂狼白子",
"userId": 2329823245,
"photoId": "5206161376908999738",
"duration": 15150,
"timestamp": 1789916802288,
"coverUrls": [
{"cdn": "p2.a.yximgs.com", "url": "https://p2.a.yximgs.com/cover.jpg"}
],
"mainMvUrls": [
{"cdn": "tymov2.a.kwimgs.com", "url": "https://tymov2.a.kwimgs.com/a_b.mp4"},
{"cdn": "txmov2.a.kwimgs.com", "url": "https://txmov2.a.kwimgs.com/a_b.mp4"},
],
"ext_params": {"mtype": 3, "video": 15150},
}
photo.update(over)
return photo
def _page(data: dict) -> str:
payload = json.dumps(data, ensure_ascii=False)
return f"<!doctype html><html><script>window.INIT_STATE={payload}</script></html>"
def _page_with(photo: dict | None, prelude: dict | None = None) -> str:
data = dict(prelude or {})
data[KEY] = {"result": 1, "photo": photo}
return _page(data)
# ───────────────────────── INIT_STATE 提取 ─────────────────────────
def test_extract_photo_skips_empty_entries(ks):
"""INIT_STATE 是一堆缓存键的 dict,photo 在第一个非空项里(顺序不定)"""
page = _page(
{
"some/cache/key": {"result": 1, "photo": None},
"another/key": {"result": 2},
KEY: {"result": 1, "photo": _photo()},
}
)
assert ks._extract_photo(page)["photoId"] == "5206161376908999738"
def test_extract_photo_tolerates_js_literals(ks):
"""页面偶有 undefined 之类非 JSON 字面量(同小红书预处理)"""
page = (
"<script>window.INIT_STATE = "
'{"k": {"result": 1, "photo": {"photoId": "1", "userName": undefined}}}'
"</script>"
)
photo = ks._extract_photo(page)
assert photo["photoId"] == "1"
assert photo["userName"] is None
def test_extract_photo_missing_state(ks):
"""风控壳页(桌面 UA / www 站)没有 INIT_STATE → 报错而不是静默返回空"""
err = sys.modules[f"{_PKG}.models"].ContentFetchError
with pytest.raises(err):
ks._extract_photo("<html><body>risk control</body></html>")
def test_extract_photo_without_photo(ks):
"""INIT_STATE 在但没有作品(已删除/私密)→ 报错"""
err = sys.modules[f"{_PKG}.models"].ContentFetchError
with pytest.raises(err):
ks._extract_photo(_page({"k": {"result": 2, "photo": None}}))
# ───────────────────────── 媒体 URL ─────────────────────────
def test_first_url_prefers_first_available(ks):
assert ks._first_url([{"cdn": "a", "url": "https://a/1.mp4"}]) == "https://a/1.mp4"
# 首项没有 url(快手同一资源的备用项可能缺字段)时继续往后找
assert ks._first_url([{"cdn": "a"}, {"url": "https://b/2.mp4"}]) == "https://b/2.mp4"
assert ks._first_url([]) is None
assert ks._first_url(None) is None
def _rep(width, height, fps, codec, bitrate, url, **over) -> dict:
rep = {
"width": width,
"height": height,
"frameRate": fps,
"videoCodec": codec,
"avgBitrate": bitrate,
"url": url,
}
rep.update(over)
return rep
def test_manifest_video_url_picks_highest_quality(ks):
"""清晰度梯取最优:先比分辨率,再帧率、编码(avc 优先)、码率"""
photo = _photo(
manifest={
"adaptationSet": [
{
"representation": [
# 1080p 30fps hevc 也要赢过 720p 60fps avc(分辨率优先)
_rep(1920, 1080, 30, "hevc", 2000, "https://c/1080.mp4"),
_rep(1280, 720, 60, "avc", 1597, "https://c/720p60.mp4"),
_rep(1280, 720, 30, "hevc", 726, "https://c/720p30.mp4"),
]
}
]
}
)
assert ks._manifest_video_url(photo) == "https://c/1080.mp4"
def test_manifest_video_url_prefers_avc_on_tie(ks):
"""同分辨率同帧率 → avc 优先(兼容性),hevc 只在画质更高时才选"""
photo = _photo(
manifest={
"adaptationSet": [
{
"representation": [
_rep(1280, 720, 60, "avc", 1597, "https://c/avc.mp4"),
# 码率更高但编码是 hevc,仍输给 avc
_rep(1280, 720, 60, "hevc", 2000, "https://c/hevc.mp4"),
]
}
]
}
)
assert ks._manifest_video_url(photo) == "https://c/avc.mp4"
def test_manifest_video_url_skips_broken_entries(ks):
"""缺 url / 结构不对的档跳过,不因为一条脏数据整体失败"""
photo = _photo(
manifest={
"adaptationSet": [
{"representation": [{"width": 1920, "height": 1080}, "junk"]},
{"representation": [_rep(1280, 720, 30, "avc", 900, "https://c/720.mp4")]},
]
}
)
assert ks._manifest_video_url(photo) == "https://c/720.mp4"
assert ks._manifest_video_url(_photo()) is None
assert ks._manifest_video_url(_photo(manifest={"adaptationSet": []})) is None
def test_video_url_falls_back_to_main_mv_urls(ks):
"""老页面没有 manifest → 退回 mainMvUrls(参考实现的默认档路径)"""
assert ks._video_url(_photo()) == "https://tymov2.a.kwimgs.com/a_b.mp4"
photo = _photo(
manifest={"adaptationSet": [{"representation": [_rep(1920, 1080, 30, "avc", 2000, "https://c/best.mp4")]}]}
)
assert ks._video_url(photo) == "https://c/best.mp4"
def test_atlas_image_urls(ks):
photo = _photo(
ext_params={
"atlas": {
"cdnList": [{"cdn": "p1.a.yximgs.com"}, {"cdn": "p2.a.yximgs.com"}],
"list": ["upic/a.jpg", "upic/b.jpg"],
}
}
)
assert ks._atlas_image_urls(photo) == [
"https://p1.a.yximgs.com/upic/a.jpg",
"https://p1.a.yximgs.com/upic/b.jpg",
]
def test_atlas_image_urls_absent(ks):
"""视频作品的 ext_params 里没有 atlas → 不是图集"""
assert ks._atlas_image_urls(_photo()) == []
assert ks._atlas_image_urls(_photo(ext_params={})) == []
# 有 atlas 但缺图床/路径 → 空(宁可当视频处理也不要拼出坏链接)
assert ks._atlas_image_urls(_photo(ext_params={"atlas": {"list": ["a.jpg"]}})) == []
assert ks._atlas_image_urls(
_photo(ext_params={"atlas": {"cdnList": [{"cdn": "c"}]}})
) == []
# 老字段名 extParams 也认(ext_params 缺失时才会去看它)
assert ks._atlas_image_urls(
_photo(
ext_params=None,
extParams={"atlas": {"cdnList": [{"cdn": "c"}], "list": ["a.jpg"]}},
)
) == ["https://c/a.jpg"]
# ───────────────────────── 命名 ─────────────────────────
def test_rel_stem_uses_author_id(ks, u):
"""有 userId(数字)→ 稳定作者目录,标题去掉 #话题"""
assert ks._build_rel_stem(_photo()) == "砂狼白子_2329823245/欧巴欧巴哦"
def test_rel_stem_without_author(ks, u):
"""昵称是兜底串("快手用户")→ 按"未知作者"规则 + 作品来源码"""
stem = ks._build_rel_stem(_photo(userName="快手用户", userId=0))
code = u.short_source_code("5206161376908999738")
assert stem == f"未知作者_{code}/欧巴欧巴哦"
def test_rel_stem_empty_caption(ks, u):
"""标题只有 #话题(slugify 后为空)→ 作品_来源码,同一作品稳定可复现"""
stem = ks._build_rel_stem(_photo(caption="#蔚蓝档案"))
code = u.short_source_code("5206161376908999738")
assert stem == f"砂狼白子_2329823245/作品_{code}"
# ───────────────────────── URL 识别 ─────────────────────────
def test_web_short_video_regex(ks):
"""www 站形态(分享出来的网页链接,实际会重写成移动端分享页)"""
for url, expected in (
("https://www.kuaishou.com/short-video/3xhjgcmir24m4nm", "3xhjgcmir24m4nm"),
("https://www.kuaishou.com/short-video/5206161376908999738", "5206161376908999738"),
("https://kuaishou.com/short-video/abc123", "abc123"),
):
m = ks.WEB_SHORT_VIDEO_RE.search(url)
assert m and m.group(1) == expected
def test_short_link_regex(ks):
m = ks.SHORT_LINK_RE.search("看看这个 https://v.kuaishou.com/nUqhAiDI 好看")
assert m and m.group(0) == "v.kuaishou.com/nUqhAiDI"