0%

【2022 年】Python3 爬虫教程 - Python 模拟执行 JavaScript

爬虫系列文章总目录:【2022 年】Python3 爬虫学习教程,本教程内容多数来自于《Python3 网络爬虫开发实战(第二版)》一书,目前截止 2022 年,可以将爬虫基本技术进行系统讲解,同时将最新前沿爬虫技术如异步、JavaScript 逆向、AST、安卓逆向、Hook、智能解析、群控技术、WebAssembly、大规模分布式、Docker、Kubernetes 等,市面上目前就仅有《Python3 网络爬虫开发实战(第二版)》一书了,点击了解详情

前面我们了解了一些 JavaScript 逆向的调试技巧,通过一些方法,我们可以找到一些突破口,进而找到关键的方法定义。

比如说,通过一些调试,我们找到了一个加密参数 token 是由某一个叫做 encrypt 方法产生的,如果里面的逻辑相对简单的话,那其实我们可以用 Python 完全重写一遍。但是现实情况往往不是这样的,一般来说,一些加密相关的方法通常会引用一些相关标准库,比如说 JavaScript 就有一个广泛使用的库,叫做 crypto-js,GitHub 仓库链接是:https://github.com/brix/crypto-js,这个库实现了很多主流的加密算法,包括对称加密、非对称加密、字符编码等等,比如对于 AES 加密,通常我们需要输入待加密文本和加密密钥,实现如下:

1
const ciphertext = CryptoJS.AES.encrypt(message, key).toString();

对于这样的情况,我们其实就没法很轻易地完全重写一遍了,因为 Python 中并不一定有和 JavaScript 完全一样的类库。

那有什么解决办法吗?有的,既然 JavaScript 已经实现好了,那我用 Python 直接模拟执行这些 JavaScript 得到结果不就好了吗?

所以,本节我们就来了解下使用 Python 模拟执行 JavaScript 的解决方案。

1. 案例引入

这里我们先看一个和上文描述的情形非常相似的案例,链接是:https://spa7.scrape.center/,如图所示:

image-20210825014021855

这是一个 NBA 球星网站,用卡片的形式展示了一些球星的基本信息,另外每一张卡片上其实都有一个加密字符串,这个加密字符串其实和球星的相关信息是有关联的,每个球星的 加密字符串也是不同的。

所以,这里我们要做的就是找出这个加密字符串的加密算法并用程序把加密字符串的生成过程模拟出来。

2. 准备工作

由于本节我们需要使用 Python 模拟执行 JavaScript,这里我们使用的库叫做 PyExecJS,我们使用 pip3 安装即可,命令如下:

1
pip3 install pyexecjs

PyExecJS 是用于执行 JavaScript 的,但执行 JavaScript 的功能需要依赖一个 JavaScript 运行环境,所以除了安装好这个库之外,我们还需要安装一个 JavaScript 运行环境,个人比较推荐的是 Node.js,所以我们还需要安装下 Node.js,可以到 https://nodejs.org/ 下载安装。更加详细的安装和配置过程可以参考:https://setup.scrape.center/pyexecjs。

PyExecJS 库在运行时会检测本地 JavaScript 运行环境来实现 JavaScript 执行,做好如上准备工作之后, 接着我们运行代码检查一下运行环境:

1
2
import execjs
print(execjs.get().name)

运行结果类似如下:

1
Node.js (V8)

如果你成功安装好 PyExecJS 库和 Node.js 的话,其结果就是 Node.js (V8),当然如果你安装的是其他的 JavaScript 运行环境,结果也会有所不同。

3. 分析

接下来我们就对这个网站稍作分析,打开 Sources 面板,我们可以非常轻易地找到加密字符串的生成逻辑,如图所示:

image-20210826034346308

首先声明了一个球员相关的列表,如:

1
2
3
4
5
6
7
8
9
10
const players = [
{
name: '凯文-杜兰特',
image: 'durant.png',
birthday: '1988-09-29',
height: '208cm',
weight: '108.9KG'
}
...
]

然后对于每一个球员,都把每个球员的信息调用了加密算法进行了加密,我们可以打个断点看下:

image-20210825014950392

这里我们可以看到,getToken 方法的输入就是单个球员的信息,就是上述列表的一个元素对象,然后 this.key 就是一个固定的字符串。整个加密逻辑就是提取了球员的名字、生日、身高、体重,然后先 Base64 编码然后再进行 DES 加密,最后返回结果。

加密算法是怎么实现的呢?其实就是依赖了 crypto-js 库,使用了 CryptoJS 对象来实现的。

那 CryptoJS 这个对象是哪里来的呢?总不能凭空产生吧?其实这个网站就是直接引用了这个库,如图所示:

image-20210826035113504

引用这个 JavaScript 文件之后,CryptoJS 就被注入到浏览器全局环境下了,因此我们就可以在别的方法里面直接使用 CryptoJS 对象里面的方法了。

4. 模拟调用

好,那既然这样,我们要怎么模拟呢?下面我们来实现下。

首先,我们要模拟的其实就是这个 getToken 方法,输入球员相关信息,得到最终的加密字符串,这里我们直接把 key 替换下,把 getToken 方法稍微改写如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getToken(player) {
let key = CryptoJS.enc.Utf8.parse("fipFfVsZsTda94hJNKJfLoaqyqMZFFimwLt");
const { name, birthday, height, weight } = player;
let base64Name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(name));
let encrypted = CryptoJS.DES.encrypt(
`${base64Name}${birthday}${height}${weight}`,
key,
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
}
);
return encrypted.toString();
}

因为这个方法的模拟执行是需要 CryptoJS 这个对象的,如果我们直接调用这个方法肯定会报 CryptoJS 未定义的错误。

那怎么办呢?我们只需要再模拟执行下刚才看到的 crypto-js.min.js 不就好了吗?

OK,所以,我们需要模拟执行的内容就是两部分:

  • 模拟运行 crypto-js.min.js 里面的 JavaScript,用于声明 CryptoJS 对象。
  • 模拟运行 getToken 方法的定义,用于声明 getToken 方法。

好,接下来我们就把 crypto-js.min.js 里面的代码和上面 getToken 方法的代码复制一下,都粘贴到一个 JavaScript 文件里面,比如就叫做 crypto.js。

接下来我们就用 PyExecJS 模拟执行一下吧,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import execjs
import json

item = {
'name': '凯文-杜兰特',
'image': 'durant.png',
'birthday': '1988-09-29',
'height': '208cm',
'weight': '108.9KG'
}

file = 'crypto.js'
node = execjs.get()
ctx = node.compile(open(file).read())

js = f"getToken({json.dumps(item, ensure_ascii=False)})"
print(js)
result = ctx.eval(js)
print(result)

这里我们单独定义了一位球员的信息,赋值为 item 变量。然后使用 execjs 的 get 方法获取了 JavaScript 执行环境,赋值为 node。

接着我们调用了 node 的 compile 方法,传入了刚才定义的 crypto.js 文件的文本内容,compile 方法会返回一个 JavaScript 的上下文对象,我们赋值为 ctx。执行到这里,其实就可以理解为,ctx 对象里面就执行过了 crypto-js.min.js,CryptoJS 就声明好了,然后也执行过了 getToken 的定义,所以 getToken 方法也定义好了,相当于完成了一些初始化的工作。

接着,我们只需要定义好我们想要执行的 JavaScript 代码就好了,我们定义了一个 js 变量,其实就是模拟调用了 getToken 方法并传入了球员信息,我们打印了下 js 变量的值,内容如下:

1
getToken({"name": "凯文-杜兰特", "image": "durant.png", "birthday": "1988-09-29", "height": "208cm", "weight": "108.9KG"})

其实这就是一个标准的 JavaScript 方法调用的写法而已。

接着我们调用 ctx 对象的 eval 方法并传入 js 变量,其实就是模拟执行了这句 JavaScript 代码,照理来说最终返回的就是加密字符串了。

然而,运行之后,我们可能看到这个报错:

1
execjs._exceptions.ProgramError: ReferenceError: CryptoJS is not defined

很奇怪,CryptoJS 未定义?我们明明执行过 crypto-js.min.js 里面的内容了呀?

问题其实出在 crypto-js.min.js 里面,可以看到其里面声明了一个 JavaScript 的自执行方法,如图所示:

image-20210825020403826

自执行方法什么意思呢?就是声明了一个方法,然后紧接着调用执行,我们可以看下这个例子:

1
2
3
!(function (a, b) {
console.log("result", a, b);
})(1, 2);

这里我们先声明了一个 function,然后接收 a 和 b 两个参数,然后把内容输出出来,然后我们把这个 function 用小括号括起来,这其实就是一个方法,可以被直接调用的,怎么调用呢?后面再跟上对应的参数就好了,比如传入 1 和 2,执行结果如下:

1
result 1 2

可以看到,这个自执行的方法就被执行了。

同理地,crypto-js.min.js 也符合这个格式,它接收 t 和 e 两个参数,t 就是 this,其实就是浏览器中的 window 对象,e 就是一个 function(用于定义 CryptoJS 的核心内容)。

我们再来观察下 crypto-js.min.js 开头的定义:

1
2
3
4
5
"object" == typeof exports
? (module.exports = exports = e())
: "function" == typeof define && define.amd
? define([], e)
: (t.CryptoJS = e());

在 Node.js 中,其实 exports 就是用来将一些对象的定义进行导出的,这里 "object" == typeof exports 其实结果就是 true,所以就执行了 module.exports = exports = e() 这段代码,这样就相当于把 e() 作为整体导出了,而这个 e() 其实就对应这后面的整个 function,function 里面定义了加密相关的各个实现,其实就指代整个加密算法库。

但是在浏览器中,其结果就不一样了,浏览器环境中并没有 exports 和 define 这两个对象。所以,上述代码在浏览器中最后执行的就是 t.CryptoJS = e() 这段代码,其实这里就是把 CryptoJS 对象挂载到 this 对象上面,而 this 就是浏览器中的全局 window 对象,后面就可以直接用了。如果我们把代码放在浏览器中运行,那是没有任何问题的。

然而,我们使用的 PyExecJS 是依赖于一个 Node.js 执行环境的,所以上述代码其实执行的是 module.exports = exports = e(),这里面并没有声明 CryptoJS 对象,也没有把 CryptoJS 挂载到全局对象里面,所以后面我们再调用 CryptoJS 就自然而然出现了未定义的错误了。

那怎么办呢?其实很简单,那我们直接声明一个 CryptoJS 变量,然后手动声明一下它的初始化不就好了吗?所以我们可以把代码稍作修改,改成如下内容:

1
2
3
4
5
6
7
8
9
10
11
var CryptoJS;
!(function (t, e) {
CryptoJS = e();
"object" == typeof exports
? (module.exports = exports = e())
: "function" == typeof define && define.amd
? define([], e)
: (t.CryptoJS = e());
})(this, function () {
//...
});

这里我们就首先声明了一个 CryptoJS 变量,然后直接给 CryptoJS 变量赋值给 e(),这样就完成了 CryptoJS 的初始化。

这样我们再重新运行刚才的 Python 脚本,就可以得到执行结果了:

1
gQSfeqldQIJKAZHH9TzRX/exvIwb0j73b2cjXvy6PeZ3rGW6sQsL2w==

这样我们就成功得到加密字符串了,和示例网站上显示的是一模一样的,这样我们就成功模拟 JavaScript 的调用完成了某个加密算法的运行过程。

5. 总结

本节介绍了利用 PyExecJS 来模拟执行 JavaScript 的方法,结合一个案例来完成了整个的实现和问题排查的过程。本节内容还是比较重要的,以后我们如果需要模拟执行 JavaScript 就可以派得上用场。

本节代码;https://github.com/Python3WebSpider/ScrapeSpa7。