有些工作内容,站在程序员的角度:写3天的代码,节省N天的工作量。不为什么,就喜欢省事的感觉。

春节前,博主的老婆的假期比博主更早开始,但是放假第二天她返回公司寻找产品设备,一问原由,才知道是项目设备离线,但是老婆公司的后台系统并没有相关的预警机制,未避免“文人相轻”的嫌疑,这里不对系统做评价。

作为老婆的工作,需要登录到系统后台中去查看各个项目的设备状态,然后根据她的公司运营规则去判断是否上报。 博主在查看老婆在后台系统中的一顿操作,寻思着,以从前爬教务处网站的成绩的经验,初步判断是可以通过代码来进行:

  • 自动登录网站
  • 爬取需要的数据
  • 导出数据

一提到爬虫,C#的童鞋说,C#写爬虫也只需要一句话的代码,主攻python的童鞋立马精神了,讲道,python只需要半句话。

img

——摘自某技术群讨论内容。

但是一想到是给老婆使用的,最好什么依赖也不要有,也不要去配置什么环境变量,就扔一个可执行文件,exe,一键导出即可。目光来到了Go,没错,就是它,支持跨平台交叉编译,直接将代码编程成二进制文件,不需要任何依赖。

python童鞋告诉我python能打包成exe,粗略看了下打包流程,还是觉得麻烦,不想折腾了。

说干就干,Let’s Go.

1.抓包

像所有的爬虫那样,都需要对我们爬取的目标进行一个分析。**抓包这个动作就必可少。**由于是网站请求,走http协议,所以这次选择的抓包工具是Fiddler,由于网站使用了加密传输的HTTPS,所以还需要对Fiddler进行设置,否则抓包查看也是加密的乱码。为了使其能够正常抓包,还需要对Fiddler进行一番设置。这里就不详述了,附上链接,参考图文介绍:https://www.jianshu.com/p/690eb9bebe3c

Fiddler抓包登录过程

网站需要登录,下面就是通过网站手动登录,并通过fiddler进行抓包的过程:

A域名下Post用户名、密码

  1. POST携带用户名、密码请求https://A.com/login

    状态码为302

    locationhttps://A.com/oauth/authorize?client_id=11E0ED550C5&redirect_uri=https://B.com/api/login&response_type=code&scope=READ&state=GKWzlE

看到这个location,熟悉OAuth2.0或者OpenId Connect的童鞋肯定暗暗窃喜,比如博主,这个我熟悉,这个就是走的 授权码流程。根据302进行重定向。

A域名下重定向授权端点、换取授权码

  1. GET请求https://A.com/oauth/authorize?client_id=11E0ED550C5&redirect_uri=https://B.com/api/login&response_type=code&scope=READ&state=GKWzlE,去换取授权码。

    状态码302

    locationhttps://B.com/api/login?code=4jl6Ud&state=GKWzlE

看吧,换来了授权码,继续302重定向

继续重定向至B域名

  1. GET请求https://B.com/api/login?code=4jl6Ud&state=GKWzlE,就这样切换了网站

    状态码302

    location为https://B.com/api//index

这里在请求中突然冒出来一个Cookie:SESSIONID=3c818c6d-97a1-407a-9512-9de604dde83d,根据开发经验这个SESSIONID有大用

后续还有几次重定向,但是基本上是以保持登录状态(Cookie)在B网站中进行重定向。

Cookie: SESSIONID=3c818c6d-97a1-407a-9512-9de604dde83d; proxyUser=null; acw_tc=2760827b16125811058633717e8fce8be9948b90272f2fb598d2313cae0ba2; Hm_lvt_8aa7b2541dc5c9cfc124ff1d86098aaa=1612579177,1612579299,1612579497,1612581149; Hm_lpvt_8aa7b2541dc5c9cfc124ff1d86098aaa=1612581149  

PS:后面Cookie中的Hm_lvt_都是百度统计需要的,这不属于我们关心的内容,作为干扰项不予处理。

2.尝试编码

经过一番搜索,试用,甄别,博主最终选择了colly这个包。

colly这个包非常强大,它的设计与使用也非常优雅,所有的处理逻辑,都采用的是事件注册的方式。

  • OnRequest 请求执行之前调用
  • OnResponse 响应返回之后调用
  • OnHTML 监听执行 selector
  • OnXML 监听执行 selector
  • OnHTMLDetach,取消监听,参数为 selector 字符串
  • OnXMLDetach,取消监听,参数为 selector 字符串
  • OnScraped,完成抓取后执行,完成所有工作后执行
  • OnError,错误回调

然后就是请求,博主这边主要使用:

  • Request():请求,由开发者指定GETPOST请求,URL等参数
  • Post(): Post请求,由开发者指定POST请求,及HTTP报文体Body

还有暂未使用的:

  • Visit():启动网页访问,博主本次没有使用这个方法。

2.1 安装colly包

博主这里使用的是最新的colly v2的版本,看了下,与原来的colly是同一个github Repository,放心使用。

go get -u github.com/gocolly/colly/v2

2.2 编码登录

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gocolly/colly/v2"
)
func PostLogin() (err error) {	
    c := colly.NewCollector(
        colly.UserAgent(sameUserAgent),
    )
    var requestData = make(map[string]string, 3)
    requestData["username"] = username
    requestData["password"] = password
    c.OnResponse(func(r *colly.Response) {
        fmt.Println("response received", r.StatusCode)
        if r.StatusCode == 200 {
            fmt.Println("Firstly,Login Action And Redirect Success...")
        }
    })
    c.OnRequest(func(r *colly.Request) {
        r.Headers.Add("Cookie", cookie)
    })

    c.OnError(func(r *colly.Response, err error) {
        // fmt.Println(r)
        fmt.Println(err)
        fmt.Println("Login Action And Redirect occurs error... Please Contact 287572291@qq.com")
    })

    //1.Login Action
    err = c.Post(loginURL, requestData)
    c.Wait()
}

3.问题出现了

3.1 状态码是200

OnResponse中返回了状态码200,这下可懵逼了,并不是302.

由于博主爬虫经验尚少,起初是想通过自己捕获Response报文的Header中的Location,然后用代码手动做重定向。经过实验:

  • 请求重放
  • Wireshark抓包代码的请求报文与Fiddler捕获的请求对比

明明Request是一模一样,但是Response却完全不一样。

经过一番苦苦思考与寻觅,最后了解到很多爬虫框架,像python的爬虫框架scrapy,这些框架会帮助我们自适应302的重定向,并不需要我们去做手动的重定向。这里还走了很多冤枉路,比如想着如何阻止重定向,博主列举一下自己在bing上搜索过的关键字:

  • go colly Redirect
  • go colly prevent defautl redirect

3.2 网页没有跳转

了解到这一层,问题还是没有解决,因为通过在OnResponser.Request.URL `中查看请求的URL,URL还是 https://A.com/login,这是为什么呢?实在搞不懂啦。

4.Postman登场

由于collyResponseBody都是字节切片:

// Response is the representation of a HTTP response made by a Collector
type Response struct {
    // StatusCode is the status code of the Response
    StatusCode int
    // Body is the content of the Response
    Body []byte
    // Ctx is a context between a Request and a Response
    Ctx *Context
    // Request is the Request object of the response
    Request *Request
    // Headers contains the Response's HTTP headers
    Headers *http.Header
    // Trace contains the HTTPTrace for the request. Will only be set by the
    // collector if Collector.TraceHTTP is set to true.
    Trace *HTTPTrace
}

调试查看非常不方便,而且如上所述,现在也有问题横在前面,是时候换一个工具了postman,这就不多介绍了,只是提一下:postman默认也是自动适应302重定向,当然你也可以关闭这个选项

image-20210218174735788

加之,postman还能具备了很多浏览器的动作,如通过获取Response中的Set-Cookie,然后自动设置Cookie值,这都为我们调试提供了方便。

4.1 新发现

通过postman进行登录,状态码的确是200,页面是没有跳转,如下图:

image-20210218175341706

原来是返回Body里面是有JavaScript脚本的。看代码,应该是开发人员为测试还预留了代码。通过逻辑可以很容易看到,浏览器会发生重定向https://B.com/api/index.shtml这里Fidder根本没有捕获到。

img

4.2 模拟登录操作,浏览器后续动作

这时查看Postman为我们管理的Cookie

image-20210218175926036

请求B域名

按照javascript脚本请求B.com/api/index.shtml,注意,就在这里,浏览器已经从域名A切换至域名B

image-20210218180117340


新Cookie

很明显,这里我们Cookie要增加了,没错就是B域名的Cookie

image-20210218181203843

第一节的Fiddler抓包的Cookie中还有SESSIONID,博主已经大致猜测到需要这个SESSIONID值才能维持登录状态;为了让博主自己和看到这篇文章的童鞋死心,还是随便找了一个登录后才能访问的接口进行请求:

image-20210218181658457

没错,We need it

5.新的问题

正如看到的那样,新的问题又摆在博主面前:

  • SESSIONID在哪里?
  • SESSIONID从哪里来?
  • Fiddler捕获的oauth/authorize授权端点,获取授权码的流程也没有经历,也绝对没有重定向,因为上面GET请求.shtml

img

苦恼呀,算了,出去打会儿篮球换个脑子吧。

6.重大发现

就在博主一筹莫展的时候,那边为了重放请求开启的Firefox浏览器好像已经登录超时了,再次操作B网站时,发生了重定向,且依然留在原来的页面,这一特殊的场景,让博主一下子精神起来,而且也被Fidder捕获到了,在这个过程中发现了请求一次B.com/api/index,注意看,没有.shtml的后缀。马上拿着连接再在Postman做测试,再次查看管理的Cookie:

image-20210218183438014

img

没错就是它了,为了进一步验证:设置关闭重定向,进行重放请求:

image-20210218183641746

继续关闭重定向,拿着上图中的location进行请求:

image-20210218183816626

问题清晰

终于看到了我们前面通过Fiddler抓包获取到的A域名下的oauth/authorize端点。至于怎么获取到SESSIONID,博主已经不想再去研究了,多半是类似IdentityServer4这种第三方中间件自己做的各种骚操作,比如:

  • 返回html,html中又有iframe自动请求

image-20200713032339671

  • 返回html,里面存在一个表单,然后脚本自动触发提交表单。
  <script>window.addEventListener('load', function(){document.forms[0].submit();});</script>

登录成功

反正我们已经登录成功了。

7.编码正式开始

7.1 登录

很明显,**整个登录过程中,出现了很多次的A、B域名之间的反复重定向。**我们需要在代码中保护好在整个反复重定向过程中产生的Cookie值,否则也是登录不成功,这是在通过PostmanCookie管理中悟出的,调整为如下代码(注意看注释):

package main

import (
	"fmt"
	"log"
	"net/http"

	"github.com/gocolly/colly/v2"
)
func PostLogin() (err error) {
    //1.Login Action Start
	c := colly.NewCollector(
		colly.UserAgent(sameUserAgent),
	)
	var requestData = make(map[string]string, 3)
	requestData["username"] = username
	requestData["password"] = password
	c.OnResponse(func(r *colly.Response) {
		fmt.Println("response received", r.StatusCode)
		if r.StatusCode == 200 {
			fmt.Println("Firstly,Login Action And Redirect Success...")
		}
	})
	c.OnRequest(func(r *colly.Request) {
		r.Headers.Add("Cookie", cookie)
	})

	c.OnError(func(r *colly.Response, err error) {
		// fmt.Println(r)
		fmt.Println(err)
		fmt.Println("Login Action And Redirect occurs error... Please Contact 287572291@qq.com")
	})

	//1.Login Action End
	err = c.Post(loginURL, requestData)
	c.Wait()

    //2.After Login Action, the Redirection Of "window.location.href" Start
	c.OnResponse(func(r *colly.Response) {
		fmt.Println("response received", r.StatusCode)
		if r.StatusCode == 200 {
			fmt.Println("After Login Action,by the \"window.location.href\",Get Cookie success...")
		}
		AfterLoginActionCookie = r.Headers.Get("Set-Cookie")
	})

	c.OnError(func(r *colly.Response, err error) {
		// log.Println(r)
		fmt.Println(err)
		fmt.Printf("%s Redirect occurs error... Please Contact 287572291@qq.com", AfterLoginSuccessURL)
	})

	//2.After Login Action, the Redirection Of "window.location.href" End
	err = c.Request("GET", AfterLoginSuccessURL, nil, nil, nil)
	c.Wait()

    //3.Obtain AfterLoginSuccess-Cookie Start
	c.OnResponse(func(r *colly.Response) {
		fmt.Println("response received", r.StatusCode)
		fmt.Println("Get Redirect Cookie success...")
		LoginSuccessCookie = r.Request.Headers.Get("cookie")
		fmt.Printf("After Login Success,Obtain Full Cookie: %s", LoginSuccessCookie)
	})

	client = http.DefaultClient
	client.CheckRedirect = func(req *http.Request, via []*http.Request) (err error) {
		for _,v:=range via{
			fmt.Println(v.URL)
		}
		return
	}

	c.OnRequest(func(r *colly.Request) {
		r.Headers.Add("Cookie", AfterLoginActionCookie)
	})

	c.OnError(func(r *colly.Response, err error) {
		log.Println(r)
		log.Println(err)
		log.Printf("%s After Login Success,Obtain Full Cookie occurs error... Please Contact 287572291@qq.com",
			LoginSessionIDURL)
	})

	//3.Obtain AfterLoginSuccess-Cookie End
	err = c.Request("GET", LoginSessionIDURL, nil, nil, nil)
	c.Wait()
	return
}
  • Login Action
    • POST用户名密码至A网站-
  • After Login Action, the Redirection Of “window.location.href”
    • GET window.location.href中的B网站 B.com/api/index.shtml 获取acw_tc开头的Cookie
  • Obtain AfterLoginSuccess-Cookie
    • GET B.com/api/index 获取SESSIONID

通过上述代码登录成功后,获取请求的Cookie值,保存全局变量以供后续爬取数据使用:

//3.Obtain AfterLoginSuccess-Cookie Start
c.OnResponse(func(r *colly.Response) {
	fmt.Println("response received", r.StatusCode)
	fmt.Println("Get Redirect Cookie success...")
	LoginSuccessCookie = r.Request.Headers.Get("cookie")
	fmt.Printf("After Login Success,Obtain Full Cookie: %s", LoginSuccessCookie)
})

7.2 爬取数据

后续爬取数据难度不大,只需要认真分析请求与请求之间的关系,比如:

  • B请求的参数来源于A请求的响应;
  • 一系列的后续操作,来源于切换项目请求后的操作;

这些纯靠编码调整,就不做过多介绍。

注意一点

encoding/json包的反序列化方法需要Struct字段大写,否则反序列化会失败,且不会报错,截取代码片段如下:

var projectData ProjectResponse	
c.OnResponse(func(r *colly.Response) {
    log.Println("response received", r.StatusCode)
    // fmt.Println(string(r.Body))
    err = json.Unmarshal(r.Body, &projectData)
    if err != nil {
        fmt.Println(err)
    }
    // fmt.Println("反序列化结果:", projectData)
})
type ProjectResponse struct{
    Code  string  
    Action  string  
    Msg  string  
    Data  ProjectData 
    Status bool 
}

7.3 数据整理

预先设定数据展示字段,为其定义struct,然后定义struct切片用于保存不断增加的数据。

7.4 导出excel

这里主要是为了把数据导出至Excel表格,更方便查看,使用了github.com/tealeg/xlsx

安装xlsx包

go get -u github.com/tealeg/xlsx

编码

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"strings"

	"github.com/gocolly/colly/v2"
	"github.com/tealeg/xlsx"
)
var result []DeviceStatusResult

// LoopObtain 循环项目
func Export() (err error) {
	// jsonResult,err:=json.Marshal(result)
	// fmt.Println(string(jsonResult))

	file := xlsx.NewFile()
	sheet, err := file.AddSheet("Sheet1")
	for _, v := range result {
		row := sheet.AddRow()
		row.SetHeightCM(1) //设置每行的高度
		for i, d := range v.DeviceInfo {
			if i == 0 {
				cell := row.AddCell()
				cell.Value = v.ProjectName
				cell = row.AddCell()
				cell.Value = d.DeviceNo
				cell = row.AddCell()
				cell.Value = d.Status
			} else {
				row := sheet.AddRow()
				row.SetHeightCM(1) //设置每行的高度
				cell := row.AddCell()
				cell.Value = ""
				cell = row.AddCell()
				cell.Value = d.DeviceNo
				cell = row.AddCell()
				cell.Value = d.Status
			}
		}
	}

	err = file.Save("设备状态表.xlsx")
	fmt.Println("Excel Export Success...")
	return
}
  • xlsx.NewFile()新建文件
  • file.AddSheet("Sheet1")增加表
  • sheet.AddRow()增加行
  • row.AddCell()行增加单元格

对于这个需求,上面的简单导出方法足矣,其余使用方法,感兴趣的童鞋可以自行查阅文档。

8.优化思考

经过测试,登录请求与数据爬取还是很快的。但是由于这个后台管理系统本身的具有一定操作局限性,比如上面提到的情况:一系列的后续请求,必须建立在切换项目请求发生后;因为切换后的状态由SESSION保存。导致整个爬取流程必须按顺序经历:

  • 选择项目
  • 切换项目
  • 请求A
  • 请求B
  • 请求C

一切都是有顺序性,想快也快不起来。

如何优化

但是我们还是想更快一点,就要想办法,博主的思考就是进行多个登录操作大概5-10个:

  • 保存多个Cookie,用以维护好多个登录状态
  • 请求并保存所有的切换的项目列表
  • 建立一个goroutine pool
    • 创建多个爬取数据的goroutine
    • 创建channel通道,用以接收项目, chan<-项目
    • 通过channel通道向goroutine传递项目

这样,效率应该会大幅度提高,毕竟只是为老婆省点事,把并发量搞大了没必要,搞不好会被阿里云判断为DDOS攻击就惨了。