改为使用API访问查询
This commit is contained in:
parent
7371f2acdb
commit
44bbf7582e
@ -5,6 +5,67 @@ exports.processMessageImages = processMessageImages;
|
||||
exports.getFileBlob = getFileBlob;
|
||||
const App_1 = require("@rocket.chat/apps-engine/definition/App");
|
||||
const messages_1 = require("@rocket.chat/apps-engine/definition/messages");
|
||||
const settings_1 = require("@rocket.chat/apps-engine/definition/settings");
|
||||
var SourceType;
|
||||
(function (SourceType) {
|
||||
SourceType["Pixiv"] = "Pixiv";
|
||||
SourceType["DeviantArt"] = "DeviantArt";
|
||||
SourceType["AniDB"] = "AniDB";
|
||||
SourceType["MyAnimeList"] = "MyAnimeList";
|
||||
SourceType["AniList"] = "AniList";
|
||||
SourceType["BCY"] = "BCY";
|
||||
SourceType["EHentai"] = "E-Hentai";
|
||||
SourceType["IMDB"] = "IMDB";
|
||||
SourceType["Twitter"] = "Twitter";
|
||||
SourceType["Danbooru"] = "Danbooru";
|
||||
SourceType["YandeRe"] = "Yande.re";
|
||||
SourceType["Gelbooru"] = "Gelbooru";
|
||||
SourceType["AnimePictures"] = "AnimePictures";
|
||||
SourceType["Unknown"] = "Unknown";
|
||||
})(SourceType || (SourceType = {}));
|
||||
function getUrlType(url) {
|
||||
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;
|
||||
}
|
||||
const defaultSauceNAOResponse = {
|
||||
header: {
|
||||
user_id: '',
|
||||
@ -14,7 +75,7 @@ const defaultSauceNAOResponse = {
|
||||
long_remaining: 0,
|
||||
short_remaining: 0,
|
||||
status: 0,
|
||||
results_requested: 0,
|
||||
results_requested: '',
|
||||
index: {},
|
||||
search_depth: '',
|
||||
minimum_similarity: 0,
|
||||
@ -30,10 +91,12 @@ class PicSearcherApp extends App_1.App {
|
||||
constructor(info, logger, accessors) {
|
||||
super(info, logger, accessors);
|
||||
this.rootUrl = '';
|
||||
this.sauceNAOApiKey = '';
|
||||
myLogger = logger;
|
||||
}
|
||||
async initialize(configurationExtend, environmentRead) {
|
||||
this.rootUrl = await environmentRead.getEnvironmentVariables().getValueByName('ROOT_URL');
|
||||
this.sauceNAOApiKey = await environmentRead.getSettings().getValueById('SauceNAOApiKey');
|
||||
myLogger.log('rootUrl:', this.rootUrl);
|
||||
return super.initialize(configurationExtend, environmentRead);
|
||||
}
|
||||
@ -47,9 +110,26 @@ class PicSearcherApp extends App_1.App {
|
||||
}
|
||||
myLogger.info('message.attachments:', message.attachments);
|
||||
const imageBlobs = await processMessageImages(message, http, read);
|
||||
const result = await searchImageOnSauceNAO(imageBlobs[0].blob, imageBlobs[0].suffix);
|
||||
const result = await searchImageOnSauceNAO(this.sauceNAOApiKey, imageBlobs[0].blob, imageBlobs[0].suffix);
|
||||
await sendSearchResults(modify, message.room, result, author);
|
||||
}
|
||||
async onSettingUpdated(setting, configurationModify, read, http) {
|
||||
if (setting.id === 'SauceNAOApiKey') {
|
||||
this.sauceNAOApiKey = setting.value;
|
||||
}
|
||||
return super.onSettingUpdated(setting, configurationModify, read, http);
|
||||
}
|
||||
async extendConfiguration(configuration, environmentRead) {
|
||||
await configuration.settings.provideSetting({
|
||||
id: 'SauceNAOApiKey',
|
||||
type: settings_1.SettingType.STRING,
|
||||
packageValue: '',
|
||||
required: true,
|
||||
public: false,
|
||||
i18nLabel: 'searcher_api_key_label',
|
||||
i18nDescription: 'searcher_api_key_description',
|
||||
});
|
||||
}
|
||||
async sendMessage(textMessage, room, author, modify) {
|
||||
const messageBuilder = modify.getCreator().startMessage({
|
||||
text: textMessage,
|
||||
@ -92,79 +172,21 @@ async function getFileBlob(fileId, read) {
|
||||
}
|
||||
return new Blob();
|
||||
}
|
||||
async function searchImageOnSauceNAO(image, suffix) {
|
||||
async function searchImageOnSauceNAO(apiKey, image, suffix) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', image, 'image.' + suffix);
|
||||
formData.append('api_key', apiKey);
|
||||
formData.append('output_type', '2');
|
||||
try {
|
||||
const response = await fetch('https://saucenao.com/search.php', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
if (response.ok) {
|
||||
const results = [];
|
||||
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 = {
|
||||
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|Tweet ID):\s*<\/strong><a href="([^"]+)"[^>]*>(\d+)<\/a>/);
|
||||
if (sourceMatch) {
|
||||
result.sourceUrl = sourceMatch[1];
|
||||
result.id = sourceMatch[2];
|
||||
}
|
||||
const creatorMatch = block.match(/<strong>(?:Member|Author|Twitter):\s*<\/strong><a href="([^"]+)"[^>]*>(.*?)<\/a>/);
|
||||
if (creatorMatch) {
|
||||
result.creatorUrl = creatorMatch[1];
|
||||
result.creator = creatorMatch[2];
|
||||
}
|
||||
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(', ');
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
const json = await response.text();
|
||||
const result = JSON.parse(json);
|
||||
SauceNAOErrStr = '';
|
||||
return results;
|
||||
return result;
|
||||
}
|
||||
else {
|
||||
console.error('请求失败:', response.status, response.statusText);
|
||||
@ -175,7 +197,7 @@ async function searchImageOnSauceNAO(image, suffix) {
|
||||
console.error('搜索请求出错:', error);
|
||||
SauceNAOErrStr = '搜索请求出错: ' + error;
|
||||
}
|
||||
return [];
|
||||
return defaultSauceNAOResponse;
|
||||
}
|
||||
function getInterpolatedColor(similarity) {
|
||||
const clampedSimilarity = Math.max(70, Math.min(90, similarity));
|
||||
@ -187,65 +209,105 @@ function getInterpolatedColor(similarity) {
|
||||
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 attachments = [];
|
||||
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;
|
||||
const similarityValue = header.similarity;
|
||||
if (similarityValue < 70) {
|
||||
continue;
|
||||
}
|
||||
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 = {
|
||||
title: {
|
||||
value: `${result.title} (${result.similarity}%)`,
|
||||
value: `${data.title} (${header.similarity}%)`,
|
||||
},
|
||||
author: {
|
||||
name: result.creator,
|
||||
},
|
||||
text: textStr,
|
||||
thumbnailUrl: result.thumbnailUrl,
|
||||
collapsed: index !== 0,
|
||||
thumbnailUrl: header.thumbnail,
|
||||
collapsed: attachments.length !== 0,
|
||||
color,
|
||||
};
|
||||
if (!result.thumbnailUrl) {
|
||||
if (!header.thumbnail) {
|
||||
delete attachmentObject.thumbnailUrl;
|
||||
}
|
||||
if (textStr === ': ') {
|
||||
delete attachmentObject.text;
|
||||
delete attachmentObject.collapsed;
|
||||
attachmentObject.actions = new Array();
|
||||
if (data.ext_urls) {
|
||||
for (const extUrl of data.ext_urls) {
|
||||
const urlType = getUrlType(extUrl);
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: urlType.toString(),
|
||||
url: extUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (result.sourceUrl) {
|
||||
(_a = attachmentObject.actions) === null || _a === void 0 ? void 0 : _a.push({
|
||||
if (data.pixiv_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: '查看原图',
|
||||
url: result.sourceUrl,
|
||||
text: '作者Pixiv',
|
||||
url: `https://www.pixiv.net/users/${data.member_id}`,
|
||||
});
|
||||
}
|
||||
if (result.creatorUrl) {
|
||||
(_b = attachmentObject.actions) === null || _b === void 0 ? void 0 : _b.push({
|
||||
if (data.da_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: '查看作者',
|
||||
url: result.creatorUrl,
|
||||
text: '作者DeviantArt',
|
||||
url: data.author_url,
|
||||
});
|
||||
}
|
||||
if (result.characters) {
|
||||
attachmentObject.fields = [{
|
||||
short: false,
|
||||
title: 'Characters',
|
||||
value: result.characters,
|
||||
}];
|
||||
if (data.bcy_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: '作者BCY',
|
||||
url: `https://bcy.net/u/${data.member_link_id}`,
|
||||
});
|
||||
}
|
||||
return attachmentObject;
|
||||
});
|
||||
const text = results.length > 0 ? '以下是查找到的结果:' : SauceNAOErrStr.length > 0 ? SauceNAOErrStr : '没有找到相关结果';
|
||||
if (data.anidb_aid) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: 'AniDB',
|
||||
url: `https://anidb.net/anime/${data.anidb_aid}`,
|
||||
});
|
||||
}
|
||||
if (data.mal_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: 'MyAnimeList',
|
||||
url: `https://myanimelist.net/anime/${data.mal_id}`,
|
||||
});
|
||||
}
|
||||
if (data.anilist_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: 'AniList',
|
||||
url: `https://anilist.co/anime/${data.anilist_id}`,
|
||||
});
|
||||
}
|
||||
if (data.imdb_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: messages_1.MessageActionType.BUTTON,
|
||||
text: 'IMDb',
|
||||
url: `https://www.imdb.com/title/${data.imdb_id}`,
|
||||
});
|
||||
}
|
||||
attachments.push(attachmentObject);
|
||||
}
|
||||
const successStr = `搜索成功,短时间内剩余查询次数:${results.header.short_remaining}/${results.header.short_limit},长时间内剩余查询次数:${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);
|
||||
.setAttachments(attachments)
|
||||
.setGroupable(true);
|
||||
await modify.getCreator().finish(messageBuilder);
|
||||
}
|
||||
async function urlToBlob(url) {
|
||||
@ -262,7 +324,7 @@ async function urlToBlob(url) {
|
||||
}
|
||||
async function test() {
|
||||
const blob = await urlToBlob('https://www.z4a.net/images/2024/12/09/69054578_p0.jpg');
|
||||
const result = await searchImageOnSauceNAO(blob, 'jpg');
|
||||
const result = await searchImageOnSauceNAO('ac635e5f5011234871260aac1d37ac8360ed979c', blob, 'jpg');
|
||||
console.log(result);
|
||||
}
|
||||
exports.default = PicSearcherApp;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {
|
||||
IAppAccessors,
|
||||
IConfigurationExtend,
|
||||
IConfigurationExtend, IConfigurationModify,
|
||||
IEnvironmentRead,
|
||||
IHttp,
|
||||
ILogger,
|
||||
@ -20,55 +20,181 @@ import {
|
||||
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';
|
||||
|
||||
// 定义 API 响应的接口(根据 API 文档自行调整)
|
||||
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: {
|
||||
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,
|
||||
}
|
||||
}>;
|
||||
header: IHeader;
|
||||
results: Array<IResult>;
|
||||
}
|
||||
interface ISearchResult {
|
||||
similarity: string;
|
||||
|
||||
// 头部信息接口
|
||||
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;
|
||||
thumbnailUrl?: string;
|
||||
sourceUrl?: string;
|
||||
creator?: string;
|
||||
creatorUrl?: 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;
|
||||
id?: string;
|
||||
characters?: 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;
|
||||
@ -83,7 +209,7 @@ const defaultSauceNAOResponse: ISauceNAOResponse = {
|
||||
long_remaining: 0,
|
||||
short_remaining: 0,
|
||||
status: 0,
|
||||
results_requested: 0,
|
||||
results_requested: '',
|
||||
index: {},
|
||||
search_depth: '',
|
||||
minimum_similarity: 0,
|
||||
@ -99,6 +225,7 @@ let SauceNAOErrStr: string;
|
||||
|
||||
export class PicSearcherApp extends App implements IPostMessageSent {
|
||||
private rootUrl: string = '';
|
||||
private sauceNAOApiKey: string = '';
|
||||
|
||||
constructor(info: IAppInfo, logger: ILogger, accessors: IAppAccessors) {
|
||||
super(info, logger, accessors);
|
||||
@ -107,6 +234,7 @@ export class PicSearcherApp extends App implements IPostMessageSent {
|
||||
|
||||
public async initialize(configurationExtend: IConfigurationExtend, environmentRead: IEnvironmentRead): Promise<void> {
|
||||
this.rootUrl = await environmentRead.getEnvironmentVariables().getValueByName('ROOT_URL');
|
||||
this.sauceNAOApiKey = await environmentRead.getSettings().getValueById('SauceNAOApiKey');
|
||||
myLogger.log('rootUrl:', this.rootUrl);
|
||||
return super.initialize(configurationExtend, environmentRead);
|
||||
}
|
||||
@ -123,10 +251,29 @@ export class PicSearcherApp extends App implements IPostMessageSent {
|
||||
}
|
||||
myLogger.info('message.attachments:', message.attachments);
|
||||
const imageBlobs = await processMessageImages(message, http, read);
|
||||
const result = await searchImageOnSauceNAO(imageBlobs[0].blob, imageBlobs[0].suffix);
|
||||
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 === 'SauceNAOApiKey') {
|
||||
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: 'SauceNAOApiKey',
|
||||
type: SettingType.STRING,
|
||||
packageValue: '',
|
||||
required: true,
|
||||
public: false,
|
||||
i18nLabel: 'searcher_api_key_label',
|
||||
i18nDescription: 'searcher_api_key_description',
|
||||
});
|
||||
}
|
||||
|
||||
private async sendMessage(textMessage: string, room: IRoom, author: IUser, modify: IModify) {
|
||||
const messageBuilder = modify.getCreator().startMessage({
|
||||
text: textMessage,
|
||||
@ -186,10 +333,12 @@ export async function getFileBlob(fileId: string, read: IRead): Promise<Blob> {
|
||||
return new Blob();
|
||||
}
|
||||
|
||||
async function searchImageOnSauceNAO(image: Blob, suffix: string): Promise<Array<ISearchResult>> {
|
||||
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 {
|
||||
@ -200,78 +349,10 @@ async function searchImageOnSauceNAO(image: Blob, suffix: string): Promise<Array
|
||||
|
||||
// 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;
|
||||
}
|
||||
const json = await response.text();
|
||||
const result = JSON.parse(json) as ISauceNAOResponse;
|
||||
SauceNAOErrStr = '';
|
||||
return results;
|
||||
return result;
|
||||
} else {
|
||||
console.error('请求失败:', response.status, response.statusText);
|
||||
SauceNAOErrStr = '请求失败: ' + response.status + ' ' + response.statusText;
|
||||
@ -281,7 +362,7 @@ async function searchImageOnSauceNAO(image: Blob, suffix: string): Promise<Array
|
||||
SauceNAOErrStr = '搜索请求出错: ' + error;
|
||||
}
|
||||
|
||||
return [];
|
||||
return defaultSauceNAOResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -310,79 +391,120 @@ function getInterpolatedColor(similarity: number): string {
|
||||
async function sendSearchResults(
|
||||
modify: IModify,
|
||||
room: IRoom,
|
||||
results: Array<ISearchResult>,
|
||||
results: ISauceNAOResponse,
|
||||
appUser: IUser,
|
||||
) {
|
||||
const attachments: Array<IMessageAttachment> = results.map((result, index) => {
|
||||
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 = parseFloat(result.similarity.replace('%', ''));
|
||||
const similarityValue = header.similarity;
|
||||
if (similarityValue < 70) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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
|
||||
value: `${data.title} (${header.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
|
||||
thumbnailUrl: header.thumbnail, // Optional: Image URL
|
||||
collapsed: attachments.length !== 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) {
|
||||
if (!header.thumbnail) {
|
||||
delete attachmentObject.thumbnailUrl;
|
||||
}
|
||||
if (textStr === ': ') {
|
||||
delete attachmentObject.text;
|
||||
delete attachmentObject.collapsed;
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (result.sourceUrl) {
|
||||
attachmentObject.actions?.push({
|
||||
if (data.pixiv_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: MessageActionType.BUTTON,
|
||||
text: '查看原图',
|
||||
url: result.sourceUrl,
|
||||
text: '作者Pixiv',
|
||||
url: `https://www.pixiv.net/users/${data.member_id}`,
|
||||
} as IMessageAction);
|
||||
}
|
||||
if (result.creatorUrl) {
|
||||
attachmentObject.actions?.push({
|
||||
if (data.da_id) {
|
||||
attachmentObject.actions.push({
|
||||
type: MessageActionType.BUTTON,
|
||||
text: '查看作者',
|
||||
url: result.creatorUrl,
|
||||
text: '作者DeviantArt',
|
||||
url: data.author_url,
|
||||
} as IMessageAction);
|
||||
}
|
||||
if (result.characters) {
|
||||
attachmentObject.fields = [{
|
||||
short: false,
|
||||
title: 'Characters',
|
||||
value: result.characters,
|
||||
}];
|
||||
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);
|
||||
}
|
||||
|
||||
return attachmentObject;
|
||||
});
|
||||
attachments.push(attachmentObject);
|
||||
}
|
||||
|
||||
const text = results.length > 0 ? '以下是查找到的结果:' : SauceNAOErrStr.length > 0 ? SauceNAOErrStr : '没有找到相关结果';
|
||||
const successStr = `搜索成功,短时间内剩余查询次数:${results.header.short_remaining}/${results.header.short_limit},长时间内剩余查询次数:${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);
|
||||
.setAttachments(attachments)
|
||||
.setGroupable(true);
|
||||
|
||||
await modify.getCreator().finish(messageBuilder);
|
||||
}
|
||||
@ -402,7 +524,7 @@ async function urlToBlob(url: string): Promise<Blob> {
|
||||
// 从 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');
|
||||
const result = await searchImageOnSauceNAO('ac635e5f5011234871260aac1d37ac8360ed979c', blob, 'jpg');
|
||||
console.log(result);
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user