katana源码分析

个人对该项目比较感兴趣,争取做到时刻保持关注。因为自己也用go实现了类似的爬虫功能,但是一对比就有点相形见绌,故可以从中学到很多东西。目前本文基于katana-0.0.1(2022-11.7)进行分析,同时会关注后续版本。

参数解析

参数解析,常用/必备参数已加粗

  • input:
    • -u:指定爬取url,支持string,string[]
  • configuration:基本配置
    • -d:指定爬取深度,从0开始,默认是2
    • -jc:解析js和css标签,经测试默认解析了
    • -ct:指定抓取时间,单位为s
    • -kf:指定是否抓取robots.txt或sitemap.xml文件中的链接
    • -mrs:解析的最大响应数据大小,默认为mb
    • -timeout:超时时间
    • -aff:启用可选表单自动填写,暂不清楚有什么作用
    • -retry:请求重试次数:默认为1
    • -proxy:设置代理,支持http和socks5
    • -H:添加自定义请求头,格式为string[]
    • -config:指定配置文件
    • -fc:指定自定义表单配置文件
  • headless:无头浏览器模式,linux暂时有点问题(Running as root without –no-sandbox is not supported.)
    • -hl:
    • -sc:
    • sb
  • scope:根据某种规则指定要爬取的url的范围
    • -cs:根据正则匹配范围内的url
    • -cos:根据正则匹配范围外的url
    • -fs:预定义范围字段,默认为”rdn”,(dn,rdn,fqdn)
    • -ns:禁用基于主机的默认作用域(暂不不知道作用)
    • -do:显示作用域内爬取的外部端点
  • filter:
    • -f:定义在输出中展示的字段(url,path,fqdn,rdn,rurl,qurl,qpath,file,key,value,kv,dir,udir)
    • -sf:定义在存储是保存的字段(url,path,fqdn,rdn,rurl,qurl,qpath,file,key,value,kv,dir,udir)
    • -em:根据扩展名进行匹配,如php,html,js,理解为include
    • -ef:根据扩展名过滤输出,如png, css,理解为exclude
  • rate-limit:
    • -c:并发数,默认为10
    • -p:并行数,默认为10
    • -rd:每个请求之间的请求延迟
    • -rl:每秒最大请求数,默认为150
    • -rlm:每分钟发送的最大请求数
  • output:
    • -o:结果写入到指定文件
    • -j:以json格式写入
    • -nc:禁用输出内容着色
    • -silent:仅显示输出
    • -v:显示详细输出
    • -version:显示项目版本号

      目录文件说明

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      44
      45
      46
      47
      48
      49
      50
      51
      52
      53
      54
      55
      56
      57
      58
      59
      60
      61
      62
      63
      64
      65
      66
      67
      68
      69
      70
      71
      72
      73
      74
      75
      76
      77
      78
      79
      80
      81
      82
      83
      84
      85
      86
      ├─cmd
      │ ├─katana
      │ │ main.go
      │ │
      │ └─tools
      │ └─crawl-maze-score
      │ main.go

      ├─internal
      │ └─runner
      │ banner.go 启动时展示的自定义信息
      │ executer.go 根据并行度(parallelism)调用核心crawl
      │ options.go 解析命令行参数,进行相应的配置
      │ runner.go 最外层运行结构体Runner,包含属性crawlerOptions, stdin, crawler(核心), options

      └─pkg
      ├─engine
      │ │ engine.go
      │ │
      │ ├─common
      │ │ http.go 封装httpclient,配置transport,timeout,以及checkRedirect(如果绑定了回调,会执行该回调函数)
      │ │
      │ ├─hybrid 两种模式之一(headless)
      │ │ crawl.go
      │ │ doc.go
      │ │ hijack.go
      │ │ hybrid.go
      │ │
      │ ├─parser
      │ │ │ parser.go
      │ │ │ parser_test.go
      │ │ │
      │ │ └─files
      │ │ request.go 抓取robots.txt或sitemap.xml的入口
      │ │ robotstxt.go
      │ │ robotstxt_test.go
      │ │ sitemapxml.go
      │ │ sitemapxml_test.go
      │ │
      │ └─standard 两种模式之一(standard)
      │ crawl.go 实际发起请求调用httpclient.Do的地方
      │ doc.go
      │ standard.go 核心Crawl

      ├─navigation
      │ request.go 处理请求url
      │ response.go 处理响应

      ├─output
      │ fields.go 输出字段格式处理
      │ fields_test.go
      │ file_writer.go 写文件
      │ format_json.go 处理成json格式
      │ format_screen.go 终端输出格式整理
      │ output.go write核心,定义了Writer接口

      ├─types
      │ crawler_options.go 定义crawler的一些配置,包括公用options
      │ options.go 定义公用options

      └─utils 工具'类'
      │ formfill.go
      │ formfill_test.go
      │ regex.go
      │ utils.go
      │ utils_test.go

      ├─extensions
      │ extensions.go
      │ extensions_test.go

      ├─filters
      │ filters.go
      │ filters_test.go
      │ simple.go

      ├─queue 广度优先(优先级队列[最小堆])和深度优先(栈[双向链表]),对应两种模式
      │ priority_queue.go 优先级队列实现 "container/heap"
      │ priority_queue_test.go
      │ queue.go 封装了两种模式,统一对外接口"VarietyQueue"
      │ stack.go 栈实现 "container/list"
      │ stack_test.go

      └─scope
      scope.go
      scope_test.go

      源码分析

      首先看下项目的依赖:
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      require (
      github.com/PuerkitoBio/goquery v1.8.0 // 页面解析库
      github.com/go-rod/rod v0.112.0 // 操作浏览器的工具
      github.com/json-iterator/go v1.1.12 // json解析器
      github.com/logrusorgru/aurora v2.0.3+incompatible // 终端输出着色
      github.com/lukasbob/srcset v0.0.0-20190730101422-86b742e617f3 // 解析HTML5 srcset
      github.com/pkg/errors v0.9.1 // 第三方异常处理包
      github.com/projectdiscovery/fastdialer v0.0.17 // 快速发起请求
      github.com/projectdiscovery/fileutil v0.0.3 // 文件处理工具
      github.com/projectdiscovery/goflags v0.1.3 // 命令行参数解决
      github.com/projectdiscovery/gologger v1.1.4 // 日志处理
      github.com/projectdiscovery/hmap v0.0.2-0.20210917080408-0fd7bd286bfa
      github.com/projectdiscovery/ratelimit v0.0.1 // 并发控制(时间间隔)
      github.com/projectdiscovery/retryablehttp-go v1.0.2 // 可自动重试的http client
      github.com/projectdiscovery/stringsutil v0.0.2 // 字符串处理
      github.com/remeh/sizedwaitgroup v1.0.0 // 并发控制(同一时间)
      github.com/rs/xid v1.4.0 // 生成唯一id
      github.com/shirou/gopsutil/v3 v3.22.10 // 跨平台进程和系统监控
      github.com/stretchr/testify v1.8.1 // 测试框架
      go.uber.org/multierr v1.8.0 // 多错误处理
      golang.org/x/net v0.1.0 // 补充go网络库
      gopkg.in/yaml.v3 v3.0.1 // 解析生成yaml数据
      )
      从最简单的命令./katana -u https://tesla.com -d 1开始入手,分析代码的运行流程。

      程序入口

      cmd/katana/main.go
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      var (
      cfgFile string // 配置文件
      options = &types.Options{} // 基本的设置
      )

      func main() {
      // 1. 读取命令行参数
      if err := readFlags(); err != nil {
      gologger.Fatal().Msgf("Could not read flags: %s\n", err)
      }

      // 2. 新建一个runner对象, 包含爬虫配置, 基本配置, 输入, 及爬虫核心
      runner, err := runner.New(options)
      if err != nil || runner == nil {
      gologger.Fatal().Msgf("could not create runner: %s\n", err)
      }
      defer runner.Close()

      // 3. 接收退出信号关闭整个channel
      // close handler
      go func() {
      c := make(chan os.Signal, 1)
      signal.Notify(c, os.Interrupt, syscall.SIGTERM)
      go func() {
      <-c
      gologger.DefaultLogger.Info().Msg("- Ctrl+C pressed in Terminal")
      runner.Close()
      os.Exit(0)
      }()
      }()

      // 4. 爬虫执行ExecuteCrawling
      if err := runner.ExecuteCrawling(); err != nil {
      gologger.Fatal().Msgf("could not execute crawling: %s", err)
      }

      }
      接下来看看runnerNewClose做了什么,在internal/runner/runner.go下:

      注:之后只会贴出核心逻辑,其他代码将会省去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 先熟悉Runner结构体
type Runner struct {
crawlerOptions *types.CrawlerOptions // 爬虫的相关设置, 如输出, 限速等
stdin bool // 是否有输入(具体作用待定)
crawler engine.Engine // 爬虫核心, 实现了Crawl(string) error和Close() error接口
options *types.Options // 基本的设置, 从命令行读取的参数都保存在该结构体对象中
}

func New(options *types.Options) (*Runner, error) {
...
// 1. 创建爬虫的相关设置对象
crawlerOptions, err := types.NewCrawlerOptions(options)
...
// 2. 选择不同的模式, 一种是Headless, 一种是standard标准模式
carwler, err = hybrid.New(crawlerOptions)
...
crawler, err = standard.New(crawlerOptions)
...
}

func (r *Runner) Close() error {
// TODO: 这里用到了多错误处理模块multierr, 之后在进一步学学
return multierr.Combine(
r.crawler.Close(),
r.crawlerOptions.Close(),
)
}

爬虫核心

接下来就到了核心部分,只要实现了文件pkg/engine/engine.go中的接口Engine就可以嵌入自己的爬虫,之后主要分析标准模式下的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Engine的实现如下, 所处文件位于pkg/engine/engine.go
type Engine interface {
Crawl(string) error
Close() error
}

// 统一外部调用的方法ExecuteCrawling实现如下, 所处文件位于internal/runner/executer.go
func (r *Runner) ExecuteCrawling() error {
// 1. 解析输入的url列表, 返回的数据类型为[]string
inputs := r.parseInputs()
...
// 2. 这里使用了并行参数Parallelism(-p)创建了几个调用Crawl方法的goroutine
wg := sizedwaitgroup.New(r.options.Parallelism)
for _, input := range inputs {
wg.Add()

go func(input string) {
defer wg.Done()
// 3. 调用具体的Crawl方法
r.crawler.Crawl(input)
}(input)
}
wg.wait()
...
}

现在来看看标准模式下的Crawl的具体实现,位于文件pkg/engine/standard/standard.go下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
func (c *Crawler) Crawl(rootURL string) error {
// 1. 解析根url
parsed, err := url.Parse(rootURL)
...
// 2. 创建队列, 这里根据配置文件中的设置广度优先还是深度优先
// 广度优先使用的优先级队列, 深度优先使用的是栈

// 这里返回的结构体VarietyQueue对象的指针
queue := queue.New(c.options.Options.Strategy)
// 将进一步封装的请求结构体navigation.Request放入到队列中
queue.Push(navigation.Request{Method: http.MethodGet, URL: rootURL, Depth: 0}, 0)
// 解析响应的回调函数, 会将新的url封装成navigation.Request后放入到队列
parseResponseCallback := c.makeParseResponseCallback(queue)

...
// 3. 若是指定了参数knownFiles(-kf), 会调用该方法
parseResponseCallback(nr)
...

// 4. 使用retryablehttp新建httpclient, 这里需注意的是传进去的参数func绑定到了httpclient的属性CheckRedirect下
// httpclient.Do会调用标准库下httpclient的Do方法, 进而调用CheckRedirect
httpclient, _, err := common.BuildClient(c.options.Dialer, c.options.Options, func(resp *http.Response, depth int) {
body, _ := io.ReadAll(resp.Body)
reader, _ := goquery.NewDocumentFromReader(bytes.NewReader(body))
// 解析响应, 这里分为三大类, 主要是响应头解析, 响应体解析及js的一些解析
// 然后将回调函数parseResponseCallback传进去, 这里深度+1
// 整体而言这部分设计较为巧妙, 之后可深入研究下
parser.ParseResponse(navigation.Response{Depth: depth + 1, Options: c.options, RootHostname: hostname, Resp: resp, Body: body, Reader: reader}, parseResponseCallback)
})
...

// 5. 消费队列里构造的消息(navigation.Request)
// 使用了sizedwaitgroup限制并发数, 具体值来自参数Concurrency(-c)
// TODO: 注意这里还使用了atomic来标识任务的执行状态,之后再看看
wg := sizedwaitgroup.New(c.options.Options.Concurrency)
...
for {
// 出队消息
item := queue.Pop()
req, ok := item.(navigation.Request)
...
wg.Add()
go func() {
defer wg.Done()
...
// 控制每秒最大并发数
c.options.RateLimit.Take()
...
// 实际发起http request的方法, 返回的是navigation.Response结构体对象
resp, err := c.makeRequest(ctx, req, hostname, req.Depth, httpclient)
...
// 对响应进行解析
parser.ParseResponse(resp, parseResponseCallback)
}()
}
}

最后再来看看发起请求makeRequest与解析响应ParseResponse都做了哪些事吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// makeRequest 位于文件pkg/engine/standard/crawl.go下
func (c *Crawler) makeRequest(ctx context.Context, request navigation.Request, rootHostname string, depth int, httpclient *retryablehttp.Client) (navigation.Response, error) {
...
// 1. 将当前深度传入到上下文中, 并封装request请求
ctx = context.WithValue(ctx, navigation.Depth{}, depth)
httpReq, err := http.NewRequestWithContext(ctx, request.Method, request.URL, nil)
...
// 2. 发起请求, 最终会调用标准库的Do方法
resp, err := httpclient.Do(req)
...
// 3. 读取响应得限制大小, 也可通过参数设置(-mrs)
limitReader := io.LimitReader(resp.Body, int64(c.options.Options.BodyReadSize))
data, err := io.ReadAll(limitReader)
...
return response, nil
}

// ParseResponse 位于文件pkg/engine/parser/parser.go
func ParseResponse(resp navigation.Response, callback func(navigation.Request)) {
// responseParsers存的是结构体responseParser, 包含两个属性
// 一个是解析类型parserType, 一个是解析函数parserFunc
// 注: 一开始注册的响应回调函数是在具体的parserFunc中执行的
for _, parser := range responseParsers {
switch {
case parser.parserType == headerParser && resp.Resp != nil:
parser.parserFunc(resp, callback)
case parser.parserType == bodyParser && resp.Reader != nil:
parser.parserFunc(resp, callback)
case parser.parserType == contentParser && len(resp.Body) > 0:
parser.parserFunc(resp, callback)
}
}
}

结果输出

写结果是在makeParseResponseCallback中调用的,正好上面只是一笔带过,下面详细分析下,该方法位于文件pkg/engine/standard/standard.go下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func (c *Crawler) makeParseResponseCallback(queue *queue.VarietyQueue) func(nr navigation.Request) {
return func(nr navigation.Request) {
...
// 1. 如果是空url或者重复的url直接return
if !c.options.UniqueFilter.UniqueURL(nr.RequestURL()) {return}
...
// 2. 对结果url进行验证过滤后写入到输出中, result为output.Result结构体对象
c.options.OutputWriter.Write(result)
...
// 3. 如果当前深度大于设置的最大深度或没在范围内则直接返回, 否则将其放入队列中
if nr.Depth >= c.options.Options.MaxDepth || !scopeValidated {
return
}
queue.Push(nr, nr.Depth)
}

}

总结

目前只是过了一下standard模式下程序的运行流程,之后再分析下headless下所做的工作,初次之外项目里还做了很多其他的操作,比如选择url范围,过滤,解析等等,要深入了解可以自己参照着实现一个,也是学习效率最快的方式了吧。顺便一提我个人运行headless模式时,报了个Running as root without --no-sandbox is not supported的错误,当时想着之后有时间再折腾折腾,没想到官方发布了0.0.2版本把这个问题修复了,不得不感叹效率之高。