简介

colly是用 Go 语言编写的功能强大的爬虫框架。它提供简洁的 API,拥有强劲的性能,可以自动处理 cookie&session,还有提供灵活的扩展机制。

首先,我们介绍colly的基本概念。然后通过几个案例来介绍colly的用法和特性:拉取 GitHub Treading,拉取百度小说热榜,下载 Unsplash 网站上的图片

快速使用

本文代码使用 Go Modules。

创建目录并初始化:

  1. $ mkdir colly && cd colly
  2. $ go mod init github.com/go-quiz/go-daily-lib/colly

安装colly库:

  1. $ go get -u github.com/gocolly/colly/v2

使用:

  1. package main
  2. import (
  3. "fmt"
  4. "github.com/gocolly/colly/v2"
  5. )
  6. func main() {
  7. c := colly.NewCollector(
  8. colly.AllowedDomains("www.baidu.com" ),
  9. )
  10. c.OnHTML("a[href]", func(e *colly.HTMLElement) {
  11. link := e.Attr("href")
  12. fmt.Printf("Link found: %q -> %s\n", e.Text, link)
  13. c.Visit(e.Request.AbsoluteURL(link))
  14. })
  15. c.OnRequest(func(r *colly.Request) {
  16. fmt.Println("Visiting", r.URL.String())
  17. })
  18. c.OnResponse(func(r *colly.Response) {
  19. fmt.Printf("Response %s: %d bytes\n", r.Request.URL, len(r.Body))
  20. })
  21. c.OnError(func(r *colly.Response, err error) {
  22. fmt.Printf("Error %s: %v\n", r.Request.URL, err)
  23. })
  24. c.Visit("http://www.baidu.com/")
  25. }

colly的使用比较简单:

首先,调用colly.NewCollector()创建一个类型为*colly.Collector的爬虫对象。由于每个网页都有很多指向其他网页的链接。如果不加限制的话,运行可能永远不会停止。所以上面通过传入一个选项colly.AllowedDomains("www.baidu.com")限制只爬取域名为www.baidu.com的网页。

然后我们调用c.OnHTML方法注册HTML回调,对每个有href属性的a元素执行回调函数。这里继续访问href指向的 URL。也就是说解析爬取到的网页,然后继续访问网页中指向其他页面的链接。

调用c.OnRequest()方法注册请求回调,每次发送请求时执行该回调,这里只是简单打印请求的 URL。

调用c.OnResponse()方法注册响应回调,每次收到响应时执行该回调,这里也只是简单的打印 URL 和响应大小。

调用c.OnError()方法注册错误回调,执行请求发生错误时执行该回调,这里简单打印 URL 和错误信息。

最后我们调用c.Visit()开始访问第一个页面。

运行:

  1. $ go run main.go
  2. Visiting http://www.baidu.com/
  3. Response http://www.baidu.com/: 303317 bytes
  4. Link found: "百度首页" -> /
  5. Link found: "设置" -> javascript:;
  6. Link found: "登录" -> https://passport.baidu.com/v2/?login&tpl=mn&u=http%3A%2F%2Fwww.baidu.com%2F&sms=5
  7. Link found: "新闻" -> http://news.baidu.com
  8. Link found: "hao123" -> https://www.hao123.com
  9. Link found: "地图" -> http://map.baidu.com
  10. Link found: "直播" -> https://live.baidu.com/
  11. Link found: "视频" -> https://haokan.baidu.com/?sfrom=baidu-top
  12. Link found: "贴吧" -> http://tieba.baidu.com
  13. ...

colly爬取到页面之后,会使用goquery解析这个页面。然后查找注册的 HTML 回调对应元素选择器(element-selector),将goquery.Selection封装成一个colly.HTMLElement执行回调。

colly.HTMLElement其实就是对goquery.Selection的简单封装:

  1. type HTMLElement struct {
  2. Name string
  3. Text string
  4. Request *Request
  5. Response *Response
  6. DOM *goquery.Selection
  7. Index int
  8. }

并提供了简单易用的方法:

  • Attr(k string):返回当前元素的属性,上面示例中我们使用e.Attr("href")获取了href属性;
  • ChildAttr(goquerySelector, attrName string):返回goquerySelector选择的第一个子元素的attrName属性;
  • ChildAttrs(goquerySelector, attrName string):返回goquerySelector选择的所有子元素的attrName属性,以[]string返回;
  • ChildText(goquerySelector string):拼接goquerySelector选择的子元素的文本内容并返回;
  • ChildTexts(goquerySelector string):返回goquerySelector选择的子元素的文本内容组成的切片,以[]string返回。
  • ForEach(goquerySelector string, callback func(int, *HTMLElement)):对每个goquerySelector选择的子元素执行回调callback
  • Unmarshal(v interface{}):通过给结构体字段指定 goquerySelector 格式的 tag,可以将一个 HTMLElement 对象 Unmarshal 到一个结构体实例中。

这些方法会被频繁地用到。下面我们就通过一些示例来介绍colly的特性和用法。

GitHub Treading

我之前写过一个拉取GitHub Treading 的 API,用colly更方便:

  1. type Repository struct {
  2. Author string
  3. Name string
  4. Link string
  5. Desc string
  6. Lang string
  7. Stars int
  8. Forks int
  9. Add int
  10. BuiltBy []string
  11. }
  12. func main() {
  13. c := colly.NewCollector(
  14. colly.MaxDepth(1),
  15. )
  16. repos := make([]*Repository, 0, 15)
  17. c.OnHTML(".Box .Box-row", func (e *colly.HTMLElement) {
  18. repo := &Repository{}
  19. // author & repository name
  20. authorRepoName := e.ChildText("h1.h3 > a")
  21. parts := strings.Split(authorRepoName, "/")
  22. repo.Author = strings.TrimSpace(parts[0])
  23. repo.Name = strings.TrimSpace(parts[1])
  24. // link
  25. repo.Link = e.Request.AbsoluteURL(e.ChildAttr("h1.h3 >a", "href"))
  26. // description
  27. repo.Desc = e.ChildText("p.pr-4")
  28. // language
  29. repo.Lang = strings.TrimSpace(e.ChildText("div.mt-2 > span.mr-3 > span[itemprop]"))
  30. // star & fork
  31. starForkStr := e.ChildText("div.mt-2 > a.mr-3")
  32. starForkStr = strings.Replace(strings.TrimSpace(starForkStr), ",", "", -1)
  33. parts = strings.Split(starForkStr, "\n")
  34. repo.Stars , _=strconv.Atoi(strings.TrimSpace(parts[0]))
  35. repo.Forks , _=strconv.Atoi(strings.TrimSpace(parts[len(parts)-1]))
  36. // add
  37. addStr := e.ChildText("div.mt-2 > span.float-sm-right")
  38. parts = strings.Split(addStr, " ")
  39. repo.Add, _ = strconv.Atoi(parts[0])
  40. // built by
  41. e.ForEach("div.mt-2 > span.mr-3 img[src]", func (index int, img *colly.HTMLElement) {
  42. repo.BuiltBy = append(repo.BuiltBy, img.Attr("src"))
  43. })
  44. repos = append(repos, repo)
  45. })
  46. c.Visit("https://github.com/trending")
  47. fmt.Printf("%d repositories\n", len(repos))
  48. fmt.Println("first repository:")
  49. for _, repo := range repos {
  50. fmt.Println("Author:", repo.Author)
  51. fmt.Println("Name:", repo.Name)
  52. break
  53. }
  54. }

我们用ChildText获取作者、仓库名、语言、星数和 fork 数、今日新增等信息,用ChildAttr获取仓库链接,这个链接是一个相对路径,通过调用e.Request.AbsoluteURL()方法将它转为一个绝对路径。

运行:

  1. $ go run main.go
  2. 25 repositories
  3. first repository:
  4. Author: Shopify
  5. Name: dawn

百度小说热榜

网页结构如下:

每日一库之71:colly - 图1

各部分结构如下:

  • 每条热榜各自在一个div.category-wrap_iQLoo中;
  • a元素下div.index_1Ew5p是排名;
  • 内容在div.content_1YWBm中;
  • 内容中a.title_dIF3B是标题;
  • 内容中两个div.intro_1l0wp,前一个是作者,后一个是类型;
  • 内容中div.desc_3CTjT是描述。

由此我们定义结构:

  1. type Hot struct {
  2. Rank string `selector:"a > div.index_1Ew5p"`
  3. Name string `selector:"div.content_1YWBm > a.title_dIF3B"`
  4. Author string `selector:"div.content_1YWBm > div.intro_1l0wp:nth-child(2)"`
  5. Type string `selector:"div.content_1YWBm > div.intro_1l0wp:nth-child(3)"`
  6. Desc string `selector:"div.desc_3CTjT"`
  7. }

tag 中是 CSS 选择器语法,添加这个是为了可以直接调用HTMLElement.Unmarshal()方法填充Hot对象。

然后创建Collector对象:

  1. c := colly.NewCollector()

注册回调:

  1. c.OnHTML("div.category-wrap_iQLoo", func(e *colly.HTMLElement) {
  2. hot := &Hot{}
  3. err := e.Unmarshal(hot)
  4. if err != nil {
  5. fmt.Println("error:", err)
  6. return
  7. }
  8. hots = append(hots, hot)
  9. })
  10. c.OnRequest(func(r *colly.Request) {
  11. fmt.Println("Requesting:", r.URL)
  12. })
  13. c.OnResponse(func(r *colly.Response) {
  14. fmt.Println("Response:", len(r.Body))
  15. })

OnHTML对每个条目执行Unmarshal生成Hot对象。

OnRequest/OnResponse只是简单输出调试信息。

然后,调用c.Visit()访问网址:

  1. err := c.Visit("https://top.baidu.com/board?tab=novel")
  2. if err != nil {
  3. fmt.Println("Visit error:", err)
  4. return
  5. }

最后添加一些调试打印:

  1. fmt.Printf("%d hots\n", len(hots))
  2. for _, hot := range hots {
  3. fmt.Println("first hot:")
  4. fmt.Println("Rank:", hot.Rank)
  5. fmt.Println("Name:", hot.Name)
  6. fmt.Println("Author:", hot.Author)
  7. fmt.Println("Type:", hot.Type)
  8. fmt.Println("Desc:", hot.Desc)
  9. break
  10. }

运行输出:

  1. Requesting: https://top.baidu.com/board?tab=novel
  2. Response: 118083
  3. 30 hots
  4. first hot:
  5. Rank: 1
  6. Name: 逆天邪神
  7. Author: 作者:火星引力
  8. Type: 类型:玄幻
  9. Desc: 掌天毒之珠,承邪神之血,修逆天之力,一代邪神,君临天下! 查看更多>

Unsplash

我写公众号文章,背景图片基本都是从 unsplash 这个网站获取。unsplash 提供了大量的、丰富的、免费的图片。这个网站有个问题,就是访问速度比较慢。既然学习爬虫,刚好利用程序自动下载图片。

unsplash 首页如下图所示:

每日一库之71:colly - 图2

网页结构如下:

每日一库之71:colly - 图3

但是首页上显示的都是尺寸较小的图片,我们点开某张图片的链接:

每日一库之71:colly - 图4

网页结构如下:

每日一库之71:colly - 图5

由于涉及三层网页结构(img最后还需要访问一次),使用一个colly.Collector对象,OnHTML回调设置需要格外小心,给编码带来比较大的心智负担。colly支持多个Collector,我们采用这种方式来编码:

  1. func main() {
  2. c1 := colly.NewCollector()
  3. c2 := c1.Clone()
  4. c3 := c1.Clone()
  5. c1.OnHTML("figure[itemProp] a[itemProp]", func(e *colly.HTMLElement) {
  6. href := e.Attr("href")
  7. if href == "" {
  8. return
  9. }
  10. c2.Visit(e.Request.AbsoluteURL(href))
  11. })
  12. c2.OnHTML("div._1g5Lu > img[src]", func(e *colly.HTMLElement) {
  13. src := e.Attr("src")
  14. if src == "" {
  15. return
  16. }
  17. c3.Visit(src)
  18. })
  19. c1.OnRequest(func(r *colly.Request) {
  20. fmt.Println("Visiting", r.URL)
  21. })
  22. c1.OnError(func(r *colly.Response, err error) {
  23. fmt.Println("Visiting", r.Request.URL, "failed:", err)
  24. })
  25. }

我们使用 3 个Collector对象,第一个Collector用于收集首页上对应的图片链接,然后使用第二个Collector去访问这些图片链接,最后让第三个Collector去下载图片。上面我们还为第一个Collector注册了请求和错误回调。

第三个Collector下载到具体的图片内容后,保存到本地:

  1. func main() {
  2. // ... 省略
  3. var count uint32
  4. c3.OnResponse(func(r *colly.Response) {
  5. fileName := fmt.Sprintf("images/img%d.jpg", atomic.AddUint32(&count, 1))
  6. err := r.Save(fileName)
  7. if err != nil {
  8. fmt.Printf("saving %s failed:%v\n", fileName, err)
  9. } else {
  10. fmt.Printf("saving %s success\n", fileName)
  11. }
  12. })
  13. c3.OnRequest(func(r *colly.Request) {
  14. fmt.Println("visiting", r.URL)
  15. })
  16. }

上面使用atomic.AddUint32()为图片生成序号。

运行程序,爬取结果:

每日一库之71:colly - 图6

异步

默认情况下,colly爬取网页是同步的,即爬完一个接着爬另一个,上面的 unplash 程序就是如此。这样需要很长时间,colly提供了异步爬取的特性,我们只需要在构造Collector对象时传入选项colly.Async(true)即可开启异步:

  1. c1 := colly.NewCollector(
  2. colly.Async(true),
  3. )

但是,由于是异步爬取,所以程序最后需要等待Collector处理完成,否则早早地退出main,程序会退出:

  1. c1.Wait()
  2. c2.Wait()
  3. c3.Wait()

再次运行,速度快了很多😀。

第二版

向下滑动 unsplash 的网页,我们发现后面的图片是异步加载的。滚动页面,通过 chrome 浏览器的 network 页签查看请求:

每日一库之71:colly - 图7

请求路径/photos,设置per_pagepage参数,返回的是一个 JSON 数组。所以有了另一种方式:

定义每一项的结构体,我们只保留必要的字段:

  1. type Item struct {
  2. Id string
  3. Width int
  4. Height int
  5. Links Links
  6. }
  7. type Links struct {
  8. Download string
  9. }

然后在OnResponse回调中解析 JSON,对每一项的Download链接调用负责下载图像的CollectorVisit()方法:

  1. c.OnResponse(func(r *colly.Response) {
  2. var items []*Item
  3. json.Unmarshal(r.Body, &items)
  4. for _, item := range items {
  5. d.Visit(item.Links.Download)
  6. }
  7. })

初始化访问,我们设置拉取 3 页,每页 12 个(和页面请求的个数一致):

  1. for page := 1; page <= 3; page++ {
  2. c.Visit(fmt.Sprintf("https://unsplash.com/napi/photos?page=%d&per_page=12", page))
  3. }

运行,查看下载的图片:

每日一库之71:colly - 图8

限速

有时候并发请求太多,网站会限制访问。这时就需要使用LimitRule了。说白了,LimitRule就是限制访问速度和并发量的:

  1. type LimitRule struct {
  2. DomainRegexp string
  3. DomainGlob string
  4. Delay time.Duration
  5. RandomDelay time.Duration
  6. Parallelism int
  7. }

常用的就Delay/RandomDelay/Parallism这几个,分别表示请求与请求之间的延迟,随机延迟,和并发数。另外必须指定对哪些域名施行限制,通过DomainRegexpDomainGlob设置,如果这两个字段都未设置Limit()方法会返回错误。用在上面的例子中:

  1. err := c.Limit(&colly.LimitRule{
  2. DomainRegexp: `unsplash\.com`,
  3. RandomDelay: 500 * time.Millisecond,
  4. Parallelism: 12,
  5. })
  6. if err != nil {
  7. log.Fatal(err)
  8. }

我们设置针对unsplash.com这个域名,请求与请求之间的随机最大延迟 500ms,最多同时并发 12 个请求。

设置超时

有时候网速较慢,colly中使用的http.Client有默认超时机制,我们可以通过colly.WithTransport()选项改写:

  1. c.WithTransport(&http.Transport{
  2. Proxy: http.ProxyFromEnvironment,
  3. DialContext: (&net.Dialer{
  4. Timeout: 30 * time.Second,
  5. KeepAlive: 30 * time.Second,
  6. }).DialContext,
  7. MaxIdleConns: 100,
  8. IdleConnTimeout: 90 * time.Second,
  9. TLSHandshakeTimeout: 10 * time.Second,
  10. ExpectContinueTimeout: 1 * time.Second,
  11. })

扩展

colly在子包extension中提供了一些扩展特性,最最常用的就是随机 User-Agent 了。通常网站会通过 User-Agent 识别请求是否是浏览器发出的,爬虫一般会设置这个 Header 把自己伪装成浏览器。使用也比较简单:

  1. import "github.com/gocolly/colly/v2/extensions"
  2. func main() {
  3. c := colly.NewCollector()
  4. extensions.RandomUserAgent(c)
  5. }

随机 User-Agent 实现也很简单,就是从一些预先定义好的 User-Agent 数组中随机一个设置到 Header 中:

  1. func RandomUserAgent(c *colly.Collector) {
  2. c.OnRequest(func(r *colly.Request) {
  3. r.Headers.Set("User-Agent", uaGens[rand.Intn(len(uaGens))]())
  4. })
  5. }

实现自己的扩展也不难,例如我们每次请求时需要设置一个特定的 Header,扩展可以这么写:

  1. func MyHeader(c *colly.Collector) {
  2. c.OnRequest(func(r *colly.Request) {
  3. r.Headers.Set("My-Header", "dj")
  4. })
  5. }

Collector对象调用MyHeader()函数即可:

  1. MyHeader(c)

总结

colly是 Go 语言中最流行的爬虫框架,支持丰富的特性。本文对一些常用特性做了介绍,并辅之以实例。限于篇幅,一些高级特性未能涉及,例如队列,存储等。对爬虫感兴趣的可去深入了解。

大家如果发现好玩、好用的 Go 语言库,欢迎到 Go 每日一库 GitHub 上提交 issue😄

参考

  1. Go 每日一库 GitHub:https://github.com/go-quiz/go-daily-lib
  2. Go 每日一库之 goquery:https://go-quiz.github.io/2020/10/11/godailylib/goquery/
  3. 用 Go 实现一个 GitHub Trending API:https://go-quiz.github.io/2021/06/16/github-trending-api/
  4. colly GitHub:https://github.com/gocolly/colly