Itsnotlupus' MiddleMan

inspect/intercept/modify any network requests

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

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

  1. // ==UserScript==
  2. // @name Itsnotlupus' MiddleMan
  3. // @namespace Itsnotlupus Industries
  4. // @version 1.1
  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. if (obj instanceof type) obj = await hook(obj.clone()) ?? obj;
  67. }
  68. return obj;
  69. }
  70. }
  71.  
  72. // The only instance we'll need
  73. const middleMan = new MiddleMan;
  74.  
  75. // A wrapper for fetch() that plugs into middleMan.
  76. const _fetch = window.fetch;
  77. async function fetch(resource, options) {
  78. const request = new Request(resource, options);
  79. const result = await middleMan.process(request);
  80. const response = result instanceof Request ? await _fetch(result) : result;
  81. return middleMan.process(response);
  82. }
  83.  
  84. /**
  85. * Polyfill a subset of EventTarget, for the sole purpose of being used in the XHR polyfill below.
  86. * Primarily exists to allow Safari to extend it without tripping on itself.
  87. * Various liberties were taken.
  88. */
  89. class EventTarget {
  90. #listeners = {};
  91. #events = {};
  92. #setEvent(name, f) {
  93. if (this.#events[name]) this.removeEventListener(name, this.#events[name]);
  94. this.#events[name] = typeof f == 'function' ? f : null;
  95. if (this.#events[name]) this.addEventListener(name, this.#events[name]);
  96. }
  97. #getEvent(name) {
  98. return this.#events[name];
  99. }
  100. constructor(events = []) {
  101. events.forEach(name => {
  102. Object.defineProperty(this, "on"+name, {
  103. get() { return this.#getEvent(name); },
  104. set(f) { this.#setEvent(name, f); }
  105. });
  106. });
  107. }
  108. addEventListener(type, listener, options = {}) {
  109. if (options === true) { options = { capture: true }; }
  110. this.#listeners[type]??=[];
  111. this.#listeners[type].push({ listener, options });
  112. options.signal?.addEventListener?.('abort', () => this.removeEventListener(type, listener, options));
  113. }
  114. removeEventListener(type, listener, options = {}) {
  115. if (options === true) { options = { capture: true }; }
  116. if (!this.#listeners[type]) return;
  117. const index = this.#listeners[type].findIndex(slot => slot.listener === listener && slot.options.capture === options.capture);
  118. if (index > -1) {
  119. this.#listeners[type].splice(index,1);
  120. }
  121. }
  122. dispatchEvent(event) {
  123. // no capturing, no bubbling, no preventDefault, no stopPropagation, and a general disdain for most of the intended featureset.
  124. const listeners = this.#listeners[event.type];
  125. if (!listeners) return;
  126. // since I can't set event.target, or generally do anything useful with an Event instance, let's Proxy it.
  127. let immediateStop = false;
  128. const eventProxy = new Proxy(event, {
  129. get: (target, prop) => {
  130. switch (prop) {
  131. case "target":
  132. case "currentTarget":
  133. return this;
  134. case "isTrusted":
  135. return true; // you betcha
  136. case "stopImmediatePropagation":
  137. return () => immediateStop = true;
  138. default:
  139. return Reflect.get(target, prop);
  140. }
  141. }
  142. });
  143. listeners.forEach(async ({listener, options}) => {
  144. if (immediateStop) return;
  145. if (options.once) this.removeEventListener(eventProxy.type, listener, options);
  146. try {
  147. listener.call(this, eventProxy);
  148. } catch (e) {
  149. // I think it's impossible to match EventTarget::dispatchEvent throwing behavior in pure JS. oh well. fudge the timing and keep on trucking.
  150. await 0;
  151. throw e;
  152. }
  153. });
  154. return true;
  155. }
  156. }
  157.  
  158. /**
  159. * An XMLHttpRequest polyfill written on top of fetch().
  160. * Nothing special here, but this allows MiddleMan to work on XHR too.
  161. *
  162. * A few gotchas:
  163. * - This is not spec-compliant. In many ways. https://xhr.spec.whatwg.org/
  164. * - xhr.upload is not implemented. we'll throw an exception if someone tries to use it.
  165. * - synchronous xhr is not implemented. all my homies hate sync xhr anyway.
  166. * - no test coverage. But I tried it on 2 sites and it didn't explode, so.. pretty good.
  167. */
  168. class XMLHttpRequest extends EventTarget {
  169. #readyState;
  170.  
  171. #requestOptions = {};
  172. #requestURL;
  173. #abortController;
  174. #timeout;
  175. #responseType = '';
  176. #mimeTypeOverride = null;
  177.  
  178. #response;
  179. #responseText;
  180. #responseXML;
  181. #responseAny;
  182.  
  183. #dataLengthComputable = false;
  184. #dataLoaded = 0;
  185. #dataTotal = 0;
  186.  
  187. #errorEvent;
  188.  
  189. UNSENT = 0;
  190. OPENED = 1;
  191. HEADERS_RECEIVED = 2;
  192. LOADING = 3;
  193. DONE = 4;
  194. static UNSENT = 0;
  195. static OPENED = 1;
  196. static HEADERS_RECEIVED = 2;
  197. static LOADING = 3;
  198. static DONE = 4;
  199.  
  200. constructor() {
  201. super(['abort','error','load','loadend','loadstart','progress','readystatechange','timeout']);
  202. this.#readyState = 0;
  203. }
  204.  
  205. get readyState() {
  206. return this.#readyState;
  207. }
  208. #assertReadyState(...validValues) {
  209. if (!validValues.includes(this.#readyState)) {
  210. throw new Error("Failed to take action on XMLHttpRequest: Invalid state.");
  211. }
  212. }
  213. #updateReadyState(value) {
  214. this.#readyState = value;
  215. this.#emitEvent("readystatechange");
  216. }
  217.  
  218. // Request setup
  219. open(method, url, async, user, password) {
  220. this.#assertReadyState(0,1);
  221. this.#requestOptions.method = method.toString().toUpperCase();
  222. this.#requestOptions.headers = new Headers()
  223. this.#requestURL = url;
  224. this.#abortController = null;
  225. this.#timeout = 0;
  226. this.#mimeTypeOverride = null;
  227. this.#response = null;
  228. this.#responseText = '';
  229. this.#responseAny = null;
  230. this.#responseXML = null;
  231. this.#dataLengthComputable = false;
  232. this.#dataLoaded = 0;
  233. this.#dataTotal = 0;
  234.  
  235. if (async === false) {
  236. throw new Error("Synchronous XHR is not supported.");
  237. }
  238. if (user || password) {
  239. this.#requestOptions.headers.set('Authorization', 'Basic '+btoa(`${user??''}:${password??''}`));
  240. }
  241. this.#updateReadyState(1);
  242. }
  243. setRequestHeader(header, value) {
  244. this.#assertReadyState(1);
  245. this.#requestOptions.headers.set(header, value);
  246. }
  247. overrideMimeType(mimeType) {
  248. this.#mimeTypeOverride = mimeType;
  249. }
  250. set responseType(type) {
  251. if (!["","arraybuffer","blob","document","json","text"].includes(type)) {
  252. console.warn(`The provided value '${type}' is not a valid enum value of type XMLHttpRequestResponseType.`);
  253. return;
  254. }
  255. this.#responseType = type;
  256. }
  257. get responseType() {
  258. return this.#responseType;
  259. }
  260. set timeout(value) {
  261. const ms = isNaN(Number(value)) ? 0 : Math.floor(Number(value));
  262. this.#timeout = value;
  263. }
  264. get timeout() {
  265. return this.#timeout;
  266. }
  267. get upload() {
  268. throw new Error("XMLHttpRequestUpload is not implemented.");
  269. }
  270. set withCredentials(flag) {
  271. this.#requestOptions.credentials = flag ? "include" : "omit";
  272. }
  273. get withCredentials() {
  274. return this.#requestOptions.credentials == "include";
  275. }
  276. async send(body = null) {
  277. this.#assertReadyState(1);
  278. if (this.#requestOptions.method != 'GET' && this.#requestOptions.method != 'HEAD') {
  279. this.#requestOptions.body = body instanceof Document ? body.documentElement.outerHTML : body;
  280. }
  281. const request = new Request(this.#requestURL, this.#requestOptions);
  282. this.#abortController = new AbortController();
  283. const signal = this.#abortController.signal;
  284. if (this.#timeout) {
  285. setTimeout(()=> this.#timedOut(), this.#timeout);
  286. }
  287. this.#emitEvent("loadstart");
  288. let response;
  289. try {
  290. response = await fetch(request, { signal });
  291. this.#updateReadyState(2);
  292. const isNotCompressed = response.type == 'basic' && !response.headers.get('content-encoding');
  293. if (isNotCompressed) {
  294. this.#dataTotal = response.headers.get('content-length') ?? 0;
  295. this.#dataLengthComputable = this.#dataTotal !== 0;
  296. }
  297. await this.#processResponse(response);
  298. } catch (e) {
  299. return this.#error();
  300. }
  301. }
  302. abort() {
  303. this.#abortController?.abort();
  304. this.#errorEvent = "abort";
  305. }
  306. #timedOut() {
  307. this.#abortController?.abort();
  308. this.#errorEvent = "timeout";
  309. }
  310. #error() {
  311. // abort and timeout end up here.
  312. this.#response = new Response('');
  313. this.#responseText = ''
  314. this.#responseAny = null;
  315. this.#dataLoaded = 0;
  316. this.#updateReadyState(4);
  317. this.#emitEvent(this.#errorEvent ?? "error");
  318. this.#emitEvent("loadend");
  319. this.#errorEvent = null;
  320. }
  321. async #processResponse(response) {
  322. this.#response = response;
  323. this.#trackProgress(response.clone());
  324. switch (this.#responseType) {
  325. case 'arraybuffer':
  326. try {
  327. this.#responseAny = await response.arrayBuffer();
  328. } catch {
  329. this.#responseAny = null;
  330. }
  331. break;
  332. case 'blob':
  333. try {
  334. this.#responseAny = await response.blob();
  335. } catch {
  336. this.#responseAny = null;
  337. }
  338. break;
  339. case 'document': {
  340. this.#responseText = await response.text();
  341. const mimeType = this.#mimeTypeOverride ?? this.#response.headers.get('content-type')?.split(';')[0].trim() ?? 'text/xml';
  342. try {
  343. const parser = new DOMParser();
  344. const doc = parser.parseFromString(this.#responseText, mimeType);
  345. this.#responseAny = this.#responseXML = doc;
  346. } catch {
  347. this.#responseAny = null;
  348. }
  349. break;
  350. }
  351. case 'json':
  352. try {
  353. this.#responseAny = await response.json();
  354. } catch {
  355. this.#responseAny = null;
  356. }
  357. break;
  358. case '':
  359. case 'text':
  360. default:
  361. this.#responseAny = this.#responseText = await response.text();
  362. break;
  363. }
  364. this.#updateReadyState(4);
  365. this.#emitEvent("load");
  366. this.#emitEvent("loadend");
  367. }
  368. async #trackProgress(response) {
  369. // count the bytes to update #dataLoaded, and add text into #responseText if appropriate
  370. const isText = this.#responseType == 'text' || (this.#responseType == '' && !response.headers.get('content-type').startsWith('text/xml'));
  371. const decoder = new TextDecoder();
  372.  
  373. const reader = response.body.getReader();
  374. const handleChunk = ({ done, value }) => {
  375. if (done) return;
  376. this.#dataLoaded += value.length;
  377. if (isText) {
  378. this.#responseText += decoder.decode(value);
  379. this.#responseAny = this.#responseText;
  380. }
  381. this.#emitEvent('progress');
  382. reader.read().then(handleChunk).catch(()=>0);
  383. };
  384. reader.read().then(handleChunk).catch(()=>0);
  385. }
  386. // Response access
  387. getResponseHeader(header) {
  388. return this.#response?.headers.get(header) ?? null;
  389. }
  390. getAllResponseHeaders() {
  391. return [...this.#response?.headers.entries()??[]].map(([key,value]) => `${key}: ${value}\r\n`).join('');
  392. }
  393. get response() {
  394. return this.#responseAny;
  395. }
  396. get responseText() {
  397. if (this.#responseType != 'text' && this.#responseType != '') {
  398. throw new Error(`Failed to read the 'responseText' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'text' (was '${this.#responseType}').`);
  399. }
  400. return this.#responseText;
  401. }
  402. get responseXML() {
  403. if (this.#responseType != 'document' && this.#responseType != '') {
  404. throw new Error(`Failed to read the 'responseXML' property from 'XMLHttpRequest': The value is only accessible if the object's 'responseType' is '' or 'document' (was '${this.#responseType}').`);
  405. }
  406. return this.#responseXML;
  407. }
  408. get responseURL() {
  409. return this.#response?.url;
  410. }
  411. get status() {
  412. return this.#response?.status ?? 0;
  413. }
  414. get statusText() {
  415. return this.#response?.statusText ?? '';
  416. }
  417.  
  418. // event dispatching resiliency
  419. async #emitEvent(name) {
  420. this.dispatchEvent(new ProgressEvent(name, {
  421. lengthComputable: this.#dataLengthComputable,
  422. loaded: this.#dataLoaded,
  423. total: this.#dataTotal
  424. }));
  425. }
  426. // I've got the perfect disguise..
  427. get [Symbol.toStringTag]() {
  428. return 'XMLHttpRequest';
  429. }
  430. static toString = ()=> 'function XMLHttpRequest() { [native code] }';
  431. }
  432.  
  433. window.XMLHttpRequest = XMLHttpRequest;
  434. window.fetch = fetch;
  435.  
  436. return middleMan;
  437.  
  438. })(globalThis.unsafeWindow ?? window);

QingJ © 2025

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