自己做翻译插件?说实话,之前我一直觉得没必要,浏览器商店里现成的翻译扩展一抓一大把,装一个就能用。但用久了,问题就来了:免费版时不时弹窗、收集浏览记录、翻译质量在专业术语上非常拉胯,尤其是遇到代码、技术文档、医学术语,翻出来完全没法看。后来大模型API火起来,我试着用免费额度搭了一个属于自己的网页翻译插件,没想到效果出奇好,整个过程还基本零成本。这篇文章就把我的完整做法写出来——怎么选免费大模型API、怎么写浏览器插件、怎么调提示词、怎么避开各种坑,一步步讲清楚。
这套方案适合三类人:一是对翻译质量和隐私有要求的普通用户,二是不想给翻译软件订阅付费的学生或开发者,三是想练手浏览器插件和大模型API调用的技术人员。你不需要有很深的编程基础,有一点JavaScript经验就能跟下来。整个插件的核心其实就是三件事:把网页文字抓出来、把文字发给大模型翻译、再把译文替换回去。听起来简单,但每个环节都有值得抠的细节,我会在下面逐一展开。
1. 整体思路拆解:为什么免费API能做翻译
1.1 现成翻译工具有哪些绕不开的痛点
先说说我为什么非要自己折腾。浏览器上主流的翻译扩展,大多走的是传统机器翻译引擎,比如统计翻译模型,译出来的句子“能看但不够准”,碰到长句经常语序混乱。你要想得到更自然的译文,就得开会员,而会员费一年算下来并不便宜。更让人介意的是隐私——很多在线翻译工具会把整个网页文本、表单内容发到云端,你浏览了什么、填了什么,它都一清二楚,免费版还会拿这些数据去做模型训练。
自己搭一个翻译插件,最直接的好处是可控:发给哪个API完全由你决定,不想要隐私风险的可以选数据政策更友好的服务商;翻译术语可以自定义,比如我把“memory”统一翻译成“内存”而不是“记忆”;界面可以做成自己习惯的样子,不用忍受广告。而且用免费大模型API的额度,日常浏览外文网站的翻译量完全够用,选对服务商的话,一个月下来账单是0元。
1.2 免费大模型API翻译的核心逻辑
大模型翻译和传统机器翻译有本质区别。传统模型更多是短语替换和规则重组,而大模型靠的是在海量语料上学到的语义理解,它会把整句话的意思“读懂”,再重新组织成自然的目标语言。所以用大模型翻译长难句、专业术语、口语表达,效果明显更好。
用API的方式实现零成本,目前主要有两个路径。一个是使用大模型服务商提供的免费额度,比如新用户注册会送几十万到几百万token,一般能用挺长时间。另一个是选择服务商开放的低价模型,按量计费非常便宜,翻译一段普通网页文本可能只要几厘钱,几乎感知不到成本。这两种方式都能做到“不用额外花钱”。
我的做法是优先用免费额度,然后把插件做成一个“按需请求”的模式——不整页翻译,只翻译你正在看的可见区域。这样一来,每次推送给API的token量很小,免费额度消耗得非常慢,真正意义上实现了零成本。后面我会讲到怎么控制这个“按需请求”的细节。
1.3 这个方案的边界与预期管理
在做之前,先把预期设对。用免费大模型API搭的翻译插件,不是万能的,它有下面几个边界:第一,必须联网,因为API请求要经过网络,不适合完全离线环境;第二,免费额度通常有速率限制,一次性翻译大量内容可能被限流;第三,大模型的上下文窗口有限,不能把整本小说一次性丢进去,需要分片处理;第四,插件需要一定的浏览器权限,安装时浏览器会弹出提示,这是正常现象。
想清楚这些边界,就不会在用的过程中产生不切实际的期望。我这个方案主要的定位是“日常网页阅读的顺滑翻译”,不是“专业级出版翻译”。如果你的需求是后者,那还是需要人工译校。但就日常浏览英文资料、读技术文档、看新闻网站来说,这套插件完全够用,而且比很多商业翻译扩展的默认效果更自然。
2. 免费大模型API选型与准备
2.1 我现在在用的几个免费API服务商
市面上的大模型API服务商不少,免费政策也经常调整,我这里分享的是我自己的实测选择标准,具体免费额度请以各家官方文档为准。我不会给你一个个链接,只讲选型思路和大致区别,你自己去平台注册一下就能看到最新政策。
我在实际测试中比较常用的有三类:第一类是以DeepSeek为代表的开源模型服务商,中文翻译质量好,API价格非常低,新用户经常有免费体验额度,上下文窗口一般比较大,适合处理长文本;第二类是智谱AI、Kimi这类国内平台,会提供一些免费模型,个人用户注册能领到一定额度的token,适合做轻量级翻译;第三类是海外平台的免费额度,同样可以用,但对国内网络的调用延迟可能稍高,你自己根据实际网络情况判断。
我的建议是不要只盯着一家。因为API免费活动变化很快,今天这家免费送得多,下个月可能就调整了。最稳妥的做法是在插件里把服务商做成可配置的,等到哪家的额度用完,换一家改一下配置就行。下面的代码会展示如何用抽象接口做到这一点。
2.2 获取API Key并安全管理
获取API Key的流程都差不多:登录服务商的开放平台,在控制台创建一个API Key,复制保存。这里有几个经验教训,必须单独列出来。
第一,API Key是敏感信息,绝对不能写死在插件代码里。尤其是如果你打算把项目传到GitHub,一旦Key泄露,别人就能用你的额度疯狂调用,轻则额度被刷光,重则产生费用。第二,浏览器的扩展其实没有绝对安全的本地存储方式,我的做法是把Key存在chrome.storage.local,并且通过配置页让用户自己填写,而不是硬编码在代码里。第三,很多平台支持创建多个Key并设置限额,建议你专门为这个翻译插件创建一个Key,同时设置单日消费上限,即使泄露也能把损失控制在零。这招非常管用。
在实际操作中,我还会顺手把Key的前几位和后几位打码后再截图,防止在聊天记录或教程里泄露。这些细节看起来啰嗦,但踩过坑的人都知道,API Key泄露的教训往往很痛。
2.3 选型时应该关注的四个技术参数
服务商那么多,怎么选?我总结了四个真正影响体验的参数:
- 上下文长度(Context Length):至少要大于8K token,不然还没翻译几段就被截断了。现在主流模型动辄32K、128K,足够用。
- 输出速度(Tokens per second):翻译体验要流畅,每秒输出速度最好快一点。你可以用平台提供的在线Playground测试一下,多试几家。
- 免费额度和速率限制(Rate Limit):重点看每分钟请求数限制,如果限制太死,批量翻译时可能频繁失败。
- 模型翻译质量:这个没有固定指标,我的笨办法是拿同一段英文技术文档分别给几家的免费模型翻译,对比流畅度和术语是否准确,15分钟就能看出差距。
用这四个参数去筛,基本能锁定合适的一家。我最终选的方案可以抽象成一段可配置代码,换API就是改改配置项,下面章节会把这部分写出来。
3. 浏览器翻译插件核心实现
3.1 插件的基础骨架:Manifest V3
现在浏览器插件的主流标准是Manifest V3,我整个项目就用这个结构。首先需要一个manifest.json文件,它是插件的“身份证”,声明了插件名称、权限、入口文件。
{ "manifest_version": 3, "name": "AI网页翻译助手", "version": "1.0.0", "description": "调用免费大模型API的网页翻译插件", "permissions": [ "storage", "activeTab" ], "host_permissions": [ "https://api.deepseek.com/*", "https://open.bigmodel.cn/*", "https://api.moonshot.cn/*" ], "action": { "default_popup": "popup.html", "default_icon": "icon.png" }, "background": { "service_worker": "background.js" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["content.js"], "run_at": "document_idle" } ] }这里有几个值得解释的点。permissions里我申请了storage和activeTab,storage用来存取配置和API Key,activeTab是让插件在点击图标后能拿到当前标签页的操作权限。host_permissions是联网访问API的许可,每一项对应一个服务商的域名,重要的事情说三遍:这个数组里的域名必须和你实际调用的API地址完全一致,不然请求会被浏览器拦截。
background使用service_worker,这是MV3的标准做法。content_scripts会注入到所有页面,用来读取和改写网页内容。注意run_at选document_idle,意思是页面加载完后再执行,这样可以避免太早抓取导致文本不全。
3.2 从网页里“干净地”抓取正文
抓取网页文本是整个插件最容易出Bug的地方。如果直接拿document.body.innerText,会把导航栏、广告、脚本内容全部塞进去,既浪费token,又容易翻译出乱七八糟的东西。我选择的方案是用TreeWalker遍历文本节点,同时过滤掉需要忽略的标签。
function getVisibleTextNodes(root) { const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { const parent = node.parentElement; if (!parent) return NodeFilter.FILTER_REJECT; const tag = parent.tagName.toLowerCase(); if (['script', 'style', 'code', 'pre', 'textarea', 'iframe'].includes(tag)) { return NodeFilter.FILTER_REJECT; } if (parent.isContentEditable) return NodeFilter.FILTER_REJECT; const text = node.textContent.trim(); if (!text || text.length < 2) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } } ); const nodes = []; while (walker.nextNode()) { nodes.push(walker.currentNode); } return nodes; }你可能会问,为什么要把script、style、textarea排除掉?因为脚本里的英文全都是代码逻辑,翻译了会破坏功能;style里的内容本来就不该显示;textarea是用户输入框,里面的内容没经过同意不应该动。code标签里是代码,翻译后代码就没法复制运行了。这些细节就是“干净抓取”和“一锅端”的区别。
还有一个容易被忽略的点:contentEditable区域。很多在线文档编辑器(比如Notion、语雀)允许用户直接编辑内容,如果贸然翻译这些区域,很可能把用户正在写的内容改掉。我加了一个判断isContentEditable,属于它的文本节点直接跳过,这样就不会误伤。
3.3 在Service Worker里调用大模型API
抓取到文本之后,要把它发给大模型。这里我强烈建议把fetch请求放在background的service worker里,而不是content script。因为content script直接跨域请求通常会受到CORS限制,而service worker配合host_permissions可以在后台发出跨域请求,更稳定也更安全。
下面是一段可复用的调用函数,我把它抽象成通用方法。你可以根据服务商的不同调整api地址、key字段和请求体格式。
// background.js async function translateText(text, config) { const { apiUrl, apiKey, model, targetLang } = config; const prompt = buildPrompt(text, targetLang); const response = await fetch(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, body: JSON.stringify({ model: model, messages: [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: prompt } ], temperature: 0.3, max_tokens: 4000 }) }); if (!response.ok) { const errText = await response.text(); throw new Error(`API请求失败 ${response.status}: ${errText}`); } const data = await response.json(); return data.choices[0].message.content.trim(); }这里的关键参数是temperature。翻译任务我建议设到0.3以下,温度越低输出越稳定,不容易出现“自由发挥”的情况。max_tokens也要控制好,设太多了容易被滥用,设少了译文会被截断。对于一般网页段落,单次请求设4000基本够用。
还要补充一个细节:为什么不把SYSTEM_PROMPT也放在用户请求里,而是单独用system字段?因为在主流大模型API的规范里,system是系统角色,负责设定整体行为;user是用户角色,负责传入具体内容。分开写,模型更容易理解任务边界,翻译时不会把提示词本身也当成待翻译内容。
3.4 译文回填与页面防错乱
API返回译文后,要把译文填充回原来的文本节点。这步有个大坑:如果你直接改node.textContent = translatedText,页面上被翻译的元素可能完全错乱,比如原本一个标题被翻成超长句子,撑坏布局,或者译文里带着换行符导致排版崩掉。
我采用的方法是“错位回填”:先把页面上的文本节点按顺序分成一组一组的,每组累计原文长度控制在几百字符内。然后把这一整组文本合并成一段发给API,拿到整段译文后,再按原文的段落结构分割开,依次填入对应的节点。简单说,就是“按组分,整段翻,按组分”。这样能最大限度保留原始段落层次,又不会为了几十个字发太多次请求。
async function translatePage(selectedNodes, config) { // 按可见区域或固定长度分组 const groupSize = 20; for (let i = 0; i < selectedNodes.length; i += groupSize) { const group = selectedNodes.slice(i, i + groupSize); const originalText = group.map(n => n.textContent).join('\n---SPLIT---\n'); const translated = await translateText(originalText, config); const translatedParts = translated.split('\n---SPLIT---\n'); if (translatedParts.length === group.length) { group.forEach((node, index) => { node.textContent = translatedParts[index]; }); } } }这个分割符是我自己定义的,原文里几乎不会出现的特殊字符串。之所以用这种分隔符,而不是用换行,是因为很多HTML页面里本身就带着换行,直接按换行分割会把一个段落切得七零八落。用唯一的标记字符能保证分组和还原之间不出错。
还有一个很实际的问题:页面有动态加载内容,翻到一半往下滚,新的内容又出现了怎么办?我的插件里用了一个简单的MutationObserver监听DOM变化,发现新增文本节点就自动继续翻译。但对于已经翻译过的节点,一定要打上标记,比如给父元素添加data-translated="true",否则观察者一旦触发,会把已经翻译的内容再发一次,形成无限循环,这是新手最常踩的坑。
4. 提示词工程:决定翻译质量的关键
4.1 一套简单却很好用的翻译提示词模板
多数人第一次试AI翻译都只写一句“translate this into Chinese”,这样出来的效果不稳定。我自己调试下来,发现系统提示词越明确,翻译质量越高。下面是我目前用着很顺手的模板:
System Prompt: 你是一名专业的网页翻译引擎。你的任务是将用户输入的网页文本翻译成指定语言。 要求: 1. 翻译结果要保持原文的语义准确、语气自然。 2. 不要添加任何解释、注释或额外的回复内容,只输出译文本身。 3. 保持原文的分段和占位符格式。如果原文用了特殊分割符,输出的译文中也必须保留同样的分割符。 4. 遇到专有名词、品牌名、代码、变量名、URL时,不需要翻译的部分要原样保留。 5. 用心翻译专业术语,确保术语保持一致。这个提示词里最有价值的其实是“只输出译文本身”和“保留分割符”这两条。前者避免了模型重复你刚才说的话,后者保证了我们上一节的分组回填能正确对齐。如果你不写这两条,模型经常会在译文前后加上“好的,这是翻译结果:”之类的内容,解析起来非常烦。
4.2 处理专业术语、代码和无障碍阅读
网页翻译的一大场景是技术文档。我刚开始测试时,模型把“function”翻译成了“函数”,这是对的,但把“lambda”翻译成了“拉姆达”,就有点怪。后来我在提示词里加了自定义术语表,通过用户配置传入。比如“API”保持不译,“React”保持不译,“深度优先搜索”固定翻译。这本质上就是一种轻量级的few-shot方法,能明显提升特定领域的准确性。
如果你经常阅读特定领域的内容(医学、法律、自动化),可以在插件的配置页里维护一份术语表,然后用JSON格式拼到提示词里。这样遇到重复术语时,模型会主动保持一致。还有一个小技巧:翻译代码块或带代码的段落时,我建议在提示词中明确写“代码块、变量名、命令行保持不变,只翻译自然语言部分”。实测下来,工具类网页的翻译体验从“完全没法用”变成了“可以直接照着操作”。
4.3 控制成本与上下文窗口的策略
免费额度虽然免费,但也不能浪费。翻译整页内容时,我会先把页面里的文本按“可见区域”进行分段处理,只翻译当前视口内的内容。只有当用户滚动或者主动点击“翻译全页”时,才继续翻译剩余区域。这个策略能减少大概70%的无效token消耗。
上下文窗口方面,我的处理逻辑是:如果一组文本的字符数较大,就自动拆分成多个chunk,每个chunk控制在1500字符左右。为什么是1500?因为中英文token比不一样,英文一个单词约等于1-1.3个token,中文一个字约等于1-2个token,1500字符对应2K-3K token,加上系统提示词,不会超过大多数模型的8K上下文限制。
我还做了一个响应式分割:如果API返回错误提示“maximum context length exceeded”,插件会自动把当前组再对半拆开重新请求。这样就可以兼容不同上下文窗口的模型,不会因为换了一个模型就导致整个插件罢工。
5. 常见问题与排查技巧实录
5.1 API请求失败:无Key、限流、超时
我自己的插件刚写完时测试,最常碰到这几个错误。第一是“401 Unauthorized”,基本就是API Key错误或者没放对位置。排查方法很简单,先用curl或PostMan直接调一次接口,如果单独调用成功,那就是插件里的赋值或发送逻辑有问题。第二是“429 Too Many Requests”,这是触发速率限制了。解决办法是在插件里加一个队列,一次只发一个请求,前一个完成后再发下一个。为了防止无限等待,每个请求设置30秒超时,超时就跳过当前组,并提示用户。第三是网络超时,多见于免费模型负载高的时候,重试一两次一般能解决。
这里有一个小经验:在background.js里加一个简单的错误处理中间层,把所有失败原因收集起来,通过chrome.runtime.sendMessage推送到弹窗页。这样遇到问题时不用打开控制台看半天,直接在插件图标上就能看到错误摘要。
5.2 翻译后页面错乱或样式崩塌
最常见的错乱有两个表现:译文长度失控导致布局撑破,或者译文里的特殊字符破坏了HTML结构。第一种可以用CSS解决,在content script里给翻译过的节点加上一个类名,设置word-break和overflow-wrap属性,让长单词自动换行。第二种需要转义保护:API返回的译文里如果含“<”或“>”之类的字符,在写入网页前要转义成HTML实体,否则会被浏览器当成标签处理,页面直接裂开。
如果你用textContent写入,其实已经天然转义了,不会有标签问题。但如果你图省事用innerHTML,就必须做严格转义。我一直建议用textContent,代价是不支持译文里的加粗、斜体等富文本,但对网页翻译这个场景,简单可靠比花哨更重要。
5.3 动态页面翻译失效
很多现代网站是SPA(单页应用),内容通过JavaScript异步加载。如果你刚注入脚本时页面还没内容,TreeWalker抓到的就是空内容。我常用方案是在document_idle执行后,延迟几百毫秒再抓一次,同时用MutationObserver监听后续变化。另外,一些网站会在滚动时频繁添加节点,如果不做节流,API请求会像洪水一样打出去。我在代码里做了500毫秒的防抖:用户停止滚动半秒后,才计算是否翻译新增内容。这样既能保证翻译跟上浏览节奏,又不会浪费请求。
5.4 插件权限被浏览器限制
Chrome商店发布插件时,“host_permissions”申请过多会被审核员盯着问。如果你是本地开发者模式加载,则没有这个限制,但如果以后想上架,最好把API域名声明成“可选权限”,在用户启用翻译功能后再动态申请。这是我后来优化掉的点,初期版本因为申请了太多域名权限,审查费了不少功夫。
5.5 一张排查速查表
整理一下我遇到的高频问题,做成速查表,方便你直接对照:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 点击翻译无反应 | content script没注入或API Key为空 | 刷新页面,检查storage配置,F12看console |
| 触发429错误 | 请求太频繁,免费额度速率限制 | 加队列,串行请求,降低并发 |
| 译文截断 | max_tokens太小或上下文超限 | 调大max_tokens,对文本分片重试 |
| 部分文本没翻译 | 动态加载或文本节点被过滤 | 优化TreeWalker过滤器,增加MutationObserver |
| 页面样式错乱 | 长词未换行,或用innerHTML误解析 | 加CSS处理,改用textContent |
| 翻译后原文本被重复翻译 | 已翻译节点没有标记 | 加data-translated标记,避免无限循环 |
6. 实测体验与几个值得改进的方向
整套插件做完之后,我拿它实际翻译了一个英文技术教程网站和一个英文新闻网站。技术教程的翻译质量,专业术语基本能对上,代码块保持原样;新闻网站的长难句翻译很自然,阅读流畅度比传统翻译引擎好一个档次。免费的API额度用了一周,从每天翻十来页的量来看,几乎看不到消耗,确实做到了零成本。
最后分享几个我自己实测下来的经验。
第一,一定要做好配置页。虽然核心功能在content script和background,但一个友好的配置页能让整个项目好用非常多。我的配置页只做了三件事:填API Key、选模型、填目标语言。用起来几乎没有学习成本。
第二,别一次性翻译整个超级长页面。哪怕免费额度多,也要克制。因为页面一旦全部被替换成中文,原文就找不回来了。我最后做了一键恢复功能,点击恢复可以重新加载页面,刷新回原文状态,有后悔药吃。
第三,这个插件的后续扩展空间其实很大。比如你可以改成“选词即译”,鼠标选中文本就弹出译文;也可以接本地模型,把API地址改成Ollama的本地服务,实现完全离线翻译;还可以加上“导出双语对照”的功能,把翻译结果存成markdown文件。这些都是同一个架构上的小改动,不需要重写核心逻辑。
做这个项目的最大体会是:很多看似“免费工具”的日常需求,其实用免费大模型API几十行代码就能自己搞定,而且效果还能按自己喜好定制。如果你也厌倦了现成翻译插件的种种限制,不妨按这个思路搭一个,折腾的过程本身也挺有意思。