import { Router, Request, Response } from 'express'; // Native fetch available in Node 20+ import { searchLimiter, saveLimiter } from '../middleware/rate-limit'; import { detectIntent } from '../intent/intent.service'; import { search, applyTitleFilter } from '../search/search.service'; import { getRankings, getHotKeywords, getCategorizedRankings } from '../search/rankings.service'; import { parseVideo } from '../video/video.service'; import { saveFromShare } from '../cloud/cloud.service'; import { getEnabledCloudTypeSet } from '../cloud/cloud-types.service'; import { getSystemConfig } from '../admin/system-config.service'; import { verifyToken } from '../admin/auth.service'; import { LinkValidator } from '../validation/link-validator.service'; import { getContentInfo } from '../content/content.service'; import { detectCloudType } from '../config/cloud-labels'; import { CLOUD_LABELS, CLOUD_COLORS } from '../config/cloud-labels'; import { getDb } from '../database/database'; const router = Router(); // ============ Search & Query ============ /** * POST /api/query * Intent recognition + execution */ router.post('/query', searchLimiter, async (req: Request, res: Response) => { try { const { input, q } = req.body; const query = input || q; if (!query || typeof query !== 'string') { res.status(400).json({ error: 'Input is required' }); return; } const intent = detectIntent(query); const ip = req.ip || req.socket.remoteAddress || ''; switch (intent.type) { case 'SEARCH': { const result = await search(intent.cleanInput, 1, ip); // Pass through: use all results, group by cloud type const allResults = result.results || []; // Transform to frontend-friendly format let formatted = (allResults || []).map((item: any, idx: number) => ({ id: `search_${idx}`, title: filterTitle(item.title || item.content || ''), description: item.content || '', share_url: item.url || '', cloud_type: detectCloudType(item.url || ''), file_size: '', update_time: item.datetime || '', source: item.source || '', file_id: '', cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '', password: item.password || '', })); // Filter out expired/invalid links formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url)); // Filter by enabled cloud types (admin-configurable per-type toggle) // Skip filter if search_all_channels is enabled const searchAllChannels = getSystemConfig('search_all_channels') === 'true'; if (!searchAllChannels) { const enabledSet = getEnabledCloudTypeSet(); formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type)); } const contentQuery = intent.cleanInput || query; const contentInfo = await getContentInfo(contentQuery).catch(() => null); const extractedTags = extractTagsFromResults(formatted, contentQuery); const linkValidationEnabled = getSystemConfig('link_validation_enabled') !== 'false'; // Set up streaming response (NDJSON) res.setHeader('Content-Type', 'application/x-ndjson'); res.setHeader('X-Accel-Buffering', 'no'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); // 0. Send searching signal immediately so frontend shows feedback res.write(JSON.stringify({ type: 'searching' }) + '\n'); // 0.5 Query local DB for previously saved resources matching keyword const savedResults = getSavedResources(intent.cleanInput); if (savedResults.length > 0) { res.write(JSON.stringify({ type: 'saved', results: savedResults, total: savedResults.length, }) + '\n'); } // 1. Send stats immediately const fallbackImage = getSystemConfig('search_fallback_image') || ''; const siteLogo = getSystemConfig('site_logo') || ''; const siteNameInStats = getSystemConfig('site_name') || 'CloudSearch'; const siteDisclaimer = getSystemConfig('site_disclaimer') || ''; const siteMarquee = getSystemConfig('site_marquee') || ''; const statsPayload = { type: 'stats', total: formatted.length, channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type), content_info: contentInfo, content_tags: extractedTags, link_validation: linkValidationEnabled, fallback_image: fallbackImage, site_logo: siteLogo, site_name: siteNameInStats, site_disclaimer: siteDisclaimer, site_marquee: siteMarquee, }; res.write(JSON.stringify(statsPayload) + '\n'); // 2. Validate links — per-type grouping, newest-first, per-type cap from config if (linkValidationEnabled) { const validator = new LinkValidator(); const resultLimit = parseInt(getSystemConfig('search_result_limit') || '10', 10); const MAX_VALID_PER_TYPE = Math.min(100, Math.max(1, resultLimit)); // configurable, 1-100 const MAX_TOTAL_VALID = MAX_VALID_PER_TYPE * 6; // up to 6 cloud types const pool = validator['pool']; // concurrency: 10 // Group formatted results by cloud_type, then sort each group by time desc const byType: Record = {}; for (const item of formatted) { const ct = item.cloud_type || 'others'; if (!byType[ct]) byType[ct] = []; byType[ct].push(item); } // Sort each group by update_time descending (newest first) for (const ct of Object.keys(byType)) { byType[ct].sort((a: any, b: any) => { const ta = a.update_time || ''; const tb = b.update_time || ''; if (!ta && !tb) return 0; if (!ta) return 1; if (!tb) return -1; return tb.localeCompare(ta); }); } // Build a round-robin validation queue: interleave items from each type // to give fair priority across all cloud types const typeOrder = ['quark', 'baidu', 'aliyun', '115', 'tianyi', '123pan', 'uc', 'xunlei', 'pikpak', 'magnet', 'ed2k', 'others']; const sortedTypes = typeOrder.filter(ct => byType[ct] && byType[ct].length > 0); // Sort by total count descending so types with more results get more validation slots sortedTypes.sort((a, b) => (byType[b]?.length || 0) - (byType[a]?.length || 0)); const validationQueue: { item: any; type: string }[] = []; const maxLen = Math.max(...sortedTypes.map(ct => byType[ct].length), 0); for (let i = 0; i < maxLen; i++) { for (const ct of sortedTypes) { if (i < byType[ct].length) { validationQueue.push({ item: byType[ct][i], type: ct }); } } } const validResults: any[] = []; const perTypeValid: Record = {}; let totalValid = 0; let totalInvalid = 0; let totalChecked = 0; const unknownItemIds: number[] = []; // IDs that got 'unknown' from PanSou // Pass 1: PanSou-only validation const tasks = validationQueue.map(({ item, type }) => pool.run(async () => { // Stop if we've hit overall cap or per-type cap if (totalValid >= MAX_TOTAL_VALID) return; if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) return; totalChecked++; try { const vr = await validator.validate(item.share_url, item.cloud_type); // 'unknown' = PanSou couldn't determine → treat as valid for now if (vr.status === 'valid' || vr.status === 'unknown') { if (vr.status === 'unknown') { unknownItemIds.push(item.id); } if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) { validResults.push(item); perTypeValid[type] = (perTypeValid[type] || 0) + 1; totalValid++; res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message }) + '\n'); } } else { totalInvalid++; res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message }) + '\n'); } } catch { if (totalValid < MAX_TOTAL_VALID && (perTypeValid[type] || 0) < MAX_VALID_PER_TYPE) { validResults.push(item); perTypeValid[type] = (perTypeValid[type] || 0) + 1; totalValid++; } res.write(JSON.stringify({ type: 'result', id: item.id, valid: true }) + '\n'); } })); await Promise.all(tasks); // Pass 2: If PanSou didn't provide enough valid results, validate // uncertain items with local fallback (external API calls) if (totalValid < MAX_TOTAL_VALID && unknownItemIds.length > 0) { const unknownItems = validationQueue.filter(({ item }) => unknownItemIds.includes(item.id)); for (const { item, type } of unknownItems) { if (totalValid >= MAX_TOTAL_VALID) break; if ((perTypeValid[type] || 0) >= MAX_VALID_PER_TYPE) break; try { const vr = await validator.validateWithLocalFallback(item.share_url, item.cloud_type); if (vr.status === 'valid') { // Already in validResults from pass 1, just count it again perTypeValid[type] = (perTypeValid[type] || 0) + 1; totalValid++; res.write(JSON.stringify({ type: 'result', id: item.id, valid: true, message: vr.message + ' (本地确认)' }) + '\n'); } else if (vr.status === 'invalid') { // Remove from validResults — was previously included as unknown const idx = validResults.findIndex(r => r.id === item.id); if (idx >= 0) { validResults.splice(idx, 1); perTypeValid[type] = Math.max(0, (perTypeValid[type] || 1) - 1); totalValid--; } totalInvalid++; res.write(JSON.stringify({ type: 'result', id: item.id, valid: false, message: vr.message + ' (本地确认失效)' }) + '\n'); } } catch { // Keep as-is (already treated as valid from pass 1) } } } const skippedCount = validationQueue.length - totalChecked; res.write(JSON.stringify({ type: 'complete', results: validResults, channels: groupResultsByChannel(validResults, (item: any) => item.cloud_type), total: validResults.length, filtered: totalInvalid, per_type: perTypeValid, skipped: skippedCount, }) + '\n'); } else { // No validation - just send all results res.write(JSON.stringify({ type: 'complete', results: formatted, channels: groupResultsByChannel(formatted, (item: any) => item.cloud_type), total: formatted.length, filtered: 0, }) + '\n'); } res.end(); break; } case 'VIDEO_PARSE': { const videoInfo = await parseVideo(intent.cleanInput); res.json({ intent: intent.type, platform: intent.platform, data: videoInfo }); break; } case 'CLOUD_SAVE': { const result = await saveFromShare(intent.cleanInput, intent.platform || '', undefined, req.ip); res.json({ intent: intent.type, platform: intent.platform, ...result }); break; } default: res.status(400).json({ error: 'Unknown intent type' }); } } catch (err: any) { console.error('[Query] Error:', err); res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * GET /api/search * Search with optional link validation filtering */ router.get('/search', searchLimiter, async (req: Request, res: Response) => { try { const keyword = (req.query.q || req.query.kw) as string; const page = parseInt(req.query.page as string || '1', 10); const ip = req.ip || req.socket.remoteAddress || ''; if (!keyword) { res.status(400).json({ error: 'Query parameter "q" is required' }); return; } const result = await search(keyword, page, ip); // Pass through: use all results const allResults = result.results || []; // Transform to frontend format let formatted = (allResults || []).map((item: any) => ({ id: item.id || '', title: filterTitle(item.title || item.content || ''), description: item.content || item.snippet || '', share_url: item.url || '', cloud_type: detectCloudType(item.url || ''), file_size: '', source: item.source || '', datetime: item.datetime || '', cover: Array.isArray(item.images) && item.images.length > 0 ? item.images[0] : '', password: item.password || '', })); // Filter out expired/invalid links const expiredCount = formatted.filter(r => r.share_url && isExpiredShareLink(r.share_url)).length; formatted = formatted.filter(r => !r.share_url || !isExpiredShareLink(r.share_url)); // Filter by enabled cloud types (admin-configurable per-type toggle) const enabledSet = getEnabledCloudTypeSet(); formatted = formatted.filter(r => !r.cloud_type || enabledSet.has(r.cloud_type)); // Return results immediately without blocking validation const channels = groupResultsByChannel(formatted, (item: any) => detectCloudType(item.url || '') ); res.json({ results: formatted, channels, total: formatted.length, filtered: expiredCount, link_validation: false, }); } catch (err: any) { console.error('[Search] Error:', err); res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * Load title filter rules from DB and apply to a title. */ function filterTitle(title: string): string { const rules = getSystemConfig('title_filter_rules') || ''; return applyTitleFilter(title, rules); } // detectCloudType is imported from config/cloud-labels // 检测失效的分享链接(支持多种模式) function isExpiredShareLink(url: string): boolean { if (!url) return false; // 空链接/纯片段(无实际链接内容) if (url.startsWith('#') || url.length < 10) return true; // PanSou 有时返回残缺链接如 "/s/xxx" 或只有 "#/list/share" if (url.startsWith('/') && !url.startsWith('//') && !url.startsWith('http')) return true; // 夸克链接格式校验 if (url.includes('pan.quark.cn')) { const baseUrl = url.split('#')[0]; // 去掉 hash 路由片段 // 有效格式必须是 pan.quark.cn/s/xxxxxx if (!/pan\.quark\.cn\/s\/[a-zA-Z0-9]+/.test(baseUrl)) return true; } // 百度网盘常见失效格式 if (url.includes('pan.baidu.com') && /share\/init\?surl=$/.test(url)) return true; // 阿里云盘失效格式(短到异常的链接) if (url.includes('aliyundrive.com') && url.length < 30) return true; return false; } /** * Group search results into channels by cloud type. * Each channel: { cloud_type, label, color, count, items } */ function groupResultsByChannel(results: any[], getCloudType?: (item: any) => string): any[] { const groups: Record = {}; const order: Record = { quark: 1, baidu: 2, aliyun: 3, '115': 4, tianyi: 5, '123pan': 6, uc: 7, xunlei: 8, pikpak: 9, magnet: 10, ed2k: 11, others: 12, }; for (const item of results) { const ct = getCloudType ? getCloudType(item) : (item.source || detectCloudType(item.url || '') || 'others'); if (!groups[ct]) groups[ct] = []; groups[ct].push(item); } return Object.entries(groups) .sort((a, b) => (order[a[0]] ?? 99) - (order[b[0]] ?? 99)) .map(([cloud_type, items]) => ({ cloud_type, label: (CLOUD_LABELS as any)[cloud_type] || cloud_type, color: (CLOUD_COLORS as any)[cloud_type] || '#95a5a6', count: items.length, items, })); } // ============ Video ============ // ============ Video ============ /** * POST /api/video/parse * Parse a video URL */ router.post('/video/parse', async (req: Request, res: Response) => { try { const { url } = req.body; if (!url) { res.status(400).json({ error: 'URL is required' }); return; } const videoInfo = await parseVideo(url); res.json(videoInfo); } catch (err: any) { console.error('[Video] Parse error:', err); res.status(500).json({ error: err.message || 'Failed to parse video' }); } }); // ============ Cloud Save ============ // ============ Cloud Save ============ /** * POST /api/save * Save a share link to a specific cloud */ router.post('/save', saveLimiter, async (req: Request, res: Response) => { try { // Support both formats: // 1. Backend-style: { url, cloudType } // 2. Frontend-style: { source: { share_url }, target_cloud } const url = req.body.url || req.body.source?.share_url || req.body.source?.url; const cloudType = req.body.cloudType || req.body.target_cloud || (req.body.source as any)?.cloud_type; const sourceTitle = req.body.source_title || req.body.source?.title || req.body.title; if (!url || !cloudType) { res.status(400).json({ error: 'URL and cloudType/cloud_type are required' }); return; } const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || ''; const result = await saveFromShare(url, cloudType, sourceTitle, ip); res.json(result); } catch (err: any) { console.error('[Save] Error:', err); res.status(500).json({ error: err.message || 'Failed to save to cloud' }); } }); /** * POST /api/video/save-to-cloud * Save a video to cloud */ router.post('/video/save-to-cloud', saveLimiter, async (req: Request, res: Response) => { try { const { videoUrl, cloudType, title } = req.body; if (!videoUrl || !cloudType) { res.status(400).json({ error: 'videoUrl and cloudType are required' }); return; } const ip = req.ip || (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || ''; const result = await saveFromShare(videoUrl, cloudType, title, ip); res.json(result); } catch (err: any) { console.error('[Video] Save-to-cloud error:', err); res.status(500).json({ error: err.message || 'Failed to save video to cloud' }); } }); // ============ Rankings ============ // ============ Rankings ============ /** * GET /api/rankings * Get search rankings */ router.get('/rankings', async (_req: Request, res: Response) => { try { const rankings = await getRankings(); res.json(rankings); } catch (err: any) { console.error('[Rankings] Error:', err); res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * GET /api/rankings/hot * Get hot keywords */ router.get('/rankings/hot', async (_req: Request, res: Response) => { try { const keywords = await getHotKeywords(); res.json(keywords); } catch (err: any) { console.error('[Hot] Error:', err); res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * GET /api/rankings/categorized * Get categorized rankings (hot + newest per category), cached for 12h */ router.get('/rankings/categorized', async (_req: Request, res: Response) => { try { const data = await getCategorizedRankings(); res.json(data); } catch (err: any) { console.error('[Categorized] Error:', err); res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * GET /api/site-config * Public site configuration (no auth required). */ router.get('/site-config', (_req: Request, res: Response) => { try { const siteLogo = getSystemConfig('site_logo') || ''; const siteName = getSystemConfig('site_name') || 'CloudSearch'; const fallbackImage = getSystemConfig('search_fallback_image') || ''; const siteDisclaimer = getSystemConfig('site_disclaimer') || ''; const siteMarquee = getSystemConfig('site_marquee') || ''; res.json({ site_logo: siteLogo, site_name: siteName, search_fallback_image: fallbackImage, site_disclaimer: siteDisclaimer, site_marquee: siteMarquee }); } catch (err: any) { res.status(500).json({ error: err.message || 'Internal server error' }); } }); /** * GET /api/me * Get current user info from token (public, no auth middleware). */ router.get('/me', (req: Request, res: Response) => { try { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { res.json({ loggedIn: false }); return; } const token = authHeader.split(' ')[1]; const payload = verifyToken(token); if (!payload) { res.json({ loggedIn: false }); return; } res.json({ loggedIn: true, id: payload.id, username: payload.username }); } catch (err: any) { res.json({ loggedIn: false }); } }); // ============ Admin ============ /** * Extract genre tags from search result titles. */ function extractTagsFromResults(results: any[], keyword: string): string[] { const tags: string[] = []; if (keyword) tags.push(keyword); const genreKeywords: Record = { '动画': '动画', '动漫': '动画', '国漫': '国漫', '剧场版': '剧场版', '年番': '年番', '动作': '动作', '奇幻': '奇幻', '玄幻': '玄幻', '仙侠': '仙侠', '古装': '古装', '爱情': '爱情', '科幻': '科幻', '喜剧': '喜剧', '悬疑': '悬疑', '恐怖': '恐怖', '惊悚': '惊悚', '剧情': '剧情', '冒险': '冒险', '战争': '战争', '武侠': '武侠', '纪录': '纪录片', '真人': '真人秀', '短片': '短片', }; const seen = new Set(); for (const r of results) { const title = (r.title || r.note || '') as string; for (const [key, val] of Object.entries(genreKeywords)) { if (title.includes(key) && !seen.has(val)) { seen.add(val); tags.push(val); } } } return tags; } /** * Query DB for previously saved resources that match the keyword. * Returns formatted results for immediate streaming before external API call. */ function getSavedResources(keyword: string): any[] { try { const db = getDb(); const rows = db.prepare(` SELECT source_url, source_title, target_cloud, share_url, created_at FROM save_records WHERE status = 'success' AND (source_title LIKE ? OR source_url LIKE ?) ORDER BY created_at DESC LIMIT 20 `).all(`%${keyword}%`, `%${keyword}%`) as any[]; return rows.map((row: any, idx: number) => ({ id: `saved_${idx}`, title: row.source_title || row.source_url || '', description: '', share_url: row.share_url || row.source_url || '', cloud_type: detectCloudType(row.share_url || row.source_url || ''), file_size: '', update_time: row.created_at || '', source: 'local', file_id: '', cover: '', password: '', })); } catch (err) { console.error('[SavedResources] DB query error:', err); return []; } } export default router;