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.1
// @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';
    console.log('[VS Debug] Script loaded! Host:', window.location.hostname);

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

        extractGalleryInfo() {
            console.log('[VS Debug] 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);
                }
            }

            console.log('[VS Debug] 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() {
            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');
                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() {
            console.log('[VS Debug] injectStyles called');
            const styleEl = document.createElement('style');
            styleEl.id = 'vs-styles';
            styleEl.textContent = STYLE;
            document.head.appendChild(styleEl);
        }

        createVerticalView() {
            console.log('[VS Debug] createVerticalView called, pages:', this.pages);

            const thumbnailContainer = document.querySelector('#thumbnail-container');
            if (thumbnailContainer) thumbnailContainer.classList.add('hidden');

            const hentaizapThumbs = document.querySelectorAll('.thumbnail, .thumb, [class*="thumb"]');
            hentaizapThumbs.forEach(el => el.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 mainContent = document.querySelector('#content, main, .content, [role="main"]');
            if (mainContent) {
                mainContent.appendChild(this.container);
            } else {
                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);

            console.log('[VS Debug] Container inserted');

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

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

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

        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.normalizeWidths();
                    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}`;
            img.style.opacity = '0.3';
            img.dataset.src = '';
            this.checkAllLoaded();
            this.updateFailedButton();
        }

        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');
                this.normalizeWidths();
            }
        }

        normalizeWidths() {
            const images = this.container.querySelectorAll('.vs-page img');
            const baseWidth = 450;
            const targetWidth = Math.round(baseWidth * this.currentZoom / 0.62);
            images.forEach(img => {
                img.classList.add('uniform-width');
                img.style.width = targetWidth + 'px';
                img.style.maxWidth = targetWidth + 'px';
            });
        }

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

    function initScript() {
        console.log('[VS Debug] initScript called, pathname:', window.location.pathname);
        if (!window.location.pathname.match(/^\/gallery\/\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 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');
            console.log('[VS Debug] tryInit - content:', !!content, 'thumbs:', !!hasThumbs, 'initialized:', vs.initialized);
            if ((content || hasThumbs) && !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.matches?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th') ||
                               node.querySelector?.('.thumbnail, .thumb, [class*="thumb"], #ap_thumbs, .gp_th')) {
                                console.log('[VS Debug] MutationObserver detected thumbs');
                                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);

        tryInit();
    }

    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(/^\/gallery\/\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();
    }
})();