从零开始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抓包分析一下浏览器正常访问的请求:
发现需要带上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发现都没设置进去
正确设置之后的请求头如下:
如果遇到动态页面或者需要登录可以用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的并发特性实现并发爬虫,提高爬取效率。