/** * ============================================ * 🔍 增强版搜索模块 (search.js) * 功能:智能搜索、结果展示、链接验证、自动回退 * 最后更新:2025-01-XX (v2.0 - 支持自动回退到 JavaScript 搜索) * ============================================ */ import { config } from './config.js'; const DEFAULT_TIMEOUT = 4000; // 请求超时:4 秒 async function fetchWithTimeout(url, options = {}, timeout = DEFAULT_TIMEOUT) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...options, signal: controller.signal }); return response; } finally { clearTimeout(timer); } } // 工具函数:防抖 function debounce(func, wait) { let timeout; return function(...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), wait); }; } // 注意:fetchGitHubData 函数已不再需要 // GitHub 数据现在由 Go 后端获取 export class SearchModule { constructor() { this.cache = new Map(); this.goApiFailedCount = 0; // Go API 失败计数 this.goApiDisabled = false; // Go API 是否已禁用 this.goApiCheckTime = 0; // 上次检查 Go API 的时间 this.initElements(); this.setupEvents(); } initElements() { this.elements = { input: document.getElementById('searchInput'), results: document.getElementById('searchResults'), container: document.querySelector('.search-container') }; if (!this.elements.input || !this.elements.results) { throw new Error('搜索模块初始化失败:未找到必要的DOM元素'); } } setupEvents() { const { input } = this.elements; input.addEventListener('input', debounce(this.handleSearch.bind(this), 300)); input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.hideResults(); } }); this.setupShortcuts(); document.addEventListener('click', (e) => { if (!this.elements.container?.contains(e.target)) { this.hideResults(); } }); } setupShortcuts() { const shortcut = document.getElementById('searchShortcut'); const isApple = /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent); if (shortcut) { shortcut.textContent = isApple ? '⌘K' : 'Ctrl K'; shortcut.setAttribute('aria-label', isApple ? '快捷键 Command K' : '快捷键 Control K'); } document.addEventListener('keydown', (e) => { const target = e.target; const isTyping = target?.matches?.('input, textarea, select, [contenteditable="true"]'); const isCommandK = e.key?.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey); const isSlash = e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey; const isSearchShortcut = isCommandK || (isSlash && !isTyping); if (!isSearchShortcut || (isTyping && target !== this.elements.input)) return; e.preventDefault(); this.elements.input.focus({ preventScroll: true }); this.elements.input.select(); this.elements.container?.scrollIntoView({ behavior: 'smooth', block: 'center' }); }); } async handleSearch(e) { const query = e.target.value.trim(); if (!query) { this.hideResults(); return; } // 最小查询长度检查 if (query.length < 2) { const { results: resultsContainer } = this.elements; resultsContainer.innerHTML = `

请输入至少 2 个字符进行搜索

`; this.showResults(); return; } this.showLoading(); try { const results = await this.search(query); this.displayResults(results); } catch (error) { console.error('[Search] 搜索失败:', error); // 显示更友好的错误信息 const { results: resultsContainer } = this.elements; let errorMessage = error.message || '未知错误'; // 根据错误类型提供更具体的提示 if (errorMessage.includes('400') || errorMessage.includes('请求失败')) { errorMessage = '搜索关键词太短,请至少输入 2 个字符'; } else if (errorMessage.includes('网络') || errorMessage.includes('fetch')) { errorMessage = '网络连接失败,请检查网络后重试'; } resultsContainer.innerHTML = `

搜索失败:${errorMessage}

请尝试:
• 输入至少 2 个字符
• 使用热门库名称(如:bootstrap, jquery)
• 刷新页面后重试

`; this.showResults(); } } displayResults(results) { const { results: resultsContainer } = this.elements; if (!results || results.length === 0) { resultsContainer.innerHTML = `

未找到相关资源

`; return; } resultsContainer.innerHTML = results .map(result => this.renderSearchResult(result)) .join(''); this.bindResultEvents(); this.showResults(); } async search(query) { const cacheKey = query.toLowerCase(); // 检查缓存 if (this.cache.has(cacheKey)) { const { timestamp, data } = this.cache.get(cacheKey); if (Date.now() - timestamp < 5 * 60 * 1000) { return data; } } // 检查 Go API 是否可用(如果连续失败3次,禁用30分钟) const now = Date.now(); const goApiRecentlyDisabled = this.goApiDisabled && (now - this.goApiCheckTime < 30 * 60 * 1000); if (!goApiRecentlyDisabled) { // Go API 可用,尝试使用 try { const currentCDN = config.getCurrentCDN(); const apiUrl = `${config.searchApiUrl}?q=${encodeURIComponent(query)}&limit=20&type=all&cdn=${config.cdnSource}`; // 创建超时控制器 const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); // 3秒超时 const response = await fetch(apiUrl, { method: 'GET', headers: { 'Accept': 'application/json', }, credentials: 'same-origin', signal: controller.signal }); clearTimeout(timeoutId); if (response.ok) { const contentType = response.headers.get('content-type'); if (contentType && contentType.includes('application/json')) { const data = await response.json(); if (data.success && data.results) { console.log('[Search] ✅ Go API 成功,返回', data.results.length, '个结果'); // 重置失败计数 this.goApiFailedCount = 0; this.goApiDisabled = false; // 缓存结果 this.cache.set(cacheKey, { timestamp: Date.now(), data: data.results }); return data.results; } } } // Go API 返回非 200 状态码 this.goApiFailedCount++; if (this.goApiFailedCount >= 3) { this.goApiDisabled = true; this.goApiCheckTime = Date.now(); console.warn('[Search] ⚠️ Go API 连续失败 3 次,禁用 30 分钟,直接使用 JavaScript 搜索'); } else { console.warn('[Search] ⚠️ Go API 返回', response.status, `(失败 ${this.goApiFailedCount}/3)`); } } catch (error) { // Go API 请求失败(网络错误、404、超时等) this.goApiFailedCount++; if (this.goApiFailedCount >= 3) { this.goApiDisabled = true; this.goApiCheckTime = Date.now(); console.warn('[Search] ⚠️ Go API 连续失败 3 次,禁用 30 分钟,直接使用 JavaScript 搜索'); } else { const errorMsg = error.name === 'AbortError' ? '请求超时' : error.message; console.warn('[Search] ⚠️ Go API 请求失败:', errorMsg, `(失败 ${this.goApiFailedCount}/3)`); } } } else { console.log('[Search] Go API 已禁用,直接使用 JavaScript 搜索'); } // 使用改进的 JavaScript 搜索(Go API 失败或禁用时) console.log('[Search] 🔄 使用 JavaScript 搜索模式'); try { return await this.searchWithJavaScript(query); } catch (jsError) { console.error('[Search] ❌ JavaScript 搜索也失败:', jsError); throw jsError; } } /** * 使用 JavaScript 直接搜索(优化版 - 更快、更准确) */ async searchWithJavaScript(query) { const cacheKey = 'js_' + query.toLowerCase(); // 检查缓存 if (this.cache.has(cacheKey)) { const { timestamp, data } = this.cache.get(cacheKey); if (Date.now() - timestamp < 5 * 60 * 1000) { return data; } } try { // 预处理查询:支持中文关键词和别名 const processedQuery = this.preprocessQuery(query); // 首先检查热门库匹配(快速路径) const popularResults = this.searchPopularLibraries(query, processedQuery); if (popularResults.length > 0) { console.log('[Search] 找到热门库匹配,优先返回'); return popularResults.slice(0, 10); } // 最小查询长度检查 if (processedQuery.length < 2) { console.warn('[Search] 查询太短,至少需要 2 个字符'); return []; } // 从 npm 搜索(限制数量以提高速度) const response = await fetchWithTimeout( `${config.npmRegistryApi}/-/v1/search?text=${encodeURIComponent(processedQuery)}&size=15`, {}, 5000 ); if (!response.ok) { // 处理 400 错误(查询太短) if (response.status === 400) { console.warn('[Search] npm API 返回 400,查询可能太短'); return []; } throw new Error(`npm API 请求失败: ${response.status}`); } const data = await response.json(); const { objects } = data || { objects: [] }; if (!objects || objects.length === 0) { return []; } // 快速过滤:先计算相关性,只处理高相关性结果 const scoredPackages = objects .map(({ package: pkg }) => ({ pkg, relevance: this.calculateRelevance(pkg, query, processedQuery) })) .filter(item => item.relevance >= 0.3) // 提高阈值,只处理高相关性 .sort((a, b) => b.relevance - a.relevance) .slice(0, 10); // 只处理前10个最相关的 // 并行处理包信息(使用 Promise.allSettled 以容忍单个包失败) // 限制并发数为 5,避免压垮服务器 const packagePromises = scoredPackages.map(async ({ pkg, relevance }) => { try { // 并行获取版本和文件列表(带超时控制) const [versionData, files] = await Promise.all([ this.getPackageVersion(pkg.name).catch(() => null), this.getPackageFiles(pkg.name, 'latest').catch(() => []) // 先尝试 latest ]); if (!versionData) { return null; } // 如果使用 latest 失败,再获取具体版本 let actualFiles = files; if ((!files || files.length === 0) && versionData.version && versionData.version !== 'latest') { try { actualFiles = await this.getPackageFiles(pkg.name, versionData.version); } catch (err) { // jsDelivr 404 错误,包可能不存在或版本问题 console.warn(`[Search] 包 ${pkg.name}@${versionData.version} 文件获取失败:`, err.message); return null; } } if (!actualFiles || actualFiles.length === 0) { return null; } // 快速过滤 CSS/JS 文件 const cssFiles = actualFiles.filter(f => /\.(min\.)?css$/i.test(f.name) ); const jsFiles = actualFiles.filter(f => /\.(min\.)?js$/i.test(f.name) ); // 优先选择 .min 文件 const bestCssFile = cssFiles.find(f => f.name.includes('.min.css')) || cssFiles[0]; const bestJsFile = jsFiles.find(f => f.name.includes('.min.js')) || jsFiles[0]; const results = []; // 只添加最佳文件(每个包最多1个CSS和1个JS) if (bestCssFile) { const cdnUrl = this.generateCdnUrl(pkg.name, versionData.version, bestCssFile.name); results.push({ type: 'css', name: pkg.name, version: versionData.version, description: pkg.description || '暂无描述', fileName: bestCssFile.name, url: cdnUrl, code: ``, relevance: relevance }); } if (bestJsFile) { const cdnUrl = this.generateCdnUrl(pkg.name, versionData.version, bestJsFile.name); results.push({ type: 'js', name: pkg.name, version: versionData.version, description: pkg.description || '暂无描述', fileName: bestJsFile.name, url: cdnUrl, code: ``, relevance: relevance }); } return results; } catch (err) { console.warn(`处理包 ${pkg.name} 失败:`, err); return null; } }); // 并发执行,但限制并发数 const maxConcurrency = 5; const results = []; for (let i = 0; i < packagePromises.length; i += maxConcurrency) { const batch = packagePromises.slice(i, i + maxConcurrency); const batchResults = await Promise.allSettled(batch); batchResults.forEach(result => { if (result.status === 'fulfilled' && result.value) { results.push(...result.value); } }); } // 排序结果 results.sort((a, b) => (b.relevance || 0) - (a.relevance || 0)); // 限制结果数量 const finalResults = results.slice(0, 20); // 缓存结果 this.cache.set(cacheKey, { timestamp: Date.now(), data: finalResults }); return finalResults; } catch (error) { console.error('[Search] JavaScript 搜索失败:', error); throw new Error('搜索服务暂不可用,请稍后重试'); } } /** * 搜索热门库(快速匹配) */ searchPopularLibraries(query, processedQuery) { const popularLibraries = [ { name: 'bootstrap', package: 'bootstrap', file: 'dist/css/bootstrap.min.css', aliases: ['bs', 'bt', '引导'], startsWith: ['b'] }, { name: 'tailwindcss', package: 'tailwindcss', file: 'dist/tailwind.min.css', aliases: ['tw', 'tailwind', '尾风'], startsWith: ['t', 'ta', 'tai'] }, { name: 'fontawesome', package: '@fortawesome/fontawesome-free', file: 'css/all.min.css', aliases: ['fa', 'fontawesome', '字体图标'], startsWith: ['f', 'fo', 'fon'] }, { name: 'Google Sans', package: 'Google Sans', file: 'font', aliases: ['google sans', '谷歌字体', '字体'], startsWith: ['g', 'go', 'goo'] }, { name: 'Google Sans Code VF', package: 'Google Sans Code', file: 'font', aliases: ['google sans code', 'code font', '代码字体', '等宽代码', '等宽字体'], startsWith: ['g', 'go', 'goo'] }, { name: 'animate.css', package: 'animate.css', file: 'animate.min.css', aliases: ['animate', '动画'], startsWith: ['a', 'an', 'ani'] }, { name: 'jquery', package: 'jquery', file: 'dist/jquery.min.js', aliases: ['jq', 'jquery'], startsWith: ['j', 'jq', 'jqu'] }, { name: 'bulma', package: 'bulma', file: 'css/bulma.min.css', aliases: ['bulma'], startsWith: ['b', 'bu', 'bul'] }, { name: 'uikit', package: 'uikit', file: 'dist/css/uikit.min.css', aliases: ['ui', 'uikit'], startsWith: ['u', 'ui', 'uik'] }, ]; const lowerQuery = query.toLowerCase().trim(); const lowerProcessed = processedQuery.toLowerCase().trim(); // 如果查询太短(1个字符),只匹配开头 if (lowerQuery.length === 1) { for (const lib of popularLibraries) { if (lib.startsWith && lib.startsWith.includes(lowerQuery)) { const cdnUrl = this.generateCdnUrl(lib.package, 'latest', lib.file); const isFont = lib.file === 'font'; const fileType = isFont || lib.file.endsWith('.css') ? 'css' : 'js'; const fontUrl = lib.name === 'Google Sans Code VF' ? 'https://fonts.bluecdn.com/css2?family=Google+Sans+Code:wght@400..700&display=swap' : 'https://fonts.bluecdn.com/css2?family=Google+Sans:wght@400;500;700&display=swap'; const finalUrl = isFont ? fontUrl : cdnUrl; return [{ type: fileType, name: lib.package, version: isFont ? (lib.name === 'Google Sans Code VF' ? '400..700' : '400 / 500 / 700') : 'latest', description: `${lib.name} - 热门库(输入 "${lib.name}" 获取完整结果)`, fileName: lib.file, url: finalUrl, code: fileType === 'css' ? `` : ``, relevance: 0.9 // 单字符匹配降低相关性 }]; } } return []; } // 正常匹配(2个字符以上) for (const lib of popularLibraries) { if (lib.name === lowerQuery || lib.name === lowerProcessed || lib.package === lowerQuery || lib.package === lowerProcessed || lib.name.startsWith(lowerQuery) || lib.name.startsWith(lowerProcessed) || lib.aliases.some(alias => { const lowerAlias = alias.toLowerCase(); return lowerAlias === lowerQuery || lowerAlias === lowerProcessed || lowerAlias.startsWith(lowerQuery) || lowerAlias.startsWith(lowerProcessed); })) { const cdnUrl = this.generateCdnUrl(lib.package, 'latest', lib.file); const isFont = lib.file === 'font'; const fileType = isFont || lib.file.endsWith('.css') ? 'css' : 'js'; const fontUrl = lib.name === 'Google Sans Code VF' ? 'https://fonts.bluecdn.com/css2?family=Google+Sans+Code:wght@400..700&display=swap' : 'https://fonts.bluecdn.com/css2?family=Google+Sans:wght@400;500;700&display=swap'; const finalUrl = isFont ? fontUrl : cdnUrl; return [{ type: fileType, name: lib.package, version: isFont ? (lib.name === 'Google Sans Code VF' ? '400..700' : '400 / 500 / 700') : 'latest', description: isFont ? `${lib.name} - 字体资源` : `${lib.name} - 热门库`, fileName: lib.file, url: finalUrl, code: fileType === 'css' ? `` : ``, relevance: 1.0 }]; } } return []; } /** * 预处理查询关键词 */ preprocessQuery(query) { const lowerQuery = query.toLowerCase().trim(); // 中文关键词映射 const chineseKeywords = { '样式': 'css', '样式表': 'css', '框架': 'framework', 'UI框架': 'ui framework', '引导': 'bootstrap', '尾风': 'tailwind', '动画': 'animate', '动画库': 'animate.css', '字体': 'font', '字体库': 'font', '字体资源': 'font', '代码字体': 'Google Sans Code', '等宽字体': 'Google Sans Code', '图标': 'icon', '图标库': 'icon library', '字体图标': 'fontawesome', }; if (chineseKeywords[lowerQuery]) { return chineseKeywords[lowerQuery]; } // 拼音和别名映射 const aliases = { 'bs': 'bootstrap', 'bt': 'bootstrap', 'tw': 'tailwindcss', 'fa': 'fontawesome', 'ui': 'uikit', }; if (aliases[lowerQuery]) { return aliases[lowerQuery]; } return query; } /** * 计算相关性分数(优化版 - 更准确的匹配) */ calculateRelevance(pkg, originalQuery, processedQuery) { let score = 0; const name = pkg.name.toLowerCase(); const description = (pkg.description || '').toLowerCase(); const keywords = (pkg.keywords || []).map(k => k.toLowerCase()); const query = processedQuery.toLowerCase(); const original = originalQuery.toLowerCase(); // 名称完全匹配(最高权重) if (name === query || name === original) { score += 100; } // 名称以查询开头(高权重) else if (name.startsWith(query) || name.startsWith(original)) { score += 85; } // 名称包含查询(中等权重) else if (name.includes(query) || name.includes(original)) { // 如果查询词在名称中间,降低权重 const position = name.indexOf(query); if (position === 0) { score += 60; // 开头位置 } else if (position > 0 && position < name.length - query.length) { score += 40; // 中间位置 } else { score += 50; // 其他位置 } } // 部分匹配(单词边界匹配) else { const words = name.split(/[-_]/); for (const word of words) { if (word === query || word === original) { score += 45; // 单词完全匹配 } else if (word.startsWith(query) || word.startsWith(original)) { score += 30; // 单词开头匹配 } else if (word.includes(query) || word.includes(original)) { score += 20; // 单词包含匹配 } } } // 描述匹配(权重较低) if (description.includes(query) || description.includes(original)) { score += 15; } // 关键词匹配(权重中等) for (const keyword of keywords) { if (keyword === query || keyword === original) { score += 35; // 关键词完全匹配 } else if (keyword.startsWith(query) || keyword.startsWith(original)) { score += 25; // 关键词开头匹配 } else if (keyword.includes(query) || keyword.includes(original)) { score += 12; // 关键词包含匹配 } } // 热门库加权(如果匹配) const popularLibs = ['bootstrap', 'tailwindcss', 'jquery', 'fontawesome', 'animate.css', 'bulma', 'uikit']; if (popularLibs.some(lib => name.includes(lib))) { score += 10; } // 归一化到 0-1 return Math.min(score / 100, 1.0); } /** * 获取包版本 */ async getPackageVersion(packageName) { try { const response = await fetchWithTimeout(`${config.npmRegistryApi}/${packageName}`, {}, 5000); if (!response.ok) return null; const data = await response.json(); return { version: data['dist-tags']?.latest || 'latest', updatedAt: data.time?.modified || null }; } catch (error) { console.warn(`[Search] 获取 ${packageName} 版本失败:`, error.message); return null; } } /** * 获取包文件列表 */ async getPackageFiles(packageName, version) { try { // 清理版本号(移除特殊字符) const cleanVersion = (version || 'latest').replace(/[^a-zA-Z0-9.-]/g, ''); const url = `https://data.jsdelivr.com/v1/package/npm/${packageName}@${cleanVersion}/flat`; const response = await fetchWithTimeout(url, {}, 8000); // 处理 404 错误(包不存在或版本问题) if (response.status === 404) { console.warn(`[Search] 包 ${packageName}@${cleanVersion} 不存在`); return []; } if (!response.ok) { console.warn(`[Search] jsDelivr API 错误: ${response.status}`); return []; } const data = await response.json(); return data.files || []; } catch (error) { // 静默处理错误,返回空数组 const errorMsg = error.name === 'AbortError' ? '请求超时' : error.message; console.warn(`[Search] 获取包文件失败 ${packageName}:`, errorMsg); return []; } } /** * 生成 CDN URL(根据不同CDN源动态生成) */ generateCdnUrl(packageName, version, fileName) { const cleanFile = fileName .replace(/^[.\/\\]+/, '') .replace(/\/+/g, '/'); const cdnHost = config.getCurrentCDN().domain; return `https://${cdnHost}/npm/${packageName}/${version}/${cleanFile}`; } // 注意:processPackageResults 方法已不再需要 // 所有处理现在在 PHP 后端完成 renderSearchResult(result) { const fileIcon = result.type === 'css' ? '' : ''; const githubStats = result.github ? `
${this.formatNumber(result.github.stars)}
` : ''; return `
${fileIcon} ${this.escapeHtml(result.name)} ${githubStats}
${this.escapeHtml(result.description)}
`; } formatNumber(num) { if (!num) return '0'; if (num >= 1000000) { return (num / 1000000).toFixed(1) + 'M'; } if (num >= 1000) { return (num / 1000).toFixed(1) + 'K'; } return num.toString(); } /** * 下载文件 */ async downloadFile(url, filename) { try { const response = await fetch(url); if (!response.ok) { throw new Error(`下载失败: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); // 清理 document.body.removeChild(a); window.URL.revokeObjectURL(blobUrl); return true; } catch (error) { console.error('下载失败:', error); throw error; } } bindResultEvents() { const { results } = this.elements; // 处理复制按钮 results.querySelectorAll('.search-copy-btn:not(.search-download-btn)').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); try { await navigator.clipboard.writeText(btn.dataset.copy); const originalHTML = btn.innerHTML; btn.innerHTML = '已复制'; btn.classList.add('copy-success'); setTimeout(() => { btn.innerHTML = originalHTML; btn.classList.remove('copy-success'); }, 2000); } catch (error) { console.error('复制失败:', error); } }); }); // 处理下载按钮 results.querySelectorAll('.search-download-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = '下载中...'; try { const url = btn.dataset.download; const filename = btn.dataset.filename || 'download.css'; await this.downloadFile(url, filename); btn.innerHTML = '已下载'; btn.classList.add('copy-success'); setTimeout(() => { btn.innerHTML = originalHTML; btn.classList.remove('copy-success'); btn.disabled = false; }, 2000); } catch (error) { btn.innerHTML = '失败'; btn.classList.add('copy-error'); setTimeout(() => { btn.innerHTML = originalHTML; btn.classList.remove('copy-error'); btn.disabled = false; }, 2000); console.error('下载失败:', error); } }); }); } showLoading() { const { results } = this.elements; results.style.display = 'block'; results.innerHTML = `
搜索中...
`; } showResults() { const { results, container } = this.elements; results.style.display = 'block'; container?.classList.add('active'); } hideResults() { const { results, container } = this.elements; results.style.display = 'none'; container?.classList.remove('active'); } showError() { const { results } = this.elements; results.innerHTML = `

搜索服务暂不可用,请稍后重试

`; } escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } } export function initSearch() { try { return new SearchModule(); } catch (error) { console.error('搜索模块初始化失败:', error); return null; } }