Skip to content
本页目录

油猴脚本重写fetch和xhr请求

写过几个油猴脚本,经常对页面请求返回的数据进行拦截或者覆盖,这篇文章就做个总结,涉及到 fetchxhr 两种类型的请求。

环境搭建

先简单写个 html 页面,搭一个 koa 服务进行测试。

html 页面提供一个 id=jsondom 用来加数据,后边我们补充 test.js 文件来请求接口。

html
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="X-UA-Compatible" content="IE=edge" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Document</title>
    </head>
    <body>
        我运行了
        <div id="json"></div>
    </body>
    <script src="test.js"></script>
</html>

html 通过 VSCodelive-server 插件运行在 http://127.0.0.1:5500/ 上。

image-20220823080047006

安装 koakoa-routenode 包,提供一个接口。

js
const koa = require("koa");
const app = new koa();
const router = require("koa-router")();
router.get("/api/query", async (ctx, next) => {
    ctx.body = {
        data: [1,2,3],
        code: 0,
        msg: "成功",
    };
});
// 跨域
app.use(async (ctx, next) => {
    ctx.set("Access-Control-Allow-Origin", "http://127.0.0.1:5500");
    ctx.set(
        "Access-Control-Allow-Headers",
        "Content-Type, Content-Length, Authorization, Accept, X-Requested-With"
    );
    ctx.set("Access-Control-Allow-Methods", "PUT, POST, GET, DELETE, OPTIONS");
    if (ctx.method === "OPTIONS") {
        ctx.body = 200;
    } else {
        await next();
    }
});
// 启动路由
app.use(router.routes());
// 设置响应头
app.use(router.allowedMethods());

// 监听端口
app.listen(3002);

提供了 /api/query 接口,返回 data: [1,2,3], 。运行在本地的 3002 端口上,并且设置跨域,允许从 http://127.0.0.1:5500 访问。

油猴脚本

先简单写一个插入 我是油猴脚本的文本 的脚本,后边再进行修改。

js
// ==UserScript==
// @name         网络拦截测试
// @namespace    https://windliang.wang/
// @version      0.1
// @description  测试
// @author       windliang
// @match        http://127.0.0.1:5500/index.html
// @run-at       document-start
// @grant        unsafeWindow
// ==/UserScript==

(function () {
    console.log(window.unsafeWindow)
    const dom = document.createElement("div");
    dom.innerText = '我是油猴脚本的文本'
    document.getElementsByTagName("body")[0].append(dom);
})();

此时页面已经被成功拦截:

image-20220823101447976

这里提一句,油猴脚本如果使用 @grant 申请了权限,此时脚本会运行在一个沙箱环境中,如果想访问原始的 window 对象,可以通过 window.unsafeWindow

并且我们加了 @run-at ,让脚本尽快执行。

html 请求的 test.js 中添加 fetch 的代码。

js
fetch("http://localhost:3002/api/query")
  .then((response) => response.json())
  .then((res) => {
  const dom = document.getElementById("json"); 
  dom.innerText = res.data;
});

看下页面,此时就会把 data 显示出来。

image-20220823102924464

如果想更改返回的数据,我们只需要在油猴脚本中重写 fetch 方法,将原数据拿到以后再返回即可。

js
// ==UserScript==
// @name         网络拦截测试
// @namespace    https://windliang.wang/
// @version      0.1
// @description  测试
// @author       windliang
// @match        http://127.0.0.1:5500/index.html
// @run-at       document-start
// @grant        unsafeWindow
// ==/UserScript==

(function () {
    console.log(window.unsafeWindow)
    const dom = document.createElement("div");
    dom.innerText = '我是油猴脚本的文本'
    document.getElementsByTagName("body")[0].append(dom);
    const originFetch = fetch;
    console.log(originFetch)
    window.unsafeWindow.fetch = (url, options) => {
        return originFetch(url, options).then(async (response) => {
            console.log(url)
            if(url === 'http://localhost:3002/api/query'){
                const responseClone = response.clone();
                let res = await responseClone.json();
                res.data.push('油猴脚本修改数据')
                const responseNew = new Response(JSON.stringify(res), response);
                return responseNew;
            }else{
                return response;

            }
        });
    };
})();

response 的处理有点绕,当时也是试了好多次才试出了这种方案。

做的事情就是把原来返回的 respones 复制,通过 json 方法拿到数据,进行修改数据,最后新生成一个 Response 进行返回。

看下效果:

image-20220823173813341

成功修改了返回的数据。

xhr

我们将 fetch 改为用 xhr 发送请求,因为页面简单所以请求可能在油猴脚本重写之前就发送了,正常网站不会这么快,所以这里加一个 setTimeout 进行延时。

js
setTimeout(() => {
    const xhr = new XMLHttpRequest();
    xhr.open('GET', 'http://localhost:3002/api/query');
    xhr.send();
    xhr.onload = function() {
        const res = JSON.parse(this.responseText);
        const dom = document.getElementById("json");
        dom.innerText = res.data;
    };
}, 0)

fetch 的思路一样,我们可以在返回前更改 responseText

重写 XMLHttpRequest 原型对象的 open 或者 send 方法,在函数内拿到用户当前的 xhr 实例,监听 readystatechange 事件,然后重写 responseText

js
const originOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (_, url) {
  if (url === "http://localhost:3002/api/query") {
    this.addEventListener("readystatechange", function () {
      if (this.readyState === 4) {
        const res = JSON.parse(this.responseText);
        res.data.push("油猴脚本修改数据");
        this.responseText = JSON.stringify(res);
      }
    });
  }
  originOpen.apply(this, arguments);
};

运行一下:

image-20220824084014585

拦截失败了,网上搜寻下答案,原因是 responseText 不是可写的,我们将原型对象上的 responseText 属性描述符打印一下。

image-20220824084726967

可以看到 set 属性是 undefined ,因此我们重写 responseText 失败了。

我们无法修改原型对象上的 responseText ,我们可以在当前 xhr 对象,也就是 this 上边定义一个同名的 responseText 属性,赋值的话有两种思路。

1、直接赋值

我们定义一个 writable: true, 的属性,然后直接赋值为我们修改后的数据。

js
const originOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (_, url) {
  if (url === "http://localhost:3002/api/query") {
    this.addEventListener("readystatechange", function () {
      if (this.readyState === 4) {
        const res = JSON.parse(this.responseText);
        // 当前 xhr 对象上定义 responseText
        Object.defineProperty(this, "responseText", { 
          writable: true,
        });
        res.data.push("油猴脚本修改数据");
        this.responseText = JSON.stringify(res);
      }
    });
  }

  originOpen.apply(this, arguments);
};

看下页面会发现成功拦截了:

image-20220824085203088

2、重写 get

js
const originOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (_, url) {
  if (url === "http://localhost:3002/api/query") {
    const xhr = this;
    const getter = Object.getOwnPropertyDescriptor(
      XMLHttpRequest.prototype,
      "response"
    ).get;
    Object.defineProperty(xhr, "responseText", {
      get: () => {
        let result = getter.call(xhr);
        try {
          const res = JSON.parse(result);
          res.data.push('油猴脚本修改数据')
          return JSON.stringify(res);
        } catch (e) {
          return result;
        }
      },
    });
  }
  originOpen.apply(this, arguments);
};

我们拿到原型对象的 get ,然后在当前对象上定义 responseTextget 属性,修改数据后返回即可。

相比于第一种方案,这种方案无需等待 readystatechange ,在开始的时候重写即可。

需要注意的是,上边方案都只是重写了 responseText 字段,不排除有的网站读取的是 response 字段,但修改的话和上边是一样的,这里就不写了。

通过对 fetchxhr 的重写,我们基本上可以对网页「为所欲为」了,发挥想象力通过油猴脚本应该可以做很多有意思的事情。

javascript
/**
 * 重写fetch方法,以便在请求结束后通知content_script
 * inject_script无法直接与background通信,所以先传到content_script,再通过他传到background
 *
 */

const originFetch = fetch;
window.fetch = (url, options) => {
  return originFetch(url, options).then(async(response) => {
    if(url.includes('/v4/pdp/get_pc')){
      const responseClone = response.clone();
      let res = await responseClone.json();
      document.body.setAttribute('data-pageConfig',JSON.stringify(res.data))
      // 因为inject_script不能直接向background传递消息, 所以先传递消息到content_script
      window.postMessage({ type: 'data-pageConfig', res }, '*');
      return response;
    }
    return response
  })
}
javascript
/**
 * 重写ajax方法,以便在请求结束后通知content_script
 * inject_script无法直接与background通信,所以先传到content_script,再通过他传到background
 */
(function(xhr) {
  const XHR = xhr.prototype
  const open = XHR.open
  const send = XHR.send

  // 对open进行patch 获取url和method
  XHR.open = function(method, url) {
    this._method = method
    this._url = url
    return open.apply(this, arguments)
  }
  // 同send进行patch 获取responseData.
  XHR.send = function(postData) {
    this.addEventListener('load', function() {
      const myUrl = this._url ? this._url.toLowerCase() : this._url
      if (myUrl) {
        if (this.responseType != 'blob' && this.responseText) {
          // responseText is string or null
          try {
            const arr = this.responseText

            // 因为inject_script不能直接向background传递消息, 所以先传递消息到content_script
            window.postMessage({ 'url': this._url, 'response': arr }, '*')
          } catch (err) {
            console.log(err)
            console.log('Error in responseType try catch')
          }
        }
      }
    })
    return send.apply(this, arguments)
  }
})(XMLHttpRequest);