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,418 @@
|
|
|
1
|
+
# Copyright 2026 ByteColtX
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""按作品缓存下载好的媒体。
|
|
5
|
+
|
|
6
|
+
同一个作品(不管用户发的是短链还是完整链)第二次出现时,直接从本地把卡片和媒体
|
|
7
|
+
发出去,**不解析、不下载、不画卡**。缓存按作品 key 存,跨会话共享——A 群发过的
|
|
8
|
+
视频,B 群再发也是直接发本地那份。
|
|
9
|
+
|
|
10
|
+
目录结构(``root`` 就是中转服务的根目录,所以协议端能直接来拉)::
|
|
11
|
+
|
|
12
|
+
<root>/media/<slug>/card.jpg 信息卡片(有就发)
|
|
13
|
+
<root>/media/<slug>/caption.txt 没出卡时发的文字
|
|
14
|
+
<root>/media/<slug>/files/<...> 下载好的媒体文件
|
|
15
|
+
|
|
16
|
+
只保留 ``ttl_seconds`` 内用过的条目(命中会刷新时间,所以常看的一直在),总量
|
|
17
|
+
超过 ``max_bytes`` 时按最久没用过的先删。模块不依赖 nonebot,方便单独测试。
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import time
|
|
27
|
+
from collections.abc import Iterable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger("nonebot_plugin_parsehub.mediacache")
|
|
31
|
+
|
|
32
|
+
# 缓存目录名(放在中转根目录下,协议端要能拉到)。
|
|
33
|
+
CACHE_DIR_NAME = "media"
|
|
34
|
+
|
|
35
|
+
# 卡片图与文字的文件名。
|
|
36
|
+
CARD_NAME = "card.jpg"
|
|
37
|
+
CAPTION_NAME = "caption.txt"
|
|
38
|
+
|
|
39
|
+
# 记下这个条目对应哪些作品 key(命中时要把它们一起记进去重,否则同一个作品
|
|
40
|
+
# 换个写法再发会被当成新作品)。
|
|
41
|
+
KEYS_NAME = "keys.txt"
|
|
42
|
+
|
|
43
|
+
# 条目里媒体的时长(秒),供时长上限在命中缓存时判断,省得为已知的长视频再解析。
|
|
44
|
+
SECONDS_NAME = "seconds.txt"
|
|
45
|
+
|
|
46
|
+
# 媒体文件放在条目目录的这个子目录里,避免和卡片图混在一起。
|
|
47
|
+
FILES_DIR_NAME = "files"
|
|
48
|
+
|
|
49
|
+
# 目录名的非法字符(作品 key 里会有 ``:`` 和 ``/``)。
|
|
50
|
+
_UNSAFE = re.compile(r"[^0-9A-Za-z._-]")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def slugify(key: str) -> str:
|
|
54
|
+
"""把作品 key 变成安全的目录名。
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
key: :func:`nonebot_plugin_parsehub.core.content_key` 生成的 key。
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
只含字母数字和 ``._-`` 的目录名。
|
|
61
|
+
"""
|
|
62
|
+
return _UNSAFE.sub("_", key)[:120] or "unknown"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class MediaCache:
|
|
66
|
+
"""作品级的媒体缓存。
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
root: 缓存根目录(``<中转根目录>/media``)。
|
|
70
|
+
ttl_seconds: 条目多久没用就删(秒),小于等于 0 表示不缓存。
|
|
71
|
+
max_bytes: 总容量上限(字节),小于等于 0 表示不限。
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
root: Path,
|
|
77
|
+
*,
|
|
78
|
+
ttl_seconds: int,
|
|
79
|
+
max_bytes: int,
|
|
80
|
+
) -> None:
|
|
81
|
+
"""记住配置,不碰磁盘。
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
root: 缓存根目录。
|
|
85
|
+
ttl_seconds: 条目过期时间(秒)。
|
|
86
|
+
max_bytes: 总容量上限(字节)。
|
|
87
|
+
"""
|
|
88
|
+
self.root = root
|
|
89
|
+
self.ttl_seconds = ttl_seconds
|
|
90
|
+
self.max_bytes = max_bytes
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def enabled(self) -> bool:
|
|
94
|
+
"""有没有开启缓存。
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
开启为 ``True``。
|
|
98
|
+
"""
|
|
99
|
+
return self.ttl_seconds > 0
|
|
100
|
+
|
|
101
|
+
def entry_dir(self, key: str) -> Path:
|
|
102
|
+
"""某个作品对应的目录。
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
key: 作品 key。
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
条目目录路径(可能不存在)。
|
|
109
|
+
"""
|
|
110
|
+
return self.root / slugify(key)
|
|
111
|
+
|
|
112
|
+
def get(self, key: str) -> Path | None:
|
|
113
|
+
"""取缓存条目,命中就刷新它的时间戳。
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
key: 作品 key。
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
条目目录;没缓存或已过期时为 ``None``。
|
|
120
|
+
"""
|
|
121
|
+
if not self.enabled:
|
|
122
|
+
return None
|
|
123
|
+
path = self.entry_dir(key)
|
|
124
|
+
if not (path / FILES_DIR_NAME).is_dir():
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
if time.time() - path.stat().st_mtime > self.ttl_seconds:
|
|
128
|
+
return None
|
|
129
|
+
os.utime(path, None) # 命中即续命,常看的一直留着
|
|
130
|
+
except OSError:
|
|
131
|
+
return None
|
|
132
|
+
if not self.media_files(path):
|
|
133
|
+
return None
|
|
134
|
+
return path
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def media_files(entry: Path) -> list[Path]:
|
|
138
|
+
"""列出条目里的媒体文件(按文件名排序)。
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
entry: 条目目录。
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
媒体文件列表,不含卡片图和文字。
|
|
145
|
+
"""
|
|
146
|
+
files_dir = entry / FILES_DIR_NAME
|
|
147
|
+
if not files_dir.is_dir():
|
|
148
|
+
return []
|
|
149
|
+
return sorted(
|
|
150
|
+
(item for item in files_dir.iterdir() if item.is_file()),
|
|
151
|
+
key=lambda item: item.name,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
@staticmethod
|
|
155
|
+
def card_path(entry: Path) -> Path | None:
|
|
156
|
+
"""条目里的卡片图。
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
entry: 条目目录。
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
卡片图路径;没有时为 ``None``。
|
|
163
|
+
"""
|
|
164
|
+
card = entry / CARD_NAME
|
|
165
|
+
return card if card.is_file() else None
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def entry_keys(entry: Path) -> list[str]:
|
|
169
|
+
"""条目对应的所有作品 key。
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
entry: 条目目录。
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
作品 key 列表;没记录过时为空列表。
|
|
176
|
+
"""
|
|
177
|
+
keys = entry / KEYS_NAME
|
|
178
|
+
if not keys.is_file():
|
|
179
|
+
return []
|
|
180
|
+
try:
|
|
181
|
+
return [
|
|
182
|
+
line.strip()
|
|
183
|
+
for line in keys.read_text("utf-8").splitlines()
|
|
184
|
+
if line.strip()
|
|
185
|
+
]
|
|
186
|
+
except OSError:
|
|
187
|
+
return []
|
|
188
|
+
|
|
189
|
+
@staticmethod
|
|
190
|
+
def entry_seconds(entry: Path) -> int:
|
|
191
|
+
"""条目里媒体的时长(秒)。
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
entry: 条目目录。
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
时长(秒);没记录过或读不出来时返回 0(调用方按「不超限」处理)。
|
|
198
|
+
"""
|
|
199
|
+
stored = entry / SECONDS_NAME
|
|
200
|
+
if not stored.is_file():
|
|
201
|
+
return 0
|
|
202
|
+
try:
|
|
203
|
+
return max(0, int(stored.read_text("utf-8").strip() or 0))
|
|
204
|
+
except (OSError, ValueError):
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def remember_key(entry: Path, key: str) -> None:
|
|
209
|
+
"""把 key 补记到条目里(老条目没有 keys.txt,命中时顺手补上)。
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
entry: 条目目录。
|
|
213
|
+
key: 作品 key。
|
|
214
|
+
"""
|
|
215
|
+
keys = MediaCache.entry_keys(entry)
|
|
216
|
+
if key in keys:
|
|
217
|
+
return
|
|
218
|
+
keys.append(key)
|
|
219
|
+
try:
|
|
220
|
+
(entry / KEYS_NAME).write_text("\n".join(keys), "utf-8")
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
logger.warning(f"[parsehub] 缓存 key 补记失败: {exc}")
|
|
223
|
+
|
|
224
|
+
@staticmethod
|
|
225
|
+
def caption_path(entry: Path) -> Path | None:
|
|
226
|
+
"""条目里的文字。
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
entry: 条目目录。
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
文字文件路径;没有时为 ``None``。
|
|
233
|
+
"""
|
|
234
|
+
caption = entry / CAPTION_NAME
|
|
235
|
+
return caption if caption.is_file() else None
|
|
236
|
+
|
|
237
|
+
def store(
|
|
238
|
+
self,
|
|
239
|
+
keys: Iterable[str],
|
|
240
|
+
files: list[Path],
|
|
241
|
+
*,
|
|
242
|
+
card: Path | None = None,
|
|
243
|
+
caption: str = "",
|
|
244
|
+
seconds: int = 0,
|
|
245
|
+
) -> Path | None:
|
|
246
|
+
"""把一个作品存进缓存(多个 key 指向同一份文件)。
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
keys: 作品 key,通常同时传「短链 key」和「解析后的 key」。
|
|
250
|
+
files: 已下载到本地的媒体文件。
|
|
251
|
+
card: 卡片图路径,没有就传 ``None``。
|
|
252
|
+
caption: 没出卡时发的文字。
|
|
253
|
+
seconds: 媒体的时长(秒),0 表示未知。
|
|
254
|
+
|
|
255
|
+
Returns:
|
|
256
|
+
条目目录;没开启缓存或没有媒体时为 ``None``。
|
|
257
|
+
"""
|
|
258
|
+
if not self.enabled or not files:
|
|
259
|
+
return None
|
|
260
|
+
keys = [key for key in keys if key]
|
|
261
|
+
if not keys:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
# 先把文件收进第一个 key 的目录,再给别的 key 建硬链接指向同一份
|
|
265
|
+
primary = self.entry_dir(keys[0])
|
|
266
|
+
try:
|
|
267
|
+
(primary / FILES_DIR_NAME).mkdir(parents=True, exist_ok=True)
|
|
268
|
+
except OSError as exc:
|
|
269
|
+
logger.warning(f"[parsehub] 缓存目录建不出来: {exc}")
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
for source in files:
|
|
273
|
+
target = primary / FILES_DIR_NAME / source.name
|
|
274
|
+
if target.exists():
|
|
275
|
+
continue
|
|
276
|
+
try:
|
|
277
|
+
os.link(source, target) # 同一个文件系统,硬链接零成本
|
|
278
|
+
except OSError:
|
|
279
|
+
try:
|
|
280
|
+
shutil.copy2(source, target)
|
|
281
|
+
except OSError as exc:
|
|
282
|
+
logger.warning(f"[parsehub] 缓存文件失败 {source}: {exc}")
|
|
283
|
+
if not self.media_files(primary):
|
|
284
|
+
shutil.rmtree(primary, ignore_errors=True)
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
if card is not None and card.is_file():
|
|
288
|
+
try:
|
|
289
|
+
shutil.copy2(card, primary / CARD_NAME)
|
|
290
|
+
except OSError as exc:
|
|
291
|
+
logger.warning(f"[parsehub] 缓存卡片失败: {exc}")
|
|
292
|
+
if caption:
|
|
293
|
+
try:
|
|
294
|
+
(primary / CAPTION_NAME).write_text(caption, "utf-8")
|
|
295
|
+
except OSError as exc:
|
|
296
|
+
logger.warning(f"[parsehub] 缓存文字失败: {exc}")
|
|
297
|
+
if seconds > 0:
|
|
298
|
+
try:
|
|
299
|
+
(primary / SECONDS_NAME).write_text(str(int(seconds)), "utf-8")
|
|
300
|
+
except OSError as exc:
|
|
301
|
+
logger.warning(f"[parsehub] 缓存时长失败: {exc}")
|
|
302
|
+
try:
|
|
303
|
+
(primary / KEYS_NAME).write_text("\n".join(keys), "utf-8")
|
|
304
|
+
except OSError as exc:
|
|
305
|
+
logger.warning(f"[parsehub] 缓存 key 记录失败: {exc}")
|
|
306
|
+
|
|
307
|
+
os.utime(primary, None)
|
|
308
|
+
for key in keys[1:]:
|
|
309
|
+
self._alias(primary, self.entry_dir(key))
|
|
310
|
+
self.sweep()
|
|
311
|
+
return primary
|
|
312
|
+
|
|
313
|
+
def _alias(self, primary: Path, alias: Path) -> None:
|
|
314
|
+
"""让另一个 key 也指向同一份缓存。
|
|
315
|
+
|
|
316
|
+
用符号链接指过去,省得再复制一遍文件;符号链接坏了就当没有这条缓存。
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
primary: 真正存文件的条目目录。
|
|
320
|
+
alias: 别名条目目录。
|
|
321
|
+
"""
|
|
322
|
+
if alias == primary or alias.exists():
|
|
323
|
+
return
|
|
324
|
+
try:
|
|
325
|
+
alias.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
os.symlink(primary.name, alias)
|
|
327
|
+
except OSError as exc:
|
|
328
|
+
logger.warning(f"[parsehub] 缓存别名失败 {alias}: {exc}")
|
|
329
|
+
|
|
330
|
+
def total_bytes(self) -> int:
|
|
331
|
+
"""缓存占用的字节数。
|
|
332
|
+
|
|
333
|
+
Returns:
|
|
334
|
+
所有条目加起来的大小。
|
|
335
|
+
"""
|
|
336
|
+
total = 0
|
|
337
|
+
for entry in self._entries():
|
|
338
|
+
for item in entry.rglob("*"):
|
|
339
|
+
try:
|
|
340
|
+
if item.is_file():
|
|
341
|
+
total += item.stat().st_size
|
|
342
|
+
except OSError:
|
|
343
|
+
continue
|
|
344
|
+
return total
|
|
345
|
+
|
|
346
|
+
def _entries(self) -> list[Path]:
|
|
347
|
+
"""列出所有条目目录(跳过符号链接别名)。
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
真实存放文件的条目目录列表。
|
|
351
|
+
"""
|
|
352
|
+
if not self.root.is_dir():
|
|
353
|
+
return []
|
|
354
|
+
return sorted(
|
|
355
|
+
(
|
|
356
|
+
item
|
|
357
|
+
for item in self.root.iterdir()
|
|
358
|
+
if item.is_dir() and not item.is_symlink()
|
|
359
|
+
),
|
|
360
|
+
key=lambda item: self._mtime(item),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
@staticmethod
|
|
364
|
+
def _mtime(path: Path) -> float:
|
|
365
|
+
"""取目录的修改时间,取不到就当 0。
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
path: 目录路径。
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
时间戳。
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
return path.stat().st_mtime
|
|
375
|
+
except OSError:
|
|
376
|
+
return 0.0
|
|
377
|
+
|
|
378
|
+
def _drop_broken_aliases(self) -> int:
|
|
379
|
+
"""清掉指向已被删掉条目的别名符号链接。
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
清掉的数量。
|
|
383
|
+
"""
|
|
384
|
+
if not self.root.is_dir():
|
|
385
|
+
return 0
|
|
386
|
+
removed = 0
|
|
387
|
+
for item in self.root.iterdir():
|
|
388
|
+
if item.is_symlink() and not item.is_dir():
|
|
389
|
+
try:
|
|
390
|
+
item.unlink()
|
|
391
|
+
removed += 1
|
|
392
|
+
except OSError:
|
|
393
|
+
continue
|
|
394
|
+
return removed
|
|
395
|
+
|
|
396
|
+
def sweep(self) -> int:
|
|
397
|
+
"""删掉过期条目,再按容量上限淘汰最久没用过的。
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
删掉的条目数量。
|
|
401
|
+
"""
|
|
402
|
+
if not self.enabled:
|
|
403
|
+
return 0
|
|
404
|
+
removed = 0
|
|
405
|
+
now = time.time()
|
|
406
|
+
self._drop_broken_aliases()
|
|
407
|
+
for entry in self._entries():
|
|
408
|
+
if now - self._mtime(entry) > self.ttl_seconds:
|
|
409
|
+
shutil.rmtree(entry, ignore_errors=True)
|
|
410
|
+
removed += 1
|
|
411
|
+
if self.max_bytes > 0:
|
|
412
|
+
while True:
|
|
413
|
+
entries = self._entries()
|
|
414
|
+
if not entries or self.total_bytes() <= self.max_bytes:
|
|
415
|
+
break
|
|
416
|
+
shutil.rmtree(entries[0], ignore_errors=True)
|
|
417
|
+
removed += 1
|
|
418
|
+
return removed
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# Copyright 2026 ByteColtX
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""媒体中转服务。
|
|
5
|
+
|
|
6
|
+
QQ 协议端(NapCat / Lagrange / LLOneBot)通常跑在另一台机器上,读不到本机的
|
|
7
|
+
``file://`` 路径,所以把下载好的媒体用一个随机目录暴露成 http 地址发给它,让它
|
|
8
|
+
自己来拉。
|
|
9
|
+
|
|
10
|
+
设计约束:
|
|
11
|
+
|
|
12
|
+
- 只读、只服务一个目录,不列目录;
|
|
13
|
+
- 目录名带随机串,猜不到;
|
|
14
|
+
- 过期文件由 :meth:`MediaRelay.cleanup` 定期清理(清扫任务在插件入口里跑)。
|
|
15
|
+
|
|
16
|
+
媒体目录按 NoneBot 官方要求交给 ``nonebot-plugin-localstore`` 管理;没装时退回
|
|
17
|
+
系统临时目录,保证插件仍能加载。
|
|
18
|
+
|
|
19
|
+
模块级单例 ``RELAY`` 是进程内共享的中转服务。
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import http.server
|
|
25
|
+
import shutil
|
|
26
|
+
import socket
|
|
27
|
+
import tempfile
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from functools import partial
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from nonebot import logger, require
|
|
36
|
+
|
|
37
|
+
# 探测内网 IP 时用的哑地址,只用来让内核选出口网卡,不会真的发包。
|
|
38
|
+
_PROBE_ADDRESS = ("10.255.255.255", 1)
|
|
39
|
+
|
|
40
|
+
# 每次任务目录名的随机长度(十六进制字符数)。
|
|
41
|
+
_TASK_DIR_RANDOM_LENGTH = 16
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_root() -> Path:
|
|
45
|
+
"""确定媒体文件的根目录。
|
|
46
|
+
|
|
47
|
+
优先使用 localstore 提供的插件缓存目录;没有安装 localstore 时退回系统临时
|
|
48
|
+
目录,并给出警告,而不是让整个插件加载失败。
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
媒体文件根目录。
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
require("nonebot_plugin_localstore")
|
|
55
|
+
|
|
56
|
+
import nonebot_plugin_localstore as store
|
|
57
|
+
|
|
58
|
+
return store.get_plugin_cache_dir()
|
|
59
|
+
except Exception: # noqa: BLE001 - 缺少依赖时降级,不能让插件整体挂掉
|
|
60
|
+
logger.warning(
|
|
61
|
+
"[parsehub] 未安装 nonebot-plugin-localstore,"
|
|
62
|
+
"媒体临时文件改用系统临时目录"
|
|
63
|
+
)
|
|
64
|
+
return Path(tempfile.gettempdir()) / "nonebot-plugin-parsehub"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def cache_root() -> Path:
|
|
68
|
+
"""返回插件缓存根目录(媒体与字体都放这儿)。
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
缓存根目录。
|
|
72
|
+
"""
|
|
73
|
+
return _resolve_root()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def data_root() -> Path:
|
|
77
|
+
"""返回插件数据目录(需要长期保留的东西放这儿)。
|
|
78
|
+
|
|
79
|
+
和 :func:`cache_root` 分开:缓存目录里的任务子目录会被定期清扫,开关状态、
|
|
80
|
+
配置这类东西不能放进去。
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
数据目录。
|
|
84
|
+
"""
|
|
85
|
+
try:
|
|
86
|
+
require("nonebot_plugin_localstore")
|
|
87
|
+
|
|
88
|
+
import nonebot_plugin_localstore as store
|
|
89
|
+
|
|
90
|
+
return store.get_plugin_data_dir()
|
|
91
|
+
except Exception: # noqa: BLE001 - 缺少依赖时降级,不能让插件整体挂掉
|
|
92
|
+
logger.warning(
|
|
93
|
+
"[parsehub] 未安装 nonebot-plugin-localstore,"
|
|
94
|
+
"插件数据改用系统临时目录(重启会丢)"
|
|
95
|
+
)
|
|
96
|
+
return Path(tempfile.gettempdir()) / "nonebot-plugin-parsehub-data"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _RelayHandler(http.server.SimpleHTTPRequestHandler):
|
|
100
|
+
"""只读静态文件服务,关掉目录列表和访问日志。"""
|
|
101
|
+
|
|
102
|
+
def list_directory(self, path: Any) -> None:
|
|
103
|
+
"""禁止列目录,直接返回 404。
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
path: 请求的目录路径,未使用。
|
|
107
|
+
"""
|
|
108
|
+
self.send_error(404, "Not Found")
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
112
|
+
"""静音访问日志,避免刷屏。
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
format: 日志格式串,未使用。
|
|
116
|
+
*args: 日志参数,未使用。
|
|
117
|
+
"""
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
def end_headers(self) -> None:
|
|
121
|
+
"""补上禁用缓存的响应头。"""
|
|
122
|
+
self.send_header("Cache-Control", "no-store")
|
|
123
|
+
super().end_headers()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class MediaRelay:
|
|
127
|
+
"""把本地媒体文件暴露成协议端可下载的 http 地址。
|
|
128
|
+
|
|
129
|
+
Attributes:
|
|
130
|
+
root: 媒体文件根目录。
|
|
131
|
+
_server: 已启动的 HTTP 服务,未启动时为 None。
|
|
132
|
+
_lock: 保护启动过程的锁。
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, root: Path) -> None:
|
|
136
|
+
"""初始化中转服务。
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
root: 媒体文件根目录。
|
|
140
|
+
"""
|
|
141
|
+
self.root = root
|
|
142
|
+
self._server: http.server.ThreadingHTTPServer | None = None
|
|
143
|
+
self._lock = threading.Lock()
|
|
144
|
+
|
|
145
|
+
def start(self, host: str, port: int) -> int:
|
|
146
|
+
"""启动 HTTP 服务(重复调用只启动一次)。
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
host: 绑定地址,例如 ``0.0.0.0``。
|
|
150
|
+
port: 绑定端口。
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
实际监听的端口。
|
|
154
|
+
"""
|
|
155
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
with self._lock:
|
|
157
|
+
if self._server is None:
|
|
158
|
+
handler = partial(_RelayHandler, directory=str(self.root))
|
|
159
|
+
self._server = http.server.ThreadingHTTPServer(
|
|
160
|
+
(host, port), handler
|
|
161
|
+
)
|
|
162
|
+
thread = threading.Thread(
|
|
163
|
+
target=self._server.serve_forever,
|
|
164
|
+
name="parsehub-files",
|
|
165
|
+
daemon=True,
|
|
166
|
+
)
|
|
167
|
+
thread.start()
|
|
168
|
+
return int(self._server.server_address[1])
|
|
169
|
+
|
|
170
|
+
def new_task_dir(self) -> Path:
|
|
171
|
+
"""为一次解析任务创建随机子目录。
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
新建的子目录路径。
|
|
175
|
+
"""
|
|
176
|
+
path = self.root / uuid.uuid4().hex[:_TASK_DIR_RANDOM_LENGTH]
|
|
177
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
return path
|
|
179
|
+
|
|
180
|
+
def url_for(self, path: Path, host: str, port: int) -> str:
|
|
181
|
+
"""把本地文件路径转成协议端能下载的地址。
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
path: 媒体文件路径,必须位于 :attr:`root` 之下。
|
|
185
|
+
host: 协议端可访问的地址。
|
|
186
|
+
port: HTTP 服务端口。
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
形如 ``http://host:port/<随机目录>/<文件名>`` 的地址。
|
|
190
|
+
"""
|
|
191
|
+
relative = path.resolve().relative_to(self.root.resolve())
|
|
192
|
+
return f"http://{host}:{port}/{relative.as_posix()}"
|
|
193
|
+
|
|
194
|
+
def cleanup(self, keep_seconds: int, skip: set[str] | None = None) -> int:
|
|
195
|
+
"""删除过期的任务目录。
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
keep_seconds: 保留时长(秒),早于该时长的目录会被删除。
|
|
199
|
+
skip: 不参与清扫的子目录名(比如媒体缓存目录)。
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
删除的目录数量。
|
|
203
|
+
"""
|
|
204
|
+
if not self.root.exists():
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
skip = skip or set()
|
|
208
|
+
now = time.time()
|
|
209
|
+
removed = 0
|
|
210
|
+
for child in self.root.iterdir():
|
|
211
|
+
if child.name in skip:
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
expired = (
|
|
215
|
+
child.is_dir()
|
|
216
|
+
and now - child.stat().st_mtime > keep_seconds
|
|
217
|
+
)
|
|
218
|
+
except OSError:
|
|
219
|
+
continue
|
|
220
|
+
if expired:
|
|
221
|
+
shutil.rmtree(child, ignore_errors=True)
|
|
222
|
+
removed += 1
|
|
223
|
+
return removed
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def guess_lan_ip() -> str:
|
|
227
|
+
"""探测本机内网 IP。
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
本机在默认出口网卡上的地址;探测失败时返回 ``127.0.0.1``。
|
|
231
|
+
"""
|
|
232
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
233
|
+
try:
|
|
234
|
+
sock.connect(_PROBE_ADDRESS)
|
|
235
|
+
return str(sock.getsockname()[0])
|
|
236
|
+
except OSError:
|
|
237
|
+
return "127.0.0.1"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# 进程内共享的中转服务。
|
|
241
|
+
RELAY = MediaRelay(_resolve_root())
|