起因是朋友月底要离职了,然后待的公司在系统里还有一些资料,想下载下来。手动一条一条下载太麻烦了,所以就找我帮忙看能不能用更科学的方法下载这些资料。
其实说是爬虫,不如说是一个自动采集下载的工具,其中,并没有实现分析网页源数据的功能,只是单纯的下载“公开”的数据。
接口分析 首先来到登录界面,https://example.com/auth/login
,F12打开Chrome
控制台分析。
后台返回的数据是json
,那么说明网站架构差不多是前后端分离的。然后发现了网页内嵌的一些js
文件:
Manifest.js
Vendor.js
Common.js
这个熟悉 的感觉,后台应该是laravel
, 那么前端很大可能就是Vue
了(是啥也不重要)。
用户授权方式 laravel
有很多种用户的认证方式,比如基于Oauth2
的Passport
,基于Api
的Sanctum
,这里直接用Postman
请求登录 Api,获取到了token
,看起来应该就是使用的jwt-auth
插件了,使用基于jwt
的token
认证。
一些API 登录后进入首页,直接从控制台查看网络请求,分析其中的接口,发现如下:
框架 编写这么一个类似的应用,最重要的应该就是框架了,其中,框架应该有的东西有:
队列
内容分析
其他等
这个应该根据实际的需求添加,比如经典的爬虫,可以使用python
的scrapy
编写。它的功能很全面。
这里用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 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" , } 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" } p.node[field.Id] = field.Name parentId, _ := strconv.Atoi(field.ParentId) p.relation[field.Id] = parentId } func (p *Path) Get (field *Field) *Field { var chain []string var buffer bytes.Buffer var queryId int queryId = field.Id for p.relation[queryId] != 0 { 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 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) 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) { queue.Put(v) } fmt.Printf("初始入口数据 %d 条 \n" , queue.Size()) for queue.Size() != 0 { field := queue.Get() if field.Type == "" { continue } path.Set(field) if field.Type == "dir" { for _, v := range getField(field, config) { queue.Put(v) } fmt.Printf("队列增加,目前有 %d 个field \n" , queue.Size()) } else { fmt.Printf("队列下载,目前有 %d 个field \n" , queue.Size()) downloadField(path.Get(field), config) } }
其他没介绍的方法,大致都跟field请求类似,都可以自己实现。没有给出完整代码只是因为意义不大,毕竟不大可能有同样的需求。授人以鱼不如授人以渔。
扩展尝试:多线程处理队列任务。