nonebot-plugin-parsehub 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nonebot_plugin_parsehub/__init__.py +1298 -0
- nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
- nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
- nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
- nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
- nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
- nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
- nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
- nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
- nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
- nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
- nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
- nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
- nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
- nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
- nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
- nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
- nonebot_plugin_parsehub/assets/logos/x.png +0 -0
- nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
- nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
- nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
- nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
- nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
- nonebot_plugin_parsehub/card.py +836 -0
- nonebot_plugin_parsehub/config.py +95 -0
- nonebot_plugin_parsehub/core.py +485 -0
- nonebot_plugin_parsehub/mediacache.py +418 -0
- nonebot_plugin_parsehub/server.py +241 -0
- nonebot_plugin_parsehub/switches.py +156 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/METADATA +275 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/RECORD +33 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/WHEEL +4 -0
- nonebot_plugin_parsehub-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1298 @@
|
|
|
1
|
+
# Copyright 2026 ByteColtX
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""ParseHub 链接解析插件(OneBot v11)。
|
|
5
|
+
|
|
6
|
+
群里出现受支持的分享链接时自动解析,并把视频 / 图集 / 正文发回群里;也可以
|
|
7
|
+
用命令 ``解析 <链接或分享文案>`` 手动触发。
|
|
8
|
+
|
|
9
|
+
处理流程:
|
|
10
|
+
|
|
11
|
+
1. 收到链接立刻给原消息点一个表情回应(不等解析结果);
|
|
12
|
+
2. 解析出平台、标题、正文和媒体直链;
|
|
13
|
+
3. 下载媒体到本机临时目录,再用内置 HTTP 服务给协议端一个 http 地址;
|
|
14
|
+
4. 媒体多于一个时合并转发,否则逐条发送。
|
|
15
|
+
|
|
16
|
+
解析与下载全部由 ``parsehub`` 完成,本插件只负责调度和发送。parsehub 是同步
|
|
17
|
+
阻塞的(内部还用了 requests),因此所有调用都通过 ``asyncio.to_thread`` 执行,
|
|
18
|
+
不会阻塞 NoneBot 的事件循环。
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import asyncio
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import time
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from nonebot import get_driver, logger, on_command, on_message
|
|
32
|
+
from nonebot.adapters.onebot.v11 import (
|
|
33
|
+
Bot,
|
|
34
|
+
GroupMessageEvent,
|
|
35
|
+
Message,
|
|
36
|
+
MessageEvent,
|
|
37
|
+
MessageSegment,
|
|
38
|
+
)
|
|
39
|
+
from nonebot.adapters.onebot.v11.exception import ActionFailed
|
|
40
|
+
from nonebot.params import CommandArg
|
|
41
|
+
from nonebot.plugin import PluginMetadata
|
|
42
|
+
from nonebot.rule import Rule
|
|
43
|
+
|
|
44
|
+
# 包内用相对导入:插件既可能作为已安装的包加载,也可能作为本地插件被放在
|
|
45
|
+
# src/plugins 下(此时模块名带前缀),绝对自引用在后一种情况下会失败。
|
|
46
|
+
from . import card, core, mediacache, server, switches
|
|
47
|
+
from .config import Config, plugin_config
|
|
48
|
+
|
|
49
|
+
__plugin_meta__ = PluginMetadata(
|
|
50
|
+
name="ParseHub 链接解析",
|
|
51
|
+
description=(
|
|
52
|
+
"群里出现受支持的分享链接时,用 ParseHub 自动解析并发送媒体"
|
|
53
|
+
"(视频 / 图集 / 正文)。"
|
|
54
|
+
),
|
|
55
|
+
usage=(
|
|
56
|
+
"发链接即可自动解析;命令:解析 <链接或分享文案>、解析平台、"
|
|
57
|
+
"解析开关 [开|关](超管 / 群管理)、解析状态"
|
|
58
|
+
),
|
|
59
|
+
type="application",
|
|
60
|
+
homepage="https://github.com/ByteColtX/nonebot-plugin-parsehub",
|
|
61
|
+
config=Config,
|
|
62
|
+
supported_adapters={"~onebot.v11"},
|
|
63
|
+
extra={"parsehub": "https://github.com/z-mio/ParseHub"},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# 表情回应用的 QQ 表情 ID,写死在代码里(改动请直接改这里)。
|
|
67
|
+
REACT_START = "424" # 收到链接,立即回应
|
|
68
|
+
REACT_OK = "144" # 视频发送成功
|
|
69
|
+
REACT_FAIL = "5" # 视频发送失败
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# 合并转发里显示的节点名取自 NICKNAME 的第一项。
|
|
73
|
+
# nonebot 把 NICKNAME 解析成 set,顺序会丢,所以这里读原始配置值,保证显示稳定
|
|
74
|
+
# (否则每次重启昵称都可能变),读不到才退回配置集合里的第一个。
|
|
75
|
+
def _configured_nicknames() -> list[str]:
|
|
76
|
+
"""按 .env 里的书写顺序读出机器人昵称。
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
昵称列表;读不到时为 ``[]``。
|
|
80
|
+
"""
|
|
81
|
+
raw = os.environ.get("NICKNAME", "").strip()
|
|
82
|
+
candidates = [raw] if raw else []
|
|
83
|
+
if not candidates:
|
|
84
|
+
env_files = get_driver().config.model_config.get("env_file") or ()
|
|
85
|
+
if isinstance(env_files, str):
|
|
86
|
+
env_files = (env_files,)
|
|
87
|
+
for name in env_files:
|
|
88
|
+
try:
|
|
89
|
+
text = Path(str(name)).read_text(encoding="utf-8")
|
|
90
|
+
except OSError:
|
|
91
|
+
continue
|
|
92
|
+
for line in text.splitlines():
|
|
93
|
+
key, sep, value = line.partition("=")
|
|
94
|
+
if sep and key.strip() == "NICKNAME":
|
|
95
|
+
candidates.append(value.strip())
|
|
96
|
+
break
|
|
97
|
+
if candidates:
|
|
98
|
+
break
|
|
99
|
+
|
|
100
|
+
for value in candidates:
|
|
101
|
+
try:
|
|
102
|
+
parsed = json.loads(value)
|
|
103
|
+
except json.JSONDecodeError: # 不是 JSON:按逗号切,去掉注释和引号
|
|
104
|
+
plain = value.split("#", 1)[0].strip()
|
|
105
|
+
parsed = [item.strip().strip("\"'") for item in plain.split(",")]
|
|
106
|
+
if isinstance(parsed, str):
|
|
107
|
+
parsed = [parsed]
|
|
108
|
+
if not isinstance(parsed, list):
|
|
109
|
+
continue
|
|
110
|
+
names = [
|
|
111
|
+
item.strip()
|
|
112
|
+
for item in parsed
|
|
113
|
+
if isinstance(item, str) and item.strip()
|
|
114
|
+
]
|
|
115
|
+
if names:
|
|
116
|
+
return names
|
|
117
|
+
|
|
118
|
+
return sorted(get_driver().config.nickname)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@lru_cache(maxsize=1)
|
|
122
|
+
def _forward_node_name() -> str:
|
|
123
|
+
"""合并转发节点上显示的名字。
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
``NICKNAME`` 的第一项;完全没配昵称时退回 ``"ParseHub"``。
|
|
127
|
+
"""
|
|
128
|
+
names = _configured_nicknames()
|
|
129
|
+
return names[0] if names else "ParseHub"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# 判断媒体类型用的后缀集合。
|
|
133
|
+
_IMAGE_EXTENSIONS = frozenset(
|
|
134
|
+
{
|
|
135
|
+
".avif",
|
|
136
|
+
".bmp",
|
|
137
|
+
".gif",
|
|
138
|
+
".heic",
|
|
139
|
+
".ico",
|
|
140
|
+
".jpeg",
|
|
141
|
+
".jpg",
|
|
142
|
+
".png",
|
|
143
|
+
".webp",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
_VIDEO_EXTENSIONS = frozenset(
|
|
147
|
+
{
|
|
148
|
+
".3gp",
|
|
149
|
+
".avi",
|
|
150
|
+
".flv",
|
|
151
|
+
".m4v",
|
|
152
|
+
".mkv",
|
|
153
|
+
".mov",
|
|
154
|
+
".mp4",
|
|
155
|
+
".ts",
|
|
156
|
+
".webm",
|
|
157
|
+
".wmv",
|
|
158
|
+
}
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# 单条消息最多处理几个链接。
|
|
162
|
+
_MAX_LINKS_PER_MESSAGE = 3
|
|
163
|
+
|
|
164
|
+
# 临时文件清扫间隔(秒)。
|
|
165
|
+
_SWEEP_INTERVAL_SECONDS = 300
|
|
166
|
+
|
|
167
|
+
# 下载失败重试次数与间隔:CDN 偶发 403 / 超时靠重试能救回来(重试会重新解析,
|
|
168
|
+
# 拿到的是新签名的地址)。
|
|
169
|
+
_DOWNLOAD_ATTEMPTS = 3
|
|
170
|
+
_DOWNLOAD_RETRY_DELAY = 1.5
|
|
171
|
+
|
|
172
|
+
# ParseHub 的错误信息套了好几层「下载错误: 下载失败: …」,发给用户前压平。
|
|
173
|
+
_ERROR_NOISE = ("下载错误", "下载失败", "解析错误", "下载超时")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _short_error(exc: BaseException) -> str:
|
|
177
|
+
"""把 ParseHub 的嵌套错误信息压成一句人话。
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
exc: 捕获到的异常。
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
去掉重复前缀后的错误描述。
|
|
184
|
+
"""
|
|
185
|
+
text = str(exc).strip()
|
|
186
|
+
for word in _ERROR_NOISE:
|
|
187
|
+
text = text.replace(f"{word}: ", "").replace(f"{word}:", "")
|
|
188
|
+
return text.strip() or type(exc).__name__
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# 命令触发时用来跳过自动解析的前缀。
|
|
192
|
+
_COMMAND_PREFIXES = ("解析", "ph", "/解析", "/ph")
|
|
193
|
+
|
|
194
|
+
# 字节换算用的常数。
|
|
195
|
+
_BYTES_PER_MEGABYTE = 1024 * 1024
|
|
196
|
+
|
|
197
|
+
# 去重缓存:同一链接在窗口期内只解析一次。
|
|
198
|
+
DEDUPE = core.DedupeCache()
|
|
199
|
+
|
|
200
|
+
# 按会话(群 / 私聊)记录的解析开关,落在插件数据目录里。
|
|
201
|
+
SWITCHES = switches.SwitchStore(server.data_root() / "sessions.json")
|
|
202
|
+
|
|
203
|
+
# 作品级媒体缓存:命中就直接发本地那份,不解析、不下载、不画卡。
|
|
204
|
+
MEDIA_CACHE = mediacache.MediaCache(
|
|
205
|
+
server.cache_root() / mediacache.CACHE_DIR_NAME,
|
|
206
|
+
ttl_seconds=max(0, plugin_config.parsehub_cache_hours) * 3600,
|
|
207
|
+
max_bytes=max(0, plugin_config.parsehub_cache_max_mb) * _BYTES_PER_MEGABYTE,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# 「解析开关」命令能接受的参数写法。
|
|
211
|
+
_ON_WORDS = frozenset({"开", "开启", "打开", "on", "true", "1"})
|
|
212
|
+
_OFF_WORDS = frozenset({"关", "关闭", "关上", "off", "false", "0"})
|
|
213
|
+
_STATUS_WORDS = frozenset({"状态", "status"})
|
|
214
|
+
|
|
215
|
+
# 视频过长提示里「去 xx 看吧」用的平台名:平台展示名不一定适合念出来。
|
|
216
|
+
_WATCH_NAMES: dict[str, str] = {
|
|
217
|
+
"bilibili": "B 站",
|
|
218
|
+
"twitter": "X",
|
|
219
|
+
"weixin": "微信",
|
|
220
|
+
"wechat": "微信",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class _RelayEndpoint:
|
|
226
|
+
"""媒体中转服务对协议端暴露的地址。
|
|
227
|
+
|
|
228
|
+
Attributes:
|
|
229
|
+
host: 协议端可访问的地址,默认本机回环。
|
|
230
|
+
port: 中转服务端口,启动前为 0。
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
host: str = "127.0.0.1"
|
|
234
|
+
port: int = 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
_ENDPOINT = _RelayEndpoint()
|
|
238
|
+
_SEMAPHORE: asyncio.Semaphore | None = None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@dataclass
|
|
242
|
+
class _SentInfo:
|
|
243
|
+
"""一次「卡片或文字」发送的结果,顺手带上内容好写进媒体缓存。
|
|
244
|
+
|
|
245
|
+
Attributes:
|
|
246
|
+
sent: 是否真的发出去了。
|
|
247
|
+
card: 发出去的卡片图路径,没出卡为 ``None``。
|
|
248
|
+
caption: 发出去的文字,没发文字为空串。
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
sent: bool = False
|
|
252
|
+
card: Path | None = None
|
|
253
|
+
caption: str = ""
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _concurrency_limit() -> asyncio.Semaphore:
|
|
257
|
+
"""返回限制并发任务数的信号量,必要时先创建。
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
进程内共享的信号量,上限取自配置,至少为 1。
|
|
261
|
+
"""
|
|
262
|
+
global _SEMAPHORE
|
|
263
|
+
if _SEMAPHORE is None:
|
|
264
|
+
_SEMAPHORE = asyncio.Semaphore(
|
|
265
|
+
max(1, plugin_config.parsehub_concurrency)
|
|
266
|
+
)
|
|
267
|
+
return _SEMAPHORE
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _size_limit_bytes() -> int:
|
|
271
|
+
"""返回单文件体积上限。
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
上限的字节数,配置为 0 表示不限制。
|
|
275
|
+
"""
|
|
276
|
+
return max(0, plugin_config.parsehub_max_mb) * _BYTES_PER_MEGABYTE
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _excerpt(text: str, limit: int) -> str:
|
|
280
|
+
"""把正文压成单行并截断。
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
text: 原始正文。
|
|
284
|
+
limit: 最大字符数。
|
|
285
|
+
|
|
286
|
+
Returns:
|
|
287
|
+
压缩后的正文,超长时以省略号结尾。
|
|
288
|
+
"""
|
|
289
|
+
collapsed = " ".join((text or "").split())
|
|
290
|
+
if len(collapsed) <= limit:
|
|
291
|
+
return collapsed
|
|
292
|
+
return collapsed[:limit] + "…"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _is_unknown_platform(exc: Exception) -> bool:
|
|
296
|
+
"""判断异常是否表示「平台不支持」。
|
|
297
|
+
|
|
298
|
+
parsehub 的 import 代价很高,这里延迟导入它的异常类型。
|
|
299
|
+
|
|
300
|
+
Args:
|
|
301
|
+
exc: 捕获到的异常。
|
|
302
|
+
|
|
303
|
+
Returns:
|
|
304
|
+
是 UnknownPlatform 时返回 True。
|
|
305
|
+
"""
|
|
306
|
+
try:
|
|
307
|
+
from parsehub.errors import UnknownPlatform
|
|
308
|
+
except ImportError:
|
|
309
|
+
return False
|
|
310
|
+
return isinstance(exc, UnknownPlatform)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _send(
|
|
314
|
+
bot: Bot,
|
|
315
|
+
event: MessageEvent,
|
|
316
|
+
message: Message,
|
|
317
|
+
) -> None:
|
|
318
|
+
"""发送一条消息。
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
bot: 当前 Bot 实例。
|
|
322
|
+
event: 触发本次发送的事件。
|
|
323
|
+
message: 待发送的消息。
|
|
324
|
+
"""
|
|
325
|
+
await bot.send(event, message)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
async def _react(bot: Bot, event: MessageEvent, emoji_id: str) -> None:
|
|
329
|
+
"""给触发消息点一个表情回应。
|
|
330
|
+
|
|
331
|
+
协议端不支持该 API 时只记 debug 日志,不影响主流程。
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
bot: 当前 Bot 实例。
|
|
335
|
+
event: 触发消息所在的事件。
|
|
336
|
+
emoji_id: QQ 表情 ID。
|
|
337
|
+
"""
|
|
338
|
+
try:
|
|
339
|
+
await bot.call_api(
|
|
340
|
+
"set_msg_emoji_like",
|
|
341
|
+
message_id=event.message_id,
|
|
342
|
+
emoji_id=emoji_id,
|
|
343
|
+
set=True,
|
|
344
|
+
)
|
|
345
|
+
except Exception as exc: # noqa: BLE001 - 回应只是锦上添花
|
|
346
|
+
logger.debug(f"[parsehub] 表情回应 {emoji_id} 失败(忽略): {exc}")
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def _send_file(
|
|
350
|
+
bot: Bot,
|
|
351
|
+
event: MessageEvent,
|
|
352
|
+
url: str,
|
|
353
|
+
name: str,
|
|
354
|
+
) -> bool:
|
|
355
|
+
"""以群文件 / 私聊文件的形式上传媒体。
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
bot: 当前 Bot 实例。
|
|
359
|
+
event: 触发消息事件。
|
|
360
|
+
url: 协议端可下载的地址。
|
|
361
|
+
name: 展示用的文件名。
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
上传成功返回 True。
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
if isinstance(event, GroupMessageEvent):
|
|
368
|
+
await bot.call_api(
|
|
369
|
+
"upload_group_file",
|
|
370
|
+
group_id=event.group_id,
|
|
371
|
+
file=url,
|
|
372
|
+
name=name,
|
|
373
|
+
)
|
|
374
|
+
else:
|
|
375
|
+
await bot.call_api(
|
|
376
|
+
"upload_private_file",
|
|
377
|
+
user_id=event.user_id,
|
|
378
|
+
file=url,
|
|
379
|
+
name=name,
|
|
380
|
+
)
|
|
381
|
+
except Exception as exc: # noqa: BLE001 - 协议端可能不支持文件上传
|
|
382
|
+
logger.warning(f"[parsehub] 文件上传失败: {exc}")
|
|
383
|
+
return False
|
|
384
|
+
return True
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def _send_video(
|
|
388
|
+
bot: Bot,
|
|
389
|
+
event: MessageEvent,
|
|
390
|
+
url: str,
|
|
391
|
+
name: str,
|
|
392
|
+
) -> bool:
|
|
393
|
+
"""用 video 消息段发送视频(不占群文件)。
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
bot: 当前 Bot 实例。
|
|
397
|
+
event: 触发消息事件。
|
|
398
|
+
url: 协议端可下载的地址。
|
|
399
|
+
name: 展示用的文件名,仅用于日志。
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
发送成功返回 True。
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
await bot.send(event, MessageSegment.video(file=url))
|
|
406
|
+
except ActionFailed as exc:
|
|
407
|
+
logger.warning(f"[parsehub] video 消息段被拒绝: {exc}")
|
|
408
|
+
return False
|
|
409
|
+
except Exception as exc: # noqa: BLE001 - 协议端行为不确定,统一兜底
|
|
410
|
+
logger.warning(f"[parsehub] video 消息段发送异常: {exc}")
|
|
411
|
+
return False
|
|
412
|
+
return True
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def _send_forward(
|
|
416
|
+
bot: Bot,
|
|
417
|
+
event: MessageEvent,
|
|
418
|
+
files: list[Path],
|
|
419
|
+
) -> bool:
|
|
420
|
+
"""把多个媒体合并成一条转发消息。
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
bot: 当前 Bot 实例。
|
|
424
|
+
event: 触发消息事件。
|
|
425
|
+
files: 待发送的本地文件列表。
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
合并转发成功返回 True;媒体类型不适合转发时返回 False,由调用方改走
|
|
429
|
+
逐条发送。
|
|
430
|
+
"""
|
|
431
|
+
nodes: list[dict] = []
|
|
432
|
+
for path in files:
|
|
433
|
+
suffix = path.suffix.lower()
|
|
434
|
+
url = server.RELAY.url_for(path, _ENDPOINT.host, _ENDPOINT.port)
|
|
435
|
+
if suffix in _IMAGE_EXTENSIONS:
|
|
436
|
+
content = {"type": "image", "data": {"file": url}}
|
|
437
|
+
elif suffix in _VIDEO_EXTENSIONS:
|
|
438
|
+
content = {"type": "video", "data": {"file": url}}
|
|
439
|
+
else:
|
|
440
|
+
return False
|
|
441
|
+
nodes.append(
|
|
442
|
+
{
|
|
443
|
+
"type": "node",
|
|
444
|
+
"data": {
|
|
445
|
+
"uin": str(bot.self_id),
|
|
446
|
+
"name": _forward_node_name(),
|
|
447
|
+
"content": [content],
|
|
448
|
+
},
|
|
449
|
+
}
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
timeout = max(1, plugin_config.parsehub_forward_timeout)
|
|
453
|
+
started = time.monotonic()
|
|
454
|
+
try:
|
|
455
|
+
if isinstance(event, GroupMessageEvent):
|
|
456
|
+
await bot.call_api(
|
|
457
|
+
"send_group_forward_msg",
|
|
458
|
+
group_id=event.group_id,
|
|
459
|
+
messages=nodes,
|
|
460
|
+
_timeout=timeout,
|
|
461
|
+
)
|
|
462
|
+
else:
|
|
463
|
+
await bot.call_api(
|
|
464
|
+
"send_private_forward_msg",
|
|
465
|
+
user_id=event.user_id,
|
|
466
|
+
messages=nodes,
|
|
467
|
+
_timeout=timeout,
|
|
468
|
+
)
|
|
469
|
+
except Exception as exc: # noqa: BLE001 - 协议端可能不支持合并转发
|
|
470
|
+
logger.warning(
|
|
471
|
+
f"[parsehub] 合并转发失败(等了 {time.monotonic() - started:.1f}s,"
|
|
472
|
+
f"上限 {timeout}s),改逐条发送: {exc}"
|
|
473
|
+
)
|
|
474
|
+
return False
|
|
475
|
+
|
|
476
|
+
logger.info(
|
|
477
|
+
f"[parsehub] 已合并转发 {len(nodes)} 条"
|
|
478
|
+
f"(耗时 {time.monotonic() - started:.1f}s)"
|
|
479
|
+
)
|
|
480
|
+
return True
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
async def _send_media_files(
|
|
484
|
+
bot: Bot,
|
|
485
|
+
event: MessageEvent,
|
|
486
|
+
files: list[Path],
|
|
487
|
+
) -> None:
|
|
488
|
+
"""发送解析出的媒体文件。
|
|
489
|
+
|
|
490
|
+
超过体积上限的文件直接跳过;多于一个文件时优先合并转发,失败或类型不合适
|
|
491
|
+
时退回逐条发送。发送结束后用表情回应表示成功或失败。
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
bot: 当前 Bot 实例。
|
|
495
|
+
event: 触发消息事件。
|
|
496
|
+
files: 待发送的本地文件列表。
|
|
497
|
+
"""
|
|
498
|
+
limit = _size_limit_bytes()
|
|
499
|
+
items: list[Path] = []
|
|
500
|
+
for path in files:
|
|
501
|
+
try:
|
|
502
|
+
size = path.stat().st_size
|
|
503
|
+
except OSError:
|
|
504
|
+
continue
|
|
505
|
+
if limit and size > limit:
|
|
506
|
+
await _send(
|
|
507
|
+
bot,
|
|
508
|
+
event,
|
|
509
|
+
Message(
|
|
510
|
+
f"({path.name} 约 {size / _BYTES_PER_MEGABYTE:.0f}MB,"
|
|
511
|
+
"超过上限,已跳过)"
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
continue
|
|
515
|
+
items.append(path)
|
|
516
|
+
|
|
517
|
+
if not items:
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
if len(items) > 1 and await _send_forward(bot, event, items):
|
|
521
|
+
await _react(bot, event, REACT_OK)
|
|
522
|
+
return
|
|
523
|
+
|
|
524
|
+
failed = False
|
|
525
|
+
for path in items:
|
|
526
|
+
url = server.RELAY.url_for(path, _ENDPOINT.host, _ENDPOINT.port)
|
|
527
|
+
suffix = path.suffix.lower()
|
|
528
|
+
if suffix in _IMAGE_EXTENSIONS:
|
|
529
|
+
await bot.send(event, MessageSegment.image(file=url))
|
|
530
|
+
elif suffix in _VIDEO_EXTENSIONS:
|
|
531
|
+
if await _send_video(bot, event, url, path.name):
|
|
532
|
+
continue
|
|
533
|
+
failed = True
|
|
534
|
+
if plugin_config.parsehub_fail_notice:
|
|
535
|
+
await _send(
|
|
536
|
+
bot,
|
|
537
|
+
event,
|
|
538
|
+
Message("⚠️ 视频发送失败(协议端拒绝了 video 消息段)"),
|
|
539
|
+
)
|
|
540
|
+
elif not await _send_file(bot, event, url, path.name):
|
|
541
|
+
failed = True
|
|
542
|
+
if plugin_config.parsehub_fail_notice:
|
|
543
|
+
await _send(bot, event, Message("⚠️ 文件发送失败"))
|
|
544
|
+
|
|
545
|
+
await _react(bot, event, REACT_FAIL if failed else REACT_OK)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
async def _collect_local_files(result: object) -> list[Path]:
|
|
549
|
+
"""把下载结果里的本地文件收集成列表。
|
|
550
|
+
|
|
551
|
+
实况照片会同时产出图片和一段短视频,两个都要发,否则实况照片会退化成静态图。
|
|
552
|
+
|
|
553
|
+
Args:
|
|
554
|
+
result: ParseHub 的 DownloadResult。
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
本地文件路径列表,可能为空。
|
|
558
|
+
"""
|
|
559
|
+
media = getattr(result, "media", None)
|
|
560
|
+
media_list = list(media) if isinstance(media, (list, tuple)) else [media]
|
|
561
|
+
|
|
562
|
+
files: list[Path] = []
|
|
563
|
+
live_photos = 0
|
|
564
|
+
for item in media_list:
|
|
565
|
+
path = getattr(item, "path", None)
|
|
566
|
+
if path:
|
|
567
|
+
files.append(Path(path))
|
|
568
|
+
video_path = getattr(item, "video_path", None)
|
|
569
|
+
if video_path:
|
|
570
|
+
files.append(Path(video_path))
|
|
571
|
+
live_photos += 1
|
|
572
|
+
|
|
573
|
+
if live_photos:
|
|
574
|
+
logger.info(
|
|
575
|
+
f"[parsehub] 含 {live_photos} 张实况照片,图片 + 视频一起发"
|
|
576
|
+
)
|
|
577
|
+
return files
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
async def handle_link(
|
|
581
|
+
bot: Bot,
|
|
582
|
+
event: MessageEvent,
|
|
583
|
+
url: str,
|
|
584
|
+
*,
|
|
585
|
+
manual: bool = False,
|
|
586
|
+
) -> None:
|
|
587
|
+
"""解析一个链接并把结果发出去。
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
bot: 当前 Bot 实例。
|
|
591
|
+
event: 触发消息事件。
|
|
592
|
+
url: 待解析的链接。
|
|
593
|
+
manual: 是否为命令触发;为 True 时无论成败都会给用户反馈。
|
|
594
|
+
"""
|
|
595
|
+
cfg = plugin_config
|
|
596
|
+
# 收到链接就先回应,不等解析结果,也不等并发信号量。
|
|
597
|
+
await _react(bot, event, REACT_START)
|
|
598
|
+
|
|
599
|
+
async with _concurrency_limit():
|
|
600
|
+
await _parse_and_send(bot, event, url, manual=manual, cfg=cfg)
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
async def _parse_and_send(
|
|
604
|
+
bot: Bot,
|
|
605
|
+
event: MessageEvent,
|
|
606
|
+
url: str,
|
|
607
|
+
*,
|
|
608
|
+
manual: bool,
|
|
609
|
+
cfg: Config,
|
|
610
|
+
) -> None:
|
|
611
|
+
"""解析链接并发送文字与媒体(并发限制已由调用方持有)。
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
bot: 当前 Bot 实例。
|
|
615
|
+
event: 触发消息事件。
|
|
616
|
+
url: 待解析的链接。
|
|
617
|
+
manual: 是否为命令触发。
|
|
618
|
+
cfg: 当前插件配置。
|
|
619
|
+
"""
|
|
620
|
+
session = _session_key(event)
|
|
621
|
+
window = max(0, cfg.parsehub_dedupe_seconds)
|
|
622
|
+
pre_key = core.content_key(url)
|
|
623
|
+
|
|
624
|
+
# 去重是「这个会话里刚发过就别再刷」的规则,必须挡在缓存前面:
|
|
625
|
+
# 否则命中缓存会把同一个作品一次次重新发出去。
|
|
626
|
+
if not manual:
|
|
627
|
+
if DEDUPE.seen(session, pre_key, window):
|
|
628
|
+
logger.info(f"[parsehub] {pre_key} 在本会话刚发过,跳过")
|
|
629
|
+
return
|
|
630
|
+
DEDUPE.mark(session, pre_key)
|
|
631
|
+
|
|
632
|
+
# 本地已经有这个作品的媒体就直接发,省掉解析、下载和画卡。
|
|
633
|
+
# 例外:缓存里记着这个作品超过时长上限(可能是加限制之前存进去的),那就
|
|
634
|
+
# 照常走解析,让后面的时长判断去发封面 + 提示,别从缓存里把长片发出去。
|
|
635
|
+
cached = MEDIA_CACHE.get(pre_key)
|
|
636
|
+
if cached is not None and not _cached_over_limit(cached, cfg):
|
|
637
|
+
_mark_cached_keys(session, cached, pre_key)
|
|
638
|
+
await _send_cached(bot, event, cached, cfg)
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
if not await core.CLIENT.is_supported(url):
|
|
642
|
+
if manual:
|
|
643
|
+
await _send(bot, event, Message("这个链接 ParseHub 不支持。"))
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
platform = await core.CLIENT.platform_id(url) or ""
|
|
647
|
+
proxy = core.proxy_for(platform)
|
|
648
|
+
cookie = core.platform_cookie(platform, cfg.parsehub_cookie)
|
|
649
|
+
logger.debug(f"[parsehub] {platform} 代理={proxy or '直连'}")
|
|
650
|
+
|
|
651
|
+
try:
|
|
652
|
+
result = await asyncio.wait_for(
|
|
653
|
+
core.CLIENT.parse(url, proxy, cookie),
|
|
654
|
+
cfg.parsehub_timeout,
|
|
655
|
+
)
|
|
656
|
+
except TimeoutError:
|
|
657
|
+
if manual:
|
|
658
|
+
await _send(bot, event, Message("解析超时,稍后再试。"))
|
|
659
|
+
return
|
|
660
|
+
except Exception as exc: # noqa: BLE001 - parsehub 异常类型很多,统一兜底
|
|
661
|
+
if _is_unknown_platform(exc):
|
|
662
|
+
if manual:
|
|
663
|
+
await _send(bot, event, Message("这个链接 ParseHub 不支持。"))
|
|
664
|
+
return
|
|
665
|
+
logger.warning(
|
|
666
|
+
f"[parsehub] 解析失败 {url}: {type(exc).__name__}: {exc}"
|
|
667
|
+
)
|
|
668
|
+
if manual:
|
|
669
|
+
await _send(bot, event, Message(f"解析失败:{exc}"))
|
|
670
|
+
return
|
|
671
|
+
|
|
672
|
+
display_name = (
|
|
673
|
+
result.platform.display_name if result.platform else "未知平台"
|
|
674
|
+
)
|
|
675
|
+
title = result.title or "(无标题)"
|
|
676
|
+
logger.info(f"[parsehub] {display_name} 解析成功: {url} -> {title}")
|
|
677
|
+
|
|
678
|
+
# 解析结果里的 raw_url 是短链展开后的地址,用它再查一次去重:同一个作品换个
|
|
679
|
+
# 写法(b23.tv/xxx 与 bilibili.com/video/BVxxx)在同一个会话里也只发一次。
|
|
680
|
+
# 手动「解析」是明确要求,不做这层拦截。
|
|
681
|
+
post_key = core.content_key(getattr(result, "raw_url", "") or url)
|
|
682
|
+
# 完整链的 pre_key 就等于 post_key,再查一次会把上面自己记的那条当成重复,
|
|
683
|
+
# 所以只有两者不同(短链展开后变了)才需要再查。
|
|
684
|
+
if not manual and post_key != pre_key:
|
|
685
|
+
if DEDUPE.seen(session, post_key, window):
|
|
686
|
+
logger.info(f"[parsehub] {post_key} 在本会话刚发过,跳过发送")
|
|
687
|
+
return
|
|
688
|
+
DEDUPE.mark(session, post_key)
|
|
689
|
+
|
|
690
|
+
# 视频太长就只给封面 + 一句提示,别为几百 MB 的长片付下载和发送的代价。
|
|
691
|
+
# 判断放在缓存查询之前:缓存里可能存着加限制之前的旧条目,不该因此绕过限制。
|
|
692
|
+
max_seconds = max(0, cfg.parsehub_max_seconds)
|
|
693
|
+
seconds = card.video_seconds(result) if max_seconds else 0
|
|
694
|
+
if max_seconds and seconds > max_seconds:
|
|
695
|
+
logger.info(
|
|
696
|
+
f"[parsehub] 视频 {card.format_duration(seconds)} 超过时长上限 "
|
|
697
|
+
f"{card.format_duration(max_seconds)},只发封面"
|
|
698
|
+
)
|
|
699
|
+
await _send_card_or_text(
|
|
700
|
+
bot, event, result, display_name, [], None, cfg
|
|
701
|
+
)
|
|
702
|
+
await _send(
|
|
703
|
+
bot,
|
|
704
|
+
event,
|
|
705
|
+
Message(
|
|
706
|
+
f"视频太长啦,还是去{_watch_name(result, display_name)}看吧"
|
|
707
|
+
),
|
|
708
|
+
)
|
|
709
|
+
await _react(bot, event, REACT_OK)
|
|
710
|
+
return
|
|
711
|
+
|
|
712
|
+
# 短链没命中缓存(缓存里记的是展开后的 key),解析完再查一次:命中就省掉下载
|
|
713
|
+
cached = MEDIA_CACHE.get(post_key)
|
|
714
|
+
if cached is not None:
|
|
715
|
+
_mark_cached_keys(session, cached, post_key)
|
|
716
|
+
await _send_cached(bot, event, cached, cfg)
|
|
717
|
+
return
|
|
718
|
+
|
|
719
|
+
if not result.media:
|
|
720
|
+
# 纯文字帖(比如 B 站动态):没有媒体,只发卡片或文字
|
|
721
|
+
await _send_card_or_text(
|
|
722
|
+
bot, event, result, display_name, [], None, cfg
|
|
723
|
+
)
|
|
724
|
+
return
|
|
725
|
+
|
|
726
|
+
media_list = (
|
|
727
|
+
list(result.media)
|
|
728
|
+
if isinstance(result.media, (list, tuple))
|
|
729
|
+
else [result.media]
|
|
730
|
+
)
|
|
731
|
+
if len(media_list) == 1:
|
|
732
|
+
oversized = await _oversized_size(media_list[0], proxy)
|
|
733
|
+
if oversized:
|
|
734
|
+
await _send(
|
|
735
|
+
bot,
|
|
736
|
+
event,
|
|
737
|
+
Message(
|
|
738
|
+
f"文件约 {oversized / _BYTES_PER_MEGABYTE:.0f}MB,"
|
|
739
|
+
"超过上限,已跳过。"
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
return
|
|
743
|
+
|
|
744
|
+
# 先出卡:thumb_url / 图片媒体通常够用,不必等下载。此时不发文字兜底,
|
|
745
|
+
# 免得下载抽帧那一步白做
|
|
746
|
+
info = await _send_card_or_text(
|
|
747
|
+
bot, event, result, display_name, [], None, cfg, allow_text=False
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
downloaded = await _download_task(bot, event, url, proxy, cookie, cfg)
|
|
751
|
+
if downloaded is None:
|
|
752
|
+
return
|
|
753
|
+
task_dir, files = downloaded
|
|
754
|
+
|
|
755
|
+
# 上面没出成卡(封面得从视频里抽帧)就带上下载好的文件再试一次
|
|
756
|
+
if not info.sent:
|
|
757
|
+
info = await _send_card_or_text(
|
|
758
|
+
bot, event, result, display_name, files, task_dir, cfg
|
|
759
|
+
)
|
|
760
|
+
if files:
|
|
761
|
+
await _send_media_files(bot, event, files)
|
|
762
|
+
logger.info(
|
|
763
|
+
f"[parsehub] 已发送 {len(files)} 个文件,"
|
|
764
|
+
f"临时目录 {task_dir}(稍后自动清理)"
|
|
765
|
+
)
|
|
766
|
+
# 存两份 key:短链那份让同一个短链下次直接命中,展开那份让别的写法也命中
|
|
767
|
+
MEDIA_CACHE.store(
|
|
768
|
+
(pre_key, post_key),
|
|
769
|
+
files,
|
|
770
|
+
card=info.card,
|
|
771
|
+
caption=info.caption,
|
|
772
|
+
seconds=card.video_seconds(result),
|
|
773
|
+
)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _cached_over_limit(entry: Path, cfg: Config) -> bool:
|
|
777
|
+
"""缓存条目里记的时长是否超过上限。
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
entry: 缓存条目目录。
|
|
781
|
+
cfg: 当前插件配置。
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
超过上限返回 True;没开时长限制或没记录时长时为 False。
|
|
785
|
+
"""
|
|
786
|
+
limit = max(0, cfg.parsehub_max_seconds)
|
|
787
|
+
if not limit:
|
|
788
|
+
return False
|
|
789
|
+
return mediacache.MediaCache.entry_seconds(entry) > limit
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _watch_name(result: object, display_name: str) -> str:
|
|
793
|
+
"""拼「还是去 xx 看吧」这句提示用的平台名。
|
|
794
|
+
|
|
795
|
+
Args:
|
|
796
|
+
result: ParseHub 解析结果。
|
|
797
|
+
display_name: 平台展示名,作为兜底。
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
适合念出来的平台名,例如 ``B 站``。
|
|
801
|
+
"""
|
|
802
|
+
platform_id = str(
|
|
803
|
+
getattr(getattr(result, "platform", None), "id", "")
|
|
804
|
+
).lower()
|
|
805
|
+
return _WATCH_NAMES.get(platform_id) or display_name or "原平台"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _build_caption(result: object, display_name: str, cfg: Config) -> Message:
|
|
809
|
+
"""拼装解析结果的文字消息(不含原始链接)。
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
result: ParseHub 的解析结果。
|
|
813
|
+
display_name: 平台中文名。
|
|
814
|
+
cfg: 当前插件配置。
|
|
815
|
+
|
|
816
|
+
Returns:
|
|
817
|
+
待发送的文字消息。
|
|
818
|
+
"""
|
|
819
|
+
lines = [f"📦 {display_name}"]
|
|
820
|
+
title = getattr(result, "title", "")
|
|
821
|
+
content = getattr(result, "content", "")
|
|
822
|
+
if title:
|
|
823
|
+
lines.append(title)
|
|
824
|
+
if content:
|
|
825
|
+
lines.append(_excerpt(content, cfg.parsehub_max_chars))
|
|
826
|
+
return Message("\n".join(lines))
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
async def _oversized_size(media: object, proxy: str) -> int | None:
|
|
830
|
+
"""在下载前探测单媒体体积,判断是否超过上限。
|
|
831
|
+
|
|
832
|
+
Args:
|
|
833
|
+
media: ParseHub 的媒体引用,需要有 ``url`` 属性。
|
|
834
|
+
proxy: 探测体积时使用的代理。
|
|
835
|
+
|
|
836
|
+
Returns:
|
|
837
|
+
超过上限时返回其字节数;没超过或探测不到时返回 None。
|
|
838
|
+
"""
|
|
839
|
+
limit = _size_limit_bytes()
|
|
840
|
+
if not limit:
|
|
841
|
+
return None
|
|
842
|
+
|
|
843
|
+
size = await core.probe_size(getattr(media, "url", "") or "", proxy=proxy)
|
|
844
|
+
if not size or size <= limit:
|
|
845
|
+
return None
|
|
846
|
+
|
|
847
|
+
logger.info(
|
|
848
|
+
f"[parsehub] 媒体约 {size / _BYTES_PER_MEGABYTE:.0f}MB,超过上限"
|
|
849
|
+
)
|
|
850
|
+
return size
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
async def _download_task(
|
|
854
|
+
bot: Bot,
|
|
855
|
+
event: MessageEvent,
|
|
856
|
+
url: str,
|
|
857
|
+
proxy: str,
|
|
858
|
+
cookie: str,
|
|
859
|
+
cfg: Config,
|
|
860
|
+
) -> tuple[Path, list[Path]] | None:
|
|
861
|
+
"""下载媒体到新的任务目录。
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
bot: 当前 Bot 实例。
|
|
865
|
+
event: 触发消息事件。
|
|
866
|
+
url: 待解析的链接。
|
|
867
|
+
proxy: 解析与下载代理。
|
|
868
|
+
cookie: 登录态 cookie。
|
|
869
|
+
cfg: 当前插件配置。
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
``(任务目录, 本地文件列表)``;下载失败时为 ``None``。
|
|
873
|
+
"""
|
|
874
|
+
# 平台 CDN 偶发 403 / 超时(签名地址过期、冷节点、限流),重试会重新解析拿到
|
|
875
|
+
# 新地址,多数情况下就过了。
|
|
876
|
+
for attempt in range(1, _DOWNLOAD_ATTEMPTS + 1):
|
|
877
|
+
task_dir = server.RELAY.new_task_dir()
|
|
878
|
+
try:
|
|
879
|
+
downloaded = await asyncio.wait_for(
|
|
880
|
+
core.CLIENT.download(url, str(task_dir), proxy, cookie),
|
|
881
|
+
cfg.parsehub_timeout,
|
|
882
|
+
)
|
|
883
|
+
except Exception as exc: # noqa: BLE001 - 下载失败原因很多,统一兜底
|
|
884
|
+
logger.warning(
|
|
885
|
+
f"[parsehub] 下载失败(第 {attempt}/{_DOWNLOAD_ATTEMPTS} 次)"
|
|
886
|
+
f" {url}: {type(exc).__name__}: {exc}"
|
|
887
|
+
)
|
|
888
|
+
if attempt < _DOWNLOAD_ATTEMPTS:
|
|
889
|
+
await asyncio.sleep(_DOWNLOAD_RETRY_DELAY)
|
|
890
|
+
continue
|
|
891
|
+
await _send(bot, event, Message(f"下载失败:{_short_error(exc)}"))
|
|
892
|
+
return None
|
|
893
|
+
return task_dir, await _collect_local_files(downloaded)
|
|
894
|
+
return None
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
async def _render_card(
|
|
898
|
+
result: object,
|
|
899
|
+
files: list[Path],
|
|
900
|
+
task_dir: Path | None,
|
|
901
|
+
cfg: Config,
|
|
902
|
+
) -> Path | None:
|
|
903
|
+
"""渲染卡片图(阻塞活走线程池)。
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
result: ParseHub 解析结果。
|
|
907
|
+
files: 已下载的本地媒体,抽帧兜底要用。
|
|
908
|
+
task_dir: 任务目录;为 ``None`` 时另建一个。
|
|
909
|
+
cfg: 当前插件配置。
|
|
910
|
+
|
|
911
|
+
Returns:
|
|
912
|
+
卡片图片路径;不可用时为 ``None``(调用方退回文字)。
|
|
913
|
+
"""
|
|
914
|
+
try:
|
|
915
|
+
font = await asyncio.to_thread(card.ensure_font, server.cache_root())
|
|
916
|
+
if font is None:
|
|
917
|
+
return None
|
|
918
|
+
cover = await asyncio.to_thread(card.fetch_cover, result, files)
|
|
919
|
+
if cover is None:
|
|
920
|
+
logger.info("[parsehub] 没有可用封面,卡片跳过")
|
|
921
|
+
return None
|
|
922
|
+
target_dir = task_dir or server.RELAY.new_task_dir()
|
|
923
|
+
out_path = target_dir / "card.jpg"
|
|
924
|
+
await asyncio.to_thread(card.render, result, cover, out_path)
|
|
925
|
+
except Exception as exc: # noqa: BLE001 - 渲染失败不能让解析白做
|
|
926
|
+
logger.warning(f"[parsehub] 卡片渲染失败: {type(exc).__name__}: {exc}")
|
|
927
|
+
return None
|
|
928
|
+
return out_path
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
async def _send_card_or_text(
|
|
932
|
+
bot: Bot,
|
|
933
|
+
event: MessageEvent,
|
|
934
|
+
result: object,
|
|
935
|
+
display_name: str,
|
|
936
|
+
files: list[Path],
|
|
937
|
+
task_dir: Path | None,
|
|
938
|
+
cfg: Config,
|
|
939
|
+
*,
|
|
940
|
+
allow_text: bool = True,
|
|
941
|
+
) -> _SentInfo:
|
|
942
|
+
"""优先发卡片图,卡片不可用时退回文字。
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
bot: 当前 Bot 实例。
|
|
946
|
+
event: 触发消息事件。
|
|
947
|
+
result: ParseHub 解析结果。
|
|
948
|
+
display_name: 平台中文名。
|
|
949
|
+
files: 已下载的本地媒体。
|
|
950
|
+
task_dir: 任务目录。
|
|
951
|
+
cfg: 当前插件配置。
|
|
952
|
+
allow_text: 出不了卡时是否立刻退回文字。下载前那次调用要传 ``False``,
|
|
953
|
+
否则文字先发了,等下载完再抽帧出卡就成了重复消息。
|
|
954
|
+
|
|
955
|
+
Returns:
|
|
956
|
+
发出去了什么(卡片路径 / 文字),用来写媒体缓存。
|
|
957
|
+
"""
|
|
958
|
+
if cfg.parsehub_card:
|
|
959
|
+
out_path = await _render_card(result, files, task_dir, cfg)
|
|
960
|
+
if out_path is not None:
|
|
961
|
+
url = server.RELAY.url_for(out_path, _ENDPOINT.host, _ENDPOINT.port)
|
|
962
|
+
try:
|
|
963
|
+
await bot.send(event, MessageSegment.image(file=url))
|
|
964
|
+
return _SentInfo(sent=True, card=out_path)
|
|
965
|
+
except Exception as exc: # noqa: BLE001 - 发图失败就退回文字
|
|
966
|
+
logger.warning(
|
|
967
|
+
f"[parsehub] 卡片发送失败: {type(exc).__name__}: {exc}"
|
|
968
|
+
)
|
|
969
|
+
if allow_text and cfg.parsehub_send_text:
|
|
970
|
+
caption = _build_caption(result, display_name, cfg)
|
|
971
|
+
await _send(bot, event, caption)
|
|
972
|
+
return _SentInfo(sent=True, caption=caption.extract_plain_text())
|
|
973
|
+
return _SentInfo()
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
async def _send_cached(
|
|
977
|
+
bot: Bot,
|
|
978
|
+
event: MessageEvent,
|
|
979
|
+
entry: Path,
|
|
980
|
+
cfg: Config,
|
|
981
|
+
) -> bool:
|
|
982
|
+
"""把缓存里的卡片 / 文字 + 媒体直接发出去。
|
|
983
|
+
|
|
984
|
+
缓存目录就在中转服务的根目录下,所以协议端能直接来拉,不用再复制一份。
|
|
985
|
+
|
|
986
|
+
Args:
|
|
987
|
+
bot: 当前 Bot 实例。
|
|
988
|
+
event: 触发消息事件。
|
|
989
|
+
entry: 缓存条目目录。
|
|
990
|
+
cfg: 当前插件配置。
|
|
991
|
+
|
|
992
|
+
Returns:
|
|
993
|
+
发出去了为 ``True``。
|
|
994
|
+
"""
|
|
995
|
+
files = mediacache.MediaCache.media_files(entry)
|
|
996
|
+
if not files:
|
|
997
|
+
return False
|
|
998
|
+
|
|
999
|
+
card = mediacache.MediaCache.card_path(entry)
|
|
1000
|
+
if cfg.parsehub_card and card is not None:
|
|
1001
|
+
url = server.RELAY.url_for(card, _ENDPOINT.host, _ENDPOINT.port)
|
|
1002
|
+
try:
|
|
1003
|
+
await bot.send(event, MessageSegment.image(file=url))
|
|
1004
|
+
except Exception as exc: # noqa: BLE001 - 发图失败就退回文字
|
|
1005
|
+
logger.warning(
|
|
1006
|
+
f"[parsehub] 缓存卡片发送失败: {type(exc).__name__}: {exc}"
|
|
1007
|
+
)
|
|
1008
|
+
card = None
|
|
1009
|
+
if card is None and cfg.parsehub_send_text:
|
|
1010
|
+
caption = mediacache.MediaCache.caption_path(entry)
|
|
1011
|
+
if caption is not None:
|
|
1012
|
+
try:
|
|
1013
|
+
await _send(bot, event, Message(caption.read_text("utf-8")))
|
|
1014
|
+
except OSError as exc:
|
|
1015
|
+
logger.warning(f"[parsehub] 缓存文字读不出来: {exc}")
|
|
1016
|
+
|
|
1017
|
+
logger.info(
|
|
1018
|
+
f"[parsehub] 命中媒体缓存,直接发送 {len(files)} 个文件({entry.name})"
|
|
1019
|
+
)
|
|
1020
|
+
await _send_media_files(bot, event, files)
|
|
1021
|
+
return True
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
def _mark_cached_keys(session: str, entry: Path, key: str) -> None:
|
|
1025
|
+
"""命中缓存时,把这个条目的所有作品 key 都记进本会话的去重表。
|
|
1026
|
+
|
|
1027
|
+
否则「短链命中缓存」只会记下短链那份 key,同一个作品换成完整链再发就漏网了。
|
|
1028
|
+
|
|
1029
|
+
Args:
|
|
1030
|
+
session: 会话标识(群 / 私聊)。
|
|
1031
|
+
entry: 缓存条目目录。
|
|
1032
|
+
key: 这次是用哪个 key 命中的(顺手补记进条目)。
|
|
1033
|
+
"""
|
|
1034
|
+
mediacache.MediaCache.remember_key(entry, key)
|
|
1035
|
+
for item in mediacache.MediaCache.entry_keys(entry):
|
|
1036
|
+
DEDUPE.mark(session, item)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _session_key(event: MessageEvent) -> str:
|
|
1040
|
+
"""取这条消息所属的会话键。
|
|
1041
|
+
|
|
1042
|
+
Args:
|
|
1043
|
+
event: 消息事件。
|
|
1044
|
+
|
|
1045
|
+
Returns:
|
|
1046
|
+
:mod:`switches` 里的会话键(群按群号,私聊按对方 QQ)。
|
|
1047
|
+
"""
|
|
1048
|
+
if isinstance(event, GroupMessageEvent):
|
|
1049
|
+
return switches.group_key(event.group_id)
|
|
1050
|
+
return switches.private_key(int(event.get_user_id()))
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _session_name(event: MessageEvent) -> str:
|
|
1054
|
+
"""给用户看的会话称呼。
|
|
1055
|
+
|
|
1056
|
+
Args:
|
|
1057
|
+
event: 消息事件。
|
|
1058
|
+
|
|
1059
|
+
Returns:
|
|
1060
|
+
``本群`` 或 ``本会话``。
|
|
1061
|
+
"""
|
|
1062
|
+
return "本群" if isinstance(event, GroupMessageEvent) else "本会话"
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _is_superuser(event: MessageEvent) -> bool:
|
|
1066
|
+
"""发消息的人是不是 NoneBot 超管。
|
|
1067
|
+
|
|
1068
|
+
Args:
|
|
1069
|
+
event: 消息事件。
|
|
1070
|
+
|
|
1071
|
+
Returns:
|
|
1072
|
+
在 ``SUPERUSERS`` 名单里为 ``True``。
|
|
1073
|
+
"""
|
|
1074
|
+
return event.get_user_id() in get_driver().config.superusers
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _is_group_admin(event: MessageEvent) -> bool:
|
|
1078
|
+
"""发消息的人是不是本群群主或管理员。
|
|
1079
|
+
|
|
1080
|
+
Args:
|
|
1081
|
+
event: 消息事件。
|
|
1082
|
+
|
|
1083
|
+
Returns:
|
|
1084
|
+
群主 / 管理员为 ``True``;私聊永远是 ``False``。
|
|
1085
|
+
"""
|
|
1086
|
+
return isinstance(event, GroupMessageEvent) and event.sender.role in (
|
|
1087
|
+
"owner",
|
|
1088
|
+
"admin",
|
|
1089
|
+
)
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def _can_control(event: MessageEvent) -> bool:
|
|
1093
|
+
"""有没有权限操作解析开关。
|
|
1094
|
+
|
|
1095
|
+
Args:
|
|
1096
|
+
event: 消息事件。
|
|
1097
|
+
|
|
1098
|
+
Returns:
|
|
1099
|
+
超管或群管理为 ``True``。
|
|
1100
|
+
"""
|
|
1101
|
+
return _is_superuser(event) or _is_group_admin(event)
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
async def _auto_rule(event: MessageEvent) -> bool:
|
|
1105
|
+
"""判断是否应该自动解析这条消息。
|
|
1106
|
+
|
|
1107
|
+
Args:
|
|
1108
|
+
event: 收到的消息事件。
|
|
1109
|
+
|
|
1110
|
+
Returns:
|
|
1111
|
+
需要自动解析时返回 True。
|
|
1112
|
+
"""
|
|
1113
|
+
if not plugin_config.parsehub_auto:
|
|
1114
|
+
return False
|
|
1115
|
+
if not SWITCHES.is_on(_session_key(event)):
|
|
1116
|
+
return False # 这个会话被关掉了(私聊默认就是关的,要超管「解析开」)
|
|
1117
|
+
|
|
1118
|
+
text = event.get_plaintext()
|
|
1119
|
+
if text.lstrip().startswith(_COMMAND_PREFIXES):
|
|
1120
|
+
return False # 命令触发交给命令匹配器
|
|
1121
|
+
return bool(core.extract_urls(text))
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
auto_matcher = on_message(rule=Rule(_auto_rule), priority=20, block=False)
|
|
1125
|
+
|
|
1126
|
+
parse_cmd = on_command(
|
|
1127
|
+
"解析",
|
|
1128
|
+
aliases={"ph", "解析链接"},
|
|
1129
|
+
priority=10,
|
|
1130
|
+
block=True,
|
|
1131
|
+
)
|
|
1132
|
+
platform_cmd = on_command(
|
|
1133
|
+
"解析平台",
|
|
1134
|
+
aliases={"ph平台", "解析支持"},
|
|
1135
|
+
priority=10,
|
|
1136
|
+
block=True,
|
|
1137
|
+
)
|
|
1138
|
+
switch_cmd = on_command(
|
|
1139
|
+
"解析开关",
|
|
1140
|
+
aliases={"解析开", "解析关", "解析状态"},
|
|
1141
|
+
priority=10,
|
|
1142
|
+
block=True,
|
|
1143
|
+
)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
@parse_cmd.handle()
|
|
1147
|
+
async def _handle_parse_cmd(
|
|
1148
|
+
bot: Bot,
|
|
1149
|
+
event: MessageEvent,
|
|
1150
|
+
args: Message = CommandArg(),
|
|
1151
|
+
) -> None:
|
|
1152
|
+
"""处理 ``解析 <链接>`` 命令。
|
|
1153
|
+
|
|
1154
|
+
Args:
|
|
1155
|
+
bot: 当前 Bot 实例。
|
|
1156
|
+
event: 触发消息事件。
|
|
1157
|
+
args: 命令后面的参数。
|
|
1158
|
+
"""
|
|
1159
|
+
if not SWITCHES.is_on(_session_key(event)) and not _can_control(event):
|
|
1160
|
+
await parse_cmd.finish(
|
|
1161
|
+
f"{_session_name(event)}的解析已关闭,"
|
|
1162
|
+
"只有超管或群管理员能手动解析。"
|
|
1163
|
+
)
|
|
1164
|
+
text = args.extract_plain_text().strip() or event.get_plaintext()
|
|
1165
|
+
urls = core.extract_urls(text)
|
|
1166
|
+
if not urls:
|
|
1167
|
+
await parse_cmd.finish("用法:解析 <链接或分享文案>")
|
|
1168
|
+
for url in urls[:_MAX_LINKS_PER_MESSAGE]:
|
|
1169
|
+
await handle_link(bot, event, url, manual=True)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
@platform_cmd.handle()
|
|
1173
|
+
async def _handle_platform_cmd() -> None:
|
|
1174
|
+
"""处理 ``解析平台`` 命令,列出 ParseHub 支持的平台。"""
|
|
1175
|
+
platforms = await core.CLIENT.platforms()
|
|
1176
|
+
if not platforms:
|
|
1177
|
+
await platform_cmd.finish("ParseHub 没有加载到任何平台。")
|
|
1178
|
+
|
|
1179
|
+
names = "、".join(f"{item['name']}({item['id']})" for item in platforms)
|
|
1180
|
+
await platform_cmd.finish(
|
|
1181
|
+
f"ParseHub 支持 {len(platforms)} 个平台:\n{names}"
|
|
1182
|
+
)
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
@switch_cmd.handle()
|
|
1186
|
+
async def _handle_switch_cmd(
|
|
1187
|
+
event: MessageEvent,
|
|
1188
|
+
args: Message = CommandArg(),
|
|
1189
|
+
) -> None:
|
|
1190
|
+
"""处理 ``解析开关`` / ``解析开`` / ``解析关`` / ``解析状态``。
|
|
1191
|
+
|
|
1192
|
+
查看状态谁都可以;改开关只允许超管和本群管理员。
|
|
1193
|
+
|
|
1194
|
+
Args:
|
|
1195
|
+
event: 触发消息事件。
|
|
1196
|
+
args: 命令后面的参数。
|
|
1197
|
+
"""
|
|
1198
|
+
key = _session_key(event)
|
|
1199
|
+
raw = event.get_plaintext().strip()
|
|
1200
|
+
head = raw.split(maxsplit=1)[0].lstrip("/#") if raw else ""
|
|
1201
|
+
arg = args.extract_plain_text().strip().lower()
|
|
1202
|
+
|
|
1203
|
+
if head == "解析状态" or arg in _STATUS_WORDS:
|
|
1204
|
+
state = "开启" if SWITCHES.is_on(key) else "关闭"
|
|
1205
|
+
await switch_cmd.finish(f"{_session_name(event)}的解析当前是:{state}")
|
|
1206
|
+
|
|
1207
|
+
if not _can_control(event):
|
|
1208
|
+
await switch_cmd.finish("只有超管或本群管理员能改解析开关。")
|
|
1209
|
+
|
|
1210
|
+
if head == "解析开" or arg in _ON_WORDS:
|
|
1211
|
+
want = True
|
|
1212
|
+
elif head == "解析关" or arg in _OFF_WORDS:
|
|
1213
|
+
want = False
|
|
1214
|
+
elif arg:
|
|
1215
|
+
await switch_cmd.finish(
|
|
1216
|
+
"用法:解析开关 [开|关],不带参数就翻转;解析状态 看当前值"
|
|
1217
|
+
)
|
|
1218
|
+
return
|
|
1219
|
+
else:
|
|
1220
|
+
want = not SWITCHES.is_on(key)
|
|
1221
|
+
|
|
1222
|
+
SWITCHES.set(key, want)
|
|
1223
|
+
name = _session_name(event)
|
|
1224
|
+
logger.info(
|
|
1225
|
+
f"[parsehub] {name}解析开关 -> {'开' if want else '关'}"
|
|
1226
|
+
f"({key},操作人 {event.get_user_id()})"
|
|
1227
|
+
)
|
|
1228
|
+
if want:
|
|
1229
|
+
await switch_cmd.finish(f"已开启{name}的解析。")
|
|
1230
|
+
await switch_cmd.finish(
|
|
1231
|
+
f"已关闭{name}的解析:链接不再自动解析,"
|
|
1232
|
+
"手动解析也只有超管和群管理员能用。"
|
|
1233
|
+
)
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
@auto_matcher.handle()
|
|
1237
|
+
async def _handle_auto(bot: Bot, event: MessageEvent) -> None:
|
|
1238
|
+
"""自动解析消息里的链接。
|
|
1239
|
+
|
|
1240
|
+
Args:
|
|
1241
|
+
bot: 当前 Bot 实例。
|
|
1242
|
+
event: 触发消息事件。
|
|
1243
|
+
"""
|
|
1244
|
+
urls = core.extract_urls(event.get_plaintext())
|
|
1245
|
+
session = _session_key(event)
|
|
1246
|
+
window = max(0, plugin_config.parsehub_dedupe_seconds)
|
|
1247
|
+
for url in urls[:_MAX_LINKS_PER_MESSAGE]:
|
|
1248
|
+
# 只看不记:记录由 _parse_and_send 负责;这里先静默跳过,免得白发一个
|
|
1249
|
+
# 「收到」表情
|
|
1250
|
+
if DEDUPE.seen(session, core.content_key(url), window):
|
|
1251
|
+
continue
|
|
1252
|
+
await handle_link(bot, event, url)
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
driver = get_driver()
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
@driver.on_startup
|
|
1259
|
+
async def _on_startup() -> None:
|
|
1260
|
+
"""启动媒体中转服务,并开始定期清理临时文件。"""
|
|
1261
|
+
cfg = plugin_config
|
|
1262
|
+
bind_host = cfg.parsehub_files_host or "0.0.0.0"
|
|
1263
|
+
_ENDPOINT.port = server.RELAY.start(bind_host, cfg.parsehub_files_port)
|
|
1264
|
+
_ENDPOINT.host = cfg.parsehub_files_host or server.guess_lan_ip()
|
|
1265
|
+
logger.info(
|
|
1266
|
+
f"[parsehub] 媒体中转服务: "
|
|
1267
|
+
f"http://{_ENDPOINT.host}:{_ENDPOINT.port}/ "
|
|
1268
|
+
f"根目录: {server.RELAY.root}"
|
|
1269
|
+
)
|
|
1270
|
+
if cfg.parsehub_card and cfg.parsehub_card_font:
|
|
1271
|
+
font_path = Path(cfg.parsehub_card_font).expanduser()
|
|
1272
|
+
if font_path.exists():
|
|
1273
|
+
card.set_font(font_path)
|
|
1274
|
+
logger.info(f"[parsehub] 卡片字体: {font_path}")
|
|
1275
|
+
else:
|
|
1276
|
+
logger.warning(
|
|
1277
|
+
f"[parsehub] 配置的字体不存在: {font_path},改用自动查找"
|
|
1278
|
+
)
|
|
1279
|
+
asyncio.create_task(_sweep_expired_media())
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
async def _sweep_expired_media() -> None:
|
|
1283
|
+
"""定期清理过期的临时媒体目录。"""
|
|
1284
|
+
while True:
|
|
1285
|
+
await asyncio.sleep(_SWEEP_INTERVAL_SECONDS)
|
|
1286
|
+
try:
|
|
1287
|
+
keep_seconds = plugin_config.parsehub_keep_minutes * 60
|
|
1288
|
+
removed = server.RELAY.cleanup(
|
|
1289
|
+
keep_seconds, skip={mediacache.CACHE_DIR_NAME}
|
|
1290
|
+
)
|
|
1291
|
+
expired = MEDIA_CACHE.sweep()
|
|
1292
|
+
if expired:
|
|
1293
|
+
logger.info(f"[parsehub] 媒体缓存清理了 {expired} 个作品")
|
|
1294
|
+
except OSError as exc:
|
|
1295
|
+
logger.warning(f"[parsehub] 清理失败: {exc}")
|
|
1296
|
+
continue
|
|
1297
|
+
if removed:
|
|
1298
|
+
logger.info(f"[parsehub] 清理了 {removed} 个过期临时目录")
|