☰
零成本自建AI网页翻译插件:从API选型到油猴脚本实战
2026/10/6 11:32:25 网站建设 项目流程

自己动手写一个翻译插件,这个念头我从年初就种下了。市面上的网页翻译工具用了一圈,要么收钱,要么弹窗,要么翻译出来的东西读着像机器硬翻。AI大模型API这两年热度高居不下,手里正好攥着几个免费账号的额度,索性花两周周末折腾出一个属于自己的AI网页翻译脚本。这篇文章从API选型、脚本原理,到完整的可运行代码、实测中的坑,一次性讲完。适合想摆脱商业翻译插件束缚的开发者,也适合想拿大模型API练手的朋友。

1. 为什么"免费API+自建翻译脚本"这套路真的行得通

1.1 商业翻译插件让人越用越憋屈的几个点

先聊点真实体验。我最早用的某款知名翻译插件,翻译质量确实不错,但用了一段时间后强迫登录账号,导览栏里还塞满了引导点击的卡片,整页翻译的入口越藏越深。后来换过一款主打无侵入的,倒是够简洁,可翻译速度忽快忽慢,遇到微信公众号、Notion这类动态渲染页面经常翻一半就停住,得手动刷新再翻一次。最让我心里没底的是隐私:我浏览的英文文档、外文新闻、有时甚至含个人信息的内容,全部要上传到翻译厂商的服务器。厂商拿这些数据做什么,我完全不知道,也没有选择权。

这不是在否定商业产品。它们的商业模式决定了产品方向:订阅、广告、数据反哺,总得占一头。可对使用者来说,换来的是几乎为零的自定义空间。我想要的"只翻译选中的一段""某个域名永远不翻译""翻译术语保持特定写法",在现成插件里都很难做到。就是想改一下提示词、换一个翻译风格,对不起,设置面板里根本没有这种东西。

1.2 免费大模型API把"自建"的门槛打到了几乎为零

以前自己动手做翻译工具,最大的拦路虎是引擎。接付费翻译API,按字符计费,个人偶尔用用还好,真拿它当日常工具,一个月大几十块甚至上百块的账单很容易就烧出来。本地部署开源模型又是一个大坑:模型文件动辄几个GB,还得有一张像样的显卡,跑起来风扇响半天。这套账算下来,大多数人都会打退堂鼓。

这两年情况彻底变了。各大模型平台为了争夺开发者生态,免费额度的力度一个比一个大。我自己实测下来,Google AI Studio给的Gemini API免费额度,个人网页翻译场景一天根本用不完;Groq用硬件加速跑开源模型,响应速度比很多付费API还快,免费层也够一个普通用户折腾;Cloudflare Workers AI更是直接按天送大量的推理额度。这些额度不是概念上的"体验",是真的能给普通个人完整跑一个工具级的应用。也就是说,翻译引擎这个曾经最贵的部分,现在可以做到零成本。

1.3 边界划清楚:自建方案适合谁

先把丑话说在前头,这套方案不是万能的。它最舒服的使用场景,是中低频的网页阅读:外文技术文档、海外新闻、英文博客、学术论文摘要,一天翻个几十页以内,完全够用。优势是零成本、完全可控、可定制,脚本长什么样完全由你说了算。但如果你需要一天翻译几十万字的生产级需求,或者完全没有动手意愿,只想装完就用的现成工具,那还是老老实实选商业产品,省下的时间比省下的钱更值。

我的建议是,把期望定在"个人工具"这个层级上,第一步能跑通"取文本、调API、放回译文"这一条链子,就已经赢过大多数收藏夹里的教程了。自建方案最大的价值不在于比商业插件强,而在于这个工具是真正长在你手上的。

2. 免费大模型API选型:我实测过的三条路线

2.1 Google AI Studio:注册门槛最低,给的额度相当大方

如果你从零开始,我最建议先试Google AI Studio。整个流程就是一个注册、生成API Key、复制端点地址,三分钟搞定。Gemini系模型的翻译质量在通用场景里相当能打,尤其处理长文本时语义连贯性强,不会翻着翻着就断片。我在测试里用中文提示词让它把英文技术文章翻成中文,输出的语序自然度比我预期高不少。免费额度的上限记不太准,以官方页面为准,但个人网页翻译这种场景,一天几百条请求大概率打不到。唯一需要注意的是,官方控制台偶尔会调整免费额度政策,脚本里最好留一个可换API地址的开关,后面我会写。

2.2 Groq:快是真快,限流也真没客气

Groq主打的是推理加速,用它跑开源模型,输出速度能让你明显感觉到差异。翻译整页文章的时候,别的接口可能要等七八秒,Groq经常三四秒就出全文,那种"边翻边出字"的体验很舒服。代价是限流也更明显,免费层对每分钟请求数卡得比较紧,热门模型偶尔会直接给你返回429。如果你打算拿Groq当主力,建议在脚本里把并发压得很低,串行请求加 delay,只求稳不求快。翻译质量方面,它托管的Llama系列模型在通用翻译上表现不错,但在涉及特定领域术语时,提示词里最好带上一些背景说明。

2.3 Cloudflare Workers AI:胜在不用操心跨域,适合进阶玩家

如果你的脚本部署在本地浏览器环境,调用第三方API天然有跨域问题。油猴脚本虽然可以通过某些方式绕过部分限制,但不如直接在服务端加一层代理干净。Cloudflare Workers AI这批额度的好处就在这:在Workers上写一个十来行的转发函数,对外暴露一个你自己域名的接口,浏览器端只认这个接口,跨域问题直接消失。缺点是上手门槛比前两者高一些,需要注册Cloudflare账号、部署Worker、处理环境变量。我给整套方案做后端时就这么玩过,跑熟之后确实省心。

2.4 横向对比与我的选择建议

我用一张表把三条路线的实测感受列出来:

平台免费额度(以官方为准)响应速度适合阶段我踩到的坑
Google AI Studio对个人场景相当充裕中等入门最快,建议首选免费额度政策会调整,Key要留好
Groq请求次数限制严一些快,体感明显追求响应速度429限流频繁,必须加重试
Cloudflare Workers AI每天包量,量大中等偏慢进阶,需要后端子部署Worker有学习成本

个人建议:如果你没有特殊偏好,先用Google AI Studio把脚本跑起来,整个过程能控制在半小时内。跑通之后,再去研究Groq怎么换成更快的模型、Cloudflare怎么加代理层。选型这件事,本质上不是选最好的,而是选你现在最顺手能跑起来的。

3. 翻译插件的基本盘:内容脚本、DOM遍历与请求调度

3.1 为什么选油猴脚本而不是正经做浏览器扩展

有人可能会问,做一个浏览器扩展不是更专业吗?确实,Chrome扩展能力更强,但发布流程牵扯到 manifest.json、开发者模式、打包审核,对一个只想解决自己问题的工具来说太重了。油猴脚本(Tampermonkey)的思路要轻得多:一个带@name、@match注释的JS文件,安装就能生效,脚本跨浏览器通用。你在脚本里通过GM_registerMenuCommand注册菜单项,就能在浏览器菜单中直接触发"翻译本页"。而对于大多数网页翻译需求,油猴提供的DOM访问权限已经绰绰有余。

3.2 文本收集:TreeWalker遍历与过滤规则

翻译网页的第一步,是把页面里"真正需要翻译的文字"捞出来。直接用document.body.innerText会有问题,它会把script标签里的脚本、style里的样式、隐藏区域的文本全都带进来,翻译出来的内容一堆乱码。我采用的是标准做法:用TreeWalker遍历document.body的文本节点,然后按几个规则过滤——直接跳过script、style、pre、code、textarea这些标签;过滤掉纯数字、纯链接、长度小于2的碎片节点;对已经翻译过的节点打上标记跳过。

写起来大概是这个形态:

const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (node.parentElement.closest("script,style,pre,code,textarea,svg")) return NodeFilter.FILTER_REJECT; const text = node.nodeValue.trim(); if (!text || text.length < 2 || /^[\d\s\W]+$/.test(text)) return NodeFilter.FILTER_REJECT; if (node.parentElement.getAttribute("data-translated")) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } });

这一段代码看着简单,却是我实际调试中改过最多的地方。很多网页会在按钮、弹层里放一些只有一个字符的文本节点,不加长度过滤的话,翻译出来的效果会很碎。

3.3 批量合并:让一次请求尽可能多地干活

如果把每个文本节点单独丢给API翻译,翻译一篇长文章的请求数量会非常恐怖,免费额度再大也扛不住,而且限流概率直线上升。正确的做法是把多个相邻文本节点合并成一批,统一发给大模型,让其在同一个请求里输出每组对应译文。这样既减少了请求次数,又因为原文在同一个上下文里,翻译出来的术语会前后一致得多。

我的策略是:收集到的文本节点按顺序累加,凑满一批(比如20个节点或3000个字符)就发送一次请求。每个文本节点分配一个形如#12#的唯一编号,模型返回的内容按编号切分回填。这样批量翻译和单条翻译的代码逻辑能完全复用,后面加缓存、加双语对照都比较顺。

这里有个经验参数:单批超过20个节点后,翻译质量会明显下降,模型偶尔会漏掉中间某几条的编号;低于8个节点时,请求次数又太密。15到20个节点一批是我试下来比较平衡的区间,字符数上限也得设一个,我控制在3000字以内。

3.4 安全替换:占位符与结构保护

直接改写textContent是新手最容易踩的坑。页面里的文本节点常常嵌在复杂HTML结构中,比如一段文字里有<a>链接、<strong>标签,如果只改整个父元素的textContent,链接和样式会被全部抹掉。

举个例子,原始HTML是这样:<p>这是一个<strong>重点</strong>词汇。</p>,如果你把整个p元素的textContent替换成译文,<strong>标签就不见了。占位符的做法是先把HTML保存一份,把<strong>替换成【0】这样的临时标记,只翻译纯文本部分:这是一个【0】词汇。模型返回译文后,再把【0】还原成<strong>标签。核心思路是:翻译的是文字,还原的是结构。这个方案在后面做双语对照时也适用,因为保留的原始HTML可以直接用来生成对照区。

4. 实战:手写一个能用的AI网页翻译脚本

4.1 脚本配置:把Key、目标语言、排除规则放一处

下面给出的代码是完整脚本的核心部分,你可以按顺序拼接成整个Tampermonkey脚本文件。所有需要你改的东西全部集中在CONFIG对象里。更换API平台、换Key、改目标语言、调等待间隔,都只动这一块,不用在业务逻辑里找。

// ==UserScript== // @name 零成本AI网页翻译 // @namespace https://yourname.github.io/ // @version 0.1 // @description 调用免费大模型API翻译网页文本,零成本自建翻译插件 // @author yourname // @match http://*/* // @match https://*/* // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // ==/UserScript== const CONFIG = { apiBase: "https://你的API地址/v1/chat/completions", apiKey: "粘贴你的Key", model: "llama-3.3-70b-versatile", targetLang: "中文", excludeTags: "script,style,pre,code,textarea,svg", batchSize: 15, delayMs: 400, maxRetries: 3, useCache: true }; const cacheStore = new Map(); function getCacheKey(text) { return `${CONFIG.targetLang}:${text}`; } function readCache(text) { if (!CONFIG.useCache) return null; return cacheStore.get(getCacheKey(text)) || GM_getValue(getCacheKey(text), null); } function writeCache(text, result) { if (!CONFIG.useCache) return; cacheStore.set(getCacheKey(text), result); if (cacheStore.size > 200) cacheStore.clear(); GM_setValue(getCacheKey(text), result); }

这里有几点说明。apiBase我故意用的是通用占位符,因为各家OpenAI兼容接口的路径前缀大同小异,你申请到哪家就把根地址填到哪。模型名也建议打开你用的平台控制台去复制,不同平台的模型ID写法不一样,别直接照抄我这段也不一定对,去官方示例里复制参数最稳。

4.2 请求封装:fetch、超时、指数退避

接下来是核心的请求函数。我按OpenAI兼容接口来写,这样能在绝大多数免费API平台上通用。

async function translateText(text) { const cached = readCache(text); if (cached) return cached; const prompt = `你是一个网页翻译助手。请把用户输入的内容翻译成${CONFIG.targetLang}。 规则: 1. 只输出译文,不要添加任何解释。 2. 保留原文的换行和分段结构。 3. 如果内容是术语、人名、代码片段,可以保留原文或给出合理译法。`; for (let attempt = 1; attempt <= CONFIG.maxRetries; attempt++) { try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 30000); const resp = await fetch(CONFIG.apiBase, { method: "POST", signal: controller.signal, headers: { "Content-Type": "application/json", "Authorization": `Bearer ${CONFIG.apiKey}` }, body: JSON.stringify({ model: CONFIG.model, temperature: 0.2, messages: [ { role: "system", content: "You are a professional translator." }, { role: "user", content: prompt + "\n\n" + text } ] }) }); clearTimeout(timer); if (resp.status === 429) { const waitMs = 1000 * Math.pow(2, attempt - 1); await sleep(waitMs); continue; } if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const data = await resp.json(); const result = data.choices[0].message.content.trim(); writeCache(text, result); return result; } catch (err) { if (attempt === CONFIG.maxRetries) throw err; await sleep(500 * attempt); } } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

我把超时设置成30秒,给大模型输出留足余量。温度调成0.2,目的是让翻译结果更稳定,而不是每次输出都不一样。注意429这个分支——很多平台免费层限制的就是每分钟请求数,遇到429直接重试是肯定失败的,先睡一段时间再说,重试间隔用指数退避而不是固定间隔。

4.3 划词翻译:鼠标选中加小浮层

划词翻译的体验,靠的是一个浮动小按钮。我监听mouseup事件,如果当前选中了非空文本,就在选区附近弹出一个固定定位的div。点击按钮后,把选中的文本交给translateText,译文显示在浮层下方的气泡里。

let floatBox = null; document.addEventListener("mouseup", () => { const sel = window.getSelection(); const raw = sel.toString().trim(); if (!raw || raw.length > 500) { removeFloat(); return; } const rect = sel.getRangeAt(0).getBoundingClientRect(); if (!floatBox) { floatBox = document.createElement("div"); floatBox.style.cssText = "position:fixed;z-index:99999;background:#fff;border:1px solid #ccc;padding:6px 12px;border-radius:6px;box-shadow:0 2px 10px rgba(0,0,0,.2);cursor:pointer;font-size:14px;"; document.body.appendChild(floatBox); } floatBox.textContent = "翻译选中"; floatBox.style.left = rect.left + "px"; floatBox.style.top = (rect.bottom + 4) + "px"; floatBox.onclick = async () => { floatBox.textContent = "翻译中..."; try { const translated = await translateText(raw); floatBox.textContent = translated; floatBox.style.maxWidth = "320px"; floatBox.style.cursor = "default"; } catch (err) { floatBox.textContent = "翻译失败"; } }; }); document.addEventListener("mousedown", (e) => { if (floatBox && !floatBox.contains(e.target)) removeFloat(); }); function removeFloat() { if (floatBox) { floatBox.remove(); floatBox = null; } }

这段代码里有个细节:点击浮层翻译时,浮层本身不能被后续的mousedown清除掉,所以我用contains(e.target)做判断。另外,选中文本超过500个字符就不弹浮层了,防止误触选中整页时浮层盖住屏幕。如果你在手机上用,还可以把浮层的position改成absolute,配合touch事件使用,但桌面浏览器上用fixed足够了。

4.4 整页翻译:分片调度的完整逻辑

整页翻译的逻辑要好几个部分组成。注册菜单命令,收集文本节点,分片发送请求,替换DOM。我给出一个能跑的主干:

(async function () { "use strict"; GM_registerMenuCommand("翻译整页", async () => { const nodes = []; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (node.parentElement.closest(CONFIG.excludeTags)) return NodeFilter.FILTER_REJECT; const text = node.nodeValue.trim(); if (!text || text.length < 2) return NodeFilter.FILTER_REJECT; if (node.parentElement.getAttribute("data-translated")) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; } }); while (walker.nextNode()) nodes.push(walker.currentNode); const results = {}; for (let i = 0; i < nodes.length; i += CONFIG.batchSize) { const batch = nodes.slice(i, i + CONFIG.batchSize); const batchText = batch.map((node, idx) => `#${i + idx}#\n${node.nodeValue.trim()}`).join("\n\n"); try { const translated = await translateText(batchText); const regex = /#(\d+)#\s*\n?([\s\S]*?)(?=\n*#\d+#|$)/g; let match; while ((match = regex.exec(translated)) !== null) { const id = parseInt(match[1], 10); if (!isNaN(id) && match[2] !== undefined) results[id] = match[2].trim(); } await sleep(CONFIG.delayMs); } catch (err) { console.error("批次翻译失败,跳过该批次", err); } } nodes.forEach((node, idx) => { if (!results[idx]) return; const parent = node.parentElement; if (parent.getAttribute("data-translated")) return; parent.setAttribute("data-translated", "1"); parent.textContent = results[idx]; }); }); })();

看了这段你应该能理解我前面说的分片了:nodes.slice对节点分批,每批形成一个带编号的文本块,发给模型后按编号解析回填。分段时我故意给每个批次加了一个CONFIG.delayMs的间隔,宁可慢一点,也要把限流风险压下去。实际翻一篇三五百行的技术文档,大概要十几秒,完全在可接受范围内。解析不到编号的文本会被忽略,不用太担心模型偶尔多输出一句话。

4.5 兜底:无Key也能跑的调试模式

最后我给脚本加了一个小开关:如果apiKey没填,所有翻译请求直接返回原文。这样一来,你可以先把脚本在油猴里装上,确认菜单命令、浮层交互、DOM遍历这些基础逻辑都是好的,再去申请API Key。别小看这个开关,我调试前端脚本时最烦的就是API报错和页面逻辑错误混在一起,完全分不清是哪一段出了问题。有了调试模式,至少能先确认"壳"是完整的,再往里面装"引擎"。

if (!CONFIG.apiKey || CONFIG.apiKey.startsWith("粘贴")) { console.warn("未配置API Key,脚本进入调试模式:翻译函数将直接返回原文。"); window.__aiTranslateDebug = true; }

5. 实测跑完后的几个坑:限流、动态DOM、翻译腔

5.1 429限流:退避重试比死磕更重要

我第一次跑整页翻译时,接口直接被连续429打懵了,脚本里没有捕获异常,导致整页翻译只翻了一半就静默失败。后来我把所有调API的地方统一收敛到translateText一个函数里,把429单独分支出来专门做退避重试。核心经验就一句话:免费API的限流信息是写在响应码里的,你要顺着它的节奏走,而不是试图绕过。限流之后等一两秒,通常比立刻重试成功率高得多。如果你同时开了好几个脚本抢同一个免费Key,限流会更严重,这种情况最好把delayMs调到800以上。

5.2 动态渲染页面:MutationObserver写成死循环的教训

很多现代网页是SPA,正文内容在首屏加载之后才异步渲染出来。整页翻译如果只跑一次,翻完的上半页内容很快会被新的DOM替换掉,等于白干。我当时想当然地加了MutationObserver监听,希望在DOM变化时自动补翻新内容。结果埋了一个大坑:翻译完的文本节点也被当成新DOM触发监听,又走了一遍翻译流程,然后再次触发监听……页面直接卡死。解决办法有两个,一是翻译后对节点打上>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询