真白萌:模糊 R17 封面

模糊真白萌 R17 小说的封面。

// ==UserScript==
// @name               Masiro: Blurs NSFW Covers
// @name:zh-TW         真白萌:模糊 R17 封面
// @name:zh-CN         真白萌:模糊 R17 封面
// @description        Blurs the covers of NSFW novels on Masiro.
// @description:zh-TW  模糊真白萌 R17 小說的封面。
// @description:zh-CN  模糊真白萌 R17 小说的封面。
// @icon               https://icons.duckduckgo.com/ip3/masiro.me.ico
// @author             Jason Kwok
// @namespace          https://jasonhk.dev/
// @version            1.4.0
// @license            MIT
// @match              https://masiro.me/admin
// @match              https://masiro.me/admin/
// @match              https://masiro.me/admin/novels
// @match              https://masiro.me/admin/novels?*
// @match              https://masiro.me/admin/novelIndex
// @match              https://masiro.me/admin/novelIndex?*
// @run-at             document-idle
// @grant              GM.getValue
// @grant              GM.setValue
// @grant              GM.deleteValue
// @grant              GM.listValues
// @grant              GM.registerMenuCommand
// @grant              GM.setClipboard
// @require            https://update.greasyfork.org/scripts/483122/1304475/style-shims.js
// @require            https://update.greasyfork.org/scripts/487244/1326878/gm-import-export.js
// @require            https://unpkg.com/typesafe-i18n@5.26.2/dist/i18n.object.min.js
// @require            https://update.greasyfork.org/scripts/482358/1296680/sleep.js
// @require            https://update.greasyfork.org/scripts/482311/1296481/queue.js
// @supportURL         https://greasyfork.org/scripts/471783/feedback
// ==/UserScript==

const LL = (function()
{
    const translations =
    {
        "en": {
            COMMAND: {
                IMPORT: "Import Novels Data Cache",
                EXPORT: "Export Cached Novels Data",
            },
            ERROR: {
                MALFORMED_JSON: "Malformed JSON data. Import failed.",
                UNKNOWN_ERROR: "Imported failed: {0}",
            },
            MESSAGE: {
                IMPORT_PROMPT: "Please provide JSON-formatted novels data cache:",
                IMPORT_FINISHED: "Import finished.",
                EXPORT_FINISHED: "Exported novels data cache to the clipboard.",
            },
        },
        "zh-Hant": {
            COMMAND: {
                IMPORT: "匯入小說資料快取",
                EXPORT: "匯出小說資料快取",
            },
            ERROR: {
                MALFORMED_JSON: "JSON 資料格式錯誤,匯入失敗。",
                UNKNOWN_ERROR: "匯入失敗:{0}",
            },
            MESSAGE: {
                IMPORT_PROMPT: "請提供 JSON 格式的小說資料快取:",
                IMPORT_FINISHED: "匯入完成。",
                EXPORT_FINISHED: "已匯出小說資料快取到剪貼簿。",
            },
        },
        "zh-Hans": {
            COMMAND: {
                IMPORT: "导入小说数据缓存",
                EXPORT: "导出小说数据缓存",
            },
            ERROR: {
                MALFORMED_JSON: "JSON 数据格式错误,导入失败。",
                UNKNOWN_ERROR: "导入失败:{0}",
            },
            MESSAGE: {
                IMPORT_PROMPT: "请提供 JSON 格式的小说数据缓存:",
                IMPORT_FINISHED: "导入完成。",
                EXPORT_FINISHED: "已导出小说数据缓存到剪贴板。",
            },
        },
    };

    let locale = "en";
    for (const language of navigator.languages.map((language) => new Intl.Locale(language).minimize()))
    {
        if (language.language === "zh")
        {
            locale = `zh-${language.maximize().script}`;
            break;
        }
        else if (language.baseName in Object.keys(translations))
        {
            locale = language.baseName;
            break;
        }
    }

    return i18nObject(locale, translations[locale]);
})();

GM.addStyle(`
    .updateCards > a.nsfw .updateImg, .layui-card.nsfw .n-img
    {
        filter: blur(var(--nsfw-blur-radius, 7.5px));
        transition: filter var(--nsfw-transition-duration, 0.3s);
    }

    .updateCards > a.nsfw:hover .updateImg, .updateCards > a.nsfw:focus-within .updateImg,
    .layui-card.nsfw:hover .n-img, .layui-card.nsfw:focus-within .n-img
    {
        filter: blur(0px);
    }
`);

if (GM.registerMenuCommand)
{
    GM.registerMenuCommand(LL.COMMAND.IMPORT(), () =>
    {
        setTimeout(async () =>
        {
            const cache = prompt(LL.MESSAGE.IMPORT_PROMPT(), "{}");
            if (cache)
            {
                try
                {
                    await GM.importValues(JSON.parse(cache));
                    alert(LL.MESSAGE.IMPORT_FINISHED());
                }
                catch (e)
                {
                    if (e instanceof SyntaxError)
                    {
                        console.error(e);
                        alert(LL.ERROR.MALFORMED_JSON());
                    }
                    else
                    {
                        console.error(e);
                        alert(LL.ERROR.UNKNOWN_ERROR(e?.message));
                    }
                }
            }
        }, 0);
    });

    GM.registerMenuCommand(LL.COMMAND.EXPORT(), () =>
    {
        setTimeout(async () =>
        {
            const cache = await GM.exportValues();
            GM.setClipboard(JSON.stringify(cache));

            alert(LL.MESSAGE.EXPORT_FINISHED());
        }, 0);
    });
}

const pathname = location.pathname;
if ((pathname === "/admin") || (pathname === "/admin/"))
{
    const queue = new Queue({ autostart: true, concurrency: 4 });

    const observer = new MutationObserver((records) =>
    {
        for (const record of records)
        {
            if (record.target.classList.contains("updateCards"))
            {
                for (const node of record.addedNodes)
                {
                    queue.push(async () =>
                    {
                        if (await isNsfw(node.href))
                        {
                            node.classList.add("nsfw");
                        }
                    });
                }
            }
        }
    });

    observer.observe(document.querySelector(".fl"), { subtree: true, childList: true });

    async function isNsfw(url)
    {
        const novelId = new URL(url).searchParams.get("novel_id");
        {
            const isNsfw = await GM.getValue(novelId);
            if (typeof isNsfw === "boolean") { return isNsfw; }
        }

        try
        {
            const response = await fetch(url);
            if (response.status === 200)
            {
                const html = await response.text();
                const parser = new DOMParser();
                const page = parser.parseFromString(html, "text/html");

                const isNsfw = Array.prototype.map.call(page.querySelectorAll(".tags .label"), (element) => element.innerText)
                                              .includes("R17");

                GM.setValue(novelId, isNsfw);
                return isNsfw;
            }
            else if (response.status === 429)
            {
                const resetTime = Number.parseInt(response.headers.get("x-ratelimit-reset"));
                await sleep((resetTime - Math.ceil(Date.now() / 1000) + 10) * 1000);
                return isNsfw(url);
            }
        }
        catch (e)
        {
            console.error(e);
        }

        return false;
    }
}
else
{
    const observer = new MutationObserver((records) =>
    {
        for (const record of records)
        {
            for (const node of record.addedNodes)
            {
                if ((node instanceof HTMLElement) && node.classList.contains("layui-card"))
                {
                    const isNsfw = Array.prototype.map.call(node.querySelectorAll(".tags > .tag"), (element) => element.innerText).includes("R17");
                    if (isNsfw) { node.classList.add("nsfw"); }

                    const url = new URL(node.querySelector(".glass + a").href);
                    GM.setValue(url.searchParams.get("novel_id"), isNsfw);
                }
            }
        }
    });

    observer.observe(document.querySelector(".n-leg"), { childList: true });
}