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.
Files changed (33) hide show
  1. nonebot_plugin_parsehub/__init__.py +1298 -0
  2. nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
  3. nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
  4. nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
  5. nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
  6. nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
  7. nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
  8. nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
  9. nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
  10. nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
  11. nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
  12. nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
  13. nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
  14. nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
  15. nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
  16. nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
  17. nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
  18. nonebot_plugin_parsehub/assets/logos/x.png +0 -0
  19. nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
  20. nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
  21. nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
  22. nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
  23. nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
  24. nonebot_plugin_parsehub/card.py +836 -0
  25. nonebot_plugin_parsehub/config.py +95 -0
  26. nonebot_plugin_parsehub/core.py +485 -0
  27. nonebot_plugin_parsehub/mediacache.py +418 -0
  28. nonebot_plugin_parsehub/server.py +241 -0
  29. nonebot_plugin_parsehub/switches.py +156 -0
  30. nonebot_plugin_parsehub-0.1.0.dist-info/METADATA +275 -0
  31. nonebot_plugin_parsehub-0.1.0.dist-info/RECORD +33 -0
  32. nonebot_plugin_parsehub-0.1.0.dist-info/WHEEL +4 -0
  33. nonebot_plugin_parsehub-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,95 @@
1
+ # Copyright 2026 ByteColtX
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """插件配置。
5
+
6
+ 所有配置项都从 NoneBot 的 ``.env`` / 环境变量读取,字段名即配置名(大小写不
7
+ 敏感),例如字段 ``parsehub_max_mb`` 对应 ``PARSEHUB_MAX_MB=100``。
8
+
9
+ 注意:``.env`` 里的值会先被解析成 int / bool / list 再做校验,因此数字类配置
10
+ 必须声明成 ``int``,列表类配置必须是合法 JSON(``["twitter", "youtube"]``),
11
+ 否则插件会在加载阶段直接失败。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from nonebot import get_plugin_config
17
+ from pydantic import BaseModel, Field
18
+
19
+ # 需要走代理的海外平台(ParseHub 的平台 id)。
20
+ OVERSEAS_PLATFORMS: tuple[str, ...] = (
21
+ "twitter",
22
+ "instagram",
23
+ "threads",
24
+ "youtube",
25
+ "tiktok",
26
+ "facebook",
27
+ "snapchat",
28
+ "medium",
29
+ )
30
+
31
+
32
+ class Config(BaseModel):
33
+ """ParseHub 链接解析插件配置。
34
+
35
+ Attributes:
36
+ parsehub_auto: 群里出现受支持的链接时是否自动解析。具体到某个会话
37
+ (群 / 私聊)的开与关用命令 ``解析开关`` 控制,不在这里配。
38
+ parsehub_dedupe_seconds: 同一链接在**同一个会话**里的去重窗口(秒)。
39
+ 跨会话不去重:同一个链接在别的群 / 私聊里照样解析。
40
+ parsehub_concurrency: 同时处理的任务数上限。
41
+ parsehub_proxy: 解析 / 下载代理地址,留空表示直连。
42
+ parsehub_proxy_platforms: 只对这些平台走代理,留空表示所有平台都走。
43
+ parsehub_cookie: 兜底 cookie,优先使用 ParseHub 自己的 cookies.toml。
44
+ parsehub_timeout: 解析加下载的总超时(秒)。
45
+ parsehub_max_mb: 单个媒体文件体积上限(MB),超过则跳过。
46
+ parsehub_cache_hours: 媒体缓存保留小时数。命中缓存就直接发本地那份,
47
+ 不再解析、下载、画卡;0 表示关闭缓存。
48
+ parsehub_cache_max_mb: 媒体缓存总容量上限(MB),超了按最久没用过的删。
49
+ parsehub_card: 是否把解析结果画成卡片图发送(失败自动退回文字)。
50
+ parsehub_card_font: 中文字体文件路径,留空则自动找系统字体或下载。
51
+ parsehub_send_text: 是否发送标题与正文文字。
52
+ parsehub_max_chars: 正文最多发送的字符数。
53
+ parsehub_forward_timeout: 合并转发等待协议端的秒数。协议端要把节点里的
54
+ 每个 URL 都抓一遍再上传,健康时 6-7 秒,但它在忙别的消息时会明显变慢
55
+ (实测超过 nonebot 默认的 30 秒上限被掐断)。这里给足余量,
56
+ 真超时了才退回逐条发送。
57
+ parsehub_fail_notice: 视频发送失败时是否额外发一条文字提示。
58
+ parsehub_files_host: 媒体中转服务对外的地址,留空则自动探测内网 IP。
59
+ parsehub_files_port: 媒体中转服务的端口。
60
+ parsehub_keep_minutes: 临时文件保留时长(分钟)。
61
+ """
62
+
63
+ # ---- 触发 ----
64
+ parsehub_auto: bool = True
65
+ parsehub_dedupe_seconds: int = 120
66
+ parsehub_concurrency: int = 2
67
+
68
+ # ---- 解析 / 下载 ----
69
+ parsehub_proxy: str = ""
70
+ parsehub_proxy_platforms: list[str] = Field(
71
+ default_factory=lambda: list(OVERSEAS_PLATFORMS)
72
+ )
73
+ parsehub_cookie: str = ""
74
+ parsehub_timeout: int = 180
75
+ parsehub_max_mb: int = 100
76
+ # 视频时长上限(秒):超过就只发封面 + 一句提示,不下载。0 = 不限。
77
+ parsehub_max_seconds: int = 300
78
+
79
+ # ---- 发送 ----
80
+ parsehub_cache_hours: int = 24
81
+ parsehub_cache_max_mb: int = 1024
82
+ parsehub_card: bool = True
83
+ parsehub_card_font: str = ""
84
+ parsehub_send_text: bool = True
85
+ parsehub_max_chars: int = 300
86
+ parsehub_forward_timeout: int = 60
87
+ parsehub_fail_notice: bool = True
88
+
89
+ # ---- 媒体中转服务 ----
90
+ parsehub_files_host: str = ""
91
+ parsehub_files_port: int = 21473
92
+ parsehub_keep_minutes: int = 30
93
+
94
+
95
+ plugin_config = get_plugin_config(Config)
@@ -0,0 +1,485 @@
1
+ # Copyright 2026 ByteColtX
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """ParseHub 调用封装。
5
+
6
+ 本模块承担三件事:
7
+
8
+ 1. 用一份便宜的域名白名单从消息文本里预筛链接,命中了才值得把 parsehub
9
+ 拉起来(它的 import 要数秒);
10
+ 2. 懒加载 ParseHub 实例,并把所有同步阻塞的调用丢进线程执行,避免阻塞
11
+ NoneBot 的事件循环;
12
+ 3. 按平台决定是否走代理、用哪个 cookie。
13
+
14
+ 模块级单例 ``CLIENT`` 是进程内共享的解析客户端,持有懒加载的 ParseHub
15
+ 实例。
16
+
17
+ 典型用法:
18
+
19
+ urls = extract_urls(message_text)
20
+ if urls:
21
+ result = await CLIENT.parse(urls[0])
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import asyncio
27
+ import re
28
+ import time
29
+ import tomllib
30
+ from typing import Any
31
+ from urllib.parse import urlsplit
32
+
33
+ import httpx
34
+
35
+ from .config import plugin_config
36
+
37
+ # 预筛白名单:只认这些域名后缀,避免为无关链接付出 parsehub 的 import 代价。
38
+ _ALLOWED_HOSTS: tuple[str, ...] = (
39
+ "b23.tv",
40
+ "bangumi.tv",
41
+ "bgm.tv",
42
+ "bili2233.cn",
43
+ "bilibili.com",
44
+ "channels.weixin.qq.com",
45
+ "chenzhongtech.com",
46
+ "coolapk.com",
47
+ "douban.com",
48
+ "douc.cc",
49
+ "douyin.com",
50
+ "facebook.com",
51
+ "fb.watch",
52
+ "fixupx.com",
53
+ "iesdouyin.com",
54
+ "instagram.com",
55
+ "ixigua.com",
56
+ "kuaishou.com",
57
+ "medium.com",
58
+ "mp.weixin.qq.com",
59
+ "pipix.com",
60
+ "snapchat.com",
61
+ "threads.com",
62
+ "threads.net",
63
+ "tieba.baidu.com",
64
+ "tiktok.com",
65
+ "toutiao.com",
66
+ "twitter.com",
67
+ "weibo.cn",
68
+ "weibo.com",
69
+ "x.com",
70
+ "xiaochuankeji.cn",
71
+ "xiaoheihe.cn",
72
+ "xiaohongshu.com",
73
+ "xhslink.cn",
74
+ "xhslink.com",
75
+ "youtu.be",
76
+ "youtube.com",
77
+ "zhihu.com",
78
+ "zuiyou.com",
79
+ )
80
+
81
+ # 中文标点和引号不会出现在 URL 里,直接当作边界。
82
+ _URL_PATTERN = re.compile(
83
+ r"https?://[^\s\u4e00-\u9fff\"'<>()\[\]{},。、;:!?]+",
84
+ re.IGNORECASE,
85
+ )
86
+ _TRAILING_CHARS = "。,、;:!?))】》\"'"
87
+
88
+ # 探测文件体积的超时(秒)。
89
+ _PROBE_TIMEOUT_SECONDS = 15.0
90
+
91
+
92
+ def extract_urls(text: str) -> list[str]:
93
+ """从消息文本里挑出可能受支持的链接。
94
+
95
+ 只做域名层面的粗筛,是否真的支持交给 ParseHub 判断。
96
+
97
+ Args:
98
+ text: 消息纯文本,可以是分享文案。
99
+
100
+ Returns:
101
+ 去重且保持原顺序的链接列表;没有命中时返回空列表。
102
+ """
103
+ found: list[str] = []
104
+ for raw_url in _URL_PATTERN.findall(text or ""):
105
+ url = raw_url.rstrip(_TRAILING_CHARS)
106
+ host = (urlsplit(url).hostname or "").lower()
107
+ if host.startswith("www."):
108
+ host = host[len("www.") :]
109
+ if not _host_allowed(host):
110
+ continue
111
+ if url not in found:
112
+ found.append(url)
113
+ return found
114
+
115
+
116
+ def _host_allowed(host: str) -> bool:
117
+ """判断域名是否落在预筛白名单里(含子域名)。
118
+
119
+ Args:
120
+ host: 已小写、已去掉 ``www.`` 的域名。
121
+
122
+ Returns:
123
+ 命中白名单返回 True。
124
+ """
125
+ return any(
126
+ host == allowed or host.endswith("." + allowed)
127
+ for allowed in _ALLOWED_HOSTS
128
+ )
129
+
130
+
131
+ def proxy_for(platform: str) -> str:
132
+ """按平台决定代理地址。
133
+
134
+ 配置了 ``PARSEHUB_PROXY`` 时,只对 ``PARSEHUB_PROXY_PLATFORMS`` 里的平台
135
+ 启用;该列表为空表示所有平台都走代理。
136
+
137
+ Args:
138
+ platform: ParseHub 的平台 id,例如 ``youtube``。
139
+
140
+ Returns:
141
+ 代理地址;不需要代理时返回空字符串。
142
+ """
143
+ proxy = plugin_config.parsehub_proxy
144
+ if not proxy:
145
+ return ""
146
+ allowed = plugin_config.parsehub_proxy_platforms
147
+ if allowed and platform not in allowed:
148
+ return ""
149
+ return proxy
150
+
151
+
152
+ def platform_cookie(platform: str, fallback: str) -> str:
153
+ """取该平台要用的 cookie。
154
+
155
+ 优先读 ParseHub 自己的 ``cookies.toml``(由 ``ph set cookie <平台>``
156
+ 写入),没有再退回配置里的兜底 cookie。
157
+
158
+ Args:
159
+ platform: ParseHub 的平台 id。
160
+ fallback: 配置里提供的兜底 cookie。
161
+
162
+ Returns:
163
+ cookie 字符串;都没有时返回空字符串。
164
+ """
165
+ if platform:
166
+ cookie = _read_cookie_file(platform)
167
+ if cookie:
168
+ return cookie
169
+ return fallback or ""
170
+
171
+
172
+ def _read_cookie_file(platform: str) -> str:
173
+ """从 ParseHub 的 cookies.toml 读取指定平台的 cookie。
174
+
175
+ parsehub 的 import 代价很高,因此这里延迟导入,避免插件加载时就把它拉起来。
176
+
177
+ Args:
178
+ platform: ParseHub 的平台 id。
179
+
180
+ Returns:
181
+ 文件里记录的 cookie;没读到返回空字符串。
182
+ """
183
+ try:
184
+ from parsehub.cli_config import default_cookie_path
185
+
186
+ path = default_cookie_path()
187
+ if not path.exists():
188
+ return ""
189
+ with path.open("rb") as file:
190
+ data = tomllib.load(file)
191
+ except (ImportError, OSError, ValueError):
192
+ return ""
193
+ value = (data.get("cookie") or {}).get(platform)
194
+ return str(value) if value else ""
195
+
196
+
197
+ async def probe_size(
198
+ url: str,
199
+ timeout: float = _PROBE_TIMEOUT_SECONDS,
200
+ proxy: str = "",
201
+ ) -> int | None:
202
+ """在下载前尽量问出文件体积。
203
+
204
+ 先试 HEAD,部分 CDN 不支持,再用 ``Range: bytes=0-0`` 从 Content-Range
205
+ 里取总长度。任何失败都视为未知,不影响主流程。
206
+
207
+ Args:
208
+ url: 媒体直链。
209
+ timeout: 单次请求超时(秒)。
210
+ proxy: 需要走代理时传入代理地址。
211
+
212
+ Returns:
213
+ 文件字节数;探测不出来时返回 None。
214
+ """
215
+ try:
216
+ async with httpx.AsyncClient(
217
+ timeout=timeout,
218
+ follow_redirects=True,
219
+ proxy=proxy or None,
220
+ ) as client:
221
+ response = await client.head(url)
222
+ length = response.headers.get("content-length", "")
223
+ if response.status_code < 400 and length.isdigit():
224
+ return int(length)
225
+
226
+ response = await client.get(url, headers={"Range": "bytes=0-0"})
227
+ content_range = response.headers.get("content-range", "")
228
+ except (httpx.HTTPError, httpx.InvalidURL):
229
+ return None
230
+
231
+ tail = content_range.rsplit("/", 1)[-1]
232
+ return int(tail) if tail.isdigit() else None
233
+
234
+
235
+ # 平台内容的 ID 特征:命中就能把同一个作品的各种写法归一成同一个 key。
236
+ # 顺序有意义,更具体的排前面(BV 号优先于 /video/ 里的数字)。
237
+ _ID_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
238
+ ("bilibili", re.compile(r"(BV[0-9A-Za-z]{10})")),
239
+ ("bilibili", re.compile(r"/av(\d+)")),
240
+ ("douyin", re.compile(r"/video/(\d{6,})")),
241
+ ("douyin", re.compile(r"/note/(\d{6,})")),
242
+ ("douyin", re.compile(r"[?&]modal_id=(\d{6,})")),
243
+ ("kuaishou", re.compile(r"/(?:short-video|fw/photo)/([0-9A-Za-z_-]+)")),
244
+ ("xiaohongshu", re.compile(r"/(?:explore|discovery/item)/([0-9A-Za-z]+)")),
245
+ ("weibo", re.compile(r"/(?:detail|status)/([0-9A-Za-z]+)")),
246
+ ("youtube", re.compile(r"youtu\.be/([0-9A-Za-z_-]{11})")),
247
+ ("youtube", re.compile(r"[?&]v=([0-9A-Za-z_-]{11})")),
248
+ ("twitter", re.compile(r"/status(?:es)?/(\d+)")),
249
+ ("instagram", re.compile(r"/(?:p|reel|tv)/([0-9A-Za-z_-]+)")),
250
+ )
251
+
252
+ # 归一化时要去掉的主机名前缀(m.bilibili.com 与 www.bilibili.com 是同一个站)。
253
+ _HOST_PREFIXES = ("www.", "m.", "mobile.")
254
+
255
+
256
+ def content_key(url: str) -> str:
257
+ """把链接归一化成去重用的 key(纯字符串处理,不发请求)。
258
+
259
+ 认得出作品 ID 的平台直接用 ``平台:id``,这样同一个作品的两种写法能对上——比如
260
+ 抖音的 ``v.douyin.com/xxx`` 和 ``iesdouyin.com/share/video/<id>``、B 站的
261
+ ``b23.tv/xxx`` 和 ``bilibili.com/video/BVxxxx``(解析结果里的 ``raw_url``
262
+ 已经是短链展开后的地址)。认不出来的退化成「主机 + 路径」,丢掉查询串和末尾
263
+ 斜杠。
264
+
265
+ Args:
266
+ url: 链接,可以是用户发的原始链接,也可以是 ParseHub 给的 ``raw_url``。
267
+
268
+ Returns:
269
+ 归一化后的 key。
270
+ """
271
+ for name, pattern in _ID_PATTERNS:
272
+ match = pattern.search(url)
273
+ if match:
274
+ return f"{name}:{match.group(1).lower()}"
275
+
276
+ parsed = urlsplit(url)
277
+ host = parsed.netloc.lower()
278
+ for prefix in _HOST_PREFIXES:
279
+ if host.startswith(prefix):
280
+ host = host[len(prefix) :]
281
+ break
282
+ return f"{host}{parsed.path.rstrip('/')}".lower()
283
+
284
+
285
+ class DedupeCache:
286
+ """同一链接在同一个会话里、时间窗口内只处理一次。
287
+
288
+ 记录 ``(会话, 链接)`` 最近一次被处理的时间戳,避免同一个群里同一张卡片被反复
289
+ 解析。**跨会话不去重**:同一个链接在 A 群发过,B 群再发照样解析。
290
+
291
+ Attributes:
292
+ _seen: ``(会话, 链接)`` 到最近处理时间戳的映射。
293
+ """
294
+
295
+ def __init__(self) -> None:
296
+ """初始化空缓存。"""
297
+ self._seen: dict[tuple[str, str], float] = {}
298
+
299
+ def seen(self, session: str, key: str, window: int) -> bool:
300
+ """这个会话里该 key 是否还在窗口内(只看不记)。
301
+
302
+ Args:
303
+ session: 会话标识(群 / 私聊)。
304
+ key: 作品 key。
305
+ window: 去重窗口(秒),小于等于 0 表示不去重。
306
+
307
+ Returns:
308
+ 窗口内出现过返回 True。
309
+ """
310
+ if window <= 0:
311
+ return False
312
+ self._prune(window)
313
+ last_seen = self._seen.get((session, key))
314
+ return last_seen is not None and time.time() - last_seen < window
315
+
316
+ def mark(self, session: str, key: str) -> None:
317
+ """记下这个会话里该 key 刚被处理过。
318
+
319
+ Args:
320
+ session: 会话标识(群 / 私聊)。
321
+ key: 作品 key。
322
+ """
323
+ self._seen[(session, key)] = time.time()
324
+
325
+ def should_skip(self, session: str, key: str, window: int) -> bool:
326
+ """查一下并顺手记上(「处理过了」的语义)。
327
+
328
+ Args:
329
+ session: 会话标识(群 / 私聊)。
330
+ key: 作品 key。
331
+ window: 去重窗口(秒),小于等于 0 表示不去重。
332
+
333
+ Returns:
334
+ 窗口内重复出现返回 True。
335
+ """
336
+ repeated = self.seen(session, key, window)
337
+ self.mark(session, key)
338
+ return repeated
339
+
340
+ def _prune(self, window: int) -> None:
341
+ """清掉窗口外的旧记录。
342
+
343
+ Args:
344
+ window: 去重窗口(秒)。
345
+ """
346
+ now = time.time()
347
+ for key, timestamp in list(self._seen.items()):
348
+ if now - timestamp > window:
349
+ self._seen.pop(key, None)
350
+
351
+
352
+ class ParseHubClient:
353
+ """ParseHub 的懒加载封装。
354
+
355
+ 首次调用 ``hub`` 时会在线程里导入 parsehub(numpy + opencv,数秒开销),
356
+ 之后复用同一个实例。
357
+
358
+ Attributes:
359
+ _hub: 缓存的 ParseHub 实例。
360
+ _lock: 保护懒加载过程的锁。
361
+ """
362
+
363
+ def __init__(self) -> None:
364
+ """初始化空的客户端。"""
365
+ self._hub: Any | None = None
366
+ self._lock = asyncio.Lock()
367
+
368
+ async def hub(self) -> Any:
369
+ """返回 ParseHub 实例,必要时先加载。
370
+
371
+ Returns:
372
+ 已就绪的 ParseHub 实例。
373
+ """
374
+ if self._hub is None:
375
+ async with self._lock:
376
+ if self._hub is None:
377
+ self._hub = await asyncio.to_thread(_import_parsehub)
378
+ return self._hub
379
+
380
+ async def platforms(self) -> list[dict]:
381
+ """列出 ParseHub 支持的平台。
382
+
383
+ Returns:
384
+ 平台信息字典的列表,每项含 ``id`` / ``name`` / ``supported_types``。
385
+ """
386
+ hub = await self.hub()
387
+ return await asyncio.to_thread(hub.get_platforms)
388
+
389
+ async def is_supported(self, url: str) -> bool:
390
+ """判断链接是否有对应的解析器。
391
+
392
+ Args:
393
+ url: 待检查的链接。
394
+
395
+ Returns:
396
+ 有解析器返回 True。
397
+ """
398
+ hub = await self.hub()
399
+ return await asyncio.to_thread(lambda: hub.get_parser(url) is not None)
400
+
401
+ async def platform_id(self, url: str) -> str | None:
402
+ """返回链接所属的平台 id。
403
+
404
+ Args:
405
+ url: 待检查的链接。
406
+
407
+ Returns:
408
+ 平台 id,例如 ``bilibili``;无对应平台时返回 None。
409
+ """
410
+ hub = await self.hub()
411
+ platform = await asyncio.to_thread(hub.get_platform, url)
412
+ return platform.id if platform else None
413
+
414
+ async def parse(
415
+ self,
416
+ url: str,
417
+ proxy: str = "",
418
+ cookie: str = "",
419
+ ) -> Any:
420
+ """解析链接或分享文案。
421
+
422
+ Args:
423
+ url: 链接或分享文案。
424
+ proxy: 解析代理,空字符串表示直连。
425
+ cookie: 登录态 cookie,空字符串表示不带。
426
+
427
+ Returns:
428
+ ParseHub 的解析结果对象。
429
+
430
+ Raises:
431
+ Exception: ParseHub 抛出的解析异常,由调用方决定如何提示用户。
432
+ """
433
+ hub = await self.hub()
434
+ return await asyncio.to_thread(
435
+ hub.parse_sync,
436
+ url,
437
+ proxy=proxy or None,
438
+ cookie=cookie or None,
439
+ )
440
+
441
+ async def download(
442
+ self,
443
+ url: str,
444
+ path: str,
445
+ proxy: str = "",
446
+ cookie: str = "",
447
+ ) -> Any:
448
+ """解析并下载媒体到指定目录。
449
+
450
+ Args:
451
+ url: 链接或分享文案。
452
+ path: 下载目录。
453
+ proxy: 解析与下载共用的代理。
454
+ cookie: 登录态 cookie。
455
+
456
+ Returns:
457
+ ParseHub 的下载结果对象,``media`` 里是本地文件。
458
+
459
+ Raises:
460
+ Exception: 下载失败时由 ParseHub 抛出的异常。
461
+ """
462
+ hub = await self.hub()
463
+ return await asyncio.to_thread(
464
+ hub.download_sync,
465
+ url,
466
+ path,
467
+ proxy=proxy or None,
468
+ parse_proxy=proxy or None,
469
+ parse_cookie=cookie or None,
470
+ )
471
+
472
+
473
+ def _import_parsehub() -> Any:
474
+ """在线程里导入 parsehub 并构造实例。
475
+
476
+ Returns:
477
+ 新的 ParseHub 实例。
478
+ """
479
+ from parsehub import ParseHub
480
+
481
+ return ParseHub()
482
+
483
+
484
+ # 进程内共享的解析客户端。
485
+ CLIENT = ParseHubClient()