Go 语言极速入门12 - 实战项目之单任务版爬虫

项目地址:https://github.com/zhaojigang/go-crawler
注意:接下来的三节爬虫项目全部来源于《Google资深工程师深度讲解Go语言》的学习笔记。

  • 单人版爬虫:一个 Goroutine 运行整个爬虫项目
  • 并发版爬虫:多个 Goroutine 在一台机器上实现爬虫项目
  • 分布式爬虫:多个 Goroutine 在多台机器上实现爬虫项目

一、爬虫整体算法

该爬虫项目爬取的是珍爱网的数据,总体算法如下


image.png
  1. 首先根据城市列表 Url 爬取城市列表,爬取出来的内容通过城市列表解析器解析出来每一个城市的 Url
  2. 然后根据每一个城市的 Url 爬取该城市的用户信息列表,通过城市解析器将用户信息列表中的用户 Url 解析出来
  3. 最后根据每一个用户的 Url 爬取该用户的详细信息,并进行解析

三种 Url 示例:

城市列表 Url:http://www.zhenai.com/zhenghun
城市 Url:http://www.zhenai.com/zhenghun/aba
用户 Url:http://album.zhenai.com/u/1902329077

二、单任务版爬虫架构

image.png
  1. 首先会将种子 Url Seed 连同其解析器 Parser 封装为一个 Request,放入 Engine 引擎中的任务队列(其实就是 []Request 切片)中,启动爬取任务(这里的 Seed 就是城市列表 Url)
  2. 之后 Engine 使用 Fetcher 爬取该 Url 的内容 text,然后使用对应 Url 的Parser 解析该 text,将解析出来的 Url(例如,城市 Url)和其 Parser 封装为 Request 加入 Engine 任务队列,将解析出来的 items(例如,城市名)打印出来
  3. 然后 Engine 不断的从其任务队列中获取任务 Request 一个个进行串行执行(使用 Fetcher 对 Request.Url 进行爬取,使用 Request.Parser 对爬取出来的 text 进行解析,将解析出来的内容部分进行封装为Request,进行后续循环,部分进行打印)

三、代码实现

image.png

3.1 请求与解析结果封装体 type.go

package engine

// 请求任务封装体
type Request struct {
    // 需爬取的 Url
    Url string
    // Url 对应的解析函数
    ParserFunc func([]byte) ParseResult
}

// 解析结果
type ParseResult struct {
    // 解析出来的多个 Request 任务
    Requests []Request
    // 解析出来的实体(例如,城市名),是任意类别(interface{},类比 java Object)
    Items    []interface{}
}

3.2 执行引擎 engine.go

package engine

import (
    "github.com/zhaojigang/crawler/fetcher"
    "log"
)

func Run(seeds ...Request) {
    // Request 任务队列
    var requests []Request
    // 将 seeds Request 放入 []requests,即初始化 []requests
    for _, r := range seeds {
        requests = append(requests, r)
    }
    // 执行任务
    for len(requests) > 0 {
        // 1. 获取第一个 Request,并从 []requests 移除,实现了一个队列功能
        r := requests[0]
        requests = requests[1:]

        // 2. 使用爬取器进行对 Request.Url 进行爬取
        body, err := fetcher.Fetch(r.Url)
        // 如果爬取出错,记录日志
        if err != nil {
            log.Printf("fetch error, url: %s, err: %v", r.Url, err)
            continue
        }

        // 3. 使用 Request 的解析函数对怕渠道的内容进行解析
        parseResult := r.ParserFunc(body)
        // 4. 将解析体中的 []Requests 加到请求任务队列 requests 的尾部
        requests = append(requests, parseResult.Requests...)

        // 5. 遍历解析出来的实体,直接打印
        for _, item := range parseResult.Items {
            log.Printf("getItems, url: %s, items: %v", r.Url, item)
        }
    }
}

3.3 爬取器 fetcher.go

package fetcher

import (
    "fmt"
    "io/ioutil"
    "net/http"
)

func Fetch(url string) ([]byte, error) {
    // 1. 爬取 url
    resp, err := http.Get(url)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("wrong statusCode, %d", resp.StatusCode)
    }
    // 2. 读取响应体并返回
    return ioutil.ReadAll(resp.Body)
}

3.4 三种解析器

城市列表解析器 citylist.go

package parser

import (
    "github.com/zhaojigang/crawler/engine"
    "regexp"
)

const cityListRe = `<a href="(http://www.zhenai.com/zhenghun/[0-9a-z]+)"[^>]*>([^<]*)</a>`

// cityList 的 ParserFunc func([]byte) ParseResult
// 解析种子页面 - 获取城市列表
func ParseCityList(contents []byte) engine.ParseResult {
    result := engine.ParseResult{}
    // 正则表达式:()用于提取
    rg := regexp.MustCompile(cityListRe)
    allSubmatch := rg.FindAllSubmatch(contents, -1)
    // 遍历每一个城市的匹配字段(城市 Url 和城市名),并且将 Url 和城市解析器封装为一个 Request
    // 最后将该 Request 添加到 ParseResult 中
    for _, m := range allSubmatch {
        result.Items = append(result.Items, "city "+string(m[2]))
        result.Requests = append(result.Requests, engine.Request{
            Url:        string(m[1]),
            ParserFunc: ParseCity,
        })
    }
    // 返回 ParseResult
    return result
}

学习 Go 正则表达式的使用

城市解析器 city.go

package parser

import (
    "github.com/zhaojigang/crawler/engine"
    "regexp"
)

// match[1]=url match[2]=name
const cityRe = `<a href="(http://album.zhenai.com/u/[0-9]+)"[^>]*>([^<]+)</a>`

// 解析单个城市 - 获取单个城市的用户列表
func ParseCity(contents []byte) engine.ParseResult {
    result := engine.ParseResult{}
    rg := regexp.MustCompile(cityRe)
    allSubmatch := rg.FindAllSubmatch(contents, -1)
    for _, m := range allSubmatch {
        name := string(m[2])
        result.Items = append(result.Items, "user "+name)
        result.Requests = append(result.Requests, engine.Request{
            Url: string(m[1]),
            ParserFunc: func(c []byte) engine.ParseResult {
                return ParseProfile(c, name) // 函数式编程,使用函数包裹函数
            },
        })
    }

    return result
}

学习函数式编程:使用函数包裹函数,即函数的返回值和入参都可以是函数。

用户解析器 profile.go

package parser

import (
    "github.com/zhaojigang/crawler/engine"
    "github.com/zhaojigang/crawler/model"
    "regexp"
    "strconv"
)

var ageRe = regexp.MustCompile(`<td><span class=""label">年龄:</span>([\d])+岁</td>`)
var incomeRe = regexp.MustCompile(`<td><span class=""label">月收入:</span>([^<]+)</td>`)

// 解析单个人的主页
func ParseProfile(contents []byte, name string) engine.ParseResult {
    profile := model.Profile{}

    // 1. 年龄
    age, err := strconv.Atoi(extractString(contents, ageRe))
    if err == nil {
        profile.Age = age
    }

    // 2. 月收入
    profile.Income = extractString(contents, incomeRe)

    // 3. 姓名
    profile.Name = name

    result := engine.ParseResult{
        Items: []interface{}{profile},
    }
    return result
}

func extractString(body []byte, re *regexp.Regexp) string {
    match := re.FindSubmatch(body) // 只找到第一个match的
    if len(match) >= 2 {
        return string(match[1])
    }
    return ""
}

profile 实体类

package model

type Profile struct {
    // 姓名
    Name string
    // 年龄
    Age int
    // 收入
    Income string
}

3.5 启动器 main.go

package main

import (
    "github.com/zhaojigang/crawler/engine"
    "github.com/zhaojigang/crawler/zhenai/parser"
)

func main() {
    engine.Run(engine.Request{
        // 种子 Url
        Url:        "http://www.zhenai.com/zhenghun",
        ParserFunc: parser.ParseCityList,
    })
}

解析器测试类

package parser

import (
    "io/ioutil"
    "testing"
)

func TestParseCityList(t *testing.T) {
    expectRequestsLen := 470
    expectCitiesLen := 470
    // 表格驱动测试
    expectRequestUrls := []string{
        "http://www.zhenai.com/zhenghun/aba",
        "http://www.zhenai.com/zhenghun/akesu",
        "http://www.zhenai.com/zhenghun/alashanmeng",
    }
    expectRequestCities := []string{
        "city 阿坝",
        "city 阿克苏",
        "city 阿拉善盟",
    }

    body, err := ioutil.ReadFile("citylist_test_data.html")
    if err != nil {
        panic(err)
    }
    result := ParseCityList(body)

    if len(result.Requests) != expectRequestsLen {
        t.Errorf("expect requestLen %d, but %d", expectRequestsLen, len(result.Requests))
    }
    if len(result.Items) != expectCitiesLen {
        t.Errorf("expect citiesLen %d, but %d", expectCitiesLen, len(result.Items))
    }

    for i, url := range expectRequestUrls {
        if url != result.Requests[i].Url {
            t.Errorf("expect url %s, but %s", url, result.Requests[i].Url)
        }
    }

    for i, city := range expectRequestCities {
        if city != result.Items[i] {
            t.Errorf("expect url %s, but %s", city, result.Items[i])
        }
    }
}

学习经典的 Go 表格驱动测试。

执行 main 函数发现执行的很慢,因为只有一个 main Goroutine 在执行,还有网络 IO,所以比较慢,接下来,将单任务版的改造成多个 Goroutine 共同执行的并发版的爬虫。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,175评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,674评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,151评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,597评论 1 269
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,505评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,969评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,455评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,118评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,227评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,213评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,214评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,928评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,512评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,616评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,848评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,228评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,772评论 2 339

推荐阅读更多精彩内容