Civitai Image Downloader

Downloads Civitai images with full metadata in a text file. Needs clipboard permission to save metadata. (Clipboard text is restored after)

// ==UserScript==
// @name         Civitai Image Downloader
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  Downloads Civitai images with full metadata in a text file. Needs clipboard permission to save metadata. (Clipboard text is restored after)
// @author       cyaoeu
// @match        https://civitai.com/images/*
// @grant        GM_download
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    function sanitizeFolderName(name) {
        return name.replace(/[^a-zA-Z0-9]/g, '_');
    }

    function addDownloadButton() {
        // Find the Tip button container
        const tipButtonContainer = document.querySelector('div.flex.gap-1[class*="@max-md:hidden"]');
        if (!tipButtonContainer) {
            console.error('Tip button container not found!');
            return;
        }

        // Create the Download button
        const downloadButton = document.createElement('button');
        downloadButton.textContent = 'Download Image + Metadata';
        downloadButton.style.cssText = `
            --badge-radius:var(--mantine-radius-sm);--badge-bg:var(--mantine-color-gray-filled);--badge-color:var(--mantine-color-white);--badge-bd:calc(0.0625rem * var(--mantine-scale)) solid transparent;font-size:12px;font-weight:600;line-height:1.5;padding-right:calc(0.75rem * var(--mantine-scale))
        `;
        downloadButton.classList = "h-9 min-w-9 rounded-full normal-case m_347db0ec mantine-Badge-root civitai-image-downloader-button";
        downloadButton.title = 'Download the image and its metadata as a text file';
        tipButtonContainer.appendChild(downloadButton);

        downloadButton.addEventListener('click', function() {
            // Extract image ID from URL
            const url = window.location.href;
            const imageIdMatch = url.match(/\/images\/(\d+)/);
            if (!imageIdMatch) {
                alert('Could not find image ID in the URL!');
                return;
            }
            const imageId = imageIdMatch[1];

            // Extract username from DOM
            const userLink = document.querySelector('a[href^="/user/"]');
            if (!userLink) {
                alert('Could not find username!');
                return;
            }
            const usernameElement = userLink.querySelector('p[data-size="md"]');
            if (!usernameElement) {
                alert('Could not find username element!');
                return;
            }
            const username = sanitizeFolderName(usernameElement.textContent.trim());

            // Extract image URL from DOM
            const imageElement = document.querySelector('img[class*="EdgeImage_image"]');
            if (!imageElement) {
                alert('Could not find image element!');
                return;
            }
            const imageUrl = imageElement.src;

            // Extract metadata from __NEXT_DATA__
            const nextDataScript = document.getElementById('__NEXT_DATA__');
            if (!nextDataScript) {
                alert('Could not find __NEXT_DATA__!');
                return;
            }
            const nextData = JSON.parse(nextDataScript.textContent);
            const imageData = nextData.props.pageProps.trpcState.json.queries.find(q => q.state.data?.id === parseInt(imageId))?.state.data;
            if (!imageData) {
                alert('Could not find image data in __NEXT_DATA__!');
                return;
            }
            const meta = imageData.meta || {};

            // Log for debugging
            console.log('Extracted metadata:', meta);

            // Extract resources from DOM
            let resourcesText = '';
            const resourcesP = Array.from(document.querySelectorAll('p.mantine-Text-root')).find(p => p.textContent === 'Resources used');
            if (resourcesP) {
                const resourcesUl = resourcesP.nextElementSibling;
                if (resourcesUl && resourcesUl.tagName === 'UL') {
                    resourcesText += 'Resources used:\n';
                    const resources = resourcesUl.querySelectorAll('li');
                    resources.forEach(resource => {
                        const name = resource.querySelector('a p.underline')?.textContent.trim() || '';
                        const version = resource.querySelector('a p.text-xs')?.textContent.trim() || '';
                        const type = resource.querySelector('div.mantine-Badge-root')?.textContent.trim() || '';
                        resourcesText += `- ${name} (${type}): ${version}\n`;
                    });
                    resourcesText += '\n';
                }
            }

            // click copy all button

            const copyAllButton = document.querySelector("p.mantine-focus-auto.flex.cursor-pointer.items-center.gap-1.text-xs.m_b6d8b162.mantine-Text-root");

            let prevText = "";
            let copiedText = "";
            navigator.clipboard.readText().then(clipText => {
                prevText = clipText
                copyAllButton.click();
                navigator.clipboard.readText().then(clipText => {
                    copiedText = clipText
                });
            });

            // hacky wait for promise to fulfill
            setTimeout(() => {
                // Format metadata text
                let metadataText = resourcesText;

                const metadataContainer = document.querySelector('div.mantine-Card-root.flex.flex-col.gap-3.rounded-xl.m_e615b15f.mantine-Card-root.m_1b7284a3.mantine-Paper-root');
                metadataText += "Positive prompt: \n" + copiedText.split("Steps")[0].replace("Negative prompt", "\nNegative prompt") + "Steps" + copiedText.split("Steps")[1]
                    .replace(/TI: \[object Object\], /g, '') // Remove TI: [object Object]
                    .replace(/,/g, '') // Remove all commas
                    .replace(/(Steps|CFG scale|Sampler|Seed|Size|Model|Schedule type|Noise Schedule|Resharpen Enabled|Resharpen Scaling|Cos Denoising strength|SD upscale overlap|Resharpen Sharpness|SD upscale upscaler|ADetailer model):/g, '\n$1:'); // Add newlines before keys

                // Create Blob for metadata
                const metadataBlob = new Blob([metadataText], { type: 'text/plain' });
                // restore clipboard
                navigator.clipboard.writeText(prevText);

                // Download image
                GM_download({
                    url: imageUrl,
                    name: `civitai/${username}/${username}_${imageId}.jpg`,
                    saveAs: false,
                    conflictAction: "overwrite"
                });

                // Download metadata
                const metadataUrl = URL.createObjectURL(metadataBlob);
                GM_download({
                    url: metadataUrl,
                    name: `civitai/${username}/${username}_${imageId}.txt`,
                    saveAs: false,
                    conflictAction: "overwrite",
                    onload: () => URL.revokeObjectURL(metadataUrl)
                });
            }, 100)
        });

    }

    window.addEventListener('load', addDownloadButton());

})();