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, MessageActionType, } from '@rocket.chat/apps-engine/definition/messages'; import type {IMessageAction} from '@rocket.chat/apps-engine/definition/messages/IMessageAction'; 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, 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; characters?: 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; let SauceNAOErrStr: string; 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 { 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 { return Promise.resolve(true); } public async executePostMessageSent(message: IMessage, read: IRead, http: IHttp, persistence: IPersistence, modify: IModify): Promise { 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 的数组 */ export async function processMessageImages(message: IMessage, http: IHttp, context: IRead): Promise> { const blobs: Array = []; // 检查消息是否包含附件 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 { 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): Promise> { // 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 = []; const html = await response.text(); // 匹配所有结果块,包括隐藏的 const resultBlocks = html.match(/
\s*([\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(/
(\d+\.\d+)%<\/div>/); if (similarityMatch) { result.similarity = similarityMatch[1]; } // Extract title const titleMatch = block.match(/
(.*?)<\/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(/(?:pixiv ID|dA ID|Tweet ID):\s*<\/strong>]*>(\d+)<\/a>/); if (sourceMatch) { result.sourceUrl = sourceMatch[1]; result.id = sourceMatch[2]; } // Extract creator and creator URL const creatorMatch = block.match(/(?:Member|Author|Twitter):\s*<\/strong>]*>(.*?)<\/a>/); if (creatorMatch) { result.creatorUrl = creatorMatch[1]; result.creator = creatorMatch[2]; } // Extract Characters const characterSection = block.match(/Characters:\s*<\/strong>(.*?)<(?:br|\/div)/); if (characterSection && characterSection[1]) { const chars = characterSection[1].match(/"([^"]+)"/g); if (chars) { result.characters = chars .map((char) => char.replace(/"/g, '')) .join(', '); } } // 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'; } else if (block.includes('Twitter')) { result.source = 'Twitter'; } results.push(result); }); if (results.length === 0) { SauceNAOErrStr = html; } SauceNAOErrStr = ''; return results; } else { console.error('请求失败:', response.status, response.statusText); SauceNAOErrStr = '请求失败: ' + response.status + ' ' + response.statusText; } } catch (error) { console.error('搜索请求出错:', error); SauceNAOErrStr = '搜索请求出错: ' + error; } return []; } /** * 根据相似度计算颜色(从红到绿) * @param similarity 相似度,范围 70% ~ 90% * @returns 十六进制颜色字符串 */ function getInterpolatedColor(similarity: number): string { // 确保相似度在 70% 到 90% 之间 const clampedSimilarity = Math.max(70, Math.min(90, similarity)); // 将相似度映射到 0-1 范围 const t = (clampedSimilarity - 70) / 20; // 计算红色和绿色分量 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, appUser: IUser, ) { const attachments: Array = 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; } if (result.characters) { textStr += '\n' + 'Characters: ' + result.characters; } const attachmentObject: IMessageAttachment = { title: { value: `${result.title} (${result.similarity}%)`, // Title content } as IMessageAttachmentTitle, author: { name: result.creator, // Source name } 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; } if (result.sourceUrl) { attachmentObject.actions?.push({ type: MessageActionType.BUTTON, text: '查看原图', url: result.sourceUrl, } as IMessageAction); } if (result.creatorUrl) { attachmentObject.actions?.push({ type: MessageActionType.BUTTON, text: '查看作者', url: result.creatorUrl, } as IMessageAction); } if (result.characters) { attachmentObject.fields = [{ short: false, title: 'Characters', value: result.characters, }]; } return attachmentObject; }); const text = results.length > 0 ? '以下是查找到的结果:' : SauceNAOErrStr.length > 0 ? SauceNAOErrStr : '没有找到相关结果'; 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 { 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;