Civitai Image Downloader and Metadata Extractor

Adds a toggle for automatic/manual downloading. In manual mode, click the original download button to trigger.

目前為 2025-06-13 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Civitai Image Downloader and Metadata Extractor
// @name:zh-CN   Civitai 图像下载及元数据提取器 (带开关)
// @namespace    http://tampermonkey.net/
// @version      2.0
// @description  Adds a toggle for automatic/manual downloading. In manual mode, click the original download button to trigger.
// @description:zh-CN 在Civitai图片页自动或手动下载图片和同名元数据.txt文件。新增自动/手动模式开关。
// @author       Your Name (with major enhancements)
// @match        https://civitai.com/images/*
// @icon         https://civitai.com/favicon-32x32.png
// @grant        GM_download
// @grant        GM_log
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_addStyle
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // --- 配置 & 常量 ---
    const AUTO_DOWNLOAD_KEY = 'civitaiDownloader_autoMode'; // 用于存储开关状态的键

    // --- 添加自定义样式 ---
    // 为我们的开关按钮在“激活”状态下设置一个蓝色背景
    GM_addStyle(`
        .civitai-downloader-toggle.active {
            background-color: #228be6 !important; /* 一个漂亮的蓝色 */
            border-color: #228be6 !important;
        }
    `);

    // ==============================================================================
    // --- 核心下载逻辑 (封装成一个函数,以便重复调用) ---
    // ==============================================================================
    async function startFullDownload() {
        console.log("Civitai Downloader: Starting full download process...");
        try {
            const metadata = extractAllMetadata();
            if (!metadata) {
                console.error("Civitai Downloader: Could not extract metadata.");
                return;
            }

            const imageId = window.location.pathname.split('/')[2];
            if (!imageId) {
                console.error("Civitai Downloader: Could not determine image ID.");
                return;
            }

            // 1. 下载 TXT 文件
            const textContent = formatMetadataAsText(metadata);
            const txtFilename = `${imageId}.txt`;
            downloadTextFile(textContent, txtFilename);

            // 2. 智能等待并下载图片
            await downloadImageWhenReady(imageId);

        } catch (error) {
            console.error("Civitai Downloader: An error occurred in the main download function.", error);
        }
    }


    // ==============================================================================
    // --- 主程序入口 ---
    // ==============================================================================
    function initialize() {
        console.log("Civitai Downloader: Initializing...");

        // 等待页面元素加载完毕
        const checkInterval = setInterval(() => {
            const downloadButton = document.querySelector('svg.tabler-icon-download')?.closest('button');
            if (downloadButton) {
                clearInterval(checkInterval); // 找到元素,停止检查
                run(downloadButton);
            }
        }, 500);

        // 设置一个超时,以防页面结构大改,找不到按钮
        setTimeout(() => clearInterval(checkInterval), 15000);
    }

    function run(downloadButton) {
        const buttonContainer = downloadButton.parentElement;
        if (!buttonContainer) return;

        // 1. 读取保存的开关状态 (默认为 true, 即自动模式)
        let isAutoMode = GM_getValue(AUTO_DOWNLOAD_KEY, true);

        // 2. 创建并注入我们的开关按钮
        const toggleButton = createToggleButton(downloadButton);
        buttonContainer.insertBefore(toggleButton, downloadButton); // 插入到下载按钮左边

        // 3. 定义一个更新按钮视觉状态的函数
        function updateToggleVisual() {
            if (isAutoMode) {
                toggleButton.classList.add('active');
                toggleButton.title = "自动下载模式已开启";
            } else {
                toggleButton.classList.remove('active');
                toggleButton.title = "自动下载模式已关闭 (点击原下载按钮手动触发)";
            }
        }
        updateToggleVisual(); // 初始化按钮颜色

        // 4. 为开关按钮添加点击事件
        toggleButton.addEventListener('click', () => {
            isAutoMode = !isAutoMode; // 切换状态
            GM_setValue(AUTO_DOWNLOAD_KEY, isAutoMode); // 保存新状态
            updateToggleVisual(); // 更新按钮颜色
            console.log(`Civitai Downloader: Auto mode set to ${isAutoMode}`);
        });

        // 5. 为原始下载按钮添加“劫持”事件
        downloadButton.addEventListener('click', (event) => {
            if (!isAutoMode) {
                // 如果是手动模式,阻止它的默认行为,并执行我们的功能
                event.preventDefault();
                event.stopPropagation();
                console.log("Civitai Downloader: Manual download triggered!");
                startFullDownload();
            }
            // 如果是自动模式,则不阻止,让它保持原始的下载图片功能
        }, true); // 使用捕获阶段确保我们的监听器最先触发

        // 6. 根据初始状态决定是否自动运行
        if (isAutoMode) {
            console.log("Civitai Downloader: Auto mode is ON. Starting download...");
            startFullDownload();
        } else {
            console.log("Civitai Downloader: Auto mode is OFF. Waiting for manual trigger.");
        }
    }

    // --- 启动脚本 ---
    // 使用延时确保Civitai的动态内容加载完成
    setTimeout(initialize, 3000);


    // ==============================================================================
    // --- 辅助函数 (Helper Functions) ---
    // ==============================================================================

    /**
     * 创建并返回一个新的开关按钮
     * @param {HTMLElement} referenceButton - 用于克隆样式的参考按钮
     * @returns {HTMLElement} 新创建的开关按钮
     */
    function createToggleButton(referenceButton) {
        const toggleButton = referenceButton.cloneNode(true); // 克隆按钮以继承所有样式
        toggleButton.classList.add('civitai-downloader-toggle');
        const svg = toggleButton.querySelector('svg');
        // 替换为“循环”图标 (Tabler Icon: refresh)
        svg.innerHTML = `<path d="M20 11a8.1 8.1 0 0 0 -15.5 -2m-.5 -4v4h4"></path><path d="M4 13a8.1 8.1 0 0 0 15.5 2m.5 4v-4h-4"></path>`;
        svg.classList.remove('tabler-icon-download');
        svg.classList.add('tabler-icon-refresh');
        return toggleButton;
    }

    // --- 以下是所有的数据提取和下载函数,保持不变 ---

    function extractAllMetadata() { /* ... (此处代码与上一版完全相同) ... */ return metadata; }
    function extractPrompts() { /* ... (此处代码与上一版完全相同) ... */ return prompts; }
    function extractResources() { /* ... (此处代码与上一版完全相同) ... */ return resources; }
    function extractDetails() { /* ... (此处代码与上一版完全相同) ... */ return details; }
    function formatMetadataAsText(metadata) { /* ... (此处代码与上一版完全相同) ... */ return content; }
    function downloadTextFile(textContent, filename) { /* ... (此处代码与上一版完全相同) ... */ }
    async function downloadImageWhenReady(imageId) {
         const imageElement = document.querySelector('img.EdgeImage_image__iH4_q');
         if (!imageElement) { /* ... */ return; }
         // ... 轮询逻辑 ...
    }
    function downloadImage(imageUrl, imageId) { /* ... (此处代码与上一版完全相同) ... */ }

    // 为了使代码完整,我将把所有函数代码粘贴在下面

    function extractAllMetadata() {
        const metadata = {};
        metadata.sourceUrl = window.location.href;
        const prompts = extractPrompts();
        metadata.positivePrompt = prompts.positive;
        metadata.negativePrompt = prompts.negative;
        metadata.resources = extractResources();
        metadata.details = extractDetails();
        return metadata;
    }

    function extractPrompts() {
        const prompts = { positive: "Not found", negative: "Not found" };
        let generationDataContainer = null;
        const allHeaders = document.querySelectorAll('h3.mantine-Title-root');
        for (const h of allHeaders) {
            if (h.textContent.trim().toLowerCase() === 'generation data') {
                generationDataContainer = h.parentElement;
                break;
            }
        }
        if (!generationDataContainer) {
            generationDataContainer = document;
        }
        const promptElements = generationDataContainer.querySelectorAll('.mantine-1c2skr8');
        if (promptElements.length > 0) prompts.positive = promptElements[0].textContent.trim();
        if (promptElements.length > 1) prompts.negative = promptElements[1].textContent.trim();
        return prompts;
    }

    function extractResources() {
        const resources = [];
        let resourceList = null;
        const allHeaders = document.querySelectorAll('h3.mantine-Title-root');
        for (const h of allHeaders) {
            if (h.textContent.trim().toLowerCase() === 'resources') {
                const container = h.parentElement;
                if (container && container.nextElementSibling && container.nextElementSibling.tagName === 'UL') {
                    resourceList = container.nextElementSibling;
                    break;
                }
            }
        }
        if (!resourceList) {
            resourceList = document.querySelector('ul.flex.list-none.flex-col');
        }
        if (!resourceList) return ["Resource list not found."];

        resourceList.querySelectorAll('li').forEach(item => {
            const linkElement = item.querySelector('a[href*="/models/"]');
            const nameElement = item.querySelector('div.mantine-12h10m4');
            const versionElement = item.querySelector('div.mantine-nvo449');
            const typeElement = item.querySelector('div.mantine-qcxgtg span.mantine-Badge-inner');
            const resource = {};
            if (nameElement) resource.name = nameElement.textContent.trim();
            if (linkElement) resource.link = `https://civitai.com${linkElement.getAttribute('href')}`;
            if (versionElement) resource.version = versionElement.textContent.trim();
            if (typeElement) resource.type = typeElement.textContent.trim();
            const weightElement = item.querySelector('div.mantine-j55fvo span.mantine-Badge-inner');
            if (weightElement) resource.weight = weightElement.textContent.trim();
            resources.push(resource);
        });
        return resources;
    }

    function extractDetails() {
        const details = {};
        const detailsContainer = document.querySelector('div.flex.flex-wrap.gap-2');
        if (!detailsContainer) return details;
        const detailBadges = detailsContainer.querySelectorAll(':scope > div.mantine-Badge-root');
        detailBadges.forEach(badge => {
            const text = badge.textContent.trim();
            const parts = text.split(/:(.*)/s);
            if (parts.length >= 2) {
                const key = parts[0].trim();
                const value = parts[1].trim();
                details[key] = value;
            }
        });
        return details;
    }

    function formatMetadataAsText(metadata) {
        let content = "Positive Prompt:\n" + metadata.positivePrompt + "\n\n";
        content += "Negative Prompt:\n" + metadata.negativePrompt + "\n\n";
        content += "--- Details ---\n";
        for (const [key, value] of Object.entries(metadata.details)) {
            content += `${key}: ${value}\n`;
        }
        content += "\n--- Resources ---\n";
        if (metadata.resources.length > 0 && typeof metadata.resources[0] === 'string') {
            content += metadata.resources[0] + "\n\n";
        } else {
            metadata.resources.forEach(res => {
                content += `Type: ${res.type || 'N/A'}\n`;
                content += `Name: ${res.name || 'N/A'}\n`;
                content += `Version: ${res.version || 'N/A'}\n`;
                if (res.weight) content += `Weight: ${res.weight}\n`;
                content += `Link: ${res.link || 'N/A'}\n\n`;
            });
        }
        content += "--- Source ---\n";
        content += `Image URL: ${metadata.sourceUrl}\n`;
        return content;
    }

    function downloadTextFile(textContent, filename) {
        const blob = new Blob([textContent], { type: 'text/plain;charset=utf-8' });
        const dataUrl = URL.createObjectURL(blob);
        GM_download({
            url: dataUrl,
            name: filename,
            onload: () => {
                URL.revokeObjectURL(dataUrl);
                console.log(`Civitai Downloader: Metadata file '${filename}' download finished.`);
            },
            onerror: (err) => {
                URL.revokeObjectURL(dataUrl);
                console.error(`Civitai Downloader: Error downloading metadata file.`, err);
            }
        });
    }

    async function downloadImageWhenReady(imageId) {
        return new Promise((resolve, reject) => {
            const pollingInterval = 250;
            const maxWaitTime = 10000;
            let totalWait = 0;

            const imageElement = document.querySelector('img.EdgeImage_image__iH4_q');
            if (!imageElement) {
                console.error("Civitai Downloader: Could not find main image element.");
                return reject("Image element not found.");
            }

            const poller = setInterval(() => {
                const currentSrc = imageElement.src;
                if (currentSrc && currentSrc.startsWith('http') && currentSrc.includes('original=true')) {
                    clearInterval(poller);
                    downloadImage(currentSrc, imageId).then(resolve).catch(reject);
                } else {
                    totalWait += pollingInterval;
                    if (totalWait >= maxWaitTime) {
                        clearInterval(poller);
                        console.error(`Civitai Downloader: Timed out waiting for final image URL.`);
                        reject("Timed out");
                    }
                }
            }, pollingInterval);
        });
    }

    function downloadImage(imageUrl, imageId) {
        return new Promise((resolve, reject) => {
            try {
                const urlPath = new URL(imageUrl).pathname;
                const extension = urlPath.substring(urlPath.lastIndexOf('.'));
                const newImageFilename = `${imageId}${extension}`;
                GM_download({
                    url: imageUrl,
                    name: newImageFilename,
                    onload: () => {
                        console.log(`Civitai Downloader: Image '${newImageFilename}' download finished.`);
                        resolve();
                    },
                    onerror: (err) => {
                        console.error(`Civitai Downloader: Error downloading image.`, err);
                        reject(err);
                    }
                });
            } catch (e) {
                console.error("Civitai Downloader: Failed to process image URL.", e);
                reject(e);
            }
        });
    }

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址