nonebot-plugin-parser 2.4.3__tar.gz → 2.5.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 (69) hide show
  1. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/PKG-INFO +5 -15
  2. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/README.md +2 -12
  3. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/pyproject.toml +7 -8
  4. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/config.py +17 -2
  5. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/helper.py +1 -2
  6. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/__init__.py +0 -2
  7. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/base.py +9 -18
  8. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/__init__.py +37 -20
  9. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py +60 -25
  10. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/opus.py +42 -33
  11. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/data.py +79 -34
  12. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/nga.py +6 -16
  13. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/weibo/__init__.py +5 -12
  14. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/base.py +19 -18
  15. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/common.py +75 -73
  16. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/default.py +5 -2
  17. nonebot_plugin_parser-2.5.0/src/nonebot_plugin_parser/renders/htmlrender.py +35 -0
  18. nonebot_plugin_parser-2.5.0/src/nonebot_plugin_parser/renders/resources/__init__.py +10 -0
  19. nonebot_plugin_parser-2.5.0/src/nonebot_plugin_parser/renders/templates/card.html.jinja +606 -0
  20. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/utils.py +11 -0
  21. nonebot_plugin_parser-2.4.3/src/nonebot_plugin_parser/renders/htmlrender.py +0 -88
  22. nonebot_plugin_parser-2.4.3/src/nonebot_plugin_parser/renders/templates/card.html.jinja +0 -394
  23. nonebot_plugin_parser-2.4.3/src/nonebot_plugin_parser/renders/templates/weibo.html.jinja +0 -425
  24. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/__init__.py +0 -0
  25. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/constants.py +0 -0
  26. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/download/__init__.py +0 -0
  27. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/download/task.py +0 -0
  28. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/download/ytdlp.py +0 -0
  29. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/exception.py +0 -0
  30. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/matchers/__init__.py +0 -0
  31. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/matchers/filter.py +0 -0
  32. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/matchers/rule.py +0 -0
  33. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/acfun/__init__.py +0 -0
  34. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/acfun/video.py +0 -0
  35. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/article.py +0 -0
  36. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/common.py +0 -0
  37. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/favlist.py +0 -0
  38. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/live.py +0 -0
  39. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/bilibili/video.py +0 -0
  40. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/cookie.py +0 -0
  41. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/douyin/__init__.py +0 -0
  42. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/douyin/slides.py +0 -0
  43. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/douyin/video.py +0 -0
  44. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/kuaishou/__init__.py +0 -0
  45. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/kuaishou/states.py +0 -0
  46. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/tiktok.py +0 -0
  47. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/twitter.py +0 -0
  48. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/weibo/article.py +0 -0
  49. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/weibo/common.py +0 -0
  50. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/weibo/show.py +0 -0
  51. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/xiaohongshu/__init__.py +0 -0
  52. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/xiaohongshu/common.py +0 -0
  53. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/xiaohongshu/discovery.py +0 -0
  54. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/xiaohongshu/explore.py +0 -0
  55. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/youtube/__init__.py +0 -0
  56. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/parsers/youtube/meta.py +0 -0
  57. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/__init__.py +0 -0
  58. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/HYSongYunLangHeiW.ttf +0 -0
  59. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/avatar.png +0 -0
  60. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/bilibili.png +0 -0
  61. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/douyin.png +0 -0
  62. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/kuaishou.png +0 -0
  63. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/play.png +0 -0
  64. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/tiktok.png +0 -0
  65. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/twitter.png +0 -0
  66. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/weibo.png +0 -0
  67. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/xiaohongshu.png +0 -0
  68. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/resources/youtube.png +0 -0
  69. {nonebot_plugin_parser-2.4.3 → nonebot_plugin_parser-2.5.0}/src/nonebot_plugin_parser/renders/weibo.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nonebot-plugin-parser
3
- Version: 2.4.3
3
+ Version: 2.5.0
4
4
  Summary: NoneBot2 链接分享解析 Alconna 版, 现支持B站|抖音|快手|微博|小红书|YouTube|TikTok|Twitter|AcFun|NGA
5
5
  Keywords: acfun,bilibili,douyin,kuaishou,nga,nonebot,nonebot2,tiktok,twitter,video,weibo,xiaohongshu,youtube
6
6
  Author: fllesser
@@ -36,8 +36,8 @@ Requires-Dist: nonebot-plugin-uninfo>=0.10.1,<1.0.0
36
36
  Requires-Dist: nonebot-plugin-htmlkit>=0.1.0rc4 ; extra == 'all'
37
37
  Requires-Dist: nonebot-plugin-htmlrender>=0.6.7 ; extra == 'all'
38
38
  Requires-Dist: yt-dlp[default]>=2026.2.21 ; extra == 'all'
39
- Requires-Dist: emosvg>=0.1.6 ; extra == 'all'
40
- Requires-Dist: emosvg>=0.1.6 ; extra == 'emosvg'
39
+ Requires-Dist: emosvg>=0.1.7 ; extra == 'all'
40
+ Requires-Dist: emosvg>=0.1.7 ; extra == 'emosvg'
41
41
  Requires-Dist: nonebot-plugin-htmlkit>=0.1.0rc4 ; extra == 'htmlkit'
42
42
  Requires-Dist: nonebot-plugin-htmlrender>=0.6.7 ; extra == 'htmlrender'
43
43
  Requires-Dist: yt-dlp[default]>=2025.2.21 ; extra == 'ytdlp'
@@ -300,8 +300,8 @@ parser_render_type="common"
300
300
  parser_append_url=False
301
301
 
302
302
  # [可选] 自定义渲染字体
303
- # 配置字体文件名,并将字体文件放置于 localstore 生成的插件 data 目录下
304
- # 例如: ./data/nonebot_plugin_parser/
303
+ # 配置字体文件名,并将字体文件放置于 localstore 生成的插件 config 目录下
304
+ # 例如: ./config/nonebot_plugin_parser/
305
305
  parser_custom_font="LXGWZhenKaiGB-Regular.ttf"
306
306
 
307
307
  # [可选] 是否需要转发媒体内容(超过 4 项时始终使用合并转发)
@@ -452,16 +452,6 @@ images = self.create_image_contents([
452
452
  ])
453
453
  ```
454
454
 
455
- > 构建图文内容(适用于类似 Bilibili 动态图文混排)
456
-
457
- ```python
458
- graphics = self.create_graphics_content(
459
- image_url="https://example.com/image.jpg",
460
- text="图片前的文字说明", # 可选
461
- alt="图片描述" # 可选,居中显示
462
- )
463
- ```
464
-
465
455
  > 创建动图内容(GIF),平台一般只提供视频(后续插件会做自动转为 gif 的处理)
466
456
 
467
457
  ```python
@@ -246,8 +246,8 @@ parser_render_type="common"
246
246
  parser_append_url=False
247
247
 
248
248
  # [可选] 自定义渲染字体
249
- # 配置字体文件名,并将字体文件放置于 localstore 生成的插件 data 目录下
250
- # 例如: ./data/nonebot_plugin_parser/
249
+ # 配置字体文件名,并将字体文件放置于 localstore 生成的插件 config 目录下
250
+ # 例如: ./config/nonebot_plugin_parser/
251
251
  parser_custom_font="LXGWZhenKaiGB-Regular.ttf"
252
252
 
253
253
  # [可选] 是否需要转发媒体内容(超过 4 项时始终使用合并转发)
@@ -398,16 +398,6 @@ images = self.create_image_contents([
398
398
  ])
399
399
  ```
400
400
 
401
- > 构建图文内容(适用于类似 Bilibili 动态图文混排)
402
-
403
- ```python
404
- graphics = self.create_graphics_content(
405
- image_url="https://example.com/image.jpg",
406
- text="图片前的文字说明", # 可选
407
- alt="图片描述" # 可选,居中显示
408
- )
409
- ```
410
-
411
401
  > 创建动图内容(GIF),平台一般只提供视频(后续插件会做自动转为 gif 的处理)
412
402
 
413
403
  ```python
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parser"
3
- version = "2.4.3"
3
+ version = "2.5.0"
4
4
  description = "NoneBot2 链接分享解析 Alconna 版, 现支持B站|抖音|快手|微博|小红书|YouTube|TikTok|Twitter|AcFun|NGA"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -53,7 +53,6 @@ dependencies = [
53
53
  "nonebot-plugin-apscheduler>=0.5.0,<1.0.0",
54
54
  "nonebot-plugin-localstore>=0.7.4,<1.0.0",
55
55
  "nonebot-plugin-uninfo>=0.10.1,<1.0.0",
56
-
57
56
  ]
58
57
 
59
58
  [project.urls]
@@ -65,12 +64,12 @@ Repository = "https://github.com/fllesser/nonebot-plugin-parser"
65
64
  htmlkit = ["nonebot-plugin-htmlkit>=0.1.0rc4"]
66
65
  htmlrender = ["nonebot-plugin-htmlrender>=0.6.7"]
67
66
  ytdlp = ["yt-dlp[default]>=2025.2.21"]
68
- emosvg = ["emosvg>=0.1.6"]
67
+ emosvg = ["emosvg>=0.1.7"]
69
68
  all = [
70
69
  "nonebot-plugin-htmlkit>=0.1.0rc4",
71
70
  "nonebot-plugin-htmlrender>=0.6.7",
72
71
  "yt-dlp[default]>=2026.2.21",
73
- "emosvg>=0.1.6",
72
+ "emosvg>=0.1.7",
74
73
  ]
75
74
 
76
75
  [dependency-groups]
@@ -82,7 +81,7 @@ dev = [
82
81
  { include-group = "test" },
83
82
  ]
84
83
  extras = [
85
- "emosvg>=0.1.6",
84
+ "emosvg>=0.1.7",
86
85
  "nonebot-plugin-htmlkit>=0.1.0rc4",
87
86
  "nonebot-plugin-htmlrender>=0.6.7",
88
87
  "yt-dlp[default]>=2026.2.21",
@@ -90,7 +89,7 @@ extras = [
90
89
  ]
91
90
  test = [
92
91
  "nonebug>=0.4.3,<1.0.0",
93
- "poethepoet>=0.42.0",
92
+ "poethepoet>=0.42.1",
94
93
  "pytest-asyncio>=1.3.0,<1.4.0",
95
94
  "pytest-cov>=7.0.0",
96
95
  "pytest-xdist>=3.8.0,<4.0.0",
@@ -114,12 +113,12 @@ conflicts = [
114
113
  ]
115
114
 
116
115
  [tool.uv.sources]
117
- nonebug = { git = "https://github.com/nonebot/nonebug" }
116
+ nonebug = { git = "https://github.com/nonebot/nonebug", rev = "master" }
118
117
 
119
118
  [tool.bumpversion]
120
119
  tag = true
121
120
  commit = true
122
- current_version = "2.4.3"
121
+ current_version = "2.5.0"
123
122
  message = "release: bump vesion from {current_version} to {new_version}"
124
123
 
125
124
  [[tool.bumpversion.files]]
@@ -1,6 +1,6 @@
1
1
  from pathlib import Path
2
2
 
3
- from nonebot import require, get_driver, get_plugin_config
3
+ from nonebot import logger, require, get_driver, get_plugin_config
4
4
  from apilmoji import ELK_SH_CDN, EmojiStyle
5
5
  from pydantic import BaseModel
6
6
  from bilibili_api.video import VideoCodecs, VideoQuality
@@ -143,7 +143,22 @@ class Config(BaseModel):
143
143
  @property
144
144
  def custom_font(self) -> Path | None:
145
145
  """自定义字体"""
146
- return (self.data_dir / self.parser_custom_font) if self.parser_custom_font else None
146
+ if self.parser_custom_font:
147
+ font_path = self.config_dir / self.parser_custom_font
148
+ if font_path.exists():
149
+ return font_path
150
+
151
+ # 尝试从旧路径迁移字体文件
152
+ old_path = self.data_dir / self.parser_custom_font
153
+ if old_path.exists():
154
+ try:
155
+ old_path.rename(font_path)
156
+ logger.info(f"字体文件 {old_path} 成功迁移到 {font_path}")
157
+ except OSError:
158
+ logger.error(f"字体文件迁移失败, 请手动将其移动到 {font_path}")
159
+ return old_path
160
+
161
+ return font_path
147
162
 
148
163
  @property
149
164
  def need_forward_contents(self) -> bool:
@@ -101,8 +101,7 @@ class UniHelper:
101
101
  """文件 Seg"""
102
102
  if not display_name:
103
103
  display_name = file.name
104
- if not display_name:
105
- raise ValueError("文件名不能为空")
104
+
106
105
  if pconfig.use_base64:
107
106
  return File(raw=file.read_bytes(), name=display_name)
108
107
  else:
@@ -23,7 +23,6 @@ from .data import (
23
23
  ImageContent,
24
24
  VideoContent,
25
25
  DynamicContent,
26
- GraphicsContent,
27
26
  )
28
27
 
29
28
  __all__ = [
@@ -31,7 +30,6 @@ __all__ = [
31
30
  "Author",
32
31
  "BaseParser",
33
32
  "DynamicContent",
34
- "GraphicsContent",
35
33
  "ImageContent",
36
34
  "ParseResult",
37
35
  "Platform",
@@ -4,9 +4,9 @@ from typing import TYPE_CHECKING, Any, TypeVar, ClassVar, cast
4
4
  from asyncio import Task
5
5
  from pathlib import Path
6
6
  from collections.abc import Callable, Coroutine
7
- from typing_extensions import Unpack
7
+ from typing_extensions import Unpack, final
8
8
 
9
- from .data import Platform, ParseResult, ParseResultKwargs
9
+ from .data import Platform, ParseResult, ImageContent, ParseResultKwargs
10
10
  from ..config import pconfig as pconfig
11
11
  from ..download import DOWNLOADER
12
12
  from ..constants import IOS_HEADER, COMMON_HEADER, ANDROID_HEADER, COMMON_TIMEOUT
@@ -83,9 +83,11 @@ class BaseParser:
83
83
  """获取所有已注册的 Parser 类"""
84
84
  return cls._registry
85
85
 
86
+ @final
86
87
  async def parse(self, keyword: str, searched: Match[str]) -> ParseResult:
87
88
  return await self._handlers[keyword](self, searched)
88
89
 
90
+ @final
89
91
  async def parse_with_redirect(
90
92
  self,
91
93
  url: str,
@@ -191,8 +193,6 @@ class BaseParser:
191
193
  image_urls: list[str],
192
194
  ):
193
195
  """创建图片内容列表"""
194
- from .data import ImageContent
195
-
196
196
  contents: list[ImageContent] = []
197
197
  for url in image_urls:
198
198
  task = DOWNLOADER.download_img(url, ext_headers=self.headers)
@@ -202,14 +202,13 @@ class BaseParser:
202
202
  def create_image_content(
203
203
  self,
204
204
  url_or_task: str | Task[Path],
205
+ alt: str | None = None,
205
206
  ):
206
207
  """创建图片内容"""
207
- from .data import ImageContent
208
-
209
208
  if isinstance(url_or_task, str):
210
209
  url_or_task = DOWNLOADER.download_img(url_or_task, ext_headers=self.headers)
211
210
 
212
- return ImageContent(url_or_task)
211
+ return ImageContent(url_or_task, alt=alt)
213
212
 
214
213
  def create_dynamic_contents(
215
214
  self,
@@ -237,17 +236,9 @@ class BaseParser:
237
236
 
238
237
  return AudioContent(url_or_task, duration)
239
238
 
240
- def create_graphics_content(
241
- self,
242
- image_url: str,
243
- text: str | None = None,
244
- alt: str | None = None,
245
- ):
246
- """创建图文内容 图片不能为空 文字可空 渲染时文字在前 图片在后"""
247
- from .data import GraphicsContent
248
-
249
- image_task = DOWNLOADER.download_img(image_url, ext_headers=self.headers)
250
- return GraphicsContent(image_task, text, alt)
239
+ def create_empty_graphics(self) -> list[str | ImageContent]:
240
+ """创建空的图片内容列表"""
241
+ return []
251
242
 
252
243
  @property
253
244
  def downloader(self):
@@ -22,6 +22,7 @@ from ..base import (
22
22
  )
23
23
  from ..data import Platform, ImageContent, MediaContent
24
24
  from ..cookie import ck2dict
25
+ from .dynamic import DynamicInfo
25
26
 
26
27
  # 选择客户端
27
28
  select_client("curl_cffi")
@@ -160,20 +161,31 @@ class BilibiliParser(BaseParser):
160
161
  """解析动态或图文"""
161
162
  from bilibili_api.dynamic import Dynamic
162
163
 
163
- from .dynamic import DynamicData
164
+ from .dynamic import DynamicWrapper
164
165
 
165
166
  dynamic = Dynamic(dynamic_id, await self.credential)
166
167
  if await dynamic.is_article():
167
168
  return await self._parse_bilibli_api_opus(dynamic.turn_to_opus())
168
169
 
169
- dynamic_info = convert(await dynamic.get_info(), DynamicData).item
170
- author = self.create_author(dynamic_info.name, dynamic_info.avatar)
170
+ dynamic_info = convert(await dynamic.get_info(), DynamicWrapper).item
171
+ return await self._parse_dynamic_info(dynamic_info)
172
+
173
+ async def _parse_dynamic_info(self, dynamic_info: DynamicInfo):
174
+ if dynamic_info.is_video():
175
+ if (major := dynamic_info.modules.major) and (archive := major.archive):
176
+ result = await self.parse_video(bvid=archive.bvid)
177
+ result.text = dynamic_info.text
178
+ result.extra["content_type"] = "动态"
179
+ return result
171
180
 
172
181
  # 下载图片
182
+ author = self.create_author(dynamic_info.name, dynamic_info.avatar)
173
183
  contents: list[MediaContent] = []
174
- for image_url in dynamic_info.image_urls:
175
- img_task = self.downloader.download_img(image_url, ext_headers=self.headers)
176
- contents.append(ImageContent(img_task))
184
+ contents.extend(self.create_image_contents(dynamic_info.image_urls))
185
+
186
+ repost = None
187
+ if dynamic_info.type == "DYNAMIC_TYPE_FORWARD" and dynamic_info.orig is not None:
188
+ repost = await self._parse_dynamic_info(dynamic_info.orig)
177
189
 
178
190
  return self.result(
179
191
  title=dynamic_info.title,
@@ -181,6 +193,8 @@ class BilibiliParser(BaseParser):
181
193
  timestamp=dynamic_info.timestamp,
182
194
  author=author,
183
195
  contents=contents,
196
+ repost=repost,
197
+ extra={"content_type": "动态"},
184
198
  )
185
199
 
186
200
  async def parse_opus_by_id(self, opus_id: int):
@@ -191,7 +205,7 @@ class BilibiliParser(BaseParser):
191
205
  async def _parse_bilibli_api_opus(self, bili_opus: Opus):
192
206
  """解析图文动态(Opus)"""
193
207
 
194
- from .opus import OpusItem, TextNode, ImageNode
208
+ from .opus import OpusItem
195
209
 
196
210
  opus_info = await bili_opus.get_info()
197
211
  if not isinstance(opus_info, dict):
@@ -201,23 +215,19 @@ class BilibiliParser(BaseParser):
201
215
  logger.debug(f"opus_data: {opus_data}")
202
216
  author = self.create_author(*opus_data.name_avatar)
203
217
 
204
- # 按顺序处理图文内容(参考 parse_read 的逻辑)
205
- contents: list[MediaContent] = []
206
- current_text = ""
207
-
218
+ # 按顺序处理图文内容
219
+ graphics = self.create_empty_graphics()
208
220
  for node in opus_data.extract_nodes():
209
- if isinstance(node, ImageNode):
210
- contents.append(self.create_graphics_content(node.url, current_text.strip(), node.alt))
211
- current_text = ""
212
- elif isinstance(node, TextNode):
213
- current_text += node.text
221
+ if isinstance(node, str):
222
+ graphics.append(node)
223
+ else:
224
+ graphics.append(self.create_image_content(node.url, alt=node.alt))
214
225
 
215
226
  return self.result(
216
227
  title=opus_data.title,
217
228
  author=author,
218
229
  timestamp=opus_data.timestamp,
219
- contents=contents,
220
- text=current_text.strip(),
230
+ graphics=graphics,
221
231
  )
222
232
 
223
233
  async def parse_live(self, room_id: int):
@@ -266,11 +276,18 @@ class BilibiliParser(BaseParser):
266
276
 
267
277
  favdata = convert(fav_dict, FavData)
268
278
 
279
+ author = self.create_author(favdata.info.upper.name, favdata.info.upper.face)
280
+
281
+ graphics: list[str | ImageContent] = []
282
+ for fav in favdata.medias:
283
+ graphics.append(self.create_image_content(fav.cover, alt=fav.desc))
284
+ graphics.append(fav.desc)
285
+
269
286
  return self.result(
270
287
  title=favdata.title,
271
288
  timestamp=favdata.timestamp,
272
- author=self.create_author(favdata.info.upper.name, favdata.info.upper.face),
273
- contents=[self.create_graphics_content(fav.cover, fav.desc) for fav in favdata.medias],
289
+ author=author,
290
+ graphics=graphics,
274
291
  )
275
292
 
276
293
  async def _get_video(self, *, bvid: str | None = None, avid: int | None = None) -> Video:
@@ -26,11 +26,26 @@ class VideoArchive(Struct):
26
26
  title: str
27
27
  desc: str
28
28
  cover: str
29
- # duration_text: str
29
+ duration_text: str = ""
30
30
  # jump_url: str
31
31
  # stat: dict[str, str]
32
32
  # badge: dict[str, Any] | None = None
33
33
 
34
+ @property
35
+ def duration_seconds(self) -> float:
36
+ """将 duration_text(如 '3:42')解析为秒数"""
37
+ if not self.duration_text:
38
+ return 0.0
39
+ parts = self.duration_text.split(":")
40
+ try:
41
+ if len(parts) == 2:
42
+ return int(parts[0]) * 60 + int(parts[1])
43
+ elif len(parts) == 3:
44
+ return int(parts[0]) * 3600 + int(parts[1]) * 60 + int(parts[2])
45
+ except ValueError:
46
+ pass
47
+ return 0.0
48
+
34
49
 
35
50
  class OpusImage(Struct):
36
51
  """图文动态图片信息"""
@@ -73,6 +88,8 @@ class DynamicMajor(Struct):
73
88
  """获取标题"""
74
89
  if self.type == "MAJOR_TYPE_ARCHIVE" and self.archive:
75
90
  return self.archive.title
91
+ if self.type == "MAJOR_TYPE_OPUS" and self.opus:
92
+ return self.opus.title
76
93
  return None
77
94
 
78
95
  @property
@@ -102,6 +119,13 @@ class DynamicMajor(Struct):
102
119
  return self.archive.cover
103
120
  return None
104
121
 
122
+ @property
123
+ def duration(self) -> float:
124
+ """获取视频时长(秒)"""
125
+ if self.type == "MAJOR_TYPE_ARCHIVE" and self.archive:
126
+ return self.archive.duration_seconds
127
+ return 0.0
128
+
105
129
 
106
130
  class DynamicModule(Struct):
107
131
  """动态模块"""
@@ -110,6 +134,8 @@ class DynamicModule(Struct):
110
134
  module_dynamic: dict[str, Any] | None = None
111
135
  module_stat: dict[str, Any] | None = None
112
136
 
137
+ _cached_major: DynamicMajor | None = None
138
+
113
139
  @property
114
140
  def author_name(self) -> str:
115
141
  """获取作者名称"""
@@ -126,7 +152,7 @@ class DynamicModule(Struct):
126
152
  return self.module_author.pub_ts
127
153
 
128
154
  @property
129
- def major_info(self) -> dict[str, Any] | None:
155
+ def _major_info(self) -> dict[str, Any] | None:
130
156
  """获取主要内容信息"""
131
157
  if self.module_dynamic:
132
158
  if major := self.module_dynamic.get("major"):
@@ -135,6 +161,24 @@ class DynamicModule(Struct):
135
161
  return self.module_dynamic
136
162
  return None
137
163
 
164
+ @property
165
+ def major(self) -> DynamicMajor | None:
166
+ """获取缓存的 DynamicMajor 实例"""
167
+ if self._cached_major is None:
168
+ major_info = self._major_info
169
+ if major_info:
170
+ self._cached_major = convert(major_info, DynamicMajor)
171
+ return self._cached_major
172
+
173
+ @property
174
+ def desc_text(self) -> str | None:
175
+ """获取动态自身的文字描述(非 major 内容的文字)"""
176
+ if self.module_dynamic:
177
+ desc = self.module_dynamic.get("desc")
178
+ if desc and isinstance(desc, dict):
179
+ return desc.get("text")
180
+ return None
181
+
138
182
 
139
183
  class DynamicInfo(Struct):
140
184
  """动态信息"""
@@ -144,6 +188,7 @@ class DynamicInfo(Struct):
144
188
  visible: bool
145
189
  modules: DynamicModule
146
190
  basic: dict[str, Any] | None = None
191
+ orig: "DynamicInfo | None" = None
147
192
 
148
193
  @property
149
194
  def name(self) -> str:
@@ -163,41 +208,31 @@ class DynamicInfo(Struct):
163
208
  @property
164
209
  def title(self) -> str | None:
165
210
  """获取标题"""
166
- major_info = self.modules.major_info
167
- if major_info:
168
- major = convert(major_info, DynamicMajor)
211
+ if major := self.modules.major:
169
212
  return major.title
170
- return None
171
213
 
172
214
  @property
173
215
  def text(self) -> str | None:
174
- """获取文本内容"""
175
- major_info = self.modules.major_info
176
- if major_info:
177
- major = convert(major_info, DynamicMajor)
216
+ """获取文本内容(优先取动态自身文字,回退到 major 的文字)"""
217
+ # 优先取动态自身描述(如发视频时附带的文字)
218
+ if desc_text := self.modules.desc_text:
219
+ return desc_text
220
+ # 回退到 major 的文字(图文摘要、视频简介等)
221
+ if major := self.modules.major:
178
222
  return major.text
179
- return None
180
223
 
181
224
  @property
182
225
  def image_urls(self) -> list[str]:
183
226
  """获取图片URL列表"""
184
- major_info = self.modules.major_info
185
- if major_info:
186
- major = convert(major_info, DynamicMajor)
227
+ if major := self.modules.major:
187
228
  return major.image_urls
188
229
  return []
189
230
 
190
- @property
191
- def cover_url(self) -> str | None:
192
- """获取封面URL"""
193
- major_info = self.modules.major_info
194
- if major_info:
195
- major = convert(major_info, DynamicMajor)
196
- return major.cover_url
197
- return None
198
-
231
+ def is_video(self) -> bool:
232
+ """判断是否为视频动态"""
233
+ major = self.modules.major
234
+ return major is not None and major.archive is not None
199
235
 
200
- class DynamicData(Struct):
201
- """动态项目"""
202
236
 
237
+ class DynamicWrapper(Struct):
203
238
  item: DynamicInfo
@@ -1,25 +1,9 @@
1
1
  from typing import Any
2
- from collections.abc import Generator
2
+ from dataclasses import dataclass
3
3
 
4
4
  from msgspec import Struct
5
5
 
6
6
 
7
- class TextNode(Struct, tag="TextNode"):
8
- """图文动态文本节点"""
9
-
10
- text: str
11
- """文本内容"""
12
-
13
-
14
- class ImageNode(Struct, tag="ImageNode"):
15
- """图文动态图片节点"""
16
-
17
- url: str
18
- """图片链接"""
19
- alt: str | None = None
20
- """图片描述"""
21
-
22
-
23
7
  class Author(Struct):
24
8
  """图文动态作者信息"""
25
9
 
@@ -102,6 +86,16 @@ class Info(Struct):
102
86
  basic: Basic | None = None
103
87
 
104
88
 
89
+ @dataclass(slots=True)
90
+ class ImageNode:
91
+ """图文动态图片节点"""
92
+
93
+ url: str
94
+ """图片链接"""
95
+ alt: str | None = None
96
+ """图片描述"""
97
+
98
+
105
99
  class OpusItem(Struct):
106
100
  """图文动态项目"""
107
101
 
@@ -124,30 +118,45 @@ class OpusItem(Struct):
124
118
  return module.module_author.pub_ts
125
119
  return None
126
120
 
127
- def extract_nodes(self) -> Generator[TextNode | ImageNode, None, None]:
128
- """生成图文节点(保持顺序)"""
121
+ def extract_nodes(self):
122
+ """提取图文节点(保持顺序)"""
129
123
  for module in self.item.modules:
130
124
  if module.module_type == "MODULE_TYPE_CONTENT" and module.module_content:
131
- for paragraph in module.module_content.paragraphs:
125
+ iterator = iter(module.module_content.paragraphs)
126
+ for paragraph in iterator:
132
127
  # 处理文本段落
133
128
  if paragraph.text and paragraph.text.nodes:
134
- text_content = self._extract_text_from_nodes(paragraph.text.nodes)
135
- text_content = text_content.strip()
136
- if text_content:
137
- yield TextNode(text="\n\n" + text_content)
138
-
129
+ cur_text = "".join(
130
+ text for text, _ in self._extract_texts_from_nodes(paragraph.text.nodes)
131
+ ).strip()
132
+ if cur_text:
133
+ yield cur_text
139
134
  # 处理图片段落
140
135
  if paragraph.pic and paragraph.pic.pics:
141
136
  for pic in paragraph.pic.pics:
142
- yield ImageNode(url=pic.url)
143
-
144
- def _extract_text_from_nodes(self, nodes: list[dict[str, Any]]) -> str:
137
+ image_node = ImageNode(url=pic.url)
138
+ next_text = ""
139
+ if (next_par := next(iterator, None)) and next_par.text and next_par.text.nodes:
140
+ for text, color in self._extract_texts_from_nodes(next_par.text.nodes):
141
+ if color == "#999999":
142
+ image_node.alt = text
143
+ else:
144
+ next_text += text
145
+ yield image_node
146
+ next_text = next_text.strip()
147
+ if next_text:
148
+ yield next_text
149
+
150
+ def _extract_texts_from_nodes(self, nodes: list[dict[str, Any]]) -> list[tuple[str, str | None]]:
145
151
  """从节点列表中提取文本内容"""
146
- text_content = ""
152
+ texts: list[tuple[str, str | None]] = []
147
153
  for node in nodes:
148
- if node.get("type") in [
154
+ if node.get("type") in (
149
155
  "TEXT_NODE_TYPE_WORD",
150
156
  "TEXT_NODE_TYPE_RICH",
151
- ] and node.get("word"):
152
- text_content += node["word"].get("words", "")
153
- return text_content
157
+ ) and node.get("word"):
158
+ text = node["word"]["words"]
159
+ color = node["word"]["color"]
160
+ texts.append((text, color))
161
+
162
+ return texts