从 Playwright 路由到浏览器协议:请求拦截如何工作

Playwright 的 page.route() 如何在不同浏览器中拦截请求,以及如何仅用 Chromium DevTools Protocol 实现同样的拦截。

中文
复制
题图:左右并排两段代码,左边是 Playwright 的 page.route 与 route.fulfill,右边把同一件事映射到 CDP 的 Fetch.requestPaused 与 Fetch.fulfillRequest

本文沿着 Playwright 的请求拦截一路向下,追到浏览器协议层。我们先看 Playwright 如何用 page.route() 拦截请求,以及它在不同浏览器里的实现方式;再看如何只用 Chromium 的 DevTools Protocol 实现请求拦截;最后看几个例子,说明了解浏览器通信机制在实践中能帮上什么忙。

我们的目标不是用底层 API 取代框架,而是搞清楚浏览器究竟暴露了哪些能力,以及什么时候直接操作底层协议是有用的。

本文所有信息和示例截至 2026 年 9 月。浏览器自动化协议变化很快,部分细节日后可能改变。

可运行的示例都在示例仓库里。

Playwright 的 page.route() 让我们可以拦截浏览器发出的请求,并决定如何处理它:放行、中止,或者自己提供一个响应。page.route() 的一个用途,是加载一个没有 DNS 或 hosts 记录的域名。

假设我们的应用跑在 http://localhost:3000,但我们希望浏览器通过 http://app.invalid 这个 URL 加载它。如果在 Playwright 测试里不做任何前置设置就调用 page.goto('http://app.invalid'),页面根本加载不出来。

不过用 page.route() 就能让这个域名在测试里正常加载。它可以拦截浏览器对 http://app.invalid 的请求,从 localhost 取回我们想在这个 URL 上加载的页面,再用这个响应去满足原始请求:

await page.route('http://app.invalid/**', async route => {
 const localUrl = route.request().url()
 .replace(
 'http://app.invalid',
 'http://localhost:3000'
 );
 const response = await route.fetch({
 url: localUrl,
 });
 await route.fulfill({
 response,
 });
});

现在测试里调用 page.goto('http://app.invalid'); 就能真正加载 http://app.invalid 的内容,而查看页面 origin 时,返回的仍是我们导航到的那个原始 URL:

await page.evaluate(() => location.origin);
// http://app.invalid

接下来看看 Playwright 用来拦截和修改请求的底层机制。

page.route() 在不同浏览器中的工作方式

Chromium

在 Chromium 中,Playwright 通过 Chrome DevTools Protocol(即 CDP)实现请求拦截。

CDP 可用于调试、检查和浏览器自动化。它按域(domain)组织,例如 PageRuntimeNetworkPerformanceFetch。既然聊的是请求拦截,本文最关心的就是 Fetch 域。

在看 Playwright 如何使用 CDP 的 Fetch 域之前,先理解 CDP 通信的整体机制会很有帮助。

客户端——Playwright、Chrome DevTools,或者我们自己写的脚本——可以连上 Chromium 并向它发送 CDP 命令,Chromium 用同一套协议回应。

例如,客户端可以通过 CDP 向 Chromium 发送一条 Fetch.enable 命令来启用请求拦截:

{
 "id": 1,
 "method": "Fetch.enable",
 "params": {
 "patterns": [{
 "urlPattern": "http://app.invalid/*",
 "requestStage": "Request"
 }]
 }
}

Chromium 返回的消息带有相同的 id。对于 Fetch.enable,一个成功响应可能就这么简单:

{
 "id": 1,
 "result": {}
}

当导航到 http://app.invalid/(我们启用了拦截的域名)时,Chromium 会暂停该请求并发出一个 Fetch.requestPaused 事件。 事件负载大致如下:

{
 "method": "Fetch.requestPaused",
 "params": {
 "requestId": "...",
 "request": {
 "url": "http://app.invalid/",
 "method": "GET",
 "headers": {}
 },
 "resourceType": "Document"
 }
}

Chromium 会一直暂停该请求,直到客户端告诉它如何处理:继续、失败,还是返回一个特定的响应。 Playwright 在 Chromium 中对 page.route() 的实现基于 CDP。 Playwright 通过 Fetch.enable 启用请求拦截:

Fetch.enable({
 handleAuthRequests: true,
 patterns: [{
 urlPattern: '*',
 requestStage: 'Request'
 }]
});

它还会监听 Chromium 在请求被拦截时发出的 Fetch.requestPaused 事件:

eventsHelper.addEventListener(
 session,
 'Fetch.requestPaused',
 this._onRequestPaused.bind(this, sessionInfo)
);

随后,Playwright 自行将路由与处理器进行匹配——Playwright 增加了额外能力,比如支持 glob 模式、正则表达式、URLPattern 对象和谓词。它还支持为一个路由配置多个处理器以及回退。 查看这个示例的可运行版本。

Firefox 和 WebKit

到目前为止,我们看的都是 Chromium 特有的实现。CDP 只存在于 Chromium 中,那么 Firefox 或 WebKit 里的浏览器通信是如何实现的? 就 Playwright 而言,它为 FirefoxWebKit 提供了自己打补丁的构建版本。这些构建让 Playwright 能在各浏览器上提供一致的自动化功能。这也是为什么 Playwright 需要自己的 Firefox 构建而不是标准的 Firefox 发行版,需要自己的 WebKit 版本而不是 Safari。 Playwright API 在所有浏览器中保持一致,底层机制则各不相同。

不用框架直接连接 Chromium

我们已经看到 Playwright 如何用 CDP 与 Chromium 通信。我们也可以不用任何框架,直接与 Chromium 通信。 首先需要连接到浏览器,这要几个步骤:

  1. 以启用远程调试的方式启动 Chromium。
  2. 请求可用的调试目标,并选择一个代表页面的目标。
  3. 连接到该目标的 web socket。 代码大致如下:
// Start Chromium with remote debugging enabled.
const chrome = spawn(process.env.CHROME_PATH, [
 '--headless=new',
 '--remote-debugging-port=9222',
 `--user-data-dir=${profileDir}`, // separate browser profile directory
 'about:blank'
]); 

// Request /json/list to see the available debugging targets, and select a target representing a page:
const response = await fetch(
 'http://127.0.0.1:9222/json/list'
);
const targets = await response.json();
const target = targets.find(
 target => target.type === 'page'
);

// The page target includes a webSocketDebuggerUrl. 
// This is the URL of the socket we can use to send CDP commands to Chromium. Let's connect to it:
const ws = new WebSocket(
 target.webSocketDebuggerUrl
);

await new Promise(resolve =>
 ws.addEventListener('open', resolve, { once: true })
);

现在我们可以向 Chromium 发送命令了。比如,启用请求拦截:

ws.send(JSON.stringify({
 id: 1,
 method: 'Fetch.enable',
 params: {
 patterns: [{
 urlPattern: 'http://app.invalid/*',
 requestStage: 'Request'
 }]
 }
}));

一旦有请求命中匹配的 URL,Chromium 就会触发 Fetch.requestPaused 事件。我们可以监听它,决定如何处理被拦截的请求:

ws.addEventListener('message', async event => {
 const message = JSON.parse(event.data);

 if (message.method !== 'Fetch.requestPaused')
 return;

 const { requestId } = message.params;

 ws.send(JSON.stringify({
 id: 2,
 method: 'Fetch.fulfillRequest',
 params: {
 requestId,
 responseCode: 200,
 body: Buffer.from('Hello').toString('base64')
 }
 }));
});

完整可运行的示例见仓库。 即便这么小的例子也需要大量样板代码,不过 Puppeteer 这类库可以替我们处理与浏览器的通信。同样的例子会变成这样:

const browser = await puppeteer.launch();
const page = await browser.newPage();

await page.setRequestInterception(true);

page.on('request', async request => {
 if (!request.url().startsWith('http://app.invalid')) {
 await request.continue();
 return;
 }

 await request.respond({
 status: 200,
 body: 'Hello'
 });
});

用了 Puppeteer,我们不必自己打开 WebSocket、跟踪消息 id 或查找 target,代码也短得多。完整的 Puppeteer 示例见此处。

WebDriver BiDi:浏览器自动化的标准化

到目前为止我们看到,浏览器自动化工具与浏览器通信的方式因浏览器而异。这让自动化变得更麻烦:同一个工具可能要为 Chromium、Firefox 和 WebKit 分别实现一套。WebDriver BiDi 就是为了在不同引擎之间提供一套通用的浏览器自动化协议。 它目前以 W3C 工作草案的形式发布,仍在积极开发中,但 Chrome 和 Firefox 已经实现了 BiDi,一些自动化框架也在使用它。例如 Puppeteer 为 Chrome 和 Firefox 都支持 BiDi,并在 Firefox 上默认使用它;WebdriverIO 则将它与其他协议一起用于浏览器自动化。 BiDi 支持许多浏览器自动化功能,包括导航和请求拦截。 这意味着我们可以用 BiDi 实现前面的例子:拦截请求,从 localhost 获取响应,再把该响应交给浏览器。 和 CDP 一样,BiDi 也通过 WebSocket 与浏览器交换命令和事件,但接口本身不同。例如,网络拦截要用 network.addIntercept——相当于 CDP 的 Fetch.enable

{
 "id": 1,
 "method": "network.addIntercept",
 "params": {
 "phases": ["beforeRequestSent"],
 "urlPatterns": [{
 "type": "pattern",
 "protocol": "http",
 "hostname": "app.invalid"
 }]
 }
}

还需要订阅事件:

{
 "id": 2,
 "method": "session.subscribe",
 "params": {
 "events": ["network.beforeRequestSent"]
 }
}

当浏览器检测到匹配的请求时,会发出 network.beforeRequestSent,我们用 network.provideResponse 来回应,它相当于 Fetch.fulfillRequest

{
 "id": 3,
 "method": "network.provideResponse",
 "params": {
 "request": "...",
 "statusCode": 200,
 "body": {
 "type": "string",
 "value": "Hello"
 }
 }
}

不过,BiDi 仍未覆盖 CDP 的全部能力,例如 CPU 限流,以及 tracingprofiling 相关的 API。 另一项缺失的功能是 CDP 暴露的 DOM.getContentQuads。它返回 quad——即元素内容框的四个角点。即使元素经过变换,quad 也能准确描述其位置。Playwright 在计算点击位置时就会用到 quad。Playwright 团队已将缺少对应的 BiDi API 列为影响交互功能完整支持 BiDi 的问题之一

为什么了解协议层有用

大多数时候,高层 API 已经够用。只有当框架没有暴露我们所需的浏览器能力,或者我们想构建一个更精简的浏览器工具时,协议层才有用武之地。

用 CDP 扩展 Playwright

Playwright 通过 CDPSession 直接暴露 Chromium 的协议,因此我们可以照常使用 Playwright,只在需要的地方下沉到 CDP。 例如,CDP 可以通过 Emulation.setCPUThrottlingRate 支持 CPU 限流,而 Playwright 没有对应的专用 API:

const cdp = await page.context().newCDPSession(page);
await cdp.send(
 'Emulation.setCPUThrottlingRate',
 { rate: 4 }
);
await page.goto('https://example.com');

测试仍然用 Playwright 编写,CDP 只是补上缺失的浏览器能力。仓库里有一份可运行的示例

为 agent 构建更小的浏览器工具

为 agent 构建工具时,协议访问同样有用。 现成的工具返回的内容可能超出任务所需,而每个多余字段一旦进入模型上下文都要消耗 token。例如 Chrome DevTools MCP 暴露了 list_network_requests,可以按资源类型过滤请求,但无法按 URL 或状态过滤。如果 agent 只想知道 /api/checkout 是否失败,我们仍然得取回完整列表,模型就会白白烧掉额外的 token。 一个更精简的工具可以暴露:

getFailedRequests({
 urlPattern: '/api/checkout'
})

并且只返回:

[
 {
 "url": "https://example.com/api/checkout",
 "method": "POST",
 "status": 500
 }
]

过滤可以在数据发给模型之前,基于 CDP 网络事件完成,这样列表能短得多,消耗的 token 也更少。 这能省多少?示例仓库做了测量:在某个测试页面上,一个精简的 CDP 工具回答同一问题所用的 token 大约只有 chrome-devtools-mcp 最佳过滤调用的 1/9。

结论

我们从 Playwright 的路由拦截入手,了解了如何通过不同协议实现浏览器拦截。 在此过程中,我们弄清了 Chromium、Firefox 和 WebKit 在浏览器自动化上的差异,Playwright 为什么需要打过补丁的浏览器构建,以及为什么需要像 BiDi 这样的跨浏览器协议。 这些知识不只对请求拦截有用:它还能帮我们理解自动化框架底层是怎么运作的,以及什么时候直接访问协议会派上用场。

来源: HackerNoon← 返回首页