乌拉圭代理ip上次聊到了《Go语言进阶之路(八):正则表达式》和《Go语言进阶之路:手撸一个LRU缓存》,这次利用正则表达式来编写一个并发爬虫。
私信“空姐”获取本爬虫源码!
说到爬虫,不得不提到前面写的《Python网络爬虫requests、bs4爬取空姐网图片》。这个爬虫很简洁,使用requests库发送http请求,使用bs4来解析html元素,获取所有图片地址。但是这个爬虫是单线程爬虫,速度太慢,一分钟只能爬下来300多张图片。所以,编写了Go语言的爬虫,亲测一分钟能爬下来800多张图片,速度提升了好几倍。先看一下效果图:
目录
一、提取相册链接和下一页链接
1.1 提取相册链接
首先,我乌拉圭代理ip们查看一下空姐网乌拉圭代理ip的网页结构,找到每个人的相册页面。在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元素中提取乌拉圭代理ip所有相册链接,正则表达式为:
// 用户相册的正则表达式,用乌拉圭代理ip于从用户相册块提取出用户相册链接,然后就可以进入相册爬取图片了 var peopleItemPattern = regexp.MustCompile(`<li\s+?class="d">(?s:.*?)<div\s+?class="c">(?s:.*?)<a\s+?href="(.*?)">`)有必要说一下,正常情况下,点号"."能匹配除了换行符外的任意字符,但是在html匹配中有很多换行符,我们想让点号能匹配到换行符,我们需要使用"(?s:.)"的形式,(?s:.*?)就表示这后面的点号可以匹配换行符了。其中的.*后面接问号?就表示这是正则表达式的勉强型匹配模式。想要详细了解勉强型匹配模式的可以看这篇文章《Go语言进阶之路(八):正则表达式》。
1.2 提取下一页链接
处理完一页之后需要翻到下一页,所以我们需要提取“下一页”的链接。乌拉圭代理ip我们看一下“下一页”所在的元素位置:
“下一页”这个链接在<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元素里面的超链接中,所以我们写出正则表达式为:
// 乌拉圭代理ip图片链接的正则表达式,用于从图片浏览页面的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{"?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进行编码转换。这乌拉圭代理ip两个包需要下载,可以使用
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会提取正则表乌拉圭代理ip达式匹配到的第一个内容和匹配组的内容。
上文我们提到,peopleUlPattern是为了提取相册列表所在的ul元素的内容,这个ul乌拉圭代理ip元素里面包含了很多个相册链接。因此我们先提取ul元素:
// FindSubmatch查找正则表达式的匹配和所乌拉圭代理ip有的子匹配组,这里是查找当前页每个人的相册链接 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中的图片浏览页链接,乌拉圭代理ip从这个链接中解析即可。
// 从当前图片页面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] // 图片id4.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中存在这个属性,那么这张图片就被爬取过,乌拉圭代理ip直接翻到下一页。
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() { // 乌拉圭代理ip创建保存的文件夹 _, 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上,私信“空姐”获取源码!