koishi-plugin-best-cave
Version:
功能强大、高度可定制的回声洞插件。支持丰富的媒体类型、内容查重、AI分析、人工审核、用户昵称、数据迁移以及本地/S3 双重文件存储后端。
1,156 lines (1,148 loc) • 92.2 kB
JavaScript
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
var __export = (target, all) => {
for (var name2 in all)
__defProp(target, name2, { get: all[name2], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
// If the importer is in node compatibility mode or this is not an ESM
// file that has been converted to a CommonJS file using a Babel-
// compatible transform (i.e. "__esModule" has not been set), then set
// "default" to the CommonJS "module.exports" for node compatibility.
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
mod
));
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
// src/index.ts
var src_exports = {};
__export(src_exports, {
Config: () => Config,
apply: () => apply,
inject: () => inject,
name: () => name,
usage: () => usage
});
module.exports = __toCommonJS(src_exports);
var import_koishi3 = require("koishi");
// src/FileManager.ts
var import_client_s3 = require("@aws-sdk/client-s3");
var fs = __toESM(require("fs/promises"));
var path = __toESM(require("path"));
var FileManager = class {
/**
* @constructor
* @param baseDir Koishi 应用的基础数据目录 (ctx.baseDir)。
* @param config 插件的配置对象。
* @param logger 日志记录器实例。
*/
constructor(baseDir, config, logger2) {
this.logger = logger2;
this.resourceDir = path.join(baseDir, "data", "cave");
if (config.enableS3 && config.endpoint && config.bucket && config.accessKeyId && config.secretAccessKey) {
this.s3Client = new import_client_s3.S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey
}
});
this.s3Bucket = config.bucket;
}
}
static {
__name(this, "FileManager");
}
resourceDir;
locks = /* @__PURE__ */ new Map();
s3Client;
s3Bucket;
/**
* @description 使用文件锁安全地执行异步文件操作,防止并发读写冲突。
* @template T 异步操作的返回类型。
* @param fullPath 需要加锁的文件的完整路径。
* @param operation 要执行的异步函数。
* @returns 异步操作的结果。
*/
async withLock(fullPath, operation) {
while (this.locks.has(fullPath)) await this.locks.get(fullPath);
const promise = operation().finally(() => {
this.locks.delete(fullPath);
});
this.locks.set(fullPath, promise);
return promise;
}
/**
* @description 保存文件,自动选择 S3 或本地存储。
* @param fileName 用作 S3 Key 或本地文件名。
* @param data 要写入的 Buffer 数据。
* @returns 保存时使用的文件名。
*/
async saveFile(fileName, data) {
if (this.s3Client) {
const command = new import_client_s3.PutObjectCommand({
Bucket: this.s3Bucket,
Key: fileName,
Body: data,
ACL: "public-read"
});
await this.s3Client.send(command);
} else {
await fs.mkdir(this.resourceDir, { recursive: true }).catch((error) => {
this.logger.error(`创建资源目录失败 ${this.resourceDir}:`, error);
throw error;
});
const filePath = path.join(this.resourceDir, fileName);
await this.withLock(filePath, () => fs.writeFile(filePath, data));
}
return fileName;
}
/**
* @description 读取文件,自动从 S3 或本地存储读取。
* @param fileName 要读取的文件名/标识符。
* @returns 文件的 Buffer 数据。
*/
async readFile(fileName) {
if (this.s3Client) {
const command = new import_client_s3.GetObjectCommand({ Bucket: this.s3Bucket, Key: fileName });
const response = await this.s3Client.send(command);
return Buffer.from(await response.Body.transformToByteArray());
} else {
const filePath = path.join(this.resourceDir, fileName);
return this.withLock(filePath, () => fs.readFile(filePath));
}
}
/**
* @description 删除文件,自动从 S3 或本地删除。
* @param fileIdentifier 要删除的文件名/标识符。
*/
async deleteFile(fileIdentifier) {
try {
if (this.s3Client) {
await this.s3Client.send(new import_client_s3.DeleteObjectCommand({ Bucket: this.s3Bucket, Key: fileIdentifier }));
} else {
const filePath = path.join(this.resourceDir, fileIdentifier);
await this.withLock(filePath, () => fs.unlink(filePath));
}
} catch (error) {
if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
this.logger.warn(`删除文件 ${fileIdentifier} 失败:`, error);
}
}
}
};
// src/NameManager.ts
var NameManager = class {
/**
* @constructor
* @param ctx - Koishi 上下文,用于初始化数据库模型。
*/
constructor(ctx) {
this.ctx = ctx;
this.ctx.model.extend("cave_user", {
userId: "string",
nickname: "string"
}, {
primary: "userId"
});
}
static {
__name(this, "NameManager");
}
/**
* @description 注册 `.name` 子命令,用于管理用户昵称。
* @param cave - 主 `cave` 命令实例。
*/
registerCommands(cave) {
cave.subcommand(".name [nickname:text]", "设置显示昵称").usage("设置在回声洞中显示的昵称。若不提供昵称,则清除现有昵称。").action(async ({ session }, nickname) => {
const trimmedNickname = nickname?.trim();
if (trimmedNickname) {
await this.setNickname(session.userId, trimmedNickname);
return `昵称已更新为:${trimmedNickname}`;
}
await this.clearNickname(session.userId);
return "昵称已清除";
});
}
/**
* @description 设置或更新指定用户的昵称。
* @param userId - 目标用户的 ID。
* @param nickname - 要设置的新昵称。
*/
async setNickname(userId, nickname) {
await this.ctx.database.upsert("cave_user", [{ userId, nickname }]);
}
/**
* @description 获取指定用户的昵称。
* @param userId - 目标用户的 ID。
* @returns 用户的昵称字符串或 null。
*/
async getNickname(userId) {
const [name2] = await this.ctx.database.get("cave_user", { userId });
return name2?.nickname ?? null;
}
/**
* @description 清除指定用户的昵称设置。
* @param userId - 目标用户的 ID。
*/
async clearNickname(userId) {
await this.ctx.database.remove("cave_user", { userId });
}
};
// src/DataManager.ts
var DataManager = class {
/**
* @constructor
* @param ctx Koishi 上下文,用于数据库操作。
* @param config 插件配置。
* @param fileManager 文件管理器实例。
* @param logger 日志记录器实例。
*/
constructor(ctx, config, fileManager, logger2) {
this.ctx = ctx;
this.config = config;
this.fileManager = fileManager;
this.logger = logger2;
}
static {
__name(this, "DataManager");
}
/**
* @description 注册 `.export` 和 `.import` 子命令。
* @param cave - 主 `cave` 命令实例。
*/
registerCommands(cave) {
const commandAction = /* @__PURE__ */ __name((action) => async ({ session }) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
try {
await session.send("正在处理,请稍候...");
return await action();
} catch (error) {
this.logger.error("数据操作时发生错误:", error);
return `操作失败: ${error.message}`;
}
}, "commandAction");
cave.subcommand(".export", "导出回声洞数据", { hidden: true, authority: 4 }).usage("将所有回声洞数据导出到 cave.json 中。").action(commandAction(() => this.exportData()));
cave.subcommand(".import", "导入回声洞数据", { hidden: true, authority: 4 }).usage("从 cave.json 中导入回声洞数据。").action(commandAction(() => this.importData()));
}
/**
* @description 导出所有 'active' 状态的回声洞数据到 `cave.json`。
* @returns 描述导出结果的消息字符串。
*/
async exportData() {
const fileName = "cave.json";
const cavesToExport = await this.ctx.database.get("cave", { status: "active" });
await this.fileManager.saveFile(fileName, Buffer.from(JSON.stringify(cavesToExport, null, 2)));
return `成功导出 ${cavesToExport.length} 条数据`;
}
/**
* @description 从 `cave.json` 文件导入回声洞数据。
* @returns 描述导入结果的消息字符串。
*/
async importData() {
const fileName = "cave.json";
let importedCaves;
try {
const fileContent = await this.fileManager.readFile(fileName);
importedCaves = JSON.parse(fileContent.toString("utf-8"));
if (!Array.isArray(importedCaves) || !importedCaves.length) throw new Error("导入文件格式无效或为空");
} catch (error) {
throw new Error(`读取导入文件失败: ${error.message}`);
}
const allDbCaves = await this.ctx.database.get("cave", {}, { fields: ["id"] });
const existingIds = new Set(allDbCaves.map((c) => c.id));
let maxId = allDbCaves.length > 0 ? Math.max(...allDbCaves.map((c) => c.id)) : 0;
const nonConflictingCaves = [];
const conflictingCaves = [];
let invalidCount = 0;
for (const importedCave of importedCaves) {
if (typeof importedCave.id !== "number" || !Array.isArray(importedCave.elements)) {
this.logger.warn(`回声洞(${importedCave.id})无效: ${JSON.stringify(importedCave)}`);
invalidCount++;
continue;
}
if (existingIds.has(importedCave.id)) {
conflictingCaves.push(importedCave);
} else {
nonConflictingCaves.push({ ...importedCave, status: "active" });
existingIds.add(importedCave.id);
maxId = Math.max(maxId, importedCave.id);
}
}
const newCavesFromConflicts = conflictingCaves.map((cave) => {
maxId++;
this.logger.info(`回声洞(${cave.id})已转移至(${maxId})`);
return { ...cave, id: maxId, status: "active" };
});
const finalCavesToUpsert = [...nonConflictingCaves, ...newCavesFromConflicts];
if (finalCavesToUpsert.length > 0) await this.ctx.database.upsert("cave", finalCavesToUpsert);
return `成功导入 ${finalCavesToUpsert.length} 条数据`;
}
};
// src/PendManager.ts
var import_koishi2 = require("koishi");
// src/Utils.ts
var import_koishi = require("koishi");
var path2 = __toESM(require("path"));
var mimeTypeMap = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".mp4": "video/mp4", ".mp3": "audio/mpeg", ".webp": "image/webp" };
async function buildCaveMessage(cave, config, fileManager, logger2, platform, prefix) {
async function transformToH(elements) {
return Promise.all(elements.map(async (el) => {
if (el.type === "text") return import_koishi.h.text(el.content);
if (el.type === "at") return (0, import_koishi.h)("at", { id: el.content });
if (el.type === "reply") return (0, import_koishi.h)("reply", { id: el.content });
if (el.type === "face") return (0, import_koishi.h)("face", { id: el.content });
if (el.type === "forward") {
try {
const forwardNodes = Array.isArray(el.content) ? el.content : [];
const messageNodes = await Promise.all(forwardNodes.map(async (node) => {
const author = (0, import_koishi.h)("author", { id: node.userId, name: node.userName });
const contentElements = await transformToH(node.elements);
const unwrappedContent = [];
const nestedMessageNodes = [];
for (const contentEl of contentElements) {
if (contentEl.type === "message" && contentEl.attrs.forward) {
nestedMessageNodes.push(...contentEl.children);
} else {
unwrappedContent.push(contentEl);
}
}
const resultNodes = [];
if (unwrappedContent.length > 0) resultNodes.push((0, import_koishi.h)("message", {}, [author, ...unwrappedContent]));
resultNodes.push(...nestedMessageNodes);
return resultNodes;
}));
return (0, import_koishi.h)("message", { forward: true }, messageNodes.flat());
} catch (error) {
logger2.warn(`解析回声洞(${cave.id})合并转发内容失败:`, error);
return import_koishi.h.text("[合并转发]");
}
}
if (["image", "video", "audio", "file"].includes(el.type)) {
const fileName = el.file;
if (!fileName) return (0, import_koishi.h)("p", {}, `[${el.type}]`);
if (config.enableS3 && config.publicUrl) return (0, import_koishi.h)(el.type, { ...el, src: new URL(fileName, config.publicUrl).href });
if (config.localPath) return (0, import_koishi.h)(el.type, { ...el, src: `file://${path2.join(config.localPath, fileName)}` });
try {
const data2 = await fileManager.readFile(fileName);
const mimeType = mimeTypeMap[path2.extname(fileName).toLowerCase()] || "application/octet-stream";
return (0, import_koishi.h)(el.type, { ...el, src: `data:${mimeType};base64,${data2.toString("base64")}` });
} catch (error) {
logger2.warn(`转换文件 ${fileName} 为 Base64 失败:`, error);
return (0, import_koishi.h)("p", {}, `[${el.type}]`);
}
}
return null;
})).then((hElements) => hElements.flat().filter(Boolean));
}
__name(transformToH, "transformToH");
const caveHElements = await transformToH(cave.elements);
const data = {
id: cave.id.toString(),
name: cave.userName,
user: cave.userId,
channel: cave.channelId,
time: cave.time.toLocaleString()
};
const placeholderRegex = /\{([^}]+)\}/g;
const replacer = /* @__PURE__ */ __name((match, rawContent) => {
const isReviewMode = !!prefix;
const [normalPart, reviewPart] = rawContent.split("/", 2);
const contentToProcess = isReviewMode ? reviewPart !== void 0 ? reviewPart : normalPart : normalPart;
if (!contentToProcess?.trim()) return "";
const useMask = contentToProcess.startsWith("*");
const key = (useMask ? contentToProcess.substring(1) : contentToProcess).trim();
if (!key) return "";
const originalValue = data[key];
if (originalValue === void 0 || originalValue === null) return match;
const valueStr = String(originalValue);
if (!useMask) return valueStr;
const len = valueStr.length;
if (len <= 5) return valueStr;
let keep = 0;
if (len <= 7) keep = 2;
else keep = 3;
return `${valueStr.substring(0, keep)}***${valueStr.substring(len - keep)}`;
}, "replacer");
const [rawHeader, rawFooter] = config.caveFormat.split("|", 2);
let header = rawHeader ? rawHeader.replace(placeholderRegex, replacer).trim() : "";
if (prefix) header = `${prefix}${header}`;
const footer = rawFooter ? rawFooter.replace(placeholderRegex, replacer).trim() : "";
const problematicTypes = ["video", "audio", "file", "forward"];
const placeholderMap = { video: "[视频]", audio: "[音频]", file: "[文件]", forward: "[合并转发]" };
const containsProblematic = platform === "onebot" && caveHElements.some((el) => problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward);
if (!containsProblematic) {
const finalMessage = [];
if (header) finalMessage.push(header + "\n");
finalMessage.push(...caveHElements);
if (footer) finalMessage.push("\n" + footer);
return [finalMessage.length > 0 ? finalMessage : []];
}
const initialMessageContent = [];
const followUpMessages = [];
for (const el of caveHElements) {
if (problematicTypes.includes(el.type) || el.type === "message" && el.attrs.forward) {
const placeholderKey = el.type === "message" && el.attrs.forward ? "forward" : el.type;
initialMessageContent.push(import_koishi.h.text(placeholderMap[placeholderKey]));
followUpMessages.push([el]);
} else {
initialMessageContent.push(el);
}
}
const finalInitialMessage = [];
if (header) finalInitialMessage.push(header + "\n");
finalInitialMessage.push(...initialMessageContent);
if (footer) finalInitialMessage.push("\n" + footer);
return [finalInitialMessage, ...followUpMessages].filter((msg) => msg.length > 0);
}
__name(buildCaveMessage, "buildCaveMessage");
async function getNextCaveId(ctx, reusableIds) {
for (const id of reusableIds) {
if (id > 0) {
reusableIds.delete(id);
return id;
}
}
if (reusableIds.has(0)) {
reusableIds.delete(0);
const [lastCave] = await ctx.database.get("cave", {}, { sort: { id: "desc" }, limit: 1 });
const newId2 = (lastCave?.id || 0) + 1;
reusableIds.add(0);
return newId2;
}
const allCaveIds = (await ctx.database.get("cave", {}, { fields: ["id"] })).map((c) => c.id);
const existingIds = new Set(allCaveIds);
let newId = 1;
while (existingIds.has(newId)) newId++;
if (existingIds.size === (allCaveIds.length > 0 ? Math.max(...allCaveIds) : 0)) reusableIds.add(0);
return newId;
}
__name(getNextCaveId, "getNextCaveId");
async function processMessageElements(sourceElements, newId, session, creationTime) {
const mediaToSave = [];
const urlToFileMap = /* @__PURE__ */ new Map();
let mediaIndex = 0;
const typeMap = { "img": "image", "image": "image", "video": "video", "audio": "audio", "file": "file", "text": "text", "at": "at", "forward": "forward", "reply": "reply", "face": "face" };
const defaultExtMap = { "image": ".jpg", "video": ".mp4", "audio": ".mp3", "file": ".dat" };
async function transform(elements) {
const result = [];
async function processForwardContent(segments) {
const innerResult = [];
for (const segment of segments) {
const sType = typeMap[segment.type];
if (!sType) continue;
if (sType === "text" && segment.data?.text?.trim()) {
innerResult.push({ type: "text", content: segment.data.text.trim() });
} else if (sType === "at" && (segment.data?.id || segment.data?.qq)) {
innerResult.push({ type: "at", content: segment.data.id || segment.data.qq });
} else if (sType === "reply" && segment.data?.id) {
innerResult.push({ type: "reply", content: segment.data.id });
} else if (["image", "video", "audio", "file"].includes(sType) && (segment.data?.src || segment.data?.url)) {
let fileIdentifier = segment.data.src || segment.data.url;
if (fileIdentifier.startsWith("http")) {
if (urlToFileMap.has(fileIdentifier)) {
fileIdentifier = urlToFileMap.get(fileIdentifier);
} else {
const ext = path2.extname(segment.data.file || "") || defaultExtMap[sType];
const currentMediaIndex = ++mediaIndex;
const newFileName = `${newId}_${currentMediaIndex}_${session.channelId || session.guildId}_${session.userId}_${creationTime.getTime()}${ext}`;
mediaToSave.push({ sourceUrl: fileIdentifier, fileName: newFileName });
urlToFileMap.set(fileIdentifier, newFileName);
fileIdentifier = newFileName;
}
}
innerResult.push({ type: sType, file: fileIdentifier });
} else if (sType === "forward" && Array.isArray(segment.data?.content)) {
const nestedForwardNodes = [];
for (const nestedNode of segment.data.content) {
if (!nestedNode.message || !Array.isArray(nestedNode.message)) continue;
const nestedContentElements = await processForwardContent(nestedNode.message);
if (nestedContentElements.length > 0) {
nestedForwardNodes.push({ userId: nestedNode.sender?.user_id, userName: nestedNode.sender?.nickname, elements: nestedContentElements });
}
}
if (nestedForwardNodes.length > 0) innerResult.push({ type: "forward", content: nestedForwardNodes });
}
}
return innerResult;
}
__name(processForwardContent, "processForwardContent");
for (const el of elements) {
const type = typeMap[el.type];
if (!type) {
if (el.children) result.push(...await transform(el.children));
continue;
}
if (type === "text" && el.attrs.content?.trim()) {
result.push({ type: "text", content: el.attrs.content.trim() });
} else if (type === "at" && el.attrs.id) {
result.push({ type: "at", content: el.attrs.id });
} else if (type === "reply" && el.attrs.id) {
result.push({ type: "reply", content: el.attrs.id });
} else if (type === "forward" && Array.isArray(el.attrs.content)) {
const forwardNodes = [];
for (const node of el.attrs.content) {
if (!node.message || !Array.isArray(node.message)) continue;
const contentElements = await processForwardContent(node.message);
if (contentElements.length > 0) {
forwardNodes.push({ userId: node.sender?.user_id, userName: node.sender?.nickname, elements: contentElements });
}
}
if (forwardNodes.length > 0) result.push({ type: "forward", content: forwardNodes });
} else if (["image", "video", "audio", "file"].includes(type) && el.attrs.src) {
let fileIdentifier = el.attrs.src;
if (fileIdentifier.startsWith("http")) {
if (urlToFileMap.has(fileIdentifier)) {
fileIdentifier = urlToFileMap.get(fileIdentifier);
} else {
const ext = path2.extname(el.attrs.file || "") || defaultExtMap[type];
const currentMediaIndex = ++mediaIndex;
const newFileName = `${newId}-${currentMediaIndex}_${session.channelId}-${session.userId}_${creationTime.getTime()}${ext}`;
mediaToSave.push({ sourceUrl: fileIdentifier, fileName: newFileName });
urlToFileMap.set(fileIdentifier, newFileName);
fileIdentifier = newFileName;
}
}
result.push({ type, file: fileIdentifier });
} else if (type === "face" && el.attrs.id) {
result.push({ type: "face", content: el.attrs.id });
}
}
return result;
}
__name(transform, "transform");
const finalElementsForDb = await transform(sourceElements);
return { finalElementsForDb, mediaToSave };
}
__name(processMessageElements, "processMessageElements");
function clusterItemsFromPairs(pairs) {
const parent = /* @__PURE__ */ new Map();
const allIds = /* @__PURE__ */ new Set();
const find = /* @__PURE__ */ __name((i) => {
if (!parent.has(i)) {
parent.set(i, i);
return i;
}
if (parent.get(i) === i) return i;
const root = find(parent.get(i));
parent.set(i, root);
return root;
}, "find");
const union = /* @__PURE__ */ __name((i, j) => {
const rootI = find(i);
const rootJ = find(j);
if (rootI !== rootJ) parent.set(rootI, rootJ);
}, "union");
for (const [id1, id2] of pairs) {
union(id1, id2);
allIds.add(id1);
allIds.add(id2);
}
if (allIds.size === 0) return [];
const clusterMap = /* @__PURE__ */ new Map();
allIds.forEach((id) => {
const root = find(id);
if (!clusterMap.has(root)) clusterMap.set(root, []);
clusterMap.get(root).push(id);
});
return Array.from(clusterMap.values()).filter((c) => c.length > 1);
}
__name(clusterItemsFromPairs, "clusterItemsFromPairs");
function generateFromLSH(items, getBucketInfo) {
const buckets = /* @__PURE__ */ new Map();
items.forEach((item) => {
const { id, keys } = getBucketInfo(item);
if (!id || !keys || keys.length === 0) return;
keys.forEach((key) => {
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key).push(id);
});
});
const candidatePairs = /* @__PURE__ */ new Set();
for (const ids of buckets.values()) {
if (ids.length < 2) continue;
const uniqueIds = [...new Set(ids)].sort((a, b) => a - b);
if (uniqueIds.length < 2) continue;
for (let i = 0; i < uniqueIds.length; i++) {
for (let j = i + 1; j < uniqueIds.length; j++) {
const pairKey = `${uniqueIds[i]}-${uniqueIds[j]}`;
candidatePairs.add(pairKey);
}
}
}
return candidatePairs;
}
__name(generateFromLSH, "generateFromLSH");
async function processNewCave(ctx, config, fileManager, logger2, reusableIds, newCave, session, mediaToSave, hashManager, aiManager, reviewManager) {
const newId = newCave.id;
try {
const initialDownloads = [];
if (mediaToSave.length > 0) {
const downloadPromises = mediaToSave.map(async (media) => {
const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 6e4 }));
return { candidateFile: media.fileName, buffer };
});
initialDownloads.push(...await Promise.all(downloadPromises));
}
const mediaForProcessing = [];
const fileRemapping = /* @__PURE__ */ new Map();
const canonicalFilesToSave = /* @__PURE__ */ new Map();
if (hashManager) {
const hashToCanonicalFile = /* @__PURE__ */ new Map();
const hashableExtensions = [".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".gif"];
const sanitizableExtensions = [".png", ".jpg", ".jpeg", ".webp", ".gif"];
for (const { candidateFile, buffer } of initialDownloads) {
const ext = path2.extname(candidateFile).toLowerCase();
let finalBuffer = buffer;
let hash;
if (hashableExtensions.includes(ext)) {
try {
hash = await hashManager.generatePHash(finalBuffer);
} catch {
if (sanitizableExtensions.includes(ext)) {
try {
const sanitized = hashManager.sanitizeImageBuffer(finalBuffer);
if (!sanitized.equals(finalBuffer)) {
finalBuffer = sanitized;
hash = await hashManager.generatePHash(finalBuffer);
}
} catch (e) {
logger2.warn(`图片修复失败 (${candidateFile}): ${e.message}`);
}
}
}
}
if (hash && hashToCanonicalFile.has(hash)) {
fileRemapping.set(candidateFile, hashToCanonicalFile.get(hash));
continue;
}
if (hash) hashToCanonicalFile.set(hash, candidateFile);
canonicalFilesToSave.set(candidateFile, finalBuffer);
fileRemapping.set(candidateFile, candidateFile);
mediaForProcessing.push({ fileName: candidateFile, buffer: finalBuffer, hash });
}
newCave.elements.forEach((el) => {
if (el.file && fileRemapping.has(el.file)) el.file = fileRemapping.get(el.file);
});
} else {
initialDownloads.forEach((d) => {
canonicalFilesToSave.set(d.candidateFile, d.buffer);
mediaForProcessing.push({ fileName: d.candidateFile, buffer: d.buffer });
});
}
let textHashesToStore = [];
let imageHashesToStore = [];
if (config.enableSimilarity && hashManager) {
try {
const combinedText = newCave.elements.filter((el) => el.type === "text" && typeof el.content === "string").map((el) => el.content).join(" ");
if (combinedText) {
const newSimhash = hashManager.generateTextSimhash(combinedText);
if (newSimhash) {
const existingTextHashes = await ctx.database.get("cave_hash", { type: "text" });
for (const existing of existingTextHashes) {
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
if (similarity >= config.textThreshold) {
await session.send(`回声洞(${newId})添加失败:文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
return;
}
}
textHashesToStore.push({ hash: newSimhash, type: "text" });
}
}
const imagesWithHash = mediaForProcessing.filter((m) => m.hash !== void 0);
if (imagesWithHash.length > 0) {
const dbImageHashes = await ctx.database.get("cave_hash", { type: "image" });
const newImageHashes = /* @__PURE__ */ new Set();
for (const media of imagesWithHash) {
const imageHash = media.hash;
if (newImageHashes.has(imageHash)) continue;
for (const existing of dbImageHashes) {
const similarity = hashManager.calculateSimilarity(imageHash, existing.hash);
if (similarity >= config.imageThreshold) {
await session.send(`回声洞(${newId})添加失败:图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
return;
}
}
newImageHashes.add(imageHash);
}
imageHashesToStore = Array.from(newImageHashes).map((hash) => ({ hash, type: "image" }));
}
} catch (error) {
logger2.warn("相似度比较失败:", error);
}
}
if (canonicalFilesToSave.size > 0) await Promise.all(Array.from(canonicalFilesToSave.entries()).map(([fileName, buffer]) => fileManager.saveFile(fileName, buffer)));
let analysisResult;
if (config.enableAI && aiManager) {
const analyses = await aiManager.analyze([newCave], mediaForProcessing);
if (analyses.length > 0) {
analysisResult = analyses[0];
await ctx.database.upsert("cave_meta", analyses);
const duplicateIds = await aiManager.checkForDuplicates(analysisResult, newCave);
if (duplicateIds?.length > 0) {
await session.send(`回声洞(${newId})添加失败:内容与回声洞(${duplicateIds.join("|")})重复`);
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
return;
}
}
}
if (config.enableSimilarity && hashManager) {
const allHashesToInsert = [...textHashesToStore, ...imageHashesToStore].map((h4) => ({ ...h4, cave: newCave.id }));
if (allHashesToInsert.length > 0) await ctx.database.upsert("cave_hash", allHashesToInsert);
}
let finalStatus = "active";
const needsManualReview = config.enablePend && session.cid !== config.adminChannel;
if (needsManualReview) {
if (config.enableAI && config.enableApprove && analysisResult) {
if (analysisResult.rating >= config.approveThreshold) {
finalStatus = "active";
} else if (config.onAIReviewFail) {
finalStatus = "pending";
} else {
await session.send(`回声洞(${newId})添加失败:AI 审核未通过 (评分: ${analysisResult.rating})`);
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
return;
}
} else {
finalStatus = "pending";
}
}
await ctx.database.upsert("cave", [{ id: newId, status: finalStatus, elements: newCave.elements }]);
if (finalStatus === "pending" && reviewManager) reviewManager.sendForPend({ ...newCave, status: finalStatus });
} catch (error) {
logger2.error(`回声洞(${newId})处理失败:`, error);
await ctx.database.upsert("cave", [{ id: newId, status: "delete" }]);
await session.send(`回声洞(${newId})处理失败: ${error.message}`);
}
}
__name(processNewCave, "processNewCave");
// src/PendManager.ts
var PendManager = class {
/**
* @param ctx Koishi 上下文。
* @param config 插件配置。
* @param fileManager 文件管理器实例。
* @param logger 日志记录器实例。
* @param reusableIds 可复用 ID 的内存缓存。
*/
constructor(ctx, config, fileManager, logger2) {
this.ctx = ctx;
this.config = config;
this.fileManager = fileManager;
this.logger = logger2;
}
static {
__name(this, "PendManager");
}
/**
* @description 注册与审核相关的子命令。
* @param cave - 主 `cave` 命令实例。
*/
registerCommands(cave) {
const pend = cave.subcommand(".pend [id:posint]", "审核回声洞", { hidden: true }).usage("查询待审核的回声洞列表,或指定 ID 查看对应待审核的回声洞。").action(async ({ session }, id) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
if (id) {
const [targetCave] = await this.ctx.database.get("cave", { id });
if (!targetCave) return `回声洞(${id})不存在`;
if (targetCave.status !== "pending") return `回声洞(${id})无需审核`;
const caveMessages = await buildCaveMessage(targetCave, this.config, this.fileManager, this.logger, session.platform, "待审核");
for (const message of caveMessages) if (message.length > 0) await session.send(import_koishi2.h.normalize(message));
return;
}
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
if (!pendingCaves.length) return "当前没有需要审核的回声洞";
return `当前共有 ${pendingCaves.length} 条待审核回声洞,序号为:
${pendingCaves.map((c) => c.id).join("|")}`;
});
const createPendAction = /* @__PURE__ */ __name((actionType) => async ({ session }, ...ids) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
let idsToProcess = ids;
if (idsToProcess.length === 0) {
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" }, { fields: ["id"] });
if (!pendingCaves.length) return "当前没有需要审核的回声洞";
idsToProcess = pendingCaves.map((c) => c.id);
}
try {
const targetStatus = actionType === "approve" ? "active" : "delete";
const actionText = actionType === "approve" ? "通过" : "拒绝";
const cavesToProcess = await this.ctx.database.get("cave", { id: { $in: idsToProcess }, status: "pending" });
if (cavesToProcess.length === 0) return `回声洞(${idsToProcess.join("|")})无需审核或不存在`;
const processedIds = cavesToProcess.map((cave2) => cave2.id);
await this.ctx.database.upsert("cave", processedIds.map((id) => ({ id, status: targetStatus })));
return `已${actionText}回声洞(${processedIds.join("|")})`;
} catch (error) {
this.logger.error(`审核操作失败:`, error);
return `操作失败: ${error.message}`;
}
}, "createPendAction");
pend.subcommand(".Y [...ids:posint]", "通过审核").usage("通过一个或多个指定 ID 的回声洞审核。若不指定 ID,则通过所有待审核的回声洞。").action(createPendAction("approve"));
pend.subcommand(".N [...ids:posint]", "拒绝审核").usage("拒绝一个或多个指定 ID 的回声洞审核。若不指定 ID,则拒绝所有待审核的回声洞。").action(createPendAction("reject"));
if (this.config.enableAI) {
pend.subcommand(".A <threshold:number>", "自动通过审核").usage("根据评分自动通过不小于指定阈值的回声洞。默认使用配置中的阈值。").action(async ({ session }, threshold) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
const finalThreshold = threshold ?? this.config.approveThreshold;
try {
const pendingCaves = await this.ctx.database.get("cave", { status: "pending" });
if (pendingCaves.length === 0) return "当前没有需要审核的回声洞";
const pendingCaveIds = pendingCaves.map((c) => c.id);
const pendingMeta = await this.ctx.database.get("cave_meta", { cave: { $in: pendingCaveIds } });
const idsToApprove = pendingMeta.filter((meta) => meta.rating >= finalThreshold).map((meta) => meta.cave);
if (idsToApprove.length === 0) return `没有找到评分不小于 ${finalThreshold} 的待审核回声洞`;
await this.ctx.database.upsert("cave", idsToApprove.map((id) => ({ id, status: "active" })));
return `已自动通过回声洞(${idsToApprove.join("|")})`;
} catch (error) {
this.logger.error("自动审核操作失败:", error);
return `操作失败: ${error.message}`;
}
});
}
}
/**
* @description 将新回声洞提交到管理群组以供审核。
* @param cave 新创建的、状态为 'pending' 的回声洞对象。
*/
async sendForPend(cave) {
if (!this.config.adminChannel?.includes(":")) {
this.logger.warn(`管理群组配置无效,已自动通过回声洞(${cave.id})`);
await this.ctx.database.upsert("cave", [{ id: cave.id, status: "active" }]);
return;
}
try {
const [platform] = this.config.adminChannel.split(":", 1);
const caveMessages = await buildCaveMessage(cave, this.config, this.fileManager, this.logger, platform, "待审核");
for (const message of caveMessages) if (message.length > 0) await this.ctx.broadcast([this.config.adminChannel], import_koishi2.h.normalize(message));
} catch (error) {
this.logger.error(`发送回声洞(${cave.id})审核消息失败:`, error);
}
}
};
// src/HashManager.ts
var import_jimp = __toESM(require("jimp"));
var crypto = __toESM(require("crypto"));
var HashManager = class {
/**
* @constructor
* @param ctx - Koishi 上下文,用于数据库操作。
* @param config - 插件配置,用于获取相似度阈值等。
* @param logger - 日志记录器实例。
* @param fileManager - 文件管理器实例,用于读取图片文件。
*/
constructor(ctx, config, logger2, fileManager) {
this.ctx = ctx;
this.config = config;
this.logger = logger2;
this.fileManager = fileManager;
this.ctx.model.extend("cave_hash", {
cave: "unsigned",
hash: "string",
type: "string"
}, {
primary: ["cave", "hash", "type"],
indexes: ["type"]
});
}
static {
__name(this, "HashManager");
}
/**
* @description 注册与哈希功能相关的子命令。
* @param cave - 主 `cave` 命令实例。
*/
registerCommands(cave) {
cave.subcommand(".hash", "校验回声洞", { hidden: true, authority: 3 }).usage("校验缺失哈希的回声洞,补全哈希记录。").action(async ({ session }) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
try {
const allCaves = await this.ctx.database.get("cave", { status: "active" });
const existingHashes = await this.ctx.database.get("cave_hash", {}, { fields: ["cave"] });
const hashedCaveIds = new Set(existingHashes.map((h4) => h4.cave));
const cavesToProcess = allCaves.filter((cave2) => !hashedCaveIds.has(cave2.id));
if (cavesToProcess.length === 0) return "无需补全回声洞哈希";
await session.send(`开始补全 ${cavesToProcess.length} 个回声洞的哈希...`);
let hashesToInsert = [];
let processedCaveCount = 0;
let totalHashesGenerated = 0;
let errorCount = 0;
const flushBatch = /* @__PURE__ */ __name(async () => {
if (hashesToInsert.length === 0) return;
await this.ctx.database.upsert("cave_hash", hashesToInsert);
totalHashesGenerated += hashesToInsert.length;
this.logger.info(`[${processedCaveCount}/${cavesToProcess.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`);
hashesToInsert = [];
}, "flushBatch");
for (const cave2 of cavesToProcess) {
processedCaveCount++;
try {
const tempHashes = [];
const uniqueHashTracker = /* @__PURE__ */ new Set();
const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
const key = `${hashObj.hash}-${hashObj.type}`;
if (!uniqueHashTracker.has(key)) {
tempHashes.push(hashObj);
uniqueHashTracker.add(key);
}
}, "addUniqueHash");
const combinedText = cave2.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
if (combinedText) {
const textHash = this.generateTextSimhash(combinedText);
if (textHash) addUniqueHash({ cave: cave2.id, hash: textHash, type: "text" });
}
for (const el of cave2.elements.filter((el2) => el2.type === "image" && el2.file)) {
const imageBuffer = await this.fileManager.readFile(el.file);
const imageHash = await this.generatePHash(imageBuffer);
addUniqueHash({ cave: cave2.id, hash: imageHash, type: "image" });
}
const newHashesForCave = tempHashes;
if (newHashesForCave.length > 0) hashesToInsert.push(...newHashesForCave);
if (hashesToInsert.length >= 100) await flushBatch();
} catch (error) {
errorCount++;
this.logger.warn(`补全回声洞(${cave2.id})哈希时出错: ${error.message}`);
}
}
await flushBatch();
const successCount = processedCaveCount - errorCount;
return `已补全 ${successCount} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`;
} catch (error) {
this.logger.error("补全哈希失败:", error);
return `操作失败: ${error.message}`;
}
});
cave.subcommand(".check", "检查相似度", { hidden: true }).usage("检查所有回声洞,找出相似度过高的内容。").option("textThreshold", "-t <threshold:number> 文本相似度阈值 (%)").option("imageThreshold", "-i <threshold:number> 图片相似度阈值 (%)").action(async ({ session, options }) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
await session.send("正在检查,请稍候...");
try {
const textThreshold = options.textThreshold ?? this.config.textThreshold;
const imageThreshold = options.imageThreshold ?? this.config.imageThreshold;
const allHashes = await this.ctx.database.get("cave_hash", {});
if (allHashes.length < 2) return "无可比较哈希";
const candidatePairs = generateFromLSH(allHashes, (hashObj) => {
const binHash = BigInt("0x" + hashObj.hash).toString(2).padStart(64, "0");
const keys = [];
for (let i = 0; i < 4; i++) {
const band = binHash.substring(i * 16, (i + 1) * 16);
keys.push(`${hashObj.type}:${i}:${band}`);
}
return { id: hashObj.cave, keys };
});
const hashLookup = /* @__PURE__ */ new Map();
allHashes.forEach((h4) => {
if (!hashLookup.has(h4.cave)) hashLookup.set(h4.cave, { text: [], image: [] });
const entry = hashLookup.get(h4.cave);
if (h4.type === "text") entry.text.push(h4.hash);
else if (h4.type === "image") entry.image.push(h4.hash);
});
const textPairs = [];
const imagePairs = [];
for (const pairKey of candidatePairs) {
const [id1, id2] = pairKey.split("-").map(Number);
const cave1Hashes = hashLookup.get(id1);
const cave2Hashes = hashLookup.get(id2);
if (cave1Hashes?.text.length && cave2Hashes?.text.length) {
for (const hash1 of cave1Hashes.text) {
for (const hash2 of cave2Hashes.text) {
const similarity = this.calculateSimilarity(hash1, hash2);
if (similarity >= textThreshold) textPairs.push({ id1, id2, similarity });
}
}
}
if (cave1Hashes?.image.length && cave2Hashes?.image.length) {
for (const hash1 of cave1Hashes.image) {
for (const hash2 of cave2Hashes.image) {
const similarity = this.calculateSimilarity(hash1, hash2);
if (similarity >= imageThreshold) imagePairs.push({ id1, id2, similarity });
}
}
}
}
if (textPairs.length === 0 && imagePairs.length === 0) return "未发现高相似度的内容";
const generateReportForType = /* @__PURE__ */ __name((pairs) => {
if (pairs.length === 0) return { reportLines: [], clusters: [] };
const numericPairs = pairs.map((p) => [p.id1, p.id2]);
const validClusters = clusterItemsFromPairs(numericPairs);
const reportLines = [];
validClusters.forEach((cluster) => {
const sortedCluster = cluster.sort((a, b) => a - b);
const clusterPairs = pairs.filter((p) => cluster.includes(p.id1) && cluster.includes(p.id2)).sort((a, b) => b.similarity - a.similarity);
const scores = clusterPairs.map((p) => `${p.similarity.toFixed(2)}%`).join("/");
reportLines.push(`- ${sortedCluster.join("|")} = ${scores}`);
});
return { reportLines, clusters: validClusters };
}, "generateReportForType");
const textResult = generateReportForType(textPairs);
const imageResult = generateReportForType(imagePairs);
const totalClusters = textResult.clusters.length + imageResult.clusters.length;
if (totalClusters === 0) return "未发现高相似度的内容";
let report = `共发现 ${totalClusters} 组高相似度的内容:`;
if (textResult.reportLines.length > 0) {
report += `
[文本相似]`;
report += `
${textResult.reportLines.join("\n")}`;
}
if (imageResult.reportLines.length > 0) {
report += `
[图片相似]`;
report += `
${imageResult.reportLines.join("\n")}`;
}
return report.trim();
} catch (error) {
this.logger.error("检查相似度失败:", error);
return `检查失败: ${error.message}`;
}
});
cave.subcommand(".fix [...ids:posint]", "修复回声洞", { hidden: true, authority: 3 }).usage("扫描并修复回声洞中的图片,可指定一个或多个 ID。").action(async ({ session }, ...ids) => {
if (session.cid !== this.config.adminChannel) return "此指令仅限在管理群组中使用";
let cavesToProcess;
try {
await session.send("正在修复,请稍候...");
if (ids.length === 0) {
cavesToProcess = await this.ctx.database.get("cave", { status: "active" });
} else {
cavesToProcess = await this.ctx.database.get("cave", { id: { $in: ids }, status: "active" });
}
if (!cavesToProcess.length) return "无可修复的回声洞";
let fixedFiles = 0;
let errorCount = 0;
for (const cave2 of cavesToProcess) {
const imageElements = cave2.elements.filter((el) => el.type === "image" && el.file);
for (const element of imageElements) {
try {
const originalBuffer = await this.fileManager.readFile(element.file);
const sanitizedBuffer = this.sanitizeImageBuffer(originalBuffer);
if (!originalBuffer.equals(sanitizedBuffer)) {
await this.fileManager.saveFile(element.file, sanitizedBuffer);
fixedFiles++;
}
} catch (error) {
if (error.code !== "ENOENT" && error.name !== "NoSuchKey") {
this.logger.warn(`无法修复回声洞(${cave2.id})的图片(${element.file}):`, error);
errorCount++;
}
}
}
}
return `已修复 ${cavesToProcess.length} 个回声洞的 ${fixedFiles} 张图片(失败 ${errorCount} 条)`;
} catch (error) {
this.logger.error("修复图像文件时发生严重错误:", error);
return `操作失败: ${error.message}`;
}
});
}
/**
* @description 扫描并修复单个图片 Buffer,移除文件结束符之后的多余数据。
* @param imageBuffer - 原始的图片 Buffer。
* @returns 修复后的图片 Buffer。如果无需修复,则返回原始 Buffer。
*/
sanitizeImageBuffer(imageBuffer) {
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const JPEG_SIGNATURE = Buffer.from([255, 216]);
const GIF_SIGNATURE = Buffer.from("GIF");
const WEBP_SIGNATURE = Buffer.from("WEBP");
let sanitizedBuffer = imageBuffer;
if (imageBuffer.slice(0, 8).equals(PNG_SIGNATURE)) {
const IEND_CHUNK = Buffer.from("IEND");
const iendIndex = imageBuffer.lastIndexOf(IEND_CHUNK);
if (iendIndex !== -1) {
const endOfPngData = iendIndex + 8;
if (imageBuffer.length > endOfPngData) sanitizedBuffer = imageBuffer.slice(0, endOfPngData);
}
} else if (imageBuffer.slice(0, 2).equals(JPEG_SIGNATURE)) {
const EOI_MARKER = Buffer.from([255, 217]);
const eoiIndex = imageBuffer.lastIndexOf(EOI_MARKER);
if (eoiIndex !== -1) {
const endOfJpegData = eoiIndex + 2;
if (imageBuffer.length > endOfJpegData) sanitizedBuffer = imageBuffer.slice(0, endOfJpegData);
}
} else if (imageBuffer.slice(0, 3).equals(GIF_SIGNATURE)) {
const GIF_TERMINATOR = Buffer.from([59]);
const terminatorIndex = imageBuffer.lastIndexOf(GIF_TERMINATOR);
if (terminatorIndex !== -1) {
const endOfGifData = terminatorIndex + 1;
if (imageBuffer.length > endOfGifData) sanitizedBuffer = imageBuffer.slice(0, endOfGifData);
}
} else if (imageBuffer.slice(8, 12).equals(WEBP_SIGNATURE)) {
const fileSize = imageBuffer.readUInt32LE(4);
const expectedLength = fileSize + 8;
if (imageBuffer.length > expectedLength) sanitizedBuffer = imageBuffer.slice(0, expectedLength);
}
return sanitizedBuffer;
}
/**
* @description 执行一维离散余弦变换 (DCT-II) 的方法。
* @param input - 输入的数字数组。
* @returns DCT 变换后的数组。
*/
dct1D(input) {
const N = input.length;
const output = new Array(N).fill(0);
const c0 = 1 / Math.sqrt(2);
for (let k = 0; k < N; k++) {
let sum = 0;
for (let n = 0; n < N; n++) sum += input[n] * Math.cos(Math.PI * (2 * n + 1) * k / (2 * N));
const ck = k === 0 ? c0 : 1;
output[k] = Math.sqrt(2 / N) * ck * sum;
}
return output;
}
/**
* @description 执行二维离散余弦变换 (DCT-II) 的方法。
* 通过对行和列分别应用一维 DCT 来实现。
* @param matrix - 输入的 N x N 像素亮度矩阵。
* @returns DCT 变换后的 N x N 系数矩阵。
*/
dct2D(matrix) {
const N = matrix.length;
if (N === 0) return [];
const tempMatrix = matrix.map((row) => this.dct1D(row));
const transposed = tempMatrix.map((_, colIndex) => tempMatrix.map((row) => row[colIndex]));
const dctResultTransposed = transposed.map((row) => this.dct1D(row));
const dctResult = dctResultTransposed.map((_, colIndex) => dctResultTransposed.map((row) => row[colIndex]));
return dctResult;
}
/**
* @description pHash 算法核心实现,使用 Jimp 和自定义 DCT。
* @param imageBuffer - 图片的 Buffer。
* @returns 64位十六进制 pHash 字符串。
*/
async generatePHash(imageBuffer) {
const image = await import_jimp.default.read(imageBuffer);
image.resize(32, 32, import_jimp.default.RESIZE_BILINEAR).greyscale();
const matrix = Array.from({ length: 32 }, () => new Array(32).fill(0));
image.scan(0, 0, 32, 32, (x, y, idx) => {
matrix[y][x] = image.bitmap.data[idx];
});
const dctMatrix = this.dct2D(matrix);
const coefficients = [];
for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) coefficients.push(dctMatrix[y][x]);
const acCoefficients = coefficients.slice(1);
const average = acCoefficients.reduce((sum, val) => sum + val, 0) / acCoefficients.length;
let binaryHash = "";
for (const val of coefficients) binaryHash += val > average ? "1" : "0";
return BigInt("0b" + binaryHash).toString(16).padStart(16, "0");
}
/**
* @description 根据汉明距离计算相似度百分比。
* @param hex1 - 第一个哈希。
* @param hex2 - 第二个哈希。
* @returns 相似度 (0-100)。
*/
calculateSimilarity(hex1, hex2) {
let distance = 0;
let bin1 = "";
for (const char of hex1) bin1 += parseIn