5.网络请求拦截与Mock
网络请求拦截是 Puppeteer 最有力的功能之一。当页面加载时,浏览器会发出数十甚至上百个请求:HTML 文档、CSS 样式表、JavaScript 脚本、图片、字体、API 接口调用等等。有时候,我们希望介入这个过程,修改请求头、阻止某些资源的加载,或者干脆返回自己构造的虚假数据。这在自动化测试、性能优化、爬虫开发等场景下都极为实用。
Puppeteer 的请求拦截机制基于 Chrome DevTools Protocol,允许我们在请求发出之前或收到响应之后进行干预。一旦开启拦截,每个请求都会暂停,等待我们的指令:是继续发送、直接返回伪造的响应,还是干脆中止。这种精细的控制能力,让我们能够模拟各种网络环境,测试应用在弱网、断网或特定数据返回情况下的表现。
请求拦截基础配置
启用请求拦截非常简单,只需要调用 page.setRequestInterception(true) 即可。但这只是第一步,真正的关键在于如何注册请求处理器。
开启拦截与基本处理
下面是一个最基础的拦截示例,它会阻止所有图片请求,让页面加载更快:
import puppeteer from 'puppeteer';
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
// 开启请求拦截
await page.setRequestInterception(true);
// 注册请求处理器
page.on('request', interceptedRequest => {
// 检查请求是否已被处理
if (interceptedRequest.isInterceptResolutionHandled()) return;
// 如果是图片,直接中止
if (
interceptedRequest.url().endsWith('.png') ||
interceptedRequest.url().endsWith('.jpg')
) {
interceptedRequest.abort();
} else {
// 其他请求正常放行
interceptedRequest.continue();
}
});
await page.goto('https://example.com');
await browser.close();
})();
这段代码展示了请求拦截的核心三要素:开启拦截、注册处理器、做出决策。page.on('request', handler) 是注册处理器的关键,每当页面发出请求时,这个处理器就会被触发。
处理器内部,我们面对的是一个 HTTPRequest 对象,它提供了请求的所有信息:URL、方法、请求头、POST 数据等。针对这个请求,我们有三种选择:
abort():中止请求,浏览器会收到一个网络错误continue():继续发送请求,可以修改请求头、URL 等参数respond():直接返回伪造的响应,不发送实际请求
这三种方法必须且只能调用一次,否则 Puppeteer 会抛出 Request is already handled! 异常。这就是请求拦截的基本契约。
检查请求状态的重要性
在实际项目中,代码往往比示例复杂得多。可能会注册多个处理器,也可能使用第三方库,这些库内部也可能注册了处理器。这就带来了一个问题:当我们的处理器执行时,请求可能已经被其他处理器处理过了。
isInterceptResolutionHandled() 方法就是用来解决这个问题的。在调用 abort()、continue() 或 respond() 之前,必须先检查这个状态。如果返回 true,说明请求已被处理,我们的处理器应该直接返回,不再做任何操作。
这个检查必须在同步代码块中完成。为什么?因为 JavaScript 的异步特性。考虑下面这个场景:
page.on('request', async interceptedRequest => {
if (interceptedRequest.isInterceptResolutionHandled()) return;
// 执行一个耗时操作
await someLongAsyncOperation();
// 此时,请求可能已经被其他处理器处理了!
if (interceptedRequest.isInterceptResolutionHandled()) return;
interceptedRequest.continue();
});
在 await someLongAsyncOperation() 期间,事件循环会继续执行其他代码,包括其他请求处理器。所以当我们从异步操作返回后,必须再次检查处理状态。这是异步拦截中最容易出错的地方。
协作拦截模式升级
随着 Puppeteer 的发展,请求拦截机制也经历了重要升级。早期版本采用"先到先得"的 Legacy Mode,一旦某个处理器调用了 abort()、continue() 或 respond(),其他处理器就没有机会执行了。这种模式在简单场景下工作良好,但在复杂应用中显得力不从心。
Legacy Mode 的局限性
Legacy Mode 的核心问题是缺乏协作性。想象一个场景:我们的应用需要阻止广告图片,同时又要 Mock API 响应。如果使用 Legacy Mode,这两个功能很难共存,因为先注册的处理器会独占决策权。
更严重的是,第三方库的行为不可控。如果某个库在内部注册了处理器并直接调用了 continue(),我们后续注册的处理器就永远无法执行。这种不确定性让代码变得脆弱。
Cooperative Intercept Mode 的工作原理
为了解决这个问题,Puppeteer 引入了 Cooperative Intercept Mode(协作拦截模式)。这种模式允许所有处理器都执行完毕,然后根据优先级和规则决定最终的处理方式。
启用协作模式很简单:在调用 abort()、continue() 或 respond() 时传入一个数字优先级参数即可。例如:
// 协作模式:以优先级 0 中止请求
request.abort('failed', 0);
// 协作模式:以优先级 5 继续请求
request.continue({}, 5);
// 协作模式:以优先级 10 返回伪造响应
request.respond(mockResponse, 10);
协作模式遵循以下规则:
- 所有处理器都会执行:Puppeteer 会等待所有处理器完成(包括异步操作)
- 优先级决定结果:数字越大,优先级越高,高优先级的决策会覆盖低优先级的
- 平局处理:如果优先级相同,按
abort>respond>continue的顺序决定 - 必须全部使用优先级:只要有一个处理器没有指定优先级(Legacy Mode),整个机制就回退到旧模式,立即执行第一个决策
优先级使用策略
Puppeteer 提供了 DEFAULT_INTERCEPT_RESOLUTION_PRIORITY 常量,其值为 0。对于没有特殊需求的处理器,建议使用这个默认值。这样可以让处理器之间友好协作,同时保留被更高优先级决策覆盖的可能性。
import {DEFAULT_INTERCEPT_RESOLUTION_PRIORITY} from 'puppeteer';
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
// 使用默认优先级,表示"我没有强烈意见"
request.continue({}, DEFAULT_INTERCEPT_RESOLUTION_PRIORITY);
});
什么时候需要自定义优先级?当我们的处理器有明确意图,希望覆盖其他决策时。例如,一个安全监控插件可能希望以高优先级中止可疑请求:
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (isSuspicious(request.url())) {
// 高优先级中止,覆盖其他处理器的 continue 决策
request.abort('blocked', 100);
} else {
// 默认优先级,允许被覆盖
request.continue({}, 0);
}
});
模式混用的陷阱
协作模式有一个重要限制:只要有一个处理器使用 Legacy Mode(不指定优先级),整个机制就失效。看下面的例子:
// 最终结果是立即中止,协作模式未激活
page.setRequestInterception(true);
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
// Legacy Mode:立即中止,不等待其他处理器
request.abort('failed');
});
page.on('request', request => {
// 这段代码永远不会执行,因为上一个处理器已经立即中止了请求
if (request.isInterceptResolutionHandled()) return;
// 即使指定了优先级,也无济于事
request.continue({}, 0);
});
这个特性意味着,在使用第三方库时必须格外小心。如果库内部使用了 Legacy Mode,我们的协作模式处理器可能永远不会生效。这也是为什么 isInterceptResolutionHandled() 检查仍然必不可少的原因。
模拟网络响应数据
Mock 数据是请求拦截最常见的应用场景之一。在测试环境中,后端服务可能尚未就绪,或者我们希望测试特定数据返回时的前端表现。这时,respond() 方法就派上用场了。
基本响应模拟
respond() 方法允许我们构造完整的 HTTP 响应,包括状态码、响应头和响应体:
await page.setRequestInterception(true);
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
// 拦截特定的 API 请求
if (request.url().includes('/api/user/profile')) {
// 返回伪造的用户数据
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 123,
name: '测试用户',
email: 'test@example.com'
})
});
} else {
// 其他请求正常放行
request.continue();
}
});
await page.goto('https://example.com');
这个例子中,当页面请求用户资料接口时,不会发送真实请求,而是直接返回我们构造的 JSON 数据。这对于测试不同用户状态下的页面表现非常有用。
响应参数详解
respond() 接受一个对象,可以包含以下字段:
status:HTTP 状态码,默认为 200headers:响应头对象,可以设置 Content-Type、Cache-Control 等contentType:快捷设置 Content-Type 头body:响应体,可以是字符串或 BufferredirectURL:如果设置,会返回 302 重定向
对于二进制内容,比如图片,可以这样处理:
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (request.url().endsWith('.jpg')) {
// 读取本地图片文件
const imageBuffer = fs.readFileSync('./mock-image.jpg');
request.respond({
status: 200,
contentType: 'image/jpeg',
body: imageBuffer
});
} else {
request.continue();
}
});
协作模式下的响应决策
在协作模式下,多个处理器可能都对同一个请求有响应意图。例如,一个处理器想返回 Mock 数据,另一个想中止请求。这时优先级就起作用了:
page.setRequestInterception(true);
// 处理器 A:以优先级 5 返回 Mock 数据
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (request.url().includes('/api/data')) {
request.respond({
status: 200,
body: JSON.stringify({mock: true})
}, 5);
} else {
request.continue({}, 0);
}
});
// 处理器 B:以优先级 10 中止请求
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (request.url().includes('/api/data')) {
// 更高优先级,会覆盖处理器 A 的 respond 决策
request.abort('blocked', 10);
} else {
request.continue({}, 0);
}
});
// 最终结果:请求被中止,因为 abort 优先级更高
这个例子展示了协作模式的强大之处:我们可以组合多个处理器,每个表达不同的意图,最终由优先级决定结果。这在复杂的测试场景中特别有用,比如基础 Mock 数据由通用处理器提供,特殊场景由更高优先级的处理器覆盖。
响应数据的序列化问题
使用 respond() 时需要注意数据序列化。Puppeteer 会将响应体原样返回给浏览器,所以必须确保格式正确。对于 JSON 数据,要先 JSON.stringify();对于文本,要注意编码;对于二进制数据,使用 Buffer。
一个常见错误是忘记设置 contentType 头,导致浏览器无法正确解析响应。例如,返回 JSON 数据时,必须设置 contentType: 'application/json',否则浏览器可能将其当作纯文本处理。
异步拦截处理器管理
异步操作是现代 JavaScript 的常态,但在请求拦截中,异步带来了特殊的挑战。请求处理器可以是同步的,也可以是异步的,Puppeteer 会等待返回的 Promise 完成。但这也意味着,在 await 期间,其他处理器可能改变请求状态。
异步处理器的执行顺序
当注册多个异步处理器时,Puppeteer 会按注册顺序依次执行,但每个处理器的异步操作是并发的。理解这一点对避免竞态条件至关重要。
page.setRequestInterception(true);
// 处理器 1:异步操作,500ms 后决策
page.on('request', interceptedRequest => {
if (interceptedRequest.isInterceptResolutionHandled()) return;
return new Promise(resolve => {
setTimeout(() => {
// 再次检查,因为期间可能有其他处理器执行了
if (interceptedRequest.isInterceptResolutionHandled()) {
resolve();
return;
}
interceptedRequest.continue({}, 5);
resolve();
}, 500);
});
});
// 处理器 2:立即决策
page.on('request', interceptedRequest => {
if (interceptedRequest.isInterceptResolutionHandled()) return;
// 这个决策会立即生效,除非协作模式激活
interceptedRequest.continue({}, 10);
});
// 在 Legacy Mode 下,处理器 2 立即执行,处理器 1 的 setTimeout 回调中发现请求已处理
// 在 Cooperative Mode 下,两个处理器都完成,优先级 10 的决策获胜
使用 interceptResolutionState 获取详细信息
除了 isInterceptResolutionHandled(),Puppeteer 还提供了 interceptResolutionState() 方法,返回更详细的状态信息:
const state = request.interceptResolutionState();
// 返回 { action: InterceptResolutionAction, priority?: number }
// InterceptResolutionAction 枚举值:
// - AlreadyHandled: 请求已处理
// - Abort: 决策为中止
// - Respond: 决策为返回响应
// - Continue: 决策为继续
这个方法在协作模式下特别有用,可以查看当前获胜的决策是什么:
page.on('request', request => {
const state = request.interceptResolutionState();
if (state.action === 'AlreadyHandled') return;
console.log(`当前决策: ${state.action}, 优先级: ${state.priority}`);
// 根据当前决策调整我们的策略
if (state.action === 'Abort' && state.priority < 100) {
// 如果当前是中止决策,但优先级低于 100,我们可以覆盖它
request.continue({}, 100);
} else {
// 否则,使用默认优先级
request.continue({}, 0);
}
});
异步操作的最佳实践
在异步处理器中,遵循以下模式可以避免大多数问题:
page.on('request', async request => {
// 1. 立即检查状态
if (request.isInterceptResolutionHandled()) return;
// 2. 执行异步操作
const shouldBlock = await checkAgainstBlocklist(request.url());
// 3. 再次检查状态
if (request.isInterceptResolutionHandled()) return;
// 4. 做出决策
if (shouldBlock) {
request.abort('blocked', 10);
} else {
request.continue({}, 0);
}
});
关键点在于:在每次 await 之后,都必须重新检查处理状态。这确保了我们的决策基于最新状态,而不是过时的信息。
超时处理
异步处理器如果长时间不 resolve,会导致请求挂起。虽然 Puppeteer 没有强制超时,但我们应该在代码中自己实现:
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
return Promise.race([
// 实际工作
(async () => {
const result = await someAsyncOperation();
if (request.isInterceptResolutionHandled()) return;
if (result.block) {
request.abort('blocked', 5);
} else {
request.continue({}, 0);
}
})(),
// 超时控制
new Promise((_, reject) =>
setTimeout(() => reject(new Error('拦截处理超时')), 3000)
)
]);
});
这个模式确保即使异步操作失败或挂起,请求也不会无限期等待。
协作请求继续的两种模式
在协作拦截模式下,continue() 的调用意图变得重要。Puppeteer 区分了两种情况:无意见继续(Unopinionated)和有意见继续(Opinionated)。
无意见继续
大多数情况下,我们的处理器只是想"放行"请求,如果没有其他处理器有更好主意的话。这就是无意见继续,应该使用默认优先级:
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
// 无意见继续:如果没人反对,就放行
request.continue(
request.continueRequestOverrides(),
DEFAULT_INTERCEPT_RESOLUTION_PRIORITY
);
});
这种模式适用于日志记录、监控、通用规则等场景。处理器表达了"继续"的意愿,但愿意被更高优先级的决策覆盖。
有意见继续
少数情况下,我们的处理器有强烈意图,希望强制继续,即使其他处理器想中止或响应。这就是有意见继续,需要使用自定义优先级:
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
// 有意见继续:强制放行,覆盖中止决策
request.continue(
request.continueRequestOverrides(),
100 // 高优先级
);
});
这种模式适用于安全白名单、关键资源保障等场景。处理器表达了"必须继续"的强烈意见。
如何抉择
在编写处理器时,应该思考:这个 continue() 调用是真正的业务需求,还是仅仅为了避免请求挂起的默认行为?如果是后者,使用默认优先级;如果是前者,考虑使用自定义优先级。
一个处理器可能同时包含两种模式。例如,广告拦截器对普通资源使用无意见继续,但对被误判的关键资源使用有意见继续:
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (isAd(request.url())) {
// 有意见:必须阻止广告
request.abort('ad-blocked', 50);
} else if (isCriticalResource(request.url())) {
// 有意见:必须加载关键资源
request.continue({}, 100);
} else {
// 无意见:默认放行
request.continue({}, 0);
}
});
为包维护者提供的升级指南
如果正在开发一个使用请求拦截的 npm 包,升级到协作模式需要特别注意向后兼容性。用户可能还在使用 Legacy Mode,或者混合使用多个版本的库。
基础升级
最简单的升级方式是给所有决策调用添加优先级参数:
// 旧代码
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (isBlocked(request.url())) {
request.abort();
} else {
request.continue();
}
});
// 升级后
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (isBlocked(request.url())) {
request.abort('blocked', 0);
} else {
request.continue(request.continueRequestOverrides(), 0);
}
});
这样修改后,包就支持了协作模式。但这种方式有两个问题:
- 向后兼容性突变:如果用户代码或其他库还在使用 Legacy Mode,请求会立即处理,我们的处理器可能不生效,用户会感到困惑
- 优先级硬编码:用户无法调整我们处理器的优先级,缺乏灵活性
推荐方案:配置化
更好的方案是导出配置函数,让用户显式启用协作模式:
// 模块内部状态
let _priority = undefined; // undefined 表示 Legacy Mode
// 导出配置函数
export const setInterceptResolutionConfig = (priority = 0) => {
_priority = priority;
};
// 处理器实现
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (isBlocked(request.url())) {
// 使用配置的优先级,undefined 时就是 Legacy Mode
request.abort('blocked', _priority);
} else {
// 无意见继续,使用默认优先级
request.continue(
request.continueRequestOverrides(),
DEFAULT_INTERCEPT_RESOLUTION_PRIORITY
);
}
});
这种方式保留了 Legacy Mode 行为,直到用户显式调用 setInterceptResolutionConfig()。用户可以在初始化时决定优先级:
import myPackage, {setInterceptResolutionConfig} from 'my-package';
// 启用协作模式,使用默认优先级
setInterceptResolutionConfig();
// 或者自定义优先级
setInterceptResolutionConfig(10);
高级配置模式
如果包需要更细粒度的控制,可以支持多种优先级配置:
interface InterceptResolutionConfig {
blockPriority?: number; // 阻止请求的优先级
allowPriority?: number; // 放行请求的优先级
}
const DEFAULT_CONFIG: InterceptResolutionConfig = {
blockPriority: undefined, // 默认 Legacy Mode
allowPriority: undefined,
};
let _config: Partial<InterceptResolutionConfig> = {};
export const setInterceptResolutionConfig = (config: InterceptResolutionConfig) => {
_config = {...DEFAULT_CONFIG, ...config};
};
page.on('request', request => {
if (request.isInterceptResolutionHandled()) return;
if (isBlocked(request.url())) {
request.abort('blocked', _config.blockPriority);
} else {
request.continue(
request.continueRequestOverrides(),
_config.allowPriority ?? DEFAULT_INTERCEPT_RESOLUTION_PRIORITY
);
}
});
这种模式允许用户为不同场景设置不同优先级:
setInterceptResolutionConfig({
blockPriority: 100, // 阻止决策高优先级
allowPriority: 0 // 放行决策默认优先级
});
版本兼容策略
在文档中,应该明确说明:
- 默认行为保持不变(Legacy Mode),避免破坏现有用户
- 提供配置函数,让用户主动选择升级
- 警告用户:只有当所有处理器都使用协作模式时,优先级机制才生效
- 建议用户检查依赖树,确保所有相关包都支持协作模式
这种渐进式升级策略,既引入了新功能,又保护了用户的投资,是成熟开源包的标志。
总结
请求拦截是 Puppeteer 的利器,但用好它需要理解其内在机制。从基础的 abort/continue/respond 三选一,到复杂的协作拦截模式,每一步都有其设计考量。
关键点回顾:
- 始终检查
isInterceptResolutionHandled(),特别是在异步操作后 - 协作模式通过优先级让多个处理器和谐共存
- Mock 数据时,注意响应格式和 Content-Type 头
- 异步处理器要小心竞态条件,每次 await 后重新检查状态
- 区分无意见继续和有意见继续,合理使用优先级
- 开发包时,提供配置函数,保持向后兼容
掌握了这些,就能在自动化测试、爬虫开发、性能优化等场景中游刃有余。下一章将探讨调试技巧,看看如何洞察无头浏览器的内部世界,让开发过程更加透明。