上次聊到了《Go语言正则表达式》和《Go语言手撸一个LRU缓存》,这次利用正则表达式来编写一个并发爬虫。
说到爬虫,不得不提到前面写的《Python网络爬虫requests、bs4爬取空姐网图片》。这个爬虫很简洁,使用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:.*?)就表示这后面的点号可以匹配换行符了。其中的.*后面接问号?就表示这是正则表达式的勉强型匹配模式。想要详细了解勉强型匹配模式的可以看这篇文章《Go语言正则表达式》。
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{"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/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匹配相册链接,提取里面匹配组所匹配到的内容。从《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和图片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
来下载。使用案例见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 运行并查看结果
运行一下查看结果,跟文章开头的结果一致:
并发爬取运行起来比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上,喜欢的可以点个star:https://github.com/ychenracing/GoApps/blob/master/src/KongjieSpider/main/kongjie.go
喜欢的可以关注我的WeiXin订阅号,随时随地学习:
参考文章