FEATURED · 精选文章

WinForm嵌入CefSharp:请求拦截、响应读取与jquery注入实战

发布时间 / 2026/9/2 3:10:12
来源 / 创域科博编辑部
栏目 / 资讯中心
WinForm嵌入CefSharp:请求拦截、响应读取与jquery注入实战 简介面向WinForm开发者的CefSharp集成参考项目针对在窗体程序中获取加载后资源、截取Request参数、拦截Response数据以及注入jQuery文件与JS代码等典型需求。项目基于VS2019与.NET 4.6环境整理可直接运行或二次改编适用于需要深度定制浏览器内核行为的Web自动化、爬虫以及客户端混合开发场景。压缩包共718个文件约371.21MB。文件构成以C#源码、C头文件与实现、CefSharp依赖库和调试符号为主附带工程配置、浏览器资源包、可执行程序等既覆盖源码项目主体也包含运行时所需的第三方组件便于在Visual Studio中打开并快速还原环境。当前已有6673人学习下载。压缩包内提供了完整WinForms项目、请求与响应拦截、jQuery与JS代码注入的实现源码。读者可通过项目了解CefSharp生命周期管理、网络请求过滤、DOM加载后注入脚本等关键环节的编写方式减少自行查阅底层API与联调排错的时间成本适合中高级.NET开发者按需提取复用。 做桌面采集工具的人大多迟早会撞上这个需求WinForm里嵌一个浏览器不光要让它打开页面还要拿到页面发出去的每个请求、收回来的每段响应甚至要在页面里塞点自己的JS好让数据自动汇总到C#这边。我最近刚把一个这样的采集器写完从request拦截到response读取再到jquery注入踩了大半天坑最后把所有链路跑通。这篇文章把我实际用过的代码和排查过程整理出来不管你是做爬虫、自动化测试还是想在客户端里嵌浏览器做数据采集应该都能直接用上。先说结论WinForm里嵌入Chromium内核CefSharp是目前能实现“请求层、响应层完全可控”的最省事方案。它把CEFChromium Embedded Framework的C能力封装成C#接口所以你能在请求发出前改参数、在响应到达后读body还能随时往页面里执行JS。这些能力WebBrowser控件基本想都别想WebView2虽然也能做一部分但论灵活度和可定制性CefSharp还是更彻底。这个项目的完整需求是这样的窗体程序启动后加载一个业务页面页面运行过程中会产生多个接口请求我需要把这些请求的URL、Header、POST参数存下来同时把特定接口的JSON响应抓回C#做解析最后还要在页面里注入jquery以及一些自动化脚本来辅助操作。下面我从选型开始讲把整条链路的实现思路和关键代码都过一遍。1. 为什么是CefSharp选型时那些绕不开的比较1.1 WebBrowser、WebView2与CefSharp的取舍WinForm里能嵌入的浏览器内核就三座大山老的System.Windows.Forms.WebBrowser微软后来推的WebView2还有开源界老牌劲旅CefSharp。WebBrowser控件本质是IE内核现在别说ES6和flex布局连很多HTTPS站点都会因为TLS版本被拒。除非你只是打开一个十几年前的内部OA系统否则这个选项可以直接划掉。WebView2是推荐方向配置简单内核也是Chromium。但真正用下来你会发现它对请求和响应层的编程介入比CefSharp重拿到response body要走异步读取逻辑还经常碰到跨域和协议限制。而且WebView2的WebResourceRequested只能捕获主框架和iframe层的请求对于页面里异步发起的细粒度资源请求处理起来不如CefSharp的IRequestHandler直观。CefSharp的优势在于它把CEF原来的CefRequestHandler和CefResourceRequestHandler完整暴露成了IRequestHandler从请求发起到响应结束每一条资源记录你都能看到、都能改。代价是初始化配置多一些、发布包大一些但你要做的是数据采集这类精细活这点成本是可以接受的。1.2 环境准备与初始化一个最容易被忽略的前提项目里通过NuGet安装CefSharp.WinForms即可我用的版本是91以后如果你之前用过老版本最大的感受会是异步API更完善了EvaluateScriptAsync用起来很顺手。初始化时要注意一个坑必须在创建ChromiumWebBrowser实例之前调用Cef.Initialize并且在程序退出时对称调用Cef.Shutdown。我见过不少人把初始化写在窗体的Load事件里但窗体构造函数里如果先new了Browser就会白屏改成在Main函数里初始化最稳。var settings new CefSettings { CachePath Path.Combine(AppDomain.CurrentDomain.BaseDirectory, cache), LogFile cef.log, LogSeverity LogSeverity.Verbose }; Cef.Initialize(settings, performDependencyCheck: true, browserProcessHandler: null);CachePath值得专门设一下。如果不设每次启动浏览器都是“无痕模式”页面所有静态资源都会重新加载开发调试时接口倒是无所谓但页面加载速度和资源数量会让你怀疑人生。设了缓存之后很多资源直接从本地取后面的性能采集也会更接近真实用户场景。还有一点CefSharp默认支持AnyCPU但如果你的项目绑定到了x86或x64记得把CefSharp的对应包也装上并且确认CefSharp.BrowserSubprocess.exe和libcef.dll被复制到了输出目录。白屏时第一个排查点就是看这两个文件在不在而不是去翻代码。2. CefSharp请求管线request和response到底在哪里被拦截2.1 五个干预点串起完整生命周期刚开始做的时候我脑子里只有“拦截请求、读取响应”这两个模糊的概念但CefSharp并不像抓包软件那样给你一个“全部流量出口”。它是一个一个的回调点把每一步交给你的代码决定。摸清这五个钩子后面写代码就有方向了。钩子触发时机能干的事OnBeforeResourceLoad请求发出前每一个资源请求都会经过这里改URL、改Header、读PostData、拦截请求GetResourceResponseFilter响应头到达后给特定请求挂载IResponseFilter用于读取或修改响应体OnResourceLoadComplete单个资源请求完成后拿HTTP状态码、最终URL、耗时OnFrameLoadEnd某个框架加载完成后在main frame注入JS的最佳时机LoadingStateChanged浏览器整体加载状态变化判断页面是否加载完成触发“加载完成后”动作用生活化的方式来理解OnBeforeResourceLoad是安检口每一个包裹都要在这里过一遍你可以拆开看、也可以换个包装GetResourceResponseFilter相当于在包裹出口装了个复印机所有通过的内容自动被复制一份OnFrameLoadEnd则是你偷偷塞传单的时机等别人家门打开一条缝把jquery塞进去。2.2 一个IRequestHandler挂载真实浏览器实例上面这些钩子都要通过实现IRequestHandler接口来提供。CefSharp的默认行为是OnBeforeResourceLoad返回false表示“继续加载”。你要做的就是把自定义逻辑挂到这个返回之前。public class CustomRequestHandler : IRequestHandler { public bool OnBeforeResourceLoad(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IRequestCallback callback) { // 这里就是request参数拦截的主战场 return false; } public IResponseFilter GetResourceResponseFilter(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) { // 返回值非null时这个请求的响应体会被filter处理 return null; } // 其他接口成员按需要实现不需要的返回默认值即可 }然后把handler挂到浏览器控件上var browser new ChromiumWebBrowser(https://example.com) { RequestHandler new CustomRequestHandler() };这里有个容易忽略的细节GetResourceResponseFilter不只是针对页面的主文档页面里的JS、CSS、图片、接口请求都会走到这里。所以写过滤逻辑时一定要用request.Url做条件判断否则你可能会“不小心”把所有响应body都读进内存页面一重内存直接爆掉。3. 截获request参数从改Header到抓PostData3.1 OnBeforeResourceLoad里的基本操作我一开始以为“截取request参数”就是把URL打出来存个日志后来发现OnBeforeResourceLoad给的能力远不止这些它能做的三件核心事情是读取、修改、拦截。改URL是CefSharp最舒服的能力。比如页面里引用了某个CDN上的jquery你想把它切成本地版本不需要去改页面源码直接在这个回调里动手if (request.Url https://cdn.example.com/lib/jquery-3.6.0.min.js) { request.Url https://127.0.0.1:8848/local/jquery-3.6.0.min.js; }修改Header也是常规操作。我在采集时经常需要给请求补一个自定义Token或者模拟移动端User-Agentvar headers request.Headers; headers[X-Custom-Token] token-from-winform; headers[User-Agent] Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X); request.Headers headers;需要注意Headers是NameValueCollection你给它的值必须是合法的HTTP头格式不能带换行符和乱七八糟的控制字符否则CefSharp会在内部拒绝这次修改甚至导致请求失败。3.2 修改请求URL与Header的几种玩法如果要拦截请求、阻止它发出可以返回true。比如你想屏蔽页面里的统计脚本if (request.Url.Contains(analytics.js)) { return true; }返回true的含义是“这个请求我已经处理了CEF不用再走网络栈了”效果等同于拦截。但官方文档提醒返回true时如果callback不为null你需要手动决定是调用callback.Continue()还是callback.Cancel()。实际项目中我一般只用同步改URL和返回false的方式很少走callback异步分支因为那个分支的逻辑在多个CefSharp版本里有行为差异一不小心就会让请求卡死。如果只是做重定向最简单的是直接判URL然后赋值return false让CEF按新URL继续走网络流程。改Header时也一样尽量在回调节点内同步完成不要在回调里写await否则会拖慢整个页面加载。3.3 读取POST请求体的正确姿势request参数不只是URLPOST请求的body同样能读。比如页面提交了一个表单或者某个接口用了application/json你都可以在这里拿到原始字节if (request.Method POST) { var postData request.PostData; if (postData ! null) { foreach (var element in postData.Elements) { var bytes element.GetBytes(); var body Encoding.UTF8.GetString(bytes); File.AppendAllText(post-body.log, ${request.Url} {body}{Environment.NewLine}); } } }这里有个很关键的细节只能在回调返回之前同步读取千万别把request.PostData存起来留到异步方法里再读。因为CefSharp的request对象在回调结束之后可能会被CEF内部释放PostData也会随之失效。我最初就是因为想“先存后读”结果拿到了一堆空引用后来改成在回调里立刻转成字符串问题就解决了。表单类的POST请求body一般是usernameabcpassword123这样的格式也可以用System.Web.HttpUtility.ParseQueryString解析成键值对。JSON格式的直接用JsonConvert.DeserializeObject进一步处理。到这里请求侧的参数就已经全部到手了。4. 从response里偷数据IResponseFilter与分块拼装的实战4.1 为什么CefSharp不能直接读取响应体刚接触CefSharp时我最大的困惑是既然都能拿到response头了为什么没有类似response.Body这种属性直接把内容给我后来才明白浏览器处理所有资源都是流式的尤其是大页面的HTML和图片根本不会整体放在内存里等你读。所以CefSharp提供的是IResponseFilter这个“水管接口”数据一块一块流经你的filter你选择每块要不要看一眼、存一份。理解了这一点就能明白GetResourceResponseFilter的返回值为什么是filter对象而不是字符串。你要做的就是给关心的URL返回一个filter实例CefSharp会在这个请求的响应体到达时把每一块数据交给你处理。4.2 一个可用的ResponseSniffer实现下面这个实现是我在项目里真正用过的功能很简单把特定URL的响应体分块累积起来在Dispose的时候拼成完整字符串。public class ResponseSniffer : IResponseFilter { private readonly string _url; private readonly MemoryStream _buffer new MemoryStream(); public ResponseSniffer(string url) { _url url; } public bool InitFilter() { return true; } public FilterStatus Filter(Stream dataIn, out long dataInRead, out long dataOutWritten) { dataInRead 0; dataOutWritten 0; if (dataIn null) { return FilterStatus.Done; } var bytes new byte[dataIn.Length]; dataInRead dataIn.Read(bytes, 0, bytes.Length); dataOutWritten dataInRead; _buffer.Write(bytes, 0, bytes.Length); return FilterStatus.Done; } public void Dispose() { var body Encoding.UTF8.GetString(_buffer.ToArray()); File.WriteAllText($response-{Guid.NewGuid():N}.json, body); } }这段代码的核心是dataOutWritten dataInRead这意味着“数据我原样放行了页面该收到什么还是什么”。如果你要做的是“读取别人发送的数据”保持原样透传是最安全的。千万别为了改动而改动一旦dataOutWritten和dataInRead不相等CEF会认为你修改了响应内容可能导致页面渲染异常。在handler里筛选URLpublic IResponseFilter GetResourceResponseFilter(IWebBrowser browserControl, IBrowser browser, IFrame frame, IRequest request, IResponse response) { if (request.Url.Contains(/api/data)) { return new ResponseSniffer(request.Url); } return null; }4.3 响应体拼接、编码识别与数据反序列化Dispose里直接按UTF8解码有个问题如果页面返回GBK编码解码出来全是乱码。做企业应用时经常遇到历史系统返回GBK所以这里最好带上编码识别。public void Dispose() { var raw _buffer.ToArray(); if (ResponseHeaders ! null) { var contentType ResponseHeaders[Content-Type]; if (contentType ! null contentType.Contains(charsetgbk)) { var content Encoding.GetEncoding(GBK).GetString(raw); // 处理content return; } } var utf8Body Encoding.UTF8.GetString(raw); // 反序列化或其他处理 }我一般在构造ResponseSniffer时把IResponse的Headers也传进去这样在Dispose时能拿到Content-Type和Content-Encoding。如果响应是gzip压缩过的Filter里拿到的字节是压缩过的原始数据直接用UTF8解会得到一堆乱码这个问题我在后面的踩坑章节专门展开讲。拿到JSON字符串之后用JsonConvert.DeserializeObject解析成C#的Model列表就能在WinForm界面里绑DataGridView或者其他控件了整个数据链路到这里就正式打通。5. 注入jquery与自定义JS时机比代码本身更重要5.1 注入三条路源码、script标签、本地资源往页面里注入jquery我试过三种方式各有优缺点。第一种是直接执行jquery源码把jquery-3.6.0.min.js文件内容读成字符串然后调用ExecuteJavaScriptAsync执行。这个办法最干净因为不依赖网络和文件协议执行完当前页面的window作用域里就有了$和jQuery。缺点是如果页面本身加载了别版本的jquery可能被覆盖。var jquerySource File.ReadAllText(jquery-3.6.0.min.js); await browser.EvaluateScriptAsync(jquerySource);第二种是动态插script标签指向你本地起的HTTP服务var script var s document.createElement(script); s.src http://127.0.0.1:8848/jquery.min.js; document.head.appendChild(s); ; browser.ExecuteJavaScriptAsync(script);这种方式容易受到页面CSP策略的限制也容易出现jquery还没加载完就开始执行依赖代码的竞态问题我不太推荐。第三种是注册自定义协议比如local://assets/jquery.min.js让浏览器从本地加载资源。这个方案配置繁琐适合需要大量本地替换资源的项目单纯为了注入jquery有点杀鸡用牛刀。实际项目中我最常用的是第一种因为EvaluateScriptAsync执行的是注入到渲染进程里的脚本和页面动态插script不一样它基本不受页面CSP限制可靠性要高很多。5.2 注入时机与执行顺序光有注入方法还不够时机错了等于白做。如果你在页面还没加载完时就执行jquery源码那一串代码可能还没跑完页面就开始加载其他脚本了反过来如果页面已经跑完了一大段业务逻辑你才注入jquery那前面的代码也用不上已经定义好的$。稳定做法是监听LoadingStateChanged等IsLoading变成false此时main frame基本完成加载再注入jquery和后续业务脚本。browser.LoadingStateChanged async (sender, args) { if (!args.IsLoading) { await browser.EvaluateScriptAsync(jquerySource); await browser.EvaluateScriptAsync(businessScript); } };如果页面内部有iframeLoadingStateChanged可能在意想不到的时间触发。更精确的是用FrameLoadEnd事件判断e.Frame.IsMainbrowser.FrameLoadEnd async (sender, args) { if (args.Frame.IsMain) { await browser.EvaluateScriptAsync(jquerySource); } };5.3 注入后的页面自动化操作完整示例注入完jquery后面就可以像写爬虫脚本一样操作页面了。我在项目里干得最多的是遍历表格数据。假设页面上有一堆tr.item行每个表格行里有两列数据我想一次性全部拿回C#var script (function() { var result []; $(tr.item).each(function() { result.push({ name: $(this).find(td:eq(0)).text(), price: $(this).find(td:eq(1)).text() }); }); return JSON.stringify(result); })(); ; var response await browser.EvaluateScriptAsync(script); if (response.Success response.Result ! null) { var items JsonConvert.DeserializeObjectListItemModel( response.Result.ToString() ); }这里有个经验JS端不管返回什么最后都用JSON.stringify转成字符串再传回C#这样在C#侧反序列化最稳。EvaluateScriptAsync的Result类型有时候是IDictionary有时候是JavascriptObject直接ToString很容易拿到[object Object]这种没用的东西提前stringify能省掉一堆麻烦。执行这种注入脚本时UI线程可能会因为页面DOM太大而卡顿我一般会在前面加一段小延迟比如await Task.Delay(300)确保DOM完全稳定。6. 加载完成后把资源清单捞回C#从DOM到Performance API6.1 第一种从DOM节点遍历如果需求是“获取加载后的资源”最直接的理解就是把页面里引用到的图片、链接、脚本全部列出来。这种场景我用JS遍历DOMvar script (function() { var images []; document.querySelectorAll(img).forEach(function(img) { images.push({ src: img.currentSrc || img.src, width: img.naturalWidth, height: img.naturalHeight }); }); var links []; document.querySelectorAll(a).forEach(function(a) { links.push(a.href); }); return JSON.stringify({ images: images, links: links }); })(); ; var result await browser.EvaluateScriptAsync(script);这种方式适合拿“页面结构里声明了哪些资源”但有个明显短板如果你要的是“这个页面实际发起了多少请求、每个请求花了多少时间”DOM里看不出这些信息。6.2 第二种用performance.getEntriesByType扒资源清单浏览器内部其实一直记录着每个页面的所有资源加载记录通过Performance API可以一次性全部拿到包括那些你根本不知道的异步请求、图片、字体、XHR。var script (function() { var entries performance.getEntriesByType(resource); return JSON.stringify(entries.map(function(e) { return { name: e.name, type: e.initiatorType, duration: Math.round(e.duration), transferSize: e.transferSize, encodedBodySize: e.encodedBodySize }; })); })(); ;这个方案我在做页面性能分析时特别常用一次就能拿到几十条资源记录还带了耗时和大小数据。如果你关心一个页面到底加载了哪些大图片、哪个接口拖慢了整体速度这一招比一个个去网络面板里看高效得多。6.3 懒加载与异步资源的处理办法用Performance API仍然会漏掉一类资源懒加载图片。页面如果用了loadinglazy或者IntersectionObserver那些没滚动到视口范围内的图片根本不会被加载performance里自然没有记录。解决思路是先在页面里模拟滚动把整个视口“逛”一遍再等一段时间让资源加载完最后再取performance数据var script window.scrollTo(0, document.body.scrollHeight); ; await browser.EvaluateScriptAsync(script); await Task.Delay(1500);滚动到底部之后可以再用window.scrollTo(0, 0)回到顶部并把页面里所有懒加载图片的src主动触发一遍。对于真正的数据采集场景我建议把“滚动-等待-收集”三步走封装成一个方法传不同的滚动高度做多次采集这样拿到的资源清单最完整。7. 实战中反复踩到的坑崩溃、乱码、白屏一个都没少7.1 退出时的Cef.Shutdown顺序这个问题我一度以为是程序bug直到翻官方文档才确认是退出顺序问题。CefSharp要求Cef.Shutdown()只能在所有Browser实例销毁之后调用一次如果你的窗体没关闭就调用了Shutdown或者两个窗体各自调了一次会出现随机崩溃。正确的做法是在主窗体的FormClosed事件里先释放Browser控件再在Application.Idle或者最后一行调用一次Cef.Shutdown()。最稳妥的方式是干脆不手动调用让CefSharp的LifeSpanHandler在进程退出时自动清理但如果你手动管了就要保证“一次且最后一次”。7.2 压缩响应体让你拿到乱码我最初做response拦截时明明接口返回的是正常JSON但filter里拼出来的字符串是一堆莫名字节。排查下来发现是Content-Encoding: gzip导致的服务器返回的响应体先被gzip压缩过filter拿到的是压缩后的字节流。解决方案有两个。一个是在请求发出前把Accept-Encoding改成identity示意服务器不要压缩var headers request.Headers; headers[Accept-Encoding] identity; request.Headers headers;但这个办法并不能保证所有服务器都听你的。更稳妥的是在filter里拿到字节后根据Content-Encoding手动解压if (contentEncoding.Contains(gzip)) { using (var gz new GZipStream(new MemoryStream(raw), CompressionMode.Decompress)) using (var reader new StreamReader(gz, Encoding.UTF8)) { var content reader.ReadToEnd(); } }我在项目里是两种都上了先尝试identity不行就解压。7.3 中文编码与响应体大小GBK乱码的问题前面提到过我在实际业务中确实遇到了一个老管理后台返回GBK编码的JSON最初按UTF8解析死活不对。后来我在ResponseSniffer里把response.Headers[Content-Type]传进去检测到charset就按对应编码解析没有显式charset时默认UTF8极少情况还要用StreamReader的DetectEncodingFromByteOrderMarks做一次猜测。响应体大小同样不能忽视。如果某个URL是下载接口动辄几十MB的body也会走filter你把它全存内存里程序很快就会被OOM杀掉。所以filter里一定要限制只处理特定URL和特定Content-Type比如只对application/json和text/html做累积其他类型直接返回FilterStatus.Done不存数据。7.4 跨域与弹窗问题做采集时经常会遇到页面自己的JS要跨域请求你的本地接口结果被CORS策略拦下。开发阶段我直接在CefSettings里加了一个禁用web security的参数settings.CefCommandLineArgs.Add(disable-web-security);这个参数只建议调试用生产环境老老实实用代理或者修改Origin头的方式解决不然自己写的功能容易成为安全隐患。弹窗问题则是另一类烦心事。页面里如果有点target_blank的链接CefSharp默认行为会新建一个浏览器窗口你的RequestHandler如果不处理新窗口里的请求就拦截不到了。我处理的办法是实现ILifeSpanHandler在OnBeforePopup里返回true阻止弹窗然后把链接的href手动加载到当前browser里。public bool OnBeforePopup(IWebBrowser browserControl, IBrowser browser, IFrame frame, string targetUrl, string targetFrameName, WindowOpenDisposition targetDisposition, bool userGesture, IPopupFeatures popupFeatures, IWindowInfo windowInfo, IBrowserSettings browserSettings, ref bool noJavascriptAccess, out IWebBrowser newBrowser) { newBrowser null; browserControl.Load(targetUrl); return true; }这样既拦截了弹窗又保证请求仍然经过你的RequestHandler。这几个坑走完之后整个链路基本就闭环了请求发出前可以改响应回来可以读页面加载完可以注入jquery和自动化脚本资源清单也可以随时捞到C#侧。回头看看CefSharp的API设计并不复杂复杂的是你要理解浏览器的资源加载时序和过滤器的数据流动方式。如果你也刚开始做类似的项目我的建议是先把OnBeforeResourceLoad和IResponseFilter这两个点做通再把注入JS的时机本文还有配套的精品资源点击获取
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻