用 Go 實現一個 GitHub Trending API

背景

上一篇文章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😄

參考

  1. ghtrending GitHub:github.com/darjun/ghtrending
  2. Go 每日一庫之 goquery:https://darjun.github.io/2020/10/11/godailylib/goquery
  3. Go 每日一庫 GitHub:https://github.com/darjun/go-daily-lib

個人博客:https://darjun.github.io

歡迎關注個人微信公衆號【GoUpUp】,共同窗習,一塊兒進步~

相關文章
相關標籤/搜索