Golang實(shí)現(xiàn)簡單爬蟲框架(2)——單任務(wù)版爬蟲
上一篇博客Golang實(shí)現(xiàn)簡單爬蟲框架(1)——項(xiàng)目介紹與環(huán)境準(zhǔn)備
)中我們介紹了go語言的開發(fā)環(huán)境搭建江解,以及爬蟲項(xiàng)目介紹。
本次爬蟲爬取的是珍愛網(wǎng)的用戶信息數(shù)據(jù)铐伴,爬取步驟為:
注意:在本此爬蟲項(xiàng)目中轻专,只會(huì)實(shí)現(xiàn)一個(gè)簡單的爬蟲架構(gòu)魔眨,包括單機(jī)版實(shí)現(xiàn)滴劲、簡單并發(fā)版以及使用隊(duì)列進(jìn)行任務(wù)調(diào)度的并發(fā)版實(shí)現(xiàn),以及數(shù)據(jù)存儲(chǔ)和展示功能寂嘉。不涉及模擬登錄奏瞬、動(dòng)態(tài)IP等技術(shù),如果你是GO語言新手想找練習(xí)項(xiàng)目或者對(duì)爬蟲感興趣的讀者垫释,請(qǐng)放心食用丝格。
1、單任務(wù)版爬蟲架構(gòu)
首先我們實(shí)現(xiàn)一個(gè)單任務(wù)版的爬蟲棵譬,且不考慮數(shù)據(jù)存儲(chǔ)與展示模塊显蝌,首先把基本功能實(shí)現(xiàn)。下面是單任務(wù)版爬蟲的整體框架
下面是具體流程說明:
- 1订咸、首先需要配置種子請(qǐng)求曼尊,就是seed,存儲(chǔ)項(xiàng)目爬蟲的初始入口
- 2脏嚷、把初始入口信息發(fā)送給爬蟲引擎骆撇,引擎把其作為任務(wù)信息放入任務(wù)隊(duì)列,只要任務(wù)隊(duì)列不空就一直從任務(wù)隊(duì)列中取任務(wù)
- 3父叙、取出任務(wù)后神郊,engine把要請(qǐng)求的任務(wù)交給Fetcher模塊,F(xiàn)etcher模塊負(fù)責(zé)通過URL抓取網(wǎng)頁數(shù)據(jù)趾唱,然后把數(shù)據(jù)返回給Engine
- 4涌乳、Engine收到網(wǎng)頁數(shù)后,把數(shù)據(jù)交給解析(Parser)模塊甜癞,Parser解析出需要的數(shù)據(jù)后返回給Engine夕晓,Engine收到解析出的信息在控制臺(tái)打印出來
項(xiàng)目目錄
2、數(shù)據(jù)結(jié)構(gòu)定義
在正式開始講解前先看一下項(xiàng)目中的數(shù)據(jù)結(jié)構(gòu)悠咱。
// /engine/types.go
package engine
// 請(qǐng)求結(jié)構(gòu)
type Request struct {
Url string // 請(qǐng)求地址
ParseFunc func([]byte) ParseResult // 解析函數(shù)
}
// 解析結(jié)果結(jié)構(gòu)
type ParseResult struct {
Requests []Request // 解析出的請(qǐng)求
Items []interface{} // 解析出的內(nèi)容
}
Request
表示一個(gè)爬取請(qǐng)求蒸辆,包括請(qǐng)求的URL
地址和使用的解析函數(shù),其解析函數(shù)返回值是一個(gè)ParseResult
類型析既,其中ParseResult
類型包括解析出的請(qǐng)求和解析出的內(nèi)容躬贡。解析內(nèi)容Items
是一個(gè)interface{}
類型,即這部分具體數(shù)據(jù)結(jié)構(gòu)由用戶自己來定義眼坏。
注意:對(duì)于Request
中的解析函數(shù)拂玻,對(duì)于每一個(gè)URL使用城市列表解析器還是用戶列表解析器,是由我們的具體業(yè)務(wù)來決定的,對(duì)于Engine
模塊不必知道解析函數(shù)具體是什么纺讲,只負(fù)責(zé)Request
中的解析函數(shù)來解析傳入的URL對(duì)應(yīng)的網(wǎng)頁數(shù)據(jù)
需要爬取的數(shù)據(jù)的定義
// /model/profile.go
package model
// 用戶的個(gè)人信息
type Profile struct {
Name string
Gender string
Age int
Height int
Weight int
Income string
Marriage string
Address string
}
3、Fetcher的實(shí)現(xiàn)
Fetcher模塊任務(wù)是獲取目標(biāo)URL的網(wǎng)頁數(shù)據(jù)囤屹,先放上代碼熬甚。
// /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"
)
// 網(wǎng)頁內(nèi)容抓取函數(shù)
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()
// 出錯(cuò)處理
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("wrong state code: %d", resp.StatusCode)
}
// 把網(wǎng)頁轉(zhuǎn)為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
}
因?yàn)樵S多網(wǎng)頁的編碼是GBK,我們需要把數(shù)據(jù)轉(zhuǎn)化為utf-8編碼肋坚,這里需要下載一個(gè)包來完成轉(zhuǎn)換乡括,打開終端輸入gopm get -g -v golang.org/x/text
可以把GBK編碼轉(zhuǎn)化為utf-8編碼。在上面代碼
bodyReader := bufio.NewReader(resp.Body)
e := determineEncoding(bodyReader)
utf8Reader := transform.NewReader(bodyReader, e.NewDecoder())
可以寫為utf8Reader := transform.NewReader(resp.Body, simplifiedchinese.GBK.NewDecoder())
也是可以的智厌。但是這樣問題是通用性太差诲泌,我們?cè)趺粗谰W(wǎng)頁是不是GBK編碼呢?此時(shí)還可以引入另外一個(gè)庫铣鹏,可以幫助我們判斷網(wǎng)頁的編碼敷扫。打開終端輸入gopm get -g -v golang.org/x/net/html
。然后把判斷網(wǎng)頁編碼模塊提取為一個(gè)函數(shù)诚卸,如上代碼所示葵第。
4、Parser模塊實(shí)現(xiàn)
(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 類型數(shù)據(jù)
// 第一個(gè)[]表示匹配到多少條數(shù)據(jù)合溺,第二個(gè)[]表示匹配的數(shù)據(jù)中要提取的任容
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]), // 每一個(gè)城市對(duì)應(yīng)的URL
ParseFunc: ParseCity, // 使用城市解析器
})
//limit--
//if limit == 0 {
// break
//}
}
return result
}
在上述代碼中卒密,獲取頁面中所有的城市與URL,然后把每個(gè)城市的URL
作為下一個(gè)Request
的URL
棠赛,對(duì)應(yīng)的解析器是ParseCity
城市解析器哮奇。
在對(duì)ParseCityList
進(jìn)行測(cè)試的時(shí)候,如果ParseFunc: ParseCity,
,這樣就會(huì)調(diào)用ParseCity
函數(shù)睛约,但是我們只想測(cè)試城市列表解析功能鼎俘,不想調(diào)用ParseCity
函數(shù),此時(shí)可以定義一個(gè)函數(shù)NilParseFun
,返回一個(gè)空的ParseResult
痰腮,寫成ParseFunc: NilParseFun,
即可而芥。
func NilParseFun([]byte) ParseResult {
return ParseResult{}
}
因?yàn)?code>http://www.zhenai.com/zhenghun頁面城市比較多,為了方便測(cè)試可以對(duì)解析的城市數(shù)量做一個(gè)限制膀值,就是代碼中的注釋部分棍丐。
注意:在解析模塊,具體解析哪些信息沧踏,以及正則表達(dá)式如何書寫歌逢,不是本次重點(diǎn)。重點(diǎn)是理解各個(gè)解析模塊之間的聯(lián)系與函數(shù)調(diào)用翘狱,同下
(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>`)
// 用戶性別正則秘案,因?yàn)樵谟脩粼斍轫摏]有性別信息,所以在用戶性別在用戶列表頁面獲取
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][1])
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)解析用戶數(shù)據(jù)
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)
// 解析完用戶信息后,沒有請(qǐng)求任務(wù)
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實(shí)現(xiàn)
Engine模塊是整個(gè)系統(tǒng)的核心赚导,獲取網(wǎng)頁數(shù)據(jù)、對(duì)數(shù)據(jù)進(jìn)行解析以及維護(hù)任務(wù)隊(duì)列赤惊。
// /engine/engine.go
package engine
import (
"crawler/fetcher"
"log"
)
// 任務(wù)執(zhí)行函數(shù)
func Run(seeds ...Request) {
// 建立任務(wù)隊(duì)列
var requests []Request
// 把傳入的任務(wù)添加到任務(wù)隊(duì)列
for _, r := range seeds {
requests = append(requests, r)
}
// 只要任務(wù)隊(duì)列不為空就一直爬取
for len(requests) > 0 {
request := requests[0]
requests = requests[1:]
// 抓取網(wǎng)頁內(nèi)容
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
}
// 根據(jù)任務(wù)請(qǐng)求中的解析函數(shù)解析網(wǎng)頁數(shù)據(jù)
parseResult := request.ParseFunc(content)
// 把解析出的請(qǐng)求添加到請(qǐng)求隊(duì)列
requests = append(requests, parseResult.Requests...)
// 打印解析出的數(shù)據(jù)
for _, item := range parseResult.Items {
log.Printf("Got item %v\n", item)
}
}
}
Engine
模塊主要是一個(gè)Run
函數(shù)吼旧,接收一個(gè)或多個(gè)任務(wù)請(qǐng)求,首先把任務(wù)請(qǐng)求添加到任務(wù)隊(duì)列未舟,然后判斷任務(wù)隊(duì)列如果不為空就一直從隊(duì)列中取任務(wù)圈暗,把任務(wù)請(qǐng)求的URL傳給Fetcher
模塊得到網(wǎng)頁數(shù)據(jù),然后根據(jù)任務(wù)請(qǐng)求中的解析函數(shù)解析網(wǎng)頁數(shù)據(jù)裕膀。然后把解析出的請(qǐng)求加入任務(wù)隊(duì)列员串,把解析出的數(shù)據(jù)打印出來。
6昼扛、main函數(shù)
package main
import (
"crawler/engine"
"crawler/zhenai/parser"
)
func main() {
engine.Run(engine.Request{ // 配置請(qǐng)求信息即可
Url: "http://www.zhenai.com/zhenghun",
ParseFunc: parser.ParseCityList,
})
}
在main
函數(shù)中直接調(diào)用Run
方法寸齐,傳入初始請(qǐng)求。
7抄谐、總結(jié)
本次博客中我們用Go語言實(shí)現(xiàn)了一個(gè)簡單的單機(jī)版爬蟲項(xiàng)目访忿。僅僅聚焦與爬蟲核心架構(gòu),沒有太多復(fù)雜的知識(shí)斯稳,關(guān)鍵是理解Engine
模塊以及各個(gè)解析模塊之間的調(diào)用關(guān)系海铆。
缺點(diǎn)是單機(jī)版爬取速度太慢了,而且沒有使用到go語言強(qiáng)大的并發(fā)特特性挣惰,所以我們下一章會(huì)在本次項(xiàng)目的基礎(chǔ)上卧斟,重構(gòu)項(xiàng)目為并發(fā)版的爬蟲。
如果想獲取Google工程師深度講解go語言視頻資源的憎茂,可以在評(píng)論區(qū)留言珍语。
項(xiàng)目的源代碼已經(jīng)托管到Github上,對(duì)于各個(gè)版本都有記錄竖幔,歡迎大家查看板乙,記得給個(gè)star,在此先謝謝大家了拳氢。