Playwright 代理:路由、认证与未改变的浏览器信号
排查 Playwright 代理路由、上下文直连规则与 SOCKS5 认证,用同一份关闭后的 profile 对照两个真实出口,区分网络变化与未改变的浏览器信号。
BotBrowser Team
Playwright 代理可以把请求送到预期出口,而时区、语言和 canvas 仍然不变。路由与浏览器环境需要分别检查:代理工作正常,并不意味着建立了新的 profile。BotBrowser 支持声明 profile、代理、时区、区域与语言输入,但不能让上游线路接受无效凭据,也不保证目标如何解释这些输入。
要点
- 先用自有源站或观测端点证明请求实际经过哪条线路,再调整浏览器设置。页面加载成功本身不能证明由哪个代理处理。
- 区分启动级默认代理与上下文明示的代理。路由排除项选择直连,不是失败后回退到另一个代理。
- Playwright 文档中的 HTTP 凭据不会给 Chromium 增加原生 SOCKS5 密码认证支持。先检查协议边界,再排查登录页面。
- 用同一份已关闭 profile 的字节一致副本,对照两个已验证的公共出口。浏览器值保持稳定是一项观测,不是目标身份判断的证明。
目录
从最先失败的操作开始
保留最先失败的操作及其响应。创建上下文失败、代理拒绝连接和网站拒绝账号操作属于不同阶段,前两项都发生在网站处理账号操作之前。排查 playwright proxy 时,最有用的初始记录是代理协议、配置作用域、目标协议以及第一个错误。分享日志前应把凭据替换为标签;错误信息可能带有服务器地址或内嵌密钥的 URL。
| 现象 | 应收集的证据 | 第一次受控调整 |
|---|---|---|
| 创建上下文时拒绝 SOCKS5 凭据 | 导航前的 API 错误;监听器连接为零 | 核对文档中的协议支持 |
HTTP 代理返回 407 挑战 | 代理挑战数与已接受的转发计数 | 检查该代理的 HTTP 凭据 |
源站返回 401 | 与代理挑战分开的源站响应 | 检查该源站的身份认证 |
| 页面经意外线路加载 | 启动代理、上下文代理计数与源站标记 | 修正配置作用域或排除项 |
| 两条线路显示相同公共地址 | 观测响应、时间戳与服务商线路标签 | 先验证上游路由,再比较信号 |
| 出口不同,浏览器值相同 | Profile 快照、浏览器二进制与逐项页面读数 | 检查测试要求对应的浏览器输入 |
导航超时不等于诊断结论。增加 page.goto() 超时可能帮助一个响应较慢的授权端点,但不能新增传输层认证方式,也不能改变上下文的路由。同样,仅凭目标的 403 无法定位失败组件。应保留响应,在授权排查范围内定位,而不是对第三方反复修改无关设置。
代理 URL 与目标 URL 回答的是不同问题。http://proxy.test:8080 表示连接 HTTP 代理。https:// 目标可以通过它的 CONNECT 隧道传输,这并不会把代理本身变成 HTTPS 代理。两个协议都应记录。隧道已被接受,也不证明上游服务商选中了哪个出口。Puppeteer 中 HTTP、HTTPS 与 SOCKS5 的故障区别 解释了这些协议术语;Playwright 分开的启动与上下文设置还引入了后面的作用域问题。
使用返回明确标记的源站,并设定请求预算与计数器。源站标记证明预期响应确实到达;代理计数器证明哪条本地线路完成了转发。IP 观测端点回答的是另一个问题:该次请求对外呈现的公共地址。分开保留这三种记录,才能避免把页面截图当作网络证据。直连和转发都可以渲染源站原封不动的 HTML。
区分启动路由、上下文路由与直连排除项
文档 API 允许在启动浏览器和创建浏览器上下文时配置代理。持久化启动也接受代理设置,并绑定自己的 userDataDir。这些设置的生命周期不同。普通 browser.newContext() 是隔离的,不会把浏览状态写入该目录;持久化上下文使用磁盘状态,关闭它也会关闭所属浏览器。仅因某份示例用了某种上下文就照搬,可能改变实验的存储条件。
明示的上下文代理是一项新的路由选择,不是要求先试启动代理、失败后再试上下文代理。应把上下文配置视为整体:服务器、所支持的凭据与排除列表。每个字段都要在创建该上下文的位置检查。launch() 中构造的线路,不能证明后续 newContext({ proxy }) 实际提供了什么。Playwright 浏览器设置的选择 先解释浏览器的归属与上下文生命周期,再进入网络对照。
精确成员 proxy.bypass 是文档中的逗号分隔路由排除列表。这里的排除表示匹配请求直接连接,不经过所配置的代理;它不表示目标授予访问权限,也不定义浏览器身份。请求被排除在上下文代理 C 之外,不证明启动代理 L 处理了它。受控矩阵需要分别记录 L 与 C,才能区分源站直连和两种转发路径。
最终解释路由规则的是浏览器,因此应使用工作负载中的精确 URL 形式测试规则。Chromium 区分简单主机名、域名模式和 IP 字面量 URL。<local> 规则针对不含点的简单主机名,并不等于所有最终解析到本机的地址。CIDR 规则匹配 URL 中的 IP 字面量,不会仅因主机名的 DNS 结果落在该网段就匹配它。故障记录中需要保留主机拼写与端口。
回环地址值得单独设置本地用例。Chromium 有隐式本地地址排除规则,Playwright 也会构造回环路由规则。1.63.0 实现会先检查明示的回环条目,再决定是否加入修饰规则。选项拼写不变,并不足以把旧结果移植到新版。笼统声称 localhost 总是直连,或未经测量就假设作用域行为,都不能构成证据。记录安装版本,并检查 127.0.0.1 的转发计数即可;无需未文档化的环境变量或额外解析器参数。
BotBrowser 代理配置参考 负责说明产品的网络输入。这些输入不会替换 Playwright 对象自身的作用域,也不会替它合并设置。切换自动化库时,需要明确转换配置模型,而不是复制外观相似的对象,就假定它仍有相同路由语义。
改变浏览器前先复现路由
本地故障 fixture 可以把目标网站的行为从诊断中移除。先在动态分配的回环端口上启动自有 HTTP 源站,再启动只允许转发到该源站的代理。使用唯一响应标记,记录挑战、接受的转发和源站直连请求;代理应拒绝其他所有目标。复现 HTTP 凭据错误不需要一个开放的通用转发器。
HTTP 认证应分别测试正确、缺失和错误凭据。成功用例必须同时证明发生了转发、读到了源站标记。拒绝用例必须证明源站没有被访问,而不只是匹配一个方便的报错字符串。浏览器重试可能产生多次挑战,所以应断言有意义的性质,例如接受的转发为零,而不是假定所有环境有相同套接字尝试次数。这些本地 HTTP 结果也不能充当上游服务商认证政策的测试。
SOCKS5 需要自己的边界。固定的 Playwright 1.63.0 实现会在 Chromium SOCKS5 路径中拒绝代理用户名与密码选项。此时,失败可能发生在 SOCKS 监听器收到连接之前。它不是网站登录失败,把凭据移到源站 HTTP header 中也不会增加缺失的 SOCKS 协商。无认证 SOCKS 监听器与携带凭据的配置测试的是不同论断。由本地适配器认证上游 SOCKS 服务属于另一种架构,必须明确标注。
路由作用域也需要独立用例。给启动代理 L 和上下文代理 C 分别设置计数器。先确定启动默认线路,再创建明示 C 线路的上下文,最后测试 C 上下文中的直连排除项;源站标记始终保持不变。否则,页面变化可能掩盖线路变化,无法分清是哪项修改产生了表面上的修复。单看源站计数无法区分 L 与 C,因为两个代理都能转发同样的内容。
浏览器任务应在有界池中运行,而不是串行跨过不同机器负载。Fixture 接受 --pool 和 --workers,默认请求 20,在选择实际并发数前测量可用 CPU 与内存。请求更多槽位不会产生更多独立用例。如果外部对照不能同时支持两条浏览器线路,应在发送观测请求前停止;先跑 A、很久后再跑 B 会削弱受控比较。
本地实测结果与可执行 fixture
受控运行使用 Playwright 1.63.0、原版 Chrome 154.0.8037.57、Node 20.20.2 与 Ubuntu 24.04.5 LTS,日期为多伦多时间 November 11, 2026。公开 HTTP fixture 的六个任务并发执行:估算 31.88 个空闲 CPU 核心、121726328832 字节可用内存,足以支持全部六项。请求池为 20,实际数量由用例数限制,不是随意降成串行。三个独立 SOCKS 用例也并发运行。这是以 root 执行、仅访问自有源站的隔离环境,使用 Playwright 默认的 chromiumSandbox: false;该条件不是生产部署建议。
| 受控用例 | 实测结果 | 证明结果的计数 |
|---|---|---|
| 启动 HTTP,正确凭据 | 经 L 读到源站标记 | L 转发 1;C 转发 0 |
| 启动 HTTP,错误凭据 | 无源站标记 | L 挑战 2;转发 0 |
| 启动 HTTP,未提供凭据 | 无源站标记 | L 挑战 1;转发 0 |
启动设置明示排除 127.0.0.1 | 直连读到源站标记 | 直连源站 1;L/C 转发 0 |
| 上下文 C 覆盖带排除项的启动 L | 经 C 读到源站标记 | C 转发 1;L 转发 0 |
上下文 C 明示排除 127.0.0.1 | 直连读到源站标记 | 直连源站 1;L/C 转发 0 |
| SOCKS5,不提供凭据 | 读到源站标记 | 路由 1;源站命中 1 |
| 启动级 SOCKS5 凭据 | API 拒绝 | 监听器连接 0;源站命中 0 |
| 上下文级 SOCKS5 凭据 | API 拒绝 | 监听器连接 0;源站命中 0 |
这些是实测计数,不是通用重试次数。错误 HTTP 凭据产生 net::ERR_HTTP_RESPONSE_CODE_FAILURE;缺失凭据产生 net::ERR_INVALID_AUTH_CREDENTIALS。两个 SOCKS 凭据用例都返回 Browser does not support socks5 proxy authentication。本地代理还拒绝了无关后台连接尝试;只有带唯一标记的应用请求才算成功转发。1.63.0 的回环结果符合其固定版本实现;较早的 1.49.0 探索结果不同,不能替代本次运行。
将下面完整的本地示例保存为 playwright-local-proxy.mjs,放在已安装 [email protected] 的包旁。把 CHROME 设为原版 Chrome 可执行文件路径。实测 Linux 主机提供 DISPLAY=:88,浏览器以 headless 运行。不需要上游凭据或外部目标。脚本会拒绝无关代理目标、清理资源、检查 CPU 与内存;断言失败或可用槽位少于两个时,以失败状态退出。
CHROME=/opt/google/chrome/chrome node playwright-local-proxy.mjs --pool 20
import assert from 'node:assert/strict';
import { randomBytes } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs/promises';
import http from 'node:http';
import os from 'node:os';
import { createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const require = createRequire(process.env.PLAYWRIGHT_RESOLVE_FROM || import.meta.url);
const { chromium } = require('playwright');
const playwrightVersion = require('playwright/package.json').version;
const expectedVersion = process.env.EXPECTED_PLAYWRIGHT_VERSION || '1.63.0';
assert.equal(playwrightVersion, expectedVersion, `Install playwright@${expectedVersion} beside this script`);
const CHROME = process.env.CHROME;
const flags = [
'--password-store=basic',
'--use-mock-keychain',
'--disable-background-networking',
'--disable-component-update',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
];
const safeError = error => ({
name: error.name,
networkCode: String(error.message).match(/net::ERR_[A-Z_]+/)?.[0] || null,
});
export async function resources() {
const sample = () =>
os.cpus().reduce(
(sum, cpu) => ({
idle: sum.idle + cpu.times.idle,
total: sum.total + Object.values(cpu.times).reduce((a, b) => a + b, 0),
}),
{ idle: 0, total: 0 }
);
const before = sample();
await new Promise(resolve => setTimeout(resolve, 250));
const after = sample();
const unusedCpuCores = (os.cpus().length * (after.idle - before.idle)) / Math.max(1, after.total - before.total);
let availableMemoryBytes = os.freemem();
if (process.platform === 'darwin') {
const vm = execFileSync('vm_stat', { encoding: 'utf8' });
const pageSize = Number(vm.match(/page size of (\d+) bytes/)[1]);
availableMemoryBytes = [...vm.matchAll(/^(?:Pages free|Pages inactive|Pages speculative):\s+(\d+)\./gm)].reduce(
(sum, match) => sum + Number(match[1]) * pageSize,
0
);
} else if (process.platform === 'linux') {
availableMemoryBytes =
Number((await fs.readFile('/proc/meminfo', 'utf8')).match(/^MemAvailable:\s+(\d+)/m)[1]) * 1024;
}
return {
capturedAt: new Date().toISOString(),
cpuCount: os.cpus().length,
unusedCpuCores: Number(unusedCpuCores.toFixed(2)),
availableMemoryBytes,
};
}
export function requestedPool(argv = process.argv.slice(2)) {
const index = argv.findIndex(value => /^--?(pool|workers)$/i.test(value));
const requested = index < 0 ? 20 : Number(argv[index + 1]);
assert.ok(Number.isInteger(requested) && requested > 0, 'Pool must be a positive integer');
return requested;
}
export function capacity(snapshot, requested, jobs) {
const cpuSlots = Math.floor(snapshot.unusedCpuCores);
const memorySlots = Math.floor(snapshot.availableMemoryBytes / 1024 ** 3);
return {
requested,
jobs,
effective: Math.min(requested, jobs, Math.max(0, cpuSlots), Math.max(0, memorySlots)),
cpuSlots,
memorySlots,
reason: 'One estimated idle CPU core and 1 GiB per browser; at least two concurrent slots required',
};
}
export async function pool(jobs, size) {
let next = 0;
const results = [];
await Promise.all(
Array.from({ length: size }, async () => {
while (next < jobs.length) {
const index = next++;
results[index] = await jobs[index]();
}
})
);
return results;
}
export async function listen(server) {
const sockets = new Set();
server.on('connection', socket => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
socket.on('error', () => {});
});
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
return {
port: server.address().port,
async close() {
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
},
};
}
export async function httpCase(spec) {
let origin, left, right, browser;
const marker = randomBytes(12).toString('hex');
const counts = { origin: { direct: 0, L: 0, C: 0 }, originOtherRequests: 0 };
const result = { case: spec.name, startedAt: new Date().toISOString(), expectedRoute: spec.expected };
try {
origin = await listen(
http.createServer((req, res) => {
if (req.url !== '/') {
counts.originOtherRequests++;
res.writeHead(404).end();
return;
}
const route = req.headers['x-local-route'] || 'direct';
counts.origin[route]++;
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`<p id="marker">${marker}</p><p id="route">${route}</p>`);
})
);
const target = `http://127.0.0.1:${origin.port}/`;
async function proxyServer(label) {
const counter = (counts[label] = { requests: 0, challenges: 0, accepted: 0, forwarded: 0, denied: 0 });
const expected = `Basic ${Buffer.from('local-user:local-password').toString('base64')}`;
const server = http.createServer((req, res) => {
if (req.url !== target) {
counter.denied++;
res.writeHead(403).end();
return;
}
counter.requests++;
if (req.headers['proxy-authorization'] !== expected) {
counter.challenges++;
res.writeHead(407, { 'Proxy-Authenticate': 'Basic realm="owned-local-test"' }).end();
return;
}
counter.accepted++;
counter.forwarded++;
const upstream = http.request(
{
host: '127.0.0.1',
port: origin.port,
path: '/',
headers: { Host: `127.0.0.1:${origin.port}`, 'X-Local-Route': label },
},
remote => {
res.writeHead(remote.statusCode, remote.headers);
remote.pipe(res);
}
);
upstream.on('error', () => {
if (!res.headersSent) res.writeHead(502);
res.end();
});
req.pipe(upstream);
});
server.on('connect', (req, socket) => {
counter.denied++;
socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
});
return listen(server);
}
left = await proxyServer('L');
right = await proxyServer('C');
function settings(port, excluded, credential = 'correct') {
const proxy = { server: `http://127.0.0.1:${port}` };
if (credential !== 'absent') {
proxy.username = 'local-user';
proxy.password = credential === 'wrong' ? 'incorrect' : 'local-password';
}
if (excluded) proxy.bypass = '127.0.0.1';
return proxy;
}
browser = await chromium.launch({
executablePath: CHROME,
headless: true,
proxy: settings(left.port, spec.launchExcluded, spec.credential),
args: flags,
timeout: 25000,
});
result.browserVersion = browser.version();
const context = await browser.newContext(
spec.contextProxy ? { proxy: settings(right.port, spec.contextExcluded) } : {}
);
await context.route('**/*', route => (route.request().url() === target ? route.continue() : route.abort()));
const page = await context.newPage();
let response = null,
navigationError = null;
try {
response = await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 12000 });
} catch (error) {
navigationError = safeError(error);
}
let content = '',
contentReadError = null;
try {
content = await page.content();
} catch (error) {
contentReadError = {
...safeError(error),
pageChangingAfterFailure: /Unable to retrieve content/.test(String(error.message)),
};
}
const markerObserved = content.includes(marker);
const observedRoute = markerObserved ? await page.locator('#route').textContent() : null;
Object.assign(result, {
navigationStatus: response?.status() ?? null,
navigationError,
contentReadError,
markerObserved,
observedRoute,
});
assert.equal(observedRoute, spec.expected);
for (const label of ['direct', 'L', 'C']) assert.equal(counts.origin[label], label === spec.expected ? 1 : 0);
for (const label of ['L', 'C']) assert.equal(counts[label].forwarded, label === spec.expected ? 1 : 0);
if (spec.expected) assert.equal(response.status(), 200);
else {
assert.ok(counts.L.challenges > 0);
assert.equal(counts.L.accepted, 0);
}
return { ...result, status: 'passed', counts };
} catch (error) {
return { ...result, status: 'failed', error: safeError(error), counts };
} finally {
if (browser) await browser.close();
if (left) await left.close();
if (right) await right.close();
if (origin) await origin.close();
}
}
export const cases = [
{ name: 'launch-http-correct', expected: 'L' },
{ name: 'launch-http-wrong', credential: 'wrong', expected: null },
{ name: 'launch-http-absent', credential: 'absent', expected: null },
{ name: 'launch-loopback-exclusion', launchExcluded: true, expected: 'direct' },
{ name: 'context-overrides-launch-exclusion', launchExcluded: true, contextProxy: true, expected: 'C' },
{
name: 'context-loopback-exclusion-no-launch-fallback',
contextProxy: true,
contextExcluded: true,
expected: 'direct',
},
];
export async function main() {
assert.ok(CHROME, 'Set CHROME to the browser executable');
assert.ok(
!process.env.PLAYWRIGHT_DISABLE_FORCED_CHROMIUM_PROXIED_LOOPBACK,
'Use Playwright default loopback handling'
);
const snapshot = await resources(),
execution = capacity(snapshot, requestedPool(), cases.length);
if (execution.effective < 2) {
const output = {
passed: false,
status: 'resource-preflight-stopped',
playwrightVersion,
resources: snapshot,
execution,
cases: [],
externalRequests: 0,
};
console.log(JSON.stringify(output, null, 2));
process.exitCode = 1;
return output;
}
let active = 0,
maximumActive = 0;
const results = await pool(
cases.map(spec => async () => {
maximumActive = Math.max(maximumActive, ++active);
try {
return { ...(await httpCase(spec)), finishedAt: new Date().toISOString() };
} finally {
active--;
}
}),
execution.effective
);
assert.ok(maximumActive >= 2, 'The local matrix must run concurrently');
const output = {
passed: results.every(result => result.status === 'passed'),
playwrightVersion,
expectedVersion,
display: process.env.DISPLAY || null,
sandbox: 'Playwright default chromiumSandbox:false; isolated owned-origin test',
forcedLoopbackHandlingDisabled: false,
resources: snapshot,
execution,
maximumActive,
cases: results,
scope:
'Loopback HTTP authentication and observed launch/context routing in this pinned runtime; no external request',
};
console.log(JSON.stringify(output, null, 2));
if (!output.passed) process.exitCode = 1;
return output;
}
if (process.argv[1] && import.meta.url === pathToFileURL(await fs.realpath(process.argv[1])).href) {
main().catch(error => {
console.error(JSON.stringify({ passed: false, error: safeError(error) }));
process.exitCode = 1;
});
}
用一份关闭后的 profile 对照两个真实出口
两个代理端口不等于双出口实验。不同的本地适配器仍可能经上游抵达同一个公共地址。比较浏览器值之前,需要两条授权线路都从自有观测端点返回有效公共地址,并确认两者不同。如果任一响应是认证页面、格式错误的 trace、意外跳转或网络错误,对照就不完整。本地端口号或服务商的国家标签不能替代这项证据。
使用一份已创建、正常关闭、随后保持不可变的普通 profile。由它制作两个独立可写副本,在任何浏览器启动之前比较目录树摘要。副本必须包含相同的选定文件,并采用相同的临时锁文件排除规则。对照结束后再计算原始输入摘要。Profile 目录名称相同,不能证明内容相同;让两个浏览器共享一个正在写入的目录,还会引入锁与状态竞争。
固定浏览器可执行文件、操作系统、启动模式、viewport,以及任何显式 locale 设置。在通过 launchPersistentContext() 创建的持久化上下文 A 与 B 之间,只改变授权线路。同时启动两条线路,各向自有观测端点导航一次,再在该页面读取浏览器值。这也界定了观测范围:这两次请求、这两个克隆输入和这个环境,而不是浏览器整个生命周期内的所有网络路径。
Cloudflare 将各域名的 /cdn-cgi/trace 端点文档化为排障资源。此处使用 botbrowser.io 上的观测端点,并设明确请求上限;不需要访问无关检测站、搜索引擎或目标网站。完整 trace 可能包含原始地址,所以应在内存中验证与比较,只在证据中保留临时的、每次运行独有的密钥摘要,而不保存原始地址或可重复使用的地址查找值。线路标签描述授权配置,不保证物理地理位置。
需要认证的 SOCKS 上游必须准确命名。受限的本地 HTTP CONNECT 适配器可以向上游认证,同时浏览器对适配器使用普通 HTTP 代理协议。成功请求证明组合路径对允许的目标有效,并没有推翻 Chromium 原生 SOCKS5 密码限制,更不能说成 Playwright 接受了 SOCKS5 用户名密码。适配器隧道计数、页面值和观测响应需要分别记录。
双真实出口实测对照
相同的固定 Ubuntu 环境并发运行两条外部浏览器线路。它们均在 2026-11-12T00:01:38.546Z 开始向观测端点导航,此时多伦多仍是 November 11。每条线路各有一次被接受的请求,返回 HTTP 200,并通过公共地址有效性检查;两个被接受的地址不同。目标受限适配器各允许一个上游隧道,额外连接尝试在本地被拒绝,没有外部重试。在外部请求之前,四项本地适配器客户端检查覆盖认证成功、凭据拒绝、畸形协商与不允许的目标;全部通过,三个负例转发均为零。
| 记录项 | 线路 A | 线路 B |
|---|---|---|
| 观测端点 HTTP 状态 / 接受请求数 | 200 / 1 | 200 / 1 |
| 公共地址 HMAC,前 12 个字符 | bf448a3168c1 | 2ef6c2495efc |
| 关闭后 profile 条目数 / 起始摘要前缀 | 134 / cb216f5343b0 | 134 / cb216f5343b0 |
| 时区 | America/Toronto | America/Toronto |
| Intl 解析出的 locale | en-US | en-US |
navigator.languages | en-US, en | en-US, en |
| Canvas SHA-256,前 12 个字符 | a37a4010499a | a37a4010499a |
| User agent 对比 | 相同 | 相同 |
iceServers: [] 下的 host ICE 候选 | 1;收集完成 | 1;收集完成 |
两条线路的完整 user agent 均为 Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/154.0.0.0 Safari/537.36。Canvas 对照绘制了 160 × 50 的画布,背景为 #e5ebf3,文字颜色为 #17243a,字体为 16px Arial,在 (5, 30) 绘制 Proxy fixture 2026,然后对 canvas.toDataURL() 执行 SHA-256。摘要由这些精确输入定义,不是通用指纹评分。
两个各含 134 个条目的副本,在启动前都与关闭后的输入一致,原始输入的摘要在运行后也未改变。在已验证的出口变化之后,读数相等说明这些页面值在本次运行中没有自动跟随线路变化;它不表示检查了全部浏览器状态。Cookie、权限、service worker、存储与后续网络活动,都可能变化而未体现在这些读数中。Host-only ICE 计数既不比较候选地址是否相等,也不测量 STUN/TURN 流量。信号相等或出口不同,都不能证明目标的账号关联判断。
分别解释每项浏览器读数
时区来自 Intl.DateTimeFormat().resolvedOptions() 暴露的浏览器环境,并非直接来自 trace 响应中的地址。一条标记为某国的线路可以与另一时区共存,因为浏览器输入与上游路由本来就分开。合理的测试要求可能是所有线路保持固定时区,也可能是声明区域环境。应先写清要求;与某个 IP 数据库一致不是普遍适用的正确性标准。
navigator.languages、解析出的 Intl locale 与 HTTP 语言 header 有关联,但不是同一项观测。记录页面实际返回值;如果关心 header,应另在自有服务器记录,不能从一个 locale 选项推断三者。上下文级设置是一项实验输入:如果与代理同时改变,就无法把差异单独归因于线路。完成单变量对照后,它可以成为下一轮测试的有用输入。
Canvas 输出取决于指定绘制、浏览器构建与渲染环境。固定绘制结果的摘要方便比较那一项输出,却不定义通用浏览器标识符。应保留绘制指令与对照方法。在渲染器与 profile 输入固定时,两条线路得到相同摘要并不意外。如果摘要不同,应先调查渲染输入,再赋予地域或账号含义。
WebRTC 的表述尤其需要收窄。使用 iceServers: [] 的 peer connection,观测的是该配置下本地 host 候选的收集情况。可以保留候选类型与数量,不暴露地址。这并未执行 STUN 或 TURN,不能证明 UDP 跟随 HTTP 代理,也不能证明没有泄漏。没有收集到候选同样不是一般网络结论。更强的要求需要另设授权端点与针对协议的观测计划。
User agent 是需要与精确二进制一起记录的另一项输入。UA 相同,不证明 ClientHello、HTTP/2 或所有页面 API 也相同。反过来,看到 UA 差异,也应先核对浏览器版本与启动设置,不能立即归因于代理轮换。对照应保持在测量所支持的层面。目标可以组合本地页面没有测量的信号;这些结果不会认证其账号关联或风险判断。
BotBrowser 的代理与地理环境对齐参考 记录支持的环境输入。它可以帮助复现声明的测试配置,但不会替应用选择正确区域政策,也不会验证上游凭据。HTTP 认证失败应在代理层修复;上下文走错线路,应在配置该上下文的位置修复;只有明确测试要求需要时,才调整浏览器值。这样才能保留下次对照的因果意义。
来源
- Playwright 网络代理文档
- BrowserType 启动代理选项
- 浏览器上下文代理选项
- 固定 Playwright 1.63.0 上下文实现
- 固定 Chromium 启动路由构造
- 固定上下文回环路由构造
- Chromium 代理协议与路由规范
- Cloudflare 排障端点
- W3C WebRTC 推荐标准
- BotBrowser 代理配置
- BotBrowser 代理与地理环境输入
实用结论
先从一个自有源站标记和一个线路计数器开始。理解本地路径之后,用字节匹配的关闭后 profile 输入验证两个授权公共出口,再对照测试要求明确指出的浏览器值。Playwright 代理正常工作,不会自动改变这些值。下一项有用的调整,是在失败层做最小修改,再进行同样的受控检查,而不是重新组合一整套代理、profile 与渲染设置。
常见问题
上下文使用启动代理,就会继承浏览器状态吗?
路由继承与状态持久化是两种不同性质。普通 newContext() 即使使用启动默认代理,也会创建隔离、非持久化上下文。持久化启动则把一个上下文绑定到磁盘目录。同输入实验应使用关闭后的快照和独立可写副本;共享代理地址不能证明 cookie 或存储共享。
直连排除项会把失败请求转交给启动代理吗?
排除项为匹配目标选择直连,而不是建立回退链。应使用相互独立的启动代理、上下文代理与源站计数器确认。如果直连线路本身不可用,这种失败也不证明访问过任一代理。检查规则时,应保持目标拼写和协议不变。
报告隐藏地址后,测量还有用吗?
公共地址有效性与 A/B 不相等断言,可以在丢弃原始值之前完成。临时的每次运行密钥摘要,让报告能够显示已接受的响应不同,而不用保存地址。密钥只用于该次运行,既不写入报告,也不复用;记录本身不能独立证明服务商出口的物理国家,也不能证明其后续路由。
本文属于指南: 浏览器自动化入门:首次运行地图