nonebot-plugin-parsehub 1.0.1__tar.gz → 1.0.2__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.
Files changed (52) hide show
  1. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/PKG-INFO +2 -2
  2. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/pyproject.toml +2 -2
  3. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/pyproject.toml.orig +2 -2
  4. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/SPEC.md +3 -0
  5. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/__init__.py +1 -1
  6. nonebot_plugin_parsehub-1.0.2/src/nonebot_plugin_parsehub/bilibili.py +252 -0
  7. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/service.py +52 -5
  8. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/LICENSE +0 -0
  9. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/README.md +0 -0
  10. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/app.py +0 -0
  11. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
  12. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
  13. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
  14. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
  15. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
  16. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
  17. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
  18. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
  19. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
  20. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
  21. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
  22. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
  23. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
  24. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
  25. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
  26. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
  27. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/x.png +0 -0
  28. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
  29. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
  30. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
  31. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
  32. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
  33. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/budget.py +0 -0
  34. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/cache.py +0 -0
  35. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/card/SPEC.md +0 -0
  36. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/card/__init__.py +0 -0
  37. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/card/cover.py +0 -0
  38. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/card/fonts.py +0 -0
  39. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/card/layout.py +0 -0
  40. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/config.py +0 -0
  41. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/dedupe.py +0 -0
  42. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/delivery.py +0 -0
  43. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/errors.py +0 -0
  44. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/feedback.py +0 -0
  45. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/keys.py +0 -0
  46. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/media.py +0 -0
  47. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/parsehub_client.py +0 -0
  48. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/paths.py +0 -0
  49. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/platforms.py +0 -0
  50. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/relay.py +0 -0
  51. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/src/nonebot_plugin_parsehub/switches.py +0 -0
  52. {nonebot_plugin_parsehub-1.0.1 → nonebot_plugin_parsehub-1.0.2}/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.1
3
+ Version: 1.0.2
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.10,<3.0.0
21
+ Requires-Dist: parsehub>=2.3.13,<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.1"
3
+ version = "1.0.2"
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.10,<3.0.0",
32
+ "parsehub>=2.3.13,<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.1"
3
+ version = "1.0.2"
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.10,<3.0.0",
26
+ "parsehub>=2.3.13,<3.0.0",
27
27
  "pillow>=12.1.0,<13.0.0",
28
28
  "pydantic>=2.0.0,<3.0.0",
29
29
  ]
@@ -36,6 +36,7 @@ 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时长查询 |
39
40
  | `cache.py` | 作品级媒体缓存(原子写、TTL、容量) |
40
41
  | `dedupe.py` | 会话级去重 |
41
42
  | `relay.py` | 只读中转服务 |
@@ -75,6 +76,8 @@ tags: [nonebot, parsehub, module]
75
76
  - 第三方依赖全是**硬依赖**:parsehub / Pillow / numpy / opencv / localstore 缺了就是
76
77
  装错了,代码里不写 `except ImportError` 兜底,也不往别的目录写;降级只发生在产品层
77
78
  (卡片→文字、字体→系统字体、体积探测→未知)。
79
+ - B 站分P由 `bilibili` 收口:解析前去掉 `p`(只解析默认P),解析后用
80
+ `pages[0].duration` 覆盖 ParseHub 给的分P总时长;查不到就保持原值。
78
81
 
79
82
  ## Known limitations
80
83
 
@@ -29,7 +29,7 @@ from .app import App
29
29
  from .config import Config, Settings, plugin_config
30
30
 
31
31
  __plugin_meta__ = PluginMetadata(
32
- name="ParseHub 链接解析",
32
+ name="轻量、异步、开箱即用的社交媒体聚合解析插件",
33
33
  description=(
34
34
  "群里出现受支持的分享链接时,用 ParseHub 自动解析并发送媒体"
35
35
  "(视频 / 图集 / 正文)。"
@@ -0,0 +1,252 @@
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`` 参数,让 ParseHub 始终解析默认P;
11
+ 2. 解析后向 B 站查一次 ``pages[0].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 default_part_url(url: str) -> str:
92
+ """去掉 B 站视频链接里的 ``p`` 参数。
93
+
94
+ 去掉后 ParseHub 会解析默认P(P1);对短链也先去掉,展开后即使带 ``p``
95
+ 也不会被保留。
96
+
97
+ Args:
98
+ url: 待解析的链接。
99
+
100
+ Returns:
101
+ 去掉 ``p`` 后的链接;非 B 站视频链接原样返回。
102
+ """
103
+ parsed = urlsplit(url)
104
+ host = (parsed.hostname or "").lower()
105
+ if not _is_video_host(host):
106
+ return url
107
+ if not _is_short_host(host) and _video_params(url) is None:
108
+ return url
109
+ query = [
110
+ (key, value)
111
+ for key, value in parse_qsl(parsed.query, keep_blank_values=True)
112
+ if key.lower() != "p"
113
+ ]
114
+ return urlunsplit(parsed._replace(query=urlencode(query)))
115
+
116
+
117
+ async def resolve_default_part_url(url: str, proxy: str = "") -> str:
118
+ """展开短链后再去掉 ``p``,确保短链也只解析默认P。
119
+
120
+ 短链本身没有 ID,ParseHub 展开时会把重定向目标里的 ``p`` 保留下来;这里先
121
+ 自己展开一次,拿到完整链后统一去掉 ``p``。展开失败就退回原链接。
122
+
123
+ Args:
124
+ url: 用户发的链接。
125
+ proxy: 展开短链用的代理。
126
+
127
+ Returns:
128
+ 指向默认P的完整链;非 B 站短链原样返回。
129
+ """
130
+ parsed = urlsplit(url)
131
+ host = (parsed.hostname or "").lower()
132
+ if not _is_short_host(host):
133
+ return default_part_url(url)
134
+ try:
135
+ async with _client(proxy) as client:
136
+ response = await client.get(
137
+ url,
138
+ headers={"User-Agent": _USER_AGENT},
139
+ follow_redirects=True,
140
+ )
141
+ response.raise_for_status()
142
+ resolved = str(response.url)
143
+ except Exception as exc: # noqa: BLE001 - 展开失败交给 ParseHub 自己处理
144
+ logger.debug(
145
+ f"[parsehub] 展开 B 站短链失败: {type(exc).__name__}: {exc}"
146
+ )
147
+ return default_part_url(url)
148
+ # 重定向到非视频页时把短链还给 ParseHub,让它按原链接选解析器。
149
+ if _video_params(resolved) is None:
150
+ return default_part_url(url)
151
+ return default_part_url(resolved)
152
+
153
+
154
+ def default_page_seconds(data: Any) -> int: # noqa: ANN401 - B 站接口 JSON
155
+ """从 B 站 ``view`` 接口的 ``data`` 里取默认P时长。
156
+
157
+ Args:
158
+ data: 接口返回的 ``data`` 字段。
159
+
160
+ Returns:
161
+ 默认P(``pages[0]``)的秒数;拿不到时退回总时长,再拿不到返回 0。
162
+ """
163
+ if not isinstance(data, dict):
164
+ return 0
165
+ pages = data.get("pages")
166
+ if isinstance(pages, list) and pages and isinstance(pages[0], dict):
167
+ try:
168
+ return max(int(pages[0].get("duration") or 0), 0)
169
+ except (TypeError, ValueError):
170
+ pass
171
+ try:
172
+ return max(int(data.get("duration") or 0), 0)
173
+ except (TypeError, ValueError):
174
+ return 0
175
+
176
+
177
+ def _client(proxy: str) -> httpx.AsyncClient:
178
+ """构造查接口用的 HTTP 客户端(测试里替换成不联网的假实现)。
179
+
180
+ Args:
181
+ proxy: 代理地址,空字符串表示直连。
182
+
183
+ Returns:
184
+ 异步 HTTP 客户端。
185
+ """
186
+ return httpx.AsyncClient(
187
+ proxy=proxy or None,
188
+ timeout=_TIMEOUT_SECONDS,
189
+ follow_redirects=True,
190
+ )
191
+
192
+
193
+ async def default_part_seconds(
194
+ url: str,
195
+ proxy: str = "",
196
+ cookie: str = "",
197
+ ) -> int:
198
+ """查询 B 站视频默认P的时长。
199
+
200
+ Args:
201
+ url: 视频链接(完整链或已展开的短链)。
202
+ proxy: 解析代理,空字符串表示直连。
203
+ cookie: 登录态 cookie,空字符串表示不带。
204
+
205
+ Returns:
206
+ 默认P的秒数;不是 B 站视频或请求失败时返回 0。
207
+ """
208
+ params = _video_params(url)
209
+ if params is None:
210
+ return 0
211
+ headers = {
212
+ "User-Agent": _USER_AGENT,
213
+ "Referer": "https://www.bilibili.com/",
214
+ }
215
+ if cookie:
216
+ headers["Cookie"] = cookie
217
+ try:
218
+ async with _client(proxy) as client:
219
+ response = await client.get(
220
+ _VIEW_API, params=params, headers=headers
221
+ )
222
+ response.raise_for_status()
223
+ payload = response.json()
224
+ except Exception as exc: # noqa: BLE001 - 查时长失败不能影响解析
225
+ logger.debug(
226
+ f"[parsehub] 查询 B 站默认P时长失败: {type(exc).__name__}: {exc}"
227
+ )
228
+ return 0
229
+ data = payload.get("data") if isinstance(payload, dict) else None
230
+ return default_page_seconds(data)
231
+
232
+
233
+ def apply_duration(result: Any, seconds: int) -> None: # noqa: ANN401 - parsehub 结果
234
+ """把默认P时长写回解析结果里的第一个媒体。
235
+
236
+ 卡片和时长上限都从 ``result.media[].duration`` 读,改这里两边一起生效。
237
+
238
+ Args:
239
+ result: ParseHub 的解析结果。
240
+ seconds: 默认P的秒数。
241
+ """
242
+ if seconds <= 0:
243
+ return
244
+ media = getattr(result, "media", None)
245
+ items = list(media) if isinstance(media, (list, tuple)) else [media]
246
+ for item in items:
247
+ if item is not None and hasattr(item, "duration"):
248
+ try:
249
+ item.duration = seconds
250
+ except (AttributeError, TypeError):
251
+ pass
252
+ return
@@ -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 errors, feedback, keys, media, platforms
34
+ from . import bilibili, 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,6 +253,8 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
253
253
  本次处理的结局。
254
254
  """
255
255
  settings = app.settings
256
+ # B 站只解析默认P:去掉 p 参数,避免用户发的 ?p=2 被当成这次要发的分P。
257
+ parse_url = bilibili.default_part_url(url)
256
258
 
257
259
  # 本地已经有这个作品的媒体就直接发,省掉解析、下载和画卡。
258
260
  cached = await asyncio.to_thread(app.cache.get, pre_key)
@@ -260,7 +262,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
260
262
  return await _deliver_cached(app, sender, event, cached, pre_key)
261
263
 
262
264
  try:
263
- supported = await app.client.is_supported(url)
265
+ supported = await app.client.is_supported(parse_url)
264
266
  except Exception as exc: # noqa: BLE001 - 判断失败就交给解析去报错
265
267
  logger.warning(
266
268
  f"[parsehub] 平台支持判断失败 {url}: {type(exc).__name__}: {exc}"
@@ -276,10 +278,20 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
276
278
  cookie = await asyncio.to_thread(platform_cookie, platform, settings.cookie)
277
279
  logger.debug(f"[parsehub] {platform} 代理={proxy or '直连'}")
278
280
 
281
+ # 短链展开后可能带 p,必须在解析前再归一一次;展开失败就用原链接。
282
+ try:
283
+ async with budget.timeout():
284
+ parse_url = await bilibili.resolve_default_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.default_part_url(url)
290
+
279
291
  started = time.monotonic()
280
292
  try:
281
293
  async with budget.timeout():
282
- parsed = await app.client.parse(url, proxy, cookie)
294
+ parsed = await app.client.parse(parse_url, proxy, cookie)
283
295
  except Exception as exc: # noqa: BLE001 - parsehub 异常类型很多,统一兜底
284
296
  failure = errors.translate(exc)
285
297
  logger.warning(
@@ -290,7 +302,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
290
302
  notice = feedback.failure_notice(exc)
291
303
  return Result(Outcome.PARSE_FAILED, notice=notice, keys=(pre_key,))
292
304
 
293
- post_key = keys.content_key(getattr(parsed, "raw_url", "") or url)
305
+ post_key = keys.content_key(getattr(parsed, "raw_url", "") or parse_url)
294
306
  all_keys = tuple(dict.fromkeys([pre_key, post_key]))
295
307
  # 平台 id 以解析结果为准(client 可能拿不到),时长单位也跟着它。
296
308
  platform_id = str(
@@ -311,6 +323,14 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
311
323
  logger.info(f"[parsehub] {post_key} 在本会话刚发过,跳过发送")
312
324
  return Result(Outcome.DUPLICATE, keys=all_keys)
313
325
 
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
+
314
334
  refs = media.refs_from(parsed)
315
335
  seconds = platforms.duration_seconds(
316
336
  platform_id, (ref.duration for ref in refs)
@@ -370,7 +390,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
370
390
  proxy,
371
391
  allow_text=False,
372
392
  ),
373
- _download(app, url, proxy, cookie, budget, parsed),
393
+ _download(app, parse_url, proxy, cookie, budget, parsed),
374
394
  )
375
395
  if files is None:
376
396
  return Result(
@@ -408,6 +428,33 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
408
428
  return Result(outcome, report=report, keys=all_keys)
409
429
 
410
430
 
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.default_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
+
411
458
  async def _deliver_cached(
412
459
  app: App,
413
460
  sender: Sender,