nonebot-plugin-parser 2.6.5__tar.gz → 2.6.7__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 (80) hide show
  1. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/PKG-INFO +6 -3
  2. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/README.md +4 -0
  3. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/pyproject.toml +4 -5
  4. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/config.py +7 -0
  5. nonebot_plugin_parser-2.6.7/src/nonebot_plugin_parser/matchers/filter.py +82 -0
  6. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/opus.py +2 -2
  7. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/task.py +6 -0
  8. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/xiaohongshu/__init__.py +1 -1
  9. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/__init__.py +1 -6
  10. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/base.py +2 -2
  11. nonebot_plugin_parser-2.6.7/src/nonebot_plugin_parser/renders/common/__init__.py +4 -0
  12. nonebot_plugin_parser-2.6.7/src/nonebot_plugin_parser/renders/common/assets.py +103 -0
  13. nonebot_plugin_parser-2.6.7/src/nonebot_plugin_parser/renders/common/font.py +86 -0
  14. nonebot_plugin_parser-2.6.5/src/nonebot_plugin_parser/renders/common.py → nonebot_plugin_parser-2.6.7/src/nonebot_plugin_parser/renders/common/renderer.py +64 -169
  15. nonebot_plugin_parser-2.6.5/src/nonebot_plugin_parser/matchers/filter.py +0 -72
  16. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/__init__.py +0 -0
  17. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/constants.py +0 -0
  18. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/download/__init__.py +0 -0
  19. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/download/task.py +0 -0
  20. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/download/ytdlp.py +0 -0
  21. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/exception.py +0 -0
  22. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/helper.py +0 -0
  23. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/matchers/__init__.py +0 -0
  24. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/matchers/rule.py +0 -0
  25. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/__init__.py +0 -0
  26. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/acfun/__init__.py +0 -0
  27. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/acfun/video.py +0 -0
  28. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/base.py +0 -0
  29. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/__init__.py +0 -0
  30. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/article.py +0 -0
  31. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/common.py +0 -0
  32. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/dynamic.py +0 -0
  33. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/favlist.py +0 -0
  34. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/live.py +0 -0
  35. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/bilibili/video.py +0 -0
  36. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/cookie.py +0 -0
  37. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/data.py +0 -0
  38. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/douyin/__init__.py +0 -0
  39. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/douyin/slides.py +0 -0
  40. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/douyin/video.py +0 -0
  41. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/kuaishou/__init__.py +0 -0
  42. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/kuaishou/states.py +0 -0
  43. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/nga.py +0 -0
  44. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/tiktok.py +0 -0
  45. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/twitter.py +0 -0
  46. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/utils.py +0 -0
  47. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/weibo/__init__.py +0 -0
  48. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/weibo/article.py +0 -0
  49. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/weibo/common.py +0 -0
  50. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/weibo/show.py +0 -0
  51. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/xiaohongshu/common.py +0 -0
  52. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/xiaohongshu/discovery.py +0 -0
  53. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/xiaohongshu/explore.py +0 -0
  54. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/youtube/__init__.py +0 -0
  55. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/parsers/youtube/meta.py +0 -0
  56. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/default.py +0 -0
  57. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/htmlrender.py +0 -0
  58. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/HYSongYunLangHeiW.ttf +0 -0
  59. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/__init__.py +0 -0
  60. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/avatar.png +0 -0
  61. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/bilibili.png +0 -0
  62. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/douyin.png +0 -0
  63. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/1.jpg +0 -0
  64. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/2.jpg +0 -0
  65. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/3.jpg +0 -0
  66. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/4.jpg +0 -0
  67. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/5.jpg +0 -0
  68. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/6.jpg +0 -0
  69. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/7.jpg +0 -0
  70. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/8.jpg +0 -0
  71. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/fallback_pic/9.jpg +0 -0
  72. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/kuaishou.png +0 -0
  73. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/play.png +0 -0
  74. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/tiktok.png +0 -0
  75. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/twitter.png +0 -0
  76. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/weibo.png +0 -0
  77. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/xiaohongshu.png +0 -0
  78. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/resources/youtube.png +0 -0
  79. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/renders/templates/card.html.jinja2 +0 -0
  80. {nonebot_plugin_parser-2.6.5 → nonebot_plugin_parser-2.6.7}/src/nonebot_plugin_parser/utils.py +0 -0
@@ -1,13 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: nonebot-plugin-parser
3
- Version: 2.6.5
3
+ Version: 2.6.7
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
7
7
  Author-email: fllesser <fllessive@gmail.com>
8
8
  Classifier: Development Status :: 5 - Production/Stable
9
9
  Classifier: Framework :: AsyncIO
10
- Classifier: License :: OSI Approved :: MIT License
11
10
  Classifier: Operating System :: OS Independent
12
11
  Classifier: Programming Language :: Python :: 3
13
12
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -28,7 +27,7 @@ Requires-Dist: nonebot2>=2.5.0,<3.0.0
28
27
  Requires-Dist: apilmoji[rich]>=0.3.1,<1.0.0
29
28
  Requires-Dist: beautifulsoup4>=4.12.0,<5.0.0
30
29
  Requires-Dist: curl-cffi>=0.13.0,!=0.14.0,<1.0.0
31
- Requires-Dist: bilibili-api-python>=17.4.1,<18.0.0
30
+ Requires-Dist: bilibili-api-python>=17.4.2,<18.0.0
32
31
  Requires-Dist: nonebot-plugin-uninfo>=0.10.1,<1.0.0
33
32
  Requires-Dist: nonebot-plugin-alconna>=0.60.4,<1.0.0
34
33
  Requires-Dist: nonebot-plugin-localstore>=0.7.4,<1.0.0
@@ -316,6 +315,10 @@ parser_emoji_cdn="https://emojicdn.elk.sh"
316
315
 
317
316
  # [可选] emoji 渲染样式 "apple", "google", "twitter", "facebook"(默认)
318
317
  parser_emoji_style="facebook"
318
+
319
+ # [可选] 是否启用群组黑名单模式(默认启用,即所有群聊的解析都是开启的)
320
+ parser_group_blacklist_enabled=True
321
+
319
322
  ```
320
323
 
321
324
  </details>
@@ -262,6 +262,10 @@ parser_emoji_cdn="https://emojicdn.elk.sh"
262
262
 
263
263
  # [可选] emoji 渲染样式 "apple", "google", "twitter", "facebook"(默认)
264
264
  parser_emoji_style="facebook"
265
+
266
+ # [可选] 是否启用群组黑名单模式(默认启用,即所有群聊的解析都是开启的)
267
+ parser_group_blacklist_enabled=True
268
+
265
269
  ```
266
270
 
267
271
  </details>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parser"
3
- version = "2.6.5"
3
+ version = "2.6.7"
4
4
  description = "NoneBot2 链接分享解析 Alconna 版, 现支持B站|抖音|快手|微博|小红书|YouTube|TikTok|Twitter|AcFun|NGA"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -24,7 +24,6 @@ keywords = [
24
24
  classifiers = [
25
25
  "Development Status :: 5 - Production/Stable",
26
26
  "Framework :: AsyncIO",
27
- "License :: OSI Approved :: MIT License",
28
27
  "Operating System :: OS Independent",
29
28
  "Programming Language :: Python :: 3",
30
29
  "Programming Language :: Python :: 3 :: Only",
@@ -48,7 +47,7 @@ dependencies = [
48
47
  "apilmoji[rich]>=0.3.1,<1.0.0",
49
48
  "beautifulsoup4>=4.12.0,<5.0.0",
50
49
  "curl_cffi>=0.13.0,<1.0.0,!=0.14.0",
51
- "bilibili-api-python>=17.4.1,<18.0.0",
50
+ "bilibili-api-python>=17.4.2,<18.0.0",
52
51
  "nonebot-plugin-uninfo>=0.10.1,<1.0.0",
53
52
  "nonebot-plugin-alconna>=0.60.4,<1.0.0",
54
53
  "nonebot-plugin-localstore>=0.7.4,<1.0.0",
@@ -100,7 +99,7 @@ pydantic-v2 = ["pydantic>=2.0.0"]
100
99
  telegram = ["nonebot-adapter-telegram>=0.1.0b20"]
101
100
 
102
101
  [build-system]
103
- requires = ["uv_build>=0.10.0,<0.11.0"]
102
+ requires = ["uv_build>=0.11.0,<0.12.0"]
104
103
  build-backend = "uv_build"
105
104
 
106
105
  [tool.uv]
@@ -116,7 +115,7 @@ conflicts = [
116
115
  [tool.bumpversion]
117
116
  tag = true
118
117
  commit = true
119
- current_version = "2.6.5"
118
+ current_version = "2.6.7"
120
119
  message = "release: bump vesion from {current_version} to {new_version}"
121
120
 
122
121
  [[tool.bumpversion.files]]
@@ -56,6 +56,8 @@ class Config(BaseModel):
56
56
  """Pilmoji 表情 CDN"""
57
57
  parser_emoji_style: EmojiStyle = EmojiStyle.FACEBOOK
58
58
  """Pilmoji 表情样式"""
59
+ parser_group_blacklist_enabled: bool = True
60
+ """是否启用群组黑名单模式(默认启用,即所有群聊的解析都是开启的)"""
59
61
 
60
62
  @property
61
63
  def nickname(self) -> str:
@@ -182,6 +184,11 @@ class Config(BaseModel):
182
184
  """Pilmoji 表情样式"""
183
185
  return self.parser_emoji_style
184
186
 
187
+ @property
188
+ def group_blacklist_enabled(self) -> bool:
189
+ """是否启用群组黑名单模式"""
190
+ return self.parser_group_blacklist_enabled
191
+
185
192
 
186
193
  pconfig: Config = get_plugin_config(Config)
187
194
  """插件配置"""
@@ -0,0 +1,82 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from nonebot import on_command
5
+ from nonebot.rule import to_me
6
+ from nonebot.matcher import Matcher
7
+ from nonebot.permission import SUPERUSER
8
+ from nonebot_plugin_uninfo import ADMIN, Session, UniSession
9
+
10
+ from ..config import pconfig
11
+
12
+ _DISABLED_GROUPS_PATH: Path = pconfig.data_dir / "disabled_groups.json"
13
+ _GROUP_SET_PATH: Path = pconfig.data_dir / "group_set.json"
14
+
15
+
16
+ def _load_or_initialize_set() -> set[str]:
17
+ """加载群配置"""
18
+ # 判断是否存在
19
+ if not _GROUP_SET_PATH.exists():
20
+ if _DISABLED_GROUPS_PATH.exists():
21
+ # 迁移旧的关闭解析名单
22
+ _DISABLED_GROUPS_PATH.rename(_GROUP_SET_PATH)
23
+ else:
24
+ _GROUP_SET_PATH.write_text(json.dumps([]))
25
+
26
+ return set(json.loads(_GROUP_SET_PATH.read_text()))
27
+
28
+
29
+ _GROUP_SET: set[str] = _load_or_initialize_set()
30
+
31
+
32
+ def _save_group_set():
33
+ _GROUP_SET_PATH.write_text(json.dumps(list(_GROUP_SET)))
34
+
35
+
36
+ def _add_group(group_key: str):
37
+ _GROUP_SET.add(group_key)
38
+ _save_group_set()
39
+
40
+
41
+ def _remove_group(group_key: str):
42
+ _GROUP_SET.discard(group_key)
43
+ _save_group_set()
44
+
45
+
46
+ def _get_group_key(session: Session) -> str:
47
+ """获取群组的唯一标识符 由平台名称和会话场景 ID 组成,例如 `QQClient_123456789`"""
48
+ return f"{session.scope}_{session.scene_path}"
49
+
50
+
51
+ def is_enabled(session: Session = UniSession()) -> bool:
52
+ """判断当前会话是否在关闭解析的名单中"""
53
+ if session.scene.is_private:
54
+ return True
55
+
56
+ group_key = _get_group_key(session)
57
+ if pconfig.group_blacklist_enabled:
58
+ return group_key not in _GROUP_SET
59
+ else:
60
+ return group_key in _GROUP_SET
61
+
62
+
63
+ @on_command("开启解析", rule=to_me(), permission=SUPERUSER | ADMIN(), block=True).handle()
64
+ async def _(matcher: Matcher, session: Session = UniSession()):
65
+ """开启解析"""
66
+ group_key = _get_group_key(session)
67
+ if pconfig.group_blacklist_enabled:
68
+ _remove_group(group_key)
69
+ else:
70
+ _add_group(group_key)
71
+ await matcher.finish("解析已开启")
72
+
73
+
74
+ @on_command("关闭解析", rule=to_me(), permission=SUPERUSER | ADMIN(), block=True).handle()
75
+ async def _(matcher: Matcher, session: Session = UniSession()):
76
+ """关闭解析"""
77
+ group_key = _get_group_key(session)
78
+ if pconfig.group_blacklist_enabled:
79
+ _add_group(group_key)
80
+ else:
81
+ _remove_group(group_key)
82
+ await matcher.finish("解析已关闭")
@@ -11,7 +11,7 @@ class Author(Struct):
11
11
  face: str
12
12
  mid: int
13
13
  pub_time: str
14
- pub_ts: int
14
+ pub_ts: str
15
15
 
16
16
 
17
17
  class Image(Struct):
@@ -115,7 +115,7 @@ class OpusItem(Struct):
115
115
  """获取发布时间戳"""
116
116
  for module in self.item.modules:
117
117
  if module.module_type == "MODULE_TYPE_AUTHOR" and module.module_author:
118
- return module.module_author.pub_ts
118
+ return int(module.module_author.pub_ts)
119
119
  return None
120
120
 
121
121
  def extract_nodes(self):
@@ -3,6 +3,10 @@ from asyncio import Task, create_task
3
3
  from pathlib import Path
4
4
  from collections.abc import Callable, Coroutine
5
5
 
6
+ from nonebot import logger
7
+
8
+ from ..exception import ParseException
9
+
6
10
 
7
11
  class PathTask:
8
12
  __slots__ = ("_path", "_task")
@@ -31,6 +35,8 @@ class PathTask:
31
35
  try:
32
36
  return await self.get()
33
37
  except Exception as e:
38
+ if not isinstance(e, ParseException):
39
+ logger.opt(exception=e).error(f"task({self._task.get_name()}) failed")
34
40
  if on_error is not None:
35
41
  on_error(e)
36
42
  return None
@@ -33,7 +33,7 @@ class XiaoHongShuParser(BaseParser):
33
33
  self.headers["cookie"] = pconfig.xhs_ck
34
34
  self.ios_headers["cookie"] = pconfig.xhs_ck
35
35
 
36
- @handle("xhslink.com", r"xhslink\.com/[A-Za-z0-9._?%&+=/#@-]+")
36
+ @handle("xhslink", r"xhslink\.(com|cn)/[A-Za-z0-9._?%&+=/#@-]+")
37
37
  async def _parse_short_link(self, searched: re.Match[str]):
38
38
  url = f"https://{searched.group(0)}"
39
39
  return await self.parse_with_redirect(url, self.ios_headers)
@@ -1,6 +1,6 @@
1
1
  import importlib
2
2
 
3
- from nonebot import logger, get_driver
3
+ from nonebot import logger
4
4
 
5
5
  from .base import BaseRenderer
6
6
  from .common import CommonRenderer
@@ -37,8 +37,3 @@ def get_renderer(platform: str) -> type[BaseRenderer]:
37
37
 
38
38
  module = importlib.import_module("." + platform, package=__name__)
39
39
  return getattr(module, "Renderer")
40
-
41
-
42
- @get_driver().on_startup
43
- async def load_resources():
44
- CommonRenderer.load_resources()
@@ -11,7 +11,7 @@ import aiofiles
11
11
  from ..config import pconfig
12
12
  from ..helper import UniHelper, UniMessage, ForwardNodeInner
13
13
  from ..parsers import ParseResult, AudioContent, ImageContent, VideoContent
14
- from ..exception import DownloadException
14
+ from ..exception import IgnoreException, DownloadException
15
15
 
16
16
 
17
17
  class BaseRenderer(ABC):
@@ -39,7 +39,7 @@ class BaseRenderer(ABC):
39
39
  other_segs: list[ForwardNodeInner] = []
40
40
 
41
41
  def on_error(e: Exception):
42
- if isinstance(e, DownloadException):
42
+ if not isinstance(e, IgnoreException):
43
43
  nonlocal failed_count
44
44
  failed_count += 1
45
45
 
@@ -0,0 +1,4 @@
1
+ from .assets import load_resources, ensure_resources
2
+ from .renderer import CommonRenderer
3
+
4
+ __all__ = ["CommonRenderer", "ensure_resources", "load_resources"]
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ from PIL import Image
4
+ from nonebot import logger, get_driver
5
+ from apilmoji import EmojiCDNSource
6
+
7
+ from .. import resources
8
+ from .font import CardFonts, CardTheme
9
+ from ...config import pconfig
10
+
11
+ PILImage = Image.Image
12
+
13
+ # apilmoji emoji 源
14
+ EMOJI_SOURCE = EmojiCDNSource(
15
+ base_url=pconfig.emoji_cdn,
16
+ style=pconfig.emoji_style,
17
+ cache_dir=pconfig.cache_dir / "emojis",
18
+ show_progress=True,
19
+ )
20
+
21
+ DEFAULT_THEME = CardTheme(
22
+ name=(0, 122, 255),
23
+ title=(102, 51, 153),
24
+ body=(51, 51, 51),
25
+ muted=(136, 136, 136),
26
+ )
27
+
28
+ AVATAR_SIZE = 80
29
+ FONTS: CardFonts
30
+ PLATFORM_LOGOS: dict[str, PILImage]
31
+ AVATAR_IMAGE: PILImage
32
+ VIDEO_BUTTON_IMAGE: PILImage
33
+
34
+ _resources_loaded = False
35
+
36
+
37
+ @get_driver().on_startup
38
+ async def load_common_renderer_resources():
39
+ load_resources()
40
+
41
+
42
+ def ensure_resources() -> None:
43
+ if _resources_loaded:
44
+ return
45
+ load_resources()
46
+
47
+
48
+ def load_resources() -> None:
49
+ """加载渲染资源(幂等)"""
50
+ global _resources_loaded, FONTS, PLATFORM_LOGOS, AVATAR_IMAGE, VIDEO_BUTTON_IMAGE
51
+
52
+ if _resources_loaded:
53
+ return
54
+
55
+ FONTS = _load_fonts()
56
+ PLATFORM_LOGOS = _load_platform_logos()
57
+ AVATAR_IMAGE = _load_default_avatar()
58
+ VIDEO_BUTTON_IMAGE = _load_video_button()
59
+
60
+ _resources_loaded = True
61
+
62
+
63
+ def _load_fonts() -> CardFonts:
64
+ """字体(昵称 / 标题 / 正文 / 辅助文案)"""
65
+ font_path = pconfig.custom_font or resources.DEFAULT_FONT_PATH
66
+ loaded = CardFonts.load(font_path, DEFAULT_THEME)
67
+ logger.success(f"加载字体「{font_path.name}」成功")
68
+ return loaded
69
+
70
+
71
+ def _load_platform_logos() -> dict[str, PILImage]:
72
+ """平台 Logo"""
73
+ from ...constants import PlatformEnum
74
+
75
+ logos: dict[str, PILImage] = {}
76
+ loaded_platforms = []
77
+ for platform_name in PlatformEnum:
78
+ logo_path = resources.RESOURCES_DIR / f"{platform_name}.png"
79
+ if logo_path.exists():
80
+ with Image.open(logo_path) as img:
81
+ logos[str(platform_name)] = img.convert("RGBA")
82
+ loaded_platforms.append(platform_name)
83
+ logger.debug(f"加载 Logo「{', '.join(loaded_platforms)}」成功")
84
+ return logos
85
+
86
+
87
+ def _load_default_avatar() -> PILImage:
88
+ """默认头像(作者无头像或加载失败时回退)"""
89
+ with Image.open(resources.DEFAULT_AVATAR_PATH) as img:
90
+ loaded = img.convert("RGBA").resize((AVATAR_SIZE, AVATAR_SIZE))
91
+ logger.debug(f"加载头像「{resources.DEFAULT_AVATAR_PATH.name}」成功")
92
+ return loaded
93
+
94
+
95
+ def _load_video_button() -> PILImage:
96
+ """视频播放按钮(封面居中叠加,半透明)"""
97
+ with Image.open(resources.DEFAULT_VIDEO_BUTTON_PATH) as img:
98
+ button = img.convert("RGBA").resize((100, 100))
99
+ alpha = button.split()[-1]
100
+ alpha = alpha.point(lambda x: int(x * 0.6))
101
+ button.putalpha(alpha)
102
+ logger.debug(f"加载视频播放按钮「{resources.DEFAULT_VIDEO_BUTTON_PATH.name}」成功")
103
+ return button
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from functools import lru_cache
5
+ from dataclasses import dataclass
6
+
7
+ from PIL import ImageFont
8
+ from apilmoji.core import get_font_height
9
+
10
+ Color = tuple[int, int, int]
11
+
12
+
13
+ @dataclass(eq=False, frozen=True, slots=True)
14
+ class FontMetrics:
15
+ """字体度量(换行、估高)"""
16
+
17
+ font: ImageFont.FreeTypeFont
18
+ line_height: int
19
+ cjk_width: int
20
+
21
+ def __hash__(self) -> int:
22
+ return hash((id(self.font), self.line_height, self.cjk_width))
23
+
24
+ @lru_cache(maxsize=500)
25
+ def get_char_width(self, char: str) -> int:
26
+ bbox = self.font.getbbox(char)
27
+ return int(bbox[2] - bbox[0])
28
+
29
+ def get_char_width_fast(self, char: str) -> int:
30
+ if "\u4e00" <= char <= "\u9fff":
31
+ return self.cjk_width
32
+ return self.get_char_width(char)
33
+
34
+ def get_text_width(self, text: str) -> int:
35
+ if not text:
36
+ return 0
37
+ return sum(self.get_char_width_fast(char) for char in text)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class CardTheme:
42
+ """卡片颜色"""
43
+
44
+ name: Color
45
+ title: Color
46
+ body: Color
47
+ muted: Color
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class StyledFont:
52
+ """度量 + 颜色"""
53
+
54
+ metrics: FontMetrics
55
+ fill: Color
56
+
57
+
58
+ def _load_styled(font_path: Path, size: int, fill: Color) -> StyledFont:
59
+ font = ImageFont.truetype(font_path, size)
60
+ return StyledFont(
61
+ metrics=FontMetrics(
62
+ font=font,
63
+ line_height=get_font_height(font),
64
+ cjk_width=size,
65
+ ),
66
+ fill=fill,
67
+ )
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class CardFonts:
72
+ """卡片各区块字体(加载时组合 theme)"""
73
+
74
+ name: StyledFont
75
+ title: StyledFont
76
+ body: StyledFont
77
+ muted: StyledFont
78
+
79
+ @classmethod
80
+ def load(cls, font_path: Path, theme: CardTheme) -> CardFonts:
81
+ return cls(
82
+ name=_load_styled(font_path, 28, theme.name),
83
+ title=_load_styled(font_path, 30, theme.title),
84
+ body=_load_styled(font_path, 24, theme.body),
85
+ muted=_load_styled(font_path, 24, theme.muted),
86
+ )
@@ -1,19 +1,17 @@
1
1
  from io import BytesIO
2
2
  from typing import ClassVar
3
3
  from pathlib import Path
4
- from functools import lru_cache
5
- from dataclasses import dataclass
6
4
  from typing_extensions import override
7
5
 
8
6
  import emoji
9
7
  from PIL import Image, ImageDraw, ImageFont
10
8
  from nonebot import logger
11
- from apilmoji import Apilmoji, EmojiCDNSource
12
- from apilmoji.core import get_font_height
9
+ from apilmoji import Apilmoji
13
10
 
14
- from . import resources
15
- from .base import ParseResult, ImageContent, ImageRenderer
16
- from ..config import pconfig
11
+ from . import assets
12
+ from .. import resources
13
+ from .font import StyledFont, FontMetrics
14
+ from ..base import ParseResult, ImageContent, ImageRenderer
17
15
 
18
16
  Color = tuple[int, int, int]
19
17
  PILImage = Image.Image
@@ -28,71 +26,11 @@ except OSError:
28
26
  emosvg = None
29
27
 
30
28
 
31
- @dataclass(eq=False, frozen=True, slots=True)
32
- class FontInfo:
33
- """字体信息数据类"""
34
-
35
- font: ImageFont.FreeTypeFont
36
- fill: Color
37
- line_height: int
38
- cjk_width: int
39
-
40
- def __hash__(self) -> int:
41
- return hash((id(self.font), self.line_height, self.cjk_width, self.fill))
42
-
43
- @lru_cache(maxsize=500)
44
- def get_char_width(self, char: str) -> int:
45
- bbox = self.font.getbbox(char)
46
- return int(bbox[2] - bbox[0])
47
-
48
- def get_char_width_fast(self, char: str) -> int:
49
- if "\u4e00" <= char <= "\u9fff":
50
- return self.cjk_width
51
- return self.get_char_width(char)
52
-
53
- def get_text_width(self, text: str) -> int:
54
- if not text:
55
- return 0
56
- return sum(self.get_char_width_fast(char) for char in text)
57
-
58
-
59
- @dataclass(eq=False, frozen=True, slots=True)
60
- class FontSet:
61
- """字体集数据类"""
62
-
63
- _FONT_INFOS = (
64
- ("name", 28, (0, 122, 255)),
65
- ("title", 30, (102, 51, 153)),
66
- ("text", 24, (51, 51, 51)),
67
- ("extra", 24, (136, 136, 136)),
68
- )
69
-
70
- name: FontInfo
71
- title: FontInfo
72
- text: FontInfo
73
- extra: FontInfo
74
-
75
- @classmethod
76
- def new(cls, font_path: Path):
77
- font_infos: dict[str, FontInfo] = {}
78
- for name, size, fill in cls._FONT_INFOS:
79
- font = ImageFont.truetype(font_path, size)
80
- height = get_font_height(font)
81
- font_infos[name] = FontInfo(
82
- font=font,
83
- fill=fill,
84
- line_height=height,
85
- cjk_width=size,
86
- )
87
- return FontSet(**font_infos)
88
-
89
-
90
29
  class CommonRenderer(ImageRenderer):
91
30
  """统一渲染器"""
92
31
 
93
32
  # 布局常量
94
33
  PADDING = 25
95
- AVATAR_SIZE = 80
96
34
  AVATAR_TEXT_GAP = 15
97
35
  SECTION_SPACING = 15
98
36
  NAME_TIME_GAP = 5
@@ -116,16 +54,9 @@ class CommonRenderer(ImageRenderer):
116
54
  REPOST_BG_COLOR: ClassVar[Color] = (247, 247, 247)
117
55
  REPOST_BORDER_COLOR: ClassVar[Color] = (230, 230, 230)
118
56
 
119
- # apilmoji emoji 源
120
- EMOJI_SOURCE: ClassVar[EmojiCDNSource] = EmojiCDNSource(
121
- base_url=pconfig.emoji_cdn,
122
- style=pconfig.emoji_style,
123
- cache_dir=pconfig.cache_dir / "emojis",
124
- show_progress=True,
125
- )
126
-
127
57
  def __init__(self, result: ParseResult, not_repost: bool = True):
128
58
  super().__init__(result, not_repost)
59
+ assets.ensure_resources()
129
60
 
130
61
  self.card_width: int = self.DEFAULT_CARD_WIDTH
131
62
  self.content_width: int = self.card_width - 2 * self.PADDING
@@ -140,49 +71,6 @@ class CommonRenderer(ImageRenderer):
140
71
  False,
141
72
  )
142
73
 
143
- @classmethod
144
- def load_resources(cls):
145
- """加载资源"""
146
- cls._load_fonts()
147
- cls._load_platform_logos()
148
- cls._load_other_resources()
149
-
150
- @classmethod
151
- def _load_fonts(cls):
152
- font_path = pconfig.custom_font or resources.DEFAULT_FONT_PATH
153
- cls.fontset = FontSet.new(font_path)
154
- logger.success(f"加载字体「{font_path.name}」成功")
155
-
156
- @classmethod
157
- def _load_platform_logos(cls):
158
- from ..constants import PlatformEnum
159
-
160
- cls.platform_logos: dict[str, PILImage] = {}
161
- loaded_platforms = []
162
- for platform_name in PlatformEnum:
163
- logo_path = resources.RESOURCES_DIR / f"{platform_name}.png"
164
-
165
- if logo_path.exists():
166
- with Image.open(logo_path) as img:
167
- cls.platform_logos[str(platform_name)] = img.convert("RGBA")
168
- loaded_platforms.append(platform_name)
169
- logger.debug(f"加载 Logo「{', '.join(loaded_platforms)}」成功")
170
-
171
- @classmethod
172
- def _load_other_resources(cls):
173
- # avatar
174
- with Image.open(resources.DEFAULT_AVATAR_PATH) as img:
175
- cls.avatar_image: PILImage = img.convert("RGBA").resize((cls.AVATAR_SIZE, cls.AVATAR_SIZE))
176
- logger.debug(f"加载头像「{resources.DEFAULT_AVATAR_PATH.name}」成功")
177
-
178
- # video button
179
- with Image.open(resources.DEFAULT_VIDEO_BUTTON_PATH) as img:
180
- cls.video_button_image: PILImage = img.convert("RGBA").resize((100, 100))
181
- alpha = cls.video_button_image.split()[-1]
182
- alpha = alpha.point(lambda x: int(x * 0.6))
183
- cls.video_button_image.putalpha(alpha)
184
- logger.debug(f"加载视频播放按钮「{resources.DEFAULT_VIDEO_BUTTON_PATH.name}」成功")
185
-
186
74
  @override
187
75
  async def render_image(self) -> bytes:
188
76
  image = await self._render_image()
@@ -214,11 +102,11 @@ class CommonRenderer(ImageRenderer):
214
102
  def _estimate_text_height(
215
103
  self,
216
104
  text: str,
217
- font: FontInfo,
105
+ metrics: FontMetrics,
218
106
  content_width: int,
219
107
  ) -> int:
220
108
  """估算文本高度(考虑换行符)"""
221
- return (text.count("\n") + 1 + len(text) * font.cjk_width // content_width) * font.line_height
109
+ return (text.count("\n") + 1 + len(text) * metrics.cjk_width // content_width) * metrics.line_height
222
110
 
223
111
  def _estimate_height(self) -> int:
224
112
  """估算画布高度"""
@@ -227,13 +115,13 @@ class CommonRenderer(ImageRenderer):
227
115
 
228
116
  # 头部(头像 + 名称 + 时间)
229
117
  if self.result.author:
230
- height += self.AVATAR_SIZE + self.SECTION_SPACING
118
+ height += assets.AVATAR_SIZE + self.SECTION_SPACING
231
119
 
232
120
  # 标题
233
121
  if self.result.title:
234
122
  height += self._estimate_text_height(
235
123
  self.result.title,
236
- self.fontset.title,
124
+ assets.FONTS.title.metrics,
237
125
  self.content_width,
238
126
  )
239
127
  height += self.SECTION_SPACING
@@ -244,7 +132,7 @@ class CommonRenderer(ImageRenderer):
244
132
  if isinstance(item, str):
245
133
  height += self._estimate_text_height(
246
134
  item,
247
- self.fontset.text,
135
+ assets.FONTS.body.metrics,
248
136
  self.content_width,
249
137
  )
250
138
  else:
@@ -258,14 +146,19 @@ class CommonRenderer(ImageRenderer):
258
146
  if self.result.text:
259
147
  height += self._estimate_text_height(
260
148
  self.result.text,
261
- self.fontset.text,
149
+ assets.FONTS.body.metrics,
262
150
  self.content_width,
263
151
  )
264
152
  height += self.SECTION_SPACING
265
153
 
266
154
  # 额外信息
267
155
  if self.result.extra_info:
268
- height += self.fontset.extra.line_height * 3 + self.SECTION_SPACING
156
+ height += self._estimate_text_height(
157
+ self.result.extra_info,
158
+ assets.FONTS.muted.metrics,
159
+ self.content_width,
160
+ )
161
+ height += self.SECTION_SPACING
269
162
 
270
163
  # 转发内容
271
164
  if self.result.repost:
@@ -288,21 +181,21 @@ class CommonRenderer(ImageRenderer):
288
181
  self._image.paste(avatar, (x_pos, self.y_pos), avatar)
289
182
 
290
183
  # 文字区域
291
- text_x = self.PADDING + self.AVATAR_SIZE + self.AVATAR_TEXT_GAP
292
- name_height = self.fontset.name.line_height
184
+ text_x = self.PADDING + assets.AVATAR_SIZE + self.AVATAR_TEXT_GAP
185
+ name_height = assets.FONTS.name.metrics.line_height
293
186
  time_str = self.result.formartted_datetime
294
- time_height = (self.NAME_TIME_GAP + self.fontset.extra.line_height) if time_str else 0
187
+ time_height = (self.NAME_TIME_GAP + assets.FONTS.muted.metrics.line_height) if time_str else 0
295
188
  text_height = name_height + time_height
296
189
 
297
190
  # 垂直居中
298
- text_y = self.y_pos + (self.AVATAR_SIZE - text_height) // 2
191
+ text_y = self.y_pos + (assets.AVATAR_SIZE - text_height) // 2
299
192
 
300
193
  # 名称
301
194
  self._draw.text(
302
195
  (text_x, text_y),
303
196
  self.result.author.name,
304
- font=self.fontset.name.font,
305
- fill=self.fontset.name.fill,
197
+ font=assets.FONTS.name.metrics.font,
198
+ fill=assets.FONTS.name.fill,
306
199
  )
307
200
  text_y += name_height
308
201
 
@@ -312,39 +205,39 @@ class CommonRenderer(ImageRenderer):
312
205
  self._draw.text(
313
206
  (text_x, text_y),
314
207
  time_str,
315
- font=self.fontset.extra.font,
316
- fill=self.fontset.extra.fill,
208
+ font=assets.FONTS.muted.metrics.font,
209
+ fill=assets.FONTS.muted.fill,
317
210
  )
318
211
 
319
212
  # 平台 Logo
320
213
  if self.not_repost:
321
214
  platform_name = self.result.platform.name
322
- if platform_name in self.platform_logos:
323
- logo = self.platform_logos[platform_name]
215
+ if platform_name in assets.PLATFORM_LOGOS:
216
+ logo = assets.PLATFORM_LOGOS[platform_name]
324
217
  logo_x = self._image.width - self.PADDING - logo.width
325
- logo_y = self.y_pos + (self.AVATAR_SIZE - logo.height) // 2
218
+ logo_y = self.y_pos + (assets.AVATAR_SIZE - logo.height) // 2
326
219
  self._image.paste(logo, (logo_x, logo_y), logo)
327
220
 
328
- self.y_pos += self.AVATAR_SIZE + self.SECTION_SPACING
221
+ self.y_pos += assets.AVATAR_SIZE + self.SECTION_SPACING
329
222
 
330
223
  def _load_avatar(self, avatar_path: Path | None) -> PILImage:
331
224
  """加载头像(带圆形裁剪)"""
332
225
  if avatar_path is None or not avatar_path.exists():
333
- return self.avatar_image
226
+ return assets.AVATAR_IMAGE
334
227
 
335
228
  try:
336
229
  with Image.open(avatar_path) as img:
337
230
  avatar = img.convert("RGBA")
338
231
  avatar = avatar.resize(
339
- (self.AVATAR_SIZE, self.AVATAR_SIZE),
232
+ (assets.AVATAR_SIZE, assets.AVATAR_SIZE),
340
233
  Image.Resampling.LANCZOS,
341
234
  )
342
235
  except Exception:
343
- return self.avatar_image
236
+ return assets.AVATAR_IMAGE
344
237
 
345
238
  # 圆形遮罩
346
- mask = Image.new("L", (self.AVATAR_SIZE, self.AVATAR_SIZE), 0)
347
- ImageDraw.Draw(mask).ellipse((0, 0, self.AVATAR_SIZE - 1, self.AVATAR_SIZE - 1), fill=255)
239
+ mask = Image.new("L", (assets.AVATAR_SIZE, assets.AVATAR_SIZE), 0)
240
+ ImageDraw.Draw(mask).ellipse((0, 0, assets.AVATAR_SIZE - 1, assets.AVATAR_SIZE - 1), fill=255)
348
241
  avatar.putalpha(mask)
349
242
  return avatar
350
243
 
@@ -356,9 +249,9 @@ class CommonRenderer(ImageRenderer):
356
249
  lines = self._wrap_text(
357
250
  self.result.title,
358
251
  self.content_width,
359
- self.fontset.title,
252
+ assets.FONTS.title.metrics,
360
253
  )
361
- self.y_pos += await self._draw_text(lines, self.fontset.title)
254
+ self.y_pos += await self._draw_text(lines, assets.FONTS.title)
362
255
  self.y_pos += self.SECTION_SPACING
363
256
 
364
257
  async def _render_main_content(self) -> None:
@@ -416,15 +309,15 @@ class CommonRenderer(ImageRenderer):
416
309
  btn_size = 100
417
310
  btn_x, btn_y = (img.width - btn_size) // 2, (img.height - btn_size) // 2
418
311
  img.paste(
419
- self.video_button_image,
312
+ assets.VIDEO_BUTTON_IMAGE,
420
313
  (btn_x, btn_y),
421
- self.video_button_image,
314
+ assets.VIDEO_BUTTON_IMAGE,
422
315
  )
423
316
 
424
317
  # 视频时长
425
318
  # display_duration = video_content.display_duration
426
319
 
427
- # font = self.fontset.extra
320
+ # paint = assets.FONTS.muted
428
321
  # text_width = font.get_text_width(display_duration)
429
322
  # # 计算文本绘制位置
430
323
  # text_x = img.width - text_width - 20
@@ -450,8 +343,8 @@ class CommonRenderer(ImageRenderer):
450
343
  # ImageDraw.Draw(img).text(
451
344
  # (text_x, text_y),
452
345
  # display_duration,
453
- # font=self.fontset.extra.font,
454
- # fill=self.fontset.extra.fill,
346
+ # font=paint.metrics.font,
347
+ # fill=paint.fill,
455
348
  # )
456
349
 
457
350
  return img.copy()
@@ -594,15 +487,16 @@ class CommonRenderer(ImageRenderer):
594
487
  # Alt 文本
595
488
  if image_content.alt:
596
489
  self.y_pos += self.SECTION_SPACING
597
- text_w = self.fontset.extra.get_text_width(image_content.alt)
490
+ paint = assets.FONTS.muted
491
+ text_w = paint.metrics.get_text_width(image_content.alt)
598
492
  text_x = self.PADDING + (self.content_width - text_w) // 2
599
493
  self._draw.text(
600
494
  (text_x, self.y_pos),
601
495
  image_content.alt,
602
- font=self.fontset.extra.font,
603
- fill=self.fontset.extra.fill,
496
+ font=paint.metrics.font,
497
+ fill=paint.fill,
604
498
  )
605
- self.y_pos += self.fontset.extra.line_height
499
+ self.y_pos += paint.metrics.line_height
606
500
 
607
501
  self.y_pos += self.SECTION_SPACING
608
502
 
@@ -615,9 +509,9 @@ class CommonRenderer(ImageRenderer):
615
509
  lines = self._wrap_text(
616
510
  text,
617
511
  self.content_width,
618
- self.fontset.text,
512
+ assets.FONTS.body.metrics,
619
513
  )
620
- self.y_pos += await self._draw_text(lines, self.fontset.text)
514
+ self.y_pos += await self._draw_text(lines, assets.FONTS.body)
621
515
  self.y_pos += self.SECTION_SPACING
622
516
 
623
517
  async def _render_extra(self) -> None:
@@ -628,9 +522,9 @@ class CommonRenderer(ImageRenderer):
628
522
  lines = self._wrap_text(
629
523
  self.result.extra_info,
630
524
  self.content_width,
631
- self.fontset.extra,
525
+ assets.FONTS.muted.metrics,
632
526
  )
633
- self.y_pos += await self._draw_text(lines, self.fontset.extra)
527
+ self.y_pos += await self._draw_text(lines, assets.FONTS.muted)
634
528
 
635
529
  async def _render_repost(self) -> None:
636
530
  """渲染转发内容"""
@@ -664,34 +558,35 @@ class CommonRenderer(ImageRenderer):
664
558
  self._image.paste(repost_img, (card_x, card_y))
665
559
  self.y_pos += container_h + self.SECTION_SPACING
666
560
 
667
- async def _draw_text(self, lines: list[str], font: FontInfo) -> int:
561
+ async def _draw_text(self, lines: list[str], styled: StyledFont) -> int:
668
562
  """绘制多行文本"""
669
563
  if not lines:
670
564
  return 0
671
565
 
566
+ metrics = styled.metrics
672
567
  xy = (self.PADDING, self.y_pos)
673
568
  if emosvg is not None:
674
569
  emosvg.text(
675
570
  self._image,
676
571
  xy,
677
572
  lines,
678
- font.font,
679
- fill=font.fill,
680
- line_height=font.line_height,
573
+ metrics.font,
574
+ fill=styled.fill,
575
+ line_height=metrics.line_height,
681
576
  )
682
577
  else:
683
578
  await Apilmoji.text(
684
579
  self._image,
685
580
  xy,
686
581
  lines,
687
- font.font,
688
- fill=font.fill,
689
- line_height=font.line_height,
690
- source=self.EMOJI_SOURCE,
582
+ metrics.font,
583
+ fill=styled.fill,
584
+ line_height=metrics.line_height,
585
+ source=assets.EMOJI_SOURCE,
691
586
  )
692
- return font.line_height * len(lines)
587
+ return metrics.line_height * len(lines)
693
588
 
694
- def _wrap_text(self, text: str, max_width: int, font: FontInfo) -> list[str]:
589
+ def _wrap_text(self, text: str, max_width: int, metrics: FontMetrics) -> list[str]:
695
590
  """文本自动换行"""
696
591
  if not text:
697
592
  return []
@@ -718,12 +613,12 @@ class CommonRenderer(ImageRenderer):
718
613
  if ed["match_start"] == idx:
719
614
  char = ed["emoji"]
720
615
  idx = ed["match_end"]
721
- char_width = font.font.size
616
+ char_width = metrics.font.size
722
617
  break
723
618
  else:
724
619
  char = paragraph[idx]
725
620
  idx += 1
726
- char_width = font.get_char_width_fast(char)
621
+ char_width = metrics.get_char_width_fast(char)
727
622
 
728
623
  if not current_line:
729
624
  current_line = char
@@ -1,72 +0,0 @@
1
- import json
2
- from pathlib import Path
3
-
4
- from nonebot import on_command
5
- from nonebot.rule import to_me
6
- from nonebot.matcher import Matcher
7
- from nonebot.permission import SUPERUSER
8
- from nonebot_plugin_uninfo import ADMIN, Session, UniSession
9
-
10
- from ..config import pconfig
11
-
12
- _DISABLED_GROUPS_PATH: Path = pconfig.data_dir / "disabled_groups.json"
13
-
14
-
15
- def load_or_initialize_set() -> set[str]:
16
- """加载或初始化关闭解析的名单"""
17
- # 判断是否存在
18
- if not _DISABLED_GROUPS_PATH.exists():
19
- _DISABLED_GROUPS_PATH.write_text(json.dumps([]))
20
- return set(json.loads(_DISABLED_GROUPS_PATH.read_text()))
21
-
22
-
23
- def save_disabled_groups():
24
- """保存关闭解析的名单"""
25
- _DISABLED_GROUPS_PATH.write_text(json.dumps(list(_DISABLED_GROUPS_SET)))
26
-
27
-
28
- # 内存中关闭解析的名单,第一次先进行初始化
29
- _DISABLED_GROUPS_SET: set[str] = load_or_initialize_set()
30
-
31
-
32
- def get_group_key(session: Session) -> str:
33
- """获取群组的唯一标识符
34
-
35
- 由平台名称和会话场景 ID 组成,例如 `QQClient_123456789`。
36
- """
37
- return f"{session.scope}_{session.scene_path}"
38
-
39
-
40
- def is_enabled(session: Session = UniSession()) -> bool:
41
- """判断当前会话是否在关闭解析的名单中"""
42
- if session.scene.is_private:
43
- return True
44
-
45
- group_key = get_group_key(session)
46
- if group_key in _DISABLED_GROUPS_SET:
47
- return False
48
- return True
49
-
50
-
51
- @on_command("开启解析", rule=to_me(), permission=SUPERUSER | ADMIN(), block=True).handle()
52
- async def _(matcher: Matcher, session: Session = UniSession()):
53
- """开启解析"""
54
- group_key = get_group_key(session)
55
- if group_key in _DISABLED_GROUPS_SET:
56
- _DISABLED_GROUPS_SET.remove(group_key)
57
- save_disabled_groups()
58
- await matcher.finish("解析已开启")
59
- else:
60
- await matcher.finish("解析已开启,无需重复开启")
61
-
62
-
63
- @on_command("关闭解析", rule=to_me(), permission=SUPERUSER | ADMIN(), block=True).handle()
64
- async def _(matcher: Matcher, session: Session = UniSession()):
65
- """关闭解析"""
66
- group_key = get_group_key(session)
67
- if group_key not in _DISABLED_GROUPS_SET:
68
- _DISABLED_GROUPS_SET.add(group_key)
69
- save_disabled_groups()
70
- await matcher.finish("解析已关闭")
71
- else:
72
- await matcher.finish("解析已关闭,无需重复关闭")