nonebot-plugin-parsehub 1.0.0__tar.gz → 1.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/PKG-INFO +20 -18
  2. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/README.md +17 -17
  3. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/pyproject.toml +4 -2
  4. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/pyproject.toml.orig +5 -2
  5. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/SPEC.md +15 -3
  6. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/__init__.py +5 -10
  7. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/budget.py +0 -11
  8. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/cache.py +2 -17
  9. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/SPEC.md +2 -1
  10. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/__init__.py +2 -3
  11. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/cover.py +6 -9
  12. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/fonts.py +3 -10
  13. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/layout.py +0 -15
  14. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/dedupe.py +0 -26
  15. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/delivery.py +34 -13
  16. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/errors.py +27 -45
  17. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/parsehub_client.py +1 -1
  18. nonebot_plugin_parsehub-1.0.1/src/nonebot_plugin_parsehub/paths.py +56 -0
  19. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/service.py +2 -1
  20. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/switches.py +29 -40
  21. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/triggers.py +8 -7
  22. nonebot_plugin_parsehub-1.0.0/src/nonebot_plugin_parsehub/paths.py +0 -93
  23. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/LICENSE +0 -0
  24. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/app.py +0 -0
  25. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
  26. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
  27. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
  28. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
  29. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
  30. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
  31. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
  32. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
  33. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
  34. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
  35. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
  36. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
  37. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
  38. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
  39. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
  40. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
  41. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/x.png +0 -0
  42. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
  43. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
  44. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
  45. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
  46. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
  47. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/config.py +0 -0
  48. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/feedback.py +0 -0
  49. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/keys.py +0 -0
  50. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/media.py +0 -0
  51. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/platforms.py +0 -0
  52. {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/relay.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nonebot-plugin-parsehub
3
- Version: 1.0.0
3
+ Version: 1.0.1
4
4
  Summary: 基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体
5
5
  Keywords: nonebot,nonebot2,plugin,parsehub,parser,media,onebot
6
6
  Author: ByteColtX
@@ -16,6 +16,8 @@ Requires-Dist: httpx>=0.28.0,<1.0.0
16
16
  Requires-Dist: nonebot-adapter-onebot>=2.4.6,<3.0.0
17
17
  Requires-Dist: nonebot-plugin-localstore>=0.7.4,<1.0.0
18
18
  Requires-Dist: nonebot2>=2.5.0,<3.0.0
19
+ Requires-Dist: numpy>=2.0.0,<3.0.0
20
+ Requires-Dist: opencv-python-headless>=4.13.0.92
19
21
  Requires-Dist: parsehub>=2.3.10,<3.0.0
20
22
  Requires-Dist: pillow>=12.1.0,<13.0.0
21
23
  Requires-Dist: pydantic>=2.0.0,<3.0.0
@@ -45,7 +47,9 @@ Description-Content-Type: text/markdown
45
47
 
46
48
  基于 [ParseHub](https://github.com/z-mio/ParseHub) 的社交媒体链接解析插件:群里出现受支持的分享链接时自动解析,发回视频 / 图集 / 图文,并配一张 App Store「Today」风格的信息卡片。
47
49
 
48
- - **自动解析**:发链接或分享文案即可,也可用 `解析` 命令手动触发
50
+ > 仅支持 **OneBot v11** 适配器(NapCat / SnowLuma / LuckyLilliaBot 等)。
51
+
52
+ - **自动解析**:发链接或分享文案即可,也可用 `/解析` 命令手动触发
49
53
  - **按会话开关**:`解析开关` 控制某个群 / 私聊,超管和群管理可操作
50
54
  - **24 个平台**:Bilibili、抖音、小红书、微博、快手、知乎、贴吧、豆瓣、今日头条、微信公众号、微信视频号、酷安、皮皮虾、最右、小黑盒、Bangumi、X/Twitter、Instagram、YouTube、TikTok、Threads、Facebook、Snapchat、Medium
51
55
  - **卡片 + 媒体**:先发信息卡片,再发视频 / 图集;2 个以上媒体合并转发,单个作品最多 20 个
@@ -56,13 +60,9 @@ Description-Content-Type: text/markdown
56
60
 
57
61
  ## 🎨 效果图
58
62
 
59
- B 站视频卡片:
60
-
61
- <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-bilibili.jpg" width="420" alt="B 站卡片">
62
-
63
- 抖音图集卡片:
64
-
65
- <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-douyin.jpg" width="420" alt="抖音卡片">
63
+ | B 站视频卡片 | 抖音图集卡片 |
64
+ | :---: | :---: |
65
+ | <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-bilibili.jpg" width="420" alt="B 站卡片"> | <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-douyin.jpg" width="420" alt="抖音卡片"> |
66
66
 
67
67
  ## 💿 安装
68
68
 
@@ -76,7 +76,7 @@ nb plugin install nonebot-plugin-parsehub --upgrade
76
76
 
77
77
  使用 uv 安装
78
78
 
79
- 在 nonebot2 项目的插件目录下, 打开命令行, 输入以下指令即可安装
79
+ 在 nonebot2 项目的根目录下, 打开命令行, 输入以下指令即可安装
80
80
 
81
81
  ```bash
82
82
  uv add nonebot-plugin-parsehub
@@ -127,7 +127,7 @@ plugins = ["nonebot_plugin_parsehub"]
127
127
 
128
128
  > 列表类配置必须是合法 JSON(如 `PARSEHUB_PROXY_PLATFORMS=["twitter", "youtube"]`);
129
129
  > 数值项有下界校验(如 `PARSEHUB_TIMEOUT >= 5`),写 0 或负数会在启动时直接报错。
130
- > 哪些群 / 私聊解析由命令 `解析开关` 控制,没有对应的配置项。
130
+ > 哪些群 / 私聊解析由命令 `/解析开关` 控制,没有对应的配置项。
131
131
 
132
132
  需要登录态的平台(X、Instagram、TikTok、豆瓣、微信视频号 等),cookie 优先从 ParseHub 自己的配置读取
133
133
  (`ph set cookie <平台>`),也可以用 `PARSEHUB_COOKIE` 兜底。
@@ -138,16 +138,18 @@ plugins = ["nonebot_plugin_parsehub"]
138
138
 
139
139
  | 指令 | 权限 | 需要@ | 范围 | 说明 |
140
140
  | :--- | :---: | :---: | :---: | :--- |
141
- | `解析 <链接或分享文案>`(别名 `ph`、`解析链接`) | 群员 | 否 | 群聊/私聊 | 手动解析,不做去重拦截 |
142
- | `解析平台` | 群员 | 否 | 群聊/私聊 | 列出支持的平台 |
143
- | `解析开关 [开\|关]`、`解析开`、`解析关` | 主人/群管 | 否 | 群聊/私聊 | 按会话开关解析(私聊仅主人) |
144
- | `解析状态` | 群员 | 否 | 群聊/私聊 | 查看当前会话的开关状态 |
141
+ | `/解析 <链接或分享文案>`(别名 `/ph`、`/解析链接`) | 群员 | 否 | 群聊/私聊 | 手动解析,不做去重拦截 |
142
+ | `/解析平台` | 群员 | 否 | 群聊/私聊 | 列出支持的平台 |
143
+ | `/解析开关 [开\|关]`、`/解析开`、`/解析关` | 主人/群管 | 否 | 群聊/私聊 | 按会话开关解析(私聊仅主人) |
144
+ | `/解析状态` | 群员 | 否 | 群聊/私聊 | 查看当前会话的开关状态 |
145
+
146
+ > 命令按 NoneBot 的 `COMMAND_START` 匹配(默认只有 `/`)。想让无前缀写法也算命令,把 `COMMAND_START` 加上 `""`。
145
147
 
146
148
  ### 触发规则
147
149
 
148
150
  - 群里发链接或分享文案即自动解析,一条消息最多处理 **3 个**链接
149
- - `解析关` 后该会话不再自动解析;手动 `解析` 只对超管 / 群管理有效
150
- - 命令前缀支持空前缀与 `/`(`COMMAND_START` 需包含 `""`)
151
+ - `/解析关` 后该会话不再自动解析;手动 `/解析` 只对超管 / 群管理有效
152
+ - 不带前缀的「解析 <链接>」**不是**命令,会当作普通消息自动解析(带去重、不额外提示)
151
153
 
152
154
  ### 解析开关
153
155
 
@@ -162,7 +164,7 @@ plugins = ["nonebot_plugin_parsehub"]
162
164
  - **保留 24 小时**(命中续期),总量上限 1GB,超了按最久没用过的删
163
165
  - **不同写法归一**:`b23.tv/xxx` 与 `bilibili.com/video/BVxxx` 是同一个作品
164
166
  - 时间线:120 秒内同会话重复 → 静默;120 秒 ~ 24 小时 → 直接发缓存;超过 24 小时 → 重新解析
165
- - 手动 `解析 <链接>` 同样吃缓存(只是不做去重拦截)
167
+ - 手动 `/解析 <链接>` 同样吃缓存(只是不做去重拦截)
166
168
 
167
169
  ### 说明
168
170
 
@@ -18,7 +18,9 @@
18
18
 
19
19
  基于 [ParseHub](https://github.com/z-mio/ParseHub) 的社交媒体链接解析插件:群里出现受支持的分享链接时自动解析,发回视频 / 图集 / 图文,并配一张 App Store「Today」风格的信息卡片。
20
20
 
21
- - **自动解析**:发链接或分享文案即可,也可用 `解析` 命令手动触发
21
+ > 仅支持 **OneBot v11** 适配器(NapCat / SnowLuma / LuckyLilliaBot 等)。
22
+
23
+ - **自动解析**:发链接或分享文案即可,也可用 `/解析` 命令手动触发
22
24
  - **按会话开关**:`解析开关` 控制某个群 / 私聊,超管和群管理可操作
23
25
  - **24 个平台**:Bilibili、抖音、小红书、微博、快手、知乎、贴吧、豆瓣、今日头条、微信公众号、微信视频号、酷安、皮皮虾、最右、小黑盒、Bangumi、X/Twitter、Instagram、YouTube、TikTok、Threads、Facebook、Snapchat、Medium
24
26
  - **卡片 + 媒体**:先发信息卡片,再发视频 / 图集;2 个以上媒体合并转发,单个作品最多 20 个
@@ -29,13 +31,9 @@
29
31
 
30
32
  ## 🎨 效果图
31
33
 
32
- B 站视频卡片:
33
-
34
- <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-bilibili.jpg" width="420" alt="B 站卡片">
35
-
36
- 抖音图集卡片:
37
-
38
- <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-douyin.jpg" width="420" alt="抖音卡片">
34
+ | B 站视频卡片 | 抖音图集卡片 |
35
+ | :---: | :---: |
36
+ | <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-bilibili.jpg" width="420" alt="B 站卡片"> | <img src="https://raw.githubusercontent.com/ByteColtX/nonebot-plugin-parsehub/main/docs/card-douyin.jpg" width="420" alt="抖音卡片"> |
39
37
 
40
38
  ## 💿 安装
41
39
 
@@ -49,7 +47,7 @@ nb plugin install nonebot-plugin-parsehub --upgrade
49
47
 
50
48
  使用 uv 安装
51
49
 
52
- 在 nonebot2 项目的插件目录下, 打开命令行, 输入以下指令即可安装
50
+ 在 nonebot2 项目的根目录下, 打开命令行, 输入以下指令即可安装
53
51
 
54
52
  ```bash
55
53
  uv add nonebot-plugin-parsehub
@@ -100,7 +98,7 @@ plugins = ["nonebot_plugin_parsehub"]
100
98
 
101
99
  > 列表类配置必须是合法 JSON(如 `PARSEHUB_PROXY_PLATFORMS=["twitter", "youtube"]`);
102
100
  > 数值项有下界校验(如 `PARSEHUB_TIMEOUT >= 5`),写 0 或负数会在启动时直接报错。
103
- > 哪些群 / 私聊解析由命令 `解析开关` 控制,没有对应的配置项。
101
+ > 哪些群 / 私聊解析由命令 `/解析开关` 控制,没有对应的配置项。
104
102
 
105
103
  需要登录态的平台(X、Instagram、TikTok、豆瓣、微信视频号 等),cookie 优先从 ParseHub 自己的配置读取
106
104
  (`ph set cookie <平台>`),也可以用 `PARSEHUB_COOKIE` 兜底。
@@ -111,16 +109,18 @@ plugins = ["nonebot_plugin_parsehub"]
111
109
 
112
110
  | 指令 | 权限 | 需要@ | 范围 | 说明 |
113
111
  | :--- | :---: | :---: | :---: | :--- |
114
- | `解析 <链接或分享文案>`(别名 `ph`、`解析链接`) | 群员 | 否 | 群聊/私聊 | 手动解析,不做去重拦截 |
115
- | `解析平台` | 群员 | 否 | 群聊/私聊 | 列出支持的平台 |
116
- | `解析开关 [开\|关]`、`解析开`、`解析关` | 主人/群管 | 否 | 群聊/私聊 | 按会话开关解析(私聊仅主人) |
117
- | `解析状态` | 群员 | 否 | 群聊/私聊 | 查看当前会话的开关状态 |
112
+ | `/解析 <链接或分享文案>`(别名 `/ph`、`/解析链接`) | 群员 | 否 | 群聊/私聊 | 手动解析,不做去重拦截 |
113
+ | `/解析平台` | 群员 | 否 | 群聊/私聊 | 列出支持的平台 |
114
+ | `/解析开关 [开\|关]`、`/解析开`、`/解析关` | 主人/群管 | 否 | 群聊/私聊 | 按会话开关解析(私聊仅主人) |
115
+ | `/解析状态` | 群员 | 否 | 群聊/私聊 | 查看当前会话的开关状态 |
116
+
117
+ > 命令按 NoneBot 的 `COMMAND_START` 匹配(默认只有 `/`)。想让无前缀写法也算命令,把 `COMMAND_START` 加上 `""`。
118
118
 
119
119
  ### 触发规则
120
120
 
121
121
  - 群里发链接或分享文案即自动解析,一条消息最多处理 **3 个**链接
122
- - `解析关` 后该会话不再自动解析;手动 `解析` 只对超管 / 群管理有效
123
- - 命令前缀支持空前缀与 `/`(`COMMAND_START` 需包含 `""`)
122
+ - `/解析关` 后该会话不再自动解析;手动 `/解析` 只对超管 / 群管理有效
123
+ - 不带前缀的「解析 <链接>」**不是**命令,会当作普通消息自动解析(带去重、不额外提示)
124
124
 
125
125
  ### 解析开关
126
126
 
@@ -135,7 +135,7 @@ plugins = ["nonebot_plugin_parsehub"]
135
135
  - **保留 24 小时**(命中续期),总量上限 1GB,超了按最久没用过的删
136
136
  - **不同写法归一**:`b23.tv/xxx` 与 `bilibili.com/video/BVxxx` 是同一个作品
137
137
  - 时间线:120 秒内同会话重复 → 静默;120 秒 ~ 24 小时 → 直接发缓存;超过 24 小时 → 重新解析
138
- - 手动 `解析 <链接>` 同样吃缓存(只是不做去重拦截)
138
+ - 手动 `/解析 <链接>` 同样吃缓存(只是不做去重拦截)
139
139
 
140
140
  ### 说明
141
141
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parsehub"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -27,6 +27,8 @@ dependencies = [
27
27
  "nonebot-adapter-onebot>=2.4.6,<3.0.0",
28
28
  "nonebot-plugin-localstore>=0.7.4,<1.0.0",
29
29
  "nonebot2>=2.5.0,<3.0.0",
30
+ "numpy>=2.0.0,<3.0.0",
31
+ "opencv-python-headless>=4.13.0.92",
30
32
  "parsehub>=2.3.10,<3.0.0",
31
33
  "pillow>=12.1.0,<13.0.0",
32
34
  "pydantic>=2.0.0,<3.0.0",
@@ -57,7 +59,7 @@ test = [
57
59
  ]
58
60
 
59
61
  [build-system]
60
- requires = ["uv_build>=0.10.0"]
62
+ requires = ["uv_build>=0.10.0,<0.14"]
61
63
  build-backend = "uv_build"
62
64
 
63
65
  [tool.nonebot]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nonebot-plugin-parsehub"
3
- version = "1.0.0"
3
+ version = "1.0.1"
4
4
  description = "基于 ParseHub 的社交媒体链接解析插件:群里出现分享链接时自动解析并发送媒体"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -20,6 +20,9 @@ dependencies = [
20
20
  "nonebot-adapter-onebot>=2.4.6,<3.0.0",
21
21
  "nonebot-plugin-localstore>=0.7.4,<1.0.0",
22
22
  "nonebot2>=2.5.0,<3.0.0",
23
+ # 卡片渐变遮罩与视频抽帧是代码里直接 import 的,必须显式声明
24
+ "numpy>=2.0.0,<3.0.0",
25
+ "opencv-python-headless>=4.13.0.92",
23
26
  "parsehub>=2.3.10,<3.0.0",
24
27
  "pillow>=12.1.0,<13.0.0",
25
28
  "pydantic>=2.0.0,<3.0.0",
@@ -46,7 +49,7 @@ test = [
46
49
  ]
47
50
 
48
51
  [build-system]
49
- requires = ["uv_build>=0.10.0"]
52
+ requires = ["uv_build>=0.10.0,<0.14"]
50
53
  build-backend = "uv_build"
51
54
 
52
55
  [tool.nonebot]
@@ -16,10 +16,13 @@ tags: [nonebot, parsehub, module]
16
16
 
17
17
  ## Boundary
18
18
 
19
- - **允许依赖**:nonebot2、onebot v11 适配器、parsehub、Pillow、httpx、localstore。
19
+ - **允许依赖**:nonebot2、onebot v11 适配器、parsehub、Pillow、httpx、localstore、
20
+ numpy、opencv-python-headless(后两者只在 `card` 里用)。
20
21
  - **禁止**:`triggers` 之外的模块 import matcher / `on_command`;`delivery` 之外的模块
21
- 调用 `sender.send`;`service` import `Bot`;`card`、`cache`、`dedupe`、`relay`、
22
- `keys`、`platforms`、`budget`、`media` import nonebot(`card` 只允许 Pillow/httpx)。
22
+ 调用 `sender.send`;`service` import `Bot`。
23
+ - **日志**:一律用 `nonebot.logger`(官方指导),领域模块也不例外;除 logger 之外,
24
+ `cache`、`dedupe`、`relay`、`keys`、`platforms`、`budget`、`media`、`card` 不碰
25
+ nonebot 的 matcher / `Bot` / 事件类型。
23
26
  - 包内一律相对 import:插件既可能作为已安装包加载,也可能作为本地插件加载。
24
27
 
25
28
  ## Module map
@@ -63,6 +66,15 @@ tags: [nonebot, parsehub, module]
63
66
  - 平台白名单在 `Settings` 里归一成 id 集合,运行期只做集合查找。
64
67
  - 配置键是唯一入口:`Config` 只负责读取与校验,运行期一律用 `Settings`,
65
68
  不再散读 `plugin_config`。
69
+ - 命令严格按 `COMMAND_START` 匹配:`should_auto` 不排除命令文本,靠命令匹配器的
70
+ `priority=10` + `block=True` 先收口。裸写的「解析 <链接>」不是命令,按普通消息
71
+ 自动解析——默认前缀只有 `/`,若自动规则也跳过它,消息会被静默丢弃。
72
+ - `paths` 把 localstore 当硬依赖(`require` 后 import),不保留临时目录兜底。
73
+ - 日志统一用 `nonebot.logger`:框架的 loguru 已经管住格式、插件标签与 `LOG_LEVEL`,
74
+ 在包内再桥接一层 stdlib logging 只是多一个可能失效的中间层。
75
+ - 第三方依赖全是**硬依赖**:parsehub / Pillow / numpy / opencv / localstore 缺了就是
76
+ 装错了,代码里不写 `except ImportError` 兜底,也不往别的目录写;降级只发生在产品层
77
+ (卡片→文字、字体→系统字体、体积探测→未知)。
66
78
 
67
79
  ## Known limitations
68
80
 
@@ -24,7 +24,7 @@ from __future__ import annotations
24
24
  from nonebot import get_driver
25
25
  from nonebot.plugin import PluginMetadata
26
26
 
27
- from . import feedback, triggers
27
+ from . import triggers
28
28
  from .app import App
29
29
  from .config import Config, Settings, plugin_config
30
30
 
@@ -35,8 +35,8 @@ __plugin_meta__ = PluginMetadata(
35
35
  "(视频 / 图集 / 正文)。"
36
36
  ),
37
37
  usage=(
38
- "发链接即可自动解析;命令:解析 <链接或分享文案>、解析平台、"
39
- "解析开关 [开|关](超管 / 群管理)、解析状态"
38
+ "发链接即可自动解析;命令:/解析 <链接或分享文案>、/解析平台、"
39
+ "/解析开关 [开|关](超管 / 群管理)、/解析状态"
40
40
  ),
41
41
  type="application",
42
42
  homepage="https://github.com/ByteColtX/nonebot-plugin-parsehub",
@@ -45,16 +45,11 @@ __plugin_meta__ = PluginMetadata(
45
45
  extra={"parsehub": "https://github.com/z-mio/ParseHub"},
46
46
  )
47
47
 
48
- # 表情 ID:收到 / 成功 / 失败。
49
- REACT_START = feedback.Reaction.START
50
- REACT_OK = feedback.Reaction.OK
51
- REACT_FAIL = feedback.Reaction.FAIL
52
-
53
48
  # 进程内唯一的插件实例;测试可以自己 App.build() 一个。
54
49
  APP = App.build(Settings.from_config(plugin_config))
55
50
 
56
- # matcher 注册(处理函数闭包捕获 APP)。
57
- MATCHERS = triggers.create_matchers(APP)
51
+ # matcher 注册(处理函数闭包捕获 APP);注册本身有副作用,不需要保留返回值。
52
+ triggers.create_matchers(APP)
58
53
 
59
54
  driver = get_driver()
60
55
  driver.on_startup(APP.startup)
@@ -52,17 +52,6 @@ class Budget:
52
52
  """
53
53
  return self.remaining <= 0
54
54
 
55
- def slice(self, cap: float) -> float:
56
- """取一段不超过剩余预算的超时时间。
57
-
58
- Args:
59
- cap: 这一段最多允许多久(秒)。
60
-
61
- Returns:
62
- 实际可用的秒数,至少 :data:`_MIN_SLICE_SECONDS`。
63
- """
64
- return max(_MIN_SLICE_SECONDS, min(cap, self.remaining))
65
-
66
55
  def timeout(self) -> AbstractAsyncContextManager[None]:
67
56
  """套在某一段异步调用外面的超时上下文。
68
57
 
@@ -20,14 +20,13 @@
20
20
 
21
21
  所有方法都是同步的(磁盘 IO),调用方负责放进线程池。
22
22
 
23
- 模块不依赖 nonebot,方便单独测试。
23
+ 除 ``nonebot.logger`` 外不依赖 nonebot,方便单独测试。
24
24
  """
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
28
  import hashlib
29
29
  import json
30
- import logging
31
30
  import os
32
31
  import shutil
33
32
  import time
@@ -36,7 +35,7 @@ from collections.abc import Iterable
36
35
  from dataclasses import dataclass
37
36
  from pathlib import Path
38
37
 
39
- logger = logging.getLogger("nonebot_plugin_parsehub.cache")
38
+ from nonebot import logger
40
39
 
41
40
  # 目录与文件名。
42
41
  FILES_DIR_NAME = "files"
@@ -183,20 +182,6 @@ class MediaCache:
183
182
  keys=tuple(meta.get("keys") or ()),
184
183
  )
185
184
 
186
- def entry_keys(self, entry: Path) -> list[str]:
187
- """条目对应的所有作品 key。
188
-
189
- Args:
190
- entry: 条目目录。
191
-
192
- Returns:
193
- 作品 key 列表;读不出来时为空列表。
194
- """
195
- meta = self._read_meta(entry)
196
- if meta is None:
197
- return []
198
- return [str(key) for key in meta.get("keys") or () if key]
199
-
200
185
  def remember_key(self, entry: Path, key: str) -> None:
201
186
  """把 key 补记到条目里(老条目命中时顺手补上)。
202
187
 
@@ -16,7 +16,8 @@ tags: [nonebot, parsehub, card]
16
16
 
17
17
  ## Boundary
18
18
 
19
- - 只依赖 Pillow、httpx 与包内的 `platforms`;**不 import nonebot**。
19
+ - 只依赖 Pillow、httpx、numpy(渐变遮罩)、`nonebot.logger` 与包内的 `platforms`;
20
+ 视频抽帧另用 opencv(缺了就跳过这一级兜底)。不碰 matcher / `Bot` / 事件类型。
20
21
  - 所有渲染是同步阻塞的 CPU 活,调用方必须放进线程池(`CardRenderer` 负责)。
21
22
 
22
23
  ## Layout
@@ -16,11 +16,12 @@
16
16
  from __future__ import annotations
17
17
 
18
18
  import asyncio
19
- import logging
20
19
  from collections.abc import Callable
21
20
  from pathlib import Path
22
21
  from typing import Any
23
22
 
23
+ from nonebot import logger
24
+
24
25
  from ..platforms import PLATFORMS
25
26
  from . import cover, fonts, layout
26
27
  from .cover import fetch_cover
@@ -96,8 +97,6 @@ __all__ = [
96
97
  "wrap",
97
98
  ]
98
99
 
99
- logger = logging.getLogger("nonebot_plugin_parsehub.card")
100
-
101
100
 
102
101
  class CardRenderer:
103
102
  """把解析结果渲染成卡片图。
@@ -9,16 +9,14 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import io
12
- import logging
13
12
  from pathlib import Path
14
13
  from typing import Any
15
14
 
16
15
  import httpx
16
+ from nonebot import logger
17
17
 
18
18
  from .fonts import _pil
19
19
 
20
- logger = logging.getLogger("nonebot_plugin_parsehub.card.cover")
21
-
22
20
 
23
21
  def _is_placeholder(data: bytes, image: Any) -> bool: # noqa: ANN401 - PIL Image
24
22
  """判断封面是不是纯色占位图。
@@ -39,19 +37,18 @@ def _is_placeholder(data: bytes, image: Any) -> bool: # noqa: ANN401 - PIL Imag
39
37
  def _frame_from_video(path: Path) -> bytes | None:
40
38
  """从本地视频里抽第一帧当封面。
41
39
 
40
+ 同步阻塞,调用方必须放进线程池。opencv 首次 import 要几百毫秒,所以延迟到
41
+ 函数里加载。
42
+
42
43
  Args:
43
44
  path: 本地视频路径。
44
45
 
45
46
  Returns:
46
47
  JPEG 字节;抽不出来时为 ``None``。
47
48
  """
48
- try:
49
- import cv2
50
-
51
- Image = _pil() # noqa: N806 - 与 PIL 模块名保持一致
52
- except ImportError:
53
- return None
49
+ import cv2
54
50
 
51
+ Image = _pil() # noqa: N806 - 与 PIL 模块名保持一致
55
52
  cap = cv2.VideoCapture(str(path))
56
53
  try:
57
54
  ok, frame = cap.read()
@@ -12,15 +12,13 @@
12
12
  from __future__ import annotations
13
13
 
14
14
  import hashlib
15
- import logging
16
15
  from dataclasses import dataclass
17
16
  from functools import lru_cache
18
17
  from pathlib import Path
19
18
  from typing import Any
20
19
 
21
20
  import httpx
22
-
23
- logger = logging.getLogger("nonebot_plugin_parsehub.card.fonts")
21
+ from nonebot import logger
24
22
 
25
23
  _EMOJI_FONT: Path | None = None
26
24
  _GLYPH_CACHE: dict[tuple[int, str], bool] = {}
@@ -71,8 +69,6 @@ FONT_SOURCES: tuple[FontSource, ...] = (
71
69
  HARMONY_REGULAR_SHA256,
72
70
  ),
73
71
  )
74
- FONT_URLS: tuple[str, ...] = tuple(source.url for source in FONT_SOURCES)
75
-
76
72
  # 系统里可能已经有这些中文字体,有就直接用,不必下载。
77
73
  SYSTEM_FONTS: tuple[str, ...] = (
78
74
  "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
@@ -249,9 +245,6 @@ EMOJI_FONT_SOURCES: tuple[FontSource, ...] = (
249
245
  # release 资产只有这一个正式地址(jsdelivr 只镜像仓库文件,镜像不了
250
246
  # release),所以只有一条源;下不到时按既有逻辑退回「没有 emoji 字体」
251
247
  )
252
- EMOJI_FONT_URLS: tuple[str, ...] = tuple(
253
- source.url for source in EMOJI_FONT_SOURCES
254
- )
255
248
  EMOJI_FONT_NAME = "Twemoji-15.0.2.ttf"
256
249
 
257
250
 
@@ -301,7 +294,7 @@ def ensure_emoji_font(cache_dir: Path, url: str = "") -> Path | None:
301
294
 
302
295
  Args:
303
296
  cache_dir: 插件缓存目录,下载的字体放在这里。
304
- url: 自定义字体地址,留空则依次尝试 :data:`EMOJI_FONT_URLS`。
297
+ url: 自定义字体地址,留空则依次尝试 :data:`EMOJI_FONT_SOURCES`。
305
298
 
306
299
  Returns:
307
300
  字体文件路径;不可用时为 ``None``。
@@ -494,7 +487,7 @@ def ensure_font(cache_dir: Path, url: str = "") -> Path | None:
494
487
 
495
488
  Args:
496
489
  cache_dir: 插件缓存目录,下载的字体放在这里。
497
- url: 自定义字体地址,留空则依次尝试 :data:`FONT_URLS`。
490
+ url: 自定义字体地址,留空则依次尝试 :data:`FONT_SOURCES`。
498
491
 
499
492
  Returns:
500
493
  字体文件路径;不可用时为 ``None``。
@@ -157,21 +157,6 @@ def _advance(text: str, font: Any, emoji_size: int = 0) -> float: # noqa: ANN40
157
157
  return total
158
158
 
159
159
 
160
- def _can_draw_emoji(font: Any, text: str) -> bool: # noqa: ANN401 - PIL FreeTypeFont
161
- """查 emoji 字体里有没有这段文字的字形。
162
-
163
- 等价于 :func:`_has_glyph`,保留这个名字是因为 emoji 那条路径一直在用它。
164
-
165
- Args:
166
- font: emoji 字体对象。
167
- text: 待画片段。
168
-
169
- Returns:
170
- 有字形为 ``True``。
171
- """
172
- return _has_glyph(font, text)
173
-
174
-
175
160
  def _strip_variation(text: str) -> str:
176
161
  """去掉变体选择符(U+FE0E / U+FE0F)。
177
162
 
@@ -58,32 +58,6 @@ class Dedupe:
58
58
  last_seen = self._seen.get((session, key))
59
59
  return last_seen is not None and time.time() - last_seen < window
60
60
 
61
- # ---- 旧式「看到即记」(手动路径之外的兼容入口)----
62
-
63
- def mark(self, session: str, key: str) -> None:
64
- """记下这个会话里该 key 刚被处理过。
65
-
66
- Args:
67
- session: 会话标识。
68
- key: 作品 key。
69
- """
70
- self._seen[(session, key)] = time.time()
71
-
72
- def should_skip(self, session: str, key: str, window: int) -> bool:
73
- """查一下并顺手记上(「处理过了」的语义)。
74
-
75
- Args:
76
- session: 会话标识。
77
- key: 作品 key。
78
- window: 去重窗口(秒)。
79
-
80
- Returns:
81
- 窗口内重复出现返回 True。
82
- """
83
- repeated = self.seen(session, key, window)
84
- self.mark(session, key)
85
- return repeated
86
-
87
61
  # ---- claim / commit / release ----
88
62
 
89
63
  def claim(self, session: str, keys: Iterable[str], window: int) -> bool:
@@ -334,18 +334,7 @@ class Delivery:
334
334
  本次投递的结果。
335
335
  """
336
336
  report = MediaReport()
337
- limit = self.settings.max_file_bytes
338
- items: list[MediaFile] = []
339
- for file in files:
340
- try:
341
- size = file.path.stat().st_size
342
- except OSError:
343
- report.skipped.append(file)
344
- continue
345
- if limit and size > limit:
346
- report.skipped.append(file)
347
- continue
348
- items.append(file)
337
+ items = await asyncio.to_thread(self._filter_by_size, files, report)
349
338
 
350
339
  if report.skipped:
351
340
  # 聚合成一条:多图集里每个超限文件各发一条会把群刷屏。
@@ -373,6 +362,36 @@ class Delivery:
373
362
  await self.send_text(sender, event, self._fail_text(file))
374
363
  return report
375
364
 
365
+ def _filter_by_size(
366
+ self,
367
+ files: list[MediaFile],
368
+ report: MediaReport,
369
+ ) -> list[MediaFile]:
370
+ """按体积上限过滤,顺手把跳过的记进 ``report``。
371
+
372
+ ``stat`` 是磁盘 IO,调用方必须放进线程池。
373
+
374
+ Args:
375
+ files: 待发送的本地媒体。
376
+ report: 本次投递的结果,``skipped`` 会被就地追加。
377
+
378
+ Returns:
379
+ 体积允许发送的媒体。
380
+ """
381
+ limit = self.settings.max_file_bytes
382
+ items: list[MediaFile] = []
383
+ for file in files:
384
+ try:
385
+ size = file.path.stat().st_size
386
+ except OSError:
387
+ report.skipped.append(file)
388
+ continue
389
+ if limit and size > limit:
390
+ report.skipped.append(file)
391
+ continue
392
+ items.append(file)
393
+ return items
394
+
376
395
  async def _send_one(
377
396
  self,
378
397
  sender: Sender,
@@ -462,6 +481,8 @@ class Delivery:
462
481
  合并转发成功返回 True;媒体类型不适合转发时返回 False,由调用方改走
463
482
  逐条发送。
464
483
  """
484
+ # 昵称要从 .env 里读,是同步磁盘 IO:只算一次,放线程里,别卡事件循环
485
+ name = await asyncio.to_thread(forward_node_name)
465
486
  nodes: list[dict] = []
466
487
  for file in files:
467
488
  if file.kind is MediaKind.FILE:
@@ -472,7 +493,7 @@ class Delivery:
472
493
  "type": "node",
473
494
  "data": {
474
495
  "uin": str(sender.self_id),
475
- "name": forward_node_name(),
496
+ "name": name,
476
497
  "content": [
477
498
  {
478
499
  "type": str(file.kind),
@@ -7,14 +7,16 @@ ParseHub 的异常类型很多,而且 import 代价高(会拉起 numpy / ope
7
7
  **边界**把异常翻译成 :class:`ParseFailure` + :class:`ErrorKind`,上层只按 kind
8
8
  分支,不再靠字符串猜「是不是作品被删了」。
9
9
 
10
- ``translate`` 首次调用时才 import ``parsehub.errors``,之后复用类对象;拿不到
11
- 时退回按类名判断。
10
+ ``translate`` 首次调用时才 import ``parsehub.errors``,之后复用类对象;异常被
11
+ 包装过时再退回类名 / 文案判断。
12
12
  """
13
13
 
14
14
  from __future__ import annotations
15
15
 
16
16
  from enum import StrEnum
17
17
 
18
+ import httpx
19
+
18
20
  # 错误提示最长留多少个字(QQ 气泡里一行放不下更长的)。
19
21
  MAX_ERROR_CHARS = 80
20
22
 
@@ -109,29 +111,28 @@ def truncate_error(text: str) -> str:
109
111
  return text[: MAX_ERROR_CHARS - 1] + "…"
110
112
 
111
113
 
112
- # parsehub 的异常类,首次 translate 时加载;空元组表示拿不到。
114
+ # parsehub 的异常类,首次用到时加载并缓存。
113
115
  _PARSEHUB_ERRORS: dict[str, type[BaseException]] | None = None
114
116
 
115
117
 
116
118
  def _parsehub_errors() -> dict[str, type[BaseException]]:
117
119
  """加载并缓存 parsehub 的异常类。
118
120
 
121
+ parsehub 的 import 很贵(会拉起 numpy / opencv),所以第一次用到才 import。
122
+
119
123
  Returns:
120
- 类名到类对象的映射;parsehub 不可用时为空字典。
124
+ 类名到类对象的映射。
121
125
  """
122
126
  global _PARSEHUB_ERRORS # noqa: PLW0603 - 单例式缓存
123
127
  if _PARSEHUB_ERRORS is None:
124
- try:
125
- from parsehub import errors as module
126
- except ImportError:
127
- _PARSEHUB_ERRORS = {}
128
- else:
129
- _PARSEHUB_ERRORS = {
130
- name: value
131
- for name in dir(module)
132
- if isinstance(value := getattr(module, name), type)
133
- and issubclass(value, BaseException)
134
- }
128
+ from parsehub import errors as module
129
+
130
+ _PARSEHUB_ERRORS = {
131
+ name: value
132
+ for name in dir(module)
133
+ if isinstance(value := getattr(module, name), type)
134
+ and issubclass(value, BaseException)
135
+ }
135
136
  return _PARSEHUB_ERRORS
136
137
 
137
138
 
@@ -145,16 +146,15 @@ def _classify(exc: BaseException) -> ErrorKind:
145
146
  失败性质。
146
147
  """
147
148
  classes = _parsehub_errors()
148
- if classes:
149
- if isinstance(exc, classes.get("UnknownPlatform", ())):
150
- return ErrorKind.UNSUPPORTED
151
- if isinstance(exc, classes.get("ContentUnavailableError", ())):
152
- return ErrorKind.UNAVAILABLE
153
- if isinstance(exc, classes.get("DownloadError", ())):
154
- return ErrorKind.NETWORK
155
- if isinstance(exc, classes.get("ParseError", ())):
156
- return ErrorKind.PARSE
157
- # parsehub 不可用时(或异常被包装过)退回类名 / 文案判断。
149
+ if isinstance(exc, classes.get("UnknownPlatform", ())):
150
+ return ErrorKind.UNSUPPORTED
151
+ if isinstance(exc, classes.get("ContentUnavailableError", ())):
152
+ return ErrorKind.UNAVAILABLE
153
+ if isinstance(exc, classes.get("DownloadError", ())):
154
+ return ErrorKind.NETWORK
155
+ if isinstance(exc, classes.get("ParseError", ())):
156
+ return ErrorKind.PARSE
157
+ # 异常被包装过时退回类名 / 文案判断。
158
158
  name = type(exc).__name__
159
159
  if "UnknownPlatform" in name:
160
160
  return ErrorKind.UNSUPPORTED
@@ -182,24 +182,6 @@ def translate(exc: BaseException) -> ParseFailure:
182
182
  kind = _classify(exc)
183
183
  if isinstance(exc, TimeoutError):
184
184
  kind = ErrorKind.TIMEOUT
185
- elif kind is ErrorKind.UNKNOWN:
186
- try:
187
- import httpx
188
- except ImportError: # pragma: no cover - httpx 是硬依赖
189
- pass
190
- else:
191
- if isinstance(exc, httpx.HTTPError):
192
- kind = ErrorKind.NETWORK
185
+ elif kind is ErrorKind.UNKNOWN and isinstance(exc, httpx.HTTPError):
186
+ kind = ErrorKind.NETWORK
193
187
  return ParseFailure(kind, short_error(exc), exc)
194
-
195
-
196
- def is_unavailable(exc: BaseException) -> bool:
197
- """这个失败是不是「作品已不可用(被删 / 下架)」。
198
-
199
- Args:
200
- exc: 捕获到的异常。
201
-
202
- Returns:
203
- 作品不可用时返回 True。
204
- """
205
- return translate(exc).kind is ErrorKind.UNAVAILABLE
@@ -58,7 +58,7 @@ def _read_cookie_file(platform: str) -> str:
58
58
  return ""
59
59
  with path.open("rb") as file:
60
60
  data = tomllib.load(file)
61
- except (ImportError, OSError, ValueError):
61
+ except (OSError, ValueError):
62
62
  return ""
63
63
  value = (data.get("cookie") or {}).get(platform)
64
64
  return str(value) if value else ""
@@ -0,0 +1,56 @@
1
+ # Copyright 2026 ByteColtX
2
+ # SPDX-License-Identifier: MIT
3
+
4
+ """插件目录约定。
5
+
6
+ 三类文件各有各的家,互不重叠:
7
+
8
+ - ``cache/media``:作品级媒体缓存(可重建、有 TTL);
9
+ - ``cache/relay``:待投递的任务目录,中转服务**只**服务这一层;
10
+ - ``data``:需要长期保留的东西(会话开关)。
11
+
12
+ 媒体与数据目录按 NoneBot 官方要求交给 ``nonebot-plugin-localstore`` 管理:它负责
13
+ Windows / macOS / Linux 上的正确路径,插件不自己拼目录、也不往系统临时目录里写。
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from pathlib import Path
19
+
20
+ from nonebot import require
21
+
22
+ # 官方写法:先 require 再 import,否则插件可能还没被加载。
23
+ require("nonebot_plugin_localstore")
24
+
25
+ import nonebot_plugin_localstore as store # noqa: E402
26
+
27
+ # 缓存根目录下的子目录名。
28
+ MEDIA_DIR_NAME = "media"
29
+ RELAY_DIR_NAME = "relay"
30
+
31
+
32
+ def cache_root() -> Path:
33
+ """插件缓存根目录(媒体缓存与字体都放这儿)。
34
+
35
+ Returns:
36
+ 缓存根目录。
37
+ """
38
+ return store.get_plugin_cache_dir()
39
+
40
+
41
+ def data_root() -> Path:
42
+ """插件数据目录(需要长期保留的东西放这儿)。
43
+
44
+ Returns:
45
+ 数据目录。
46
+ """
47
+ return store.get_plugin_data_dir()
48
+
49
+
50
+ def relay_root() -> Path:
51
+ """中转服务根目录(只放待投递的任务目录)。
52
+
53
+ Returns:
54
+ 缓存根目录下的 ``relay``。
55
+ """
56
+ return cache_root() / RELAY_DIR_NAME
@@ -272,7 +272,8 @@ async def _deliver( # noqa: C901 - 顺序编排本身就是一条长流水线
272
272
 
273
273
  budget = Budget(settings.timeout)
274
274
  proxy = settings.proxy_for(platform)
275
- cookie = platform_cookie(platform, settings.cookie)
275
+ # cookies.toml 是同步磁盘读,放线程里,别卡住事件循环
276
+ cookie = await asyncio.to_thread(platform_cookie, platform, settings.cookie)
276
277
  logger.debug(f"[parsehub] {platform} 代理={proxy or '直连'}")
277
278
 
278
279
  started = time.monotonic()
@@ -7,7 +7,7 @@
7
7
  新群、新私聊也不需要任何初始化。
8
8
 
9
9
  状态存成 JSON,写的时候先写临时文件再 ``os.replace``,避免进程被杀的瞬间留下半
10
- 个文件。模块本身不依赖 nonebot,方便单独测试。
10
+ 个文件。除 ``nonebot.logger`` 外不依赖 nonebot,方便单独测试。
11
11
 
12
12
  用法::
13
13
 
@@ -21,12 +21,11 @@
21
21
  from __future__ import annotations
22
22
 
23
23
  import json
24
- import logging
25
24
  import os
25
+ import threading
26
26
  from pathlib import Path
27
27
 
28
- logger = logging.getLogger("nonebot_plugin_parsehub.switches")
29
-
28
+ from nonebot import logger
30
29
 
31
30
  # 没有记录时的默认值:群和私聊一样,都是允许解析。
32
31
  DEFAULT_ON = True
@@ -77,6 +76,9 @@ class SwitchStore:
77
76
  self.path = path
78
77
  self.default = default
79
78
  self._state: dict[str, bool] = {}
79
+ # ``set`` 现在跑在线程池里:两个群同时改开关时既会抢同一个 ``*.tmp``,
80
+ # 也会在 ``json.dumps`` 遍历 ``_state`` 时撞上另一个线程的写入。
81
+ self._lock = threading.RLock()
80
82
  self.load()
81
83
 
82
84
  def load(self) -> None:
@@ -106,18 +108,23 @@ class SwitchStore:
106
108
  self._state = {str(key): bool(value) for key, value in sessions.items()}
107
109
 
108
110
  def save(self) -> None:
109
- """原子写盘(先写 ``*.tmp`` 再 rename)。"""
110
- try:
111
- self.path.parent.mkdir(parents=True, exist_ok=True)
112
- tmp = self.path.with_name(self.path.name + ".tmp")
113
- payload = {"schema": SCHEMA, "sessions": self._state}
114
- tmp.write_text(
115
- json.dumps(payload, ensure_ascii=False, sort_keys=True),
116
- "utf-8",
117
- )
118
- os.replace(tmp, self.path)
119
- except OSError as exc:
120
- logger.warning(f"[parsehub] 解析开关写盘失败: {exc}")
111
+ """原子写盘(先写 ``*.tmp`` 再 rename)。
112
+
113
+ 调用方可能来自线程池,所以整个「拼 JSON → 写 tmp → rename」都在锁里,
114
+ 否则两路写入会抢同一个 tmp 文件、或者撞上遍历中的字典。
115
+ """
116
+ with self._lock:
117
+ try:
118
+ self.path.parent.mkdir(parents=True, exist_ok=True)
119
+ tmp = self.path.with_name(self.path.name + ".tmp")
120
+ payload = {"schema": SCHEMA, "sessions": self._state}
121
+ tmp.write_text(
122
+ json.dumps(payload, ensure_ascii=False, sort_keys=True),
123
+ "utf-8",
124
+ )
125
+ os.replace(tmp, self.path)
126
+ except OSError as exc:
127
+ logger.warning(f"[parsehub] 解析开关写盘失败: {exc}")
121
128
 
122
129
  def is_on(self, key: str) -> bool:
123
130
  """这个会话允不允许解析。
@@ -140,28 +147,10 @@ class SwitchStore:
140
147
  Returns:
141
148
  设置之后的值。
142
149
  """
143
- if on == self.default:
144
- self._state.pop(key, None) # 和默认值一样就不留记录
145
- else:
146
- self._state[key] = on
147
- self.save()
150
+ with self._lock:
151
+ if on == self.default:
152
+ self._state.pop(key, None) # 和默认值一样就不留记录
153
+ else:
154
+ self._state[key] = on
155
+ self.save()
148
156
  return on
149
-
150
- def toggle(self, key: str) -> bool:
151
- """翻转开关。
152
-
153
- Args:
154
- key: 会话键。
155
-
156
- Returns:
157
- 翻转之后的值。
158
- """
159
- return self.set(key, not self.is_on(key))
160
-
161
- def off_keys(self) -> list[str]:
162
- """所有被关掉的会话键(按字母序)。
163
-
164
- Returns:
165
- 会话键列表。
166
- """
167
- return sorted(key for key, on in self._state.items() if not on)
@@ -12,6 +12,7 @@ matcher 由 :func:`create_matchers` 按传入的 ``App`` 创建,测试可以
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import asyncio
15
16
  from dataclasses import dataclass
16
17
  from typing import TYPE_CHECKING
17
18
 
@@ -35,9 +36,6 @@ if TYPE_CHECKING:
35
36
  # 单条消息最多处理几个链接。
36
37
  MAX_LINKS_PER_MESSAGE = 3
37
38
 
38
- # 命令触发时用来跳过自动解析的前缀。
39
- COMMAND_PREFIXES = ("解析", "ph", "/解析", "/ph")
40
-
41
39
  # 「解析开关」命令能接受的参数写法。
42
40
  ON_WORDS = frozenset({"开", "开启", "打开", "on", "true", "1"})
43
41
  OFF_WORDS = frozenset({"关", "关闭", "关上", "off", "false", "0"})
@@ -127,6 +125,11 @@ def can_control(event: MessageEvent) -> bool:
127
125
  async def should_auto(app: App, event: MessageEvent) -> bool:
128
126
  """判断是否应该自动解析这条消息。
129
127
 
128
+ 命令不需要在这里排除:``解析`` / ``ph`` 等命令匹配器 ``priority=10`` 且
129
+ ``block=True``,命中后事件不会流到 ``priority=20`` 的自动匹配器。反过来,
130
+ ``COMMAND_START`` 没配空前缀时裸写的「解析 <链接>」本来就不是命令,必须按
131
+ 普通消息自动解析,否则两个匹配器都不管、消息被静默丢弃。
132
+
130
133
  Args:
131
134
  app: 插件实例(需要 ``settings`` 与 ``switches``)。
132
135
  event: 收到的消息事件。
@@ -140,9 +143,6 @@ async def should_auto(app: App, event: MessageEvent) -> bool:
140
143
  return False
141
144
  if not switches.is_on(session_key(event)):
142
145
  return False # 这个会话被关掉了
143
- text = event.get_plaintext()
144
- if text.lstrip().startswith(COMMAND_PREFIXES):
145
- return False # 命令触发交给命令匹配器
146
146
  return bool(keys.extract_urls(link_sources(event)))
147
147
 
148
148
 
@@ -267,7 +267,8 @@ def create_matchers(app: App) -> Matchers:
267
267
  else:
268
268
  want = not switches.is_on(key)
269
269
 
270
- switches.set(key, want)
270
+ # 落盘是同步磁盘 IO,交给线程池,别卡住事件循环
271
+ want = await asyncio.to_thread(switches.set, key, want)
271
272
  name = session_name(event)
272
273
  logger.info(
273
274
  f"[parsehub] {name}解析开关 -> {'开' if want else '关'}"
@@ -1,93 +0,0 @@
1
- # Copyright 2026 ByteColtX
2
- # SPDX-License-Identifier: MIT
3
-
4
- """插件目录约定。
5
-
6
- 三类文件各有各的家,互不重叠:
7
-
8
- - ``cache/media``:作品级媒体缓存(可重建、有 TTL);
9
- - ``cache/relay``:待投递的任务目录,中转服务**只**服务这一层;
10
- - ``data``:需要长期保留的东西(会话开关)。
11
-
12
- 媒体目录按 NoneBot 官方要求交给 ``nonebot-plugin-localstore`` 管理;没装时退回
13
- 系统临时目录,保证插件仍能加载。
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import tempfile
19
- from pathlib import Path
20
-
21
- from nonebot import logger, require
22
-
23
- # 缓存根目录下的子目录名。
24
- MEDIA_DIR_NAME = "media"
25
- RELAY_DIR_NAME = "relay"
26
-
27
- # 没装 localstore 时的兜底目录名。
28
- _FALLBACK_DIR_NAME = "nonebot-plugin-parsehub"
29
-
30
-
31
- def _store() -> object | None:
32
- """尽量拿到 localstore 模块。
33
-
34
- Returns:
35
- ``nonebot_plugin_localstore`` 模块;没装时为 None。
36
- """
37
- try:
38
- require("nonebot_plugin_localstore")
39
-
40
- import nonebot_plugin_localstore as store
41
- except Exception: # noqa: BLE001 - 缺少依赖时降级,不能让插件整体挂掉
42
- return None
43
- return store
44
-
45
-
46
- def cache_root() -> Path:
47
- """插件缓存根目录(媒体缓存与字体都放这儿)。
48
-
49
- Returns:
50
- 缓存根目录。
51
- """
52
- store = _store()
53
- if store is None:
54
- logger.warning(
55
- "[parsehub] 未安装 nonebot-plugin-localstore,"
56
- "媒体临时文件改用系统临时目录"
57
- )
58
- return Path(tempfile.gettempdir()) / _FALLBACK_DIR_NAME
59
- return store.get_plugin_cache_dir()
60
-
61
-
62
- def data_root() -> Path:
63
- """插件数据目录(需要长期保留的东西放这儿)。
64
-
65
- Returns:
66
- 数据目录。
67
- """
68
- store = _store()
69
- if store is None:
70
- logger.warning(
71
- "[parsehub] 未安装 nonebot-plugin-localstore,"
72
- "插件数据改用系统临时目录(重启会丢)"
73
- )
74
- return Path(tempfile.gettempdir()) / f"{_FALLBACK_DIR_NAME}-data"
75
- return store.get_plugin_data_dir()
76
-
77
-
78
- def media_root() -> Path:
79
- """作品级媒体缓存目录。
80
-
81
- Returns:
82
- 缓存根目录下的 ``media``。
83
- """
84
- return cache_root() / MEDIA_DIR_NAME
85
-
86
-
87
- def relay_root() -> Path:
88
- """中转服务根目录(只放待投递的任务目录)。
89
-
90
- Returns:
91
- 缓存根目录下的 ``relay``。
92
- """
93
- return cache_root() / RELAY_DIR_NAME