rocket.chat.picsearcher/PicSearcherApp.ts
Nanako 7371f2acdb 修改:将颜色插值范围改到70%到90%
新增:当请求失败时发送日志
修改:将原图链接和作者链接移动到按钮中
修改:添加图片角色信息
2024-12-09 23:44:05 +08:00

412 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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;
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<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): Promise<Array<ISearchResult>> {
// 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();
// 匹配所有结果块,包括隐藏的
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|Tweet 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|Twitter):\s*<\/strong><a href="([^"]+)"[^>]*>(.*?)<\/a>/);
if (creatorMatch) {
result.creatorUrl = creatorMatch[1];
result.creator = creatorMatch[2];
}
// Extract Characters
const characterSection = block.match(/<strong>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<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;
}
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<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;