5. 网络请求拦截与Mock

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 数据等。针对这个请求,我们有三种选择:

  1. abort() :中止请求,浏览器会收到一个网络错误
  2. continue() :继续发送请求,可以修改请求头、URL 等参数
  3. 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);

协作模式遵循以下规则:

  1. 所有处理器都会执行:Puppeteer 会等待所有处理器完成(包括异步操作)
  2. 优先级决定结果:数字越大,优先级越高,高优先级的决策会覆盖低优先级的
  3. 平局处理:如果优先级相同,按 abort > respond > continue 的顺序决定
  4. 必须全部使用优先级:只要有一个处理器没有指定优先级(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 状态码,默认为 200
  • headers :响应头对象,可以设置 Content-Type、Cache-Control 等
  • contentType :快捷设置 Content-Type 头
  • body :响应体,可以是字符串或 Buffer
  • redirectURL :如果设置,会返回 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);
  }
});

这样修改后,包就支持了协作模式。但这种方式有两个问题:

  1. 向后兼容性突变:如果用户代码或其他库还在使用 Legacy Mode,请求会立即处理,我们的处理器可能不生效,用户会感到困惑
  2. 优先级硬编码:用户无法调整我们处理器的优先级,缺乏灵活性

推荐方案:配置化

更好的方案是导出配置函数,让用户显式启用协作模式:

// 模块内部状态
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     // 放行决策默认优先级
});

版本兼容策略

在文档中,应该明确说明:

  1. 默认行为保持不变(Legacy Mode),避免破坏现有用户
  2. 提供配置函数,让用户主动选择升级
  3. 警告用户:只有当所有处理器都使用协作模式时,优先级机制才生效
  4. 建议用户检查依赖树,确保所有相关包都支持协作模式

这种渐进式升级策略,既引入了新功能,又保护了用户的投资,是成熟开源包的标志。

总结

请求拦截是 Puppeteer 的利器,但用好它需要理解其内在机制。从基础的 abort/continue/respond 三选一,到复杂的协作拦截模式,每一步都有其设计考量。

关键点回顾:

  • 始终检查 isInterceptResolutionHandled(),特别是在异步操作后
  • 协作模式通过优先级让多个处理器和谐共存
  • Mock 数据时,注意响应格式和 Content-Type 头
  • 异步处理器要小心竞态条件,每次 await 后重新检查状态
  • 区分无意见继续和有意见继续,合理使用优先级
  • 开发包时,提供配置函数,保持向后兼容

掌握了这些,就能在自动化测试、爬虫开发、性能优化等场景中游刃有余。下一章将探讨调试技巧,看看如何洞察无头浏览器的内部世界,让开发过程更加透明。