Fiddler 和 Charles 是常见的 HTTP 调试器,它们会在本地运行一个代理服务器,能够查看浏览器或其它客户端软件经过这个代理发起的请求和服务器的响应,也能够在请求提交到服务器以前和服务器返回响应以后设置断点,手工修改请求、响应的内容。另外,两个软件均可以以“中间人攻击”的形式,解密 HTTPS 通讯。css
某天,我在调试某个网站的过程当中,但愿把“修改 ajax 请求的响应”这一功能持久化下来方便使用。Fiddler 提供了自定义脚本的功能,但它的 macOS 版本和我用 Homebrew 安装的 Mono 不兼容,并且我也不但愿个人全部网络流量都通过 Fiddler 代理。另外考虑到未来可能把这个功能小范围发布出来供其它人使用,那么 Fiddler 或者 Charles 这种“重量级”解决方案就被否认了,因此我很天然地想到了使用 Chrome 扩展来完成此任务。web
实现一个 Chrome 扩展,自动修改特定网站的 ajax 请求的响应。网站是一个重度使用 ajax 的网站,而且带有比较严格的“反做弊”限制,对 Cookie、HTTP Header(Referer等)甚至多个 ajax 请求的顺序都有要求,若是出错就没法继续。ajax
从 Fiddler 这种方案天然而然地想到,若是 Chrome 也提供相似的“断点”机制,外加 JavaScript 脚本,上述问题就解决了。因而搜了一下 “chrome extension hook ajax”,找到了Is there a hook for when an AJAX call returns? 第一个回答即是使用 WebRequest API 。chrome
因而开始学习 WebRequest API 的文档,它能监听的事件如上图所示,最有可能知足条件的就是 onResponseStarted 了,但仔细一看发现它是一个异步事件,并不能实现“断点”,也不能作任何改动(This event is informational and handled asynchronously. It does not allow modifying or cancelling the request)json
换个关键词 “webrequest api modify response” 再次搜索,找到了和个人需求十分吻合的问题和答案:chrome extension - modifying HTTP response ,其中有三个有用的信息:api
我想的方案是:在背景页中以 blocking 模式注册监听 onBeforeRequest 事件,在事件处理函数中,从新发起这个 ajax 请求。此处要注意两点:1.要在原页面的环境中请求,大体思路是在页面中注入脚本,背景页使用消息机制和页面脚本通讯,2.“从新发起”的 ajax 请求依然会被 onBeforeRequest 事件拦截,须要作额外的处理。“从新发起”的 ajax 请求拿到数据后,进行修改,最后编码为 data:-URL 做为 blockingResponse 返回。浏览器
背景页的代码大体以下:服务器
chrome.webRequest.onBeforeRequest.addListener( function(details) { // 若是请求带有特殊标记,则不进行修改 if (details.url.endsWith("#do_not_modify_this_request")) { return {} } // 使用消息机制将请求传递给页面再发起 ajax,而不是在背景页中发起 chrome.tabs.sendMessage(details.tabId, details, function(response) { // 此处能够修改response... redirectUrl = "data:application/json;charset=UTF-8;base64," + Base64.encode(newResponse) }); return {redirectUrl: ...}; }, {urls: [...]}, ["blocking", "requestBody"] );
chrome.runtime.onMessage.addListener( function(request, sender, sendResponse) { // 从新发起的请求要作标记,避免无限循环 var settings = { url: request.url + "#do_not_modify_this_request", method: request.method, dataType: "text" }; if (request.requestBody && request.requestBody.formData) { settings.data = request.requestBody.formData; } $.ajax(settings).done(function(data) { sendResponse(data); }); // 因为 sendResponse 是异步调用的,须要返回 true return true; } );
前途是光明的,道路是曲折的。在这里我就遇到了“暂时的困难”,写完上面的代码,我居然不知道怎么写下去了。前面说的方案,到这里彷佛已经完成了 90% 了:背景页拦截了 ajax 请求,在页面注入脚本从新发起了请求,拿到告终果,进行了修改,编码成 data:-URL。但差就差在最后一步上,这个 data:-URL 是在回调函数里拼出来的,而执行回掉函数的时候,外层的事件处理函数早就应该返回啦。并无任何机制能够在返回前“等一下” sendMessage。网络
继续上搜索引擎,我看到了 54257 - The absence of synchronous message API make impossible to pass options to scripts that are loaded before the page to block content. - chromium - Monorail 这又是一个 issue ,并且结论仍是 WontFix 。app
其中有人提到,用 storage API 能够解决,但下一条回复反驳了他,由于 storage API 自己也是异步的。因而这个想法又一次失败了。
我把背景页改了一下,页面脚本保持不变:
chrome.webRequest.onBeforeRequest.addListener( function(details) { // 发起 ajax 请求的部分不变,再也不处理响应 if (details.url.endsWith("#do_not_modify_this_request")) { return {} } chrome.tabs.sendMessage(details.tabId, details, function(response) {}); // 直接生成新页面,进行重定向 content = "......" return {redirectUrl: "data:application/json;charset=UTF-8;base64," + Base64.encode(content)}; }, {urls: [...]}, ["blocking", "requestBody"] );
方法简单粗暴,ajax 请求照常发起(由于服务端的限制,若是不发起这个 ajax 请求的话,下一步其它 ajax 必然会返回错误,功能就没法使用了),可是结果直接忽略,用预先准备好的假页面直接返回。
这个方案实际执行时“时好时坏”,缘由是,事件处理函数返回后,页面的下一个 ajax 请求就会发起,若是模拟 ajax 请求先于它发生,则结果正常。反之,若是下一个 ajax 请求发起时,个人模拟请求还没有发送,那服务端就直接拒绝执行,返回“服务器繁忙”的错误,其实这“服务器繁忙”就是“发现你在做弊”的意思。
最后,只剩下这一个方案了,上面那个答案说得很复杂,但搜一下仍是能找到“成品”方案。How can I modify the XMLHttpRequest responsetext received by another function? 中已经写好了代码,略作修改以下:
function modifyResponse(response) { var original_response, modified_response; if (this.readyState === 4) { // 使用在 openBypass 中保存的相关参数判断是否须要修改 if (this.requestUrl ... && this.requestMethod ...) { original_response = response.target.responseText; Object.defineProperty(this, "responseText", {writable: true}); modified_response = JSON.parse(original_response); // 根据 sendBypass 中保存的数据修改响应内容 this.responseText = JSON.stringify(modified_response); } } } function openBypass(original_function) { return function(method, url, async) { // 保存请求相关参数 this.requestMethod = method; this.requestURL = url; this.addEventListener("readystatechange", modifyResponse); return original_function.apply(this, arguments); }; } function sendBypass(original_function) { return function(data) { // 保存请求相关参数 this.requestData = data; return original_function.apply(this, arguments); }; } XMLHttpRequest.prototype.open = openBypass(XMLHttpRequest.prototype.open); XMLHttpRequest.prototype.send = sendBypass(XMLHttpRequest.prototype.send);
这段代码会替换 XMLHttpRequest 中的 open 和 send 函数,在 open 中优先注册 readystatechange 的事件监听,以便在原页面代码执行前修改 responseText 的内容。
这段代码不能直接注入页面,由于 Chrome 扩展的 Content Script 会运行在隔离环境中,直接注入的话,并不能影响到页面原有的 XMLHttpRequest。想要实现咱们想要的功能,能够参考Building a Chrome Extension - Inject code in a page using a Content script 的作法。再写一个文件:
var s = document.createElement("script"); s.src = chrome.extension.getURL("xmlhttp.js"); s.onload = function() { this.remove(); }; (document.head || document.documentElement).appendChild(s);
这个功能一看就知道,在页面上增长一个 <script> 标签,src 属性指向插件中的 xmlhttp.js。为了让这个文件能在页面内被引用,须要在 manifest.json 里加一行:
"web_accessible_resources": ["xmlhttp.js"]
须要注意的是:用这种方法插入的脚本,是没法控制在什么时候执行的(不像 Content Script,能够设置 document_start、document_end、document_idle),而在此文件执行前发起的 ajax 是没法被修改的。幸亏在个人需求中,这些 ajax 请求都是由用户点击触发的,在那以前时间充足,足够我动手脚了。
综上所述,一个能修改 ajax 响应的 Chrome 扩展就写好了。