Improved-Everything-Hook

A robust and modular script to hook JavaScript methods and AJAX requests

// ==UserScript==
// @name         Improved-Everything-Hook
// @namespace    https://example.com/improved-everything-hook/
// @version      1.0.0
// @description  A robust and modular script to hook JavaScript methods and AJAX requests
// @author       Enhanced by xAI
// @match        http://*/*
// @match        https://*/*
// @grant        none
// @run-at       document-start
// ==/UserScript==

(function () {
    'use strict';

    /**
     * Utility module for common operations
     * @module Utils
     */
    const Utils = {
        /**
         * Checks if a value is a function
         * @param {*} func - Value to check
         * @returns {boolean}
         */
        isFunction: (func) => typeof func === 'function',

        /**
         * Checks if a value is an object and not null
         * @param {*} obj - Value to check
         * @returns {boolean}
         */
        isExistObject: (obj) => obj !== null && typeof obj === 'object',

        /**
         * Checks if a value is an array
         * @param {*} arr - Value to check
         * @returns {boolean}
         */
        isArray: (arr) => Array.isArray(arr),

        /**
         * Executes an array of methods with given arguments
         * @param {Object} context - Execution context
         * @param {Array<Function>} methods - Methods to execute
         * @param {Array} args - Arguments to pass
         * @returns {*} Last non-null result
         */
        invokeMethods: (context, methods, args) => {
            if (!Utils.isArray(methods)) return null;
            let result = null;
            methods.forEach(method => {
                if (Utils.isFunction(method)) {
                    try {
                        const r = method.apply(context, args);
                        if (r !== null && r !== undefined) result = r;
                    } catch (e) {
                        console.warn(`Error executing method: ${e.message}`);
                    }
                }
            });
            return result;
        },

        /**
         * URL utility for parsing and matching URLs
         */
        UrlUtils: {
            /**
             * Tests if a URL matches a pattern
             * @param {string} url - URL to test
             * @param {string} pattern - Regex pattern
             * @returns {boolean}
             */
            urlMatching: (url, pattern) => new RegExp(pattern).test(url),

            /**
             * Gets URL without query parameters
             * @param {string} url - Full URL
             * @returns {string}
             */
            getUrlWithoutParam: (url) => url.split('?')[0],

            /**
             * Merges URL with parameters
             * @param {string} url - Base URL
             * @param {Array<{key: string, value: string}>} params - Parameters
             * @returns {string}
             */
            mergeUrlAndParams: (url, params) => {
                if (!Utils.isArray(params)) return url;
                const paramStr = params
                    .filter(p => p.key && p.value)
                    .map(p => `${p.key}=${p.value}`)
                    .join('&');
                return paramStr ? `${url}?${paramStr}` : url;
            }
        }
    };

    /**
     * Core hooking class
     * @class EHook
     */
    class EHook {
        constructor() {
            this._autoId = 1;
            this._hookedMap = new Map();
            this._hookedContextMap = new Map();
        }

        /**
         * Generates a unique ID
         * @returns {string}
         */
        _getAutoStrId() {
            return `__auto__${this._autoId++}`;
        }

        /**
         * Gets or creates a hook ID for a context
         * @param {Object} context - Context object
         * @returns {string}
         */
        _getHookedId(context) {
            for (const [id, ctx] of this._hookedContextMap) {
                if (ctx === context) return id;
            }
            const id = this._getAutoStrId();
            this._hookedContextMap.set(id, context);
            return id;
        }

        /**
         * Gets or creates a method map for a context
         * @param {Object} context - Context object
         * @returns {Map}
         */
        _getHookedMethodMap(context) {
            const id = this._getHookedId(context);
            if (!this._hookedMap.has(id)) {
                this._hookedMap.set(id, new Map());
            }
            return this._hookedMap.get(id);
        }

        /**
         * Gets or creates a hook task for a method
         * @param {Object} context - Context object
         * @param {string} methodName - Method name
         * @returns {Object}
         */
        _getHookedMethodTask(context, methodName) {
            const methodMap = this._getHookedMethodMap(context);
            if (!methodMap.has(methodName)) {
                methodMap.set(methodName, {
                    original: undefined,
                    replace: undefined,
                    task: { before: [], current: undefined, after: [] }
                });
            }
            return methodMap.get(methodName);
        }

        /**
         * Hooks a method with custom logic
         * @param {Object} parent - Object containing the method
         * @param {string} methodName - Method name
         * @param {Object} config - Hook configuration
         * @returns {number} Hook ID or -1 on failure
         */
        hook(parent, methodName, config = {}) {
            const context = config.context || parent;
            if (!parent[methodName]) {
                parent[methodName] = () => {};
            }
            if (!Utils.isFunction(parent[methodName])) {
                return -1;
            }

            const methodTask = this._getHookedMethodTask(parent, methodName);
            const id = this._autoId++;
            let hooked = false;

            if (Utils.isFunction(config.replace)) {
                methodTask.replace = { id, method: config.replace };
                hooked = true;
            }
            if (Utils.isFunction(config.before)) {
                methodTask.task.before.push({ id, method: config.before });
                hooked = true;
            }
            if (Utils.isFunction(config.current)) {
                methodTask.task.current = { id, method: config.current };
                hooked = true;
            }
            if (Utils.isFunction(config.after)) {
                methodTask.task.after.push({ id, method: config.after });
                hooked = true;
            }

            if (hooked) {
                this._hook(parent, methodName, context);
                return id;
            }
            return -1;
        }

        /**
         * Replaces a method with a hooked version
         * @private
         */
        _hook(parent, methodName, context) {
            const methodTask = this._getHookedMethodTask(parent, methodName);
            if (!methodTask.original) {
                methodTask.original = parent[methodName];
            }

            if (methodTask.replace && Utils.isFunction(methodTask.replace.method)) {
                parent[methodName] = methodTask.replace.method(methodTask.original);
                return;
            }

            const hookedMethod = (...args) => {
                let result;
                try {
                    // Execute before hooks
                    Utils.invokeMethods(context, methodTask.task.before, [methodTask.original, args]);

                    // Execute current or original method
                    if (methodTask.task.current && Utils.isFunction(methodTask.task.current.method)) {
                        result = methodTask.task.current.method.call(context, parent, methodTask.original, args);
                    } else {
                        result = methodTask.original.apply(context, args);
                    }

                    // Execute after hooks
                    const afterArgs = [methodTask.original, args, result];
                    const afterResult = Utils.invokeMethods(context, methodTask.task.after, afterArgs);
                    return afterResult !== null ? afterResult : result;
                } catch (e) {
                    console.error(`Error in hooked method ${methodName}: ${e.message}`);
                    return methodTask.original.apply(context, args);
                }
            };

            // Preserve original method properties
            Object.defineProperties(hookedMethod, {
                toString: {
                    value: methodTask.original.toString.bind(methodTask.original),
                    configurable: false,
                    enumerable: false
                }
            });
            hookedMethod.prototype = methodTask.original.prototype;
            parent[methodName] = hookedMethod;
        }

        /**
         * Hooks AJAX requests
         * @param {Object} methods - Methods to hook (e.g., open, send, onreadystatechange)
         * @returns {number} Hook ID
         */
        hookAjax(methods) {
            if (this.isHooked(window, 'XMLHttpRequest')) {
                return -1;
            }

            return this.hookReplace(window, 'XMLHttpRequest', (OriginalXMLHttpRequest) => {
                class HookedXMLHttpRequest {
                    constructor() {
                        this.xhr = new OriginalXMLHttpRequest();
                        this.xhr.xhr = this; // Reference to outer xhr
                        for (const prop in this.xhr) {
                            if (Utils.isFunction(this.xhr[prop])) {
                                this[prop] = (...args) => {
                                    if (Utils.isFunction(methods[prop])) {
                                        this.hookBefore(this.xhr, prop, methods[prop]);
                                    }
                                    return this.xhr[prop].apply(this.xhr, args);
                                };
                            } else {
                                Object.defineProperty(this, prop, {
                                    get: () => this[prop + '_'] || this.xhr[prop],
                                    set: (value) => {
                                        if (prop.startsWith('on') && Utils.isFunction(methods[prop])) {
                                            this.hookBefore(this.xhr, prop, methods[prop]);
                                            this.xhr[prop] = (...args) => value.apply(this.xhr, args);
                                        } else {
                                            this[prop + '_'] = value;
                                        }
                                    }
                                });
                            }
                        }
                    }
                }
                return HookedXMLHttpRequest;
            });
        }

        /**
         * Replaces a method entirely
         * @param {Object} parent - Parent object
         * @param {string} methodName - Method name
         * @param {Function} replace - Replacement function
         * @param {Object} [context] - Execution context
         * @returns {number} Hook ID
         */
        hookReplace(parent, methodName, replace, context) {
            return this.hook(parent, methodName, { replace, context });
        }

        /**
         * Hooks a method to run before the original
         * @param {Object} parent - Parent object
         * @param {string} methodName - Method name
         * @param {Function} before - Function to run before
         * @param {Object} [context] - Execution context
         * @returns {number} Hook ID
         */
        hookBefore(parent, methodName, before, context) {
            return this.hook(parent, methodName, { before, context });
        }

        /**
         * Checks if a method is hooked
         * @param {Object} context - Context object
         * @param {string} methodName - Method name
         * @returns {boolean}
         */
        isHooked(context, methodName) {
            const methodMap = this._getHookedMethodMap(context);
            return methodMap.has(methodName) && !!methodMap.get(methodName).original;
        }

        /**
         * Unhooks a method
         * @param {Object} context - Context object
         * @param {string} methodName - Method name
         * @param {boolean} [isDeeply=false] - Whether to remove all hooks
         * @param {number} [eqId] - Specific hook ID to remove
         */
        unHook(context, methodName, isDeeply = false, eqId) {
            if (!context[methodName] || !Utils.isFunction(context[methodName])) return;

            const methodMap = this._getHookedMethodMap(context);
            const methodTask = methodMap.get(methodName);

            if (eqId && this.unHookById(eqId)) return;

            if (methodTask?.original) {
                context[methodName] = methodTask.original;
                if (isDeeply) methodMap.delete(methodName);
            }
        }

        /**
         * Unhooks by ID
         * @param {number} eqId - Hook ID
         * @returns {boolean} Whether a hook was removed
         */
        unHookById(eqId) {
            let hasEq = false;
            for (const [_, methodMap] of this._hookedMap) {
                for (const [_, task] of methodMap) {
                    task.task.before = task.task.before.filter(b => {
                        if (b.id === eqId) hasEq = true;
                        return b.id !== eqId;
                    });
                    task.task.after = task.task.after.filter(a => {
                        if (a.id === eqId) hasEq = true;
                        return a.id !== eqId;
                    });
                    if (task.task.current?.id === eqId) {
                        task.task.current = undefined;
                        hasEq = true;
                    }
                    if (task.replace?.id === eqId) {
                        task.replace = undefined;
                        hasEq = true;
                    }
                }
            }
            return hasEq;
        }
    }

    /**
     * AJAX-specific hooking class
     * @class AHook
     */
    class AHook {
        constructor() {
            this.isHooked = false;
            this._urlDispatcherList = [];
            this._autoId = 1;
            this.eHook = new EHook();
        }

        /**
         * Registers an AJAX URL interceptor
         * @param {string} urlPatcher - URL pattern to match
         * @param {Object|Function} configOrRequest - Configuration or request hook
         * @param {Function} [response] - Response hook
         * @returns {number} Registration ID
         */
        register(urlPatcher, configOrRequest, response) {
            if (!urlPatcher) return -1;

            const config = {};
            if (Utils.isFunction(configOrRequest)) {
                config.hookRequest = configOrRequest;
            } else if (Utils.isExistObject(configOrRequest)) {
                Object.assign(config, configOrRequest);
            }
            if (Utils.isFunction(response)) {
                config.hookResponse = response;
            }

            if (!Object.keys(config).length) return -1;

            const id = this._autoId++;
            this._urlDispatcherList.push({ id, patcher: urlPatcher, config });

            if (!this.isHooked) {
                this.startHook();
            }
            return id;
        }

        /**
         * Starts AJAX hooking
         */
        startHook() {
            const methods = {
                open: (xhr, args) => {
                    const [method, fullUrl, async] = args;
                    const url = Utils.UrlUtils.getUrlWithoutParam(fullUrl);
                    xhr.patcherList = this._urlPatcher(url);
                    xhr.openArgs = { method, url, async };
                    Utils.invokeMethods(xhr, xhr.patcherList.map(p => p.config.hookRequest), [xhr.openArgs]);
                    args[1] = Utils.UrlUtils.mergeUrlAndParams(xhr.openArgs.url, xhr.openArgs.params);
                },
                onreadystatechange: (xhr, args) => {
                    if (xhr.readyState === 4 && (xhr.status === 200 || xhr.status === 304)) {
                        this._onResponse(xhr, args);
                    }
                },
                onload: (xhr, args) => this._onResponse(xhr, args)
            };

            this.___hookedId = this.eHook.hookAjax(methods);
            this.isHooked = true;
        }

        /**
         * Matches URL against registered patchers
         * @private
         */
        _urlPatcher(url) {
            return this._urlDispatcherList.filter(p => Utils.UrlUtils.urlMatching(url, p.patcher));
        }

        /**
         * Handles response hooks
         * @private
         */
        _onResponse(xhr, args) {
            const results = Utils.invokeMethods(xhr, xhr.patcherList.map(p => p.config.hookResponse), args);
            const lastResult = results?.find(r => r !== null && r !== undefined);
            if (lastResult) {
                xhr.responseText_ = xhr.response_ = lastResult;
            }
        }
    }

    // Expose to global scope
    window.eHook = new EHook();
    window.aHook = new AHook();

    /**
     * Example usage:
     * window.eHook.hook(window, 'alert', {
     *   before: (original, args) => console.log('Before alert:', args),
     *   after: (original, args, result) => console.log('After alert:', result)
     * });
     *
     * window.aHook.register('https://example.com/api/.*', {
     *   hookRequest: (args) => console.log('Request:', args),
     *   hookResponse: (xhr, args) => console.log('Response:', xhr.responseText)
     * });
     */
})();