nhentai Vertical Scroll

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

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

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