最近发现知乎上感兴趣的问题越来越少,于是准备聚合下其他平台技术问答,比如 segmentfault、stackoverflow 等。
要完成这个工作,肯定是离不开爬虫的。我就顺便抽时间研究了 Go 的一款爬虫框架 colly。
概要介绍
colly 是 Go 实现的比较有名的一款爬虫框架,而且 Go 在高并发和分布式场景的优势也正是爬虫技术所需要的。它的主要特点是轻量、快速,设计非常优雅,并且分布式的支持也非常简单,易于扩展。
如何学习
爬虫最有名的框架应该就是 Python 的 scrapy,很多人最早接触的爬虫框架就是它,我也不例外。它的文档非常齐全,扩展组件也很丰富。当我们要设计一款爬虫框架时,常会参考它的设计。之前看到一些文章介绍 Go 中也有类似 scrapy 的实现。
相比而言,colly 的学习资料就少的可怜了。刚看到它的时候,我总会情不自禁想借鉴我的 scrapy 使用经验,但结果发现这种生搬硬套并不可行。
到此,我们自然地想到去找些文章阅读,但结果是 colly 相关文章确实有点少,能找到的基本都是官方提供的,而且看起来似乎不是那么完善。没办法,慢慢啃吧!官方的学习资料通常都会有三处,分别是文档、案例和源码。
今天,暂时先从官方文档角度吧!正文开始。
官方文档
官方文档介绍着重使用方法,如果是有爬虫经验的朋友,扫完一遍文档很快。我花了点时间将官网文档的按自己的思路整理了一版。
主体内容不多,涉及安装、快速开始、如何配置、调试、分布式爬虫、存储、运用多收集器、配置优化、扩展。
其中的每篇文档都很短小,甚至是少的基本都不用翻页滚动。
如何安装
colly 的安装和其他的 Go 库安装一样简单。如下:
go get -u github.com/gocolly/colly
一行命令搞定。So easy!
快速开始
我们来通过一个 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 支持的事件类型,如下:
- OnRequest 请求执行之前调用
- OnResponse 响应返回之后调用
- OnHTML 监听执行 selector
- OnXML 监听执行 selector
- OnHTMLDetach,取消监听,参数为 selector 字符串
- OnXMLDetach,取消监听,参数为 selector 字符串
- OnScraped,完成抓取后执行,完成所有工作后执行
- OnError,错误回调
最后一步,c.Visit() 正式启动网页访问。
c.Visit("http://go-colly.org/")
案例的完成代码在 colly 源码的 _example 目录下 basic 中提供。
如何配置
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))]
}
return string(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,
}
调试
在用 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。我们只需提供相应的 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 即可查看输出内容。
分布式
分布式爬虫,可以从几个层面考虑,分别是代理层面、执行层面和存储层面。
代理层面
通过设置代理池,我们可以将下载任务分配给不同节点执行,有助于提供爬虫的网页下载速度。同时,这样还能有效降低因爬取速度太快而导致 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 接口中的方法。
其实,colly 已经内置了部分 storage 的实现,查看 storage。下一节也会谈到这个话题。
存储
前面刚提过这个话题,我们具体看看 colly 已经支持的 storage 有哪些吧。
InMemoryStorage,即内存,colly 的默认存储,我们可以通过 collector.SetStorage() 替换。
RedisStorage,或许是因为 redis 在分布式场景下使用更多,官方提供了使用案例。
其他还有 Sqlite3Storage 和 MongoStorage。
多收集器
我们前面演示的爬虫都是比较简单的,处理逻辑都很类似。如果是一个复杂的爬虫,我们可以通过创建不同的 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。
配置优化
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,})
扩展
colly 提供了一些扩展,主要与爬虫相关的常用功能,如 referer、random_user_agent、url_length_filter 等。源码路径在 colly/extensions/ 下。
通过一个示例了解它们的使用方法,如下:
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 包时,同样也是文档少的可怜,但简单读下源码,就能立刻明白了该如何去使用它。
或许这就是 Go 的大道至简吧。
最后,如果大家在使用 colly 时遇到什么问题,官方的 example 绝对是最佳实践,建议可以抽时间一读。