AjaxHooker

public
renjiyuusei Oct 13, 2024 Never 25
Clone
JavaScript AjaxHooker 455 lines (454 loc) | 19.39 KB
1
// ==UserScript==
2
// @name AjaxHooker
3
// @author Yuusei
4
// @version 1.4.3
5
// ==/UserScript==
6
7
var ajaxHooker = function() {
8
'use strict';
9
const version = '1.4.3';
10
const hookInst = {
11
hookFns: [],
12
filters: []
13
};
14
const win = window.unsafeWindow || document.defaultView || window;
15
let winAh = win.__ajaxHooker;
16
const resProto = win.Response.prototype;
17
const xhrResponses = ['response', 'responseText', 'responseXML'];
18
const fetchResponses = ['arrayBuffer', 'blob', 'formData', 'json', 'text'];
19
const fetchInitProps = ['method', 'headers', 'body', 'mode', 'credentials', 'cache', 'redirect',
20
'referrer', 'referrerPolicy', 'integrity', 'keepalive', 'signal', 'priority'];
21
const xhrAsyncEvents = ['readystatechange', 'load', 'loadend'];
22
const getType = ({}).toString.call.bind(({}).toString);
23
const getDescriptor = Object.getOwnPropertyDescriptor.bind(Object);
24
const emptyFn = () => {};
25
const errorFn = e => console.error(e);
26
function isThenable(obj) {
27
return obj && ['object', 'function'].includes(typeof obj) && typeof obj.then === 'function';
28
}
29
function catchError(fn, ...args) {
30
try {
31
const result = fn(...args);
32
if (isThenable(result)) return result.then(null, errorFn);
33
return result;
34
} catch (err) {
35
console.error(err);
36
}
37
}
38
function defineProp(obj, prop, getter, setter) {
39
Object.defineProperty(obj, prop, {
40
configurable: true,
41
enumerable: true,
42
get: getter,
43
set: setter
44
});
45
}
46
function readonly(obj, prop, value = obj[prop]) {
47
defineProp(obj, prop, () => value, emptyFn);
48
}
49
function writable(obj, prop, value = obj[prop]) {
50
Object.defineProperty(obj, prop, {
51
configurable: true,
52
enumerable: true,
53
writable: true,
54
value: value
55
});
56
}
57
function parseHeaders(obj) {
58
const headers = {};
59
switch (getType(obj)) {
60
case '[object String]':
61
for (const line of obj.trim().split(/[\r\n]+/)) {
62
const [header, value] = line.split(/\s*:\s*/);
63
if (!header) break;
64
const lheader = header.toLowerCase();
65
headers[lheader] = lheader in headers ? `${headers[lheader]}, ${value}` : value;
66
}
67
break;
68
case '[object Headers]':
69
for (const [key, val] of obj) {
70
headers[key] = val;
71
}
72
break;
73
case '[object Object]':
74
return {...obj};
75
}
76
return headers;
77
}
78
function stopImmediatePropagation() {
79
this.ajaxHooker_isStopped = true;
80
}
81
class SyncThenable {
82
then(fn) {
83
fn && fn();
84
return new SyncThenable();
85
}
86
}
87
class AHRequest {
88
constructor(request) {
89
this.request = request;
90
this.requestClone = {...this.request};
91
}
92
shouldFilter(filters) {
93
const {type, url, method, async} = this.request;
94
return filters.length && !filters.find(obj => {
95
switch (true) {
96
case obj.type && obj.type !== type:
97
case getType(obj.url) === '[object String]' && !url.includes(obj.url):
98
case getType(obj.url) === '[object RegExp]' && !obj.url.test(url):
99
case obj.method && obj.method.toUpperCase() !== method.toUpperCase():
100
case 'async' in obj && obj.async !== async:
101
return false;
102
}
103
return true;
104
});
105
}
106
waitForRequestKeys() {
107
const requestKeys = ['url', 'method', 'abort', 'headers', 'data'];
108
if (!this.request.async) {
109
win.__ajaxHooker.hookInsts.forEach(({hookFns, filters}) => {
110
if (this.shouldFilter(filters)) return;
111
hookFns.forEach(fn => {
112
if (getType(fn) === '[object Function]') catchError(fn, this.request);
113
});
114
requestKeys.forEach(key => {
115
if (isThenable(this.request[key])) this.request[key] = this.requestClone[key];
116
});
117
});
118
return new SyncThenable();
119
}
120
const promises = [];
121
win.__ajaxHooker.hookInsts.forEach(({hookFns, filters}) => {
122
if (this.shouldFilter(filters)) return;
123
promises.push(Promise.all(hookFns.map(fn => catchError(fn, this.request))).then(() =>
124
Promise.all(requestKeys.map(key => Promise.resolve(this.request[key]).then(
125
val => this.request[key] = val,
126
() => this.request[key] = this.requestClone[key]
127
)))
128
));
129
});
130
return Promise.all(promises);
131
}
132
waitForResponseKeys(response) {
133
const responseKeys = this.request.type === 'xhr' ? xhrResponses : fetchResponses;
134
if (!this.request.async) {
135
if (getType(this.request.response) === '[object Function]') {
136
catchError(this.request.response, response);
137
responseKeys.forEach(key => {
138
if ('get' in getDescriptor(response, key) || isThenable(response[key])) {
139
delete response[key];
140
}
141
});
142
}
143
return new SyncThenable();
144
}
145
return Promise.resolve(catchError(this.request.response, response)).then(() =>
146
Promise.all(responseKeys.map(key => {
147
const descriptor = getDescriptor(response, key);
148
if (descriptor && 'value' in descriptor) {
149
return Promise.resolve(descriptor.value).then(
150
val => response[key] = val,
151
() => delete response[key]
152
);
153
} else {
154
delete response[key];
155
}
156
}))
157
);
158
}
159
}
160
const proxyHandler = {
161
get(target, prop) {
162
const descriptor = getDescriptor(target, prop);
163
if (descriptor && !descriptor.configurable && !descriptor.writable && !descriptor.get) return target[prop];
164
const ah = target.__ajaxHooker;
165
if (ah && ah.proxyProps) {
166
if (prop in ah.proxyProps) {
167
const pDescriptor = ah.proxyProps[prop];
168
if ('get' in pDescriptor) return pDescriptor.get();
169
if (typeof pDescriptor.value === 'function') return pDescriptor.value.bind(ah);
170
return pDescriptor.value;
171
}
172
if (typeof target[prop] === 'function') return target[prop].bind(target);
173
}
174
return target[prop];
175
},
176
set(target, prop, value) {
177
const descriptor = getDescriptor(target, prop);
178
if (descriptor && !descriptor.configurable && !descriptor.writable && !descriptor.set) return true;
179
const ah = target.__ajaxHooker;
180
if (ah && ah.proxyProps && prop in ah.proxyProps) {
181
const pDescriptor = ah.proxyProps[prop];
182
pDescriptor.set ? pDescriptor.set(value) : (pDescriptor.value = value);
183
} else {
184
target[prop] = value;
185
}
186
return true;
187
}
188
};
189
class XhrHooker {
190
constructor(xhr) {
191
const ah = this;
192
Object.assign(ah, {
193
originalXhr: xhr,
194
proxyXhr: new Proxy(xhr, proxyHandler),
195
resThenable: new SyncThenable(),
196
proxyProps: {},
197
proxyEvents: {}
198
});
199
xhr.addEventListener('readystatechange', e => {
200
if (ah.proxyXhr.readyState === 4 && ah.request && typeof ah.request.response === 'function') {
201
const response = {
202
finalUrl: ah.proxyXhr.responseURL,
203
status: ah.proxyXhr.status,
204
responseHeaders: parseHeaders(ah.proxyXhr.getAllResponseHeaders())
205
};
206
const tempValues = {};
207
for (const key of xhrResponses) {
208
try {
209
tempValues[key] = ah.originalXhr[key];
210
} catch (err) {}
211
defineProp(response, key, () => {
212
return response[key] = tempValues[key];
213
}, val => {
214
delete response[key];
215
response[key] = val;
216
});
217
}
218
ah.resThenable = new AHRequest(ah.request).waitForResponseKeys(response).then(() => {
219
for (const key of xhrResponses) {
220
ah.proxyProps[key] = {get: () => {
221
if (!(key in response)) response[key] = tempValues[key];
222
return response[key];
223
}};
224
}
225
});
226
}
227
ah.dispatchEvent(e);
228
});
229
xhr.addEventListener('load', e => ah.dispatchEvent(e));
230
xhr.addEventListener('loadend', e => ah.dispatchEvent(e));
231
for (const evt of xhrAsyncEvents) {
232
const onEvt = 'on' + evt;
233
ah.proxyProps[onEvt] = {
234
get: () => ah.proxyEvents[onEvt] || null,
235
set: val => ah.addEvent(onEvt, val)
236
};
237
}
238
for (const method of ['setRequestHeader', 'addEventListener', 'removeEventListener', 'open', 'send']) {
239
ah.proxyProps[method] = {value: ah[method]};
240
}
241
}
242
toJSON() {} // Converting circular structure to JSON
243
addEvent(type, event) {
244
if (type.startsWith('on')) {
245
this.proxyEvents[type] = typeof event === 'function' ? event : null;
246
} else {
247
if (typeof event === 'object' && event !== null) event = event.handleEvent;
248
if (typeof event !== 'function') return;
249
this.proxyEvents[type] = this.proxyEvents[type] || new Set();
250
this.proxyEvents[type].add(event);
251
}
252
}
253
removeEvent(type, event) {
254
if (type.startsWith('on')) {
255
this.proxyEvents[type] = null;
256
} else {
257
if (typeof event === 'object' && event !== null) event = event.handleEvent;
258
this.proxyEvents[type] && this.proxyEvents[type].delete(event);
259
}
260
}
261
dispatchEvent(e) {
262
e.stopImmediatePropagation = stopImmediatePropagation;
263
defineProp(e, 'target', () => this.proxyXhr);
264
defineProp(e, 'currentTarget', () => this.proxyXhr);
265
this.proxyEvents[e.type] && this.proxyEvents[e.type].forEach(fn => {
266
this.resThenable.then(() => !e.ajaxHooker_isStopped && fn.call(this.proxyXhr, e));
267
});
268
if (e.ajaxHooker_isStopped) return;
269
const onEvent = this.proxyEvents['on' + e.type];
270
onEvent && this.resThenable.then(onEvent.bind(this.proxyXhr, e));
271
}
272
setRequestHeader(header, value) {
273
this.originalXhr.setRequestHeader(header, value);
274
if (!this.request) return;
275
const headers = this.request.headers;
276
headers[header] = header in headers ? `${headers[header]}, ${value}` : value;
277
}
278
addEventListener(...args) {
279
if (xhrAsyncEvents.includes(args[0])) {
280
this.addEvent(args[0], args[1]);
281
} else {
282
this.originalXhr.addEventListener(...args);
283
}
284
}
285
removeEventListener(...args) {
286
if (xhrAsyncEvents.includes(args[0])) {
287
this.removeEvent(args[0], args[1]);
288
} else {
289
this.originalXhr.removeEventListener(...args);
290
}
291
}
292
open(method, url, async = true, ...args) {
293
this.request = {
294
type: 'xhr',
295
url: url.toString(),
296
method: method.toUpperCase(),
297
abort: false,
298
headers: {},
299
data: null,
300
response: null,
301
async: !!async
302
};
303
this.openArgs = args;
304
this.resThenable = new SyncThenable();
305
['responseURL', 'readyState', 'status', 'statusText', ...xhrResponses].forEach(key => {
306
delete this.proxyProps[key];
307
});
308
return this.originalXhr.open(method, url, async, ...args);
309
}
310
send(data) {
311
const ah = this;
312
const xhr = ah.originalXhr;
313
const request = ah.request;
314
if (!request) return xhr.send(data);
315
request.data = data;
316
new AHRequest(request).waitForRequestKeys().then(() => {
317
if (request.abort) {
318
if (typeof request.response === 'function') {
319
Object.assign(ah.proxyProps, {
320
responseURL: {value: request.url},
321
readyState: {value: 4},
322
status: {value: 200},
323
statusText: {value: 'OK'}
324
});
325
xhrAsyncEvents.forEach(evt => xhr.dispatchEvent(new Event(evt)));
326
}
327
} else {
328
xhr.open(request.method, request.url, request.async, ...ah.openArgs);
329
for (const header in request.headers) {
330
xhr.setRequestHeader(header, request.headers[header]);
331
}
332
xhr.send(request.data);
333
}
334
});
335
}
336
}
337
function fakeXHR() {
338
const xhr = new winAh.realXHR();
339
if ('__ajaxHooker' in xhr) console.warn('检测到不同版本的ajaxHooker,可能发生冲突!');
340
xhr.__ajaxHooker = new XhrHooker(xhr);
341
return xhr.__ajaxHooker.proxyXhr;
342
}
343
fakeXHR.prototype = win.XMLHttpRequest.prototype;
344
Object.keys(win.XMLHttpRequest).forEach(key => fakeXHR[key] = win.XMLHttpRequest[key]);
345
function fakeFetch(url, options = {}) {
346
if (!url) return winAh.realFetch.call(win, url, options);
347
return new Promise(async (resolve, reject) => {
348
const init = {};
349
if (getType(url) === '[object Request]') {
350
for (const prop of fetchInitProps) init[prop] = url[prop];
351
if (url.body) init.body = await url.arrayBuffer();
352
url = url.url;
353
}
354
url = url.toString();
355
Object.assign(init, options);
356
init.method = init.method || 'GET';
357
init.headers = init.headers || {};
358
const request = {
359
type: 'fetch',
360
url: url,
361
method: init.method.toUpperCase(),
362
abort: false,
363
headers: parseHeaders(init.headers),
364
data: init.body,
365
response: null,
366
async: true
367
};
368
const req = new AHRequest(request);
369
await req.waitForRequestKeys();
370
if (request.abort) {
371
if (typeof request.response === 'function') {
372
const response = {
373
finalUrl: request.url,
374
status: 200,
375
responseHeaders: {}
376
};
377
await req.waitForResponseKeys(response);
378
const key = fetchResponses.find(k => k in response);
379
let val = response[key];
380
if (key === 'json' && typeof val === 'object') {
381
val = catchError(JSON.stringify.bind(JSON), val);
382
}
383
const res = new Response(val, {
384
status: 200,
385
statusText: 'OK'
386
});
387
defineProp(res, 'type', () => 'basic');
388
defineProp(res, 'url', () => request.url);
389
resolve(res);
390
} else {
391
reject(new DOMException('aborted', 'AbortError'));
392
}
393
return;
394
}
395
init.method = request.method;
396
init.headers = request.headers;
397
init.body = request.data;
398
winAh.realFetch.call(win, request.url, init).then(res => {
399
if (typeof request.response === 'function') {
400
const response = {
401
finalUrl: res.url,
402
status: res.status,
403
responseHeaders: parseHeaders(res.headers)
404
};
405
fetchResponses.forEach(key => res[key] = function() {
406
if (key in response) return Promise.resolve(response[key]);
407
return resProto[key].call(this).then(val => {
408
response[key] = val;
409
return req.waitForResponseKeys(response).then(() => key in response ? response[key] : val);
410
});
411
});
412
}
413
resolve(res);
414
}, reject);
415
});
416
}
417
function fakeFetchClone() {
418
const descriptors = Object.getOwnPropertyDescriptors(this);
419
const res = winAh.realFetchClone.call(this);
420
Object.defineProperties(res, descriptors);
421
return res;
422
}
423
winAh = win.__ajaxHooker = winAh || {
424
version, fakeXHR, fakeFetch, fakeFetchClone,
425
realXHR: win.XMLHttpRequest,
426
realFetch: win.fetch,
427
realFetchClone: resProto.clone,
428
hookInsts: new Set()
429
};
430
if (winAh.version !== version) console.warn('A different version of AjaxHooker was detected, which may cause conflicts!');
431
win.XMLHttpRequest = winAh.fakeXHR;
432
win.fetch = winAh.fakeFetch;
433
resProto.clone = winAh.fakeFetchClone;
434
winAh.hookInsts.add(hookInst);
435
return {
436
hook: fn => hookInst.hookFns.push(fn),
437
filter: arr => {
438
if (Array.isArray(arr)) hookInst.filters = arr;
439
},
440
protect: () => {
441
readonly(win, 'XMLHttpRequest', winAh.fakeXHR);
442
readonly(win, 'fetch', winAh.fakeFetch);
443
readonly(resProto, 'clone', winAh.fakeFetchClone);
444
},
445
unhook: () => {
446
winAh.hookInsts.delete(hookInst);
447
if (!winAh.hookInsts.size) {
448
writable(win, 'XMLHttpRequest', winAh.realXHR);
449
writable(win, 'fetch', winAh.realFetch);
450
writable(resProto, 'clone', winAh.realFetchClone);
451
delete win.__ajaxHooker;
452
}
453
}
454
};
455
}();