cmatrixprobe

发呆业务爱好者

从零开始Golang爬虫(二)| 初步完成

学习了爬虫的预备知识后,就可以开始写代码实现了

首先分析爬虫的大致步骤如下:

爬虫流程

通过构造请求、返回解析结果、再构造请求……比如爬取图书信息,可以先获取图书的分类,在分类中获取图书列表,最后在列表中获取每个图书的信息,逐层地获取数据,实际上就是BFS的应用。


因为我比较喜欢看电影,考虑到项目的实用性,我决定爬取多个电影资源网站以及豆瓣的评论

请求与解析结构定义

package engine

import "io"

// Request 构造请求
type Request struct {
    URL             string
    ParseFunc func(io.ReadCloser) ParseResult
}

// ParseResult 解析结果
type ParseResult struct {
    Items    []interface{}
    Requests []Request
}
  • Request包含要请求的url地址以及对应的解析函数,由于goquery只能解析io.Reader和html.Node,所以形参为io.ReadCloser
  • ParseResult包含解析的结果列表以及下一层的Request

页面爬取

先用Fiddler抓包分析一下浏览器正常访问的请求:

80s浏览器请求头

发现需要带上Cookie

package fetch

import (
    "fmt"
    browser "github.com/EDDYCJY/fake-useragent"
    "github.com/parnurzeal/gorequest"
    "io"
    "net/http"
)

var cookie = "__cm_warden_uid=108808cd92f4e57056aec2eeaf924514coo; __cm_warden_upi=MTIzLjE4Ni4xMTIuMjQ2; Hm_lpvt_caa88905362b7957005130b28e579d36=1586947875"

// Fetch 获取页面
func Fetch(url string) (io.ReadCloser, error) {
    resp, _, errors := gorequest.New().
        Get(url).
        Set("User-Agent", browser.Computer()).
        Set("Cookie", cookie).
        End()
    if errors != nil {
        return nil, fmt.Errorf("Fetching %s: %v ", url, errors)
    }
    if resp.StatusCode != http.StatusOK {
        return nil, fmt.Errorf("Fetching %s: %d ", url, resp.StatusCode)
    }
    return resp.Body, nil
}
  • 通过gorequest可以发起Get请求
  • fake-useragent随机切换UA
  • 分析浏览器的请求头并设置Cookie
  • 也可以通过Proxy设置代理

这里比较坑爹的是gorequest方法链的执行顺序,我一开始把Set请求头放到了Get(url)前面,结果怎么也请求不成功,看一下Fiddler发现都没设置进去

正确设置之后的请求头如下:

gorequest请求成功

如果遇到动态页面或者需要登录可以用selenium,不过内存占用会很大

https://github.com/tebeka/selenium
https://github.com/SeleniumHQ/docker-selenium

数据解析

1. 首先定义要解析的数据

package model

import "time"

// Movie 电影数据结构
type Movie struct {
    Name     string    // 片名
    Profile  string    // 简介
    Alias    []string  // 别名
    Actor    []string  // 演员
    Type     []string  // 类型
    Origin   []string  // 地区
    Language []string  // 语言
    Director string    // 导演
    Date     time.Time // 上映日期
    Duration int       // 片长
    Score    float64   // 豆瓣评分
    Synopsis string    // 剧情介绍
    Download []string  // 下载链接
}

2. 以80s电影网为例,解析电影列表

package s80

import (
    "github.com/PuerkitoBio/goquery"
    "io"
    "log"
    "moviecrawl/engine"
    "strings"
)

var domain = "https://www.80s.tw"

// ParseMovieList 解析电影列表
func ParseMovieList(body io.ReadCloser) engine.ParseResult {
    defer body.Close()

    doc, err := goquery.NewDocumentFromReader(body)
    if err != nil {
        log.Fatalf("Parse error: %s", err)
    }

    var result engine.ParseResult
    doc.Find(".me1 h3 a[href^=\"/movie/\"]").Each(func(i int, s *goquery.Selection) {
        name := strings.TrimSpace(s.Text())
        href, exists := s.Attr("href")
        if !exists {
            log.Fatalf("Not exist")
        }
        url := domain + href
        result.Items = append(result.Items, name)
        result.Requests = append(result.Requests, engine.Request{
            URL:       url,
            ParseFunc: ParseMovie,
        })
    })
    return result
}

3. 分析电影详情页

这里的解析分为几种情况,以钢铁侠3为例:

  • 片名直接解析即可,CSS选择器为h1.font14w

  • 简介
    简介
    片名的之后的首个兄弟span节点,即h1~span:first-of-type

  • 演员、类型、地区和语言
    类型
    动作和科幻所在的标签a以及父标签span.span_block与其他地方都一样,没有特征,所以可以先通过span.font_888:contains(类型)查找到兄弟标签,然后利用NextAll获取

  • 导演的情况类似,不过只有一位,利用Next获取

  • 别名、日期、片长、评分和剧情
    片长
    134分钟在片长的父元素内,通过span.font_888:contains(片长)查找到片长标签,再通过Parent获取父标签,然后利用parent.Children().Remove()删除所有子标签,最后返回Text(如果不删除子标签会将子标签的Text一并返回)

  • 下载链接
    找到链接所在的div#cpdl2list,提取所有magnet开头的链接

4. 功能封装

基于以上各种情况实现一个repo包,完成对goquery.Selection的简单封装:

package repo

import (
    "fmt"
    "github.com/PuerkitoBio/goquery"
    "strings"
)

// Selection 基于goquery.Selection简单封装
type Selection struct {
    OSelection *goquery.Selection
}

// Find 直接返回去除空格后的text
func (s *Selection) Find(selector string) string {
    res := s.OSelection.Find(selector).Text()
    return strings.TrimSpace(res)
}

// FindParent 在s中查找selector的父节点
func (s *Selection) FindParent(selector, text string) string {
    css := fmt.Sprintf("%s:contains(%s)", selector, text)
    parent := s.OSelection.Find(css).Parent()
    parent.Children().Remove()
    return strings.TrimSpace(parent.Text())
}

// FindNextByContains 在s中查找selector:contains(text)的下一个节点
func (s *Selection) FindNextByContains(selector, text string) string {
    css := fmt.Sprintf("%s:contains(%s)", selector, text)
    res := s.OSelection.Find(css).Next().Text()
    return strings.TrimSpace(res)
}

// FindNextAllByContains 在s中查找selector:contains(text)的所有后继兄弟节点
func (s *Selection) FindNextAllByContains(selector, text string) []string {
    css := fmt.Sprintf("%s:contains(%s)", selector, text)
    return s.OSelection.Find(css).NextAll().
        Map(func(i int, s *goquery.Selection) string {
            return strings.TrimSpace(s.Text())
        })
}

// FindNextTag 在s中查找selector的nextSelector
func (s *Selection) FindNextByFirstOfType(selector, nextSelector string) string {
    css := fmt.Sprintf("%s~%s:first-of-type", selector, nextSelector)
    res := s.OSelection.Find(css).Text()
    return strings.TrimSpace(res)
}

// FindAllByAttr 在s中查找所有符合selector[attr]的节点
func (s *Selection) FindAllByAttr(selector string, attr string) []string {
    return s.OSelection.Find(selector).Map(func(i int, s *goquery.Selection) string {
        res, exists := s.Attr(attr)
        if !exists {
            return ""
        }
        return strings.TrimSpace(res)
    })
}

5. 解析电影信息

package s80

import (
    "github.com/PuerkitoBio/goquery"
    "io"
    "log"
    "moviecrawl/engine"
    "moviecrawl/model"
    "moviecrawl/repo"
    "regexp"
    "strconv"
    "strings"
    "time"
)

// ParseMovie 解析电影信息
func ParseMovie(body io.ReadCloser) engine.ParseResult {
    defer body.Close()

    doc, err := goquery.NewDocumentFromReader(body)
    if err != nil {
        log.Printf("New doc from body: %s", err)
        return engine.ParseResult{}
    }

    info := repo.Selection{
        OSelection: doc.Find(".info"),
    }
    link := repo.Selection{
        OSelection: doc.Find("#cpdl2list"),
    }
    font888 := "span.font_888"

    movie := model.Movie{
        Name:     info.Find("h1"),
        Profile:  info.FindNextByFirstOfType("h1", "span"),
        Alias:    parseSplit(info.FindParent(font888, "又名"), ","),
        Actor:    info.FindNextAllByContains(font888, "演员"),
        Type:     info.FindNextAllByContains(font888, "类型"),
        Origin:   info.FindNextAllByContains(font888, "地区"),
        Language: info.FindNextAllByContains(font888, "语言"),
        Director: info.FindNextByContains(font888, "导演"),
        Date:     parseDate(info.FindParent(font888, "上映日期")),
        Duration: parseLeftInt(info.FindParent(font888, "片长")),
        Score:    parseFloat(info.FindParent(font888, "豆瓣评分")),
        Synopsis: strings.TrimRight(info.FindParent(font888, "剧情介绍"), "编辑整理"),
        Download: link.FindAllByAttr("a[href^=magnet]", "href"),
    }

    var result engine.ParseResult
    result.Items = append(result.Items, movie)
    return result
}

// 对结果以split分割
func parseSplit(s, sep string) []string {
    res := strings.Split(s, sep)
    for i := range res {
        res[i] = strings.TrimSpace(res[i])
    }
    return res
}

// 解析为float
func parseFloat(s string) float64 {
    s = strings.TrimSpace(s)
    if s == "暂无" {
        return 0
    }
    res, err := strconv.ParseFloat(s, 64)
    if err != nil {
        log.Printf("Parse %s: %s\n", s, err)
        return 0
    }
    return res
}

// 提取字符串左侧数字
func parseLeftInt(s string) int {
    s = strings.TrimSpace(s)
    re := regexp.MustCompile(`(\d+)[^\d]*`)
    strs := re.FindStringSubmatch(s)
    var str string
    if len(strs) > 1 {
        str = strs[1]
    }
    res, err := strconv.Atoi(str)
    if err != nil {
        log.Printf("Parse %s: %s\n", str, err)
        return 0
    }
    return res
}

// 正则表达式匹配 yyyy-MM 或 yyyy-MM-dd
func parseDate(date string) time.Time {
    dateRe := regexp.MustCompile(`\d{4}-\d{2}(-\d{2})?`)
    date = dateRe.FindString(date)
    result, err := time.Parse("2006-01-02", date)
    if err != nil {
        log.Println(err)
        return time.Time{}
    }
    return result
}

基于队列实现的BFS爬虫引擎

package engine

import (
    "log"
    "moviecrawl/fetch"
)

// Run 运行爬虫
func Run(seeds ...Request) {
    var requests []Request
    for _, seed := range seeds {
        requests = append(requests, seed)
    }
    for len(requests) > 0 {
        r := requests[0]
        requests = requests[1:]
        log.Printf("Fetch url: %s", r.URL)
        body, err := fetch.Fetch(r.URL)
        if err != nil {
            log.Println(err)
            continue
        }
        parseResult := r.ParseFunc(body)
        requests = append(requests, parseResult.Requests...)

        for _, item := range parseResult.Items {
            log.Printf("Got item: %s", item)
        }
    }
}

开始任务

package main

import (
    "moviecrawl/engine"
    "moviecrawl/parse/s80"
    "strconv"
)

// Task 爬虫任务
type Task struct {
    url   string // 起始url
    start int    // 起始页码
    end   int    // 终止页码
}

func NewTask(url string, start int, end int) *Task {
    return &Task{
        url:   url,
        start: start,
        end:   end,
    }
}

func (t Task) InitRequests() []engine.Request {
    var requests []engine.Request
    for i := t.start; i <= t.end; i++ {
        requests = append(requests, engine.Request{
            URL:       t.url + strconv.Itoa(i),
            ParseFunc: s80.ParseMovieList,
        })
    }
    return requests
}
package main

import (
    "log"
    "moviecrawl/engine"
)

func init() {
    log.SetFlags(log.Lshortfile)
}

func main() {
    task80s := NewTask("https://www.80s.tw/movie/list/-----p/", 1, 100)
    requests := task80s.InitRequests()
    engine.Run(requests...)
}

运行结果

爬取结果

小结

至此爬虫就初步完成了,不过至少1s才爬取到一条数据,应该充分利用goroutine的并发特性实现并发爬虫,提高爬取效率。

从零开始Golang爬虫(一)| 预备知识

上一篇

从零开始Golang爬虫(三)| 并发调度

下一篇
评论
头像 发表评论 说点什么
还没有评论
1509
1