上一篇文章Go 每日一庫之 bubbletea咱們介紹了炫酷的 TUI 程序框架 — bubbletea
。最後實現了一個拉取 GitHub Trending 倉庫,並顯示在控制檯的程序。因爲 GitHub 沒有提供官方的 Trending API,咱們用goquery
本身實現了一個。上篇文章因爲篇幅關係zh,沒有介紹如何實現。本文我整理了一下代碼,並以單獨的代碼庫形式開放出來。git
首先,咱們來觀察一下 GitHub Trending 的結構:github
左上角能夠切換倉庫(Repositories)和開發者(Developers)。右邊能夠選擇語言(Spoken Language,本地語言,漢語、英文等)、語言(Language,編程語言,Golang、C++等)和時間範圍(Date Range,支持 3 個維度,Today、This week、This month)。golang
而後下面是每一個倉庫的信息:chrome
① 倉庫做者和名字編程
② 倉庫描述微信
③ 主要使用的編程語言(建立倉庫時設置的),也可能沒有app
④ 星數框架
⑤ 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😄
歡迎關注個人微信公衆號【GoUpUp】,共同窗習,一塊兒進步~