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.

目前為 2025-09-22 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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