koishi-plugin-best-cave 2.2.8 → 2.3.0
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.js +183 -245
- package/package.json +1 -1
- package/readme.md +13 -10
- package/lib/HashManager.d.ts +0 -116
- package/lib/Utils.d.ts +0 -71
- package/lib/index.d.ts +0 -54
package/lib/index.js
CHANGED
|
@@ -431,62 +431,38 @@ async function handleFileUploads(ctx, config, fileManager, logger2, reviewManage
|
|
|
431
431
|
try {
|
|
432
432
|
const downloadedMedia = [];
|
|
433
433
|
const imageHashesToStore = [];
|
|
434
|
-
const
|
|
435
|
-
const
|
|
436
|
-
const
|
|
437
|
-
const existingSubHashObjects = existingHashes.filter((h4) => h4.type.startsWith("sub_phash_"));
|
|
434
|
+
const allExistingImageHashes = hashManager ? await ctx.database.get("cave_hash", { type: { $ne: "simhash" } }) : [];
|
|
435
|
+
const existingGlobalHashes = allExistingImageHashes.filter((h4) => h4.type === "phash_g");
|
|
436
|
+
const existingQuadrantHashes = allExistingImageHashes.filter((h4) => h4.type.startsWith("phash_q"));
|
|
438
437
|
for (const media of mediaToToSave) {
|
|
439
438
|
const buffer = Buffer.from(await ctx.http.get(media.sourceUrl, { responseType: "arraybuffer", timeout: 3e4 }));
|
|
440
439
|
downloadedMedia.push({ fileName: media.fileName, buffer });
|
|
441
440
|
if (hashManager && [".png", ".jpg", ".jpeg", ".webp"].includes(path2.extname(media.fileName).toLowerCase())) {
|
|
442
|
-
const {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const similarityScores = /* @__PURE__ */ new Map();
|
|
446
|
-
for (const existing of existingColorPHashes) {
|
|
447
|
-
const similarity = hashManager.calculateSimilarity(colorPHash, existing.hash);
|
|
441
|
+
const { globalHash, quadrantHashes } = await hashManager.generateAllImageHashes(buffer);
|
|
442
|
+
for (const existing of existingGlobalHashes) {
|
|
443
|
+
const similarity = hashManager.calculateSimilarity(globalHash, existing.hash);
|
|
448
444
|
if (similarity >= config.imageWholeThreshold) {
|
|
449
|
-
|
|
450
|
-
|
|
445
|
+
await session.send(`图片与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`);
|
|
446
|
+
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
447
|
+
cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
|
|
448
|
+
return;
|
|
451
449
|
}
|
|
452
450
|
}
|
|
453
|
-
for (const existing of existingDHashes) {
|
|
454
|
-
const similarity = hashManager.calculateSimilarity(dHash, existing.hash);
|
|
455
|
-
if (similarity >= config.imageWholeThreshold) {
|
|
456
|
-
if (!similarityScores.has(existing.cave)) similarityScores.set(existing.cave, {});
|
|
457
|
-
similarityScores.get(existing.cave).dSim = similarity;
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
|
-
for (const [caveId, scores] of similarityScores.entries()) {
|
|
461
|
-
if (scores.colorSim && scores.dSim) {
|
|
462
|
-
caveToDelete = caveId;
|
|
463
|
-
highestCombinedSimilarity = scores.colorSim;
|
|
464
|
-
break;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
if (caveToDelete) {
|
|
468
|
-
await session.send(`图片与回声洞(${caveToDelete})的相似度为 ${(highestCombinedSimilarity * 100).toFixed(2)}%,超过阈值`);
|
|
469
|
-
await ctx.database.upsert("cave", [{ id: cave.id, status: "delete" }]);
|
|
470
|
-
cleanupPendingDeletions(ctx, fileManager, logger2, reusableIds);
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
451
|
const notifiedPartialCaves = /* @__PURE__ */ new Set();
|
|
474
|
-
for (const newSubHash of Object.values(
|
|
475
|
-
for (const existing of
|
|
452
|
+
for (const newSubHash of Object.values(quadrantHashes)) {
|
|
453
|
+
for (const existing of existingQuadrantHashes) {
|
|
476
454
|
if (notifiedPartialCaves.has(existing.cave)) continue;
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
await session.send(`图片局部与回声洞(${existing.cave})的相似度为 ${(similarity * 100).toFixed(2)}%`);
|
|
455
|
+
if (newSubHash === existing.hash) {
|
|
456
|
+
await session.send(`图片与回声洞(${existing.cave})局部相同`);
|
|
480
457
|
notifiedPartialCaves.add(existing.cave);
|
|
481
458
|
}
|
|
482
459
|
}
|
|
483
460
|
}
|
|
484
|
-
imageHashesToStore.push({ hash:
|
|
485
|
-
imageHashesToStore.push({ hash:
|
|
486
|
-
imageHashesToStore.push({ hash:
|
|
487
|
-
imageHashesToStore.push({ hash:
|
|
488
|
-
imageHashesToStore.push({ hash:
|
|
489
|
-
imageHashesToStore.push({ hash: subHashes.q4, type: "sub_phash_q4" });
|
|
461
|
+
imageHashesToStore.push({ hash: globalHash, type: "phash_g" });
|
|
462
|
+
imageHashesToStore.push({ hash: quadrantHashes.q1, type: "phash_q1" });
|
|
463
|
+
imageHashesToStore.push({ hash: quadrantHashes.q2, type: "phash_q2" });
|
|
464
|
+
imageHashesToStore.push({ hash: quadrantHashes.q3, type: "phash_q3" });
|
|
465
|
+
imageHashesToStore.push({ hash: quadrantHashes.q4, type: "phash_q4" });
|
|
490
466
|
}
|
|
491
467
|
}
|
|
492
468
|
await Promise.all(downloadedMedia.map((item) => fileManager.saveFile(item.fileName, item.buffer)));
|
|
@@ -606,9 +582,9 @@ var HashManager = class {
|
|
|
606
582
|
/**
|
|
607
583
|
* @constructor
|
|
608
584
|
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
609
|
-
* @param config -
|
|
585
|
+
* @param config - 插件配置,用于获取相似度阈值等。
|
|
610
586
|
* @param logger - 日志记录器实例。
|
|
611
|
-
* @param fileManager -
|
|
587
|
+
* @param fileManager - 文件管理器实例,用于读取图片文件。
|
|
612
588
|
*/
|
|
613
589
|
constructor(ctx, config, logger2, fileManager) {
|
|
614
590
|
this.ctx = ctx;
|
|
@@ -627,7 +603,7 @@ var HashManager = class {
|
|
|
627
603
|
__name(this, "HashManager");
|
|
628
604
|
}
|
|
629
605
|
/**
|
|
630
|
-
* @description
|
|
606
|
+
* @description 注册与哈希功能相关的 `.hash` 和 `.check` 子命令。
|
|
631
607
|
* @param cave - 主 `cave` 命令实例。
|
|
632
608
|
*/
|
|
633
609
|
registerCommands(cave) {
|
|
@@ -637,7 +613,7 @@ var HashManager = class {
|
|
|
637
613
|
return "此指令仅限在管理群组中使用";
|
|
638
614
|
}
|
|
639
615
|
}, "adminCheck");
|
|
640
|
-
cave.subcommand(".hash", "校验回声洞").usage("
|
|
616
|
+
cave.subcommand(".hash", "校验回声洞").usage("校验缺失哈希的回声洞,补全哈希记录。").action(async (argv) => {
|
|
641
617
|
const checkResult = adminCheck(argv);
|
|
642
618
|
if (checkResult) return checkResult;
|
|
643
619
|
await argv.session.send("正在处理,请稍候...");
|
|
@@ -648,7 +624,7 @@ var HashManager = class {
|
|
|
648
624
|
return `操作失败: ${error.message}`;
|
|
649
625
|
}
|
|
650
626
|
});
|
|
651
|
-
cave.subcommand(".check", "
|
|
627
|
+
cave.subcommand(".check", "检查相似度").usage("检查所有回声洞,找出相似度过高的内容。").action(async (argv) => {
|
|
652
628
|
const checkResult = adminCheck(argv);
|
|
653
629
|
if (checkResult) return checkResult;
|
|
654
630
|
await argv.session.send("正在检查,请稍候...");
|
|
@@ -662,249 +638,160 @@ var HashManager = class {
|
|
|
662
638
|
}
|
|
663
639
|
/**
|
|
664
640
|
* @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。
|
|
665
|
-
* @returns
|
|
641
|
+
* @returns 一个包含操作结果的报告字符串。
|
|
666
642
|
*/
|
|
667
643
|
async generateHashesForHistoricalCaves() {
|
|
668
644
|
const allCaves = await this.ctx.database.get("cave", { status: "active" });
|
|
669
|
-
const existingHashes = await this.ctx.database.get("cave_hash", {}
|
|
645
|
+
const existingHashes = await this.ctx.database.get("cave_hash", {});
|
|
670
646
|
const existingHashSet = new Set(existingHashes.map((h4) => `${h4.cave}-${h4.hash}-${h4.type}`));
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
const totalToProcessCount = cavesToProcess.length;
|
|
674
|
-
if (totalToProcessCount === 0) {
|
|
675
|
-
return "无需补全回声洞哈希";
|
|
676
|
-
}
|
|
677
|
-
this.logger.info(`开始补全 ${totalToProcessCount} 个回声洞的哈希...`);
|
|
647
|
+
if (allCaves.length === 0) return "无需补全回声洞哈希";
|
|
648
|
+
this.logger.info(`开始补全 ${allCaves.length} 个回声洞的哈希...`);
|
|
678
649
|
let hashesToInsert = [];
|
|
679
|
-
const batchHashSet = /* @__PURE__ */ new Set();
|
|
680
650
|
let processedCaveCount = 0;
|
|
681
651
|
let totalHashesGenerated = 0;
|
|
682
652
|
let errorCount = 0;
|
|
683
653
|
const flushBatch = /* @__PURE__ */ __name(async () => {
|
|
684
|
-
|
|
685
|
-
if (batchSize === 0) return;
|
|
654
|
+
if (hashesToInsert.length === 0) return;
|
|
686
655
|
await this.ctx.database.upsert("cave_hash", hashesToInsert);
|
|
687
|
-
totalHashesGenerated +=
|
|
688
|
-
this.logger.info(
|
|
656
|
+
totalHashesGenerated += hashesToInsert.length;
|
|
657
|
+
this.logger.info(`[${processedCaveCount}/${allCaves.length}] 正在导入 ${hashesToInsert.length} 条回声洞哈希...`);
|
|
689
658
|
hashesToInsert = [];
|
|
690
|
-
batchHashSet.clear();
|
|
691
659
|
}, "flushBatch");
|
|
692
|
-
for (const cave of
|
|
660
|
+
for (const cave of allCaves) {
|
|
693
661
|
processedCaveCount++;
|
|
694
662
|
try {
|
|
695
663
|
const newHashesForCave = await this.generateAllHashesForCave(cave);
|
|
696
664
|
for (const hashObj of newHashesForCave) {
|
|
697
665
|
const uniqueKey = `${hashObj.cave}-${hashObj.hash}-${hashObj.type}`;
|
|
698
|
-
if (!existingHashSet.has(uniqueKey)
|
|
666
|
+
if (!existingHashSet.has(uniqueKey)) {
|
|
699
667
|
hashesToInsert.push(hashObj);
|
|
700
|
-
|
|
668
|
+
existingHashSet.add(uniqueKey);
|
|
701
669
|
}
|
|
702
670
|
}
|
|
671
|
+
if (hashesToInsert.length >= 100) {
|
|
672
|
+
await flushBatch();
|
|
673
|
+
}
|
|
703
674
|
} catch (error) {
|
|
704
675
|
errorCount++;
|
|
705
|
-
this.logger.warn(`补全回声洞(${cave.id}
|
|
706
|
-
continue;
|
|
707
|
-
}
|
|
708
|
-
if (hashesToInsert.length >= 100) {
|
|
709
|
-
await flushBatch();
|
|
676
|
+
this.logger.warn(`补全回声洞(${cave.id})哈希时发生错误: ${error.message}`);
|
|
710
677
|
}
|
|
711
678
|
}
|
|
712
679
|
await flushBatch();
|
|
713
|
-
return `已补全 ${
|
|
680
|
+
return `已补全 ${allCaves.length} 个回声洞的 ${totalHashesGenerated} 条哈希(失败 ${errorCount} 条)`;
|
|
714
681
|
}
|
|
715
682
|
/**
|
|
716
|
-
* @description
|
|
683
|
+
* @description 为单个回声洞对象生成所有类型的哈希(文本+图片)。
|
|
717
684
|
* @param cave - 回声洞对象。
|
|
718
|
-
* @returns
|
|
685
|
+
* @returns 生成的哈希对象数组。
|
|
719
686
|
*/
|
|
720
687
|
async generateAllHashesForCave(cave) {
|
|
721
|
-
const
|
|
688
|
+
const tempHashes = [];
|
|
689
|
+
const uniqueHashTracker = /* @__PURE__ */ new Set();
|
|
690
|
+
const addUniqueHash = /* @__PURE__ */ __name((hashObj) => {
|
|
691
|
+
const key = `${hashObj.hash}-${hashObj.type}`;
|
|
692
|
+
if (!uniqueHashTracker.has(key)) {
|
|
693
|
+
tempHashes.push(hashObj);
|
|
694
|
+
uniqueHashTracker.add(key);
|
|
695
|
+
}
|
|
696
|
+
}, "addUniqueHash");
|
|
722
697
|
const combinedText = cave.elements.filter((el) => el.type === "text" && el.content).map((el) => el.content).join(" ");
|
|
723
698
|
if (combinedText) {
|
|
724
699
|
const textHash = this.generateTextSimhash(combinedText);
|
|
725
|
-
if (textHash) {
|
|
726
|
-
allHashes.push({ cave: cave.id, hash: textHash, type: "simhash" });
|
|
727
|
-
}
|
|
700
|
+
if (textHash) addUniqueHash({ cave: cave.id, hash: textHash, type: "simhash" });
|
|
728
701
|
}
|
|
729
702
|
for (const el of cave.elements.filter((el2) => el2.type === "image" && el2.file)) {
|
|
730
703
|
try {
|
|
731
704
|
const imageBuffer = await this.fileManager.readFile(el.file);
|
|
732
|
-
const
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
allHashes.push({ cave: cave.id, hash: imageHashes.subHashes.q4, type: "sub_phash_q4" });
|
|
705
|
+
const { globalHash, quadrantHashes } = await this.generateAllImageHashes(imageBuffer);
|
|
706
|
+
addUniqueHash({ cave: cave.id, hash: globalHash, type: "phash_g" });
|
|
707
|
+
addUniqueHash({ cave: cave.id, hash: quadrantHashes.q1, type: "phash_q1" });
|
|
708
|
+
addUniqueHash({ cave: cave.id, hash: quadrantHashes.q2, type: "phash_q2" });
|
|
709
|
+
addUniqueHash({ cave: cave.id, hash: quadrantHashes.q3, type: "phash_q3" });
|
|
710
|
+
addUniqueHash({ cave: cave.id, hash: quadrantHashes.q4, type: "phash_q4" });
|
|
739
711
|
} catch (e) {
|
|
740
|
-
this.logger.warn(`无法为回声洞(${cave.id}
|
|
712
|
+
this.logger.warn(`无法为回声洞(${cave.id})的图片(${el.file})生成哈希:`, e);
|
|
741
713
|
}
|
|
742
714
|
}
|
|
743
|
-
return
|
|
715
|
+
return tempHashes;
|
|
744
716
|
}
|
|
745
717
|
/**
|
|
746
|
-
* @description
|
|
747
|
-
* @
|
|
748
|
-
* @returns {Promise<object>} 包含所有图片哈希的对象。
|
|
749
|
-
*/
|
|
750
|
-
async generateAllImageHashes(imageBuffer) {
|
|
751
|
-
const [colorPHash, dHash, subHashes] = await Promise.all([
|
|
752
|
-
this.generateColorPHash(imageBuffer),
|
|
753
|
-
this.generateDHash(imageBuffer),
|
|
754
|
-
this.generateImageSubHashes(imageBuffer)
|
|
755
|
-
]);
|
|
756
|
-
return { colorPHash, dHash, subHashes };
|
|
757
|
-
}
|
|
758
|
-
/**
|
|
759
|
-
* @description 对回声洞进行混合策略的相似度与重复内容检查。
|
|
760
|
-
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
718
|
+
* @description 对数据库中所有哈希进行两两比较,找出相似度过高的内容。
|
|
719
|
+
* @returns 一个包含检查结果的报告字符串。
|
|
761
720
|
*/
|
|
762
721
|
async checkForSimilarCaves() {
|
|
763
722
|
const allHashes = await this.ctx.database.get("cave_hash", {});
|
|
764
|
-
const
|
|
765
|
-
const
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
phash_color: /* @__PURE__ */ new Map(),
|
|
769
|
-
dhash_gray: /* @__PURE__ */ new Map()
|
|
770
|
-
};
|
|
771
|
-
const subHashGroups = /* @__PURE__ */ new Map();
|
|
723
|
+
const allCaveIds = [...new Set(allHashes.map((h4) => h4.cave))];
|
|
724
|
+
const textHashes = /* @__PURE__ */ new Map();
|
|
725
|
+
const globalHashes = /* @__PURE__ */ new Map();
|
|
726
|
+
const quadrantHashes = /* @__PURE__ */ new Map();
|
|
772
727
|
for (const hash of allHashes) {
|
|
773
|
-
if (
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
728
|
+
if (hash.type === "simhash") {
|
|
729
|
+
textHashes.set(hash.cave, hash.hash);
|
|
730
|
+
} else if (hash.type === "phash_g") {
|
|
731
|
+
globalHashes.set(hash.cave, hash.hash);
|
|
732
|
+
} else if (hash.type.startsWith("phash_q")) {
|
|
733
|
+
if (!quadrantHashes.has(hash.cave)) quadrantHashes.set(hash.cave, /* @__PURE__ */ new Set());
|
|
734
|
+
quadrantHashes.get(hash.cave).add(hash.hash);
|
|
779
735
|
}
|
|
780
736
|
}
|
|
781
737
|
const similarPairs = {
|
|
782
738
|
text: /* @__PURE__ */ new Set(),
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
image_part: /* @__PURE__ */ new Set()
|
|
739
|
+
global: /* @__PURE__ */ new Set(),
|
|
740
|
+
partial: /* @__PURE__ */ new Set()
|
|
786
741
|
};
|
|
787
742
|
for (let i = 0; i < allCaveIds.length; i++) {
|
|
788
743
|
for (let j = i + 1; j < allCaveIds.length; j++) {
|
|
789
744
|
const id1 = allCaveIds[i];
|
|
790
745
|
const id2 = allCaveIds[j];
|
|
791
|
-
const
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
746
|
+
const pair = [id1, id2].sort((a, b) => a - b).join(" & ");
|
|
747
|
+
const text1 = textHashes.get(id1);
|
|
748
|
+
const text2 = textHashes.get(id2);
|
|
749
|
+
if (text1 && text2) {
|
|
750
|
+
const similarity = this.calculateSimilarity(text1, text2);
|
|
751
|
+
if (similarity >= this.config.textThreshold) {
|
|
752
|
+
similarPairs.text.add(`${pair} = ${similarity.toFixed(2)}%`);
|
|
797
753
|
}
|
|
798
754
|
}
|
|
799
|
-
const
|
|
800
|
-
const
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
similarPairs.image_color.add(`${id1} & ${id2} = ${(sim * 100).toFixed(2)}%`);
|
|
806
|
-
}
|
|
755
|
+
const global1 = globalHashes.get(id1);
|
|
756
|
+
const global2 = globalHashes.get(id2);
|
|
757
|
+
if (global1 && global2) {
|
|
758
|
+
const similarity = this.calculateSimilarity(global1, global2);
|
|
759
|
+
if (similarity >= this.config.imageWholeThreshold) {
|
|
760
|
+
similarPairs.global.add(`${pair} = ${similarity.toFixed(2)}%`);
|
|
807
761
|
}
|
|
808
762
|
}
|
|
809
|
-
const
|
|
810
|
-
const
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
if (
|
|
815
|
-
|
|
763
|
+
const quads1 = quadrantHashes.get(id1);
|
|
764
|
+
const quads2 = quadrantHashes.get(id2);
|
|
765
|
+
if (quads1 && quads2 && quads1.size > 0 && quads2.size > 0) {
|
|
766
|
+
let matchFound = false;
|
|
767
|
+
for (const h1 of quads1) {
|
|
768
|
+
if (quads2.has(h1)) {
|
|
769
|
+
matchFound = true;
|
|
770
|
+
break;
|
|
816
771
|
}
|
|
817
772
|
}
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
const subHashes2 = subHashGroups.get(id2) || [];
|
|
821
|
-
if (subHashes1.length > 0 && subHashes2.length > 0) {
|
|
822
|
-
let maxPartSim = 0;
|
|
823
|
-
for (const h1 of subHashes1) {
|
|
824
|
-
for (const h22 of subHashes2) {
|
|
825
|
-
const sim = this.calculateSimilarity(h1, h22);
|
|
826
|
-
if (sim > maxPartSim) {
|
|
827
|
-
maxPartSim = sim;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
}
|
|
831
|
-
if (maxPartSim >= this.config.imagePartThreshold) {
|
|
832
|
-
similarPairs.image_part.add(`${id1} & ${id2} = ${(maxPartSim * 100).toFixed(2)}%`);
|
|
773
|
+
if (matchFound) {
|
|
774
|
+
similarPairs.partial.add(pair);
|
|
833
775
|
}
|
|
834
776
|
}
|
|
835
777
|
}
|
|
836
778
|
}
|
|
837
|
-
const totalFindings = similarPairs.text.size + similarPairs.
|
|
779
|
+
const totalFindings = similarPairs.text.size + similarPairs.global.size + similarPairs.partial.size;
|
|
838
780
|
if (totalFindings === 0) return "未发现高相似度的内容";
|
|
839
781
|
let report = `已发现 ${totalFindings} 组高相似度的内容:`;
|
|
840
|
-
if (similarPairs.text.size > 0) report += "\n
|
|
841
|
-
if (similarPairs.
|
|
842
|
-
if (similarPairs.
|
|
843
|
-
if (similarPairs.image_part.size > 0) report += "\n图片局部近似:\n" + [...similarPairs.image_part].join("\n");
|
|
782
|
+
if (similarPairs.text.size > 0) report += "\n文本内容相似:\n" + [...similarPairs.text].join("\n");
|
|
783
|
+
if (similarPairs.global.size > 0) report += "\n图片整体相似:\n" + [...similarPairs.global].join("\n");
|
|
784
|
+
if (similarPairs.partial.size > 0) report += "\n图片局部相同:\n" + [...similarPairs.partial].join("\n");
|
|
844
785
|
return report.trim();
|
|
845
786
|
}
|
|
846
787
|
/**
|
|
847
|
-
* @description
|
|
848
|
-
* @param
|
|
849
|
-
* @
|
|
850
|
-
* @returns {string} 该通道的二进制哈希字符串。
|
|
851
|
-
*/
|
|
852
|
-
_calculateHashFromRawChannel(channelData, size) {
|
|
853
|
-
const totalLuminance = channelData.reduce((acc, val) => acc + val, 0);
|
|
854
|
-
const avgLuminance = totalLuminance / (size * size);
|
|
855
|
-
return channelData.map((lum) => lum > avgLuminance ? "1" : "0").join("");
|
|
856
|
-
}
|
|
857
|
-
/**
|
|
858
|
-
* @description 生成768位颜色感知哈希(Color pHash)。
|
|
859
|
-
* @param imageBuffer - 图片的 Buffer 数据。
|
|
860
|
-
* @returns {Promise<string>} 768位二进制哈希对应的192位十六进制字符串。
|
|
861
|
-
*/
|
|
862
|
-
async generateColorPHash(imageBuffer) {
|
|
863
|
-
const { data, info } = await (0, import_sharp.default)(imageBuffer).resize(16, 16, { fit: "fill" }).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
|
864
|
-
const { channels } = info;
|
|
865
|
-
const r = [], g = [], b = [];
|
|
866
|
-
for (let i = 0; i < data.length; i += channels) {
|
|
867
|
-
r.push(data[i]);
|
|
868
|
-
g.push(data[i + 1]);
|
|
869
|
-
b.push(data[i + 2]);
|
|
870
|
-
}
|
|
871
|
-
const rHash = this._calculateHashFromRawChannel(r, 16);
|
|
872
|
-
const gHash = this._calculateHashFromRawChannel(g, 16);
|
|
873
|
-
const bHash = this._calculateHashFromRawChannel(b, 16);
|
|
874
|
-
const combinedHash = rHash + gHash + bHash;
|
|
875
|
-
let hex = "";
|
|
876
|
-
for (let i = 0; i < combinedHash.length; i += 4) {
|
|
877
|
-
hex += parseInt(combinedHash.substring(i, i + 4), 2).toString(16);
|
|
878
|
-
}
|
|
879
|
-
return hex.padStart(192, "0");
|
|
880
|
-
}
|
|
881
|
-
/**
|
|
882
|
-
* @description 生成256位差异哈希(dHash)。
|
|
883
|
-
* @param imageBuffer - 图片的 Buffer 数据。
|
|
884
|
-
* @returns {Promise<string>} 256位二进制哈希对应的64位十六进制字符串。
|
|
885
|
-
*/
|
|
886
|
-
async generateDHash(imageBuffer) {
|
|
887
|
-
const pixels = await (0, import_sharp.default)(imageBuffer).grayscale().resize(17, 16, { fit: "fill" }).raw().toBuffer();
|
|
888
|
-
let hash = "";
|
|
889
|
-
for (let y = 0; y < 16; y++) {
|
|
890
|
-
for (let x = 0; x < 16; x++) {
|
|
891
|
-
const i = y * 17 + x;
|
|
892
|
-
hash += pixels[i] > pixels[i + 1] ? "1" : "0";
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
return BigInt("0b" + hash).toString(16).padStart(64, "0");
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* @description 将图片切割为4个象限并为每个象限生成Color pHash。
|
|
899
|
-
* @param imageBuffer - 图片的 Buffer 数据。
|
|
900
|
-
* @returns {Promise<object>} 包含四个象限哈希的对象。
|
|
788
|
+
* @description 为单个图片Buffer生成全局pHash和四个象限的局部pHash。
|
|
789
|
+
* @param imageBuffer - 图片的Buffer数据。
|
|
790
|
+
* @returns 包含全局哈希和四象限哈希的对象。
|
|
901
791
|
*/
|
|
902
|
-
async
|
|
792
|
+
async generateAllImageHashes(imageBuffer) {
|
|
793
|
+
const globalHash = await this._generatePHash(imageBuffer, 256);
|
|
903
794
|
const { width, height } = await (0, import_sharp.default)(imageBuffer).metadata();
|
|
904
|
-
if (!width || !height || width < 16 || height < 16) {
|
|
905
|
-
const fallbackHash = await this.generateColorPHash(imageBuffer);
|
|
906
|
-
return { q1: fallbackHash, q2: fallbackHash, q3: fallbackHash, q4: fallbackHash };
|
|
907
|
-
}
|
|
908
795
|
const w2 = Math.floor(width / 2), h22 = Math.floor(height / 2);
|
|
909
796
|
const regions = [
|
|
910
797
|
{ left: 0, top: 0, width: w2, height: h22 },
|
|
@@ -914,17 +801,73 @@ var HashManager = class {
|
|
|
914
801
|
];
|
|
915
802
|
const [q1, q2, q3, q4] = await Promise.all(
|
|
916
803
|
regions.map((region) => {
|
|
917
|
-
if (region.width <
|
|
918
|
-
return (0, import_sharp.default)(imageBuffer).extract(region).toBuffer().then((b) => this.
|
|
804
|
+
if (region.width < 16 || region.height < 16) return this._generatePHash(imageBuffer, 64);
|
|
805
|
+
return (0, import_sharp.default)(imageBuffer).extract(region).toBuffer().then((b) => this._generatePHash(b, 64));
|
|
919
806
|
})
|
|
920
807
|
);
|
|
921
|
-
return { q1, q2, q3, q4 };
|
|
808
|
+
return { globalHash, quadrantHashes: { q1, q2, q3, q4 } };
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* @description 执行二维离散余弦变换 (DCT-II)。
|
|
812
|
+
* @param matrix - 输入的 N x N 像素亮度矩阵。
|
|
813
|
+
* @returns DCT变换后的 N x N 系数矩阵。
|
|
814
|
+
*/
|
|
815
|
+
_dct2D(matrix) {
|
|
816
|
+
const N = matrix.length;
|
|
817
|
+
if (N === 0) return [];
|
|
818
|
+
const cosines = Array.from(
|
|
819
|
+
{ length: N },
|
|
820
|
+
(_, i) => Array.from({ length: N }, (_2, j) => Math.cos(Math.PI * (2 * i + 1) * j / (2 * N)))
|
|
821
|
+
);
|
|
822
|
+
const applyDct1D = /* @__PURE__ */ __name((input) => {
|
|
823
|
+
const output = new Array(N).fill(0);
|
|
824
|
+
const scale = Math.sqrt(2 / N);
|
|
825
|
+
for (let k = 0; k < N; k++) {
|
|
826
|
+
let sum = 0;
|
|
827
|
+
for (let n = 0; n < N; n++) {
|
|
828
|
+
sum += input[n] * cosines[n][k];
|
|
829
|
+
}
|
|
830
|
+
output[k] = scale * sum;
|
|
831
|
+
}
|
|
832
|
+
output[0] /= Math.sqrt(2);
|
|
833
|
+
return output;
|
|
834
|
+
}, "applyDct1D");
|
|
835
|
+
const tempMatrix = matrix.map((row) => applyDct1D(row));
|
|
836
|
+
const transposed = tempMatrix[0].map((_, col) => tempMatrix.map((row) => row[col]));
|
|
837
|
+
const dctResult = transposed.map((row) => applyDct1D(row));
|
|
838
|
+
return dctResult[0].map((_, col) => dctResult.map((row) => row[col]));
|
|
922
839
|
}
|
|
923
840
|
/**
|
|
924
|
-
* @description
|
|
925
|
-
* @param
|
|
926
|
-
* @param
|
|
927
|
-
* @returns
|
|
841
|
+
* @description pHash 算法核心实现。
|
|
842
|
+
* @param imageBuffer - 图片的Buffer。
|
|
843
|
+
* @param size - 期望的哈希位数 (必须是完全平方数, 如 64 或 256)。
|
|
844
|
+
* @returns 十六进制pHash字符串。
|
|
845
|
+
*/
|
|
846
|
+
async _generatePHash(imageBuffer, size) {
|
|
847
|
+
const dctSize = 32;
|
|
848
|
+
const hashGridSize = Math.sqrt(size);
|
|
849
|
+
if (!Number.isInteger(hashGridSize)) throw new Error("哈希位数必须是完全平方数");
|
|
850
|
+
const pixels = await (0, import_sharp.default)(imageBuffer).grayscale().resize(dctSize, dctSize, { fit: "fill" }).raw().toBuffer();
|
|
851
|
+
const matrix = [];
|
|
852
|
+
for (let y = 0; y < dctSize; y++) {
|
|
853
|
+
matrix.push(Array.from(pixels.slice(y * dctSize, (y + 1) * dctSize)));
|
|
854
|
+
}
|
|
855
|
+
const dctMatrix = this._dct2D(matrix);
|
|
856
|
+
const coefficients = [];
|
|
857
|
+
for (let y = 0; y < hashGridSize; y++) {
|
|
858
|
+
for (let x = 0; x < hashGridSize; x++) {
|
|
859
|
+
coefficients.push(dctMatrix[y][x]);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
const median = [...coefficients.slice(1)].sort((a, b) => a - b)[Math.floor((coefficients.length - 1) / 2)];
|
|
863
|
+
const binaryHash = coefficients.map((val) => val > median ? "1" : "0").join("");
|
|
864
|
+
return BigInt("0b" + binaryHash).toString(16).padStart(size / 4, "0");
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* @description 计算两个十六进制哈希字符串之间的汉明距离 (不同位的数量)。
|
|
868
|
+
* @param hex1 - 第一个哈希。
|
|
869
|
+
* @param hex2 - 第二个哈希。
|
|
870
|
+
* @returns 汉明距离。
|
|
928
871
|
*/
|
|
929
872
|
calculateHammingDistance(hex1, hex2) {
|
|
930
873
|
let distance = 0;
|
|
@@ -937,26 +880,24 @@ var HashManager = class {
|
|
|
937
880
|
return distance;
|
|
938
881
|
}
|
|
939
882
|
/**
|
|
940
|
-
* @description
|
|
941
|
-
* @param hex1 -
|
|
942
|
-
* @param hex2 -
|
|
943
|
-
* @returns
|
|
883
|
+
* @description 根据汉明距离计算相似度百分比。
|
|
884
|
+
* @param hex1 - 第一个哈希。
|
|
885
|
+
* @param hex2 - 第二个哈希。
|
|
886
|
+
* @returns 相似度 (0-100)。
|
|
944
887
|
*/
|
|
945
888
|
calculateSimilarity(hex1, hex2) {
|
|
946
889
|
const distance = this.calculateHammingDistance(hex1, hex2);
|
|
947
890
|
const hashLength = Math.max(hex1.length, hex2.length) * 4;
|
|
948
|
-
return hashLength === 0 ?
|
|
891
|
+
return hashLength === 0 ? 100 : (1 - distance / hashLength) * 100;
|
|
949
892
|
}
|
|
950
893
|
/**
|
|
951
|
-
* @description
|
|
894
|
+
* @description 为文本生成 64 位 Simhash 字符串。
|
|
952
895
|
* @param text - 需要处理的文本。
|
|
953
|
-
* @returns
|
|
896
|
+
* @returns 16位十六进制 Simhash 字符串。
|
|
954
897
|
*/
|
|
955
898
|
generateTextSimhash(text) {
|
|
956
899
|
const cleanText = (text || "").toLowerCase().replace(/\s+/g, "");
|
|
957
|
-
if (!cleanText)
|
|
958
|
-
return "";
|
|
959
|
-
}
|
|
900
|
+
if (!cleanText) return "";
|
|
960
901
|
const n = 2;
|
|
961
902
|
const tokens = /* @__PURE__ */ new Set();
|
|
962
903
|
if (cleanText.length < n) {
|
|
@@ -967,9 +908,7 @@ var HashManager = class {
|
|
|
967
908
|
}
|
|
968
909
|
}
|
|
969
910
|
const tokenArray = Array.from(tokens);
|
|
970
|
-
if (tokenArray.length === 0)
|
|
971
|
-
return "";
|
|
972
|
-
}
|
|
911
|
+
if (tokenArray.length === 0) return "";
|
|
973
912
|
const vector = new Array(64).fill(0);
|
|
974
913
|
tokenArray.forEach((token) => {
|
|
975
914
|
const hash = crypto.createHash("md5").update(token).digest();
|
|
@@ -983,8 +922,8 @@ var HashManager = class {
|
|
|
983
922
|
};
|
|
984
923
|
function hexToBinary(hex) {
|
|
985
924
|
let bin = "";
|
|
986
|
-
for (
|
|
987
|
-
bin += parseInt(
|
|
925
|
+
for (const char of hex) {
|
|
926
|
+
bin += parseInt(char, 16).toString(2).padStart(4, "0");
|
|
988
927
|
}
|
|
989
928
|
return bin;
|
|
990
929
|
}
|
|
@@ -1018,9 +957,8 @@ var Config = import_koishi3.Schema.intersect([
|
|
|
1018
957
|
import_koishi3.Schema.object({
|
|
1019
958
|
enableReview: import_koishi3.Schema.boolean().default(false).description("启用审核"),
|
|
1020
959
|
enableSimilarity: import_koishi3.Schema.boolean().default(false).description("启用查重"),
|
|
1021
|
-
textThreshold: import_koishi3.Schema.number().min(0).max(
|
|
1022
|
-
imageWholeThreshold: import_koishi3.Schema.number().min(0).max(
|
|
1023
|
-
imagePartThreshold: import_koishi3.Schema.number().min(0).max(1).step(0.01).default(0.95).description("图片局部相似度阈值")
|
|
960
|
+
textThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("文本相似度阈值 (%)"),
|
|
961
|
+
imageWholeThreshold: import_koishi3.Schema.number().min(0).max(100).step(0.01).default(95).description("图片相似度阈值 (%)")
|
|
1024
962
|
}).description("复核配置"),
|
|
1025
963
|
import_koishi3.Schema.object({
|
|
1026
964
|
localPath: import_koishi3.Schema.string().description("文件映射路径"),
|
|
@@ -1097,7 +1035,7 @@ function apply(ctx, config) {
|
|
|
1097
1035
|
for (const existing of existingTextHashes) {
|
|
1098
1036
|
const similarity = hashManager.calculateSimilarity(newSimhash, existing.hash);
|
|
1099
1037
|
if (similarity >= config.textThreshold) {
|
|
1100
|
-
return `文本与回声洞(${existing.cave}
|
|
1038
|
+
return `文本与回声洞(${existing.cave})的相似度(${similarity.toFixed(2)}%)超过阈值`;
|
|
1101
1039
|
}
|
|
1102
1040
|
}
|
|
1103
1041
|
textHashesToStore.push({ hash: newSimhash, type: "simhash" });
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
|
|
9
9
|
- **丰富的内容形式**:不止于文本,轻松发布包含图片、视频、音频甚至文件的混合内容。插件能自动解析回复或引用的消息,并将其完整存入回声洞。
|
|
10
10
|
- **灵活的存储后端**:媒体文件可存储在 **本地服务器** (`data/cave` 目录),或配置使用 **AWS S3** 兼容的云端对象存储。支持通过公共URL、本地文件路径或Base64三种方式发送媒体。
|
|
11
|
-
-
|
|
11
|
+
- **高级内容查重**:(可选) 启用后,插件会在添加时自动计算文本(Simhash)和图片(pHash,包括整体及分块哈希)的哈希值,拒绝与现有内容相似度过高的投稿,并能对局部相似的图片进行提示,有效防止重复。
|
|
12
12
|
- **完善的审核机制**:(可选) 启用后,所有新投稿都将进入待审核状态,并通知管理群组。只有管理员审核通过后,内容才会对用户可见,确保内容质量。
|
|
13
13
|
- **精细的作用域**:通过 `perChannel` 配置,可设定回声洞是在所有群聊中共享(全局模式),还是在每个群聊中独立(分群模式)。
|
|
14
14
|
- **专属用户昵称**:(可选) 用户可以为自己在回声洞中的发言设置一个专属昵称,增加趣味性。
|
|
15
15
|
- **便捷的数据管理**:(可选) 管理员可通过指令轻松地将所有回声洞数据导出为 `JSON` 文件备份,或从文件中恢复数据,迁移无忧。
|
|
16
|
-
- **强大的维护工具**:管理员可以通过 `cave.hash`
|
|
16
|
+
- **强大的维护工具**:管理员可以通过 `cave.hash` 指令为历史数据批量生成哈希值,并通过 `cave.check` 获取一份所有内容的相似度报告。
|
|
17
17
|
- **权限分离**:普通用户可删除自己的投稿。审核、数据管理等高级操作则仅限在指定的 **管理群组** 内由管理员执行。
|
|
18
|
+
- **智能ID管理**:采用ID回收和空缺扫描机制,确保回声洞序号紧凑,并能高效处理大量删除和添加操作。
|
|
18
19
|
|
|
19
20
|
## 📖 指令说明
|
|
20
21
|
|
|
@@ -23,7 +24,7 @@
|
|
|
23
24
|
| 指令 | 别名/选项 | 说明 |
|
|
24
25
|
| :--- | :--- | :--- |
|
|
25
26
|
| `cave` | | 随机查看一条回声洞。 |
|
|
26
|
-
| `cave.add [内容]` | `cave -a [内容]` |
|
|
27
|
+
| `cave.add [内容]` | `cave -a [内容]` | 添加一条新的回声洞。可直接跟内容,也可回复/引用消息,或等待机器人提示后在一分钟内发送。 |
|
|
27
28
|
| `cave.view <序号>` | `cave -g <序号>` | 查看指定序号的回声洞。 |
|
|
28
29
|
| `cave.del <序号>` | `cave -r <序号>` | 删除指定序号的回声洞。仅投稿人或在管理群组内的管理员可操作。 |
|
|
29
30
|
| `cave.list` | `cave -l` | 查询并列出自己投稿过的所有回声洞序号及总数。 |
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
| `cave.review.N [序号]` | `enableReview: true` | **(仅限管理群组)** 拒绝审核。若不提供序号,则拒绝所有待审核内容。 |
|
|
42
43
|
| `cave.export` | `enableIO: true` | **(仅限管理群组)** 将所有`active`状态的回声洞导出到 `cave_export.json`。 |
|
|
43
44
|
| `cave.import` | `enableIO: true` | **(仅限管理群组)** 从 `cave_import.json` 文件中导入数据。 |
|
|
44
|
-
| `cave.hash` | `enableSimilarity: true` | **(仅限管理群组)**
|
|
45
|
+
| `cave.hash` | `enableSimilarity: true` | **(仅限管理群组)** 校验所有历史数据,为缺失哈希的回声洞补全记录。 |
|
|
46
|
+
| `cave.check` | `enableSimilarity: true` | **(仅限管理群组)** 检查所有回声洞的哈希,生成一份关于文本和图片相似度的报告。 |
|
|
45
47
|
|
|
46
48
|
## ⚙️ 配置说明
|
|
47
49
|
|
|
@@ -60,10 +62,10 @@
|
|
|
60
62
|
|
|
61
63
|
| 配置项 | 类型 | 默认值 | 说明 |
|
|
62
64
|
| :--- | :--- | :--- | :--- |
|
|
63
|
-
| `enableReview` | `boolean` | `false` |
|
|
65
|
+
| `enableReview` | `boolean` | `false` | 是否启用审核机制。启用后,新投稿将进入`pending`状态。 |
|
|
64
66
|
| `enableSimilarity` | `boolean` | `false` | 是否启用内容相似度检查(查重)。 |
|
|
65
|
-
| `textThreshold` | `number` | `
|
|
66
|
-
| `
|
|
67
|
+
| `textThreshold` | `number` | `95` | **文本**相似度阈值 (0-100)。基于Simhash汉明距离计算,超过此值将被拒绝。 |
|
|
68
|
+
| `imageWholeThreshold` | `number` | `95` | **图片整体**相似度阈值 (0-100)。基于pHash汉明距离计算,超过此值将被拒绝。 |
|
|
67
69
|
|
|
68
70
|
### 存储配置
|
|
69
71
|
|
|
@@ -83,7 +85,8 @@
|
|
|
83
85
|
1. **媒体发送方式**:插件会按以下优先级决定如何发送图片、视频等媒体文件:
|
|
84
86
|
1. **S3 公共URL**:如果 `enableS3` 和 `publicUrl` 已配置。
|
|
85
87
|
2. **本地文件路径**:如果 `localPath` 已配置。
|
|
86
|
-
3. **Base64 编码**:如果以上两项均未配置,将文件转为 Base64
|
|
87
|
-
2. **文件存储权限**:若使用本地存储,请确保 Koishi 拥有对 `data/cave` 目录的读写权限。若使用 S3,请确保 Access Key
|
|
88
|
+
3. **Base64 编码**:如果以上两项均未配置,将文件转为 Base64 发送(可能受平台大小限制或支持问题)。
|
|
89
|
+
2. **文件存储权限**:若使用本地存储,请确保 Koishi 拥有对 `data/cave` 目录的读写权限。若使用 S3,请确保 Access Key 权限和存储桶策略(如ACL设为`public-read`)配置正确。
|
|
88
90
|
3. **异步删除**:删除操作(`cave.del` 或审核拒绝)会将内容状态标记为 `delete`,然后由后台任务异步清理关联的文件和数据库条目,以避免阻塞当前指令。
|
|
89
|
-
4. **数据迁移**:导入功能会读取插件数据目录下的 `cave_import.json`。导出功能则会生成 `cave_export.json
|
|
91
|
+
4. **数据迁移**:导入功能会读取插件数据目录下的 `cave_import.json`。导出功能则会生成 `cave_export.json`。请在操作前放置或备份好相应文件。导入时,ID会从现有最大ID开始自增,不会覆盖或修改老数据。
|
|
92
|
+
5. **查重机制**:图片查重不仅比较整图,还会比较图片的四个象限。如果象限完全一致,会发送提示但不会直接拒绝,为识别拼接图、裁剪图等提供了依据。
|
package/lib/HashManager.d.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { Context, Logger } from 'koishi';
|
|
2
|
-
import { Config, CaveObject } from './index';
|
|
3
|
-
import { FileManager } from './FileManager';
|
|
4
|
-
/**
|
|
5
|
-
* @description 数据库 `cave_hash` 表的完整对象模型。
|
|
6
|
-
*/
|
|
7
|
-
export interface CaveHashObject {
|
|
8
|
-
cave: number;
|
|
9
|
-
hash: string;
|
|
10
|
-
type: 'simhash' | 'phash_color' | 'dhash_gray' | 'sub_phash_q1' | 'sub_phash_q2' | 'sub_phash_q3' | 'sub_phash_q4';
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* @class HashManager
|
|
14
|
-
* @description 封装了所有与文本和图片哈希生成、相似度比较、以及相关命令的功能。
|
|
15
|
-
* 实现了高精度的混合策略查重方案。
|
|
16
|
-
*/
|
|
17
|
-
export declare class HashManager {
|
|
18
|
-
private ctx;
|
|
19
|
-
private config;
|
|
20
|
-
private logger;
|
|
21
|
-
private fileManager;
|
|
22
|
-
/**
|
|
23
|
-
* @constructor
|
|
24
|
-
* @param ctx - Koishi 上下文,用于数据库操作。
|
|
25
|
-
* @param config - 插件配置,用于获取相似度阈值。
|
|
26
|
-
* @param logger - 日志记录器实例。
|
|
27
|
-
* @param fileManager - 文件管理器实例,用于处理历史数据。
|
|
28
|
-
*/
|
|
29
|
-
constructor(ctx: Context, config: Config, logger: Logger, fileManager: FileManager);
|
|
30
|
-
/**
|
|
31
|
-
* @description 注册与哈希校验相关的子命令。
|
|
32
|
-
* @param cave - 主 `cave` 命令实例。
|
|
33
|
-
*/
|
|
34
|
-
registerCommands(cave: any): void;
|
|
35
|
-
/**
|
|
36
|
-
* @description 检查数据库中所有回声洞,为没有哈希记录的历史数据生成哈希。
|
|
37
|
-
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
38
|
-
*/
|
|
39
|
-
generateHashesForHistoricalCaves(): Promise<string>;
|
|
40
|
-
/**
|
|
41
|
-
* @description 为单个回声洞对象生成所有类型的哈希。
|
|
42
|
-
* @param cave - 回声洞对象。
|
|
43
|
-
* @returns {Promise<CaveHashObject[]>} 生成的哈希对象数组。
|
|
44
|
-
*/
|
|
45
|
-
generateAllHashesForCave(cave: Pick<CaveObject, 'id' | 'elements'>): Promise<CaveHashObject[]>;
|
|
46
|
-
/**
|
|
47
|
-
* @description 为单个图片Buffer生成所有类型的哈希。
|
|
48
|
-
* @param imageBuffer - 图片的Buffer数据。
|
|
49
|
-
* @returns {Promise<object>} 包含所有图片哈希的对象。
|
|
50
|
-
*/
|
|
51
|
-
generateAllImageHashes(imageBuffer: Buffer): Promise<{
|
|
52
|
-
colorPHash: string;
|
|
53
|
-
dHash: string;
|
|
54
|
-
subHashes: {
|
|
55
|
-
q1: string;
|
|
56
|
-
q2: string;
|
|
57
|
-
q3: string;
|
|
58
|
-
q4: string;
|
|
59
|
-
};
|
|
60
|
-
}>;
|
|
61
|
-
/**
|
|
62
|
-
* @description 对回声洞进行混合策略的相似度与重复内容检查。
|
|
63
|
-
* @returns {Promise<string>} 一个包含操作结果的报告字符串。
|
|
64
|
-
*/
|
|
65
|
-
checkForSimilarCaves(): Promise<string>;
|
|
66
|
-
/**
|
|
67
|
-
* @description 从单通道原始像素数据计算pHash。
|
|
68
|
-
* @param channelData - 单通道的像素值数组。
|
|
69
|
-
* @param size - 图像的边长(例如16)。
|
|
70
|
-
* @returns {string} 该通道的二进制哈希字符串。
|
|
71
|
-
*/
|
|
72
|
-
private _calculateHashFromRawChannel;
|
|
73
|
-
/**
|
|
74
|
-
* @description 生成768位颜色感知哈希(Color pHash)。
|
|
75
|
-
* @param imageBuffer - 图片的 Buffer 数据。
|
|
76
|
-
* @returns {Promise<string>} 768位二进制哈希对应的192位十六进制字符串。
|
|
77
|
-
*/
|
|
78
|
-
generateColorPHash(imageBuffer: Buffer): Promise<string>;
|
|
79
|
-
/**
|
|
80
|
-
* @description 生成256位差异哈希(dHash)。
|
|
81
|
-
* @param imageBuffer - 图片的 Buffer 数据。
|
|
82
|
-
* @returns {Promise<string>} 256位二进制哈希对应的64位十六进制字符串。
|
|
83
|
-
*/
|
|
84
|
-
generateDHash(imageBuffer: Buffer): Promise<string>;
|
|
85
|
-
/**
|
|
86
|
-
* @description 将图片切割为4个象限并为每个象限生成Color pHash。
|
|
87
|
-
* @param imageBuffer - 图片的 Buffer 数据。
|
|
88
|
-
* @returns {Promise<object>} 包含四个象限哈希的对象。
|
|
89
|
-
*/
|
|
90
|
-
generateImageSubHashes(imageBuffer: Buffer): Promise<{
|
|
91
|
-
q1: string;
|
|
92
|
-
q2: string;
|
|
93
|
-
q3: string;
|
|
94
|
-
q4: string;
|
|
95
|
-
}>;
|
|
96
|
-
/**
|
|
97
|
-
* @description 计算两个十六进制哈希字符串之间的汉明距离。
|
|
98
|
-
* @param hex1 - 第一个十六进制哈希字符串。
|
|
99
|
-
* @param hex2 - 第二个十六进制哈希字符串。
|
|
100
|
-
* @returns {number} 两个哈希之间的距离。
|
|
101
|
-
*/
|
|
102
|
-
calculateHammingDistance(hex1: string, hex2: string): number;
|
|
103
|
-
/**
|
|
104
|
-
* @description 根据汉明距离计算图片或文本哈希的相似度。
|
|
105
|
-
* @param hex1 - 第一个十六进制哈希字符串。
|
|
106
|
-
* @param hex2 - 第二个十六进制哈希字符串。
|
|
107
|
-
* @returns {number} 范围在0到1之间的相似度得分。
|
|
108
|
-
*/
|
|
109
|
-
calculateSimilarity(hex1: string, hex2: string): number;
|
|
110
|
-
/**
|
|
111
|
-
* @description 为文本生成基于 Simhash 算法的哈希字符串。
|
|
112
|
-
* @param text - 需要处理的文本。
|
|
113
|
-
* @returns {string} 64位二进制 Simhash 对应的16位十六进制字符串。
|
|
114
|
-
*/
|
|
115
|
-
generateTextSimhash(text: string): string;
|
|
116
|
-
}
|
package/lib/Utils.d.ts
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
import { Context, h, Logger, Session } from 'koishi';
|
|
2
|
-
import { CaveObject, Config, StoredElement } from './index';
|
|
3
|
-
import { FileManager } from './FileManager';
|
|
4
|
-
import { HashManager, CaveHashObject } from './HashManager';
|
|
5
|
-
import { ReviewManager } from './ReviewManager';
|
|
6
|
-
/**
|
|
7
|
-
* @description 将数据库存储的 StoredElement[] 转换为 Koishi 的 h() 元素数组。
|
|
8
|
-
* @param elements 从数据库读取的元素数组。
|
|
9
|
-
* @returns 转换后的 h() 元素数组。
|
|
10
|
-
*/
|
|
11
|
-
export declare function storedFormatToHElements(elements: StoredElement[]): h[];
|
|
12
|
-
/**
|
|
13
|
-
* @description 构建一条用于发送的完整回声洞消息,处理不同存储后端的资源链接。
|
|
14
|
-
* @param cave 回声洞对象。
|
|
15
|
-
* @param config 插件配置。
|
|
16
|
-
* @param fileManager 文件管理器实例。
|
|
17
|
-
* @param logger 日志记录器实例。
|
|
18
|
-
* @returns 包含 h() 元素和字符串的消息数组。
|
|
19
|
-
*/
|
|
20
|
-
export declare function buildCaveMessage(cave: CaveObject, config: Config, fileManager: FileManager, logger: Logger): Promise<(string | h)[]>;
|
|
21
|
-
/**
|
|
22
|
-
* @description 清理数据库中标记为 'delete' 状态的回声洞及其关联文件和哈希。
|
|
23
|
-
* @param ctx Koishi 上下文。
|
|
24
|
-
* @param fileManager 文件管理器实例。
|
|
25
|
-
* @param logger 日志记录器实例。
|
|
26
|
-
* @param reusableIds 可复用 ID 的内存缓存。
|
|
27
|
-
*/
|
|
28
|
-
export declare function cleanupPendingDeletions(ctx: Context, fileManager: FileManager, logger: Logger, reusableIds: Set<number>): Promise<void>;
|
|
29
|
-
/**
|
|
30
|
-
* @description 根据配置和会话,生成数据库查询的范围条件。
|
|
31
|
-
* @param session 当前会话。
|
|
32
|
-
* @param config 插件配置。
|
|
33
|
-
* @param includeStatus 是否包含 status: 'active' 条件,默认为 true。
|
|
34
|
-
* @returns 数据库查询条件对象。
|
|
35
|
-
*/
|
|
36
|
-
export declare function getScopeQuery(session: Session, config: Config, includeStatus?: boolean): object;
|
|
37
|
-
/**
|
|
38
|
-
* @description 获取下一个可用的回声洞 ID,采用“回收ID > 扫描空缺 > 最大ID+1”策略。
|
|
39
|
-
* @param ctx Koishi 上下文。
|
|
40
|
-
* @param query 查询范围条件。
|
|
41
|
-
* @param reusableIds 可复用 ID 的内存缓存。
|
|
42
|
-
* @returns 可用的新 ID。
|
|
43
|
-
*/
|
|
44
|
-
export declare function getNextCaveId(ctx: Context, query: object, reusableIds: Set<number>): Promise<number>;
|
|
45
|
-
/**
|
|
46
|
-
* @description 检查用户是否处于指令冷却中。
|
|
47
|
-
* @returns 若在冷却中则提示字符串,否则 null。
|
|
48
|
-
*/
|
|
49
|
-
export declare function checkCooldown(session: Session, config: Config, lastUsed: Map<string, number>): string | null;
|
|
50
|
-
/**
|
|
51
|
-
* @description 更新指定频道的指令使用时间戳。
|
|
52
|
-
*/
|
|
53
|
-
export declare function updateCooldownTimestamp(session: Session, config: Config, lastUsed: Map<string, number>): void;
|
|
54
|
-
/**
|
|
55
|
-
* @description 解析消息元素,分离出文本和待下载的媒体文件。
|
|
56
|
-
* @param sourceElements 原始的 Koishi 消息元素数组。
|
|
57
|
-
* @param newId 这条回声洞的新 ID。
|
|
58
|
-
* @param session 触发操作的会话。
|
|
59
|
-
* @returns 包含数据库元素和待保存媒体列表的对象。
|
|
60
|
-
*/
|
|
61
|
-
export declare function processMessageElements(sourceElements: h[], newId: number, session: Session): Promise<{
|
|
62
|
-
finalElementsForDb: StoredElement[];
|
|
63
|
-
mediaToSave: {
|
|
64
|
-
sourceUrl: string;
|
|
65
|
-
fileName: string;
|
|
66
|
-
}[];
|
|
67
|
-
}>;
|
|
68
|
-
export declare function handleFileUploads(ctx: Context, config: Config, fileManager: FileManager, logger: Logger, reviewManager: ReviewManager, cave: CaveObject, mediaToToSave: {
|
|
69
|
-
sourceUrl: string;
|
|
70
|
-
fileName: string;
|
|
71
|
-
}[], reusableIds: Set<number>, session: Session, hashManager: HashManager, textHashesToStore: Omit<CaveHashObject, 'cave'>[]): Promise<void>;
|
package/lib/index.d.ts
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { Context, Schema } from 'koishi';
|
|
2
|
-
import { CaveHashObject } from './HashManager';
|
|
3
|
-
export declare const name = "best-cave";
|
|
4
|
-
export declare const inject: string[];
|
|
5
|
-
export declare const usage = "\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #4a6ee0;\">\uD83D\uDCCC \u63D2\u4EF6\u8BF4\u660E</h2>\n <p>\uD83D\uDCD6 <strong>\u4F7F\u7528\u6587\u6863</strong>\uFF1A\u8BF7\u70B9\u51FB\u5DE6\u4E0A\u89D2\u7684 <strong>\u63D2\u4EF6\u4E3B\u9875</strong> \u67E5\u770B\u63D2\u4EF6\u4F7F\u7528\u6587\u6863</p>\n <p>\uD83D\uDD0D <strong>\u66F4\u591A\u63D2\u4EF6</strong>\uFF1A\u53EF\u8BBF\u95EE <a href=\"https://github.com/YisRime\" style=\"color:#4a6ee0;text-decoration:none;\">\u82E1\u6DDE\u7684 GitHub</a> \u67E5\u770B\u672C\u4EBA\u7684\u6240\u6709\u63D2\u4EF6</p>\n</div>\n<div style=\"border-radius: 10px; border: 1px solid #ddd; padding: 16px; margin-bottom: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1);\">\n <h2 style=\"margin-top: 0; color: #e0574a;\">\u2764\uFE0F \u652F\u6301\u4E0E\u53CD\u9988</h2>\n <p>\uD83C\uDF1F \u559C\u6B22\u8FD9\u4E2A\u63D2\u4EF6\uFF1F\u8BF7\u5728 <a href=\"https://github.com/YisRime\" style=\"color:#e0574a;text-decoration:none;\">GitHub</a> \u4E0A\u7ED9\u6211\u4E00\u4E2A Star\uFF01</p>\n <p>\uD83D\uDC1B \u9047\u5230\u95EE\u9898\uFF1F\u8BF7\u901A\u8FC7 <strong>Issues</strong> \u63D0\u4EA4\u53CD\u9988\uFF0C\u6216\u52A0\u5165 QQ \u7FA4 <a href=\"https://qm.qq.com/q/PdLMx9Jowq\" style=\"color:#e0574a;text-decoration:none;\"><strong>855571375</strong></a> \u8FDB\u884C\u4EA4\u6D41</p>\n</div>\n";
|
|
6
|
-
/**
|
|
7
|
-
* @description 存储在数据库中的单个消息元素。
|
|
8
|
-
*/
|
|
9
|
-
export interface StoredElement {
|
|
10
|
-
type: 'text' | 'image' | 'video' | 'audio' | 'file';
|
|
11
|
-
content?: string;
|
|
12
|
-
file?: string;
|
|
13
|
-
}
|
|
14
|
-
/**
|
|
15
|
-
* @description 数据库 `cave` 表的完整对象模型。
|
|
16
|
-
*/
|
|
17
|
-
export interface CaveObject {
|
|
18
|
-
id: number;
|
|
19
|
-
elements: StoredElement[];
|
|
20
|
-
channelId: string;
|
|
21
|
-
userId: string;
|
|
22
|
-
userName: string;
|
|
23
|
-
status: 'active' | 'delete' | 'pending' | 'preload';
|
|
24
|
-
time: Date;
|
|
25
|
-
}
|
|
26
|
-
declare module 'koishi' {
|
|
27
|
-
interface Tables {
|
|
28
|
-
cave: CaveObject;
|
|
29
|
-
cave_hash: CaveHashObject;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
export interface Config {
|
|
33
|
-
coolDown: number;
|
|
34
|
-
perChannel: boolean;
|
|
35
|
-
adminChannel: string;
|
|
36
|
-
enableProfile: boolean;
|
|
37
|
-
enableIO: boolean;
|
|
38
|
-
enableReview: boolean;
|
|
39
|
-
caveFormat: string;
|
|
40
|
-
enableSimilarity: boolean;
|
|
41
|
-
textThreshold: number;
|
|
42
|
-
imageWholeThreshold: number;
|
|
43
|
-
imagePartThreshold: number;
|
|
44
|
-
localPath?: string;
|
|
45
|
-
enableS3: boolean;
|
|
46
|
-
endpoint?: string;
|
|
47
|
-
region?: string;
|
|
48
|
-
accessKeyId?: string;
|
|
49
|
-
secretAccessKey?: string;
|
|
50
|
-
bucket?: string;
|
|
51
|
-
publicUrl?: string;
|
|
52
|
-
}
|
|
53
|
-
export declare const Config: Schema<Config>;
|
|
54
|
-
export declare function apply(ctx: Context, config: Config): void;
|