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

发布于:

编程

目录

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

10. 多个站点信息合并

在抓取网页时,单一的网页上获取到的内容有限,有时需要在多个网页上获取信息后,综合返回站点信息。当前的数据结构不具备这种能力。为了实现这个能力,需要对 Site 结构体进行升级,增加链表的支持。

type Site struct {
	Url       string
	...
	Next *Site
}

同时,获取站点信息的 Meta() 函数增加获取子站点信息的调用,并将子站点信息传递给父站点。

func (site Site) Meta() Meta {
	...
	var next = Meta{}
	if site.Next != nil {
		next = site.Next.Meta()
	}
    
	meta.Title = combine(site.Title.Value(doc), next.Title)
	meta.Actor = combine(site.Actor.Value(doc), next.Actor)
	meta.Poster = combine(site.Poster.Value(doc), next.Poster)
	...
	return meta
}

此时站点配置清单内容如下:

func Mgs(id string) Site {

	mobile := Site{
		Url:       fmt.Sprintf("https://sp.mgstage.com/product/product_detail/SP-%s/", id),
		UserAgent: MobileUserAgent,
		Cookies:   []http.Cookie{{Name: "adc", Value: "1"}},

		CssSelector: CssSelector{
			Title:  Selector(".sample-image-wrap.h1 > img").Attribute("alt"),
			...
		},
	}

	return Site{
		Url:       fmt.Sprintf("https://www.mgstage.com/product/product_detail/%s/", id),
		UserAgent: UserAgent,
		Cookies:   mobile.Cookies,
		CssSelector: CssSelector{
			...
		},
		Next: &mobile,
	}
}

完整内容参考 commit 修改记录。

11. 增加额外字段信息

再增加一个 heyzo.go 的站点配置清单,由于编码过程中需要对中间查找的 provider_id 属性进行保存,虽然最后没有用到,还是增加了配置清单中额外字段配置的支持。

首先在 Meta 结构体中增加一个 Extrasmap

type Meta struct {
    Id       string            `json:"id"`
	...
	Extras   map[string]string `json:"extras"`
}

然后在 Selector 结构体中增加一个 Extrasmap :

type CssSelector struct {
	Id       *Item
	...
	Extras   map[string]*Item
}

func (selectors CssSelector) AddExtra(key string, selector *Item) CssSelector {
	if selectors.Extras == nil {
		selectors.Extras = make(map[string]*Item)
	}
	selectors.Extras[key] = selector
	return selectors
}

其中 AddExtra() 函数使选择器支持连续的添加额外字段能力,例如使用如下方法添加:

	return Site{
		...
		CssSelector: CssSelector{
			Title:    Selector("h1"),
			...
		}.AddExtra(providerId, Selector("input[name=provider_id]").Attribute("value")),
    }

最后在站点抓取站点信息时,对 Extras 进行解析:

func (site Site) Meta() Meta {
	...
	// extract extras to meta
	if site.Extras != nil {
		meta.Extras = make(map[string]string)
		for key, value := range site.Extras {
			meta.Extras[key] = value.Value(doc)
		}

		for key, value := range next.Extras {
			meta.Extras[key] = value
		}
	} else {
		meta.Extras = next.Extras
	}

	return meta
}

完整内容请参考修改 commit 记录。

12. 增加正则表达式匹配查找支持

之后又增加了 fantia.go 站点配置清单,已可以直接支持。又增加 getchu.go 站点配置清单时,由于时间直接存放在大块的 body 文字中,无法通过 Css Selector获取到时间信息。而时间又有相对固定的格式如 2017/02/06,这样结构的字符串很容易通过正则表达式获取到,所以再一次对 Selector 扩展,增加了正则表达式匹配查找的支持。

首先 Selector -> Item 结构体增加正则配置函数及字段:

type Item struct {
	...
	matcher   string
}

func (selector Item) Match(matcher string) *Item {
	selector.matcher = matcher
	return &selector
}

然后实现正则匹配的能力:

func (selector Item) matcherValue(doc *goquery.Document) string {
	re := regexp.MustCompile(selector.matcher)
	text := doc.Text()

	matches := re.FindAllString(text, -1)
	if len(matches) > 0 {
		return matches[0]
	}
	return ""
}

func (selector *Item) Value(doc *goquery.Document) string {
	...

	if len(selector.matcher) > 0 {
		return selector.matcherValue(doc)
	}
	...
}

最后,增加站点配置清单:

func Getchu(id string) Site {
	return Site{
		...

		CssSelector: CssSelector{
			...
			Release:  Selector("~").Match(`\d{4}/\d{2}/\d{2}`),
			Duration: Selector("~").Match(`動画.*分`),
			Id:       Selector("input[name=id]").Attribute("value"),
			...
		},
	}
}

这里 Match() 函数即实现了正则匹配的配置,这是对 Selector 选择器的又一次升级,之后还将继续优化

完整内容请查看 commit 记录。

增加了正则匹配之后,又增加了一个 tokyo.go 站点配置清单,爬虫框架可以轻松适用。

13. 增加 Json 站点解析能力

个别的网站如 1pondo 使用了完全的 Json 渲染,内容都是在浏览器 xhr 请求服务器 API 后动态加载的。一般来说这是最简单的爬虫爬取接口了,但是实际编写这个爬虫时,增加 Json 的解析确是最困难的部分。

爬虫的框架整体是基于 CSS Selector 实现的,它是不能支持文本内容检索的。所以类似正则表达式匹配,需要为 Json 格式解析另辟蹊径。

首先,在 Selector -> Item 结构体中增加匹配 json 节点的 query 字段及配置函数 Query()

type Item struct {
	selector  string
	attribute string
	replacer  *strings.Replacer
	preset    string
	presets   []string
	matcher   string // regex
	query     string // json
}

func Query(query string) *Item {
	return &Item{query: query}
}

Site 结构体中增加解析存储,这里使用了 interface{} 类型作为基本类型,方便之后通过 parseJson() 函数解析:

type Site struct {
	...
	Json      bool
	JsonData  interface{}
    ....
}

func (site Site) parseJson() Meta {
	var meta = Meta{}
	body, err := ioutil.ReadAll(site.Body())
	err = json.Unmarshal(body, &site.JsonData)
	if err != nil {
		log.Fatal(err)
	}

	data := make(map[string]interface{})
	m, ok := site.JsonData.(map[string]interface{})
	if ok {
		for k, v := range m {
			//fmt.Println(k, "=>", v)
			data[k] = v
		}
	}

	next := Meta{}
	if site.Next != nil {
		next = site.Next.Meta()
	}

	// extract meta data from json data
	meta.Title = oneOf(site.Title.Query(data), next.Title)
	...

	return meta
}

然后实现解析 Json 的能力,下面代码中 Query() 函数是实现从 Site.JsonData 中提取单个字段值,而 Queries() 则为提取数组值。其中的关键函数或较难理解的函数为 queries() 函数,将解析到的值转为 []string 后返回:

// get item
func (selector *Item) Query(data map[string]interface{}) string {
	if selector == nil {
		return ""
	}
	if len(selector.preset) > 0 {
		return selector.preset
	}
	return query(data, selector.query)
}

func query(data map[string]interface{}, key string) string {
	value := data[key]

	if value != nil {
		return fmt.Sprintf("%v", value)
	}
	return ""
}

// get items array
func (selector *Item) Queries(data map[string]interface{}) []string {

	if selector == nil {
		return []string{}
	}

	if selector.presets != nil {
		return selector.presets
	}

	return queries(data, selector.query)
}

func queries(data map[string]interface{}, key string) []string {
	var res []string
	x := data[key]
	if x != nil {
		// if json object is not slice then ignore
		if reflect.ValueOf(x).Kind() == reflect.Slice {
			array := x.([]interface{})
			for _, v := range array {
				value := fmt.Sprintf("%v", v)
				res = append(res, value)
			}
		}
	}
	return res
}

目前这段解析代码还不是很完美,只能支持单一层级的 Json 字符串,如果层级较深,则不能解析出来,以后应该还会根据需求优化。

最后,添加站点配置清单即可从 Json 数据中获取到站点信息。完整代码请参考 commit 记录。

14. 增加装饰器,支持配置清单自定义修改

在编写 pondo.go 清单过程中,因为解析到的 Image 字段是一个不定的结构类型,可能有两种字段 ImgFileName,以及有一个 Protected 类型表示是否可以公开访问:

type PondoImage struct {
	Img       string
	Filename  string
	Protected bool
}

需要对这个字段做特殊处理,而不能简单的依赖爬虫框架库来实现解析逻辑,框架里实现这个解析可能会较为复杂。

Golang 不是完全面向对象的编程语言,不能简单的通过继承和重写 (override) 来在父类中调用子类方法,需要使用接口实现类似回调的方式。

首先在 Site 结构体中增加 Docor 接口。

type Site struct {
    ...
	Decor     Decor
    ...
}

type Decor interface {
	Decorate(meta *Meta) *Meta
}

func (site Site) Decorate(meta *Meta) *Meta {
	return &site.meta
}

然后在 podon.go 中实现装饰器并传递给 site

func Pondo(id string) Site {
	next := Site{
		...
		Json:      true,
		Decor:     PondoDecor{},

		Selector: Selector{
			Images: Query("Rows"),
		},
	}

	site := Site{
		...
		Next: &next,
	}

	return site
}


type PondoDecor struct {
	Decor
}

type PondoImage struct {
	Img       string
	Filename  string
	Protected bool
}

func (decor PondoDecor) Decorate(meta *Meta) *Meta {
	origin := meta.Images

	if len(origin) > 0 {
		var images []string
		var pondo PondoImage
		for _, s := range origin {
			err := json.Unmarshal([]byte(s), &pondo)
			if err != nil {
				log.Fatal(err)
			}
			if !pondo.Protected {
				img := pondo.Img
				if len(img) == 0 {
					img = pondo.Filename
				}
				images = append(images, "https://www.1pondo.tv/dyn/dla/images/"+img)
			}
		}
		meta.Images = images
	}
	return meta
}

最后,修改 Site.Meta() 函数,增加装饰器调用:

func (site Site) Meta() Meta {
	...
	if site.Decor != nil {
		return *site.Decor.Decorate(&site.meta)
	} else {
		return site.meta
	}
}

之后,每次爬虫框架在运行完后会执行站点配置清单的装饰器函数来修改元数据。完整的代码请参考 commit 记录。

结语

至此,一个完整的站点元数据爬虫框架就完成了,要成为一个完整的应用,还需要增加文件整理,上传下载等类似我之前写的 Dmaster 程序功能,这些能力将会继续更新。要获取到最新的更新和完整的工程代码,请关注仓库:

https://github.com/ruriio/tidy

TIL - 学到的小知识点

通过这周末两天的实战,学习到了以下几个零碎的小知识点:

  • Go 中方法和变量名称首字母大小写控制了访问权,大写表示公有,小写私有。
  • Go 不是面向对象的编程语言,没有子类函数继承和覆盖重写,父类里调用子类的实现函数,需要通过接口”绕路”实现。
  • CSS 选择器可以使用逗号( , ) 并联查询;可以使用 .classA.classB 精确匹配有多个 class的元素如 <div class="classA classB">text</div>