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>
This commit is contained in:
1 parent
a31aab00e8
commit
a789a44f5d
25 files changed
+3508
-867
No files matched your search
@@ -17,6 +17,7 @@ hexi/data/temp 是下载媒体中转区(见 utils.get_temp_root),发送成功后
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
@@ -25,9 +26,11 @@ from nonebot.adapters.onebot.v11 import Bot, MessageEvent
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hexi.core.custom_utils import check_manage
|
||||
from hexi.core.task_slots import TASKS, Task
|
||||
|
||||
require("nonebot_plugin_apscheduler")
|
||||
from nonebot_plugin_apscheduler import scheduler
|
||||
from hexi.core.scheduler import add_job
|
||||
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
@@ -101,30 +104,59 @@ def _prune_empty_dirs(root: Path, max_age: float) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def clean_temp_files(sub: str = "", days: int | None = None) -> tuple[int, int]:
|
||||
def clean_temp_files(
|
||||
sub: str = "",
|
||||
days: int | None = None,
|
||||
on_progress: Callable[..., None] | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""清理 temp[/sub] 下超过期限的文件与随之空掉的目录。
|
||||
|
||||
Args:
|
||||
on_progress: 进度回调 `(done, total, phase, message, warning)`(关键字可省)——
|
||||
扫描阶段 total 未知,之后按"处理过的文件数"上报。跑在哪个线程调用都行
|
||||
(任务槽那边会切回事件循环)。**返回 False 表示调用方请求取消**,
|
||||
本函数会在处理完当前文件后尽早收尾(清理天然可中断,不硬杀线程)。
|
||||
|
||||
Returns:
|
||||
(removed, total) — 删除的文件数、统计到的文件总数
|
||||
(removed, total) — 删除的文件数、统计到的文件数(被取消时是"取消前"的进度)
|
||||
"""
|
||||
|
||||
def report(**kwargs) -> bool:
|
||||
if on_progress is None:
|
||||
return True # 没有槽(纯函数调用)时不存在取消
|
||||
return bool(on_progress(**kwargs))
|
||||
|
||||
root = get_temp_root(sub)
|
||||
if not root.is_dir():
|
||||
return 0, 0
|
||||
|
||||
max_age = _max_age_seconds(days)
|
||||
report(phase="扫描中", unit="个文件", message="正在统计文件…")
|
||||
files = _walk_files(root)
|
||||
report(
|
||||
done=0, total=len(files), phase="清理中",
|
||||
unit="个文件", message=f"共 {len(files)} 个文件",
|
||||
)
|
||||
|
||||
removed = 0
|
||||
for path in files:
|
||||
if not _file_is_stale(path, max_age):
|
||||
continue
|
||||
canceled = False
|
||||
for index, path in enumerate(files, 1):
|
||||
if _file_is_stale(path, max_age):
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
logger.info(f"temp 清理: 删除 {path}")
|
||||
except OSError as e:
|
||||
# 文件被占用(如发送中)等场景,留待下轮
|
||||
# 文件被占用(如发送中)等场景,留待下轮 —— 也算"部分失败",报给任务槽
|
||||
logger.warning(f"temp 清理: 跳过 {path} ({e})")
|
||||
report(warning=f"跳过 {path.name}(被占用?):{e}")
|
||||
if not report(done=index, total=len(files), message=f"已删除 {removed} 个"):
|
||||
logger.info("temp 清理: 收到取消请求,提前结束")
|
||||
canceled = True
|
||||
break
|
||||
|
||||
if not canceled:
|
||||
report(phase="回收空目录", done=len(files), total=len(files))
|
||||
dirs = _prune_empty_dirs(root, max_age)
|
||||
if dirs:
|
||||
logger.info(f"temp 清理: 同时收掉 {dirs} 个空目录")
|
||||
@@ -165,7 +197,27 @@ async def _handle_clean(bot: Bot, event: MessageEvent):
|
||||
except ValueError:
|
||||
await UniMessage.text("天数参数不合法,示例:/清理temp 3").send()
|
||||
return
|
||||
removed, total = await _run_clean(days)
|
||||
task, reused = submit_clean(
|
||||
days,
|
||||
title="清理 temp(命令)",
|
||||
trigger="command",
|
||||
actor=event.get_user_id(),
|
||||
group_id=getattr(event, "group_id", "") or "",
|
||||
)
|
||||
if reused:
|
||||
await UniMessage.text(
|
||||
"已经有一个清理任务在跑了,等它跑完再来吧~(进度可在网页端看)"
|
||||
).send()
|
||||
return
|
||||
await TASKS.wait(task) # 命令要"跑完再回复",与网页端的即提交即返回不同
|
||||
|
||||
if task.status == "canceled":
|
||||
await UniMessage.text("清理被取消了(网页端点过取消)。").send()
|
||||
return
|
||||
if task.status != "done":
|
||||
await UniMessage.text(f"清理失败了:{task.error}").send()
|
||||
return
|
||||
removed, total = task.result or (0, 0)
|
||||
if total == 0:
|
||||
await UniMessage.text("temp 目录目前是空的,没什么可清理的~").send()
|
||||
else:
|
||||
@@ -174,7 +226,7 @@ async def _handle_clean(bot: Bot, event: MessageEvent):
|
||||
|
||||
@stats_cmd.handle()
|
||||
async def _handle_stats(event: MessageEvent):
|
||||
st = await _run_stats()
|
||||
st = await run_stats()
|
||||
if st["files"] == 0:
|
||||
if st.get("dirs"):
|
||||
await UniMessage.text(
|
||||
@@ -191,13 +243,37 @@ async def _handle_stats(event: MessageEvent):
|
||||
).send()
|
||||
|
||||
|
||||
async def _run_clean(days: int | None = None) -> tuple[int, int]:
|
||||
"""执行清理并记录日志(供命令与定时任务共用)"""
|
||||
removed, total = await asyncio.to_thread(clean_temp_files, "", days)
|
||||
return removed, total
|
||||
#: 任务槽名:命令 / Web / 每日定时三条路共用同一个槽,同槽只跑一个(防重复触发)
|
||||
CLEANUP_SLOT = "video_analysis:temp_cleanup"
|
||||
|
||||
|
||||
async def _run_stats() -> dict:
|
||||
def submit_clean(
|
||||
days: int | None,
|
||||
*,
|
||||
title: str,
|
||||
trigger: str = "",
|
||||
actor: str = "",
|
||||
group_id: str = "",
|
||||
) -> tuple[Task, bool]:
|
||||
"""把清理提交到任务槽,立即返回 (任务, 是否复用了正在跑的任务)。
|
||||
|
||||
任务体跑在线程池里(见 hexi.core.task_slots),进度实时推给网页端;
|
||||
同槽已有任务时不会重复清理 —— 服务端这一层才是"防连点"的真正保证。
|
||||
trigger / actor / group_id 是审计信息(谁点的:web 用户名 / command 的 QQ 号)。
|
||||
"""
|
||||
return TASKS.submit(
|
||||
slot=CLEANUP_SLOT,
|
||||
title=title,
|
||||
plugin_id="video_analysis",
|
||||
trigger=trigger,
|
||||
actor=actor,
|
||||
group_id=group_id,
|
||||
func=lambda report: clean_temp_files(days=days, on_progress=report),
|
||||
)
|
||||
|
||||
|
||||
async def run_stats() -> dict:
|
||||
"""异步统计(命令 / Web 缓存面板共用)"""
|
||||
return await asyncio.to_thread(temp_stats)
|
||||
|
||||
|
||||
@@ -213,7 +289,7 @@ def _register_auto_job() -> None:
|
||||
from apscheduler.jobstores.base import JobLookupError # noqa: PLC0415
|
||||
|
||||
if cleanup_config.video_analysis_temp_cleanup_mode == "auto":
|
||||
scheduler.add_job(
|
||||
add_job(
|
||||
_scheduled_cleanup,
|
||||
"cron",
|
||||
hour=cleanup_config.video_analysis_temp_cleanup_hour,
|
||||
@@ -239,13 +315,18 @@ def reload_cleanup_config() -> None:
|
||||
|
||||
|
||||
async def _scheduled_cleanup() -> None:
|
||||
"""每日定时清理 temp 目录(auto 模式)"""
|
||||
"""每日定时清理 temp 目录(auto 模式);同样走任务槽(与手动清理互斥)"""
|
||||
logger.info("temp 自动清理: 开始")
|
||||
try:
|
||||
removed, total = await asyncio.to_thread(clean_temp_files)
|
||||
task, reused = submit_clean(None, title="清理 temp(每日定时)", trigger="schedule")
|
||||
if reused:
|
||||
logger.info("temp 自动清理: 已有清理任务在跑,跳过本次")
|
||||
return
|
||||
await TASKS.wait(task)
|
||||
if task.status != "done":
|
||||
logger.error(f"temp 自动清理未完成({task.status}): {task.error}")
|
||||
return
|
||||
removed, total = task.result or (0, 0)
|
||||
logger.info(f"temp 自动清理完成:删除 {removed} 个,当前共 {total} 个文件")
|
||||
except Exception as e:
|
||||
logger.exception(f"temp 自动清理失败: {e}")
|
||||
|
||||
|
||||
# 插件加载时按模式注册(auto)或提示(manual)
|
||||
|
||||
@@ -1,109 +1,32 @@
|
||||
"""抖音视频/图文解析编排层"""
|
||||
"""抖音视频/图文解析编排层
|
||||
|
||||
链路(2026-10 起,免浏览器):短链/全链接 → `douyin_web.fetch_douyin_web`
|
||||
(短链重定向、签名请求 web detail 接口、最高码率/最高画质下载都在该模块内)。
|
||||
旧链路(iesdouyin SSR 静态解析 + playwright 浏览器拦截)已废弃,
|
||||
详见 services/fetchers/douyin_web.py 顶部说明。
|
||||
"""
|
||||
|
||||
import re
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
import httpx
|
||||
from nonebot import logger
|
||||
|
||||
from ..services.fetchers.douyin_api import fetch_douyin_content
|
||||
from ..services.fetchers.douyin_ssr import MOBILE_UA, fetch_douyin_note_ssr
|
||||
from ..models import DouyinFetchError
|
||||
from ..services.fetchers.douyin_web import fetch_douyin_web
|
||||
from ..utils import get_data_dir, parse_netscape_cookies
|
||||
from ..policy import Policy
|
||||
from .sender import PendingMedia, _as_paths
|
||||
|
||||
SHORT_LINK_PATTERN = re.compile(r"(v\.douyin\.com/[A-Za-z0-9_\-]+)")
|
||||
|
||||
|
||||
async def _resolve_short_link(url: str) -> Optional[str]:
|
||||
"""短链重定向:httpx 取 Location(最多 3 跳),失败返回 None"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers={"User-Agent": MOBILE_UA},
|
||||
follow_redirects=False,
|
||||
timeout=10,
|
||||
) as client:
|
||||
current = url
|
||||
for _ in range(3):
|
||||
resp = await client.get(current)
|
||||
if resp.status_code >= 400:
|
||||
return None
|
||||
location = resp.headers.get("Location")
|
||||
if not location:
|
||||
final = str(resp.url)
|
||||
# 200 但仍是短链本身(如 JS 挑战壳页)→ 静默失败,显式记录;
|
||||
# 图文短链若此处失败将退化到 playwright 旧链路
|
||||
if "v.douyin.com" in final:
|
||||
logger.warning(f"短链返回挑战页(未跳转): {final}")
|
||||
return final
|
||||
# Location 可能是相对路径,需拼上当前 URL
|
||||
current = urllib.parse.urljoin(current, location)
|
||||
return current
|
||||
except Exception:
|
||||
logger.warning(f"短链重定向失败: {url}")
|
||||
return None
|
||||
|
||||
|
||||
async def parse_douyin(
|
||||
url: str,
|
||||
) -> tuple[Optional[str], Optional[Union[Path, list[Path]]], bool]:
|
||||
"""解析抖音链接(短链 / 全链接),返回 (title, file_paths, is_image_post)
|
||||
|
||||
分派规则(不能依赖 URL 路径:短链重定向后图文也统一变成
|
||||
iesdouyin.com/share/video/{id} 形态):
|
||||
- note 形态链接 → SSR 静态解析,失败不回退 playwright
|
||||
(其图文链路存在 aweme/post 取到作者其他作品的缺陷)
|
||||
- 其余形态 → 先试 SSR 按内容判定:有 images 即图文 → SSR 秒级下载;
|
||||
真视频 / SSR 失败 → playwright 拦截(保留全部清晰度能力)
|
||||
"""
|
||||
# 优先匹配短链接 v.douyin.com/xxx
|
||||
short = SHORT_LINK_PATTERN.search(url)
|
||||
if short:
|
||||
target_url = "https://" + short.group(1)
|
||||
# 短链先重定向拿到全链接,确定图文/视频类型后分派
|
||||
resolved = await _resolve_short_link(target_url)
|
||||
if resolved:
|
||||
logger.info(f"短链重定向: {target_url} -> {resolved}")
|
||||
target_url = resolved
|
||||
else:
|
||||
# 全链接: www.douyin.com/note/xxx、www.douyin.com/video/xxx,
|
||||
# 以及抖音复制出的 iesdouyin.com/share/video|note/xxx(弱网/无网下 App
|
||||
# 生成的恢复连接同样会是这个形态)。统一提取出作品 id 后再交给 SSR / playwright。
|
||||
m = re.search(
|
||||
r"(https?://(?:www\.)?(?:m\.)?(?:ies)?douyin\.com/(?:share/)?(note|video)/(\d+))",
|
||||
url,
|
||||
)
|
||||
if not m:
|
||||
return None, None, False
|
||||
# 统一规范化为 www.douyin.com/{note|video}/{id},避免 iesdouyin 分享页
|
||||
# 在无登录态/弱网下只返回壳页,直接落到浏览器可正常打开的作品页。
|
||||
target_url = f"https://www.douyin.com/{m.group(2)}/{m.group(3)}"
|
||||
|
||||
"""解析抖音链接(短链 / 全链接),返回 (title, file_paths, is_image_post)"""
|
||||
try:
|
||||
is_img_post = False
|
||||
cookies_path = get_data_dir() / "cookies.txt"
|
||||
cookies = parse_netscape_cookies(cookies_path)
|
||||
# 先试 SSR(秒级、免浏览器),失败一律回退 playwright 链路。
|
||||
# 2026-08-13 起抖音对 iesdouyin SSR 端点整体降级(登录态也拿不到
|
||||
# videoInfoRes,只返回 33KB 壳页),note/video 形态统一回退,
|
||||
# 图文作品的"取到作者其他作品"缺陷由 douyin_api 按 aweme_id 精确匹配修复。
|
||||
try:
|
||||
title, file_path = await fetch_douyin_note_ssr(target_url, cookies)
|
||||
except DouyinFetchError:
|
||||
logger.info("SSR 解析失败,回退 playwright 链路")
|
||||
title, file_path = None, None
|
||||
if file_path is None:
|
||||
title, file_path = await fetch_douyin_content(
|
||||
target_url, cookies, 10, False
|
||||
)
|
||||
if isinstance(file_path, list):
|
||||
is_img_post = True
|
||||
return title, file_path, is_img_post
|
||||
return await fetch_douyin_web(url, cookies)
|
||||
except Exception:
|
||||
logger.exception(f"获取直链失败 {target_url}")
|
||||
logger.exception(f"获取直链失败 {url}")
|
||||
return None, None, False
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,11 @@ from nonebot.rule import to_me
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
from nonebot_plugin_alconna.uniseg import get_target
|
||||
|
||||
from ..services.fetchers.bilibili_content import fetch_bilibili_content, resolve_short_link
|
||||
from ..services.fetchers.bilibili_content import (
|
||||
fetch_bilibili_content,
|
||||
resolve_short_link,
|
||||
)
|
||||
from ..services.fetchers.kuaishou_content import fetch_kuaishou_content
|
||||
from ..services.fetchers.rednote_content import fetch_rednote_content
|
||||
from .douyin import parse_douyin, process_douyin_res
|
||||
from .sender import PendingMedia, send_pending_media
|
||||
@@ -32,6 +36,8 @@ VALID_HOSTS = [
|
||||
"iesdouyin.com",
|
||||
"m.douyin.com",
|
||||
"jingxuan.douyin.com",
|
||||
"kuaishou.com",
|
||||
"chenzhongtech.com",
|
||||
"x.com",
|
||||
"twitter.com",
|
||||
"xiaohongshu.com",
|
||||
@@ -39,7 +45,16 @@ VALID_HOSTS = [
|
||||
"xhslink.cn",
|
||||
]
|
||||
|
||||
auto_video_handler = on_message(priority=10, block=False)
|
||||
async def _not_to_me(event: Event) -> bool:
|
||||
"""不是 @bot / 昵称开头的消息(`to_me()` 的反面;Rule 没有 `__invert__`)。"""
|
||||
return not event.is_tome()
|
||||
|
||||
|
||||
# auto 与 active 是互斥的两条路:@bot(或昵称开头)的消息归 active 管,其余归
|
||||
# auto。两个 matcher 都是 block=False(不拦其它插件),若 auto 不排除 to_me,
|
||||
# 一条 @bot 的链接消息会同时命中两个 handler → 解析发送两遍(长视频要等几分钟,
|
||||
# 重复非常显眼;2026-10-13 排查发送重复时发现)。
|
||||
auto_video_handler = on_message(priority=10, block=False, rule=_not_to_me)
|
||||
active_video_handler = on_message(priority=10, block=False, rule=to_me())
|
||||
|
||||
|
||||
@@ -203,14 +218,41 @@ async def dispatch_url(
|
||||
logger.warning(f"媒体解析:未能获取到作品:{url}")
|
||||
return None, None
|
||||
return await process_douyin_res(
|
||||
title, parsed_path, is_private, image_post,
|
||||
policy=policy, platform=platform,
|
||||
title,
|
||||
parsed_path,
|
||||
is_private,
|
||||
image_post,
|
||||
policy=policy,
|
||||
platform=platform,
|
||||
)
|
||||
except Exception:
|
||||
await UniMessage.text(f"无法解析到媒体:{url}").send()
|
||||
logger.exception(f"媒体解析:无法解析到媒体:{url}")
|
||||
return None, None
|
||||
|
||||
if "kuaishou.com" in url or "chenzhongtech.com" in url:
|
||||
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
|
||||
try:
|
||||
title, parsed_path = await fetch_kuaishou_content(url)
|
||||
if not title and parsed_path is None:
|
||||
await UniMessage.text(f"无法解析到媒体:{url}").send()
|
||||
return None, None
|
||||
if parsed_path == []:
|
||||
await UniMessage.text(title).send()
|
||||
return None, None
|
||||
return await process_douyin_res(
|
||||
title,
|
||||
parsed_path,
|
||||
is_private,
|
||||
isinstance(parsed_path, list),
|
||||
policy=policy,
|
||||
platform=platform,
|
||||
)
|
||||
except Exception:
|
||||
await UniMessage.text(f"无法解析到媒体:{url}").send()
|
||||
logger.exception(f"快手解析:无法解析:{url}")
|
||||
return None, None
|
||||
|
||||
if any(
|
||||
kw in url
|
||||
for kw in (
|
||||
@@ -230,8 +272,12 @@ async def dispatch_url(
|
||||
await UniMessage.text(title).send()
|
||||
return None, None
|
||||
return await process_douyin_res(
|
||||
title, parsed_path, is_private,
|
||||
isinstance(parsed_path, list), policy=policy, platform=platform,
|
||||
title,
|
||||
parsed_path,
|
||||
is_private,
|
||||
isinstance(parsed_path, list),
|
||||
policy=policy,
|
||||
platform=platform,
|
||||
)
|
||||
except Exception:
|
||||
await UniMessage.text(f"无法解析到内容:{url}").send()
|
||||
@@ -249,8 +295,12 @@ async def dispatch_url(
|
||||
await UniMessage.text(title).send()
|
||||
return None, None
|
||||
return await process_douyin_res(
|
||||
title, parsed_path, is_private,
|
||||
isinstance(parsed_path, list), policy=policy, platform=platform,
|
||||
title,
|
||||
parsed_path,
|
||||
is_private,
|
||||
isinstance(parsed_path, list),
|
||||
policy=policy,
|
||||
platform=platform,
|
||||
)
|
||||
except Exception:
|
||||
await UniMessage.text(f"无法解析到内容:{url}").send()
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
"""媒体多级发送 — temp 本地文件优先,失败逐级降级
|
||||
"""媒体多级发送 — 本地优先,归档与发送解耦(并行),失败逐级降级
|
||||
|
||||
发送策略(2026-08-22 用户需求):
|
||||
1. temp 本地文件直接发送(最快,不经 S3)
|
||||
2. 失败 → 上传本地 S3,用预签名链接发送
|
||||
2. 失败 → 用**已归档**的 S3 预签名链接发送
|
||||
3. 再失败 → 回退 temp 本地文件再发一次
|
||||
|
||||
**归档是前提,不是发送的副产品(2026-09-25 用户明确)**:媒体一旦下载成功就要
|
||||
进本地 S3——与发送走哪一级、成功与否**无关**。因此 `archive_media` 在
|
||||
`send_pending_media` 一进来就无条件开跑,与本地直发**并行**:本地发送不等它
|
||||
(保住最快那条路),但它一定会跑完(发送全失败也照传)。原因:temp 只留 7 天
|
||||
(cleanup.py 删),桶里那份才是长期存档;顺带产出公网下载链接(`upload_public`
|
||||
开着时),「发送下载链接」策略直接用它。每个文件只上传一次——第 2 级用的就是
|
||||
归档的预签名链接,没有"补传"这一步。
|
||||
|
||||
本地直发要真的通,得让协议端能拿到文件:2026-09-22 起由 `hexi/core/outbound_media`
|
||||
的全局钩子把本地路径改写成 `/media/<token>` 链接(此前本地那级因协议端不在本机、
|
||||
`file://` 必然 ENOENT 而恒失败,实际每次都落到第 2 级 S3)。因此**媒体不再进 S3
|
||||
归档是有意的**——只有群策略真要发下载链接(`policy.sends_link`)时才补传一次拿
|
||||
public_url,见 `_local_sent`。
|
||||
`file://` 必然 ENOENT 而恒失败,实际每次都落到第 2 级 S3)。注意那条 `/media`
|
||||
链接**只给协议端拉取用**(TTL 300s、bot 自己的路由),不能当下载链接发给群友。
|
||||
|
||||
多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图)
|
||||
等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错
|
||||
@@ -20,15 +27,25 @@ public_url,见 `_local_sent`。
|
||||
传到群文件(默认打包成一个 zip,可配解压密码,见 config.group_file_settings),
|
||||
失败只记日志、不影响发送结果(打包/上传实现在 services/storage/group_file.py)。
|
||||
|
||||
**超时=结果未知,绝不降级重试**(2026-10-13):NoneBot 默认 `api_timeout=30s`,
|
||||
而长视频在协议端要「下载 + 转存到 QQ」,实测 172MB 视频约 2 分钟。超时只是 bot
|
||||
不再等待响应——**协议端会把这次发送做完**,于是三级降级把同一个视频送了三份
|
||||
(群里收到三份 + 一条"媒体发送失败")。修法有两半:发送调用带 `_timeout`
|
||||
(`send_timeout()`,默认 300s,`video_analysis_send_timeout` 可调)等它真正
|
||||
发完;万一仍超时则按「可能已送达」处理——停在这一级、不发失败提示,只有
|
||||
协议端**明确报错**(ActionFailed)才降级到下一级。
|
||||
|
||||
temp 下的文件发送成功后也不清理(用户手动处理 data/temp)。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from nonebot import get_bot, get_driver, logger
|
||||
from nonebot.adapters import Event
|
||||
from nonebot.exception import NetworkError
|
||||
from nonebot_plugin_alconna import UniMessage
|
||||
|
||||
from ..config import group_file_settings
|
||||
@@ -57,6 +74,56 @@ def _as_paths(file_paths: Path | list[Path]) -> list[Path]:
|
||||
return [Path(file_paths)]
|
||||
|
||||
|
||||
# ─────────────────── 发送超时与「结果未知」判定 ───────────────────
|
||||
|
||||
#: 媒体发送 per-call 超时(秒)——长视频在协议端「下载 + 转存 QQ」要几十秒
|
||||
#: 到几分钟(实测 172MB ≈ 140s),NoneBot 默认 api_timeout=30s 必超时。
|
||||
_DEFAULT_SEND_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def send_timeout() -> float:
|
||||
"""媒体发送的 per-call 超时(`_timeout` 关键字,OneBot 适配器原生支持)。
|
||||
|
||||
超时只是 bot 不再等响应,**协议端会把这次发送做完**;所以这个值同时是
|
||||
「重试会重复发送」的边界,默认 300s,`video_analysis_send_timeout` 可调。
|
||||
"""
|
||||
raw = (
|
||||
os.environ.get("video_analysis_send_timeout")
|
||||
or os.environ.get("VIDEO_ANALYSIS_SEND_TIMEOUT")
|
||||
or ""
|
||||
).strip()
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
if raw:
|
||||
logger.warning(
|
||||
f"video_analysis_send_timeout={raw!r} 不是数字,"
|
||||
f"按默认 {_DEFAULT_SEND_TIMEOUT:g}s 处理"
|
||||
)
|
||||
return _DEFAULT_SEND_TIMEOUT
|
||||
return value if value > 0 else _DEFAULT_SEND_TIMEOUT
|
||||
|
||||
|
||||
def unknown_send_outcome(exc: BaseException) -> bool:
|
||||
"""这次发送是否「结果未知」——协议端可能仍在处理并最终送达。
|
||||
|
||||
`NetworkError` 涵盖调用超时与连接异常:请求已经发出,只是 bot 放弃了
|
||||
等待。长视频超时后协议端照样把视频发进群(2026-10-13 实测:172MB 视频
|
||||
三级降级各超时一次,群里收到三份),所以这种结果**不能再降级重试**,
|
||||
否则每试一级就多送一份;对外的"媒体发送失败"提示同理不能再发。
|
||||
"""
|
||||
return isinstance(exc, NetworkError) or isinstance(
|
||||
exc, (asyncio.TimeoutError, TimeoutError)
|
||||
)
|
||||
|
||||
|
||||
def _log_unknown(stage: str, exc: BaseException) -> None:
|
||||
logger.warning(
|
||||
f"{stage}发送等待超时,结果未知(协议端可能仍在处理并最终送达);"
|
||||
f"不再降级重试以免重复发送: {exc}"
|
||||
)
|
||||
|
||||
|
||||
# ─────────────────────── 合并转发(多媒体专用) ───────────────────────
|
||||
|
||||
|
||||
@@ -114,37 +181,81 @@ async def _send_forward_media(
|
||||
*,
|
||||
group_id: int | None = None,
|
||||
user_id: int | None = None,
|
||||
) -> bool:
|
||||
"""发送合并转发消息(群聊 / 私聊),失败返回 False"""
|
||||
) -> bool | None:
|
||||
"""发送合并转发消息(群聊 / 私聊)。
|
||||
|
||||
返回 True=成功 / False=失败(协议端明确报错,可降级重试)/
|
||||
None=结果未知(超时,调用方必须停止重试,见 `unknown_send_outcome`)。
|
||||
"""
|
||||
try:
|
||||
bot = get_bot()
|
||||
nodes = _build_forward_nodes(items, sender_name, bot.self_id)
|
||||
timeout = send_timeout()
|
||||
if group_id is not None:
|
||||
await bot.call_api(
|
||||
"send_group_forward_msg", group_id=group_id, messages=nodes
|
||||
"send_group_forward_msg",
|
||||
group_id=group_id,
|
||||
messages=nodes,
|
||||
_timeout=timeout,
|
||||
)
|
||||
else:
|
||||
await bot.call_api("send_forward_msg", user_id=user_id, messages=nodes)
|
||||
await bot.call_api(
|
||||
"send_forward_msg", user_id=user_id, messages=nodes, _timeout=timeout
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
if unknown_send_outcome(e):
|
||||
_log_unknown("合并转发", e)
|
||||
return None
|
||||
logger.warning(f"合并转发发送失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def _build_s3_forward_items(
|
||||
media: PendingMedia,
|
||||
) -> tuple[list[tuple[Path, str | None]], str | None]:
|
||||
"""上传本地 S3,返回 ([(文件, 预签名链接)...], public_url)"""
|
||||
items: list[tuple[Path, str | None]] = []
|
||||
public_url = None
|
||||
# ─────────────────────── 归档(发送的前提) ───────────────────────
|
||||
|
||||
|
||||
@dataclass
|
||||
class UploadedFile:
|
||||
"""归档结果:本地文件 → 局域网预签名链接(发送兜底)+ 公网链接(下载链接)"""
|
||||
|
||||
path: Path
|
||||
local_url: str | None = None
|
||||
public_url: str | None = None
|
||||
|
||||
|
||||
async def archive_media(media: PendingMedia) -> list[UploadedFile]:
|
||||
"""逐文件归档到本地 S3 —— **无条件**执行,与发送成败无关。
|
||||
|
||||
这是「媒体必须进桶」这条前提的落点(用户 2026-09-25 定):temp 只留 7 天,
|
||||
桶里那份才是长期存档。boto3 同步阻塞(大视频一次传几十秒),统一丢线程池。
|
||||
单个文件失败只 warning 并**继续**后面的(多图作品不该因第 3 张失败丢掉后 7 张),
|
||||
任何情况都不抛——归档失败不能反过来影响发送,发送失败也不影响归档。
|
||||
"""
|
||||
uploads: list[UploadedFile] = []
|
||||
for fp in media.files:
|
||||
local_url, pub = upload_with_plan(fp, policy=media.policy)
|
||||
if not local_url:
|
||||
raise RuntimeError(f"上传本地 S3 失败: {fp}")
|
||||
if pub:
|
||||
public_url = pub
|
||||
items.append((fp, local_url))
|
||||
return items, public_url
|
||||
item = UploadedFile(path=fp)
|
||||
try:
|
||||
item.local_url, item.public_url = await asyncio.to_thread(
|
||||
upload_with_plan, fp, policy=media.policy
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"归档到本地 S3 失败 {fp}: {e}")
|
||||
uploads.append(item)
|
||||
return uploads
|
||||
|
||||
|
||||
def _presigned_items(uploads: list[UploadedFile]) -> list[tuple[Path, str | None]]:
|
||||
"""归档结果 → 链接版消息/合并转发要的 [(文件, 预签名链接)](缺链接=该文件没传上)"""
|
||||
return [(item.path, item.local_url) for item in uploads]
|
||||
|
||||
|
||||
def _last_public_url(uploads: list[UploadedFile]) -> str | None:
|
||||
"""公网下载链接取最后一个非空值(`upload_public` 关时恒为 None)"""
|
||||
public_url = None
|
||||
for item in uploads:
|
||||
if item.public_url:
|
||||
public_url = item.public_url
|
||||
return public_url
|
||||
|
||||
|
||||
# ───────────────────────────── 多级发送 ─────────────────────────────
|
||||
@@ -161,68 +272,49 @@ def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
|
||||
return msg
|
||||
|
||||
|
||||
def _build_s3_msg(
|
||||
media: PendingMedia,
|
||||
) -> tuple[UniMessage, str | None]:
|
||||
"""上传本地 S3 并构建链接版消息,返回 (message, public_url)"""
|
||||
def _build_s3_msg(items: list[tuple[Path, str | None]]) -> UniMessage:
|
||||
"""用归档好的预签名链接构建链接版消息
|
||||
|
||||
有文件没拿到链接(归档时上传失败)就抛,交由调用方降级到下一级。
|
||||
"""
|
||||
urls: list[tuple[Path, str]] = []
|
||||
for fp, url in items:
|
||||
if not url:
|
||||
raise RuntimeError(f"归档未拿到预签名链接: {fp}")
|
||||
urls.append((fp, url))
|
||||
|
||||
msg = UniMessage()
|
||||
public_url = None
|
||||
for fp in media.files:
|
||||
local_url, pub = upload_with_plan(fp, policy=media.policy)
|
||||
if not local_url:
|
||||
raise RuntimeError(f"上传本地 S3 失败: {fp}")
|
||||
if pub:
|
||||
public_url = pub
|
||||
for fp, url in urls:
|
||||
if _is_video(fp):
|
||||
msg.video(url=local_url)
|
||||
msg.video(url=url)
|
||||
else:
|
||||
msg.image(url=local_url)
|
||||
return msg, public_url
|
||||
|
||||
|
||||
async def _public_link_for(media: PendingMedia) -> str | None:
|
||||
"""发送已由本地通道完成后,为「发送下载链接」策略补一次 S3 上传。
|
||||
|
||||
本地直发不经 S3(最快),但群策略开了链接就需要公网 URL——这里**只取链接**,
|
||||
不重建消息。失败只记日志并返回 None:发送本身已经成功了,不能反过来判失败。
|
||||
"""
|
||||
public_url = None
|
||||
for fp in media.files:
|
||||
try:
|
||||
_, pub = await asyncio.to_thread(upload_with_plan, fp, policy=media.policy)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"补传 S3 取下载链接失败 {fp}: {e}")
|
||||
return None
|
||||
if pub:
|
||||
public_url = pub
|
||||
return public_url
|
||||
|
||||
|
||||
async def _local_sent(media: PendingMedia) -> tuple[bool, str | None]:
|
||||
"""本地通道发送成功后的返回。
|
||||
|
||||
只有策略真的要发下载链接(`sends_link` = send_link 且已开公网)时才补 S3;
|
||||
其余情况一概不碰 S3 —— 媒体不走 S3 归档是有意为之(temp 保留由 cleanup 管)。
|
||||
"""
|
||||
if media.policy is not None and media.policy.sends_link:
|
||||
return True, await _public_link_for(media)
|
||||
return True, None
|
||||
msg.image(url=url)
|
||||
return msg
|
||||
|
||||
|
||||
async def _send_media_core(
|
||||
media: PendingMedia, event: Event | None = None
|
||||
) -> tuple[bool, str | None]:
|
||||
"""多级发送主体,返回 (是否成功, public_url)
|
||||
media: PendingMedia,
|
||||
event: Event | None,
|
||||
archive: "asyncio.Task[list[UploadedFile]]",
|
||||
) -> bool:
|
||||
"""多级发送主体,返回是否成功(public_url 由调用方从归档结果取)。
|
||||
|
||||
public_url 有两种来源:走 S3 链接发送成功,或本地发送成功但策略要链接时
|
||||
补传 S3(见 `_local_sent`);其余情况为 None(调用方据 `sends_link` 决定是否发文字)。
|
||||
`archive` 是**已经在跑**的归档任务(见 `send_pending_media`):本地直发不等它,
|
||||
需要链接时再 `await`——同一个 task 多次 await 安全且结果复用,所以每个文件
|
||||
只上传一次,没有"补传"这一步。
|
||||
temp 文件发送成功后保留(用户手动清理 data/temp)。
|
||||
|
||||
多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接),
|
||||
失败后仍落回下方普通消息链路兜底。
|
||||
|
||||
**超时(结果未知)时按"可能已送达"处理**:停在这一级并返回 True——协议端
|
||||
可能仍在把这份发完,降级重试会重复发送、发"发送失败"提示也会误导。只有
|
||||
协议端明确报错(ActionFailed 等)才降级,见 `unknown_send_outcome`。
|
||||
"""
|
||||
if not media.files:
|
||||
return False, None
|
||||
return False
|
||||
|
||||
timeout = send_timeout()
|
||||
|
||||
# ── 0. 多媒体 → 合并转发(一个图/视频一个节点)─────────────────
|
||||
if event is not None and _needs_forward(media.files):
|
||||
@@ -230,51 +322,66 @@ async def _send_media_core(
|
||||
user_id = None if group_id is not None else getattr(event, "user_id", None)
|
||||
if group_id is not None or user_id is not None:
|
||||
sender_name = _forward_sender_name()
|
||||
if await _send_forward_media(
|
||||
sent = await _send_forward_media(
|
||||
[(fp, None) for fp in media.files],
|
||||
sender_name,
|
||||
group_id=group_id,
|
||||
user_id=user_id,
|
||||
):
|
||||
)
|
||||
if sent is None:
|
||||
return True
|
||||
if sent:
|
||||
logger.info("媒体发送成功(合并转发,本地文件)")
|
||||
return await _local_sent(media)
|
||||
try:
|
||||
s3_items, public_url = await _build_s3_forward_items(media)
|
||||
if await _send_forward_media(
|
||||
s3_items, sender_name, group_id=group_id, user_id=user_id
|
||||
):
|
||||
return True
|
||||
items = _presigned_items(await archive)
|
||||
if all(url for _, url in items):
|
||||
sent = await _send_forward_media(
|
||||
items, sender_name, group_id=group_id, user_id=user_id
|
||||
)
|
||||
if sent is None:
|
||||
return True
|
||||
if sent:
|
||||
logger.info("媒体发送成功(合并转发,S3 链接)")
|
||||
return True, public_url
|
||||
except Exception as e:
|
||||
logger.warning(f"S3 链接合并转发失败,走普通消息链路: {e}")
|
||||
return True
|
||||
else:
|
||||
logger.warning("归档未全部成功,跳过 S3 链接合并转发,走普通消息链路")
|
||||
else:
|
||||
logger.warning("无法确定群号/用户号,跳过合并转发,走普通消息链路")
|
||||
|
||||
# ── 1. temp 本地文件直接发送 ──────────────────────────────
|
||||
try:
|
||||
await _build_local_msg(media.files, media.image_post).send()
|
||||
await _build_local_msg(media.files, media.image_post).send(_timeout=timeout)
|
||||
logger.info("媒体发送成功(temp 本地文件直达)")
|
||||
return await _local_sent(media)
|
||||
return True
|
||||
except Exception as e:
|
||||
if unknown_send_outcome(e):
|
||||
_log_unknown("temp 本地文件直达", e)
|
||||
return True
|
||||
logger.warning(f"temp 本地文件发送失败,切换本地 S3 链接: {e}")
|
||||
|
||||
# ── 2. 上传本地 S3 → 链接发送 ─────────────────────────────
|
||||
# ── 2. 用归档好的预签名链接发送 ─────────────────────────────
|
||||
try:
|
||||
s3_msg, public_url = _build_s3_msg(media)
|
||||
await s3_msg.send()
|
||||
s3_msg = _build_s3_msg(_presigned_items(await archive))
|
||||
await s3_msg.send(_timeout=timeout)
|
||||
logger.info("媒体发送成功(本地 S3 链接)")
|
||||
return True, public_url
|
||||
return True
|
||||
except Exception as e:
|
||||
if unknown_send_outcome(e):
|
||||
_log_unknown("本地 S3 链接", e)
|
||||
return True
|
||||
logger.warning(f"本地 S3 链接发送失败,回退 temp 本地文件: {e}")
|
||||
|
||||
# ── 3. 回退:temp 本地文件再发一次 ────────────────────────
|
||||
try:
|
||||
await _build_local_msg(media.files, media.image_post).send()
|
||||
await _build_local_msg(media.files, media.image_post).send(_timeout=timeout)
|
||||
logger.info("媒体发送成功(回退 temp 本地文件)")
|
||||
return await _local_sent(media)
|
||||
return True
|
||||
except Exception as e:
|
||||
if unknown_send_outcome(e):
|
||||
_log_unknown("回退 temp 本地文件", e)
|
||||
return True
|
||||
logger.exception(f"回退发送失败: {e}")
|
||||
return False, None
|
||||
return False
|
||||
|
||||
|
||||
async def _upload_group_files(media: PendingMedia, event: Event | None) -> None:
|
||||
@@ -312,10 +419,24 @@ async def _upload_group_files(media: PendingMedia, event: Event | None) -> None:
|
||||
async def send_pending_media(
|
||||
media: PendingMedia, event: Event | None = None
|
||||
) -> tuple[bool, str | None]:
|
||||
"""多级发送 + 群文件并行通道,返回 (是否成功, public_url)。"""
|
||||
"""归档 + 多级发送 + 群文件并行通道,返回 (是否成功, public_url)。
|
||||
|
||||
**归档无条件且先行开跑**(用户 2026-09-25 定的前提):媒体进了 temp 就必须
|
||||
再进一次桶,与发送走哪一级、成功与否无关。它与本地直发并行,所以发送仍旧是
|
||||
最快那条路;`public_url` 取自归档结果(`upload_public` 关时为 None),交给
|
||||
entry.py 按策略决定是否发链接文本。
|
||||
|
||||
「是否成功」对**超时(结果未知)**按 True 算:协议端可能仍在发,调用方
|
||||
不该再给群里发"媒体发送失败"(长视频实测就是超时后照样送达)。
|
||||
"""
|
||||
if not media.files:
|
||||
return False, None
|
||||
|
||||
ok, public_url = await _send_media_core(media, event)
|
||||
archive = asyncio.create_task(archive_media(media))
|
||||
try:
|
||||
ok = await _send_media_core(media, event, archive)
|
||||
finally:
|
||||
# 发送层任何异常都不能把归档半途丢下:归档自己的失败已在内部吞掉。
|
||||
uploads = await archive
|
||||
await _upload_group_files(media, event)
|
||||
return ok, public_url
|
||||
return ok, _last_public_url(uploads)
|
||||
@@ -44,11 +44,12 @@ from nonebot import logger
|
||||
XHS = "小红书"
|
||||
BILIBILI = "哔哩哔哩"
|
||||
DOUYIN = "抖音"
|
||||
KUAISHOU = "快手"
|
||||
YOUTUBE = "Youtube"
|
||||
X = "X"
|
||||
|
||||
#: 规范标签(存储值 = 展示值,命令/Web 输入同一个词)
|
||||
PLATFORMS: tuple[str, ...] = (XHS, BILIBILI, DOUYIN, YOUTUBE, X)
|
||||
PLATFORMS: tuple[str, ...] = (XHS, BILIBILI, DOUYIN, KUAISHOU, YOUTUBE, X)
|
||||
|
||||
#: 平台 → 域名片段(判定 auto_link / ban_link 命中用)
|
||||
PLATFORM_DOMAINS: dict[str, tuple[str, ...]] = {
|
||||
@@ -60,6 +61,9 @@ PLATFORM_DOMAINS: dict[str, tuple[str, ...]] = {
|
||||
"m.douyin.com",
|
||||
"jingxuan.douyin.com",
|
||||
),
|
||||
# 短链 v.kuaishou.com / 网页版 www.kuaishou.com /
|
||||
# 分享页 v.m.chenzhongtech.com(QQ 卡片 jumpUrl 就走这个域)
|
||||
KUAISHOU: ("kuaishou.com", "chenzhongtech.com"),
|
||||
YOUTUBE: ("youtube.com", "youtu.be"),
|
||||
X: ("x.com", "twitter.com"),
|
||||
}
|
||||
@@ -74,6 +78,9 @@ _PLATFORM_ALIASES: dict[str, str] = {
|
||||
"哔哩哔哩": BILIBILI,
|
||||
"douyin": DOUYIN,
|
||||
"抖音": DOUYIN,
|
||||
"kuaishou": KUAISHOU,
|
||||
"ks": KUAISHOU,
|
||||
"快手": KUAISHOU,
|
||||
"yt": YOUTUBE,
|
||||
"youtube": YOUTUBE,
|
||||
"油管": YOUTUBE,
|
||||
|
||||
@@ -224,7 +224,7 @@ def _build_rel_stem(
|
||||
"""
|
||||
return (
|
||||
f"{build_author_dir(author, author_id, source=source)}"
|
||||
f"/{build_work_stem(title)}"
|
||||
f"/{build_work_stem(title, source=source)}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,309 +1,17 @@
|
||||
"""抖音内容抓取 — 浏览器自动化 + API 拦截 + 媒体下载"""
|
||||
"""媒体下载辅助 — 图集并发下载(抖音/B站/小红书/快手四条链路共用)
|
||||
|
||||
历史上的 playwright 拦截链路与分轨视频合并已随抖音新链路(douyin_web.py)
|
||||
废弃移除;这里只保留纯下载工具。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
from nonebot import logger
|
||||
from playwright.async_api import async_playwright
|
||||
|
||||
from ...models import DouyinFetchError
|
||||
from ...utils import get_temp_root, unique_media_path
|
||||
from .douyin_parser import (
|
||||
extract_trailing_digits,
|
||||
parse_animated_note_videos,
|
||||
parse_douyin_response,
|
||||
parse_note_images,
|
||||
parse_ssr_page,
|
||||
parse_video_urls,
|
||||
)
|
||||
|
||||
|
||||
def merge_video_audio(video_file: Path, audio_file: Path, out_file: Path):
|
||||
"""分轨视频与音频合并"""
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg",
|
||||
"-i",
|
||||
str(video_file),
|
||||
"-i",
|
||||
str(audio_file),
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-movflags",
|
||||
"faststart",
|
||||
"-y",
|
||||
str(out_file),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================= 主入口 =============================
|
||||
|
||||
|
||||
async def fetch_douyin_content(
|
||||
douyin_url: str,
|
||||
cookies: List[dict],
|
||||
wait_seconds: int = 10,
|
||||
headless: bool = True,
|
||||
) -> tuple[str | None, Path | List[Path] | None]:
|
||||
"""
|
||||
获取抖音内容(视频或图文)
|
||||
|
||||
返回:
|
||||
(title, content)
|
||||
- 视频: (title, Path)
|
||||
- 图文: (title, List[Path])
|
||||
- 失败: (None, None)
|
||||
"""
|
||||
if not cookies:
|
||||
raise DouyinFetchError("cookies 为空")
|
||||
|
||||
tmp_root = get_temp_root("douyin")
|
||||
api_response: Optional[dict] = None
|
||||
api_response_favorite: Optional[dict] = None
|
||||
aweme_id = 0
|
||||
# 目标作品 id(从入口 URL 提取):aweme/post 返回的是作者作品列表,
|
||||
# 按此 id 精确匹配要解析的作品,避免取到作者的其他作品
|
||||
target_aweme_id = extract_trailing_digits(douyin_url) or ""
|
||||
referer_url = None
|
||||
|
||||
async with async_playwright() as p:
|
||||
browser = await p.chromium.launch(
|
||||
executable_path="C:/Program Files/Google/Chrome/Application/chrome.exe",
|
||||
headless=headless,
|
||||
args=[
|
||||
"--autoplay-policy=no-user-gesture-required",
|
||||
"--disable-features=AutoplayDisableSuppression",
|
||||
],
|
||||
)
|
||||
|
||||
context = await browser.new_context(
|
||||
viewport={"width": 1280, "height": 720},
|
||||
device_scale_factor=2,
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/122.0.0.0 Safari/537.36"
|
||||
),
|
||||
locale="zh-CN",
|
||||
)
|
||||
|
||||
await context.add_cookies(cookies)
|
||||
page = await context.new_page()
|
||||
|
||||
async def handle_response(response):
|
||||
nonlocal api_response, api_response_favorite, aweme_id, referer_url
|
||||
try:
|
||||
if (
|
||||
"https://www.douyin.com/note" in response.url
|
||||
or "https://www.douyin.com/video" in response.url
|
||||
):
|
||||
referer_url = response.url
|
||||
logger.info(f"作品链接:{referer_url}")
|
||||
aweme_id = extract_trailing_digits(response.url)
|
||||
logger.info(f"作品id:{aweme_id}")
|
||||
|
||||
if "aweme/v1/web/aweme/detail" in response.url:
|
||||
if not api_response:
|
||||
logger.info(f"捕获到视频 API: {response.url}")
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "json" in ct:
|
||||
api_response = await response.json()
|
||||
logger.info("API 响应已捕获")
|
||||
return
|
||||
else:
|
||||
logger.info("API 已有捕获")
|
||||
return
|
||||
|
||||
elif "aweme/v1/web/aweme/post" in response.url:
|
||||
logger.info(f"捕获到图文 API: {response.url}")
|
||||
ct = response.headers.get("content-type", "")
|
||||
if "json" in ct:
|
||||
data = await response.json()
|
||||
aweme_list = data.get("aweme_list", [])
|
||||
if aweme_list:
|
||||
# 列表是作者的全部作品,优先按目标作品 id 精确匹配
|
||||
# (未登录/风控时目标作品可能不在列表里)
|
||||
match_id = aweme_id or target_aweme_id
|
||||
target_aweme = None
|
||||
if match_id:
|
||||
target_aweme = next(
|
||||
(
|
||||
a
|
||||
for a in aweme_list
|
||||
if str(a.get("aweme_id")) == str(match_id)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not target_aweme:
|
||||
target_aweme = aweme_list[0]
|
||||
if target_aweme.get("images"):
|
||||
logger.info("找到正确的图文 API 响应(包含图片数据)")
|
||||
data["aweme_list"] = [target_aweme]
|
||||
api_response = data
|
||||
return
|
||||
else:
|
||||
logger.warning(
|
||||
"此响应不包含有效的图片数据,等待下一个请求"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"处理响应失败: {e}")
|
||||
|
||||
page.on("response", handle_response)
|
||||
|
||||
await page.goto(douyin_url, wait_until="domcontentloaded")
|
||||
|
||||
max_wait = wait_seconds
|
||||
for i in range(max_wait):
|
||||
if api_response:
|
||||
logger.info(f"成功在第 {i + 1} 秒捕获 API 响应")
|
||||
break
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
# 截取页面 HTML(在关闭浏览器前),用于 SSR 回退解析
|
||||
page_html = await page.content() if not api_response else None
|
||||
|
||||
await browser.close()
|
||||
|
||||
if not api_response and page_html:
|
||||
api_response = parse_ssr_page(page_html)
|
||||
if api_response:
|
||||
logger.info("通过 SSR 页面回退解析获取到图文数据")
|
||||
|
||||
if not api_response:
|
||||
raise DouyinFetchError("无法捕获 API 响应,请检查网络或 URL")
|
||||
|
||||
parsed = parse_douyin_response(api_response, referer_url)
|
||||
|
||||
headers = {
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/122.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
|
||||
if parsed.media_type == "视频":
|
||||
content = await _process_video(
|
||||
api_response, tmp_root, parsed.rel_stem, aweme_id, headers
|
||||
)
|
||||
return parsed.raw_title, content
|
||||
|
||||
elif parsed.media_type == "图片":
|
||||
# 先解析 images 列表,区分纯动图和图文/图+视频
|
||||
images_urls, video_url = parse_note_images(
|
||||
api_response, api_response_favorite, aweme_id
|
||||
)
|
||||
if images_urls:
|
||||
# 有图片(纯图文 或 图+视频混合作品)
|
||||
content = await _process_note_with_parsed(
|
||||
images_urls,
|
||||
video_url,
|
||||
tmp_root,
|
||||
parsed.rel_stem,
|
||||
headers,
|
||||
)
|
||||
else:
|
||||
# 纯动图(所有项都是视频)
|
||||
content = await _process_animated_note(
|
||||
api_response, tmp_root, parsed.rel_stem, headers
|
||||
)
|
||||
return parsed.raw_title, content
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
# ============================= 视频下载 =============================
|
||||
|
||||
|
||||
async def _process_video(
|
||||
api_response: dict,
|
||||
tmp_root: Path,
|
||||
rel_stem: str,
|
||||
aweme_id: str,
|
||||
headers: Dict[str, str],
|
||||
) -> Path:
|
||||
"""处理视频内容,返回本地文件路径
|
||||
|
||||
rel_stem 是相对平台根的路径词干 `{作者目录}/{作品名}`(见 ParsedDouyinContent.rel_stem)。
|
||||
"""
|
||||
groups = parse_video_urls(api_response)
|
||||
|
||||
best_group = None
|
||||
for g in groups.values():
|
||||
if g["FULL"] or (g["VIDEO"] and g["AUDIO"]):
|
||||
best_group = g
|
||||
break
|
||||
|
||||
if not best_group:
|
||||
raise DouyinFetchError("没有可用的视频组合")
|
||||
|
||||
# 完整视频 — 流式下载
|
||||
if best_group["FULL"]:
|
||||
full = best_group["FULL"]
|
||||
logger.info(f"找到完整视频,数量: {len(full)}")
|
||||
best = max(full, key=lambda x: x["br"])
|
||||
logger.info(f"选择码率: {best['br']} - {best['url'][:60]}...")
|
||||
|
||||
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
async with client.stream("GET", best["url"]) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
f.write(chunk)
|
||||
logger.info(f"视频下载完成: {output_path}")
|
||||
return output_path
|
||||
|
||||
# 分轨视频 — 分别下载后合并
|
||||
logger.info(
|
||||
f"使用分轨模式,视频数: {len(best_group['VIDEO'])}, "
|
||||
f"音频数: {len(best_group['AUDIO'])}"
|
||||
)
|
||||
video = max(best_group["VIDEO"], key=lambda x: x["br"])
|
||||
audio = max(best_group["AUDIO"], key=lambda x: x["br"])
|
||||
logger.info(f"选择视频码率: {video['br']}")
|
||||
logger.info(f"选择音频码率: {audio['br']}")
|
||||
|
||||
# 先定下产物名(顺带建好作者目录),分轨中间文件与产物同目录
|
||||
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
|
||||
video_path = output_path.parent / f"{output_path.stem}_v.mp4"
|
||||
audio_path = output_path.parent / f"{output_path.stem}_a.mp4"
|
||||
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
logger.info("开始下载视频...")
|
||||
async with client.stream("GET", video["url"]) as v:
|
||||
v.raise_for_status()
|
||||
with open(video_path, "wb") as f:
|
||||
async for chunk in v.aiter_bytes(8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info("开始下载音频...")
|
||||
async with client.stream("GET", audio["url"]) as a:
|
||||
a.raise_for_status()
|
||||
with open(audio_path, "wb") as f:
|
||||
async for chunk in a.aiter_bytes(8192):
|
||||
f.write(chunk)
|
||||
|
||||
logger.info("合并视频和音频...")
|
||||
# ffmpeg 是同步子进程,直接 await 不了:不丢线程池会卡住整个事件循环
|
||||
# (合并期间 Web 轮询、群消息全都停摆)
|
||||
await asyncio.to_thread(merge_video_audio, video_path, audio_path, output_path)
|
||||
video_path.unlink()
|
||||
audio_path.unlink()
|
||||
|
||||
logger.info(f"视频下载完成: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
# ============================= 图文下载 =============================
|
||||
from ...utils import unique_media_path
|
||||
|
||||
|
||||
async def _process_note_with_parsed(
|
||||
@@ -313,41 +21,49 @@ async def _process_note_with_parsed(
|
||||
rel_stem: str,
|
||||
headers: Dict[str, str],
|
||||
) -> List[Path]:
|
||||
"""根据已解析的图片/视频 URL 列表,并行下载
|
||||
"""按已解析的图片/视频 URL 列表并发下载
|
||||
|
||||
一个作品一个目录:`{平台根}/{作者目录}/{作品名}[_{短码}]/001.jpg…`
|
||||
- images_urls:每项是该媒体位的 URL 候选列表(按优先级排序,逐个回退)
|
||||
- video_url:图+视频混合作品中追加到末尾的视频(单 URL,可为 None)
|
||||
"""
|
||||
note_dir = unique_media_path(tmp_root / rel_stem)
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"图文保存目录: {note_dir}")
|
||||
|
||||
async def _download_one(idx: int, url: str, ext: str = "") -> Optional[Path]:
|
||||
if not ext:
|
||||
ext = _infer_extension(url)
|
||||
filename = f"{idx:03d}{ext}"
|
||||
async def _download_one(
|
||||
idx: int, url_list: List[str], ext: str = ""
|
||||
) -> Optional[Path]:
|
||||
for url in url_list:
|
||||
use_ext = ext or _infer_extension(url)
|
||||
filename = f"{idx:03d}{use_ext}"
|
||||
filepath = note_dir / filename
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
async with httpx.AsyncClient(headers=headers, timeout=300) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(filepath, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
async for chunk in resp.aiter_bytes(65536):
|
||||
f.write(chunk)
|
||||
if filepath.stat().st_size == 0:
|
||||
raise RuntimeError("响应为空(0 字节)")
|
||||
logger.info(f"已保存: {filename}")
|
||||
return filepath
|
||||
except Exception as e:
|
||||
logger.error(f"下载 {idx} 失败: {e}")
|
||||
logger.warning(f"下载 {idx} 失败(尝试下一个候选): {e}")
|
||||
logger.error(f"下载 {idx} 失败: 所有候选 URL 均不可用")
|
||||
return None
|
||||
|
||||
tasks = [
|
||||
_download_one(i, url_list[0])
|
||||
_download_one(i, url_list)
|
||||
for i, url_list in enumerate(images_urls, 1)
|
||||
if url_list
|
||||
]
|
||||
|
||||
# 图+视频混合作品:视频追加到下载任务
|
||||
if video_url:
|
||||
next_idx = len(images_urls) + 1
|
||||
tasks.append(_download_one(next_idx, video_url, ext=".mp4"))
|
||||
tasks.append(_download_one(next_idx, [video_url], ext=".mp4"))
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
saved_paths: List[Path] = [p for p in results if p is not None]
|
||||
@@ -356,75 +72,9 @@ async def _process_note_with_parsed(
|
||||
return saved_paths
|
||||
|
||||
|
||||
async def _process_note(
|
||||
api_response: dict,
|
||||
api_response_favorite: dict,
|
||||
tmp_root: Path,
|
||||
rel_stem: str,
|
||||
aweme_id: str,
|
||||
headers: Dict[str, str],
|
||||
) -> List[Path]:
|
||||
"""处理图文内容,并行下载所有图片;图+视频混合作品同时下载视频"""
|
||||
images_urls, video_url = parse_note_images(
|
||||
api_response, api_response_favorite, aweme_id
|
||||
)
|
||||
logger.info(f"解析到的图片链接:{images_urls}")
|
||||
|
||||
if not images_urls and not video_url:
|
||||
raise DouyinFetchError("未找到图文链接")
|
||||
|
||||
return await _process_note_with_parsed(
|
||||
images_urls, video_url, tmp_root, rel_stem, headers
|
||||
)
|
||||
|
||||
|
||||
# ============================= 动图下载 =============================
|
||||
|
||||
|
||||
async def _process_animated_note(
|
||||
api_response: dict,
|
||||
tmp_root: Path,
|
||||
rel_stem: str,
|
||||
headers: Dict[str, str],
|
||||
) -> List[Path]:
|
||||
"""处理动图内容(media_type=42),并行下载所有无声 mp4 视频"""
|
||||
video_urls = parse_animated_note_videos(api_response)
|
||||
logger.info(f"解析到的动图视频链接: {video_urls}")
|
||||
|
||||
note_dir = unique_media_path(tmp_root / rel_stem)
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"动图保存目录: {note_dir}")
|
||||
|
||||
async def _download_one(idx: int, url: str) -> Optional[Path]:
|
||||
filename = f"{idx:03d}.mp4"
|
||||
filepath = note_dir / filename
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=headers) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(filepath, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
f.write(chunk)
|
||||
logger.info(f"动图视频已保存: {filename}")
|
||||
return filepath
|
||||
except Exception as e:
|
||||
logger.error(f"下载动图视频 {idx} 失败: {e}")
|
||||
return None
|
||||
|
||||
tasks = [
|
||||
_download_one(i, url)
|
||||
for i, url in enumerate(video_urls, 1)
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
saved_paths: List[Path] = [p for p in results if p is not None]
|
||||
|
||||
logger.info(f"动图下载完成,共 {len(saved_paths)} 个视频")
|
||||
return saved_paths
|
||||
|
||||
|
||||
def _infer_extension(url: str) -> str:
|
||||
"""从 URL 推断文件扩展名"""
|
||||
extensions = [".webp", ".jpg", ".jpeg", ".png", ".gif", ".avif"]
|
||||
extensions = [".webp", ".jpg", ".jpeg", ".png", ".gif", ".avif", ".heic"]
|
||||
url_lower = url.lower()
|
||||
for ext in extensions:
|
||||
if ext in url_lower:
|
||||
|
||||
@@ -418,7 +418,7 @@ def parse_douyin_response(
|
||||
author_dir = build_author_dir(
|
||||
raw_nickname, raw_author_id, source=referer_url or aweme_id
|
||||
)
|
||||
file_name = build_work_stem(raw_title)
|
||||
file_name = build_work_stem(raw_title, source=referer_url or aweme_id)
|
||||
|
||||
logger.info(
|
||||
f"内容标题: {raw_title}, 作者: {raw_nickname}({raw_author_id}), "
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""抖音 a_bogus 签名 — 纯 Python 实现(免浏览器/免 Node)。
|
||||
|
||||
来源:https://github.com/Johnserf-Seed/f2 (Apache-2.0 License,
|
||||
许可证全文见 https://github.com/Johnserf-Seed/f2/blob/main/LICENSE)
|
||||
文件 f2/utils/crypto/bytedance/abogus.py(2026-10-13 拉取,未做任何修改;
|
||||
原文件头部的 f2 内部依赖仅有标准库与 gmssl,vendor 后可直接使用)。
|
||||
|
||||
与上游保持一致:算法对应 bdms 1.0.1.19,2026-10 实测可过抖音 ArgusSecurityPlugin 网关。
|
||||
若抖音再次更换签名算法,更新点就是本文件(对照 f2 上游 diff)。
|
||||
|
||||
依赖:gmssl(pip install gmssl,纯 Python 的国密 SM3 实现)。
|
||||
"""
|
||||
# fmt: off
|
||||
# ruff: noqa
|
||||
# path: f2/utils/crypto/bytedance/abogus.py
|
||||
|
||||
#!/usr/bin/env python
|
||||
# -*- encoding: utf-8 -*-
|
||||
"""
|
||||
@Description:abogus.py
|
||||
@Date :2024/06/16 11:21:14
|
||||
@Author :JohnserfSeed
|
||||
@version :0.0.4
|
||||
@License :Apache License 2.0
|
||||
@Github :https://github.com/johnserf-seed
|
||||
@Mail :support@f2.wiki
|
||||
-------------------------------------------------
|
||||
Change Log :
|
||||
2024/06/16 17:27:47 - Create ABogus algorithm & black style
|
||||
2024/06/16 17:27:47 - Limit custom ua late open source full version
|
||||
2024/07/08 21:50:12 - Open the full version of the custom UA
|
||||
2025/03/05 15:55:42 - Perf Post Method Generate
|
||||
2025/04/27 17:15:26 - Fix mypy type error
|
||||
-------------------------------------------------
|
||||
"""
|
||||
|
||||
import random
|
||||
import time
|
||||
from typing import Callable, Dict, List, Union
|
||||
|
||||
from gmssl import func, sm3 # type: ignore[import-untyped]
|
||||
|
||||
|
||||
class StringProcessor:
|
||||
"""
|
||||
StringProcessor 类用于计算ABogus算法中所需的字符串处理方法。
|
||||
包括字符串和 ASCII 码之间的转换、无符号右移运算等。
|
||||
|
||||
类方法:
|
||||
|
||||
to_ord_str(s: str) -> str:
|
||||
将字符串转换为 ASCII 码字符串。
|
||||
|
||||
to_ord_array(s: str) -> List[int]:
|
||||
将字符串转换为 ASCII 码列表。
|
||||
|
||||
to_char_str(s: str) -> str:
|
||||
将 ASCII 码列表转换为字符串。
|
||||
|
||||
to_char_array(s: str) -> List[int]:
|
||||
将字符串转换为 ASCII 码列表。
|
||||
|
||||
js_shift_right(val: int, n: int) -> int:
|
||||
实现 JavaScript 中的无符号右移运算。
|
||||
|
||||
generate_random_bytes(length: int = 3) -> str:
|
||||
生成一组伪随机字节字符串,用于混淆数据。
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
# 将字符串转换为 ASCII 码字符串
|
||||
ord_str = StringProcessor.to_ord_str("Hello, World!")
|
||||
print(ord_str)
|
||||
|
||||
# 将字符串转换为 ASCII 码列表
|
||||
ord_array = StringProcessor.to_ord_array("Hello, World!")
|
||||
print(ord_array)
|
||||
|
||||
# 将 ASCII 码列表转换为字符串
|
||||
char_str = StringProcessor.to_char_str(ord_array)
|
||||
print(char_str)
|
||||
|
||||
# 将字符串转换为 ASCII 码列表
|
||||
char_array = StringProcessor.to_char_array("Hello, World!")
|
||||
print(char_array)
|
||||
|
||||
# 实现 JavaScript 中的无符号右移运算
|
||||
shifted_val = StringProcessor.js_shift_right(10, 2)
|
||||
print(shifted_val)
|
||||
|
||||
# 生成一组伪随机字节字符串
|
||||
random_bytes = StringProcessor.generate_random_bytes(3)
|
||||
print(random_bytes)
|
||||
```
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def to_ord_str(s: str) -> str:
|
||||
"""
|
||||
将字符串转换为 ASCII 码字符串 (Convert a string to an ASCII code string).
|
||||
|
||||
Args:
|
||||
s (str): 输入字符串 (Input string).
|
||||
|
||||
Returns:
|
||||
str: 转换后的 ASCII 码字符串 (Converted ASCII code string).
|
||||
"""
|
||||
return "".join([chr(ord(char)) for char in s])
|
||||
|
||||
@staticmethod
|
||||
def to_ord_array(s: str) -> List[int]:
|
||||
"""
|
||||
将字符串转换为 ASCII 码列表 (Convert a string to a list of ASCII codes).
|
||||
|
||||
Args:
|
||||
s (str): 输入字符串 (Input string).
|
||||
|
||||
Returns:
|
||||
List[int]: 转换后的 ASCII 码列表 (Converted list of ASCII codes).
|
||||
"""
|
||||
return [ord(char) for char in s]
|
||||
|
||||
@staticmethod
|
||||
def to_char_str(s: Union[str, List[int]]) -> str:
|
||||
"""
|
||||
将 ASCII 码列表转换为字符串 (Convert a list of ASCII codes to a string).
|
||||
|
||||
Args:
|
||||
s (Union[str, List[int]]): ASCII 码列表或字符串 (List of ASCII codes or string).
|
||||
|
||||
Returns:
|
||||
str: 转换后的字符串 (Converted string).
|
||||
"""
|
||||
if isinstance(s, list):
|
||||
return "".join([chr(i) for i in s])
|
||||
elif isinstance(s, str):
|
||||
return s
|
||||
else:
|
||||
raise TypeError(
|
||||
"Expected str or List[int], got {}".format(type(s).__name__)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def to_char_array(s: str) -> List[int]:
|
||||
"""
|
||||
将字符串转换为 ASCII 码列表 (Convert a string to a list of ASCII codes).
|
||||
|
||||
Args:
|
||||
s (str): 输入字符串 (Input string).
|
||||
|
||||
Returns:
|
||||
List[int]: 转换后的 ASCII 码列表 (Converted list of ASCII codes).
|
||||
"""
|
||||
return [ord(char) for char in s]
|
||||
|
||||
@staticmethod
|
||||
def js_shift_right(val: int, n: int) -> int:
|
||||
"""
|
||||
实现 JavaScript 中的无符号右移运算 (Implement the unsigned right shift operation in JavaScript).
|
||||
|
||||
Args:
|
||||
val (int): 输入值 (Input value).
|
||||
n (int): 右移位数 (Number of bits to shift right).
|
||||
|
||||
Returns:
|
||||
int: 右移后的值 (Value after right shift).
|
||||
"""
|
||||
return (val % 0x100000000) >> n
|
||||
|
||||
@staticmethod
|
||||
def generate_random_bytes(length: int = 3) -> str:
|
||||
"""
|
||||
生成一组伪随机字节字符串,用于混淆数据 (Generate a pseudo-random byte string to obfuscate the data).
|
||||
|
||||
Args:
|
||||
length (int): 生成的字节序列长度 (Length of the byte sequence to generate).
|
||||
|
||||
Returns:
|
||||
str: 生成的伪随机字节字符串 (Generated pseudo-random byte string).
|
||||
"""
|
||||
|
||||
def generate_byte_sequence() -> List[str]:
|
||||
_rd = int(random.random() * 10000)
|
||||
return [
|
||||
chr(((_rd & 255) & 170) | 1),
|
||||
chr(((_rd & 255) & 85) | 2),
|
||||
chr((StringProcessor.js_shift_right(_rd, 8) & 170) | 5),
|
||||
chr((StringProcessor.js_shift_right(_rd, 8) & 85) | 40),
|
||||
]
|
||||
|
||||
result = []
|
||||
for _ in range(length):
|
||||
result.extend(generate_byte_sequence())
|
||||
|
||||
return "".join(result)
|
||||
|
||||
|
||||
class CryptoUtility:
|
||||
"""
|
||||
CryptoUtility 类用于提供加密和编码的工具方法,包括 SM3 哈希、添加盐值、Base64 编码和 RC4 加密等。
|
||||
|
||||
类属性:
|
||||
salt (str): 加密盐值 (Encryption salt).
|
||||
base64_alphabet (List[str]): 自定义 Base64 字符表 (Custom Base64 alphabet).
|
||||
|
||||
类方法:
|
||||
sm3_to_array(input_data: Union[str, List[int]]) -> List[int]:
|
||||
计算请求体的 SM3 哈希值,并将结果转换为整数数组。
|
||||
|
||||
add_salt(param: str) -> str:
|
||||
为字符串参数添加盐值。
|
||||
|
||||
process_param(param: Union[str, List[int]], add_salt: bool) -> Union[str, List[int]]:
|
||||
处理输入参数,根据需要添加盐值。
|
||||
|
||||
params_to_array(param: Union[str, List[int]], add_salt: bool = True) -> List[int]:
|
||||
获取输入参数的哈希数组。
|
||||
|
||||
transform_bytes(bytes_list: List[int]) -> str:
|
||||
对输入的字节列表进行加密/解密操作,返回处理后的字符串。
|
||||
|
||||
base64_encode(input_string: str, selected_alphabet: int = 0) -> str:
|
||||
使用自定义字符表对输入字符串进行 Base64 编码。
|
||||
|
||||
abogus_encode(abogus_bytes_str: str, selected_alphabet: int) -> str:
|
||||
对输入的字节字符串进行自定义 Base64 编码,并添加位移和填充。
|
||||
|
||||
rc4_encrypt(key: bytes, plaintext: str) -> bytes:
|
||||
使用 RC4 算法加密数据。
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
# 计算请求体的 SM3 哈希值
|
||||
sm3_hash = CryptoUtility.sm3_to_array("Hello, World!")
|
||||
print(sm3_hash)
|
||||
|
||||
# 为字符串参数添加盐值
|
||||
salted_param = CryptoUtility.add_salt("Hello, World!")
|
||||
print(salted_param)
|
||||
|
||||
# 获取输入参数的哈希数组
|
||||
hash_array = CryptoUtility.params_to_array("Hello, World!")
|
||||
print(hash_array)
|
||||
|
||||
# 对输入的字节列表进行加密/解密操作
|
||||
encrypted_str = CryptoUtility.transform_bytes([72, 101, 108, 108, 111, 44, 32, 87, 111, 114, 108, 100, 33])
|
||||
print(encrypted_str)
|
||||
|
||||
# 使用自定义字符表对输入字符串进行 Base64 编码
|
||||
base64_str = CryptoUtility.base64_encode("Hello, World!")
|
||||
print(base64_str)
|
||||
|
||||
# 对输入的字节字符串进行自定义 Base64 编码,并添加位移和填充
|
||||
abogus_str = CryptoUtility.abogus_encode("Hello, World!", 0)
|
||||
print(abogus_str)
|
||||
|
||||
# 使用 RC4 算法加密数据
|
||||
key = b"key"
|
||||
plaintext = "Hello, World!"
|
||||
ciphertext = CryptoUtility.rc4_encrypt(key, plaintext)
|
||||
print(ciphertext)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, salt: str, custom_base64_alphabet: List[str]):
|
||||
"""
|
||||
初始化 CryptoUtility 类
|
||||
Initialize the CryptoUtility class.
|
||||
|
||||
Args:
|
||||
salt (str): 加密盐值 (Encryption salt).
|
||||
custom_base64_alphabet (List[str]): 自定义 Base64 字符表 (Custom Base64 alphabet).
|
||||
"""
|
||||
self.salt = salt
|
||||
self.base64_alphabet = custom_base64_alphabet
|
||||
|
||||
# fmt: off
|
||||
self.big_array = [
|
||||
121, 243, 55, 234, 103, 36, 47, 228, 30, 231, 106, 6, 115, 95, 78, 101, 250, 207, 198, 50,
|
||||
139, 227, 220, 105, 97, 143, 34, 28, 194, 215, 18, 100, 159, 160, 43, 8, 169, 217, 180, 120,
|
||||
247, 45, 90, 11, 27, 197, 46, 3, 84, 72, 5, 68, 62, 56, 221, 75, 144, 79, 73, 161,
|
||||
178, 81, 64, 187, 134, 117, 186, 118, 16, 241, 130, 71, 89, 147, 122, 129, 65, 40, 88, 150,
|
||||
110, 219, 199, 255, 181, 254, 48, 4, 195, 248, 208, 32, 116, 167, 69, 201, 17, 124, 125, 104,
|
||||
96, 83, 80, 127, 236, 108, 154, 126, 204, 15, 20, 135, 112, 158, 13, 1, 188, 164, 210, 237,
|
||||
222, 98, 212, 77, 253, 42, 170, 202, 26, 22, 29, 182, 251, 10, 173, 152, 58, 138, 54, 141,
|
||||
185, 33, 157, 31, 252, 132, 233, 235, 102, 196, 191, 223, 240, 148, 39, 123, 92, 82, 128, 109,
|
||||
57, 24, 38, 113, 209, 245, 2, 119, 153, 229, 189, 214, 230, 174, 232, 63, 52, 205, 86, 140,
|
||||
66, 175, 111, 171, 246, 133, 238, 193, 99, 60, 74, 91, 225, 51, 76, 37, 145, 211, 166, 151,
|
||||
213, 206, 0, 200, 244, 176, 218, 44, 184, 172, 49, 216, 93, 168, 53, 21, 183, 41, 67, 85,
|
||||
224, 155, 226, 242, 87, 177, 146, 70, 190, 12, 162, 19, 137, 114, 25, 165, 163, 192, 23, 59,
|
||||
9, 94, 179, 107, 35, 7, 142, 131, 239, 203, 149, 136, 61, 249, 14, 156
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
@staticmethod
|
||||
def sm3_to_array(input_data: Union[str, List[int]]) -> List[int]:
|
||||
"""
|
||||
计算请求体的 SM3 哈希值,并将结果转换为整数数组 (Calculate the SM3 hash value of the request body and convert the result to an array of integers).
|
||||
|
||||
Args:
|
||||
input_data (Union[str, List[int]]): 输入数据 (Input data).
|
||||
|
||||
Returns:
|
||||
List[int]: 哈希值的整数数组 (Array of integers representing the hash value).
|
||||
"""
|
||||
# 如果输入是字符串,则将其编码为字节数组
|
||||
if isinstance(input_data, str):
|
||||
input_data_bytes = input_data.encode("utf-8")
|
||||
else:
|
||||
input_data_bytes = bytes(input_data) # 将 List[int] 转换为字节数组
|
||||
|
||||
# 将字节数组转换为适合 sm3.sm3_hash 函数处理的列表格式
|
||||
hex_result = sm3.sm3_hash(func.bytes_to_list(input_data_bytes))
|
||||
|
||||
# 将十六进制字符串结果转换为十进制整数列表
|
||||
return [int(hex_result[i : i + 2], 16) for i in range(0, len(hex_result), 2)]
|
||||
|
||||
def add_salt(self, param: str) -> str:
|
||||
"""
|
||||
为字符串参数添加盐值 (Add salt to the string parameter).
|
||||
|
||||
Args:
|
||||
param (str): 输入字符串 (Input string).
|
||||
|
||||
Returns:
|
||||
str: 添加盐值后的字符串 (String with added salt).
|
||||
"""
|
||||
return param + self.salt
|
||||
|
||||
def process_param(
|
||||
self, param: Union[str, List[int]], add_salt: bool
|
||||
) -> Union[str, List[int]]:
|
||||
"""
|
||||
处理输入参数,根据需要添加盐值 (Process input parameter and add salt if needed).
|
||||
|
||||
Args:
|
||||
param (Union[str, List[int]]): 输入参数 (Input parameter).
|
||||
add_salt (bool): 是否添加盐值 (Whether to add salt).
|
||||
|
||||
Returns:
|
||||
Union[str, List[int]]: 处理后的参数 (Processed parameter).
|
||||
"""
|
||||
if isinstance(param, str) and add_salt:
|
||||
param = self.add_salt(param)
|
||||
return param
|
||||
|
||||
def params_to_array(
|
||||
self, param: Union[str, List[int]], add_salt: bool = True
|
||||
) -> List[int]:
|
||||
"""
|
||||
获取输入参数的哈希数组 (Get the hash array of the input parameter).
|
||||
|
||||
Args:
|
||||
param (Union[str, List[int]]): 输入参数 (Input parameter).
|
||||
add_salt (bool): 是否添加盐值 (Whether to add salt).
|
||||
|
||||
Returns:
|
||||
List[int]: 哈希数组 (Hash array).
|
||||
"""
|
||||
processed_param = self.process_param(param, add_salt)
|
||||
return self.sm3_to_array(processed_param)
|
||||
|
||||
def transform_bytes(self, bytes_list: List[int]) -> str:
|
||||
"""
|
||||
对输入的字节列表进行加密/解密操作,返回处理后的字符串 (Encrypt/decrypt the input byte list and return the processed string).
|
||||
|
||||
Args:
|
||||
bytes_list (List[int]): 输入的字节列表 (Input byte list).
|
||||
|
||||
Returns:
|
||||
str: 处理后的字符串 (Processed string).
|
||||
"""
|
||||
# 将字节列表转换为字符字符串
|
||||
bytes_str = StringProcessor.to_char_str(bytes_list)
|
||||
result_str = []
|
||||
index_b = self.big_array[1]
|
||||
initial_value = 0
|
||||
value_e = 0
|
||||
|
||||
for index, char in enumerate(bytes_str):
|
||||
if index == 0:
|
||||
initial_value = self.big_array[index_b]
|
||||
sum_initial = index_b + initial_value
|
||||
|
||||
self.big_array[1] = initial_value
|
||||
self.big_array[index_b] = index_b
|
||||
else:
|
||||
sum_initial = initial_value + value_e
|
||||
|
||||
char_value = ord(char)
|
||||
sum_initial %= len(self.big_array)
|
||||
value_f = self.big_array[sum_initial]
|
||||
encrypted_char = char_value ^ value_f
|
||||
result_str.append(chr(encrypted_char))
|
||||
|
||||
# 交换数组元素
|
||||
value_e = self.big_array[(index + 2) % len(self.big_array)]
|
||||
sum_initial = (index_b + value_e) % len(self.big_array)
|
||||
initial_value = self.big_array[sum_initial]
|
||||
self.big_array[sum_initial] = self.big_array[
|
||||
(index + 2) % len(self.big_array)
|
||||
]
|
||||
self.big_array[(index + 2) % len(self.big_array)] = initial_value
|
||||
index_b = sum_initial
|
||||
|
||||
return "".join(result_str)
|
||||
|
||||
def base64_encode(self, input_string: str, selected_alphabet: int = 0) -> str:
|
||||
"""
|
||||
使用自定义字符表对输入字符串进行 Base64 编码 (Encode the input string using a custom Base64 alphabet).
|
||||
|
||||
Args:
|
||||
input_string (str): 输入字符串 (Input string).
|
||||
selected_alphabet (int): 选择的自定义 Base64 字符表索引 (Selected custom Base64 alphabet index).
|
||||
|
||||
Returns:
|
||||
str: 编码后的字符串 (Encoded string).
|
||||
"""
|
||||
|
||||
# 将输入字符串转换为ASCII码的二进制形式
|
||||
binary_string = "".join(["{:08b}".format(ord(char)) for char in input_string])
|
||||
|
||||
# 补全二进制字符串使其长度为6的倍数
|
||||
padding_length = (6 - len(binary_string) % 6) % 6
|
||||
binary_string += "0" * padding_length
|
||||
|
||||
# 将二进制字符串分割为6位一组
|
||||
base64_indices = [
|
||||
int(binary_string[i : i + 6], 2) for i in range(0, len(binary_string), 6)
|
||||
]
|
||||
|
||||
# 根据自定义字符表生成输出字符串
|
||||
output_string = "".join(
|
||||
[self.base64_alphabet[selected_alphabet][index] for index in base64_indices]
|
||||
)
|
||||
|
||||
# 添加等号填充,使符合 Base64 编码规范
|
||||
output_string += "=" * (padding_length // 2)
|
||||
|
||||
return output_string
|
||||
|
||||
def abogus_encode(self, abogus_bytes_str: str, selected_alphabet: int) -> str:
|
||||
"""
|
||||
对输入的字节字符串进行自定义 Base64 编码,并添加位移和填充 (Encode the input byte string using a custom Base64 alphabet, and add shifts and padding).
|
||||
|
||||
Args:
|
||||
abogus_bytes_str (str): 输入的字节字符串 (Input byte string).
|
||||
selected_alphabet (int): 选择的自定义 Base64 字符表索引 (Selected custom Base64 alphabet index).
|
||||
|
||||
Returns:
|
||||
str: 编码后的字符串 (Encoded string).
|
||||
"""
|
||||
abogus = []
|
||||
|
||||
for i in range(0, len(abogus_bytes_str), 3):
|
||||
if i + 2 < len(abogus_bytes_str):
|
||||
n = (
|
||||
(ord(abogus_bytes_str[i]) << 16)
|
||||
| (ord(abogus_bytes_str[i + 1]) << 8)
|
||||
| ord(abogus_bytes_str[i + 2])
|
||||
)
|
||||
elif i + 1 < len(abogus_bytes_str):
|
||||
n = (ord(abogus_bytes_str[i]) << 16) | (
|
||||
ord(abogus_bytes_str[i + 1]) << 8
|
||||
)
|
||||
else:
|
||||
n = ord(abogus_bytes_str[i]) << 16
|
||||
|
||||
for j, k in zip(range(18, -1, -6), (0xFC0000, 0x03F000, 0x0FC0, 0x3F)):
|
||||
if j == 6 and i + 1 >= len(abogus_bytes_str):
|
||||
break
|
||||
if j == 0 and i + 2 >= len(abogus_bytes_str):
|
||||
break
|
||||
abogus.append(self.base64_alphabet[selected_alphabet][(n & k) >> j])
|
||||
|
||||
abogus.append("=" * ((4 - len(abogus) % 4) % 4))
|
||||
return "".join(abogus)
|
||||
|
||||
@staticmethod
|
||||
def rc4_encrypt(key: bytes, plaintext: str) -> bytes:
|
||||
"""
|
||||
使用 RC4 算法加密数据 (Encrypt data using the RC4 algorithm).
|
||||
|
||||
Args:
|
||||
key (bytes): 加密密钥 (Encryption key).
|
||||
plaintext (str): 明文数据 (Plaintext data).
|
||||
|
||||
Returns:
|
||||
bytes: 加密后的数据 (Encrypted data).
|
||||
"""
|
||||
S = list(range(256))
|
||||
j = 0
|
||||
for i in range(256):
|
||||
j = (j + S[i] + key[i % len(key)]) % 256
|
||||
S[i], S[j] = S[j], S[i]
|
||||
|
||||
i = j = 0
|
||||
ciphertext = []
|
||||
for char in plaintext:
|
||||
i = (i + 1) % 256
|
||||
j = (j + S[i]) % 256
|
||||
S[i], S[j] = S[j], S[i]
|
||||
K = S[(S[i] + S[j]) % 256]
|
||||
ciphertext.append(ord(char) ^ K)
|
||||
|
||||
return bytes(ciphertext)
|
||||
|
||||
|
||||
class BrowserFingerprintGenerator:
|
||||
"""
|
||||
BrowserFingerprintGenerator 用于生成模拟的浏览器指纹信息,用于在不同浏览器环境中进行测试和数据采集。
|
||||
|
||||
方法:
|
||||
generate_fingerprint(browser_type="Edge"):
|
||||
根据指定的浏览器类型生成浏览器指纹。
|
||||
|
||||
generate_chrome_fingerprint():
|
||||
生成 Chrome 浏览器指纹。
|
||||
|
||||
generate_firefox_fingerprint():
|
||||
生成 Firefox 浏览器指纹。
|
||||
|
||||
generate_safari_fingerprint():
|
||||
生成 Safari 浏览器指纹。
|
||||
|
||||
generate_edge_fingerprint():
|
||||
生成 Edge 浏览器指纹。
|
||||
|
||||
_generate_fingerprint(platform="Win32"):
|
||||
根据给定的参数生成浏览器指纹字符串。
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
chrome_fp = BrowserFingerprintGenerator.generate_fingerprint("Chrome")
|
||||
print(chrome_fp)
|
||||
```
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def generate_fingerprint(cls, browser_type: str = "Edge") -> str:
|
||||
"""
|
||||
根据指定的浏览器类型生成浏览器指纹。 (Generate a browser fingerprint based on the specified browser type.)
|
||||
|
||||
Args:
|
||||
browser_type (str): 浏览器类型 (Browser type).
|
||||
|
||||
Returns:
|
||||
str: 生成的浏览器指纹字符串 (Generated browser fingerprint string).
|
||||
"""
|
||||
browsers: Dict[str, Callable[[], str]] = {
|
||||
"Chrome": cls.generate_chrome_fingerprint,
|
||||
"Firefox": cls.generate_firefox_fingerprint,
|
||||
"Safari": cls.generate_safari_fingerprint,
|
||||
"Edge": cls.generate_edge_fingerprint,
|
||||
}
|
||||
return browsers.get(browser_type, cls.generate_chrome_fingerprint)()
|
||||
|
||||
@classmethod
|
||||
def generate_chrome_fingerprint(cls) -> str:
|
||||
return cls._generate_fingerprint(platform="Win32")
|
||||
|
||||
@classmethod
|
||||
def generate_firefox_fingerprint(cls) -> str:
|
||||
return cls._generate_fingerprint(platform="Win32")
|
||||
|
||||
@classmethod
|
||||
def generate_safari_fingerprint(cls) -> str:
|
||||
return cls._generate_fingerprint(platform="MacIntel")
|
||||
|
||||
@classmethod
|
||||
def generate_edge_fingerprint(cls) -> str:
|
||||
return cls._generate_fingerprint(platform="Win32")
|
||||
|
||||
@staticmethod
|
||||
def _generate_fingerprint(platform: str) -> str:
|
||||
"""
|
||||
根据给定的参数生成浏览器指纹字符串。 (Generate a browser fingerprint string based on the given parameters.)
|
||||
|
||||
Args:
|
||||
platform (str): 操作系统平台 (Operating system platform).
|
||||
|
||||
Returns:
|
||||
str: 生成的浏览器指纹字符串 (Generated browser fingerprint string).
|
||||
"""
|
||||
inner_width = random.randint(1024, 1920)
|
||||
inner_height = random.randint(768, 1080)
|
||||
outer_width = inner_width + random.randint(24, 32)
|
||||
outer_height = inner_height + random.randint(75, 90)
|
||||
screen_x = 0
|
||||
screen_y = random.choice([0, 30])
|
||||
size_width = random.randint(1024, 1920)
|
||||
size_height = random.randint(768, 1080)
|
||||
avail_width = random.randint(1280, 1920)
|
||||
avail_height = random.randint(800, 1080)
|
||||
|
||||
fingerprint = (
|
||||
f"{inner_width}|{inner_height}|{outer_width}|{outer_height}|"
|
||||
f"{screen_x}|{screen_y}|0|0|{size_width}|{size_height}|"
|
||||
f"{avail_width}|{avail_height}|{inner_width}|{inner_height}|24|24|{platform}"
|
||||
)
|
||||
return fingerprint
|
||||
|
||||
|
||||
class ABogus:
|
||||
"""
|
||||
ABogus 类用于生成 ABogus 参数。
|
||||
|
||||
类属性:
|
||||
array1 (List[int]): 加密请求体 (Encrypted request body).
|
||||
array2 (List[int]): 加密请求头 (Encrypted request header).
|
||||
array3 (List[int]): 加密 UA (Encrypted User-Agent).
|
||||
aid (int): AID 值 (AID value).
|
||||
pageId (int): 页面 ID (Page ID).
|
||||
salt (str): 加密盐值 (Encryption salt).
|
||||
options (List[int]): 请求选项 (Request options).
|
||||
ua_key (bytes): UA 加密密钥 (UA encryption key).
|
||||
character (str): 自定义 Base64 字符表 (Custom Base64 alphabet).
|
||||
character2 (str): 自定义 Base64 字符表 (Custom Base64 alphabet).
|
||||
character_list (List[str]): 自定义 Base64 字符表列表 (List of custom Base64 alphabets).
|
||||
crypto_utility (CryptoUtility): 加密工具类 (Encryption utility).
|
||||
user_agent (str): 自定义 UA (Custom User-Agent).
|
||||
browser_fp (str): 浏览器指纹 (Browser fingerprint).
|
||||
sort_index (List[int]): 排序索引 (Sort index).
|
||||
sort_index_2 (List[int]): 排序索引 (Sort index).
|
||||
|
||||
说明:
|
||||
options 参数用于指定请求的类型,GET 请求使用 [0, 1, 8],POST 请求使用 [0, 1, 14]。14兼容8,POST同样可以编码params,故写死。
|
||||
(The options parameter is used to specify the type of request. GET requests use [0, 1, 8], and POST requests use [0, 1, 14]. 14 is compatible with 8, and POST can also encode params, so it is hardcoded.)
|
||||
|
||||
方法:
|
||||
encode_data(data: str, alphabet_index: int = 0) -> str:
|
||||
使用指定的字符表对数据进行 Base64 编码 (Encode the data using the specified Base64 alphabet).
|
||||
|
||||
generate_abogus(params: str, request: str = "") -> str:
|
||||
生成 ABogus 参数 (Generate the ABogus parameter).
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
# 生成 ABogus 参数,置空使用默认 UA 和浏览器指纹
|
||||
abogus = ABogus(user_agent="xxx", fp="xxx")
|
||||
abogus_param = abogus.generate_abogus("device_platform=webapp&aid=6383&channel=channel_pc_web&aweme_id=7380308675841297704……省略……")
|
||||
print(abogus_param[1])
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fp: str = "",
|
||||
user_agent: str = "",
|
||||
options: List[int] = [0, 1, 14],
|
||||
):
|
||||
self.aid = 6383
|
||||
self.pageId = 0 # 1.0.1.19 -> 6241
|
||||
self.salt = "cus" # 1.0.1.19 -> 加密盐 # dhzx
|
||||
self.boe = False
|
||||
self.ddrt = 8.5
|
||||
self.ic = 8.5
|
||||
self.paths = [
|
||||
"^/webcast/",
|
||||
"^/aweme/v1/",
|
||||
"^/aweme/v2/",
|
||||
"/v1/message/send",
|
||||
"^/live/",
|
||||
"^/captcha/",
|
||||
"^/ecom/",
|
||||
]
|
||||
self.array1: list[int] = [] # 加密请求体
|
||||
self.array2: list[int] = [] # 加密请求头 为空
|
||||
self.array3: list[int] = [] # 加密UA
|
||||
self.options = options # GET [0, 1, 8] POST [0, 1, 14]
|
||||
self.ua_key = b"\x00\x01\x0e" # ua加密key
|
||||
|
||||
self.character = (
|
||||
"Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
|
||||
)
|
||||
self.character2 = (
|
||||
"ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe"
|
||||
)
|
||||
self.character_list = [self.character, self.character2] # 自定义base64字符表
|
||||
|
||||
self.crypto_utility = CryptoUtility(
|
||||
self.salt, self.character_list
|
||||
) # 加密工具类
|
||||
|
||||
self.user_agent = (
|
||||
user_agent
|
||||
if user_agent is not None and user_agent != ""
|
||||
else "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0"
|
||||
) # 自定义ua,为空则设置一个默认ua
|
||||
|
||||
self.browser_fp = (
|
||||
fp
|
||||
if fp is not None and fp != ""
|
||||
else BrowserFingerprintGenerator.generate_fingerprint("Edge")
|
||||
) # 自定义浏览器指纹,为空则生成Edge指纹
|
||||
|
||||
# fmt: off
|
||||
self.sort_index = [
|
||||
18, 20, 52, 26, 30, 34, 58, 38, 40, 53, 42, 21, 27, 54, 55, 31, 35, 57, 39, 41, 43, 22, 28,
|
||||
32, 60, 36, 23, 29, 33, 37, 44, 45, 59, 46, 47, 48, 49, 50, 24, 25, 65, 66, 70, 71
|
||||
]
|
||||
self.sort_index_2 = [
|
||||
18, 20, 26, 30, 34, 38, 40, 42, 21, 27, 31, 35, 39, 41, 43, 22, 28, 32, 36, 23, 29, 33, 37,
|
||||
44, 45, 46, 47, 48, 49, 50, 24, 25, 52, 53, 54, 55, 57, 58, 59, 60, 65, 66, 70, 71
|
||||
]
|
||||
# fmt: on
|
||||
|
||||
def encode_data(self, data: str, alphabet_index: int = 0) -> str:
|
||||
"""
|
||||
使用指定的字符表对数据进行 Base64 编码 (Encode the data using the specified Base64 alphabet).
|
||||
|
||||
Args:
|
||||
data (str): 输入数据 (Input data).
|
||||
alphabet_index (int): 自定义字符表索引 (Custom alphabet index).
|
||||
|
||||
Returns:
|
||||
str: 编码后的数据 (Encoded data).
|
||||
"""
|
||||
return self.crypto_utility.abogus_encode(data, alphabet_index)
|
||||
|
||||
def generate_abogus(self, params: str, body: str = "") -> tuple:
|
||||
"""
|
||||
生成 abogus 参数 (Generate the ABogus parameter).
|
||||
|
||||
Args:
|
||||
params (str): 请求参数 (Request parameters).
|
||||
body (str): 请求体,GET接口则为空 (Request body, empty for GET interfaces).
|
||||
|
||||
Returns:
|
||||
tuple: params 生成的 abogus 参数 和 ua (ABogus parameter generated by params and ua).
|
||||
"""
|
||||
ab_dir = {
|
||||
8: 3, # 固定
|
||||
15: {
|
||||
"aid": self.aid,
|
||||
"pageId": self.pageId,
|
||||
"boe": self.boe,
|
||||
"ddrt": self.ddrt,
|
||||
"paths": self.paths,
|
||||
"track": {"mode": 0, "delay": 300, "paths": []},
|
||||
"dump": True,
|
||||
"rpU": "",
|
||||
},
|
||||
18: 44,
|
||||
19: [1, 0, 1, 0, 1],
|
||||
66: 0, # 固定
|
||||
69: 0, # 固定
|
||||
70: 0, # 固定
|
||||
71: 0, # 固定
|
||||
}
|
||||
|
||||
# 开始加密时间
|
||||
start_encryption = int(time.time() * 1000)
|
||||
|
||||
# params参数加盐加密
|
||||
array1 = self.crypto_utility.params_to_array(
|
||||
self.crypto_utility.params_to_array(params)
|
||||
)
|
||||
array2 = self.crypto_utility.params_to_array(
|
||||
self.crypto_utility.params_to_array(body)
|
||||
)
|
||||
array3 = self.crypto_utility.params_to_array(
|
||||
self.crypto_utility.base64_encode(
|
||||
"".join(
|
||||
[
|
||||
chr(b)
|
||||
for b in self.crypto_utility.rc4_encrypt(
|
||||
self.ua_key, self.user_agent
|
||||
)
|
||||
]
|
||||
),
|
||||
1,
|
||||
),
|
||||
add_salt=False,
|
||||
)
|
||||
|
||||
# 结束加密时间
|
||||
end_encryption = int(time.time() * 1000)
|
||||
|
||||
# 插入加密开始时间
|
||||
ab_dir[20] = (start_encryption >> 24) & 255
|
||||
ab_dir[21] = (start_encryption >> 16) & 255
|
||||
ab_dir[22] = (start_encryption >> 8) & 255
|
||||
ab_dir[23] = start_encryption & 255
|
||||
ab_dir[24] = int(start_encryption / 256 / 256 / 256 / 256) >> 0
|
||||
ab_dir[25] = int(start_encryption / 256 / 256 / 256 / 256 / 256) >> 0
|
||||
|
||||
# 插入请求头配置
|
||||
ab_dir[26] = (self.options[0] >> 24) & 255
|
||||
ab_dir[27] = (self.options[0] >> 16) & 255
|
||||
ab_dir[28] = (self.options[0] >> 8) & 255
|
||||
ab_dir[29] = self.options[0] & 255
|
||||
|
||||
# 插入请求方法
|
||||
ab_dir[30] = int(self.options[1] / 256) & 255
|
||||
ab_dir[31] = (self.options[1] % 256) & 255
|
||||
ab_dir[32] = (self.options[1] >> 24) & 255
|
||||
ab_dir[33] = (self.options[1] >> 16) & 255
|
||||
|
||||
# 插入请求头加密
|
||||
ab_dir[34] = (self.options[2] >> 24) & 255
|
||||
ab_dir[35] = (self.options[2] >> 16) & 255
|
||||
ab_dir[36] = (self.options[2] >> 8) & 255
|
||||
ab_dir[37] = self.options[2] & 255
|
||||
|
||||
# 插入请求体加密
|
||||
ab_dir[38] = array1[21]
|
||||
ab_dir[39] = array1[22]
|
||||
# 插入body加密
|
||||
ab_dir[40] = array2[21]
|
||||
ab_dir[41] = array2[22]
|
||||
# 插入ua加密
|
||||
ab_dir[42] = array3[23]
|
||||
ab_dir[43] = array3[24]
|
||||
|
||||
# 插入加密结束时间
|
||||
ab_dir[44] = (end_encryption >> 24) & 255
|
||||
ab_dir[45] = (end_encryption >> 16) & 255
|
||||
ab_dir[46] = (end_encryption >> 8) & 255
|
||||
ab_dir[47] = end_encryption & 255
|
||||
ab_dir[48] = ab_dir[8]
|
||||
ab_dir[49] = int(end_encryption / 256 / 256 / 256 / 256) >> 0
|
||||
ab_dir[50] = int(end_encryption / 256 / 256 / 256 / 256 / 256) >> 0
|
||||
|
||||
# 插入固定值
|
||||
ab_dir[51] = (self.pageId >> 24) & 255
|
||||
ab_dir[52] = (self.pageId >> 16) & 255
|
||||
ab_dir[53] = (self.pageId >> 8) & 255
|
||||
ab_dir[54] = self.pageId & 255
|
||||
ab_dir[55] = self.pageId
|
||||
ab_dir[56] = self.aid
|
||||
ab_dir[57] = self.aid & 255
|
||||
ab_dir[58] = (self.aid >> 8) & 255
|
||||
ab_dir[59] = (self.aid >> 16) & 255
|
||||
ab_dir[60] = (self.aid >> 24) & 255
|
||||
|
||||
# 插入浏览器指纹
|
||||
ab_dir[64] = len(self.browser_fp)
|
||||
ab_dir[65] = len(self.browser_fp)
|
||||
|
||||
# 获取 ab_dir 中 sort_index 的值
|
||||
sorted_values: List[int] = []
|
||||
for i in self.sort_index:
|
||||
value = ab_dir.get(i, 0)
|
||||
if isinstance(value, (int, float)):
|
||||
sorted_values.append(int(value))
|
||||
elif isinstance(value, str) and value.isdigit():
|
||||
sorted_values.append(int(value))
|
||||
else:
|
||||
sorted_values.append(0) # 对于不能转换为整数的值使用0作为默认值
|
||||
|
||||
# 将浏览器指纹转换为 ASCII 码列表
|
||||
edge_fp_array = StringProcessor.to_char_array(self.browser_fp)
|
||||
|
||||
# 将浏览器指纹长度的低 8 位作为异或值
|
||||
ab_xor = (len(self.browser_fp) & 255) >> 8 & 255
|
||||
|
||||
# 进行异或计算
|
||||
for index in range(len(self.sort_index_2) - 1):
|
||||
if index == 0:
|
||||
# 防御性编程,确保返回的是整数
|
||||
value = ab_dir.get(self.sort_index_2[index], 0)
|
||||
if isinstance(value, (int, float)):
|
||||
ab_xor = int(value)
|
||||
else:
|
||||
ab_xor = 0
|
||||
# 同样防御性地处理第二个值
|
||||
value = ab_dir.get(self.sort_index_2[index + 1], 0)
|
||||
if isinstance(value, (int, float)):
|
||||
ab_xor ^= int(value)
|
||||
|
||||
# 安全转换edge_fp_array中的值为整数
|
||||
clean_edge_fp = []
|
||||
for x in edge_fp_array:
|
||||
if isinstance(x, (int, float, str)) and str(x).isdigit():
|
||||
clean_edge_fp.append(int(x))
|
||||
else:
|
||||
# 对于无法转换的值使用0填充
|
||||
clean_edge_fp.append(0)
|
||||
|
||||
sorted_values.extend(clean_edge_fp)
|
||||
sorted_values.append(ab_xor)
|
||||
|
||||
abogus_bytes_str = (
|
||||
StringProcessor.generate_random_bytes()
|
||||
+ self.crypto_utility.transform_bytes(sorted_values)
|
||||
)
|
||||
|
||||
abogus = self.crypto_utility.abogus_encode(abogus_bytes_str, 0)
|
||||
params = "%s&a_bogus=%s" % (params, abogus)
|
||||
return (params, abogus, self.user_agent, body)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 24/06/16 晚点开源自定义ua
|
||||
# 24/07/08 支持自定义ua和浏览器指纹
|
||||
# 24/11/15 完成1.0.1.19版本abogus算法,择日开源
|
||||
# 25/03/05 修复POST请求参数加密错误,修补环境
|
||||
# 25/04/27 修复mypy检查错误
|
||||
|
||||
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0"
|
||||
chrome_fp = BrowserFingerprintGenerator.generate_fingerprint("Edge")
|
||||
abogus = ABogus(user_agent=user_agent, fp=chrome_fp)
|
||||
|
||||
# GET
|
||||
url = "https://www.douyin.com/aweme/v1/web/aweme/detail/?"
|
||||
params = "device_platform=webapp&aid=6383&channel=channel_pc_web&sec_user_id=MS4wLjABAAAArDVBosPJF3eIWVEFp0szuJ-e1V_-rK0ieJeWwpE77E8&max_cursor=0&locate_query=false&show_live_replay_strategy=1&need_time_list=1&time_list_query=0&whale_cut_token=&cut_version=1&count=18&publish_video_strategy_type=2&from_user_page=1&update_version_code=170400&pc_client_type=1&pc_libra_divert=Windows&support_h265=1&support_dash=0&version_code=290100&version_name=29.1.0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=Win32&browser_name=Edge&browser_version=131.0.0.0&browser_online=true&engine_name=Blink&engine_version=131.0.0.0&os_name=Windows&os_version=10&cpu_core_num=12&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50"
|
||||
body = ""
|
||||
print(url + abogus.generate_abogus(params=params, body=body)[0])
|
||||
|
||||
# POST
|
||||
url = "https://www.douyin.com/aweme/v2/web/aweme/stats/?"
|
||||
params = "device_platform=webapp&aid=6383&channel=channel_pc_web&pc_client_type=1&pc_libra_divert=Windows&update_version_code=170400&support_h265=1&support_dash=0&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1920&screen_height=1080&browser_language=zh-CN&browser_platform=Win32&browser_name=Edge&browser_version=131.0.0.0&browser_online=true&engine_name=Blink&engine_version=131.0.0.0&os_name=Windows&os_version=10&cpu_core_num=12&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50"
|
||||
body = "aweme_type=0&item_id=7467485482314763572&play_delta=1&source=0"
|
||||
print(url + abogus.generate_abogus(params=params, body=body)[0])
|
||||
|
||||
# # 测试生成100个abogus参数 和 100个指纹所需时间
|
||||
# start = time.time()
|
||||
# for _ in range(100):
|
||||
# abogus.generate_abogus(params=params, body=body)
|
||||
# end = time.time()
|
||||
# print("生成100个abogus参数和指纹所需时间:", end - start) # 生成100个abogus参数和指纹所需时间: 2.203000783920288
|
||||
|
||||
# start = time.time()
|
||||
# for _ in range(100):
|
||||
# BrowserFingerprintGenerator.generate_fingerprint("Chrome")
|
||||
# end = time.time()
|
||||
# print("生成100个指纹所需时间:", end - start) # 生成100个指纹所需时间: 0.00400090217590332
|
||||
@@ -1,153 +0,0 @@
|
||||
"""抖音图文 SSR 静态解析 — 不走浏览器,直接解析服务端渲染页面
|
||||
|
||||
与 douyin_api.py 的 playwright 拦截方案互补:
|
||||
- 视频作品 → fetch_douyin_content(playwright 拦截,可取全部清晰度)
|
||||
- 图文作品 → fetch_douyin_note_ssr(本模块,按 aweme_id 精准渲染,秒级返回)
|
||||
|
||||
SSR 数据(window._ROUTER_DATA / __pace_f RSC)是服务端按作品 ID 渲染的页面数据,
|
||||
不存在 playwright 拦截 aweme/v1/web/aweme/post 时取到作者其他作品的问题。
|
||||
|
||||
注意:必须用移动端 UA 请求 iesdouyin.com/share 端点,桌面 UA 会被 302 回主站
|
||||
并返回 JS 反爬壳(无任何 SSR 数据)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from nonebot import logger
|
||||
|
||||
from ...models import DouyinFetchError
|
||||
from ...utils import get_temp_root
|
||||
from .douyin_api import _process_animated_note, _process_note_with_parsed
|
||||
from .douyin_parser import parse_douyin_response, parse_note_images, parse_ssr_page
|
||||
|
||||
MOBILE_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 "
|
||||
"Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
ROUTER_DATA_RE = re.compile(r"window\._ROUTER_DATA\s*=\s*(.*?)</script>", re.S)
|
||||
# 短链重定向后统一为 iesdouyin.com/share/video/{id} 形态(图文作品也如此),
|
||||
# 因此 note / video 两种形态都要能提取 ID
|
||||
AWEME_ID_RE = re.compile(r"/(?:share/)?(?:note|video)/(\d+)")
|
||||
|
||||
|
||||
async def fetch_douyin_note_ssr(
|
||||
note_url: str,
|
||||
cookies: List[dict],
|
||||
) -> tuple[Optional[str], Optional[List[Path]]]:
|
||||
"""
|
||||
解析图文作品(SSR 静态解析,秒级返回)
|
||||
|
||||
接受 note / video 任意形态的抖音链接(重定向后图文也常是 share/video),
|
||||
按 SSR 内容判定:有 images → 图文/动图下载;无 images(真视频)→
|
||||
返回 (None, None) 交由 playwright 链路。
|
||||
|
||||
Returns:
|
||||
(title, file_paths) — 图片/视频本地路径列表;非图文返回 (None, None);
|
||||
失败抛 DouyinFetchError
|
||||
"""
|
||||
m = AWEME_ID_RE.search(note_url)
|
||||
if not m:
|
||||
raise DouyinFetchError(f"无法从链接提取作品 ID: {note_url}")
|
||||
vid = m.group(1)
|
||||
|
||||
api_response = await _fetch_ssr_api_response(note_url, vid, cookies)
|
||||
|
||||
# 内容判定:SSR 数据无 images → 真视频作品,回落 playwright
|
||||
aweme = (api_response.get("aweme_list") or [None])[0] or {}
|
||||
if not aweme.get("images"):
|
||||
logger.info("SSR 内容为视频作品(无 images),回退 playwright 链路")
|
||||
return None, None
|
||||
|
||||
# 复用原有解析链:标题 / 作者 / 文件名(referer 统一用 note 形态,
|
||||
# 避免输入是 share/video 时被误判为"视频")
|
||||
parsed = parse_douyin_response(
|
||||
api_response, f"https://www.douyin.com/note/{vid}"
|
||||
)
|
||||
logger.info(f"SSR 标题: {parsed.raw_title}")
|
||||
logger.info(f"SSR 作者: {parsed.raw_nickname}")
|
||||
|
||||
tmp_root = get_temp_root("douyin")
|
||||
headers = {
|
||||
"Referer": "https://www.douyin.com/",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/122.0.0.0 Safari/537.36"
|
||||
),
|
||||
}
|
||||
|
||||
# 复用原有分派:纯图文 / 图+视频混合 → 并发下载;纯动图 → 逐张下载 mp4
|
||||
images_urls, video_url = parse_note_images(api_response, None, vid)
|
||||
if images_urls:
|
||||
file_paths = await _process_note_with_parsed(
|
||||
images_urls, video_url, tmp_root, parsed.rel_stem, headers
|
||||
)
|
||||
else:
|
||||
file_paths = await _process_animated_note(
|
||||
api_response, tmp_root, parsed.rel_stem, headers
|
||||
)
|
||||
return parsed.raw_title, file_paths
|
||||
|
||||
|
||||
async def _fetch_ssr_api_response(
|
||||
note_url: str, vid: str, cookies: List[dict]
|
||||
) -> dict:
|
||||
"""请求 douyin.com note 页面并提取 SSR 数据,规范化为 aweme_list 格式
|
||||
|
||||
2026-08-13 起 iesdouyin.com/share/note 端点对所有请求(含登录态)只返回
|
||||
33KB 壳页(无任何作品数据),改从 www.douyin.com/note/{id} 提取
|
||||
__pace_f RSC 数据——服务端按作品 id 精确渲染,与 URL 一一对应,
|
||||
不存在 aweme/post 作者列表"取错作品"的问题。
|
||||
必须用桌面 UA + 登录 cookie(whale 风控对未登录请求截断作品数据)。
|
||||
"""
|
||||
url = f"https://www.douyin.com/note/{vid}"
|
||||
cookie_map = {c["name"]: c["value"] for c in cookies} if cookies else None
|
||||
headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/151.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Referer": "https://www.douyin.com/",
|
||||
}
|
||||
async with httpx.AsyncClient(
|
||||
headers=headers, timeout=20, follow_redirects=True
|
||||
) as client:
|
||||
resp = await client.get(url, cookies=cookie_map)
|
||||
if resp.status_code >= 400:
|
||||
raise DouyinFetchError(f"SSR 页面请求失败: status={resp.status_code}")
|
||||
html = resp.text
|
||||
logger.info(f"SSR 页面: {url} status={resp.status_code} size={len(html)}")
|
||||
|
||||
# 1. 旧版 SSR: window._ROUTER_DATA(snake_case 结构,可直接复用原有解析函数)
|
||||
if m := ROUTER_DATA_RE.search(html):
|
||||
data = _clean_js_json(m.group(1))
|
||||
if data:
|
||||
loader = data.get("loaderData", {})
|
||||
page = loader.get("note_(id)/page") or loader.get("video_(id)/page")
|
||||
item_list = (page.get("videoInfoRes") or {}).get("item_list") or []
|
||||
if item_list:
|
||||
logger.info("SSR 数据来源: _ROUTER_DATA")
|
||||
return {"aweme_list": [item_list[0]]}
|
||||
# 2. 新版 SSR: __pace_f.push RSC 流(parse_ssr_page 已规范化为 aweme_list 格式)
|
||||
if parsed := parse_ssr_page(html):
|
||||
logger.info("SSR 数据来源: __pace_f RSC")
|
||||
return parsed
|
||||
raise DouyinFetchError("SSR 页面无图文数据(可能已被删除或风控)")
|
||||
|
||||
|
||||
def _clean_js_json(raw: str) -> Optional[dict]:
|
||||
"""清洗 JS 对象文本为合法 JSON($undefined / $Lx 占位符)"""
|
||||
text = raw.strip().rstrip(";").strip()
|
||||
text = re.sub(r"\$undefined", "null", text)
|
||||
text = re.sub(r"\$L\d+", "null", text)
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("SSR JSON 解析失败")
|
||||
return None
|
||||
@@ -0,0 +1,388 @@
|
||||
"""抖音 web 接口抓取 — 免浏览器方案(2026-10 版)
|
||||
|
||||
链路:作品链接 → 提取 aweme_id → 签名请求 `aweme/v1/web/aweme/detail/`
|
||||
→ 解析 aweme_detail → 下载「最高码率视频 / 最高画质图片(含动图)」。
|
||||
|
||||
网关要求(2026-08 起 ArgusSecurityPlugin):
|
||||
1. 请求头 `x-tt-argus` 必须存在(实测只做存在性校验,占位值 "1" 即可);
|
||||
2. 请求头 `uifid` = cookie 里的 UIFID(未登录为 UIFID_TEMP);
|
||||
3. 查询参数 `a_bogus` 由纯 Python 签名器(douyin_sign.py,源自 f2,Apache-2.0)生成,
|
||||
UA 必须与签名时传入的 UA 一致(签名对 UA 做了二次哈希)。
|
||||
以上三点缺一即被网关 403(报错文案 Uifid Not Found / Signature Not Found)。
|
||||
|
||||
为什么废弃旧链路(SSR + playwright):
|
||||
- iesdouyin 分享页 SSR 已整体降级为壳页(无 videoInfoRes);
|
||||
- www.douyin.com 页面 HTML 对非浏览器客户端返回 jsvm 反爬壳,浏览器进页面也会
|
||||
随机触发验证码中间页(headless 同样被拦);
|
||||
- web 接口是页面播放器同款数据源,含全部清晰度档(bit_rate 梯)与无水印直链。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
import httpx
|
||||
from nonebot import logger
|
||||
|
||||
from ...models import DouyinFetchError
|
||||
from ...utils import get_temp_root, unique_media_path
|
||||
from .douyin_parser import parse_douyin_response
|
||||
from .douyin_sign import ABogus
|
||||
|
||||
# UA 必须与签名输入一致(ABogus 对 UA 做二次哈希);browser_name/version 参数同步为 Edge
|
||||
WEB_UA = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0"
|
||||
)
|
||||
MOBILE_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 "
|
||||
"Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
DETAIL_API = "https://www.douyin.com/aweme/v1/web/aweme/detail/"
|
||||
REFERER = "https://www.douyin.com/"
|
||||
AWEME_ID_RE = re.compile(r"/(?:share/)?(?:note|video)/(\d+)")
|
||||
|
||||
# web 端基础参数(对齐浏览器请求;msToken 实测非必需,不生成)
|
||||
_BASE_PARAMS: Dict[str, Union[str, int]] = {
|
||||
"device_platform": "webapp",
|
||||
"aid": "6383",
|
||||
"channel": "channel_pc_web",
|
||||
"pc_client_type": 1,
|
||||
"publish_video_strategy_type": 2,
|
||||
"pc_libra_divert": "Windows",
|
||||
"version_code": "290100",
|
||||
"version_name": "29.1.0",
|
||||
"cookie_enabled": "true",
|
||||
"screen_width": 1920,
|
||||
"screen_height": 1080,
|
||||
"browser_language": "zh-CN",
|
||||
"browser_platform": "Win32",
|
||||
"browser_name": "Edge",
|
||||
"browser_version": "130.0.0.0",
|
||||
"browser_online": "true",
|
||||
"engine_name": "Blink",
|
||||
"engine_version": "130.0.0.0",
|
||||
"os_name": "Windows",
|
||||
"os_version": "10",
|
||||
"cpu_core_num": 12,
|
||||
"device_memory": 8,
|
||||
"platform": "PC",
|
||||
"downlink": 10,
|
||||
"effective_type": "4g",
|
||||
"round_trip_time": 100,
|
||||
}
|
||||
|
||||
|
||||
def extract_aweme_id(url: str) -> Optional[str]:
|
||||
"""从分享链接 / 全链接里提取作品 id(share/note、share/video、note、video 均支持)"""
|
||||
m = AWEME_ID_RE.search(url)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = re.search(r"/(\d+)(?:\?|#|$)", url)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
async def resolve_short_link(url: str) -> Optional[str]:
|
||||
"""短链重定向:取 Location(最多 3 跳);失败返回 None
|
||||
|
||||
只用于拿真实作品 URL(提取 aweme_id),不依赖 iesdouyin 页面内容。
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers={"User-Agent": MOBILE_UA},
|
||||
follow_redirects=False,
|
||||
timeout=10,
|
||||
) as client:
|
||||
current = url
|
||||
for _ in range(3):
|
||||
resp = await client.get(current)
|
||||
if resp.status_code >= 400:
|
||||
return None
|
||||
location = resp.headers.get("Location")
|
||||
if not location:
|
||||
return str(resp.url)
|
||||
current = urllib.parse.urljoin(current, location)
|
||||
return current
|
||||
except Exception:
|
||||
logger.warning(f"抖音短链重定向失败: {url}")
|
||||
return None
|
||||
|
||||
|
||||
def build_signed_query(aweme_id: str) -> str:
|
||||
"""构造带 a_bogus 签名的查询串(纯函数,便于测试)"""
|
||||
params = dict(_BASE_PARAMS)
|
||||
params["aweme_id"] = aweme_id
|
||||
param_str = "&".join(f"{k}={v}" for k, v in params.items())
|
||||
signed, a_bogus, _, _ = ABogus(user_agent=WEB_UA).generate_abogus(param_str)
|
||||
logger.debug(f"抖音签名: a_bogus={a_bogus[:16]}... len={len(a_bogus)}")
|
||||
return signed
|
||||
|
||||
|
||||
def _douyin_cookie_map(cookies: List[dict]) -> Dict[str, str]:
|
||||
"""Netscape cookie 列表 → {name: value}(只保留 douyin 域)"""
|
||||
return {
|
||||
c["name"]: c["value"]
|
||||
for c in cookies or []
|
||||
if "douyin" in (c.get("domain") or "")
|
||||
}
|
||||
|
||||
|
||||
def _build_headers(cookie_map: Dict[str, str]) -> Dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": WEB_UA,
|
||||
"Referer": REFERER,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
# 网关只校验存在性,占位值即可(f2 项目 issue #443 实测结论)
|
||||
"x-tt-argus": "1",
|
||||
}
|
||||
uifid = cookie_map.get("UIFID") or cookie_map.get("UIFID_TEMP")
|
||||
if uifid:
|
||||
headers["uifid"] = uifid
|
||||
return headers
|
||||
|
||||
|
||||
async def fetch_douyin_detail(aweme_id: str, cookies: List[dict]) -> dict:
|
||||
"""请求作品 detail 接口,返回 aweme_detail dict(失败抛 DouyinFetchError)"""
|
||||
cookie_map = _douyin_cookie_map(cookies)
|
||||
if not (cookie_map.get("UIFID") or cookie_map.get("UIFID_TEMP")):
|
||||
raise DouyinFetchError("cookies.txt 缺少抖音 UIFID(重新登录抖音网页版导出)")
|
||||
|
||||
url = f"{DETAIL_API}?{build_signed_query(aweme_id)}"
|
||||
headers = _build_headers(cookie_map)
|
||||
last_error: Optional[Exception] = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers=headers, cookies=cookie_map, timeout=20
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code == 403:
|
||||
raise DouyinFetchError(f"detail 接口被网关拦截: {resp.text[:80]}")
|
||||
if resp.status_code >= 400:
|
||||
raise DouyinFetchError(f"detail 接口请求失败: {resp.status_code}")
|
||||
data = resp.json()
|
||||
detail = data.get("aweme_detail")
|
||||
if detail:
|
||||
return detail
|
||||
# status_code=0 但无数据:反爬空响应(签名偶发不同步),重试一次
|
||||
last_error = DouyinFetchError(
|
||||
f"接口返回空数据 (status_code={data.get('status_code')})"
|
||||
)
|
||||
except DouyinFetchError as e:
|
||||
last_error = e
|
||||
except Exception as e: # 网络异常
|
||||
last_error = e
|
||||
if attempt == 0:
|
||||
logger.warning(f"detail 请求失败,1 秒后重试: {last_error!r}")
|
||||
await asyncio.sleep(1)
|
||||
raise DouyinFetchError(f"获取作品详情失败: {last_error}")
|
||||
|
||||
|
||||
# ============================= 媒体选择 =============================
|
||||
|
||||
|
||||
def _best_urls_from_video(video: dict) -> Tuple[List[str], dict]:
|
||||
"""从 video 对象选最高码率档的 URL 候选列表(含备选 CDN),返回 (urls, meta)"""
|
||||
gears = [
|
||||
g
|
||||
for g in (video.get("bit_rate") or [])
|
||||
if isinstance(g, dict) and (g.get("play_addr") or {}).get("url_list")
|
||||
]
|
||||
if gears:
|
||||
best = max(gears, key=lambda g: g.get("bit_rate") or 0)
|
||||
play_addr = best.get("play_addr") or {}
|
||||
meta = {
|
||||
"gear_name": best.get("gear_name"),
|
||||
"bit_rate": best.get("bit_rate"),
|
||||
"width": play_addr.get("width"),
|
||||
"height": play_addr.get("height"),
|
||||
"is_h265": best.get("is_h265"),
|
||||
"size": play_addr.get("data_size"),
|
||||
}
|
||||
return list(play_addr.get("url_list") or []), meta
|
||||
play_addr = video.get("play_addr") or {}
|
||||
meta = {
|
||||
"gear_name": None,
|
||||
"bit_rate": None,
|
||||
"width": play_addr.get("width"),
|
||||
"height": play_addr.get("height"),
|
||||
"is_h265": video.get("is_h265"),
|
||||
"size": play_addr.get("data_size"),
|
||||
}
|
||||
return list(play_addr.get("url_list") or []), meta
|
||||
|
||||
|
||||
def collect_download_items(
|
||||
detail: dict,
|
||||
) -> Tuple[List[Tuple[List[str], str]], bool]:
|
||||
"""从 aweme_detail 收集下载项。
|
||||
|
||||
返回 (items, is_image_post):
|
||||
- 视频作品 → ([(视频URL候选, ".mp4")], False)
|
||||
- 图文(含图+视频混合)→ 每项一个 (URL候选列表, 扩展名):
|
||||
图片项按 无水印 url_list → 带作者水印的 download_url_list 排序;
|
||||
内嵌视频项(动图 / 图集中的视频)走最高码率档。
|
||||
is_image_post=True
|
||||
"""
|
||||
images = detail.get("images") or []
|
||||
if not images:
|
||||
urls, meta = _best_urls_from_video(detail.get("video") or {})
|
||||
if not urls:
|
||||
raise DouyinFetchError("未找到可用视频直链")
|
||||
logger.info(
|
||||
f"抖音视频最高档: {meta.get('gear_name')} "
|
||||
f"{meta.get('width')}x{meta.get('height')} "
|
||||
f"br={meta.get('bit_rate')} size={meta.get('size')} "
|
||||
f"h265={meta.get('is_h265')} 候选={len(urls)}"
|
||||
)
|
||||
return [(urls, ".mp4")], False
|
||||
|
||||
items: List[Tuple[List[str], str]] = []
|
||||
for img in images:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
video = img.get("video")
|
||||
if isinstance(video, dict) and (video.get("play_addr") or {}).get("url_list"):
|
||||
urls, meta = _best_urls_from_video(video)
|
||||
if urls:
|
||||
logger.info(
|
||||
f"图集内视频项: {meta.get('gear_name')} br={meta.get('bit_rate')}"
|
||||
)
|
||||
items.append((urls, ".mp4"))
|
||||
continue
|
||||
# 图片项:url_list 为无水印版(q75 webp/jpeg);
|
||||
# download_url_list 是带作者水印的"保存到相册"版(tplv-dy-water-v2,
|
||||
# base64 里就是作者昵称),只能当兜底绝不能优先
|
||||
urls = list(img.get("url_list") or []) + list(
|
||||
img.get("download_url_list") or []
|
||||
)
|
||||
if urls:
|
||||
items.append((urls, ""))
|
||||
if not items:
|
||||
raise DouyinFetchError("图文作品未解析到任何图片/视频")
|
||||
logger.info(f"抖音图文作品: {len(items)} 个下载项")
|
||||
return items, True
|
||||
|
||||
|
||||
# ============================= 下载 =============================
|
||||
|
||||
|
||||
def _infer_extension(url: str) -> str:
|
||||
"""从 URL 推断文件扩展名(douyinpic 图片 URL 无扩展名时默认 jpg)"""
|
||||
url_lower = url.lower()
|
||||
for ext in (".webp", ".jpeg", ".jpg", ".png", ".gif", ".avif", ".heic"):
|
||||
if ext in url_lower:
|
||||
return ext
|
||||
return ".jpg"
|
||||
|
||||
|
||||
async def _stream_download(
|
||||
url: str, filepath: Path, headers: dict, timeout: float = 300
|
||||
) -> None:
|
||||
async with httpx.AsyncClient(headers=headers, timeout=timeout) as client:
|
||||
async with client.stream("GET", url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(filepath, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(65536):
|
||||
f.write(chunk)
|
||||
|
||||
|
||||
async def _download_with_fallback(
|
||||
candidates: List[str], filepath: Path, headers: dict
|
||||
) -> Optional[Path]:
|
||||
"""按候选 URL 顺序下载(CDN 多备份),全部失败返回 None"""
|
||||
for url in candidates:
|
||||
try:
|
||||
await _stream_download(url, filepath, headers)
|
||||
if filepath.stat().st_size == 0: # 空响应也当失败,换下一个候选
|
||||
raise DouyinFetchError("响应为空(0 字节)")
|
||||
logger.info(f"已保存: {filepath.name} ({filepath.stat().st_size} 字节)")
|
||||
return filepath
|
||||
except Exception as e:
|
||||
logger.warning(f"下载失败,尝试下一个 CDN: {url[:80]}... ({e!r})")
|
||||
return None
|
||||
|
||||
|
||||
async def _download_video_file(
|
||||
candidates: List[str], tmp_root: Path, rel_stem: str, headers: dict
|
||||
) -> Path:
|
||||
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
|
||||
result = await _download_with_fallback(candidates, output_path, headers)
|
||||
if result is None:
|
||||
raise DouyinFetchError("视频下载失败(所有 CDN 均不可用)")
|
||||
logger.info(f"视频下载完成: {result}")
|
||||
return result
|
||||
|
||||
|
||||
async def _download_note_items(
|
||||
items: List[Tuple[List[str], str]],
|
||||
tmp_root: Path,
|
||||
rel_stem: str,
|
||||
headers: dict,
|
||||
) -> List[Path]:
|
||||
"""图集/动图:一个作品一个目录,`001.jpg / 002.mp4 ...` 并发下载"""
|
||||
note_dir = unique_media_path(tmp_root / rel_stem)
|
||||
note_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.info(f"图集保存目录: {note_dir}")
|
||||
|
||||
async def _one(idx: int, candidates: List[str], ext: str) -> Optional[Path]:
|
||||
ext = ext or _infer_extension(candidates[0])
|
||||
filepath = note_dir / f"{idx:03d}{ext}"
|
||||
return await _download_with_fallback(candidates, filepath, headers)
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(_one(i, urls, ext) for i, (urls, ext) in enumerate(items, 1))
|
||||
)
|
||||
saved = [p for p in results if p is not None]
|
||||
if not saved:
|
||||
raise DouyinFetchError("图集下载失败(所有文件均失败)")
|
||||
logger.info(f"图集下载完成,共 {len(saved)}/{len(items)} 个文件")
|
||||
return saved
|
||||
|
||||
|
||||
# ============================= 主入口 =============================
|
||||
|
||||
|
||||
async def fetch_douyin_web(
|
||||
url: str,
|
||||
cookies: List[dict],
|
||||
) -> Tuple[str, Union[Path, List[Path]], bool]:
|
||||
"""解析抖音作品(视频 / 图文 / 动图),返回 (title, 本地路径, 是否图文)
|
||||
|
||||
失败抛 DouyinFetchError(由调用方兜底)。
|
||||
"""
|
||||
aweme_id = extract_aweme_id(url)
|
||||
if not aweme_id:
|
||||
# 短链(v.douyin.com/xxx)或异常形态 → 先重定向拿全链接再提取
|
||||
resolved = await resolve_short_link(url)
|
||||
if resolved:
|
||||
logger.info(f"抖音短链重定向: {url} -> {resolved}")
|
||||
url = resolved
|
||||
aweme_id = extract_aweme_id(url)
|
||||
if not aweme_id:
|
||||
raise DouyinFetchError(f"无法从链接提取作品 ID: {url}")
|
||||
|
||||
detail = await fetch_douyin_detail(aweme_id, cookies)
|
||||
items, is_img_post = collect_download_items(detail)
|
||||
|
||||
# 内容形态以接口数据为准(分享短链重定向后图文也常是 share/video 形态)
|
||||
referer = f"https://www.douyin.com/{'note' if is_img_post else 'video'}/{aweme_id}"
|
||||
parsed = parse_douyin_response({"aweme_detail": detail}, referer)
|
||||
logger.info(f"抖音标题: {parsed.raw_title}")
|
||||
logger.info(f"抖音作者: {parsed.raw_nickname}")
|
||||
logger.info(f"抖音落盘: {parsed.rel_stem}")
|
||||
|
||||
tmp_root = get_temp_root("douyin")
|
||||
headers = {"Referer": REFERER, "User-Agent": WEB_UA}
|
||||
if is_img_post:
|
||||
paths: Union[Path, List[Path]] = await _download_note_items(
|
||||
items, tmp_root, parsed.rel_stem, headers
|
||||
)
|
||||
else:
|
||||
paths = await _download_video_file(
|
||||
items[0][0], tmp_root, parsed.rel_stem, headers
|
||||
)
|
||||
return parsed.raw_title, paths, is_img_post
|
||||
@@ -0,0 +1,322 @@
|
||||
"""快手作品解析 — 分享页 window.INIT_STATE 静态提取
|
||||
|
||||
借鉴 nonebot-plugin-parser 的 KuaiShouParser:
|
||||
- v.kuaishou.com/{code} 短链 → 取 Location 跳到 v.m.chenzhongtech.com/fw/photo/{id}
|
||||
- www.kuaishou.com/short-video/{id} → 重写为移动端 fw/photo/{id}
|
||||
(桌面站匿名请求返回 `Intercept-Result: risk-control` 的 JSON,拿不到页面)
|
||||
- /fw/long-video/ 与 /fw/photo/ 返回结构不同,统一按 /fw/photo/ 请求
|
||||
- 页面 HTML 提取 window.INIT_STATE → dict 里第一个非空 photo
|
||||
- 视频优先从清晰度梯 manifest.adaptationSet 挑最优档(见 `_manifest_video_url`),
|
||||
没有 manifest 才退回 photo.mainMvUrls(默认档)
|
||||
- 图集取 photo.ext_params.atlas 的 cdnList + list 拼 URL
|
||||
|
||||
必须用移动端(iPhone)UA 请求:桌面 UA 与匿名 www 站都只返回风控壳页。
|
||||
|
||||
返回 (title, 文件):
|
||||
- 视频作品 → (title, 视频文件 Path)
|
||||
- 图集 / 图+视频混排 → (title, [文件路径列表],视频排在图片之后)
|
||||
- 纯文字作品 → (title, [])
|
||||
- 解析失败 → (None, None)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
from nonebot import logger
|
||||
|
||||
from ...models import ContentFetchError
|
||||
from ...utils import (
|
||||
build_author_dir,
|
||||
build_work_stem,
|
||||
get_temp_root,
|
||||
unique_media_path,
|
||||
)
|
||||
|
||||
#: 移动端 UA(桌面 UA 会被风控挡下,见模块文档)
|
||||
KUAISHOU_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_6 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Mobile/15E148 "
|
||||
"Safari/604.1 Edg/132.0.0.0"
|
||||
)
|
||||
KUAISHOU_REFERER = "https://v.kuaishou.com/"
|
||||
|
||||
SHORT_LINK_RE = re.compile(r"v\.kuaishou\.com/[A-Za-z0-9._?%&+\-=/]+")
|
||||
WEB_SHORT_VIDEO_RE = re.compile(
|
||||
r"(?:www\.)?kuaishou\.com/(?:short-video|fw/[a-z-]+)/([A-Za-z0-9]+)"
|
||||
)
|
||||
INIT_STATE_RE = re.compile(r"window\.INIT_STATE\s*=\s*(.*?)</script>", re.S)
|
||||
|
||||
#: 昵称缺失时的兜底串(utils.build_author_dir 认它,转成"未知作者"规则)
|
||||
FALLBACK_AUTHOR = "快手用户"
|
||||
|
||||
|
||||
async def fetch_kuaishou_content(
|
||||
url: str,
|
||||
) -> tuple[Optional[str], Optional[Union[Path, list[Path]]]]:
|
||||
"""解析快手作品链接(短链 / 分享页 / 网页版短链)"""
|
||||
try:
|
||||
return await _parse(url)
|
||||
except Exception:
|
||||
logger.exception(f"快手解析失败: {url}")
|
||||
return None, None
|
||||
|
||||
|
||||
async def _parse(url: str):
|
||||
# 1. 短链 → 单跳重定向(Location 即 v.m.chenzhongtech.com/fw/photo/{id})
|
||||
if "v.kuaishou.com" in url:
|
||||
resolved = await _resolve_redirect(url)
|
||||
if resolved:
|
||||
logger.info(f"快手短链重定向: {url} -> {resolved}")
|
||||
url = resolved
|
||||
|
||||
# 2. www.kuaishou.com/short-video/{id} → 移动端分享页(同一套 INIT_STATE)
|
||||
m = WEB_SHORT_VIDEO_RE.search(url)
|
||||
if m and "chenzhongtech.com" not in url:
|
||||
url = f"https://v.m.chenzhongtech.com/fw/photo/{m.group(1)}"
|
||||
logger.info(f"快手链接规范化: {url}")
|
||||
|
||||
# 3. long-video 与 photo 返回结构不同,统一按 photo 请求
|
||||
url = url.replace("/fw/long-video/", "/fw/photo/")
|
||||
|
||||
html = await _fetch_page(url)
|
||||
photo = _extract_photo(html)
|
||||
logger.info(f"快手页面解析: {_describe(photo)}")
|
||||
return await _build_result(photo)
|
||||
|
||||
|
||||
def _describe(photo: dict) -> str:
|
||||
"""日志用的一句话摘要(标题/作者/时长)"""
|
||||
caption = str(photo.get("caption") or "").strip()
|
||||
nickname = str(photo.get("userName") or "").strip()
|
||||
if len(caption) > 30:
|
||||
caption = caption[:30]
|
||||
return f"作者={nickname or '未知'}, 标题={caption or '无'}, 时长={photo.get('duration') or 0}ms"
|
||||
|
||||
|
||||
def _extract_photo(html: str) -> dict:
|
||||
"""从 window.INIT_STATE 提取作品数据(取第一个非空 photo)"""
|
||||
m = INIT_STATE_RE.search(html)
|
||||
if not m:
|
||||
raise ContentFetchError("快手页面无 INIT_STATE(可能已删除或风控)")
|
||||
|
||||
raw = m.group(1).strip()
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
# 页面偶有 undefined 之类的非 JSON 字面量(同小红书预处理)
|
||||
try:
|
||||
data = json.loads(raw.replace("undefined", "null"))
|
||||
except json.JSONDecodeError:
|
||||
raise ContentFetchError("快手 INIT_STATE JSON 解析失败")
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ContentFetchError("快手 INIT_STATE 结构异常")
|
||||
for value in data.values():
|
||||
if isinstance(value, dict) and isinstance(value.get("photo"), dict):
|
||||
return value["photo"]
|
||||
raise ContentFetchError("页面数据中未找到作品(可能已删除或风控)")
|
||||
|
||||
|
||||
def _first_url(items) -> Optional[str]:
|
||||
"""[{cdn, url}, ...] / [url, ...] → 第一个可用 URL(快手同一资源给多个 CDN)"""
|
||||
if not isinstance(items, list):
|
||||
return None
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("url"):
|
||||
return str(item["url"])
|
||||
if isinstance(item, str) and item:
|
||||
return item
|
||||
return None
|
||||
|
||||
|
||||
#: 同分辨率/帧率时优先 avc(QQ 客户端播放、群文件解压后二次剪辑都最稳;
|
||||
#: hevc 体积小但兼容性差,只在画质更高时才选)
|
||||
_CODEC_RANK = {"avc": 0, "avc1": 0, "h264": 0}
|
||||
|
||||
|
||||
def _manifest_video_url(photo: dict) -> Optional[str]:
|
||||
"""从清晰度梯 `manifest.adaptationSet[].representation[]` 挑最优档
|
||||
|
||||
排序键 = 分辨率 → 帧率 → 编码(avc 优先,见 `_CODEC_RANK`)→ 码率。
|
||||
参考实现直接用 mainMvUrls(默认档),多档作品拿不到最高清晰度。
|
||||
"""
|
||||
adaptations = (photo.get("manifest") or {}).get("adaptationSet")
|
||||
if not isinstance(adaptations, list):
|
||||
return None
|
||||
|
||||
best_rep = None
|
||||
best_key = None
|
||||
total = 0
|
||||
for adaptation in adaptations:
|
||||
if not isinstance(adaptation, dict):
|
||||
continue
|
||||
for rep in adaptation.get("representation") or []:
|
||||
if not isinstance(rep, dict) or not rep.get("url"):
|
||||
continue
|
||||
total += 1
|
||||
codec = str(rep.get("videoCodec") or "").lower()
|
||||
key = (
|
||||
(rep.get("width") or 0) * (rep.get("height") or 0),
|
||||
rep.get("frameRate") or 0,
|
||||
-_CODEC_RANK.get(codec, 1), # 取 max,故用负值让 avc 排前
|
||||
rep.get("avgBitrate") or 0,
|
||||
)
|
||||
if best_key is None or key > best_key:
|
||||
best_key, best_rep = key, rep
|
||||
|
||||
if best_rep is None:
|
||||
return None
|
||||
logger.info(
|
||||
f"快手清晰度: {best_rep.get('width')}x{best_rep.get('height')} "
|
||||
f"{best_rep.get('frameRate')}fps {best_rep.get('videoCodec')} "
|
||||
f"avg={best_rep.get('avgBitrate')}kbps "
|
||||
f"size={best_rep.get('fileSize')}({total} 档中选最优)"
|
||||
)
|
||||
return str(best_rep["url"])
|
||||
|
||||
|
||||
def _video_url(photo: dict) -> Optional[str]:
|
||||
"""视频直链:清晰度梯最优档 → mainMvUrls 兜底(老页面可能没 manifest)"""
|
||||
return _manifest_video_url(photo) or _first_url(photo.get("mainMvUrls"))
|
||||
|
||||
|
||||
def _atlas_image_urls(photo: dict) -> list[str]:
|
||||
"""图集图片:ext_params.atlas.cdnList(图床域名)+ list(路径)拼 URL
|
||||
|
||||
atlas 缺失/为空即非图集作品(视频的 ext_params 里只有编码参数)。
|
||||
"""
|
||||
ext = photo.get("ext_params") or photo.get("extParams") or {}
|
||||
atlas = ext.get("atlas") if isinstance(ext, dict) else None
|
||||
if not isinstance(atlas, dict):
|
||||
return []
|
||||
|
||||
routes = atlas.get("list")
|
||||
if not isinstance(routes, list) or not routes:
|
||||
return []
|
||||
|
||||
cdn = ""
|
||||
for item in atlas.get("cdnList") or []:
|
||||
if isinstance(item, dict) and item.get("cdn"):
|
||||
cdn = str(item["cdn"])
|
||||
break
|
||||
if not cdn:
|
||||
return []
|
||||
|
||||
return [
|
||||
f"https://{cdn}/{route}"
|
||||
for route in routes
|
||||
if isinstance(route, str) and route
|
||||
]
|
||||
|
||||
|
||||
def _build_rel_stem(photo: dict) -> str:
|
||||
"""相对平台根的路径词干:`{作者}_{userId}/{作品名}`"""
|
||||
author = str(photo.get("userName") or "").replace("ㅤ", "").strip()
|
||||
author = author or FALLBACK_AUTHOR
|
||||
source = str(photo.get("photoId") or "")
|
||||
return (
|
||||
f"{build_author_dir(author, photo.get('userId'), source=source)}"
|
||||
f"/{build_work_stem(str(photo.get('caption') or ''), source=source)}"
|
||||
)
|
||||
|
||||
|
||||
async def _build_result(photo: dict) -> tuple[str, Union[Path, list[Path]]]:
|
||||
text = str(photo.get("caption") or "").strip() or FALLBACK_AUTHOR
|
||||
rel_stem = _build_rel_stem(photo)
|
||||
|
||||
video_url = _video_url(photo)
|
||||
image_urls = _atlas_image_urls(photo)
|
||||
|
||||
# 图集(含"图 + 视频"混排):一个作品一个目录,视频排在图片之后
|
||||
if image_urls:
|
||||
files = await _download_images(image_urls, video_url, rel_stem)
|
||||
if not files:
|
||||
raise ContentFetchError("快手媒体下载失败")
|
||||
logger.info(
|
||||
f"快手图集: 图片={len(image_urls)} 张, "
|
||||
f"视频={'有' if video_url else '无'}, 落盘={len(files)} 个"
|
||||
)
|
||||
return text, files
|
||||
|
||||
if video_url:
|
||||
path = await _download_video(video_url, rel_stem)
|
||||
return text, path
|
||||
|
||||
logger.info(f"快手文字作品(无媒体): {text[:30]}")
|
||||
return text, []
|
||||
|
||||
|
||||
async def _download_images(
|
||||
image_urls: list[str], video_url: Optional[str], rel_stem: str
|
||||
) -> list[Path]:
|
||||
"""并发下载图集(复用抖音图文的下载流程,视频追加在图片之后)"""
|
||||
|
||||
from .douyin_api import _process_note_with_parsed
|
||||
|
||||
tmp_root = get_temp_root("kuaishou")
|
||||
headers = {"Referer": KUAISHOU_REFERER, "User-Agent": KUAISHOU_UA}
|
||||
return await _process_note_with_parsed(
|
||||
[[u] for u in image_urls], video_url, tmp_root, rel_stem, headers
|
||||
)
|
||||
|
||||
|
||||
async def _download_video(video_url: str, rel_stem: str) -> Path:
|
||||
"""流式下载视频到 `{平台根}/{作者目录}/{作品名}.mp4`"""
|
||||
import httpx
|
||||
|
||||
tmp_root = get_temp_root("kuaishou")
|
||||
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
|
||||
headers = {"Referer": KUAISHOU_REFERER, "User-Agent": KUAISHOU_UA}
|
||||
async with httpx.AsyncClient(headers=headers, timeout=300) as client:
|
||||
async with client.stream("GET", video_url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
f.write(chunk)
|
||||
logger.info(f"快手视频下载完成: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
async def _fetch_page(url: str) -> str:
|
||||
import httpx
|
||||
|
||||
headers = {"User-Agent": KUAISHOU_UA, "Referer": KUAISHOU_REFERER}
|
||||
async with httpx.AsyncClient(
|
||||
headers=headers, timeout=20, follow_redirects=True
|
||||
) as client:
|
||||
resp = await client.get(url)
|
||||
if resp.status_code >= 400:
|
||||
raise ContentFetchError(f"快手页面请求失败: status={resp.status_code}")
|
||||
return resp.text
|
||||
|
||||
|
||||
async def _resolve_redirect(url: str) -> Optional[str]:
|
||||
"""短链重定向(最多 3 跳,取最终 URL)
|
||||
|
||||
v.kuaishou.com 是 302 + Location,单跳即到 chenzhongtech 分享页;
|
||||
与小红书一样用移动端 headers,避免被当作非 App 来源拒绝。
|
||||
"""
|
||||
import httpx
|
||||
|
||||
headers = {"User-Agent": KUAISHOU_UA, "Referer": KUAISHOU_REFERER}
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers=headers, follow_redirects=False, timeout=10
|
||||
) as client:
|
||||
current = url
|
||||
for _ in range(3):
|
||||
resp = await client.get(current)
|
||||
if resp.status_code >= 400:
|
||||
return None
|
||||
location = resp.headers.get("Location")
|
||||
if not location:
|
||||
return str(resp.url)
|
||||
# Location 可能是相对路径,需拼上当前 URL
|
||||
current = urllib.parse.urljoin(current, location)
|
||||
return current
|
||||
except Exception:
|
||||
logger.warning(f"快手短链重定向失败: {url}")
|
||||
return None
|
||||
@@ -6,8 +6,9 @@
|
||||
- xiaohongshu.com/discovery/item/{id}?xsec_token=... → 移动端点(fallback)
|
||||
- 页面 HTML 提取 window.__INITIAL_STATE__ → noteDetailMap[id].note
|
||||
|
||||
请求端点依次尝试 国际站 rednote.com(含无水印原片 originVideoKey)
|
||||
→ 国内站 xiaohongshu.com(国内笔记国际站无数据,需 cookies.txt 登录态)。
|
||||
请求端点按链接来源选 host 顺序:国内链接(xiaohongshu.com/xhslink.com)先试
|
||||
国内站、国际链接(rednote.com)先试国际站(国际站数据含无水印原片 originVideoKey;
|
||||
国内笔记在国际站没有数据,反之亦然),失败再换站。需 cookies.txt 登录态。
|
||||
|
||||
返回 (title, 文件):
|
||||
- 图文笔记 → (title, [图片路径列表])
|
||||
@@ -44,16 +45,15 @@ REDNOTE_UA = (
|
||||
REDNOTE_REFERER = "https://www.xiaohongshu.com/"
|
||||
|
||||
REDNOTE_SHORT_RE = re.compile(r"xhslink\.(?:com|cn)/[A-Za-z0-9._?%&+=/#@-]+")
|
||||
EXPLORE_RE = re.compile(
|
||||
r"xiaohongshu\.com/explore/([0-9a-zA-Z]+)(\?[^\s\"'<>]+)?"
|
||||
)
|
||||
EXPLORE_RE = re.compile(r"xiaohongshu\.com/explore/([0-9a-zA-Z]+)(\?[^\s\"'<>]+)?")
|
||||
DISCOVERY_RE = re.compile(
|
||||
r"xiaohongshu\.com/discovery/item/([0-9a-zA-Z]+)(\?[^\s\"'<>]+)?"
|
||||
)
|
||||
INITIAL_STATE_RE = re.compile(r"window\.__INITIAL_STATE__=(.*?)</script>", re.S)
|
||||
|
||||
# 请求 host 顺序:国际站优先(含无水印原片),失败回退国内站(国内笔记)
|
||||
REDNOTE_HOSTS = ("www.rednote.com", "www.xiaohongshu.com")
|
||||
# 请求 host 候选:链接来自哪个站就先用哪个站(国内笔记在国际站没有数据,
|
||||
# 国际站笔记(rednote.com)带无水印原片 originVideoKey,互不通用)
|
||||
REDNOTE_HOSTS = ("www.xiaohongshu.com", "www.rednote.com")
|
||||
|
||||
|
||||
async def fetch_rednote_content(
|
||||
@@ -75,14 +75,18 @@ async def _parse(url: str):
|
||||
logger.info(f"xhslink 重定向: {url} -> {resolved}")
|
||||
url = resolved
|
||||
|
||||
# 2. explore 优先(国际站才有 originVideoKey 无水印原片),
|
||||
# 依次尝试 国际站 → 国内站;失败再用同一 id+query 回退 discovery 端点
|
||||
# 链接来自哪个站就先试哪个站(国际站 rednote.com / 国内站 xiaohongshu.com)
|
||||
hosts = REDNOTE_HOSTS
|
||||
if "rednote.com" in url:
|
||||
hosts = tuple(reversed(REDNOTE_HOSTS))
|
||||
|
||||
# 2. explore 优先;失败再用同一 id+query 回退 discovery 端点
|
||||
if m := EXPLORE_RE.search(url):
|
||||
note_id, query = m.group(1), (m.group(2) or "").lstrip("?")
|
||||
return await _fetch_with_retry(note_id, query, ("explore", "discovery"))
|
||||
return await _fetch_with_retry(note_id, query, ("explore", "discovery"), hosts)
|
||||
if m := DISCOVERY_RE.search(url):
|
||||
note_id, query = m.group(1), (m.group(2) or "").lstrip("?")
|
||||
return await _fetch_with_retry(note_id, query, ("discovery",))
|
||||
return await _fetch_with_retry(note_id, query, ("discovery",), hosts)
|
||||
|
||||
# 3. 短链重定向终态: xiaohongshu.com/explore?target_note_id={id}&xsec_token=...
|
||||
# (discovery/item 会 302 到该形态,note id 在 query 里),
|
||||
@@ -91,14 +95,10 @@ async def _parse(url: str):
|
||||
qs = urllib.parse.parse_qs(m.group(1))
|
||||
note_id = (qs.get("target_note_id") or [""])[0]
|
||||
if note_id:
|
||||
keep = {
|
||||
k: v
|
||||
for k, v in qs.items()
|
||||
if k in ("xsec_token", "xsec_source")
|
||||
}
|
||||
keep = {k: v for k, v in qs.items() if k in ("xsec_token", "xsec_source")}
|
||||
query = urllib.parse.urlencode(keep, doseq=True)
|
||||
return await _fetch_with_retry(
|
||||
note_id, query, ("explore", "discovery")
|
||||
note_id, query, ("explore", "discovery"), hosts
|
||||
)
|
||||
|
||||
logger.warning(f"无法识别的小红书链接: {url}")
|
||||
@@ -106,15 +106,18 @@ async def _parse(url: str):
|
||||
|
||||
|
||||
async def _fetch_with_retry(
|
||||
note_id: str, query: str, paths: tuple[str, ...]
|
||||
note_id: str,
|
||||
query: str,
|
||||
paths: tuple[str, ...],
|
||||
hosts: tuple[str, ...] = REDNOTE_HOSTS,
|
||||
) -> tuple[str, Union[Path, list[Path]]] | None:
|
||||
"""依次尝试 国际站 → 国内站 × 端点;全部失败后稍候重试一轮
|
||||
"""依次尝试 host × 端点;全部失败后稍候重试一轮
|
||||
|
||||
小红书对突发请求会节流(页面 200 但 noteDetailMap 为空/无数据),
|
||||
重试一轮可绕过大部分瞬时风控;重试仍失败返回 (None, None)。
|
||||
"""
|
||||
for attempt in range(2):
|
||||
for host in REDNOTE_HOSTS:
|
||||
for host in hosts:
|
||||
for path in paths:
|
||||
fetch = _parse_explore if path == "explore" else _parse_discovery
|
||||
try:
|
||||
@@ -211,9 +214,7 @@ def _extract_note(html: str, note_id: str) -> dict:
|
||||
async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
|
||||
# 空壳 note(短链重定向带 undertake_note_error=该内容暂时无法查看)
|
||||
# → 笔记已删除/私密,直接报错而不是误判为纯文字笔记
|
||||
if not any(
|
||||
note.get(k) for k in ("title", "desc", "type", "imageList", "video")
|
||||
):
|
||||
if not any(note.get(k) for k in ("title", "desc", "type", "imageList", "video")):
|
||||
raise ContentFetchError("笔记内容不可见(可能已删除/私密),无法解析")
|
||||
title = note.get("title") or ""
|
||||
desc = note.get("desc") or ""
|
||||
@@ -229,18 +230,19 @@ async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
|
||||
# 1. 视频笔记 → 无水印原片优先
|
||||
if note.get("type") == "video" and note.get("video"):
|
||||
# 1a. 无水印原片(国际站数据 video.consumer.originVideoKey)
|
||||
consumer = (note["video"].get("consumer") or {})
|
||||
consumer = note["video"].get("consumer") or {}
|
||||
okey = consumer.get("originVideoKey")
|
||||
if okey:
|
||||
video_url = f"https://sns-video-bd.xhscdn.com/{okey}"
|
||||
logger.info(f"小红书视频: 无水印原片 originVideoKey={okey[:30]}...")
|
||||
video_path = await _download_video(video_url, rel_stem)
|
||||
video_path = await _download_video([video_url], rel_stem)
|
||||
return text, video_path
|
||||
|
||||
# 1b. 无 originVideoKey(国内站数据)→ 从 stream 分组选无水印原片
|
||||
# 国内站 masterUrl 同样是 sns-video-v6 原片 CDN(无水印),
|
||||
# 1b. 无 originVideoKey(国内站数据)→ 从 stream 分组选最高档
|
||||
# 国内站 masterUrl 同样是 sns-video 原片 CDN(无水印),
|
||||
# 但不同抓取批次返回的清晰度集合不同(同组多条/分组顺序不定),
|
||||
# 因此跨全部编码分组收集候选,取 size 最大(质量最高)的流。
|
||||
# 因此跨全部编码分组收集候选,取 size 最大(质量最高)的流;
|
||||
# 每个流附带 backupUrls(备选 CDN),下载时逐个回退。
|
||||
stream = ((note["video"].get("media") or {}).get("stream")) or {}
|
||||
candidates = [
|
||||
it
|
||||
@@ -254,23 +256,34 @@ async def _build_result(note: dict) -> tuple[str, Union[Path, list[Path]]]:
|
||||
candidates,
|
||||
key=lambda it: (it.get("size") or 0, it.get("avgBitrate") or 0),
|
||||
)
|
||||
video_url = best["masterUrl"]
|
||||
urls = [best["masterUrl"]] + [
|
||||
u for u in (best.get("backupUrls") or []) if u
|
||||
]
|
||||
duration = best.get("duration", 0)
|
||||
logger.info(
|
||||
f"小红书视频: 无水印流 {best.get('qualityType')} "
|
||||
f"{best.get('width')}x{best.get('height')} {best.get('fps')}fps "
|
||||
f"size={best.get('size')} duration={duration}ms"
|
||||
f"size={best.get('size')} duration={duration}ms "
|
||||
f"候选={len(urls)}"
|
||||
)
|
||||
video_path = await _download_video(video_url, rel_stem)
|
||||
video_path = await _download_video(urls, rel_stem)
|
||||
return text, video_path
|
||||
raise ContentFetchError("小红书视频流解析失败")
|
||||
|
||||
# 2. 图文笔记
|
||||
images = [
|
||||
img.get("urlDefault") or img.get("url")
|
||||
for img in note.get("imageList") or []
|
||||
]
|
||||
images = [u for u in images if u]
|
||||
# 2. 图文笔记(urlDefault = WB_DFT 默认档;兜底取 infoList 里 WB_DFT 场景)
|
||||
images = []
|
||||
for img in note.get("imageList") or []:
|
||||
if not isinstance(img, dict):
|
||||
continue
|
||||
url = img.get("urlDefault")
|
||||
if not url:
|
||||
for info in img.get("infoList") or []:
|
||||
if isinstance(info, dict) and info.get("imageScene") == "WB_DFT":
|
||||
url = info.get("url")
|
||||
break
|
||||
url = url or img.get("url")
|
||||
if url:
|
||||
images.append(url)
|
||||
if not images:
|
||||
logger.info(f"小红书文字笔记: {text[:30]}")
|
||||
return text, []
|
||||
@@ -290,16 +303,14 @@ def _extract_author_id(note: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _build_rel_stem(
|
||||
nickname: str, author_id: str, title: str, source: str = ""
|
||||
) -> str:
|
||||
def _build_rel_stem(nickname: str, author_id: str, title: str, source: str = "") -> str:
|
||||
"""相对平台根的路径词干:`{作者}_{userId}/{作品名}`
|
||||
|
||||
昵称/作者 id 都拿不到时用 source(笔记 id)当来源码(见 utils.build_author_dir)。
|
||||
"""
|
||||
return (
|
||||
f"{build_author_dir(nickname, author_id, source=source)}"
|
||||
f"/{build_work_stem(title)}"
|
||||
f"/{build_work_stem(title, source=source)}"
|
||||
)
|
||||
|
||||
|
||||
@@ -318,8 +329,8 @@ async def _download_images(image_urls: list[str], rel_stem: str) -> list[Path]:
|
||||
)
|
||||
|
||||
|
||||
async def _download_video(video_url: str, rel_stem: str) -> Path:
|
||||
"""流式下载视频
|
||||
async def _download_video(video_urls: list[str], rel_stem: str) -> Path:
|
||||
"""流式下载视频(masterUrl + backupUrls 逐个回退)
|
||||
|
||||
注意:sns-video-bd(无水印原片)不带 Referer 或带 xiaohongshu.com
|
||||
均可,但带 rednote.com Referer 会 403,因此不设 Referer。
|
||||
@@ -329,14 +340,23 @@ async def _download_video(video_url: str, rel_stem: str) -> Path:
|
||||
tmp_root = get_temp_root("xiaohongshu")
|
||||
output_path = unique_media_path(tmp_root / f"{rel_stem}.mp4")
|
||||
headers = {"User-Agent": REDNOTE_UA}
|
||||
last_error: Exception | None = None
|
||||
for video_url in video_urls:
|
||||
try:
|
||||
async with httpx.AsyncClient(headers=headers, timeout=300) as client:
|
||||
async with client.stream("GET", video_url) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(output_path, "wb") as f:
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
async for chunk in resp.aiter_bytes(65536):
|
||||
f.write(chunk)
|
||||
if output_path.stat().st_size == 0:
|
||||
raise ContentFetchError("响应为空(0 字节)")
|
||||
logger.info(f"小红书视频下载完成: {output_path}")
|
||||
return output_path
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"小红书视频下载失败(尝试下一个候选 CDN): {e}")
|
||||
raise ContentFetchError(f"小红书视频下载失败: {last_error}")
|
||||
|
||||
|
||||
async def _fetch_page(url: str, headers: dict) -> str:
|
||||
|
||||
@@ -19,6 +19,7 @@ from ...utils import (
|
||||
build_work_stem,
|
||||
get_data_dir,
|
||||
get_temp_root,
|
||||
short_source_code,
|
||||
slugify,
|
||||
unique_media_path,
|
||||
)
|
||||
@@ -145,7 +146,8 @@ async def download_video(
|
||||
ext = m.group(1)
|
||||
|
||||
url_stem = Path(url.split("?")[0]).stem or "video"
|
||||
slug_stem = slugify(url_stem, max_length=15) or "视频"
|
||||
# 兜底名同 build_work_stem:用来源短码,别让不同直链挤在同一个 `视频` 下
|
||||
slug_stem = slugify(url_stem, max_length=15) or f"视频_{short_source_code(url)}"
|
||||
# 直链拿不到作者信息 → `未知作者_{来源短码}`(同一链接稳定、不同链接不撞)
|
||||
author_dir = build_author_dir(author, author_id, source=url)
|
||||
final_path = unique_media_path(temp_root / author_dir / f"{slug_stem}.{ext}")
|
||||
@@ -277,8 +279,10 @@ async def download_video(
|
||||
title = ((info or {}).get("title") or "").strip()
|
||||
|
||||
author_dir = build_author_dir(uploader, uploader_id, source=url)
|
||||
# 标题空时的兜底名用来源短码(作品 id 优先,其次作品链接)
|
||||
work_stem = build_work_stem(title, source=(info or {}).get("id") or url)
|
||||
new_path = unique_media_path(
|
||||
temp_root / author_dir / f"{build_work_stem(title)}{original_file.suffix}"
|
||||
temp_root / author_dir / f"{work_stem}{original_file.suffix}"
|
||||
)
|
||||
shutil.move(str(original_file), str(new_path))
|
||||
# 下载用的 scratch 目录已空,顺手收掉(cleanup 不删目录)
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import zipfile
|
||||
from collections.abc import Iterable
|
||||
@@ -23,6 +24,7 @@ from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from nonebot import get_bot, logger
|
||||
from nonebot.exception import NetworkError
|
||||
|
||||
if TYPE_CHECKING: # 仅类型检查:运行期不导入,单测可裸加载本模块
|
||||
from ..policy import Policy
|
||||
@@ -154,12 +156,42 @@ def _file_uri(path: Path) -> str:
|
||||
return "file:///" + str(path.resolve()).replace("\\", "/")
|
||||
|
||||
|
||||
#: 群文件上传 per-call 超时(秒)——大文件在协议端「下载 + 上传群文件」会超过
|
||||
#: NoneBot 默认的 30s(同 handlers/sender.py 的 `send_timeout`,就地实现以保持
|
||||
#: 本模块可裸加载;配置键同一个 `video_analysis_send_timeout`)。
|
||||
_DEFAULT_SEND_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _send_timeout() -> float:
|
||||
raw = (
|
||||
os.environ.get("video_analysis_send_timeout")
|
||||
or os.environ.get("VIDEO_ANALYSIS_SEND_TIMEOUT")
|
||||
or ""
|
||||
).strip()
|
||||
try:
|
||||
value = float(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_SEND_TIMEOUT
|
||||
return value if value > 0 else _DEFAULT_SEND_TIMEOUT
|
||||
|
||||
|
||||
def _unknown_outcome(exc: BaseException) -> bool:
|
||||
"""超时类错误 = 结果未知:协议端可能仍在处理并最终送达(同 sender 的判定)。
|
||||
|
||||
这种失败**不能**再降级到本地直传重试——那会让群文件里出现两份。
|
||||
"""
|
||||
return isinstance(exc, NetworkError) or isinstance(
|
||||
exc, (asyncio.TimeoutError, TimeoutError)
|
||||
)
|
||||
|
||||
|
||||
async def _call_upload(
|
||||
file_value: str, group_id: int, name: str, folder_id: str | None = None
|
||||
) -> None:
|
||||
params: dict = {"group_id": group_id, "file": file_value, "name": name}
|
||||
if folder_id:
|
||||
params["folder"] = folder_id
|
||||
params["_timeout"] = _send_timeout()
|
||||
await get_bot().call_api("upload_group_file", **params)
|
||||
|
||||
|
||||
@@ -181,7 +213,9 @@ async def upload_group_file(
|
||||
) -> bool:
|
||||
"""上传单个文件到群文件,返回是否成功。
|
||||
|
||||
两级投递,**S3 链接优先、本地直传兜底**:
|
||||
两级投递,**S3 链接优先、本地直传兜底**;只有第 1 级**明确失败**才降级,
|
||||
超时(结果未知)直接收手——协议端可能还在传,再直传一份就重复了
|
||||
(见 `_unknown_outcome`)。
|
||||
|
||||
本环境实测 NapCat 读不到 bot 进程写的本地文件(`realpath ... ENOENT`,
|
||||
媒体消息的本地直发 55 次全失败、换 S3 链接后次次成功),所以直传只留作
|
||||
@@ -206,6 +240,14 @@ async def upload_group_file(
|
||||
try:
|
||||
await _call_upload(url, group_id, display_name, folder_id)
|
||||
except Exception as e:
|
||||
if _unknown_outcome(e):
|
||||
# 超时=结果未知:协议端可能还在传,再走本地直传会出两份群文件
|
||||
logger.warning(
|
||||
f"群文件 S3 链接上传超时,结果未知(协议端可能仍在传输),"
|
||||
f"不再降级直传以免重复(群 {group_id} / {display_name}): "
|
||||
f"{str(e)[:160]}"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"群文件 S3 链接上传失败(群 {group_id} / {display_name}): "
|
||||
f"{str(e)[:160]};改用本地路径重试"
|
||||
@@ -220,6 +262,12 @@ async def upload_group_file(
|
||||
try:
|
||||
await _call_upload(_file_uri(path), group_id, display_name, folder_id)
|
||||
except Exception as e:
|
||||
if _unknown_outcome(e):
|
||||
logger.warning(
|
||||
f"群文件上传超时,结果未知(协议端可能仍在传输)"
|
||||
f"(群 {group_id} / {display_name}): {str(e)[:160]}"
|
||||
)
|
||||
return True
|
||||
logger.warning(
|
||||
f"群文件上传失败(群 {group_id} / {display_name}): {str(e)[:160]}"
|
||||
)
|
||||
|
||||
@@ -66,6 +66,10 @@ POSTER_WIDTH = 480
|
||||
POSTER_SUFFIX = "_封面.jpg"
|
||||
POSTER_TIMEOUT = 20
|
||||
|
||||
#: bot 跑在 pythonw.exe(无控制台)下,spawn 控制台程序(ffmpeg)不带这个标志
|
||||
#: 会弹出一个 cmd 黑窗口;只在 Windows 有该常量,其它平台取 0 不影响调用
|
||||
CREATE_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
#: 状态机:queued → running → done / failed
|
||||
ACTIVE_STATUSES = ("queued", "running")
|
||||
|
||||
@@ -136,9 +140,14 @@ async def make_poster(video: Path) -> Optional[Path]:
|
||||
str(out),
|
||||
]
|
||||
try:
|
||||
# 同步子进程必须丢线程池,否则抽帧期间整个事件循环都停摆
|
||||
# 同步子进程必须丢线程池,否则抽帧期间整个事件循环都停摆;
|
||||
# CREATE_NO_WINDOW 防止 pythonw 下弹 cmd 黑窗口
|
||||
await asyncio.to_thread(
|
||||
subprocess.run, cmd, capture_output=True, timeout=POSTER_TIMEOUT
|
||||
subprocess.run,
|
||||
cmd,
|
||||
capture_output=True,
|
||||
timeout=POSTER_TIMEOUT,
|
||||
creationflags=CREATE_NO_WINDOW,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 封面失败不该拖累任务
|
||||
logger.warning(f"抽关键帧失败:{video.name}")
|
||||
@@ -167,12 +176,14 @@ async def fetch_media(url: str) -> FetchResult:
|
||||
|
||||
分派规则与 `handlers/entry.py::dispatch_url` 一致:抖音 → parse_douyin;
|
||||
B站动态/专栏 → fetch_bilibili_content;其余 B站链接 → download_video(yt-dlp);
|
||||
小红书 → fetch_rednote_content;其它平台 → download_video。
|
||||
b23 / xhslink / 抖音短链的重定向在各 fetcher 内部自己处理。
|
||||
小红书 → fetch_rednote_content;快手 → fetch_kuaishou_content;
|
||||
其它平台 → download_video。
|
||||
b23 / xhslink / 快手短链的重定向在各 fetcher 内部自己处理。
|
||||
"""
|
||||
from ..handlers.douyin import parse_douyin
|
||||
from ..policy import BILIBILI, DOUYIN, XHS
|
||||
from ..policy import BILIBILI, DOUYIN, KUAISHOU, XHS
|
||||
from .fetchers.bilibili_content import fetch_bilibili_content
|
||||
from .fetchers.kuaishou_content import fetch_kuaishou_content
|
||||
from .fetchers.rednote_content import fetch_rednote_content
|
||||
from .fetchers.video_downloader import download_video
|
||||
|
||||
@@ -183,6 +194,9 @@ async def fetch_media(url: str) -> FetchResult:
|
||||
|
||||
if platform == DOUYIN:
|
||||
title, parsed, image_post = await parse_douyin(url)
|
||||
elif platform == KUAISHOU:
|
||||
title, parsed = await fetch_kuaishou_content(url)
|
||||
image_post = isinstance(parsed, list)
|
||||
elif platform == XHS:
|
||||
title, parsed = await fetch_rednote_content(url)
|
||||
image_post = isinstance(parsed, list)
|
||||
@@ -344,7 +358,7 @@ class JobManager:
|
||||
# ── 清理 ──────────────────────────────────────────────
|
||||
|
||||
def remove(self, job_id: str) -> bool:
|
||||
"""删掉一条任务;还在跑的一并取消(Chrome 那头由 playwright 自己收尾)。"""
|
||||
"""删掉一条任务;还在跑的一并取消(进行中的下载协程由取消信号收尾)。"""
|
||||
job = self._jobs.pop(job_id, None)
|
||||
if job is None:
|
||||
return False
|
||||
|
||||
@@ -118,7 +118,7 @@ def short_time_code(at: Optional[float] = None) -> str:
|
||||
_JUNK_AUTHOR_IDS = {"", "0", "none", "null", "na", "nan", "undefined"}
|
||||
|
||||
#: 抓取层拿不到昵称时的兜底串(slugify 后)—— 它们等于"没有作者信息"
|
||||
_PLACEHOLDER_AUTHORS = {"未知作者", "小红书用户", "b站用户"}
|
||||
_PLACEHOLDER_AUTHORS = {"未知作者", "小红书用户", "b站用户", "快手用户"}
|
||||
|
||||
|
||||
def _clean_author_id(author_id: Optional[str]) -> str:
|
||||
@@ -174,9 +174,18 @@ def build_author_dir(
|
||||
return f"未知作者_{short_time_code(at)}"
|
||||
|
||||
|
||||
def build_work_stem(title: Optional[str]) -> str:
|
||||
"""作品名做文件名/子目录名:slugify(沿用 15 字上限),空则 `作品`"""
|
||||
return slugify(title or "", max_length=15) or "作品"
|
||||
def build_work_stem(title: Optional[str], *, source: Optional[str] = None) -> str:
|
||||
"""作品名做文件名/子目录名:slugify(沿用 15 字上限),空则 `作品_{来源短码}`
|
||||
|
||||
标题为空(或只有 `#话题`,slugify 后为空)时**不能**固定用 `作品`:同一作者的
|
||||
多个无名作品会全挤在一个名字下,只能靠 4 位**时间**短码区分,每次重解析名字都
|
||||
变,S3 key 与群文件成员名都不可复现。改用 `作品_{short_source_code(source)}`:
|
||||
同一来源稳定、不同来源不撞,与 `未知作者_{来源码}` 同一套约定。
|
||||
`source` 也没给才退回固定的 `作品`(保住旧契约)。
|
||||
"""
|
||||
return slugify(title or "", max_length=15) or (
|
||||
f"作品_{short_source_code(source)}" if source else "作品"
|
||||
)
|
||||
|
||||
|
||||
def unique_media_path(path: Path, *, at: Optional[float] = None) -> Path:
|
||||
@@ -227,7 +236,7 @@ def media_rel_dir_of(file_path: Path | str) -> str:
|
||||
return "/".join(parts[:-1]) if len(parts) > 1 else ""
|
||||
|
||||
|
||||
def parse_netscape_cookies(file_path: str) -> List[dict]:
|
||||
def parse_netscape_cookies(file_path: str | Path) -> List[dict]:
|
||||
"""解析 Netscape 格式 cookies 文件"""
|
||||
cookies = []
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
|
||||
@@ -15,14 +15,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from nonebot import get_adapter
|
||||
from nonebot.adapters.onebot.v11 import Adapter
|
||||
|
||||
from hexi.web_hub.web_auth import require_admin
|
||||
from hexi.web_hub.web_auth import get_current_user, require_admin
|
||||
|
||||
from .cleanup import cleanup_config, run_stats, submit_clean
|
||||
from .policy import PLANS, PLATFORMS, STORE, Policy
|
||||
from .services.web_jobs import JOBS, extract_urls
|
||||
|
||||
@@ -167,6 +168,50 @@ def build_admin_app() -> FastAPI | None:
|
||||
removed = JOBS.clear_finished()
|
||||
return _ok({"removed": removed}, f"已清理 {removed} 条任务")
|
||||
|
||||
# ── temp 缓存:占用统计 + 手动清理 ──────────────────────
|
||||
# 与「清理temp / temp统计」命令、每日定时任务同一套函数(cleanup.py),
|
||||
# 不重写清理逻辑;文件系统操作丢线程池,别卡住事件循环。
|
||||
|
||||
@app.get("/temp/stats", response_class=JSONResponse, dependencies=[auth])
|
||||
async def temp_stats_api():
|
||||
"""temp 目录占用(文件数/总大小/目录数)+ 当前自动清理配置。"""
|
||||
st = await run_stats()
|
||||
st["mode"] = cleanup_config.video_analysis_temp_cleanup_mode
|
||||
st["max_age_days"] = cleanup_config.video_analysis_temp_max_age_days
|
||||
st["hour"] = cleanup_config.video_analysis_temp_cleanup_hour
|
||||
return _ok(st)
|
||||
|
||||
@app.post("/temp/cleanup", response_class=JSONResponse, dependencies=[auth])
|
||||
async def temp_cleanup_api(data: dict, user: dict = Depends(get_current_user)):
|
||||
"""手动清理 temp:**提交到任务槽后立即返回**,进度走 /hub/api/tasks/stream。
|
||||
|
||||
`days=0` 表示全部清空,不传则用配置的保留天数。清理本身是几十秒级的阻塞
|
||||
任务,放请求里会把页面交互一起卡住;同槽已有任务时返回它并带 `reused=true`
|
||||
(前端据此提示"已在运行",服务端这层才是防连点的真正保证)。
|
||||
"""
|
||||
raw = data.get("days")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
days: int | None = None # 用配置的保留天数
|
||||
shown = cleanup_config.video_analysis_temp_max_age_days
|
||||
else:
|
||||
try:
|
||||
days = max(0, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return _fail("天数参数不合法")
|
||||
shown = days
|
||||
|
||||
title = "清理 temp(全部)" if shown == 0 else f"清理 temp(保留 {shown} 天)"
|
||||
task, reused = submit_clean(
|
||||
days,
|
||||
title=title,
|
||||
trigger="web",
|
||||
actor=str((user or {}).get("username") or ""),
|
||||
)
|
||||
return _ok(
|
||||
{"task": task.to_dict(), "reused": reused},
|
||||
"已有清理任务在跑" if reused else "清理已开始",
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
"""抖音免浏览器链路(douyin_web.py)单元测试
|
||||
|
||||
覆盖:作品 id 提取 / 签名查询串 / 网关头构造 / 最高码率档选择 / 图集下载项收集。
|
||||
签名与媒体选择都是纯函数,不碰网络(真实链路实测见 debug 目录脚本)。
|
||||
|
||||
douyin_web.py 有插件内相对导入(`...models` / `...utils` / `.douyin_sign` 等),
|
||||
按 test_kuaishou_parse.py 同款姿势:sys.modules 预置包替身 + 真实加载依赖后裸加载。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
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"
|
||||
|
||||
_REAL_MODULES = {
|
||||
f"{_PKG}.models": _PLUGIN / "models.py",
|
||||
f"{_PKG}.utils": _PLUGIN / "utils.py",
|
||||
f"{_PKG}.services.fetchers.douyin_parser": _PLUGIN
|
||||
/ "services/fetchers/douyin_parser.py",
|
||||
f"{_PKG}.services.fetchers.douyin_sign": _PLUGIN
|
||||
/ "services/fetchers/douyin_sign.py",
|
||||
f"{_PKG}.services.fetchers.douyin_api": _PLUGIN / "services/fetchers/douyin_api.py",
|
||||
f"{_PKG}.services.fetchers.douyin_web": _PLUGIN / "services/fetchers/douyin_web.py",
|
||||
}
|
||||
_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 dw():
|
||||
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)
|
||||
|
||||
yield sys.modules[f"{_PKG}.services.fetchers.douyin_web"]
|
||||
|
||||
for name, previous in saved.items():
|
||||
if previous is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous
|
||||
|
||||
|
||||
# ───────────────────────── 样本 ─────────────────────────
|
||||
|
||||
|
||||
def _image(url_list=None, download_url_list=None, video=None) -> dict:
|
||||
img = {
|
||||
"uri": "img-uri",
|
||||
"width": 2160,
|
||||
"height": 2880,
|
||||
"url_list": (
|
||||
url_list
|
||||
if url_list is not None
|
||||
else ["https://p3.douyinpic.com/a~q75.webp"]
|
||||
),
|
||||
"download_url_list": (
|
||||
download_url_list
|
||||
if download_url_list is not None
|
||||
else ["https://p9.douyinpic.com/a~tplv-dy-water-v2:xxx.webp"]
|
||||
),
|
||||
}
|
||||
if video is not None:
|
||||
img["video"] = video
|
||||
return img
|
||||
|
||||
|
||||
def _video(bit_rate=None, play_urls=("https://v95.douyinvod.com/full.mp4",)) -> dict:
|
||||
v = {"play_addr": {"url_list": list(play_urls), "width": 1080, "height": 1440}}
|
||||
if bit_rate is not None:
|
||||
v["bit_rate"] = bit_rate
|
||||
return v
|
||||
|
||||
|
||||
def _gear(br, urls, gear_name="gear", h265=0, size=100):
|
||||
return {
|
||||
"gear_name": gear_name,
|
||||
"bit_rate": br,
|
||||
"is_h265": h265,
|
||||
"play_addr": {
|
||||
"url_list": list(urls),
|
||||
"width": 1080,
|
||||
"height": 1440,
|
||||
"data_size": size,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ───────────────────────── extract_aweme_id ─────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,expected",
|
||||
[
|
||||
("https://www.douyin.com/note/7696029938990839419", "7696029938990839419"),
|
||||
(
|
||||
"https://www.douyin.com/video/7618117951811949541?foo=1",
|
||||
"7618117951811949541",
|
||||
),
|
||||
(
|
||||
"https://www.iesdouyin.com/share/video/7412345678901234567/?region=CN",
|
||||
"7412345678901234567",
|
||||
),
|
||||
(
|
||||
"https://www.iesdouyin.com/share/note/7412345678901234567/",
|
||||
"7412345678901234567",
|
||||
),
|
||||
("https://v.douyin.com/abc123/", None),
|
||||
("https://github.com/x/y", None),
|
||||
],
|
||||
)
|
||||
def test_extract_aweme_id(dw, url, expected):
|
||||
assert dw.extract_aweme_id(url) == expected
|
||||
|
||||
|
||||
# ───────────────────────── 签名 ─────────────────────────
|
||||
|
||||
|
||||
def test_build_signed_query(dw):
|
||||
qs = dw.build_signed_query("7618117951811949541")
|
||||
assert "aweme_id=7618117951811949541" in qs
|
||||
assert "a_bogus=" in qs
|
||||
a_bogus = qs.split("a_bogus=")[-1]
|
||||
assert len(a_bogus) >= 100
|
||||
# 两次签名都有效(签名带随机熵,不要求逐字节一致)
|
||||
qs2 = dw.build_signed_query("7618117951811949541")
|
||||
assert "a_bogus=" in qs2
|
||||
|
||||
|
||||
def test_build_signed_query_requires_aweme_id_last(dw):
|
||||
"""aweme_id 应在查询串末尾、a_bogus 追加在其后(与浏览器请求形态一致)"""
|
||||
qs = dw.build_signed_query("123")
|
||||
assert qs.index("aweme_id=123") < qs.index("a_bogus=")
|
||||
|
||||
|
||||
# ───────────────────────── 网关头 ─────────────────────────
|
||||
|
||||
|
||||
def test_build_headers_always_argus_and_uifid(dw):
|
||||
headers = dw._build_headers({"UIFID": "u-123"})
|
||||
assert headers["x-tt-argus"] == "1"
|
||||
assert headers["uifid"] == "u-123"
|
||||
assert headers["User-Agent"] == dw.WEB_UA
|
||||
|
||||
|
||||
def test_build_headers_uifid_temp_fallback(dw):
|
||||
headers = dw._build_headers({"UIFID_TEMP": "tmp"})
|
||||
assert headers["uifid"] == "tmp"
|
||||
assert "uifid" not in dw._build_headers({})
|
||||
|
||||
|
||||
def test_cookie_map_filters_domain(dw):
|
||||
cookies = [
|
||||
{"name": "UIFID", "value": "u1", "domain": ".douyin.com"},
|
||||
{"name": "a1", "value": "x", "domain": ".xiaohongshu.com"},
|
||||
{"name": "ttwid", "value": "t", "domain": "www.douyin.com"},
|
||||
]
|
||||
m = dw._douyin_cookie_map(cookies)
|
||||
assert m == {"UIFID": "u1", "ttwid": "t"}
|
||||
|
||||
|
||||
# ───────────────────────── 最高码率档选择 ─────────────────────────
|
||||
|
||||
|
||||
def test_best_gear_picks_max_bitrate(dw):
|
||||
video = {
|
||||
"bit_rate": [
|
||||
_gear(773939, ["https://cdn/1080.mp4"], gear_name="normal_1080_0"),
|
||||
_gear(385679, ["https://cdn/720.mp4"], gear_name="720_1_1", h265=1),
|
||||
_gear(
|
||||
1419088,
|
||||
["https://cdn/best.mp4", "https://cdn/best-bak.mp4"],
|
||||
gear_name="1080_1_1",
|
||||
),
|
||||
],
|
||||
"play_addr": {"url_list": ["https://cdn/main.mp4"]},
|
||||
}
|
||||
urls, meta = dw._best_urls_from_video(video)
|
||||
assert urls == ["https://cdn/best.mp4", "https://cdn/best-bak.mp4"]
|
||||
assert meta["bit_rate"] == 1419088
|
||||
assert meta["width"] == 1080
|
||||
|
||||
|
||||
def test_best_gear_falls_back_to_play_addr(dw):
|
||||
video = _video(bit_rate=[], play_urls=["https://cdn/only.mp4"])
|
||||
urls, meta = dw._best_urls_from_video(video)
|
||||
assert urls == ["https://cdn/only.mp4"]
|
||||
assert meta["gear_name"] is None
|
||||
|
||||
|
||||
def test_best_gear_skips_gear_without_urls(dw):
|
||||
video = {
|
||||
"bit_rate": [
|
||||
{"bit_rate": 9999999, "play_addr": {"url_list": []}},
|
||||
_gear(100, ["https://cdn/ok.mp4"]),
|
||||
],
|
||||
"play_addr": {"url_list": ["https://cdn/main.mp4"]},
|
||||
}
|
||||
urls, _ = dw._best_urls_from_video(video)
|
||||
assert urls == ["https://cdn/ok.mp4"]
|
||||
|
||||
|
||||
# ───────────────────────── 下载项收集 ─────────────────────────
|
||||
|
||||
|
||||
def test_collect_video_post(dw):
|
||||
detail = {
|
||||
"aweme_id": "1",
|
||||
"desc": "x",
|
||||
"video": _video(bit_rate=[_gear(500, ["https://cdn/v.mp4"])]),
|
||||
}
|
||||
items, is_img = dw.collect_download_items(detail)
|
||||
assert is_img is False
|
||||
assert items == [(["https://cdn/v.mp4"], ".mp4")]
|
||||
|
||||
|
||||
def test_collect_video_post_without_urls_raises(dw):
|
||||
with pytest.raises(Exception):
|
||||
dw.collect_download_items({"video": {"play_addr": {"url_list": []}}})
|
||||
|
||||
|
||||
def test_collect_images_prefers_no_watermark_urls(dw):
|
||||
detail = {
|
||||
"images": [
|
||||
_image(
|
||||
url_list=["https://p3/a-q75.webp", "https://p3/a-q75.jpeg"],
|
||||
download_url_list=["https://p9/a-water.webp"],
|
||||
)
|
||||
]
|
||||
}
|
||||
items, is_img = dw.collect_download_items(detail)
|
||||
assert is_img is True
|
||||
assert items[0][0] == [
|
||||
"https://p3/a-q75.webp",
|
||||
"https://p3/a-q75.jpeg", # 无水印在前
|
||||
"https://p9/a-water.webp", # 带作者水印的保存版兜底
|
||||
]
|
||||
assert items[0][1] == ""
|
||||
|
||||
|
||||
def test_collect_animated_note_uses_video_gears(dw):
|
||||
anim_video = {
|
||||
"bit_rate": [_gear(300, ["https://cdn/anim-best.mp4"])],
|
||||
"play_addr": {"url_list": ["https://cdn/anim.mp4"]},
|
||||
}
|
||||
detail = {
|
||||
"images": [
|
||||
_image(url_list=[], download_url_list=[], video=anim_video),
|
||||
_image(url_list=[], download_url_list=[], video=anim_video),
|
||||
]
|
||||
}
|
||||
items, is_img = dw.collect_download_items(detail)
|
||||
assert is_img is True
|
||||
assert items == [
|
||||
(["https://cdn/anim-best.mp4"], ".mp4"),
|
||||
(["https://cdn/anim-best.mp4"], ".mp4"),
|
||||
]
|
||||
|
||||
|
||||
def test_collect_mixed_note(dw):
|
||||
"""图 + 内嵌视频混排:各自按类型收集"""
|
||||
detail = {
|
||||
"images": [
|
||||
_image(),
|
||||
_image(
|
||||
url_list=[],
|
||||
download_url_list=[],
|
||||
video={"play_addr": {"url_list": ["https://cdn/clip.mp4"]}},
|
||||
),
|
||||
]
|
||||
}
|
||||
items, is_img = dw.collect_download_items(detail)
|
||||
assert is_img is True
|
||||
assert len(items) == 2
|
||||
assert items[0][1] == "" # 图片
|
||||
assert items[1] == (["https://cdn/clip.mp4"], ".mp4")
|
||||
|
||||
|
||||
# ───────────────────────── 扩展名推断 ─────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url,expected",
|
||||
[
|
||||
("https://x/a.jpg?x=1", ".jpg"),
|
||||
("https://x/a~q75.webp?y=2", ".webp"),
|
||||
("https://x/a.jpeg", ".jpeg"),
|
||||
("https://x/a", ".jpg"),
|
||||
("https://x/a.heic", ".heic"),
|
||||
],
|
||||
)
|
||||
def test_infer_extension(dw, url, expected):
|
||||
assert dw._infer_extension(url) == expected
|
||||
@@ -0,0 +1,302 @@
|
||||
"""快手分享页解析(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"
|
||||
@@ -0,0 +1,214 @@
|
||||
"""temp 目录清理逻辑(video_analysis/cleanup.py)单元测试
|
||||
|
||||
这组函数现在同时被「/清理temp 命令」「每日定时」「Web 缓存面板的手动清理」
|
||||
三条路复用,是**真的会删文件**的代码,所以单测覆盖"删什么/不删什么"。
|
||||
|
||||
cleanup.py 有插件内相对导入(`.utils`)和 import 期副作用(`get_plugin_config`
|
||||
建配置对象、`_register_auto_job()` 注册定时任务),无法裸加载 —— 按
|
||||
test_video_sender_link.py 的办法:sys.modules 预置替身 + monkeypatch nonebot 的
|
||||
几个工厂函数,再按路径加载目标模块。
|
||||
|
||||
文件一律用 tmp_path 造真实文件/目录 + `os.utime` 改 mtime,**不 mock 文件系统**:
|
||||
这些判断(mtime 过期、目录为空、60s 目录下限)全在真实 stat 语义上。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
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"
|
||||
|
||||
_STUBS = {
|
||||
"hexi": {},
|
||||
"hexi.core": {},
|
||||
"hexi.core.custom_utils": {"check_manage": None},
|
||||
"hexi.core.scheduler": {"add_job": lambda *a, **k: None},
|
||||
# cleanup.py 现在把清理提交到任务槽;这里只测 clean_temp_files / temp_stats,
|
||||
# 槽本身由 tests/test_task_slots.py 覆盖,所以给个够 import 的替身即可。
|
||||
"hexi.core.task_slots": {"TASKS": None, "Task": type("Task", (), {})},
|
||||
"nonebot_plugin_apscheduler": {"scheduler": types.SimpleNamespace(remove_job=None)},
|
||||
"nonebot_plugin_alconna": {"UniMessage": type("UniMessage", (), {})},
|
||||
}
|
||||
|
||||
|
||||
class _FakeMatcher:
|
||||
"""`on_command(...)` 的替身:`.handle()` 当装饰器用。"""
|
||||
|
||||
def handle(self, *args, **kwargs):
|
||||
def deco(func):
|
||||
return func
|
||||
|
||||
return deco
|
||||
|
||||
|
||||
class _TempRoot:
|
||||
"""`utils.get_temp_root` 的替身:指向测试用的临时根。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.root: Path | None = None
|
||||
|
||||
def __call__(self, sub: str = "") -> Path:
|
||||
assert self.root is not None, "测试里要先 sched.get_temp_root.root = ..."
|
||||
return self.root / sub if sub else self.root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cleanup(monkeypatch, tmp_path):
|
||||
saved = {name: sys.modules.get(name) for name in _STUBS}
|
||||
for name, attrs in _STUBS.items():
|
||||
module = types.ModuleType(name)
|
||||
for key, value in attrs.items():
|
||||
setattr(module, key, value)
|
||||
sys.modules[name] = module
|
||||
|
||||
# 插件包本体也用替身,避免触发 __init__ 的 NoneBot 初始化
|
||||
saved[_PKG] = sys.modules.get(_PKG)
|
||||
sys.modules[_PKG] = types.ModuleType(_PKG)
|
||||
temp_root = _TempRoot()
|
||||
utils_mod = types.ModuleType(f"{_PKG}.utils")
|
||||
utils_mod.get_temp_root = temp_root
|
||||
saved[f"{_PKG}.utils"] = sys.modules.get(f"{_PKG}.utils")
|
||||
sys.modules[f"{_PKG}.utils"] = utils_mod
|
||||
|
||||
import nonebot
|
||||
|
||||
monkeypatch.setattr(nonebot, "require", lambda name: None, raising=False)
|
||||
monkeypatch.setattr(
|
||||
nonebot, "get_plugin_config", lambda cls: cls(), raising=False
|
||||
) # 默认配置:auto / 7 天 / 4 点
|
||||
monkeypatch.setattr(nonebot, "on_command", lambda *a, **k: _FakeMatcher(), raising=False)
|
||||
|
||||
target = f"{_PKG}.cleanup"
|
||||
saved[target] = sys.modules.get(target)
|
||||
spec = importlib.util.spec_from_file_location(target, _PLUGIN / "cleanup.py")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[target] = module
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
temp_root.root = tmp_path / "temp"
|
||||
temp_root.root.mkdir()
|
||||
module._tmp = temp_root.root # 方便测试直接拿到根
|
||||
|
||||
yield module
|
||||
|
||||
for name, previous in saved.items():
|
||||
if previous is None:
|
||||
sys.modules.pop(name, None)
|
||||
else:
|
||||
sys.modules[name] = previous
|
||||
|
||||
|
||||
def _make(root: Path, rel: str, age_days: float = 0.0, data: bytes = b"x") -> Path:
|
||||
"""造一个文件,并把 mtime 往回拨 age_days 天。"""
|
||||
path = root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
if age_days:
|
||||
t = time.time() - age_days * 86400
|
||||
os.utime(path, (t, t))
|
||||
return path
|
||||
|
||||
|
||||
# ───────────────────────── 清理 ─────────────────────────
|
||||
|
||||
|
||||
def test_removes_stale_keeps_fresh(cleanup):
|
||||
root = cleanup._tmp
|
||||
stale = _make(root, "douyin/作者_1/旧作品.mp4", age_days=2)
|
||||
fresh = _make(root, "douyin/作者_1/新作品.mp4", age_days=0)
|
||||
|
||||
removed, total = cleanup.clean_temp_files(days=1)
|
||||
|
||||
assert (removed, total) == (1, 2)
|
||||
assert not stale.exists()
|
||||
assert fresh.exists()
|
||||
|
||||
|
||||
def test_days_zero_clears_files_but_keeps_fresh_dirs(cleanup):
|
||||
"""`/清理temp 0`(= Web 的「全部清空」):文件全删,但刚建的目录留着。
|
||||
|
||||
目录另有 60s 下限(见 `_prune_empty_dirs`)—— 下载落盘是先 mkdir 再写文件,
|
||||
把目录一起删掉会让正在进行的下载失败。
|
||||
"""
|
||||
root = cleanup._tmp
|
||||
a = _make(root, "kuaishou/作者_2/作品.mp4", age_days=0)
|
||||
b = _make(root, "xiaohongshu/作者_3/笔记/001.jpg", age_days=0)
|
||||
|
||||
removed, total = cleanup.clean_temp_files(days=0)
|
||||
|
||||
assert (removed, total) == (2, 2)
|
||||
assert not a.exists() and not b.exists()
|
||||
assert (root / "kuaishou/作者_2").is_dir() # 新鲜目录保留
|
||||
assert (root / "xiaohongshu/作者_3/笔记").is_dir()
|
||||
|
||||
|
||||
def test_prunes_stale_empty_dirs_bottom_up(cleanup):
|
||||
"""过期空目录会被收掉,但是**逐轮**回收而不是同一轮全清。
|
||||
|
||||
删文件(或删子目录)会把所在目录的 mtime 刷成"现在",当轮它就不再"过期",
|
||||
要等下一轮 —— 这正是 `_prune_empty_dirs` 注释里写的行为(也被这条测试钉住)。
|
||||
"""
|
||||
root = cleanup._tmp
|
||||
_make(root, "bilibili/作者_4/作品/001.jpg", age_days=3)
|
||||
nested = root / "bilibili/作者_4/作品"
|
||||
author = root / "bilibili/作者_4"
|
||||
|
||||
removed, _ = cleanup.clean_temp_files(days=1)
|
||||
assert removed == 1
|
||||
assert nested.is_dir() and author.is_dir() # 文件刚删,目录 mtime 是新的
|
||||
|
||||
old = time.time() - 3 * 86400 # 模拟又过了几天
|
||||
os.utime(nested, (old, old))
|
||||
os.utime(author, (old, old))
|
||||
cleanup.clean_temp_files(days=1)
|
||||
assert not nested.exists() # 深的先走
|
||||
|
||||
os.utime(author, (old, old)) # 上一轮删子目录,又把父目录的 mtime 刷新了
|
||||
cleanup.clean_temp_files(days=1)
|
||||
assert not author.exists()
|
||||
|
||||
|
||||
def test_keeps_stale_but_nonempty_dirs(cleanup):
|
||||
"""过期但里面还有新鲜文件的目录不能删。"""
|
||||
root = cleanup._tmp
|
||||
_make(root, "douyin/作者_5/新片.mp4", age_days=0)
|
||||
old_dir = root / "douyin/作者_5"
|
||||
os.utime(old_dir, (time.time() - 3 * 86400,) * 2)
|
||||
|
||||
cleanup.clean_temp_files(days=1)
|
||||
|
||||
assert old_dir.is_dir()
|
||||
|
||||
|
||||
def test_missing_root_is_noop(cleanup):
|
||||
"""根目录不存在(还没下载过任何东西)→ (0, 0),不抛。"""
|
||||
cleanup._tmp.rmdir()
|
||||
assert cleanup.clean_temp_files(days=1) == (0, 0)
|
||||
|
||||
|
||||
# ───────────────────────── 统计 ─────────────────────────
|
||||
|
||||
|
||||
def test_stats_counts_files_bytes_dirs(cleanup):
|
||||
root = cleanup._tmp
|
||||
_make(root, "douyin/作者_6/a.mp4", age_days=2, data=b"12345")
|
||||
_make(root, "douyin/作者_6/b.mp4", age_days=0, data=b"123")
|
||||
_make(root, "douyin/作者_7/c.jpg", age_days=5, data=b"1")
|
||||
|
||||
st = cleanup.temp_stats()
|
||||
|
||||
assert st["files"] == 3
|
||||
assert st["bytes"] == 9
|
||||
assert st["dirs"] == 3 # douyin / 作者_6 / 作者_7(子目录各算一层)
|
||||
|
||||
|
||||
def test_stats_missing_root(cleanup):
|
||||
cleanup._tmp.rmdir()
|
||||
assert cleanup.temp_stats() == {"files": 0, "bytes": 0, "dirs": 0}
|
||||
@@ -11,6 +11,7 @@ import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from nonebot.adapters.onebot.v11.exception import NetworkError
|
||||
|
||||
_MODULE_PATH = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
@@ -223,6 +224,75 @@ async def test_missing_file_short_circuits(gf, tmp_path, monkeypatch):
|
||||
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
|
||||
|
||||
|
||||
# ───────────────────────── 投递编排 ─────────────────────────
|
||||
|
||||
|
||||
@@ -241,9 +311,7 @@ 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
|
||||
)
|
||||
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")
|
||||
@@ -271,9 +339,7 @@ async def test_raw_mode_prefixes_work_name_for_multi_files(gf, tmp_path, monkeyp
|
||||
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/海边日落"
|
||||
)
|
||||
await gf.upload_group_files(images, 123, zip_files=False, rel_dir="作者_9/海边日落")
|
||||
assert seen == ["海边日落_001.jpg", "海边日落_002.jpg"]
|
||||
|
||||
seen.clear()
|
||||
|
||||
@@ -6,6 +6,7 @@ get_temp_root 的返回值,测试里 monkeypatch 成临时目录。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -127,11 +128,31 @@ def test_work_stem_strips_hashtags_and_spaces(u):
|
||||
|
||||
|
||||
def test_work_stem_empty_falls_back(u):
|
||||
"""没给来源时保持旧契约(固定 `作品`)"""
|
||||
assert u.build_work_stem("") == "作品"
|
||||
assert u.build_work_stem(None) == "作品"
|
||||
assert u.build_work_stem("🎬") == "作品"
|
||||
|
||||
|
||||
def test_work_stem_empty_uses_source_code(u):
|
||||
"""给了来源 → 空标题兜底成 `作品_{来源短码}`:同作品稳定、不同作品不撞
|
||||
|
||||
固定 `作品` 会让同一作者的多个无名作品挤在一个名字下,靠 4 位**时间**短码
|
||||
区分,每次重解析名字都变(S3 key / 群文件成员名不可复现)。
|
||||
"""
|
||||
url = "https://www.douyin.com/video/7412345678901234567"
|
||||
stem = u.build_work_stem("#话题", source=url)
|
||||
assert stem == f"作品_{u.short_source_code(url)}"
|
||||
assert re.fullmatch(r"作品_[0-9a-z]{4}", stem)
|
||||
assert stem == u.build_work_stem("", source=url) # 同来源稳定
|
||||
assert stem != u.build_work_stem("", source=url + "8") # 不同来源不同码
|
||||
|
||||
|
||||
def test_work_stem_source_ignored_when_title_present(u):
|
||||
"""有标题时来源短码不参与(标题本身已有区分度,别把名字改花)"""
|
||||
assert u.build_work_stem("旅行 #随手拍", source="whatever") == "旅行"
|
||||
|
||||
|
||||
def test_work_stem_truncates(u):
|
||||
assert len(u.build_work_stem("标题" * 20)) <= 15
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ def test_normalize_platform_aliases(policy):
|
||||
assert policy.normalize_platform("小红书") == "小红书"
|
||||
assert policy.normalize_platform("b23") == "哔哩哔哩"
|
||||
assert policy.normalize_platform("B站") == "哔哩哔哩"
|
||||
assert policy.normalize_platform("ks") == "快手"
|
||||
assert policy.normalize_platform("快手") == "快手"
|
||||
assert policy.normalize_platform("yt") == "Youtube"
|
||||
assert policy.normalize_platform("twitter") == "X"
|
||||
assert policy.normalize_platform(" X ") == "X"
|
||||
@@ -81,6 +83,10 @@ def test_match_platform_domains(policy):
|
||||
assert policy.match_platform("https://twitter.com/a/status/1") == "X"
|
||||
assert policy.match_platform("https://b23.tv/abc") == "哔哩哔哩"
|
||||
assert policy.match_platform("https://v.douyin.com/abc") == "抖音"
|
||||
assert policy.match_platform("https://v.kuaishou.com/nUqhAiDI") == "快手"
|
||||
assert policy.match_platform("https://www.kuaishou.com/short-video/abc") == "快手"
|
||||
# QQ 卡片 jumpUrl 走 v.m.chenzhongtech.com(快手分享页域)
|
||||
assert policy.match_platform("https://v.m.chenzhongtech.com/fw/photo/abc") == "快手"
|
||||
assert policy.match_platform("https://youtu.be/abc") == "Youtube"
|
||||
assert policy.match_platform("https://example.com/a.mp4") is None
|
||||
|
||||
|
||||
+326
-61
@@ -1,24 +1,27 @@
|
||||
"""媒体发送成功后「补传 S3 取下载链接」的单元测试
|
||||
"""sender.py 归档语义的单元测试(「媒体必须进本地 S3」是前提)
|
||||
|
||||
背景:2026-09-22 起 `hexi/core/outbound_media` 的全局钩子把本地路径改写成
|
||||
`/media/<token>`,本地直发那一级才真正可用。此前它因协议端不在本机、`file://`
|
||||
必然 ENOENT 而**恒失败**,所以每次都落到第 2 级 S3 —— `public_url` 因此总有值。
|
||||
本地直发通了以后,如果什么都不做,`public_url` 会恒为 None,`entry.py` 的
|
||||
`if pub and policy.sends_link` 就会让「发送下载链接」**静默失效**。
|
||||
用户 2026-09-25 定的前提:**无论发不发送消息**,下载成功的媒体都要进本地 S3
|
||||
(temp 只留 7 天,cleanup.py 删;桶里那份才是长期存档)。因此 sender 的结构是
|
||||
「归档与发送解耦且并行」:
|
||||
|
||||
`_local_sent` / `_public_link_for` 就是为此把「取链接」与「发送」解耦:
|
||||
只有策略真要链接(`sends_link`)时才补一次 S3,其余情况一概不碰 S3。
|
||||
- `archive_media` 无条件逐文件上传:单个文件失败只 warning 并继续,任何情况都不抛;
|
||||
- `send_pending_media` 一进来就 `create_task` 归档,与本地直发并行开跑,
|
||||
发送全失败也一定 await 它跑完;`public_url` 从归档结果取(最后一个非空);
|
||||
- 第 2 级(S3 链接发送)复用归档的预签名链接 —— 每个文件只上传一次,没有"补传"。
|
||||
|
||||
sender.py 有插件内相对导入(`..config` / `..policy` / `..services...`),
|
||||
无法裸加载,故在 sys.modules 里预置这些依赖的替身再按路径加载目标模块。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
import sys
|
||||
import time
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from nonebot.adapters.onebot.v11.exception import ActionFailed, NetworkError
|
||||
|
||||
_REPO = Path(__file__).resolve().parents[1]
|
||||
_PKG = "hexi.plugins.nonebot_plugin_video_analysis"
|
||||
@@ -71,23 +74,31 @@ def sender():
|
||||
|
||||
|
||||
class _Policy:
|
||||
"""只带 sends_link 的策略替身(真实 Policy 需要一堆字段/归一化)。"""
|
||||
|
||||
def __init__(self, sends_link: bool):
|
||||
self.sends_link = sends_link
|
||||
"""群策略替身(归档只是把它透传给 upload_with_plan,不需要真实字段)。"""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def uploads(monkeypatch, sender):
|
||||
"""记录 upload_with_plan 的调用;返回 (调用列表, 设置返回值的钩子)。"""
|
||||
calls: list[Path] = []
|
||||
result: dict = {"pub": "https://pub.example/x.mp4", "raise": None}
|
||||
"""记录 upload_with_plan 的调用;返回 (调用列表, 结果配置)。
|
||||
|
||||
result 可覆盖的字段:
|
||||
- `pub`:常量,或按调用顺序消费的列表(元素为 None = 没传公网);
|
||||
- `raises`:文件名 → 该文件上传时抛出的异常(其余文件照常上传)。
|
||||
"""
|
||||
calls: list[tuple[Path, object]] = []
|
||||
result: dict = {"pub": "https://pub.example/x.mp4", "raises": {}}
|
||||
|
||||
def fake_upload(file_path, *, policy=None):
|
||||
calls.append(Path(file_path))
|
||||
if result["raise"]:
|
||||
raise result["raise"]
|
||||
return "local-url", result["pub"]
|
||||
fp = Path(file_path)
|
||||
calls.append((fp, policy))
|
||||
pub = result["pub"]
|
||||
if isinstance(pub, list):
|
||||
# 列表按下标对应「第几个文件」(先取值再判失败:元素与文件一一对应)
|
||||
pub = pub.pop(0) if pub else None
|
||||
error = result["raises"].get(fp.name)
|
||||
if error:
|
||||
raise error
|
||||
return "local-url", pub
|
||||
|
||||
monkeypatch.setattr(sender, "upload_with_plan", fake_upload)
|
||||
return calls, result
|
||||
@@ -95,61 +106,315 @@ def uploads(monkeypatch, sender):
|
||||
|
||||
@pytest.fixture
|
||||
def media(sender):
|
||||
def build(sends_link: bool, n: int = 1, policy=None):
|
||||
def build(n: int = 1, policy=_Policy()):
|
||||
files = [Path(f"D:/temp/f{i}.mp4") for i in range(n)]
|
||||
return sender.PendingMedia(
|
||||
files=files,
|
||||
policy=_Policy(sends_link) if policy is None else policy,
|
||||
)
|
||||
return sender.PendingMedia(files=files, policy=policy)
|
||||
|
||||
return build
|
||||
|
||||
|
||||
async def test_no_s3_when_link_disabled(sender, media, uploads):
|
||||
"""默认路径:本地发成功就够了,绝不碰 S3(媒体不走 S3 归档是有意的)。"""
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=False))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert calls == []
|
||||
# ───────────────────────── 归档本体(无条件) ─────────────────────────
|
||||
|
||||
|
||||
async def test_s3_fetched_when_link_enabled(sender, media, uploads):
|
||||
"""策略要下载链接时:补传 S3 并把 public_url 交出去(本次修的回归)。"""
|
||||
async def test_archive_uploads_every_file(sender, media, uploads):
|
||||
"""逐文件归档,拿到 (预签名链接, 公网链接)。"""
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=True))
|
||||
result = await sender.archive_media(media(n=3))
|
||||
assert len(calls) == 3
|
||||
assert [(r.path.name, r.local_url, r.public_url) for r in result] == [
|
||||
(f"f{i}.mp4", "local-url", "https://pub.example/x.mp4") for i in range(3)
|
||||
]
|
||||
|
||||
|
||||
async def test_archive_continues_after_single_failure(sender, media, uploads):
|
||||
"""单个文件失败只跳过它,后面的照常传(多图作品不早退)。"""
|
||||
calls, result = uploads
|
||||
result["raises"] = {"f1.mp4": RuntimeError("这个文件 S3 挂了")}
|
||||
|
||||
archived = await sender.archive_media(media(n=3))
|
||||
assert [r.local_url for r in archived] == ["local-url", None, "local-url"]
|
||||
assert len(calls) == 3 # 失败的那个也试过了,f2 没被跳过
|
||||
|
||||
|
||||
async def test_archive_never_raises(sender, media, uploads):
|
||||
"""全部上传失败也不抛(归档失败不能反过来影响发送)。"""
|
||||
calls, result = uploads
|
||||
error = RuntimeError("S3 挂了")
|
||||
result["raises"] = {"f0.mp4": error, "f1.mp4": error}
|
||||
archived = await sender.archive_media(media(n=2))
|
||||
assert [r.local_url for r in archived] == [None, None]
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
async def test_archive_passes_policy_through(sender, media, uploads):
|
||||
"""policy=None(如 Web 面板路径)也照常归档:upload_with_plan 自己兜 Policy()。"""
|
||||
calls, _ = uploads
|
||||
await sender.archive_media(media(policy=None))
|
||||
assert [policy for _, policy in calls] == [None]
|
||||
|
||||
|
||||
async def test_last_public_url_takes_last_non_empty(sender, media, uploads):
|
||||
"""公网链接取最后一个非空值;只有本地链接的文件不影响结果。"""
|
||||
calls, result = uploads
|
||||
result["pub"] = [None, "https://pub.example/b.mp4", None]
|
||||
archived = await sender.archive_media(media(n=3))
|
||||
assert sender._last_public_url(archived) == "https://pub.example/b.mp4"
|
||||
|
||||
|
||||
async def test_last_public_url_none_when_upload_public_off(sender, media, uploads):
|
||||
"""S3 有本地链接但没公网(upload_public 关)→ None,且算不上失败。"""
|
||||
_, result = uploads
|
||||
result["pub"] = None
|
||||
archived = await sender.archive_media(media())
|
||||
assert archived[0].local_url == "local-url"
|
||||
assert sender._last_public_url(archived) is None
|
||||
|
||||
|
||||
# ─────────────────── 发送入口:归档无条件且与之并行 ───────────────────
|
||||
|
||||
|
||||
async def test_archives_even_when_send_fails(monkeypatch, sender, media, uploads):
|
||||
"""前提的核心:发送全失败也照样归档,public_url 照常交出去。"""
|
||||
calls, _ = uploads
|
||||
|
||||
async def fake_core(m, event, archive):
|
||||
return False # 三级全失败
|
||||
|
||||
monkeypatch.setattr(sender, "_send_media_core", fake_core)
|
||||
ok, pub = await sender.send_pending_media(media(n=2))
|
||||
assert (ok, pub) == (False, "https://pub.example/x.mp4")
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
async def test_archives_when_send_succeeds(monkeypatch, sender, media, uploads):
|
||||
"""发送成功也归档(归档不是发送的副产品)。"""
|
||||
calls, _ = uploads
|
||||
|
||||
async def fake_core(m, event, archive):
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(sender, "_send_media_core", fake_core)
|
||||
ok, pub = await sender.send_pending_media(media())
|
||||
assert (ok, pub) == (True, "https://pub.example/x.mp4")
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_multi_file_uploads_each(sender, media, uploads):
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=True, n=3))
|
||||
async def test_archive_runs_in_parallel_with_send(monkeypatch, sender, media, uploads):
|
||||
"""归档与发送并行开跑,不是"发送跑完才传"(本地直发不等它)。"""
|
||||
started: list[str] = []
|
||||
|
||||
async def fake_archive(m):
|
||||
started.append("archive")
|
||||
return [sender.UploadedFile(path=fp) for fp in m.files]
|
||||
|
||||
async def fake_core(m, event, archive):
|
||||
await asyncio.sleep(0) # 让出一个事件循环回合
|
||||
assert started, "归档应当已在跑,而不是等发送结束才开始"
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(sender, "archive_media", fake_archive)
|
||||
monkeypatch.setattr(sender, "_send_media_core", fake_core)
|
||||
ok, _ = await sender.send_pending_media(media())
|
||||
assert ok is True
|
||||
assert pub == "https://pub.example/x.mp4"
|
||||
assert len(calls) == 3
|
||||
|
||||
|
||||
async def test_no_public_url_keeps_none(sender, media, uploads):
|
||||
"""S3 有本地链接但没公网(upload_public 关)→ 不带链接,但也不算失败。"""
|
||||
calls, result = uploads
|
||||
result["pub"] = None
|
||||
ok, pub = await sender._local_sent(media(sends_link=True))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_upload_failure_never_breaks_send(sender, media, uploads):
|
||||
"""补传失败只记日志:发送本身已经成功了,不能反过来判失败。"""
|
||||
calls, result = uploads
|
||||
result["raise"] = RuntimeError("S3 挂了")
|
||||
ok, pub = await sender._local_sent(media(sends_link=True))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
async def test_policy_none_is_safe(sender, media, uploads):
|
||||
"""无策略(如 Web 面板路径)时不补传也不炸。"""
|
||||
async def test_send_failure_still_waits_for_archive(
|
||||
monkeypatch, sender, media, uploads
|
||||
):
|
||||
"""发送层抛异常也不能把归档半途丢下(finally 里 await 它跑完)。"""
|
||||
calls, _ = uploads
|
||||
ok, pub = await sender._local_sent(media(sends_link=False, policy=None))
|
||||
assert (ok, pub) == (True, None)
|
||||
assert calls == []
|
||||
finished: list[str] = []
|
||||
|
||||
async def fake_core(m, event, archive):
|
||||
raise RuntimeError("发送层炸了")
|
||||
|
||||
def slow_upload(file_path, *, policy=None): # to_thread 里跑的是同步函数
|
||||
calls.append((Path(file_path), policy))
|
||||
time.sleep(0.01)
|
||||
finished.append(Path(file_path).name)
|
||||
return "local-url", "https://pub.example/x.mp4"
|
||||
|
||||
monkeypatch.setattr(sender, "upload_with_plan", slow_upload)
|
||||
monkeypatch.setattr(sender, "_send_media_core", fake_core)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await sender.send_pending_media(media(n=2))
|
||||
assert finished == ["f0.mp4", "f1.mp4"] # 归档完整跑完才抛出去
|
||||
|
||||
|
||||
# ─────────────────── 第 2 级复用归档结果(不重复上传) ───────────────────
|
||||
|
||||
|
||||
async def test_s3_level_reuses_archived_urls(monkeypatch, sender, media, uploads):
|
||||
"""本地直发失败 → 用归档的预签名链接发送,每个文件只上传一次。"""
|
||||
calls, _ = uploads
|
||||
built: list = []
|
||||
|
||||
def fake_local_msg(files, image_post):
|
||||
class _Msg:
|
||||
async def send(self, **_kw): # 本地直发失败
|
||||
raise RuntimeError("协议端拿不到文件")
|
||||
|
||||
return _Msg()
|
||||
|
||||
def fake_s3_msg(items):
|
||||
built.append(items)
|
||||
|
||||
class _Msg:
|
||||
async def send(self, **_kw): # 链接版发送成功
|
||||
pass
|
||||
|
||||
return _Msg()
|
||||
|
||||
monkeypatch.setattr(sender, "_build_local_msg", fake_local_msg)
|
||||
monkeypatch.setattr(sender, "_build_s3_msg", fake_s3_msg)
|
||||
|
||||
ok, pub = await sender.send_pending_media(media(n=2))
|
||||
assert (ok, pub) == (True, "https://pub.example/x.mp4")
|
||||
assert len(calls) == 2 # 只有归档那次上传,第 2 级没有重复传
|
||||
assert built == [
|
||||
[(Path("D:/temp/f0.mp4"), "local-url"), (Path("D:/temp/f1.mp4"), "local-url")]
|
||||
]
|
||||
|
||||
|
||||
async def test_no_files_is_noop(sender):
|
||||
"""没有媒体文件时既不归档也不发送。"""
|
||||
ok, pub = await sender.send_pending_media(sender.PendingMedia(files=[]))
|
||||
assert (ok, pub) == (False, None)
|
||||
|
||||
|
||||
# ─────────── 超时=结果未知:不降级重试(长视频防重复发送) ───────────
|
||||
# 2026-10-13 实测:172MB 视频三级降级各超时一次(NoneBot 默认 api_timeout=30s,
|
||||
# 而协议端下载+转存要 ~2 分钟),但协议端在 bot 超时后照样把三份全发进群。
|
||||
# 超时只是 bot 放弃等待 ⇒ 结果未知 ⇒ 重试必重复。
|
||||
|
||||
|
||||
def test_send_timeout_default_and_env(sender, monkeypatch):
|
||||
"""默认 300s;`video_analysis_send_timeout` 可调,非法/非正值回退默认。"""
|
||||
monkeypatch.delenv("video_analysis_send_timeout", raising=False)
|
||||
monkeypatch.delenv("VIDEO_ANALYSIS_SEND_TIMEOUT", raising=False)
|
||||
assert sender.send_timeout() == 300.0
|
||||
|
||||
monkeypatch.setenv("video_analysis_send_timeout", "120")
|
||||
assert sender.send_timeout() == 120.0
|
||||
|
||||
monkeypatch.setenv("video_analysis_send_timeout", "abc")
|
||||
assert sender.send_timeout() == 300.0
|
||||
|
||||
monkeypatch.setenv("video_analysis_send_timeout", "0")
|
||||
assert sender.send_timeout() == 300.0
|
||||
|
||||
|
||||
def test_unknown_outcome_classification(sender):
|
||||
"""超时/网络错误=结果未知;ActionFailed(协议端明确报错)=确定失败。"""
|
||||
assert sender.unknown_send_outcome(NetworkError("timeout")) is True
|
||||
assert sender.unknown_send_outcome(asyncio.TimeoutError()) is True
|
||||
assert sender.unknown_send_outcome(ActionFailed(error="fetch failed")) is False
|
||||
assert sender.unknown_send_outcome(RuntimeError("本地错误")) is False
|
||||
|
||||
|
||||
async def test_media_send_carries_per_call_timeout(monkeypatch, sender, media, uploads):
|
||||
"""媒体发送带 `_timeout`(适配器 per-call 超时),长视频才等得到真结果。"""
|
||||
monkeypatch.setenv("video_analysis_send_timeout", "42")
|
||||
seen: list[dict] = []
|
||||
|
||||
def fake_local_msg(files, image_post):
|
||||
class _Msg:
|
||||
async def send(self, **kwargs):
|
||||
seen.append(kwargs)
|
||||
|
||||
return _Msg()
|
||||
|
||||
monkeypatch.setattr(sender, "_build_local_msg", fake_local_msg)
|
||||
ok, _ = await sender.send_pending_media(media())
|
||||
assert ok is True
|
||||
assert seen == [{"_timeout": 42.0}]
|
||||
|
||||
|
||||
async def test_local_timeout_does_not_resend(monkeypatch, sender, media, uploads):
|
||||
"""本地直发超时(结果未知)→ 不走 S3 降级、按「可能已送达」返回。"""
|
||||
s3_built: list = []
|
||||
|
||||
def fake_local_msg(files, image_post):
|
||||
class _Msg:
|
||||
async def send(self, **_kw):
|
||||
raise NetworkError("WebSocket call api send_msg timeout")
|
||||
|
||||
return _Msg()
|
||||
|
||||
def fake_s3_msg(items):
|
||||
s3_built.append(items)
|
||||
|
||||
class _Msg:
|
||||
async def send(self, **_kw):
|
||||
pass
|
||||
|
||||
return _Msg()
|
||||
|
||||
monkeypatch.setattr(sender, "_build_local_msg", fake_local_msg)
|
||||
monkeypatch.setattr(sender, "_build_s3_msg", fake_s3_msg)
|
||||
|
||||
ok, pub = await sender.send_pending_media(media())
|
||||
assert ok is True # 结果未知按可能已送达:不触发「媒体发送失败」提示
|
||||
assert pub == "https://pub.example/x.mp4" # 归档照常完成
|
||||
assert s3_built == [] # 关键:没有第二次发送
|
||||
|
||||
|
||||
async def test_action_failed_still_cascades_to_s3(monkeypatch, sender, media, uploads):
|
||||
"""协议端明确报错(如 fetch failed)= 确定没发出去,照旧降级到 S3 链接。"""
|
||||
sent: list = []
|
||||
|
||||
def fake_local_msg(files, image_post):
|
||||
class _Msg:
|
||||
async def send(self, **_kw):
|
||||
raise ActionFailed(error="fetch failed")
|
||||
|
||||
return _Msg()
|
||||
|
||||
def fake_s3_msg(items):
|
||||
class _Msg:
|
||||
async def send(self, **_kw): # 链接版成功
|
||||
sent.append(items)
|
||||
|
||||
return _Msg()
|
||||
|
||||
monkeypatch.setattr(sender, "_build_local_msg", fake_local_msg)
|
||||
monkeypatch.setattr(sender, "_build_s3_msg", fake_s3_msg)
|
||||
|
||||
ok, _ = await sender.send_pending_media(media())
|
||||
assert ok is True
|
||||
assert len(sent) == 1 # 恰好降级一次
|
||||
|
||||
|
||||
async def test_forward_timeout_does_not_fall_through(
|
||||
monkeypatch, sender, media, uploads
|
||||
):
|
||||
"""合并转发超时(结果未知)→ 不再落回普通消息链路重发。"""
|
||||
two = media(n=2)
|
||||
fallback_built: list = []
|
||||
|
||||
async def fake_forward(items, sender_name, *, group_id=None, user_id=None):
|
||||
return None # 结果未知
|
||||
|
||||
def fake_local_msg(files, image_post):
|
||||
fallback_built.append(files)
|
||||
|
||||
class _Msg:
|
||||
async def send(self, **_kw):
|
||||
raise AssertionError("不该走到普通消息链路")
|
||||
|
||||
return _Msg()
|
||||
|
||||
monkeypatch.setattr(sender, "_send_forward_media", fake_forward)
|
||||
monkeypatch.setattr(sender, "_forward_sender_name", lambda: "Bot")
|
||||
monkeypatch.setattr(sender, "_build_local_msg", fake_local_msg)
|
||||
|
||||
archive = asyncio.create_task(sender.archive_media(two))
|
||||
try:
|
||||
ok = await sender._send_media_core(
|
||||
two, types.SimpleNamespace(group_id=123), archive
|
||||
)
|
||||
finally:
|
||||
await archive
|
||||
assert ok is True
|
||||
assert fallback_built == []
|
||||
Reference in new issue
Block a user