典型的js加密爬虫实战,本文章内容仅作学习交流。

首先放一下参考文章:爬虫JS破解经典案例之百度翻译

如果没有这篇文章的话,我可能要花很多功夫才能找到具体的js代码位置。

页面加载分析

初步探索

打开百度翻译,然后右键、检查、network,刷新页面即可看到网页的加载过程。具体的图示可以在之前的leetcode爬虫中找到。

在输入任意查询内容点击翻译后,在XHR项中可以看到langdetectv2transapi?form & to两个请求。检查内容不难发现,前者的作用是语言检查, 后者才是真正的翻译。

from: en
to: zh
query: hello everyone
transtype: translang
simple_means_flag: 3
sign: 58244.262325
token: 440bdb196410660e636b3223ebb83a94
domain: common

上面是我以”hello everyone”作为待翻译文本发起请求的表单内容,fromtoquery这三项意义很明确,其余项无法直接判断出具体的作用。

深入挖掘

多输入几组数据,可以发现sign会随着query的值发生变化,而token项却不会发生改变。

这里我参考了别人的方式,搜索”sign:“很快找到了这个请求的发起代码:

h={from:p.fromLang,to:p.toLang,query:n,transtype:r,simple_means_flag:3,sign:y(n),token:window.common.token,domain:w.getCurDomain()}

另外这里的token也可以通过搜索”window[‘common’]“找到,发现他其实位于html代码中,是一个固定的值,可以直接用正则表达式解析html代码获取。所以现在需要解决的问题就是sign的产生。

JS破解

sign通过y函数产生,另外通过观察可以发现y函数的传参实际上是待查询的内容,所以只要找到y函数,理论上我们可以获得任意输入的sign值。那么怎么才能找到y函数呢?第一个想法当然是继续搜索,但实际上一搜索就会发现,因为特征太少无法确定具体是哪一个搜索结果。这里需要使用一个很关键的技术:JS调试

JS调试

先说一下调试的具体步骤,如果没有相关基础的话直接上手还是会有点问题的。

首先找到代码所在行,对于复杂网页,一行内可能有多条语句,这时候如果直接点击想要调试的函数的话,实际上不会添加断点。正确的操作应该是像Python代码那样,先点击这一行的最左侧的行号,点击行号之后在把鼠标移动到想要调试的函数上,这时候会发现函数可以被点击,点击函数名便能添加断点。(当然也可以直接点击代码框左下角的花括号,直接把代码格式化,我当时找了很久没找到😣)

在添加完断点之后,继续输入内容,点击翻译,可以发现网站停止加载,并且返回调试界面可以看到变量名及其对应的值,将鼠标放到函数名上可以跳转到函数的具体位置,这样就能找到y函数的真正位置了。其具体内容如下:

function e(r) {
    var o = r.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g);
    if (null === o) {
        var t = r.length;
        t > 30 && (r = "" + r.substr(0, 10) + r.substr(Math.floor(t / 2) - 5, 10) + r.substr(-10, 10))
    } else {
        for (var e = r.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/), C = 0, h = e.length, f = []; h > C; C++)"" !== e[C] && f.push.apply(f, a(e[C].split(""))), C !== h - 1 && f.push(o[C]);
        var g = f.length; g > 30 && (r = f.slice(0, 10).join("") + f.slice(Math.floor(g / 2) - 5, Math.floor(g / 2) + 5).join("") + f.slice(-10).join(""))
    }
    var i = null;   // 直接执行会提示i没有定义 所以加了一行
    var u = void 0, l = "" + String.fromCharCode(103) + String.fromCharCode(116) + String.fromCharCode(107); 
    u = null !== i ? i : (i = window[l] || "") || "";
    for (var d = u.split("."), m = Number(d[0]) || 0, s = Number(d[1]) || 0, S = [], c = 0, v = 0; v < r.length; v++) {
        var A = r.charCodeAt(v); 128 > A ? S[c++] = A : (2048 > A ? S[c++] = A >> 6 | 192 : (55296 === (64512 & A) && v + 1 < r.length && 56320 === (64512 & r.charCodeAt(v + 1)) ? (A = 65536 + ((1023 & A) << 10) + (1023 & r.charCodeAt(++v)), S[c++] = A >> 18 | 240, S[c++] = A >> 12 & 63 | 128) : S[c++] = A >> 12 | 224, S[c++] = A >> 6 & 63 | 128), S[c++] = 63 & A | 128)
    } for (var p = m, F = "" + String.fromCharCode(43) + String.fromCharCode(45) + String.fromCharCode(97) + ("" + String.fromCharCode(94) + String.fromCharCode(43) + String.fromCharCode(54)), D = "" + String.fromCharCode(43) + String.fromCharCode(45) + String.fromCharCode(51) + ("" + String.fromCharCode(94) + String.fromCharCode(43) + String.fromCharCode(98)) + ("" + String.fromCharCode(43) + String.fromCharCode(45) + String.fromCharCode(102)), b = 0; b < S.length; b++)p += S[b], p = n(p, F);
    return p = n(p, D), p ^= s, 0 > p && (p = (2147483647 & p) + 2147483648), p %= 1e6, p.toString() + "." + (p ^ m)
}

可以看到这段代码还是比较复杂的,如果懂js的话,可以稍微解读一下,因为这段代码还没有混淆到完全没办法看的程度(把所有的字符都用括号或者下划线之类的替代)。

这里讲三个后续执行代码的时候才会发现的点:

  1. 代码使用了全局的window变量,js函数当中未作定义,通过调试可以发现实际上是为了获取该变量的gtk值,该值也是写在html当中的,可以直接正则获得,获得之后对js代码进行稍微修改即可。
  2. i变量未定义,没有找到i变量的具体位置,但是查看之后其没有很突出的作用,于是自己加了一条语句,对i变量进行了定义。
  3. 函数内部调用了另外的函数n😑,具体的调用可以在函数的return位置,如果不仔细看过,第一遍是不会发现这个点的。找到n的方法也很简单,直接打断点就好了,这里不做详细解释。n的代码会在最后给出。

Python执行JS代码

前面我们已经获得了sign生成函数的具体代码,但是我们的爬虫语言是Python,如果把js代码翻译成Python就太麻烦了,还好有已经封装好的第三方库可以执行。

能够实现上述功能的第三方库有很多,我这里使用了pyexecjs,需要注意该模块需要安装node.js配合使用。假设我们已经把生成函数放在了fanyi.js当中,可以通过下面的方式进行调用:

import execjs

query = "待翻译语句"
fh = open('fanyi.js', 'r')
fun = execjs.compile(fh.read())
print(fun.call('e', query))

代码实现

在做完前面的准备工作之后,便能开始具体的代码书写,整体思路如下:

  1. 请求百度翻译首页
  2. 解析html获得tokengtk
  3. 用得到的gkt替换js代码中的对应内容
  4. 编译js代码并执行,获得sign
  5. 请求具体的语言检测、翻译网址,得到结果

在实现爬虫时,我又对查询请求做了进一步分析,发现在提交的众多cookie当中,最关键的是BAIDUID这一项,而访问百度翻译首页刚好可以获得这个cookie。

这样便能直接用一个session,先访问百度翻译首页,获得cookie的同时解析html得到tokengtk,进行下一步的操作。到这里代码已经很清晰了,但是写完之后却发现不能成功执行,服务端报错998😭

看到错误之后以为是js代码出了问题,调整了很久,但是最后还是不行,于是又求助了百度,在这里找到了一个解决办法,先访问一下百度首页,带着百度首页的cookie访问百度翻译,这样就不会报错。

具体的原理我到现在还不清楚,我之前进行测试的时候,如果不先访问百度首页的话,访问百度翻译解析出来的token是一个一成不变的值,我起初认为是js代码有错误,就一直进行了测试,半个多小时token的值都没有变化。token是在html当中写死的,有问题只能出在服务端上,所以我认为先访问百度首页的目的就是在访问百度翻译的时候带着一个cookie,让服务器返回html的时候生成正确的token。至于具体是不是这个原因,等学的更多了才知道。

下面是我的代码:

import re
import requests
import execjs
import json


js_content = r"""function e(r) {
    var o = r.match(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g);
    if (null === o) {
        var t = r.length;
        t > 30 && (r = "" + r.substr(0, 10) + r.substr(Math.floor(t / 2) - 5, 10) + r.substr(-10, 10))
    } else {
        for (var e = r.split(/[\uD800-\uDBFF][\uDC00-\uDFFF]/), C = 0, h = e.length, f = []; h > C; C++)"" !== e[C] && f.push.apply(f, a(e[C].split(""))), C !== h - 1 && f.push(o[C]);
        var g = f.length; g > 30 && (r = f.slice(0, 10).join("") + f.slice(Math.floor(g / 2) - 5, Math.floor(g / 2) + 5).join("") + f.slice(-10).join(""))
    }
    var i = null 
    var u = void 0, l = "" + String.fromCharCode(103) + String.fromCharCode(116) + String.fromCharCode(107); 
    u = null !== i ? i : (i = window[l] || "") || "";
    for (var d = u.split("."), m = Number(d[0]) || 0, s = Number(d[1]) || 0, S = [], c = 0, v = 0; v < r.length; v++) {
        var A = r.charCodeAt(v); 128 > A ? S[c++] = A : (2048 > A ? S[c++] = A >> 6 | 192 : (55296 === (64512 & A) && v + 1 < r.length && 56320 === (64512 & r.charCodeAt(v + 1)) ? (A = 65536 + ((1023 & A) << 10) + (1023 & r.charCodeAt(++v)), S[c++] = A >> 18 | 240, S[c++] = A >> 12 & 63 | 128) : S[c++] = A >> 12 | 224, S[c++] = A >> 6 & 63 | 128), S[c++] = 63 & A | 128)
    } for (var p = m, F = "" + String.fromCharCode(43) + String.fromCharCode(45) + String.fromCharCode(97) + ("" + String.fromCharCode(94) + String.fromCharCode(43) + String.fromCharCode(54)), D = "" + String.fromCharCode(43) + String.fromCharCode(45) + String.fromCharCode(51) + ("" + String.fromCharCode(94) + String.fromCharCode(43) + String.fromCharCode(98)) + ("" + String.fromCharCode(43) + String.fromCharCode(45) + String.fromCharCode(102)), b = 0; b < S.length; b++)p += S[b], p = n(p, F);
    return p = n(p, D), p ^= s, 0 > p && (p = (2147483647 & p) + 2147483648), p %= 1e6, p.toString() + "." + (p ^ m)
}

function n(r, o) {
    for (var t = 0; t < o.length - 2; t += 3) {
        var a = o.charAt(t + 2); a = a >= "a" ? a.charCodeAt(0) - 87 : Number(a), a = "+" === o.charAt(t + 1) ? r >>> a : r << a, r = "+" === o.charAt(t) ? r + a & 4294967295 : r ^ a
    } return r
}
"""


class TransMachine():
    def __init__(self):
        self.session = requests.session()

    def get_token_and_gtk(self):
        user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4133.0 Safari/537.36 Edg/84.0.508.0"
        headers = {
            'User-Agent': user_agent
        }
        res = self.session.get("https://www.baidu.com/", headers=headers)     # 神也不知道为什么访问百度首页就能避免998
        print(res.cookies)
        url = 'https://fanyi.baidu.com/'
        res = self.session.get(url, headers=headers)
        print(res.cookies)
        res = res.text
        # fh = open('res.html', 'wb')
        # fh.write(res.encode())
        # fh.close()

        token = re.findall("token: '(.*?)'", res)[0]
        gtk = re.findall(".gtk = '(.*?)'", res)[0]

        return token, gtk


    def get_trans(self, words, token, sign):
        check_res = self.session.post(
            "https://fanyi.baidu.com/langdetect", data={'query': words})
        check_json = json.loads(check_res.text)
        lan = check_json['lan']

        if lan == 'zh':
            lan_from = 'zh'
            lan_to = 'en'
        elif lan == 'en':
            lan_from = 'en'
            lan_to = 'zh'
        else:
            return '语言检测错误'

        url = 'https://fanyi.baidu.com/v2transapi'
        form_data = {
            'from': lan_from,
            'to': lan_to,
            'query': words,
            'transtype': 'realtime',
            'simple_means_flag': 3,
            'sign': sign,
            'token': token,
            'domain': 'common'
        }

        user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4133.0 Safari/537.36 Edg/84.0.508.0"
        headers = {
            'User-Agent': user_agent,
            'origin': 'https://fanyi.baidu.com',
            'referer': 'https://fanyi.baidu.com/'
        }

        res = self.session.post(url, data=form_data, headers=headers).content.decode()
        res_json = json.loads(res)
        res = res_json['trans_result']['data'][0]['dst']
        return '翻译结果:' + res


if __name__ == '__main__':
    TM = TransMachine()
    token, gtk = TM.get_token_and_gtk()
    print('token:', token, 'gtk:', gtk)

    # 方括号需要加\
    js_content = re.sub(r"window\[l\]", '"' + gtk + '"', js_content)
    fh = open('res.js', 'w')
    fh.write(js_content)
    fh.close()
    fun = execjs.compile(js_content)

    while True:
        print('=========================================')
        words = input('输入要翻译的内容(中文或英文,输入空白退出):')
        if words == "":
            break
        sign = fun.call('e', words)
        print(TM.get_trans( words, token, sign))

上面为了cookie花了超级久的功夫,但实际上,可以直接手动访问一次,把cookie复制到代码里面,看到这个做法之后人直接傻了🙃