// ==UserScript== // @name ajaxHooker // @author cxxjackie // @version 1.2.4 // @supportURL https://bbs.tampermonkey.net.cn/thread-3284-1-1.html // ==/UserScript== var ajaxHooker = function() { const win = window.unsafeWindow || document.defaultView || window; const hookFns = []; const realXhr = win.XMLHttpRequest; const resProto = win.Response.prototype; const toString = Object.prototype.toString; const realFetch = win.fetch; const xhrResponses = ['response', 'responseText', 'responseXML']; const fetchResponses = ['arrayBuffer', 'blob', 'formData', 'json', 'text']; const xhrAsyncEvents = ['readystatechange', 'load', 'loadend']; let filter; function emptyFn() {} function errorFn(err) { console.error(err); } function defineProp(obj, prop, getter, setter) { Object.defineProperty(obj, prop, { configurable: true, enumerable: true, get: getter, set: setter }); } function readonly(obj, prop, value = obj[prop]) { defineProp(obj, prop, () => value, emptyFn); } function writable(obj, prop, value = obj[prop]) { Object.defineProperty(obj, prop, { configurable: true, enumerable: true, writable: true, value: value }); } function toFilterObj(obj) { return { type: obj.type, url: obj.url, method: obj.method && obj.method.toUpperCase() }; } function shouldFilter(type, url, method) { return filter && !filter.find(obj => (!obj.type || obj.type === type) && (!obj.url || (toString.call(obj.url) === '[object String]' ? url.includes(obj.url) : obj.url.test(url))) && (!obj.method || obj.method === method.toUpperCase()) ); } function lookupGetter(obj, prop) { let getter; let proto = obj; while (proto) { const descriptor = Object.getOwnPropertyDescriptor(proto, prop); getter = descriptor && descriptor.get; if (getter) break; proto = Object.getPrototypeOf(proto); } return getter ? getter.bind(obj) : emptyFn; } function waitForHookFns(request) { return Promise.all(hookFns.map(fn => Promise.resolve(fn(request)).then(emptyFn, errorFn))); } function waitForRequestKeys(request, requestClone) { return Promise.all(['url', 'method', 'abort', 'headers', 'data'].map(key => { return Promise.resolve(request[key]).then(val => request[key] = val, () => request[key] = requestClone[key]); })); } function fakeEventSIP() { this.ajaxHooker_stopped = true; } function xhrDelegateEvent(e) { const xhr = e.target; e.stopImmediatePropagation = fakeEventSIP; xhr.__ajaxHooker.hookedEvents[e.type].forEach(fn => !e.ajaxHooker_stopped && fn.call(xhr, e)); const onEvent = xhr.__ajaxHooker.hookedEvents['on' + e.type]; typeof onEvent === 'function' && onEvent.call(xhr, e); } function xhrReadyStateChange(e) { if (e.target.readyState === 4) { e.target.dispatchEvent(new CustomEvent('ajaxHooker_responseReady', {detail: e})); } else { e.target.__ajaxHooker.delegateEvent(e); } } function xhrLoadAndLoadend(e) { e.target.__ajaxHooker.delegateEvent(e); } function fakeXhrOpen(method, url, ...args) { const ah = this.__ajaxHooker; ah.url = url.toString(); ah.method = method.toUpperCase(); ah.openArgs = args; ah.headers = {}; return ah.originalMethods.open(method, url, ...args); } function fakeXhr() { const xhr = new realXhr(); let ah = xhr.__ajaxHooker; if (!ah) { ah = xhr.__ajaxHooker = { headers: {}, hookedEvents: { readystatechange: new Set(), load: new Set(), loadend: new Set() }, delegateEvent: xhrDelegateEvent, originalGetters: {}, originalMethods: {} }; xhr.addEventListener('readystatechange', xhrReadyStateChange); xhr.addEventListener('load', xhrLoadAndLoadend); xhr.addEventListener('loadend', xhrLoadAndLoadend); for (const key of xhrResponses) { ah.originalGetters[key] = lookupGetter(xhr, key); } for (const method of ['open', 'setRequestHeader', 'addEventListener', 'removeEventListener']) { ah.originalMethods[method] = xhr[method].bind(xhr); } xhr.open = fakeXhrOpen; xhr.setRequestHeader = (header, value) => { ah.originalMethods.setRequestHeader(header, value); if (xhr.readyState === 1) { if (ah.headers[header]) { ah.headers[header] += ', ' + value; } else { ah.headers[header] = value; } } } xhr.addEventListener = function(...args) { if (xhrAsyncEvents.includes(args[0])) { ah.hookedEvents[args[0]].add(args[1]); } else { ah.originalMethods.addEventListener(...args); } }; xhr.removeEventListener = function(...args) { if (xhrAsyncEvents.includes(args[0])) { ah.hookedEvents[args[0]].delete(args[1]); } else { ah.originalMethods.removeEventListener(...args); } }; xhrAsyncEvents.forEach(evt => { const onEvt = 'on' + evt; defineProp(xhr, onEvt, () => { return ah.hookedEvents[onEvt] || null; }, val => { ah.hookedEvents[onEvt] = typeof val === 'function' ? val : null; }); }); } const realSend = xhr.send.bind(xhr); xhr.send = function(data) { if (xhr.readyState !== 1) return realSend(data); ah.delegateEvent = xhrDelegateEvent; xhrResponses.forEach(prop => { delete xhr[prop]; // delete descriptor }); if (shouldFilter('xhr', ah.url, ah.method)) { xhr.addEventListener('ajaxHooker_responseReady', e => { ah.delegateEvent(e.detail); }); return realSend(data); } try { const request = { type: 'xhr', url: ah.url, method: ah.method, abort: false, headers: ah.headers, data: data, response: null }; const requestClone = {...request}; waitForHookFns(request).then(() => { waitForRequestKeys(request, requestClone).then(() => { if (request.abort) return; ah.originalMethods.open(request.method, request.url, ...ah.openArgs); for (const header in request.headers) { ah.originalMethods.setRequestHeader(header, request.headers[header]); } data = request.data; xhr.addEventListener('ajaxHooker_responseReady', e => { try { if (typeof request.response === 'function') { const arg = { finalUrl: xhr.responseURL, status: xhr.status, responseHeaders: {} }; for (const line of xhr.getAllResponseHeaders().trim().split(/[\r\n]+/)) { const parts = line.split(/:\s*/); if (parts.length === 2) { const lheader = parts[0].toLowerCase(); if (arg.responseHeaders[lheader]) { arg.responseHeaders[lheader] += ', ' + parts[1]; } else { arg.responseHeaders[lheader] = parts[1]; } } } xhrResponses.forEach(prop => { defineProp(arg, prop, () => { return arg[prop] = ah.originalGetters[prop](); }, val => { delete arg[prop]; arg[prop] = val; }); defineProp(xhr, prop, () => { const val = ah.originalGetters[prop](); xhr.dispatchEvent(new CustomEvent('ajaxHooker_readResponse', { detail: {prop, val} })); return val; }); }); xhr.addEventListener('ajaxHooker_readResponse', e => { arg[e.detail.prop] = e.detail.val; }); const resPromise = Promise.resolve(request.response(arg)).then(() => { const task = []; xhrResponses.forEach(prop => { const descriptor = Object.getOwnPropertyDescriptor(arg, prop); if (descriptor && 'value' in descriptor) { task.push(Promise.resolve(descriptor.value).then(val => { arg[prop] = val; defineProp(xhr, prop, () => { xhr.dispatchEvent(new CustomEvent('ajaxHooker_readResponse', { detail: {prop, val} })); return val; }); }, emptyFn)); } }); return Promise.all(task); }, errorFn); const eventsClone = {}; xhrAsyncEvents.forEach(type => { eventsClone[type] = new Set([...ah.hookedEvents[type]]); eventsClone['on' + type] = ah.hookedEvents['on' + type]; }); ah.delegateEvent = event => resPromise.then(() => { event.stopImmediatePropagation = fakeEventSIP; eventsClone[event.type].forEach(fn => !event.ajaxHooker_stopped && fn.call(xhr, event)); const onEvent = eventsClone['on' + event.type]; typeof onEvent === 'function' && onEvent.call(xhr, event); }); } } catch (err) { console.error(err); } ah.delegateEvent(e.detail); }); realSend(data); }); }); } catch (err) { console.error(err); realSend(data); } }; return xhr; } function hookFetchResponse(response, arg, callback) { fetchResponses.forEach(prop => { response[prop] = () => new Promise((resolve, reject) => { resProto[prop].call(response).then(res => { if (prop in arg) { resolve(arg[prop]); } else { try{ arg[prop] = res; Promise.resolve(callback(arg)).then(() => { if (prop in arg) { Promise.resolve(arg[prop]).then(val => resolve(arg[prop] = val), () => resolve(res)); } else { resolve(res); } }, errorFn); } catch (err) { console.error(err); resolve(res); } } }, reject); }); }); } function fakeFetch(url, init) { if (url && typeof url.toString === 'function') { url = url.toString(); init = init || {}; init.method = init.method || 'GET'; init.headers = init.headers || {}; if (shouldFilter('fetch', url, init.method)) return realFetch.call(win, url, init); const request = { type: 'fetch', url: url, method: init.method.toUpperCase(), abort: false, headers: {}, data: init.body, response: null }; if (toString.call(init.headers) === '[object Headers]') { for (const [key, val] of init.headers) { request.headers[key] = val; } } else { request.headers = {...init.headers}; } const requestClone = {...request}; return new Promise((resolve, reject) => { try { waitForHookFns(request).then(() => { waitForRequestKeys(request, requestClone).then(() => { if (request.abort) return reject('aborted'); url = request.url; init.method = request.method; init.headers = request.headers; init.body = request.data; realFetch.call(win, url, init).then(response => { if (typeof request.response === 'function') { const arg = { finalUrl: response.url, status: response.status, responseHeaders: {} }; for (const [key, val] of response.headers) { arg.responseHeaders[key] = val; } hookFetchResponse(response, arg, request.response); response.clone = () => { const resClone = resProto.clone.call(response); hookFetchResponse(resClone, arg, request.response); return resClone; }; } resolve(response); }, reject); }); }); } catch (err) { console.error(err); return realFetch.call(win, url, init); } }); } else { return realFetch.call(win, url, init); } } win.XMLHttpRequest = fakeXhr; Object.keys(realXhr).forEach(key => fakeXhr[key] = realXhr[key]); fakeXhr.prototype = realXhr.prototype; win.fetch = fakeFetch; return { hook: fn => hookFns.push(fn), filter: arr => { filter = Array.isArray(arr) && arr.map(toFilterObj) }, protect: () => { readonly(win, 'XMLHttpRequest', fakeXhr); readonly(win, 'fetch', fakeFetch); }, unhook: () => { writable(win, 'XMLHttpRequest', realXhr); writable(win, 'fetch', realFetch); } }; }();