Golang實(shí)現(xiàn)簡單爬蟲框架(2)——單任務(wù)版爬蟲

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ù)版爬蟲的整體框架

批注 2019-05-20 114847.png

下面是具體流程說明:

  • 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)目目錄

批注 2019-05-20 115906.png

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è)RequestURL棠赛,對(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,在此先謝謝大家了拳氢。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末募逞,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子馋评,更是在濱河造成了極大的恐慌放接,老刑警劉巖,帶你破解...
    沈念sama閱讀 211,376評(píng)論 6 491
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件留特,死亡現(xiàn)場離奇詭異纠脾,居然都是意外死亡玛瘸,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,126評(píng)論 2 385
  • 文/潘曉璐 我一進(jìn)店門苟蹈,熙熙樓的掌柜王于貴愁眉苦臉地迎上來糊渊,“玉大人,你說我怎么就攤上這事慧脱≡倮矗” “怎么了?”我有些...
    開封第一講書人閱讀 156,966評(píng)論 0 347
  • 文/不壞的土叔 我叫張陵磷瘤,是天一觀的道長。 經(jīng)常有香客問我搜变,道長采缚,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 56,432評(píng)論 1 283
  • 正文 為了忘掉前任挠他,我火速辦了婚禮扳抽,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘殖侵。我一直安慰自己贸呢,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,519評(píng)論 6 385
  • 文/花漫 我一把揭開白布拢军。 她就那樣靜靜地躺著楞陷,像睡著了一般。 火紅的嫁衣襯著肌膚如雪茉唉。 梳的紋絲不亂的頭發(fā)上固蛾,一...
    開封第一講書人閱讀 49,792評(píng)論 1 290
  • 那天,我揣著相機(jī)與錄音度陆,去河邊找鬼艾凯。 笑死,一個(gè)胖子當(dāng)著我的面吹牛懂傀,可吹牛的內(nèi)容都是我干的趾诗。 我是一名探鬼主播,決...
    沈念sama閱讀 38,933評(píng)論 3 406
  • 文/蒼蘭香墨 我猛地睜開眼蹬蚁,長吁一口氣:“原來是場噩夢(mèng)啊……” “哼恃泪!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起犀斋,我...
    開封第一講書人閱讀 37,701評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤悟泵,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后闪水,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體糕非,經(jīng)...
    沈念sama閱讀 44,143評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡蒙具,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,488評(píng)論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了朽肥。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片禁筏。...
    茶點(diǎn)故事閱讀 38,626評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖衡招,靈堂內(nèi)的尸體忽然破棺而出篱昔,到底是詐尸還是另有隱情,我是刑警寧澤始腾,帶...
    沈念sama閱讀 34,292評(píng)論 4 329
  • 正文 年R本政府宣布州刽,位于F島的核電站,受9級(jí)特大地震影響浪箭,放射性物質(zhì)發(fā)生泄漏穗椅。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,896評(píng)論 3 313
  • 文/蒙蒙 一奶栖、第九天 我趴在偏房一處隱蔽的房頂上張望匹表。 院中可真熱鬧,春花似錦宣鄙、人聲如沸袍镀。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,742評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽苇羡。三九已至,卻和暖如春鼻弧,著一層夾襖步出監(jiān)牢的瞬間宣虾,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,977評(píng)論 1 265
  • 我被黑心中介騙來泰國打工温数, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留绣硝,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,324評(píng)論 2 360
  • 正文 我出身青樓撑刺,卻偏偏與公主長得像鹉胖,于是被迫代替她去往敵國和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子够傍,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,494評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容