Itsnotlupus' MiddleMan

inspect/intercept/modify any network requests

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

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

  1. // ==UserScript==
  2. // @name Itsnotlupus' MiddleMan
  3. // @namespace Itsnotlupus Industries
  4. // @version 1.3
  5. // @description inspect/intercept/modify any network requests
  6. // @author Itsnotlupus
  7. // @license MIT
  8. // ==/UserScript==
  9.  
  10. const middleMan = (function(window) {
  11.  
  12. /**
  13. * A small class that lets you register middleware for Fetch/XHR traffic.
  14. *
  15. */
  16. class MiddleMan {
  17. routes = {
  18. Request: {},
  19. Response: {}
  20. };
  21. regexps = {};
  22.  
  23. addHook(route, {requestHandler, responseHandler}) {
  24. if (requestHandler) {
  25. this.routes.Request[route]??=[];
  26. this.routes.Request[route].push(requestHandler);
  27. }
  28. if (responseHandler) {
  29. this.routes.Response[route]??=[];
  30. this.routes.Response[route].push(responseHandler);
  31. }
  32. this.regexps[route]??=this.routeToRegexp(route);
  33. }
  34.  
  35. removeHook(route, {requestHandler, responseHandler}) {
  36. if (requestHandler && this.routes.Request[route]?.includes(requestHandler)) {
  37. const i = this.routes.Request[route].indexOf(requestHandler);
  38. this.routes.Request[route].splice(i,1);
  39. }
  40. if (responseHandler && this.routes.Response[route]?.includes(responseHandler)) {
  41. const i = this.routes.Response[route].indexOf(responseHandler);
  42. this.routes.Response[route].splice(i,1);
  43. }
  44. }
  45.  
  46. // 2 modes: start with '/' => full regexp, otherwise we only recognize '*" as a wildcard.
  47. routeToRegexp(path) {
  48. const r = path instanceof RegExp ? path :
  49. path.startsWith('/') ?
  50. path.split('/').slice(1,-1).join('') :
  51. ['^', ...path.split(/([*])/).map((chunk, i) => i%2==0 ? chunk.replace(/([^a-zA-Z0-9])/g, "\\$1") : '.'+chunk), '$'].join('');
  52. return new RegExp(r);
  53. }
  54.  
  55. /**
  56. * Call this with a Request or a Response, and it'll loop through
  57. * each relevant hook to inspect and/or transform it.
  58. */
  59. async process(obj) {
  60. const { constructor: type, constructor: { name } } = obj;
  61. const routes = this.routes[name], hooks = [];
  62. Object.keys(routes).forEach(k => {
  63. if (obj.url.match(this.regexps[k])) hooks.push(...routes[k]);
  64. });
  65. for (const hook of hooks) {
  66. try {
  67. if (obj instanceof type) obj = await hook(obj.clone()) ?? obj;
  68. } catch (e) {
  69. console.error(`MiddleMan: Uncaught exception in ${name} hook!`, e);
  70. }
  71. }
  72. return obj;
  73. }
  74. }
  75.  
  76. // The only instance we'll need
  77. const middleMan = new MiddleMan;
  78.  
  79. // A wrapper for fetch() that plugs into middleMan.
  80. const _fetch = window.fetch;
  81. async function fetch(resource, options) {
  82. const request = new Request(resource, options);
  83. const result = await middleMan.process(request);
  84. const response = result instanceof Request ? await _fetch(result) : result;
  85. return middleMan.process(response);
  86. }
  87.  
  88. /**
  89. * Polyfill a subset of EventTarget, for the sole purpose of being used in the XHR polyfill below.
  90. * Primarily written to allow Safari to extend it without tripping on itself.
  91. * Various liberties were taken.
  92. * We call ourselves XMLHttpRequestEventTarget because that's a thing, and some well-meaning libraries (zone.js)
  93. * feel compelled to grab methods from this object and call them on XHR instances, so let's make them happy.
  94. */
  95. class XMLHttpRequestEventTarget {
  96. #listeners = {};
  97. #events = {};
  98. #setEvent(type, f) {
  99. if (this.#events[type]) this.removeEventListener(type, this.#events[type]);
  100. this.#events[type] = typeof f == 'function' ? f : null;
  101. if (this.#events[type]) this.addEventListener(type, this.#events[type]);
  102. }
  103. #getEvent(type) {
  104. return this.#events[type];
  105. }
  106. constructor(events = []) {
  107. events.forEach(type => {
  108. Object.defineProperty(this, "on"+type, {
  109. get() { return this.#getEvent(type); },
  110. set(f) { this.#setEvent(type, f); }
  111. });
  112. });
  113. }
  114. addEventListener(type, listener, options = {}) {
  115. if (options === true) { options = { capture: true }; }
  116. this.#listeners[type]??=[];
  117. this.#listeners[type].push({ listener, options });
  118. options.signal?.addEventListener?.('abort', () => this.removeEventListener(type, listener, options));
  119. }
  120. removeEventListener(type, listener, options = {}) {
  121. if (options === true) { options = { capture: true }; }
  122. if (!this.#listeners[type]) return;
  123. const index = this.#listeners[type].findIndex(slot => slot.listener === listener && slot.options.capture === options.capture);
  124. if (index > -1) {
  125. this.#listeners[type].splice(index,1);
  126. }
  127. }
  128. dispatchEvent(event) {
  129. // no capturing, no bubbling, no preventDefault, no stopPropagation, and a general disdain for most of the event featureset.
  130. const listeners = this.#listeners[event.type];
  131. if (!listeners) return;
  132. // since I can't set event.target, or generally do anything useful with an Event instance, let's Proxy it.
  133. let immediateStop = false;
  134. const eventProxy = new Proxy(event, {
  135. get: (event, prop) => {
  136. switch (prop) {
  137. case "target":
  138. case "currentTarget":
  139. return this;
  140. case "isTrusted":
  141. return true; // you betcha
  142. case "stopImmediatePropagation":
  143. return () => { immediateStop = true };
  144. default: {
  145. const val = Reflect.get(event, prop);
  146. return typeof val =='function' ? new Proxy(val, {
  147. apply(fn, _, args) {
  148. return Reflect.apply(fn, event, args);
  149. }
  150. }) : val;
  151. }
  152. }
  153. }
  154. });
  155. listeners.forEach(async ({listener, options}) => {
  156. if (immediateStop) return;
  157. if (options.once) this.removeEventListener(eventProxy.type, listener, options);
  158. try {
  159. listener.call(this, eventProxy);
  160. } catch (e) {
  161. // I think it's impossible to match EventTarget::dispatchEvent throwing behavior in pure JS. oh well. fudge the timing and keep on trucking.
  162. await 0;
  163. throw e;
  164. }
  165. });
  166. return true;
  167. }
  168. }
  169.  
  170. class XMLHttpRequestUpload extends XMLHttpRequestEventTarget {
  171. constructor() {
  172. super(["loadstart","progress","abort","error","load","timeout","loadend"]);
  173. }
  174. }
  175.  
  176. /**
  177. * An XMLHttpRequest polyfill written on top of fetch().
  178. * Nothing special here, but this allows MiddleMan to work on XHR too.
  179. *
  180. * A few gotchas:
  181. * - synchronous xhr is not implemented. all my homies hate sync xhr anyway.
  182. * - https://xhr.spec.whatwg.org/ was gently perused, and https://github.com/web-platform-tests/wpt 's output was pondered.
  183. * - In short, this is not spec-compliant. But it can work on a bunch of websites anyway.
  184. */
  185. class XMLHttpRequest extends XMLHttpRequestEventTarget {
  186. #readyState;
  187.  
  188. #requestOptions = {};
  189. #requestURL;
  190. #abortController;
  191. #timeout = 0;
  192. #responseType = '';
  193. #mimeTypeOverride = null;
  194.  
  195. #response;
  196. #responseText;
  197. #responseXML;
  198. #responseAny;
  199. #status; // a response.status override for error conditions.
  200. #finalMimeType;
  201. #finalResponseType;
  202.  
  203. #dataLengthComputable = false;
  204. #dataLoaded = 0;
  205. #dataTotal = 0;
  206.  
  207. #uploadEventTarget;
  208.  
  209. #errorEvent;
  210.  
  211. #sendFlag;
  212.  
  213. UNSENT = 0;
  214. OPENED = 1;
  215. HEADERS_RECEIVED = 2;
  216. LOADING = 3;
  217. DONE = 4;
  218. static UNSENT = 0;
  219. static OPENED = 1;
  220. static HEADERS_RECEIVED = 2;
  221. static LOADING = 3;
  222. static DONE = 4;
  223.  
  224. constructor() {
  225. super(['abort','error','load','loadend','loadstart','progress','readystatechange','timeout']);
  226. this.#readyState = 0;
  227. }
  228.  
  229. get readyState() {
  230. return this.#readyState;
  231. }
  232. #assertReadyState(...validValues) {
  233. if (!validValues.includes(this.#readyState)) {
  234. throw new new DOMException("", "InvalidStateError");
  235. }
  236. }
  237. #updateReadyState(value) {
  238. this.#readyState = value;
  239. this.#emitEvent("readystatechange");
  240. }
  241.  
  242. // Request setup
  243. open(method, url, async, user, password) {
  244. this.#requestOptions.method = method.toString().toUpperCase();
  245. this.#requestOptions.headers = new Headers()
  246. this.#requestURL = url;
  247. this.#abortController = null;
  248. this.#mimeTypeOverride = null;
  249. this.#response = null;
  250. this.#responseText = '';
  251. this.#responseAny = null;
  252. this.#responseXML = null;
  253. this.#status = null;
  254. this.#dataLengthComputable = false;
  255. this.#dataLoaded = 0;
  256. this.#dataTotal = 0;
  257. this.#sendFlag = false;
  258.  
  259. if (async === false) {
  260. throw new Error("Synchronous XHR is not supported.");
  261. // I suspect that if I just let those run asynchronously, it'd be fine 80%+ of the time.
  262. // on the other hand, it's been deprecated for many years, and seems to be primarily used
  263. // for user tracking by devs who can't be bothered to hit newer APIs. so..
  264. }
  265. if (user || password) {
  266. this.#requestOptions.headers.set('Authorization', 'Basic '+btoa(`${user??''}:${password??''}`));
  267. }
  268. this.#updateReadyState(1);
  269. }
  270. setRequestHeader(header, value) {
  271. this.#assertReadyState(1);
  272. if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
  273. this.#requestOptions.headers.set(header, value);
  274. }
  275. overrideMimeType(mimeType) {
  276. this.#assertReadyState(0,1,2);
  277. this.#mimeTypeOverride = mimeType;
  278. }
  279. set responseType(type) {
  280. this.#assertReadyState(0,1,2);
  281. if (!["","arraybuffer","blob","document","json","text"].includes(type)) {
  282. console.warn(`The provided value '${type}' is not a valid enum value of type XMLHttpRequestResponseType.`);
  283. return;
  284. }
  285. this.#responseType = type;
  286. }
  287. get responseType() {
  288. return this.#responseType;
  289. }
  290. set timeout(value) {
  291. const ms = isNaN(Number(value)) ? 0 : Math.floor(Number(value));
  292. this.#timeout = value;
  293. }
  294. get timeout() {
  295. return this.#timeout;
  296. }
  297. get upload() {
  298. Promise.resolve(()=>{ throw new Error("XMLHttpRequestUpload is not implemented."); });
  299. if (!this.#uploadEventTarget) {
  300. this.#uploadEventTarget = new XMLHttpRequestUpload();
  301. }
  302. return this.#uploadEventTarget;
  303. // if the request has a body, we'll dispatch events on the upload event target in the next method.
  304. }
  305. async #trackUploadEvents() {
  306. let loaded =0, total = 0, hasSize = false, error = false;;
  307. const emitUploadEvent = type => {
  308. this.#uploadEventTarget.dispatchEvent(new ProgressEvent(type, {
  309. lengthComputable: hasSize,
  310. loaded,
  311. total
  312. }));
  313. }
  314. ["$abort", "$error", "$timeout"].forEach(type => {
  315. this.addEventListener(type, e => {
  316. error = true;
  317. hasSize = false;
  318. loaded = total = 0;
  319. emitUploadEvent(e.type.slice(1));
  320. emitUploadEvent("loadend");
  321. }, { once: true });
  322. });
  323. const blob = await new Response(this.#requestOptions.body).blob();
  324. total = blob.size;
  325. hasSize = total>0;
  326. this.#requestOptions.duplex = "half";
  327. this.#requestOptions.body = blob.stream().pipeThrough(new TransformStream({
  328. transform(chunk, controller) {
  329. if (error) return;
  330. controller.enqueue(chunk);
  331. loaded += chunk.byteLength;
  332. emitUploadEvent('progress');
  333. },
  334. flush(controller) {
  335. if (error) return;
  336. emitUploadEvent('progress');
  337. emitUploadEvent('load');
  338. emitUploadEvent('loadend');
  339. }
  340. }));
  341. emitUploadEvent('loadstart');
  342. }
  343. set withCredentials(flag) {
  344. if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
  345. this.#requestOptions.credentials = flag ? "include" : "omit";
  346. }
  347. get withCredentials() {
  348. return this.#requestOptions.credentials == "include";
  349. }
  350. send(body = null) {
  351. this.#assertReadyState(1);
  352. if (this.#sendFlag) throw new DOMException("", "InvalidStateError");
  353. this.#sendFlag = true;
  354. if (this.#requestOptions.method != 'GET' && this.#requestOptions.method != 'HEAD') {
  355. this.#requestOptions.body = body instanceof Document ? body.documentElement.outerHTML : body;
  356. }
  357. const request = new Request(this.#requestURL, this.#requestOptions);
  358. this.#abortController = new AbortController();
  359. const signal = this.#abortController.signal;
  360. if (this.#timeout) {
  361. setTimeout(()=> this.#timedOut(), this.#timeout);
  362. }
  363. this.#emitEvent("loadstart");
  364. (async ()=> {
  365. if (this.#uploadEventTarget && this.#requestOptions.body) {
  366. // user asked for .upload, and the request has a body. track upload events.
  367. await this.#trackUploadEvents(this.#requestOptions);
  368. }
  369. let response;
  370. try {
  371. this.#response = await fetch(request, { signal });
  372. let finalResponseType = this.#responseType;
  373. this.#finalMimeType = this.#mimeTypeOverride ?? this.#response.headers.get('content-type')?.split(';')[0].trim() ?? 'text/xml'; // header parsing is still iffy
  374. if (!finalResponseType) {
  375. finalResponseType = ([ 'text/html', 'text/xml', 'application/xml'].includes(this.#finalMimeType) || this.#finalMimeType.endsWith("+xml")) ? 'document' : 'text';
  376. }
  377. this.#finalResponseType = finalResponseType;
  378. this.#updateReadyState(2);
  379. const isNotCompressed = this.#response.type == 'basic' && !this.#response.headers.get('content-encoding');
  380. if (isNotCompressed) {
  381. this.#dataTotal = this.#response.headers.get('content-length') ?? 0;
  382. this.#dataLengthComputable = this.#dataTotal !== 0;
  383. }
  384. await this.#processResponse();
  385. } catch (e) {
  386. return this.#error();
  387. } finally {
  388. this.#sendFlag = false;
  389. }
  390. })();
  391. }
  392. /**
  393. * Spec breakage: When readyState == 1, abort will happen asynchronously.
  394. * (ie nothing will have changed when this function returns.)
  395. */
  396. abort() {
  397. this.#abortController?.abort();
  398. this.#errorEvent = "abort";
  399. if (this.#readyState > 1) { // too late to send signal abort the fetch itself, resolve manually.
  400. this.#error(true);
  401. }
  402. }
  403. #timedOut() {
  404. this.#abortController?.abort();
  405. this.#errorEvent = "timeout";
  406. }
  407. #error(late) {
  408. // abort and timeout end up here.
  409. this.#response = new Response();
  410. this.#status = 0;
  411. this.#responseText = ''
  412. this.#responseAny = null;
  413. this.#responseXML = null;
  414. this.#dataLoaded = 0;
  415. this.#readyState = 0; // event-less readyState change. somehow.
  416. if (!late) {
  417. this.#updateReadyState(4);
  418. this.#emitEvent('$'+(this.#errorEvent ?? "error")); // XXX weird hack to get the upload events to fire first.
  419. this.#emitEvent(this.#errorEvent ?? "error");
  420. this.#emitEvent("loadend");
  421. }
  422. this.#errorEvent = null;
  423. }
  424. async #processResponse() {
  425. this.#trackProgress(this.#response.clone());
  426.  
  427. switch (this.#finalResponseType) {
  428. case 'arraybuffer':
  429. try {
  430. this.#responseAny = await this.#response.arrayBuffer();
  431. } catch {
  432. this.#responseAny = null;
  433. }
  434. break;
  435. case 'blob':
  436. try {
  437. this.#responseAny = await this.#response.blob();
  438. } catch {
  439. this.#responseAny = null;
  440. }
  441. break;
  442. case 'document': {
  443. this.#responseText = await this.#response.text();
  444. try {
  445. const parser = new DOMParser();
  446. const doc = parser.parseFromString(this.#responseText, this.#finalMimeType);
  447. this.#responseAny = this.#responseXML = doc;
  448. } catch {
  449. this.#responseAny = null;
  450. }
  451. break;
  452. }
  453. case 'json':
  454. try {
  455. this.#responseAny = await this.#response.json();
  456. } catch {
  457. this.#responseAny = null;
  458. }
  459. break;
  460. case 'text':
  461. default:
  462. this.#responseAny = this.#responseText = await this.#response.text();
  463. break;
  464. }
  465. if (this.#status == 0) {
  466. // blank out the responses.
  467. this.#responseAny = null;
  468. this.#responseXML = null;
  469. this.#responseText = '';
  470. } else {
  471. this.#readyState = 4; //XXX
  472. this.#emitEvent("load");
  473. }
  474. this.#updateReadyState(4);
  475. this.#emitEvent("loadend");
  476. }
  477. async #trackProgress(response) {
  478. if (!response.body) return;
  479. // count the bytes to update #dataLoaded, and add text into #responseText if appropriate
  480. const isText = this.#finalResponseType == 'text';
  481. const decoder = new TextDecoder();
  482.  
  483. const reader = response.body.getReader();
  484. const handleChunk = ({ done, value }) => {
  485. if (done) return;
  486. this.#dataLoaded += value.length;
  487. if (isText) {
  488. this.#responseText += decoder.decode(value);
  489. this.#responseAny = this.#responseText;
  490. }
  491. if (this.#readyState == 2) this.#updateReadyState(3);
  492. this.#emitEvent('progress');
  493. reader.read().then(handleChunk).catch(()=>0);
  494. };
  495. reader.read().then(handleChunk).catch(()=>0);
  496. }
  497. // Response access
  498. getResponseHeader(header) {
  499. try {
  500. return this.#response?.headers.get(header) ?? null;
  501. } catch {
  502. return null;
  503. }
  504. }
  505. getAllResponseHeaders() {
  506. return [...this.#response?.headers.entries()??[]].map(([key,value]) => `${key}: ${value}\r\n`).join('');
  507. }
  508. get response() {
  509. return this.#responseAny;
  510. }
  511. get responseText() {
  512. if (this.#finalResponseType !== 'text' && this.#responseType !== '') {
  513. throw new DOMException(`Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${this.#responseType}').`, "InvalidStateError");
  514. }
  515. return this.#responseText;
  516. }
  517. get responseXML() {
  518. if (this.#finalResponseType !== 'document' && this.#responseType !== '') {
  519. throw new DOMException(`Failed to read the 'responseXML' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'document' (was '${this.#responseType}').`, "InvalidStateError");
  520. }
  521. return this.#responseXML;
  522. }
  523. get responseURL() {
  524. return this.#response?.url;
  525. }
  526. get status() {
  527. return this.#status ?? this.#response?.status ?? 0;
  528. }
  529. get statusText() {
  530. return this.#response?.statusText ?? '';
  531. }
  532.  
  533. // event dispatching resiliency
  534. async #emitEvent(type) {
  535. this.dispatchEvent(new ProgressEvent(type, {
  536. lengthComputable: this.#dataLengthComputable,
  537. loaded: this.#dataLoaded,
  538. total: this.#dataTotal
  539. }));
  540. }
  541. // I've got the perfect disguise..
  542. get [Symbol.toStringTag]() {
  543. return 'XMLHttpRequest';
  544. }
  545. static toString = ()=> 'function XMLHttpRequest() { [native code] }';
  546. }
  547.  
  548. window.XMLHttpRequestEventTarget = XMLHttpRequestEventTarget;
  549. window.XMLHttpRequestUpload = XMLHttpRequestUpload;
  550. window.XMLHttpRequest = XMLHttpRequest;
  551. window.fetch = fetch;
  552.  
  553. return middleMan;
  554.  
  555. })(globalThis.unsafeWindow ?? window);

QingJ © 2025

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