diff --git a/ghproxy/public/search.html b/ghproxy/public/search.html index cc84db9..2eaf62e 100644 --- a/ghproxy/public/search.html +++ b/ghproxy/public/search.html @@ -511,6 +511,17 @@ prevButton.disabled = currentPage <= 1; nextButton.disabled = currentPage >= totalPages; + + // 添加页码显示 + const paginationDiv = document.querySelector('.pagination'); + const pageInfo = document.getElementById('pageInfo'); + if (!pageInfo) { + const infoSpan = document.createElement('span'); + infoSpan.id = 'pageInfo'; + infoSpan.style.margin = '0 10px'; + paginationDiv.insertBefore(infoSpan, nextButton); + } + document.getElementById('pageInfo').textContent = `第 ${currentPage} / ${totalPages} 页`; } function showSearchResults() { @@ -539,7 +550,7 @@ try { console.log('执行搜索:', query); - const response = await fetch(`/search?q=${encodeURIComponent(query)}`); + const response = await fetch(`/search?q=${encodeURIComponent(query)}&page=${currentPage}&page_size=25`); const data = await response.json(); if (!response.ok) { @@ -547,6 +558,15 @@ } console.log('搜索响应:', data); + + // 更新总页数和分页状态 + if (typeof data.count === 'number') { + totalPages = Math.ceil(data.count / 25); + } else { + totalPages = data.results ? Math.ceil(data.results.length / 25) : 1; + } + updatePagination(); + displayResults(data.results); } catch (error) { console.error('搜索错误:', error); @@ -572,57 +592,55 @@ function formatTimeAgo(dateString) { if (!dateString) return '未知时间'; - const date = new Date(dateString); - if (isNaN(date.getTime())) { - // 尝试解析其他日期格式 - const formats = [ - 'YYYY-MM-DD', - 'YYYY/MM/DD', - 'MM/DD/YYYY', - 'DD/MM/YYYY' - ]; + try { + let date; + // 尝试标准格式解析 + date = new Date(dateString); - for (const format of formats) { - const d = tryParseDate(dateString, format); - if (d && !isNaN(d.getTime())) { - date = d; - break; + // 如果解析失败,尝试其他常见格式 + if (isNaN(date.getTime())) { + const formats = [ + { regex: /^(\d{4})-(\d{2})-(\d{2})$/, handler: (m) => new Date(m[1], m[2] - 1, m[3]) }, + { regex: /^(\d{4})\/(\d{2})\/(\d{2})$/, handler: (m) => new Date(m[1], m[2] - 1, m[3]) }, + { regex: /^(\d{2})\/(\d{2})\/(\d{4})$/, handler: (m) => new Date(m[3], m[1] - 1, m[2]) } + ]; + + for (const format of formats) { + const match = dateString.match(format.regex); + if (match) { + date = format.handler(match); + if (!isNaN(date.getTime())) break; + } + } + + if (isNaN(date.getTime())) { + console.warn('无法解析日期:', dateString); + return '未知时间'; } } - if (isNaN(date.getTime())) return '未知时间'; + const now = new Date(); + const diffTime = Math.abs(now - date); + const diffMinutes = Math.floor(diffTime / (1000 * 60)); + const diffHours = Math.floor(diffTime / (1000 * 60 * 60)); + const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + // 更精确的时间显示 + if (diffMinutes < 1) return '刚刚'; + if (diffMinutes < 60) return `${diffMinutes}分钟前`; + if (diffHours < 24) return `${diffHours}小时前`; + if (diffDays < 7) return `${diffDays}天前`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前`; + if (diffMonths < 12) return `${diffMonths}个月前`; + if (diffYears < 1) return '近1年'; + return `${diffYears}年前`; + + } catch (error) { + console.warn('日期处理错误:', error); + return '未知时间'; } - - const now = new Date(); - const diffTime = Math.abs(now - date); - const diffMinutes = Math.floor(diffTime / (1000 * 60)); - const diffHours = Math.floor(diffTime / (1000 * 60 * 60)); - const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); - const diffMonths = Math.floor(diffDays / 30); - const diffYears = Math.floor(diffDays / 365); - - if (diffMinutes < 5) return '几秒前'; - if (diffMinutes < 60) return `${diffMinutes}分钟前`; - if (diffHours < 24) return `${diffHours}小时前`; - if (diffDays < 7) return `${diffDays}天前`; - if (diffDays < 30) return `${Math.floor(diffDays / 7)}周前`; - if (diffMonths < 12) return `${diffMonths}个月前`; - return `${diffYears}年前`; - } - - function tryParseDate(dateString, format) { - const parts = dateString.split(/[-/]/); - const formatParts = format.split(/[-/]/); - const dateObj = {}; - - for (let i = 0; i < formatParts.length; i++) { - const part = formatParts[i].toUpperCase(); - if (part === 'YYYY') dateObj.year = parseInt(parts[i]); - else if (part === 'MM') dateObj.month = parseInt(parts[i]) - 1; - else if (part === 'DD') dateObj.day = parseInt(parts[i]); - } - - return new Date(dateObj.year, dateObj.month, dateObj.day); } function displayResults(results) { @@ -738,8 +756,10 @@ function displayTags(tags) { const tagList = document.getElementById('tagList'); const namespace = currentRepo.namespace || (currentRepo.is_official ? 'library' : ''); - const repoName = currentRepo.name || currentRepo.repo_name; - const fullRepoName = namespace ? `${namespace}/${repoName}` : repoName; + const name = currentRepo.name || currentRepo.repo_name || ''; + // 移除可能重复的 library/ 前缀 + const cleanName = name.replace(/^library\//, ''); + const fullRepoName = currentRepo.is_official ? cleanName : `${namespace}/${cleanName}`; let header = `
@@ -748,12 +768,11 @@ ${fullRepoName} ${currentRepo.is_official ? '官方' : ''} ${currentRepo.affiliation ? `By ${currentRepo.affiliation}` : ''} - ${currentRepo.is_automated ? '自动构建' : ''}
${currentRepo.short_description || '暂无描述'}
${currentRepo.star_count > 0 ? `⭐ ${formatNumber(currentRepo.star_count)}` : ''} - ${currentRepo.pull_count > 0 ? `⬇️ ${formatNumber(currentRepo.pull_count)}` : ''} + ${currentRepo.pull_count > 0 ? `⬇️ ${formatNumber(currentRepo.pull_count)}+` : ''} ${currentRepo.last_updated ? `更新于 ${formatTimeAgo(currentRepo.last_updated)}` : ''}
diff --git a/ghproxy/search.go b/ghproxy/search.go index faf6295..90093ae 100644 --- a/ghproxy/search.go +++ b/ghproxy/search.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "net/url" + "sort" "strings" "sync" "time" @@ -70,41 +71,206 @@ type cacheEntry struct { timestamp time.Time } -var ( - cache = make(map[string]cacheEntry) - cacheLock sync.RWMutex - cacheTTL = 30 * time.Minute +const ( + maxCacheSize = 1000 // 最大缓存条目数 + cacheTTL = 30 * time.Minute ) -func getCachedResult(key string) (interface{}, bool) { - cacheLock.RLock() - defer cacheLock.RUnlock() - entry, exists := cache[key] +type Cache struct { + data map[string]cacheEntry + mu sync.RWMutex + maxSize int +} + +var ( + searchCache = &Cache{ + data: make(map[string]cacheEntry), + maxSize: maxCacheSize, + } +) + +func (c *Cache) Get(key string) (interface{}, bool) { + c.mu.RLock() + entry, exists := c.data[key] + c.mu.RUnlock() + if !exists { return nil, false } + if time.Since(entry.timestamp) > cacheTTL { + c.mu.Lock() + delete(c.data, key) + c.mu.Unlock() return nil, false } + return entry.data, true } -func setCacheResult(key string, data interface{}) { - cacheLock.Lock() - defer cacheLock.Unlock() - cache[key] = cacheEntry{ +func (c *Cache) Set(key string, data interface{}) { + c.mu.Lock() + defer c.mu.Unlock() + + // 如果缓存已满,删除最旧的条目 + if len(c.data) >= c.maxSize { + oldest := time.Now() + var oldestKey string + for k, v := range c.data { + if v.timestamp.Before(oldest) { + oldest = v.timestamp + oldestKey = k + } + } + delete(c.data, oldestKey) + } + + c.data[key] = cacheEntry{ data: data, timestamp: time.Now(), } } +func (c *Cache) Cleanup() { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for key, entry := range c.data { + if now.Sub(entry.timestamp) > cacheTTL { + delete(c.data, key) + } + } +} + +// 定期清理过期缓存 +func init() { + go func() { + ticker := time.NewTicker(5 * time.Minute) + for range ticker.C { + searchCache.Cleanup() + } + }() +} + +// 改进的搜索结果过滤函数 +func filterSearchResults(results []Repository, query string) []Repository { + searchTerm := strings.ToLower(strings.TrimPrefix(query, "library/")) + filtered := make([]Repository, 0) + + for _, repo := range results { + // 标准化仓库名称 + repoName := strings.ToLower(repo.Name) + repoDesc := strings.ToLower(repo.Description) + + // 计算相关性得分 + score := 0 + + // 完全匹配 + if repoName == searchTerm { + score += 100 + } + + // 前缀匹配 + if strings.HasPrefix(repoName, searchTerm) { + score += 50 + } + + // 包含匹配 + if strings.Contains(repoName, searchTerm) { + score += 30 + } + + // 描述匹配 + if strings.Contains(repoDesc, searchTerm) { + score += 10 + } + + // 官方镜像加分 + if repo.IsOfficial { + score += 20 + } + + // 分数达到阈值的结果才保留 + if score > 0 { + filtered = append(filtered, repo) + } + } + + // 按相关性排序 + sort.Slice(filtered, func(i, j int) bool { + // 优先考虑官方镜像 + if filtered[i].IsOfficial != filtered[j].IsOfficial { + return filtered[i].IsOfficial + } + // 其次考虑拉取次数 + return filtered[i].PullCount > filtered[j].PullCount + }) + + return filtered +} + // searchDockerHub 搜索镜像 func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { cacheKey := fmt.Sprintf("search:%s:%d:%d", query, page, pageSize) - if cached, ok := getCachedResult(cacheKey); ok { + + // 尝试从缓存获取 + if cached, ok := searchCache.Get(cacheKey); ok { return cached.(*SearchResult), nil } + + // 重试逻辑 + var result *SearchResult + var lastErr error + + for retries := 3; retries > 0; retries-- { + result, lastErr = trySearchDockerHub(ctx, query, page, pageSize) + if lastErr == nil { + break + } + + // 判断是否需要重试 + if !isRetryableError(lastErr) { + return nil, fmt.Errorf("搜索失败: %v", lastErr) + } + + // 等待后重试 + time.Sleep(time.Second * time.Duration(4-retries)) + } + + if lastErr != nil { + return nil, fmt.Errorf("搜索失败,已重试3次: %v", lastErr) + } + + // 过滤和处理搜索结果 + result.Results = filterSearchResults(result.Results, query) + result.Count = len(result.Results) + + // 缓存结果 + searchCache.Set(cacheKey, result) + + return result, nil +} +// 判断错误是否可重试 +func isRetryableError(err error) bool { + if err == nil { + return false + } + + // 网络错误、超时等可以重试 + if strings.Contains(err.Error(), "timeout") || + strings.Contains(err.Error(), "connection refused") || + strings.Contains(err.Error(), "no such host") || + strings.Contains(err.Error(), "too many requests") { + return true + } + + return false +} + +// trySearchDockerHub 执行实际的Docker Hub API请求 +func trySearchDockerHub(ctx context.Context, query string, page, pageSize int) (*SearchResult, error) { // 构建Docker Hub API请求 baseURL := "https://registry.hub.docker.com/v2/search/repositories/" params := url.Values{} @@ -125,7 +291,17 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36") // 发送请求 - client := &http.Client{Timeout: 10 * time.Second} + client := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + DisableCompression: true, + DisableKeepAlives: false, + MaxIdleConnsPerHost: 10, + }, + } + resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("发送请求失败: %v", err) @@ -140,25 +316,25 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se // 检查响应状态码 if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) + // 特殊处理常见错误 + switch resp.StatusCode { + case http.StatusTooManyRequests: + return nil, fmt.Errorf("请求过于频繁,请稍后重试") + case http.StatusNotFound: + return nil, fmt.Errorf("未找到相关镜像") + case http.StatusBadGateway, http.StatusServiceUnavailable: + return nil, fmt.Errorf("Docker Hub服务暂时不可用,请稍后重试") + default: + return nil, fmt.Errorf("请求失败: 状态码=%d, 响应=%s", resp.StatusCode, string(body)) + } } - // 打印响应内容以便调试 - fmt.Printf("搜索响应: %s\n", string(body)) - // 解析响应 var result SearchResult if err := json.Unmarshal(body, &result); err != nil { return nil, fmt.Errorf("解析响应失败: %v", err) } - // 打印解析后的结果 - fmt.Printf("搜索结果: 总数=%d, 结果数=%d\n", result.Count, len(result.Results)) - for i, repo := range result.Results { - fmt.Printf("仓库[%d]: 名称=%s, 所有者=%s, 描述=%s, 是否官方=%v\n", - i, repo.Name, repo.RepoOwner, repo.Description, repo.IsOfficial) - } - // 处理搜索结果 for i := range result.Results { if result.Results[i].IsOfficial { @@ -180,7 +356,6 @@ func searchDockerHub(ctx context.Context, query string, page, pageSize int) (*Se } } - setCacheResult(cacheKey, &result) return &result, nil } @@ -191,7 +366,7 @@ func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, } cacheKey := fmt.Sprintf("tags:%s:%s", namespace, name) - if cached, ok := getCachedResult(cacheKey); ok { + if cached, ok := searchCache.Get(cacheKey); ok { return cached.([]TagInfo), nil } @@ -253,7 +428,8 @@ func getRepositoryTags(ctx context.Context, namespace, name string) ([]TagInfo, i, tag.Name, tag.FullSize, tag.LastUpdated) } - setCacheResult(cacheKey, result.Results) + // 缓存结果 + searchCache.Set(cacheKey, result.Results) return result.Results, nil }