从零开始的 Go 爬虫框架编程实战 - 上篇

发布于:

编程

目录

这是一篇讲解如何编写 Go 爬虫框架的编程实战系列长文,是一个爬虫框架程序从无到有的成长进化史。

我是一个 Android 开发工程师,之前并没有 Golang 程序的编写历史,只学习过 Golang 的基本语法。不过我有较多的 Java/Kotlin/Python/Shell/C++ 编程经验,重视编码风格,尊重最佳实践,同时也是一个编程爱好者, Linux用户。

编程语言只是一个开发工具,代码的逻辑和架构思想是相同的,这大概也是我能在周末两天内快速完成编写这个程序的原因。

文中的写法和格式风格都是依据经验和现学现用,如果有不符合代码规范或最佳实践的地方,还请不吝赐教。

为什么选择 Go 爬虫

我写过几个爬虫类型的程序,基本都是在处理整理资源,这些代码大多是 Python 实现的,没有开源,也基本是自用。比如

  • MinorNews - 在主流和国外媒体上爬取指定关键字的旅游资讯新闻信息并发送邮件。
  • DFront - 浏览器插件,通过 Ctrl-Q 快捷键一键发送当前网页资源到后端 Dmaster
  • Dmaster - 通过 DFront 接口上报的网址和信息,索引查找对应支持的站点并抓取资源链接,下载压缩、整理等操作,并发送通知提示。
  • PhoneNumber - 这是一个开源的号码查询库,其中有两个数据源是查询网页的搜索结果并解析。

后端都是用 Python - flask - celery 编写的,爬虫方面使用的多是 Beautiful soup 库,爬取新闻时有的网站使用了 PhantomJSheadless browser 爬取内容。

从开发效率角度看,Python 虽然很高效,但是需要集成 Python3 的环境,即使搭配 Docker 使用也不是很方便。最近一个月使用 Python 写了一组提取各个站点资源信息并整理资源到相应目录的脚本,各个脚本文件放到 ~/bin 目录后直接运行,虽然很方面实用,但是没有成系统的架构和统一的程序入口,迁移发布也不是很方便,而且每个脚本重复的代码太多,维护起来效率不高。

因为 Go 可以很方便的编译和发布,为了达成统一入口和方便发布的目的,这次的爬虫转入到 Go 编写爬虫。

从零开始,高效的 Go 开发环境

得益于之前的开源应用,我已经连续两年获取到了 IntelliJ IDEA 的免费开源许可,可以无限制的在开源项目中使用 IntelliJ 的全家桶,即全部平台 IDE,毫无疑问选择了 GoLand 开发环境。因为平时也会运行一些 Go 代码编译,所以本地已经有了 Go 环境,下载 GoLand linux zip 包以后,直接解压运行 bin/goland.sh 就可以使用了。

我之前没有写过 Go 程序,只写了 Gohello world,学习过基本语法,并没有系统的学习和开发 Go 程序经验。在周末的两天里,从零开始到爬虫框架和 11 个站点爬虫配置,使我再次体会到高效的 IDE 工具对开发效率的巨大影响。

开发过程及框架介绍

这次的爬虫起的名字为 Tidy,预期通过它可以高效系统的整理文件和获取信息,目前只有抓取站点信息的能力,之后会加入文件整理,下载压缩解压等一系列的功能。开放源代码 ruriio/tidy

开发过程可以从 commits 日志来看,以下内容是对流水帐的整理。

1. 基础数据结构和架构

程序使用 Meta 结构体的基础数据结构,内容包含标题、发行时间、演员、发行商、作品编号等元数据信息,放在 model/meta.go 内。

type Meta struct {
	Id       string
	Title    string
	Actor    string
	Producer string
	Series   string
	Age      string
	Sample   string
	Poster   string
	Images   []string
	Label    string
	Genre    string
}

为了方便打印,编写了 meta json 化输出的方法,也放置在 meta.go 文件中。

func (meta Meta) Json() string {
	out, err := json.Marshal(meta)
	if err != nil {
		log.Panic(err)
	}
	return string(out)
}

目录结构方面,使用 model 目录存放模块代码,使用 sites 目录存放站点配置代码。在根目录放置了 main.go scrape.go 文件作为程序入口和爬虫的逻辑体。

为了统一站点配置,创建 Site 结构体,包含所有站点相关的信息如 Url UserAgent Title 等。同时将常用的桌面和移动端 UA内置在常量里,方便调用。

package sites

type Site struct {
	Url       string
	UserAgent string
	Title string
}

const UserAgent string = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.90 Safari/537.36"
const MobileUserAgent string = "Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1"

2. 基础站点配置清单

Site 结构体类似于父类或者站点配置清单,真正的站点配置在单个的站点配置清单中返回,如 dmm.go

package sites

import "fmt"

func Dmm(id string) Site {
	return Site{
		Url:       url(id),
		UserAgent: MobileUserAgent,
		Title:     ".ttl-grp",
	}
}

func url(id string) string {
	return fmt.Sprintf("https://www.dmm.co.jp/mono/dvd/-/detail/=/cid=%s/", id)
}

在这个站点配置中,初始化了一个 Site 结构体,并通过 id 参数填充了 url,爬起使用移动端的网页,同时对应的 Title 标题字段,使用了 .ttl-grpCSS Selector 。字段这一块会在后续过程中优化,现在的目标是先搭起框架跑起来。如果没有编写爬虫和写前端的经验,建议先阅读下 CSS3 选择器 的相关文档。

如果要新建站点,只需要再创建一个类似的站点配置清单即可。

3. 基础爬虫逻辑代码

爬虫的库有很多,我选择了 goquery 库底层爬虫支持库。爬虫逻辑代码是爬虫类型程序的核心,基本都是用配置的 CSS selector 查找目标然后解析。例如 scrape.go 的代码片段。

func Scrape(site Site) Meta {
	var meta = Meta{}
	log.Printf("url: %s", site.Url)
	resp, err := get(site)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != 200 {
		log.Fatalf("stats code error: %d %s", resp.StatusCode, resp.Status)
	}

	// Load the HTML document
	doc, err := goquery.NewDocumentFromReader(resp.Body)

	if err != nil {
		log.Fatal(err)
	}

	meta.Title = strings.TrimSpace(doc.Find(site.Title).First().Text())

	doc.Find(".ttl-grp").Each(func(i int, s *goquery.Selection) {
		// For each item found, get the band and title
		bind := s.Find("a").Text()
		title := s.Find("i").Text()
		fmt.Printf("Review %d: %s - %s\n", i, bind, title)
	})

	return meta
}

代码片段中,使用 goquery.NewDocumentFromReader(resp.Body) 创建了一个 Document 对象,然后通过 doc.Find(site.Title).First().Text() 查找到目标,并将字符串处理,返回 meta

同时也对 http GET 请求进行了封装,这里填充了 header 字段,之后会增加 cookie 支持:

func get(site Site) (*http.Response, error) { 
	client := &http.Client{}
	req, err := http.NewRequest("GET", site.Url, nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("User-Agent", site.UserAgent)
	return client.Do(req)
}

同时为了调试方便,增加了打印 html 的方法:

func printBody(resp *http.Response) {
	body, err := ioutil.ReadAll(resp.Body)

	if err != nil {
		log.Fatal(err)
	}

	log.Printf("body: %s", string(body))
}

4. Selector 选择器封装

在上一节的代码片断中,可以看到使用了 doc.Find() 函数获取文本:

Site{
	Url:       url(id),
	UserAgent: MobileUserAgent,
	Title:     ".ttl-grp",
}

meta.Title = strings.TrimSpace(doc.Find(site.Title).First().Text())

可以对这一断代码进行封装,作为预置项方便扩展和使用(site.go):

type Selector struct {
	Id       Item
	Title    Item
	Actor    Item
	Poster   Item
	Series   Item
	Producer Item
	Release  Item
	Duration Item
	Sample   Item
	Images   Item
	Label    Item
	Genre    Item
}

type Item struct {
	selector string
	replacer *strings.Replacer
}

func selector(selector string) Item {
	return Item{selector: selector, replacer: strings.NewReplacer("", "")}
}

func replacer(selector string, replacer *strings.Replacer) Item {
	return Item{selector: selector, replacer: replacer}
}

Selector 中的每一个元素 Item 对应于 Meta 中的属性, Item 又包含一个 selector string 表示 css selector 的文本,replacer *strings.Replacer 是用来扩展 Selector 的能力,使它可以支持替换文本的操作,这个能力在后文中会再多次增强。此时的 dmm.go 中站点配置也得到升级:

func Dmm(id string) Site {
	return Site{
		Url:       url(id),
		UserAgent: MobileUserAgent,

		Selector: Selector{
			Title:    selector(".ttl-grp"),
			Actor:    selector("ul.parts-maindata > li > a > span"),
			Poster:   replacer(".package", NewReplacer("ps.jpg", "pl.jpg")),
			Producer: selector(".parts-subdata"),
			Sample:   selector(".play-btn"),
			Series:   selector("#work-mono-info > dl:nth-child(4) > dd"),
			Release:  selector("#work-mono-info > dl:nth-child(8) > dd"),
			Duration: selector("#work-mono-info > dl:nth-child(9) > dd"),
			Id:       selector("#work-mono-info > dl:nth-child(10) > dd"),
			Label:    selector("#work-mono-info > dl:nth-child(6) > dd > ul > li > a"),
			Genre:    selector("#work-mono-info > dl.box-genreinfo > dd > ul > li > a"),
			Images:   replacer("#sample-list > ul > li > a > span > img", NewReplacer("-", "jp-")),
		},
	}
}

从这段代码可以看出,站点配置清单中的 Selector 结构体元素初始化已经变为使用 selector()replacer()操作函数执行,这段代码仍将在后文中优化。

5. 获取标签文字和属性

在上一节的代码片断中,可以看到使用了 doc.Find(site.Title).First().Text() 来获取文本,同时也做了去首尾空白的处理,为了代码逻辑更合理,将这一块的代码提取到 Site -> Selector -> Item 结构体中:

func (selector Item) Text(doc *goquery.Document) string {
	text := strings.TrimSpace(doc.Find(selector.selector).First().Text())
	return selector.replacer.Replace(text)
}

func (selector Item) Texts(doc *goquery.Document) []string {
	var texts []string
	doc.Find(selector.selector).Each(func(i int, selection *goquery.Selection) {
		text := strings.TrimSpace(selection.Text())
		text = selector.replacer.Replace(text)
		texts = append(texts, text)
	})

	return texts
}

这时,调用 Selector.Item.Text(doc) 函数就可以获取到文本或文本数组内容了。进一步扩展,获取标签的属性内容,函数如下:

func (selector Item) Attr(doc *goquery.Document, attr string) string {
	src, exist := doc.Find(selector.selector).First().Attr(attr)
	if exist {
		return selector.replacer.Replace(strings.TrimSpace(src))
	}
	return ""
}

func (selector Item) Attrs(doc *goquery.Document, attr string) []string {
	var attrs []string
	doc.Find(selector.selector).Each(func(i int, selection *goquery.Selection) {
		src, exist := selection.Attr(attr)
		if exist {
			text := strings.TrimSpace(src)
			text = selector.replacer.Replace(text)
			attrs = append(attrs, text)
		}
	})
	return attrs
}

例如,使用 selector.Attr(doc, "href") 即可获取到当前 selectorhref 属性值。

此时在 scrape.go 爬虫逻辑中,通过使用如下封装获取所有的查找值。

	// extract meta data from web page
	meta.Title = site.Title.Text(doc)
	meta.Actor = site.Actor.Text(doc)
	meta.Poster = site.Poster.Image(doc)
	meta.Producer = site.Producer.Text(doc)
	meta.Sample = site.Sample.Link(doc)
	meta.Series = site.Series.Text(doc)
	meta.Release = site.Release.Text(doc)
	meta.Duration = site.Duration.Text(doc)
	meta.Id = site.Id.Text(doc)
	meta.Label = site.Label.Text(doc)
	meta.Genre = site.Genre.Texts(doc)
	meta.Images = site.Images.Images(doc)

可以看到此时 tidy 已经具备了基本的爬虫框架能力,即只需添加新的站点清单,执行代码就可以获取到新增加站点的信息。

当前是通过在单元测试代码中打印 meta 输出来检索信息的,例如:

func TestScrape(t *testing.T) {
	meta := Scrape(sites.Dmm("ssxx678"))

	fmt.Println(meta.Json())
}

下一步继续增加站点,看在版本不断升级的过程中会遇到什么问题,并不断完善和改进。