rocket.chat.picsearcher/PicSearcherApp.ts
Nanako 15968a8ec6 修复可能的崩溃
修复ApiKey配置消失
2024-12-10 13:02:15 +08:00

564 lines
17 KiB
TypeScript
Raw Permalink 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, IConfigurationModify,
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 {ISetting, SettingType} from '@rocket.chat/apps-engine/definition/settings';
import {IUser} from '@rocket.chat/apps-engine/definition/users';
enum SourceType {
Pixiv = 'Pixiv',
DeviantArt = 'DeviantArt',
AniDB = 'AniDB',
MyAnimeList = 'MyAnimeList',
AniList = 'AniList',
BCY = 'BCY',
EHentai = 'E-Hentai',
IMDB = 'IMDB',
Twitter = 'Twitter',
Danbooru = 'Danbooru',
YandeRe = 'Yande.re',
Gelbooru = 'Gelbooru',
AnimePictures = 'AnimePictures',
Unknown = 'Unknown',
}
function getUrlType(url: string): SourceType {
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes('pixiv.net')) {
return SourceType.Pixiv;
}
if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) {
return SourceType.Twitter;
}
if (lowerUrl.includes('danbooru.donmai.us')) {
return SourceType.Danbooru;
}
if (lowerUrl.includes('yande.re')) {
return SourceType.YandeRe;
}
if (lowerUrl.includes('gelbooru.com')) {
return SourceType.Gelbooru;
}
if (lowerUrl.includes('anime-pictures.net')) {
return SourceType.AnimePictures;
}
if (lowerUrl.includes('deviantart.com')) {
return SourceType.DeviantArt;
}
if (lowerUrl.includes('anidb.net')) {
return SourceType.AniDB;
}
if (lowerUrl.includes('myanimelist.net')) {
return SourceType.MyAnimeList;
}
if (lowerUrl.includes('anilist.co')) {
return SourceType.AniList;
}
if (lowerUrl.includes('bcy.net')) {
return SourceType.BCY;
}
if (lowerUrl.includes('e-hentai.org')) {
return SourceType.EHentai;
}
if (lowerUrl.includes('imdb.com')) {
return SourceType.IMDB;
}
return SourceType.Unknown;
}
// 主要响应接口
interface ISauceNAOResponse {
header: IHeader;
results: Array<IResult>;
}
// 头部信息接口
interface IHeader {
// 用户ID
user_id: string;
// 账户类型
account_type: string;
// 短时限
short_limit: string;
// 长时限
long_limit: string;
// 剩余长时限
long_remaining: number;
// 剩余短时限
short_remaining: number;
// 状态
status: number;
// 请求结果
results_requested: string;
// 索引
index: Record<string, IIndexData>;
// 搜索深度
search_depth: string;
// 最小相似度
minimum_similarity: number;
// 查询图片显示
query_image_display: string;
// 查询图片
query_image: string;
// 返回结果数
results_returned: number;
}
// 索引数据接口
interface IIndexData {
// 状态
status: number;
// 父ID
parent_id: number;
// ID
id: number;
// 结果
results: number;
}
// 搜索结果接口
interface IResult {
header: IResultHeader;
data: IResultData;
}
// 结果头部信息接口
interface IResultHeader {
// 相似度
similarity: number;
// 缩略图
thumbnail: string;
// 索引ID
index_id: number;
// 索引名称
index_name: string;
dupes: number;
// 隐藏
hidden: number;
}
// 结果数据接口
interface IResultData {
ext_urls?: Array<string>;
title?: string;
// Pixiv 特有字段
pixiv_id?: number;
member_name?: string;
member_id?: number;
// DeviantArt 特有字段
da_id?: string;
author_name?: string;
author_url?: string;
// E-Hentai 特有字段
source?: string;
creator?: Array<string>;
eng_name?: string;
jp_name?: string;
// BCY 特有字段
bcy_id?: number;
member_link_id?: number;
bcy_type?: string;
// 动画特有字段
anidb_aid?: number;
mal_id?: number;
anilist_id?: number;
part?: string;
year?: string;
est_time?: string;
// 电影特有字段
imdb_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: '',
index: {},
search_depth: '',
minimum_similarity: 0,
query_image_display: '',
query_image: '',
results_returned: 0,
},
results: [],
};
let myLogger: ILogger;
let SauceNAOErrStr: string;
const sauceNAOApiKeyID = 'sauceNAO_api_key';
export class PicSearcherApp extends App implements IPostMessageSent {
private rootUrl: string = '';
private sauceNAOApiKey: string = 'ac635e5f5011234871260aac1d37ac8360ed979c';
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(this.sauceNAOApiKey, imageBlobs[0].blob, imageBlobs[0].suffix);
await sendSearchResults(modify, message.room, result, author!);
}
public async onSettingUpdated(setting: ISetting, configurationModify: IConfigurationModify, read: IRead, http: IHttp): Promise<void> {
if (setting.id === sauceNAOApiKeyID) {
this.sauceNAOApiKey = setting.value;
}
return super.onSettingUpdated(setting, configurationModify, read, http);
}
protected async extendConfiguration(configuration: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {
await configuration.settings.provideSetting({
id: sauceNAOApiKeyID,
type: SettingType.STRING,
packageValue: '',
required: true,
public: false,
i18nLabel: 'sauceNAO_api_key',
i18nDescription: 'sauceNAO_api_key_desc',
});
}
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(apiKey: string, image: Blob, suffix: string): Promise<ISauceNAOResponse> {
// 1. 创建 FormData 并附加文件
const formData = new FormData();
formData.append('file', image, 'image.' + suffix); // 文件名可以随意
formData.append('api_key', apiKey);
formData.append('output_type', '2'); // 详细输出
// 2. 发送 POST 请求到 SauceNAO
try {
const response = await fetch('https://saucenao.com/search.php', {
method: 'POST',
body: formData,
});
// 3. 解析返回的 HTML 数据
if (response.ok) {
const json = await response.text();
const result = JSON.parse(json) as ISauceNAOResponse;
SauceNAOErrStr = '';
return result;
} else {
console.error('请求失败:', response.status, response.statusText);
SauceNAOErrStr = '请求失败: ' + response.status + ' ' + response.statusText;
}
} catch (error) {
console.error('搜索请求出错:', error);
SauceNAOErrStr = '搜索请求出错: ' + error;
}
return defaultSauceNAOResponse;
}
/**
* 根据相似度计算颜色(从红到绿)
* @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: ISauceNAOResponse,
appUser: IUser,
) {
const attachments: Array<IMessageAttachment> = [];
// 将results.results按照相似度排序但如果ext_urls没有的话排在最后面
results.results.sort((a, b) => {
if (a.data.ext_urls && !b.data.ext_urls) {
return -1;
}
if (!a.data.ext_urls && b.data.ext_urls) {
return 1;
}
return b.header.similarity - a.header.similarity;
});
for (const result of results.results) {
const header = result.header;
const data = result.data;
// From percentage string to numeric value
const similarityValue = header.similarity;
if (similarityValue < 70) {
continue;
}
// Dynamically generate color
const color = getInterpolatedColor(similarityValue);
const titleText = data.title || '未知标题';
const attachmentObject: IMessageAttachment = {
title: {
value: `${titleText} (${header.similarity}%)`, // Title content
} as IMessageAttachmentTitle,
text: header.index_name,
thumbnailUrl: header.thumbnail, // Optional: Image URL
collapsed: attachments.length !== 0, // First one expanded, others collapsed
color, // Dynamically set color
};
if (!header.thumbnail) {
delete attachmentObject.thumbnailUrl;
}
attachmentObject.actions = new Array<IMessageAction>();
if (data.ext_urls) {
for (const extUrl of data.ext_urls) {
const urlType = getUrlType(extUrl);
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: urlType.toString(),
url: extUrl,
} as IMessageAction);
}
}
// Add more buttons for different sources
if (data.member_id) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: '作者Pixiv',
url: `https://www.pixiv.net/users/${data.member_id}`,
} as IMessageAction);
}
if (data.da_id) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: '作者DeviantArt',
url: data.author_url,
} as IMessageAction);
}
if (data.bcy_id) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: '作者BCY',
url: `https://bcy.net/u/${data.member_link_id}`,
} as IMessageAction);
}
if (data.anidb_aid) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: 'AniDB',
url: `https://anidb.net/anime/${data.anidb_aid}`,
} as IMessageAction);
}
if (data.mal_id) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: 'MyAnimeList',
url: `https://myanimelist.net/anime/${data.mal_id}`,
} as IMessageAction);
}
if (data.anilist_id) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: 'AniList',
url: `https://anilist.co/anime/${data.anilist_id}`,
} as IMessageAction);
}
if (data.imdb_id) {
attachmentObject.actions.push({
type: MessageActionType.BUTTON,
text: 'IMDb',
url: `https://www.imdb.com/title/${data.imdb_id}`,
} as IMessageAction);
}
if (data.member_name !== undefined) {
attachmentObject.author = {
name: data.member_name,
} as IMessageAttachmentAuthor;
}
if (data.author_name !== undefined) {
attachmentObject.author = {
name: data.author_name,
} as IMessageAttachmentAuthor;
}
if (Array.isArray(data.creator)) {
attachmentObject.author = {
name: data.creator.join(', '),
} as IMessageAttachmentAuthor;
}
if (data.eng_name !== undefined) {
attachmentObject.author = {
name: data.eng_name,
} as IMessageAttachmentAuthor;
}
if (data.jp_name !== undefined) {
attachmentObject.author = {
name: data.jp_name,
} as IMessageAttachmentAuthor;
}
attachments.push(attachmentObject);
}
const successStr = `搜索成功30秒内剩余查询次数${results.header.short_remaining}/${results.header.short_limit}24小时内剩余查询次数${results.header.long_remaining}/${results.header.long_limit}`;
const text = attachments.length > 0 ? successStr : SauceNAOErrStr.length > 0 ? SauceNAOErrStr : '没有找到相关结果';
const messageBuilder = modify.getCreator().startMessage()
.setRoom(room)
.setUsernameAlias(appUser.username)
.setText(text)
.setAttachments(attachments)
.setGroupable(true);
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('ac635e5f5011234871260aac1d37ac8360ed979c', blob, 'jpg');
console.log(result);
}
// test();
export default PicSearcherApp;