从零开始的 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
库,爬取新闻时有的网站使用了 PhantomJS
和 headless browser
爬取内容。
从开发效率角度看,Python
虽然很高效,但是需要集成 Python3
的环境,即使搭配 Docker
使用也不是很方便。最近一个月使用 Python
写了一组提取各个站点资源信息并整理资源到相应目录的脚本,各个脚本文件放到 ~/bin
目录后直接运行,虽然很方面实用,但是没有成系统的架构和统一的程序入口,迁移发布也不是很方便,而且每个脚本重复的代码太多,维护起来效率不高。
因为 Go
可以很方便的编译和发布,为了达成统一入口和方便发布的目的,这次的爬虫转入到 Go
编写爬虫。
从零开始,高效的 Go 开发环境
得益于之前的开源应用,我已经连续两年获取到了 IntelliJ IDEA
的免费开源许可,可以无限制的在开源项目中使用 IntelliJ
的全家桶,即全部平台 IDE
,毫无疑问选择了 GoLand
开发环境。因为平时也会运行一些 Go
代码编译,所以本地已经有了 Go
环境,下载 GoLand linux zip
包以后,直接解压运行 bin/goland.sh
就可以使用了。
我之前没有写过 Go
程序,只写了 Go
的 hello 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-grp
的 CSS 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")
即可获取到当前 selector
的 href
属性值。
此时在 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())
}
下一步继续增加站点,看在版本不断升级的过程中会遇到什么问题,并不断完善和改进。
从零开始的 Go 爬虫框架编程实战 - 上篇