Civitai 图像下载及元数据提取器

新增下载记录功能,自动模式下可智能跳过已下载过的页面,防止重复下载。

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Civitai Image Downloader and Metadata Extractor
// @name:zh-CN   Civitai 图像下载及元数据提取器
// @namespace    http://tampermonkey.net/
// @version      2.1
// @description  Adds a toggle for auto/manual downloading and remembers downloaded images to prevent re-downloading.
// @description:zh-CN 新增下载记录功能,自动模式下可智能跳过已下载过的页面,防止重复下载。
// @author       Camellia895
// @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
// @license      MIT
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- 配置 & 常量 ---
    const AUTO_DOWNLOAD_KEY = 'civitaiDownloader_autoMode';
    const DOWNLOADED_IDS_KEY = 'civitaiDownloader_downloadedIds'; // 新增:用于存储下载记录的键

    // --- 添加自定义样式 ---
    GM_addStyle(`
        /* 蓝色表示自动模式开启 */
        .civitai-downloader-toggle.active {
            background-color: #228be6 !important;
            border-color: #228be6 !important;
        }
        /* 绿色表示此页已下载过 */
        .civitai-downloader-toggle.downloaded {
            background-color: #2f9e44 !important; /* A nice green */
            border-color: #2f9e44 !important;
        }
    `);

    // ==============================================================================
    // --- 核心下载逻辑 ---
    // ==============================================================================
    async function startFullDownload(imageId) {
        console.log("Civitai Downloader: Starting full download process...");
        try {
            const metadata = extractAllMetadata();
            if (!metadata) return;

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

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

            // 3. 标记为已下载 (仅在所有下载成功后执行)
            markAsDownloaded(imageId);

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

    // ==============================================================================
    // --- 主程序入口 ---
    // ==============================================================================
    function initialize() {
        // 等待页面元素加载完毕
        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 imageId = window.location.pathname.split('/')[2];
        if (!imageId) return;

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

        // 1. 读取所有状态
        let isAutoMode = GM_getValue(AUTO_DOWNLOAD_KEY, true);
        const downloadedIds = GM_getValue(DOWNLOADED_IDS_KEY, {});
        const hasBeenDownloaded = !!downloadedIds[imageId];

        // 2. 创建并注入开关按钮
        const toggleButton = createToggleButton(downloadButton);
        buttonContainer.insertBefore(toggleButton, downloadButton);

        // 3. 更新按钮视觉状态的函数
        function updateToggleVisual() {
            toggleButton.classList.remove('active', 'downloaded'); // 先清除所有状态
            if (hasBeenDownloaded) {
                toggleButton.classList.add('downloaded');
                toggleButton.title = "此页已下载过 (点击原下载按钮可强制重新下载)";
            } else if (isAutoMode) {
                toggleButton.classList.add('active');
                toggleButton.title = "自动下载模式已开启";
            } else {
                toggleButton.title = "自动下载模式已关闭 (点击原下载按钮手动触发)";
            }
        }
        updateToggleVisual();

        // 4. 为开关按钮添加点击事件
        toggleButton.addEventListener('click', () => {
            isAutoMode = !isAutoMode;
            GM_setValue(AUTO_DOWNLOAD_KEY, isAutoMode);
            // 切换模式时,需要重新评估视觉状态
            // 注意:我们不改变`hasBeenDownloaded`的值,只改变`isAutoMode`
            updateToggleVisual();
            console.log(`Civitai Downloader: Auto mode set to ${isAutoMode}`);
        });

        // 5. 为原始下载按钮添加“劫持”事件 (强制下载)
        downloadButton.addEventListener('click', (event) => {
            // 只要是手动点击,无论任何状态,都执行下载
            event.preventDefault();
            event.stopPropagation();
            console.log("Civitai Downloader: Manual/Force download triggered!");
            startFullDownload(imageId);
        }, true);

        // 6. 根据初始状态决定是否自动运行
        if (isAutoMode && !hasBeenDownloaded) {
            console.log("Civitai Downloader: Auto mode ON and page is new. Starting download...");
            startFullDownload(imageId);
        } else if (isAutoMode && hasBeenDownloaded) {
            console.log("Civitai Downloader: Auto mode ON, but page already downloaded. Skipping.");
        } else {
            console.log("Civitai Downloader: Auto mode OFF. Waiting for manual trigger.");
        }
    }

    // --- 启动脚本 ---
    setTimeout(initialize, 3000);

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

    /**
     * 将当前图片ID标记为已下载
     */
    function markAsDownloaded(imageId) {
        const downloadedIds = GM_getValue(DOWNLOADED_IDS_KEY, {});
        downloadedIds[imageId] = true;
        GM_setValue(DOWNLOADED_IDS_KEY, downloadedIds);
        console.log(`Civitai Downloader: Page ${imageId} marked as downloaded.`);

        // 可选:标记后立即更新按钮颜色为绿色,提供即时反馈
        const toggleButton = document.querySelector('.civitai-downloader-toggle');
        if (toggleButton) {
            toggleButton.classList.remove('active');
            toggleButton.classList.add('downloaded');
            toggleButton.title = "此页已下载过 (点击原下载按钮可强制重新下载)";
        }
    }

    // 其他辅助函数保持不变
    function createToggleButton(referenceButton) { /* ... */ }
    function extractAllMetadata() { /* ... */ }
    function extractPrompts() { /* ... */ }
    function extractResources() { /* ... */ }
    function extractDetails() { /* ... */ }
    function formatMetadataAsText(metadata) { /* ... */ }
    function downloadTextFile(textContent, filename) { /* ... */ }
    async function downloadImageWhenReady(imageId) { /* ... */ }
    function downloadImage(imageUrl, imageId) { /* ... */ }

    // --- 完整函数代码 ---
    function createToggleButton(referenceButton) {
        const toggleButton = referenceButton.cloneNode(true);
        toggleButton.classList.add('civitai-downloader-toggle');
        const svg = toggleButton.querySelector('svg');
        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() {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);}});}
})();