GPT gallery downloader (GPTGD)

Creates a button to download all images from galleries & favorite photos in a single .zip file. Large collections are split into multiple zip files.

Verze ze dne 22. 09. 2025. Zobrazit nejnovější verzi.

// ==UserScript==
// @name         GPT gallery downloader (GPTGD)
// @namespace    _pc
// @version      6.5
// @license      MIT
// @description  Creates a button to download all images from galleries & favorite photos in a single .zip file. Large collections are split into multiple zip files.
// @author       verydelight
// @match        https://www.gayporntube.com/user/*
// @match        https://www.gayporntube.com/galleries/*
// @connect      gayporntube.com
// @connect      media-1-albums.gayporntube.com
// @connect      media-2-albums.gayporntube.com
// @icon         https://www.gayporntube.com/favicon.ico
// @compatible   Firefox Tampermonkey
// @grant        GM.xmlHttpRequest
// @grant        GM.download
// @require      https://update.greasyfork.org/scripts/473358/1237031/JSZip.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/FileSaver.min.js
// ==/UserScript==


(function() {
    'use strict';

    const enableDebugLog = false; // Set to true for detailed console logging

    console.log("GPTGD: Script is starting.");

    const profileHashPattern = /^#page.*-favorite_photos.*$/;

    const allImageUrls = new Set();
    const baseUrl = window.location.href.split('#')[0];
    let zip;

    let downloadStatus;
    let progressElement;
    let downloadButton;
    let totalImagesToDownload = 0;
    let imagesDownloadedOverall = 0;
    let mainPageRetryCount = 0;
    const maxRetries = 10;
    const CHUNK_SIZE = 50;
    const CHUNK_THRESHOLD = 75;

    const convertToFullSizeUrl = (url) => {
        const pattern1 = /\/thumbs/;
        const pattern2 = /\/main\/\d{1,4}x\d{1,4}/;
        return url.replace(pattern1, '').replace(pattern2, '/sources');
    };

    const extractAndStoreImages = (container) => {
        if (enableDebugLog) console.log("GPTGD: Extracting images from container:", container);
        const images = container.querySelectorAll('img');
        if (enableDebugLog) console.log(`GPTGD: Found ${images.length} images.`);
        images.forEach((img) => {
            let imageUrl = img.src;
            if (!imageUrl || imageUrl.startsWith('data:')) {
                imageUrl = img.getAttribute('data-src');
            }
            if (imageUrl) {
                const fullSizeUrl = convertToFullSizeUrl(imageUrl);
                allImageUrls.add(fullSizeUrl);
                if (enableDebugLog) console.log(`GPTGD: Added image URL to set: ${fullSizeUrl}`);
            }
        });
        if (enableDebugLog) console.log(`GPTGD: Current total unique images: ${allImageUrls.size}`);
    };

    const downloadFile = (url, filename, zipInstance) => {
        if (enableDebugLog) console.log(`GPTGD: Starting download for: ${url}`);
        return new Promise((resolve, reject) => {
            GM.xmlHttpRequest({
                method: 'GET',
                responseType: 'blob',
                url: url,
                headers: {
                    "Content-Type": "image/jpeg",
                    "Accept": "image/jpeg"
                },
                onload: (response) => {
                    if (enableDebugLog) console.log(`GPTGD: Download successful for: ${filename}`);
                    const blob = new Blob([response.response], {
                        type: 'image/jpeg'
                    });
                    zipInstance.file(filename, blob, {
                        binary: true
                    });
                    resolve();
                },
                onerror: (err) => {
                    console.error("GPTGD: Error in fetching and downloading file: ", url, err);
                    reject(err);
                }
            });
        });
    };

    const scrapeAllPages = (totalPages, updateStatusCallback) => {
        if (enableDebugLog) console.log(`GPTGD: Starting multi-page scraping for ${totalPages} pages.`);
        return new Promise((resolve) => {
            let currentPage = 1;

            const processNextPage = () => {
                if (currentPage > totalPages) {
                    if (enableDebugLog) console.log(`GPTGD: Finished scraping. Found a total of ${allImageUrls.size} unique images across all pages.`);
                    resolve();
                    return;
                }

                updateStatusCallback(`Processing page ${currentPage}/${totalPages}...`);

                const iframe = document.createElement('iframe');
                iframe.style.display = 'none';
                iframe.src = `${baseUrl}#page${currentPage}-favorite_photos`;
                if (enableDebugLog) console.log(`GPTGD: Loading iframe for page ${currentPage}: ${iframe.src}`);

                iframe.onload = () => {
                    if (enableDebugLog) console.log(`GPTGD: Iframe loaded for page ${currentPage}.`);
                    let iframeRetryCount = 0;
                    const maxIframeRetries = 10;

                    const checkIframeContent = () => {
                        try {
                            const doc = iframe.contentDocument || iframe.contentWindow.document;
                            const imageContainer = doc.querySelector('#custom_fav_albums_images_fav_albums_images');

                            if (imageContainer) {
                                if (enableDebugLog) console.log(`GPTGD: Found image container in iframe for page ${currentPage}.`);
                                extractAndStoreImages(imageContainer);
                                if (enableDebugLog) console.log(`GPTGD: Scraped page ${currentPage}. Current images: ${allImageUrls.size}`);
                                iframe.remove();
                                currentPage++;
                                processNextPage();
                            } else if (iframeRetryCount < maxIframeRetries) {
                                iframeRetryCount++;
                                if (enableDebugLog) console.log(`GPTGD: Image container not found in iframe for page ${currentPage}. Retrying... (Attempt ${iframeRetryCount}/${maxIframeRetries})`);
                                setTimeout(checkIframeContent, 500);
                            } else {
                                console.error(`GPTGD: Failed to find image container in iframe for page ${currentPage} after multiple retries.`);
                                iframe.remove();
                                currentPage++;
                                processNextPage();
                            }
                        } catch (e) {
                            console.error(`GPTGD: Error processing iframe content for page ${currentPage}:`, e);
                            iframe.remove();
                            currentPage++;
                            processNextPage();
                        }
                    };
                    checkIframeContent();
                };

                document.body.appendChild(iframe);
            };

            processNextPage();
        });
    };

    const calculateTotalParts = (totalImages) => {
        if (totalImages <= CHUNK_THRESHOLD) {
            return 1;
        }
        let parts = 1;
        let imagesLeft = totalImages - CHUNK_SIZE;
        while (imagesLeft > 0) {
            if (imagesLeft > CHUNK_THRESHOLD) {
                parts++;
                imagesLeft -= CHUNK_SIZE;
            } else {
                parts++;
                imagesLeft = 0;
            }
        }
        return parts;
    };


    const processAndZipChunk = async (urls, partNumber, totalParts, isSingleChunk) => {
        zip = new JSZip();
        let downloadTime = 0;
        const totalInChunk = urls.length;
        const partText = isSingleChunk ? '' : `part ${partNumber}/${totalParts}: `;

        for (let i = 0; i < totalInChunk; i++) {
            const url = urls[i];
            const fileName = url.split('/').pop();
            const downloadStart = Date.now();
            try {
                await downloadFile(url, fileName, zip);
                const downloadEnd = Date.now();
                downloadTime += downloadEnd - downloadStart;
                imagesDownloadedOverall++;

                const overallProgressPercent = Math.round((imagesDownloadedOverall / totalImagesToDownload) * 100);
                progressElement.setAttribute("value", overallProgressPercent);

                const averageTimePerFile = downloadTime / (i + 1);
                const downloadEstimate = Math.round((averageTimePerFile * (totalImagesToDownload - imagesDownloadedOverall)) / 1000);
                const timeRemaining = downloadEstimate > 0 ? ` (ca.: ${formatTime(downloadEstimate)} remaining)` : '';
                if (partText){
                    downloadStatus.textContent = `Downloading ${partText}image ${i + 1}/${totalInChunk} [${imagesDownloadedOverall}/${totalImagesToDownload}] ${timeRemaining}`;
                }else{
                    downloadStatus.textContent = `Downloading image ${i + 1}/${totalInChunk} ${timeRemaining}`;
                }
            } catch (e) {
                console.error("GPTGD: Failed to download image: ", url, e);
                downloadStatus.textContent = `Error downloading image ${imagesDownloadedOverall + 1} (${i + 1} of current chunk). Skipping...`;
            }
        }

        downloadStatus.textContent = isSingleChunk ? `Downloaded. Zipping files...` : `Part ${partNumber}/${totalParts} downloaded. Zipping files...`;

        let finalZipFileName = getZipFileName();
        if (!isSingleChunk) {
            finalZipFileName += `_part${partNumber}`;
        }

        const content = await zip.generateAsync({
            type: "blob",
            compression: "DEFLATE",
            compressionOptions: {
                level: 6
            }
        });
        saveAs(content, finalZipFileName);
        downloadStatus.textContent = isSingleChunk ? `Saved.` : `Part ${partNumber}/${totalParts} saved.`;

        if (enableDebugLog) console.log(`GPTGD: Part ${partNumber} download complete and file saved.`);
    };

    const startDownload = async () => {
        if (enableDebugLog) console.log("GPTGD: Starting download process.");
        downloadStatus.textContent = "Starting to download images...";
        progressElement.style.display = "block";

        let imageUrls = Array.from(allImageUrls);
        totalImagesToDownload = imageUrls.length;
        imagesDownloadedOverall = 0;
        if (enableDebugLog) console.log(`GPTGD: Total images to download: ${totalImagesToDownload}`);

        const isSingleChunk = totalImagesToDownload <= CHUNK_THRESHOLD;
        const totalParts = calculateTotalParts(totalImagesToDownload);

        let partNumber = 1;
        while (imageUrls.length > 0) {
            let chunkSize;
            if (imageUrls.length > CHUNK_THRESHOLD) {
                chunkSize = CHUNK_SIZE;
            } else {
                chunkSize = imageUrls.length;
            }

            const chunk = imageUrls.splice(0, chunkSize);
            await processAndZipChunk(chunk, partNumber, totalParts, isSingleChunk);
            partNumber++;
        }

        downloadStatus.textContent = "All parts downloaded and saved!";
        if (enableDebugLog) console.log("GPTGD: All downloads complete.");
    };

    const getZipFileName = () => {
        if (window.location.href.includes('/galleries/')) {
            const zipFileName = document.querySelector('h1.title').innerText
                .replace(/[^a-zA-Z0-9-_ ]/g, '')
                .replace(/\s+/g, ' ')
                .substring(0, 245)
                .replace(/^_+|_+$/g, '')
                .trim();
            return `GPT Gallery - ${zipFileName}`;
        } else {
            const userOrGallery = window.location.pathname.split('/')[2];
            return `GPT Favorites - ${userOrGallery}`;
        }
    };


    function formatTime(seconds) {
        const minutes = Math.floor(seconds / 60);
        const secs = seconds % 60;
        const formattedMinutes = minutes < 10 ? "0" + minutes : minutes;
        const formattedSeconds = secs < 10 ? "0" + secs : secs;
        return minutes > 0 ? `${formattedMinutes}:${formattedSeconds} minutes` : `${formattedSeconds} seconds`;
    }

    const createDownloadUI = (container) => {
        if (enableDebugLog) console.log("GPTGD: Creating download UI.");
        const existingButton = document.querySelector('.gptgd-bttn');
        if (existingButton) existingButton.remove();

        downloadStatus = document.createElement("div");
        progressElement = document.createElement("progress");
        downloadButton = document.createElement("button");
        if (window.location.href.includes('/galleries/')) {
            downloadButton.textContent = "Download gallery";
        } else if (window.location.href.includes('/user/')){
            downloadButton.textContent = "Download favourites";
        }
        downloadButton.type = "button";
        downloadButton.classList.add('gptgd-bttn');
        downloadButton.style.cssText = "padding: 10px 20px; font-size: 16px; font-weight: bold; color: white; background-color: #007BFF; border: none; border-radius: 5px; cursor: pointer; margin-top: 10px;";

        progressElement.setAttribute("value", 0);
        progressElement.setAttribute("max", 100);
        progressElement.style.width = "100%";
        progressElement.style.height = "20px";
        progressElement.style.display = "none";

        downloadStatus.style.cssText = "margin-top: 10px; font-style: italic;";

        const h2 = container.querySelector('h2');
        const h1 = container.querySelector('h1');
        if (h2) {
            h2.insertAdjacentElement('afterend', downloadButton);
            downloadButton.insertAdjacentElement('afterend', downloadStatus);
            downloadStatus.insertAdjacentElement('afterend', progressElement);
        } else if (h1) {
            h1.insertAdjacentElement('afterend', downloadButton);
            downloadButton.insertAdjacentElement('afterend', downloadStatus);
            downloadStatus.insertAdjacentElement('afterend', progressElement);
        } else {
            container.prepend(downloadButton, downloadStatus, progressElement);
        }
        if (enableDebugLog) console.log("GPTGD: Download UI created successfully.");
    };

    const handleDOMChanges = () => {
        if (enableDebugLog) console.log("GPTGD: handleDOMChanges called.");
        if (enableDebugLog) console.log(`GPTGD: Current URL is ${window.location.href}`);
        const isGalleryPage = window.location.href.includes('/galleries/');
        const isProfilePage = window.location.href.includes('/user/') && window.location.hash.match(profileHashPattern);
        if (enableDebugLog) console.log(`GPTGD: isGalleryPage = ${isGalleryPage}, isProfilePage = ${isProfilePage}`);

        let container = null;
        if (isGalleryPage) {
            container = document.querySelector('#album_view_album_view');
        } else if (isProfilePage) {
            container = document.querySelector('#custom_fav_albums_images_fav_albums_images');
        }

        if (container) {
            mainPageRetryCount = 0;
            const buttonExists = document.querySelector('.gptgd-bttn');
            if (!buttonExists) {
                if (enableDebugLog) console.log("GPTGD: Required container found and button does not exist. Creating UI.");
                allImageUrls.clear();
                createDownloadUI(container);
                downloadButton.addEventListener("click", async () => {
                    if (enableDebugLog) console.log("GPTGD: Download button clicked.");
                    downloadButton.style.display = "none";
                    if (isGalleryPage) {
                        if (enableDebugLog) console.log("GPTGD: Gallery download logic triggered.");
                        downloadStatus.textContent = "Processing images on current page...";
                        const images = document.querySelectorAll('#album_view_album_view > #tab5 img');
                        if (enableDebugLog) console.log(`GPTGD: Found ${images.length} images on the gallery page.`);
                        images.forEach((img) => {
                            let imageUrl = img.getAttribute('data-src');
                            if (imageUrl) {
                                const fullSizeUrl = convertToFullSizeUrl(imageUrl);
                                allImageUrls.add(fullSizeUrl);
                            }
                        });
                        if (enableDebugLog) console.log(`GPTGD: Extracted ${allImageUrls.size} image URLs from the gallery page.`);
                        startDownload();
                    } else if (isProfilePage) {
                        if (enableDebugLog) console.log("GPTGD: Profile download logic triggered.");
                        const paginationContainer = document.querySelector('#custom_fav_albums_images_fav_albums_images_pagination');
                        if (paginationContainer) {
                            const nextButton = paginationContainer.querySelector('li.next');
                            const lastPageLi = nextButton?.previousElementSibling;
                            const totalPages = parseInt(lastPageLi?.textContent.trim(), 10) || 1;
                            if (enableDebugLog) console.log(`GPTGD: Pagination found. Total pages: ${totalPages}`);

                            downloadStatus.textContent = `Processing page 1/${totalPages}...`;
                            await scrapeAllPages(totalPages, (status) => {
                                downloadStatus.textContent = status;
                            });
                        } else {
                            if (enableDebugLog) console.log("GPTGD: No pagination container found, assuming single page.");
                            downloadStatus.textContent = `Processing images on current page...`;
                            extractAndStoreImages(container);
                        }
                        startDownload();
                    }
                });
            } else {
                if (enableDebugLog) console.log("GPTGD: Button already exists, no need to recreate UI.");
            }
        } else if (mainPageRetryCount < maxRetries) {
            mainPageRetryCount++;
            if (enableDebugLog) console.log(`GPTGD: Container not found. Retrying in 500ms... (Attempt ${mainPageRetryCount}/${maxRetries})`);
            setTimeout(handleDOMChanges, 500);
        } else {
            if (enableDebugLog) console.log("GPTGD: Failed to find required container after multiple retries.");
            const existingButton = document.querySelector('.gptgd-bttn');
            if (existingButton) {
                if (enableDebugLog) console.log("GPTGD: Navigated away from a target page. Removing UI.");
                existingButton.remove();
                if (downloadStatus) downloadStatus.remove();
                if (progressElement) progressElement.remove();
            }
        }
    };

    if (enableDebugLog) console.log("GPTGD: Starting MutationObserver on the body.");
    const observer = new MutationObserver(handleDOMChanges);
    observer.observe(document.body, { childList: true, subtree: true });

    handleDOMChanges();
})();