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
+3536
-895
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user