Itsnotlupus' MiddleMan

inspect/intercept/modify any network requests

目前為 2023-08-12 提交的版本,檢視 最新版本

此腳本不應該直接安裝,它是一個供其他腳本使用的函式庫。欲使用本函式庫,請在腳本 metadata 寫上: // @require https://update.cn-greasyfork.org/scripts/472943/1234294/Itsnotlupus%27%20MiddleMan.js

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Itsnotlupus' MiddleMan
// @namespace    Itsnotlupus Industries
// @version      1.0
// @description  inspect/intercept/modify any network requests
// @author       Itsnotlupus
// @license      MIT
// ==/UserScript==

const middleMan = (function(window) {

  /**
   * A small class that lets you register middleware for Fetch/XHR traffic.
   *
   */
  class MiddleMan {
    routes = {
      Request: {},
      Response: {}
    };
    regexps = {};

    addHook(route, {requestHandler, responseHandler}) {
      if (requestHandler) {
        this.routes.Request[route]??=[];
        this.routes.Request[route].push(requestHandler);
      }
      if (responseHandler) {
        this.routes.Response[route]??=[];
        this.routes.Response[route].push(responseHandler);
      }
      this.regexps[route]??=this.routeToRegexp(route);
    }

    removeHook(route, {requestHandler, responseHandler}) {
      if (requestHandler && this.routes.Request[route]?.includes(requestHandler)) {
        const i = this.routes.Request[route].indexOf(requestHandler);
        this.routes.Request[route].splice(i,1);
      }
      if (responseHandler && this.routes.Response[route]?.includes(responseHandler)) {
        const i = this.routes.Response[route].indexOf(responseHandler);
        this.routes.Response[route].splice(i,1);
      }
    }

    // 2 modes: start with '/' => full regexp, otherwise we only recognize '*" as a wildcard.
    routeToRegexp(path) {
      const r = path instanceof RegExp ? path :
        path.startsWith('/') ?
          path.split('/').slice(1,-1) :
          ['^', ...path.split(/([*])/).map((chunk, i) => i%2==0 ? chunk.replace(/([^a-zA-Z0-9])/g, "\\$1") : '.'+chunk), '$'];
      return new RegExp(r.join(''));
    }

    /**
     * Call this with a Request or a Response, and it'll loop through
     * each relevant hook to inspect and/or transform it.
     */
    async process(obj) {
      const { constructor: type, constructor: { name } } = obj;
      // const hooks = this.getHooks(this.routes[name], obj.url);
      const routes = this.routes[name], hooks = [];
      Object.keys(routes).forEach(k => {
        if (obj.url.match(this.regexps[k])) hooks.push(...routes[k]);
      });
      for (const hook of hooks) {
        if (obj instanceof type) obj = await hook(obj.clone()) ?? obj;
      }
      return obj;
    }
  }

  // The only instance we'll need
  const middleMan = new MiddleMan;

  // A wrapper for fetch() that plugs into middleMan.
  const _fetch = window.fetch;
  async function fetch(resource, options) {
    const request = new Request(resource, options);
    const result = await middleMan.process(request);
    const response = result instanceof Request ? await _fetch(result) : result;
    return middleMan.process(response);
  }

  /**
   * An XMLHttpRequest polyfill written on top of fetch().
   * Nothing special here, except that means XHR can get hooked through middleMan too.
   *
   * A few gotchas:
   * - This is not spec-compliant. In many ways. https://xhr.spec.whatwg.org/
   * - xhr.upload is not implemented. we'll throw an exception if someone tries to use it.
   * - that "extends EventTarget" line below used to be a non-started on Safari. Perhaps it still is.
   * - no test coverage. But I tried it on 2 sites and it didn't explode, so.. pretty good.
   */
  class XMLHttpRequest extends EventTarget {
    #readyState;

    #requestOptions;
    #requestURL;
    #abortController;
    #timeout;
    #responseType;
    #mimeTypeOverride;

    #response;
    #responseText;
    #responseXML;
    #responseAny;

    #dataLengthComputable = false;
    #dataLoaded = 0;
    #dataTotal = 0;

    #errorEvent;

    UNSENT = 0;
    OPENED = 1;
    HEADERS_RECEIVED = 2;
    LOADING = 3;
    DONE = 4;
    static UNSENT = 0;
    static OPENED = 1;
    static HEADERS_RECEIVED = 2;
    static LOADING = 3;
    static DONE = 4;

    constructor() {
      super();
      this.#readyState = 0;
    }

    get readyState() {
      return this.#readyState;
    }
    #assertReadyState(...validValues) {
      if (!validValues.includes(this.#readyState)) {
        throw new Error("Failed to take action on XMLHttpRequest: Invalid state.");
      }
    }
    #updateReadyState(value) {
      this.#readyState = value;
      this.#emitEvent("readystatechange");
    }

    // Request setup
    open(method, url, async, user, password) {
      this.#assertReadyState(0,1);
      this.#requestOptions = {
        method: method.toString().toUpperCase(),
        headers: new Headers()
      };
      this.#requestURL = url;
      this.#abortController = null;
      this.#timeout = 0;
      this.#responseType = '';
      this.#mimeTypeOverride = null;
      this.#response = null;
      this.#responseText = '';
      this.#responseAny = '';
      this.#responseXML = null;
      this.#dataLengthComputable = false;
      this.#dataLoaded = 0;
      this.#dataTotal = 0;

      if (async === false) {
        throw new Error("Synchronous XHR is not supported.");
      }
      if (user || password) {
        this.#requestOptions.headers.set('Authorization', 'Basic '+btoa(`${user??''}:${password??''}`));
      }
      this.#updateReadyState(1);
    }
    setRequestHeader(header, value) {
      this.#assertReadyState(1);
      this.#requestOptions.headers.set(header, value);
    }
    overrideMimeType(mimeType) {
      this.#mimeTypeOverride = mimeType;
    }
    set responseType(type) {
      if (!["","arraybuffer","blob","document","json","text"].includes(type)) {
        console.warn(`The provided value '${type}' is not a valid enum value of type XMLHttpRequestResponseType.`);
        return;
      }
      this.#responseType = type;
    }
    get responseType() {
      return this.#responseType;
    }
    set timeout(value) {
      const ms = isNaN(Number(value)) ? 0 : Math.floor(Number(value));
      this.#timeout = value;
    }
    get timeout() {
      return this.#timeout;
    }
    get upload() {
      throw new Error("XMLHttpRequestUpload is not implemented.");
    }
    set withCredentials(flag) {
      this.#requestOptions.credentials = flag ? "include" : "omit";
    }
    get withCredentials() {
      return this.#requestOptions.credentials !== "omit"; // "same-origin" returns true here. whatever.
    }
    async send(body = null) {
      this.#assertReadyState(1);
      this.#requestOptions.body = body instanceof Document ? body.documentElement.outerHTML : body;
      const request = new Request(this.#requestURL, this.#requestOptions);
      this.#abortController = new AbortController();
      const signal = this.#abortController.signal;
      if (this.#timeout) {
        setTimeout(()=> this.#timedOut(), this.#timeout);
      }
      this.#emitEvent("loadstart");
      let response;
      try {
        response = await fetch(request, { signal });
      } catch (e) {
        return this.#error();
      }
      this.#dataTotal = response.headers.get('content-length') ?? 0;
      this.#dataLengthComputable = this.#dataTotal !== 0;
      this.#updateReadyState(2);
      this.#processResponse(response);
    }
    abort() {
      this.#abortController?.abort();
      this.#errorEvent = "abort";
    }
    #timedOut() {
      this.#abortController?.abort();
      this.#errorEvent = "timeout";
    }
    #error() {
      // abort and timeout end up here.
      this.#response = new Response('');
      this.#updateReadyState(4);
      this.#emitEvent(this.#errorEvent ?? "error");
      this.#emitEvent("loadend");
      this.#errorEvent = null;
    }
    async #processResponse(response) {
      this.#response = response;
      // TODO: remove all the clone() calls, I probably don't need them.
      // ok, maybe one clone, if we start using body.getReader() to track downloads and emit meaningful "progress" events. TODO LATER
      const progressResponse = response.clone();
      const { size } = await progressResponse.blob();
      this.#dataLoaded = size;
      this.#emitEvent('progress');
      switch (this.#responseType) {
        case 'arraybuffer':
          try {
            this.#responseAny = await response.clone().arrayBuffer();
          } catch {
            this.#responseAny = null;
          }
          break;
        case 'blob':
          try {
            this.#responseAny = await response.clone().blob();
          } catch {
            this.#responseAny = null;
          }
          break;
        case 'document': {
          this.#responseText = await response.clone().text();
          const mimeType = this.#mimeTypeOverride ?? this.#response.headers.get('content-type')?.split(';')[0].trim() ?? 'text/xml';
          try {
            const parser = new DOMParser();
            const doc = parser.parseFromString(this.#responseText, mimeType);
            this.#responseAny = this.#responseXML = doc;
          } catch {
            this.#responseAny = null;
          }
          break;
        }
        case 'json':
          try {
            this.#responseAny = await response.clone().json();
          } catch {
            this.#responseAny = null;
          }
          break;
        case '':
        case 'text':
        default:
          this.#responseAny = this.#responseText = await response.clone().text();
          break;
      }
      this.#updateReadyState(4);
      this.#emitEvent("load");
      this.#emitEvent("loadend");
    }
    // Response access
    getResponseHeader(header) {
      return this.#response?.headers.get(header) ?? null;
    }
    getAllResponseHeaders() {
      return [...this.#response?.headers.entries()??[]].map(([key,value]) => `${key}: ${value}\r\n`).join('');
    }
    get response() {
      return this.#responseAny;
    }
    get responseText() {
      return this.#responseText;
    }
    get responseXML() {
      return this.#responseXML;
    }
    get responseURL() {
      return this.#response?.url;
    }
    get status() {
      return this.#response?.status ?? 0;
    }
    get statusText() {
      return this.#response?.statusText ?? '';
    }

    // event dispatching resiliency
    async #emitEvent(name) {
      try {
        this.dispatchEvent(new ProgressEvent(name, {
          lengthComputable: this.#dataLengthComputable,
          loaded: this.#dataLoaded,
          total: this.#dataTotal
        }));
      } catch (e) {
        await 0;
        throw e;
      }
    }
    // informal event handlers
    #events = {};
    #setEvent(name, f) {
      if (this.#events[name]) this.removeEventListener(name, this.#events[name]);
      this.#events[name] = f;
      this.addEventListener(name, this.#events[name]);
    }
    #getEvent(name) {
      return this.#events[name];
    }
    set onabort(f) { this.#setEvent('abort', f); }
    get onabort() { return this.#getEvent('abort'); }
    set onerror(f) { this.#setEvent('error', f); }
    get onerror() { return this.#getEvent('error'); }
    set onload(f) { this.#setEvent('load', f); }
    get onload() { return this.#getEvent('load'); }
    set onloadend(f) { this.#setEvent('loadend', f); }
    get onloadend() { this.#getEvent('loadend'); }
    set onloadstart(f) { this.#setEvent('loadstart', f); }
    get onloadstart() { this.#getEvent('loadstart'); }
    set onprogress(f) { this.#setEvent('progress', f); }
    get onprogress() { this.#getEvent('progress'); }
    set onreadystatechange(f) { this.#setEvent('readystatechange', f); }
    get onreadystatechange() { this.#getEvent('readystatechange'); }
    set ontimeout(f) { this.#setEvent('timeout', f); }
    get ontimeout() { this.#getEvent('timeout'); }
    // I've got the perfect disguise..
    get [Symbol.toStringTag]() {
      return 'XMLHttpRequest';
    }
    static toString = ()=> 'function XMLHttpRequest() { [native code] }';
  }

  window.XMLHttpRequest = XMLHttpRequest;
  window.fetch = fetch;

  return middleMan;

})(globalThis.unsafeWindow ?? window);