564 lines
17 KiB
TypeScript
564 lines
17 KiB
TypeScript
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;
|