说实话,我最早开始写爬虫脚本的时候,最烦的就是碰到那种带签名的接口,尤其是MD5签名。你明明在浏览器里看见请求发出去了,参数也都在,但一模一样的参数用Python发出去,对面就是不认账。后来我才意识到,这种接口往往不是参数本身的问题,而是某些参数必须通过加密规则动态生成,少一个、错一个都不行。
有道翻译的网页接口就是这样一个典型的例子。它既不像大型平台那样动不动就搞滑块验证、行为检测,但也不是完全没有防护。它的核心防线恰恰就是一个基于MD5的密钥签名机制。如果你能把这个接口彻底跑通,就等于掌握了一套分析所有同类签名接口的通用方法论。这篇文章我就把整个分析过程和完整代码拆开讲清楚,尤其是ts、salt、sign这些参数到底怎么来、为什么要这么做。
1. 为什么爬虫工程师绕不开有道翻译的密钥接口
1.1 翻译接口是练习接口签名分析的绝佳样本
先聊点实际的。我见过不少刚入门爬虫的朋友,一上来就盯着电商平台、社交平台练手,结果被各种复杂的加密参数、风控策略折磨得怀疑人生。其实对于想搞懂接口签名的人来说,有道翻译网页版是一个绝佳的中间难度样本。
它够真实,是一个完整的线上服务,接口参数是动态生成并参与后端校验的,不是那种本地Mock出来的教学接口。同时它的加密逻辑又足够清晰,核心只涉及MD5散列、时间戳、随机数这几件事,没有任何强混淆或自定义加密算法。花一两个小时把分析思路理顺,比空泛地看十篇“MD5加密原理”的文章更有用。
另外,翻译类接口在实际开发里也有真实需求,比如自己写一个命令行翻译工具、多语言批量处理脚本、或者给内部系统加一个快速翻译通道。虽然有道官方也提供API,但免费额度、申请流程、调用限制都是坎,很多人更愿意自己动手解决。
1.2 先搞清楚“密钥接口”具体指什么
按照标题的字面意思,很多人会误以为有道翻译有一个“获取密钥”的独立接口,调用它就能拿到一个固定的密钥。实际上并不是这样。
这里的“密钥接口”指的是翻译请求中必须携带的一串加密参数,分别是salt(随机盐值)、sign(MD5签名)、lts(毫秒时间戳)、bv(浏览器指纹版本)。每次翻译请求,服务端都会根据这串参数校验请求是否合法、是否来自真实的网页客户端。如果校验不通过,服务端直接返回 error code:50 之类的错误。
所以从本质上看,“获取密钥接口”就是“还原有道翻译前端生成加密参数的逻辑”。我们不是在调用一个密钥服务,而是自己用Python复现出那个签名生成算法。一旦复现成功,我们发的每个翻译请求都像是从正经浏览器里发出去的。
在后面的章节里,我会把这个逻辑一层一层剖开,从抓包看到的具体字段开始,一直到代码完整跑通为止。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从Network面板入手:抓出有道翻译的每次请求密钥参数
2.1 环境准备与抓包前的浏览器设置
在开始分析之前,先准备一下环境。你只需要一个Chrome浏览器,以及本机装好的Python 3环境。我建议先用浏览器打开有道翻译网页版,但先不要输入文字。
然后按F12打开开发者工具,切换到Network(网络)面板。这里有一个非常容易被忽略的选项——Preserve log(保留日志)。建议勾上它,因为翻译请求发出后,页面可能并不会发生跳转,但某些请求会在极短时间内被新的请求替换掉,如果不保留日志,你可能会漏掉关键请求。
准备工作做完后,在翻译输入框里随便输入一句英文,比如“hello world”,然后观察Network面板的变化。你会看到一个名为translate或类似名称的XHR请求刷出来。这个请求就是有道翻译的核心翻译接口,里面的Form Data就是我们后面要逐个还原的参数。
2.2 逐行分析请求头与Form Data
点击那个XHR请求,在右侧面板里切到Headers(请求头)选项卡。往下翻,找到Request Headers区域,同时再往下找Form Data区域。这是整个分析过程的“案发现场”。
以下是抓包后看到的请求头和表单参数(以我当时抓到的版本为例,具体数值会随时间变化,但字段结构基本稳定):
| 参数 | 示例值 | 说明 |
|---|---|---|
| i | hello world | 待翻译的文本 |
| from | AUTO | 源语言 |
| to | AUTO | 目标语言 |
| smartresult | dict | 固定值 |
| client | fanyideskweb | 客户端标识,固定值 |
| salt | 17198318965287 | 一串数字,看起来像时间戳加随机数 |
| sign | 5c4b2f8b6e4c1f0d2c1a0f4c3a1e2d3b | 32位MD5值 |
| lts | 1719831896528 | 13位毫秒时间戳 |
| bv | d7f0a3c2e6b1a9d2e4f5c6a1b2c3d4e5 | 32位MD5值 |
| doctype | json | 固定值 |
| version | 2.1 | 固定值 |
| keyfrom | fanyi.web | 固定值 |
| action | FY_BY_REALTlME | 固定值 |
你注意看这张表里的字段,可以分为两类:一类是永远不变的常量,比如client、keyfrom、action;另一类是动态生成的,比如salt、sign、lts、bv。我们要还原的就是后者。
这里我要特别强调一个方法论上的问题:分析接口签名,切忌拿到参数就盲目猜测。正确做法是先做分类,把每个动态参数可能的生成依据列出来。比如lts很明显是13位时间戳,因为时间戳转毫秒后正好是13位数字;salt看起来是lts后面拼接了一两位数字;bv是一串32位MD5;sign是另一串32位MD5。带着这些初步判断,下一步去JS源码里验证。
3. MD5签名全链路拆解:ts、bv、sign到底怎么算出来的
3.1 lts和salt的时间戳生成逻辑
先说最简单的lts。它的值是13位数字,所以它的生成逻辑基本可以确定是:
python复制import time
lts = str(int(time.time() * 1000)) # 当前毫秒时间戳
这就是一个标准的毫秒级时间戳。接下来看salt,salt看起来像是lts后面多了一两位数字。你可能会想:它是lts + 某个固定后缀吗?答案不是。
我当时的做法是,连续刷新多次翻译请求,观察salt的规律。发现lts每次都在变,salt也每次都在变,二者几乎相等但salt最后多了一到两位数字。经过多次对比,salt可以拆解为lts的13位数字加上一个随机数,最终形成一个15位或16位的字符串。
再结合网上同类分析的经验,以及JS源码里的拼接逻辑,可以确认salt的生成规则是:
python复制import random
salt = lts + str(random.randint(1, 9))
在新版本中,盐值的后面可能拼接一到两位随机数字,本质仍然是一个随机盐值。它的角色是参与最终sign的生成,保证即便两次翻译的内容一模一样,生成的sign也完全不同,从而防止请求被重放或缓存。
3.2 sign的拼接规则与隐藏盐值的发现过程
sign是整个接口的核心。它是一个32位小写十六进制字符串,这正是标准MD5散列的特征。问题是:MD5的输入是什么?
我当时的第一个猜想是:直接把原文i做MD5。试了一下,不对。第二个猜想是:i拼上salt。也不对。
接下来就是最可能找到答案的地方——JS源码。在开发者工具的Sources(源代码)面板里搜索关键词“fanyideskweb”或者“sign”,通常可以找到一段JS代码,里面会有一行字符串拼接操作。搜索有技巧,不要直接搜“sign”,因为它出现的次数太多了。优先搜索固定的常量值,比如client的值“fanyideskweb”。
找到之后,你会发现一段类似这样的逻辑:
javascript复制var e = t.text;
var r = Date.parse(new Date()) / 1000;
var i = r + "" + Math.floor(10 * Math.random());
var n = md5("fanyideskweb" + e + i + "Ygy_4c=r#e#4EX^NUGUc5");
这行代码透露了所有答案:
- e 是待翻译文本
- i 是salt
- 盐值是 Ygy_4c=r#e#4EX^NUGUc5
- sign = MD5("fanyideskweb" + 原文 + salt + 盐值)
于是,对应到Python的sign生成逻辑就是:
python复制import hashlib
sign = hashlib.md5(("fanyideskweb" + text + salt + "Ygy_4c=r#e#4EX^NUGUc5").encode("utf-8")).hexdigest()
这里有一个大坑:盐值不是永远不变的。有道的研发团队会不定期更新这个盐值,网上的很多教程因为写得太早,盐值早就过期了。我写好这个脚本之后,也遇到过某一天突然全部请求返回error code:50的情况,排查了半天,重新抓包发现盐值换了。所以你在实际复现时,如果签名算法报错,第一件事就是去搜索当前JS源码里的盐值,把新的替换上去。
3.3 bv参数的MD5来源
bv参数很多人会忽略,但少了它也会报错。它的值是固定的32位MD5字符串,也就是说它的生成逻辑不是每次动态计算,而是用某个固定字符串做一次MD5。
固定字符串是什么呢?答案是浏览器的User-Agent(UA)。有道前端会取当前浏览器的UA字符串,对它做一次MD5,然后作为bv参数传给服务端。服务端收到之后会对比这个值是否和当前请求头里的UA一致,如果不一致则说明客户端环境异常。
所以生成bv的Python代码是:
python复制import hashlib
user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
bv = hashlib.md5(user_agent.encode("utf-8")).hexdigest()
需要注意的是,你用什么UA请求,就必须用同一个UA做bv,两者必须保持一致,否则会被判定为伪造请求。
3.4 为什么有道要采用这种“客户端ID+时间戳+随机数+盐值”的设计
从工程角度来看,这个签名方案的设计思路非常清晰,值得我们在自己开发接口时借鉴。
- 客户端ID(fanyideskweb):声明请求来源,告诉服务端这是网页端发起的请求,而不是某个服务端脚本。
- 时间戳(lts):声明请求的时效性,服务端可以对比当前时间,如果时间差超过一定阈值就拒绝请求,防止重复抓包后的异步重放。
- 随机盐值(salt):保证每次请求的签名结果都是唯一的,即使同样的词翻译一百遍,sign也完全不同,让简单的缓存欺骗方案失效。
- MD5(sign):把上面这些信息打成不可逆指纹。服务端只需要拿到原始拼接顺序,同样计算一遍MD5,然后和客户端提交的sign做对比,一致就放行,不一致就拒绝。
这套设计真正的高明之处在于:它并不要求客户端密钥绝对保密(因为盐值就在前端代码里),而是通过“必须完整还原拼接顺序才能构造合法请求”来抬高请求伪造的门槛。这就像一把锁,不是为了锁住所有门,而是为了让没有钥匙的人不能大摇大摆走进来。
4. 用Python把有道翻译密钥接口完整跑通
4.1 写代码前的整体设计
在写代码之前,我习惯先把流程画清楚:构造会话 -> 准备参数 -> 生成签名 -> 发起POST请求 -> 解析JSON。这样写代码的时候就不会东一榔头西一棒子。
其中最关键的一点是,在生成lts、salt、sign时,必须确保使用的是同一个时间基点。比如你用time.time()生成了lts,那么salt拼的也应该是由这个lts扩展出来的随机盐值,sign里拼接的也必须是这个salt对应的值。如果三个参数之间时间基点对不上,服务端大概率会判定请求非法。
我建议把这套核心逻辑封装成一个函数,比如python获取有道翻译参数的函数,每次都返回完整的表单数据字典。这样后续无论是做命令行工具还是批量翻译脚本,都能直接复用。
完整的可运行Python脚本如下:
python复制import time
import random
import hashlib
import requests
# 有道翻译网页版的固定盐值,遇到error code:50时建议重新抓包确认
# 注意:这个值如果失效,需要去有道翻译的JS源码里搜索"fanyideskweb"重新获取
YOUDAO_SALT = "Ygy_4c=r#e#4EX^NUGUc5"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://fanyi.youdao.com/",
"Origin": "https://fanyi.youdao.com",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
}
def get_md5(text: str) -> str:
"""计算字符串的MD5值,统一用UTF-8编码"""
return hashlib.md5(text.encode("utf-8")).hexdigest()
def get_form_data(text: str):
"""生成有道翻译接口所需的全部表单参数,包含密钥签名逻辑"""
# bv参数: 对浏览器UA做MD5
bv = get_md5(headers["User-Agent"])
# lts参数: 13位毫秒时间戳
lts = str(int(time.time() * 1000))
# salt参数: 13位时间戳 + 随机一位数字
salt = lts + str(random.randint(1, 9))
# sign参数: MD5("fanyideskweb" + 待翻译文本 + salt + 盐值)
sign = get_md5("fanyideskweb" + text + salt + YOUDAO_SALT)
# 剩下的字段都是固定值
form_data = {
"i": text,
"from": "AUTO",
"to": "AUTO",
"smartresult": "dict",
"client": "fanyideskweb",
"salt": salt,
"sign": sign,
"lts": lts,
"bv": bv,
"doctype": "json",
"version": "2.1",
"keyfrom": "fanyi.web",
"action": "FY_BY_REALTlME",
}
return form_data
def translate(text: str) -> str:
"""向有道翻译API发起请求,返回翻译结果"""
url = "https://fanyi.youdao.com/translate?smartresult=dict&smartresult=rule"
form_data = get_form_data(text)
# 发请求,超时时间建议给5秒,避免无限等待
resp = requests.post(url, data=form_data, headers=headers, timeout=5)
resp.raise_for_status()
# 解析JSON结果
result_json = resp.json()
error_code = result_json.get("errorCode", "未知")
if error_code != 0:
raise RuntimeError(f"翻译失败, errorCode: {error_code}")
# 提取翻译结果
translate_results = result_json.get("translateResult", [])
if translate_results and translate_results[0]:
# 返回结果可能有多行,合并成完整字符串
translated_text = "".join(part.get("tgt", "") for part in translate_results[0])
return translated_text
return ""
if __name__ == "__main__":
result = translate("hello world")
print(result)
直接运行这段脚本,输出结果应该是:
code复制你好,世界
如果你第一次跑就能直接出结果,那说明你手上的盐值还没失效。如果你跑出来是error code:50,别急着怀疑代码,先看下一节。
4.2 几个容易忽略的代码细节
虽然上面的代码看起来不难,但有几个细节会影响成败。
第一,params和data不能乱用。有道翻译这个接口接收的是表单数据,不是URL查询参数。如果你把form_data放到params参数里,服务端收到的就是GET参数,自然不匹配。很多新手都栽在这个地方。
第二,headers里的Content-Type必须是application/x-www-form-urlencoded。requests库在传data字典时会自动设置这个头,但如果你手动改了headers又没加对,服务端解析表单就可能出问题。
第三,resp.json()之前建议先打印一下resp.text看看原始返回。有些情况下接口会返回一段HTML而不是JSON,比如被风控拦截、请求频率过高时。遇到这种情况先别慌,把原始返回内容打出来,通常里面会藏着线索。
4.3 如果返回error code:50该怎么办
error code:50基本可以断定是签名校验失败。可能的原因和排查顺序如下:
- 盐值过期:先去有道翻译网页版搜索JS源码里的“fanyideskweb”,把新的盐值替换进YOUDAO_SALT。这是最可能的原因。
- sign拼接顺序写错:确认顺序是“fanyideskweb” + 翻译文本 + salt + 盐值,缺一个字符都不行。
- lts和salt不一致:salt必须基于当前请求的lts生成,不能先用一个旧lts计算好再缓存复用。
- bv和UA不一致:如果修改了User-Agent,bv必须同步用新UA重新计算。
把这个清单从上到下过一遍,大部分签名问题都能定位。
5. 实测中必踩的坑:请求头、频率控制与返回值解析
5.1 请求头字段缺失导致接口直接拒绝的坑
很多人在复现时只用了一个User-Agent,其他请求头全部省略,然后发现接口时而返回正常、时而返回异常。实际上,有道的服务端对请求头有一定的校验,尤其是Referer和Origin。
如果你的请求既没有Referer也没有Origin,服务端看到的是一个来源不明的POST请求,就可能触发异常行为,轻则返回空结果,重则直接拒绝服务。我之前试过只保留UA不发Referer,第一次请求成功,第二次就开始间歇性返回error code:50。后来把Referer和Origin补上,问题就消失了。
建议把下面的请求头原样带上:
python复制headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://fanyi.youdao.com/",
"Origin": "https://fanyi.youdao.com",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
}
Cookie我实测下来可有可无,但如果你遇到频繁请求被限流的情况,带上浏览器里的真实Cookie会有一定帮助。
5.2 请求频率与封禁策略:签名不是拿来暴力刷的
有道的服务端不是傻子,即使你每次请求的sign都能通过校验,短时间内高频请求同样会被识别为异常流量。它的表现一般是:前几次请求正常,之后突然开始返回error code:50,换参数也没用,过一段时间又自己恢复了。
我实测过,在小规模连续翻译请求下,频率控制在每秒最多一次是安全的。如果是批量翻译几百条文本,建议在每次请求之间加sleep,比如sleep 0.5到1秒。你可能会觉得这样太慢,但比起被封IP、被拉黑十分钟重新等恢复,这点延迟完全值得。
另外,尽量别在代码里开几十个线程同时去请求,这等于给自己找麻烦。如果确实有大并发需求,你应该去考虑官方API,而不是薅网页版的羊毛。
5.3 返回值乱码或结构变化时怎么处理
有道翻译接口的返回是JSON格式,正常情况下字段结构是固定的。你需要提取的翻译结果在translateResult里。但偶尔返回结果会出现一些微妙变化,比如接口把某个字段改个名字,或者返回结果多包了一层。
如果发现解析不到结果,最好的做法是把resp.json()整体打印出来,看它的字段结构。不要只凭网上教程里的结构硬解,因为接口是会演进的。
还有一个小坑:有道返回的JSON里有时会有一些看起来像乱码的Unicode转义序列,这是JSON标准的一部分,Python的json模块会自动解码,不用手动处理。如果你用正则表达式去提取结果反而容易出问题。
6. 这个案例给接口签名逆向带来的通用思路
6.1 “先分类再逐项还原”是接口参数分析的通用路线
把有道翻译这个案例跑通之后,你就会发现,分析任何带签名的接口,本质上都是同一个流程。
第一步,抓包,把所有参数列出来。第二步,给参数分类:常量、时间相关、输入相关、随机数。第三步,去JS源码里搜索常量特征,定位签名生成逻辑。第四步,把签名逻辑翻译成Python代码。第五步,测试,比对返回结果。
这套流程可以平移到很多网站接口上,比如某些工具站的查询接口、学习平台的答题接口、天气查询接口等。只要它们的签名逻辑是基于MD5、SHA1这些常见散列算法,分析思路完全一致。
6.2 MD5签名本质上是“把关键信息打一个不可逆的指纹”
很多人一看到MD5就觉得神秘,其实把它想成一个“指纹生成器”就对了。不管输入多长,输出的长度固定;输入哪怕差一个字符,输出的结果面目全非;并且理论上是不可逆的,你不能从MD5反推出原始输入。
正因为不可逆,所以我们作为调用方,要做的事情不是去“破解”那个MD5串,而是去找到它输入时的原始内容。在某道翻译这个案例里,原始内容就是“fanyideskweb” + 翻译文本 + salt + 盐值。你在JS源码里找到的也正是这行拼接逻辑。把所有类似的接口经验放在一起,你会发现一个规律:签名不可破,但组成签名的原料都在前端代码里摆着,找到原料,签名自然就复现了。
6.3 合法性与工程化建议
这篇文章虽然是在讲如何逆向有道翻译的网页接口,但我还是想多说一句:接口签名这套东西最好用于学习、测试、或者和接口方有明确约定后的技术研究。如果项目里真的有稳定、高并发的翻译需求,购买官方API或者使用官方SDK才是正道。网页接口随时可能调整加密逻辑,今天能用不代表明天能用,把它放在生产环境里风险很高。
我个人的习惯是把这类分析过程沉淀成一份测试记录,保存在自己的代码笔记里,以后遇到类似签名接口时直接参考,而不是依赖某个线上接口去跑业务。
在跑通有道翻译接口之后,我最大的收获不是“终于能免费翻译了”,而是真正建立了对接口签名机制的敏感性。以后再看到一串32位的参数,我会下意识地想:它是不是MD5?输入里会包含哪些字段?盐值藏在哪个JS片段里?
最后再分享一个小技巧:如果你改了一版脚本后总是请求失败,优先检查lts和salt是否是同一个时间基点生成的,这是一个非常容易忽略的细节。很多时候你以为是对面改了加密算法,其实只是你自己的时间戳和随机数没有对上。先确认这点,能省下大量排查时间。
