七叶笔记 » golang编程 » Go语言高并发爬虫,比Python运行快了好几倍

Go语言高并发爬虫,比Python运行快了好几倍

上次聊到了《 》和《 》,这次利用正则表达式来编写一个并发爬虫。

私信“空姐”获取本爬虫 源码

说到爬虫,不得不提到前面写的《 》。这个爬虫很简洁,使用requests库发送http请求,使用bs4来解析html元素,获取所有图片地址。但是这个爬虫是单线程爬虫,速度太慢,一分钟只能爬下来300多张图片。所以,编写了Go语言的爬虫,亲测一分钟能爬下来800多张图片,速度提升了好几倍。先看一下效果图:

一、提取相册链接和下一页链接

1.1 提取相册链接

首先,我们查看一下空姐网的网页结构,找到每个人的相册页面。在kongjie.com里面随意翻翻,就能找到热门相册页面,如图:

分析一下该页面结构,提取出每个人的相册页链接。

可以看到,ul下面包含了很多个li元素,每个li元素就是每个人的相册,li元素图片上的链接就是每个人的相册链接。所以我们写出提取ul元素的正则表达式为:

 // 用户相册块的正则表达式,用于从相册列表页提取出用户相册块,用户相册块中包含很多个用户的相册链接
var peopleUlPattern = regexp.MustCompile(`<div\s+?class="ptw">(?s:.*?)<ul\s+?class="ml\s+?mlp\s+?cl">(?s:(.*?))</ul>`)  

然后从ul元素中提取所有相册链接,正则表达式为:

 // 用户相册的正则表达式,用于从用户相册块提取出用户相册链接,然后就可以进入相册爬取图片了
var peopleItemPattern = regexp.MustCompile(`<li\s+?class="d">(?s:.*?)<div\s+?class="c">(?s:.*?)<a\s+?href="(.*?)">`)  

有必要说一下,正常情况下,点号”.”能匹配除了换行符外的任意字符,但是在html匹配中有很多换行符,我们想让点号能匹配到换行符,我们需要使用”(?s:.)”的形式,(?s:.*?)就表示这后面的点号可以匹配换行符了。其中的.*后面接问号?就表示这是正则表达式的勉强型匹配模式。想要详细了解勉强型匹配模式的可以看这篇文章《 》。

1.2 提取下一页链接

处理完一页之后需要翻到下一页,所以我们需要提取“下一页”的链接。我们看一下“下一页”所在的元素位置:

“下一页”这个链接在<div class=”pgs cl mtm”>元素里面的<div class=”pg”>的元素里的最后一个链接,而且“下一页”这个链接的class=”nxt”。所以我们编写出正则表达式为:

 // 下一个相册列表页链接的正则表达式,用于从相册列表页提取出下一页链接,翻页爬取
var nextAlbumPageUrlPattern = regexp.MustCompile(`<div\s+?class="pgs\s+?cl\s+?mtm">(?s:.*?)</label>(?s:.*?)<a\s+?href="(.*?)"\s+?class="nxt">下一页</a>`)  

二、进入相册提取图片链接和下一张页面的链接

2.1 提取图片链接

相册能提取了之后,我们进入相册,提取图片链接和下一张图片页面的链接。先来看一下图片浏览页的结构。

可以看到,图片在<div class=”photo_pic”那个div元素里面的超链接中,所以我们写出正则表达式为:

 // 图片链接的正则表达式,用于从图片浏览页面的html内容中提取出图片链接,然后保存图片
var imageUrlPattern = regexp.MustCompile(`<div\s+?id="photo_pic"\s+?class="c">(?s:.*?)<a\s+?href=".*?<img\s+?src="(.*?)"\s+?id="pic"`)  

同时,我们看到图片浏览页的链接地址中包含了uid和picid,那么,我们就可以在保存图片到本地时,使用uid+picid的方式保存文件名,这样爬取下来的图片就不会重名了。因此,我们提取uid和picid的正则表达式为:

 // 用户id和图片id的正则表达式,用于从url中提取用户id和图片id,保存图片时这些id会拼接成图片名
var uidPicIdPattern = regexp.MustCompile(`.*?uid=(\d+).*?picid=(\d+).*?`)  

2.2 提取下一张图片浏览页的链接

我们在图片浏览页面提取了图片的url,那么浏览图片的时候翻到下一张,我们需要提取“下一张”的链接。看一下“下一张”的网页结构:

下一张这个链接在<div class=”pns mlnv vm mtm cl”>元素下的最后一个超链接,超链接的几个属性为class=”btn” title=”下一张”,这样就很好提取了,我们写出提取的正则表达式为:

 // 下一张图片所在的图片浏览页面的链接正则表达式,用于从图片浏览页面提取出下一页链接,翻页爬取
var nextImagePageUrlPattern = regexp.MustCompile(`<div\s+?class="pns\s+?mlnv\s+?vm\s+?mtm\s+?cl">(?s:.*?)<a\s+?href="(.*?)"\s+?class="btn".*?title="下一张"(?s:.*?)<img\s+?src".*?"\s+?alt="下一张"`)  

我们现在可以提取相册链接和图片链接了,所有正则表达式提取完毕,接下来就是开始爬取网页了。

三、爬取所有相册链接和翻页

先爬取所有相册并翻页。首先就是发起http请求,拿到相册列表页的html内容,提取所有相册链接。先来看一下http请求。

3.1 发起http请求并解析response

我们使用Go语言原生的http库来发起http请求。为了让我们的http请求更像是浏览器发出的,我们为Request添加 Header 属性,设置一下UserAgent和Referer。该部分源代码如下:

定义header:

 var headers = map[string][]string{
  "Accept":                    []string{"text/html,application/xhtml+xml,application/xml", "q=0.9,image/webp,*/*;q=0.8"},
  "Accept-Encoding":           []string{"gzip, deflate, sdch"},
  "Accept-Language":           []string{"zh-CN,zh;q=0.8,en;q=0.6,zh-TW;q=0.4"},
  "Accept- charset ":            []string{"utf-8"},
  "Connection":                []string{"keep-alive"},
  "DNT":                       []string{"1"},
  "Host":                      []string{"www.kongjie.com"},
  "Referer":                   []string{"#34;},
  "Upgrade-Insecure-Requests": []string{"1"},
  "User-Agent":                []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36"},
}  

设置header和发起http请求,我们封装成了getResponseWithGlobalHeaders函数:

 func getReponseWithGlobalHeaders(url string) *http.Response {
  req, _ := http.NewRequest("GET", url, nil)
  if headers != nil && len(headers) != 0 {
    for k, v := range headers {
      for _, val := range v {
        req.Header.Add(k, val)
      }
    }
  }

  res, err := http.DefaultClient.Do(req)
  if err != nil {
    panic(err)
  }
  return res
}  

拿到response之后,我们需要对response进行解压缩,并做 编码 转换。网页返回是gzip压缩内容,Go语言http库拿到的response是没有帮我们做任何解析和转换的,因此,我们需要使用gzip库解压缩。网页返回的编码是 gbk ,我们需要转换成UTF-8编码,否则会出现乱码,匹配不到我们想要的内容。

这里,我们使用golang.org/x/net/html/charset和golang.org/x/text/transform进行编码转换。这两个包需要下载,可以使用

 go get -t golang.org/x/net/html/charset
go get -t golang.org/x/text/transform  

下载这两个包。我们解压缩和转码的源代码如下,封装成getHtmlFromUrl函数:

 func getHtmlFromUrl(url string) []byte {
  response := getReponseWithGlobalHeaders(url)

  reader := response.Body
  // 返回的内容被压缩成gzip格式了,需要解压一下
  if response.Header.Get("Content-Encoding") == "gzip" {
    reader, _ = gzip.NewReader(response.Body)
  }
  // 此时htmlContent还是gbk编码,需要转换成utf8编码
  htmlContent, _ := ioutil.ReadAll(reader)

  oldReader := bufio.NewReader(bytes.NewReader(htmlContent))
  peekBytes, _ := oldReader.Peek(1024)
  e, _, _ := charset.DetermineEncoding(peekBytes, "")
  utf8reader := transform.NewReader(oldReader, e.NewDecoder())
  // 此时htmlContent就已经是utf8编码了
  htmlContent, _ = ioutil.ReadAll(utf8reader)

  if err := response.Body.Close(); err != nil {
    fmt.Println("error happened when closing response body!", err)
  }
  return htmlContent
}  

3.2 提取相册链接和翻页

拿到正常的http response之后,我们就开始提取相册链接和翻页处理了。

我们使用FindSubmatch匹配相册链接,提取里面匹配组所匹配到的内容。从《 》文章中我们知道,FindSubmatch会提取正则表达式匹配到的第一个内容和匹配组的内容。

上文我们提到,peopleUlPattern是为了提取相册列表所在的ul元素的内容,这个ul元素里面包含了很多个相册链接。因此我们先提取ul元素:

 // FindSubmatch查找正则表达式的匹配和所有的子匹配组,这里是查找当前页每个人的相册链接
peopleListElement := peopleUlPattern.FindSubmatch(albumHtmlContent)  

这里可以看到,如果当前页ul元素里面没有内容,那么我们就要翻到下一页继续提取。如果都没有“下一页”的链接,那么说明爬虫全部爬完了,可以结束了。

 if len(peopleListElement) <= 0 {
  // 当前页没有相册
  fmt.Println("no peopleListElement!, url=", nextUrl)
  // 当前页所有用户相册链接解析完毕,翻到下一页
  nextAlbumUrl := nextAlbumPageUrlPattern.FindSubmatch(albumHtmlContent)
  if len(nextAlbumUrl) <= 0 {
    fmt.Println("all albums crawled!")
    break
  }
  nextUrl = string(nextAlbumUrl[1])
  continue
}  

提取了ul元素之后,我们就可以提取ul里面所有li元素中的相册链接了。从《 》文章中我们知道,FindAllSubmatch会提取正则表达式匹配到的所有内容和所有匹配组的内容。这样我们就能够拿到ul里面所有的相册链接了。拿到相册链接后,我们把链接发送到imagePageUrlChan通道中,用于后文中使用goroutine并发爬取。

 // 子匹配组是第二个元素。里面包含了很多用户的相册连接
peopleUlContent := peopleListElement[1]
peopleItems := peopleItemPattern.FindAllSubmatch(peopleUlContent, -1)
if len(peopleItems) > 0 {
  for _, peopleItem := range peopleItems {
    if len(peopleItem) <= 0 {
      continue
    }
    // 找到了一个用户的相册链接,放入imagePageUrlChan中等待爬取
    peopleAlbumUrl := strings.ReplaceAll(string(peopleItem[1]), `&`, "&")
    imagePageUrlChan <- peopleAlbumUrl
  }
}  

当前页ul解析完毕之后,我们就翻页爬取下一页所有的相册链接。

 // 当前页所有用户相册链接解析完毕,翻到下一页
nextAlbumUrl := nextAlbumPageUrlPattern.FindSubmatch(albumHtmlContent)
if len(nextAlbumUrl) <= 0 {
  fmt.Println("all albums crawled!")
  break
}
nextUrl = strings.ReplaceAll(string(nextAlbumUrl[1]), `&`, "&")
fmt.Println(nextUrl)  

这样,我们解析相册的源码就大功告成了:

 // 解析出相册url,然后进入相册爬取图片
func parseAlbumUrl(nextUrl string) {
  for {
    albumHtmlContent := getHtmlFromUrl(nextUrl)

    // FindSubmatch查找正则表达式的匹配和所有的子匹配组,这里是查找当前页每个人的相册链接
    peopleListElement := peopleUlPattern.FindSubmatch(albumHtmlContent)
    if len(peopleListElement) <= 0 {
      // 当前页没有相册
      fmt.Println("no peopleListElement!, url=", nextUrl)
      // 当前页所有用户相册链接解析完毕,翻到下一页
      nextAlbumUrl := nextAlbumPageUrlPattern.FindSubmatch(albumHtmlContent)
      if len(nextAlbumUrl) <= 0 {
        fmt.Println("all albums crawled!")
        break
      }
      nextUrl = string(nextAlbumUrl[1])
      continue
    }

    // 子匹配组是第二个元素。里面包含了很多用户的相册连接
    peopleUlContent := peopleListElement[1]
    peopleItems := peopleItemPattern.FindAllSubmatch(peopleUlContent, -1)
    if len(peopleItems) > 0 {
      for _, peopleItem := range peopleItems {
        if len(peopleItem) <= 0 {
          continue
        }
        // 找到了一个用户的相册链接,放入imagePageUrlChan中等待爬取
        peopleAlbumUrl := strings.ReplaceAll(string(peopleItem[1]), `&`, "&")
        imagePageUrlChan <- peopleAlbumUrl
      }
    }
    // 当前页所有用户相册链接解析完毕,翻到下一页
    nextAlbumUrl := nextAlbumPageUrlPattern.FindSubmatch(albumHtmlContent)
    if len(nextAlbumUrl) <= 0 {
      fmt.Println("all albums crawled!")
      break
    }
    nextUrl = strings.ReplaceAll(string(nextAlbumUrl[1]), `&`, "&")
    fmt.Println(nextUrl)
  }
  cl os e(imagePageUrlChan)
}  

四、进入爬取所有图片和翻页,保存图片

4.1 从图片浏览页链接解析出uid和picid

上文提到过,我们要保存图片到本地,同时保证图片名不重复,我们可以从图片浏览页链接中解析uid和picid作为文件名。我们在上文3.2中拿到imagePageUrlChan中的图片浏览页链接,从这个链接中解析即可。

 // 从当前图片页面url中获取当前图片所属的用户id和图片id
uidPicIdMatch := uidPicIdPattern.FindStringSubmatch(imagePageUrl)
if len(uidPicIdMatch) <= 0 {
  fmt.Println("can not find any uidPicId! imagePageUrl=", imagePageUrl)
  continue
}
uid := uidPicIdMatch[1]   // 用户id
picId := uidPicIdMatch[2] // 图片id  

4.2 进入相册爬取图片和翻到下一张

进入相册到达图片浏览页,可以提取出图片链接。我们先获取图片浏览页的html内容,从html里使用FindSubmatch提取图片src属性。

 imagePageHtmlContent := getHtmlFromUrl(imagePageUrl)

// redis中不存在,说明这张图片没被爬取过
exists := hexists("kongjie", uid+":"+picId)
if !exists {
  // 获取图片src,即图片具体链接
  imageSrcList := imageUrlPattern.FindSubmatch(imagePageHtmlContent)
  if len(imageSrcList) > 0 {
    imageSrc := string(imageSrcList[1])
    imageSrc = strings.ReplaceAll(string(imageSrc), `&`, "&")
    saveImage(imageSrc, uid, picId)
    hset("kongjie", uid+":"+picId, "1")
  }
}
// 解析下一张图片页面的url,继续爬取
nextImagePageUrlSubmatch := nextImagePageUrlPattern.FindSubmatch(imagePageHtmlContent)
if len(nextImagePageUrlSubmatch) <= 0 {
  continue
}
nextImagePageUrl := string(nextImagePageUrlSubmatch[1])
imagePageUrlChan <- nextImagePageUrl  

可以看到,我们这里使用redis去重。如果redis中不存在这张图片的属性,则图片没有被爬取过,接下来就会调用saveImage函数来保存图片。如果redis中存在这个属性,那么这张图片就被爬取过,直接翻到下一页。

hexists源码如下:

 // redis链接信息
var redisOption = redis.DialPassword("flyvar")                      // redis密码
var redisConn, _ = redis.Dial("tcp", "127.0.0.1:6379", redisOption) // 连接本地redis

// 串行访问redis,否则goroutine并发访问redis时会报错
var redisLock sync.Mutex

func hexists(key, field string) bool {
  redisLock.Lock()
  defer redisLock.Unlock()
  exists, err := redisConn.Do("HEXISTS", key, field)
  if err != nil {
    fmt.Println("redis hexists error!", err)
  }
  if exists == nil {
    return false
  }
  return exists.(int64) == 1
}  

这里我们使用了开源库redigo来访问redis。redigo可以使用

 go get github.com/gomodule/redigo/redis  

来下载。使用案例见。

4.3 保存图片

拿到图片src之后,就可以保存图片了。我们saveImage函数源码如下:

 // 保存图片到全局变量saveFolder文件夹下,图片名字为“uid_picId.ext”。
// 其中,uid是用户id,picId是空姐网图片id,ext是图片的扩展名。
func saveImage(imageUrl string, uid string, picId string) {
  res := getReponseWithGlobalHeaders(imageUrl)
  defer func() {
    if err := res.Body.Close(); err != nil {
      fmt.Println(err)
    }
  }()
  // 获取图片扩展名
  fileNameExt := path.Ext(imageUrl)
  // 图片保存的全路径
  savePath := path.Join(SaveFolder, uid+"_"+picId+fileNameExt)
  imageWriter, _ := os.OpenFile(savePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
  length, _ := io.Copy(imageWriter, res.Body)
  fmt.Println(uid + "_" + picId + fileNameExt + " image saved! " + strconv.Itoa(int(length)) + " bytes." + imageUrl)
}
  

五、创建goroutine并发爬取

5.1 并发爬取

我们使用单线程爬取所有相册链接,然后并发爬取每个相册里面的所有图片并保存。我们使用sync.WaitGroup等待所有goroutine爬取完成,源码如下:

 var wg sync.WaitGroup

func main() {
  // 创建保存的文件夹
  _, err := os.Open(SaveFolder)
  if err != nil {
    if os.IsNotExist(err) {
      _ = os.MkdirAll(SaveFolder, 0666)
    }
  }

  // 开启CONCURRENT_NUM个goroutine来爬取用户相册中所有图片的动作
  wg.Add(ConcurrentNum)
  for i := 0; i < ConcurrentNum; i++ {
    go getImagesInAlbum()
  }

  // 开启单个goroutine爬取所有用户的相册链接
  parseAlbumUrl(startUrl)

  // 等待爬取完成
  wg.Wait()
}  

5.2 运行并查看结果

运行一下查看结果,跟文章开头的结果一致:

并发爬取运行起来比Python快多了!

六、遇到的问题

6.1 http返回乱码

一开始直接使用原生http返回的response拿到body内容后,打印出来一直是乱码。发现空姐网返回的内容中Content-Type内容为text/html; charset=gbk,是GBK编码,需要转换到UTF-8才能进行正常处理。

参考了网上使用mahonia库和golang.org/x/text/encoding/simplifiedchinese库进行转换,一直没有解决。后来通过网上《golang http的动态ip代理、返回乱码解决》发现,空姐网返回的html header里面Content-Encoding为gzip内容,即返回内容是压缩过的,需要使用gzip库进行解压缩才能得到html内容。然后才能进行GBK转UTF-8的操作。

解压缩和GBK转换UTF-8的源码如下:

 response := getReponseWithGlobalHeaders(url)

reader := response.Body
// 返回的内容被压缩成gzip格式了,需要解压一下
if response.Header.Get("Content-Encoding") == "gzip" {
  reader, _ = gzip.NewReader(response.Body)
}
// 此时htmlContent还是gbk编码,需要转换成utf8编码
htmlContent, _ := ioutil.ReadAll(reader)

oldReader := bufio.NewReader(bytes.NewReader(htmlContent))
peekBytes, _ := oldReader.Peek(1024)
e, _, _ := charset.DetermineEncoding(peekBytes, "")
utf8reader := transform.NewReader(oldReader, e.NewDecoder())
// 此时htmlContent就已经是utf8编码了
htmlContent, _ = ioutil.ReadAll(utf8reader)  

项目源码在Github上,私信“空姐”获取源码!

参考文章

相关文章