| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342 |
- // ============================================================================
- // 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)<article[^>]*class="[^"]*Box-row[^"]*"[^>]*>(.*?)</article>`)
- // 仓库名链接:h2 内 a[href]
- var trendingNameRe = regexp.MustCompile(`(?s)<h2[^>]*>.*?<a[^>]+href="(/[^"/]+/[^"/]+)"[^>]*>(.*?)</a>`)
- // 仓库简介:article 内唯一的 p(class 含 color-fg-muted)
- var trendingDescRe = regexp.MustCompile(`(?s)<p[^>]*class="[^"]*color-fg-muted[^"]*"[^>]*>(.*?)</p>`)
- // 主要语言
- 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
- }
|