Go语言并发爬虫,爬取空姐网所有相册图片

转载地址:Go语言中文网

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

Go语言并发爬虫,爬取空姐网所有相册图片_第1张图片

 

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

1.1 提取相册链接

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

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

Go语言并发爬虫,爬取空姐网所有相册图片_第2张图片

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

 

// 用户相册块的正则表达式,用于从相册列表页提取出用户相册块,用户相册块中包含很多个用户的相册链接var peopleUlPattern = regexp.MustCompile(`(?s:.*?)(?s:(.*?))`)
然后从ul元素中提取所有相册链接,正则表达式为:

​​​​​​​

// 用户相册的正则表达式,用于从用户相册块提取出用户相册链接,然后就可以进入相册爬取图片了var peopleItemPattern = regexp.MustCompile(`(?s:.*?)(?s:.*?)`)
有必要说一下,正常情况下,点号"."能匹配除了换行符外的任意字符,但是在html匹配中有很多换行符,我们想让点号能匹配到换行符,我们需要使用"(?s:.)"的形式,(?s:.*?)就表示这后面的点号可以匹配换行符了。其中的.*后面接问号?就表示这是正则表达式的勉强型匹配模式。想要详细了解勉强型匹配模式的可以看这篇文章《Go语言进阶之路(八):正则表达式》。

 

1.2 提取下一页链接

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

Go语言并发爬虫,爬取空姐网所有相册图片_第3张图片

“下一页”这个链接在

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

​​​​​​​

// 下一个相册列表页链接的正则表达式,用于从相册列表页提取出下一页链接,翻页爬取var nextAlbumPageUrlPattern = regexp.MustCompile(`(?s:.*?)(?s:.*?)下一页`)

 

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

2.1 提取图片链接

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

Go语言并发爬虫,爬取空姐网所有相册图片_第4张图片

可以看到,图片在

​​​​​​​

// 图片链接的正则表达式,用于从图片浏览页面的html内容中提取出图片链接,然后保存图片var imageUrlPattern = regexp.MustCompile(`(?s:.*?)

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

 

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

 

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

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

Go语言并发爬虫,爬取空姐网所有相册图片_第5张图片

下一张这个链接在

元素下的最后一个超链接,超链接的几个属性为class="btn" title="下一张",这样就很好提取了,我们写出提取的正则表达式为:

​​​​​​​

// 下一张图片所在的图片浏览页面的链接正则表达式,用于从图片浏览页面提取出下一页链接,翻页爬取var nextImagePageUrlPattern = regexp.MustCompile(`(?s:.*?)

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

 

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

先爬取所有相册并翻页。首先就是发起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{"http://www.kongjie.com/home.php?mod=space&do=album&view=all&order=hot&page=1"},  "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/charsetgo 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匹配相册链接,提取里面匹配组所匹配到的内容。从《Go语言进阶之路(八):正则表达式》文章中我们知道,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元素中的相册链接了。从《Go语言进阶之路(八):正则表达式》文章中我们知道,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)  }  close(imagePageUrlChan)}
 

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

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

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

​​​​​​​

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

来下载。使用案例见https://github.com/pete911/examples-redigo。

 

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 运行并查看结果

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

Go语言并发爬虫,爬取空姐网所有相册图片_第6张图片

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


 

六、遇到的问题

6.1 http返回乱码

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

Go语言并发爬虫,爬取空姐网所有相册图片_第7张图片

参考了网上使用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上,欢迎关注!https://github.com/ychenracing/GoApps/blob/master/src/KongjieSpider/main/kongjie.go

 

你可能感兴趣的:(golang)