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.
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/PKG-INFO +20 -18
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/README.md +17 -17
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/pyproject.toml +4 -2
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/pyproject.toml.orig +5 -2
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/SPEC.md +15 -3
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/__init__.py +5 -10
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/budget.py +0 -11
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/cache.py +2 -17
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/SPEC.md +2 -1
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/__init__.py +2 -3
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/cover.py +6 -9
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/fonts.py +3 -10
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/card/layout.py +0 -15
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/dedupe.py +0 -26
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/delivery.py +34 -13
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/errors.py +27 -45
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/parsehub_client.py +1 -1
- nonebot_plugin_parsehub-1.0.1/src/nonebot_plugin_parsehub/paths.py +56 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/service.py +2 -1
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/switches.py +29 -40
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/triggers.py +8 -7
- nonebot_plugin_parsehub-1.0.0/src/nonebot_plugin_parsehub/paths.py +0 -93
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/LICENSE +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/app.py +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/baidu.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/bilibili.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/coolapk.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/douban.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/douyin.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/facebook.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/instagram.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/kuaishou.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/medium.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/pipix.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/snapchat.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/threads.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/tiktok.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/toutiao.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/wechat.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/weibo.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/x.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/xiaoheihe.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/xiaohongshu.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/youtube.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/zhihu.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/assets/logos/zuiyou.png +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/config.py +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/feedback.py +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/keys.py +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/media.py +0 -0
- {nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/platforms.py +0 -0
- {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.
|
|
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
|
-
|
|
|
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
|
-
-
|
|
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
|
-
|
|
|
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
|
-
-
|
|
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.
|
|
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.
|
|
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]
|
{nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/SPEC.md
RENAMED
|
@@ -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
|
|
22
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
{nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/cache.py
RENAMED
|
@@ -20,14 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
所有方法都是同步的(磁盘 IO),调用方负责放进线程池。
|
|
22
22
|
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:`
|
|
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:`
|
|
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
|
-
|
|
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":
|
|
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
|
|
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
|
-
|
|
124
|
+
类名到类对象的映射。
|
|
121
125
|
"""
|
|
122
126
|
global _PARSEHUB_ERRORS # noqa: PLW0603 - 单例式缓存
|
|
123
127
|
if _PARSEHUB_ERRORS is None:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
144
|
-
self.
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/app.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/keys.py
RENAMED
|
File without changes
|
{nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/media.py
RENAMED
|
File without changes
|
|
File without changes
|
{nonebot_plugin_parsehub-1.0.0 → nonebot_plugin_parsehub-1.0.1}/src/nonebot_plugin_parsehub/relay.py
RENAMED
|
File without changes
|