chrome-plugin-manga-translator

Round 11 audit — 这个答案,是什么时候取的?

前十轮都在问一句话:这句话是真的吗? 钱花在哪、有没有人授权过、界面上说的话和代码做的事对不对得上。

这一轮换了个方向,问的是一个更朴素的问题:

这个答案,是什么时候取的?用到它的时候,它还成立吗?

派了三位审查员,同时自己把「排队 → 执行 → 出素材」这条链从头走了一遍。

结果:链上有四处,答案取早了,或取到之后没人再看它一眼。


P1 — worker 自己的句子,到了页面上变成「发生未知错误」

现象

worker 拒绝一个任务时(主开关关了、服务商没配好),把理由压成一个字符串 交回页面。页面拿这个字符串去过错误分类器,而分类器的类别里全是 “服务商出了问题”:401、429、超时、内容审核……

没有”扩展自己还没准备好”这一类。

于是它回答 UNKNOWN_ERROR,再加上原话:

发生未知错误:当前服务商还没有配置好,请先在设置页完成配置

后半句是对的,前半句把它框成了”一个我们自己也说不清的故障”。 一个用户自己五分钟就能改好的设置,被渲染成需要去找客服的样子。

根因

worker 和页面各写了一份那句话的文案,页面靠关键词去猜它属于哪一类。 两份文案会漂移,而猜的那个分类器根本没有这一类可猜。

修法

拒绝语搬进共享协议(src/shared/runtime-contracts.ts)。worker 说的是 协议里的句子,页面认的也是协议里的句子 —— 不再猜,直接查表。

命中时页面渲染:worker 的原话(不再套「发生未知错误」)+ 一句下一步 + 那颗「打开设置」按钮。

并且整个翻译在这一张就停下来。 没配好的服务商对每一张图的失败都一样, 旧路径会把剩下的十九张挨个跑一遍、挨个报一次”失败”。用户会看到二十条 同一条错误,而答案在第一行。


P1 — 队列里存的是十分钟前的答案

现象

src/background/background.ts 在入队那一刻解析一次服务商,然后把这个结果 交给队列里的任务:

const active = resolveActiveProvider(currentConfig);   // ← 入队时取一次
...
return translationJobQueue.enqueue({
  job,
  run: async () => {
    const configAtRunTime = await getConfig();          // ← 运行时会再读一次
    if (!isTranslationEnabled(configAtRunTime)) { ... }  // 但只用来看主开关
    const response = await translateImageViaProviderDirect({
      provider: active.provider,                        // ← 用的是十分钟前那个
      apiKey: active.apiKey,
      ...

主开关在运行时会再查一次,服务商不会。

为什么这是 bug,而不是设计

同一段代码里,主开关那段的注释写着理由:

The check above only covers the moment the job was queued. A chapter sits in the queue for minutes — one job per tile, a vision model that takes 10-30s each — and the user can turn the extension off while it waits. “Off” has to mean off all the way down, or the extension bills the user for a switch they turned off.

同一个理由,对服务商成立得更强。

用户换服务商,最常见的原因就是不想再花钱 —— 从云端换到本机。 换完之后,队列里剩下的那十几张图还是往刚离开的那个云端接口发, 扩展于是替用户继续付他刚刚决定不付的钱。

另一个方向同样坏:密钥过期了,用户去设置页换了一把新密钥。 队列里的任务拿的还是那把旧的,于是对着一个已经不存在的密钥失败 —— 用户看到的是「认证失败」,而他明明刚换过密钥。

修法

服务商和主开关一样,在任务真正开始的那一刻再解析一次:

const activeAtRunTime = resolveActiveProvider(configAtRunTime);

再补一道闸:运行时的服务商如果还没配好(没有地址,云端的还没有密钥), 任务停下来,并说清楚该去做什么 —— 而不是带着一个空密钥去请求, 然后报一个跟这张图毫无关系的「认证失败」。

这条闸只在公开版里可能触发:个人版在构建时预置了密钥, normalizeProviderSettings 会用它补上空的字段。


P1 — 采集脚本会把一次失败录成素材

现象

scripts/capture-store-assets.mjs 以前只等「翻译流程结束」, 不检查结果。模型连不上时,那次「翻译」也是结束的 —— 于是录下来的三十秒里全是「请求超时,请稍后重试」,截图里是一张没翻的漫画。

商店素材是一次失败的录像。

修法

三道检查,任何一道不过就停下来,不碰已有的文件:

  1. 预热之后:真的翻出东西了吗(count + cachedCount > 0)?
  2. 录完之后:页面上的覆盖层真的在吗(.manga-translator-overlay 的数量)?
  3. 最终状态:翻出来的张数和失败原因是什么?

失败时的报错直接写清楚原因和下一步: 「Chrome 从这台机器上到不了服务商 —— 看上面那行 worker -> provider, 如果服务商本身能到,试 --forward。」


P2 — --forward 改的是没人读的那份配置

现象

--forward 的意思是:本机跑一个转发器,把扩展的请求转到服务商。 它要改扩展里的服务商地址。

它改的是 providers[provider].baseUrl。而 worker 解析服务商时, 顶层那份 state.openaiCompatible 优先 —— 于是这次修改落在了 一个没人读的字段里,扩展照旧去请求那个到不了的地址。

这个模式一直是坏的,而且坏得很安静:脚本不报错,只是翻译一直失败。

修法

两份都改,并且把最终生效的服务商和地址打印出来:

[capture] config patched: provider=openai-compatible baseUrl=http://127.0.0.1:62993

P2 — 脚本不知道 worker 到不到得了服务商

现象

脚本知道页面能不能连上(它自己开了个本地服务器), 但真正发请求的是 worker,而这两者在这台机器上可以是两个世界。

没有这一行的时候,失败只有一句话:「翻译超时」。

修法

脚本现在从 worker 内部探一次服务商的地址,把结果和耗时打印出来:

[capture] worker -> provider: unreachable after 5003ms — The operation timed out

并且把 worker 的 console 输出和网络请求转发进采集日志。 这一行是「脚本坏了」和「这台机器到不了」之间的全部区别 —— 而后者才是真相。


P2 — 演示视频录的是一次失败

store/listing/demo.mp4 里录的是「成功 0 张,失败 2 张 / 请求超时」。

商店的视频是选填的。一段拍失败的视频比没有视频更糟: 它会被挂到商品页上,成为这个产品给人的第一印象。

删掉了。 采集脚本仍然能在能连上服务商的机器上重新生成一个 —— 流程是文档里写好的那一条命令。


P1 — 页面说”我会继续翻译”,然后就不再继续了

现象

自动续翻有一个预算:一页最多二十轮自动扫描(防止一个无限滚动的页面 一直触发新的计费请求)。预算用完时,观察器停了。

但页面上没有一个人知道这件事。

读者滚动、等待,什么也不会发生。而这两句话都是当时取的答案, 它们在预算用完的那一刻就过期了,没有人再去看一眼。

更糟的一半

applyAutoTranslateMode() 每次拿到新的配置快照都会重新武装观察器 —— 用的是配置上的开关,不是实际的状态。于是预算用完之后, 下一次配置刷新又把观察器立了起来:它每一轮都会被触发,每一轮都被 预算挡住,什么都不做。

一个不会发生任何事情的观察器,和一个根本不存在的观察器, 在读者眼里是一样的。

修法

把”这一页现在还会不会自动翻译新图片”收成一个函数, 配置和预算都要看:

function isAutoContinueArmed(): boolean {
  return isAutoTranslateEnabled && autoTranslateRunsRemaining > 0;
}

上报给 HUD 和弹窗的 autoContinueArmed 用的是它, 观察器武装用的也是它。

另外多报一个字段 autoContinueBudgetExhausted:光说”不会自动翻译了” 不够,读者分不清是自己没开开关还是这一页翻够了次数 —— 弹窗以前把它说成「「自动续翻」已打开,但本站不在名单里,本页也没有手动 翻译过」,每一句都是假的(开关是开着的,本页也翻过)。

现在弹窗说的是:

这一页的自动续翻已经用完次数,新出现的图片不会再自动翻译(不会产生 费用)。再点一次「翻译当前页面」可以重新开始。

顺带把”再来一次”的路径确认了一遍:用户手动翻一次,预算就补满 (origin === 'user' 时重置)。读者不会被卡死。


P1 — 运行途中出现的图片,再也不会被翻译

现象

观察器去抖 800 毫秒,而一次视觉模型请求要十到三十秒。

一张图在这轮翻译进行当中加载出来 → 观察器去抖 → 800 毫秒后触发 → shouldAutoTranslateFollowUp() 看到页面状态是 translating, 拒绝(翻译中不能再来一轮)→ 这一次续翻被丢掉。

然后没有任何东西重新排程。

这一轮跑完之后,这一页就停在”有一张图没翻”的状态上 —— 而弹窗还在说「滚动到它们出现后会自动继续翻译」。

修法

这一轮结束的时候(互斥锁释放之后)再排一次:

if (!translationCancelled && !fatalError && !wasAborted && isAutoContinueArmed()) {
  autoTranslateScheduler.schedule();
}

三个”不”是刻意的:


P1 — 画面换了,译文还盖在上面

现象

页面可以在翻译之后把图片的地址换掉:懒加载站点把缩略图换成大图是 常规做法,阅读器里点一下”看大图”也是。

换完之后,屏幕上已经是另一张画面,而旧译文还盖在上面 —— 它描述的是别的像素。框的位置也未必对。

而且新地址那张图再也不会被翻译了:第九轮定的规矩是 “观察器只问从来没问过的图”,而这张图问过了(问的是缩略图)。

于是这一页停在”盖着旧译文的原图”上。

修法

译完一张图就记住它当时是哪个地址,并听它自己的 load 事件 —— 属性可以先改、字节后到,只有 load 才意味着”新画面已经到位”。

画面变了就做三件事:

  1. 撤掉旧译文(removeTranslation,面板和锚点也跟着撤)。
  2. 把”这张图翻过了”的标记撤掉 —— 新画面回到候选池, 弹窗能数出它、也能给它标价。
  3. 保留”问过了”那条记录 —— 观察器不替读者重花这一次钱。 同一页漫画换了个分辨率,不是新的一页;要重翻,读者自己点一次。

地址只差一个版本号(page.webp?v=2)不算换画面:那一层译文还好着, 不该被白白撤掉。


P2 — 清除之后,画面的译文又回来了

现象

「清除本页译文」取消正在跑的那一轮,但那一轮还在收尾: 它发出去的请求还没回来。等它们回来的时候,它把已经画好的译文又画回 刚被清空的画面上,然后宣布「翻译完成」。

页面先是空的,然后又不是了。

修法

clearAll() 不再把 translationCancelled 复位。那一轮读这个标记来决定 “要不要报告结果” —— 复位了它就会以为自己是正常跑完的。下一轮开始时 它自己会重新武装这个标记。


P2 — 换章之后,报的是上一章的成绩

现象

单页阅读器换章不刷新页面,只换 URL。旧章的翻译还在跑的时候, 新章已经出现了。

旧章那一轮跑完了,报的是「翻译完成:成功 8 张」—— 而新章一张图都没有。

修法

换章时那一轮被明确标记为”被取代”,它不再报结果。


P2 — 改设置的那一下,剩下半页都失败了

现象

用户在翻译途中改了设置。worker 推来新的配置快照,页面把手里的 translator 置空(好让下一张图用新的服务商)。

这一轮里排队的图接着就发现没有 translator 了,抛出一句 Services not initialized —— 一句跟读者的图片毫无关系的失败。

修法

下一张图自己把需要的东西重建起来。配置快照带的就是用户刚要的配置, 重建正是对的做法,不是错误。


P1 — 一次请求,在账单上出现了两次

现象

两个标签页打开同一页漫画,或者观察器和读者自己的按钮同时要同一张图: worker 会把它们合并成一个 job(pageKey 相同)。两个调用方拿到同一份 结果,服务商只被问了一次。

而记账发生在调用方那边(内容脚本里的翻译器)—— 于是两个调用方各记一笔。

用户没花的钱,在「本月 N 次 API 调用」上出现了两次。这个数字是读者拿来 对账的;对不上账的账本比没有账本更糟。

根因

账记在了”谁收到了答案”,而不是”谁问了服务商”。

合并发生的时候,这两件事就分家了:一次请求,两个收到答案的人。

修法

记账挪到 worker 那一层(provider-direct-client.ts)—— 只有它知道 “真的问了一次”。这一次请求记一次,两个调用方拿到的是同一个数字。

翻译器自己只留一条记录:缓存命中。那不是一次请求,是一次没有发生的 请求,它要留在”这次翻译没有花钱”的那一栏里。

provider 那一层记的”2xx 但答案读不懂”不变:那一层之后错误就被拍扁成一句 话了,usage 再也拿不到,只能由它记。


这次验证的方式

测试 去掉修复后
排队中的任务用运行时的服务商 用入队时的服务商 —— 用户换了服务商,钱照旧花
服务商没配好时任务停下来 带着空密钥去请求,报「认证失败」
worker 的拒绝语原样出现在卡片上 「发生未知错误:当前服务商还没有配置好」
第一张被拒就停掉整轮 十九张图各失败一次,各报一条同样的错误
预热没翻出东西就不写素材 把一次失败录成商店素材
--forward 改两份服务商配置 只改没人读的那份 —— 模式一直是坏的
打印 worker 到服务商的连通性 失败只有一句「翻译超时」
预算用完后不再说”会自动继续翻译” 页面继续承诺一件不会发生的事
预算用完后说清楚为什么 「「自动续翻」已打开,但本站不在名单里」—— 每一句都是假的
用户再点一次就能重新开始 读者被卡住,只能刷新页面
运行途中出现的图会被翻 那张图永远停在原图上
画面换了,旧译文被撤掉 旧译文盖在新画面上
画面换了,新图回到候选池 弹窗说”已经翻过了”,其实没有
观察器不为换图重花一次钱 同一页漫画付两次钱
清除之后那一轮不再画回来 刚清空的画面又出现译文
换章之后不再报上一章的成绩 新章报「成功 8 张」
改设置之后剩下半页不失败 剩下的图报「Services not initialized」
一次请求只记一笔 一次请求在账单上出现两次
翻译器不再自己记账 内容脚本里再记一次 —— 两个调用方就是两笔

模式

前十轮的题目是「这句话对不对」。这一轮的题目是:

这个答案是什么时候取的?

一个答案在被取出来的那一刻是真的。它躺在队列里、被写进文件、 被贴上商店页面,中间隔着十分钟、一次失败、一份没人读的字段 —— 它就过期了,而没有人检查。

这一轮改的每一处,都是在使用的那一刻重新取一次答案, 并且在答案不成立时说清楚为什么。

而”重新取一次”不总是意味着多取一次。有时候正确的做法是 承认它过期了:预算用完了,就说预算用完了;画面换了, 就把不再描述画面的译文撤掉。