该文章讲述了如何逆向有道翻译,并且使用 wxPython 库来制作一个便宜翻译器。

事先说明:

  • 本文仅供学习交流,严禁用于商业用途,否则后果自负!
  • 本文仅供学习交流,严禁用于商业用途,否则后果自负!
  • 本文仅供学习交流,严禁用于商业用途,否则后果自负!
  • 你要先有 Python、requests 库和一定的 JS 基础
  • 你要先知道如何使用网页开发者工具

逆向有道翻译

我们首要知道的是有道翻译客户端是如何发送请求给服务器的,才能知道如何逆向它。

随便在有道翻译上输入一个单词,再在网络选项卡里翻找,你会发现有一个请求是这样的:

该请求发送的表单数据中赫然出现了我们输入的单词,这就是我们要找的东西了。

再来看看这个请求的响应:

嗯…… 是一大串加密过的东西,完全看不懂捏。解密的事情之后再说,我们先来想:要如何发出请求才能得到类似的响应呢?

重新来看该请求的表单数据,参数 i 顾名思义便是我们输入的单词的明文,而参数 mysticTime 一眼就能看出是一个时间戳。我们可以此时再输入一个单词,来看看表单数据的哪些参数是不变的、哪些是变化的。

除了时间戳外,还有个参数 sign 也是动态的。也就是说整个请求中,仅有 signmysticTime 是动态的。又因为 mysticTime 是一个时间戳,所以我们只需要关注、逆向 sign 就行了。

既然客户端需要发送请求给服务器,那自然就需要一个加密算法来加密 sign、便于服务器解密。我们可以在网页开发者工具中找到这个加密算法。有道翻译还是很厚道的,一搜索「sign」这个字眼就能找到客户端封装表单数据的函数:

是不是很熟悉?这个函数中的每个变量都能在「webtranslate」这个请求的表单数据里找到。

让我们来分析一下这个函数:

  • o 是一个时间戳
  • sign 值由 k(o, e) 得到,o 是时间戳、e 我们暂且不知道

再来看看 k 函数,还真别说,就在 E 函数的上面:

js
1
2
3
function k(e, t) {
return j(`client=${d}&mysticTime=${e}&product=${u}&key=${t}`)
}

需要注意一点,sign: k(o, e)o 是时间戳、e 是不知名的变量;而到 k 函数中,e 是时间戳,t 才是不知名的变量。
切记不要搞混了!

能看出,被传入 k 函数中的不知名的变量实际上是 key,并且还多了两个不知名的变量 du。我们可以直接打断点,看看这些变量的值:

再走一步我们便能看到 k 函数的两个参数。e 属实是时间戳,千万不要混淆了!

停在该断点,我们可以看到 du 的值:

相当于在 k 函数中,会生成这么一个字符串:client=fanyideskweb&mysticTime=${t}&product=webfanyi&key=fsdsogkndfokasodnaso,仅有时间戳是动态,我们无脑塞时间戳就完事了。

但是!这个字符串被生成后,还会被传入 j 函数进行加密,所以我们还需要看看 j 函数的内容:

js
1
2
3
function j(e) {
return i.a.createHash("md5").update(e.toString()).digest("hex")
}

嗯…… 无比熟悉有木有,这就是 MD5 加密算法;j 函数把 k 函数拼凑的字符串进行了 MD5 加密(注意是 hex 格式的),然后返回加密后的结果。

继续走两步,E 函数到了尾声,果断打开控制台查询一下 sign 的值,正是加密后的内容,而不是一串明文:


继续码字的时候页面被刷新了,所以 sign 值和用上面的时间戳以及 key 加密出来的结果不同,懒得再截图了,大家自己试试吧。

至此我们已经搞清楚了有道翻译客户端是拿着什么参数去请求服务器的,接下来我们可以用 Python 来模拟这个请求。

Python 模拟请求

比起直接用 requests.post(),我们这次要使用 requests.session.post(),否则不会成功;headersdata 之后再怼:

python
1
2
3
session = requests.session()
session.get('https://fanyi.youdao.com/')
res = session.post('https://dict.youdao.com/webtranslate')

headers 的内容可以直接从开发者工具那边复制,别忘了加上 Referer

data 的内容我们需要模拟一下:

python
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
# 时间戳
# 因为JS的时间戳是毫秒级的,而Python的是秒级的,所以要乘以1000
t = str(int(time.time() * 1000))

# 加密和构建sign值
s = f'client=fanyideskweb&mysticTime={t}&product=webfanyi&key=fsdsogkndfokasodnaso'
sign = hashlib.md5(s.encode()).hexdigest()

data={
'i': word, # 要翻译的单词
'from': 'auto',
'domain': 0,
'dictResult': 'true',
'keyid': 'webfanyi',
'sign': sign,
'client': 'fanyideskweb',
'product': 'webfanyi',
'appVersion': '1.0.0',
'vendor': 'web',
'pointParam': 'client,mysticTime,product',
'mysticTime': t,
'keyfrom': 'fanyi.web',
'mid': 1,
'screen': 1,
'model': 1,
'network': 'wifi',
'abtest': 0,
'yduuid': 'abcdefg'
}

接着来看一下响应的内容~

居然是一大串看不懂的东西?!当然,相信大家看到这一大串中结尾的 = 时,应该就知道这是 base64 编码了。我们可以用 Python 来解码,不过还要插一句题外话:这个是 base64 变种,叫做 URL-safe base64,它把 +/ 换成了 -_。这个变种的诞生是为了让 base64 编码后的内容能够安全地放在 URL 中。

简单来说就是:我们还需要把 -_ 换回来才能解码:

python
1
2
res = res.replace('-', '+').replace('_', '/')
data = base64.b64decode(res)

再次运行,好家伙,得到了一串更过分的字节。

解密

既然有道翻译客户端已经准备好了表单数据,那证明这之后它就会发送请求给服务器,我们且看客户端是如何发送请求、又如何处理响应的。

继续往下走一步,我们就能看到客户端发送请求的代码了:

中间的链接是不是很熟悉?再看看右边的 E(t),这正是会返回 sign 值的 E 函数。再用控制台一一检查过去,会发现老面孔和新面孔:

  • (e,t) 是 key 值
  • a["d"] 返回一个 Promise 对象,等下细说
  • e 是表单数据的一部分,里面包含了我们要翻译的单词的明文
  • n["a"] 可以不深究,你只需要知道它可以把多个对象合并成一个。事实上,它的作用就是把 eE(t) 返回的两个表单数据合并成一个

这里说一下 a["d"],它的结构是这样的:

js
1
2
3
4
5
6
7
8
9
10
11
12
function l(e, t, o) {
return new Promise((n,c)=>{
a["a"].post(e, t, o).then(e=>{
n(e.data)
}
).catch(e=>{
c(e)
}
)
}
)
}
  • e 是请求的链接
  • t 是表单数据
  • o 是请求头,也就是上上图中那个孤零零的 "Content-Type": "application/x-www-form-urlencoded"

如果你不了解 Promise,那么你可以把它理解成一个异步函数,它的作用是等待 a["a"].post(e, t, o) 这个请求完成,然后返回响应的内容。
如果这个请求成功了,那么 e 就是响应的内容,给到 n();如果这个请求失败了,那么 e 就是错误信息,给到 c()

n()c() 的内容都是空函数,所以我们不需要管它们。

那么 a["d"] 返回的 Promise 对象去了哪里咧?回到客户端发送请求的那一步(也就是上上图的 I = (e,t)=>略略略),看开发者工具的调用堆栈

调用堆栈:在开发者工具中你可以看到每个函数被调用的顺序。

点击 Po 便会自动引导到这个函数的定义处:

Mo["a"].getTextTranslateResult() 正是函数 I

刚才提到的 a["d"] 所返回的 Promise 对象去了此处的 Mo["a"].getTextTranslateResult(),服务器成功响应,走到 then,也就是我们解密的关键:有道翻译客户端对这个响应做了什么处理?

我们来看一下 then 中的代码:

js
1
2
3
4
5
6
7
.then(o=>{
Mo["a"].cancelLastGpt();
const n = Mo["a"].decodeData(o, Ko["a"].state.text.decodeKey, Ko["a"].state.text.decodeIv)
, a = n ? JSON.parse(n) : {};
0 === a.code ? e.success && t(e.success)(a) : e.fail && t(e.fail)(a)
}
)
  • Mo["a"].cancelLastGpt() 不需要管,感兴趣的可以自己去看看
  • const n = Mo["a"].decodeData...略 才是我们要看的,decodeData,是不是立即就明白这行代码的作用了?

有道翻译客户端使用了 Ko["a"].state.text.decodeKeyKo["a"].state.text.decodeIv 来解密响应的内容。Key 和 Iv,好好好,这不就是 AES 加密算法吗?!

打开控制台打印一下这两个变量的值:

万事俱备,只欠东风,让我们最后看一眼 Mo["a"].decodeData() 函数:

  • 接收 ton 三个参数,分别代表了 base64 编码后的数据、用于解密的 key 和用于解密的 iv
  1. 分别将解密用的 key 和 iv 转化为 16 进制的字节
  2. 用 key 和 iv 创建一个 AES 解密器 r
  3. 用解密器 r 解密 base64 编码后的数据 t,得到 JSON 数据,也就是原始的响应数据
  4. 最终返回明文

搞清楚逻辑后我们便可以继续用 Python 解密:

python
1
2
3
4
5
6
7
8
9
10
11
12
13
def digest_key(value):
md5_new = hashlib.md5()
md5_new.update(val.encode())
return md5_new.digest()

o = 'ydsecret://query/key/B*RGygVywfNBwpmBaZg*WT7SIOUP2T0C9WHMZN39j^DAdaZhAnxvGcCY6VYFwnHl'
n = 'ydsecret://query/iv/C@lZe2YzHtZ2CYgaXKSVfsb7Y4QWHjITPPZ0nQp87fBeJ!Iv6v^6fvi2WN@bYpJ4'

key = digest_key(o)
iv = digest_key(n)

aes = AES.new(key, AES.MODE_CBC, iv)
data = aes.decrypt(data).decode()

得到的结果:

制作翻译器

经过以上逆向过程,我们还可以制作一个翻译器,让它能够翻译我们输入的单词。

首先我们需要一个 GUI 库,这里我选择了 wxPython。安装方法:

bash
1
pip install wxPython

其次我们需要定义一个窗口类,该类继承自 wx.Frame

python
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
class MyFrame(wx.Frame):
def __init__(self, parent, title):
# 调用父类的构造函数
super(MyFrame, self).__init__(parent, title=title, size=(400, 250))

# 创建一个面板
panel = wx.Panel(self)

# 左侧文本框
input_text = wx.TextCtrl(panel, style=wx.TE_MULTILINE, size=(150, 150))

# 右侧文本框
output_text = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY, size=(150, 150))

# 按钮
button = wx.Button(panel, label='翻译', size=(50, 25))

# 水平布局
sizer = wx.BoxSizer(wx.HORIZONTAL)
# 添加输入框和输出框
sizer.Add(input_text, 1, wx.EXPAND)
sizer.Add(output_text, 1, wx.EXPAND)

# 垂直布局
inner_sizer = wx.BoxSizer(wx.VERTICAL)
# 添加水平布局和按钮
inner_sizer.Add(sizer, 0, wx.ALL | wx.EXPAND, 10)
inner_sizer.Add(button, 0, wx.ALIGN_CENTER)

# 设置面板的布局
panel.SetSizerAndFit(inner_sizer)

运行一下,布局长这样:

用户需要在左侧输入单词、点击按钮,右侧才能够显示翻译的结果。因此我们还需要给按钮绑定一个事件:

python
1
2
3
4
5
6
7
8
9
10
11
12
    # ...继def __init__()的内容...
# 绑定按钮事件
self.Bind(wx.EVT_BUTTON, self.on_button_click, button)

# 当按钮被点击时,执行该函数
def on_button_click(self, event):
# 获取输入框和输出框
input_text = self.FindWindowById(event.GetId()-2)
output_text = self.FindWindowById(event.GetId()-1)

# 获取输入框的内容
word = input_text.GetValue()

接下来的内容我相信大家都心知肚明,正是上述的所有内容的结合体。我把那些代码封装成了函数塞在按钮事件中,大家则可以自己动动手、试试看(绝对不是因为我懒)。

python
1
2
3
4
5
6
7
8
9
# 继def on_button_click()的内容
# 发送请求
res = request(word)
# 将响应用base64解码
data = decode_result(res)
# 解密解码后的响应,获得JSON数据中的翻译结果
word = decrypt_data(data)
# 设置输出框的内容
output_text.SetValue(word)

完工!