nhentai Infinite Scroll

Dynamically loads more comics as you scroll on nhentai.net (including favorites)

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         nhentai Infinite Scroll
// @namespace    http://tampermonkey.net/
// @version      3.1
// @description  Dynamically loads more comics as you scroll on nhentai.net (including favorites)
// @author       Hentiedup (original), [Snow2122] (adaptation)
// @license      MIT
// @match        https://nhentai.net/*
// @require      https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @grant        GM_getValue
// @grant        GM_addStyle
// @noframes
// ==/UserScript==

(function() {
    'use strict';

    // --- SETTINGS ---
    const infinite_load = GM_getValue("infinite_load", true);
    if (!infinite_load) return;

    // --- STATE ---
    let isLoadingNextPage = false;
    let autoLoadInterval  = null;
    let currentPageKey    = null;

    // --- HELPERS ---

    function isFavoritesPage() {
        return window.location.pathname.startsWith("/user/favorites");
    }

    /**
     * Finds the gallery container using a fallback chain.
     *
     * Different page types use different container classes on nhentai:
     *   - Tag / artist / etc. pages  →  .container.index-container
     *   - Search result pages        →  may use a different class (no .container)
     *
     * We try selectors from most-specific to least-specific, then fall back
     * to using the parentElement of any gallery item that isn't in a special
     * section (popular / advertisement).
     */
    function getGalleryContainer() {
        if (isFavoritesPage()) return $("#favcontainer");

        // Level 1 — standard listing pages (home, tag, artist, character, …)
        let $c = $(".container.index-container:not(.advertisement, .index-popular)").first();
        if ($c.length) return $c;

        // Level 2 — search result pages (may omit the .container class)
        $c = $(".index-container:not(.advertisement, .index-popular)").first();
        if ($c.length) return $c;

        // Level 3 — last resort: whatever element wraps the first gallery item
        //            that isn't inside a special section.
        $c = $("div.gallery")
                .not(".index-popular div.gallery, .advertisement div.gallery")
                .first()
                .parent();
        return $c; // may be an empty jQuery object — caller checks .length
    }

    /**
     * Extracts gallery items from a fetched page document.
     * Uses the same fallback chain as getGalleryContainer().
     */
    function extractGalleries($doc) {
        // Level 1
        let $items = $doc.find(".container.index-container:not(.advertisement, .index-popular) div.gallery");
        if ($items.length) return $items;

        // Level 2
        $items = $doc.find(".index-container:not(.advertisement, .index-popular) div.gallery");
        if ($items.length) return $items;

        // Level 3 — any gallery not inside a special section
        return $doc.find("div.gallery").not(
            $doc.find(".index-popular div.gallery, .advertisement div.gallery")
        );
    }

    function getCurrentPageNum() {
        const href  = $("section.desktop-pagination a.page.current").last().attr("href") || "";
        const match = href.match(/[?&]page=(\d+)/);
        return match ? parseInt(match[1], 10) : 1;
    }

    function getLastPageNum() {
        const href  = $("section.desktop-pagination a.last").first().attr("href") || "";
        const match = href.match(/[?&]page=(\d+)/);
        return match ? parseInt(match[1], 10) : 0; // 0 = not yet rendered
    }

    // --- STYLES ---
    function addStyles() {
        GM_addStyle(`
            #NHI_loader_icon {
                height: 355px;
                line-height: 355px;
                width: 100%;
                text-align: center;
            }
            #NHI_loader_icon > div { display: inline-flex; }
            .loader {
                color: #ed2553;
                font-size: 10px;
                width: 1em; height: 1em;
                border-radius: 50%;
                position: relative;
                text-indent: -9999em;
                animation: mulShdSpin 1.3s infinite linear;
                transform: translateZ(0);
            }
            @keyframes mulShdSpin {
                0%,100%{ box-shadow:0 -3em 0 .2em,2em -2em 0 0,3em 0 0 -1em,2em 2em 0 -1em,0 3em 0 -1em,-2em 2em 0 -1em,-3em 0 0 -1em,-2em -2em 0 0; }
                12.5%  { box-shadow:0 -3em 0 0,2em -2em 0 .2em,3em 0 0 0,2em 2em 0 -1em,0 3em 0 -1em,-2em 2em 0 -1em,-3em 0 0 -1em,-2em -2em 0 -1em; }
                25%    { box-shadow:0 -3em 0 -.5em,2em -2em 0 0,3em 0 0 .2em,2em 2em 0 0,0 3em 0 -1em,-2em 2em 0 -1em,-3em 0 0 -1em,-2em -2em 0 -1em; }
                37.5%  { box-shadow:0 -3em 0 -1em,2em -2em 0 -1em,3em 0 0 0,2em 2em 0 .2em,0 3em 0 0,-2em 2em 0 -1em,-3em 0 0 -1em,-2em -2em 0 -1em; }
                50%    { box-shadow:0 -3em 0 -1em,2em -2em 0 -1em,3em 0 0 -1em,2em 2em 0 0,0 3em 0 .2em,-2em 2em 0 0,-3em 0 0 -1em,-2em -2em 0 -1em; }
                62.5%  { box-shadow:0 -3em 0 -1em,2em -2em 0 -1em,3em 0 0 -1em,2em 2em 0 -1em,0 3em 0 0,-2em 2em 0 .2em,-3em 0 0 0,-2em -2em 0 -1em; }
                75%    { box-shadow:0 -3em 0 -1em,2em -2em 0 -1em,3em 0 0 -1em,2em 2em 0 -1em,0 3em 0 -1em,-2em 2em 0 0,-3em 0 0 .2em,-2em -2em 0 0; }
                87.5%  { box-shadow:0 -3em 0 0,2em -2em 0 -1em,3em 0 0 -1em,2em 2em 0 -1em,0 3em 0 -1em,-2em 2em 0 0,-3em 0 0 0,-2em -2em 0 .2em; }
            }
        `);
    }

    // --- FETCH AND APPEND ---

    function tryLoadInNextPageComics(pageNumToLoad, fetchUrlWithoutPageNum, retryNum, maxRetries) {
        retryNum  = retryNum  || 0;
        maxRetries = maxRetries || 5;

        if (retryNum === 0 && isLoadingNextPage) return;

        // Read lastPageNum fresh every call so we always use the current page's value.
        const lastPageNum = getLastPageNum();
        if (lastPageNum > 0 && pageNumToLoad > lastPageNum) return;

        isLoadingNextPage = true;

        const galleryContainer = getGalleryContainer();

        if ($("#NHI_loader_icon").length === 0) {
            galleryContainer.append(
                '<div id="NHI_loader_icon" class="gallery"><div><span class="loader"></span></div></div>'
            );
        }

        $.get({ url: fetchUrlWithoutPageNum + pageNumToLoad, dataType: "html" }, (data) => {
            const $doc = $(data);

            if (isFavoritesPage()) {
                $doc.find("div.gallery-favorite").each((i, el) => {
                    const $el       = $(el);
                    const coverHref = $el.find(".gallery .cover").attr("href");
                    if ($(`.cover[href='${coverHref}']`, galleryContainer).length) return;
                    galleryContainer.append($el);
                });
            } else {
                extractGalleries($doc).each((i, el) => {
                    const $el       = $(el);
                    const coverHref = $el.find(".cover").attr("href");
                    if ($(`.cover[href='${coverHref}']`, galleryContainer).length) return;
                    galleryContainer.append($el);
                });
            }

            // Update desktop paginator to reflect new current page.
            const $desktopPag = $("section.desktop-pagination");
            const $pageLink   = $desktopPag.find(`a.page[href$='page=${pageNumToLoad}']`);
            if ($pageLink.length) {
                $pageLink.addClass("current");
            } else {
                $desktopPag.find("a.next").before(
                    `<a href="${fetchUrlWithoutPageNum}${pageNumToLoad}" class="page current">${pageNumToLoad}</a>`
                );
            }

            // Update mobile paginator if present.
            $("section.mobile-pagination")
                .find(`a.page[href$='page=${pageNumToLoad}']`)
                .addClass("current");

            $("#NHI_loader_icon").remove();
            isLoadingNextPage = false;

        }).fail(() => {
            if (retryNum < maxRetries) {
                console.log(`[NHI] Page ${pageNumToLoad} failed — retry ${retryNum + 1}/${maxRetries}`);
                setTimeout(() => {
                    tryLoadInNextPageComics(pageNumToLoad, fetchUrlWithoutPageNum, retryNum + 1, maxRetries);
                }, 1000);
            } else {
                console.log(`[NHI] Page ${pageNumToLoad} — giving up after ${maxRetries} retries.`);
                $("#NHI_loader_icon").remove();
                isLoadingNextPage = false;
            }
        });
    }

    // --- PAGE INITIALISATION ---

    function initPage() {
        // Build a stable key: pathname + query string, minus any page= param.
        const rawSearch = window.location.search
            .replace(/[?&]page=\d+/, "")
            .replace(/^&/, "?");
        const pageKey = window.location.pathname + rawSearch;

        // Already initialised for this exact page — skip.
        if (pageKey === currentPageKey) return;

        // The gallery container must exist before we can initialise.
        // NOTE: we do NOT require section.desktop-pagination here because on
        // some page types (e.g. search) it may render slightly after the gallery.
        // The scroll listener and interval read lastPageNum dynamically instead.
        const $gallery = getGalleryContainer();
        if (!$gallery.length) return;

        // Mark as initialised now so concurrent retry passes don't double-init.
        currentPageKey = pageKey;

        // Tear down previous page's state.
        isLoadingNextPage = false;
        $(window).off("scroll.nhi");
        if (autoLoadInterval !== null) {
            clearInterval(autoLoadInterval);
            autoLoadInterval = null;
        }
        $("#NHI_loader_icon").remove();

        // Base URL for fetching subsequent pages.
        // rawSearch already excludes any existing page= param.
        const finalUrlWithoutPageNum =
            window.location.pathname +
            rawSearch +
            (rawSearch.length ? "&" : "?") +
            "page=";

        console.log(`[NHI] Initialised → ${pageKey} | fetch base: ${finalUrlWithoutPageNum}`);

        // ------------------------------------------------------------------
        // SCROLL LISTENER
        // lastPageNum and getCurrentPageNum() are read fresh on every scroll
        // event so they always reflect the current page's paginator, never
        // a value captured from a previous page.
        // ------------------------------------------------------------------
        $(window).on("scroll.nhi", () => {
            const scrolledTo = $(window).scrollTop() + (window.visualViewport?.height || $(window).height());
            if (scrolledTo >= $(document).height() - 500) {
                const last = getLastPageNum();
                if (last === 0) return; // paginator not rendered yet
                tryLoadInNextPageComics(getCurrentPageNum() + 1, finalUrlWithoutPageNum);
            }
        });

        // ------------------------------------------------------------------
        // AUTO-FILL INTERVAL
        // Keeps loading pages while the page is too short to produce a scrollbar.
        // Waits for paginator a.last to render before checking page counts.
        // ------------------------------------------------------------------
        autoLoadInterval = setInterval(() => {
            const last = getLastPageNum();
            if (last === 0) return; // a.last not rendered yet — wait

            const next = getCurrentPageNum() + 1;
            if (next > last) {
                clearInterval(autoLoadInterval);
                autoLoadInterval = null;
                return;
            }

            const doc = document.documentElement;
            if (doc.scrollHeight <= doc.clientHeight) {
                tryLoadInNextPageComics(next, finalUrlWithoutPageNum);
            } else {
                clearInterval(autoLoadInterval);
                autoLoadInterval = null;
            }
        }, 200);
    }

    // --- SPA NAVIGATION DETECTION ---

    function onNavigate() {
        // Reset key so the next initPage() call re-initialises for the new URL.
        currentPageKey = null;

        // Schedule multiple attempts at staggered delays.
        // Earlier passes catch fast renders; later passes catch slow ones.
        // Starting at 200 ms avoids reading stale DOM from the previous page.
        [200, 450, 800, 1300, 2200, 3500].forEach((d) => setTimeout(initPage, d));
    }

    (function patchHistory() {
        const wrap = (orig) => function (...args) {
            orig.apply(this, args);
            onNavigate();
        };
        history.pushState    = wrap(history.pushState.bind(history));
        history.replaceState = wrap(history.replaceState.bind(history));
        window.addEventListener("popstate", onNavigate);
    })();

    // --- BOOT ---
    addStyles();

    // Initial page load: try immediately and at short intervals in case
    // SvelteKit hasn't rendered the gallery yet.
    [0, 150, 400, 800, 1500].forEach((d) => setTimeout(initPage, d));

    console.log('[NHentai - Infinite Scroll] UserScript loaded. v3.1');

})();