Hentaizap Vertical Scroll

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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