前言
在前边两节我们分析了两个动态页面,过程还算简单。今天,我们来看一个复杂的例子。本来博主自己找到了一个例子准备分析的。不过,在分析时偶然搜到一篇分析动态页面的文章,过程详细清晰,且过程一波三折。博主抱着学习与分享的心态转载再创造这片文章,不过经过博主自己的实践,整个过程有一些小问题,在后边也会指出,原文参考自崔老师崔庆才的博客。
正文
疑难杂症
中国空气质量在线监测分析平台是一个收录全国各大城市天气数据的网站,包括温度、湿度、PM 2.5、AQI 等数据,链接为:https://www.aqistudy.cn/html/city_detail.html,预览图如下:
通过这个网站我们可以获取到各大城市任何一天的天气数据,对数据分析还是非常有用的。
然而不幸的是,该网站的数据接口通信都被加密了。经过分析之后发现其页面数据是通过 Ajax 加载的,数据接口地址是:https://www.aqistudy.cn/apinew/aqistudyapi.php,是一个 POST 形式访问的接口,这个接口的请求数据和返回数据都被加密了,即 POST 请求的 Data、返回的数据都被加密了,下图是数据接口的 Form Data 部分,可见传输数据是一个加密后的字符串:
下图是该接口返回的内容,同样是经过加密的字符串:
遇到这种接口加密的情况,一般来说我们会选择避开请求接口的方式进行数据爬取,如使用 Selenium 模拟浏览器来执行。但这个网站的数据是图表展示的,所以其数据会变得难以提取。
那怎么办呢?刚啊!
一刚到底
之前的老法子都行不通了,那就只能上了!接下来我们就不得不去分析这个网站接口的加密逻辑,并通过一些技巧来破解这个接口了。
首先找到突破口,当我们点击了这个搜索按钮之后,后台便会发出 Ajax 请求,说明这个点击动作是被监听的,所以我们可以找一下这个点击事件对应的处理代码在哪里,这里可以借助于 Firefox 来实现,它可以分析页面某个元素的绑定事件以及定位到具体的代码在哪一行,如图所示:
这里我们发现这个搜索按钮绑定了三个事件,blur、click、focus,同时 Firefox 还帮助我们列出来了对应事件的处理函数在哪个代码的哪一行,这里可以看到 click 事件是在 city_detail.html 的第 139 行处理的,而且是调用了 getData() 函数。
接下来我们就可以顺藤摸瓜,找到 city_detail.html 文件的 getData() 函数,然后再找到这个函数的定义即可,很容易地,我们在 city_detail.html 的第 463 行就找到了这个函数的定义:
经过分析发现它又调用了 getAQIData() 和 getWeatherData() 两个方法,而这两个方法的声明就在下面,再进一步分析发现这两个方法都调用了 getServerData() 这个方法,并传递了 method、param 等参数,然后还有一个回调函数很明显是对返回数据进行处理的,这说明 Ajax 请求就是由这个 getServerData() 方法发起的,如图所示:
所以这里我们只需要再找到 getServerData() 方法的定义即可分析它的加密逻辑了。继续搜索,然而在原始 html 文件中没有搜索到该方法,那就继续去搜寻其他的 JavaScript 文件有没有这个定义,终于经过一番寻找,居然在 jquery-1.8.0.min.js 这个文件中找到了:
有的小伙伴可能会说,jquery.min.js 不是一个库文件吗,怎么会有这种方法声明?嗯,我只想说,最危险的地方就是最安全的地方。
好了,现在终于找到这个方法了,可为什么看不懂呢?这个方法名后面怎么直接跟了一些奇怪的字符串,而且不符合一般的 JavaScript 写法。其实这里是经过 JavaScript 混淆加密了,混淆加密之后,代码将变为不可读的形式,但是功能是完全一致的,这是一种常见的 JavaScript 加密手段。
那到这里了该怎么解呢?当然是接着刚啊!
反混淆
JavaScript 混淆之后,其实是有反混淆方法的,最简单的方法便是搜索在线反混淆网站,这里提供一个:http://www.bm8.com.cn/jsConfusion/,我们将 jquery-1.8.0.min.js 中第二行 eval 开头的混淆后的 JavaScript 代码复制一下,然后粘贴到这个网站中进行反混淆,就可以看到正常的 JavaScript 代码了,搜索一下就可以找到 getServerData() 方法了,可以看到这个方法确实发出了一个 Ajax 请求,请求了刚才我们分析到的接口:
那么到这里我们又可以发现一个很关键的方法,那就是 getParam(),它接受了 method 和 object 参数,然后返回得到的 param 结果就作为 POST Data 参数请求接口了,所以 param 就是加密后的 POST Data,一些加密逻辑都在 getParam() 方法里面,其方法实现如下:
var getParam = (function () {
function ObjectSort(obj) {
var newObject = {};
Object.keys(obj).sort().map(function (key) {
newObject[key] = obj[key]
});
return newObject
}
return function (method, obj) {
var appId = '1a45f75b824b2dc628d5955356b5ef18';
var clienttype = 'WEB';
var timestamp = new Date().getTime();
var param = {
appId: appId,
method: method,
timestamp: timestamp,
clienttype: clienttype,
object: obj,
secret: hex_md5(appId + method + timestamp + clienttype + JSON.stringify(ObjectSort(obj)))
};
param = BASE64.encrypt(JSON.stringify(param));
return AES.encrypt(param, aes_client_key, aes_client_iv)
}
})();
可以看到这里使用了 Base64 和 AES 加密。加密之后的字符串便作为 POST Data 传送给服务器了,然后服务器再进行解密处理,然后进行逻辑处理,然后再对处理后的数据进行加密,返回了加密后的数据,那么 JavaScript 再接收到之后再进行一次解密,再渲染才能得到正常的结果。
所以这里还需要分析服务器传回的数据是怎样解密的。顺腾摸瓜,很容易就找到一个 decodeData() 方法,其定义如下:
function decodeData(data) {
data = AES.decrypt(data, aes_server_key, aes_server_iv);
data = DES.decrypt(data, des_key, des_iv);
data = BASE64.decrypt(data);
return data
}
嗯,这里又经过了三层解密,才把正常的明文数据解析出来。
所以一切都清晰了,我们需要实现两个过程才能正常使用这个接口,即实现 POST Data 的加密过程和 Response Data 的解密过程。其中 POST Data 的加密过程是 Base64 + AES 加密,Response Data 的解密是 AES + DES + Base64 解密。加密解密的 Key 也都在 JavaScript 文件里能找到,我们用 Python 实现这些加密解密过程就可以了。
所以接下来怎么办?接着刚啊!
接着刚才怪!
何必去费那些事去用 Python 重写一遍 JavaScript,万一二者里面有数据格式不统一或者二者由于语言不兼容问题导致计算结果偏差,上哪里去 Debug?
那怎么办?这里我们借助于 PyExecJS 库来实现 JavaScript 模拟就好了。
PyExecJS
PyExecJS 是一个可以使用 Python 来模拟运行 JavaScript 的库。大家可能听说过 PyV8,它也是用来模拟执行 JavaScript 的库,可是由于这个项目已经不维护了,而且对 Python3 的支持不好,而且安装出现各种问题,所以这里选用了 PyExecJS 库来代替它。
首先我们来安装一下这个库:
$ pip install PyExecJS
使用 pip 安装即可。
在使用这个库之前请确保你的机器上安装了以下其中一个JS运行环境:
- JScript
- JavaScriptCore!
- Nashorn
- Node
- PhantomJS
- PyV8
- SlimerJS
-
SpiderMonkey
PyExecJS 库会按照优先级调用这些引擎来实现 JavaScript 执行,这里推荐安装 Node.js 或 PhantomJS。
接着我们运行代码检查一下运行环境:
import execjs
print(execjs.get().name)
运行之后,由于我安装了 Node.js,所以这里会使用 Node.js 作为渲染引擎,结果如下:
Node.js (V8)
接下来我们将刚才反混淆的 JavaScript 保存成一个文件,叫做 encryption.js,然后用 PyExecJS 模拟运行相关的方法即可。
首先我们来实现加密过程,这里 getServerData() 方法其实已经帮我们实现好了,并实现了 Ajax 请求,但这个方法里面有获取 Storage 的方法,Node.js 不适用,所以这里我们直接改写下,实现一个 getEncryptedData() 方法实现加密,在 encryption.js 里面实现如下方法:
function getEncryptedData(method, city, type, startTime, endTime) { var param = {};
param.city = city;
param.type = type;
param.startTime = startTime;
param.endTime = endTime;
return getParam(method, param);
}
接着我们模拟执行这些方法即可:
import execjs
# Init environment
node = execjs.get()
# Params
method = 'GETCITYWEATHER'
city = '北京'
type = 'HOUR'
start_time = '2018-01-25 00:00:00'
end_time = '2018-01-25 23:00:00'
# Compile javascript
file = 'encryption.js'
ctx = node.compile(open(file).read())
# Get params
js = 'getEncryptedData("{0}", "{1}", "{2}", "{3}", "{4}")'.format(method, city, type, start_time, end_time)
params = ctx.eval(js)
这里我们首先定义一些参数,如 method、city、start_time 等,这些都可以通过分析 JavaScript 很容易得出其规则。
然后这里首先通过 execjs(即 PyExecJS)的 get() 方法声明一个运行环境,然后调用 compile() 方法来执行刚才保存下来的加密库 encryption.js,因为这里面包含了一些加密方法和自定义方法,所以只有执行一遍才能调用。
接着我们再构造一个 js 字符串,传递这些参数,然后通过 eval() 方法来模拟执行,得到的结果赋值为 params,这个就是 POST Data 的加密数据。
接着我们直接用 requests 库来模拟 POST 请求就好了,也没必要用 jQuery 自带的 Ajax 了,当然后者也是可行的,只不过需要加载一下 jQuery 库。
接着我们用 requests 库来模拟 POST 请求:
# Get encrypted response text
api = 'https://www.aqistudy.cn/apinew/aqistudyapi.php'
response = requests.post(api, data={'d': params})
这样 response 的内容就是服务器返回的加密的内容了。
# Decode data
js = 'decodeData("{0}")'.format(response.text)
decrypted_data = ctx.eval(js)
这样 decrypted_data 就是解密后的字符串了,解密之后,实际上是一个 JSON 字符串:
{‘success’: True, ‘errcode’: 0, ‘errmsg’: ‘success’, ‘result’: {‘success’: True, ‘data’: {‘total’: 22, ‘rows’: [{‘time’: ‘2018-01-25 00:00:00’, ‘temp’: ‘-7’, ‘humi’: ‘35’, ‘wse’: ‘1’, ‘wd’: ‘东北风’, ‘tq’: ‘晴’}, {‘time’: ‘2018-01-25 01:00:00’, ‘temp’: ‘-9’, ‘humi’: ‘38’, ‘wse’: ‘1’, ‘wd’: ‘西风’, ‘tq’: ‘晴’}, {‘time’: ‘2018-01-25 02:00:00’, ‘temp’: ‘-10’, ‘humi’: ‘40’, ‘wse’: ‘1’, ‘wd’: ‘东北风’, ‘tq’: ‘晴’}, {‘time’: ‘2018-01-25 03:00:00’, ‘temp’: ‘-8’, ‘humi’: ‘27’, ‘wse’: ‘2’, ‘wd’: ‘东北风’, ‘tq’: ‘晴’}, {‘time’: ‘2018-01-25 04:00:00’, ‘temp’: ‘-8’, ‘humi’: ‘26’, ‘wse’: ‘2’, ‘wd’: ‘东风’, ‘tq’: ‘晴’}, {‘time’: ‘2018-01-25 05:00:00’, ‘temp’: ‘-8’, ‘humi’: ‘23’, ‘wse’: ‘2’, ‘wd’: ‘东北风’, ‘tq’: ‘晴’}, {‘time’: ‘2018-01-25 06:00:00’, ‘temp’: ‘-9’, ‘humi’: ‘27’, ‘wse’: ‘2’, ‘wd’: ‘东北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 07:00:00’, ‘temp’: ‘-9’, ‘humi’: ‘24’, ‘wse’: ‘2’, ‘wd’: ‘东北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 08:00:00’, ‘temp’: ‘-9’, ‘humi’: ‘25’, ‘wse’: ‘2’, ‘wd’: ‘东风’, ‘tq’: ‘晴转多云转多云间晴’}, {‘time’: ‘2018-01-25 09:00:00’, ‘temp’: ‘-8’, ‘humi’: ‘21’, ‘wse’: ‘3’, ‘wd’: ‘东北风’, ‘tq’: ‘晴转多云转多云间晴’}, {‘time’: ‘2018-01-25 10:00:00’, ‘temp’: ‘-7’, ‘humi’: ‘19’, ‘wse’: ‘3’, ‘wd’: ‘东北风’, ‘tq’: ‘晴转多云转多云间晴’}, {‘time’: ‘2018-01-25 11:00:00’, ‘temp’: ‘-6’, ‘humi’: ‘18’, ‘wse’: ‘3’, ‘wd’: ‘东北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 12:00:00’, ‘temp’: ‘-6’, ‘humi’: ‘17’, ‘wse’: ‘3’, ‘wd’: ‘东北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 13:00:00’, ‘temp’: ‘-5’, ‘humi’: ‘17’, ‘wse’: ‘2’, ‘wd’: ‘东北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 14:00:00’, ‘temp’: ‘-5’, ‘humi’: ‘16’, ‘wse’: ‘2’, ‘wd’: ‘东风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 15:00:00’, ‘temp’: ‘-5’, ‘humi’: ‘15’, ‘wse’: ‘2’, ‘wd’: ‘北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 16:00:00’, ‘temp’: ‘-5’, ‘humi’: ‘16’, ‘wse’: ‘2’, ‘wd’: ‘东北风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 17:00:00’, ‘temp’: ‘-5’, ‘humi’: ‘16’, ‘wse’: ‘2’, ‘wd’: ‘东风’, ‘tq’: ‘多云’}, {‘time’: ‘2018-01-25 18:00:00’, ‘temp’: ‘-6’, ‘humi’: ‘18’, ‘wse’: ‘2’, ‘wd’: ‘东风’, ‘tq’: ‘晴间多云’}, {‘time’: ‘2018-01-25 19:00:00’, ‘temp’: ‘-7’, ‘humi’: ‘19’, ‘wse’: ‘2’, ‘wd’: ‘东风’, ‘tq’: ‘晴间多云’}, {‘time’: ‘2018-01-25 20:00:00’, ‘temp’: ‘-7’, ‘humi’: ‘19’, ‘wse’: ‘1’, ‘wd’: ‘东风’, ‘tq’: ‘晴间多云’}, {‘time’: ‘2018-01-25 21:00:00’, ‘temp’: ‘-7’, ‘humi’: ‘19’, ‘wse’: ‘0’, ‘wd’: ‘南风’, ‘tq’: ‘晴间多云’}]}}}
大功告成!
这样我们就可以成功获取温度、湿度、风力、天气等信息了。
另外这部分数据其实不全,还有 PM 2.5、AQI 等数据需要用另外一个 method 参数 GETDETAIL,修改一下即可获取这部分数据了。
再往后的数据就是解析和存储了,这里不再赘述。
改动之处
以上是崔老师文章的主要内容,我之前按照过程来实现的时候也是没有问题的,然而等到了我写博客的时候,就发现原网站好像出现了一些小问题,打开之后数据无法显示,但是接口还在,我查看了一下接口内容里边服务器返回的数据,发现多了一些东西:
大概是网站的数据库出了一些问题,于是返回的数据多了一些东西,我们只要做下简单的replace处理就行。代码如下:
text=response.text.replace('''<br />
<b>Warning</b>: mysqli_connect(): Headers and client library minor version mismatch. Headers:50556 Library:50637 in <b>/var/www/aqistudy/config/db_config.php</b> on line <b>39</b><br />''','')
text=text.replace(' ','')
text=text.replace('\n','')
因为是固定的错误所以就直接replace替换了,也可以用正则匹配提取,大家有兴趣自己试试。
这样再对返回的数据使用decode()函数解析就可以了。
另外还有一点,我的机器默认使用的是JScript作为execjs的引擎的,但是这个JScript不支持原来网页的某些JS代码,博主换成了nodeJS才成功解析的。改动后代码如下:
# -*- coding: utf-8 -*-
# @Date: 2018-04-11 15:12:33
# @Last Modified time: 2018-04-21 22:26:33
import execjs
import os
import requests
#选择使用NodeJS作为引擎渲染ececjs,需要安装nodeJS
# Init environment
node = execjs.get(execjs.runtime_names.Node)
# Params
method = 'GETCITYWEATHER'
city = '北京'
stype = 'HOUR'
start_time = '2018-01-25 00:00:00'
end_time = '2018-01-25 23:00:00'
# Compile javascript
file = 'weather_encryption.js'
ctx = execjs.compile(open(file,'r',encoding="utf-8").read())
# Get params
js = 'getEncryptedData("{0}", "{1}", "{2}", "{3}", "{4}")'.format(method, city, stype, start_time, end_time)
params = ctx.eval(js)
# Get encrypted response text
api = 'https://www.aqistudy.cn/apinew/aqistudyapi.php'
response = requests.post(api, data={'d': params})
text=response.text.replace('''<br /> <b>Warning</b>: mysqli_connect(): Headers and client library minor version mismatch. Headers:50556 Library:50637 in <b>/var/www/aqistudy/config/db_config.php</b> on line <b>39</b><br />''','')
text=text.replace(' ','')
text=text.replace('\n','')
# Decode data
# js = 'decodeData("{0}")'.format(response.text)
js = 'decodeData("{0}")'.format(text)
decrypted_data = ctx.eval(js)
print(decrypted_data)
结语
整个动态页面爬取系列暂时就写这三篇文章吧,其实还有更加高深的加密技术,比如某鱼直播各个直播间的rtmp地址,其加密逻辑是隐藏在flash里边的,需要抓包反编译才有破解的可能,而且某鱼之前还换过好几次加密的手段,不知道现在是不是还是与我上次分析的一样。有兴趣的小伙伴可以自己分析一下,如果成功了请在下方留言,还望不吝赐教。
都看到这里了,不给个赞嘛!源码已上传至github,欢迎前来吐槽。