1. 为什么我要自己动手做一个翻译客户端
市面上的翻译工具其实不少,浏览器插件、在线网页、手机App,随手一搜一大把。但用久了总会遇到几个让人难受的点:网页版广告多、接口限流、复制粘贴来回切换窗口效率低;客户端软件要么收费,要么功能臃肿,要么干脆不支持我需要的语种组合。我平时看外文资料比较多,对翻译的响应速度和稳定性要求挺高,于是就动了念头——干脆自己写一个。
这个项目的核心思路很直接:找到某个在线翻译服务在浏览器里实际调用的Web接口,分析它的请求结构,搞清楚参数是怎么拼的、签名是怎么算的,然后用代码模拟这个请求,把翻译结果拿回来,封装成一个轻量的桌面客户端。整个过程涉及抓包分析、请求参数拆解、签名算法还原、接口调用封装、客户端界面搭建这几个环节,是一条完整的Web接口逆向链路。
说白了,这就是一次“从浏览器里偷师”的实践。你不需要有服务端的配合,也不需要官方开放API,只要浏览器能正常访问的功能,理论上都可以通过这种方式把它“搬”到自己的程序里。当然,这里要强调一点:本文讨论的技术仅用于个人学习和研究,实际使用中请遵守目标服务的使用条款,不要用于商业用途或大规模请求。
适合读这篇内容的人:有一定编程基础、对网络请求和HTTP协议有基本了解、想学习接口逆向分析思路的开发者。如果你完全没接触过抓包工具,也没关系,我会把每一步都拆开讲清楚。前端、后端、移动端的朋友都能从中找到有用的东西,因为接口逆向这套方法论是通用的。
我选择翻译服务作为练手对象,是因为它的请求结构相对清晰,返回数据格式规整,适合作为逆向入门的案例。而且做出来的东西确实能日常使用,有正反馈,不像有些练习项目做完就扔了。下面我把整个过程的思路、踩过的坑、关键细节都摊开来聊。
2. 整体方案设计与技术选型思路
2.1 为什么选Web接口而不是官方API
做翻译客户端,第一条路是走官方开放平台。注册账号、申请密钥、按量付费,流程正规,文档齐全。但问题也很明显:免费额度有限,超出后要充值;有些平台的接口调用有QPS限制,频繁查询会被限流;还有就是注册流程繁琐,对于只是想练手或者个人轻度使用的人来说,成本偏高。
第二条路就是直接分析Web端调用的接口。浏览器里能用的翻译功能,背后一定有一个HTTP请求在跑。把这个请求的结构搞清楚,用代码复现出来,就能拿到同样的结果。这条路的好处是:不需要注册、不需要密钥、请求结构相对固定。坏处是:接口可能随时变动,没有官方保障,稳定性依赖对方。但对于个人学习和轻量使用来说,这条路性价比最高。
我最终选了后者。核心考量是:这个项目的目的是学习接口逆向的完整方法论,而不是做一个商业级产品。走官方API虽然省事,但学不到东西。自己分析接口,从抓包到签名还原,每一步都是实打实的技术积累。
2.2 技术栈选择:Python做核心,PyQt做界面
语言层面我选了Python。原因很简单:处理HTTP请求、解析JSON、做字符串加密,Python的生态最顺手。requests库发请求,json库解析返回,hashlib做摘要,execjs或者纯Python复现JS加密逻辑,整套工具链成熟且轻量。
界面层面考虑过几个方案。Web技术栈(Electron之类)太重,打包出来几百兆,对于一个翻译小工具来说没必要。Tkinter太简陋,做出来的界面不好看。最后选了PyQt5,原因是:组件丰富、跨平台、打包相对方便,而且Python和Qt的结合很成熟,网上资料多,遇到问题好查。
整体架构分三层:网络请求层负责构造请求、处理签名、发送HTTP调用;业务逻辑层负责翻译结果的解析、缓存、历史记录管理;界面层负责输入输出、快捷键响应、托盘图标等交互。三层之间通过信号槽或者简单的函数调用通信,耦合度低,方便单独调试。
2.3 逆向分析的基本流程
在动手写代码之前,得先把目标接口摸清楚。我的分析流程是这样的:
- 打开浏览器开发者工具,切到Network面板,勾选Preserve log,过滤XHR/Fetch请求。
- 在翻译页面输入一段测试文本,触发翻译动作,观察新出现的网络请求。
- 定位到翻译接口,查看请求URL、请求方法、请求头、请求体。
- 分析请求参数,区分哪些是固定值、哪些是动态生成的、哪些是跟输入内容相关的。
- 重点排查签名字段,看是否有
sign、token、salt之类的参数,如果有,去JS源码里找生成逻辑。 - 用代码复现请求,先不带签名发一次,看返回什么错误,再逐步补全参数,直到拿到正确结果。
这个流程看起来简单,但实际操作中会有很多细节需要处理。比如请求头里的Referer、Origin、User-Agent可能都有校验;比如签名参数可能是多个字段拼接后再做哈希;比如时间戳的精度可能是秒也可能是毫秒。这些都要在分析阶段确认清楚。
3. 抓包分析与接口结构拆解
3.1 定位翻译请求的关键技巧
打开开发者工具之后,第一件事是清空现有请求记录,然后输入一段有明显特征的测试文本,比如“这是一个测试句子abc123”。这样做的目的是方便在请求列表里快速定位——你可以在Filter框里直接搜“abc123”,如果请求体里包含这个字符串,就能立刻找到目标请求。
我实测下来,翻译类接口通常是POST请求,Content-Type一般是application/x-www-form-urlencoded或者application/json。请求URL里往往带有translate、trans、fanyi之类的关键词。找到候选请求后,点进去看详情,重点看三个地方:Payload(请求体)、Headers(请求头)、Response(返回值)。
有一个容易忽略的点:有些网站会把翻译请求放在iframe或者Web Worker里发,这种情况下在主的Network面板可能看不到,需要在开发者工具的设置里勾选“Show requests from iframes”或者切换到对应的上下文。我一开始就踩过这个坑,找了半天没找到请求,后来发现是在一个隐藏的iframe里发的。
3.2 请求参数的分类与含义
定位到请求之后,把Payload里的参数全部列出来,逐个分析。以我分析的这个翻译接口为例,请求体大概长这样:
i=hello world from=auto to=zh sign=xxxxx salt=1234567890 ts=1234567890123我把它分成三类:
第一类是业务参数,比如i是待翻译的文本,from是源语言,to是目标语言。这些参数直接跟用户输入相关,含义明确。
第二类是控制参数,比如from=auto表示自动检测源语言。这类参数通常是枚举值,可以在JS代码里找到所有可选值。
第三类是安全参数,比如sign、salt、ts。这三个是重点。ts看起来是时间戳,salt是随机盐值,sign是签名。签名的存在意味着服务端会校验请求的合法性,如果签名不对,请求会被拒绝。
注意:不同平台的参数命名可能不同,有的叫
token,有的叫nonce,有的叫signature。但核心逻辑是一样的——都是为了防止接口被随意调用。
3.3 签名参数的生成逻辑还原
找到签名参数之后,下一步是搞清楚它是怎么算出来的。方法很简单:在开发者工具的Sources面板里,全局搜索sign这个关键词,或者搜索salt、ts这些参数名。通常能定位到发起请求的那段JS代码。
我定位到的代码逻辑大致是这样的:
var salt = Date.now(); var sign = md5("appid" + i + salt + "secretKey");翻译成人话就是:取当前时间戳作为salt,然后把一个固定的appid、待翻译文本、salt、以及一个固定的密钥拼接起来,做一次MD5哈希,结果就是sign。
这里有几个关键点需要确认:
- 拼接顺序:是
appid + i + salt + key还是i + appid + salt + key?顺序不同,结果完全不同。这个必须从JS代码里精确还原。 - 哈希算法:是MD5、SHA1还是SHA256?代码里写的是
md5,那就用MD5。 - 密钥来源:那个
secretKey是硬编码在JS里的,还是从某个接口动态获取的?如果是硬编码,直接抄过来就行;如果是动态获取,还得再分析一层。 - 时间戳精度:
Date.now()返回的是毫秒级时间戳。如果服务端校验时间戳的有效期(比如5分钟内),那客户端的时间必须准确。
我遇到的一个坑是:JS里的字符串拼接用了+,但某些参数可能是数字类型,拼接时会自动转换。在Python里复现时,要确保类型转换一致,否则算出来的MD5会对不上。比如JS里123 + "abc"结果是"123abc",Python里如果直接拼接int和str会报错,得先str()转换。
3.4 请求头中的隐藏校验
除了请求体里的签名,请求头里也可能有校验字段。常见的有:
Referer:必须是目标网站的域名,否则可能被拒绝。Origin:同上,用于跨域校验。User-Agent:有些服务会检查UA,如果是脚本类的UA可能被拦截。Cookie:可能包含会话标识,某些接口需要登录态才能调用。X-Requested-With:有些接口要求必须是XMLHttpRequest。
我在复现请求时,先把浏览器里看到的请求头全部照抄到代码里,确保能跑通,然后再逐个删减,看哪些是必须的、哪些可以省略。这样既能保证功能可用,又能让代码尽量简洁。
实测下来,Referer和User-Agent通常是最关键的,Cookie看情况——如果接口不需要登录,一般可以不带。但有些服务会通过Cookie做频率限制,这种情况下带上Cookie反而可能被限流,需要根据实际情况调整。
4. 用Python复现请求并封装翻译功能
4.1 构造签名与请求体
搞清楚签名逻辑之后,用Python复现就是体力活了。核心代码如下:
import hashlib import time import requests APPID = "固定值" SECRET_KEY = "从JS里抄来的密钥" def make_sign(text, salt): raw = APPID + text + str(salt) + SECRET_KEY return hashlib.md5(raw.encode("utf-8")).hexdigest() def translate(text, from_lang="auto", to_lang="zh"): salt = int(time.time() * 1000) sign = make_sign(text, salt) data = { "i": text, "from": from_lang, "to": to_lang, "sign": sign, "salt": salt, "ts": salt, } headers = { "User-Agent": "Mozilla/5.0 ...", "Referer": "https://目标网站/", "Origin": "https://目标网站", } resp = requests.post(API_URL, data=data, headers=headers, timeout=10) return resp.json()这段代码里有几个细节值得展开说。
时间戳的处理:JS里Date.now()返回毫秒级整数,Python里用int(time.time() * 1000)来模拟。注意不要用time.time()直接取整,那样是秒级的,跟JS对不上。
编码问题:MD5计算前要把字符串编码成bytes。JS内部是UTF-16,但做MD5时通常按UTF-8处理。Python里显式指定encode("utf-8"),避免中文文本出问题。
请求超时:一定要设timeout,否则网络异常时程序会卡死。我一般设10秒,翻译这种轻量请求足够了。
异常处理:实际使用中网络抖动、接口返回格式变化、签名失效都可能发生,要用try-except包起来,给用户友好的提示,而不是直接崩溃。
4.2 返回结果的解析与容错
接口返回的通常是JSON格式,结构大概是这样:
{ "code": 0, "data": { "translations": [{"translatedText": "你好世界"}] } }解析的时候不能想当然,要做好容错。比如code不为0时表示出错,要把错误信息提取出来;比如data里可能没有translations字段,要用.get()方法安全访问;比如返回的可能是HTML转义过的文本,需要做反转义处理。
我踩过的一个坑是:某些翻译结果里包含HTML实体,比如&、<,直接显示会很难看。解决办法是用html.unescape()处理一下。还有一个坑是:长文本翻译时,接口可能对单次请求的字符数有限制,超长文本需要分段发送,然后把结果拼接起来。分段的时候要注意按句子边界切分,不要从单词中间截断。
4.3 缓存与频率控制
翻译接口通常有频率限制,短时间内大量请求可能被临时封禁。为了减少不必要的请求,我加了两层保护:
第一层是本地缓存。用字典或者SQLite存一份“原文+源语言+目标语言 → 译文”的映射。每次翻译前先查缓存,命中就直接返回,不再发请求。缓存可以设一个过期时间,比如24小时,避免长期占用存储。
第二层是请求间隔控制。在两次请求之间加一个最小间隔,比如200毫秒。用time.sleep()实现,简单粗暴但有效。如果用户连续输入,可以做一个防抖处理——等用户停止输入500毫秒后再触发翻译,而不是每敲一个字符就发一次请求。
提示:缓存键的构造要注意,
from和to语言不同,同一个原文的译文是不同的,所以缓存键必须包含语言对信息。
4.4 封装成可复用的翻译类
把上面的逻辑封装成一个类,方便界面层调用:
class Translator: def __init__(self): self.cache = {} self.last_request_time = 0 self.min_interval = 0.2 def translate(self, text, from_lang="auto", to_lang="zh"): cache_key = f"{from_lang}:{to_lang}:{text}" if cache_key in self.cache: return self.cache[cache_key] # 频率控制 elapsed = time.time() - self.last_request_time if elapsed < self.min_interval: time.sleep(self.min_interval - elapsed) # 发请求 result = self._do_request(text, from_lang, to_lang) self.last_request_time = time.time() self.cache[cache_key] = result return result这样界面层只需要调用translator.translate(text),不用关心签名、缓存、频率控制这些细节。职责分离,代码也好维护。
5. 客户端界面搭建与交互优化
5.1 界面布局设计
PyQt5做界面,核心是布局管理。我的设计很简单:上面一个多行输入框,下面一个多行输出框,中间放一排按钮(翻译、清空、复制、交换语言),底部一个状态栏显示当前状态。
用QVBoxLayout做垂直布局,把输入框、按钮行、输出框依次放进去。按钮行用QHBoxLayout做水平布局。输入框和输出框都用QTextEdit,支持多行文本和富文本显示。
窗口大小设一个合理的默认值,比如600x500,同时允许用户自由调整。设置setMinimumSize防止窗口被缩得太小导致控件重叠。
5.2 快捷键与托盘功能
翻译工具的使用场景往往是“随手查一下”,所以快捷键很重要。我设置了两个:
Ctrl+Enter:触发翻译。Ctrl+Shift+T:全局快捷键,在任何窗口下按下都能唤起翻译窗口并聚焦到输入框。
全局快捷键在PyQt里需要用QHotkey或者调用系统API实现,稍微麻烦一点,但体验提升很大。托盘图标也是同理,最小化到托盘而不是任务栏,需要的时候点一下托盘图标就能唤出窗口。
5.3 异步请求避免界面卡顿
这是新手最容易犯的错误:在界面线程里直接发HTTP请求。网络好的时候可能感觉不到,一旦网络慢或者接口响应慢,整个界面就会卡死,按钮点不动,窗口拖不动,用户体验极差。
正确的做法是把翻译请求放到单独的线程里执行,完成后通过信号槽通知界面更新。PyQt5里可以用QThread配合自定义信号:
class TranslateWorker(QThread): finished = pyqtSignal(str) def __init__(self, translator, text): super().__init__() self.translator = translator self.text = text def run(self): result = self.translator.translate(self.text) self.finished.emit(result)界面层创建worker,连接finished信号到更新输出框的槽函数,然后start()。这样界面始终保持响应,用户可以看到“翻译中...”的提示,而不是整个窗口假死。
5.4 打包与分发
开发完成后,用PyInstaller打包成单个可执行文件。命令大概是这样:
pyinstaller --onefile --windowed --icon=app.ico main.py--onefile把所有依赖打成一个文件,方便分发;--windowed去掉控制台窗口;--icon设置程序图标。打包出来的文件体积大概几十兆,对于个人工具来说可以接受。
需要注意的是,如果代码里用了execjs调用外部JS引擎,打包时要确保相关依赖也被包含进去。纯Python实现的签名逻辑就没有这个问题,这也是我推荐用纯Python复现签名的原因之一。
6. 常见问题与排查技巧实录
6.1 签名校验失败的排查思路
签名对不上是最常见的问题。排查步骤:
- 确认拼接顺序:回到JS代码,逐字符核对拼接顺序,包括分隔符(有没有
&、|之类的连接符)。 - 确认哈希算法:是MD5、SHA1还是SHA256?输出是十六进制还是Base64?
- 确认编码:中文字符串在拼接前是否做了URL编码?是否做了UTF-8编码?
- 确认时间戳:服务端校验的时间戳是秒级还是毫秒级?客户端时间是否准确?
- 打印中间结果:在JS里
console.log出拼接后的原始字符串和最终签名,在Python里也打印出来,逐字符对比。
我遇到过一次签名对不上的情况,排查了半天发现是JS里用了encodeURIComponent对文本做了编码,而我在Python里没做。加上urllib.parse.quote()之后问题解决。
6.2 接口返回异常的状态码处理
接口可能返回各种异常状态:
| 状态码/错误码 | 含义 | 处理方式 |
|---|---|---|
| 200 + code=0 | 成功 | 正常解析译文 |
| 200 + code!=0 | 业务错误 | 提取错误信息提示用户 |
| 429 | 请求过于频繁 | 增加请求间隔,稍后重试 |
| 403 | 签名无效或权限不足 | 检查签名逻辑,确认请求头完整 |
| 500 | 服务端错误 | 提示用户稍后重试 |
| 超时 | 网络问题 | 重试一次,仍失败则提示检查网络 |
对于429,除了增加间隔,还可以考虑在本地做更严格的频率限制,比如每分钟最多请求N次。对于403,优先检查签名和请求头,而不是怀疑网络问题。
6.3 接口变动后的快速适配
Web接口随时可能变动,这是走这条路必须接受的风险。为了降低维护成本,我把所有跟接口相关的配置抽到一个单独的配置文件里:
API_URL = "https://..." APPID = "..." SECRET_KEY = "..." SIGN_TEMPLATE = "{appid}{text}{salt}{key}"这样接口变动时,只需要改配置,不用动核心代码。如果签名逻辑变了,那就得重新分析JS,更新make_sign函数。为了快速定位问题,我在代码里加了详细的日志,记录每次请求的参数和返回,出问题时一看日志就知道是哪一步不对。
6.4 实操心得与避坑清单
最后分享几条我踩过坑之后总结的经验:
- 不要硬编码时间戳精度:先用秒级试,不行再换毫秒级,或者直接看JS代码里用的是
Date.now()还是Math.floor(Date.now()/1000)。 - 请求头能抄就抄:分析阶段把浏览器里的请求头全部复制过来,跑通之后再逐个删减,不要一开始就自己精简。
- 文本编码要统一:从输入框拿到的是Unicode字符串,发请求前统一编码成UTF-8,解析返回时也按UTF-8处理。
- 缓存要设上限:字典缓存如果不清理,长时间运行会占用大量内存。可以设一个最大条目数,超出后按LRU策略淘汰。
- 异常要兜底:任何网络请求都可能失败,任何JSON解析都可能出错,用
try-except包住,给用户一个能看懂的提示,而不是一堆traceback。 - 测试要覆盖边界:空字符串、超长文本、特殊字符、emoji、多语言混合,这些都要测一遍,确保不会崩溃。
这个项目从分析到完成大概花了我两个周末的时间,其中大部分时间花在签名分析和异常处理上。真正写代码的时间反而不多。如果你也想动手做一个类似的工具,我的建议是:先花时间把接口分析透彻,把签名逻辑用最笨的方法(打印中间结果逐字符对比)确认无误,然后再写代码。分析阶段多花一小时,编码阶段能省三小时。
后续如果想扩展,可以考虑加入多引擎切换(同时接入几个翻译源,取最优结果)、划词翻译(监听鼠标选中事件)、OCR翻译(截图识别文字再翻译)等功能。不过那是另一个话题了,先把核心链路跑通再说。