// ============================================================================
// trending.go — GitHub Trending 热门仓库抓取
//
// 抓取 https://github.com/trending 解析仓库名 / 简介 / 语言 / 星数 / 今日新增星数 /
// fork 数;简介若为英文则自动翻译为中文(MyMemory 免费翻译接口)。
// ============================================================================
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
)
// TrendingRepo 一个热门仓库条目(来源可为 GitHub Trending 或 HelloGitHub)
type TrendingRepo struct {
Name string `json:"name"` // owner/repo 短名
URL string `json:"url"` // 仓库主页地址(GitHub 直链)
Desc string `json:"desc"` // 简介(英文已自动翻译为中文,翻译失败保留原文)
Lang string `json:"lang"` // 主要语言,无则空
Stars string `json:"stars"` // 总星数(如 "106,375")
TodayStars string `json:"todayStars"` // 今日新增星数(如 "1,140",无则空)
Forks string `json:"forks"` // fork 数(如 "9,365",无则空)
Source string `json:"source"` // 来源:"github"(GitHub Trending)/ "hellogithub"
Heat string `json:"heat"` // 热度(HelloGitHub 点击量,其它来源为空)
}
// fetchTrendingRepos 合并抓取 GitHub Trending 与 HelloGitHub 两个来源的热门仓库。
// GitHub Trending 英文简介自动翻译为中文;HelloGitHub 简介本身已是中文。
// 单来源失败不影响另一来源,全部失败才返回错误。
func fetchTrendingRepos() ([]TrendingRepo, error) {
var (
githubRepos []TrendingRepo
hgRepos []TrendingRepo
)
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
githubRepos, _ = fetchGitHubTrending() // 失败返回 nil,不阻塞另一来源
}()
go func() {
defer wg.Done()
hgRepos, _ = fetchHelloGitHub()
}()
wg.Wait()
// 拼接:GitHub Trending 在前,HelloGitHub 在后
merged := make([]TrendingRepo, 0, len(githubRepos)+len(hgRepos))
merged = append(merged, githubRepos...)
merged = append(merged, hgRepos...)
if len(merged) == 0 {
return nil, fmt.Errorf("热门仓库获取失败:GitHub Trending 与 HelloGitHub 均不可用")
}
return merged, nil
}
const trendingURL = "https://github.com/trending" // GitHub Trending 页
// 匹配整个仓库条目(article.Box-row)
var trendingItemRe = regexp.MustCompile(`(?s)
]*class="[^"]*color-fg-muted[^"]*"[^>]*>(.*?)
`) // 主要语言 var trendingLangRe = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>([^<]+)<`) // 今日星数:如 "1,140 stars today" var trendingTodayRe = regexp.MustCompile(`([\d,]+)\s*stars\s*today`) // 总星数链接 / fork 数链接(数字可能嵌套在 span 内,允许中间任意标签) var trendingStarsRe = regexp.MustCompile(`href="[^"]*stargazers"[^>]*>(?:<[^>]+>)*\s*([\d,]+)`) var trendingForksRe = regexp.MustCompile(`href="[^"]*forks"[^>]*>(?:<[^>]+>)*\s*([\d,]+)`) var htmlTagRe2 = regexp.MustCompile(`<[^>]+>`) // 剥掉内联标签残留 // fetchGitHubTrending 抓取 GitHub Trending 页并解析热门仓库列表。 // 英文简介自动翻译为中文;任一仓库翻译失败不影响整体。 // github.com 直连偶发超时,故内置重试。 func fetchGitHubTrending() ([]TrendingRepo, error) { var body string var lastErr error for attempt := 1; attempt <= 3; attempt++ { body, lastErr = httpGetText(trendingURL) if lastErr == nil { break } time.Sleep(2 * time.Second) // 退避后重试 } if lastErr != nil { return nil, lastErr } // 提取所有仓库条目 blocks := trendingItemRe.FindAllStringSubmatch(body, -1) repos := make([]TrendingRepo, 0, len(blocks)) // 并发翻译英文简介,避免逐个串行拖慢 var mu sync.Mutex var wg sync.WaitGroup for _, block := range blocks { if len(block) < 2 { continue } item := parseTrendingBlock(block[1]) if item.Name == "" { continue // 无法解析仓库名则跳过 } item.Source = "github" // 标记来源:GitHub Trending repos = append(repos, item) wg.Add(1) go func(idx int) { defer wg.Done() if needsTranslate(repos[idx].Desc) { mu.Lock() repos[idx].Desc = translateText(repos[idx].Desc) mu.Unlock() } }(len(repos) - 1) } wg.Wait() if len(repos) == 0 { return nil, fmt.Errorf("未从 trending 页面解析到仓库") } return repos, nil } // parseTrendingBlock 从单个 article 块中解析仓库字段 func parseTrendingBlock(block string) TrendingRepo { item := TrendingRepo{} // 仓库名与地址 if m := trendingNameRe.FindStringSubmatch(block); m != nil { item.URL = "https://github.com" + strings.TrimSuffix(m[1], "/") if len(m) > 2 { item.Name = stripTags(m[2]) } if item.Name == "" && len(m) > 1 { item.Name = strings.Trim(strings.Trim(m[1], "/"), "/") } // 名称可能被 span 拆分产生换行/多余空白,压缩为单行空格分隔 item.Name = strings.Join(strings.Fields(item.Name), " ") } // 简介 if m := trendingDescRe.FindStringSubmatch(block); m != nil { item.Desc = strings.TrimSpace(stripTags(m[1])) } // 语言 if m := trendingLangRe.FindStringSubmatch(block); m != nil { item.Lang = strings.TrimSpace(m[1]) } // 今日星数 if m := trendingTodayRe.FindStringSubmatch(block); m != nil { item.TodayStars = m[1] } // 总星数 if m := trendingStarsRe.FindStringSubmatch(block); m != nil { item.Stars = m[1] } // fork 数 if m := trendingForksRe.FindStringSubmatch(block); m != nil { item.Forks = m[1] } return item } // stripTags 去除 HTML 标签并解码常见实体,返回纯文本 func stripTags(s string) string { s = htmlTagRe2.ReplaceAllString(s, "") s = htmlUnescape(s) return strings.TrimSpace(s) } // htmlUnescape 解码 HTML 常用实体(& < > " ' ) func htmlUnescape(s string) string { replacer := strings.NewReplacer( "&", "&", "<", "<", ">", ">", """, `"`, "'", "'", "'", "'", " ", " ", ) return replacer.Replace(s) } // needsTranslate 判断字符串是否主要为英文(需翻译)。 // 规则:无 CJK 字符,且 ASCII 字母占比 > 50%。 func needsTranslate(s string) bool { s = strings.TrimSpace(s) if s == "" { return false } total := 0 letters := 0 cjk := 0 for _, r := range s { if unicode.IsLetter(r) || unicode.IsDigit(r) { total++ } if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') { letters++ } if r >= 0x4E00 && r <= 0x9FFF { // 基本 CJK 统一表意文字 cjk++ } } if total == 0 { return false } // 含 CJK 字符则视为已中文,不翻译;否则按字母占比判断 if cjk > 0 { return false } return float64(letters)/float64(total) > 0.5 } // myMemory 翻译响应结构 type myMemoryResponse struct { ResponseData struct { TranslatedText string `json:"translatedText"` } `json:"responseData"` ResponseStatus int `json:"responseStatus"` } // translateText 调用 MyMemory 免费翻译接口把英文译为中文。 // 失败或返回空则原样返回(不阻塞展示)。 func translateText(text string) string { text = strings.TrimSpace(text) if text == "" { return text } apiURL := "https://api.mymemory.translated.net/get?q=" + url.QueryEscape(text) + "&langpair=en|zh-CN" client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(apiURL) if err != nil { return text // 翻译失败保留原文 } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode > 299 { return text } var out myMemoryResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return text } if out.ResponseStatus != 200 || strings.TrimSpace(out.ResponseData.TranslatedText) == "" { return text } return strings.TrimSpace(out.ResponseData.TranslatedText) } // formatTrendingStars 把 "1,140" 字符串解析为数字(用于前端可排序,暂未使用) func formatTrendingStars(s string) int64 { n, _ := strconv.ParseInt(strings.ReplaceAll(s, ",", ""), 10, 64) return n } // ------------------------------------------------------------------------ // HelloGitHub 来源:https://hellogithub.com/ 中文热门 GitHub 项目周刊。 // 页面为 Next.js CSR,仓库列表由 API 提供(无需登录,无星数/fork)。 // ------------------------------------------------------------------------ const hellogithubAPI = "https://api.hellogithub.com/v1/" // HelloGitHub 列表 API // hellogithubItem 列表 API 单条记录 type hellogithubItem struct { FullName string `json:"full_name"` // owner/repo Title string `json:"title"` // 中文标题 Summary string `json:"summary"` // 中文简介 PrimaryLang string `json:"primary_lang"` // 主要语言 LangColor string `json:"lang_color"` // 语言颜色(十六进制) ClicksTotal int `json:"clicks_total"` // 点击热度 CommentTotal int `json:"comment_total"` // 评论数 IsHot bool `json:"is_hot"` // 是否热门标记 } // hellogithubResp 列表 API 响应 type hellogithubResp struct { Success bool `json:"success"` Page int `json:"page"` HasMore bool `json:"has_more"` Data []hellogithubItem `json:"data"` } // fetchHelloGitHub 抓取 HelloGitHub 精选热门仓库(首页第一页)。 // 返回的条目简介本身是中文,无需翻译。 func fetchHelloGitHub() ([]TrendingRepo, error) { body, err := httpGetText(hellogithubAPI + "?sort_by=featured&page=1") if err != nil { return nil, err } var resp hellogithubResp if err := json.Unmarshal([]byte(body), &resp); err != nil { return nil, err } if !resp.Success || len(resp.Data) == 0 { return nil, fmt.Errorf("HelloGitHub API 返回空数据") } repos := make([]TrendingRepo, 0, len(resp.Data)) for _, it := range resp.Data { if it.FullName == "" { continue } // 简介优先中文 summary,其次英文 summary_en,最后 title desc := it.Summary if strings.TrimSpace(desc) == "" { desc = it.Title } repos = append(repos, TrendingRepo{ Name: it.FullName, // owner/repo URL: "https://github.com/" + it.FullName, // GitHub 直链 Desc: strings.TrimSpace(desc), Lang: it.PrimaryLang, Source: "hellogithub", Heat: strconv.Itoa(it.ClicksTotal), // 热度(点击量) }) } if len(repos) == 0 { return nil, fmt.Errorf("HelloGitHub API 无有效仓库") } return repos, nil }