Dynamically loads more comics as you scroll on nhentai.net (including favorites)
// ==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');
})();