Init
This commit is contained in:
commit
e10e107a74
16
.editorconfig
Normal file
16
.editorconfig
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# EditorConfig is awesome: http://EditorConfig.org
|
||||||
|
|
||||||
|
# top-most EditorConfig file
|
||||||
|
root = true
|
||||||
|
|
||||||
|
# Unix-style newlines with a newline ending every file
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
28
.gitignore
vendored
Normal file
28
.gitignore
vendored
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# ignore modules pulled in from npm
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# rc-apps package output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# JetBrains IDEs
|
||||||
|
out/
|
||||||
|
.idea/
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
._*
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
19
.rcappsconfig
Normal file
19
.rcappsconfig
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"url": "http://192.168.1.104:3010",
|
||||||
|
"username": "Nanako",
|
||||||
|
"password": "Nasiko@90",
|
||||||
|
"ignoredFiles": [
|
||||||
|
"**/README.md",
|
||||||
|
"**/package-lock.json",
|
||||||
|
"**/package.json",
|
||||||
|
"**/tslint.json",
|
||||||
|
"**/tsconfig.json",
|
||||||
|
"**/*.js",
|
||||||
|
"**/*.js.map",
|
||||||
|
"**/*.d.ts",
|
||||||
|
"**/*.spec.ts",
|
||||||
|
"**/*.test.ts",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/.*"
|
||||||
|
]
|
||||||
|
}
|
11
.vscode/extensions.json
vendored
Normal file
11
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"EditorConfig.editorconfig",
|
||||||
|
"eamodio.gitlens",
|
||||||
|
"eg2.vscode-npm-script",
|
||||||
|
"wayou.vscode-todo-highlight",
|
||||||
|
"minhthai.vscode-todo-parser",
|
||||||
|
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||||
|
"rbbit.typescript-hero"
|
||||||
|
]
|
||||||
|
}
|
233
PicSearcherApp.js
Normal file
233
PicSearcherApp.js
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.PicSearcherApp = void 0;
|
||||||
|
exports.processMessageImages = processMessageImages;
|
||||||
|
exports.getFileBlob = getFileBlob;
|
||||||
|
const App_1 = require("@rocket.chat/apps-engine/definition/App");
|
||||||
|
const defaultSauceNAOResponse = {
|
||||||
|
header: {
|
||||||
|
user_id: '',
|
||||||
|
account_type: '',
|
||||||
|
short_limit: '',
|
||||||
|
long_limit: '',
|
||||||
|
long_remaining: 0,
|
||||||
|
short_remaining: 0,
|
||||||
|
status: 0,
|
||||||
|
results_requested: 0,
|
||||||
|
index: {},
|
||||||
|
search_depth: '',
|
||||||
|
minimum_similarity: 0,
|
||||||
|
query_image_display: '',
|
||||||
|
query_image: '',
|
||||||
|
results_returned: 0,
|
||||||
|
},
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
let myLogger;
|
||||||
|
class PicSearcherApp extends App_1.App {
|
||||||
|
constructor(info, logger, accessors) {
|
||||||
|
super(info, logger, accessors);
|
||||||
|
this.rootUrl = '';
|
||||||
|
myLogger = logger;
|
||||||
|
}
|
||||||
|
async initialize(configurationExtend, environmentRead) {
|
||||||
|
this.rootUrl = await environmentRead.getEnvironmentVariables().getValueByName('ROOT_URL');
|
||||||
|
myLogger.log('rootUrl:', this.rootUrl);
|
||||||
|
return super.initialize(configurationExtend, environmentRead);
|
||||||
|
}
|
||||||
|
checkPostMessageSent(message, read, http) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
async executePostMessageSent(message, read, http, persistence, modify) {
|
||||||
|
const author = await read.getUserReader().getAppUser();
|
||||||
|
if (!message.attachments || message.attachments.length <= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
myLogger.info('message.attachments:', message.attachments);
|
||||||
|
const imageBlobs = await processMessageImages(message, http, read);
|
||||||
|
const result = await searchImageOnSauceNAO(imageBlobs[0].blob, imageBlobs[0].suffix);
|
||||||
|
await sendSearchResults(modify, message.room, result, author);
|
||||||
|
}
|
||||||
|
async sendMessage(textMessage, room, author, modify) {
|
||||||
|
const messageBuilder = modify.getCreator().startMessage({
|
||||||
|
text: textMessage,
|
||||||
|
});
|
||||||
|
messageBuilder.setSender(author);
|
||||||
|
messageBuilder.setRoom(room);
|
||||||
|
return modify.getCreator().finish(messageBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.PicSearcherApp = PicSearcherApp;
|
||||||
|
async function processMessageImages(message, http, context) {
|
||||||
|
const blobs = [];
|
||||||
|
if (!message.attachments || message.attachments.length === 0) {
|
||||||
|
throw new Error('No attachments found in the message.');
|
||||||
|
}
|
||||||
|
for (const attachment of message.attachments) {
|
||||||
|
if (attachment.imageUrl) {
|
||||||
|
try {
|
||||||
|
const fileId = attachment.imageUrl.split('/')[2];
|
||||||
|
const r = {
|
||||||
|
blob: await getFileBlob(fileId, context),
|
||||||
|
suffix: attachment.imageUrl.split('.').pop() || '',
|
||||||
|
};
|
||||||
|
blobs.push(r);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw new Error(`Error fetching image content: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return blobs;
|
||||||
|
}
|
||||||
|
async function getFileBlob(fileId, read) {
|
||||||
|
try {
|
||||||
|
const buffer = await read.getUploadReader().getBufferById(fileId);
|
||||||
|
return new Blob([buffer]);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
myLogger.error(`Error fetching file content: ${error.message}`);
|
||||||
|
}
|
||||||
|
return new Blob();
|
||||||
|
}
|
||||||
|
async function searchImageOnSauceNAO(image, suffix) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', image, 'image.' + suffix);
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://saucenao.com/search.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const results = [];
|
||||||
|
const html = await response.text();
|
||||||
|
myLogger.info('HTML:', html);
|
||||||
|
const resultBlocks = html.match(/<div class="result">\s*<table class="resulttable">([\s\S]*?)<\/table>\s*<\/div>/g) || [];
|
||||||
|
resultBlocks.forEach((block) => {
|
||||||
|
const result = {
|
||||||
|
similarity: '',
|
||||||
|
title: '',
|
||||||
|
thumbnailUrl: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
creator: '',
|
||||||
|
creatorUrl: '',
|
||||||
|
source: '',
|
||||||
|
id: '',
|
||||||
|
};
|
||||||
|
const similarityMatch = block.match(/<div class="resultsimilarityinfo">(\d+\.\d+)%<\/div>/);
|
||||||
|
if (similarityMatch) {
|
||||||
|
result.similarity = similarityMatch[1];
|
||||||
|
}
|
||||||
|
const titleMatch = block.match(/<div class="resulttitle"><strong>(.*?)<\/strong>/);
|
||||||
|
if (titleMatch) {
|
||||||
|
result.title = titleMatch[1];
|
||||||
|
}
|
||||||
|
const thumbnailMatch = block.match(/src="(https:\/\/img\d\.saucenao\.com\/[^"]+)"/);
|
||||||
|
if (thumbnailMatch) {
|
||||||
|
result.thumbnailUrl = thumbnailMatch[1];
|
||||||
|
}
|
||||||
|
const sourceMatch = block.match(/<strong>(?:pixiv ID|dA ID):\s*<\/strong><a href="([^"]+)"[^>]*>(\d+)<\/a>/);
|
||||||
|
if (sourceMatch) {
|
||||||
|
result.sourceUrl = sourceMatch[1];
|
||||||
|
result.id = sourceMatch[2];
|
||||||
|
}
|
||||||
|
const creatorMatch = block.match(/<strong>(?:Member|Author):\s*<\/strong><a href="([^"]+)"[^>]*>(.*?)<\/a>/);
|
||||||
|
if (creatorMatch) {
|
||||||
|
result.creatorUrl = creatorMatch[1];
|
||||||
|
result.creator = creatorMatch[2];
|
||||||
|
}
|
||||||
|
if (block.includes('pixiv ID:')) {
|
||||||
|
result.source = 'Pixiv';
|
||||||
|
}
|
||||||
|
else if (block.includes('dA ID:')) {
|
||||||
|
result.source = 'DeviantArt';
|
||||||
|
}
|
||||||
|
else if (block.includes('E-Hentai')) {
|
||||||
|
result.source = 'E-Hentai';
|
||||||
|
}
|
||||||
|
results.push(result);
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
console.error('请求失败:', response.status, response.statusText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error('搜索请求出错:', error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
function getInterpolatedColor(similarity) {
|
||||||
|
const clampedSimilarity = Math.max(80, Math.min(90, similarity));
|
||||||
|
const t = (clampedSimilarity - 80) / 10;
|
||||||
|
const red = Math.round(255 * (1 - t));
|
||||||
|
const green = Math.round(255 * t);
|
||||||
|
const redHex = red.toString(16).padStart(2, '0');
|
||||||
|
const greenHex = green.toString(16).padStart(2, '0');
|
||||||
|
return `#${redHex}${greenHex}00`;
|
||||||
|
}
|
||||||
|
async function sendSearchResults(modify, room, results, appUser) {
|
||||||
|
const attachments = results.map((result, index) => {
|
||||||
|
var _a, _b;
|
||||||
|
const similarityValue = parseFloat(result.similarity.replace('%', ''));
|
||||||
|
const color = getInterpolatedColor(similarityValue);
|
||||||
|
let textStr = result.source + ': ' + result.id;
|
||||||
|
if (result.sourceUrl) {
|
||||||
|
textStr += '\n' + result.sourceUrl;
|
||||||
|
}
|
||||||
|
const attachmentObject = {
|
||||||
|
title: {
|
||||||
|
value: `${result.title} (${result.similarity}%)`,
|
||||||
|
link: result.sourceUrl,
|
||||||
|
},
|
||||||
|
author: {
|
||||||
|
name: result.creator,
|
||||||
|
link: result.creatorUrl,
|
||||||
|
},
|
||||||
|
text: textStr,
|
||||||
|
thumbnailUrl: result.thumbnailUrl,
|
||||||
|
collapsed: index !== 0,
|
||||||
|
color,
|
||||||
|
};
|
||||||
|
if (!result.sourceUrl) {
|
||||||
|
(_a = attachmentObject.title) === null || _a === void 0 ? true : delete _a.link;
|
||||||
|
}
|
||||||
|
if (!result.creatorUrl) {
|
||||||
|
(_b = attachmentObject.author) === null || _b === void 0 ? true : delete _b.link;
|
||||||
|
}
|
||||||
|
if (!result.thumbnailUrl) {
|
||||||
|
delete attachmentObject.thumbnailUrl;
|
||||||
|
}
|
||||||
|
if (textStr === ': ') {
|
||||||
|
delete attachmentObject.text;
|
||||||
|
delete attachmentObject.collapsed;
|
||||||
|
}
|
||||||
|
return attachmentObject;
|
||||||
|
});
|
||||||
|
const text = results.length > 0 ? '以下是查找到的结果:' : '没有找到相关结果';
|
||||||
|
const messageBuilder = modify.getCreator().startMessage()
|
||||||
|
.setRoom(room)
|
||||||
|
.setUsernameAlias(appUser.username)
|
||||||
|
.setText(text)
|
||||||
|
.setAttachments(attachments);
|
||||||
|
await modify.getCreator().finish(messageBuilder);
|
||||||
|
}
|
||||||
|
async function urlToBlob(url) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.blob();
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw new Error(`Failed to fetch image: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function test() {
|
||||||
|
const blob = await urlToBlob('https://www.z4a.net/images/2024/12/09/69054578_p0.jpg');
|
||||||
|
const result = await searchImageOnSauceNAO(blob, 'jpg');
|
||||||
|
console.log(result);
|
||||||
|
}
|
||||||
|
exports.default = PicSearcherApp;
|
367
PicSearcherApp.ts
Normal file
367
PicSearcherApp.ts
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
import {
|
||||||
|
IAppAccessors,
|
||||||
|
IConfigurationExtend,
|
||||||
|
IEnvironmentRead,
|
||||||
|
IHttp,
|
||||||
|
ILogger,
|
||||||
|
IModify,
|
||||||
|
IPersistence,
|
||||||
|
IRead,
|
||||||
|
} from '@rocket.chat/apps-engine/definition/accessors';
|
||||||
|
import {App} from '@rocket.chat/apps-engine/definition/App';
|
||||||
|
import {
|
||||||
|
IMessage,
|
||||||
|
IMessageAttachment,
|
||||||
|
IMessageAttachmentAuthor,
|
||||||
|
IMessageAttachmentTitle,
|
||||||
|
IPostMessageSent,
|
||||||
|
} from '@rocket.chat/apps-engine/definition/messages';
|
||||||
|
import {IAppInfo} from '@rocket.chat/apps-engine/definition/metadata';
|
||||||
|
import {IRoom} from '@rocket.chat/apps-engine/definition/rooms';
|
||||||
|
import {IUser} from '@rocket.chat/apps-engine/definition/users';
|
||||||
|
|
||||||
|
// 定义 API 响应的接口(根据 API 文档自行调整)
|
||||||
|
interface ISauceNAOResponse {
|
||||||
|
header: {
|
||||||
|
user_id: string;
|
||||||
|
account_type: string;
|
||||||
|
short_limit: string;
|
||||||
|
long_limit: string;
|
||||||
|
long_remaining: number;
|
||||||
|
short_remaining: number;
|
||||||
|
status: number;
|
||||||
|
results_requested: number;
|
||||||
|
index: any;
|
||||||
|
search_depth: string;
|
||||||
|
minimum_similarity: number;
|
||||||
|
query_image_display: string;
|
||||||
|
query_image: string;
|
||||||
|
results_returned: number;
|
||||||
|
};
|
||||||
|
results: Array<{
|
||||||
|
header: {
|
||||||
|
similarity: string;
|
||||||
|
thumbnail: string;
|
||||||
|
index_id: number;
|
||||||
|
index_name: string;
|
||||||
|
dupes: number;
|
||||||
|
hidden: number;
|
||||||
|
};
|
||||||
|
data: {
|
||||||
|
ext_urls: Array<string>,
|
||||||
|
title: string,
|
||||||
|
da_id: string,
|
||||||
|
author_name: string,
|
||||||
|
author_url: string,
|
||||||
|
}
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
interface ISearchResult {
|
||||||
|
similarity: string;
|
||||||
|
title?: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
sourceUrl?: string;
|
||||||
|
creator?: string;
|
||||||
|
creatorUrl?: string;
|
||||||
|
source?: string;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
interface IImageBlob {
|
||||||
|
blob: Blob;
|
||||||
|
suffix: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSauceNAOResponse: ISauceNAOResponse = {
|
||||||
|
header: {
|
||||||
|
user_id: '',
|
||||||
|
account_type: '',
|
||||||
|
short_limit: '',
|
||||||
|
long_limit: '',
|
||||||
|
long_remaining: 0,
|
||||||
|
short_remaining: 0,
|
||||||
|
status: 0,
|
||||||
|
results_requested: 0,
|
||||||
|
index: {},
|
||||||
|
search_depth: '',
|
||||||
|
minimum_similarity: 0,
|
||||||
|
query_image_display: '',
|
||||||
|
query_image: '',
|
||||||
|
results_returned: 0,
|
||||||
|
},
|
||||||
|
results: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
let myLogger: ILogger;
|
||||||
|
|
||||||
|
export class PicSearcherApp extends App implements IPostMessageSent {
|
||||||
|
private rootUrl: string = '';
|
||||||
|
|
||||||
|
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
|
||||||
|
super(info, logger, accessors);
|
||||||
|
myLogger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async initialize(configurationExtend: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {
|
||||||
|
this.rootUrl = await environmentRead.getEnvironmentVariables().getValueByName('ROOT_URL');
|
||||||
|
myLogger.log('rootUrl:', this.rootUrl);
|
||||||
|
return super.initialize(configurationExtend, environmentRead);
|
||||||
|
}
|
||||||
|
|
||||||
|
public checkPostMessageSent?(message: IMessage, read: IRead, http: IHttp): Promise<boolean> {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async executePostMessageSent(message: IMessage, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise<void> {
|
||||||
|
const author = await read.getUserReader().getAppUser();
|
||||||
|
// 尝试获取imageurl, 如果image大于20MB则使用缩略图
|
||||||
|
if (!message.attachments || message.attachments.length <= 0) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
myLogger.info('message.attachments:', message.attachments);
|
||||||
|
const imageBlobs = await processMessageImages(message, http, read);
|
||||||
|
const result = await searchImageOnSauceNAO(imageBlobs[0].blob, imageBlobs[0].suffix);
|
||||||
|
await sendSearchResults(modify, message.room, result, author!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendMessage(textMessage: string, room: IRoom, author: IUser, modify: IModify) {
|
||||||
|
const messageBuilder = modify.getCreator().startMessage({
|
||||||
|
text: textMessage,
|
||||||
|
} as IMessage);
|
||||||
|
messageBuilder.setSender(author);
|
||||||
|
messageBuilder.setRoom(room);
|
||||||
|
return modify.getCreator().finish(messageBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主函数:处理消息中的所有图片,并返回 Blob 数组
|
||||||
|
* @param message IMessage 消息对象
|
||||||
|
* @param http IHttp HTTP 访问接口
|
||||||
|
* @param context IRead 读取接口
|
||||||
|
* @returns Promise<Blob[]> 包含所有图片 Blob 的数组
|
||||||
|
*/
|
||||||
|
export async function processMessageImages(message: IMessage, http: IHttp, context: IRead): Promise<Array<IImageBlob>> {
|
||||||
|
const blobs: Array<IImageBlob> = [];
|
||||||
|
|
||||||
|
// 检查消息是否包含附件
|
||||||
|
if (!message.attachments || message.attachments.length === 0) {
|
||||||
|
throw new Error('No attachments found in the message.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 遍历附件并处理图片
|
||||||
|
for (const attachment of message.attachments) {
|
||||||
|
if (attachment.imageUrl) {
|
||||||
|
try {
|
||||||
|
const fileId = attachment.imageUrl.split('/')[2];
|
||||||
|
const r: IImageBlob = {
|
||||||
|
// 下载图片并转换为 Blob
|
||||||
|
blob: await getFileBlob(fileId, context),
|
||||||
|
// 获取文件后缀
|
||||||
|
suffix: attachment.imageUrl.split('.').pop() || '',
|
||||||
|
};
|
||||||
|
blobs.push(r);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Error fetching image content: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return blobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅助函数:获取文件Blob对象
|
||||||
|
*/
|
||||||
|
export async function getFileBlob(fileId: string, read: IRead): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const buffer = await read.getUploadReader().getBufferById(fileId);
|
||||||
|
return new Blob([buffer]);
|
||||||
|
} catch (error) {
|
||||||
|
myLogger.error(`Error fetching file content: ${error.message}`);
|
||||||
|
}
|
||||||
|
return new Blob();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchImageOnSauceNAO(image: Blob, suffix: string) {
|
||||||
|
// 1. 创建 FormData 并附加文件
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', image, 'image.' + suffix); // 文件名可以随意
|
||||||
|
|
||||||
|
// 2. 发送 POST 请求到 SauceNAO
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://saucenao.com/search.php', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 解析返回的 HTML 数据
|
||||||
|
if (response.ok) {
|
||||||
|
const results: Array<ISearchResult> = [];
|
||||||
|
const html = await response.text();
|
||||||
|
myLogger.info('HTML:', html);
|
||||||
|
// 匹配所有结果块,包括隐藏的
|
||||||
|
const resultBlocks = html.match(/<div class="result">\s*<table class="resulttable">([\s\S]*?)<\/table>\s*<\/div>/g) || [];
|
||||||
|
|
||||||
|
resultBlocks.forEach((block) => {
|
||||||
|
const result: ISearchResult = {
|
||||||
|
similarity: '',
|
||||||
|
title: '',
|
||||||
|
thumbnailUrl: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
creator: '',
|
||||||
|
creatorUrl: '',
|
||||||
|
source: '',
|
||||||
|
id: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract similarity
|
||||||
|
const similarityMatch = block.match(/<div class="resultsimilarityinfo">(\d+\.\d+)%<\/div>/);
|
||||||
|
if (similarityMatch) { result.similarity = similarityMatch[1]; }
|
||||||
|
|
||||||
|
// Extract title
|
||||||
|
const titleMatch = block.match(/<div class="resulttitle"><strong>(.*?)<\/strong>/);
|
||||||
|
if (titleMatch) { result.title = titleMatch[1]; }
|
||||||
|
|
||||||
|
// Extract thumbnail URL
|
||||||
|
const thumbnailMatch = block.match(/src="(https:\/\/img\d\.saucenao\.com\/[^"]+)"/);
|
||||||
|
if (thumbnailMatch) { result.thumbnailUrl = thumbnailMatch[1]; }
|
||||||
|
|
||||||
|
// Extract source URL and ID
|
||||||
|
const sourceMatch = block.match(/<strong>(?:pixiv ID|dA ID):\s*<\/strong><a href="([^"]+)"[^>]*>(\d+)<\/a>/);
|
||||||
|
if (sourceMatch) {
|
||||||
|
result.sourceUrl = sourceMatch[1];
|
||||||
|
result.id = sourceMatch[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract creator and creator URL
|
||||||
|
const creatorMatch = block.match(/<strong>(?:Member|Author):\s*<\/strong><a href="([^"]+)"[^>]*>(.*?)<\/a>/);
|
||||||
|
if (creatorMatch) {
|
||||||
|
result.creatorUrl = creatorMatch[1];
|
||||||
|
result.creator = creatorMatch[2];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine source
|
||||||
|
if (block.includes('pixiv ID:')) {
|
||||||
|
result.source = 'Pixiv';
|
||||||
|
} else if (block.includes('dA ID:')) {
|
||||||
|
result.source = 'DeviantArt';
|
||||||
|
} else if (block.includes('E-Hentai')) {
|
||||||
|
result.source = 'E-Hentai';
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push(result);
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
} else {
|
||||||
|
console.error('请求失败:', response.status, response.statusText);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索请求出错:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据相似度计算颜色(从红到绿)
|
||||||
|
* @param similarity 相似度,范围 80% ~ 90%
|
||||||
|
* @returns 十六进制颜色字符串
|
||||||
|
*/
|
||||||
|
function getInterpolatedColor(similarity: number): string {
|
||||||
|
// 确保相似度在 80% 到 90% 之间
|
||||||
|
const clampedSimilarity = Math.max(80, Math.min(90, similarity));
|
||||||
|
|
||||||
|
// 将相似度映射到 0-1 范围
|
||||||
|
const t = (clampedSimilarity - 80) / 10;
|
||||||
|
|
||||||
|
// 计算红色和绿色分量
|
||||||
|
const red = Math.round(255 * (1 - t));
|
||||||
|
const green = Math.round(255 * t);
|
||||||
|
|
||||||
|
// 将 RGB 值转换为十六进制颜色字符串
|
||||||
|
const redHex = red.toString(16).padStart(2, '0');
|
||||||
|
const greenHex = green.toString(16).padStart(2, '0');
|
||||||
|
|
||||||
|
return `#${redHex}${greenHex}00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSearchResults(
|
||||||
|
modify: IModify,
|
||||||
|
room: IRoom,
|
||||||
|
results: Array<ISearchResult>,
|
||||||
|
appUser: IUser,
|
||||||
|
) {
|
||||||
|
const attachments: Array<IMessageAttachment> = results.map((result, index) => {
|
||||||
|
// From percentage string to numeric value
|
||||||
|
const similarityValue = parseFloat(result.similarity.replace('%', ''));
|
||||||
|
|
||||||
|
// Dynamically generate color
|
||||||
|
const color = getInterpolatedColor(similarityValue);
|
||||||
|
let textStr = result.source + ': ' + result.id;
|
||||||
|
if (result.sourceUrl) {
|
||||||
|
textStr += '\n' + result.sourceUrl;
|
||||||
|
}
|
||||||
|
const attachmentObject: IMessageAttachment = {
|
||||||
|
title: {
|
||||||
|
value: `${result.title} (${result.similarity}%)`, // Title content
|
||||||
|
link: result.sourceUrl, // Title link
|
||||||
|
} as IMessageAttachmentTitle,
|
||||||
|
author: {
|
||||||
|
name: result.creator, // Source name
|
||||||
|
link: result.creatorUrl, // Source link
|
||||||
|
} as IMessageAttachmentAuthor,
|
||||||
|
text: textStr, // Image ID
|
||||||
|
thumbnailUrl: result.thumbnailUrl, // Optional: Image URL
|
||||||
|
collapsed: index !== 0, // First one expanded, others collapsed
|
||||||
|
color, // Dynamically set color
|
||||||
|
};
|
||||||
|
if (!result.sourceUrl) {
|
||||||
|
delete attachmentObject.title?.link;
|
||||||
|
}
|
||||||
|
if (!result.creatorUrl) {
|
||||||
|
delete attachmentObject.author?.link;
|
||||||
|
}
|
||||||
|
if (!result.thumbnailUrl) {
|
||||||
|
delete attachmentObject.thumbnailUrl;
|
||||||
|
}
|
||||||
|
if (textStr === ': ') {
|
||||||
|
delete attachmentObject.text;
|
||||||
|
delete attachmentObject.collapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return attachmentObject;
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = results.length > 0 ? '以下是查找到的结果:' : '没有找到相关结果';
|
||||||
|
|
||||||
|
const messageBuilder = modify.getCreator().startMessage()
|
||||||
|
.setRoom(room)
|
||||||
|
.setUsernameAlias(appUser.username)
|
||||||
|
.setText(text)
|
||||||
|
.setAttachments(attachments);
|
||||||
|
|
||||||
|
await modify.getCreator().finish(messageBuilder);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function urlToBlob(url: string): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.blob();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Failed to fetch image: ${error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 URL 获取图片 Blob
|
||||||
|
async function test() {
|
||||||
|
const blob = await urlToBlob('https://www.z4a.net/images/2024/12/09/69054578_p0.jpg');
|
||||||
|
const result = await searchImageOnSauceNAO(blob, 'jpg');
|
||||||
|
console.log(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// test();
|
||||||
|
|
||||||
|
export default PicSearcherApp;
|
18
app.json
Normal file
18
app.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"id": "bf4f0997-e7b7-40ff-b2b7-ba2ce5a15542",
|
||||||
|
"version": "0.0.2",
|
||||||
|
"requiredApiVersion": "^1.44.0",
|
||||||
|
"iconFile": "favicon.png",
|
||||||
|
"author": {
|
||||||
|
"name": "nanako",
|
||||||
|
"homepage": "nanako.site",
|
||||||
|
"support": "null"
|
||||||
|
},
|
||||||
|
"name": "picsearcher",
|
||||||
|
"nameSlug": "picsearcher",
|
||||||
|
"classFile": "PicSearcherApp.ts",
|
||||||
|
"description": "图片来源搜索",
|
||||||
|
"implements": [
|
||||||
|
"IPostMessageSent"
|
||||||
|
]
|
||||||
|
}
|
BIN
favicon.png
Normal file
BIN
favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 352 B |
1610
package-lock.json
generated
Normal file
1610
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
12
package.json
Normal file
12
package.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"@rocket.chat/apps-engine": "^1.44.0",
|
||||||
|
"@types/node": "14.14.6",
|
||||||
|
"tslint": "^5.10.0",
|
||||||
|
"typescript": "^5.6.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@rocket.chat/icons": "^0.38.0",
|
||||||
|
"@rocket.chat/ui-kit": "^0.36.1"
|
||||||
|
}
|
||||||
|
}
|
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2018",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"declaration": false,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"removeComments": true,
|
||||||
|
"strictNullChecks": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
15
tslint.json
Normal file
15
tslint.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"extends": "tslint:recommended",
|
||||||
|
"rules": {
|
||||||
|
"array-type": [true, "generic"],
|
||||||
|
"member-access": true,
|
||||||
|
"no-console": [false],
|
||||||
|
"no-duplicate-variable": true,
|
||||||
|
"object-literal-sort-keys": false,
|
||||||
|
"quotemark": [true, "single"],
|
||||||
|
"max-line-length": [true, {
|
||||||
|
"limit": 160,
|
||||||
|
"ignore-pattern": "^import | *export .*? {"
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user