pURLfy

The ultimate URL purifier

当前为 2024-04-09 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.cn-greasyfork.org/scripts/492078/1357326/pURLfy.js

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

class Purlfy extends EventTarget {
    redirectEnabled = false;
    lambdaEnabled = false;
    maxIterations = 5;
    #log = console.log.bind(console, "\x1b[38;2;220;20;60m[pURLfy]\x1b[0m");
    #paramDecoders = {
        "url": decodeURIComponent,
        "base64": s => decodeURIComponent(escape(atob(s))),
    };
    #statistics = {
        url: 0,
        param: 0,
        decoded: 0,
        redirected: 0,
        char: 0
    };
    #rules = {};

    constructor(options) {
        super();
        this.redirectEnabled = options?.redirectEnabled ?? this.redirectEnabled;
        this.lambdaEnabled = options?.lambdaEnabled ?? this.lambdaEnabled;
        this.maxIterations = options?.maxIterations ?? this.maxIterations;
        Object.assign(this.#statistics, options?.statistics);
        this.#log = options?.log ?? this.#log;
    }

    clearStatistics() {
        this.#statistics = {
            url: 0,
            param: 0,
            decoded: 0,
            redirected: 0,
            char: 0
        };
        this.#onStatisticsChange();
    }

    clearRules() {
        this.#rules = {};
    }

    getStatistics() {
        return this.#statistics;
    }

    importRules(rules) {
        Object.assign(this.#rules, rules);
    }

    #udfOrType(value, type) { // If the given value is of the given type or undefined
        return value === undefined || typeof value === type;
    }

    #validRule(rule) { // Check if the given rule is valid
        if (!rule || !rule.mode || !rule.description || !rule.author) return false;
        switch (rule.mode) {
            case "white":
            case "black":
                return Array.isArray(rule.params);
            case "param":
                return Array.isArray(rule.params) && (rule.decode === undefined || Array.isArray(rule.decode)) && this.#udfOrType(rule.continue, "boolean");
            case "regex":
                return false; // Not implemented yet
            case "redirect":
                return this.redirectEnabled && this.#udfOrType(rule.continue, "boolean");
            case "lambda":
                return this.lambdaEnabled && typeof rule.lambda === "string" && this.#udfOrType(rule.continue, "boolean");
            default:
                return false;
        }
    }

    #matchRule(parts) { // Iteratively match the longest rule for the given URL parts
        let fallbackRule = null; // Most precise fallback rule
        let currentRules = this.#rules;
        for (const part of parts) {
            if (currentRules.hasOwnProperty("")) {
                fallbackRule = currentRules[""];
            }
            if (currentRules.hasOwnProperty(part + "/")) {
                currentRules = currentRules[part + "/"];
            } else if (currentRules.hasOwnProperty(part)) {
                const rule = currentRules[part];
                if (this.#validRule(rule)) {
                    return rule;
                }
            }
        }
        if (this.#validRule(fallbackRule)) {
            return fallbackRule;
        }
        return null;
    }

    #onStatisticsChange() {
        this.dispatchEvent(new Event("statisticschange", {
            detail: this.#statistics
        }));
    }

    async purify(originalUrl) { // Purify the given URL based on `rules`
        let shallContinue = true;
        let url = originalUrl;
        let firstRule = null;
        let iteration = 0;
        this.#log("Purifying URL:", url);
        while (shallContinue && iteration++ < this.maxIterations) {
            const logi = (...args) => this.#log(`[#${iteration}]`, ...args);
            let urlObj;
            if (URL.canParse(url)) {
                urlObj = new URL(url);
            } else {
                logi(`Cannot parse URL ${url}`);
                return url;
            }
            const protocol = urlObj.protocol;
            if (protocol !== "http:" && protocol !== "https:") { // Not a valid HTTP URL
                logi(`Not a HTTP URL: ${url}`);
                return url;
            }
            const hostAndPath = urlObj.host + urlObj.pathname;
            const parts = hostAndPath.split("/").filter(part => part !== "");
            const rule = this.#matchRule(parts);
            if (!rule) { // No matching rule found
                logi(`No matching rule found for ${url}.`);
                return url;
            }
            firstRule ??= rule;
            logi(`Matching rule: ${rule.description} by ${rule.author}`);
            const mode = rule.mode;
            const paramsCntBefore = urlObj.searchParams.size;
            shallContinue = false;
            switch (mode) { // Purifies `urlObj` based on the rule
                case "white": { // Whitelist mode
                    const newParams = new URLSearchParams();
                    for (const param of rule.params) {
                        if (urlObj.searchParams.has(param)) {
                            newParams.set(param, urlObj.searchParams.get(param));
                        }
                    }
                    urlObj.search = newParams.toString();
                    break;
                }
                case "black": { // Blacklist mode
                    for (const param of rule.params) {
                        urlObj.searchParams.delete(param);
                    }
                    break;
                }
                case "param": { // Specific param mode
                    // Decode given parameter to be used as a new URL
                    let paramValue = null;
                    for (const param of rule.params) { // Find the first available parameter value
                        if (urlObj.searchParams.has(param)) {
                            paramValue = urlObj.searchParams.get(param);
                            break;
                        }
                    }
                    if (!paramValue) {
                        logi("Parameter(s) not found:", rule.params.join(", "));
                        break;
                    }
                    let dest = paramValue;
                    for (const name of (rule.decode ?? ["url"])) {
                        const decoder = this.#paramDecoders[name] ?? (s => s);
                        dest = decoder(dest);
                    }
                    urlObj = new URL(dest);
                    shallContinue = rule.continue ?? true;
                    this.#statistics.decoded++;
                    break;
                }
                case "regex": { // Regex mode
                    logi("Regex mode not implemented yet");
                    break;
                }
                case "redirect": { // Redirect mode
                    if (!this.redirectEnabled) {
                        logi("Redirect mode is disabled.");
                        break;
                    }
                    let r = null;
                    try {
                        r = await fetch(url, {
                            method: "HEAD",
                            redirect: "manual"
                        });
                    } catch (e) {
                        logi("Error fetching URL:", e);
                        break;
                    }
                    if ((r.status === 301 || r.status === 302) && r.headers.has("location")) {
                        let dest = r.headers.get("location");
                        urlObj = new URL(dest);
                        shallContinue = rule.continue ?? true;
                        this.#statistics.redirected++;
                    }
                    break;
                }
                case "lambda": {
                    if (!this.lambdaEnabled) {
                        logi("Lambda mode is disabled.");
                        break;
                    }
                    try {
                        const lambda = new Function("url", rule.lambda);
                        urlObj = lambda(urlObj);
                    } catch (e) {
                        logi("Error executing lambda:", e);
                    }
                    shallContinue = rule.continue ?? true;
                    break;
                }
                default: {
                    logi("Invalid mode:", mode);
                    break;
                }
            }
            logi("Purified URL:", urlObj.href);
            const paramsCntAfter = urlObj.searchParams.size;
            this.#statistics.param += (["white", "black"].includes(mode)) ? (paramsCntBefore - paramsCntAfter) : 0;
            this.#statistics.char += Math.max(url.length - urlObj.href.length, 0); // Prevent negative char count
            url = urlObj.href;
        }
        if (originalUrl === url) { // No changes made
            this.#log("No changes made.");
            return {
                url: url,
                rule: `* ${firstRule.description} by ${firstRule.author}`
            };
        }
        this.#statistics.url++;
        this.#onStatisticsChange();
        return {
            url: url,
            rule: `${firstRule.description} by ${firstRule.author}`
        };
    }
}