nonebot-plugin-parsehub 1.0.2__tar.gz → 1.0.3__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.2 → nonebot_plugin_parsehub-1.0.3}/PKG-INFO +2 -1
  2. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/README.md +1 -0
  3. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/pyproject.toml +1 -1
  4. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/pyproject.toml.orig +1 -1
  5. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/SPEC.md +4 -3
  6. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/bilibili.py +95 -38
  7. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/keys.py +53 -5
  8. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/service.py +9 -9
  9. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/LICENSE +0 -0
  10. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/__init__.py +0 -0
  11. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/app.py +0 -0
  12. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
  13. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
  14. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
  15. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
  16. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
  17. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
  18. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
  19. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
  20. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
  21. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
  22. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
  23. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
  24. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
  25. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
  26. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
  27. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
  28. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/x.png +0 -0
  29. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
  30. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
  31. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
  32. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
  33. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
  34. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/budget.py +0 -0
  35. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/cache.py +0 -0
  36. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/card/SPEC.md +0 -0
  37. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/card/__init__.py +0 -0
  38. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/card/cover.py +0 -0
  39. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/card/fonts.py +0 -0
  40. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/card/layout.py +0 -0
  41. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/config.py +0 -0
  42. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/dedupe.py +0 -0
  43. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/delivery.py +0 -0
  44. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/errors.py +0 -0
  45. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/feedback.py +0 -0
  46. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/media.py +0 -0
  47. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/parsehub_client.py +0 -0
  48. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/paths.py +0 -0
  49. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/platforms.py +0 -0
  50. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/relay.py +0 -0
  51. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/src/nonebot_plugin_parsehub/switches.py +0 -0
  52. {nonebot_plugin_parsehub-1.0.2 → nonebot_plugin_parsehub-1.0.3}/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.2
3
+ Version: 1.0.3
4
4
  Summary: 基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体
5
5
  Keywords: nonebot,nonebot2,plugin,parsehub,parser,media,onebot
6
6
  Author: ByteColtX
@@ -168,6 +168,7 @@ plugins = ["nonebot_plugin_parsehub"]
168
168
 
169
169
  ### 说明
170
170
 
171
+ - B 站链接带 `?p=N` 时解析第 N P,不带时默认 P1;时长按该 P 计算
171
172
  - 视频被协议端拒绝时只点失败表情,**不会**退回群文件
172
173
  - 投递三选一、**互不兜底**:`file` 发本机路径(协议端同机)/ `http` 发中转地址(跨机)/ `base64` 内容内联(请求体约 +1/3)
173
174
  - parsehub 全程同步阻塞,插件把所有调用放进 `asyncio.to_thread`;首次导入懒加载,不影响启动
@@ -139,6 +139,7 @@ plugins = ["nonebot_plugin_parsehub"]
139
139
 
140
140
  ### 说明
141
141
 
142
+ - B 站链接带 `?p=N` 时解析第 N P,不带时默认 P1;时长按该 P 计算
142
143
  - 视频被协议端拒绝时只点失败表情,**不会**退回群文件
143
144
  - 投递三选一、**互不兜底**:`file` 发本机路径(协议端同机)/ `http` 发中转地址(跨机)/ `base64` 内容内联(请求体约 +1/3)
144
145
  - parsehub 全程同步阻塞,插件把所有调用放进 `asyncio.to_thread`;首次导入懒加载,不影响启动
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parsehub"
3
- version = "1.0.2"
3
+ version = "1.0.3"
4
4
  description = "基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parsehub"
3
- version = "1.0.2"
3
+ version = "1.0.3"
4
4
  description = "基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -36,7 +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
+ | `bilibili.py` | B 站分P归一与单P时长查询 |
40
40
  | `cache.py` | 作品级媒体缓存(原子写、TTL、容量) |
41
41
  | `dedupe.py` | 会话级去重 |
42
42
  | `relay.py` | 只读中转服务 |
@@ -76,8 +76,9 @@ tags: [nonebot, parsehub, module]
76
76
  - 第三方依赖全是**硬依赖**:parsehub / Pillow / numpy / opencv / localstore 缺了就是
77
77
  装错了,代码里不写 `except ImportError` 兜底,也不往别的目录写;降级只发生在产品层
78
78
  (卡片→文字、字体→系统字体、体积探测→未知)。
79
- - B 站分P由 `bilibili` 收口:解析前去掉 `p`(只解析默认P),解析后用
80
- `pages[0].duration` 覆盖 ParseHub 给的分P总时长;查不到就保持原值。
79
+ - B 站分P由 `bilibili` 收口:链接带 `p=N` 就解析第 N P,不带时用默认P(P1);
80
+ 解析后用对应 `pages` 项的 `duration` 覆盖 ParseHub 给的分P总时长,查不到就保持原值。
81
+ 分P会写进作品 key,避免不同 P 撞缓存。
81
82
 
82
83
  ## Known limitations
83
84
 
@@ -1,14 +1,14 @@
1
1
  # Copyright 2026 ByteColtX
2
2
  # SPDX-License-Identifier: MIT
3
3
 
4
- """B 站分P兼容:只解析默认P,并按默认P的时长判断。
4
+ """B 站分P兼容:按链接指定的 P 解析,未指定时用默认P。
5
5
 
6
6
  ParseHub 的 B 站解析器在 ``p=1`` 时直接取 ``View.duration``,而 B 站这个字段
7
7
  是**所有分P的时长总和**;分P越多,插件越容易误判成超长视频。这里在解析前后各
8
8
  补一刀:
9
9
 
10
- 1. 解析前去掉 ``p`` 参数,让 ParseHub 始终解析默认P;
11
- 2. 解析后向 B 站查一次 ``pages[0].duration``,覆盖结果里的总时长。
10
+ 1. 解析前归一 ``p``:带 ``p=N`` 就保留第 N P,不带就按默认P(P1);
11
+ 2. 解析后向 B 站查一次对应分P的 ``duration``,覆盖结果里的总时长。
12
12
 
13
13
  第 2 步失败时保持原样(退化成旧行为),不影响解析和下载。
14
14
  """
@@ -27,7 +27,7 @@ _BV_PATTERN = re.compile(r"(BV[0-9A-Za-z]{10})")
27
27
  _AV_PATTERN = re.compile(r"(?:/|=)av(\d+)", re.IGNORECASE)
28
28
  _AID_PATTERN = re.compile(r"[?&]aid=(\d+)")
29
29
 
30
- # 需要做默认P归一的主机。短链没有 ID,但同样可能带 p 参数。
30
+ # 需要做分P归一的主机。短链没有 ID,但同样可能带 p 参数。
31
31
  _VIDEO_HOSTS = ("bilibili.com", "b23.tv", "bili2233.cn")
32
32
  _SHORT_HOSTS = ("b23.tv", "bili2233.cn")
33
33
 
@@ -88,49 +88,85 @@ def _is_short_host(host: str) -> bool:
88
88
  )
89
89
 
90
90
 
91
- def default_part_url(url: str) -> str:
92
- """去掉 B 站视频链接里的 ``p`` 参数。
93
-
94
- 去掉后 ParseHub 会解析默认P(P1);对短链也先去掉,展开后即使带 ``p``
95
- 也不会被保留。
91
+ def requested_part(url: str) -> int:
92
+ """取链接里指定的分P,没写或非法时按默认P(P1)。
96
93
 
97
94
  Args:
98
95
  url: 待解析的链接。
99
96
 
100
97
  Returns:
101
- 去掉 ``p`` 后的链接;非 B 站视频链接原样返回。
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
+ 改写后的链接。
102
120
  """
103
121
  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
122
  query = [
110
123
  (key, value)
111
124
  for key, value in parse_qsl(parsed.query, keep_blank_values=True)
112
125
  if key.lower() != "p"
113
126
  ]
127
+ if part > 1:
128
+ query.append(("p", str(part)))
114
129
  return urlunsplit(parsed._replace(query=urlencode(query)))
115
130
 
116
131
 
117
- async def resolve_default_part_url(url: str, proxy: str = "") -> str:
118
- """展开短链后再去掉 ``p``,确保短链也只解析默认P。
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解析。
119
154
 
120
155
  短链本身没有 ID,ParseHub 展开时会把重定向目标里的 ``p`` 保留下来;这里先
121
- 自己展开一次,拿到完整链后统一去掉 ``p``。展开失败就退回原链接。
156
+ 自己展开一次,拿到完整链后统一归一。展开失败就退回原链接。
122
157
 
123
158
  Args:
124
159
  url: 用户发的链接。
125
160
  proxy: 展开短链用的代理。
126
161
 
127
162
  Returns:
128
- 指向默认P的完整链;非 B 站短链原样返回。
163
+ 指向目标分P的完整链;非 B 站短链原样返回。
129
164
  """
130
165
  parsed = urlsplit(url)
131
166
  host = (parsed.hostname or "").lower()
132
167
  if not _is_short_host(host):
133
- return default_part_url(url)
168
+ return part_url(url)
169
+ part = requested_part(url)
134
170
  try:
135
171
  async with _client(proxy) as client:
136
172
  response = await client.get(
@@ -144,30 +180,51 @@ async def resolve_default_part_url(url: str, proxy: str = "") -> str:
144
180
  logger.debug(
145
181
  f"[parsehub] 展开 B 站短链失败: {type(exc).__name__}: {exc}"
146
182
  )
147
- return default_part_url(url)
183
+ return part_url(url)
148
184
  # 重定向到非视频页时把短链还给 ParseHub,让它按原链接选解析器。
149
185
  if _video_params(resolved) is None:
150
- return default_part_url(url)
151
- return default_part_url(resolved)
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
152
192
 
153
193
 
154
- def default_page_seconds(data: Any) -> int: # noqa: ANN401 - B 站接口 JSON
155
- """从 B 站 ``view`` 接口的 ``data`` 里取默认P时长。
194
+ def page_seconds(data: Any, part: int = 1) -> int: # noqa: ANN401 - B 站接口 JSON
195
+ """从 B 站 ``view`` 接口的 ``data`` 里取指定分P时长。
156
196
 
157
197
  Args:
158
198
  data: 接口返回的 ``data`` 字段。
199
+ part: 分P序号,1 表示默认P。
159
200
 
160
201
  Returns:
161
- 默认P(``pages[0]``)的秒数;拿不到时退回总时长,再拿不到返回 0。
202
+ 该分P的秒数;找不到时退回第一个分P,再拿不到退回总时长,最后返回 0。
162
203
  """
163
204
  if not isinstance(data, dict):
164
205
  return 0
165
206
  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
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
171
228
  try:
172
229
  return max(int(data.get("duration") or 0), 0)
173
230
  except (TypeError, ValueError):
@@ -190,12 +247,12 @@ def _client(proxy: str) -> httpx.AsyncClient:
190
247
  )
191
248
 
192
249
 
193
- async def default_part_seconds(
250
+ async def part_seconds(
194
251
  url: str,
195
252
  proxy: str = "",
196
253
  cookie: str = "",
197
254
  ) -> int:
198
- """查询 B 站视频默认P的时长。
255
+ """查询 B 站视频指定分P的时长。
199
256
 
200
257
  Args:
201
258
  url: 视频链接(完整链或已展开的短链)。
@@ -203,7 +260,7 @@ async def default_part_seconds(
203
260
  cookie: 登录态 cookie,空字符串表示不带。
204
261
 
205
262
  Returns:
206
- 默认P的秒数;不是 B 站视频或请求失败时返回 0。
263
+ 指定分P的秒数;不是 B 站视频或请求失败时返回 0。
207
264
  """
208
265
  params = _video_params(url)
209
266
  if params is None:
@@ -223,21 +280,21 @@ async def default_part_seconds(
223
280
  payload = response.json()
224
281
  except Exception as exc: # noqa: BLE001 - 查时长失败不能影响解析
225
282
  logger.debug(
226
- f"[parsehub] 查询 B 站默认P时长失败: {type(exc).__name__}: {exc}"
283
+ f"[parsehub] 查询 B 站分P时长失败: {type(exc).__name__}: {exc}"
227
284
  )
228
285
  return 0
229
286
  data = payload.get("data") if isinstance(payload, dict) else None
230
- return default_page_seconds(data)
287
+ return page_seconds(data, requested_part(url))
231
288
 
232
289
 
233
290
  def apply_duration(result: Any, seconds: int) -> None: # noqa: ANN401 - parsehub 结果
234
- """把默认P时长写回解析结果里的第一个媒体。
291
+ """把分P时长写回解析结果里的第一个媒体。
235
292
 
236
293
  卡片和时长上限都从 ``result.media[].duration`` 读,改这里两边一起生效。
237
294
 
238
295
  Args:
239
296
  result: ParseHub 的解析结果。
240
- seconds: 默认P的秒数。
297
+ seconds: 分P的秒数。
241
298
  """
242
299
  if seconds <= 0:
243
300
  return
@@ -14,7 +14,7 @@
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
- from urllib.parse import urlsplit
17
+ from urllib.parse import parse_qsl, urlsplit
18
18
 
19
19
  # 预筛白名单:只认这些域名后缀(含子域名)。
20
20
  ALLOWED_HOSTS: tuple[str, ...] = (
@@ -88,6 +88,44 @@ _ID_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
88
88
  # 归一化时要去掉的主机名前缀(m.bilibili.com 与 www.bilibili.com 是同一个站)。
89
89
  _HOST_PREFIXES = ("www.", "m.", "mobile.")
90
90
 
91
+ # B 站短链没有作品 ID,但分P要进 key,否则同一个短链的不同 P 会撞缓存。
92
+ _BILIBILI_SHORT_HOSTS = ("b23.tv", "bili2233.cn")
93
+
94
+
95
+ def _bilibili_part(url: str) -> int:
96
+ """取 B 站链接里的分P序号。
97
+
98
+ Args:
99
+ url: 链接。
100
+
101
+ Returns:
102
+ 大于 0 的分P序号;没写或非法时为默认P(1)。
103
+ """
104
+ for key, value in parse_qsl(urlsplit(url).query, keep_blank_values=True):
105
+ if key.lower() != "p":
106
+ continue
107
+ try:
108
+ part = int(value)
109
+ except (TypeError, ValueError):
110
+ return 1
111
+ return part if part > 0 else 1
112
+ return 1
113
+
114
+
115
+ def _is_bilibili_short_host(host: str) -> bool:
116
+ """判断主机是不是 B 站短链站。
117
+
118
+ Args:
119
+ host: 已小写的主机名。
120
+
121
+ Returns:
122
+ 命中返回 True。
123
+ """
124
+ return any(
125
+ host == item or host.endswith("." + item)
126
+ for item in _BILIBILI_SHORT_HOSTS
127
+ )
128
+
91
129
 
92
130
  def extract_urls(text: str) -> list[str]:
93
131
  """从消息文本里挑出可能受支持的链接。
@@ -134,8 +172,8 @@ def content_key(url: str) -> str:
134
172
  认得出作品 ID 的平台直接用 ``平台:id``,这样同一个作品的两种写法能对上——比如
135
173
  抖音的 ``v.douyin.com/xxx`` 和 ``iesdouyin.com/share/video/<id>``、B 站的
136
174
  ``b23.tv/xxx`` 和 ``bilibili.com/video/BVxxxx``(解析结果里的 ``raw_url``
137
- 已经是短链展开后的地址)。认不出来的退化成「主机 + 路径」,丢掉查询串和末尾
138
- 斜杠。
175
+ 已经是短链展开后的地址)。B 站带 ``p`` 时把分P也写进 key,避免不同 P
176
+ 撞缓存。认不出来的退化成「主机 + 路径」,丢掉查询串和末尾斜杠。
139
177
 
140
178
  Args:
141
179
  url: 链接,可以是用户发的原始链接,也可以是 ParseHub 给的 ``raw_url``。
@@ -146,7 +184,12 @@ def content_key(url: str) -> str:
146
184
  for name, pattern in _ID_PATTERNS:
147
185
  match = pattern.search(url)
148
186
  if match:
149
- return f"{name}:{match.group(1).lower()}"
187
+ key = f"{name}:{match.group(1).lower()}"
188
+ if name == "bilibili":
189
+ part = _bilibili_part(url)
190
+ if part > 1:
191
+ key += f":p{part}"
192
+ return key
150
193
 
151
194
  parsed = urlsplit(url)
152
195
  host = parsed.netloc.lower()
@@ -154,4 +197,9 @@ def content_key(url: str) -> str:
154
197
  if host.startswith(prefix):
155
198
  host = host[len(prefix) :]
156
199
  break
157
- return f"{host}{parsed.path.rstrip('/')}".lower()
200
+ key = f"{host}{parsed.path.rstrip('/')}".lower()
201
+ if _is_bilibili_short_host(host):
202
+ part = _bilibili_part(url)
203
+ if part > 1:
204
+ key += f":p{part}"
205
+ return key
@@ -253,8 +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
+ # B 站分P:带 ?p=N 就解析第 N P,不带就按默认P(P1)。
257
+ parse_url = bilibili.part_url(url)
258
258
 
259
259
  # 本地已经有这个作品的媒体就直接发,省掉解析、下载和画卡。
260
260
  cached = await asyncio.to_thread(app.cache.get, pre_key)
@@ -281,12 +281,12 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
281
281
  # 短链展开后可能带 p,必须在解析前再归一一次;展开失败就用原链接。
282
282
  try:
283
283
  async with budget.timeout():
284
- parse_url = await bilibili.resolve_default_part_url(url, proxy)
284
+ parse_url = await bilibili.resolve_part_url(url, proxy)
285
285
  except Exception as exc: # noqa: BLE001 - 展开失败交给 ParseHub 自己处理
286
286
  logger.debug(
287
- f"[parsehub] 归一 B 站默认P失败: {type(exc).__name__}: {exc}"
287
+ f"[parsehub] 归一 B 站分P失败: {type(exc).__name__}: {exc}"
288
288
  )
289
- parse_url = bilibili.default_part_url(url)
289
+ parse_url = bilibili.part_url(url)
290
290
 
291
291
  started = time.monotonic()
292
292
  try:
@@ -323,7 +323,7 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
323
323
  logger.info(f"[parsehub] {post_key} 在本会话刚发过,跳过发送")
324
324
  return Result(Outcome.DUPLICATE, keys=all_keys)
325
325
 
326
- # ParseHub 的 B 站结果里 duration 是所有分P的总和,这里用默认P的时长覆盖。
326
+ # ParseHub 的 B 站结果里 duration 是所有分P的总和,这里用目标P的时长覆盖。
327
327
  if platform_id.lower() == "bilibili":
328
328
  lookup_url = getattr(parsed, "raw_url", "") or parse_url
329
329
  page_seconds = await _bilibili_seconds(
@@ -434,7 +434,7 @@ async def _bilibili_seconds(
434
434
  cookie: str,
435
435
  budget: Budget,
436
436
  ) -> int:
437
- """查询 B 站默认P的时长,失败时返回 0。
437
+ """查询 B 站目标分P的时长,失败时返回 0。
438
438
 
439
439
  Args:
440
440
  url: 已展开的 B 站视频链接。
@@ -443,11 +443,11 @@ async def _bilibili_seconds(
443
443
  budget: 本次工作的超时预算。
444
444
 
445
445
  Returns:
446
- 默认P的秒数;查询失败或不是视频时返回 0。
446
+ 目标分P的秒数;查询失败或不是视频时返回 0。
447
447
  """
448
448
  try:
449
449
  async with budget.timeout():
450
- return await bilibili.default_part_seconds(url, proxy, cookie)
450
+ return await bilibili.part_seconds(url, proxy, cookie)
451
451
  except Exception as exc: # noqa: BLE001 - 查时长失败不能影响主流程
452
452
  logger.debug(
453
453
  f"[parsehub] 查询 B 站默认P时长失败: {type(exc).__name__}: {exc}"