Go 爬蟲之 colly 從入門到不放棄指南

最近發現知乎上感興趣的問題越來越少,於是準備聚合下其他平臺技術問答,比如 segmentfault、stackoverflow 等。

要完成這個工作,肯定是離不開爬蟲的。我就順便抽時間研究了 Go 的一款爬蟲框架 colly。

概要介紹

colly 是 Go 實現的比較有名的一款爬蟲框架,而且 Go 在高併發和分佈式場景的優勢也正是爬蟲技術所需要的。它的主要特點是輕量、快速,設計非常優雅,並且分佈式的支持也非常簡單,易於擴展。

如何學習

爬蟲最有名的框架應該就是 Python 的 scrapy,很多人最早接觸的爬蟲框架就是它,我也不例外。它的文檔非常齊全,擴展組件也很豐富。當我們要設計一款爬蟲框架時,常會參考它的設計。之前看到一些文章介紹 Go 中也有類似 scrapy 的實現。

相比而言,colly 的學習資料就少的可憐了。剛看到它的時候,我總會情不自禁想借鑑我的 scrapy 使用經驗,但結果發現這種生搬硬套並不可行。

到此,我們自然地想到去找些文章閱讀,但結果是 colly 相關文章確實有點少,能找到的基本都是官方提供的,而且看起來似乎不是那麼完善。沒辦法,慢慢啃吧!官方的學習資料通常都會有三處,分別是文檔、案例和源碼。

今天,暫時先從官方文檔角度吧!正文開始。

官方文檔

官方文檔 [1] 介紹着重使用方法,如果是有爬蟲經驗的朋友,掃完一遍文檔很快。我花了點時間將官網文檔的按自己的思路整理了一版。

主體內容不多,涉及安裝 [2]、快速開始 [3]、如何配置 [4]、調試 [5]、分佈式爬蟲 [6]、存儲 [7]、運用多收集器 [8]、配置優化 [9]、擴展 [10]。

其中的每篇文檔都很短小,甚至是少的基本都不用翻頁滾動。

如何安裝 [11]

colly 的安裝和其他的 Go 庫安裝一樣簡單。如下:

go get -u github.com/gocolly/colly

一行命令搞定。So easy!

快速開始 [12]

我們來通過一個 hello word 案例快速體驗下 colly 的使用。步驟如下:

第一步,導入 colly。

import"github.com/gocolly/colly"

第二步,創建 collector。

c := colly.NewCollector()

第三步,事件監聽,通過 callback 執行事件處理。

// Find and visit all links
c.OnHTML("a[href]", func(e *colly.HTMLElement) {
	link := e.Attr("href")
	// Print link
	fmt.Printf("Link found: %q -> %s\n", e.Text, link)
	// Visit link found on page
	// Only those links are visited which are in AllowedDomains
	c.Visit(e.Request.AbsoluteURL(link))
})

c.OnRequest(func(r *colly.Request) {
	fmt.Println("Visiting", r.URL)
})

我們順便列舉一下 colly 支持的事件類型,如下:

最後一步,c.Visit() 正式啓動網頁訪問。

c.Visit("http://go-colly.org/")

案例的完整代碼在 colly 源碼的 _example 目錄下 basic[13] 中提供。

如何配置 [14]

colly 是一款配置靈活的框架,提供了大量的可供開發人員配置的選項。默認情況下,每個選項都提供了較優的默認值。

如下是採用默認創建的 collector。

c := colly.NewCollector()

配置創建的 collector,比如設置 useragent 和允許重複訪問。代碼如下:

c2 := colly.NewCollector(
	colly.UserAgent("xy"),
	colly.AllowURLRevisit(),
)

我們也可以創建後再改變配置。

c2 := colly.NewCollector()
c2.UserAgent = "xy"
c2.AllowURLRevisit = true

collector 的配置可以在爬蟲執行到任何階段改變。一個經典的例子,通過隨機改變 user-agent,可以幫助我們實現簡單的反爬。

const letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func RandomString() string {
	b := make([]byte, rand.Intn(10)+10)
	for i := range b {
		b[i] = letterBytes[rand.Intn(len(letterBytes))]
	}
	returnstring(b)
}

c := colly.NewCollector()

c.OnRequest(func(r *colly.Request) {
	r.Headers.Set("User-Agent", RandomString())
})

前面說過,collector 默認已經爲我們選擇了較優的配置,其實它們也可以通過環境變量改變。這樣,我們就可以不用爲了改變配置,每次都得重新編譯了。環境變量配置是在 collector 初始化時生效,正式啓動後,配置是可以被覆蓋的。

支持的配置項,如下:

ALLOWED_DOMAINS (字符串切片),允許的域名,比如 []string{"segmentfault.com", "zhihu.com"}
CACHE_DIR (string) 緩存目錄
DETECT_CHARSET (y/n) 是否檢測響應編碼
DISABLE_COOKIES (y/n) 禁止 cookies
DISALLOWED_DOMAINS (字符串切片),禁止的域名,同 ALLOWED_DOMAINS 類型
IGNORE_ROBOTSTXT (y/n) 是否忽略 ROBOTS 協議
MAX_BODY_SIZE (int) 響應最大
MAX_DEPTH (int - 0 means infinite) 訪問深度
PARSE_HTTP_ERROR_RESPONSE (y/n) 解析 HTTP 響應錯誤
USER_AGENT (string)

它們都是些非常容易理解的選項。

我們再來看看 HTTP 的配置,都是些常用的配置,比如代理、各種超時時間等。

c := colly.NewCollector()
c.WithTransport(&http.Transport{
	Proxy: http.ProxyFromEnvironment,
	DialContext: (&net.Dialer{
		Timeout:   30 * time.Second,          // 超時時間
		KeepAlive: 30 * time.Second,          // keepAlive 超時時間
		DualStack: true,
	}).DialContext,
	MaxIdleConns:          100,               // 最大空閒連接數
	IdleConnTimeout:       90 * time.Second,  // 空閒連接超時
	TLSHandshakeTimeout:   10 * time.Second,  // TLS 握手超時
	ExpectContinueTimeout: 1 * time.Second,  
}

調試 [15]

在用 scrapy 的時候,它提供了非常好用的 shell 幫助我們非常方便地實現 debug。但非常可惜 colly 中並沒有類似功能,這裏的 debugger 主要是指運行時的信息收集。

debugger 是一個接口,我們只要實現它其中的兩個方法,就可完成運行時信息的收集。

type Debugger interface {
    // Init initializes the backend
    Init() error
    // Event receives a new collector event.
    Event(e *Event)
}

源碼中有個典型的案例,LogDebugger[16]。我們只需提供相應的 io.Writer 類型變量,具體如何使用呢?

一個案例,如下:

package main

import (
	"log"
	"os"

	"github.com/gocolly/colly"
	"github.com/gocolly/colly/debug"
)

func main() {
	writer, err := os.OpenFile("collector.log", os.O_RDWR|os.O_CREATE, 0666)
	if err != nil {
		panic(err)
	}

	c := colly.NewCollector(colly.Debugger(&debug.LogDebugger{Output: writer}), colly.MaxDepth(2))
	c.OnHTML("a[href]", func(e *colly.HTMLElement) {
		if err := e.Request.Visit(e.Attr("href")); err != nil {
			log.Printf("visit err: %v", err)
		}
	})

	if err := c.Visit("http://go-colly.org/"); err != nil {
		panic(err)
	}
}

運行完成,打開 collector.log 即可查看輸出內容。

分佈式 [17]

分佈式爬蟲,可以從幾個層面考慮,分別是代理層面、執行層面和存儲層面。

代理層面

通過設置代理池,我們可以將下載任務分配給不同節點執行,有助於提供爬蟲的網頁下載速度。同時,這樣還能有效降低因爬取速度太快而導致 IP 被禁的可能性。

colly 實現代理 IP 的代碼如下:

package main

import (
	"github.com/gocolly/colly"
	"github.com/gocolly/colly/proxy"
)

func main() {
	c := colly.NewCollector()

	if p, err := proxy.RoundRobinProxySwitcher(
		"socks5://127.0.0.1:1337",
		"socks5://127.0.0.1:1338",
		"http://127.0.0.1:8080",
	); err == nil {
		c.SetProxyFunc(p)
	}
	// ...
}

proxy.RoundRobinProxySwitcher 是 colly 內置的通過輪詢方式實現代理切換的函數。當然,我們也可以完全自定義。

比如,一個代理隨機切換的案例,如下:

var proxies []*url.URL = []*url.URL{
	&url.URL{Host: "127.0.0.1:8080"},
	&url.URL{Host: "127.0.0.1:8081"},
}

func randomProxySwitcher(_ *http.Request) (*url.URL, error) {
	return proxies[random.Intn(len(proxies))], nil
}

// ...
c.SetProxyFunc(randomProxySwitcher)

不過需要注意,此時的爬蟲仍然是中心化的,任務只在一個節點上執行。

執行層面

這種方式通過將任務分配給不同的節點執行,實現真正意義的分佈式。

如果實現分佈式執行,首先需要面對一個問題,如何將任務分配給不同的節點,實現不同任務節點之間的協同工作呢?

首先,我們選擇合適的通信方案。常見的通信協議有 HTTP、TCP,一種無狀態的文本協議、一個是面向連接的協議。除此之外,還可選擇的有種類豐富的 RPC 協議,比如 Jsonrpc、facebook 的 thrift、google 的 grpc 等。

文檔提供了一個 HTTP 服務示例代碼,負責接收請求與任務執行。如下:

package main

import (
	"encoding/json"
	"log"
	"net/http"

	"github.com/gocolly/colly"
)

type pageInfo struct {
	StatusCode int
	Links      map[string]int
}

func handler(w http.ResponseWriter, r *http.Request) {
	URL := r.URL.Query().Get("url")
	if URL == "" {
		log.Println("missing URL argument")
		return
	}
	log.Println("visiting", URL)

	c := colly.NewCollector()

	p := &pageInfo{Links: make(map[string]int)}

	// count links
	c.OnHTML("a[href]", func(e *colly.HTMLElement) {
		link := e.Request.AbsoluteURL(e.Attr("href"))
		if link != "" {
			p.Links[link]++
		}
	})

	// extract status code
	c.OnResponse(func(r *colly.Response) {
		log.Println("response received", r.StatusCode)
		p.StatusCode = r.StatusCode
	})
	c.OnError(func(r *colly.Response, err error) {
		log.Println("error:", r.StatusCode, err)
		p.StatusCode = r.StatusCode
	})

	c.Visit(URL)

	// dump results
	b, err := json.Marshal(p)
	if err != nil {
		log.Println("failed to serialize response:", err)
		return
	}
	w.Header().Add("Content-Type", "application/json")
	w.Write(b)
}

func main() {
	// example usage: curl -s 'http://127.0.0.1:7171/?url=http://go-colly.org/'
	addr := ":7171"

	http.HandleFunc("/", handler)

	log.Println("listening on", addr)
	log.Fatal(http.ListenAndServe(addr, nil))
}

這裏並沒有提供調度器的代碼,不過實現不算複雜。任務完成後,服務會將相應的鏈接返回給調度器,調度器負責將新的任務發送給工作節點繼續執行。

如果需要根據節點負載情況決定任務執行節點,還需要服務提供監控 API 獲取節點性能數據幫助調度器決策。

存儲層面

我們已經通過將任務分配到不同節點執行實現了分佈式。但部分數據,比如 cookies、訪問的 url 記錄等,在節點之間需要共享。默認情況下,這些數據是保存內存中的,只能是每個 collector 獨享一份數據。

我們可以通過將數據保存至 redis、mongo 等存儲中,實現節點間的數據共享。colly 支持在任何存儲間切換,只要相應存儲實現 colly/storage.Storage[18] 接口中的方法。

其實,colly 已經內置了部分 storage 的實現,查看 storage[19]。下一節也會談到這個話題。

存儲 [20]

前面剛提過這個話題,我們具體看看 colly 已經支持的 storage 有哪些吧。

InMemoryStorage[21],即內存,colly 的默認存儲,我們可以通過 collector.SetStorage() 替換。

RedisStorage[22],或許是因爲 redis 在分佈式場景下使用更多,官方提供了使用案例 [23]。

其他還有 Sqlite3Storage[24] 和 MongoStorage[25]。

多收集器 [26]

我們前面演示的爬蟲都是比較簡單的,處理邏輯都很類似。如果是一個複雜的爬蟲,我們可以通過創建不同的 collector 負責不同任務的處理。

如何理解這段話呢?舉個例子吧。

如果大家寫過一段時間爬蟲,肯定遇到過父子頁面抓取的問題,通常父頁面的處理邏輯與子頁面是不同的,並且通常父子頁面間還有數據共享的需求。用過 scrapy 應該知道,scrapy 通過在 request 綁定回調函數實現不同頁面的邏輯處理,而數據共享是通過在 request 上綁定數據實現將父頁面數據傳遞給子頁面。

研究之後,我們發現 scrapy 的這種方式 colly 並不支持。那該怎麼做?這就是我們要解決的問題。

對於不同頁面的處理邏輯,我們可以定義創建多個收集器,即 collector,不同 collector 負責處理不同的頁面邏輯。

c := colly.NewCollector(
	colly.UserAgent("myUserAgent"),
	colly.AllowedDomains("foo.com", "bar.com"),
)
// Custom User-Agent and allowed domains are cloned to c2
c2 := c.Clone()

通常情況下,父子頁面的 collector 是相同的。上面的示例中,子頁面的 collector c2 通過 clone,將父級 collector 的配置也都複製了下來。

而父子頁面之間的數據傳遞,可以通過 Context 實現在不同 collector 之間傳遞。注意這個 Context 只是 colly 實現的數據共享的結構,並非 Go 標準庫中的 Context。

c.OnResponse(func(r *colly.Response) {
	r.Ctx.Put("Custom-header", r.Headers.Get("Custom-Header"))
	c2.Request("GET", "https://foo.com/", nil, r.Ctx, nil)
})

如此一來,我們在子頁面中就可以通過 r.Ctx 獲取到父級傳入的數據了。關於這個場景,我們可以查看官方提供的案例 coursera_courses[27]。

配置優化 [28]

colly 的默認配置針對是少量站點的優化配置。如果你是針對大量站點的抓取,還需要一些改進。

持久化存儲

默認情況下,colly 中的 cookies 和 url 是保存在內存中,我們要換成可持久化的存儲。前面介紹過,colly 已經實現一些常用的可持久化的存儲組件。

啓用異步加快任務執行

colly 默認會阻塞等待請求執行完成,這將會導致等待執行任務數越來越大。我們可以通過設置 collector 的 Async 選項爲 true 實現異步處理,從而避免這個問題。如果採用這種方式,記住增加 c.Wait(),否則程序會立刻退出。

禁止或限制 KeepAlive 連接

colly 默認開啓 KeepAlive 增加爬蟲的抓取速度。但是,這對打開的文件描述符有要求,對於長時間運行的任務,進程非常容易就能達到最大描述符的限制。

禁止 HTTP 的 KeepAlive 的示例代碼,如下。

c := colly.NewCollector()
c.WithTransport(&http.Transport{
    DisableKeepAlives: true,
})

擴展 [29]

colly 提供了一些擴展,主要與爬蟲相關的常用功能,如 referer、random_user_agent、url_length_filter 等。源碼路徑在 colly/extensions/[30] 下。

通過一個示例瞭解它們的使用方法,如下:

import (
    "log"

    "github.com/gocolly/colly"
    "github.com/gocolly/colly/extensions"
)

func main() {
    c := colly.NewCollector()
    visited := false

    extensions.RandomUserAgent(c)
    extensions.Referrer(c)

    c.OnResponse(func(r *colly.Response) {
        log.Println(string(r.Body))
        if !visited {
            visited = true
            r.Request.Visit("/get?q=2")
        }
    })

    c.Visit("http://httpbin.org/get")
}

只需將 collector 傳入擴展函數中即可。這麼簡單就搞定了啊。

那麼,我們能不能自己實現一個擴展呢?

在使用 scrapy 的時候,我們如果要實現一個擴展需要提前瞭解不少概念,仔細閱讀它的文檔。但 colly 在文檔中壓根也並沒有相關說明啊。腫麼辦呢?看樣子只能看源碼了。

我們打開 referer 插件的源碼,如下:

package extensions

import (
	"github.com/gocolly/colly"
)

// Referer sets valid Referer HTTP header to requests.
// Warning: this extension works only if you use Request.Visit
// from callbacks instead of Collector.Visit.
func Referer(c *colly.Collector) {
	c.OnResponse(func(r *colly.Response) {
		r.Ctx.Put("_referer", r.Request.URL.String())
	})
	c.OnRequest(func(r *colly.Request) {
		if ref := r.Ctx.Get("_referer"); ref != "" {
			r.Headers.Set("Referer", ref)
		}
	})
}

在 collector 上增加一些事件回調就實現一個擴展。這麼簡單的源碼,完全不用文檔說明就可以實現一個自己的擴展了。 當然,如果仔細觀察,我們會發現,其實它的思路和 scrapy 是類似的,都是通過擴展 request 和 response 的回調實現,而 colly 之所以如此簡潔主要得益於它優雅的設計和 Go 簡單的語法。

總結

讀完 colly 的官方文檔會發現,雖然它的文檔簡陋無比,但應該介紹的內容基本上都涉及到了。如果有部分未涉及的內容,我也在本文之中做了相關的補充。之前在使用 Go 的 elastic[31] 包時,同樣也是文檔少的可憐,但簡單讀下源碼,就能立刻明白了該如何去使用它。

或許這就是 Go 的大道至簡吧。

最後,如果大家在使用 colly 時遇到什麼問題,官方的 example 絕對是最佳實踐,建議可以抽時間一讀。

補充說明

平時我除了寫技術博客,還經常在各問答平臺回答技術問題,這些內容我都會聚合到自己的微信公衆號中。

有興趣的朋友可掃碼關注!

參考資料

[1]

官方文檔: http://go-colly.org/docs/

[2]

安裝: http://go-colly.org/docs/introduction/install/

[3]

快速開始: http://go-colly.org/docs/introduction/start/

[4]

如何配置: http://go-colly.org/docs/introduction/configuration/

[5]

調試: http://go-colly.org/docs/introduction/configuration/

[6]

分佈式爬蟲: http://go-colly.org/docs/best_practices/distributed/

[7]

存儲: http://go-colly.org/docs/best_practices/storage/

[8]

運用多收集器: http://go-colly.org/docs/best_practices/multi_collector/

[9]

配置優化: http://go-colly.org/docs/best_practices/crawling/

[10]

擴展: http://go-colly.org/docs/best_practices/extensions/

[11]

如何安裝: http://go-colly.org/docs/introduction/install/

[12]

快速開始: http://go-colly.org/docs/introduction/start/

[13]

basic: _https://github.com/gocolly/colly/blob/master/examples/basic/basic.go

[14]

如何配置: http://go-colly.org/docs/introduction/configuration/

[15]

調試: http://go-colly.org/docs/best_practices/debugging/

[16]

LogDebugger: https://github.com/gocolly/colly/blob/master/debug/logdebugger.go

[17]

分佈式: http://go-colly.org/docs/best_practices/distributed/

[18]

colly/storage.Storage: https://godoc.org/github.com/gocolly/colly/storage#Storage

[19]

storage: http://go-colly.org/docs/best_practices/storage/

[20]

存儲: http://go-colly.org/docs/best_practices/storage/

[21]

InMemoryStorage: https://github.com/gocolly/colly/blob/master/storage/storage.go

[22]

RedisStorage: https://github.com/gocolly/redisstorage

[23]

使用案例: http://go-colly.org/docs/examples/redis_backend/

[24]

Sqlite3Storage: https://github.com/velebak/colly-sqlite3-storage

[25]

MongoStorage: https://github.com/zolamk/colly-mongo-storage

[26]

多收集器: http://go-colly.org/docs/best_practices/multi_collector/

[27]

coursera_courses: http://go-colly.org/docs/examples/coursera_courses/

[28]

配置優化: http://go-colly.org/docs/best_practices/crawling/

[29]

擴展: http://go-colly.org/docs/best_practices/extensions/

[30]

colly/extensions/: https://github.com/gocolly/colly/tree/master/extensions

[31]

elastic: https://github.com/olivere/elastic

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Rdzpn3pEswRz3aGox4zRsg