n-artists

Userscript to favourite nhentai artists

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         n-artists
// @namespace    URL
// @version      0.2.4
// @description  Userscript to favourite nhentai artists
// @icon         https://nhentai.net/favicon.png
// @author       Sisyphus
// @license      MIT
// @homepage     https://github.com/SisypheOvO
// @match        https://nhentai.net/*
// @run-at       document-end
// @grant        none
// ==/UserScript==


(function () {
    'use strict';

    const FAVORITES_KEY = "favoriteArtists";
    const THUMBS_KEY = "artistsThumbnail";
    function replacer(_key, value) {
        if (value instanceof Map) {
            return { dataType: "Map", value: Array.from(value.entries()) };
        }
        return value;
    }
    function reviver(_key, value) {
        if (typeof value === "object" && value !== null && value.dataType === "Map") {
            return new Map(value.value);
        }
        return value;
    }
    function getFavorites() {
        try {
            return JSON.parse(localStorage.getItem(FAVORITES_KEY) || "[]");
        }
        catch (e) {
            console.error("getFavorites parse error", e);
            return [];
        }
    }
    function saveFavorites(list) {
        localStorage.setItem(FAVORITES_KEY, JSON.stringify(list));
    }
    function getThumbnails() {
        try {
            const raw = localStorage.getItem(THUMBS_KEY);
            if (!raw)
                return new Map();
            return JSON.parse(raw, reviver);
        }
        catch (e) {
            console.error("getThumbnails parse error", e);
            return new Map();
        }
    }
    function saveThumbnails(map) {
        localStorage.setItem(THUMBS_KEY, JSON.stringify(map, replacer));
    }

    const MAX_CONCURRENCY = 3;
    let activeRequests = 0;
    const requestQueue = [];
    function acquireSlot() {
        return new Promise((resolve) => {
            if (activeRequests < MAX_CONCURRENCY) {
                activeRequests++;
                resolve();
            }
            else {
                requestQueue.push(() => {
                    activeRequests++;
                    resolve();
                });
            }
        });
    }
    function releaseSlot() {
        activeRequests--;
        const next = requestQueue.shift();
        if (next)
            next();
    }
    async function fetchWithTimeout(input, init, timeout = 8000) {
        const controller = new AbortController();
        const id = setTimeout(() => controller.abort(), timeout);
        try {
            const resp = await fetch(input, { ...(init || {}), signal: controller.signal });
            return resp;
        }
        finally {
            clearTimeout(id);
        }
    }
    async function fetchArtistThumbnail(artistName) {
        await acquireSlot();
        try {
            const slug = artistName.replace(/\s/g, "-");
            const resp = await fetchWithTimeout(`https://nhentai.net/artist/${slug}/popular`, { credentials: "include" }, 8000);
            if (!resp.ok)
                return null;
            const text = await resp.text();
            const rgx = /https:\/\/t[0-9]{1}\.nhentai\.net\/galleries\/[0-9]{1,8}\/thumb\.jpg/;
            const m = text.match(rgx);
            return m ? m[0] : null;
        }
        catch (e) {
            if (e?.name === "AbortError") {
                console.warn("fetchArtistThumbnail aborted (timeout) for", artistName);
            }
            else {
                console.error("fetchArtistThumbnail error", e);
            }
            return null;
        }
        finally {
            releaseSlot();
        }
    }

    async function toggleFavorite(artistName) {
        try {
            const favs = getFavorites();
            const thumbs = getThumbnails();
            const idx = favs.indexOf(artistName);
            let added = false;
            if (idx === -1) {
                favs.push(artistName);
                added = true;
                // async fetch thumbnail and persist
                fetchArtistThumbnail(artistName)
                    .then((url) => {
                    if (url) {
                        thumbs.set(artistName, url);
                        saveThumbnails(thumbs);
                    }
                })
                    .catch((e) => console.warn("thumbnail fetch failed", e));
            }
            else {
                favs.splice(idx, 1);
                thumbs.delete(artistName);
                saveThumbnails(thumbs);
            }
            saveFavorites(favs);
            return added;
        }
        catch (e) {
            console.error("toggleFavorite error", e);
            return false;
        }
    }
    function updateButtonState(added, btn, artistName) {
        btn.classList.toggle("is-favorite", added);
        const icon = btn.querySelector("i");
        if (!icon)
            return;
        if (added) {
            icon.classList.remove("far");
            icon.classList.add("fa");
            icon.style.color = "var(--accent)";
        }
        else {
            icon.classList.remove("fa");
            icon.classList.add("far");
            icon.style.removeProperty("color");
        }
    }

    function createBtnArtistPage(className, title) {
        const wrapper = document.createElement("span");
        wrapper.style = "padding: 0.13em 0.26em; margin-right: 0.13em; display: inline-flex; align-items: center; justify-content: center; background-color: var(--border); border-radius: .3em; vertical-align: middle;";
        const el = document.createElement("i");
        el.className = className;
        el.style.cursor = "pointer";
        el.style.lineHeight = "inherit";
        el.style.margin = "0";
        wrapper.appendChild(el);
        return wrapper;
    }
    function injectFavBtns2ArtistPage() {
        const header = document.querySelector("h1");
        if (!header)
            return;
        if (header.querySelector(".favoriteArtistButton"))
            return;
        const match = window.location.pathname.match(/\/artist\/([^/]+)\//);
        const artistName = match ? decodeURIComponent(match[1].replace(/-/g, " ")) : null;
        if (!artistName)
            return;
        const btn = createBtnArtistPage("far fa-heart favoriteArtistButton");
        btn.dataset.artist = artistName;
        const added = getFavorites().includes(artistName);
        updateButtonState(added, btn);
        btn.addEventListener("click", async (event) => {
            event.preventDefault();
            event.stopPropagation();
            const added = await toggleFavorite(artistName);
            updateButtonState(added, btn);
        });
        header.insertAdjacentElement("afterbegin", btn);
    }
    function initArtistPage() {
        try {
            injectFavBtns2ArtistPage();
            if (window.__nArtistsArtistObserver)
                return;
            const observer = new MutationObserver(() => {
                injectFavBtns2ArtistPage();
            });
            observer.observe(document.body, {
                childList: true,
                subtree: true,
            });
            window.__nArtistsArtistObserver = observer;
        }
        catch (e) {
            console.error("initArtistPage error", e);
        }
    }

    function createBtnWorkPage(className, title) {
        const wrapper = document.createElement("span");
        wrapper.style = "padding: 0.13em 0.26em; display: inline-flex; align-items: center; justify-content: center; background-color: var(--border); border-top-left-radius: .3em; border-bottom-left-radius: .3em;";
        const el = document.createElement("i");
        el.className = className;
        el.style.cursor = "pointer";
        wrapper.appendChild(el);
        return wrapper;
    }
    function injectFavBtns2WorkPage() {
        const tagContainers = Array.from(document.querySelectorAll("div.tag-container.field-name:not(.hidden)"));
        const artistContainer = tagContainers.find((el) => el.textContent && el.textContent.includes("Artists:"));
        if (!artistContainer)
            return;
        const artistTagChips = Array.from(artistContainer.querySelectorAll("a.tagchip[href^='/artist/']"));
        for (const artistTagChip of artistTagChips) {
            if (artistTagChip.querySelector(".favoriteArtistButton"))
                continue;
            const href = artistTagChip.getAttribute("href");
            const match = href?.match(/\/artist\/([^/?#]+)/);
            const artistName = match ? decodeURIComponent(match[1].replace(/-/g, " ")) : null;
            if (!artistName)
                continue;
            const btn = createBtnWorkPage("far fa-heart favoriteArtistButton");
            btn.dataset.artist = artistName;
            const added = getFavorites().includes(artistName);
            updateButtonState(added, btn);
            btn.addEventListener("click", async (event) => {
                event.preventDefault();
                event.stopPropagation();
                const added = await toggleFavorite(artistName);
                updateButtonState(added, btn);
            });
            artistTagChip.insertAdjacentElement("afterbegin", btn);
        }
    }
    /**
     * init artwork page by adding favorite buttons to artist tags
     */
    function initWorkPage() {
        try {
            injectFavBtns2WorkPage();
            if (window.__nArtistsWorkObserver)
                return;
            const observer = new MutationObserver(() => {
                injectFavBtns2WorkPage();
            });
            observer.observe(document.body, {
                childList: true,
                subtree: true,
            });
            window.__nArtistsWorkObserver = observer;
        }
        catch (e) {
            console.error("initWorkPage error", e);
        }
    }

    let showingFavoriteArtists = false;
    let lastFavoritesRouteKey = null;
    let importInputEl = null;
    function getFavoritesWorkspace() {
        return document.getElementById("favcontainer");
    }
    function updateDisplayButtonLabel(button) {
        button.textContent = showingFavoriteArtists ? "Show Favorite Doujins" : "Show Favorite Artists";
        button.setAttribute("aria-pressed", showingFavoriteArtists ? "true" : "false");
    }
    function renderFavorites(container) {
        const favs = getFavorites();
        const thumbs = getThumbnails();
        container.innerHTML = "";
        for (const artist of favs) {
            const thumb = thumbs.get(artist) || "";
            const root = document.createElement("div");
            root.className = "gallery-favorite";
            root.setAttribute("artist-name", artist);
            const gallery = document.createElement("div");
            gallery.className = "gallery";
            gallery.style = "margin-bottom: 4px;";
            const link = document.createElement("a");
            link.className = "cover";
            link.href = `/artist/${artist.replace(/\s/g, "-")}/`;
            const img = document.createElement("img");
            img.width = 250;
            img.height = 353;
            if (thumb)
                img.src = thumb;
            img.alt = artist;
            img.style = "position: relative; object-fit: cover; width: 100%; height: 100%;";
            const caption = document.createElement("div");
            caption.className = "caption";
            caption.textContent = artist;
            caption.style = "position: relative;";
            link.appendChild(img);
            link.appendChild(caption);
            link.style = "display: inline-flex; padding: 0; margin: 0; flex-direction: column; align-items: center;";
            gallery.appendChild(link);
            root.appendChild(gallery);
            container.appendChild(root);
        }
    }
    function getFavoriteArtistsTxt() {
        const favorites = getFavorites();
        const thumbs = getThumbnails();
        return favorites
            .map((artist) => {
            const thumbnail = thumbs.get(artist) || "";
            return `${artist}\t${thumbnail}`;
        })
            .join("\n");
    }
    function downloadTxtFile(filename, content) {
        const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
        const url = URL.createObjectURL(blob);
        const link = document.createElement("a");
        link.href = url;
        link.download = filename;
        document.body.appendChild(link);
        link.click();
        link.remove();
        URL.revokeObjectURL(url);
    }
    function formatTimestamp(date) {
        const pad = (value) => String(value).padStart(2, "0");
        return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`;
    }
    function parseFavoriteArtistsTxt(text) {
        const seen = new Set();
        const artists = [];
        const thumbnails = new Map();
        for (const rawLine of text.split(/\r?\n/)) {
            const line = rawLine.trim();
            if (!line)
                continue;
            const [artistPart, thumbnailPart = ""] = line.split("\t");
            const artist = artistPart.trim();
            if (!artist)
                continue;
            if (seen.has(artist))
                continue;
            seen.add(artist);
            artists.push(artist);
            const thumbnail = thumbnailPart.trim();
            if (thumbnail) {
                thumbnails.set(artist, thumbnail);
            }
        }
        return { artists, thumbnails };
    }
    function getDiffSummary(currentList, importedList) {
        const currentSet = new Set(currentList);
        const importedSet = new Set(importedList);
        const added = importedList.filter((artist) => !currentSet.has(artist));
        const removed = currentList.filter((artist) => !importedSet.has(artist));
        return { added, removed };
    }
    function isLargeImportDiff(currentCount, addedCount, removedCount) {
        const threshold = Math.max(10, Math.ceil(currentCount * 0.2));
        return addedCount >= threshold || removedCount >= threshold;
    }
    function formatArtistSample(list, limit = 5) {
        if (list.length === 0)
            return "(none)";
        return list.slice(0, limit).join(", ") + (list.length > limit ? ` ... (+${list.length - limit})` : "");
    }
    function confirmImportReplacement(currentList, importedList) {
        const { added, removed } = getDiffSummary(currentList, importedList);
        const largeDiff = isLargeImportDiff(currentList.length, added.length, removed.length);
        const warning = largeDiff ? "\n\nWarning: the difference is large. Replacing the current list is irreversible. You may want to back up first." : "";
        const message = ["Review the import file before continuing.", `Current artists: ${currentList.length}`, `Imported artists: ${importedList.length}`, `To be added: ${added.length}`, `To be removed: ${removed.length}`, `Added sample: ${formatArtistSample(added)}`, `Removed sample: ${formatArtistSample(removed)}`, warning, "", "Continue import and overwrite the current list?"].join("\n");
        return window.confirm(message);
    }
    function refreshFavoriteArtistsView() {
        if (!showingFavoriteArtists)
            return;
        const panel = getArtistsPanel();
        if (!panel)
            return;
        renderFavorites(panel);
    }
    async function importFavoriteArtistsFile(file) {
        const text = await file.text();
        const { artists, thumbnails } = parseFavoriteArtistsTxt(text);
        const currentFavorites = getFavorites();
        if (!confirmImportReplacement(currentFavorites, artists)) {
            return;
        }
        const existingThumbnails = getThumbnails();
        const nextThumbnails = new Map(existingThumbnails);
        for (const artist of artists) {
            const parsedThumbnail = thumbnails.get(artist);
            if (parsedThumbnail) {
                nextThumbnails.set(artist, parsedThumbnail);
                continue;
            }
            const existingThumbnail = existingThumbnails.get(artist);
            if (existingThumbnail) {
                nextThumbnails.set(artist, existingThumbnail);
                continue;
            }
            try {
                const fetchedThumbnail = await fetchArtistThumbnail(artist);
                if (fetchedThumbnail) {
                    nextThumbnails.set(artist, fetchedThumbnail);
                }
            }
            catch (e) {
                console.warn("fetch thumbnail during import failed", artist, e);
            }
        }
        saveFavorites(artists);
        saveThumbnails(nextThumbnails);
        refreshFavoriteArtistsView();
    }
    function getArtistsPanel() {
        return document.getElementById("favorite-artists-panel");
    }
    function setArtworkPaginationVisible(visible) {
        const isMobile = window.matchMedia("(max-width: 599px)").matches;
        const activeSelector = isMobile ? "mobile-pagination" : "desktop-pagination";
        console.log("Setting artwork pagination visible?", visible, "activeSelector", activeSelector);
        for (const pagination of document.querySelectorAll(".pagination")) {
            const paginationElement = pagination;
            console.log("Pagination element", paginationElement, "activeSelector", activeSelector);
            const isActivePagination = paginationElement.classList.contains(activeSelector);
            console.log("Is active pagination?", isActivePagination);
            paginationElement.style.display = visible && isActivePagination ? "block" : "none";
            console.log("Set pagination display to", paginationElement.style.display);
        }
    }
    function showFavoriteArtists(button) {
        const workspace = getFavoritesWorkspace();
        if (!workspace)
            return;
        let panel = getArtistsPanel();
        if (!panel) {
            panel = document.createElement("div");
            panel.id = "favorite-artists-panel";
            panel.style = "margin-top: 0.75em;";
            workspace.insertAdjacentElement("beforebegin", panel);
        }
        workspace.style.display = "none";
        setArtworkPaginationVisible(false);
        panel.style.display = "block";
        renderFavorites(panel);
        showingFavoriteArtists = true;
        if (button)
            updateDisplayButtonLabel(button);
    }
    function showFavoriteArtworks(button) {
        const workspace = getFavoritesWorkspace();
        if (!workspace)
            return;
        const panel = getArtistsPanel();
        if (panel)
            panel.remove();
        workspace.style.display = "block";
        setArtworkPaginationVisible(true);
        showingFavoriteArtists = false;
        if (button)
            updateDisplayButtonLabel(button);
    }
    function syncFavoritesRouteState() {
        const currentRouteKey = `${window.location.pathname}${window.location.search}`;
        if (lastFavoritesRouteKey !== currentRouteKey) {
            lastFavoritesRouteKey = currentRouteKey;
            showingFavoriteArtists = false;
            const panel = getArtistsPanel();
            if (panel)
                panel.remove();
            const workspace = getFavoritesWorkspace();
            if (workspace)
                workspace.style.display = "block";
            setArtworkPaginationVisible(true);
        }
    }
    function injectDisplayButtonStyle() {
        if (document.getElementById("favorites-display-button-style"))
            return;
        const style = document.createElement("style");
        style.id = "favorites-display-button-style";
        style.textContent = `
    #displayFavoriteArtists, #exportFavoriteArtists, #importFavoriteArtists {
        margin-left: 0.5em;
        background-color: var(--border);
        transition: background-color 0.2s ease;
    }

    #displayFavoriteArtists:hover, #exportFavoriteArtists:hover, #importFavoriteArtists:hover {
        background-color: var(--accent-hover);
    }
`;
        document.head.appendChild(style);
    }
    function injectDisplayButton() {
        const element = document.getElementById("favorites-random-button");
        if (!element)
            return;
        if (document.getElementById("displayFavoriteArtists"))
            return;
        const displayButton = document.createElement("button");
        displayButton.id = "displayFavoriteArtists";
        displayButton.className = "btn";
        displayButton.type = "button";
        updateDisplayButtonLabel(displayButton);
        element.parentNode?.insertBefore(displayButton, element.nextSibling || null);
        displayButton.addEventListener("click", () => {
            if (showingFavoriteArtists) {
                showFavoriteArtworks(displayButton);
            }
            else {
                showFavoriteArtists(displayButton);
            }
        });
        const exportButton = document.createElement("button");
        exportButton.id = "exportFavoriteArtists";
        exportButton.className = "btn";
        exportButton.type = "button";
        exportButton.textContent = "Export Artists TXT";
        exportButton.style.marginLeft = "0.5em";
        exportButton.addEventListener("click", () => {
            downloadTxtFile(`favorite-artists-${formatTimestamp(new Date())}.txt`, `${getFavoriteArtistsTxt()}\n`);
        });
        const importButton = document.createElement("button");
        importButton.id = "importFavoriteArtists";
        importButton.className = "btn";
        importButton.type = "button";
        importButton.textContent = "Import Artists TXT";
        importButton.style.marginLeft = "0.5em";
        importButton.addEventListener("click", () => {
            importInputEl?.click();
        });
        if (!importInputEl) {
            importInputEl = document.createElement("input");
            importInputEl.type = "file";
            importInputEl.accept = ".txt,text/plain";
            importInputEl.hidden = true;
            importInputEl.addEventListener("change", async () => {
                const file = importInputEl?.files?.[0];
                if (!file)
                    return;
                try {
                    await importFavoriteArtistsFile(file);
                }
                catch (e) {
                    console.error("importFavoriteArtistsFile error", e);
                }
                finally {
                    if (importInputEl)
                        importInputEl.value = "";
                }
            });
            document.body.appendChild(importInputEl);
        }
        element.insertAdjacentElement("afterend", importButton);
        element.insertAdjacentElement("afterend", exportButton);
    }
    function initFavoritesPage() {
        try {
            injectDisplayButtonStyle();
            syncFavoritesRouteState();
            injectDisplayButton();
            if (window.__nArtistsFavoritesObserver)
                return;
            const observer = new MutationObserver(() => {
                injectDisplayButton();
            });
            observer.observe(document.body, {
                childList: true,
                subtree: true,
            });
            window.__nArtistsFavoritesObserver = observer;
        }
        catch (e) {
            console.error("initFavoritesPage error", e);
        }
    }

    function smallCSSInject() {
        if (document.getElementById("favorites-search-style"))
            return;
        const style = document.createElement("style");
        style.id = "favorites-search-style";
        style.textContent = `
    #favorites-search {
    display: inline-flex;
}`;
        document.head.appendChild(style);
    }
    function runRoute() {
        const url = window.location.href;
        const reg_url_1 = /https:\/\/nhentai\.net\/g\/[0-9]*\/$/;
        const reg_url_2 = /^https:\/\/nhentai\.net\/user\/favorites(?!\/)(?:\?.*)?$/;
        const reg_url_3 = /https:\/\/nhentai\.net\/artist\/[^/]+\/$/;
        if (reg_url_1.test(url)) {
            initWorkPage();
        }
        else if (reg_url_2.test(url)) {
            smallCSSInject();
            initFavoritesPage();
        }
        else if (reg_url_3.test(url)) {
            initArtistPage();
        }
    }
    function installRouteWatcher() {
        if (window.__nArtistsRouteWatcherInstalled)
            return;
        const runAndRemember = () => {
            runRoute();
        };
        const wrapHistoryMethod = (methodName) => {
            const original = history[methodName];
            history[methodName] = function (...args) {
                const result = original.apply(history, args);
                window.dispatchEvent(new Event("n-artists:locationchange"));
                return result;
            };
        };
        wrapHistoryMethod("pushState");
        wrapHistoryMethod("replaceState");
        window.addEventListener("popstate", runAndRemember);
        window.addEventListener("hashchange", runAndRemember);
        window.addEventListener("n-artists:locationchange", runAndRemember);
        window.__nArtistsRouteWatcherInstalled = true;
    }
    installRouteWatcher();
    runRoute();

})();