nonebot-plugin-parsehub 1.0.3__tar.gz → 1.0.4__tar.gz
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-1.0.3 → nonebot_plugin_parsehub-1.0.4}/PKG-INFO +2 -2
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/pyproject.toml +2 -2
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/pyproject.toml.orig +2 -2
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/SPEC.md +2 -4
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/service.py +5 -52
- nonebot_plugin_parsehub-1.0.3/src/nonebot_plugin_parsehub/bilibili.py +0 -309
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/LICENSE +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/README.md +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/__init__.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/app.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/x.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/budget.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/cache.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/card/SPEC.md +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/card/__init__.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/card/cover.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/card/fonts.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/card/layout.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/config.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/dedupe.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/delivery.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/errors.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/feedback.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/keys.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/media.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/parsehub_client.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/paths.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/platforms.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/relay.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/switches.py +0 -0
- {nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/triggers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-parsehub
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: 基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体
|
|
5
5
|
Keywords: nonebot,nonebot2,plugin,parsehub,parser,media,onebot
|
|
6
6
|
Author: ByteColtX
|
|
@@ -18,7 +18,7 @@ Requires-Dist: nonebot-plugin-localstore>=0.7.4,<1.0.0
|
|
|
18
18
|
Requires-Dist: nonebot2>=2.5.0,<3.0.0
|
|
19
19
|
Requires-Dist: numpy>=2.0.0,<3.0.0
|
|
20
20
|
Requires-Dist: opencv-python-headless>=4.13.0.92
|
|
21
|
-
Requires-Dist: parsehub>=2.3.
|
|
21
|
+
Requires-Dist: parsehub>=2.3.14,<3.0.0
|
|
22
22
|
Requires-Dist: pillow>=12.1.0,<13.0.0
|
|
23
23
|
Requires-Dist: pydantic>=2.0.0,<3.0.0
|
|
24
24
|
Requires-Python: >=3.12
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nonebot-plugin-parsehub"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.4"
|
|
4
4
|
description = "基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -29,7 +29,7 @@ dependencies = [
|
|
|
29
29
|
"nonebot2>=2.5.0,<3.0.0",
|
|
30
30
|
"numpy>=2.0.0,<3.0.0",
|
|
31
31
|
"opencv-python-headless>=4.13.0.92",
|
|
32
|
-
"parsehub>=2.3.
|
|
32
|
+
"parsehub>=2.3.14,<3.0.0",
|
|
33
33
|
"pillow>=12.1.0,<13.0.0",
|
|
34
34
|
"pydantic>=2.0.0,<3.0.0",
|
|
35
35
|
]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nonebot-plugin-parsehub"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.4"
|
|
4
4
|
description = "基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -23,7 +23,7 @@ dependencies = [
|
|
|
23
23
|
# 卡片渐变遮罩与视频抽帧是代码里直接 import 的,必须显式声明
|
|
24
24
|
"numpy>=2.0.0,<3.0.0",
|
|
25
25
|
"opencv-python-headless>=4.13.0.92",
|
|
26
|
-
"parsehub>=2.3.
|
|
26
|
+
"parsehub>=2.3.14,<3.0.0",
|
|
27
27
|
"pillow>=12.1.0,<13.0.0",
|
|
28
28
|
"pydantic>=2.0.0,<3.0.0",
|
|
29
29
|
]
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/SPEC.md
RENAMED
|
@@ -36,7 +36,6 @@ tags: [nonebot, parsehub, module]
|
|
|
36
36
|
| `delivery.py` | `Sender` 协议、三种上传模式、合并转发、`MediaReport` |
|
|
37
37
|
| `feedback.py` | `Reaction` 与全部提示文案 |
|
|
38
38
|
| `parsehub_client.py` | 懒加载客户端、cookie 读取 |
|
|
39
|
-
| `bilibili.py` | B 站分P归一与单P时长查询 |
|
|
40
39
|
| `cache.py` | 作品级媒体缓存(原子写、TTL、容量) |
|
|
41
40
|
| `dedupe.py` | 会话级去重 |
|
|
42
41
|
| `relay.py` | 只读中转服务 |
|
|
@@ -76,9 +75,8 @@ tags: [nonebot, parsehub, module]
|
|
|
76
75
|
- 第三方依赖全是**硬依赖**:parsehub / Pillow / numpy / opencv / localstore 缺了就是
|
|
77
76
|
装错了,代码里不写 `except ImportError` 兜底,也不往别的目录写;降级只发生在产品层
|
|
78
77
|
(卡片→文字、字体→系统字体、体积探测→未知)。
|
|
79
|
-
- B 站分P
|
|
80
|
-
|
|
81
|
-
分P会写进作品 key,避免不同 P 撞缓存。
|
|
78
|
+
- B 站分P交给上游 ParseHub(2.3.14 起按 `p` 选分P并返回对应时长);插件只在
|
|
79
|
+
`keys.content_key` 里把分P写进作品 key,避免不同 P 撞缓存。
|
|
82
80
|
|
|
83
81
|
## Known limitations
|
|
84
82
|
|
|
@@ -31,7 +31,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
31
31
|
from nonebot import logger
|
|
32
32
|
from nonebot.adapters.onebot.v11 import GroupMessageEvent, MessageEvent
|
|
33
33
|
|
|
34
|
-
from . import
|
|
34
|
+
from . import errors, feedback, keys, media, platforms
|
|
35
35
|
from .budget import Budget
|
|
36
36
|
from .cache import CacheEntry
|
|
37
37
|
from .delivery import MediaReport, Sender
|
|
@@ -253,8 +253,6 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
253
253
|
本次处理的结局。
|
|
254
254
|
"""
|
|
255
255
|
settings = app.settings
|
|
256
|
-
# B 站分P:带 ?p=N 就解析第 N P,不带就按默认P(P1)。
|
|
257
|
-
parse_url = bilibili.part_url(url)
|
|
258
256
|
|
|
259
257
|
# 本地已经有这个作品的媒体就直接发,省掉解析、下载和画卡。
|
|
260
258
|
cached = await asyncio.to_thread(app.cache.get, pre_key)
|
|
@@ -262,7 +260,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
262
260
|
return await _deliver_cached(app, sender, event, cached, pre_key)
|
|
263
261
|
|
|
264
262
|
try:
|
|
265
|
-
supported = await app.client.is_supported(
|
|
263
|
+
supported = await app.client.is_supported(url)
|
|
266
264
|
except Exception as exc: # noqa: BLE001 - 判断失败就交给解析去报错
|
|
267
265
|
logger.warning(
|
|
268
266
|
f"[parsehub] 平台支持判断失败 {url}: {type(exc).__name__}: {exc}"
|
|
@@ -278,20 +276,10 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
278
276
|
cookie = await asyncio.to_thread(platform_cookie, platform, settings.cookie)
|
|
279
277
|
logger.debug(f"[parsehub] {platform} 代理={proxy or '直连'}")
|
|
280
278
|
|
|
281
|
-
# 短链展开后可能带 p,必须在解析前再归一一次;展开失败就用原链接。
|
|
282
|
-
try:
|
|
283
|
-
async with budget.timeout():
|
|
284
|
-
parse_url = await bilibili.resolve_part_url(url, proxy)
|
|
285
|
-
except Exception as exc: # noqa: BLE001 - 展开失败交给 ParseHub 自己处理
|
|
286
|
-
logger.debug(
|
|
287
|
-
f"[parsehub] 归一 B 站分P失败: {type(exc).__name__}: {exc}"
|
|
288
|
-
)
|
|
289
|
-
parse_url = bilibili.part_url(url)
|
|
290
|
-
|
|
291
279
|
started = time.monotonic()
|
|
292
280
|
try:
|
|
293
281
|
async with budget.timeout():
|
|
294
|
-
parsed = await app.client.parse(
|
|
282
|
+
parsed = await app.client.parse(url, proxy, cookie)
|
|
295
283
|
except Exception as exc: # noqa: BLE001 - parsehub 异常类型很多,统一兜底
|
|
296
284
|
failure = errors.translate(exc)
|
|
297
285
|
logger.warning(
|
|
@@ -302,7 +290,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
302
290
|
notice = feedback.failure_notice(exc)
|
|
303
291
|
return Result(Outcome.PARSE_FAILED, notice=notice, keys=(pre_key,))
|
|
304
292
|
|
|
305
|
-
post_key = keys.content_key(getattr(parsed, "raw_url", "") or
|
|
293
|
+
post_key = keys.content_key(getattr(parsed, "raw_url", "") or url)
|
|
306
294
|
all_keys = tuple(dict.fromkeys([pre_key, post_key]))
|
|
307
295
|
# 平台 id 以解析结果为准(client 可能拿不到),时长单位也跟着它。
|
|
308
296
|
platform_id = str(
|
|
@@ -323,14 +311,6 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
323
311
|
logger.info(f"[parsehub] {post_key} 在本会话刚发过,跳过发送")
|
|
324
312
|
return Result(Outcome.DUPLICATE, keys=all_keys)
|
|
325
313
|
|
|
326
|
-
# ParseHub 的 B 站结果里 duration 是所有分P的总和,这里用目标P的时长覆盖。
|
|
327
|
-
if platform_id.lower() == "bilibili":
|
|
328
|
-
lookup_url = getattr(parsed, "raw_url", "") or parse_url
|
|
329
|
-
page_seconds = await _bilibili_seconds(
|
|
330
|
-
lookup_url, proxy, cookie, budget
|
|
331
|
-
)
|
|
332
|
-
bilibili.apply_duration(parsed, page_seconds)
|
|
333
|
-
|
|
334
314
|
refs = media.refs_from(parsed)
|
|
335
315
|
seconds = platforms.duration_seconds(
|
|
336
316
|
platform_id, (ref.duration for ref in refs)
|
|
@@ -390,7 +370,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
390
370
|
proxy,
|
|
391
371
|
allow_text=False,
|
|
392
372
|
),
|
|
393
|
-
_download(app,
|
|
373
|
+
_download(app, url, proxy, cookie, budget, parsed),
|
|
394
374
|
)
|
|
395
375
|
if files is None:
|
|
396
376
|
return Result(
|
|
@@ -428,33 +408,6 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
|
|
|
428
408
|
return Result(outcome, report=report, keys=all_keys)
|
|
429
409
|
|
|
430
410
|
|
|
431
|
-
async def _bilibili_seconds(
|
|
432
|
-
url: str,
|
|
433
|
-
proxy: str,
|
|
434
|
-
cookie: str,
|
|
435
|
-
budget: Budget,
|
|
436
|
-
) -> int:
|
|
437
|
-
"""查询 B 站目标分P的时长,失败时返回 0。
|
|
438
|
-
|
|
439
|
-
Args:
|
|
440
|
-
url: 已展开的 B 站视频链接。
|
|
441
|
-
proxy: 解析代理。
|
|
442
|
-
cookie: 登录态 cookie。
|
|
443
|
-
budget: 本次工作的超时预算。
|
|
444
|
-
|
|
445
|
-
Returns:
|
|
446
|
-
目标分P的秒数;查询失败或不是视频时返回 0。
|
|
447
|
-
"""
|
|
448
|
-
try:
|
|
449
|
-
async with budget.timeout():
|
|
450
|
-
return await bilibili.part_seconds(url, proxy, cookie)
|
|
451
|
-
except Exception as exc: # noqa: BLE001 - 查时长失败不能影响主流程
|
|
452
|
-
logger.debug(
|
|
453
|
-
f"[parsehub] 查询 B 站默认P时长失败: {type(exc).__name__}: {exc}"
|
|
454
|
-
)
|
|
455
|
-
return 0
|
|
456
|
-
|
|
457
|
-
|
|
458
411
|
async def _deliver_cached(
|
|
459
412
|
app: App,
|
|
460
413
|
sender: Sender,
|
|
@@ -1,309 +0,0 @@
|
|
|
1
|
-
# Copyright 2026 ByteColtX
|
|
2
|
-
# SPDX-License-Identifier: MIT
|
|
3
|
-
|
|
4
|
-
"""B 站分P兼容:按链接指定的 P 解析,未指定时用默认P。
|
|
5
|
-
|
|
6
|
-
ParseHub 的 B 站解析器在 ``p=1`` 时直接取 ``View.duration``,而 B 站这个字段
|
|
7
|
-
是**所有分P的时长总和**;分P越多,插件越容易误判成超长视频。这里在解析前后各
|
|
8
|
-
补一刀:
|
|
9
|
-
|
|
10
|
-
1. 解析前归一 ``p``:带 ``p=N`` 就保留第 N P,不带就按默认P(P1);
|
|
11
|
-
2. 解析后向 B 站查一次对应分P的 ``duration``,覆盖结果里的总时长。
|
|
12
|
-
|
|
13
|
-
第 2 步失败时保持原样(退化成旧行为),不影响解析和下载。
|
|
14
|
-
"""
|
|
15
|
-
|
|
16
|
-
from __future__ import annotations
|
|
17
|
-
|
|
18
|
-
import re
|
|
19
|
-
from typing import Any
|
|
20
|
-
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
21
|
-
|
|
22
|
-
import httpx
|
|
23
|
-
from nonebot import logger
|
|
24
|
-
|
|
25
|
-
# B 站视频 ID。BV 号大小写敏感,av 号只在路径或参数里认。
|
|
26
|
-
_BV_PATTERN = re.compile(r"(BV[0-9A-Za-z]{10})")
|
|
27
|
-
_AV_PATTERN = re.compile(r"(?:/|=)av(\d+)", re.IGNORECASE)
|
|
28
|
-
_AID_PATTERN = re.compile(r"[?&]aid=(\d+)")
|
|
29
|
-
|
|
30
|
-
# 需要做分P归一的主机。短链没有 ID,但同样可能带 p 参数。
|
|
31
|
-
_VIDEO_HOSTS = ("bilibili.com", "b23.tv", "bili2233.cn")
|
|
32
|
-
_SHORT_HOSTS = ("b23.tv", "bili2233.cn")
|
|
33
|
-
|
|
34
|
-
_VIEW_API = "https://api.bilibili.com/x/web-interface/view"
|
|
35
|
-
_USER_AGENT = (
|
|
36
|
-
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
37
|
-
"(KHTML, like Gecko) Chrome/144.0.0.0 Safari/537.36"
|
|
38
|
-
)
|
|
39
|
-
_TIMEOUT_SECONDS = 5.0
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _video_params(url: str) -> dict[str, str] | None:
|
|
43
|
-
"""从链接里取出 B 站视频 ID。
|
|
44
|
-
|
|
45
|
-
Args:
|
|
46
|
-
url: 可能是完整链或短链展开后的链接。
|
|
47
|
-
|
|
48
|
-
Returns:
|
|
49
|
-
``{"bvid": ...}`` 或 ``{"aid": ...}``;认不出来时返回 None。
|
|
50
|
-
"""
|
|
51
|
-
match = _BV_PATTERN.search(url)
|
|
52
|
-
if match:
|
|
53
|
-
return {"bvid": match.group(1)}
|
|
54
|
-
match = _AV_PATTERN.search(url)
|
|
55
|
-
if match:
|
|
56
|
-
return {"aid": match.group(1)}
|
|
57
|
-
match = _AID_PATTERN.search(url)
|
|
58
|
-
if match:
|
|
59
|
-
return {"aid": match.group(1)}
|
|
60
|
-
return None
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _is_video_host(host: str) -> bool:
|
|
64
|
-
"""判断主机是不是 B 站视频站(含短链站)。
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
host: 已小写的主机名。
|
|
68
|
-
|
|
69
|
-
Returns:
|
|
70
|
-
命中返回 True。
|
|
71
|
-
"""
|
|
72
|
-
return any(
|
|
73
|
-
host == item or host.endswith("." + item) for item in _VIDEO_HOSTS
|
|
74
|
-
)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def _is_short_host(host: str) -> bool:
|
|
78
|
-
"""判断主机是不是 B 站短链站。
|
|
79
|
-
|
|
80
|
-
Args:
|
|
81
|
-
host: 已小写的主机名。
|
|
82
|
-
|
|
83
|
-
Returns:
|
|
84
|
-
命中返回 True。
|
|
85
|
-
"""
|
|
86
|
-
return any(
|
|
87
|
-
host == item or host.endswith("." + item) for item in _SHORT_HOSTS
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
def requested_part(url: str) -> int:
|
|
92
|
-
"""取链接里指定的分P,没写或非法时按默认P(P1)。
|
|
93
|
-
|
|
94
|
-
Args:
|
|
95
|
-
url: 待解析的链接。
|
|
96
|
-
|
|
97
|
-
Returns:
|
|
98
|
-
大于 0 的分P序号。
|
|
99
|
-
"""
|
|
100
|
-
for key, value in parse_qsl(urlsplit(url).query, keep_blank_values=True):
|
|
101
|
-
if key.lower() != "p":
|
|
102
|
-
continue
|
|
103
|
-
try:
|
|
104
|
-
part = int(value)
|
|
105
|
-
except (TypeError, ValueError):
|
|
106
|
-
return 1
|
|
107
|
-
return part if part > 0 else 1
|
|
108
|
-
return 1
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _set_part(url: str, part: int) -> str:
|
|
112
|
-
"""把链接的 ``p`` 参数改写成指定分P。
|
|
113
|
-
|
|
114
|
-
Args:
|
|
115
|
-
url: 待改写的链接。
|
|
116
|
-
part: 分P序号,1 表示默认P(不写 ``p``)。
|
|
117
|
-
|
|
118
|
-
Returns:
|
|
119
|
-
改写后的链接。
|
|
120
|
-
"""
|
|
121
|
-
parsed = urlsplit(url)
|
|
122
|
-
query = [
|
|
123
|
-
(key, value)
|
|
124
|
-
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
|
125
|
-
if key.lower() != "p"
|
|
126
|
-
]
|
|
127
|
-
if part > 1:
|
|
128
|
-
query.append(("p", str(part)))
|
|
129
|
-
return urlunsplit(parsed._replace(query=urlencode(query)))
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def part_url(url: str) -> str:
|
|
133
|
-
"""归一 B 站视频链接的分P参数。
|
|
134
|
-
|
|
135
|
-
带 ``p=N`` 就保留第 N P;不带、非法或 ``p=1`` 都按默认P处理(去掉 ``p``)。
|
|
136
|
-
|
|
137
|
-
Args:
|
|
138
|
-
url: 待解析的链接。
|
|
139
|
-
|
|
140
|
-
Returns:
|
|
141
|
-
归一后的链接;非 B 站视频链接原样返回。
|
|
142
|
-
"""
|
|
143
|
-
parsed = urlsplit(url)
|
|
144
|
-
host = (parsed.hostname or "").lower()
|
|
145
|
-
if not _is_video_host(host):
|
|
146
|
-
return url
|
|
147
|
-
if not _is_short_host(host) and _video_params(url) is None:
|
|
148
|
-
return url
|
|
149
|
-
return _set_part(url, requested_part(url))
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
async def resolve_part_url(url: str, proxy: str = "") -> str:
|
|
153
|
-
"""展开短链后归一 ``p``,确保短链也按目标分P解析。
|
|
154
|
-
|
|
155
|
-
短链本身没有 ID,ParseHub 展开时会把重定向目标里的 ``p`` 保留下来;这里先
|
|
156
|
-
自己展开一次,拿到完整链后统一归一。展开失败就退回原链接。
|
|
157
|
-
|
|
158
|
-
Args:
|
|
159
|
-
url: 用户发的链接。
|
|
160
|
-
proxy: 展开短链用的代理。
|
|
161
|
-
|
|
162
|
-
Returns:
|
|
163
|
-
指向目标分P的完整链;非 B 站短链原样返回。
|
|
164
|
-
"""
|
|
165
|
-
parsed = urlsplit(url)
|
|
166
|
-
host = (parsed.hostname or "").lower()
|
|
167
|
-
if not _is_short_host(host):
|
|
168
|
-
return part_url(url)
|
|
169
|
-
part = requested_part(url)
|
|
170
|
-
try:
|
|
171
|
-
async with _client(proxy) as client:
|
|
172
|
-
response = await client.get(
|
|
173
|
-
url,
|
|
174
|
-
headers={"User-Agent": _USER_AGENT},
|
|
175
|
-
follow_redirects=True,
|
|
176
|
-
)
|
|
177
|
-
response.raise_for_status()
|
|
178
|
-
resolved = str(response.url)
|
|
179
|
-
except Exception as exc: # noqa: BLE001 - 展开失败交给 ParseHub 自己处理
|
|
180
|
-
logger.debug(
|
|
181
|
-
f"[parsehub] 展开 B 站短链失败: {type(exc).__name__}: {exc}"
|
|
182
|
-
)
|
|
183
|
-
return part_url(url)
|
|
184
|
-
# 重定向到非视频页时把短链还给 ParseHub,让它按原链接选解析器。
|
|
185
|
-
if _video_params(resolved) is None:
|
|
186
|
-
return part_url(url)
|
|
187
|
-
target = part_url(resolved)
|
|
188
|
-
# 短链自己带了 p,而重定向目标没带:保留用户指定的分P。
|
|
189
|
-
if part > 1 and requested_part(target) == 1:
|
|
190
|
-
target = _set_part(target, part)
|
|
191
|
-
return target
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def page_seconds(data: Any, part: int = 1) -> int: # noqa: ANN401 - B 站接口 JSON
|
|
195
|
-
"""从 B 站 ``view`` 接口的 ``data`` 里取指定分P时长。
|
|
196
|
-
|
|
197
|
-
Args:
|
|
198
|
-
data: 接口返回的 ``data`` 字段。
|
|
199
|
-
part: 分P序号,1 表示默认P。
|
|
200
|
-
|
|
201
|
-
Returns:
|
|
202
|
-
该分P的秒数;找不到时退回第一个分P,再拿不到退回总时长,最后返回 0。
|
|
203
|
-
"""
|
|
204
|
-
if not isinstance(data, dict):
|
|
205
|
-
return 0
|
|
206
|
-
pages = data.get("pages")
|
|
207
|
-
if isinstance(pages, list) and pages:
|
|
208
|
-
chosen: dict | None = None
|
|
209
|
-
for item in pages:
|
|
210
|
-
if not isinstance(item, dict):
|
|
211
|
-
continue
|
|
212
|
-
try:
|
|
213
|
-
page_no = int(item.get("page") or 0)
|
|
214
|
-
except (TypeError, ValueError):
|
|
215
|
-
continue
|
|
216
|
-
if page_no == part:
|
|
217
|
-
chosen = item
|
|
218
|
-
break
|
|
219
|
-
if chosen is None:
|
|
220
|
-
chosen = next(
|
|
221
|
-
(item for item in pages if isinstance(item, dict)), None
|
|
222
|
-
)
|
|
223
|
-
if chosen is not None:
|
|
224
|
-
try:
|
|
225
|
-
return max(int(chosen.get("duration") or 0), 0)
|
|
226
|
-
except (TypeError, ValueError):
|
|
227
|
-
pass
|
|
228
|
-
try:
|
|
229
|
-
return max(int(data.get("duration") or 0), 0)
|
|
230
|
-
except (TypeError, ValueError):
|
|
231
|
-
return 0
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
def _client(proxy: str) -> httpx.AsyncClient:
|
|
235
|
-
"""构造查接口用的 HTTP 客户端(测试里替换成不联网的假实现)。
|
|
236
|
-
|
|
237
|
-
Args:
|
|
238
|
-
proxy: 代理地址,空字符串表示直连。
|
|
239
|
-
|
|
240
|
-
Returns:
|
|
241
|
-
异步 HTTP 客户端。
|
|
242
|
-
"""
|
|
243
|
-
return httpx.AsyncClient(
|
|
244
|
-
proxy=proxy or None,
|
|
245
|
-
timeout=_TIMEOUT_SECONDS,
|
|
246
|
-
follow_redirects=True,
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
async def part_seconds(
|
|
251
|
-
url: str,
|
|
252
|
-
proxy: str = "",
|
|
253
|
-
cookie: str = "",
|
|
254
|
-
) -> int:
|
|
255
|
-
"""查询 B 站视频指定分P的时长。
|
|
256
|
-
|
|
257
|
-
Args:
|
|
258
|
-
url: 视频链接(完整链或已展开的短链)。
|
|
259
|
-
proxy: 解析代理,空字符串表示直连。
|
|
260
|
-
cookie: 登录态 cookie,空字符串表示不带。
|
|
261
|
-
|
|
262
|
-
Returns:
|
|
263
|
-
指定分P的秒数;不是 B 站视频或请求失败时返回 0。
|
|
264
|
-
"""
|
|
265
|
-
params = _video_params(url)
|
|
266
|
-
if params is None:
|
|
267
|
-
return 0
|
|
268
|
-
headers = {
|
|
269
|
-
"User-Agent": _USER_AGENT,
|
|
270
|
-
"Referer": "https://www.bilibili.com/",
|
|
271
|
-
}
|
|
272
|
-
if cookie:
|
|
273
|
-
headers["Cookie"] = cookie
|
|
274
|
-
try:
|
|
275
|
-
async with _client(proxy) as client:
|
|
276
|
-
response = await client.get(
|
|
277
|
-
_VIEW_API, params=params, headers=headers
|
|
278
|
-
)
|
|
279
|
-
response.raise_for_status()
|
|
280
|
-
payload = response.json()
|
|
281
|
-
except Exception as exc: # noqa: BLE001 - 查时长失败不能影响解析
|
|
282
|
-
logger.debug(
|
|
283
|
-
f"[parsehub] 查询 B 站分P时长失败: {type(exc).__name__}: {exc}"
|
|
284
|
-
)
|
|
285
|
-
return 0
|
|
286
|
-
data = payload.get("data") if isinstance(payload, dict) else None
|
|
287
|
-
return page_seconds(data, requested_part(url))
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def apply_duration(result: Any, seconds: int) -> None: # noqa: ANN401 - parsehub 结果
|
|
291
|
-
"""把分P时长写回解析结果里的第一个媒体。
|
|
292
|
-
|
|
293
|
-
卡片和时长上限都从 ``result.media[].duration`` 读,改这里两边一起生效。
|
|
294
|
-
|
|
295
|
-
Args:
|
|
296
|
-
result: ParseHub 的解析结果。
|
|
297
|
-
seconds: 分P的秒数。
|
|
298
|
-
"""
|
|
299
|
-
if seconds <= 0:
|
|
300
|
-
return
|
|
301
|
-
media = getattr(result, "media", None)
|
|
302
|
-
items = list(media) if isinstance(media, (list, tuple)) else [media]
|
|
303
|
-
for item in items:
|
|
304
|
-
if item is not None and hasattr(item, "duration"):
|
|
305
|
-
try:
|
|
306
|
-
item.duration = seconds
|
|
307
|
-
except (AttributeError, TypeError):
|
|
308
|
-
pass
|
|
309
|
-
return
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/cache.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/keys.py
RENAMED
|
File without changes
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/media.py
RENAMED
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/paths.py
RENAMED
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.3 → nonebot_plugin_parsehub-1.0.4}/src/nonebot_plugin_parsehub/relay.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|