nakama/backend/routes/search.js

462 lines
17 KiB
JavaScript
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.

const express = require('express');
const router = express.Router();
const axios = require('axios');
const { authenticate } = require('../middleware/auth');
const { proxyLimiter } = require('../middleware/rateLimiter');
const config = require('../config');
// e621 требует описательный User-Agent с контактами
const E621_USER_AGENT = 'NakamaApp/1.0 (by glpshchn00 on e621; Telegram: @glpshchn00)';
const CACHE_TTL_MS = 60 * 1000; // 1 минута
const searchCache = new Map();
function getCacheKey(source, params) {
return `${source}:${params.query}:${params.limit || ''}:${params.page || ''}`;
}
function getFromCache(key) {
const entry = searchCache.get(key);
if (!entry) return null;
if (entry.expires < Date.now()) {
searchCache.delete(key);
return null;
}
return entry.data;
}
function setCache(key, data) {
if (searchCache.size > 200) {
// Удалить устаревшие записи, если превышен лимит
for (const [cacheKey, entry] of searchCache.entries()) {
if (entry.expires < Date.now()) {
searchCache.delete(cacheKey);
}
}
if (searchCache.size > 200) {
const oldestKey = searchCache.keys().next().value;
if (oldestKey) {
searchCache.delete(oldestKey);
}
}
}
searchCache.set(key, {
data,
expires: Date.now() + CACHE_TTL_MS
});
}
// Функция для создания прокси URL
function createProxyUrl(originalUrl) {
if (!originalUrl) return null;
// Кодируем URL в base64
const encodedUrl = Buffer.from(originalUrl).toString('base64');
return `/api/search/proxy/${encodedUrl}`;
}
// Эндпоинт для проксирования изображений
// Используем более мягкий rate limiter для прокси
router.get('/proxy/:encodedUrl', proxyLimiter, async (req, res) => {
try {
const { encodedUrl } = req.params;
// Декодируем URL
const originalUrl = Buffer.from(encodedUrl, 'base64').toString('utf-8');
// Проверяем, что URL от разрешенных доменов
const allowedDomains = [
'e621.net',
'static1.e621.net',
'gelbooru.com',
'img3.gelbooru.com',
'img2.gelbooru.com',
'img1.gelbooru.com',
'simg3.gelbooru.com',
'simg4.gelbooru.com'
];
const urlObj = new URL(originalUrl);
if (!allowedDomains.some(domain => urlObj.hostname.includes(domain))) {
return res.status(403).json({ error: 'Запрещенный домен' });
}
// Запрашиваем изображение
// Для e621 добавляем авторизацию
const headers = {
'User-Agent': E621_USER_AGENT,
'Referer': urlObj.origin
};
// Если это e621, добавляем авторизацию
if (urlObj.hostname.includes('e621.net')) {
const auth = Buffer.from(`${config.e621Username}:${config.e621ApiKey}`).toString('base64');
headers['Authorization'] = `Basic ${auth}`;
}
const response = await axios.get(originalUrl, {
responseType: 'stream',
headers,
timeout: 30000 // 30 секунд таймаут
});
// Копируем заголовки
res.setHeader('Content-Type', response.headers['content-type']);
res.setHeader('Cache-Control', 'public, max-age=86400'); // Кешируем на 24 часа
if (response.headers['content-length']) {
res.setHeader('Content-Length', response.headers['content-length']);
}
// Стримим изображение
response.data.pipe(res);
} catch (error) {
console.error('Ошибка проксирования изображения:', error.message);
res.status(500).json({ error: 'Ошибка загрузки изображения' });
}
});
// e621 API поиск
router.get('/furry', authenticate, async (req, res) => {
try {
const { query, limit = 320, page = 1 } = req.query; // e621 поддерживает до 320 постов на страницу
if (!query) {
return res.status(400).json({ error: 'Параметр query обязателен' });
}
const cacheKey = getCacheKey('e621', { query: query.trim(), limit, page });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
// Поддержка множественных тегов через пробел
// e621 API автоматически обрабатывает теги через пробел в параметре tags
try {
// Базовая авторизация для e621 API
const auth = Buffer.from(`${config.e621Username}:${config.e621ApiKey}`).toString('base64');
const response = await axios.get('https://e621.net/posts.json', {
params: {
tags: query.trim(), // Множественные теги через пробел
limit: Math.min(parseInt(limit) || 320, 320), // Максимум 320
page: parseInt(page) || 1
},
headers: {
'User-Agent': E621_USER_AGENT,
'Authorization': `Basic ${auth}`
},
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ posts: [] });
}
// Проверка на наличие данных (e621 может возвращать массив напрямую или объект с posts)
let postsData = null;
if (Array.isArray(response.data)) {
postsData = response.data;
} else if (response.data && Array.isArray(response.data.posts)) {
postsData = response.data.posts;
} else if (response.data && Array.isArray(response.data.data)) {
postsData = response.data.data;
} else {
console.warn('⚠️ e621 вернул неверный формат данных для постов:', {
type: typeof response.data,
keys: response.data ? Object.keys(response.data) : null,
hasPosts: !!(response.data && response.data.posts),
isArray: Array.isArray(response.data)
});
return res.json({ posts: [] });
}
const posts = postsData
.filter(post => post && post.file && post.file.url) // Фильтруем посты без URL
.map(post => ({
id: post.id,
url: createProxyUrl(post.file.url),
preview: post.preview && post.preview.url ? createProxyUrl(post.preview.url) : null,
tags: post.tags && post.tags.general ? post.tags.general : [],
rating: post.rating || 'q',
score: post.score && post.score.total ? post.score.total : 0,
source: 'e621'
}));
const payload = { posts };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ posts: [] });
}
console.error('Ошибка e621 API:', error.message);
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
} catch (error) {
console.error('Ошибка поиска e621:', error);
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
});
// Gelbooru API поиск
router.get('/anime', authenticate, async (req, res) => {
try {
const { query, limit = 320, page = 1 } = req.query; // Gelbooru поддерживает до 320 постов на страницу
if (!query) {
return res.status(400).json({ error: 'Параметр query обязателен' });
}
const cacheKey = getCacheKey('gelbooru', { query: query.trim(), limit, page });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
try {
const response = await axios.get('https://gelbooru.com/index.php', {
params: {
page: 'dapi',
s: 'post',
q: 'index',
json: 1,
tags: query.trim(), // Множественные теги через пробел
limit: Math.min(parseInt(limit) || 320, 320), // Максимум 320
pid: parseInt(page) || 1,
api_key: config.gelbooruApiKey,
user_id: config.gelbooruUserId
},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ posts: [] });
}
// Обработка разных форматов ответа Gelbooru
let postsData = [];
if (Array.isArray(response.data)) {
postsData = response.data;
} else if (response.data && response.data.post) {
postsData = Array.isArray(response.data.post) ? response.data.post : [response.data.post];
} else if (response.data && Array.isArray(response.data)) {
postsData = response.data;
}
const posts = postsData.map(post => ({
id: post.id,
url: createProxyUrl(post.file_url),
preview: createProxyUrl(post.preview_url || post.thumbnail_url || post.file_url),
tags: post.tags ? (typeof post.tags === 'string' ? post.tags.split(' ') : post.tags) : [],
rating: post.rating || 'unknown',
score: post.score || 0,
source: 'gelbooru'
}));
const payload = { posts };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ posts: [] });
}
console.error('Ошибка Gelbooru API:', error.message);
if (error.response) {
console.error('Gelbooru ответ:', error.response.status, error.response.data);
}
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
} catch (error) {
console.error('Ошибка поиска Gelbooru:', error);
return res.json({ posts: [] }); // Возвращаем пустой массив вместо ошибки
}
});
// Автокомплит тегов для e621
router.get('/furry/tags', authenticate, async (req, res) => {
try {
const { query } = req.query;
if (!query || query.length < 2) {
return res.json({ tags: [] });
}
const cacheKey = getCacheKey('e621-tags', { query: query.trim().toLowerCase() });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
try {
// Базовая авторизация для e621 API
const auth = Buffer.from(`${config.e621Username}:${config.e621ApiKey}`).toString('base64');
const response = await axios.get('https://e621.net/tags.json', {
params: {
'search[name_matches]': `${query}*`,
'search[order]': 'count',
limit: 10
},
headers: {
'User-Agent': E621_USER_AGENT,
'Authorization': `Basic ${auth}`
},
timeout: 10000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ tags: [] });
}
// Проверка на массив (e621 может возвращать массив напрямую или объект с массивом)
let tagsData = null;
if (Array.isArray(response.data)) {
tagsData = response.data;
} else if (response.data && Array.isArray(response.data.tags)) {
tagsData = response.data.tags;
} else if (response.data && Array.isArray(response.data.data)) {
tagsData = response.data.data;
} else {
console.warn('⚠️ e621 вернул неверный формат данных для тегов:', {
type: typeof response.data,
keys: response.data ? Object.keys(response.data) : null,
data: response.data
});
return res.json({ tags: [] });
}
const tags = tagsData.map(tag => ({
name: tag.name,
count: tag.post_count || tag.count || 0
}));
const payload = { tags };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ e621 rate limit (429)');
return res.json({ tags: [] });
}
console.error('Ошибка получения тегов e621:', error.message);
return res.json({ tags: [] }); // Возвращаем пустой массив вместо ошибки
}
} catch (error) {
console.error('Ошибка получения тегов:', error);
return res.json({ tags: [] }); // Возвращаем пустой массив вместо ошибки
}
});
// Автокомплит тегов для Gelbooru
router.get('/anime/tags', authenticate, async (req, res) => {
try {
const { query } = req.query;
if (!query || query.length < 2) {
return res.json({ tags: [] });
}
const cacheKey = getCacheKey('gelbooru-tags', { query: query.trim().toLowerCase() });
const cached = getFromCache(cacheKey);
if (cached) {
return res.json(cached);
}
try {
const response = await axios.get('https://gelbooru.com/index.php', {
params: {
page: 'dapi',
s: 'tag',
q: 'index',
json: 1,
name_pattern: `${query}%`,
orderby: 'count',
limit: 10,
api_key: config.gelbooruApiKey,
user_id: config.gelbooruUserId
},
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
},
timeout: 30000,
validateStatus: (status) => status < 500 // Не бросать ошибку для 429
});
// Обработка 429 (Too Many Requests)
if (response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ tags: [] });
}
// Обработка разных форматов ответа Gelbooru
let tagsData = [];
if (Array.isArray(response.data)) {
tagsData = response.data;
} else if (response.data && response.data.tag) {
tagsData = Array.isArray(response.data.tag) ? response.data.tag : [response.data.tag];
} else if (response.data && Array.isArray(response.data)) {
tagsData = response.data;
}
// Проверка на массив перед map
if (!Array.isArray(tagsData)) {
console.warn('⚠️ Gelbooru вернул не массив тегов');
return res.json({ tags: [] });
}
const tags = tagsData.map(tag => ({
name: tag.name || tag.tag || '',
count: tag.count || tag.post_count || 0
})).filter(tag => tag.name);
const payload = { tags };
setCache(cacheKey, payload);
return res.json(payload);
} catch (error) {
// Обработка 429 ошибок
if (error.response && error.response.status === 429) {
console.warn('⚠️ Gelbooru rate limit (429)');
return res.json({ tags: [] });
}
console.error('Ошибка получения тегов Gelbooru:', error.message);
if (error.response) {
console.error('Gelbooru ответ:', error.response.status, error.response.data);
}
// В случае ошибки возвращаем пустой массив вместо ошибки
return res.json({ tags: [] });
}
} catch (error) {
console.error('Ошибка получения тегов Gelbooru:', error);
// В случае ошибки возвращаем пустой массив вместо ошибки
return res.json({ tags: [] });
}
});
module.exports = router;