Hentaizap Vertical Scroll

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Hentaizap Vertical Scroll
// @namespace    https://hentaizap.com
// @version      3.3
// @author       JoyArz
// @description  View hentaizap galleries in a vertical scroll instead of clicking page by page
// @match        https://hentaizap.com/gallery/*/
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const DEBUG = false;
    const log = (...args) => DEBUG && console.log('[VS Debug]', ...args);

    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; align-items: flex-start; background: #0f0f14; margin-bottom: 8px; min-height: 100px; }
        .vs-page img { max-width: 100%; max-height: 100vh; width: auto; height: auto; object-fit: contain; display: block; background: #1a1a22; }
        .vs-page img.loading { opacity: 0.3; background: transparent; }
        .vs-page img.uniform-width { height: auto; }
        .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; }
        .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 HentaizapScroll {
        constructor() {
            this.galleryId = null;
            this.pages = 0;
            this.currentZoom = 0.62;
            this.container = null;
            this.loadedPages = new Set();
            this.failedPages = new Set();
            this.queuedPages = new Set();
            this.loadedCount = 0;
            this.initialized = false;

            this.batchSize = 10;
            this.currentBatchStart = 0;
            this.isLoadingBatches = false;
            this.maxRetries = 2;

            this.hentaizapServer = null;
            this.hentaizapDir = null;
            this.hentaizapHash = null;

            this._normalizeTimer = null;
            this.pageObserver = null;
        }

        extractGalleryInfo() {
            log('extractGalleryInfo called');
            const pathMatch = window.location.pathname.match(/\/gallery\/(\d+)/);
            if (pathMatch) this.galleryId = pathMatch[1];

            const loadServer = document.getElementById('load_server');
            const loadDir = document.getElementById('load_dir');
            const loadId = document.getElementById('load_id');
            const loadPages = document.getElementById('load_pages');

            if (loadServer) this.hentaizapServer = loadServer.value;
            if (loadDir) this.hentaizapDir = loadDir.value;
            if (loadId) this.hentaizapHash = loadId.value;
            if (loadPages) this.pages = parseInt(loadPages.value, 10) || 0;

            if (this.pages === 0) {
                const pageNav = document.querySelector('.page-nav, .pagination, [class*="page"]');
                if (pageNav) {
                    const nums = pageNav.textContent.match(/\d+/g);
                    if (nums && nums.length > 0) {
                        this.pages = parseInt(nums[nums.length - 1], 10);
                    }
                }
            }

            if (this.pages === 0) {
                const infoPg = document.querySelector('.info_pg');
                if (infoPg) {
                    const m = infoPg.textContent.match(/Pages?:\s*(\d+)/i);
                    if (m) this.pages = parseInt(m[1], 10);
                }
            }

            log('Final result:', { galleryId: this.galleryId, pages: this.pages });
        }

        getImageUrls(pageNum) {
            if (!this.hentaizapServer || !this.hentaizapDir || !this.hentaizapHash) return [];
            const server = `m${this.hentaizapServer}`;
            const base = `https://${server}.hentaizap.com/${this.hentaizapDir}/${this.hentaizapHash}/${pageNum}`;
            return [`${base}.webp`, `${base}.jpg`, `${base}.jpeg`, `${base}.png`];
        }

        getImageUrl(pageNum) {
            return this.getImageUrls(pageNum)[0];
        }

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

            this.extractGalleryInfo();

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

            this.injectStyles();
            this.createVerticalView();
            this.addNavigation();
            this.setupObserver();
            this.loadInitialBatch();
        }

        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() {
            log('createVerticalView called, pages:', this.pages);

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

            // Replace #ap_thumbs in-place so the reader sits right after the
            // info/action-button area, not appended to the bottom of the page.
            const apThumbs = document.getElementById('ap_thumbs');
            if (apThumbs) {
                apThumbs.replaceWith(this.container);
            } else {
                // Fallback: hide any stray thumbnail elements, then append to content
                document.querySelectorAll('.thumbnail, .thumb, [class*="thumb"], #thumbnail-container')
                    .forEach(el => el.classList.add('hidden'));
                const mainContent = document.querySelector('#content, main, .content, [role="main"]');
                (mainContent || document.body).appendChild(this.container);
            }

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

            log('Container inserted');
        }

        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);
            });
            document.getElementById('vs-retry-failed').addEventListener('click', () => {
                this.retryFailedPages();
            });

            this.updateFailedButton();
        }

        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() {
            log('retryFailedPages called, failed count:', this.failedPages.size);
            if (this.failedPages.size === 0) return;

            const failedEls = Array.from(this.container.querySelectorAll('.vs-page-failed'));
            this.failedPages.clear();

            failedEls.forEach(pageEl => {
                const pageNum = parseInt(pageEl.dataset.page, 10);
                pageEl.classList.remove('vs-page-failed');
                const img = pageEl.querySelector('img');
                img.style.opacity = '';
                img.alt = `Page ${pageNum}`;
                this.loadedPages.delete(pageNum);
                this.queuedPages.delete(pageNum);
                this.loadedCount--;
                this.loadImageWithRetry(pageEl, null, () => {});
            });

            this.updateFailedButton();
        }

        toggleZoom(btn) {
            if (this.currentZoom === 0.62) this.currentZoom = 1.0;
            else if (this.currentZoom === 1.0) this.currentZoom = 1.5;
            else this.currentZoom = 0.62;
            this.normalizeWidths();
            if (btn) btn.textContent = `Zoom: ${this.currentZoom}x`;
        }

        setupObserver() {
            if (!('IntersectionObserver' in window)) {
                window.addEventListener('scroll', () => this.loadVisiblePagesFallback(), { passive: true });
                return;
            }

            this.pageObserver = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) this.loadNearbyPages(entry.target);
                });
            }, { rootMargin: '300px 0px' });

            this.container.querySelectorAll('.vs-page').forEach(page => {
                this.pageObserver.observe(page);
            });
        }

        loadNearbyPages(pageEl) {
            const pageNum = parseInt(pageEl.dataset.page, 10);
            for (let i = pageNum; i <= Math.min(pageNum + 3, this.pages); i++) {
                if (this.loadedPages.has(i) || this.queuedPages.has(i)) continue;
                const el = this.container.querySelector(`[data-page="${i}"]`);
                if (!el) continue;
                this.queuedPages.add(i);
                this.loadImageWithRetry(el, document.getElementById('vs-loading'), () => {});
            }
        }

        loadVisiblePagesFallback() {
            if (!this.container) return;
            const currentPage = Math.floor((window.scrollY + window.innerHeight) / window.innerHeight);
            this.container.querySelectorAll('.vs-page').forEach(pageEl => {
                const pageNum = parseInt(pageEl.dataset.page, 10);
                if (this.loadedPages.has(pageNum) || this.queuedPages.has(pageNum)) return;
                if (pageNum <= currentPage + 3) {
                    this.queuedPages.add(pageNum);
                    this.loadImageWithRetry(pageEl, null, () => {});
                }
            });
        }

        loadInitialBatch() {
            log('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));

            toLoad.forEach(pageEl => {
                this.queuedPages.add(parseInt(pageEl.dataset.page, 10));
            });

            this.loadBatch(toLoad, loading, () => {
                this.currentBatchStart = toLoad.length;
                log('Initial batch complete, batchStart:', this.currentBatchStart);
                this.loadRemainingBatches();
            });
        }

        loadBatch(pageEls, loading, onComplete) {
            const total = pageEls.length;
            if (total === 0) { if (onComplete) onComplete(); return; }
            let loaded = 0;
            pageEls.forEach(pageEl => {
                this.loadImageWithRetry(pageEl, loading, () => {
                    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;
            log('loadImageWithRetry page', pageNum);

            const tryLoad = (urlIndex) => {
                if (urlIndex >= urls.length) {
                    log('All formats failed for page', pageNum);
                    this.markFailed(pageEl, pageNum, loading);
                    onDone();
                    return;
                }

                img.onload = () => {
                    img.classList.remove('loading');
                    this.loadedPages.add(pageNum);
                    this.loadedCount++;
                    if (loading) loading.textContent = `Loaded ${this.loadedCount}/${this.pages}`;
                    log('Page', pageNum, 'loaded');
                    this.normalizeWidths();
                    this.checkAllLoaded();
                    onDone();
                };

                img.onerror = () => {
                    if (retryCount < this.maxRetries) {
                        retryCount++;
                        setTimeout(() => tryLoad(urlIndex), 300 * retryCount);
                    } else {
                        retryCount = 0;
                        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}`;
            img.style.opacity = '0.3';
            img.dataset.src = '';
            this.checkAllLoaded();
            this.updateFailedButton();
        }

        checkAllLoaded() {
            log('checkAllLoaded:', this.loadedPages.size, '/', this.pages);
            if (this.loadedPages.size >= this.pages) {
                const loading = document.getElementById('vs-loading');
                if (loading) loading.classList.add('hidden');
                this.normalizeWidths();
            }
        }

        normalizeWidths() {
            if (this._normalizeTimer) clearTimeout(this._normalizeTimer);
            this._normalizeTimer = setTimeout(() => {
                if (!this.container) return;
                const baseWidth = 450;
                const targetWidth = Math.min(
                    Math.round(baseWidth * this.currentZoom / 0.62),
                    window.innerWidth
                );
                this.container.querySelectorAll('.vs-page img').forEach(img => {
                    img.classList.add('uniform-width');
                    img.style.width = targetWidth + 'px';
                    img.style.maxWidth = targetWidth + 'px';
                });
            }, 100);
        }

        loadRemainingBatches() {
            log('loadRemainingBatches called, batchStart:', this.currentBatchStart);
            if (this.isLoadingBatches || this.currentBatchStart >= this.pages) {
                if (this.currentBatchStart >= this.pages) 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);

            if (nextBatch.length === 0) {
                this.isLoadingBatches = false;
                this.checkAllLoaded();
                return;
            }

            nextBatch.forEach(pageEl => {
                this.queuedPages.add(parseInt(pageEl.dataset.page, 10));
            });

            this.loadBatch(nextBatch, null, () => {
                this.currentBatchStart += nextBatch.length;
                this.isLoadingBatches = false;
                this.checkAllLoaded();
                if (this.currentBatchStart < this.pages) {
                    setTimeout(() => this.loadRemainingBatches(), 100);
                }
            });
        }
    }

    function initScript() {
        log('initScript called, pathname:', window.location.pathname);
        if (!window.location.pathname.match(/^\/gallery\/\d+/)) return;

        // Clean up any previous instance
        ['#vs-styles', '.vs-nav', '.vs-nav-top', '#vs-loading'].forEach(sel => {
            document.querySelector(sel)?.remove();
        });

        const vs = new HentaizapScroll();
        let initAttempts = 0;
        const maxInitAttempts = 20;

        const tryInit = () => {
            const content = document.querySelector('#content, main, .content, [role="main"]');
            const hasThumbs = document.querySelector('.thumbnail, .thumb, [class*="thumb"], #thumbnail-container, #ap_thumbs, .gp_th');
            if ((content || hasThumbs) && !vs.initialized) vs.init();
        };

        const mutationObserver = new MutationObserver((mutations) => {
            if (vs.initialized) return;
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeName === 'SECTION' || node.nodeName === 'DIV') {
                        if (node.matches?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th') ||
                            node.querySelector?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th')) {
                            setTimeout(() => vs.init(), 100);
                            return;
                        }
                    }
                }
            }
        });

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

        const pollInit = setInterval(() => {
            tryInit();
            if (vs.initialized || ++initAttempts >= maxInitAttempts) {
                clearInterval(pollInit);
                mutationObserver.disconnect();
            }
        }, 500);

        tryInit();
    }

    // SPA navigation detection
    let lastPathname = window.location.pathname;
    setInterval(() => {
        if (window.location.pathname !== lastPathname) {
            lastPathname = window.location.pathname;
            if (window.location.pathname.match(/^\/gallery\/\d+/)) {
                setTimeout(() => initScript(), 500);
            }
        }
    }, 1000);

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