☰
Electron拦截网络请求并读取响应体:webRequest边界与CDP实战
2026/11/2 5:42:16 网站建设 项目流程

调这个功能的那天晚上,我把session.webRequest的文档翻了三遍,onHeadersReceived的每个回调参数都打了一遍日志,最后确认了一件事:Electron 官方的 webRequest 接口,从设计上就不会把响应体交给你。这不是版本问题,也不是配置没写对,是这条路径本身只走到响应头为止。后来换成webContents.debugger走 Chrome DevTools Protocol,才真正把 response body 拿到了手,中间又踩了 iframe 抓不到、大文件取 body 卡死、getResponseBody报No resource with given identifier found这一串坑。

这篇东西就是把这几个月的实操经验摊开讲。核心围绕 Electron 环境下拦截网络请求并读取 response 内容这件事,把四条可行路线的能力边界、代码写法、时序陷阱和选型逻辑讲透。适合正在做桌面端抓包工具、接口调试器、页面性能监控、或者单纯想给自己应用加一个网络日志面板的开发者。前端基础够用就能看懂,但如果你连webContents和session的关系都还没理顺,建议先补一下 Electron 的主进程与渲染进程模型,不然下面有些坑你会踩得莫名其妙。

1. 先把"拦截响应"拆成三层能力

很多人一上来就问"怎么拦截 response",这句话其实混了三件完全不同的事。搞不清这三层的区别,选型必然跑偏。

1.1 改请求头、改响应头、读响应体是三码事

第一层是干预请求发起。你可以在请求真正发出之前改 URL、改 method、加 header、甚至直接 cancel 掉。这层能力webRequest.onBeforeRequest和onBeforeSendHeaders都给得很完整,想干什么是自由的。

第二层是干预响应头。请求回来了,服务器返回了状态码和一堆 header,你可以在渲染进程看到之前把它们改掉,比如强行加Access-Control-Allow-Origin绕过跨域限制,或者改Content-Type让浏览器用别的方式解析。这层onHeadersReceived也能做。

第三层才是读响应体。也就是真正拿到那坨 JSON、那段 HTML、那张图片的二进制内容。这一层 webRequest 完全不给。

大部分人卡住的位置就在第三层。他们看到onHeadersReceived的回调里有responseHeaders、statusLine、statusCode,很自然地以为再往下就能摸到 body 了——结果文档翻到底也没有。这个设计不是疏忽,后面第 2 节会讲原因。

顺带说个反直觉的点:请求体反而是能拿到的。onBeforeRequest的 details 里有个uploadData字段,POST 上去的 form data、JSON body 都在里面,每一项可能是bytes(Buffer)、file(文件路径)或者blobUUID。所以你会看到一种诡异的现象——发出去的能看,收回来的看不到。我第一次遇到这个反差的时候愣了半天。

1.2 四条路线的能力对照

在 Electron 里读响应体,实际能走的路就四条,我把它们的真实能力整理成表,方便你直接对号入座。

方案能否读响应体覆盖范围实现复杂度主要限制
session.webRequest否全部网络请求(含 iframe、资源)低只有 header 级信息,无 body
webContents.debugger+ CDP是该 webContents 及其子 frame中与内置 DevTools 互斥,需管时序
preload 注入改写 fetch/XHR是页面 JS 主动发起的请求低拦不到 img/script/iframe,易被检测
自定义协议protocol.handle是仅自己注册的协议中对 https/http 无效
本地调试代理是全部(含下载、WebSocket)高工程量大,需处理 TLS

表格里最后一行我放在这但没展开,因为它的工程量跟前面几条不是一个量级——你要自己起 HTTP 服务、处理 CONNECT 隧道、处理 TLS 解密,写完之后维护成本也高。除非你要做的是一个通用的、能抓到任何资源的抓包工具,否则不建议一上来就走这条路。

真正性价比最高的是第二条:webContents.debugger。它借用的是 Chromium 自带的调试协议,能力跟你在 DevTools 的 Network 面板里看到的基本一致,能拿到 body、能拿到 timing、能拿到 initiator 调用栈。代价是它跟内置 DevTools 抢同一个调试端口,这个坑后面单独讲。

第三条 preload 注入看起来最简单,几行代码就能 hook 住window.fetch,但它的适用范围比想象中窄得多。页面里的<img src>、<script src>、CSS 里的url()、iframe 加载的整个文档,这些统统不经过页面的 JS,你 hook 不到。而且它还有个隐蔽的上下文隔离问题,第 4 节细说。

2. webRequest 的真实边界:它为什么注定读不到 body

先把这条最容易被误用的路讲清楚,省得你在上面浪费一整天。

2.1 它挂载在网络栈的哪个位置

session.webRequest本质上是 Chromium 网络栈对外暴露的一个观察者接口。请求从渲染进程发起,经过 URLLoader 走到网络服务,这一路上 Electron 能在几个固定节点上挂回调:请求发起前、发送 header 前、收到响应头后、请求完成或失败时。

关键在于,响应体一旦开始流转,就直接被打包送进渲染进程的资源加载器了,中间不会额外复制一份给 embedder。这是有意的性能取舍——一个 50MB 的视频文件,如果为了让你能读到,在网络层再留一份完整拷贝,内存直接翻倍。所以 Electron 只把元数据级别的东西暴露出来:URL、method、状态码、header、资源类型、发起它的 webContents id。

我的理解是,这个接口的设计目标从一开始就是"让应用能做访问控制和安全策略",而不是"让应用能抓包"。你能用它做白名单拦截、能改 UA、能注入 CORS 头,这些都在元数据层面就够用了。想读内容,就得换一条真正能碰到数据流的路径。

2.2 onHeadersReceived 到底能改什么

既然提到这,把onHeadersReceived的能力边界也说清楚,因为它在实际项目里用得极多。

它的回调签名大致是这样:

session.defaultSession.webRequest.onHeadersReceived( { urls: ['*://*.example.internal/*'] }, (details, callback) => { callback({ responseHeaders: { ...details.responseHeaders, 'Access-Control-Allow-Origin': ['*'], 'X-Debug-Injected': ['1'] } }) } )

注意responseHeaders的值是字符串数组,不是字符串。这是很多人第一次写会报错的地方,{'X-Foo': 'bar'}是无效的,必须是{'X-Foo': ['bar']}。多个值就放多个元素,比如Set-Cookie。

这个能力在本地联调时非常好用。后端没配 CORS、前端又要直连内网测试环境,与其让后端改配置,不如在客户端这边统一加一个头,一次配置全局生效。同样的思路也能用来改Content-Security-Policy,把某些被拦的资源放行——当然这只在开发环境干,别带到生产。

但无论你怎么改,改的都是"信封",信封里的"信纸"你碰不到。details里永远不会有 body 字段。

2.3 一个高频误判:filter 加了 urls 就有内容

我见过不止一个人这么写:以为filter里的urls匹配上了,回调里就能拿到内容。实际上urls只是决定"哪些请求触发这个回调",跟"回调里有什么数据"毫无关系。

还有个小细节值得记一下:webRequest的监听器是按 session 生效的。你给session.defaultSession注册的监听器,管不到用partition创建出来的独立 session。如果你的应用里开了多个BrowserWindow且用了不同的 partition,得分别注册。这个坑在多窗口应用里特别容易漏,表现是"主窗口能拦到,弹窗里的请求一条都收不到"。

另外,webRequest的回调是同步阻塞网络栈的。你在onBeforeRequest里做耗时操作(比如同步读文件、跑一次复杂的正则匹配),会直接拖慢整个请求链路。真要在这层做重活,把结果缓存起来,别每次都现算。

3. DevTools Protocol 方案:从 attach 到 getResponseBody 的完整链路

这条是正路。整个流程可以概括成:attach 到 webContents、开启 Network 域、监听事件配对 requestId、在请求结束时取 body。

3.1 为什么必须用 debugger 而不是现成 API

Electron 里操作webContents.debugger的时候,你其实是在跟一个实现了 CDP 的调试客户端对话。Chromium 内部所有跟网络相关的信息,DevTools 前端能看到的,都通过这个协议暴露。所以能力上限跟你在浏览器里按 F12 看到的那套完全一致。

有个前提条件必须提前知道:同一个 webContents 只能挂一个调试客户端。如果你的代码已经attach了,用户再手动打开内置 DevTools,就会抛Another debugger is already attached to the target。反过来也一样,DevTools 开着的时候你 attach 会失败。

实战中的处理方式有两种。一种是干脆不开放内置 DevTools,用自定义的调试面板替代;另一种是在 attach 之前先检测状态,失败了就延迟重试,并且监听detach事件做恢复。我倾向第一种,因为两套调试界面共存本身就很别扭。

attach 的时机也很讲究。要在页面开始加载之前就挂上去,否则首屏那批请求一个都抓不到——Network.enable之前发生的请求,CDP 是不会补发的。所以正确的顺序是在BrowserWindow创建之后、loadURL之前完成 attach 和 enable。

const { BrowserWindow } = require('electron') const win = new BrowserWindow({ width: 1280, height: 800 }) const wc = win.webContents // 关键:先 attach,再 loadURL wc.debugger.attach('1.3') await wc.debugger.sendCommand('Network.enable', { maxTotalBufferSize: 100 * 1024 * 1024, maxResourceBufferSize: 10 * 1024 * 1024, maxPostDataSize: 1024 * 1024 }) win.loadURL('https://example.internal/app')

Network.enable的三个参数值得说道。maxResourceBufferSize是单个资源的缓冲上限,默认值比较小(大约 10MB 量级),超过这个大小的响应,getResponseBody就会拿不到内容。maxTotalBufferSize是所有资源加起来的总量。你把这两个值调大,内存占用也会跟着上去,所以别一拍脑袋填个 1GB,按实际业务需求给。

maxPostDataSize控制的是请求体的保留长度,如果你同时要抓 POST 内容,这个也得调。

3.2 requestId 配对表怎么维护

CDP 的事件是分散的。一个请求的生命周期里,你会先后收到Network.requestWillBeSent、Network.responseReceived、Network.loadingFinished(或者Network.loadingFailed),它们靠requestId串起来。

问题在于,你真正需要的信息分散在不同事件里:URL 和 method 在requestWillBeSent,状态码和响应头在responseReceived,而"请求结束了可以取 body 了"这个信号在loadingFinished。所以必须自己维护一张 Map。

const pending = new Map() // requestId -> { url, method, status, mimeType } wc.debugger.on('message', (event, method, params) => { if (method === 'Network.requestWillBeSent') { pending.set(params.requestId, { url: params.request.url, method: params.request.method, status: 0, mimeType: '' }) } if (method === 'Network.responseReceived') { const item = pending.get(params.requestId) if (item) { item.status = params.response.status item.mimeType = params.response.mimeType item.headers = params.response.headers } } if (method === 'Network.loadingFinished') { // 这里才能取 body } if (method === 'Network.loadingFailed') { pending.delete(params.requestId) } })

这张表必须双向清理,loadingFinished和loadingFailed都要删。只加不删的后果是内存持续增长,长时间运行的桌面应用(比如挂着跑一整天的那种)会出现明显的卡顿甚至 OOM。我最早就是只在 finished 里删,结果页面里的请求一旦失败(网络中断、被拦截、超时),那条记录就永远滞留了。

还有一个隐藏的泄漏点:页面导航或者刷新的时候,正在进行的请求可能既不发loadingFinished也不发loadingFailed。所以还要监听Page.frameNavigated或者渲染进程的崩溃事件,做一次全量清理。稳妥的做法是给每条记录加个时间戳,定期扫一遍超时的条目。

3.3 loadingFinished 与 getResponseBody 的时序窗口

这是整个流程里最容易出错的地方。在Network.responseReceived里调getResponseBody基本一定失败,因为那一刻响应体还没下载完,协议层会返回No resource with given identifier found。

必须等到loadingFinished。这个事件的含义是"这个请求的所有数据都已经接收完毕",此时缓冲区里才有完整内容。

if (method === 'Network.loadingFinished') { const item = pending.get(params.requestId) pending.delete(params.requestId) // 先做过滤,避免为无用资源调协议 if (!item || item.status === 304 || item.status === 204) return if (/^(image|font|video|audio)\//.test(item.mimeType)) return try { const { body, base64Encoded } = await wc.debugger.sendCommand( 'Network.getResponseBody', { requestId: params.requestId } ) const content = base64Encoded ? Buffer.from(body, 'base64') : body handleContent(item.url, content) } catch (err) { // 大文件、被取消的请求、已经失效的 requestId 都会走到这里 console.warn('getResponseBody failed:', item.url, err.message) } }

注意那个try/catch不是可选的。即使到了loadingFinished,仍然有几种情况会失败:超过maxResourceBufferSize的大文件、请求在最后一刻被取消、以及某些特殊的响应类型。没有兜底的话,一个异常就能把整个message监听器搞崩。

304 Not Modified和204 No Content也要提前跳过。304 的情况特别坑——服务器返回的是空 body,但 CDP 有时会认为这个资源在缓存里存在,调getResponseBody会返回一个历史版本的缓存内容,你把它当成"这次请求的响应"就错了。判断状态码直接 return 是最省事的做法。

3.4 base64 与编码处理的正确姿势

getResponseBody返回的body是字符串,base64Encoded是布尔值。当它为true的时候,body是 base64 编码的二进制内容,你要自己解;为false的时候,body是直接可读的文本。

什么时候是true?主要是二进制资源——图片、字体、protobuf、gzip 压缩后没被正常处理的响应等。所以如果你的目标是抓 JSON 接口,可以先判断mimeType里含不含json、text、javascript,然后走纯文本分支;其他情况统一按 base64 处理,避免解析出乱码。

还有个常见的坑是字符编码。如果服务器返回的是charset=gbk这类非 UTF-8 编码,拿到的字符串可能是乱码。稳妥做法是临时把响应强制按二进制取:

// 先发一次,强制拿 base64 原始字节 // 实际上更可靠的做法是在 responseReceived 时记录 response.headers['content-type'] // 拿到 base64 后用对应 charset 解码 const raw = Buffer.from(body, 'base64') const text = iconv.decode(raw, charsetFromContentType)

不过如果你只处理自家后端返回的 UTF-8 JSON,这一层可以省。别过早优化。

4. preload 注入方案的适用面与反噬

这条路的代码量最少,但坑的隐蔽程度最高。

4.1 改写 fetch 和 XMLHttpRequest 的具体写法

思路很直白:在页面执行之前,把window.fetch和XMLHttpRequest包一层,请求返回时把响应体复制一份上报。

// preload.js const origFetch = window.fetch window.fetch = async function (...args) { const res = await origFetch.apply(this, args) // 必须 clone,否则原始 body 会被消费掉,页面拿到空响应 const copy = res.clone() copy.text().then(text => { window.postMessage({ __tag: 'net-log', url: res.url, status: res.status, body: text }, '*') }).catch(() => {}) return res }

res.clone()这一步是必须的。Response的 body 是一次性的流,读了就没了。如果你直接把res.text()拿去上报,页面里的业务代码再读就是空字符串,表现是"接口突然返回空白",排查起来极其费劲。我见过有人在这上面耗了两天。

XHR 那边类似,但要更小心:

const OrigXHR = window.XMLHttpRequest function WrappedXHR() { const xhr = new OrigXHR() const origOpen = xhr.open let meta = {} xhr.open = function (method, url, ...rest) { meta = { method, url } return origOpen.call(xhr, method, url, ...rest) } xhr.addEventListener('load', function () { let body = '' if (xhr.responseType === '' || xhr.responseType === 'text') { body = xhr.responseText } else if (xhr.responseType === 'json') { body = JSON.stringify(xhr.response) } // 上报 }) return xhr } WrappedXHR.prototype = OrigXHR.prototype window.XMLHttpRequest = WrappedXHR

responseType的处理是这里面最容易漏的分支。如果页面把responseType设成了arraybuffer或blob,你读responseText会直接抛异常。所以要先判断类型再决定怎么取。

4.2 为什么它拦不到 img、script 这类资源请求

这是这条路最大的认知盲区。页面上绝大多数"资源"根本不是 JS 发的请求:

  • <img src="...">由浏览器内核直接发起
  • <script src="...">、<link rel="stylesheet">同理
  • CSS 里的background: url(...)也是内核处理
  • iframe 加载整个文档,走的是导航流程
  • Service Worker 里的请求跑在独立线程,跟你 preload 的 window 完全没关系

这些全部绕过window.fetch。所以如果你的需求是"统计页面所有网络请求",preload 方案会漏掉一大半,数据完全不可用。

反过来说,如果你的需求非常明确——"我只关心页面主动调的那几个 API 接口"——那 preload 就很合适,代码量少、不需要处理 CDP 的时序问题、也不用担心跟 DevTools 冲突。

4.3 上下文隔离下 window 到底是谁的

这一节是硬核踩坑。Electron 从 12 开始默认开启contextIsolation: true,意味着 preload 脚本运行在一个独立的 JavaScript 上下文里,它看到的window跟页面主世界的window不是同一个对象。

结果就是:你在 preload 里写window.fetch = ...,改的是隔离世界的fetch,页面里那套完全不受影响。代码不报错,但一条日志都没有。第一次遇到这个的人通常会怀疑人生。

绕过去的方式有几种:

一种是用webFrame.executeJavaScript在页面主世界执行补丁代码,配合contextBridge暴露一个回传通道:

// preload.js const { contextBridge, webFrame } = require('electron') contextBridge.exposeInMainWorld('__netBridge', { report: (payload) => { /* 走 ipcRenderer 发给主进程 */ } }) webFrame.executeJavaScript(` const origFetch = window.fetch; window.fetch = async function(...args) { const res = await origFetch.apply(this, args); const copy = res.clone(); copy.text().then(t => { window.__netBridge.report({ url: res.url, status: res.status, body: t }); }).catch(() => {}); return res; }; `)

另一个办法是直接关掉contextIsolation。我强烈不建议——这是安全边界,关掉之后页面里的恶意脚本能直接摸到 Node 的require,风险不在一个量级上。

还有一点要提醒:无论哪种注入方式,页面是可以检测到你的。window.fetch.toString()如果返回的不是[native code],稍微有点心的页面脚本就能发现被劫持了。做内部工具无所谓,做通用产品得考虑这个。

5. 子框架、重定向、流式响应:三个最容易翻车的场景

前面讲的是主干流程,真正让项目卡住的是这几个边缘情况。

5.1 iframe 内的请求为什么默认抓不到

webContents.debugger默认只 attach 到主 frame。页面里嵌了 iframe,尤其是跨域的 iframe(比如内嵌的第三方支付、地图、客服组件),它们各自是独立的 target,请求事件不会冒泡到你主 frame 的监听器上。

解决办法是开启自动 attach:

await wc.debugger.sendCommand('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: true, flatten: true })

flatten: true是重点。开启之后,子 target 的事件会以扁平化的方式复用同一个message事件,但多出一个sessionId参数,你必须把它原样带回去才能操作对应的子 target。

wc.debugger.on('message', (event, method, params, sessionId) => { // sessionId 为空表示主 frame,有值表示子 frame if (method === 'Network.loadingFinished') { wc.debugger.sendCommand( 'Network.getResponseBody', { requestId: params.requestId }, sessionId // 必须传,否则找不到这个请求 ) } })

不传sessionId的表现是:getResponseBody报No resource with given identifier found,但你在responseReceived里明明看到请求了。这个矛盾现象就是 sessionId 丢失的典型症状。

还有个副作用得提前知道:waitForDebuggerOnStart: true会让子 frame 在启动时暂停,等你发Runtime.runIfWaitingForDebugger才继续。如果你的代码忘了发这个命令,整个 iframe 就卡住不加载了,表现是页面白屏但主文档正常。这是个很容易让人抓狂的 bug,因为控制台里一条错误都没有。

5.2 重定向链里 requestId 的变化规律

一个请求如果经历了 301/302 跳转,CDP 里会产生多个 requestId,而不是复用同一个。

具体规律是:第一次requestWillBeSent拿到 id A,请求返回 302;接着你会收到一个新的requestWillBeSent,id 是 B,它的params.redirectResponse字段里装着那次的 302 响应。然后再来 B 的responseReceived和loadingFinished。

这意味着你在配对表里要做好几件事。首先,A 这个 id 永远不会收到loadingFinished,它会被永久滞留——必须靠"有redirectResponse就说明上一跳已经结束"这个规则把它清掉。其次,如果你关心的是最终响应,就只处理 B;如果关心整条链路,就得把 A 的redirectResponse也记下来。

另外一个细节:重定向的中间响应体通常是拿不到的,302 一般也没 body,所以别指望能读到跳转页面的内容。

5.3 EventSource 与分块传输的 body 获取

EventSource(SSE)和分块传输(chunked)的场景比较特殊。

SSE 的请求永远不会发loadingFinished——连接一直开着,除非服务端主动关闭。所以如果你只监听loadingFinished,这些请求一条都抓不到。

CDP 提供了专门的事件来处理:

if (method === 'Network.eventSourceMessageReceived') { // params.data 就是单条 SSE 消息 // params.eventName, params.eventId 对应事件名和 id }

WebSocket 也是类似的道理,用Network.webSocketCreated、Network.webSocketFrameSent、Network.webSocketFrameReceived这三个事件。注意 WebSocket 帧的payloadData在某些情况下是 base64 编码的(opcode为 2 的二进制帧),要判断后再处理。

至于普通的 chunked 响应,loadingFinished是会正常触发的,getResponseBody拿到的是拼接完成的完整内容,这个不用特殊处理。但如果你的场景是"要实时看到流式输出的每一个片段"(比如接了 AI 接口做逐字打印的界面),getResponseBody就不行了——它只在结束后给你完整结果。这时候Network.dataReceived事件里的dataLength只能告诉你收到了多少字节,拿不到内容本身。真要抓流式内容,还是得回落到 preload 层 hook fetch 的response.body流。

6. 选型与落地:不同场景该走哪条路

讲完机制,最后落到"你手上这个需求到底该选哪个"。

6.1 抓包分析类工具:只能上 debugger

如果你的产品定位是"给页面做网络分析",比如做一个类似 DevTools Network 面板的界面,需要展示 URL、状态码、耗时、响应体、请求头,那没有第二个选择,必须用webContents.debugger。

这种情况下要做好几件事:一是缓冲区大小按业务调,别用默认值;二是过滤规则前置,静态资源(图片、字体、媒体)在responseReceived阶段就根据mimeType打标记,loadingFinished时直接跳过,省掉getResponseBody的协议往返——这个调用本身是有开销的,几百个资源累积起来能明显感觉到卡顿;三是 UI 侧的渲染要做虚拟列表,别把几百条记录全部铺到 DOM 上。

6.2 接口 Mock 与联调:webRequest 更省事

如果你的目标是"把某个接口的响应换成本地 mock 数据",很多人第一反应是用 debugger 拦截然后改写——这是一条弯路。

正确姿势是protocol.handle配合自定义协议,或者干脆用一个本地 HTTP 服务,把session.setProxy指过去。前者适合你自己的应用内部通信,后者适合调试第三方页面。用 CDP 拿 body 再改写,中间隔了一层,时序也更难控。

顺带说一句,如果只是改响应头(比如加 CORS、改缓存策略),onHeadersReceived足够了,别动 body。

6.3 性能与埋点监控:关注的是时序不是内容

做页面性能监控的团队,往往不需要响应体,需要的是每个请求的精确时间戳:DNS 查询多久、TCP 握手多久、TTFB 多久、下载耗时多久。

这些数据在Network.responseReceived的params.response.timing里全都有,而且精度到微秒。requestWillBeSent的params.timestamp也给了单调时钟值,可以直接用来算时间差。

这条路压根不需要getResponseBody,所以性能开销小得多,Network.enable的缓冲区也可以调小。很多团队在这上面走了弯路,为了"顺便也存一下 body 方便排查",把缓冲区开到很大,结果监控本身的性能开销成了问题。

6.4 一份可直接抄的最小可用实现

最后给一份完整的、能直接跑的骨架,包含 attach、事件分发、配对清理、body 提取和错误兜底。

const { app, BrowserWindow, ipcMain } = require('electron') const pending = new Map() const TIMEOUT_MS = 60 * 1000 function shouldCapture(item) { if (!item) return false if (item.status === 204 || item.status === 304) return false if (/^(image|font|video|audio)\//.test(item.mimeType)) return false return true } async function attachMonitor(wc) { if (wc.debugger.isAttached()) return try { wc.debugger.attach('1.3') } catch (e) { console.error('attach failed:', e.message) return } await wc.debugger.sendCommand('Network.enable', { maxTotalBufferSize: 100 * 1024 * 1024, maxResourceBufferSize: 10 * 1024 * 1024 }) await wc.debugger.sendCommand('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }) wc.debugger.on('message', async (event, method, params, sessionId) => { if (method === 'Network.requestWillBeSent') { if (params.redirectResponse) { // 上一跳结束,清掉旧记录 pending.delete(params.requestId) } pending.set(params.requestId, { url: params.request.url, method: params.request.method, status: 0, mimeType: '', ts: Date.now() }) return } if (method === 'Network.responseReceived') { const item = pending.get(params.requestId) if (item) { item.status = params.response.status item.mimeType = params.response.mimeType || '' } return } if (method === 'Network.loadingFailed') { pending.delete(params.requestId) return } if (method === 'Network.loadingFinished') { const item = pending.get(params.requestId) pending.delete(params.requestId) if (!shouldCapture(item)) return try { const res = await wc.debugger.sendCommand( 'Network.getResponseBody', { requestId: params.requestId }, sessionId ) const content = res.base64Encoded ? Buffer.from(res.body, 'base64') : res.body wc.send('net-captured', { url: item.url, method: item.method, status: item.status, mimeType: item.mimeType, size: Buffer.byteLength(content), body: res.base64Encoded ? null : content }) } catch (err) { // 静默失败,常见于大文件和已失效的 requestId } } }) wc.debugger.on('detach', (event, reason) => { pending.clear() console.warn('debugger detached:', reason) }) // 定期清理僵尸条目 setInterval(() => { const now = Date.now() for (const [id, item] of pending) { if (now - item.ts > TIMEOUT_MS) pending.delete(id) } }, 30000) } app.whenReady().then(() => { const win = new BrowserWindow({ width: 1280, height: 800, webPreferences: { preload: require('path').join(__dirname, 'preload.js'), contextIsolation: true } }) attachMonitor(win.webContents).then(() => { win.loadURL('https://example.internal/app') }) })

几个细节值得单独强调。Target.setAutoAttach里我把waitForDebuggerOnStart设成了false,意思是子 frame 不用等我放行,直接跑。这样避免了忘记发Runtime.runIfWaitingForDebugger导致白屏的问题,代价是子 frame 极早期的请求可能漏抓一两条。对大多数场景这个取舍是划算的。

shouldCapture的过滤放在loadingFinished而不是responseReceived,是因为在responseReceived阶段我还不知道状态码是不是 304——虽然那时其实已经有status了,但把判断集中在一个地方更好维护。如果你追求极致性能,可以挪到responseReceived阶段打个标记,loadingFinished直接看标记。

还有一个我没写进去但你应该考虑的:wc.send到渲染进程的数据如果是大 body,IPC 序列化会成为瓶颈。超过几 MB 的内容建议落盘,只把路径传过去,渲染进程按需读取。

最后分享一点实际操作中的体会。这套东西刚跑通的时候,我特别得意,觉得什么都能抓了。结果接了一个内嵌第三方组件的页面,发现那个 iframe 里的请求一条都收不到——就是我前面说的Target.setAutoAttach没开。开了之后又遇到白屏,因为waitForDebuggerOnStart没配对runIfWaitingForDebugger。这类问题在文档里都是轻描淡写一句话,真正踩上去一个比一个耗时间。

我现在做这类功能的习惯是,先把"抓不到"当成默认预期,一个个排除:attach 成功了吗?Network.enable在导航之前发了吗?配对表里这条记录清掉了没有?sessionId传对了吗?状态码是不是 304?按这个顺序走一遍,八成的问题都能定位到。比漫无目的地翻文档高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询