用 Go 實現一個 GitHub Trending API
背景
上一篇文章 Go 每日一庫之 bubbletea 我們介紹了炫酷的 TUI 程序框架 — bubbletea
。最後實現了一個拉取 GitHub Trending 倉庫,並顯示在控制檯的程序。由於 GitHub 沒有提供官方的 Trending API,我們用goquery
自己實現了一個。上篇文章由於篇幅關係,沒有介紹如何實現。本文我整理了一下代碼,並以單獨的代碼庫形式開放出來。
先觀察
首先,我們來觀察一下 GitHub Trending 的結構:
左上角可以切換倉庫(Repositories)和開發者(Developers)。右邊可以選擇語言(Spoken Language,本地語言,漢語、英文等)、語言(Language,編程語言,Golang、C++ 等)和時間範圍(Date Range,支持 3 個維度,Today、This week、This month)。
然後下面是每個倉庫的信息:
① 倉庫作者和名字
② 倉庫描述
③ 主要使用的編程語言(創建倉庫時設置的),也可能沒有
④ 星數
⑤ fork 數
⑥ 貢獻者列表
⑦ 選定的時間範圍內(Today、This week、This month)新增多少星數
開發者頁面也是類似的,只不過信息少了很多:
① 作者信息
② 最火的倉庫信息
注意到切換的開發者頁面後,URL 變成爲github.com/trending/developers
。另外當我們選擇本地語言爲中文、開發語言爲 Go 和時間範圍爲 Today 後,URL 變爲https://github.com/trending/go?since=daily&spoken_language_code=zh
,通過在 query-string 中增加相應的鍵值對錶示這種選擇。
準備
在 GitHub 上創建倉庫ghtrending
,clone 到本地,執行go mod init
初始化:
$ go mod init github.com/darjun/ghtrending
然後執行go get
下載goquery
庫:
$ go get github.com/PuerkitoBio/goquery
根據倉庫和開發者的信息定義兩個結構:
type Repository struct {
Author string
Name string
Link string
Desc string
Lang string
Stars int
Forks int
Add int
BuiltBy []string
}
type Developer struct {
Name string
Username string
PopularRepo string
Desc string
}
開爬
要想使用goquery
獲取相應的信息,我們首先要知道,對應的網頁結構。按 F12 打開 chrome 開發者工具,選擇Elements
頁籤,即可看到網頁結構:
使用左上角的按鈕就可以很快速的查看網頁上任何內容的結構,我們點擊單個倉庫條目:
右邊Elements
窗口顯示每個倉庫條目對應一個article
元素:
可以使用標準庫net/http
獲取整個網頁的內容:
resp, err := http.Get("https://github.com/trending")
然後從resp
對象中創建goquery
文檔結構:
doc, err := goquery.NewDocumentFromReader(resp.Body)
有了文檔結構對象,我們可以調用其Find()
方法,傳入選擇器,這裏我選擇.Box .Box-row
。.Box
是整個列表div
的 class,.Box-row
是倉庫條目的 class。這樣的選擇更精準。Find()
方法返回一個*goquery.Selection
對象,我們可以調用其Each()
方法對每個條目進行解析。Each()
接收一個func(int, *goquery.Selection)
類型的函數,第二個參數即爲每個倉庫條目在 goquery 中的結構:
doc.Find(".Box .Box-row").Each(func(i int, s *goquery.Selection) {
})
接下來我們看看如何提取各個部分。在Elements
窗口中移動,可以很直觀的看到每個元素對應頁面的哪個部分:
我們找到倉庫名和作者對應的結構:
它被包在article
元素下的h1
元素下的a
元素內,作者名在span
元素內,倉庫名直接在a
下,另外倉庫的 URL 鏈接是a
元素的href
屬性。我們來獲取它們:
titleSel := s.Find("h1 a")
repo.Author = strings.Trim(titleSel.Find("span").Text(), "/\n ")
repo.Name = strings.TrimSpace(titleSel.Contents().Last().Text())
relativeLink, _ := titleSel.Attr("href")
if len(relativeLink) > 0 {
repo.Link = "https://github.com" + relativeLink
}
倉庫描述在article
元素內的p
元素中:
repo.Desc = strings.TrimSpace(s.Find("p").Text())
編程語言,星數,fork 數,貢獻者(BuiltBy
)和新增星數都在article
元素的最後一個div
中。編程語言、BuiltBy
和新增星數在span
元素內,星數和 fork 數在a
元素內。如果編程語言未設置,則少一個span
元素:
var langIdx, addIdx, builtByIdx int
spanSel := s.Find("div>span")
if spanSel.Size() == 2 {
// language not exist
langIdx = -1
addIdx = 1
} else {
builtByIdx = 1
addIdx = 2
}
// language
if langIdx >= 0 {
repo.Lang = strings.TrimSpace(spanSel.Eq(langIdx).Text())
} else {
repo.Lang = "unknown"
}
// add
addParts := strings.SplitN(strings.TrimSpace(spanSel.Eq(addIdx).Text()), " ", 2)
repo.Add, _ = strconv.Atoi(addParts[0])
// builtby
spanSel.Eq(builtByIdx).Find("a>img").Each(func(i int, img *goquery.Selection) {
src, _ := img.Attr("src")
repo.BuiltBy = append(repo.BuiltBy, src)
})
然後是星數和 fork 數:
aSel := s.Find("div>a")
starStr := strings.TrimSpace(aSel.Eq(-2).Text())
star, _ := strconv.Atoi(strings.Replace(starStr, ",", "", -1))
repo.Stars = star
forkStr := strings.TrimSpace(aSel.Eq(-1).Text())
fork, _ := strconv.Atoi(strings.Replace(forkStr, ",", "", -1))
repo.Forks = fork
Developers 也是類似的做法。這裏就不贅述了。使用goquery
有一點需要注意,因爲網頁層級結構比較複雜,我們使用選擇器的時候儘量多限定一些元素、class,以確保找到的確實是我們想要的那個結構。另外網頁上獲取的內容有很多空格,需要使用strings.TrimSpace()
移除。
接口設計
基本工作完成之後,我們來看看如何設計接口。我想提供一個類型和一個創建該類型對象的方法,然後調用對象的FetchRepos()
和FetchDevelopers()
方法就可以獲取倉庫和開發者列表。但是我不希望用戶瞭解這個類型的細節。所以我定義了一個接口:
type Fetcher interface {
FetchRepos() ([]*Repository, error)
FetchDevelopers() ([]*Developer, error)
}
我們定義一個類型來實現這個接口:
type trending struct{}
func New() Fetcher {
return &trending{}
}
func (t trending) FetchRepos() ([]*Repository, error) {
}
func (t trending) FetchDevelopers() ([]*Developer, error) {
}
我們上面介紹的爬取邏輯就是放在FetchRepos()
和FetchDevelopers()
方法中。
然後,我們就可以在其他地方使用了:
import "github.com/darjun/ghtrending"
t := ghtrending.New()
repos, err := t.FetchRepos()
developers, err := t.FetchDevelopers()
選項
前面也說過,GitHub Trending 支持選定本地語言、編程語言和時間範圍等。我們希望把這些設置作爲選項,使用 Go 語言常用的選項模式 / 函數式選項(functional option)。先定義選項結構:
type options struct {
GitHubURL string
SpokenLang string
Language string // programming language
DateRange string
}
type option func(*options)
然後定義 3 個DataRange
選項:
func WithDaily() option {
return func(opt *options) {
opt.DateRange = "daily"
}
}
func WithWeekly() option {
return func(opt *options) {
opt.DateRange = "weekly"
}
}
func WithMonthly() option {
return func(opt *options) {
opt.DateRange = "monthly"
}
}
以後可能還有其他範圍的時間,留一個通用一點的選項:
func WithDateRange(dr string) option {
return func(opt *options) {
opt.DateRange = dr
}
}
編程語言選項:
func WithLanguage(lang string) option {
return func(opt *options) {
opt.Language = lang
}
}
本地語言選項,國家和代碼分開,例如 Chinese 的代碼爲 cn:
func WithSpokenLanguageCode(code string) option {
return func(opt *options) {
opt.SpokenLang = code
}
}
func WithSpokenLanguageFull(lang string) option {
return func(opt *options) {
opt.SpokenLang = spokenLangCode[lang]
}
}
spokenLangCode
是 GitHub 支持的國家和代碼的對照,我是從 GitHub Trending 頁面爬取的。大概是這樣的:
var (
spokenLangCode map[string]string
)
func init() {
spokenLangCode = map[string]string{
"abkhazian": "ab",
"afar": "aa",
"afrikaans": "af",
"akan": "ak",
"albanian": "sq",
// ...
}
}
最後我希望 GitHub 的 URL 也可以設置:
func WithURL(url string) option {
return func(opt *options) {
opt.GitHubURL = url
}
}
我們在trending
結構中增加options
字段,然後改造一下New()
方法,讓它接受可變參數的選項。這樣我們只需要設置我們想要設置的,其他的選項都可以採用默認值,例如GitHubURL
:
type trending struct {
opts options
}
func loadOptions(opts ...option) options {
o := options{
GitHubURL: "http://github.com",
}
for _, option := range opts {
option(&o)
}
return o
}
func New(opts ...option) Fetcher {
return &trending{
opts: loadOptions(opts...),
}
}
最後在FetchRepos()
方法和FetchDevelopers()
方法中根據選項拼接 URL:
fmt.Sprintf("%s/trending/%s?spoken_language_code=%s&since=%s", t.opts.GitHubURL, t.opts.Language, t.opts.SpokenLang, t.opts.DateRange)
fmt.Sprintf("%s/trending/developers?lanugage=%s&since=%s", t.opts.GitHubURL, t.opts.Language, t.opts.DateRange)
加入選項之後,如果我們要獲取一週內的,Go 語言 Trending 列表,可以這樣:
t := ghtrending.New(ghtrending.WithWeekly(), ghtreading.WithLanguage("Go"))
repos, _ := t.FetchRepos()
簡單方法
另外,我們還提供一個不需要創建trending
對象,直接調用接口獲取倉庫和開發者列表的方法(懶人專用):
func TrendingRepositories(opts ...option) ([]*Repository, error) {
return New(opts...).FetchRepos()
}
func TrendingDevelopers(opts ...option) ([]*Developer, error) {
return New(opts...).FetchDevelopers()
}
使用效果
新建目錄並初始化 Go Modules:
$ mkdir -p demo/ghtrending && cd demo/ghtrending
$ go mod init github/darjun/demo/ghtrending
下載包:
編寫代碼:
package main
import (
"fmt"
"log"
"github.com/darjun/ghtrending"
)
func main() {
t := ghtrending.New()
repos, err := t.FetchRepos()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d repos\n", len(repos))
fmt.Printf("first repo:%#v\n", repos[0])
developers, err := t.FetchDevelopers()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d developers\n", len(developers))
fmt.Printf("first developer:%#v\n", developers[0])
}
運行效果:
文檔
最後,我們加點文檔:
一個小開源庫就完成了。
總結
本文介紹如何使用goquery
爬取網頁。着重介紹了ghtrending
的接口設計。在編寫一個庫的時候,應該提供易用的、最小化的接口。用戶不需要了解庫的實現細節就可以使用。ghtrending
使用函數式選項就是一個例子,有需要才傳遞,無需要可不提供。
自己通過爬取網頁的方式來獲取 Trending 列表比較容易受限制,例如過段時間 GitHub 網頁結構變了,代碼就不得不做適配。在官方沒有提供 API 的情況下,目前也只能這麼做了。
大家如果發現好玩、好用的 Go 語言庫,歡迎到 Go 每日一庫 GitHub 上提交 issue😄
參考
-
ghtrending GitHub:github.com/darjun/ghtrending
-
Go 每日一庫之 goquery:https://darjun.github.io/2020/10/11/godailylib/goquery
-
Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib
原文鏈接:用 Go 實現一個 GitHub Trending API
福利
我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/22IHyAVOEAhfqnepcRXA5g