koishi-plugin-best-cave 1.3.0 → 1.3.1
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.
- package/lib/index.d.ts +1 -0
- package/lib/index.js +268 -59
- package/lib/utils/HashStorage.d.ts +61 -0
- package/lib/utils/ImageHasher.d.ts +61 -7
- package/lib/utils/fileHandler.d.ts +44 -0
- package/lib/utils/idManager.d.ts +52 -0
- package/package.json +1 -1
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -33,14 +33,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
33
33
|
// src/locales/zh-CN.yml
|
|
34
34
|
var require_zh_CN = __commonJS({
|
|
35
35
|
"src/locales/zh-CN.yml"(exports2, module2) {
|
|
36
|
-
module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", imageMaxSize: "图片最大大小(MB)", duplicateThreshold: "图片相似度阈值(0-1)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", p: "通过审核(批量)", d: "拒绝审核(批量)", l: "查询投稿统计" }, add: { noContent: "请在一分钟内发送内容", operationTimeout: "操作超时,添加取消", videoDisabled: "不允许上传视频", submitPending: "提交成功,序号为({0})", addSuccess: "添加成功,序号为({0})", mediaSizeExceeded: "{0}文件大小超过限制", localFileNotAllowed: "检测到本地文件路径,无法保存" }, remove: { noPermission: "你无权删除他人添加的回声洞", deletePending: "删除(待审核)", deleted: "已删除" }, list: { pageInfo: "第 {0} / {1} 页", header: "当前共有 {0} 项回声洞:", totalItems: "用户 {0} 共计投稿 {1} 项:", idsLine: "{0}" }, audit: { noPending: "暂无待审核回声洞", pendingNotFound: "未找到待审核回声洞", pendingResult: "{0},剩余 {1} 个待审核回声洞:[{2}]", auditPassed: "已通过", auditRejected: "已拒绝", batchAuditResult: "已{0} {1}/{2} 项回声洞", title: "待审核回声洞:", from: "投稿人:", sendFailed: "发送审核消息失败,无法联系管理员 {0}" }, error: { noContent: "回声洞内容为空", getCave: "获取回声洞失败", noCave: "没有回声洞", invalidId: "请输入有效的回声洞ID", notFound: "未找到该回声洞",
|
|
36
|
+
module2.exports = { _config: { manager: "管理员", number: "冷却时间(秒)", enableAudit: "启用审核", imageMaxSize: "图片最大大小(MB)", enableDuplicate: "启用图片查重", duplicateThreshold: "图片相似度阈值(0-1)", allowVideo: "允许视频上传", videoMaxSize: "视频最大大小(MB)", enablePagination: "启用统计分页", itemsPerPage: "每页显示数目", blacklist: "黑名单(用户)", whitelist: "审核白名单(用户/群组/频道)" }, commands: { cave: { description: "回声洞", usage: "支持添加、抽取、查看、管理回声洞", examples: "使用 cave 随机抽取回声洞\n使用 -a 直接添加或引用添加\n使用 -g 查看指定回声洞\n使用 -r 删除指定回声洞", options: { a: "添加回声洞", g: "查看回声洞", r: "删除回声洞", p: "通过审核(批量)", d: "拒绝审核(批量)", l: "查询投稿统计" }, add: { noContent: "请在一分钟内发送内容", operationTimeout: "操作超时,添加取消", videoDisabled: "不允许上传视频", submitPending: "提交成功,序号为({0})", addSuccess: "添加成功,序号为({0})", mediaSizeExceeded: "{0}文件大小超过限制", localFileNotAllowed: "检测到本地文件路径,无法保存" }, remove: { noPermission: "你无权删除他人添加的回声洞", deletePending: "删除(待审核)", deleted: "已删除" }, list: { pageInfo: "第 {0} / {1} 页", header: "当前共有 {0} 项回声洞:", totalItems: "用户 {0} 共计投稿 {1} 项:", idsLine: "{0}" }, audit: { noPending: "暂无待审核回声洞", pendingNotFound: "未找到待审核回声洞", pendingResult: "{0},剩余 {1} 个待审核回声洞:[{2}]", auditPassed: "已通过", auditRejected: "已拒绝", batchAuditResult: "已{0} {1}/{2} 项回声洞", title: "待审核回声洞:", from: "投稿人:", sendFailed: "发送审核消息失败,无法联系管理员 {0}" }, error: { noContent: "回声洞内容为空", getCave: "获取回声洞失败", noCave: "没有回声洞", invalidId: "请输入有效的回声洞ID", notFound: "未找到该回声洞", exactDuplicateFound: "发现完全相同的", similarDuplicateFound: "发现相似度为 {0}% 的" }, message: { blacklisted: "你已被列入黑名单", managerOnly: "此操作仅限管理员可用", cooldown: "群聊冷却中...请在 {0} 秒后重试", caveTitle: "回声洞 —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0}文件大小超过限制" } } } };
|
|
37
37
|
}
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
// src/locales/en-US.yml
|
|
41
41
|
var require_en_US = __commonJS({
|
|
42
42
|
"src/locales/en-US.yml"(exports2, module2) {
|
|
43
|
-
module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", imageMaxSize: "Maximum image size (MB)", duplicateThreshold: "Image similarity threshold (0-1)", allowVideo: "Allow video
|
|
43
|
+
module2.exports = { _config: { manager: "Administrator", number: "Cooldown time (seconds)", enableAudit: "Enable moderation", imageMaxSize: "Maximum image size (MB)", enableDuplicate: "Enable image duplicate check", duplicateThreshold: "Image similarity threshold (0-1)", allowVideo: "Allow video upload", videoMaxSize: "Maximum video size (MB)", enablePagination: "Enable statistics pagination", itemsPerPage: "Items per page", blacklist: "Blacklist (users)", whitelist: "Moderation whitelist (users/groups/channels)" }, commands: { cave: { description: "Echo Cave", usage: "Support adding, drawing, viewing, and managing echo caves", examples: "Use cave to randomly draw an echo\nUse -a to add directly or add by reference\nUse -g to view specific echo\nUse -r to delete specific echo", options: { a: "Add echo", g: "View echo", r: "Delete echo", p: "Approve moderation (batch)", d: "Reject moderation (batch)", l: "Query submission statistics" }, add: { noContent: "Please send content within one minute", operationTimeout: "Operation timeout, addition cancelled", videoDisabled: "Video upload not allowed", submitPending: "Submission successful, ID is ({0})", addSuccess: "Added successfully, ID is ({0})", mediaSizeExceeded: "{0} file size exceeds limit", localFileNotAllowed: "Local file path detected, cannot save" }, remove: { noPermission: "You don't have permission to delete others' echos", deletePending: "Delete (pending review)", deleted: "Deleted" }, list: { pageInfo: "Page {0} / {1}", header: "Currently there are {0} echos:", totalItems: "User {0} has submitted {1} items:", idsLine: "{0}" }, audit: { noPending: "No pending echos for review", pendingNotFound: "Pending echo not found", pendingResult: "{0}, {1} pending echos remaining: [{2}]", auditPassed: "Approved", auditRejected: "Rejected", batchAuditResult: "{0} {1}/{2} echos", title: "Pending echos:", from: "Submitted by:", sendFailed: "Failed to send moderation message, cannot contact administrator {0}" }, error: { noContent: "Echo content is empty", getCave: "Failed to get echo", noCave: "No echos available", invalidId: "Please enter a valid echo ID", notFound: "Echo not found", exactDuplicateFound: "Found exactly identical", similarDuplicateFound: "Found {0}% similar" }, message: { blacklisted: "You have been blacklisted", managerOnly: "This operation is limited to administrators only", cooldown: "Group chat cooling down... Please try again in {0} seconds", caveTitle: "Echo Cave —— ({0})", contributorSuffix: "—— {0}", mediaSizeExceeded: "{0} file size exceeds limit" } } } };
|
|
44
44
|
}
|
|
45
45
|
});
|
|
46
46
|
|
|
@@ -70,14 +70,24 @@ var FileHandler = class {
|
|
|
70
70
|
static RETRY_COUNT = 3;
|
|
71
71
|
static RETRY_DELAY = 1e3;
|
|
72
72
|
static CONCURRENCY_LIMIT = 5;
|
|
73
|
-
|
|
73
|
+
/**
|
|
74
|
+
* 并发控制
|
|
75
|
+
* @param operation 要执行的操作
|
|
76
|
+
* @param limit 并发限制
|
|
77
|
+
* @returns 操作结果
|
|
78
|
+
*/
|
|
74
79
|
static async withConcurrencyLimit(operation, limit = this.CONCURRENCY_LIMIT) {
|
|
75
80
|
while (this.locks.size >= limit) {
|
|
76
81
|
await Promise.race(this.locks.values());
|
|
77
82
|
}
|
|
78
83
|
return operation();
|
|
79
84
|
}
|
|
80
|
-
|
|
85
|
+
/**
|
|
86
|
+
* 文件操作包装器
|
|
87
|
+
* @param filePath 文件路径
|
|
88
|
+
* @param operation 要执行的操作
|
|
89
|
+
* @returns 操作结果
|
|
90
|
+
*/
|
|
81
91
|
static async withFileOp(filePath, operation) {
|
|
82
92
|
const key = filePath;
|
|
83
93
|
while (this.locks.has(key)) {
|
|
@@ -101,7 +111,11 @@ var FileHandler = class {
|
|
|
101
111
|
this.locks.delete(key);
|
|
102
112
|
}
|
|
103
113
|
}
|
|
104
|
-
|
|
114
|
+
/**
|
|
115
|
+
* 事务处理
|
|
116
|
+
* @param operations 要执行的操作数组
|
|
117
|
+
* @returns 操作结果数组
|
|
118
|
+
*/
|
|
105
119
|
static async withTransaction(operations) {
|
|
106
120
|
const results = [];
|
|
107
121
|
const completed = /* @__PURE__ */ new Set();
|
|
@@ -125,7 +139,11 @@ var FileHandler = class {
|
|
|
125
139
|
throw error;
|
|
126
140
|
}
|
|
127
141
|
}
|
|
128
|
-
|
|
142
|
+
/**
|
|
143
|
+
* 读取 JSON 数据
|
|
144
|
+
* @param filePath 文件路径
|
|
145
|
+
* @returns JSON 数据
|
|
146
|
+
*/
|
|
129
147
|
static async readJsonData(filePath) {
|
|
130
148
|
return this.withFileOp(filePath, async () => {
|
|
131
149
|
try {
|
|
@@ -136,6 +154,11 @@ var FileHandler = class {
|
|
|
136
154
|
}
|
|
137
155
|
});
|
|
138
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* 写入 JSON 数据
|
|
159
|
+
* @param filePath 文件路径
|
|
160
|
+
* @param data 要写入的数据
|
|
161
|
+
*/
|
|
139
162
|
static async writeJsonData(filePath, data) {
|
|
140
163
|
const tmpPath = `${filePath}.tmp`;
|
|
141
164
|
await this.withFileOp(filePath, async () => {
|
|
@@ -143,7 +166,10 @@ var FileHandler = class {
|
|
|
143
166
|
await fs.promises.rename(tmpPath, filePath);
|
|
144
167
|
});
|
|
145
168
|
}
|
|
146
|
-
|
|
169
|
+
/**
|
|
170
|
+
* 确保目录存在
|
|
171
|
+
* @param dir 目录路径
|
|
172
|
+
*/
|
|
147
173
|
static async ensureDirectory(dir) {
|
|
148
174
|
await this.withConcurrencyLimit(async () => {
|
|
149
175
|
if (!fs.existsSync(dir)) {
|
|
@@ -151,6 +177,10 @@ var FileHandler = class {
|
|
|
151
177
|
}
|
|
152
178
|
});
|
|
153
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* 确保 JSON 文件存在
|
|
182
|
+
* @param filePath 文件路径
|
|
183
|
+
*/
|
|
154
184
|
static async ensureJsonFile(filePath) {
|
|
155
185
|
await this.withFileOp(filePath, async () => {
|
|
156
186
|
if (!fs.existsSync(filePath)) {
|
|
@@ -158,7 +188,11 @@ var FileHandler = class {
|
|
|
158
188
|
}
|
|
159
189
|
});
|
|
160
190
|
}
|
|
161
|
-
|
|
191
|
+
/**
|
|
192
|
+
* 保存媒体文件
|
|
193
|
+
* @param filePath 文件路径
|
|
194
|
+
* @param data 文件数据
|
|
195
|
+
*/
|
|
162
196
|
static async saveMediaFile(filePath, data) {
|
|
163
197
|
await this.withConcurrencyLimit(async () => {
|
|
164
198
|
const dir = path.dirname(filePath);
|
|
@@ -169,6 +203,10 @@ var FileHandler = class {
|
|
|
169
203
|
);
|
|
170
204
|
});
|
|
171
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* 删除媒体文件
|
|
208
|
+
* @param filePath 文件路径
|
|
209
|
+
*/
|
|
172
210
|
static async deleteMediaFile(filePath) {
|
|
173
211
|
await this.withFileOp(filePath, async () => {
|
|
174
212
|
if (fs.existsSync(filePath)) {
|
|
@@ -193,10 +231,20 @@ var IdManager = class {
|
|
|
193
231
|
statusFilePath;
|
|
194
232
|
stats = {};
|
|
195
233
|
usedIds = /* @__PURE__ */ new Set();
|
|
234
|
+
/**
|
|
235
|
+
* 初始化ID管理器
|
|
236
|
+
* @param baseDir - 基础目录路径
|
|
237
|
+
*/
|
|
196
238
|
constructor(baseDir) {
|
|
197
239
|
const caveDir = path2.join(baseDir, "data", "cave");
|
|
198
240
|
this.statusFilePath = path2.join(caveDir, "status.json");
|
|
199
241
|
}
|
|
242
|
+
/**
|
|
243
|
+
* 初始化ID管理系统
|
|
244
|
+
* @param caveFilePath - 正式回声洞数据文件路径
|
|
245
|
+
* @param pendingFilePath - 待处理回声洞数据文件路径
|
|
246
|
+
* @throws 当初始化失败时抛出错误
|
|
247
|
+
*/
|
|
200
248
|
async initialize(caveFilePath, pendingFilePath) {
|
|
201
249
|
if (this.initialized) return;
|
|
202
250
|
try {
|
|
@@ -252,6 +300,15 @@ var IdManager = class {
|
|
|
252
300
|
throw error;
|
|
253
301
|
}
|
|
254
302
|
}
|
|
303
|
+
/**
|
|
304
|
+
* 处理ID冲突
|
|
305
|
+
* @param conflicts - ID冲突映射表
|
|
306
|
+
* @param caveFilePath - 正式回声洞数据文件路径
|
|
307
|
+
* @param pendingFilePath - 待处理回声洞数据文件路径
|
|
308
|
+
* @param caveData - 正式回声洞数据
|
|
309
|
+
* @param pendingData - 待处理回声洞数据
|
|
310
|
+
* @private
|
|
311
|
+
*/
|
|
255
312
|
async handleConflicts(conflicts, caveFilePath, pendingFilePath, caveData, pendingData) {
|
|
256
313
|
logger2.warn(`Found ${conflicts.size} ID conflicts`);
|
|
257
314
|
let modified = false;
|
|
@@ -276,6 +333,11 @@ var IdManager = class {
|
|
|
276
333
|
logger2.success("ID conflicts resolved");
|
|
277
334
|
}
|
|
278
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* 获取下一个可用的ID
|
|
338
|
+
* @returns 下一个可用的ID
|
|
339
|
+
* @throws 当ID管理器未初始化时抛出错误
|
|
340
|
+
*/
|
|
279
341
|
getNextId() {
|
|
280
342
|
if (!this.initialized) {
|
|
281
343
|
throw new Error("IdManager not initialized");
|
|
@@ -296,6 +358,11 @@ var IdManager = class {
|
|
|
296
358
|
);
|
|
297
359
|
return nextId;
|
|
298
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* 标记ID为已删除状态
|
|
363
|
+
* @param id - 要标记为删除的ID
|
|
364
|
+
* @throws 当ID管理器未初始化时抛出错误
|
|
365
|
+
*/
|
|
299
366
|
async markDeleted(id) {
|
|
300
367
|
if (!this.initialized) {
|
|
301
368
|
throw new Error("IdManager not initialized");
|
|
@@ -307,6 +374,11 @@ var IdManager = class {
|
|
|
307
374
|
this.maxId = Math.max(maxUsedId, maxDeletedId);
|
|
308
375
|
await this.saveStatus();
|
|
309
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* 添加贡献统计
|
|
379
|
+
* @param contributorNumber - 贡献者编号
|
|
380
|
+
* @param caveId - 回声洞ID
|
|
381
|
+
*/
|
|
310
382
|
async addStat(contributorNumber, caveId) {
|
|
311
383
|
if (contributorNumber === "10000") return;
|
|
312
384
|
if (!this.stats[contributorNumber]) {
|
|
@@ -315,6 +387,11 @@ var IdManager = class {
|
|
|
315
387
|
this.stats[contributorNumber].push(caveId);
|
|
316
388
|
await this.saveStatus();
|
|
317
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* 移除贡献统计
|
|
392
|
+
* @param contributorNumber - 贡献者编号
|
|
393
|
+
* @param caveId - 回声洞ID
|
|
394
|
+
*/
|
|
318
395
|
async removeStat(contributorNumber, caveId) {
|
|
319
396
|
if (this.stats[contributorNumber]) {
|
|
320
397
|
this.stats[contributorNumber] = this.stats[contributorNumber].filter((id) => id !== caveId);
|
|
@@ -324,9 +401,18 @@ var IdManager = class {
|
|
|
324
401
|
await this.saveStatus();
|
|
325
402
|
}
|
|
326
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* 获取所有贡献统计信息
|
|
406
|
+
* @returns 贡献者编号到回声洞ID列表的映射
|
|
407
|
+
*/
|
|
327
408
|
getStats() {
|
|
328
409
|
return this.stats;
|
|
329
410
|
}
|
|
411
|
+
/**
|
|
412
|
+
* 保存当前状态到文件
|
|
413
|
+
* @private
|
|
414
|
+
* @throws 当保存失败时抛出错误
|
|
415
|
+
*/
|
|
330
416
|
async saveStatus() {
|
|
331
417
|
try {
|
|
332
418
|
const status = {
|
|
@@ -357,16 +443,25 @@ var ImageHasher = class {
|
|
|
357
443
|
__name(this, "ImageHasher");
|
|
358
444
|
}
|
|
359
445
|
/**
|
|
360
|
-
*
|
|
446
|
+
* 计算图片的感知哈希值
|
|
447
|
+
* @param imageBuffer - 图片的二进制数据
|
|
448
|
+
* @returns 返回64位的十六进制哈希字符串
|
|
449
|
+
* @throws 当图片处理失败时可能抛出错误
|
|
361
450
|
*/
|
|
362
451
|
static async calculateHash(imageBuffer) {
|
|
363
452
|
const { data } = await (0, import_sharp.default)(imageBuffer).grayscale().resize(32, 32, { fit: "fill" }).raw().toBuffer({ resolveWithObject: true });
|
|
364
|
-
const
|
|
365
|
-
const features = this.extractFeatures(
|
|
366
|
-
const
|
|
367
|
-
const binaryHash = features.map((val) => val >
|
|
453
|
+
const dctMatrix = this.computeDCT(data, 32);
|
|
454
|
+
const features = this.extractFeatures(dctMatrix, 32);
|
|
455
|
+
const median = this.calculateMedian(features);
|
|
456
|
+
const binaryHash = features.map((val) => val > median ? "1" : "0").join("");
|
|
368
457
|
return this.binaryToHex(binaryHash);
|
|
369
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* 将二进制字符串转换为十六进制
|
|
461
|
+
* @param binary - 二进制字符串
|
|
462
|
+
* @returns 十六进制字符串
|
|
463
|
+
* @private
|
|
464
|
+
*/
|
|
370
465
|
static binaryToHex(binary) {
|
|
371
466
|
const hex = [];
|
|
372
467
|
for (let i = 0; i < binary.length; i += 4) {
|
|
@@ -375,6 +470,12 @@ var ImageHasher = class {
|
|
|
375
470
|
}
|
|
376
471
|
return hex.join("");
|
|
377
472
|
}
|
|
473
|
+
/**
|
|
474
|
+
* 将十六进制字符串转换为二进制
|
|
475
|
+
* @param hex - 十六进制字符串
|
|
476
|
+
* @returns 二进制字符串
|
|
477
|
+
* @private
|
|
478
|
+
*/
|
|
378
479
|
static hexToBinary(hex) {
|
|
379
480
|
let binary = "";
|
|
380
481
|
for (const char of hex) {
|
|
@@ -383,40 +484,64 @@ var ImageHasher = class {
|
|
|
383
484
|
}
|
|
384
485
|
return binary;
|
|
385
486
|
}
|
|
386
|
-
|
|
487
|
+
/**
|
|
488
|
+
* 计算图像的DCT(离散余弦变换)
|
|
489
|
+
* @param data - 图像数据
|
|
490
|
+
* @param size - 图像尺寸
|
|
491
|
+
* @returns DCT变换后的矩阵
|
|
492
|
+
* @private
|
|
493
|
+
*/
|
|
494
|
+
static computeDCT(data, size) {
|
|
387
495
|
const matrix = Array(size).fill(0).map(() => Array(size).fill(0));
|
|
496
|
+
const output = Array(size).fill(0).map(() => Array(size).fill(0));
|
|
388
497
|
for (let i = 0; i < size; i++) {
|
|
389
498
|
for (let j = 0; j < size; j++) {
|
|
390
499
|
matrix[i][j] = data[i * size + j];
|
|
391
500
|
}
|
|
392
501
|
}
|
|
393
|
-
for (let
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
502
|
+
for (let u = 0; u < size; u++) {
|
|
503
|
+
for (let v = 0; v < size; v++) {
|
|
504
|
+
let sum = 0;
|
|
505
|
+
for (let x = 0; x < size; x++) {
|
|
506
|
+
for (let y = 0; y < size; y++) {
|
|
507
|
+
const cx = Math.cos((2 * x + 1) * u * Math.PI / (2 * size));
|
|
508
|
+
const cy = Math.cos((2 * y + 1) * v * Math.PI / (2 * size));
|
|
509
|
+
sum += matrix[x][y] * cx * cy;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
output[u][v] = sum * this.getDCTCoefficient(u, size) * this.getDCTCoefficient(v, size);
|
|
401
513
|
}
|
|
402
514
|
}
|
|
403
|
-
return
|
|
515
|
+
return output;
|
|
404
516
|
}
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
for (let i = 0; i < len; i++) {
|
|
417
|
-
arr[i] = temp[i];
|
|
418
|
-
}
|
|
517
|
+
/**
|
|
518
|
+
* 获取DCT系数
|
|
519
|
+
* @param index - 索引值
|
|
520
|
+
* @param size - 矩阵大小
|
|
521
|
+
* @returns DCT系数
|
|
522
|
+
* @private
|
|
523
|
+
*/
|
|
524
|
+
static getDCTCoefficient(index, size) {
|
|
525
|
+
return index === 0 ? Math.sqrt(1 / size) : Math.sqrt(2 / size);
|
|
419
526
|
}
|
|
527
|
+
/**
|
|
528
|
+
* 计算数组的中位数
|
|
529
|
+
* @param arr - 输入数组
|
|
530
|
+
* @returns 中位数
|
|
531
|
+
* @private
|
|
532
|
+
*/
|
|
533
|
+
static calculateMedian(arr) {
|
|
534
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
535
|
+
const mid = Math.floor(sorted.length / 2);
|
|
536
|
+
return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* 从DCT矩阵中提取特征值
|
|
540
|
+
* @param matrix - DCT矩阵
|
|
541
|
+
* @param size - 矩阵大小
|
|
542
|
+
* @returns 特征值数组
|
|
543
|
+
* @private
|
|
544
|
+
*/
|
|
420
545
|
static extractFeatures(matrix, size) {
|
|
421
546
|
const features = [];
|
|
422
547
|
const featureSize = 8;
|
|
@@ -428,7 +553,11 @@ var ImageHasher = class {
|
|
|
428
553
|
return features;
|
|
429
554
|
}
|
|
430
555
|
/**
|
|
431
|
-
*
|
|
556
|
+
* 计算两个哈希值之间的汉明距离
|
|
557
|
+
* @param hash1 - 第一个哈希值
|
|
558
|
+
* @param hash2 - 第二个哈希值
|
|
559
|
+
* @returns 汉明距离
|
|
560
|
+
* @throws 当两个哈希值长度不等时抛出错误
|
|
432
561
|
*/
|
|
433
562
|
static calculateDistance(hash1, hash2) {
|
|
434
563
|
if (hash1.length !== hash2.length) {
|
|
@@ -443,14 +572,20 @@ var ImageHasher = class {
|
|
|
443
572
|
return distance;
|
|
444
573
|
}
|
|
445
574
|
/**
|
|
446
|
-
*
|
|
575
|
+
* 计算两个图片哈希值的相似度
|
|
576
|
+
* @param hash1 - 第一个哈希值
|
|
577
|
+
* @param hash2 - 第二个哈希值
|
|
578
|
+
* @returns 返回0-1之间的相似度值,1表示完全相同,0表示完全不同
|
|
447
579
|
*/
|
|
448
580
|
static calculateSimilarity(hash1, hash2) {
|
|
449
581
|
const distance = this.calculateDistance(hash1, hash2);
|
|
450
582
|
return (64 - distance) / 64;
|
|
451
583
|
}
|
|
452
584
|
/**
|
|
453
|
-
*
|
|
585
|
+
* 批量比较一个新哈希值与多个已存在哈希值的相似度
|
|
586
|
+
* @param newHash - 新的哈希值
|
|
587
|
+
* @param existingHashes - 已存在的哈希值数组
|
|
588
|
+
* @returns 相似度数组,每个元素对应一个已存在哈希值的相似度
|
|
454
589
|
*/
|
|
455
590
|
static batchCompareSimilarity(newHash, existingHashes) {
|
|
456
591
|
return existingHashes.map((hash) => this.calculateSimilarity(newHash, hash));
|
|
@@ -462,6 +597,10 @@ var import_util = require("util");
|
|
|
462
597
|
var logger3 = new import_koishi3.Logger("HashStorage");
|
|
463
598
|
var readFileAsync = (0, import_util.promisify)(fs3.readFile);
|
|
464
599
|
var HashStorage = class _HashStorage {
|
|
600
|
+
/**
|
|
601
|
+
* 初始化HashStorage实例
|
|
602
|
+
* @param caveDir 回声洞数据目录路径
|
|
603
|
+
*/
|
|
465
604
|
constructor(caveDir) {
|
|
466
605
|
this.caveDir = caveDir;
|
|
467
606
|
}
|
|
@@ -470,11 +609,11 @@ var HashStorage = class _HashStorage {
|
|
|
470
609
|
}
|
|
471
610
|
// 哈希数据文件名
|
|
472
611
|
static HASH_FILE = "hash.json";
|
|
473
|
-
//
|
|
612
|
+
// 回声洞数据文件名
|
|
474
613
|
static CAVE_FILE = "cave.json";
|
|
475
614
|
// 批处理大小
|
|
476
615
|
static BATCH_SIZE = 50;
|
|
477
|
-
//
|
|
616
|
+
// 存储回声洞ID到图片哈希值的映射
|
|
478
617
|
hashes = /* @__PURE__ */ new Map();
|
|
479
618
|
// 初始化状态标志
|
|
480
619
|
initialized = false;
|
|
@@ -487,6 +626,11 @@ var HashStorage = class _HashStorage {
|
|
|
487
626
|
get caveFilePath() {
|
|
488
627
|
return path3.join(this.caveDir, _HashStorage.CAVE_FILE);
|
|
489
628
|
}
|
|
629
|
+
/**
|
|
630
|
+
* 初始化哈希存储
|
|
631
|
+
* 读取现有哈希数据或重新构建哈希值
|
|
632
|
+
* @throws 初始化失败时抛出错误
|
|
633
|
+
*/
|
|
490
634
|
async initialize() {
|
|
491
635
|
if (this.initialized) return;
|
|
492
636
|
try {
|
|
@@ -507,6 +651,10 @@ var HashStorage = class _HashStorage {
|
|
|
507
651
|
throw error;
|
|
508
652
|
}
|
|
509
653
|
}
|
|
654
|
+
/**
|
|
655
|
+
* 获取当前哈希存储状态
|
|
656
|
+
* @returns 包含最后更新时间和所有条目的状态对象
|
|
657
|
+
*/
|
|
510
658
|
async getStatus() {
|
|
511
659
|
if (!this.initialized) await this.initialize();
|
|
512
660
|
return {
|
|
@@ -517,6 +665,11 @@ var HashStorage = class _HashStorage {
|
|
|
517
665
|
}))
|
|
518
666
|
};
|
|
519
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* 更新指定回声洞的图片哈希值
|
|
670
|
+
* @param caveId 回声洞ID
|
|
671
|
+
* @param imgBuffers 图片buffer数组
|
|
672
|
+
*/
|
|
520
673
|
async updateCaveHash(caveId, imgBuffers) {
|
|
521
674
|
if (!this.initialized) await this.initialize();
|
|
522
675
|
try {
|
|
@@ -533,6 +686,10 @@ var HashStorage = class _HashStorage {
|
|
|
533
686
|
logger3.error(`Failed to update hash (cave ${caveId}): ${error.message}`);
|
|
534
687
|
}
|
|
535
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* 更新所有回声洞的哈希值
|
|
691
|
+
* @param isInitialBuild 是否为初始构建
|
|
692
|
+
*/
|
|
536
693
|
async updateAllCaves(isInitialBuild = false) {
|
|
537
694
|
if (!this.initialized && !isInitialBuild) {
|
|
538
695
|
await this.initialize();
|
|
@@ -582,6 +739,12 @@ var HashStorage = class _HashStorage {
|
|
|
582
739
|
throw error;
|
|
583
740
|
}
|
|
584
741
|
}
|
|
742
|
+
/**
|
|
743
|
+
* 查找重复的图片
|
|
744
|
+
* @param imgBuffers 待查找的图片buffer数组
|
|
745
|
+
* @param threshold 相似度阈值
|
|
746
|
+
* @returns 匹配结果数组,包含索引、回声洞ID和相似度
|
|
747
|
+
*/
|
|
585
748
|
async findDuplicates(imgBuffers, threshold) {
|
|
586
749
|
if (!this.initialized) await this.initialize();
|
|
587
750
|
const inputHashes = await Promise.all(
|
|
@@ -616,10 +779,19 @@ var HashStorage = class _HashStorage {
|
|
|
616
779
|
})
|
|
617
780
|
);
|
|
618
781
|
}
|
|
782
|
+
/**
|
|
783
|
+
* 加载回声洞数据
|
|
784
|
+
* @returns 回声洞数据数组
|
|
785
|
+
* @private
|
|
786
|
+
*/
|
|
619
787
|
async loadCaveData() {
|
|
620
788
|
const data = await FileHandler.readJsonData(this.caveFilePath);
|
|
621
789
|
return Array.isArray(data) ? data.flat() : [];
|
|
622
790
|
}
|
|
791
|
+
/**
|
|
792
|
+
* 保存哈希数据到文件
|
|
793
|
+
* @private
|
|
794
|
+
*/
|
|
623
795
|
async saveHashes() {
|
|
624
796
|
const data = {
|
|
625
797
|
hashes: Object.fromEntries(this.hashes),
|
|
@@ -627,6 +799,10 @@ var HashStorage = class _HashStorage {
|
|
|
627
799
|
};
|
|
628
800
|
await FileHandler.writeJsonData(this.filePath, [data]);
|
|
629
801
|
}
|
|
802
|
+
/**
|
|
803
|
+
* 构建初始哈希数据
|
|
804
|
+
* @private
|
|
805
|
+
*/
|
|
630
806
|
async buildInitialHashes() {
|
|
631
807
|
const caveData = await this.loadCaveData();
|
|
632
808
|
let processedImageCount = 0;
|
|
@@ -663,6 +839,10 @@ var HashStorage = class _HashStorage {
|
|
|
663
839
|
await this.saveHashes();
|
|
664
840
|
logger3.success(`Build completed. Processed ${processedImageCount}/${totalImages} images`);
|
|
665
841
|
}
|
|
842
|
+
/**
|
|
843
|
+
* 更新缺失的哈希值
|
|
844
|
+
* @private
|
|
845
|
+
*/
|
|
666
846
|
async updateMissingHashes() {
|
|
667
847
|
const caveData = await this.loadCaveData();
|
|
668
848
|
let updatedCount = 0;
|
|
@@ -695,6 +875,13 @@ var HashStorage = class _HashStorage {
|
|
|
695
875
|
logger3.info(`Updated ${updatedCount} new hashes`);
|
|
696
876
|
}
|
|
697
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* 批量处理数组项
|
|
880
|
+
* @param items 待处理项数组
|
|
881
|
+
* @param processor 处理函数
|
|
882
|
+
* @param batchSize 批处理大小
|
|
883
|
+
* @private
|
|
884
|
+
*/
|
|
698
885
|
async processBatch(items, processor, batchSize = _HashStorage.BATCH_SIZE) {
|
|
699
886
|
for (let i = 0; i < items.length; i += batchSize) {
|
|
700
887
|
const batch = items.slice(i, i + batchSize);
|
|
@@ -723,6 +910,8 @@ var Config = import_koishi4.Schema.object({
|
|
|
723
910
|
// 启用审核
|
|
724
911
|
imageMaxSize: import_koishi4.Schema.number().default(4),
|
|
725
912
|
// 图片大小限制(MB)
|
|
913
|
+
enableDuplicate: import_koishi4.Schema.boolean().default(true),
|
|
914
|
+
// 开启查重
|
|
726
915
|
duplicateThreshold: import_koishi4.Schema.number().default(0.8),
|
|
727
916
|
// 查重阈值(0-1)
|
|
728
917
|
allowVideo: import_koishi4.Schema.boolean().default(true),
|
|
@@ -918,7 +1107,6 @@ async function apply(ctx, config) {
|
|
|
918
1107
|
type: "video",
|
|
919
1108
|
file: savedVideos[0],
|
|
920
1109
|
index: Number.MAX_SAFE_INTEGER
|
|
921
|
-
// 确保视频总是在最后
|
|
922
1110
|
});
|
|
923
1111
|
}
|
|
924
1112
|
const hashStorage2 = new HashStorage(path4.join(ctx2.baseDir, "data", "cave"));
|
|
@@ -1280,9 +1468,6 @@ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config
|
|
|
1280
1468
|
const downloadTasks = urls.map(async (url, i) => {
|
|
1281
1469
|
const fileName = fileNames[i];
|
|
1282
1470
|
const ext = path4.extname(fileName || url) || (mediaType === "img" ? ".png" : ".mp4");
|
|
1283
|
-
const baseName = path4.basename(fileName || "media", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
|
|
1284
|
-
const finalFileName = `${caveId}_${baseName}${ext}`;
|
|
1285
|
-
const filePath = path4.join(resourceDir, finalFileName);
|
|
1286
1471
|
try {
|
|
1287
1472
|
const response = await ctx.http(decodeURIComponent(url).replace(/&/g, "&"), {
|
|
1288
1473
|
method: "GET",
|
|
@@ -1295,31 +1480,55 @@ async function saveMedia(urls, fileNames, resourceDir, caveId, mediaType, config
|
|
|
1295
1480
|
}
|
|
1296
1481
|
});
|
|
1297
1482
|
if (!response.data) throw new Error("empty_response");
|
|
1483
|
+
const buffer = Buffer.from(response.data);
|
|
1298
1484
|
if (mediaType === "img") {
|
|
1299
|
-
const
|
|
1300
|
-
const
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
const
|
|
1304
|
-
if (
|
|
1485
|
+
const baseName = path4.basename(fileName || "md5", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
|
|
1486
|
+
const files = await fs4.promises.readdir(resourceDir);
|
|
1487
|
+
const duplicateFile = files.find((file) => file.startsWith(baseName + "_"));
|
|
1488
|
+
if (duplicateFile) {
|
|
1489
|
+
const duplicateCaveId = parseInt(duplicateFile.split("_")[1]);
|
|
1490
|
+
if (!isNaN(duplicateCaveId)) {
|
|
1305
1491
|
const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
|
|
1306
1492
|
const data = await FileHandler.readJsonData(caveFilePath);
|
|
1307
|
-
const originalCave = data.find((item) => item.cave_id ===
|
|
1493
|
+
const originalCave = data.find((item) => item.cave_id === duplicateCaveId);
|
|
1308
1494
|
if (originalCave) {
|
|
1309
|
-
const message = session.text(
|
|
1310
|
-
"commands.cave.error.duplicateFound",
|
|
1311
|
-
[(similarity * 100).toFixed(1)]
|
|
1312
|
-
);
|
|
1495
|
+
const message = session.text("commands.cave.error.exactDuplicateFound");
|
|
1313
1496
|
await session.send(message + await buildMessage(originalCave, resourceDir, session));
|
|
1314
1497
|
throw new Error("duplicate_found");
|
|
1315
1498
|
}
|
|
1316
1499
|
}
|
|
1317
1500
|
}
|
|
1501
|
+
if (config.enableDuplicate) {
|
|
1502
|
+
const result = await hashStorage.findDuplicates([buffer], config.duplicateThreshold);
|
|
1503
|
+
if (result.length > 0 && result[0] !== null) {
|
|
1504
|
+
const duplicate = result[0];
|
|
1505
|
+
const similarity = duplicate.similarity;
|
|
1506
|
+
if (similarity >= config.duplicateThreshold) {
|
|
1507
|
+
const caveFilePath = path4.join(ctx.baseDir, "data", "cave", "cave.json");
|
|
1508
|
+
const data = await FileHandler.readJsonData(caveFilePath);
|
|
1509
|
+
const originalCave = data.find((item) => item.cave_id === duplicate.caveId);
|
|
1510
|
+
if (originalCave) {
|
|
1511
|
+
const message = session.text(
|
|
1512
|
+
"commands.cave.error.similarDuplicateFound",
|
|
1513
|
+
[(similarity * 100).toFixed(1)]
|
|
1514
|
+
);
|
|
1515
|
+
await session.send(message + await buildMessage(originalCave, resourceDir, session));
|
|
1516
|
+
throw new Error("duplicate_found");
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
const finalFileName = `${caveId}_${baseName}${ext}`;
|
|
1522
|
+
const filePath = path4.join(resourceDir, finalFileName);
|
|
1318
1523
|
await FileHandler.saveMediaFile(filePath, buffer);
|
|
1524
|
+
return finalFileName;
|
|
1319
1525
|
} else {
|
|
1320
|
-
|
|
1526
|
+
const baseName = path4.basename(fileName || "video", ext).replace(/[^\u4e00-\u9fa5a-zA-Z0-9]/g, "");
|
|
1527
|
+
const finalFileName = `${caveId}_${baseName}${ext}`;
|
|
1528
|
+
const filePath = path4.join(resourceDir, finalFileName);
|
|
1529
|
+
await FileHandler.saveMediaFile(filePath, buffer);
|
|
1530
|
+
return finalFileName;
|
|
1321
1531
|
}
|
|
1322
|
-
return finalFileName;
|
|
1323
1532
|
} catch (error) {
|
|
1324
1533
|
if (error.message === "duplicate_found") {
|
|
1325
1534
|
throw error;
|
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 哈希存储状态信息
|
|
3
|
+
*/
|
|
1
4
|
interface HashStatus {
|
|
5
|
+
/** 最后更新时间戳 */
|
|
2
6
|
lastUpdated: string;
|
|
7
|
+
/** 所有回声洞的哈希值条目 */
|
|
3
8
|
entries: Array<{
|
|
4
9
|
caveId: number;
|
|
5
10
|
hashes: string[];
|
|
6
11
|
}>;
|
|
7
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* 图片哈希值存储管理类
|
|
15
|
+
* 负责管理和维护回声洞图片的哈希值
|
|
16
|
+
*/
|
|
8
17
|
export declare class HashStorage {
|
|
9
18
|
private readonly caveDir;
|
|
10
19
|
private static readonly HASH_FILE;
|
|
@@ -12,23 +21,75 @@ export declare class HashStorage {
|
|
|
12
21
|
private static readonly BATCH_SIZE;
|
|
13
22
|
private hashes;
|
|
14
23
|
private initialized;
|
|
24
|
+
/**
|
|
25
|
+
* 初始化HashStorage实例
|
|
26
|
+
* @param caveDir 回声洞数据目录路径
|
|
27
|
+
*/
|
|
15
28
|
constructor(caveDir: string);
|
|
16
29
|
private get filePath();
|
|
17
30
|
private get resourceDir();
|
|
18
31
|
private get caveFilePath();
|
|
32
|
+
/**
|
|
33
|
+
* 初始化哈希存储
|
|
34
|
+
* 读取现有哈希数据或重新构建哈希值
|
|
35
|
+
* @throws 初始化失败时抛出错误
|
|
36
|
+
*/
|
|
19
37
|
initialize(): Promise<void>;
|
|
38
|
+
/**
|
|
39
|
+
* 获取当前哈希存储状态
|
|
40
|
+
* @returns 包含最后更新时间和所有条目的状态对象
|
|
41
|
+
*/
|
|
20
42
|
getStatus(): Promise<HashStatus>;
|
|
43
|
+
/**
|
|
44
|
+
* 更新指定回声洞的图片哈希值
|
|
45
|
+
* @param caveId 回声洞ID
|
|
46
|
+
* @param imgBuffers 图片buffer数组
|
|
47
|
+
*/
|
|
21
48
|
updateCaveHash(caveId: number, imgBuffers?: Buffer[]): Promise<void>;
|
|
49
|
+
/**
|
|
50
|
+
* 更新所有回声洞的哈希值
|
|
51
|
+
* @param isInitialBuild 是否为初始构建
|
|
52
|
+
*/
|
|
22
53
|
updateAllCaves(isInitialBuild?: boolean): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* 查找重复的图片
|
|
56
|
+
* @param imgBuffers 待查找的图片buffer数组
|
|
57
|
+
* @param threshold 相似度阈值
|
|
58
|
+
* @returns 匹配结果数组,包含索引、回声洞ID和相似度
|
|
59
|
+
*/
|
|
23
60
|
findDuplicates(imgBuffers: Buffer[], threshold: number): Promise<Array<{
|
|
24
61
|
index: number;
|
|
25
62
|
caveId: number;
|
|
26
63
|
similarity: number;
|
|
27
64
|
} | null>>;
|
|
65
|
+
/**
|
|
66
|
+
* 加载回声洞数据
|
|
67
|
+
* @returns 回声洞数据数组
|
|
68
|
+
* @private
|
|
69
|
+
*/
|
|
28
70
|
private loadCaveData;
|
|
71
|
+
/**
|
|
72
|
+
* 保存哈希数据到文件
|
|
73
|
+
* @private
|
|
74
|
+
*/
|
|
29
75
|
private saveHashes;
|
|
76
|
+
/**
|
|
77
|
+
* 构建初始哈希数据
|
|
78
|
+
* @private
|
|
79
|
+
*/
|
|
30
80
|
private buildInitialHashes;
|
|
81
|
+
/**
|
|
82
|
+
* 更新缺失的哈希值
|
|
83
|
+
* @private
|
|
84
|
+
*/
|
|
31
85
|
private updateMissingHashes;
|
|
86
|
+
/**
|
|
87
|
+
* 批量处理数组项
|
|
88
|
+
* @param items 待处理项数组
|
|
89
|
+
* @param processor 处理函数
|
|
90
|
+
* @param batchSize 批处理大小
|
|
91
|
+
* @private
|
|
92
|
+
*/
|
|
32
93
|
private processBatch;
|
|
33
94
|
}
|
|
34
95
|
export {};
|
|
@@ -1,27 +1,81 @@
|
|
|
1
1
|
import { Buffer } from 'buffer';
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* 图片哈希计算工具类
|
|
4
|
+
* 使用 DCT(离散余弦变换)方法计算图片的感知哈希值,可用于图片相似度比较
|
|
4
5
|
*/
|
|
5
6
|
export declare class ImageHasher {
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
+
* 计算图片的感知哈希值
|
|
9
|
+
* @param imageBuffer - 图片的二进制数据
|
|
10
|
+
* @returns 返回64位的十六进制哈希字符串
|
|
11
|
+
* @throws 当图片处理失败时可能抛出错误
|
|
8
12
|
*/
|
|
9
13
|
static calculateHash(imageBuffer: Buffer): Promise<string>;
|
|
14
|
+
/**
|
|
15
|
+
* 将二进制字符串转换为十六进制
|
|
16
|
+
* @param binary - 二进制字符串
|
|
17
|
+
* @returns 十六进制字符串
|
|
18
|
+
* @private
|
|
19
|
+
*/
|
|
10
20
|
private static binaryToHex;
|
|
21
|
+
/**
|
|
22
|
+
* 将十六进制字符串转换为二进制
|
|
23
|
+
* @param hex - 十六进制字符串
|
|
24
|
+
* @returns 二进制字符串
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
11
27
|
private static hexToBinary;
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
/**
|
|
29
|
+
* 计算图像的DCT(离散余弦变换)
|
|
30
|
+
* @param data - 图像数据
|
|
31
|
+
* @param size - 图像尺寸
|
|
32
|
+
* @returns DCT变换后的矩阵
|
|
33
|
+
* @private
|
|
34
|
+
*/
|
|
35
|
+
private static computeDCT;
|
|
36
|
+
/**
|
|
37
|
+
* 获取DCT系数
|
|
38
|
+
* @param index - 索引值
|
|
39
|
+
* @param size - 矩阵大小
|
|
40
|
+
* @returns DCT系数
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
private static getDCTCoefficient;
|
|
44
|
+
/**
|
|
45
|
+
* 计算数组的中位数
|
|
46
|
+
* @param arr - 输入数组
|
|
47
|
+
* @returns 中位数
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
private static calculateMedian;
|
|
51
|
+
/**
|
|
52
|
+
* 从DCT矩阵中提取特征值
|
|
53
|
+
* @param matrix - DCT矩阵
|
|
54
|
+
* @param size - 矩阵大小
|
|
55
|
+
* @returns 特征值数组
|
|
56
|
+
* @private
|
|
57
|
+
*/
|
|
14
58
|
private static extractFeatures;
|
|
15
59
|
/**
|
|
16
|
-
*
|
|
60
|
+
* 计算两个哈希值之间的汉明距离
|
|
61
|
+
* @param hash1 - 第一个哈希值
|
|
62
|
+
* @param hash2 - 第二个哈希值
|
|
63
|
+
* @returns 汉明距离
|
|
64
|
+
* @throws 当两个哈希值长度不等时抛出错误
|
|
17
65
|
*/
|
|
18
66
|
static calculateDistance(hash1: string, hash2: string): number;
|
|
19
67
|
/**
|
|
20
|
-
*
|
|
68
|
+
* 计算两个图片哈希值的相似度
|
|
69
|
+
* @param hash1 - 第一个哈希值
|
|
70
|
+
* @param hash2 - 第二个哈希值
|
|
71
|
+
* @returns 返回0-1之间的相似度值,1表示完全相同,0表示完全不同
|
|
21
72
|
*/
|
|
22
73
|
static calculateSimilarity(hash1: string, hash2: string): number;
|
|
23
74
|
/**
|
|
24
|
-
*
|
|
75
|
+
* 批量比较一个新哈希值与多个已存在哈希值的相似度
|
|
76
|
+
* @param newHash - 新的哈希值
|
|
77
|
+
* @param existingHashes - 已存在的哈希值数组
|
|
78
|
+
* @returns 相似度数组,每个元素对应一个已存在哈希值的相似度
|
|
25
79
|
*/
|
|
26
80
|
static batchCompareSimilarity(newHash: string, existingHashes: string[]): number[];
|
|
27
81
|
}
|
|
@@ -3,17 +3,61 @@ export declare class FileHandler {
|
|
|
3
3
|
private static readonly RETRY_COUNT;
|
|
4
4
|
private static readonly RETRY_DELAY;
|
|
5
5
|
private static readonly CONCURRENCY_LIMIT;
|
|
6
|
+
/**
|
|
7
|
+
* 并发控制
|
|
8
|
+
* @param operation 要执行的操作
|
|
9
|
+
* @param limit 并发限制
|
|
10
|
+
* @returns 操作结果
|
|
11
|
+
*/
|
|
6
12
|
private static withConcurrencyLimit;
|
|
13
|
+
/**
|
|
14
|
+
* 文件操作包装器
|
|
15
|
+
* @param filePath 文件路径
|
|
16
|
+
* @param operation 要执行的操作
|
|
17
|
+
* @returns 操作结果
|
|
18
|
+
*/
|
|
7
19
|
private static withFileOp;
|
|
20
|
+
/**
|
|
21
|
+
* 事务处理
|
|
22
|
+
* @param operations 要执行的操作数组
|
|
23
|
+
* @returns 操作结果数组
|
|
24
|
+
*/
|
|
8
25
|
static withTransaction<T>(operations: Array<{
|
|
9
26
|
filePath: string;
|
|
10
27
|
operation: () => Promise<T>;
|
|
11
28
|
rollback?: () => Promise<void>;
|
|
12
29
|
}>): Promise<T[]>;
|
|
30
|
+
/**
|
|
31
|
+
* 读取 JSON 数据
|
|
32
|
+
* @param filePath 文件路径
|
|
33
|
+
* @returns JSON 数据
|
|
34
|
+
*/
|
|
13
35
|
static readJsonData<T>(filePath: string): Promise<T[]>;
|
|
36
|
+
/**
|
|
37
|
+
* 写入 JSON 数据
|
|
38
|
+
* @param filePath 文件路径
|
|
39
|
+
* @param data 要写入的数据
|
|
40
|
+
*/
|
|
14
41
|
static writeJsonData<T>(filePath: string, data: T[]): Promise<void>;
|
|
42
|
+
/**
|
|
43
|
+
* 确保目录存在
|
|
44
|
+
* @param dir 目录路径
|
|
45
|
+
*/
|
|
15
46
|
static ensureDirectory(dir: string): Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* 确保 JSON 文件存在
|
|
49
|
+
* @param filePath 文件路径
|
|
50
|
+
*/
|
|
16
51
|
static ensureJsonFile(filePath: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* 保存媒体文件
|
|
54
|
+
* @param filePath 文件路径
|
|
55
|
+
* @param data 文件数据
|
|
56
|
+
*/
|
|
17
57
|
static saveMediaFile(filePath: string, data: Buffer | string): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* 删除媒体文件
|
|
60
|
+
* @param filePath 文件路径
|
|
61
|
+
*/
|
|
18
62
|
static deleteMediaFile(filePath: string): Promise<void>;
|
|
19
63
|
}
|
package/lib/utils/idManager.d.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ID管理器类
|
|
3
|
+
* 负责管理回声洞ID的分配、删除和统计信息
|
|
4
|
+
*/
|
|
1
5
|
export declare class IdManager {
|
|
2
6
|
private deletedIds;
|
|
3
7
|
private maxId;
|
|
@@ -5,13 +9,61 @@ export declare class IdManager {
|
|
|
5
9
|
private readonly statusFilePath;
|
|
6
10
|
private stats;
|
|
7
11
|
private usedIds;
|
|
12
|
+
/**
|
|
13
|
+
* 初始化ID管理器
|
|
14
|
+
* @param baseDir - 基础目录路径
|
|
15
|
+
*/
|
|
8
16
|
constructor(baseDir: string);
|
|
17
|
+
/**
|
|
18
|
+
* 初始化ID管理系统
|
|
19
|
+
* @param caveFilePath - 正式回声洞数据文件路径
|
|
20
|
+
* @param pendingFilePath - 待处理回声洞数据文件路径
|
|
21
|
+
* @throws 当初始化失败时抛出错误
|
|
22
|
+
*/
|
|
9
23
|
initialize(caveFilePath: string, pendingFilePath: string): Promise<void>;
|
|
24
|
+
/**
|
|
25
|
+
* 处理ID冲突
|
|
26
|
+
* @param conflicts - ID冲突映射表
|
|
27
|
+
* @param caveFilePath - 正式回声洞数据文件路径
|
|
28
|
+
* @param pendingFilePath - 待处理回声洞数据文件路径
|
|
29
|
+
* @param caveData - 正式回声洞数据
|
|
30
|
+
* @param pendingData - 待处理回声洞数据
|
|
31
|
+
* @private
|
|
32
|
+
*/
|
|
10
33
|
private handleConflicts;
|
|
34
|
+
/**
|
|
35
|
+
* 获取下一个可用的ID
|
|
36
|
+
* @returns 下一个可用的ID
|
|
37
|
+
* @throws 当ID管理器未初始化时抛出错误
|
|
38
|
+
*/
|
|
11
39
|
getNextId(): number;
|
|
40
|
+
/**
|
|
41
|
+
* 标记ID为已删除状态
|
|
42
|
+
* @param id - 要标记为删除的ID
|
|
43
|
+
* @throws 当ID管理器未初始化时抛出错误
|
|
44
|
+
*/
|
|
12
45
|
markDeleted(id: number): Promise<void>;
|
|
46
|
+
/**
|
|
47
|
+
* 添加贡献统计
|
|
48
|
+
* @param contributorNumber - 贡献者编号
|
|
49
|
+
* @param caveId - 回声洞ID
|
|
50
|
+
*/
|
|
13
51
|
addStat(contributorNumber: string, caveId: number): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* 移除贡献统计
|
|
54
|
+
* @param contributorNumber - 贡献者编号
|
|
55
|
+
* @param caveId - 回声洞ID
|
|
56
|
+
*/
|
|
14
57
|
removeStat(contributorNumber: string, caveId: number): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* 获取所有贡献统计信息
|
|
60
|
+
* @returns 贡献者编号到回声洞ID列表的映射
|
|
61
|
+
*/
|
|
15
62
|
getStats(): Record<string, number[]>;
|
|
63
|
+
/**
|
|
64
|
+
* 保存当前状态到文件
|
|
65
|
+
* @private
|
|
66
|
+
* @throws 当保存失败时抛出错误
|
|
67
|
+
*/
|
|
16
68
|
private saveStatus;
|
|
17
69
|
}
|