- 抖音(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>
336 lines
12 KiB
Python
336 lines
12 KiB
Python
"""消息入口与分派:自动解析 / 主动解析(文本链接 + QQ小程序/分享卡片)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import re
|
||
|
||
from nonebot import on_message, logger
|
||
from nonebot.adapters import Event
|
||
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.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
|
||
from .universal import handle_universal
|
||
from ..list_proc import get_policy, is_group_whitelisted, is_user_blacklisted
|
||
from ..policy import Policy, match_platform
|
||
|
||
URL_PATTERN = re.compile(r"(https?://\S+)")
|
||
XCX_PATTERN = r"QQ小程序(?:&#93;|]|\])"
|
||
|
||
VALID_HOSTS = [
|
||
"b23.tv",
|
||
"bilibili.com",
|
||
"youtube.com",
|
||
"youtu.be",
|
||
"douyin.com",
|
||
"v.douyin.com",
|
||
"iesdouyin.com",
|
||
"m.douyin.com",
|
||
"jingxuan.douyin.com",
|
||
"kuaishou.com",
|
||
"chenzhongtech.com",
|
||
"x.com",
|
||
"twitter.com",
|
||
"xiaohongshu.com",
|
||
"xhslink.com",
|
||
"xhslink.cn",
|
||
]
|
||
|
||
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())
|
||
|
||
|
||
async def _check_access(
|
||
event: Event, *, auto_only: bool = False, msg: str | None = None
|
||
) -> tuple[bool, Policy | None]:
|
||
"""统一权限检查。
|
||
|
||
auto_only=True → 自动解析:需白名单群 + (开启自动解析或消息命中自动策略)。
|
||
auto_only=False → 主动触发:需白名单群 + 非黑名单用户。
|
||
|
||
私聊不做自动解析,读 default 节策略后直接解析。
|
||
禁用策略(ban_link)是消息级的(按链接判定),见 match_message。
|
||
|
||
Returns:
|
||
(allowed, policy) — policy 决定存储/投递,不允许时为 None
|
||
"""
|
||
target = get_target(event)
|
||
|
||
if target.private:
|
||
if auto_only:
|
||
logger.info("权限分析:自动解析不处理私聊")
|
||
return False, None
|
||
if is_user_blacklisted(event):
|
||
logger.info(f"权限分析:黑名单用户私聊,不回复: {event.get_user_id()}")
|
||
return False, None
|
||
logger.info("权限分析:私聊,直接解析")
|
||
return True, get_policy(event)
|
||
|
||
group_id = str(event.group_id)
|
||
|
||
if not is_group_whitelisted(event):
|
||
logger.info(f"权限分析:群 {group_id} 不在白名单,不做处理")
|
||
return False, None
|
||
|
||
if is_user_blacklisted(event):
|
||
logger.info(f"权限分析:黑名单用户,不回复: {event.get_user_id()}")
|
||
return False, None
|
||
|
||
policy = get_policy(event)
|
||
|
||
if auto_only and not policy.auto and not _match_auto_link(msg or "", policy):
|
||
logger.info(f"权限分析:群 {group_id} 未开启自动解析且未命中自动策略")
|
||
return False, None
|
||
|
||
logger.info(
|
||
f"权限分析:群 {group_id} 权限通过 — 自动解析: {policy.auto}, "
|
||
f"存储: {policy.plan}, 公网: {policy.upload_public}, "
|
||
f"群文件: {policy.upload_group_file}"
|
||
)
|
||
return True, policy
|
||
|
||
|
||
def _match_auto_link(msg: str, policy: Policy) -> bool:
|
||
"""消息中的 URL 是否命中群策略的自动策略(auto_link)。"""
|
||
if not policy.auto_link:
|
||
return False
|
||
for url in URL_PATTERN.findall(msg):
|
||
platform = policy.auto_matched(url)
|
||
if platform:
|
||
logger.info(f"自动策略:{platform} 命中消息 {url}")
|
||
return True
|
||
return False
|
||
|
||
|
||
def _skip_banned(url: str, policy: Policy) -> bool:
|
||
"""链接是否命中禁用策略(命中即静默丢弃,只记日志)。"""
|
||
platform = policy.banned(url)
|
||
if platform:
|
||
logger.info(f"禁用策略:{platform} 已禁用,忽略链接 {url}")
|
||
return True
|
||
return False
|
||
|
||
|
||
@auto_video_handler.handle()
|
||
async def handle_auto_video(event: Event):
|
||
msg = str(event.get_message()).strip()
|
||
allowed, policy = await _check_access(event, auto_only=True, msg=msg)
|
||
if allowed and policy is not None:
|
||
await match_message(event, policy)
|
||
|
||
|
||
@active_video_handler.handle()
|
||
async def handle_active_video(event: Event):
|
||
allowed, policy = await _check_access(event, auto_only=False)
|
||
if allowed and policy is not None:
|
||
await match_message(event, policy)
|
||
|
||
|
||
async def match_message(event: Event, policy: Policy):
|
||
"""消息匹配与分派:文本链接 / QQ小程序卡片统一走 dispatch_url。
|
||
|
||
命中禁用策略的链接在这里丢弃;消息里还有其它可用链接则继续解析。
|
||
"""
|
||
msg = str(event.get_message()).strip()
|
||
logger.info(f"消息解析:获取到的消息:{msg}")
|
||
is_private = get_target(event).private
|
||
|
||
message = None
|
||
public_url = None
|
||
url = ""
|
||
|
||
if re.search(XCX_PATTERN, msg) or "CQ:json" in msg or "CQ:share" in msg:
|
||
logger.info("消息解析:检测到 CQ 卡片")
|
||
url = await _extract_xcx_url(msg) or ""
|
||
logger.info(f"消息解析:卡片链接:{url}")
|
||
if not url or not any(domain in url for domain in VALID_HOSTS):
|
||
return
|
||
if _skip_banned(url, policy):
|
||
return
|
||
message, public_url = await dispatch_url(url, is_private, policy)
|
||
if not message:
|
||
return
|
||
else:
|
||
urls = [u for u in URL_PATTERN.findall(msg) if not _skip_banned(u, policy)]
|
||
for url in urls:
|
||
logger.info(f"消息解析:作品链接:{url}")
|
||
message, public_url = await dispatch_url(url, is_private, policy)
|
||
if message:
|
||
break
|
||
if not message:
|
||
return
|
||
|
||
if isinstance(message, PendingMedia):
|
||
ok, pub = await send_pending_media(message, event)
|
||
if pub and policy.sends_link:
|
||
await UniMessage.text(f"{pub}").send()
|
||
if not ok:
|
||
await UniMessage.text(f"媒体发送失败:{url}").send()
|
||
else:
|
||
if public_url and policy.sends_link:
|
||
await UniMessage.text(f"{public_url}").send()
|
||
await message.send()
|
||
|
||
|
||
async def dispatch_url(
|
||
url: str,
|
||
is_private: bool,
|
||
policy: Policy,
|
||
) -> tuple[UniMessage | None, str | None]:
|
||
"""按平台分派解析(文本链接与小程序卡片共用)。"""
|
||
url = url.rstrip(",。!?、;:)】》\"')")
|
||
|
||
if "b23.tv" in url or "bili2233.cn" in url:
|
||
resolved = await resolve_short_link(url)
|
||
if resolved:
|
||
logger.info(f"b23 短链重定向: {url} -> {resolved}")
|
||
url = resolved
|
||
|
||
# 平台标签:短链重定向之后再判定(群文件限定平台用)
|
||
platform = match_platform(url)
|
||
|
||
if "douyin.com" in url or "v.douyin.com" in url or "iesdouyin.com" in url:
|
||
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
|
||
try:
|
||
title, parsed_path, image_post = await parse_douyin(url)
|
||
if not title and not parsed_path:
|
||
# 链接识别成功但未能获取到作品(网络/风控/链接失效等),
|
||
# 给出明确失败提示,避免只发"请稍候"后无下文。
|
||
await UniMessage.text(f"无法解析到媒体:{url}").send()
|
||
logger.warning(f"媒体解析:未能获取到作品:{url}")
|
||
return None, None
|
||
return await process_douyin_res(
|
||
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 (
|
||
"bilibili.com/opus",
|
||
"bilibili.com/dynamic",
|
||
"t.bilibili.com",
|
||
"bilibili.com/read",
|
||
)
|
||
):
|
||
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
|
||
try:
|
||
title, parsed_path = await fetch_bilibili_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"B站内容解析:无法解析:{url}")
|
||
return None, None
|
||
|
||
if "xiaohongshu.com" in url or "xhslink." in url:
|
||
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
|
||
try:
|
||
title, parsed_path = await fetch_rednote_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(domain in url for domain in VALID_HOSTS):
|
||
await UniMessage.text("检测到链接,正在处理,请稍候...").send()
|
||
try:
|
||
return await handle_universal(url, is_private, policy, platform)
|
||
except Exception as e:
|
||
logger.exception(e)
|
||
await UniMessage.text("下载过程中出现错误。").send()
|
||
return None, None
|
||
|
||
return None, None
|
||
|
||
|
||
async def _extract_xcx_url(msg: str) -> str | None:
|
||
"""从 CQ 卡片消息中提取跳转 URL(保留 query 参数)。"""
|
||
match = re.search(r'"qqdocurl":"(.*?)"', msg)
|
||
if not match:
|
||
match = re.search(r'"jumpUrl":"(.*?)"', msg)
|
||
if not match:
|
||
logger.warning("未找到 qqdocurl/jumpUrl 字段")
|
||
return None
|
||
|
||
raw_url = match.group(1)
|
||
unescaped = html.unescape(raw_url)
|
||
cleaned_url = unescaped.replace(r"\/", "/")
|
||
logger.info(f"卡片提取链接: {cleaned_url}")
|
||
return cleaned_url
|