trending.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. // ============================================================================
  2. // trending.go — GitHub Trending 热门仓库抓取
  3. //
  4. // 抓取 https://github.com/trending 解析仓库名 / 简介 / 语言 / 星数 / 今日新增星数 /
  5. // fork 数;简介若为英文则自动翻译为中文(MyMemory 免费翻译接口)。
  6. // ============================================================================
  7. package main
  8. import (
  9. "encoding/json"
  10. "fmt"
  11. "net/http"
  12. "net/url"
  13. "regexp"
  14. "strconv"
  15. "strings"
  16. "sync"
  17. "time"
  18. "unicode"
  19. )
  20. // TrendingRepo 一个热门仓库条目(来源可为 GitHub Trending 或 HelloGitHub)
  21. type TrendingRepo struct {
  22. Name string `json:"name"` // owner/repo 短名
  23. URL string `json:"url"` // 仓库主页地址(GitHub 直链)
  24. Desc string `json:"desc"` // 简介(英文已自动翻译为中文,翻译失败保留原文)
  25. Lang string `json:"lang"` // 主要语言,无则空
  26. Stars string `json:"stars"` // 总星数(如 "106,375")
  27. TodayStars string `json:"todayStars"` // 今日新增星数(如 "1,140",无则空)
  28. Forks string `json:"forks"` // fork 数(如 "9,365",无则空)
  29. Source string `json:"source"` // 来源:"github"(GitHub Trending)/ "hellogithub"
  30. Heat string `json:"heat"` // 热度(HelloGitHub 点击量,其它来源为空)
  31. }
  32. // fetchTrendingRepos 合并抓取 GitHub Trending 与 HelloGitHub 两个来源的热门仓库。
  33. // GitHub Trending 英文简介自动翻译为中文;HelloGitHub 简介本身已是中文。
  34. // 单来源失败不影响另一来源,全部失败才返回错误。
  35. func fetchTrendingRepos() ([]TrendingRepo, error) {
  36. var (
  37. githubRepos []TrendingRepo
  38. hgRepos []TrendingRepo
  39. )
  40. var wg sync.WaitGroup
  41. wg.Add(2)
  42. go func() {
  43. defer wg.Done()
  44. githubRepos, _ = fetchGitHubTrending() // 失败返回 nil,不阻塞另一来源
  45. }()
  46. go func() {
  47. defer wg.Done()
  48. hgRepos, _ = fetchHelloGitHub()
  49. }()
  50. wg.Wait()
  51. // 拼接:GitHub Trending 在前,HelloGitHub 在后
  52. merged := make([]TrendingRepo, 0, len(githubRepos)+len(hgRepos))
  53. merged = append(merged, githubRepos...)
  54. merged = append(merged, hgRepos...)
  55. if len(merged) == 0 {
  56. return nil, fmt.Errorf("热门仓库获取失败:GitHub Trending 与 HelloGitHub 均不可用")
  57. }
  58. return merged, nil
  59. }
  60. const trendingURL = "https://github.com/trending" // GitHub Trending 页
  61. // 匹配整个仓库条目(article.Box-row)
  62. var trendingItemRe = regexp.MustCompile(`(?s)<article[^>]*class="[^"]*Box-row[^"]*"[^>]*>(.*?)</article>`)
  63. // 仓库名链接:h2 内 a[href]
  64. var trendingNameRe = regexp.MustCompile(`(?s)<h2[^>]*>.*?<a[^>]+href="(/[^"/]+/[^"/]+)"[^>]*>(.*?)</a>`)
  65. // 仓库简介:article 内唯一的 p(class 含 color-fg-muted)
  66. var trendingDescRe = regexp.MustCompile(`(?s)<p[^>]*class="[^"]*color-fg-muted[^"]*"[^>]*>(.*?)</p>`)
  67. // 主要语言
  68. var trendingLangRe = regexp.MustCompile(`itemprop="programmingLanguage"[^>]*>([^<]+)<`)
  69. // 今日星数:如 "1,140 stars today"
  70. var trendingTodayRe = regexp.MustCompile(`([\d,]+)\s*stars\s*today`)
  71. // 总星数链接 / fork 数链接(数字可能嵌套在 span 内,允许中间任意标签)
  72. var trendingStarsRe = regexp.MustCompile(`href="[^"]*stargazers"[^>]*>(?:<[^>]+>)*\s*([\d,]+)`)
  73. var trendingForksRe = regexp.MustCompile(`href="[^"]*forks"[^>]*>(?:<[^>]+>)*\s*([\d,]+)`)
  74. var htmlTagRe2 = regexp.MustCompile(`<[^>]+>`) // 剥掉内联标签残留
  75. // fetchGitHubTrending 抓取 GitHub Trending 页并解析热门仓库列表。
  76. // 英文简介自动翻译为中文;任一仓库翻译失败不影响整体。
  77. // github.com 直连偶发超时,故内置重试。
  78. func fetchGitHubTrending() ([]TrendingRepo, error) {
  79. var body string
  80. var lastErr error
  81. for attempt := 1; attempt <= 3; attempt++ {
  82. body, lastErr = httpGetText(trendingURL)
  83. if lastErr == nil {
  84. break
  85. }
  86. time.Sleep(2 * time.Second) // 退避后重试
  87. }
  88. if lastErr != nil {
  89. return nil, lastErr
  90. }
  91. // 提取所有仓库条目
  92. blocks := trendingItemRe.FindAllStringSubmatch(body, -1)
  93. repos := make([]TrendingRepo, 0, len(blocks))
  94. // 并发翻译英文简介,避免逐个串行拖慢
  95. var mu sync.Mutex
  96. var wg sync.WaitGroup
  97. for _, block := range blocks {
  98. if len(block) < 2 {
  99. continue
  100. }
  101. item := parseTrendingBlock(block[1])
  102. if item.Name == "" {
  103. continue // 无法解析仓库名则跳过
  104. }
  105. item.Source = "github" // 标记来源:GitHub Trending
  106. repos = append(repos, item)
  107. wg.Add(1)
  108. go func(idx int) {
  109. defer wg.Done()
  110. if needsTranslate(repos[idx].Desc) {
  111. mu.Lock()
  112. repos[idx].Desc = translateText(repos[idx].Desc)
  113. mu.Unlock()
  114. }
  115. }(len(repos) - 1)
  116. }
  117. wg.Wait()
  118. if len(repos) == 0 {
  119. return nil, fmt.Errorf("未从 trending 页面解析到仓库")
  120. }
  121. return repos, nil
  122. }
  123. // parseTrendingBlock 从单个 article 块中解析仓库字段
  124. func parseTrendingBlock(block string) TrendingRepo {
  125. item := TrendingRepo{}
  126. // 仓库名与地址
  127. if m := trendingNameRe.FindStringSubmatch(block); m != nil {
  128. item.URL = "https://github.com" + strings.TrimSuffix(m[1], "/")
  129. if len(m) > 2 {
  130. item.Name = stripTags(m[2])
  131. }
  132. if item.Name == "" && len(m) > 1 {
  133. item.Name = strings.Trim(strings.Trim(m[1], "/"), "/")
  134. }
  135. // 名称可能被 span 拆分产生换行/多余空白,压缩为单行空格分隔
  136. item.Name = strings.Join(strings.Fields(item.Name), " ")
  137. }
  138. // 简介
  139. if m := trendingDescRe.FindStringSubmatch(block); m != nil {
  140. item.Desc = strings.TrimSpace(stripTags(m[1]))
  141. }
  142. // 语言
  143. if m := trendingLangRe.FindStringSubmatch(block); m != nil {
  144. item.Lang = strings.TrimSpace(m[1])
  145. }
  146. // 今日星数
  147. if m := trendingTodayRe.FindStringSubmatch(block); m != nil {
  148. item.TodayStars = m[1]
  149. }
  150. // 总星数
  151. if m := trendingStarsRe.FindStringSubmatch(block); m != nil {
  152. item.Stars = m[1]
  153. }
  154. // fork 数
  155. if m := trendingForksRe.FindStringSubmatch(block); m != nil {
  156. item.Forks = m[1]
  157. }
  158. return item
  159. }
  160. // stripTags 去除 HTML 标签并解码常见实体,返回纯文本
  161. func stripTags(s string) string {
  162. s = htmlTagRe2.ReplaceAllString(s, "")
  163. s = htmlUnescape(s)
  164. return strings.TrimSpace(s)
  165. }
  166. // htmlUnescape 解码 HTML 常用实体(&amp; &lt; &gt; &quot; &#39; &nbsp;)
  167. func htmlUnescape(s string) string {
  168. replacer := strings.NewReplacer(
  169. "&amp;", "&",
  170. "&lt;", "<",
  171. "&gt;", ">",
  172. "&quot;", `"`,
  173. "&#39;", "'",
  174. "&#x27;", "'",
  175. "&nbsp;", " ",
  176. )
  177. return replacer.Replace(s)
  178. }
  179. // needsTranslate 判断字符串是否主要为英文(需翻译)。
  180. // 规则:无 CJK 字符,且 ASCII 字母占比 > 50%。
  181. func needsTranslate(s string) bool {
  182. s = strings.TrimSpace(s)
  183. if s == "" {
  184. return false
  185. }
  186. total := 0
  187. letters := 0
  188. cjk := 0
  189. for _, r := range s {
  190. if unicode.IsLetter(r) || unicode.IsDigit(r) {
  191. total++
  192. }
  193. if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
  194. letters++
  195. }
  196. if r >= 0x4E00 && r <= 0x9FFF { // 基本 CJK 统一表意文字
  197. cjk++
  198. }
  199. }
  200. if total == 0 {
  201. return false
  202. }
  203. // 含 CJK 字符则视为已中文,不翻译;否则按字母占比判断
  204. if cjk > 0 {
  205. return false
  206. }
  207. return float64(letters)/float64(total) > 0.5
  208. }
  209. // myMemory 翻译响应结构
  210. type myMemoryResponse struct {
  211. ResponseData struct {
  212. TranslatedText string `json:"translatedText"`
  213. } `json:"responseData"`
  214. ResponseStatus int `json:"responseStatus"`
  215. }
  216. // translateText 调用 MyMemory 免费翻译接口把英文译为中文。
  217. // 失败或返回空则原样返回(不阻塞展示)。
  218. func translateText(text string) string {
  219. text = strings.TrimSpace(text)
  220. if text == "" {
  221. return text
  222. }
  223. apiURL := "https://api.mymemory.translated.net/get?q=" + url.QueryEscape(text) + "&langpair=en|zh-CN"
  224. client := &http.Client{Timeout: 10 * time.Second}
  225. resp, err := client.Get(apiURL)
  226. if err != nil {
  227. return text // 翻译失败保留原文
  228. }
  229. defer resp.Body.Close()
  230. if resp.StatusCode < 200 || resp.StatusCode > 299 {
  231. return text
  232. }
  233. var out myMemoryResponse
  234. if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
  235. return text
  236. }
  237. if out.ResponseStatus != 200 || strings.TrimSpace(out.ResponseData.TranslatedText) == "" {
  238. return text
  239. }
  240. return strings.TrimSpace(out.ResponseData.TranslatedText)
  241. }
  242. // formatTrendingStars 把 "1,140" 字符串解析为数字(用于前端可排序,暂未使用)
  243. func formatTrendingStars(s string) int64 {
  244. n, _ := strconv.ParseInt(strings.ReplaceAll(s, ",", ""), 10, 64)
  245. return n
  246. }
  247. // ------------------------------------------------------------------------
  248. // HelloGitHub 来源:https://hellogithub.com/ 中文热门 GitHub 项目周刊。
  249. // 页面为 Next.js CSR,仓库列表由 API 提供(无需登录,无星数/fork)。
  250. // ------------------------------------------------------------------------
  251. const hellogithubAPI = "https://api.hellogithub.com/v1/" // HelloGitHub 列表 API
  252. // hellogithubItem 列表 API 单条记录
  253. type hellogithubItem struct {
  254. FullName string `json:"full_name"` // owner/repo
  255. Title string `json:"title"` // 中文标题
  256. Summary string `json:"summary"` // 中文简介
  257. PrimaryLang string `json:"primary_lang"` // 主要语言
  258. LangColor string `json:"lang_color"` // 语言颜色(十六进制)
  259. ClicksTotal int `json:"clicks_total"` // 点击热度
  260. CommentTotal int `json:"comment_total"` // 评论数
  261. IsHot bool `json:"is_hot"` // 是否热门标记
  262. }
  263. // hellogithubResp 列表 API 响应
  264. type hellogithubResp struct {
  265. Success bool `json:"success"`
  266. Page int `json:"page"`
  267. HasMore bool `json:"has_more"`
  268. Data []hellogithubItem `json:"data"`
  269. }
  270. // fetchHelloGitHub 抓取 HelloGitHub 精选热门仓库(首页第一页)。
  271. // 返回的条目简介本身是中文,无需翻译。
  272. func fetchHelloGitHub() ([]TrendingRepo, error) {
  273. body, err := httpGetText(hellogithubAPI + "?sort_by=featured&page=1")
  274. if err != nil {
  275. return nil, err
  276. }
  277. var resp hellogithubResp
  278. if err := json.Unmarshal([]byte(body), &resp); err != nil {
  279. return nil, err
  280. }
  281. if !resp.Success || len(resp.Data) == 0 {
  282. return nil, fmt.Errorf("HelloGitHub API 返回空数据")
  283. }
  284. repos := make([]TrendingRepo, 0, len(resp.Data))
  285. for _, it := range resp.Data {
  286. if it.FullName == "" {
  287. continue
  288. }
  289. // 简介优先中文 summary,其次英文 summary_en,最后 title
  290. desc := it.Summary
  291. if strings.TrimSpace(desc) == "" {
  292. desc = it.Title
  293. }
  294. repos = append(repos, TrendingRepo{
  295. Name: it.FullName, // owner/repo
  296. URL: "https://github.com/" + it.FullName, // GitHub 直链
  297. Desc: strings.TrimSpace(desc),
  298. Lang: it.PrimaryLang,
  299. Source: "hellogithub",
  300. Heat: strconv.Itoa(it.ClicksTotal), // 热度(点击量)
  301. })
  302. }
  303. if len(repos) == 0 {
  304. return nil, fmt.Errorf("HelloGitHub API 无有效仓库")
  305. }
  306. return repos, nil
  307. }