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

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

443 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""媒体多级发送 — 本地优先,归档与发送解耦(并行),失败逐级降级
发送策略(2026-08-22 用户需求):
1. temp 本地文件直接发送(最快,不经 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)。注意那条 `/media`
链接**只给协议端拉取用**(TTL 300s、bot 自己的路由),不能当下载链接发给群友。
多媒体(2026-09-08 用户需求): 多图(图集)、图+视频混合、多视频(动图)
等一个作品落地成多个文件时,直接 UniMessage 拼成一个消息发送会报错
(视频段不能与其他段混合,一条消息也放不下多段视频/图集体验差)
→ 统一走合并转发,一个图/视频一个节点。
群文件(并行通道): `policy.upload_group_file` 开着时,消息链跑完后额外把文件
传到群文件(默认打包成一个 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
from ..policy import Policy
from ..services.storage.group_file import upload_group_files
from ..services.storage.s3 import upload_with_plan
from ..utils import media_rel_dir_of
@dataclass
class PendingMedia:
"""待发送媒体:本地文件 + 上传元数据(发送前不做任何上传/清理)"""
files: list[Path]
image_post: bool = False
is_private: bool = False
policy: Policy | None = None
#: 平台规范标签(见 policy.match_platform),群文件限定平台用
platform: str | None = None
title: str = ""
def _as_paths(file_paths: Path | list[Path]) -> list[Path]:
if isinstance(file_paths, list):
return [Path(p) for p in file_paths]
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}"
)
# ─────────────────────── 合并转发(多媒体专用) ───────────────────────
#: 按视频段发送的扩展名(其余按图片发;直链下载可能落 webm/mov 等)
_VIDEO_SUFFIXES = {".mp4", ".webm", ".mov", ".flv", ".mkv", ".ts"}
def _is_video(fp: Path) -> bool:
return fp.suffix.lower() in _VIDEO_SUFFIXES
def _needs_forward(files: list[Path]) -> bool:
"""是否需要走合并转发:多图/图+视频混合/多视频(动图)等多媒体作品
落地成多个文件(>1 个)统一一个媒体一个节点转发;
单图/单视频保持原有直发链路。
"""
return len(files) > 1
def _forward_sender_name() -> str:
"""合并转发节点的发送者昵称(用机器人配置昵称)"""
nickname = get_driver().config.nickname
return next(iter(nickname), "Bot") if nickname else "Bot"
def _media_segment(fp: Path, url: str | None = None) -> dict:
"""onebot v11 消息段字典:image / video,file 为本地路径或链接"""
media_type = "video" if _is_video(fp) else "image"
return {"type": media_type, "data": {"file": url or str(fp)}}
def _build_forward_nodes(
items: list[tuple[Path, str | None]],
sender_name: str,
self_id: int,
) -> list[dict]:
"""合并转发节点列表:一个媒体一个节点,content 为该媒体的消息段数组"""
return [
{
"type": "node",
"data": {
"name": sender_name,
"uin": str(self_id),
"content": [_media_segment(fp, url)],
},
}
for fp, url in items
]
async def _send_forward_media(
items: list[tuple[Path, str | None]],
sender_name: str,
*,
group_id: int | None = None,
user_id: int | None = None,
) -> 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,
_timeout=timeout,
)
else:
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
# ─────────────────────── 归档(发送的前提) ───────────────────────
@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:
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
# ───────────────────────────── 多级发送 ─────────────────────────────
def _build_local_msg(files: list[Path], image_post: bool) -> UniMessage:
"""本地文件版消息(视频扩展名 → 视频段,其余 → 图片段)"""
msg = UniMessage()
for fp in files:
if _is_video(fp):
msg.video(path=fp)
else:
msg.image(path=fp)
return msg
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()
for fp, url in urls:
if _is_video(fp):
msg.video(url=url)
else:
msg.image(url=url)
return msg
async def _send_media_core(
media: PendingMedia,
event: Event | None,
archive: "asyncio.Task[list[UploadedFile]]",
) -> bool:
"""多级发送主体,返回是否成功(public_url 由调用方从归档结果取)。
`archive` 是**已经在跑**的归档任务(见 `send_pending_media`):本地直发不等它,
需要链接时再 `await`——同一个 task 多次 await 安全且结果复用,所以每个文件
只上传一次,没有"补传"这一步。
temp 文件发送成功后保留(用户手动清理 data/temp)。
多媒体作品(多图 / 图+视频 / 多视频)先走合并转发(本地文件 → S3 链接),
失败后仍落回下方普通消息链路兜底。
**超时(结果未知)时按"可能已送达"处理**:停在这一级并返回 True——协议端
可能仍在把这份发完,降级重试会重复发送、发"发送失败"提示也会误导。只有
协议端明确报错(ActionFailed 等)才降级,见 `unknown_send_outcome`。
"""
if not media.files:
return False
timeout = send_timeout()
# ── 0. 多媒体 → 合并转发(一个图/视频一个节点)─────────────────
if event is not None and _needs_forward(media.files):
group_id = getattr(event, "group_id", None)
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()
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 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
else:
logger.warning("归档未全部成功,跳过 S3 链接合并转发,走普通消息链路")
else:
logger.warning("无法确定群号/用户号,跳过合并转发,走普通消息链路")
# ── 1. temp 本地文件直接发送 ──────────────────────────────
try:
await _build_local_msg(media.files, media.image_post).send(_timeout=timeout)
logger.info("媒体发送成功(temp 本地文件直达)")
return True
except Exception as e:
if unknown_send_outcome(e):
_log_unknown("temp 本地文件直达", e)
return True
logger.warning(f"temp 本地文件发送失败,切换本地 S3 链接: {e}")
# ── 2. 用归档好的预签名链接发送 ─────────────────────────────
try:
s3_msg = _build_s3_msg(_presigned_items(await archive))
await s3_msg.send(_timeout=timeout)
logger.info("媒体发送成功(本地 S3 链接)")
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(_timeout=timeout)
logger.info("媒体发送成功(回退 temp 本地文件)")
return True
except Exception as e:
if unknown_send_outcome(e):
_log_unknown("回退 temp 本地文件", e)
return True
logger.exception(f"回退发送失败: {e}")
return False
async def _upload_group_files(media: PendingMedia, event: Event | None) -> None:
"""群文件并行通道:消息链跑完后按策略额外传一份(失败只记日志)。
是否打包成 zip / 是否加密由全局配置决定(config.group_file_settings);
平台清单(policy.group_file_platforms)非空时只传清单里的平台,其余平台
照常走消息、不传群文件。
"""
policy = media.policy
if policy is None or event is None:
return
if not policy.allows_group_file(media.platform):
if policy.upload_group_file:
logger.info(
f"群文件限定平台 {policy.group_file_platforms},"
f"本次为 {media.platform or '未知平台'},跳过群文件上传"
)
return
group_id = getattr(event, "group_id", None)
if group_id is None:
return
zip_files, password = group_file_settings()
await upload_group_files(
media.files,
int(group_id),
title=media.title,
zip_files=zip_files,
password=password,
policy=policy,
rel_dir=media_rel_dir_of(media.files[0]),
)
async def send_pending_media(
media: PendingMedia, event: Event | None = None
) -> tuple[bool, str | None]:
"""归档 + 多级发送 + 群文件并行通道,返回 (是否成功, public_url)。
**归档无条件且先行开跑**(用户 2026-09-25 定的前提):媒体进了 temp 就必须
再进一次桶,与发送走哪一级、成功与否无关。它与本地直发并行,所以发送仍旧是
最快那条路;`public_url` 取自归档结果(`upload_public` 关时为 None),交给
entry.py 按策略决定是否发链接文本。
「是否成功」对**超时(结果未知)**按 True 算:协议端可能仍在发,调用方
不该再给群里发"媒体发送失败"(长视频实测就是超时后照样送达)。
"""
if not media.files:
return False, None
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, _last_public_url(uploads)