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

事先说明:

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

逆向有道翻译

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

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

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

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

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

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

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

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

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

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

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

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

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函数的内容:

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之后再怼:

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

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

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

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中。

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

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"],它的结构是这样的:

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中的代码:

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解密:

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。安装方法:

1
pip install wxPython

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

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)

运行一下,布局长这样:

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

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()

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

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)

完工!