// 一个任务实例,
type Task struct {
Name string // 用户界面显示的名称(应保证唯一性)
Url string
Cookie string
WaitTime time.Duration
Reload bool // 网站是否可以重复爬取
MaxDepth int
Visited map[string]bool
VisitedLock sync.Mutex
Fetcher Fetcher
Rule RuleTree // 规则树
}
var DoubangroupTask = &collect.Task{
Name: "find\_douban\_sun\_room",
WaitTime: 1 \* time.Second,
MaxDepth: 5,
Cookie: "xxx",
Rule: collect.RuleTree{
Root: func() []\*collect.Request {
var roots []\*collect.Request
for i := 0; i < 25; i += 25 {
str := fmt.Sprintf("<https://www.douban.com/group/szsh/discussion?start=%d>", i)
roots = append(roots, &collect.Request{
Priority: 1,
Url: str,
Method: "GET",
RuleName: "解析网站URL",
})
}
return roots
},
Trunk: map[string]\*collect.Rule{
"解析网站URL": &collect.Rule{ParseURL},
"解析阳台房": &collect.Rule{GetSunRoom},
},
},
}
当前任务规则包括了“解析网站 URL” 和“解析阳台房” 这两个规则,分别对应了处理函数 ParseURL 和 GetSunRoom,如下所示。
const urlListRe = `(<https://www.douban.com/group/topic/[0-9a-z]+/>)"[^>]\*>([^<]+)</a>`
const ContentRe = `<div class="topic-content">[\s\S]\*?阳台[\s\S]\*?<div class="aside">`
func ParseURL(ctx \*collect.Context) collect.ParseResult {
re := regexp.MustCompile(urlListRe)
matches := re.FindAllSubmatch(ctx.Body, -1)
result := collect.ParseResult{}
for \_, m := range matches {
u := string(m[1])
result.Requesrts = append(
result.Requesrts, &collect.Request{
Method: "GET",
Task: ctx.Req.Task,
Url: u,
Depth: ctx.Req.Depth + 1,
RuleName: "解析阳台房",
})
}
return result
}
func GetSunRoom(ctx \*collect.Context) collect.ParseResult {
re := regexp.MustCompile(ContentRe)
ok := re.Match(ctx.Body)
if !ok {
return collect.ParseResult{
Items: []interface{}{},
}
}
result := collect.ParseResult{
Items: []interface{}{ctx.Req.Url},
}
return result
}
step2 初始化任务与规则
在 engine/schedule.go 文件中,init 函数中的 Store.Add 函数将任务加载到全局的任务队列中。在 Go 中,init 函数是一个特殊的函数,它会在 main 函数之前自动执行。注意,当添加的任务越来越多之后,代码会变得臃肿,这不是一种优雅的写法。后面我们还会优化它。
// engine/schedule.go
func init() {
Store.Add(doubangroup.DoubangroupTask)
}
func (c \*CrawlerStore) Add(task \*collect.Task) {
c.hash[task.Name] = task
c.list = append(c.list, task)
}
// 全局爬虫任务实例
var Store = &CrawlerStore{
list: []\*collect.Task{},
hash: map[string]\*collect.Task{},
}
type CrawlerStore struct {
list []\*collect.Task
hash map[string]\*collect.Task
}
step3 启动任务
启动爬虫任务的方式可以分为两种,一种是加载配置文件,另一种是在调用用户接口时,传递任务名称和参数。不过在这里我们先用硬编码的形式来实现。而通过配置文件和用户接口来操作任务的方式我们会有专门的课程来实现。
func main(){
...
seeds = append(seeds, &collect.Task{
Name: "find\_douban\_sun\_room",
Fetcher: f,
})
s := engine.NewEngine(
engine.WithFetcher(f),
engine.WithLogger(logger),
engine.WithWorkCount(5),
engine.WithSeeds(seeds),
engine.WithScheduler(engine.NewSchedule()),
)
s.Run()
}
step4 加载任务
在调度器启动时,通过 task.Rule.Root() 获取初始化任务,并加入到任务队列中。
func (e \*Crawler) Schedule() {
var reqs []\*collect.Request
for \_, seed := range e.Seeds {
task := Store.hash[seed.Name]
// 获取初始化任务
rootreqs := task.Rule.Root()
reqs = append(reqs, rootreqs...)
}
go e.scheduler.Schedule()
go e.scheduler.Push(reqs...)
}
在 Worker 处理请求时,需要从 Rule.Trunk 中获取当前请求的解析规则,并将内容和请求包装到 Context 中,调用 ParseFunc 对内容进行解析。
func (s \*Crawler) CreateWork() {
for {
...
//获取当前任务对应的规则
rule := req.Task.Rule.Trunk[req.RuleName]
// 内容解析
result := rule.ParseFunc(&collect.Context{
body,
req,
})
// 新的任务加入队列中
if len(result.Requesrts) > 0 {
go s.scheduler.Push(result.Requesrts...)
}
}
}
动态规则引擎
像 Javascript、Python、Lua 这样的动态语言与 Go 有显著不同,它们不需要提前进行编译,能够比较灵活地书写并执行动态的规则。这一功能依赖于一种语言解释器,这种解释器一般是静态的语言编写的,例如 C/C++,解释器会解析这些动态语言的语法,然后执行相应规则。
一家人工智能企业的核心产品之一是对视频流进行人脸识别。完成视频中人脸的解析涉及到视频的解码、 人脸的识别、人脸的矫正、特征的提取等多个阶段。这整个过程被称为一个 pipeline。pipeline 中的每一个阶段可能是串行的也可能是并行的。在过去,人脸、人群、物体的识别都需要单独来开发,这样开发的周期比较长,也缺乏灵活性。
为了应对未来灵活多变的检测需求,例如监测人是否摔倒,工人是否佩戴安全帽等,我们需要更短的开发周期,需要用更灵活的方式把这些阶段串联起来。这时这家公司就在 Go 中使用了 Lua 虚拟机。开发者遇到一个新的长尾需求时,通过书写 Lua 脚本来定义新的规则。在 Go 程序中通过动态加载 Lua 脚本来实现灵活性。
我们现在的爬虫项目其实也面邻着一样的问题。网站和规则是多种多样的,我们无法穷尽所有的规则,如果每次遇到新网站都要重新写代码,写爬取规则然后重启程序,这会比较繁琐,所以我们希望能够动态地在程序运行过程中加载规则。
动态规则带来的另一个好处是,降低了书写代码规则的门槛,它甚至可以让业务人员也能书写简单的规则。说到在爬虫项目中实现动态规则的引擎,我们首先想到的就是使用 Javascript 虚拟机了。因为使用 JS 操作网页有天然的优势。
自己要在短时间内实现一个工业级的虚拟机可能比较困难,我们可以使用一些开源的项目,例如otto。otto 是用 Go 编写的 JavaScript 虚拟机,用于在 Go 中执行 Javascript 语法。
下面是用 otto 编写的一个简单的例子。在这个例子中,script 字符串即为要执行的 Javascript 语法,console.log 是 JS 中的函数,用于打印变量。
package main
import (
"fmt"
"github.com/robertkrimen/otto"
)
func main() {
vm := otto.New()
script := `
var n = 100;
console.log("hello-" + n);
n;
`
value, \_ := vm.Run(script)
fmt.Println("value:", value.String())
}
// output
hello-100
value: 100
这样,我们就实现了在 Go 语言中执行 JS 脚本的目的。实际上,otto 内部解析了这一串字符串,并按照 JS 语法中对应的规则进行了相应的处理,例如脚本中的 console.log 函数最终其实也调用了 Go 中的 fmt 函数,实现将文本打印到控制台。不过我们要注意的是,不一定 JS 的所有语法 JS 虚拟机都是支持的,是否支持取决于当前虚拟机的实现。例如当前 otto 支持 JS5 语法,但是不支持 JS6 语法。
step1 构建动态规则模型TaskModle
type (
TaskModle struct {
Name string `json:"name"` // 任务名称,应保证唯一性
Url string `json:"url"`
Cookie string `json:"cookie"`
WaitTime time.Duration `json:"wait\_time"`
Reload bool `json:"reload"` // 网站是否可以重复爬取
MaxDepth int64 `json:"max\_depth"`
Root string `json:"root\_script"`
Rules []RuleModle `json:"rule"`
}
RuleModle struct {
Name string `json:"name"`
ParseFunc string `json:"parse\_script"`
}
)
为什么这里要单独构建一个任务的结构体呢?主要原因在于,现在我们的规则都是字符串了,这和之前的静态规则引擎有本质的不同。其中,TaskModle.Root 为初始化种子节点的 JS 脚本,TaskModle.Rules 为具体爬虫任务的规则树。
step2 动态爬虫规则
示例代码如下。其中,Root 脚本就是我们要生成的种子网站 URL。在这里我们构建了一个 JS 数组 arr,将生成的请求数组添加到 arr 之后,又调用了 AddJsReq 函数。AddJsReq 函数其实是一个 Go 函数,用于最终生成 Go 中的请求数组。在这里我们可以看到,在 Go 的 JS 虚拟机中,还可以灵活地调用原生的 Go 函数。
var DoubangroupJSTask = &collect.TaskModle{
Property: collect.Property{
Name: "js\_find\_douban\_sun\_room",
WaitTime: 1 \* time.Second,
MaxDepth: 5,
Cookie: "xxx",
},
Root: `
var arr = new Array();
for (var i = 25; i <= 25; i+=25) {
var obj = {
Url: "<https://www.douban.com/group/szsh/discussion?start=>" + i,
Priority: 1,
RuleName: "解析网站URL",
Method: "GET",
};
arr.push(obj);
};
console.log(arr[0].Url);
AddJsReq(arr);
`,
Rules: []collect.RuleModle{
{
Name: "解析网站URL",
ParseFunc: `
ctx.ParseJSReg("解析阳台房","(<https://www.douban.com/group/topic/[0-9a-z]+/>)\\"[^>]\*>([^<]+)</a>");
`,
},
{
Name: "解析阳台房",
ParseFunc: `
//console.log("parse output");
ctx.OutputJS("<div class=\\"topic-content\\">[\\\\s\\\\S]\*?阳台[\\\\s\\\\S]\*?<div class=\\"aside\\">");
`,
},
},
}
而在 Rules 脚本中,我们加入了两个爬虫规则,分别是“解析网站 URL”和 “解析阳台房”,他们都可以使用非常简单的规则来实现。在这里我们调用了 ctx.ParseJSReg 来解析请求,调用了 ctx.OutputJS 来解析并输出找到的内容,注意这里的 ctx.ParseJSReg 与 ctx.OutputJS 也是 Go 原生的函数,下面我们会看到他们的实现。
step3 动态规则中的 Go 函数。
AddJsReqs 函数将在 JS 脚本中的请求数据变为 Go 结构中的数组[]*collect.Request,而 ctx.ParseJSReg 方法则会动态解析 JS 中传递的正则表达式并生成新的请求, ctx.OutputJS 负责解析传递过来的正则表达式并完成结果的输出。注意 JS 虚拟机会自动将 JS 脚本中的参数转换为函数参数中对应的结构。
// 用于动态规则添加请求。
func AddJsReqs(jreqs []map[string]interface{}) []\*collect.Request {
reqs := make([]\*collect.Request, 0)
for \_, jreq := range jreqs {
req := &collect.Request{}
u, ok := jreq["Url"].(string)
if !ok {
return nil
}
req.Url = u
req.RuleName, \_ = jreq["RuleName"].(string)
### 最后
> **🍅 硬核资料**:关注即可领取PPT模板、简历模板、行业经典书籍PDF。
> **🍅 技术互助**:技术群大佬指点迷津,你的问题可能不是问题,求资源在群里喊一声。
> **🍅 面试题库**:由技术群里的小伙伴们共同投稿,热乎的大厂面试真题,持续更新中。
> **🍅 知识体系**:含编程语言、算法、大数据生态圈组件(Mysql、Hive、Spark、Flink)、数据仓库、Python、前端等等。