View nhentai galleries in a vertical scroll instead of clicking page by page
// ==UserScript==
// @name nhentai Vertical Scroll
// @namespace https://nhentai.net
// @version 1.8
// @author JoyArz
// @description View nhentai galleries in a vertical scroll instead of clicking page by page
// @match https://nhentai.net/g/*/
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
const STYLE = `
body {
background: #0f0f14 !important;
}
#content {
background: #0f0f14 !important;
}
.vs-container {
position: relative;
width: 100%;
max-width: 100vw;
margin: 0 auto;
padding-top: 20px;
}
.vs-page {
position: relative;
width: 100%;
display: flex;
justify-content: center;
background: #0f0f14;
margin-bottom: 8px;
min-height: 100px;
}
.vs-page img {
max-width: 100%;
max-height: 100vh;
object-fit: contain;
display: block;
}
.vs-page img.loading {
opacity: 0.3;
}
.vs-loading {
position: fixed;
bottom: 20px;
left: 20px;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-family: sans-serif;
font-size: 12px;
z-index: 9999;
text-align: center;
}
.vs-nav {
position: fixed;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 10px;
z-index: 9998;
}
.vs-nav button {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: #ff3366;
color: white;
font-size: 16px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s, transform 0.2s;
}
.vs-nav button:hover {
opacity: 1;
transform: scale(1.1);
}
.vs-nav-top {
position: fixed;
top: 20px;
right: 20px;
z-index: 9998;
}
.vs-nav-top button {
padding: 4px 8px;
border-radius: 12px;
border: none;
background: #ff3366;
color: white;
font-size: 11px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
.vs-nav-top button:hover {
opacity: 1;
}
.vs-error {
color: #ff3366;
padding: 20px;
text-align: center;
}
.vs-page-failed {
border: 2px dashed #ff3366;
}
.vs-page-failed::after {
content: 'Failed';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #ff3366;
font-size: 24px;
font-weight: bold;
}
.hidden { display: none !important; }
`;
const NAV_HTML = `
<div class="vs-nav">
<button id="vs-scroll-top" title="Scroll to top">▲</button>
<button id="vs-scroll-bottom" title="Scroll to bottom">▼</button>
<button id="vs-retry-failed" title="Retry failed pages">↻</button>
</div>
<div class="vs-nav-top">
<button id="vs-toggle-zoom">Zoom: 0.62x</button>
<button id="vs-show-failed"></button>
</div>
`;
class NHentaiVerticalScroll {
constructor() {
this.galleryId = null;
this.pages = 0;
this.currentZoom = 0.62;
this.container = null;
this.loadedPages = new Set();
this.failedPages = new Set();
this.loadedCount = 0;
this.initialized = false;
this.batchSize = 10;
this.currentBatchStart = 0;
this.isLoadingBatches = false;
this.maxRetries = 2;
}
extractGalleryInfo() {
console.log('[VS Debug] extractGalleryInfo called');
const thumbs = document.querySelectorAll('#thumbnail-container .thumb-container');
console.log('[VS Debug] Thumbnail containers found:', thumbs.length);
if (thumbs.length > 0) {
this.pages = thumbs.length;
const firstThumb = thumbs[0].querySelector('img');
if (firstThumb) {
console.log('[VS Debug] First thumb src:', firstThumb.src);
const srcMatch = firstThumb.src.match(/\/galleries\/(\d+)\//);
if (srcMatch) {
this.galleryId = srcMatch[1];
console.log('[VS Debug] Gallery ID from thumb:', this.galleryId);
}
}
}
if (!this.galleryId) {
console.log('[VS Debug] No gallery ID from thumbs, trying cover');
const coverImg = document.querySelector('#cover img');
console.log('[VS Debug] Cover img found:', !!coverImg);
if (coverImg) {
console.log('[VS Debug] Cover src:', coverImg.src);
const srcMatch = coverImg.src.match(/\/galleries\/(\d+)\//);
if (srcMatch) {
this.galleryId = srcMatch[1];
console.log('[VS Debug] Gallery ID from cover:', this.galleryId);
}
}
}
console.log('[VS Debug] Final result:', { galleryId: this.galleryId, pages: this.pages });
}
getImageUrls(pageNum) {
const servers = ['i1', 'i2', 'i3', 'i4'];
const server = servers[(pageNum - 1) % 4];
const base = `https://${server}.nhentai.net/galleries/${this.galleryId}/${pageNum}`;
return [`${base}.webp`, `${base}.jpg`, `${base}.png`];
}
getImageUrl(pageNum) {
return this.getImageUrls(pageNum)[0];
}
init() {
console.log('[VS Debug] init called');
if (this.initialized) return;
this.initialized = true;
this.extractGalleryInfo();
if (!this.galleryId || !this.pages) {
console.log('[VS Debug] Gallery info missing, showing error');
this.showError('Could not detect gallery info');
return;
}
this.injectStyles();
this.createVerticalView();
this.addNavigation();
this.setupKeyboardNav();
this.loadVisiblePages();
}
showError(msg) {
console.log('[VS Debug] showError called:', msg);
const errorDiv = document.createElement('div');
errorDiv.className = 'vs-error';
errorDiv.textContent = msg;
const content = document.querySelector('#content');
if (content) {
content.innerHTML = '';
content.appendChild(errorDiv);
}
}
injectStyles() {
console.log('[VS Debug] injectStyles called');
const styleEl = document.createElement('style');
styleEl.id = 'vs-styles';
styleEl.textContent = STYLE;
document.head.appendChild(styleEl);
console.log('[VS Debug] Styles injected');
}
createVerticalView() {
console.log('[VS Debug] createVerticalView called, pages:', this.pages);
const thumbnailContainer = document.querySelector('#thumbnail-container');
console.log('[VS Debug] Thumbnail container found:', !!thumbnailContainer);
if (thumbnailContainer) {
thumbnailContainer.classList.add('hidden');
}
this.container = document.createElement('div');
this.container.className = 'vs-container';
for (let i = 1; i <= this.pages; i++) {
const pageEl = document.createElement('div');
pageEl.className = 'vs-page';
pageEl.dataset.page = i;
pageEl.innerHTML = `<img class="loading" data-src="${this.getImageUrl(i)}" alt="Page ${i}">`;
this.container.appendChild(pageEl);
}
console.log('[VS Debug] Created', this.pages, 'page elements');
const bigcontainer = document.querySelector('#bigcontainer');
const relatedContainer = document.querySelector('#related-container');
const commentPostContainer = document.querySelector('#comment-post-container');
const commentContainer = document.querySelector('#comment-container');
console.log('[VS Debug] Containers - bigcontainer:', !!bigcontainer, 'related:', !!relatedContainer, 'commentPost:', !!commentPostContainer, 'comment:', !!commentContainer);
let inserted = false;
if (relatedContainer && relatedContainer.parentNode) {
relatedContainer.parentNode.insertBefore(this.container, relatedContainer);
inserted = true;
} else if (commentPostContainer && commentPostContainer.parentNode) {
commentPostContainer.parentNode.insertBefore(this.container, commentPostContainer);
inserted = true;
} else if (commentContainer && commentContainer.parentNode) {
commentContainer.parentNode.insertBefore(this.container, commentContainer);
inserted = true;
} else {
const content = document.querySelector('#content');
if (content) {
content.appendChild(this.container);
inserted = true;
}
}
console.log('[VS Debug] Container inserted:', inserted);
const loading = document.createElement('div');
loading.className = 'vs-loading';
loading.id = 'vs-loading';
loading.textContent = `Loading...`;
document.body.appendChild(loading);
console.log('[VS Debug] Loading indicator added');
setTimeout(() => {
this.loadInitialBatch();
}, 500);
}
loadInitialBatch() {
console.log('[VS Debug] loadInitialBatch called');
const loading = document.getElementById('vs-loading');
const pages = Array.from(this.container.querySelectorAll('.vs-page'));
const toLoad = pages.slice(0, Math.min(this.batchSize, this.pages));
console.log('[VS Debug] Loading initial batch of', toLoad.length, 'pages');
this.loadBatch(toLoad, loading, () => {
this.currentBatchStart = toLoad.length;
console.log('[VS Debug] Initial batch complete, batchStart:', this.currentBatchStart);
this.loadRemainingBatches();
});
}
loadBatch(pageEls, loading, onComplete) {
let loaded = 0;
const total = pageEls.length;
console.log('[VS Debug] loadBatch called with', total, 'elements');
if (total === 0) { if (onComplete) onComplete(); return; }
pageEls.forEach((pageEl) => {
this.loadImageWithRetry(pageEl, loading, () => {
loaded++;
if (loaded >= total && onComplete) onComplete();
});
});
}
loadImageWithRetry(pageEl, loading, onDone) {
const img = pageEl.querySelector('img');
const pageNum = parseInt(pageEl.dataset.page, 10);
const urls = this.getImageUrls(pageNum);
let retryCount = 0;
console.log('[VS Debug] loadImageWithRetry page', pageNum, 'urls:', urls);
const tryLoad = (urlIndex) => {
if (urlIndex >= urls.length) {
console.log('[VS Debug] All formats failed for page', pageNum);
this.markFailed(pageEl, pageNum, loading);
onDone();
return;
}
console.log('[VS Debug] Trying URL index', urlIndex, ':', urls[urlIndex]);
img.onload = () => {
img.classList.remove('loading');
this.loadedPages.add(pageNum);
this.loadedCount++;
if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
console.log('[VS Debug] Page', pageNum, 'loaded successfully');
this.checkAllLoaded();
onDone();
};
img.onerror = () => {
console.log('[VS Debug] Page', pageNum, 'failed, urlIndex:', urlIndex, 'retryCount:', retryCount);
if (retryCount < this.maxRetries) {
retryCount++;
console.log('[VS Debug] Retrying page', pageNum, 'attempt', retryCount);
setTimeout(() => tryLoad(urlIndex), 300 * retryCount);
} else {
retryCount = 0;
console.log('[VS Debug] Trying next format for page', pageNum);
tryLoad(urlIndex + 1);
}
};
img.src = urls[urlIndex];
};
tryLoad(0);
}
markFailed(pageEl, pageNum, loading) {
this.failedPages.add(pageNum);
this.loadedPages.add(pageNum);
this.loadedCount++;
if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
pageEl.classList.add('vs-page-failed');
const img = pageEl.querySelector('img');
img.classList.remove('loading');
img.alt = `Failed page ${pageNum} (ORB blocked)`;
img.style.opacity = '0.3';
img.dataset.src = '';
this.checkAllLoaded();
}
checkAllLoaded() {
const totalProcessed = this.loadedPages.size + this.failedPages.size;
console.log('[VS Debug] checkAllLoaded:', totalProcessed, '/', this.pages);
if (totalProcessed >= this.pages) {
const loading = document.getElementById('vs-loading');
if (loading) loading.classList.add('hidden');
}
}
loadRemainingBatches() {
console.log('[VS Debug] loadRemainingBatches called, batchStart:', this.currentBatchStart, 'pages:', this.pages);
if (this.isLoadingBatches) return;
if (this.currentBatchStart >= this.pages) {
console.log('[VS Debug] All batches loaded, stopping');
this.checkAllLoaded();
return;
}
this.isLoadingBatches = true;
const pages = Array.from(this.container.querySelectorAll('.vs-page'));
const nextBatch = pages.slice(this.currentBatchStart, this.currentBatchStart + this.batchSize);
console.log('[VS Debug] Loading next batch of', nextBatch.length, 'pages');
if (nextBatch.length === 0) {
this.isLoadingBatches = false;
this.checkAllLoaded();
return;
}
this.loadBatch(nextBatch, null, () => {
this.currentBatchStart += nextBatch.length;
console.log('[VS Debug] Batch complete, new batchStart:', this.currentBatchStart);
this.isLoadingBatches = false;
this.checkAllLoaded();
if (this.currentBatchStart < this.pages) {
setTimeout(() => this.loadRemainingBatches(), 100);
}
});
}
loadVisiblePages() {
if (!this.container) return;
const viewportHeight = window.innerHeight;
const scrollY = window.scrollY;
const pages = Array.from(this.container.querySelectorAll('.vs-page'));
const currentPage = Math.floor((scrollY + viewportHeight) / viewportHeight);
console.log('[VS Debug] loadVisiblePages called, scrollY:', scrollY, 'currentPage:', currentPage);
pages.forEach(pageEl => {
const pageNum = parseInt(pageEl.dataset.page, 10);
if (this.loadedPages.has(pageNum)) return;
if (pageNum <= currentPage + 3) {
const img = pageEl.querySelector('img');
if (img && img.dataset.src) {
console.log('[VS Debug] Loading visible page', pageNum);
this.loadedPages.add(pageNum);
this.loadImageWithRetry(pageEl, null, () => {});
}
}
});
}
addNavigation() {
console.log('[VS Debug] addNavigation called');
const navDiv = document.createElement('div');
navDiv.innerHTML = NAV_HTML;
document.body.appendChild(navDiv);
document.getElementById('vs-scroll-top').addEventListener('click', () => {
window.scrollTo({ top: 0, behavior: 'smooth' });
});
document.getElementById('vs-scroll-bottom').addEventListener('click', () => {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
});
document.getElementById('vs-toggle-zoom').addEventListener('click', (e) => {
this.toggleZoom(e.target);
});
document.getElementById('vs-retry-failed').addEventListener('click', () => {
this.retryFailedPages();
});
this.updateFailedButton();
console.log('[VS Debug] Navigation added');
}
updateFailedButton() {
const btn = document.getElementById('vs-show-failed');
if (!btn) return;
const count = this.failedPages.size;
btn.textContent = count > 0 ? `Failed: ${count}` : '';
btn.style.display = count > 0 ? 'block' : 'none';
}
retryFailedPages() {
console.log('[VS Debug] retryFailedPages called, failed count:', this.failedPages.size);
if (this.failedPages.size === 0) return;
const pages = Array.from(this.container.querySelectorAll('.vs-page-failed'));
this.failedPages.clear();
pages.forEach(pageEl => {
pageEl.classList.remove('vs-page-failed');
const img = pageEl.querySelector('img');
img.style.opacity = '';
img.alt = `Page ${pageEl.dataset.page}`;
this.loadImageWithRetry(pageEl, null, () => {});
});
this.updateFailedButton();
}
toggleZoom(btn) {
console.log('[VS Debug] toggleZoom called, current:', this.currentZoom);
if (this.currentZoom === 0.62) this.currentZoom = 1.0;
else if (this.currentZoom === 1.0) this.currentZoom = 1.5;
else this.currentZoom = 0.62;
document.querySelectorAll('.vs-page img').forEach(img => {
img.style.maxHeight = `${100 * this.currentZoom}vh`;
});
if (btn) btn.textContent = `Zoom: ${this.currentZoom}x`;
}
setupKeyboardNav() {
window.addEventListener('scroll', () => {
this.loadVisiblePages();
}, { passive: true });
setInterval(() => {
this.loadVisiblePages();
}, 500);
}
}
function initScript() {
console.log('[VS Debug] initScript called, pathname:', window.location.pathname);
if (!window.location.pathname.match(/^\/g\/\d+/)) {
console.log('[VS Debug] URL does not match gallery pattern, skipping');
return;
}
const existingStyles = document.querySelector('#vs-styles');
if (existingStyles) existingStyles.remove();
const existingNav = document.querySelector('.vs-nav');
if (existingNav) existingNav.remove();
const existingNavTop = document.querySelector('.vs-nav-top');
if (existingNavTop) existingNavTop.remove();
const existingLoading = document.querySelector('#vs-loading');
if (existingLoading) existingLoading.remove();
const vs = new NHentaiVerticalScroll();
let initAttempts = 0;
const maxInitAttempts = 20;
const tryInit = () => {
const content = document.querySelector('#content');
const thumbnailContainer = document.querySelector('#thumbnail-container');
console.log('[VS Debug] tryInit - content:', !!content, 'thumbnailContainer:', !!thumbnailContainer, 'initialized:', vs.initialized);
if (content && thumbnailContainer && !vs.initialized) {
console.log('[VS Debug] Calling vs.init()');
vs.init();
}
};
const observer = new MutationObserver((mutations) => {
if (vs.initialized) return;
for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
for (const node of mutation.addedNodes) {
if (node.nodeName === 'SECTION' || node.nodeName === 'DIV') {
if (node.id === 'thumbnail-container' || node.querySelector?.('#thumbnail-container')) {
console.log('[VS Debug] MutationObserver detected thumbnail-container');
setTimeout(() => vs.init(), 100);
return;
}
}
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
console.log('[VS Debug] MutationObserver started');
const pollInit = setInterval(() => {
tryInit();
initAttempts++;
console.log('[VS Debug] Poll attempt', initAttempts, '/', maxInitAttempts);
if (vs.initialized || initAttempts >= maxInitAttempts) {
clearInterval(pollInit);
console.log('[VS Debug] Polling stopped, initialized:', vs.initialized);
}
}, 500);
}
let lastPathname = window.location.pathname;
setInterval(() => {
if (window.location.pathname !== lastPathname) {
console.log('[VS Debug] URL changed from', lastPathname, 'to', window.location.pathname);
lastPathname = window.location.pathname;
if (window.location.pathname.match(/^\/g\/\d+/)) {
setTimeout(() => {
const styles = document.querySelector('#vs-styles');
if (styles) styles.remove();
const nav = document.querySelector('.vs-nav');
if (nav) nav.remove();
const navTop = document.querySelector('.vs-nav-top');
if (navTop) navTop.remove();
const loading = document.querySelector('#vs-loading');
if (loading) loading.remove();
initScript();
}, 500);
}
}
}, 1000);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initScript);
} else {
initScript();
}
})();