这篇文章记录的是一个很具体的问题。

如果你平时喜欢拆前端、盯运行时,或者对“明明官方没给这个入口,我能不能自己把它接出来”这种事情有天然兴趣,那你应该会理解这篇文章的出发点。

看到 Claude Web 没有中文,我最初的反应不是“等官方以后也许会加”,而是:

那我能不能自己把它做出来?

我想给 Claude Web 加一个 简体中文 语言选项,而且不是简单把界面上某几个按钮替换成中文,而是尽量复用 Claude 自己的 i18n 加载链路,让页面像切官方语言一样,在运行时重新加载语言资源。

最后做到的效果是:

  • 在 Claude Web 的语言菜单里注入一个 简体中文
  • 页面保持官方支持的 locale,不去伪造后端 profile
  • 运行时拦截 Claude 的 i18n 请求,返回自定义中文资源
  • 最关键的是,找到 Claude Web 内部真正控制语言重载的 runtime 入口,在不刷新页面的情况下触发语言重新加载

这件事表面上看像“做个浏览器扩展”,但中间真正难的不是菜单注入,也不是静态资源服务,而是这个问题:

Claude Web 页面运行时里,到底谁才是真正的语言切换入口?

如果你只想看最后的答案,可以先剧透:

window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride(...)

但如果你真的准备在某个现代前端应用里做类似的事情,我建议你别只抄这行。真正值钱的是我是怎么走到这行代码的。

Claude Web 语言菜单里的中文入口

问题从一开始就不是“翻译文件放哪里”

一开始很容易把问题理解成:

  • 我准备一份 zh-CN.json
  • 让 Claude 去请求它
  • 然后把菜单里多塞一个 “简体中文”

如果你也会本能地这么想,不奇怪,我一开始也是这么想的。

但很快就会撞到第一个硬约束:Claude 后端 profile 的 locale 不是任意字符串,而是一个受限枚举。

实际观察到的行为是:

  • 选择官方语言时,Claude 会发一个 profile 更新请求
  • 后端保存一个 locale 字段,比如 en-US
  • 如果强行尝试 zh-CN,后端会直接拒绝

也就是说,这条路根本走不通:

“把 Claude 的官方语言直接改成 zh-CN”

这不是前端 trick 不够的问题,而是后端模型就不接受。

所以从这个节点开始,整个方案必须改成:

  • 官方 locale 保持 Claude 支持的值
  • 自己额外维护一个扩展侧的 override locale
  • 当 override 生效时,拦截 Claude 页面里的 i18n 请求,返回自定义中文语言包

后来我把这个扩展自己的状态固定成了:

localStorage["claude-i18n:locale"] = "zh-CN"

这个设计非常关键,因为它把“扩展语言模式”和“Claude 官方 profile locale”彻底解耦了。

换句话说,我不是在试图说服 Claude “你现在支持中文了”,而是在 Claude 原本语言系统的外面,再加一层自己的 override。

前半段其实是比较正常的扩展工程

项目的基本形态是一个 Chrome Extension,再配一个静态资源仓库给语言包和版本文件。

大体分成几层:

  1. 页面注入层
    在 Claude 页面里插入自定义的 简体中文 菜单项。

  2. 页面 hook 层
    在 page context 里拦截 fetch / XMLHttpRequest,把 Claude 对 /i18n/... 的请求接走。

  3. service worker 层
    负责去远端拿自定义语言资源、读取版本信息、做缓存命中和更新。

  4. 语言资源静态站点
    提供: /i18n/zh-CN.json
    /i18n/statsig/zh-CN.json
    以及版本清单。

其中静态资源站点还有一个看起来很小、但其实很重要的工程问题:不要把扩展目录整个发布出去。

仓库里有 extension/,如果直接把仓库当静态站点推上去,等于把扩展源码和资源一起公开暴露。后来我采用的是很朴素但有效的做法:

  • 用构建脚本把允许发布的文件白名单复制到 dist/
  • 只发布入口页、404 和语言目录
  • extension/ 完全不进产物

这个阶段本身不难,但它决定了后面整套方案能不能以一个比较干净的方式部署。

dist 白名单产物

如果你未来也要做这种“浏览器扩展 + 页面注入 + 自己托管静态资源”的混合系统,我建议不要小看这些基础工程问题。很多项目后面跑不稳,不是死在逆向上,而是死在边界一开始就没划清楚。

注入菜单并不算最难,真正难的是“看起来切了”不等于“真的切了”

Claude Web 的语言菜单不是一个静态 DOM,而是 Radix UI 的 portal / popper 菜单。

我最开始在 DevTools 里确认到的关键结构是:

  • 悬浮左侧 Language 项后,右侧 submenu 会动态插入到 document.body
  • submenu 的外层节点是:
<div data-radix-popper-content-wrapper>
  • 真正的菜单节点是:
<div data-radix-menu-content role="menu">
  • 这个 submenu 和左侧 trigger 的关联,不是靠文本,而是:
    • submenu 上有 aria-labelledby
    • 左侧 trigger 有匹配的 id
    • 同时 trigger 的 aria-controls 指回 submenu

这一步很重要,因为我一开始也走过“看文字猜是不是语言菜单”的路线。那条路能工作一会儿,但很快会在 profile 菜单、设置菜单上误注入,完全不稳。

真正可靠的识别方式,是利用 Radix 自己建立的结构关系。

这里其实有一个很通用的经验:
优先相信组件库自己维护的结构关系,不要优先相信显示给用户看的文案。

DevTools 中的 Radix submenu DOM 结构

简体中文 菜单项注入进去之后,还有一堆 UI 细节问题:

  • hover 高亮没有
  • 勾选图标不对
  • clone 到已选语言项时,会把选中态一起复制过去
  • 布局容易歪掉

这些都能修,但它们其实都不是真正的难点。

真正的难点在于:就算你把 简体中文 这个菜单项做得跟原生一模一样,也不代表 Claude 真的完成了语言切换。

这个坑很值得单独强调,因为很多扩展最后都折在这里:

你以为你在改“功能”,其实你改的只是“表象”。

我后来遇到的一个典型 bug 是这样的:

  • 当前官方语言其实还是 en-US
  • 我点了扩展注入的 简体中文
  • 视觉上中文项勾上了,英文项取消了
  • 但 Claude 内部真正的“当前官方语言”并没有变

结果就是:

  • 页面看起来像中文模式
  • 但再点 English 切回去时,Claude 自己不一定会走完整的语言切换链路
  • 勾选态和内部真实状态开始脱节

这一刻我才意识到:DOM 勾选态不是控制源。

spa:locale 也不是控制源

另一个很自然的想法是直接写 localStorage["spa:locale"]

我确实验证过,改它有时会影响一部分可见状态,甚至会让某些地方短暂表现出“语言变了”的样子。

但它不是真正的 runtime 控制入口,原因有两个:

  1. Claude 的页面逻辑并不会在每次菜单 hover 或每次渲染时都把它当成唯一真值重新读取。
  2. 即使你把它改成 zh-CN,页面也不会自动走完整的 i18n 重载流程。

所以 spa:locale 的地位大概更像:

  • 某个持久化痕迹
  • 某些初始化状态的输入

但绝不是:

“我改了它,Claude 就会无刷新切语言”

这个判断让我后面少走了很多弯路。

如果你读到这里已经感觉这件事开始不像普通前端开发,而更像前端逆向了,对,就是从这里开始变味的。

错误路线:spa:locale 和假勾选态

真正关键的问题:Claude Web 到底怎么在运行时加载语言

后面的工作开始从“扩展工程”变成“前端逆向”。

我的核心目标被收敛成一句话:

找到 Claude Web 页面运行时里那个真正可调用的语言覆盖入口。

我当时已经掌握了一条非常关键的源码线索。Claude 的 i18n loader 大概长这样:

function rsn() {
  const { locale: e } = L()
    , { activeOrganization: t } = LI()
    , n = t?.uuid
    , s = lW(e => e.localeOverride)
    , a = s ?? e
    , r = lW(e => e.setGatedMessages)
    , i = lW(e => e.clearGatedMessages)
    , { data: o } = f({
      queryKey: ["i18n_public", a],
      queryFn: async () => {
        const e = encodeURIComponent(a)
          , t = "en-US" !== a
          , [n, s, r] = await Promise.all([
              fetch(`/i18n/${e}.json`),
              fetch(`/i18n/statsig/${e}.json`),
              t ? fetch(`/i18n/${e}.overrides.json`).catch(() => null) : Promise.resolve(null)
          ]);
        ...
      }
    })
  ...
}

以及与之配套的 Zustand-like store:

const oW = S(e => ({
  messages: {},
  messagesLocale: null,
  gates: [],
  isLoaded: false,
  localeOverride: null,
  setGatedMessages: ...,
  setLocaleOverride: t => e({ localeOverride: t }),
  clearGatedMessages: ...
}))

这两个片段已经把真相暴露得差不多了:

  • 页面真正用来决定要加载哪种语言的是:
localeOverride ?? locale
  • 只要 localeOverride 被改掉,后面的 i18n 请求就会跟着改
  • 真正的可调用入口不是菜单 click handler,也不是 spa:locale
  • 而是 store 上的:
setLocaleOverride(...)

后来我在正确的运行时作用域里手动调用过一次:

window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride("zh-CN")

结果非常直接:

  • 页面无刷新
  • 立刻重新请求:
    • /i18n/zh-CN.json
    • /i18n/statsig/zh-CN.json
    • /i18n/zh-CN.overrides.json

到这里,最大的技术问题其实已经从“能不能无刷新切语言”变成了:

扩展怎么稳定拿到这个 store?

这时候问题的质地已经完全变了。

前面的问题还是“我怎么实现一个功能”,到这里已经变成:

我怎么在一个不是我写的现代前端应用里,
稳定拿到一个藏在运行时闭包里的真实控制对象?

为什么我没有继续在 React 树里盲挖

很多人第一反应都会是:

  • 扫 React fiber
  • 扫 hook 链
  • 扫全局对象
  • 扫 webpack cache

这些我都碰过,而且不是完全没结果,但都不够稳。

如果你做过类似的事情,你应该很熟悉这种感觉:

  • 好像每次都快找到了
  • 好像每次都能 work 一点
  • 但你心里知道,这种东西一旦做成产品,迟早会炸

不稳的原因大概有几类:

  1. React 树太嘈杂
    Claude Web 页面很复杂,fiber 和 hook 链里候选对象太多,很难把“有一点像 store 的对象”和“真正的 i18n store”可靠区分开。

  2. 运行时结构漂移
    即使某一版里某个组件名、某条 hook 链路能用,换个构建很可能就变了。

  3. 它们都是“事后搜尸体”
    页面都已经跑起来了,你再去盲扫,很难知道自己看到的是不是那个真正控制语言切换的对象。

我后来很明确地把这些路线降级成“辅助观察手段”,而不是主路线。

这是我很想和未来读者强调的一点:

能帮你观察到真相的工具,不一定适合成为最终方案。

我试过两条更激进的路,最后都放弃了

1. 替换顶层 index-*.js

这个思路很诱人:

  • document_start 抢到 Claude 顶层的 type="module" 入口脚本
  • 拉原始源码
  • 注入一行把 i18n store 泄露到全局
  • 再用一个 patched 的 blob: module 替掉原始入口

理论上这很优雅,因为你能在应用最早期改源码。

而且老实说,这种方案一开始会给人一种“我已经拿到上帝视角”的错觉。

问题是 Claude 用的是 Vite ESM 运行时,这种“整包入口替换”会悄悄改变一堆语义:

  • 相对 import 的解析基准
  • import.meta.url
  • 模块图的启动顺序
  • 某些 runtime 初始化假设

我后来即使把相对模块改写成绝对 URL,把 import.meta.url 也尽量补回去了,应用启动还是会逐渐跑歪,最后出现过类似:

Must call inside CurrentAccountProvider

这种错误非常说明问题:

不是某个 import 单独坏了,而是整体启动语义被破坏了

所以这条路最后被我定性为:

太侵入,不适合作为产品方案

2. chrome.debugger

第二条可行但不能发布的路,是借 Chrome DevTools Protocol。

它技术上非常有效:

  • 扩展通过 chrome.debugger 附到 Claude tab
  • 等目标 index-*.js 被解析
  • Debugger.getScriptSource 拿源码
  • 按我已知的 i18n loader 特征定位到断点位置
  • 在正确 call frame 里把 store 泄露到全局

这条路甚至已经能稳定把状态暴露成:

window.__CLAUDE_I18N_DEBUGGER_STATUS__

并且能自动拿到 store 变量名。

但它有一个产品上完全不能接受的问题:Chrome 会显示非常显眼的横幅,告诉用户这个扩展正在调试页面。

这对开发探针是可以的,对普通用户绝对不行。

这种时刻特别容易让人掉进一个误区:技术上已经可行,于是下意识觉得事情已经解决了。

其实没有。

对于一个真正要给别人装的扩展来说,“会不会弹一个像安全告警一样的横幅”本身就是技术约束。

所以它最后被保留下来的价值,只是:

  • 帮我验证 runtime 锚点
  • 帮我确认真正的入口确实是 setLocaleOverride(...)

而不是最终实现本身。

关键转折:别再找变量名了,去抓“store 被创建出来的瞬间”

真正把事情做成的转折点,是我换了一个问题。

之前的问题一直是:

“怎么在页面跑起来以后找到那个叫 lW 或 oW 的东西?”

这个问题本身就不稳,因为变量名会漂移。

也就是说,我之前一直在找“一个名字”,但真正该找的是“一个时机”。

后来我换成了另一个问题:

“这个 store 最初是怎么长出来的?”

答案是:它是一个 Zustand bound store。

Zustand 很常见的一种绑定方式,是把一个可调用 hook 函数和 store API 合并在一起,变成一个“既能当 hook 用、又带 getState / setState / subscribe”的对象。这个过程通常会经过类似:

Object.assign(useBoundStore, api)

也就是说,只要我能在这个瞬间拦一下 Object.assign,我就有机会在 store 绑定完成的那一刻把目标对象抓住。

说得更直白一点:

我不需要知道它以后会叫 oWlW 还是别的什么名字。
我只需要在它刚被“做出来”的那一刻认出它。

这比任何事后 blind scan 都更稳,因为它抓的是“出生现场”。

最终方案:document_start + 一次性 Object.assign 劫持

最后成功的实现非常克制。

不是:

  • 替换整个 bundle
  • 长期 monkey patch 一堆运行时 API
  • 反复扫 React 内部结构

而是:

  1. 用 content script 在 document_start 注入 page hook
  2. page hook 只临时替换一次 Object.assign
  3. 每次 Object.assign(target, ...sources) 时,只检查极窄的一种对象形态
  4. 一旦命中目标 store:
    • 把它暴露到 window.__CLAUDE_I18N_STORE__
    • 立即恢复原始 Object.assign
    • 后续通过暴露出来的句柄调用 setLocaleOverride(...)

我的识别条件不是变量名,而是 store state / action 的结构:

state &&
typeof state === "object" &&
"localeOverride" in state &&
"messagesLocale" in state &&
typeof state.setLocaleOverride === "function" &&
typeof state.setGatedMessages === "function" &&
typeof state.clearGatedMessages === "function"

这几个条件合在一起,指向性已经非常强了。

命中后我把它保存成:

window.__CLAUDE_I18N_STORE__

同时还加了两层保护:

  • 命中后立刻恢复原始 Object.assign
  • 如果一段时间内没命中,也自动超时恢复,避免长期污染页面运行时

这个方案的好处非常明显:

  • 不依赖 lW / oW 这种漂移符号名
  • 不依赖 React fiber / hook 树
  • 不替换顶层入口,不破坏 Vite 运行时语义
  • 不用 chrome.debugger,没有用户可见横幅
  • 注入点足够早,但修改面足够窄

它本质上不是“黑魔法越用越多”,反而是把改动面收缩到了最小

这一点我非常喜欢。

很多 runtime hack 往后做会变成一个越来越大的泥球,而这次恰恰相反:越往后走,方案越克制,越窄,越像一个能活下来的工程实现。

extension/hook.js 中的核心捕获代码

验证成功的那一刻

做完这个原型以后,我在页面里先看状态:

window.__CLAUDE_I18N_RUNTIME_STATUS__

拿到的是:

{
  "stage": "ready",
  "captured": true,
  "reason": "assign-target",
  "keys": [
    "messages",
    "messagesLocale",
    "gates",
    "isLoaded",
    "localeOverride",
    "setGatedMessages",
    "setLocaleOverride",
    "clearGatedMessages"
  ],
  "localeOverride": "zh-CN",
  "messagesLocale": null
}

这其实已经说明抓到的就是它了。

window.<strong>CLAUDE_I18N_RUNTIME_STATUS</strong> 的控制台输出

然后我做了最关键的一步验证:

window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride("ja-JP")

页面当场无刷新切成了日语。

那一刻事情就定了。

如果你也做过逆向或者运行时注入,你应该懂这种时刻有多爽。

不是因为“页面变成日语了”这件事本身有多重要,而是因为你知道:

  • 入口找对了
  • 假设闭环了
  • 方案从“实验”跨进了“工程可用”

调用 setLocaleOverride 后页面切成日语的效果

这不是“在 DevTools 某个幸运作用域里手调成功一次”,而是:

  • 扩展自动抓到了真正的 runtime i18n store
  • 这个句柄可以被稳定调用
  • Claude 自己的 i18n 重载链路被正常触发了

也就是说,真正的入口可以正式写成:

window.__CLAUDE_I18N_STORE__.getState().setLocaleOverride(...)

更准确地说,产品级入口不是这个全局变量名本身,而是:

通过一次性运行时捕获拿到的 i18n store 句柄,然后调用它的 setLocaleOverride(...)

我最后学到的其实不是“怎么 hack Claude”,而是“怎么缩小注入面”

回头看这整个过程,最值得记住的不是某个具体命令,而是几条方法论:

1. 先区分“视觉状态”和“真实控制源”

菜单勾选态不是控制源。
spa:locale 不是控制源。
后端 profile locale 也不是你能随便扩展的控制源。

真正控制语言重载的是运行时 i18n store 里的 localeOverride

2. 不要迷信能扫到一切的 blind scan

如果一个对象是模块内部闭包状态,那你事后去扫描整个运行时,只会得到一堆不稳定的候选对象。

比起“到处搜”,更重要的是找到它的创建时机必经边界

3. 整包 patch 往往不是最聪明的办法

很多时候最先想到的方案是“我把整个 bundle 改了不就行了”。
这在现代前端应用里经常是最危险的方案。

不是不能做,而是你很容易改坏:

  • 模块解析
  • 初始化时序
  • 运行时上下文

尤其在 Vite ESM 这种环境里,入口替换的副作用非常大。

4. 真正好的注入点通常更窄

这次最终成功,不是因为 patch 得更狠了,而是因为 patch 得更窄了:

  • 只动一次 Object.assign
  • 只盯一类极窄对象形态
  • 命中立刻恢复

这比很多“通用逆向技巧”都更工程、更能发布。

如果这篇文章最后只能留下一句话,我希望是这句:

在现代前端应用里做 runtime 注入,最强的方案往往不是改得最多的那个,而是侵入最小、命中最早、恢复最快的那个。

这件事现在还剩下什么

到这里,最大的不确定性已经被清掉了。

已经确认的事情包括:

  • Claude Web 的真正 runtime 语言覆盖入口是 setLocaleOverride(...)
  • 这个入口在页面里确实能无刷新触发语言重载
  • 不能硬编码压缩变量名
  • 可以通过低侵入的一次性运行时工厂劫持,稳定拿到目标 store

剩下的工作更偏工程收口:

  • 把当前原型封装成正式的页面桥接 API
  • 和菜单注入、请求拦截、缓存逻辑整合
  • 处理 override 激活 / 清除时的状态同步
  • 继续验证不同 Claude 构建版本下这条捕获条件是否足够稳

但最难的那个问题已经解决了。

不是“中文资源能不能送进去”,而是:

Claude Web 运行时里,到底有没有一个真正可调用、能无刷新切语言的入口?

答案是:有。

而且最后找到它的方式,不是最重的那条路,而是最窄的那条路。

如果你未来真的打算在别的站点上做类似的事情,我希望这篇记录能帮你少走一点我走过的弯路。

至少下次当你又准备开始扫 fiber、扫 hooks、扫 webpack cache 的时候,也许会先停一下,问自己一个更好的问题:

我到底是在找一个名字,还是在找一个时机?