Civitai 图像下载及元数据提取器 (带开关)

在Civitai图片页自动或手动下载图片和同名元数据.txt文件。新增自动/手动模式开关。

当前为 2025-06-13 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();