上一篇博客《Golang 实现简单爬虫框架(1)——项目介绍与环境准备》中我们介绍了 go 语言的开发环境搭建,以及爬虫项目介绍。
本次爬虫爬取的是珍爱网的用户信息数据,爬取步骤为:
- 1. 进入珍爱网城市页面爬取所有的城市信息
- 2. 进入城市详情页爬取用户 URL 地址信息
- 3. 进入用户详情页爬取所需要的用户信息
注意:在本此爬虫项目中,只会实现一个简单的爬虫架构,包括单机版实现、简单并发版以及使用队列进行任务调度的并发版实现,以及数据存储和展示功能。不涉及模拟登录、动态 IP 等技术,如果你是 GO 语言新手想找练习项目或者对爬虫感兴趣的读者,请放心食用。
1、单任务版爬虫架构
首先我们实现一个单任务版的爬虫,且不考虑数据存储与展示模块,首先把基本功能实现。下面是单任务版爬虫的整体框架
下面是具体流程说明:
- 1、首先需要配置种子请求,就是 seed,存储项目爬虫的初始入口
- 2、把初始入口信息发送给爬虫引擎,引擎把其作为任务信息放入任务队列,只要任务队列不空就一直从任务队列中取任务
- 3、取出任务后,engine 把要请求的任务交给 Fetcher 模块,Fetcher 模块负责通过 URL 抓取网页数据,然后把数据返回给 Engine
- 4、Engine 收到网页数后,把数据交给解析(Parser)模块,Parser 解析出需要的数据后返回给 Engine,Engine 收到解析出的信息在控制台打印出来
项目目录
2、数据结构定义
在正式开始讲解前先看一下项目中的数据结构。
// /engine/types.go
package engine
// 请求结构
type Request struct {
Url string // 请求地址
ParseFunc func([]byte) ParseResult // 解析函数
}
// 解析结果结构
type ParseResult struct {Requests []Request // 解析出的请求
Items []interface{} // 解析出的内容
}
Request
表示一个爬取请求,包括请求的 URL
地址和使用的解析函数,其解析函数返回值是一个 ParseResult
类型,其中 ParseResult
类型包括解析出的请求和解析出的内容。解析内容 Items
是一个 interface{}
类型,即这部分具体数据结构由用户自己来定义。
注意:对于 Request
中的解析函数,对于每一个 URL 使用城市列表解析器还是用户列表解析器,是由我们的具体业务来决定的,对于 Engine
模块不必知道解析函数具体是什么,只负责 Request
中的解析函数来解析传入的 URL 对应的网页数据
需要爬取的数据的定义
// /model/profile.go
package model
// 用户的个人信息
type Profile struct {
Name string
Gender string
Age int
Height int
Weight int
Income string
Marriage string
Address string
}
3、Fetcher 的实现
Fetcher 模块任务是获取目标 URL 的网页数据,先放上代码。
// /fetcher/fetcher.go
package fetcher
import (
"bufio"
"fmt"
"io/ioutil"
"log"
"net/http"
"golang.org/x/net/html/charset"
"golang.org/x/text/encoding"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)
// 网页内容抓取函数
func Fetch(url string) ([]byte, error) {client := &http.Client{}
req, err := http.NewRequest("GET", url, nil)
if err != nil {log.Fatalln(err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36")
resp, err := client.Do(req)
if err != nil {return nil, err}
defer resp.Body.Close()
// 出错处理
if resp.StatusCode != http.StatusOK {return nil, fmt.Errorf("wrong state code: %d", resp.StatusCode)
}
// 把网页转为 utf- 8 编码
bodyReader := bufio.NewReader(resp.Body)
e := determineEncoding(bodyReader)
utf8Reader := transform.NewReader(bodyReader, e.NewDecoder())
return ioutil.ReadAll(utf8Reader)
}
func determineEncoding(r *bufio.Reader) encoding.Encoding {bytes, err := r.Peek(1024)
if err != nil {log.Printf("Fetcher error %v\n", err)
return unicode.UTF8
}
e, _, _ := charset.DetermineEncoding(bytes, "")
return e
}
因为许多网页的编码是 GBK,我们需要把数据转化为 utf- 8 编码,这里需要下载一个包来完成转换,打开终端输入 gopm get -g -v golang.org/x/text
可以把 GBK 编码转化为 utf- 8 编码。在上面代码
bodyReader := bufio.NewReader(resp.Body)
e := determineEncoding(bodyReader)
utf8Reader := transform.NewReader(bodyReader, e.NewDecoder())
可以写为 utf8Reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder())
也是可以的。但是这样问题是通用性太差,我们怎么知道网页是不是 GBK 编码呢?此时还可以引入另外一个库,可以帮助我们判断网页的编码。打开终端输入gopm get -g -v golang.org/x/net/html
。然后把判断网页编码模块提取为一个函数,如上代码所示。
4、Parser 模块实现
(1)解析城市列表与 URL:
// /zhenai/parser/citylist.go
package parser
import (
"crawler/engine"
"regexp"
)
const cityListRe = `<a href="(http://www.zhenai.com/zhenghun/[0-9a-z]+)"[^>]*>([^<]+)</a>`
// 解析城市列表
func ParseCityList(bytes []byte) engine.ParseResult {re := regexp.MustCompile(cityListRe)
// submatch 是 [][][]byte 类型数据
// 第一个 [] 表示匹配到多少条数据,第二个 [] 表示匹配的数据中要提取的任容
submatch := re.FindAllSubmatch(bytes, -1)
result := engine.ParseResult{}
//limit := 10
for _, item := range submatch {result.Items = append(result.Items, "City:"+string(item[2]))
result.Requests = append(result.Requests, engine.Request{Url: string(item[1]), // 每一个城市对应的 URL
ParseFunc: ParseCity, // 使用城市解析器
})
//limit--
//if limit == 0 {
// break
//}
}
return result
}
在上述代码中,获取页面中所有的城市与 URL,然后把每个城市的 URL
作为下一个 Request
的URL
,对应的解析器是 ParseCity
城市解析器。
在对 ParseCityList
进行测试的时候,如果 ParseFunc: ParseCity,
, 这样就会调用ParseCity
函数,但是我们只想测试城市列表解析功能,不想调用 ParseCity
函数,此时可以定义一个函数 NilParseFun
, 返回一个空的ParseResult
,写成ParseFunc: NilParseFun,
即可。
func NilParseFun([]byte) ParseResult {return ParseResult{}
}
因为 http://www.zhenai.com/zhenghun
页面城市比较多,为了方便测试可以对解析的城市数量做一个限制,就是代码中的注释部分。
注意:在解析模块,具体解析哪些信息,以及正则表达式如何书写,不是本次重点。重点是理解各个解析模块之间的联系与函数调用,同下
(2)解析用户列表与 URL
// /zhenai/parse/city.go
package parser
import (
"crawler/engine"
"regexp"
)
var cityRe = regexp.MustCompile(`<a href="(http://album.zhenai.com/u/[0-9]+)"[^>]*>([^<]+)</a>`)
// 用户性别正则,因为在用户详情页没有性别信息,所以在用户性别在用户列表页面获取
var sexRe = regexp.MustCompile(`<td width="180"><span class="grayL"> 性别:</span>([^<]+)</td>`)
// 城市页面用户解析器
func ParseCity(bytes []byte) engine.ParseResult {submatch := cityRe.FindAllSubmatch(bytes, -1)
gendermatch := sexRe.FindAllSubmatch(bytes, -1)
result := engine.ParseResult{}
for k, item := range submatch {name := string(item[2])
gender := string(gendermatch[k][3])
result.Items = append(result.Items, "User:"+name)
result.Requests = append(result.Requests, engine.Request{Url: string(item[1]),
ParseFunc: func(bytes []byte) engine.ParseResult {return ParseProfile(bytes, name, gender)
},
})
}
return result
}
(3)解析用户数据
package parser
import (
"crawler/engine"
"crawler/model"
"regexp"
"strconv"
)
var ageRe = regexp.MustCompile(`<div class="m-btn purple" [^>]*>([\d]+)岁 </div>`)
var heightRe = regexp.MustCompile(`<div class="m-btn purple" [^>]*>([\d]+)cm</div>`)
var weightRe = regexp.MustCompile(`<div class="m-btn purple" [^>]*>([\d]+)kg</div>`)
var incomeRe = regexp.MustCompile(`<div class="m-btn purple" [^>]*> 月收入:([^<]+)</div>`)
var marriageRe = regexp.MustCompile(`<div class="m-btn purple" [^>]*>([^<]+)</div>`)
var addressRe = regexp.MustCompile(`<div class="m-btn purple" [^>]*> 工作地:([^<]+)</div>`)
func ParseProfile(bytes []byte, name string, gender string) engine.ParseResult {profile := model.Profile{}
profile.Name = name
profile.Gender = gender
if age, err := strconv.Atoi(extractString(bytes, ageRe)); err == nil {profile.Age = age}
if height, err := strconv.Atoi(extractString(bytes, heightRe)); err == nil {profile.Height = height}
if weight, err := strconv.Atoi(extractString(bytes, weightRe)); err == nil {profile.Weight = weight}
profile.Income = extractString(bytes, incomeRe)
profile.Marriage = extractString(bytes, marriageRe)
profile.Address = extractString(bytes, addressRe)
// 解析完用户信息后,没有请求任务
result := engine.ParseResult{Items: []interface{}{profile},
}
return result
}
func extractString(contents []byte, re *regexp.Regexp) string {submatch := re.FindSubmatch(contents)
if len(submatch) >= 2 {return string(submatch[1])
} else {return ""}
}
5、Engine 实现
Engine 模块是整个系统的核心,获取网页数据、对数据进行解析以及维护任务队列。
// /engine/engine.go
package engine
import (
"crawler/fetcher"
"log"
)
// 任务执行函数
func Run(seeds ...Request) {
// 建立任务队列
var requests []Request
// 把传入的任务添加到任务队列
for _, r := range seeds {requests = append(requests, r)
}
// 只要任务队列不为空就一直爬取
for len(requests) > 0 {request := requests[0]
requests = requests[1:]
// 抓取网页内容
log.Printf("Fetching %s\n", request.Url)
content, err := fetcher.Fetch(request.Url)
if err != nil {log.Printf("Fetch error, Url: %s %v\n", request.Url, err)
continue
}
// 根据任务请求中的解析函数解析网页数据
parseResult := request.ParseFunc(content)
// 把解析出的请求添加到请求队列
requests = append(requests, parseResult.Requests...)
// 打印解析出的数据
for _, item := range parseResult.Items {log.Printf("Got item %v\n", item)
}
}
}
Engine
模块主要是一个 Run
函数,接收一个或多个任务请求,首先把任务请求添加到任务队列,然后判断任务队列如果不为空就一直从队列中取任务,把任务请求的 URL 传给 Fetcher
模块得到网页数据,然后根据任务请求中的解析函数解析网页数据。然后把解析出的请求加入任务队列,把解析出的数据打印出来。
6、main 函数
package main
import (
"crawler/engine"
"crawler/zhenai/parser"
)
func main() {
engine.Run(engine.Request{ // 配置请求信息即可
Url: "http://www.zhenai.com/zhenghun",
ParseFunc: parser.ParseCityList,
})
}
在 main
函数中直接调用 Run
方法,传入初始请求。
7、总结
本次博客中我们用 Go 语言实现了一个简单的单机版爬虫项目。仅仅聚焦与爬虫核心架构,没有太多复杂的知识,关键是理解 Engine
模块以及各个解析模块之间的调用关系。
缺点是单机版爬取速度太慢了,而且没有使用到 go 语言强大的并发特性,所以我们下一章会在本次项目的基础上,重构项目为并发版的爬虫。
如果想获取 Google 工程师深度讲解 go 语言视频资源的,可以在评论区留言。
项目的源代码已经托管到 Github 上,对于各个版本都有记录,欢迎大家查看,记得给个 star,在此先谢谢大家了。
觉得文章不错的话就点个赞吧~~ 谢谢