parsehub 2.3.17__tar.gz → 2.4.0__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 (88) hide show
  1. {parsehub-2.3.17/src/parsehub.egg-info → parsehub-2.4.0}/PKG-INFO +2 -2
  2. {parsehub-2.3.17 → parsehub-2.4.0}/README.md +1 -1
  3. {parsehub-2.3.17 → parsehub-2.4.0}/pyproject.toml +1 -1
  4. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/bilibili.py +66 -3
  5. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/bilibili.py +26 -0
  6. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/__init__.py +6 -2
  7. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/media_file.py +16 -2
  8. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/media_ref.py +18 -2
  9. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/post.py +1 -0
  10. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/result.py +20 -3
  11. {parsehub-2.3.17 → parsehub-2.4.0/src/parsehub.egg-info}/PKG-INFO +2 -2
  12. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub.egg-info/SOURCES.txt +1 -0
  13. parsehub-2.4.0/test/test_bilibili_music_offline.py +127 -0
  14. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_core_offline.py +5 -0
  15. {parsehub-2.3.17 → parsehub-2.4.0}/LICENSE +0 -0
  16. {parsehub-2.3.17 → parsehub-2.4.0}/setup.cfg +0 -0
  17. {parsehub-2.3.17 → parsehub-2.4.0}/src/__init__.py +0 -0
  18. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/__init__.py +0 -0
  19. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/cli.py +0 -0
  20. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/cli_config.py +0 -0
  21. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/config/__init__.py +0 -0
  22. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/config/config.py +0 -0
  23. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/errors.py +0 -0
  24. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/__init__.py +0 -0
  25. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/base/__init__.py +0 -0
  26. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/base/base.py +0 -0
  27. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/base/ytdlp.py +0 -0
  28. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/__init__.py +0 -0
  29. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/bangumi.py +0 -0
  30. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/coolapk.py +0 -0
  31. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/douban.py +0 -0
  32. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/douyin.py +0 -0
  33. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/facebook.py +0 -0
  34. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/instagram.py +0 -0
  35. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/kuaishou.py +0 -0
  36. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/medium.py +0 -0
  37. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/pipix.py +0 -0
  38. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/snapchat.py +0 -0
  39. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/threads.py +0 -0
  40. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/tieba.py +0 -0
  41. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/tiktok.py +0 -0
  42. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/toutiao.py +0 -0
  43. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/twitter.py +0 -0
  44. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/weibo.py +0 -0
  45. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/weixin.py +0 -0
  46. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/wxchannels.py +0 -0
  47. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/xhs.py +0 -0
  48. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/xiaoheihe.py +0 -0
  49. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/youtube.py +0 -0
  50. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/zhihu.py +0 -0
  51. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/parsers/parser/zuiyou.py +0 -0
  52. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/__init__.py +0 -0
  53. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/bangumi.py +0 -0
  54. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/coolapk.py +0 -0
  55. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/douban.py +0 -0
  56. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/douyin.py +0 -0
  57. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/instagram.py +0 -0
  58. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/kuaishou.py +0 -0
  59. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/medium.py +0 -0
  60. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/pipix.py +0 -0
  61. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/threads.py +0 -0
  62. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/tieba.py +0 -0
  63. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/tiktok.py +0 -0
  64. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/toutiao.py +0 -0
  65. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/twitter.py +0 -0
  66. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/weibo.py +0 -0
  67. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/weixin.py +0 -0
  68. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/wxchannels.py +0 -0
  69. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/xhs.py +0 -0
  70. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/xiaoheihe.py +0 -0
  71. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/zhihu.py +0 -0
  72. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/provider_api/zuiyou.py +0 -0
  73. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/callback.py +0 -0
  74. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/types/platform.py +0 -0
  75. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/utils/downloader.py +0 -0
  76. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/utils/fallback.py +0 -0
  77. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/utils/helpers.py +0 -0
  78. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub/utils/media_info.py +0 -0
  79. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub.egg-info/dependency_links.txt +0 -0
  80. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub.egg-info/entry_points.txt +0 -0
  81. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub.egg-info/requires.txt +0 -0
  82. {parsehub-2.3.17 → parsehub-2.4.0}/src/parsehub.egg-info/top_level.txt +0 -0
  83. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_bilibili_offline.py +0 -0
  84. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_cli.py +0 -0
  85. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_cli_config.py +0 -0
  86. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_downloader.py +0 -0
  87. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_instagram_duration_offline.py +0 -0
  88. {parsehub-2.3.17 → parsehub-2.4.0}/test/test_zuiyou_duration_offline.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.17
3
+ Version: 2.4.0
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -82,7 +82,7 @@ Dynamic: license-file
82
82
  | **YouTube** | ✅ | | 🎵 音乐 |
83
83
  | **Facebook** | ✅ | | |
84
84
  | **Threads** | ✅ | ✅ | |
85
- | **Bilibili** | ✅ | | 📝 动态 |
85
+ | **Bilibili** | ✅ | | 📝 动态, 🎵 音乐 |
86
86
  | **抖音** | ✅ | ✅ | ☀️日常 |
87
87
  | **TikTok** | ✅ | ✅ | |
88
88
  | **微博** | ✅ | ✅ | |
@@ -37,7 +37,7 @@
37
37
  | **YouTube** | ✅ | | 🎵 音乐 |
38
38
  | **Facebook** | ✅ | | |
39
39
  | **Threads** | ✅ | ✅ | |
40
- | **Bilibili** | ✅ | | 📝 动态 |
40
+ | **Bilibili** | ✅ | | 📝 动态, 🎵 音乐 |
41
41
  | **抖音** | ✅ | ✅ | ☀️日常 |
42
42
  | **TikTok** | ✅ | ✅ | |
43
43
  | **微博** | ✅ | ✅ | |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "parsehub"
3
- version = "2.3.17"
3
+ version = "2.4.0"
4
4
  description = "轻量、异步、开箱即用的社交媒体聚合解析库"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.0"
@@ -12,6 +12,8 @@ from ...types import (
12
12
  ImageParseResult,
13
13
  ImageRef,
14
14
  LivePhotoRef,
15
+ MusicParseResult,
16
+ MusicRef,
15
17
  ParseError,
16
18
  Platform,
17
19
  ProgressCallback,
@@ -25,17 +27,21 @@ from ..base.ytdlp import YtParser, YtVideoParseResult
25
27
 
26
28
  class BiliParse(BaseParser):
27
29
  __platform__ = Platform.BILIBILI
28
- __supported_type__ = ["视频", "动态"]
29
- __match__ = r"^(http(s)?://)?((((w){3}.|(m).|(t).)?bilibili\.com)/(video|opus|\b\d{18,19}\b)|b23.tv|bili2233.cn).*"
30
+ __supported_type__ = ["视频", "动态", "音乐"]
31
+ __match__ = r"^(http(s)?://)?((((w){3}.|(m).|(t).)?bilibili\.com)/(video|opus|audio/au\d+|\b\d{18,19}\b)|b23.tv|bili2233.cn).*"
30
32
  __reserved_parameters__ = ["p"]
31
33
  __redirect_keywords__ = ["b23.tv", "bili2233.cn"]
32
34
 
33
- async def _do_parse(self, raw_url: str) -> YtVideoParseResult | BiliVideoParseResult | ImageParseResult:
35
+ async def _do_parse(
36
+ self, raw_url: str
37
+ ) -> YtVideoParseResult | BiliVideoParseResult | BiliMusicParseResult | ImageParseResult:
34
38
  parsed = urlparse(raw_url)
35
39
  if parsed.hostname == "mall.bilibili.com":
36
40
  raise ParseError("不支持解析商品")
37
41
  if parsed.path.startswith("/bangumi/"):
38
42
  raise ParseError("不支持解析番剧和影视")
43
+ if BiliAPI.get_music_id(raw_url):
44
+ return await self.bili_music_parse(raw_url)
39
45
  if await self.is_dynamic(raw_url):
40
46
  dynamic = await self.get_dynamic_info(raw_url)
41
47
  content = self.hashtag_handler(dynamic.content or "")
@@ -142,6 +148,36 @@ class BiliParse(BaseParser):
142
148
  ),
143
149
  )
144
150
 
151
+ async def bili_music_parse(self, url: str) -> BiliMusicParseResult:
152
+ async with BiliAPI(proxy=self.proxy) as bili:
153
+ info_response = await bili.get_music_info(url)
154
+ if not (info := info_response.get("data")):
155
+ if info_response.get("code") == 4511001:
156
+ raise ContentUnavailableError("音乐未找到或已下架")
157
+ raise ParseError(f"获取音乐信息失败: {info_response.get('message') or '未知错误'}")
158
+
159
+ play_response = await bili.get_music_url(str(info["id"]))
160
+ cdns: list[str] = (play_response.get("data") or {}).get("cdns") or []
161
+ if not cdns:
162
+ raise ParseError("获取音乐链接失败")
163
+
164
+ return BiliMusicParseResult(
165
+ title=info.get("title") or "",
166
+ music=MusicRef(
167
+ url=cdns[0],
168
+ ext=self._music_ext(cdns[0]),
169
+ thumb_url=info.get("cover") or None,
170
+ backup_urls=cdns[1:],
171
+ duration=int(info.get("duration") or 0),
172
+ ),
173
+ content=info.get("intro") or "",
174
+ )
175
+
176
+ @staticmethod
177
+ def _music_ext(url: str) -> str:
178
+ suffix = Path(urlparse(url).path).suffix
179
+ return suffix.removeprefix(".") or "m4a"
180
+
145
181
  async def ytp_parse(self, url: str) -> YtVideoParseResult:
146
182
  return await BiliYtParse(proxy=self.proxy, cookie=self.cookie)._do_parse(url)
147
183
 
@@ -205,7 +241,34 @@ class BiliVideoParseResult(VideoParseResult):
205
241
  )
206
242
 
207
243
 
244
+ class BiliMusicParseResult(MusicParseResult):
245
+ async def _do_download(
246
+ self,
247
+ *,
248
+ output_dir: Path,
249
+ callback: ProgressCallback | None = None,
250
+ callback_args: tuple = (),
251
+ callback_kwargs: dict | None = None,
252
+ proxy: str | None = None,
253
+ headers: dict | None = None,
254
+ connections: int = 4,
255
+ concurrency: int = 4,
256
+ ) -> DownloadResult:
257
+ headers = {"referer": "https://www.bilibili.com", "User-Agent": UA}
258
+ return await super()._do_download(
259
+ output_dir=output_dir,
260
+ callback=callback,
261
+ callback_args=callback_args,
262
+ callback_kwargs=callback_kwargs,
263
+ proxy=proxy,
264
+ headers=headers,
265
+ connections=connections,
266
+ concurrency=concurrency,
267
+ )
268
+
269
+
208
270
  __all__ = [
209
271
  "BiliParse",
210
272
  "BiliVideoParseResult",
273
+ "BiliMusicParseResult",
211
274
  ]
@@ -125,6 +125,27 @@ class BiliAPI:
125
125
  )
126
126
  return cast(dict[str, Any], response.json())
127
127
 
128
+ async def get_music_info(self, url: str) -> dict:
129
+ """获取音乐详细信息"""
130
+ sid = self.get_music_id(url)
131
+ if not sid:
132
+ raise ValueError(f"Invalid url: {url}")
133
+ response = await self._get_client().get(
134
+ "https://www.bilibili.com/audio/music-service-c/web/song/info",
135
+ params={"sid": sid},
136
+ )
137
+ response.raise_for_status()
138
+ return cast(dict[str, Any], response.json())
139
+
140
+ async def get_music_url(self, sid: str) -> dict:
141
+ """获取音乐播放链接"""
142
+ response = await self._get_client().get(
143
+ "https://www.bilibili.com/audio/music-service-c/web/url",
144
+ params={"sid": sid},
145
+ )
146
+ response.raise_for_status()
147
+ return cast(dict[str, Any], response.json())
148
+
128
149
  async def get_buvid(self):
129
150
  """获取 buvid"""
130
151
  response = await self._get_client().get(
@@ -188,6 +209,11 @@ class BiliAPI:
188
209
  tmp = tmp * BASE + idx
189
210
  return f"av{(tmp & MASK_CODE) ^ XOR_CODE}"
190
211
 
212
+ @staticmethod
213
+ def get_music_id(url: str) -> str | None:
214
+ m = re.search(r"/audio/au(\d+)", url)
215
+ return m.group(1) if m else None
216
+
191
217
  def get_bvid(self, url: str):
192
218
  m_bv = re.search(r"BV[0-9A-Za-z]{10,}", url)
193
219
  if m_bv:
@@ -8,8 +8,8 @@ from ..errors import (
8
8
  ParseError,
9
9
  )
10
10
  from .callback import ProgressCallback, ProgressUnit
11
- from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, MediaFile, VideoFile
12
- from .media_ref import AniRef, AnyMediaRef, ImageRef, LivePhotoRef, MediaRef, VideoRef
11
+ from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, MediaFile, MusicFile, VideoFile
12
+ from .media_ref import AniRef, AnyMediaRef, ImageRef, LivePhotoRef, MediaRef, MusicRef, VideoRef
13
13
  from .platform import Platform
14
14
  from .post import PostType
15
15
  from .result import (
@@ -17,6 +17,7 @@ from .result import (
17
17
  DownloadResult,
18
18
  ImageParseResult,
19
19
  MultimediaParseResult,
20
+ MusicParseResult,
20
21
  ParseResult,
21
22
  RichTextParseResult,
22
23
  VideoParseResult,
@@ -33,12 +34,14 @@ __all__ = [
33
34
  "MediaRef",
34
35
  "AniRef",
35
36
  "VideoRef",
37
+ "MusicRef",
36
38
  "ImageRef",
37
39
  "LivePhotoRef",
38
40
  "DownloadResult",
39
41
  "ParseResult",
40
42
  "ImageParseResult",
41
43
  "VideoParseResult",
44
+ "MusicParseResult",
42
45
  "MultimediaParseResult",
43
46
  "RichTextParseResult",
44
47
  "AnyMediaRef",
@@ -49,6 +52,7 @@ __all__ = [
49
52
  "ImageFile",
50
53
  "AniFile",
51
54
  "LivePhotoFile",
55
+ "MusicFile",
52
56
  "AnyMediaFile",
53
57
  "ProgressCallback",
54
58
  "ProgressUnit",
@@ -78,6 +78,20 @@ class AniFile(MediaFile):
78
78
  self.duration = info.duration
79
79
 
80
80
 
81
+ @dataclass(kw_only=True)
82
+ class MusicFile(MediaFile):
83
+ """本地音乐
84
+ Attributes:
85
+ path: 路径
86
+ duration: 音乐时长,单位: 秒
87
+ """
88
+
89
+ duration: int = 0
90
+
91
+ def __post_init__(self) -> None:
92
+ """音乐不做宽高探测;时长由解析结果传入。"""
93
+
94
+
81
95
  @dataclass(kw_only=True)
82
96
  class LivePhotoFile(MediaFile):
83
97
  """本地 Live Photo
@@ -100,6 +114,6 @@ class LivePhotoFile(MediaFile):
100
114
  self.duration = info.duration
101
115
 
102
116
 
103
- AnyMediaFile = MediaFile | VideoFile | ImageFile | AniFile | LivePhotoFile
117
+ AnyMediaFile = MediaFile | VideoFile | ImageFile | AniFile | LivePhotoFile | MusicFile
104
118
 
105
- __all__ = ["MediaFile", "VideoFile", "ImageFile", "AniFile", "LivePhotoFile", "AnyMediaFile"]
119
+ __all__ = ["MediaFile", "VideoFile", "ImageFile", "AniFile", "LivePhotoFile", "MusicFile", "AnyMediaFile"]
@@ -37,6 +37,22 @@ class VideoRef(MediaRef):
37
37
  duration: int = 0
38
38
 
39
39
 
40
+ @dataclass(kw_only=True)
41
+ class MusicRef(MediaRef):
42
+ """音乐
43
+ Attributes:
44
+ url: URL
45
+ ext: 默认扩展名
46
+ thumb_url: 缩略图 URL
47
+ width: 宽度
48
+ height: 高度
49
+ duration: 音乐时长,单位: 秒
50
+ """
51
+
52
+ ext: str = "m4a"
53
+ duration: int = 0
54
+
55
+
40
56
  @dataclass(kw_only=True)
41
57
  class ImageRef(MediaRef):
42
58
  """图片
@@ -87,6 +103,6 @@ class LivePhotoRef(MediaRef):
87
103
  duration: int = 3
88
104
 
89
105
 
90
- AnyMediaRef = ImageRef | VideoRef | AniRef | LivePhotoRef
106
+ AnyMediaRef = ImageRef | VideoRef | AniRef | LivePhotoRef | MusicRef
91
107
 
92
- __all__ = ["MediaRef", "VideoRef", "ImageRef", "AniRef", "LivePhotoRef", "AnyMediaRef"]
108
+ __all__ = ["MediaRef", "VideoRef", "MusicRef", "ImageRef", "AniRef", "LivePhotoRef", "AnyMediaRef"]
@@ -9,3 +9,4 @@ class PostType(StrEnum):
9
9
  VIDEO = "video"
10
10
  MULTIMEDIA = "multimedia"
11
11
  RICHTEXT = "richtext"
12
+ MUSIC = "music"
@@ -20,8 +20,8 @@ from ..utils.downloader import download
20
20
  from ..utils.fallback import FallbackStage, run_fallback
21
21
  from ..utils.helpers import run_sync
22
22
  from .callback import ProgressCallback
23
- from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, VideoFile
24
- from .media_ref import AniRef, AnyMediaRef, ImageRef, LivePhotoRef, VideoRef
23
+ from .media_file import AniFile, AnyMediaFile, ImageFile, LivePhotoFile, MusicFile, VideoFile
24
+ from .media_ref import AniRef, AnyMediaRef, ImageRef, LivePhotoRef, MusicRef, VideoRef
25
25
  from .platform import Platform
26
26
  from .post import PostType
27
27
 
@@ -155,6 +155,8 @@ class ParseResult(ABC): # noqa: B024
155
155
  mf = ImageFile(path=f, width=media.width, height=media.height)
156
156
  case VideoRef():
157
157
  mf = VideoFile(path=f, width=media.width, height=media.height, duration=media.duration)
158
+ case MusicRef():
159
+ mf = MusicFile(path=f, duration=media.duration)
158
160
  case AniRef():
159
161
  mf = AniFile(path=f, width=media.width, height=media.height, duration=media.duration)
160
162
  case LivePhotoRef():
@@ -361,6 +363,21 @@ class VideoParseResult(ParseResult):
361
363
  )
362
364
 
363
365
 
366
+ class MusicParseResult(ParseResult):
367
+ """单个音乐"""
368
+
369
+ type = PostType.MUSIC
370
+
371
+ def __init__(
372
+ self,
373
+ title: str = "",
374
+ music: str | MusicRef | None = None,
375
+ content: str = "",
376
+ ):
377
+ music = MusicRef(url=music) if isinstance(music, str) else music
378
+ super().__init__(title=title, media=music, content=content)
379
+
380
+
364
381
  class ImageParseResult(ParseResult):
365
382
  """单图 / 多图 / 图集 / 实况照片"""
366
383
 
@@ -464,4 +481,4 @@ class DownloadResult:
464
481
  return f"{self.__class__.__name__}(media={media_count}, output_dir={self.output_dir})"
465
482
 
466
483
 
467
- AnyParseResult = VideoParseResult | ImageParseResult | MultimediaParseResult | RichTextParseResult
484
+ AnyParseResult = VideoParseResult | ImageParseResult | MultimediaParseResult | RichTextParseResult | MusicParseResult
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: parsehub
3
- Version: 2.3.17
3
+ Version: 2.4.0
4
4
  Summary: 轻量、异步、开箱即用的社交媒体聚合解析库
5
5
  Author-email: 梓澪 <zilingmio@gmail.com>
6
6
  License: MIT
@@ -82,7 +82,7 @@ Dynamic: license-file
82
82
  | **YouTube** | ✅ | | 🎵 音乐 |
83
83
  | **Facebook** | ✅ | | |
84
84
  | **Threads** | ✅ | ✅ | |
85
- | **Bilibili** | ✅ | | 📝 动态 |
85
+ | **Bilibili** | ✅ | | 📝 动态, 🎵 音乐 |
86
86
  | **抖音** | ✅ | ✅ | ☀️日常 |
87
87
  | **TikTok** | ✅ | ✅ | |
88
88
  | **微博** | ✅ | ✅ | |
@@ -76,6 +76,7 @@ src/parsehub/utils/downloader.py
76
76
  src/parsehub/utils/fallback.py
77
77
  src/parsehub/utils/helpers.py
78
78
  src/parsehub/utils/media_info.py
79
+ test/test_bilibili_music_offline.py
79
80
  test/test_bilibili_offline.py
80
81
  test/test_cli.py
81
82
  test/test_cli_config.py
@@ -0,0 +1,127 @@
1
+ import unittest
2
+ from pathlib import Path
3
+ from unittest.mock import AsyncMock, patch
4
+
5
+ from parsehub.errors import ContentUnavailableError, ParseError
6
+ from parsehub.parsers.parser.bilibili import BiliMusicParseResult, BiliParse
7
+ from parsehub.provider_api.bilibili import BiliAPI
8
+ from parsehub.types import MusicFile, MusicParseResult, MusicRef, ParseResult
9
+
10
+ MUSIC_INFO = {
11
+ "code": 0,
12
+ "data": {
13
+ "id": 2854883,
14
+ "title": "残 酷 月 光",
15
+ "cover": "http://i0.hdslb.com/bfs/music/bc477834dc9b74abdf9869989fc7a0937b25e020.jpg",
16
+ "intro": "突然想唱 就录了一下",
17
+ "duration": 200,
18
+ },
19
+ }
20
+
21
+ MUSIC_PLAY = {
22
+ "code": 0,
23
+ "data": {"cdns": ["https://cdn.example/music-192k.m4a"], "size": 150},
24
+ }
25
+
26
+
27
+ class FakeBiliMusicAPI:
28
+ """响应取自线上实测: song/info 正常响应、4511001 与 -400 错误响应、单条 cdn"""
29
+
30
+ info_response: dict = MUSIC_INFO
31
+ play_response: dict = MUSIC_PLAY
32
+ last_sid: str | None = None
33
+
34
+ get_music_id = staticmethod(BiliAPI.get_music_id) # _do_parse 经 BiliAPI 路由, 替换后需保留
35
+
36
+ def __init__(self, proxy=None):
37
+ self.proxy = proxy
38
+
39
+ async def __aenter__(self):
40
+ return self
41
+
42
+ async def __aexit__(self, *args):
43
+ return None
44
+
45
+ async def get_music_info(self, url):
46
+ return FakeBiliMusicAPI.info_response
47
+
48
+ async def get_music_url(self, sid):
49
+ FakeBiliMusicAPI.last_sid = sid
50
+ return FakeBiliMusicAPI.play_response
51
+
52
+
53
+ class TestBiliMusicParse(unittest.IsolatedAsyncioTestCase):
54
+ async def test_mobile_music_url_parses_to_music_result(self):
55
+ with patch("parsehub.parsers.parser.bilibili.BiliAPI", FakeBiliMusicAPI):
56
+ result = await BiliParse().parse("https://m.bilibili.com/audio/au2854883?share_source=copy")
57
+
58
+ self.assertIsInstance(result, BiliMusicParseResult)
59
+ self.assertIsInstance(result, MusicParseResult)
60
+ self.assertEqual(result.to_dict()["type"], "music")
61
+ self.assertEqual(result.title, "残 酷 月 光")
62
+ self.assertEqual(result.content, "突然想唱 就录了一下")
63
+ self.assertEqual(
64
+ result.media,
65
+ MusicRef(
66
+ url="https://cdn.example/music-192k.m4a",
67
+ thumb_url="http://i0.hdslb.com/bfs/music/bc477834dc9b74abdf9869989fc7a0937b25e020.jpg",
68
+ duration=200,
69
+ ),
70
+ )
71
+ self.assertEqual(result.raw_url, "https://m.bilibili.com/audio/au2854883")
72
+ self.assertEqual(FakeBiliMusicAPI.last_sid, "2854883")
73
+
74
+ async def test_missing_music_is_content_unavailable(self):
75
+ with (
76
+ patch("parsehub.parsers.parser.bilibili.BiliAPI", FakeBiliMusicAPI),
77
+ patch.object(
78
+ FakeBiliMusicAPI,
79
+ "info_response",
80
+ {"code": 4511001, "data": None, "message": "音乐未找到或已下架"},
81
+ ),
82
+ ):
83
+ with self.assertRaises(ContentUnavailableError):
84
+ await BiliParse()._do_parse("https://www.bilibili.com/audio/au2854883")
85
+
86
+ async def test_api_error_keeps_server_message(self):
87
+ with (
88
+ patch("parsehub.parsers.parser.bilibili.BiliAPI", FakeBiliMusicAPI),
89
+ patch.object(FakeBiliMusicAPI, "info_response", {"code": -400, "message": "请求错误"}),
90
+ ):
91
+ with self.assertRaisesRegex(ParseError, "获取音乐信息失败: 请求错误"):
92
+ await BiliParse()._do_parse("https://www.bilibili.com/audio/au2854883")
93
+
94
+ async def test_missing_playurl_is_parse_error(self):
95
+ with (
96
+ patch("parsehub.parsers.parser.bilibili.BiliAPI", FakeBiliMusicAPI),
97
+ patch.object(
98
+ FakeBiliMusicAPI, "play_response", {"code": 4511001, "data": None, "message": "音乐未找到或已下架"}
99
+ ),
100
+ ):
101
+ with self.assertRaisesRegex(ParseError, "获取音乐链接失败"):
102
+ await BiliParse()._do_parse("https://www.bilibili.com/audio/au2854883")
103
+
104
+
105
+ class TestMusicResultDownload(unittest.IsolatedAsyncioTestCase):
106
+ async def test_music_result_downloads_with_m4a_and_keeps_duration(self):
107
+ result = MusicParseResult(title="clip", music=MusicRef(url="https://cdn.example/a", duration=12))
108
+
109
+ with patch("parsehub.types.result.download", new=AsyncMock(return_value="/tmp/clip.m4a")) as mocked:
110
+ out = await result._do_download(output_dir=Path("/tmp/does-not-matter"))
111
+
112
+ self.assertIsInstance(out.media, MusicFile)
113
+ self.assertEqual(out.media.duration, 12)
114
+ self.assertTrue(Path(mocked.await_args.args[1]).name.endswith("clip.m4a"))
115
+
116
+ async def test_download_injects_bilibili_referer(self):
117
+ result = BiliMusicParseResult(title="clip", music=MusicRef(url="https://cdn.example/a"))
118
+
119
+ with patch.object(ParseResult, "_do_download", new=AsyncMock()) as mocked:
120
+ await result._do_download(output_dir=Path("/tmp/x"))
121
+
122
+ # 音乐 CDN 无 Referer 返回 403
123
+ self.assertEqual(mocked.await_args.kwargs["headers"]["referer"], "https://www.bilibili.com")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ unittest.main()
@@ -131,6 +131,7 @@ class TestParserRegistry(unittest.TestCase):
131
131
  self.assertEqual(len(by_id), len(platforms))
132
132
  self.assertEqual(by_id["tieba"]["name"], Platform.TIEBA.display_name)
133
133
  self.assertIn("图文", by_id["tieba"]["supported_types"])
134
+ self.assertIn("音乐", by_id["bilibili"]["supported_types"])
134
135
  self.assertEqual(parsehub.get_platform("https://tieba.baidu.com/p/9939510114"), Platform.TIEBA)
135
136
  self.assertIsNone(parsehub.get_platform("https://example.invalid/not-supported"))
136
137
 
@@ -554,6 +555,8 @@ class TestPlatformUrlMatching(unittest.TestCase):
554
555
  "https://www.bilibili.com/video/av123456",
555
556
  "https://www.bilibili.com/opus/1234567890123456789",
556
557
  "https://t.bilibili.com/1234567890123456789",
558
+ "https://www.bilibili.com/audio/au2854883",
559
+ "https://m.bilibili.com/audio/au2854883",
557
560
  "https://b23.tv/abc123",
558
561
  "https://bili2233.cn/abc123",
559
562
  ],
@@ -687,6 +690,8 @@ class TestPlatformUrlMatching(unittest.TestCase):
687
690
  "https://www.youtube.com/live/1h_uc3K4Cpg",
688
691
  "https://www.youtube.com/post/Ugkxexample",
689
692
  "https://www.youtube.com/@example",
693
+ "https://www.bilibili.com/audio/home",
694
+ "https://www.bilibili.com/audio/am10624",
690
695
  ]
691
696
 
692
697
  for url in urls:
File without changes
File without changes
File without changes
File without changes
File without changes