nonebot-plugin-randpic 2.0.1__tar.gz → 2.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/PKG-INFO +25 -9
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/README.md +24 -8
- nonebot_plugin_randpic-2.1.0/nonebot_plugin_randpic/ali_oss.py +132 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/library.py +11 -10
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/models.py +10 -7
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/repository.py +31 -94
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/service.py +112 -62
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/utils.py +2 -2
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/web.py +28 -26
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/pyproject.toml +1 -1
- nonebot_plugin_randpic-2.0.1/nonebot_plugin_randpic/ali_oss.py +0 -71
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/LICENSE +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/__init__.py +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/compress.py +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/config.py +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/drivers/fastapi.py +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/handlers.py +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/journal.py +0 -0
- {nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/progress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nonebot-plugin-randpic
|
|
3
|
-
Version: 2.0
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: 一个通过指令随机发送你存储的图片的nonebot2插件
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -66,10 +66,22 @@ _✨ 一个发送指令就能让你的 bot 发出对应指令的图片的插件
|
|
|
66
66
|
|
|
67
67
|
首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
之后每次启动都会核对文件变化,并保留已有图片的发送次数和消息引用关系。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
|
|
70
70
|
|
|
71
71
|
实现说明见 [架构与数据一致性](./docs/architecture.md)。
|
|
72
72
|
|
|
73
|
+
### 数据库
|
|
74
|
+
|
|
75
|
+
数据库只保存三类业务数据:
|
|
76
|
+
|
|
77
|
+
| 表 | 用途 |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `randpic_images` | 图片身份、指令、文件名、内容哈希、感知哈希、发送次数、文件大小和删除状态 |
|
|
80
|
+
| `randpic_messages` | Bot/群/消息 ID 与图片的对应关系,用于引用删除 |
|
|
81
|
+
| `randpic_sequences` | 每个指令的最大文件编号,防止删除后复用编号 |
|
|
82
|
+
|
|
83
|
+
网页发布进度仅保存在内存,启动时重新生成;OSS 状态直接从远端获取,不建立数据库镜像或任务表。
|
|
84
|
+
|
|
73
85
|
### 本地存储和离线加图
|
|
74
86
|
|
|
75
87
|
`randpic_store_dir_path` 指定的路径就是存储根目录,**不会额外追加一层 `randpic`**。未设置时,由 `nonebot-plugin-localstore` 提供默认目录。
|
|
@@ -84,8 +96,8 @@ data/randpic/
|
|
|
84
96
|
│ │ └── randpic_capoo_0000000001.png
|
|
85
97
|
│ └── 马哥/
|
|
86
98
|
├── database/
|
|
87
|
-
│ ├── data.db #
|
|
88
|
-
│ └── ... #
|
|
99
|
+
│ ├── data.db # 图片、发送次数、历史消息、文件编号
|
|
100
|
+
│ └── ... # 文件恢复日志、可选 OSS 页面缓存等内部文件
|
|
89
101
|
└── public/ # 自动生成的本地静态网页与图片副本,请勿手动维护
|
|
90
102
|
```
|
|
91
103
|
|
|
@@ -105,7 +117,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
105
117
|
|
|
106
118
|
使用 FastAPI 驱动时,Bot 启动后即可通过 `http://<host>:<port>/randpic/` 查看图库,无需配置 OSS。默认每页 100 张图片,可通过 `randpic_web_page_size` 调整。图片使用 `loading="lazy"`,按需加载。
|
|
107
119
|
|
|
108
|
-
|
|
120
|
+
增删图片后本地页面立即重新发布,浏览器手动刷新即可查看最新内容。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
|
|
109
121
|
|
|
110
122
|
#### 阿里云 OSS
|
|
111
123
|
|
|
@@ -114,7 +126,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
114
126
|
- `randpic_endpoint`:绑定到 Bucket 的完整网页域名,如 `https://gallery.example.com`。
|
|
115
127
|
- `randpic_bucket`:Bucket 名称。
|
|
116
128
|
- `randpic_region`:地域 ID,如 `cn-beijing`,可查阅 [OSS 地域和访问域名](https://help.aliyun.com/zh/oss/regions-and-endpoints)。
|
|
117
|
-
- AccessKey
|
|
129
|
+
- AccessKey:需要 `oss:ListObjects`、`oss:GetObject`、`oss:PutObject` 和 `oss:DeleteObject` 权限,用于列举、核对、上传和删除图库对象。
|
|
118
130
|
|
|
119
131
|
请配置网页访问权限、域名及静态网站首页 `index.html`,示意如下:
|
|
120
132
|
|
|
@@ -122,7 +134,11 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
122
134
|
|
|
123
135
|
启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
|
|
124
136
|
|
|
125
|
-
|
|
137
|
+
OSS 同步使用 Python SDK 分页列举远端对象,与本地发布文件比较大小和校验值,仅上传变化的文件,并清理不在本地发布清单中的云端图库文件。普通对象直接比较列表中的 ETag 与本地 MD5;遇到不一致的同大小对象,再查询 SHA-256 自定义元数据。无需安装 `ossutil`,数据库不保存云端状态或同步任务。
|
|
138
|
+
|
|
139
|
+
后台只在内容变化或失败待重试时同步,失败后约每 30 秒重新比较;重启后重新检查远端,继续完成差异同步。网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;被排除或移除的分类,其云端图库文件会在比较后清理。
|
|
140
|
+
|
|
141
|
+
Bucket 根目录的 `index.html`、`<指令>/index.html`、分页及 `randpic_<指令>_<编号>.<图片后缀>` 均由插件维护。其他命名的文件不参与自动删除;请不要与其他站点共用上述路径。
|
|
126
142
|
|
|
127
143
|
## 💿 安装
|
|
128
144
|
|
|
@@ -222,8 +238,8 @@ randpic_oss_no_upload_list=["capoo"]
|
|
|
222
238
|
|
|
223
239
|
群管理员或群主引用本插件发送的图库图片,再发送 `删除` 即可。添加成功时 Bot 回复的图片也支持引用删除。
|
|
224
240
|
|
|
225
|
-
-
|
|
226
|
-
- 消息与图片的对应关系会持久化,重启 Bot
|
|
241
|
+
- 删除会移除原图和网页副本,并将数据库中的图片标记为已删除,在启用 OSS 时删除云端对象、更新页面;影响所有群共用的图库,不会撤回已经发送的 QQ 消息。
|
|
242
|
+
- 消息与图片的对应关系会持久化,重启 Bot 后仍可引用删除。没有对应记录的消息无法直接删除,请让 Bot 重新发送对应图片后再操作。
|
|
227
243
|
- 删除过的图片再次引用会提示已删除;若网页或 OSS 同步失败,再次引用原消息发送 `删除` 可重试,`上传oss` 也会重试未完成的删除同步。
|
|
228
244
|
|
|
229
245
|
### 效果图
|
|
@@ -35,10 +35,22 @@ _✨ 一个发送指令就能让你的 bot 发出对应指令的图片的插件
|
|
|
35
35
|
|
|
36
36
|
首次启动会创建数据库,并扫描配置的图片文件夹,根据实际图片建立索引,发送次数从 0 开始。数据库不存在时也可由图片文件重新建立索引。
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
之后每次启动都会核对文件变化,并保留已有图片的发送次数和消息引用关系。这些运行数据无法仅凭图片内容恢复,因此不要在正常使用期间删除数据库。
|
|
39
39
|
|
|
40
40
|
实现说明见 [架构与数据一致性](./docs/architecture.md)。
|
|
41
41
|
|
|
42
|
+
### 数据库
|
|
43
|
+
|
|
44
|
+
数据库只保存三类业务数据:
|
|
45
|
+
|
|
46
|
+
| 表 | 用途 |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `randpic_images` | 图片身份、指令、文件名、内容哈希、感知哈希、发送次数、文件大小和删除状态 |
|
|
49
|
+
| `randpic_messages` | Bot/群/消息 ID 与图片的对应关系,用于引用删除 |
|
|
50
|
+
| `randpic_sequences` | 每个指令的最大文件编号,防止删除后复用编号 |
|
|
51
|
+
|
|
52
|
+
网页发布进度仅保存在内存,启动时重新生成;OSS 状态直接从远端获取,不建立数据库镜像或任务表。
|
|
53
|
+
|
|
42
54
|
### 本地存储和离线加图
|
|
43
55
|
|
|
44
56
|
`randpic_store_dir_path` 指定的路径就是存储根目录,**不会额外追加一层 `randpic`**。未设置时,由 `nonebot-plugin-localstore` 提供默认目录。
|
|
@@ -53,8 +65,8 @@ data/randpic/
|
|
|
53
65
|
│ │ └── randpic_capoo_0000000001.png
|
|
54
66
|
│ └── 马哥/
|
|
55
67
|
├── database/
|
|
56
|
-
│ ├── data.db #
|
|
57
|
-
│ └── ... #
|
|
68
|
+
│ ├── data.db # 图片、发送次数、历史消息、文件编号
|
|
69
|
+
│ └── ... # 文件恢复日志、可选 OSS 页面缓存等内部文件
|
|
58
70
|
└── public/ # 自动生成的本地静态网页与图片副本,请勿手动维护
|
|
59
71
|
```
|
|
60
72
|
|
|
@@ -74,7 +86,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
74
86
|
|
|
75
87
|
使用 FastAPI 驱动时,Bot 启动后即可通过 `http://<host>:<port>/randpic/` 查看图库,无需配置 OSS。默认每页 100 张图片,可通过 `randpic_web_page_size` 调整。图片使用 `loading="lazy"`,按需加载。
|
|
76
88
|
|
|
77
|
-
|
|
89
|
+
增删图片后本地页面立即重新发布,浏览器手动刷新即可查看最新内容。没有 FastAPI 服务的驱动仍可使用聊天指令和 OSS 同步。
|
|
78
90
|
|
|
79
91
|
#### 阿里云 OSS
|
|
80
92
|
|
|
@@ -83,7 +95,7 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
83
95
|
- `randpic_endpoint`:绑定到 Bucket 的完整网页域名,如 `https://gallery.example.com`。
|
|
84
96
|
- `randpic_bucket`:Bucket 名称。
|
|
85
97
|
- `randpic_region`:地域 ID,如 `cn-beijing`,可查阅 [OSS 地域和访问域名](https://help.aliyun.com/zh/oss/regions-and-endpoints)。
|
|
86
|
-
- AccessKey
|
|
98
|
+
- AccessKey:需要 `oss:ListObjects`、`oss:GetObject`、`oss:PutObject` 和 `oss:DeleteObject` 权限,用于列举、核对、上传和删除图库对象。
|
|
87
99
|
|
|
88
100
|
请配置网页访问权限、域名及静态网站首页 `index.html`,示意如下:
|
|
89
101
|
|
|
@@ -91,7 +103,11 @@ Bot 运行期间请通过指令增删图片,直接修改文件需重启才能
|
|
|
91
103
|
|
|
92
104
|
启动、添加和删除后会自动同步;`上传oss` 可手动全量重新发布、重试未完成任务。默认最多 16 个并发请求,普通终端中以单行动态进度显示完成数、百分比和成功/失败数,最多每秒刷新 5 次;其他插件的默认 NoneBot 日志显示在进度行上方。同步结束后清除动态行,只保留一条结果汇总,错误详情仍单独记录。输出重定向、非交互控制台或已移除 NoneBot 默认日志处理器时仅输出汇总。
|
|
93
105
|
|
|
94
|
-
|
|
106
|
+
OSS 同步使用 Python SDK 分页列举远端对象,与本地发布文件比较大小和校验值,仅上传变化的文件,并清理不在本地发布清单中的云端图库文件。普通对象直接比较列表中的 ETag 与本地 MD5;遇到不一致的同大小对象,再查询 SHA-256 自定义元数据。无需安装 `ossutil`,数据库不保存云端状态或同步任务。
|
|
107
|
+
|
|
108
|
+
后台只在内容变化或失败待重试时同步,失败后约每 30 秒重新比较;重启后重新检查远端,继续完成差异同步。网络故障期间云端可能暂时落后于本地图。`randpic_oss_no_upload_list` 只影响 OSS,本地网页仍显示所有配置的分类;被排除或移除的分类,其云端图库文件会在比较后清理。
|
|
109
|
+
|
|
110
|
+
Bucket 根目录的 `index.html`、`<指令>/index.html`、分页及 `randpic_<指令>_<编号>.<图片后缀>` 均由插件维护。其他命名的文件不参与自动删除;请不要与其他站点共用上述路径。
|
|
95
111
|
|
|
96
112
|
## 💿 安装
|
|
97
113
|
|
|
@@ -191,8 +207,8 @@ randpic_oss_no_upload_list=["capoo"]
|
|
|
191
207
|
|
|
192
208
|
群管理员或群主引用本插件发送的图库图片,再发送 `删除` 即可。添加成功时 Bot 回复的图片也支持引用删除。
|
|
193
209
|
|
|
194
|
-
-
|
|
195
|
-
- 消息与图片的对应关系会持久化,重启 Bot
|
|
210
|
+
- 删除会移除原图和网页副本,并将数据库中的图片标记为已删除,在启用 OSS 时删除云端对象、更新页面;影响所有群共用的图库,不会撤回已经发送的 QQ 消息。
|
|
211
|
+
- 消息与图片的对应关系会持久化,重启 Bot 后仍可引用删除。没有对应记录的消息无法直接删除,请让 Bot 重新发送对应图片后再操作。
|
|
196
212
|
- 删除过的图片再次引用会提示已删除;若网页或 OSS 同步失败,再次引用原消息发送 `删除` 可重试,`上传oss` 也会重试未完成的删除同步。
|
|
197
213
|
|
|
198
214
|
### 效果图
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""复用异步 OSS 客户端;不持有图库或数据库的锁。"""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import mimetypes
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path, PurePosixPath
|
|
8
|
+
from typing import Dict, Optional
|
|
9
|
+
|
|
10
|
+
import alibabacloud_oss_v2 as oss
|
|
11
|
+
import alibabacloud_oss_v2.aio as oss_aio
|
|
12
|
+
from nonebot.log import logger
|
|
13
|
+
|
|
14
|
+
from .config import Config
|
|
15
|
+
from .models import PublishedFile, RandpicError, RemoteObject
|
|
16
|
+
from .utils import SUPPORTED_EXTENSIONS, parse_index, run_blocking, valid_command
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def is_gallery_key(key: str) -> bool:
|
|
20
|
+
"""只处理插件自己的网页和规范命名图片,不清理 Bucket 内任意文件。"""
|
|
21
|
+
if key == 'index.html':
|
|
22
|
+
return True
|
|
23
|
+
parts = PurePosixPath(key).parts
|
|
24
|
+
if len(parts) != 2 or key != '/'.join(parts) or not valid_command(parts[0]):
|
|
25
|
+
return False
|
|
26
|
+
command, filename = parts
|
|
27
|
+
if filename == 'index.html' or re.fullmatch(r'page-[1-9][0-9]*\.html', filename):
|
|
28
|
+
return True
|
|
29
|
+
path = PurePosixPath(filename)
|
|
30
|
+
return path.suffix.lower() in SUPPORTED_EXTENSIONS and parse_index(path.stem, command) is not None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class OSSUploaderV2:
|
|
34
|
+
def __init__(self, config: Config):
|
|
35
|
+
credentials = oss.credentials.StaticCredentialsProvider(
|
|
36
|
+
access_key_id=config.randpic_oss_access_key_id,
|
|
37
|
+
access_key_secret=config.randpic_oss_access_key_secret,
|
|
38
|
+
)
|
|
39
|
+
self.cfg = oss.config.load_default()
|
|
40
|
+
self.cfg.credentials_provider = credentials
|
|
41
|
+
self.cfg.region = config.randpic_region
|
|
42
|
+
self.bucket = config.randpic_bucket
|
|
43
|
+
self.client = None
|
|
44
|
+
|
|
45
|
+
async def open(self) -> None:
|
|
46
|
+
if self.client is None:
|
|
47
|
+
self.client = oss_aio.AsyncClient(self.cfg)
|
|
48
|
+
|
|
49
|
+
async def close(self) -> None:
|
|
50
|
+
if self.client is not None:
|
|
51
|
+
await self.client.close()
|
|
52
|
+
self.client = None
|
|
53
|
+
|
|
54
|
+
async def list_files(self) -> Dict[str, RemoteObject]:
|
|
55
|
+
"""完整分页列举成功后才交给同步器,避免部分列表造成错误删除。"""
|
|
56
|
+
await self.open()
|
|
57
|
+
objects: Dict[str, RemoteObject] = {}
|
|
58
|
+
token = None
|
|
59
|
+
seen_tokens = set()
|
|
60
|
+
while True:
|
|
61
|
+
result = await asyncio.wait_for(self.client.list_objects_v2(oss.ListObjectsV2Request(
|
|
62
|
+
bucket=self.bucket, max_keys=1000, continuation_token=token,
|
|
63
|
+
)), timeout=60)
|
|
64
|
+
if not 200 <= result.status_code < 300:
|
|
65
|
+
raise RandpicError(f'列举 OSS 对象失败:HTTP {result.status_code}')
|
|
66
|
+
for item in result.contents or []:
|
|
67
|
+
if item.key is not None and is_gallery_key(item.key):
|
|
68
|
+
objects[item.key] = RemoteObject(int(item.size), str(item.etag or '').strip('"').lower())
|
|
69
|
+
if not result.is_truncated:
|
|
70
|
+
return objects
|
|
71
|
+
next_token = result.next_continuation_token
|
|
72
|
+
if not next_token or next_token in seen_tokens:
|
|
73
|
+
raise RandpicError('OSS 分页标记无效,本轮停止同步。')
|
|
74
|
+
seen_tokens.add(next_token)
|
|
75
|
+
token = next_token
|
|
76
|
+
|
|
77
|
+
async def matches_file(self, key: str, file: PublishedFile, remote: RemoteObject) -> bool:
|
|
78
|
+
if remote.size != file.size:
|
|
79
|
+
return False
|
|
80
|
+
if remote.etag == file.etag:
|
|
81
|
+
return True
|
|
82
|
+
# 分片上传或加密对象的 ETag 不保证为 MD5,按本插件的 SHA-256 元数据核对。
|
|
83
|
+
await self.open()
|
|
84
|
+
try:
|
|
85
|
+
result = await asyncio.wait_for(self.client.head_object(oss.HeadObjectRequest(
|
|
86
|
+
bucket=self.bucket, key=key,
|
|
87
|
+
)), timeout=60)
|
|
88
|
+
except oss.exceptions.ServiceError as exc:
|
|
89
|
+
if exc.code == 'NoSuchKey':
|
|
90
|
+
return False
|
|
91
|
+
raise
|
|
92
|
+
if not 200 <= result.status_code < 300:
|
|
93
|
+
raise RandpicError(f'读取 OSS 对象属性失败:{key} (HTTP {result.status_code})')
|
|
94
|
+
return (result.content_length == file.size
|
|
95
|
+
and (result.metadata or {}).get('randpic-sha256') == file.content_hash)
|
|
96
|
+
|
|
97
|
+
async def upload_file(self, local_file_path: str, oss_key: str, expected_hash: Optional[str] = None) -> bool:
|
|
98
|
+
try:
|
|
99
|
+
await self.open()
|
|
100
|
+
data = await run_blocking(Path(local_file_path).read_bytes)
|
|
101
|
+
content_hash = hashlib.sha256(data).hexdigest()
|
|
102
|
+
if expected_hash is not None and content_hash != expected_hash:
|
|
103
|
+
return False # 任务已过期,后续版本会重新上传,不发送旧内容。
|
|
104
|
+
result = await asyncio.wait_for(self.client.put_object(oss.PutObjectRequest(
|
|
105
|
+
bucket=self.bucket, key=oss_key, body=data,
|
|
106
|
+
metadata={'randpic-sha256': content_hash},
|
|
107
|
+
content_type=mimetypes.guess_type(oss_key)[0] or 'application/octet-stream',
|
|
108
|
+
cache_control='no-cache' if oss_key.endswith('.html') else 'public, max-age=31536000, immutable',
|
|
109
|
+
)), timeout=60)
|
|
110
|
+
if 200 <= result.status_code < 300:
|
|
111
|
+
return True
|
|
112
|
+
logger.error(f'OSS 上传失败:{oss_key} (HTTP {result.status_code})')
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
logger.error(f'OSS 上传失败:{oss_key} ({exc})')
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
async def delete_file(self, oss_key: str) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
await self.open()
|
|
120
|
+
result = await asyncio.wait_for(self.client.delete_object(
|
|
121
|
+
oss.DeleteObjectRequest(bucket=self.bucket, key=oss_key)
|
|
122
|
+
), timeout=60)
|
|
123
|
+
if 200 <= result.status_code < 300:
|
|
124
|
+
return True
|
|
125
|
+
logger.error(f'OSS 删除失败:{oss_key} (HTTP {result.status_code})')
|
|
126
|
+
except oss.exceptions.ServiceError as exc:
|
|
127
|
+
if exc.code == 'NoSuchKey':
|
|
128
|
+
return True
|
|
129
|
+
logger.error(f'OSS 删除失败:{oss_key} ({exc})')
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logger.error(f'OSS 删除失败:{oss_key} ({exc})')
|
|
132
|
+
return False
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/library.py
RENAMED
|
@@ -38,6 +38,7 @@ class ImageLibrary:
|
|
|
38
38
|
self.settings = CommandSettings(self.root)
|
|
39
39
|
self.journal = FileJournal(self.root, self.repository)
|
|
40
40
|
self.store_lock = StoreLock(self.root / 'database' / 'randpic.lock')
|
|
41
|
+
self.revision = 0
|
|
41
42
|
|
|
42
43
|
def start(self) -> None:
|
|
43
44
|
for directory in (self.root, self.image_root, self.root / 'database'):
|
|
@@ -61,7 +62,7 @@ class ImageLibrary:
|
|
|
61
62
|
def _candidates(self, command: str, existing: List[ImageRecord]) -> List[_Candidate]:
|
|
62
63
|
directory = safe_path(self.image_root, f'{command}/placeholder').parent
|
|
63
64
|
directory.mkdir(parents=True, exist_ok=True)
|
|
64
|
-
cached = {image.content_hash: image for image in existing
|
|
65
|
+
cached = {image.content_hash: image for image in existing}
|
|
65
66
|
candidates = []
|
|
66
67
|
for source in sorted(directory.iterdir()):
|
|
67
68
|
if source.is_symlink() or not source.is_file() or source.name.startswith('.randpic-'):
|
|
@@ -161,15 +162,15 @@ class ImageLibrary:
|
|
|
161
162
|
self.journal.write(image, candidate.source, candidate.payload)
|
|
162
163
|
destination = self.image_root / image.key
|
|
163
164
|
stat = destination.stat()
|
|
164
|
-
image = replace(image, byte_size=stat.st_size
|
|
165
|
+
image = replace(image, byte_size=stat.st_size)
|
|
165
166
|
self.repository.upsert_image(db, image)
|
|
166
167
|
records.append(image)
|
|
167
168
|
self.repository.set_sequence(db, command, maximum)
|
|
168
169
|
old_state = {(i.id, i.filename, i.content_hash) for i in existing}
|
|
169
170
|
new_state = {(i.id, i.filename, i.content_hash) for i in records}
|
|
170
171
|
changed = changed or old_state != new_state
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
if changed:
|
|
173
|
+
self.revision += 1
|
|
173
174
|
finally:
|
|
174
175
|
# 事务已经提交时清理备份,事务失败时还原文件。
|
|
175
176
|
self.journal.recover()
|
|
@@ -197,12 +198,12 @@ class ImageLibrary:
|
|
|
197
198
|
payload = self.journal.stage(processed.data)
|
|
198
199
|
self.journal.write(image, None, payload)
|
|
199
200
|
stat = (directory / image.filename).stat()
|
|
200
|
-
image = replace(image, byte_size=stat.st_size
|
|
201
|
+
image = replace(image, byte_size=stat.st_size)
|
|
201
202
|
self.repository.upsert_image(db, image)
|
|
202
203
|
self.repository.set_sequence(db, command, index)
|
|
203
|
-
self.repository.mark_dirty(db)
|
|
204
204
|
finally:
|
|
205
205
|
self.journal.recover()
|
|
206
|
+
self.revision += 1
|
|
206
207
|
saved = self.settings.add(command)
|
|
207
208
|
return MutationResult(image, settings_saved=saved)
|
|
208
209
|
|
|
@@ -216,11 +217,11 @@ class ImageLibrary:
|
|
|
216
217
|
if path.exists() and (not path.is_file() or digest_file(path) != image.content_hash):
|
|
217
218
|
raise RandpicError('这条消息对应的文件已被替换,未执行删除。请重新发送当前图片后再操作。')
|
|
218
219
|
self.journal.delete(image)
|
|
219
|
-
|
|
220
|
-
self.repository.mark_dirty(db)
|
|
220
|
+
self.repository.mark_deleted(db, image.id)
|
|
221
221
|
result = MutationResult(replace(image, deleted=True))
|
|
222
222
|
finally:
|
|
223
223
|
self.journal.recover()
|
|
224
|
+
self.revision += 1
|
|
224
225
|
return result
|
|
225
226
|
|
|
226
227
|
def pick(self, command: str) -> Optional[ImageSnapshot]:
|
|
@@ -237,6 +238,6 @@ class ImageLibrary:
|
|
|
237
238
|
except FileNotFoundError:
|
|
238
239
|
# 清理在运行中意外消失的文件,并继续挑选其他图片。
|
|
239
240
|
with self.repository.connection(write=True) as db:
|
|
240
|
-
|
|
241
|
-
|
|
241
|
+
self.repository.mark_deleted(db, image.id)
|
|
242
|
+
self.revision += 1
|
|
242
243
|
logger.warning(f'图片文件已丢失,已从图库索引移除:{path}')
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/models.py
RENAMED
|
@@ -25,9 +25,7 @@ class ImageRecord:
|
|
|
25
25
|
phash: str
|
|
26
26
|
send_count: int = 0
|
|
27
27
|
byte_size: int = 0
|
|
28
|
-
mtime_ns: int = 0
|
|
29
28
|
deleted: bool = False
|
|
30
|
-
validated: bool = True
|
|
31
29
|
|
|
32
30
|
@property
|
|
33
31
|
def key(self) -> str:
|
|
@@ -49,12 +47,17 @@ class MutationResult:
|
|
|
49
47
|
|
|
50
48
|
|
|
51
49
|
@dataclass(frozen=True)
|
|
52
|
-
class
|
|
53
|
-
|
|
54
|
-
action: str
|
|
55
|
-
local_path: Optional[str]
|
|
50
|
+
class PublishedFile:
|
|
51
|
+
path: str
|
|
56
52
|
content_hash: str
|
|
57
|
-
|
|
53
|
+
etag: str
|
|
54
|
+
size: int
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class RemoteObject:
|
|
59
|
+
size: int
|
|
60
|
+
etag: str
|
|
58
61
|
|
|
59
62
|
|
|
60
63
|
@dataclass(frozen=True)
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/repository.py
RENAMED
|
@@ -4,41 +4,40 @@ import sqlite3
|
|
|
4
4
|
from contextlib import contextmanager
|
|
5
5
|
from dataclasses import fields
|
|
6
6
|
from pathlib import Path
|
|
7
|
-
from typing import Iterator, List,
|
|
7
|
+
from typing import Iterator, List, Optional
|
|
8
8
|
|
|
9
|
-
from .models import ImageRecord, RandpicError
|
|
9
|
+
from .models import ImageRecord, RandpicError
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
IMAGE_COLUMNS = ', '.join(field.name for field in fields(ImageRecord))
|
|
13
13
|
SCHEMA = '''
|
|
14
|
-
CREATE TABLE IF NOT EXISTS randpic_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
15
14
|
CREATE TABLE IF NOT EXISTS randpic_images (
|
|
16
15
|
id TEXT PRIMARY KEY, command TEXT NOT NULL, filename TEXT NOT NULL,
|
|
17
|
-
content_hash TEXT NOT NULL, phash TEXT NOT NULL,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
);
|
|
22
|
-
CREATE UNIQUE INDEX IF NOT EXISTS randpic_image_path
|
|
23
|
-
|
|
16
|
+
content_hash TEXT NOT NULL, phash TEXT NOT NULL,
|
|
17
|
+
send_count INTEGER NOT NULL DEFAULT 0 CHECK(send_count >= 0),
|
|
18
|
+
byte_size INTEGER NOT NULL DEFAULT 0 CHECK(byte_size >= 0),
|
|
19
|
+
deleted INTEGER NOT NULL DEFAULT 0 CHECK(deleted IN (0, 1))
|
|
20
|
+
) WITHOUT ROWID;
|
|
21
|
+
CREATE UNIQUE INDEX IF NOT EXISTS randpic_image_path
|
|
22
|
+
ON randpic_images(command, filename) WHERE deleted = 0;
|
|
23
|
+
CREATE INDEX IF NOT EXISTS randpic_image_command
|
|
24
|
+
ON randpic_images(command, id) WHERE deleted = 0;
|
|
25
|
+
CREATE INDEX IF NOT EXISTS randpic_image_hash
|
|
26
|
+
ON randpic_images(command, content_hash) WHERE deleted = 0;
|
|
24
27
|
CREATE TABLE IF NOT EXISTS randpic_messages (
|
|
25
28
|
bot_id TEXT NOT NULL, group_id INTEGER NOT NULL, message_id INTEGER NOT NULL,
|
|
26
|
-
image_id TEXT NOT NULL REFERENCES randpic_images(id),
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
CREATE TABLE IF NOT EXISTS
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
local_path TEXT, content_hash TEXT NOT NULL, revision INTEGER NOT NULL,
|
|
33
|
-
attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT
|
|
34
|
-
);
|
|
29
|
+
image_id TEXT NOT NULL REFERENCES randpic_images(id),
|
|
30
|
+
PRIMARY KEY(bot_id, group_id, message_id)
|
|
31
|
+
) WITHOUT ROWID;
|
|
32
|
+
CREATE TABLE IF NOT EXISTS randpic_sequences (
|
|
33
|
+
command TEXT PRIMARY KEY, last_index INTEGER NOT NULL CHECK(last_index >= 0)
|
|
34
|
+
) WITHOUT ROWID;
|
|
35
35
|
'''
|
|
36
36
|
|
|
37
37
|
|
|
38
38
|
def _image(row: sqlite3.Row) -> ImageRecord:
|
|
39
39
|
values = dict(row)
|
|
40
40
|
values['deleted'] = bool(values['deleted'])
|
|
41
|
-
values['validated'] = bool(values['validated'])
|
|
42
41
|
return ImageRecord(**values)
|
|
43
42
|
|
|
44
43
|
|
|
@@ -74,33 +73,16 @@ class Repository:
|
|
|
74
73
|
finally:
|
|
75
74
|
db.close()
|
|
76
75
|
|
|
77
|
-
@staticmethod
|
|
78
|
-
def meta(db: sqlite3.Connection, key: str, default: str = '0') -> str:
|
|
79
|
-
row = db.execute('SELECT value FROM randpic_meta WHERE key = ?', (key,)).fetchone()
|
|
80
|
-
return row[0] if row else default
|
|
81
|
-
|
|
82
|
-
@staticmethod
|
|
83
|
-
def set_meta(db: sqlite3.Connection, key: str, value: str) -> None:
|
|
84
|
-
db.execute('INSERT OR REPLACE INTO randpic_meta VALUES (?, ?)', (key, value))
|
|
85
|
-
|
|
86
|
-
def mark_dirty(self, db: sqlite3.Connection) -> None:
|
|
87
|
-
self.set_meta(db, 'catalog_revision', str(int(self.meta(db, 'catalog_revision')) + 1))
|
|
88
|
-
|
|
89
76
|
def initialize(self) -> None:
|
|
90
77
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
91
78
|
with self.connection() as db:
|
|
92
|
-
names = {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
|
93
|
-
version = int(self.meta(db, 'schema_version')) if 'randpic_meta' in names else 0
|
|
94
|
-
if version > 2:
|
|
95
|
-
raise RandpicError('数据库来自更新版本的 Randpic,不能用 2.0.0 覆盖。')
|
|
96
79
|
db.execute('PRAGMA journal_mode = WAL')
|
|
97
80
|
db.execute('PRAGMA synchronous = FULL')
|
|
98
81
|
db.executescript(SCHEMA)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
self.mark_dirty(db)
|
|
82
|
+
|
|
83
|
+
@staticmethod
|
|
84
|
+
def mark_deleted(db: sqlite3.Connection, image_id: str) -> None:
|
|
85
|
+
db.execute('UPDATE randpic_images SET deleted = 1 WHERE id = ?', (image_id,))
|
|
104
86
|
|
|
105
87
|
@staticmethod
|
|
106
88
|
def upsert_image(db: sqlite3.Connection, image: ImageRecord) -> None:
|
|
@@ -145,9 +127,14 @@ class Repository:
|
|
|
145
127
|
|
|
146
128
|
@staticmethod
|
|
147
129
|
def duplicate(db: sqlite3.Connection, command: str, content_hash: str, phash: str) -> bool:
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
130
|
+
exact = db.execute('''SELECT 1 FROM randpic_images
|
|
131
|
+
WHERE command = ? AND deleted = 0 AND content_hash = ? LIMIT 1''',
|
|
132
|
+
(command, content_hash)).fetchone()
|
|
133
|
+
if exact is not None:
|
|
134
|
+
return True
|
|
135
|
+
return db.execute('''SELECT 1 FROM randpic_images
|
|
136
|
+
WHERE command = ? AND deleted = 0 AND hamming_distance(phash, ?) <= 5 LIMIT 1''',
|
|
137
|
+
(command, phash)).fetchone() is not None
|
|
151
138
|
|
|
152
139
|
@staticmethod
|
|
153
140
|
def sequence(db: sqlite3.Connection, command: str) -> int:
|
|
@@ -179,53 +166,3 @@ class Repository:
|
|
|
179
166
|
if row is None:
|
|
180
167
|
raise RandpicError('找不到这条消息对应的图库图片,请引用本插件发送的图片。')
|
|
181
168
|
return _image(row)
|
|
182
|
-
|
|
183
|
-
def revision(self) -> Tuple[int, int]:
|
|
184
|
-
with self.connection() as db:
|
|
185
|
-
return int(self.meta(db, 'catalog_revision')), int(self.meta(db, 'published_revision', '-1'))
|
|
186
|
-
|
|
187
|
-
def published(self, revision: int) -> None:
|
|
188
|
-
with self.connection(write=True) as db:
|
|
189
|
-
self.set_meta(db, 'published_revision', str(revision))
|
|
190
|
-
|
|
191
|
-
def reconcile_remote(self, manifest: Mapping[str, Tuple[str, str]], *, force: bool = False) -> None:
|
|
192
|
-
with self.connection(write=True) as db:
|
|
193
|
-
known = {row['key']: row['content_hash'] for row in db.execute('SELECT * FROM randpic_remote')}
|
|
194
|
-
jobs = {row['key']: row for row in db.execute('SELECT * FROM randpic_sync_jobs')}
|
|
195
|
-
revision = int(self.meta(db, 'sync_revision'))
|
|
196
|
-
|
|
197
|
-
def enqueue(key: str, action: str, path: Optional[str], content_hash: str) -> None:
|
|
198
|
-
nonlocal revision
|
|
199
|
-
old = jobs.get(key)
|
|
200
|
-
if old and (old['action'], old['local_path'], old['content_hash']) == (action, path, content_hash) and not force:
|
|
201
|
-
return
|
|
202
|
-
revision += 1
|
|
203
|
-
db.execute('''INSERT OR REPLACE INTO randpic_sync_jobs
|
|
204
|
-
(key, action, local_path, content_hash, revision) VALUES (?, ?, ?, ?, ?)''',
|
|
205
|
-
(key, action, path, content_hash, revision))
|
|
206
|
-
|
|
207
|
-
for key, (path, content_hash) in manifest.items():
|
|
208
|
-
if force or known.get(key) != content_hash or key in jobs:
|
|
209
|
-
enqueue(key, 'upload', path, content_hash)
|
|
210
|
-
for key in (set(known) | set(jobs)) - set(manifest):
|
|
211
|
-
enqueue(key, 'delete', None, '')
|
|
212
|
-
self.set_meta(db, 'sync_revision', str(revision))
|
|
213
|
-
|
|
214
|
-
def sync_jobs(self) -> List[SyncJob]:
|
|
215
|
-
with self.connection() as db:
|
|
216
|
-
return [SyncJob(row['key'], row['action'], row['local_path'], row['content_hash'], row['revision'])
|
|
217
|
-
for row in db.execute('SELECT * FROM randpic_sync_jobs ORDER BY revision')]
|
|
218
|
-
|
|
219
|
-
def complete_job(self, job: SyncJob) -> None:
|
|
220
|
-
with self.connection(write=True) as db:
|
|
221
|
-
if job.action == 'delete':
|
|
222
|
-
db.execute('DELETE FROM randpic_remote WHERE key = ?', (job.key,))
|
|
223
|
-
else:
|
|
224
|
-
db.execute('INSERT OR REPLACE INTO randpic_remote VALUES (?, ?)', (job.key, job.content_hash))
|
|
225
|
-
# 老请求完成时,只能确认自己的版本,不能覆盖期间新加入的删除/上传任务。
|
|
226
|
-
db.execute('DELETE FROM randpic_sync_jobs WHERE key = ? AND revision = ?', (job.key, job.revision))
|
|
227
|
-
|
|
228
|
-
def fail_job(self, job: SyncJob, error: str) -> None:
|
|
229
|
-
with self.connection(write=True) as db:
|
|
230
|
-
db.execute('''UPDATE randpic_sync_jobs SET attempts = attempts + 1, last_error = ?
|
|
231
|
-
WHERE key = ? AND revision = ?''', (error[:500], job.key, job.revision))
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/service.py
RENAMED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""异步应用服务:业务串行提交,网页及时发布,按远端实际状态同步。"""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
from dataclasses import replace
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import Any, Optional
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
7
|
from urllib.parse import urlsplit
|
|
8
8
|
|
|
9
9
|
import httpx
|
|
@@ -12,7 +12,7 @@ from nonebot.log import logger
|
|
|
12
12
|
from .ali_oss import OSSUploaderV2
|
|
13
13
|
from .config import Config
|
|
14
14
|
from .library import ImageLibrary
|
|
15
|
-
from .models import ImageRecord, ImageSnapshot, MutationResult, RandpicError, SyncResult
|
|
15
|
+
from .models import ImageRecord, ImageSnapshot, MutationResult, PublishedFile, RandpicError, SyncResult
|
|
16
16
|
from .progress import SyncProgress
|
|
17
17
|
from .utils import run_blocking
|
|
18
18
|
from .web import GalleryPublisher
|
|
@@ -35,6 +35,10 @@ class RandpicService:
|
|
|
35
35
|
self._worker: Optional[asyncio.Task] = None
|
|
36
36
|
self.ready = False
|
|
37
37
|
self._web_dirty = True
|
|
38
|
+
self._published_revision = -1
|
|
39
|
+
self._published_remote_enabled = False
|
|
40
|
+
self._manifest: Dict[str, PublishedFile] = {}
|
|
41
|
+
self._remote_dirty = True
|
|
38
42
|
self._closed = False
|
|
39
43
|
|
|
40
44
|
@property
|
|
@@ -93,19 +97,23 @@ class RandpicService:
|
|
|
93
97
|
finally:
|
|
94
98
|
await run_blocking(self.library.close)
|
|
95
99
|
|
|
96
|
-
async def _refresh_locked(self, *, force: bool = False
|
|
100
|
+
async def _refresh_locked(self, *, force: bool = False) -> Optional[str]:
|
|
97
101
|
try:
|
|
98
|
-
revision
|
|
99
|
-
|
|
102
|
+
revision = self.library.revision
|
|
103
|
+
remote_enabled = self.uploader is not None
|
|
104
|
+
if (not force and not self._web_dirty and revision == self._published_revision
|
|
105
|
+
and remote_enabled == self._published_remote_enabled):
|
|
100
106
|
return None
|
|
101
107
|
images = await run_blocking(self.repository.list_images)
|
|
102
108
|
manifest = await run_blocking(
|
|
103
|
-
self.publisher.publish, images, list(self.commands),
|
|
109
|
+
self.publisher.publish, images, list(self.commands),
|
|
110
|
+
set(self.config.randpic_oss_no_upload_list), remote_enabled=remote_enabled,
|
|
104
111
|
)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
self._manifest = manifest
|
|
113
|
+
self._published_revision = revision
|
|
114
|
+
self._published_remote_enabled = remote_enabled
|
|
108
115
|
self._web_dirty = False
|
|
116
|
+
self._remote_dirty = remote_enabled
|
|
109
117
|
return None
|
|
110
118
|
except Exception as exc:
|
|
111
119
|
self._web_dirty = True
|
|
@@ -156,7 +164,7 @@ class RandpicService:
|
|
|
156
164
|
self._require_ready()
|
|
157
165
|
snapshot = await run_blocking(self.library.pick, command)
|
|
158
166
|
error = await self._refresh_locked()
|
|
159
|
-
if error:
|
|
167
|
+
if error or self._remote_dirty:
|
|
160
168
|
self._wake.set()
|
|
161
169
|
return snapshot
|
|
162
170
|
|
|
@@ -184,61 +192,100 @@ class RandpicService:
|
|
|
184
192
|
return SyncResult()
|
|
185
193
|
async with self._mutation_lock:
|
|
186
194
|
self._require_ready()
|
|
187
|
-
error = await self._refresh_locked(
|
|
195
|
+
error = await self._refresh_locked(force=force)
|
|
196
|
+
manifest = dict(self._manifest)
|
|
188
197
|
if error:
|
|
189
|
-
return SyncResult(web_error=error)
|
|
190
|
-
if self.uploader is None:
|
|
198
|
+
return SyncResult(pending=1, web_error=error)
|
|
199
|
+
if self.uploader is None or (not force and not self._remote_dirty):
|
|
191
200
|
return SyncResult()
|
|
192
|
-
|
|
193
|
-
if not jobs:
|
|
194
|
-
return SyncResult()
|
|
195
|
-
succeeded = failed = completed = 0
|
|
201
|
+
self._remote_dirty = True
|
|
196
202
|
started = asyncio.get_running_loop().time()
|
|
203
|
+
try:
|
|
204
|
+
remote = await self.uploader.list_files()
|
|
205
|
+
except Exception as exc:
|
|
206
|
+
logger.error(f'列举 OSS 文件失败,本轮未执行上传或删除,将自动重试:{exc}')
|
|
207
|
+
return SyncResult(failed=1, pending=1)
|
|
208
|
+
|
|
209
|
+
# 普通 PutObject 的 ETag 与 MD5 相同;大多数对象只需分页列表即可确认。
|
|
210
|
+
jobs = []
|
|
211
|
+
comparisons = []
|
|
212
|
+
failed_keys = set()
|
|
213
|
+
for key, file in manifest.items():
|
|
214
|
+
old = remote.get(key)
|
|
215
|
+
if force or old is None or old.size != file.size:
|
|
216
|
+
jobs.append((key, file))
|
|
217
|
+
elif old.etag != file.etag:
|
|
218
|
+
comparisons.append((key, file, old))
|
|
219
|
+
candidates = iter(comparisons)
|
|
220
|
+
|
|
221
|
+
async def compare_worker():
|
|
222
|
+
for key, file, old in candidates:
|
|
223
|
+
try:
|
|
224
|
+
if not await self.uploader.matches_file(key, file, old):
|
|
225
|
+
jobs.append((key, file))
|
|
226
|
+
except Exception as exc:
|
|
227
|
+
failed_keys.add(key)
|
|
228
|
+
logger.error(f'核对 OSS 文件失败,将自动重试:{key} ({exc})')
|
|
229
|
+
|
|
230
|
+
limit = self.config.randpic_upload_concurrency
|
|
231
|
+
await asyncio.gather(*(compare_worker() for _ in range(min(limit, len(comparisons)))))
|
|
232
|
+
jobs.extend((key, None) for key in remote.keys() - manifest.keys())
|
|
233
|
+
succeeded = completed = 0
|
|
197
234
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
235
|
+
if jobs:
|
|
236
|
+
with SyncProgress(len(jobs)) as progress:
|
|
237
|
+
async def phase(items):
|
|
238
|
+
remaining = iter(items)
|
|
239
|
+
|
|
240
|
+
async def worker():
|
|
241
|
+
nonlocal succeeded, completed
|
|
242
|
+
for key, file in remaining:
|
|
243
|
+
try:
|
|
244
|
+
async with self._mutation_lock:
|
|
245
|
+
# 跳过被同步期间新操作替代的上传或删除。
|
|
246
|
+
if self._manifest.get(key) != file:
|
|
247
|
+
continue
|
|
248
|
+
if file is None:
|
|
249
|
+
ok = await self.uploader.delete_file(key)
|
|
250
|
+
else:
|
|
251
|
+
ok = await self.uploader.upload_file(file.path, key, file.content_hash)
|
|
252
|
+
if ok:
|
|
253
|
+
succeeded += 1
|
|
254
|
+
else:
|
|
255
|
+
failed_keys.add(key)
|
|
256
|
+
except Exception as exc:
|
|
257
|
+
failed_keys.add(key)
|
|
258
|
+
logger.error(f'同步 OSS 文件失败,将自动重试:{key} ({exc})')
|
|
259
|
+
finally:
|
|
260
|
+
completed += 1
|
|
261
|
+
progress.update(completed, succeeded, len(failed_keys))
|
|
262
|
+
|
|
263
|
+
await asyncio.gather(*(worker() for _ in range(min(limit, len(items)))))
|
|
264
|
+
|
|
265
|
+
# 先处理图片,再更新网页;网络期间不占用图库修改锁。
|
|
266
|
+
await phase([job for job in jobs if not job[0].endswith('.html')])
|
|
267
|
+
await phase([job for job in jobs if job[0].endswith('.html')])
|
|
268
|
+
|
|
269
|
+
async with self._mutation_lock:
|
|
270
|
+
pending_keys = failed_keys | {
|
|
271
|
+
key for key in manifest.keys() | self._manifest.keys()
|
|
272
|
+
if manifest.get(key) != self._manifest.get(key)
|
|
273
|
+
}
|
|
274
|
+
if self._web_dirty or self.library.revision != self._published_revision:
|
|
275
|
+
pending_keys.add('<网页待发布>')
|
|
276
|
+
self._remote_dirty = bool(pending_keys)
|
|
277
|
+
changed = manifest != self._manifest
|
|
278
|
+
if changed:
|
|
279
|
+
self._wake.set()
|
|
280
|
+
if jobs or failed_keys:
|
|
281
|
+
elapsed = asyncio.get_running_loop().time() - started
|
|
282
|
+
summary = (f'OSS 同步完成:成功 {succeeded},失败 {len(failed_keys)},'
|
|
283
|
+
f'待处理 {len(pending_keys)},用时 {elapsed:.1f} 秒')
|
|
284
|
+
if failed_keys:
|
|
285
|
+
logger.warning(summary + ';将重新比较远端内容后重试')
|
|
286
|
+
else:
|
|
287
|
+
logger.info(summary)
|
|
288
|
+
return SyncResult(succeeded=succeeded, failed=len(failed_keys), pending=len(pending_keys))
|
|
242
289
|
|
|
243
290
|
async def _retry_worker(self) -> None:
|
|
244
291
|
while True:
|
|
@@ -249,6 +296,9 @@ class RandpicService:
|
|
|
249
296
|
self._wake.clear()
|
|
250
297
|
if self._closed:
|
|
251
298
|
return
|
|
299
|
+
if (not self._web_dirty and self.library.revision == self._published_revision
|
|
300
|
+
and (self.uploader is None or not self._remote_dirty)):
|
|
301
|
+
continue
|
|
252
302
|
try:
|
|
253
303
|
await self.sync()
|
|
254
304
|
except Exception as exc:
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/utils.py
RENAMED
|
@@ -49,8 +49,8 @@ def safe_path(root: Path, key: str) -> Path:
|
|
|
49
49
|
return path
|
|
50
50
|
|
|
51
51
|
|
|
52
|
-
def digest_file(path: Path) -> str:
|
|
53
|
-
digest = hashlib.
|
|
52
|
+
def digest_file(path: Path, algorithm: str = 'sha256') -> str:
|
|
53
|
+
digest = hashlib.new(algorithm, usedforsecurity=False)
|
|
54
54
|
with path.open('rb') as file:
|
|
55
55
|
for block in iter(lambda: file.read(128 * 1024), b''):
|
|
56
56
|
digest.update(block)
|
|
@@ -1,14 +1,13 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""静态网页发布:原子替换文件、分页与图片懒加载。"""
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
|
-
import json
|
|
5
4
|
import math
|
|
6
5
|
from html import escape
|
|
7
6
|
from pathlib import Path
|
|
8
|
-
from typing import Dict, Iterable, List, Mapping, Sequence, Set
|
|
7
|
+
from typing import Dict, Iterable, List, Mapping, Sequence, Set
|
|
9
8
|
from urllib.parse import quote
|
|
10
9
|
|
|
11
|
-
from .models import ImageRecord, RandpicError
|
|
10
|
+
from .models import ImageRecord, PublishedFile, RandpicError
|
|
12
11
|
from .utils import atomic_write, digest_file, parse_index, safe_path
|
|
13
12
|
|
|
14
13
|
GALLERY_STYLE = '''
|
|
@@ -67,8 +66,7 @@ def _url(image: ImageRecord) -> str:
|
|
|
67
66
|
return quote(image.filename, safe='') + '?v=' + image.content_hash[:16]
|
|
68
67
|
|
|
69
68
|
|
|
70
|
-
def _shell(title: str, home: str,
|
|
71
|
-
state = json.dumps({'version': version, 'command': command, 'page': page}, ensure_ascii=False).replace('<', '\\u003c')
|
|
69
|
+
def _shell(title: str, home: str, content: str) -> str:
|
|
72
70
|
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">
|
|
73
71
|
<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#f5f6f2">
|
|
74
72
|
<title>{escape(title)} · Randpic</title><style>{GALLERY_STYLE}
|
|
@@ -77,22 +75,13 @@ def _shell(title: str, home: str, version: str, command: str, page: int, content
|
|
|
77
75
|
</style></head><body><div class="shell">
|
|
78
76
|
<nav class="topbar"><a class="brand" href="{home}"><span class="brand-mark">✳</span><span>RANDPIC / 图片收藏馆</span></a><span class="top-note">捕捉喜欢的瞬间</span></nav>
|
|
79
77
|
{content}<footer>Randpic · 收藏每一个喜欢的瞬间</footer></div>
|
|
80
|
-
|
|
81
|
-
setInterval(async()=>{{if(document.hidden)return;try{{
|
|
82
|
-
const response=await fetch(home+'catalog.json',{{cache:'no-store'}});if(!response.ok)return;
|
|
83
|
-
const next=await response.json();if(next.version===state.version)return;
|
|
84
|
-
if(state.command && (!(state.command in next.pages)||state.page>next.pages[state.command])){{location.href=state.command in next.pages?'./':home;return;}}
|
|
85
|
-
location.reload();}}catch(error){{}}}},15000);}})();</script></body></html>'''
|
|
78
|
+
</body></html>'''
|
|
86
79
|
|
|
87
80
|
|
|
88
81
|
def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int) -> Dict[str, bytes]:
|
|
89
|
-
"""
|
|
90
|
-
identity = [(command, [(image.id, image.filename, image.content_hash) for image in images])
|
|
91
|
-
for command, images in sorted(groups.items())]
|
|
92
|
-
version = hashlib.sha256(json.dumps(identity, ensure_ascii=False).encode()).hexdigest()
|
|
82
|
+
"""生成纯静态 HTML,只让内容实际变化的页面产生同步任务。"""
|
|
93
83
|
pages = {command: max(1, math.ceil(len(images) / page_size)) for command, images in groups.items()}
|
|
94
|
-
|
|
95
|
-
files = {'catalog.json': catalog}
|
|
84
|
+
files: Dict[str, bytes] = {}
|
|
96
85
|
total = sum(len(images) for images in groups.values())
|
|
97
86
|
cards = []
|
|
98
87
|
for command, images in sorted(groups.items()):
|
|
@@ -106,7 +95,7 @@ def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int)
|
|
|
106
95
|
home = f'''<header class="hero"><p class="eyebrow">YOUR IMAGE LIBRARY</p><h1>每一张,都值得<br>被好好收藏。</h1>
|
|
107
96
|
<p class="hero-copy">在这里慢慢翻看收藏的每一份灵感。</p></header><div class="section-head"><h2>全部分类</h2>
|
|
108
97
|
<span class="count">{len(groups)} 个分类 · {total} 张图片</span></div><main class="folder-grid">{''.join(cards)}</main>'''
|
|
109
|
-
files['index.html'] = _shell('图片收藏馆', './',
|
|
98
|
+
files['index.html'] = _shell('图片收藏馆', './', home).encode()
|
|
110
99
|
for command, images in groups.items():
|
|
111
100
|
for page in range(1, pages[command] + 1):
|
|
112
101
|
cards = []
|
|
@@ -128,7 +117,7 @@ def render_catalog(groups: Mapping[str, Sequence[ImageRecord]], page_size: int)
|
|
|
128
117
|
<header class="gallery-title"><p class="eyebrow">COLLECTION</p><h1>{escape(command)}</h1><p class="hero-copy">本分类共收藏 {len(images)} 张图片。</p></header>
|
|
129
118
|
<main class="image-grid">{''.join(cards)}</main><nav class="pagination">{''.join(navigation)}</nav>'''
|
|
130
119
|
filename = 'index.html' if page == 1 else f'page-{page}.html'
|
|
131
|
-
files[f'{command}/{filename}'] = _shell(command, '../',
|
|
120
|
+
files[f'{command}/{filename}'] = _shell(command, '../', content).encode()
|
|
132
121
|
return files
|
|
133
122
|
|
|
134
123
|
|
|
@@ -140,6 +129,7 @@ class GalleryPublisher:
|
|
|
140
129
|
self.remote_directory = root / 'database' / 'oss_pages'
|
|
141
130
|
self.page_size = page_size
|
|
142
131
|
self._asset_hashes: Dict[str, str] = {}
|
|
132
|
+
self._asset_md5: Dict[str, str] = {}
|
|
143
133
|
|
|
144
134
|
@staticmethod
|
|
145
135
|
def _write_pages(directory: Path, pages: Mapping[str, bytes]) -> None:
|
|
@@ -163,8 +153,8 @@ class GalleryPublisher:
|
|
|
163
153
|
except OSError:
|
|
164
154
|
pass
|
|
165
155
|
|
|
166
|
-
def publish(self, images: Sequence[ImageRecord], commands: Iterable[str], excluded: Set[str]) -> Dict[str,
|
|
167
|
-
for directory in (self.local_directory, self.remote_directory):
|
|
156
|
+
def publish(self, images: Sequence[ImageRecord], commands: Iterable[str], excluded: Set[str], *, remote_enabled: bool = False) -> Dict[str, PublishedFile]:
|
|
157
|
+
for directory in ((self.local_directory, self.remote_directory) if remote_enabled else (self.local_directory,)):
|
|
168
158
|
if directory.is_symlink():
|
|
169
159
|
raise RandpicError('网页输出目录不能是符号链接。')
|
|
170
160
|
directory.mkdir(parents=True, exist_ok=True)
|
|
@@ -175,6 +165,8 @@ class GalleryPublisher:
|
|
|
175
165
|
for command in groups:
|
|
176
166
|
groups[command].sort(key=lambda image: (parse_index(Path(image.filename).stem, command) or 0, image.filename))
|
|
177
167
|
assets: Dict[str, str] = {}
|
|
168
|
+
checksums: Dict[str, str] = {}
|
|
169
|
+
files: Dict[str, PublishedFile] = {}
|
|
178
170
|
for group in groups.values():
|
|
179
171
|
for image in group:
|
|
180
172
|
path = safe_path(self.local_directory, image.key)
|
|
@@ -186,16 +178,26 @@ class GalleryPublisher:
|
|
|
186
178
|
raise RandpicError(f'图片文件在运行时被修改:{image.key}')
|
|
187
179
|
atomic_write(path, data)
|
|
188
180
|
assets[image.key] = image.content_hash
|
|
181
|
+
if remote_enabled and image.command not in excluded:
|
|
182
|
+
checksum = self._asset_md5.get(image.key) if known else None
|
|
183
|
+
if checksum is None:
|
|
184
|
+
checksum = digest_file(path, 'md5')
|
|
185
|
+
checksums[image.key] = checksum
|
|
186
|
+
files[image.key] = PublishedFile(str(path), image.content_hash, checksum, image.byte_size)
|
|
189
187
|
local_pages = render_catalog(groups, self.page_size)
|
|
190
188
|
self._write_pages(self.local_directory, local_pages)
|
|
191
189
|
self._prune(self.local_directory, set(local_pages) | set(assets))
|
|
190
|
+
self._asset_hashes, self._asset_md5 = assets, checksums
|
|
191
|
+
if not remote_enabled:
|
|
192
|
+
return {}
|
|
192
193
|
remote_groups = {command: group for command, group in groups.items() if command not in excluded}
|
|
193
194
|
remote_pages = render_catalog(remote_groups, self.page_size)
|
|
194
195
|
self._write_pages(self.remote_directory, remote_pages)
|
|
195
196
|
self._prune(self.remote_directory, set(remote_pages))
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
197
|
+
manifest = {key: PublishedFile(
|
|
198
|
+
str(self.remote_directory / key), hashlib.sha256(data).hexdigest(),
|
|
199
|
+
hashlib.md5(data, usedforsecurity=False).hexdigest(), len(data),
|
|
200
|
+
) for key, data in remote_pages.items()}
|
|
201
|
+
manifest.update({image.key: files[image.key]
|
|
200
202
|
for group in remote_groups.values() for image in group})
|
|
201
203
|
return manifest
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"""复用异步 OSS 客户端;不持有图库或数据库的锁。"""
|
|
2
|
-
|
|
3
|
-
import asyncio
|
|
4
|
-
import hashlib
|
|
5
|
-
import mimetypes
|
|
6
|
-
from pathlib import Path
|
|
7
|
-
from typing import Optional
|
|
8
|
-
|
|
9
|
-
import alibabacloud_oss_v2 as oss
|
|
10
|
-
import alibabacloud_oss_v2.aio as oss_aio
|
|
11
|
-
from nonebot.log import logger
|
|
12
|
-
|
|
13
|
-
from .config import Config
|
|
14
|
-
from .utils import run_blocking
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class OSSUploaderV2:
|
|
18
|
-
def __init__(self, config: Config):
|
|
19
|
-
credentials = oss.credentials.StaticCredentialsProvider(
|
|
20
|
-
access_key_id=config.randpic_oss_access_key_id,
|
|
21
|
-
access_key_secret=config.randpic_oss_access_key_secret,
|
|
22
|
-
)
|
|
23
|
-
self.cfg = oss.config.load_default()
|
|
24
|
-
self.cfg.credentials_provider = credentials
|
|
25
|
-
self.cfg.region = config.randpic_region
|
|
26
|
-
self.bucket = config.randpic_bucket
|
|
27
|
-
self.client = None
|
|
28
|
-
|
|
29
|
-
async def open(self) -> None:
|
|
30
|
-
if self.client is None:
|
|
31
|
-
self.client = oss_aio.AsyncClient(self.cfg)
|
|
32
|
-
|
|
33
|
-
async def close(self) -> None:
|
|
34
|
-
if self.client is not None:
|
|
35
|
-
await self.client.close()
|
|
36
|
-
self.client = None
|
|
37
|
-
|
|
38
|
-
async def upload_file(self, local_file_path: str, oss_key: str, expected_hash: Optional[str] = None) -> bool:
|
|
39
|
-
try:
|
|
40
|
-
await self.open()
|
|
41
|
-
data = await run_blocking(Path(local_file_path).read_bytes)
|
|
42
|
-
if expected_hash is not None and hashlib.sha256(data).hexdigest() != expected_hash:
|
|
43
|
-
return False # 任务已过期,后续版本会重新上传,不发送旧内容。
|
|
44
|
-
result = await asyncio.wait_for(self.client.put_object(oss.PutObjectRequest(
|
|
45
|
-
bucket=self.bucket, key=oss_key, body=data,
|
|
46
|
-
content_type=mimetypes.guess_type(oss_key)[0] or 'application/octet-stream',
|
|
47
|
-
cache_control='no-cache' if oss_key.endswith(('.html', '.json')) else 'public, max-age=31536000, immutable',
|
|
48
|
-
)), timeout=60)
|
|
49
|
-
if 200 <= result.status_code < 300:
|
|
50
|
-
return True
|
|
51
|
-
logger.error(f'OSS 上传失败:{oss_key} (HTTP {result.status_code})')
|
|
52
|
-
except Exception as exc:
|
|
53
|
-
logger.error(f'OSS 上传失败:{oss_key} ({exc})')
|
|
54
|
-
return False
|
|
55
|
-
|
|
56
|
-
async def delete_file(self, oss_key: str) -> bool:
|
|
57
|
-
try:
|
|
58
|
-
await self.open()
|
|
59
|
-
result = await asyncio.wait_for(self.client.delete_object(
|
|
60
|
-
oss.DeleteObjectRequest(bucket=self.bucket, key=oss_key)
|
|
61
|
-
), timeout=60)
|
|
62
|
-
if 200 <= result.status_code < 300:
|
|
63
|
-
return True
|
|
64
|
-
logger.error(f'OSS 删除失败:{oss_key} (HTTP {result.status_code})')
|
|
65
|
-
except oss.exceptions.ServiceError as exc:
|
|
66
|
-
if exc.code == 'NoSuchKey':
|
|
67
|
-
return True
|
|
68
|
-
logger.error(f'OSS 删除失败:{oss_key} ({exc})')
|
|
69
|
-
except Exception as exc:
|
|
70
|
-
logger.error(f'OSS 删除失败:{oss_key} ({exc})')
|
|
71
|
-
return False
|
|
File without changes
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/__init__.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/compress.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/config.py
RENAMED
|
File without changes
|
|
File without changes
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/handlers.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/journal.py
RENAMED
|
File without changes
|
{nonebot_plugin_randpic-2.0.1 → nonebot_plugin_randpic-2.1.0}/nonebot_plugin_randpic/progress.py
RENAMED
|
File without changes
|