Itsnotlupus' MiddleMan

inspect/intercept/modify any network requests

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

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

// ==UserScript==
// @name         Itsnotlupus' MiddleMan
// @namespace    Itsnotlupus Industries
// @version      1.0.1
// @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 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-starter 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);

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址