← 返回

我是如何用 Codex 做出一个沉浸式 AI 自动翻译插件的

codexbrowser-extensiontranslationdeepseekvibe-coding

GitHub 项目地址: realreadpaper/translate

先说结论

这个项目最后变成了 Immersive AI Translate:一个面向 Chrome / Edge 的 Manifest V3 浏览器扩展。

它的稳定能力是网页正文沉浸式翻译:点击页面悬浮球后,扩展优先翻译当前视口附近内容,把译文插回原页面,并支持双语、仅原文、仅译文三种阅读模式。

我真正想验证的不是“AI 能不能写一个翻译插件”,而是:当功能从网页扩展到 PDF、YouTube 字幕、ASR/OCR 预留能力时,项目还能不能保持清晰边界。

最后沉淀下来的核心拆法是:

  • segment:翻译的最小稳定单位。
  • provider:只负责请求模型和解析响应。
  • target:区分网页、PDF、YouTube 字幕。
  • renderer:把结果放回正确位置。
  • prompt contract:让模型输出成为可验证接口,而不是随缘文本。

阅读路径

这篇按实现顺序展开:

  1. 先让 Codex 设计边界,而不是直接写 content script。
  2. segment id 解决网页译文回填。
  3. 用 provider adapter 隔离 DeepSeek、OpenAI-compatible 和传统翻译服务。
  4. 用 translation target 扩展 PDF 和 YouTube。
  5. 用更硬的提示词约束模型输出。
  6. 用测试和 E2E 把边界钉住。

1. 我先让 Codex 做设计,而不是写代码

一开始如果直接说“帮我写一个网页翻译插件”,很容易得到一个 demo:读取页面文字、发给模型、把结果塞回 DOM。这个路径看起来快,但后面一定会被复杂网页、动态内容、模型输出格式和阅读模式拖垮。

所以第一条 prompt 不是实现需求,而是设计约束:

设计一个 Chrome MV3 沉浸式网页翻译扩展。
 
要求:
- 不破坏原网页 DOM 结构
- 译文必须能按段落稳定回填
- 支持双语、仅原文、仅译文三种模式
- Provider 可替换,默认 DeepSeek,同时支持 OpenAI-compatible
- API Key 保存在浏览器本地
- 先写清楚模块边界、消息协议和测试策略
- 不要直接实现代码

Codex 给出的第一版边界非常接近最终结构:

popup/options
  -> background service worker
  -> provider adapter
  -> content script
  -> DOM extractor / renderer
  -> chrome.storage.local

这个拆法的价值是:UI、配置、模型请求、DOM 操作和持久化互相不抢职责。后面加 PDF、YouTube 时,项目没有立刻变成一坨特判。

2. 网页翻译的关键:不要翻译整页 HTML

网页翻译最危险的捷径,是把整页 innerHTML 丢给模型。它会破坏事件、样式、脚本、React/Vue 状态,也很难恢复原文。

我让 Codex 按文本块实现:

Content script
  -> DOM TreeWalker 找可见文本节点
  -> 跳过 script/style/code/pre/input/hidden
  -> 归并成 SourceSegment[]
  -> 给真实 DOM 节点写 data-segment-id
  -> 等译文回来后插入 data-translation-for 节点

核心数据形态很简单:

type SourceSegment = {
  id: string;
  text: string;
};
 
type TranslatedSegment = {
  id: string;
  translatedText: string;
};

这个设计解决了三个问题:

  • 译文能按 id 回到原文附近。
  • 阅读模式切换不需要重新请求模型。
  • 扩展只拥有自己插入的译文节点,不接管整个网页。

后来悬浮球的“视口优先翻译”也是从这里长出来的:点击后先翻译当前视口附近的 segment,用户滚动时再翻译后续内容。单批默认控制在 6 段,等待时间短,模型也不容易乱返回。

3. Provider 只做模型适配

Provider adapter 不碰 DOM,也不管 popup。它只处理四件事:

  • validateConfig()
  • translateSegments()
  • normalizeError()
  • getMeta()

Background service worker 才是调度层:

读取 settings
  -> 校验 provider 配置
  -> 收集 segment
  -> 分批
  -> 调 provider
  -> 把结果发回 content script

这样 DeepSeek、OpenAI-compatible、自定义 Base URL、传统翻译服务都可以走同一套任务流。新增 provider 时,不需要碰 dom-extractorsegment-renderer

4. 提示词不是文案,是接口契约

模型返回值必须可机器解析,所以 prompt 写得很硬:

You are a professional translation engine for immersive bilingual reading.
 
Return only one valid JSON object matching exactly:
{"segments":[{"id":"same id","translatedText":"translation"}]}
 
Rules:
- Copy each input id byte-for-byte.
- Never translate, shorten, rename, or omit ids.
- Do not return markdown fences, explanations, notes, or extra text.
- If a segment is hard to translate, keep its original text.

这里的重点不是“翻译得更好”,而是“返回值能对齐”。浏览器扩展最怕的是:翻译质量还可以,但某个 id 被模型改了,导致译文错位。

即便 prompt 很硬,真实模型仍会返回各种格式:

  • Markdown json 代码块
  • 顶层数组
  • { "segments": [...] }
  • { "translations": [...] }
  • 字段名变成 translation / targetText
  • 近似 JSON,但字符串没包好

所以我让 Codex 加了 parseTranslatedSegments 容错层。它的原则是:优先标准 JSON;能修复就修复;能按源 segment 顺序恢复就恢复;无法可靠恢复才让该批失败。

经验很明确:prompt 负责约束正常路径,parser 负责保护异常路径。

5. PDF 和 YouTube 不能塞进网页逻辑

网页跑通后,我开始加 PDF 和 YouTube。这个阶段如果继续往 content script 里塞判断,很快就会失控。

于是我把 prompt 改成“目标架构”:

把现有网页翻译升级为 translation target 架构。
 
目标类型:
- html-page
- pdf-document
- youtube-subtitles
 
要求:
- Provider 批量翻译逻辑复用
- 不把 YouTube/PDF 特判混进 dom-extractor
- 每种 target 有自己的 collector 和 renderer
- anchor 能表达 DOM、PDF block、subtitle cue 的定位信息

这个设计让三类内容走同一个翻译调度,但各自保留采集和渲染方式:

html-page
  collector: DOM segment
  renderer: insert translation node
 
pdf-document
  collector: PDF text block
  renderer: PDF translation workspace
 
youtube-subtitles
  collector: timedtext / ASR cue
  renderer: video overlay

PDF 的边界被定得很窄:只处理独立 PDF 文档,打开扩展自己的 PDF 翻译工作台,优先读取文本层。扫描版 OCR 先保留入口,不假装已经稳定。

YouTube 也先走稳定路径:有字幕轨道时读取 timedtext,提前翻译 cue,再由 overlay 按播放器时间显示。无字幕视频才进入 ASR 实验路径,而且 ASR 不阻塞有字幕视频。

6. 不同内容需要不同提示词

同一个 provider,不同 contentKind,应该用不同 prompt。

网页文本强调保留语气和 UI 标签:

For web page text, preserve the original tone and intent.
Keep URLs, product names, code identifiers, and UI labels stable.

PDF 更像技术文档或论文,需要保留公式、引用和参考文献:

The input is from an academic or technical PDF.
Preserve formulas, citations, references, code identifiers,
variable names, model names, dataset names, section numbers,
figure/table labels, and bibliography markers.

YouTube 字幕则要短、自然、适合屏幕阅读:

The input is timed subtitle text.
Translate naturally and concisely so each cue remains readable on screen.

这比在外层硬调参数更有效,因为它把“译文应该长什么样”直接绑定到内容类型。

7. 广告清理是翻译质量的一部分

真实网页里,污染翻译请求的往往不是正文,而是广告、赞助卡片、动态推荐模块和广告 iframe。

所以翻译前会先跑广告清理:

  • 页面打开后先清理一次。
  • 用户点击悬浮球时再清理一次。
  • 自动翻译和 popup 手动翻译也会先清理。
  • 动态插入的广告通过监听继续隐藏。

这不是为了做广告拦截器,而是为了让待翻译 segment 更接近用户真正要读的内容。

8. 测试怎么跟上

这个项目能继续扩展,靠的是测试直接覆盖边界:

  • dom-extractor:segment 顺序、跳过规则、站点兜底。
  • segment-renderer:双语、仅原文、仅译文。
  • provider:JSON contract、异常响应、错误归一化。
  • PDF:文本块解析、缓存、工作台参数。
  • YouTube:字幕轨道、overlay、预取队列、ASR session。
  • Playwright:扩展 E2E 和 DeepSeek 联机冒烟测试。

我给 Codex 的实现 prompt 通常会带验证命令:

先补 Vitest 覆盖:
- provider 返回 Markdown json fence 时能解析
- provider 返回 {segments:[...]} 时能解析
- id 缺失但顺序一致时能按 sourceSegments 恢复
 
然后实现最小解析逻辑。
最后运行对应 test 文件。
不要改无关模块。

这样 Codex 不会把“更健壮”理解成重构半个 provider 层。

现在的状态

当前稳定能力仍然是 html-page 网页翻译:

  • 悬浮球视口优先翻译。
  • Popup 保留全页手动入口。
  • 支持双语、仅原文、仅译文。
  • DeepSeek 默认配置,支持 OpenAI-compatible、自定义 Base URL 和传统 provider。

实验链路:

  • PDF:独立工作台,可翻译可提取文本层的文档,OCR 兜底保留入口。
  • YouTube:已有字幕轨道可渲染翻译 overlay,无字幕 ASR 仍在实验中。

收获

这次最大的收获是:AI 写代码很快,但项目能不能长大,取决于边界。

对这个翻译插件来说,关键边界是:

  • segment 是最小稳定翻译单位。
  • provider 不碰 DOM。
  • target 不污染普通网页链路。
  • renderer 只把结果放回正确位置。
  • prompt 是接口契约的一部分。

Codex 最适合的角色不是一次性生成整个扩展,而是把每条边界变成代码、测试和文档。它负责速度,我负责方向。