nhentai Vertical Scroll

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         nhentai Vertical Scroll
// @namespace    https://nhentai.net
// @version      1.4
// @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;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: rgba(0, 0, 0, 0.9);
            color: white;
            padding: 20px 40px;
            border-radius: 10px;
            font-family: sans-serif;
            font-size: 16px;
            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: 50px;
            height: 50px;
            border-radius: 50%;
            border: none;
            background: #ff3366;
            color: white;
            font-size: 20px;
            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: 10px 20px;
            border-radius: 20px;
            border: none;
            background: #ff3366;
            color: white;
            font-size: 14px;
            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;
        }
        .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>
        </div>
        <div class="vs-nav-top">
            <button id="vs-toggle-zoom">Zoom: 1.0x</button>
        </div>
    `;

    class NHentaiVerticalScroll {
        constructor() {
            this.galleryId = null;
            this.pages = 0;
            this.currentZoom = 1.0;
            this.container = null;
            this.loadedPages = new Set();
            this.loadedCount = 0;
            this.initialized = false;
        }

        extractGalleryInfo() {
            const thumbs = document.querySelectorAll('#thumbnail-container .thumb-container');
            if (thumbs.length > 0) {
                this.pages = thumbs.length;
                const firstThumb = thumbs[0].querySelector('img');
                if (firstThumb) {
                    const srcMatch = firstThumb.src.match(/\/galleries\/(\d+)\//);
                    if (srcMatch) {
                        this.galleryId = srcMatch[1];
                    }
                }
            }

            if (!this.galleryId) {
                const coverImg = document.querySelector('#cover img');
                if (coverImg) {
                    const srcMatch = coverImg.src.match(/\/galleries\/(\d+)\//);
                    if (srcMatch) {
                        this.galleryId = srcMatch[1];
                    }
                }
            }

            console.log('NHentai VS:', { galleryId: this.galleryId, pages: this.pages });
        }

        getImageUrl(pageNum) {
            const servers = ['i1', 'i2', 'i3', 'i4'];
            const server = servers[(pageNum - 1) % 4];
            return `https://${server}.nhentai.net/galleries/${this.galleryId}/${pageNum}.webp`;
        }

        init() {
            if (this.initialized) return;
            this.initialized = true;

            this.extractGalleryInfo();

            if (!this.galleryId || !this.pages) {
                this.showError('Could not detect gallery info');
                return;
            }

            this.injectStyles();
            this.createVerticalView();
            this.addNavigation();
            this.setupKeyboardNav();
            this.loadVisiblePages();
        }

        showError(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() {
            const styleEl = document.createElement('style');
            styleEl.id = 'vs-styles';
            styleEl.textContent = STYLE;
            document.head.appendChild(styleEl);
        }

        createVerticalView() {
            const thumbnailContainer = document.querySelector('#thumbnail-container');
            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);
            }

            const bigcontainer = document.querySelector('#bigcontainer');
            const relatedContainer = document.querySelector('#related-container');
            const commentPostContainer = document.querySelector('#comment-post-container');
            const commentContainer = document.querySelector('#comment-container');

            if (relatedContainer && relatedContainer.parentNode) {
                relatedContainer.parentNode.insertBefore(this.container, relatedContainer);
            } else if (commentPostContainer && commentPostContainer.parentNode) {
                commentPostContainer.parentNode.insertBefore(this.container, commentPostContainer);
            } else if (commentContainer && commentContainer.parentNode) {
                commentContainer.parentNode.insertBefore(this.container, commentContainer);
            } else {
                const content = document.querySelector('#content');
                if (content) {
                    content.appendChild(this.container);
                }
            }

            const loading = document.createElement('div');
            loading.className = 'vs-loading';
            loading.id = 'vs-loading';
            loading.textContent = `Loading...`;
            document.body.appendChild(loading);

            setTimeout(() => {
                this.loadInitialBatch();
            }, 500);
        }

        loadInitialBatch() {
            const loading = document.getElementById('vs-loading');
            const pages = Array.from(this.container.querySelectorAll('.vs-page'));
            const batchSize = 10;
            let loaded = 0;

            const toLoad = pages.slice(0, Math.min(batchSize, this.pages));

            toLoad.forEach((pageEl) => {
                const img = pageEl.querySelector('img');
                const pageNum = parseInt(pageEl.dataset.page, 10);

                img.onload = () => {
                    img.classList.remove('loading');
                    this.loadedPages.add(pageNum);
                    this.loadedCount++;
                    loaded++;
                    if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
                    if (loaded >= toLoad.length) {
                        setTimeout(() => { if (loading) loading.classList.add('hidden'); }, 300);
                    }
                };

                img.onerror = () => {
                    img.classList.remove('loading');
                    img.alt = `Failed page ${pageNum}`;
                    this.loadedPages.add(pageNum);
                    this.loadedCount++;
                    loaded++;
                    if (loaded >= toLoad.length) {
                        setTimeout(() => { if (loading) loading.classList.add('hidden'); }, 300);
                    }
                };

                img.src = img.dataset.src;
            });
        }

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

            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) {
                        img.src = img.dataset.src;
                        this.loadedPages.add(pageNum);

                        img.onload = () => img.classList.remove('loading');
                        img.onerror = () => {
                            img.classList.remove('loading');
                            img.alt = `Failed page ${pageNum}`;
                        };
                    }
                }
            });
        }

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

        toggleZoom(btn) {
            this.currentZoom = this.currentZoom === 1.0 ? 1.5 : 1.0;
            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() {
        if (document.querySelector('#vs-styles')) return;

        const vs = new NHentaiVerticalScroll();

        const observer = new MutationObserver((mutations) => {
            let shouldInit = false;
            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')) {
                                shouldInit = true;
                                break;
                            }
                        }
                    }
                }
            }
            if (shouldInit && !vs.initialized) {
                setTimeout(() => vs.init(), 100);
            }
        });

        observer.observe(document.body, { childList: true, subtree: true });

        setTimeout(() => {
            const content = document.querySelector('#content');
            const thumbnailContainer = document.querySelector('#thumbnail-container');
            if (content && thumbnailContainer && !vs.initialized) {
                vs.init();
            }
        }, 2000);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', initScript);
    } else {
        initScript();
    }
})();