// ==UserScript== // @name ajaxHooker // @author cxxjackie // @version 1.4.0 // @supportURL https://bbs.tampermonkey.net.cn/thread-3284-1-1.html // ==/UserScript== var ajaxHooker = function() { 'use strict'; const version = '1.4.0'; const hookInst = { hookFns: [], filters: [] }; const win = window.unsafeWindow || document.defaultView || window; let winAh = win.__ajaxHooker; const resProto = win.Response.prototype; const xhrResponses = ['response', 'responseText', 'responseXML']; const fetchResponses = ['arrayBuffer', 'blob', 'formData', 'json', 'text']; const fetchInitProps = ['method', 'headers', 'body', 'mode', 'credentials', 'cache', 'redirect', 'referrer', 'referrerPolicy', 'integrity', 'keepalive', 'signal', 'priority']; const xhrAsyncEvents = ['readystatechange', 'load', 'loadend']; const getType = ({}).toString.call.bind(({}).toString); const getDescriptor = Object.getOwnPropertyDescriptor.bind(Object); const emptyFn = () => {}; const errorFn = e => console.error(e); function isThenable(obj) { return obj && ['object', 'function'].includes(typeof obj) && typeof obj.then === 'function'; } function catchError(fn, ...args) { try { const result = fn(...args); if (isThenable(result)) return result.then(null, errorFn); return result; } catch (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 parseHeaders(obj) { const headers = {}; switch (getType(obj)) { case '[object String]': for (const line of obj.trim().split(/[\r\n]+/)) { const [header, value] = line.split(/\s*:\s*/); if (!header) break; const lheader = header.toLowerCase(); headers[lheader] = lheader in headers ? `${headers[lheader]}, ${value}` : value; } break; case '[object Headers]': for (const [key, val] of obj) { headers[key] = val; } break; case '[object Object]': return {...obj}; } return headers; } function stopImmediatePropagation() { this.ajaxHooker_isStopped = true; } class SyncThenable { then(fn) { fn && fn(); return new SyncThenable(); } } class AHRequest { constructor(request) { this.request = request; this.requestClone = {...this.request}; } shouldFilter(filters) { const {type, url, method, async} = this.request; return filters.length && !filters.find(obj => { switch (true) { case obj.type && obj.type !== type: case getType(obj.url) === '[object String]' && !url.includes(obj.url): case getType(obj.url) === '[object RegExp]' && !obj.url.test(url): case obj.method && obj.method.toUpperCase() !== method.toUpperCase(): case 'async' in obj && obj.async !== async: return false; } return true; }); } waitForRequestKeys() { const requestKeys = ['url', 'method', 'abort', 'headers', 'data']; if (!this.request.async) { win.__ajaxHooker.hookInsts.forEach(({hookFns, filters}) => { if (this.shouldFilter(filters)) return; hookFns.forEach(fn => { if (getType(fn) === '[object Function]') catchError(fn, this.request); }); requestKeys.forEach(key => { if (isThenable(this.request[key])) this.request[key] = this.requestClone[key]; }); }); return new SyncThenable(); } const promises = []; win.__ajaxHooker.hookInsts.forEach(({hookFns, filters}) => { if (this.shouldFilter(filters)) return; promises.push(Promise.all(hookFns.map(fn => catchError(fn, this.request))).then(() => Promise.all(requestKeys.map(key => Promise.resolve(this.request[key]).then( val => this.request[key] = val, () => this.request[key] = this.requestClone[key] ))) )); }); return Promise.all(promises); } waitForResponseKeys(response) { const responseKeys = this.request.type === 'xhr' ? xhrResponses : fetchResponses; if (!this.request.async) { if (getType(this.request.response) === '[object Function]') { catchError(this.request.response, response); responseKeys.forEach(key => { if ('get' in getDescriptor(response, key) || isThenable(response[key])) { delete response[key]; } }); } return new SyncThenable(); } return Promise.resolve(catchError(this.request.response, response)).then(() => Promise.all(responseKeys.map(key => { const descriptor = getDescriptor(response, key); if (descriptor && 'value' in descriptor) { return Promise.resolve(descriptor.value).then( val => response[key] = val, () => delete response[key] ); } else { delete response[key]; } })) ); } } const proxyHandler = { get(target, prop) { const descriptor = getDescriptor(target, prop); if (descriptor && !descriptor.configurable && !descriptor.writable && !descriptor.get) return target[prop]; const ah = target.__ajaxHooker; if (ah && ah.proxyProps) { if (prop in ah.proxyProps) { const pDescriptor = ah.proxyProps[prop]; if ('get' in pDescriptor) return pDescriptor.get(); if (typeof pDescriptor.value === 'function') return pDescriptor.value.bind(ah); return pDescriptor.value; } if (typeof target[prop] === 'function') return target[prop].bind(target); } return target[prop]; }, set(target, prop, value) { const descriptor = getDescriptor(target, prop); if (descriptor && !descriptor.configurable && !descriptor.writable && !descriptor.set) return true; const ah = target.__ajaxHooker; if (ah && ah.proxyProps && prop in ah.proxyProps) { const pDescriptor = ah.proxyProps[prop]; pDescriptor.set ? pDescriptor.set(value) : (pDescriptor.value = value); } else { target[prop] = value; } return true; } }; class XhrHooker { constructor(xhr) { const ah = this; Object.assign(ah, { originalXhr: xhr, proxyXhr: new Proxy(xhr, proxyHandler), resThenable: new SyncThenable(), proxyProps: {}, proxyEvents: {} }); xhr.addEventListener('readystatechange', e => { if (ah.proxyXhr.readyState === 4 && ah.request && typeof ah.request.response === 'function') { const response = { finalUrl: ah.proxyXhr.responseURL, status: ah.proxyXhr.status, responseHeaders: parseHeaders(ah.proxyXhr.getAllResponseHeaders()) }; const tempValues = {}; for (const key of xhrResponses) { try { tempValues[key] = ah.originalXhr[key]; } catch (err) {} defineProp(response, key, () => { return response[key] = tempValues[key]; }, val => { delete response[key]; response[key] = val; }); } ah.resThenable = new AHRequest(ah.request).waitForResponseKeys(response).then(() => { for (const key of xhrResponses) { ah.proxyProps[key] = {get: () => { if (!(key in response)) response[key] = tempValues[key]; return response[key]; }}; } }); } ah.dispatchEvent(e); }); xhr.addEventListener('load', e => ah.dispatchEvent(e)); xhr.addEventListener('loadend', e => ah.dispatchEvent(e)); for (const evt of xhrAsyncEvents) { const onEvt = 'on' + evt; ah.proxyProps[onEvt] = { get: () => ah.proxyEvents[onEvt] || null, set: val => ah.addEvent(onEvt, val) }; } for (const method of ['setRequestHeader', 'addEventListener', 'removeEventListener', 'open', 'send']) { ah.proxyProps[method] = {value: ah[method]}; } } toJSON() {} // Converting circular structure to JSON addEvent(type, event) { if (type.startsWith('on')) { this.proxyEvents[type] = typeof event === 'function' ? event : null; } else { if (typeof event === 'object' && event !== null) event = event.handleEvent; if (typeof event !== 'function') return; this.proxyEvents[type] = this.proxyEvents[type] || new Set(); this.proxyEvents[type].add(event); } } removeEvent(type, event) { if (type.startsWith('on')) { this.proxyEvents[type] = null; } else { if (typeof event === 'object' && event !== null) event = event.handleEvent; this.proxyEvents[type] && this.proxyEvents[type].delete(event); } } dispatchEvent(e) { e.stopImmediatePropagation = stopImmediatePropagation; defineProp(e, 'target', () => this.proxyXhr); this.proxyEvents[e.type] && this.proxyEvents[e.type].forEach(fn => { this.resThenable.then(() => !e.ajaxHooker_isStopped && fn.call(this.proxyXhr, e)); }); if (e.ajaxHooker_isStopped) return; const onEvent = this.proxyEvents['on' + e.type]; onEvent && this.resThenable.then(onEvent.bind(this.proxyXhr, e)); } setRequestHeader(header, value) { this.originalXhr.setRequestHeader(header, value); if (this.originalXhr.readyState !== 1) return; const headers = this.request.headers; headers[header] = header in headers ? `${headers[header]}, ${value}` : value; } addEventListener(...args) { if (xhrAsyncEvents.includes(args[0])) { this.addEvent(args[0], args[1]); } else { this.originalXhr.addEventListener(...args); } } removeEventListener(...args) { if (xhrAsyncEvents.includes(args[0])) { this.removeEvent(args[0], args[1]); } else { this.originalXhr.removeEventListener(...args); } } open(method, url, async = true, ...args) { this.request = { type: 'xhr', url: url.toString(), method: method.toUpperCase(), abort: false, headers: {}, data: null, response: null, async: !!async }; this.openArgs = args; this.resThenable = new SyncThenable(); ['responseURL', 'readyState', 'status', 'statusText', ...xhrResponses].forEach(key => { delete this.proxyProps[key]; }); return this.originalXhr.open(method, url, async, ...args); } send(data) { const ah = this; const xhr = ah.originalXhr; const request = ah.request; if (xhr.readyState !== 1) return xhr.send(data); request.data = data; new AHRequest(request).waitForRequestKeys().then(() => { if (request.abort) { if (typeof request.response === 'function') { Object.assign(ah.proxyProps, { responseURL: {value: request.url}, readyState: {value: 4}, status: {value: 200}, statusText: {value: 'OK'} }); xhrAsyncEvents.forEach(evt => xhr.dispatchEvent(new Event(evt))); } } else { xhr.open(request.method, request.url, request.async, ...ah.openArgs); for (const header in request.headers) { xhr.setRequestHeader(header, request.headers[header]); } xhr.send(request.data); } }); } } function fakeXHR() { const xhr = new winAh.realXHR(); if ('__ajaxHooker' in xhr) console.warn('检测到不同版本的ajaxHooker,可能发生冲突!'); xhr.__ajaxHooker = new XhrHooker(xhr); return xhr.__ajaxHooker.proxyXhr; } fakeXHR.prototype = win.XMLHttpRequest.prototype; Object.keys(win.XMLHttpRequest).forEach(key => fakeXHR[key] = win.XMLHttpRequest[key]); function fakeFetch(url, options = {}) { if (!url) return winAh.realFetch.call(win, url, options); const init = {}; if (getType(url) === '[object Request]') { for (const prop of fetchInitProps) init[prop] = url[prop]; url = url.url; } url = url.toString(); Object.assign(init, options); init.method = init.method || 'GET'; init.headers = init.headers || {}; const request = { type: 'fetch', url: url, method: init.method.toUpperCase(), abort: false, headers: parseHeaders(init.headers), data: init.body, response: null, async: true }; const req = new AHRequest(request); return new Promise((resolve, reject) => { req.waitForRequestKeys().then(() => { if (request.abort) { if (typeof request.response === 'function') { const response = { finalUrl: request.url, status: 200, responseHeaders: {} }; req.waitForResponseKeys(response).then(() => { const key = fetchResponses.find(k => k in response); let val = response[key]; if (key === 'json' && typeof val === 'object') { val = catchError(JSON.stringify.bind(JSON), val); } const res = new Response(val, { status: 200, statusText: 'OK' }); defineProp(res, 'type', () => 'basic'); defineProp(res, 'url', () => request.url); resolve(res); }); } else { reject(new DOMException('aborted', 'AbortError')); } return; } init.method = request.method; init.headers = request.headers; init.body = request.data; winAh.realFetch.call(win, request.url, init).then(res => { if (typeof request.response === 'function') { const response = { finalUrl: res.url, status: res.status, responseHeaders: parseHeaders(res.headers) }; fetchResponses.forEach(key => res[key] = function() { if (key in response) return Promise.resolve(response[key]); return resProto[key].call(this).then(val => { response[key] = val; return req.waitForResponseKeys(response).then(() => key in response ? response[key] : val); }); }); } resolve(res); }, reject); }).catch(err => { console.error(err); resolve(winAh.realFetch.call(win, url, init)); }); }); } function fakeFetchClone() { const descriptors = Object.getOwnPropertyDescriptors(this); const res = winAh.realFetchClone.call(this); Object.defineProperties(res, descriptors); return res; } winAh = win.__ajaxHooker = winAh || { version, fakeXHR, fakeFetch, fakeFetchClone, realXHR: win.XMLHttpRequest, realFetch: win.fetch, realFetchClone: resProto.clone, hookInsts: new Set() }; win.XMLHttpRequest = winAh.fakeXHR; win.fetch = winAh.fakeFetch; resProto.clone = winAh.fakeFetchClone; winAh.hookInsts.add(hookInst); return { hook: fn => hookInst.hookFns.push(fn), filter: arr => { if (Array.isArray(arr)) hookInst.filters = arr; }, protect: () => { readonly(win, 'XMLHttpRequest', winAh.fakeXHR); readonly(win, 'fetch', winAh.fakeFetch); readonly(resProto, 'clone', winAh.fakeFetchClone); }, unhook: () => { winAh.hookInsts.delete(hookInst); if (!winAh.hookInsts.size) { writable(win, 'XMLHttpRequest', winAh.realXHR); writable(win, 'fetch', winAh.realFetch); writable(resProto, 'clone', winAh.realFetchClone); delete win.__ajaxHooker; } } }; }();