news 2026/9/18 19:37:17

三层一致 UA 切换:请求头、JS 与 Client Hints

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
三层一致 UA 切换:请求头、JS 与 Client Hints

1. 一个 1.7 分的扩展,暴露了 UA 切换这件事有多难

那个被很多人当成官方标配的 UA 切换器扩展,最近评分掉到了 1.7。我翻了一下差评内容,绝大多数吐槽集中在同一件事上:切了 UA,页面该认出你还是认出你。有的站点看你navigator.userAgent是 Safari,但请求头里还写着 Chrome;有的站点反过来,请求头改成功了,前端 JS 一读还是老值;还有更隐蔽的一类,前两层都对上了,但Sec-CH-UA那一串 Client Hints 头原封不动地漏了出去,服务端直接把两套信息一对比,就知道你在伪装。

我把这类问题统称为「三层不一致」。所谓三层,指的是HTTP 请求头里的 User-AgentJS 里能读到的navigator.userAgent、以及Client Hints 体系(Sec-CH-UA*头 +navigator.userAgentData。这三层只要有一层没对齐,稍微认真一点的 UA 检测就能拆穿你。

市面上的 UA 切换器大多只改第一层。原因也不难理解:改请求头是浏览器扩展里最成熟的能力,declarativeNetRequest几行 JSON 就能搞定;改 JS 层要往页面主世界注入脚本,涉及执行时机和竞态,麻烦得多;Client Hints 则是近几年才铺开的新东西,很多扩展作者压根没跟上。

我花了大概一天时间,写了一个三层一致的开源替代,代码量不大,但踩的坑比预想的多。这篇就把整个设计思路、实现细节、验证方法和踩坑记录摊开说清楚。适合三类人看:写浏览器扩展的前端同学、需要做兼容性自测和埋点校验的 QA/测试同学、以及被 UA 检测坑过的爬虫与数据采集方向的朋友。工具本身的使用场景是本地开发调试和自动化测试,不要拿它去干违反目标站点服务条款的事,这一点我后面还会再强调一次。

2. 三层一致到底指哪三层:先把原理掰开

2.1 第一层:HTTP 请求头里的 User-Agent

这是最表层、也最容易被改的一层。浏览器每次发请求,都会在请求头带上一行User-Agent,形如:

User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36

这行字符串的历史包袱极重。Mozilla/5.0是上世纪浏览器大战留下的遗迹,几乎每个浏览器都要假装自己是 Mozilla;AppleWebKit/537.36是 WebKit 的产物,Chrome 内核早就换成了 Blink,但为了兼容当年只认 WebKit 的嗅探代码,这串字样还留着;KHTML, like Gecko更是纯粹的历史化石。真正有用的信息其实只有末尾的Chrome/126.0.0.0 Safari/537.36和括号里的平台描述。

正因为这串字符串又长又混乱,Chrome 从 110 版本开始推行 UA 缩减(User-Agent Reduction),把次版本号、平台版本号这些细节冻结或者删掉,改成用 Client Hints 按需提供。所以你看到Chrome/126.0.0.0这种版本号后三段全是 0 的写法,那不是浏览器偷懒,是官方策略。

改这一层,扩展里的标准做法是declarativeNetRequest(简称 DNR)的modifyHeaders动作。它声明式地告诉浏览器「匹配到这些请求时,把某个头替换成某个值」,浏览器在网络层执行,不需要扩展参与每一次请求,性能和隐私都更友好。

注意:改user-agent请求头的时候,一定要顺手处理sec-ch-uasec-ch-ua-platformsec-ch-ua-mobile这一组头。只改前者不删后者,等于自己给自己挖坑。

2.2 第二层:JS 可读的 navigator.userAgent

第二层是页面脚本能直接读到的东西。navigator.userAgent返回的字符串,正常情况下和请求头里的User-Agent是一致的(同一套 UA 缩减规则),但它是浏览器暴露给 JS 的一个独立属性。

绝大多数前端嗅探代码读的就是这个。比如判断「是不是移动端」,很多老代码写的是:

const isMobile = /iPhone|iPad|Android/i.test(navigator.userAgent);

还有一堆衍生属性:

属性典型值说明
navigator.userAgentMozilla/5.0 (Windows NT 10.0...)主 UA 字符串
navigator.appVersion5.0 (Windows NT 10.0; Win64; x64)...UA 去掉开头的 Mozilla/
navigator.platformWin32MacIntelLinux x86_64平台标识,已废弃但仍在广泛使用
navigator.vendorGoogle Inc.浏览器厂商
navigator.maxTouchPoints010触控点数,常被用来判断设备类型

改这一层,必须往页面的主世界(MAIN world)注入脚本,把Navigator.prototype上的 getter 重写掉。这里有两个关键点:第一,主世界和扩展的隔离世界是两套 JS 环境,隔离世界里改navigator对页面脚本毫无影响;第二,执行的时机必须早于页面自己的嗅探代码,否则人家已经读完了你才改,白忙活。

2.3 第三层:Client Hints 与 navigator.userAgentData

第三层是很多人忽略的地方。Chrome 在 JS 侧暴露了navigator.userAgentData

navigator.userAgentData.brands // [{ brand: "Chromium", version: "126" }, // { brand: "Google Chrome", version: "126" }, // { brand: "Not.A/Brand", version: "8" }] navigator.userAgentData.mobile // false navigator.userAgentData.platform // "Windows"

更进一步,还有个异步接口getHighEntropyValues(),能拿到高熵信息:

await navigator.userAgentData.getHighEntropyValues([ 'platformVersion', 'architecture', 'bitness', 'model', 'uaFullVersion', 'fullVersionList' ]);

配套的请求头有这些:

请求头含义备注
Sec-CH-UA品牌与主版本列表低熵,默认发送
Sec-CH-UA-Mobile是否移动端低熵,默认发送
Sec-CH-UA-Platform平台名低熵,默认发送
Sec-CH-UA-Full-Version-List完整版本列表高熵,需服务端Accept-CH请求后才发
Sec-CH-UA-Platform-Version平台版本号高熵
Sec-CH-UA-ArchCPU 架构高熵
Sec-CH-UA-Bitness位数高熵
Sec-CH-UA-Model设备型号高熵,移动端才有意义

高熵那几项默认不发送,服务端要先用响应头Accept-CH: Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform-Version主动索取,浏览器才会在后续请求里带上。但注意,一旦服务端索取过,这个「授权」是按源(origin)记住的,后续同源请求都会带上。

2.4 三层不一致的时候,到底会暴露什么

举个具体的例子。假设你把第一层改成了 macOS 上的 Safari 17:

  • 请求头:User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15
  • Sec-CH-UA没删,服务端看到的是"Chromium";v="126", "Google Chrome";v="126"—— 矛盾一。
  • JS 层navigator.userAgent还是原值 —— 矛盾二,前端埋点上报的 UA 和后端日志里的 UA 对不上,数据侧先炸。
  • navigator.userAgentData.brands还在,页面一句navigator.userAgentData?.brands就知道你是 Chromium 系 —— 矛盾三。
  • 再细一点,navigator.platformWin32navigator.maxTouchPoints是 0,screen尺寸、devicePixelRatio也都还是 Windows 那套 —— 矛盾四,这一层靠 UA 切换器本身解决不了,属于更高阶的指纹问题。

看清楚这四类矛盾,你就明白为什么市面上只改请求头的扩展会被差评。前三条是扩展能解决的,第四条要动的东西太多,做不到「完全隐身」,也没必要——我们的目标不是对抗指纹识别,而是让页面在做 UA 分支判断时走到我们想测的那条路径上。

3. 方案设计:为什么最后选了这套组合

3.1 唯一能用的权限模型:MV3 下的现实选择

Manifest V3 之后,webRequest的阻塞式改写能力被砍掉了,现在webRequestBlocking只对少数企业策略场景开放。普通扩展想改请求头,官方给的路径就是declarativeNetRequest

这里有一个选择:用静态规则(写在rule_resources里随扩展打包)还是动态规则(运行时通过 API 增删改)。

静态规则的优点是启动即生效,不需要扩展后台先跑起来,缺点是配置固定,用户改一下就要更新扩展包,显然不现实。

动态规则分两种作用域:

  • updateDynamicRules:持久化,跨浏览器重启保留,但有数量上限(默认 5000 条左右的全局配额,具体随版本变化)。
  • updateSessionRules:只在当前浏览器会话有效,重启清空,配额独立。

我的选择是用 session rules 做临时切换,用 dynamic rules 存用户自定义的 UA 模板。理由是:UA 切换本身是个「临时状态」,用户切换完刷新页面就该生效,关掉浏览器不该留下痕迹;而自定义模板是需要长期保留的资产。这样设计,也避免 session rules 配额被历史垃圾规则塞满。

3.2 请求头层的规则粒度:全局还是按标签页

第二个决策点是规则的作用范围。我做了个取舍:

  • 默认模式:全局生效。所有标签页都按当前 UA 配置走,适合「我现在要集中测某套 UA 下的页面表现」这种场景。
  • 可选模式:按标签页生效。DNR 的规则条件里支持tabIds字段,可以只对指定标签页生效。

全局模式的好处是简单,坏处是容易忘——你调完 A 站忘了关,回头刷 B 站发现排版全乱。按标签页模式更精确,但需要监听tabs.onRemoved清理规则,否则规则会随着标签页开关越堆越多。

我把两个都实现了,默认走全局,弹窗里给一个「仅当前标签页」的勾选框。实现上,按标签页模式会给每个 tabId 分配一个规则 ID:1000 + tabId。这个偏移量选 1000 是为了给动态规则留出 ID 空间,避免两类规则 ID 撞车。

3.3 配置结构设计:一份 Profile 要包含什么

三层一致的前提是,配置对象本身得是「三层齐全」的。所以我的 Profile 结构长这样:

{ id: 'safari-mac-17', label: 'Safari 17 / macOS', // 第一层:请求头 headers: { 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', // 显式声明要删除的 Client Hints 头 remove: ['sec-ch-ua', 'sec-ch-ua-mobile', 'sec-ch-ua-platform', 'sec-ch-ua-full-version-list', 'sec-ch-ua-platform-version', 'sec-ch-ua-arch', 'sec-ch-ua-bitness', 'sec-ch-ua-model'] }, // 第二层:JS 可读属性 js: { userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', appVersion: '5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15', platform: 'MacIntel', vendor: 'Apple Computer, Inc.', maxTouchPoints: 0 }, // 第三层:Client Hints hints: { enabled: false, // Safari 不支持 userAgentData brands: [], mobile: false, platform: 'macOS', highEntropy: {} } }

注意hints.enabled这个字段。切换成 Firefox 或者 Safari 的时候,正确做法不是「伪造一套 Chromium 的 brands」,而是让navigator.userAgentData直接不存在——因为真实的 Safari 和 Firefox 根本没有这个 API。这一点很反直觉,很多扩展作者的第一反应是「造一套假的 brands 塞进去」,结果造出来的东西比不造还假。

3.4 与自动化测试的配合:留一个后门

写这个工具的一大动机是我要给一个测试套件做 UA 分支覆盖。所以我预留了一个本地 API:扩展的 service worker 里注册了一个消息通道,自动化脚本(比如 Puppeteer 或者 Playwright)可以通过 CDP 调用扩展的 messaging,或者更简单——在 URL 上挂一个约定参数,让扩展识别并切换。

后者更土,但确实好用:

https://your-test-site.local/page?__tri_ua=safari-mac-17

扩展在主世界脚本里解析这个参数,如果存在且和当前配置不一致,就通过window.postMessage通知隔离世界,隔离世界再转发给 background 更新规则。用完之后弹窗里点一下「清除临时切换」,规则就回滚了。

提示:这个后门只在开发环境用。上线到正式 profile 里的时候,建议把参数匹配限制在localhost*.local域名,避免被别人拿去当攻击面。

4. 动手实现:从零到能跑起来

4.1 目录结构与 manifest 关键字段

先看目录,我尽量保持扁平:

tri-ua-switcher/ ├── manifest.json ├── src/ │ ├── background.js # service worker,管规则和状态 │ ├── shared/ │ │ ├── profiles.js # 内置 UA 模板库 │ │ └── rule-builder.js # DNR 规则生成 │ ├── content/ │ │ ├── isolated.js # 隔离世界:读配置、转发消息 │ │ └── main.js # 主世界:改写 navigator │ └── popup/ │ ├── index.html │ └── popup.js

manifest.json里有几个字段是成败关键:

{ "manifest_version": 3, "name": "Tri-UA Switcher", "version": "0.1.0", "minimum_chrome_version": "116", "permissions": [ "declarativeNetRequest", "storage", "scripting", "tabs", "webNavigation" ], "host_permissions": ["<all_urls>"], "background": { "service_worker": "src/background.js", "type": "module" }, "action": { "default_popup": "src/popup/index.html" }, "content_scripts": [ { "matches": ["<all_urls>"], "js": ["src/content/main.js"], "run_at": "document_start", "world": "MAIN", "all_frames": true, "match_about_blank": true }, { "matches": ["<all_urls>"], "js": ["src/content/isolated.js"], "run_at": "document_start", "all_frames": true } ] }

几个点值得单独说:

"world": "MAIN"是 Chrome 111 才稳定的能力。在这之前想往主世界注入,只能靠document.createElement('script')src指向扩展内的资源文件,还得在web_accessible_resources里声明。现在直接声明world干净太多,而且能保证run_at: document_start的时机。

all_frames: true是必须的。很多站点的登录、支付、嵌入播放器都跑在 iframe 里,你只改主框架,iframe 里的 UA 判断照样走原路径。

match_about_blank: true也有用,about:blank的 iframe 默认不匹配 content script,但很多站点会先建一个空白 iframe 再往里写内容,不匹配就漏了。

minimum_chrome_version建议卡在 116 以上,因为高熵 Client Hints 的注入时机和injectImmediately的稳定性在后面几个版本才靠谱。

4.2 用 DNR 改请求头:规则生成的三个细节

规则生成我抽了个rule-builder.js

const RULE_ID_BASE = 1000; export function buildHeaderRule(profile, { tabId, priority = 1 } = {}) { const requestHeaders = []; for (const [name, value] of Object.entries(profile.headers)) { if (name === 'remove') continue; requestHeaders.push({ header: name, operation: 'set', value }); } for (const name of profile.headers.remove ?? []) { requestHeaders.push({ header: name, operation: 'remove' }); } const condition = { resourceTypes: [ 'main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', 'object', 'xmlhttprequest', 'ping', 'media', 'websocket', 'other' ] }; if (typeof tabId === 'number') { condition.tabIds = [tabId]; } return { id: RULE_ID_BASE + (tabId ?? 0), priority, action: { type: 'modifyHeaders', requestHeaders }, condition }; }

三个容易翻车的细节:

第一,resourceTypes必须列全。默认不写这个字段的话,DNR 只匹配一部分类型。我第一次跑就发现主文档的 UA 改了,但页面里的 XHR 请求还是老 UA,服务端接口层照样能看出矛盾。把所有类型都列上才是稳妥做法——当然代价是规则匹配的覆盖面变大,性能上有一点点开销,实测可以忽略。

第二,删除操作要用operation: 'remove',而不是set成空字符串。有些服务端会检查头是否存在,空字符串和不存在是两回事。特别是sec-ch-ua这类,设成空值反而更可疑。

第三,规则 ID 不能重复。updateSessionRulesaddRules时如果 ID 已存在会直接抛错,所以每次更新前必须先removeRuleIds把老规则删掉。同理,updateDynamicRules也遵守这个约束。

4.3 主世界的 navigator 改写:惰性 getter 的写法

主世界脚本是整个工具里最容易写错的部分。核心思路是把Navigator.prototype上那几个属性的 getter 换掉:

(() => { const CFG_KEY = '__TRI_UA_CFG__'; const state = { ready: false, cfg: { js: { userAgent: navigator.userAgent, appVersion: navigator.appVersion, platform: navigator.platform, vendor: navigator.vendor, maxTouchPoints: navigator.maxTouchPoints ?? 0 }, hints: { enabled: true } } }; // 隔离世界通过这个事件把配置推过来 window.addEventListener('message', (ev) => { if (ev.source !== window) return; const data = ev.data; if (!data || data[CFG_KEY] !== 'push') return; Object.assign(state.cfg, data.payload); state.ready = true; }); function define(name, target, getter) { const desc = Object.getOwnPropertyDescriptor(target, name); if (!desc || !desc.configurable) return; Object.defineProperty(target, name, { configurable: true, enumerable: desc.enumerable, get: getter }); } define('userAgent', Navigator.prototype, () => state.cfg.js.userAgent); define('appVersion', Navigator.prototype, () => state.cfg.js.appVersion); define('platform', Navigator.prototype, () => state.cfg.js.platform); define('vendor', Navigator.prototype, () => state.cfg.js.vendor); define('maxTouchPoints', Navigator.prototype, () => state.cfg.js.maxTouchPoints); // userAgentData:Safari / Firefox 场景要让它整体消失 (function patchUserAgentData() { const fake = { brands: [], mobile: false, platform: '', getHighEntropyValues: async () => ({}) }; Object.defineProperty(navigator, 'userAgentData', { configurable: true, enumerable: false, get() { if (!state.cfg.hints.enabled) return undefined; return { brands: state.cfg.hints.brands ?? [], mobile: state.cfg.hints.mobile ?? false, platform: state.cfg.hints.platform ?? '', getHighEntropyValues: async (hints) => { const src = state.cfg.hints.highEntropy ?? {}; const out = {}; for (const h of hints) { if (h in src) out[h] = src[h]; } return out; }, toJSON() { return { brands: this.brands, mobile: this.mobile, platform: this.platform }; } }; } }); })(); // 隔离世界可能来得比我们晚,主动握一次手 window.postMessage({ [CFG_KEY]: 'pull' }, '*'); })();

这段代码里有四个点是我踩坑踩出来的:

Navigator.prototype而不是navigator实例。直接改实例的话,页面脚本里任何一次Object.getPrototypeOf(navigator).userAgent都能拿到原值,等于没改。改原型才是从根上断掉。

必须保留configurable: true有些站点(尤其是安全类的)会二次包装这些属性,如果你把configurable设成false,对方改不动,反而可能直接抛异常,暴露被篡改的事实。

userAgentData要用实例属性而不是原型。因为真实 Chrome 里userAgentData就是挂在Navigator.prototype上的访问器,但 Safari 里压根没有这个属性。用Object.defineProperty(navigator, ...)定义在实例上,会让'userAgentData' in navigator返回trueNavigator.prototype上没有,这个细微差异理论上是可检测的。更严谨的做法是判断原属性在原型还是实例上,跟着定义在同一层。我这里为了代码可读性做了简化,实际项目里可以按这个思路补上。

惰性 getter 是应对竞态的关键。主世界脚本和隔离世界脚本的执行顺序不保证,所以 getter 里读的是state.cfg,配置没到时返回原始值,配置到了自然返回新值。这样即使握手晚了几毫秒,页面里大多数「延迟判断」的嗅探逻辑也能拿到正确的值。

4.4 隔离世界的桥接与消息流

隔离世界的活很简单:读配置,转发给主世界,顺便处理从 background 来的指令。

const CFG_KEY = '__TRI_UA_CFG__'; async function push() { const { activeProfile } = await chrome.storage.session.get('activeProfile'); if (!activeProfile) return; const payload = { js: activeProfile.js, hints: activeProfile.hints }; window.postMessage({ [CFG_KEY]: 'push', payload }, '*'); } window.addEventListener('message', async (ev) => { if (ev.source !== window) return; if (ev.data?.[CFG_KEY] === 'pull') push(); }); chrome.runtime.onMessage.addListener((msg) => { if (msg?.type === 'TRI_UA_UPDATED') push(); }); push();

chrome.storage.session而不是storage.local,是因为 UA 切换状态本来就不该持久化。session storage 在浏览器重启后自动清空,正好符合「临时调试」的定位。注意storage.session默认对 content script 不可见,需要在 background 里调一次chrome.storage.session.setAccessLevel({ accessLevel: 'TRUSTED_AND_UNTRUSTED_CONTEXTS' }),这一步漏了的话隔离世界读到的一直是undefined

4.5 弹窗 UI:三个下拉加一个开关就够了

弹窗不需要花哨。我做了一个极简结构:UA 模板下拉、作用范围开关(全局/当前标签页)、Client Hints 同步开关、应用按钮、重置按钮。核心逻辑在popup.js

document.querySelector('#apply').addEventListener('click', async () => { const profileId = document.querySelector('#profile').value; const onlyCurrentTab = document.querySelector('#scope').checked; const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); await chrome.runtime.sendMessage({ type: 'APPLY_PROFILE', profileId, tabId: onlyCurrentTab ? tab.id : null }); });

background 收到之后做三件事:更新 session rules、写storage.session、向所有标签页广播TRI_UA_UPDATED。广播这一步别省,否则已打开的标签页里 JS 层还是旧值,得手动刷新才生效。

5. 怎么确认三层真的对齐了:验证方法

5.1 搭一个本地验证页,一次看齐三层

验证不能靠感觉。我搭了个最土的 Node 服务,同一个响应里既返回服务端看到的头,也返回页面 JS 读到的值:

import http from 'node:http'; const PAGE = `<!doctype html> <html><body> <pre id="out">loading...</pre> <script> (async () => { const ua = navigator.userAgent; const plat = navigator.platform; const uad = navigator.userAgentData ? { brands: navigator.userAgentData.brands, mobile: navigator.userAgentData.mobile, platform: navigator.userAgentData.platform, high: await navigator.userAgentData.getHighEntropyValues( ['platformVersion','architecture','bitness','model','fullVersionList'] ) } : null; const res = await fetch('/echo'); const server = await res.json(); document.getElementById('out').textContent = JSON.stringify( { js: { ua, plat, uad }, server, consistent: server['user-agent'] === ua }, null, 2 ); })(); </script> </body></html>`; http.createServer((req, res) => { if (req.url === '/echo') { res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ 'user-agent': req.headers['user-agent'], 'sec-ch-ua': req.headers['sec-ch-ua'] ?? null, 'sec-ch-ua-platform': req.headers['sec-ch-ua-platform'] ?? null, 'sec-ch-ua-mobile': req.headers['sec-ch-ua-mobile'] ?? null })); return; } res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'accept-ch': 'Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform-Version' }); res.end(PAGE); })).listen(8787, () => console.log('http://127.0.0.1:8787'));

这个页面有三个用途:第一,consistent字段直接告诉你请求头和服务端是否一致;第二,sec-ch-ua*是不是null,一眼看出 Client Hints 有没有被清干净;第三,uad是不是null,验证「切到 Safari/Firefox 时 userAgentData 应该消失」这条规则有没有生效。

我实测的时候,第一版扩展跑出来的结果是consistent: false,原因就是我在规则里漏写了xmlhttprequest这个 resourceType——fetch('/echo')走的是 XHR 类型,没匹配到规则,服务端收到的还是原始 UA。这种问题不搭验证页根本发现不了。

5.2 用真实站点的埋点日志做交叉验证

本地页能验证机制,但验证不了实际效果。更靠谱的一招是找那种「前端埋点把 UA 上报、后端接口也记录 UA」的页面,切换之后看两条记录对不对得上。

具体做法:切换 UA,打开目标页,正常操作几下让埋点触发,然后在 Network 面板里过滤上报请求,看请求头上的user-agent,再看请求体里的 UA 字段。两边一致,说明前两层通了。这一步能顺带发现一种隐蔽问题:某些前端框架会在页面初始化时把navigator.userAgent缓存进一个模块级变量,你的脚本注入晚了一点点,缓存进去的就是原值。这种只能靠提早注入时机解决。

5.3 自动化回归:把三层校验写进测试用例

给 CI 用的话,把验证页的逻辑挪进 Playwright:

import { test, expect } from '@playwright/test'; const PROFILES = ['chrome-win-126', 'safari-mac-17', 'firefox-linux-128']; for (const id of PROFILES) { test(`profile ${id} keeps three layers aligned`, async ({ page }) => { await page.goto(`http://127.0.0.1:8787/?__tri_ua=${id}`); await page.waitForFunction(() => !document.querySelector('#out').textContent.startsWith('loading')); const result = JSON.parse(await page.textContent('#out')); expect(result.consistent).toBe(true); if (id.startsWith('safari') || id.startsWith('firefox')) { expect(result.js.uad).toBeNull(); expect(result.server['sec-ch-ua']).toBeNull(); } else { expect(result.js.uad.brands.length).toBeGreaterThan(0); } }); }

这套用例跑一遍大概十几秒,但能挡住 90% 的回归问题。之前我把sec-ch-ua-platform从删除列表里误删过一次,就是被 safari 那条用例拦下来的。

6. 踩坑记录与常见问题速查

6.1 一份问题速查表

现象大概率原因处理办法
请求头改了,JS 层没变主世界脚本没注入成功,或注入晚于页面嗅探检查world: MAINdocument_start;改用injectImmediately
JS 层改了,接口请求还是老 UAresourceTypes漏了xmlhttprequest把所有类型列全
切 Safari 后仍被识别为 Chromiumsec-ch-ua*头没删,或userAgentData没屏蔽补删除操作,并把hints.enabled设为 false
iframe 内不生效all_frames: true补上,同时考虑match_about_blank
规则越积越多,切换变慢session rules 没清理每次更新前removeRuleIds,监听tabs.onRemoved
隔离世界读不到配置storage.session访问级别没放开setAccessLevel
页面报错Cannot redefine property目标属性configurable: false跳过该属性并记录日志,不要强改
切换后要手动刷新才生效没广播更新消息background 里向所有 tab 发消息

6.2 三个最容易翻车的时机问题

第一个是document_start的真正含义。很多人以为document_start是「页面还没开始解析」,其实它是「文档已创建,但 DOM 树还没构建、脚本还没执行」。这个时间点注入是够早的,但你在脚本里访问document.body会拿到null。我第一版就在主世界脚本里读了document.documentElement.dataset,直接报错。

第二个是 service worker 的冷启动。MV3 的 background 是事件驱动的 service worker,空闲 30 秒左右会被回收。用户在弹窗里点「应用」的时候,background 可能正在冷启动,这个启动过程有几十到几百毫秒延迟。如果用户在切换后立刻刷新页面,可能出现「规则还没写进去,页面已经加载完了」。我的处理是在APPLY_PROFILE响应里带一个ready信号,弹窗收到之后再更新 UI;同时在隔离世界脚本里做一次「握手重试」,配置没拿到就连发三次pull,间隔 50ms。

第三个是 DNR 规则的生效延迟。updateSessionRules是异步的,返回的 Promise resolve 只代表规则已经写入,不代表下一个请求一定命中。实测下来延迟在几毫秒级别,但如果你在同一个 tick 里立刻发起请求,有极小概率漏过。稳妥做法是自动化脚本里等 100ms 再导航。

6.3 关于合法边界,我想多说两句

这个工具的能力边界要说清楚。它能做的是:让页面在做 UA 分支判断时走到指定路径,方便你测不同 UA 下的渲染差异、接口行为、埋点字段。它不能做的是:对抗成熟的设备指纹识别。前面提过,screen尺寸、devicePixelRatio、WebGL 渲染器字符串、字体列表、时区、语言偏好这些东西,任何一个都能和 UA 交叉验证,要全改一遍工程量是另一个量级。

更重要的是使用边界。这套东西的定位是本地开发调试和自动化测试,用在你自己负责的站点、你自己的测试环境上,没有任何问题。拿它去绕开别人站点的访问控制、伪造身份做规模化采集,那是另一回事,可能触犯对方的服务条款,也可能踩到法律红线。我在代码里把默认作用域限制在可配置的白名单域名上,就是为了提醒自己这个边界。你要复现的话,建议也保留这个约束。

7. 一些实操心得和后续可以扩展的方向

折腾这一天下来,最大的体会是:UA 切换的难点从来不在「改」,而在「改得一致」。只改一层,五分钟就能写完;把三层对齐,工作量翻十倍,但效果也是质的差别。我做完之后拿几个内部测试页跑了一遍,之前那些因为 UA 判断错乱导致的「随机失败」用例,一次性全绿了。

如果要把这个工具再用得顺手一点,我会往两个方向扩展。一是加一个「UA 片段对比」的小功能:把当前配置的三层值并排列出来,让用户一眼看到哪里可能矛盾,比如设了 macOS 但maxTouchPoints填了 10 这种低级错误。二是做配置的导入导出,把团队里常用的几套 UA 预设存成 JSON 文件,让 QA 同学一键加载,避免每个人手动填一串字符串时打错一个字符。

还有个小技巧分享给同样在写扩展的人:调试主世界脚本的时候,console.log是打在页面上下文的,DevTools 的控制台里能直接看到,但要记得在 console 的 context 选择器里切到页面而不是扩展。我一开始找了半天日志,以为注入失败,其实只是看错了上下文。另外,如果你的主世界脚本里要抛异常做调试,记得用try/catch包住,未捕获的异常会让页面的window.onerror触发,某些监控系统会把它当业务错误上报,污染线上数据。

代码我放在了本地仓库,等到把userAgentData的原型/实例层级那块再打磨一下,就准备公开出去。有在用类似工具的朋友,欢迎把你遇到的 UA 检测方式留言告诉我,我看看能不能补进验证页的用例集里——尤其是那种「三层全对上了但还是被识别」的刁钻场景,那才是真正值得研究的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 19:35:14

RWA代币化全指南:从资产选择到合规与流动性落地

RWA这个话题&#xff0c;我在圈子里跟人聊了很多次&#xff0c;发现一个很有意思的现象&#xff1a;真正动手参与过的人不多&#xff0c;但几乎所有做传统资产的人都在问&#xff0c;做链上原生资产的人也在问。它不像DeFi那种纯链上玩法&#xff0c;一上来就是池子、收益、合约…

作者头像 李华
网站建设 2026/9/18 19:34:59

ArcKit /arckit:story实战:八章节叙事自动生成项目完整历史档案

ArcKit /arckit:story实战&#xff1a;八章节叙事自动生成项目完整历史档案 【免费下载链接】arc-kit The Enterprise Architecture Governance Harness — strategy, architecture, delivery, and assurance using AI coding assistants 项目地址: https://gitcode.com/GitH…

作者头像 李华
网站建设 2026/9/18 19:29:51

TI C2000 DSP实现三相异步电机矢量控制实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 19:29:01

Qt树形列表菜单开发指南:从QTreeWidget到QTreeView+Model实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华