基于go语言编写的简单api爬虫

基于go语言编写的简单api爬虫

起因是朋友月底要离职了,然后待的公司在系统里还有一些资料,想下载下来。手动一条一条下载太麻烦了,所以就找我帮忙看能不能用更科学的方法下载这些资料。

其实说是爬虫,不如说是一个自动采集下载的工具,其中,并没有实现分析网页源数据的功能,只是单纯的下载“公开”的数据。

接口分析

首先来到登录界面,https://example.com/auth/loginF12打开Chrome 控制台分析。

image-20200522021914274

后台返回的数据是json,那么说明网站架构差不多是前后端分离的。然后发现了网页内嵌的一些js文件:

  • Manifest.js
  • Vendor.js
  • Common.js

image-20200522022335498

这个熟悉的感觉,后台应该是laravel, 那么前端很大可能就是Vue了(是啥也不重要)。

用户授权方式

laravel 有很多种用户的认证方式,比如基于Oauth2Passport,基于ApiSanctum,这里直接用Postman请求登录 Api,获取到了token,看起来应该就是使用的jwt-auth插件了,使用基于jwttoken认证。

一些API

登录后进入首页,直接从控制台查看网络请求,分析其中的接口,发现如下:

框架

编写这么一个类似的应用,最重要的应该就是框架了,其中,框架应该有的东西有:

  1. 队列
  2. 内容分析
  3. 其他等

这个应该根据实际的需求添加,比如经典的爬虫,可以使用pythonscrapy编写。它的功能很全面。

这里用go来写的原因只是因为对python不太感冒,然后这个简单的东西用scrapy不合(hui)适(yong)。

数据结构

看看请求返回的内容结构:

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
{
"stat": "succ",
"data": {
"current_page": 1,
"data": [
{
"id": 1032,
"size": "0",
"updated_at": "2019-09-19 11:15:46",
"name": "通用文档",
"ftype": "dir",
"osspath": ""
},
{
// ...
}
],
"first_page_url": "https://example.com/api/visible_list?page=1",
"from": 1,
"last_page": 1,
"last_page_url": "https://example.com/api/visible_list?page=1",
"next_page_url": null,
"path": "https://example.com/api/visible_list",
"per_page": "10",
"prev_page_url": null,
"to": 10,
"total": 10
}
}

大致结构都是一样的,都是使用的laravel的分页功能,所以我们可以简化所有的文件夹或者文件为一个field,它的结构是这样的。

1
2
3
4
5
6
7
8
9
type Field struct {
Id int `json:"id"`
Name string `json:"name"`
Type string `json:"ftype"`
ParentId string `json:"parent_id"`
Size string `json:"size"`
OssPath string `json:"osspath"`
Path string `json:"path"`
}

顶级的文件夹是没有ParentId属性的,这里使用json反序列化,没有的数据会自动填充为零值。

队列

既然所有的文件夹或者文件都是一个类似的field,那么我们可以设计一个队列,将首页获得的field存入队列,然后根据field类型进行对应操作,类型为文件夹的,通过文件夹Api取得这个文件夹下面的field并添加到队列的尾部,如果类型为文件,那么下载这个文件。

队列的实现方法

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
// 队列
type Queue struct {
items []Field
}

func (s *Queue) New() *Queue {
s.items = []Field{}
return s
}

func (s *Queue) Put(t Field) {
s.items = append(s.items, t)//插入数据到队列尾部
}

func (s *Queue) Get() *Field {
item := s.items[0] //先进先出
s.items = s.items[1:]
return &item
}

func (s *Queue) IsEmpty() bool {
return len(s.items) == 0//队列是否为空
}

func (s *Queue) Size() int {
return len(s.items)//队列长度
}

路径处理

我们知道,所有的文件夹或者文件都是一个个field,那么,我们需要处理对应的路径关系,毕竟不能将所有有层级关系的资料都下载到同一个目录,这会给查找资料带来大麻烦。这里之前的ParentId派上用场了。

结构

我们需要将所有的field的父节点记录下来,将该节点的名称记录下来。所有我们有了如下结构。

1
2
3
4
type Path struct {
node map[int]string //节点 当前节点id 对应信息,可修改为interface{}复杂类型,这里只需要name选项,则string可满足
relation map[int]int //关系 当前节点的父节点
}

初始化方法

因为我们的结构体内使用了map类型,所以我们需要初始化一下,否则直接设置值会导致nil的属性无法被赋值。

1
2
3
4
5
6
7
func (p *Path) New() *Path {
p.node = map[int]string{
-1: "download", //设置一个 -1 的节点信息,后面将所有内容都保存在这个目录里
}
p.relation = map[int]int{}
return p
}

所有方法

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
type Path struct {
node map[int]string
relation map[int]int
}

func (p *Path) New() *Path {
p.node = map[int]string{
-1: "download",
}
p.relation = map[int]int{}
return p
}

func (p *Path) Set(field *Field) {//将信息存入路径处理块内
if field.ParentId == "" {
field.ParentId = "-1"//将顶级目录加入父节点 -1
}

p.node[field.Id] = field.Name
parentId, _ := strconv.Atoi(field.ParentId)//转换string为int类型
p.relation[field.Id] = parentId
}

func (p *Path) Get(field *Field) *Field {//传入field的指针,返回field指针
var chain []string
var buffer bytes.Buffer
var queryId int

queryId = field.Id
for p.relation[queryId] != 0 {//根据已存入的信息查询当前field的所有关联节点
queryId = p.relation[queryId]
chain = append(chain, p.node[queryId])
}
for i := len(chain) - 1; i > -1; i-- {//根据逆向查询的链式节点合成为路径
buffer.WriteString(chain[i] + "/")
}
field.Path = buffer.String()
return field
}

请求封装

因为大部分请求都类似,所以我们可以进行一个简单的封装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func Fetch(config *Config, url string, auth bool) *http.Response {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
panic(err)
}
if auth {//设置授权头,如果请求涉及到第三方网站,比如下载,这里不设置,避免出错
req.Header.Set("Authorization", config.auth)
}
req.Header.Set("User-Agent", config.userAgent)
req.Header.Set("Referer", config.referer)

resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
return resp
}

Field获取

通过文件夹id获取该文件下的内容:

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
//根据响应内容格式编写对应的结构体,方便json反序列化
type FieldResponseData struct {
Stat string `json:"stat"`
Data struct {
Data []Field `json:"data"`
} `json:"data"`
}

func getField(field *Field, config *Config) []Field {
baseUrl := "https://example.com/api/listdir?page=1&per_page=100&showcenter=1&dirid="
resp := Fetch(config, baseUrl+strconv.Itoa(field.Id), true)
defer resp.Body.Close()

if resp.StatusCode != 200 {
fmt.Println(baseUrl+strconv.Itoa(field.Id), " 请求出错 ", resp.StatusCode)
return nil
}

fetch, _ := ioutil.ReadAll(resp.Body)//读取body的内容
FieldData := &FieldResponseData{}
if err := json.Unmarshal(fetch, FieldData); err != nil {
panic(err)
}
return FieldData.Data.Data
}

这里值得注意的一点是,请求的内容体比较小,使用ioutil.ReadAll()读取没什么问题,但是当下载大文件的时候,这里使用这个方法会占用大量的内存,因为,这个方法会将所有的数据读取到内存中,读取完毕后再一次性写入文件。 请求的内容体很大的时候,比如下载大文件,这里应当使用io.copy方法。这个方法会使用默认的32k缓存区,持续的将流复制到文件。

队列循环任务

队列应当怎么开始任务,怎样才算结束任务,怎么循环处理。

我们可以大致使用下面的方法来循环处理队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for _, v := range getStart(config) {//先请求顶级目录数据,将field插入队列
queue.Put(v)
}
fmt.Printf("初始入口数据 %d 条 \n", queue.Size())

for queue.Size() != 0 {//当队列不为空,循环处理
field := queue.Get() //队列顶部取出任务

if field.Type == "" {
continue //可能存在空文件夹,直接跳过
}

path.Set(field) //将field设置到路径地图
if field.Type == "dir" { // 文件夹 再请求field
for _, v := range getField(field, config) {//将获得的field插入队列尾部
queue.Put(v)
}
fmt.Printf("队列增加,目前有 %d 个field \n", queue.Size())
} else {//下载field
fmt.Printf("队列下载,目前有 %d 个field \n", queue.Size())
downloadField(path.Get(field), config)
}
}

其他没介绍的方法,大致都跟field请求类似,都可以自己实现。没有给出完整代码只是因为意义不大,毕竟不大可能有同样的需求。授人以鱼不如授人以渔。

扩展尝试:多线程处理队列任务。

评论


:D 一言句子获取中...