Hentaizap Vertical Scroll

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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