nhentai Vertical Scroll

View nhentai galleries in a vertical scroll instead of clicking page by page

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(Tôi đã có Trình quản lý tập lệnh người dùng, hãy cài đặt nó!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

Advertisement:

// ==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();
    }
})();