Greasy Fork is available in English.

Discord Image Downloader

Adds a download button to images and GIFs in Discord.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Discord Image Downloader
// @namespace    http://tampermonkey.net/
// @version      1.06
// @description  Adds a download button to images and GIFs in Discord.
// @author       Yukiteru
// @match        https://discord.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=discord.com
// @grant        GM_download
// @grant        GM_log
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    GM_log('Discord Universal Image Downloader script started.');

    // --- Configuration ---
    const MESSAGE_LI_SELECTOR = 'li[id^="chat-messages-"]';
    const MESSAGE_ACCESSORIES_SELECTOR = 'div[id^="message-accessories-"]';

    // Selectors for different types of media containers within accessories
    const MOSAIC_ITEM_SELECTOR = 'div[class^="mosaicItem"]';             // Grid items (attachments, some embeds)
    const SPOILER_CONTENT_SELECTOR = 'div[class^="spoilerContent"]';     // Spoiler overlay (often inside mosaicItem)
    const INLINE_MEDIA_EMBED_SELECTOR = 'div[class^="inlineMediaEmbed"]';// Simple inline embeds (like the new example)
    // Combined selector for any top-level distinct media block
    const ANY_MEDIA_BLOCK_SELECTOR = `${MOSAIC_ITEM_SELECTOR}, ${INLINE_MEDIA_EMBED_SELECTOR}`;

    // Selectors for elements *within* a media block
    const IMAGE_WRAPPER_SELECTOR = 'div[class^="imageWrapper"]';         // Actual image container (consistent across types)
    const ORIGINAL_LINK_SELECTOR = 'a[class^="originalLink"]';          // Link with source URL (consistent)
    const HOVER_BUTTON_GROUP_SELECTOR = 'div[class^="hoverButtonGroup"]';// Target container for buttons (may need creation)
    // const IMAGE_CONTENT_SELECTOR = 'div[class^="imageContent"]';      // Common parent, less specific now

    // Markers
    const IMAGE_PROCESSED_MARKER = 'data-dl-img-processed';
    const SPOILER_LISTENER_MARKER = 'data-dl-spoiler-listener-added';
    const DOWNLOAD_BUTTON_CLASS = 'discord-native-dl-button';

    // Native Button Styling
    const NATIVE_ANCHOR_CLASSES_PREFIXES = ['anchor_', 'anchorUnderlineOnHover_', 'hoverButton_'];
    const DOWNLOAD_SVG_HTML = `
        <svg class="downloadHoverButtonIcon__6c706" aria-hidden="true" role="img" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
            <path fill="currentColor" d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1ZM3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2H3Z" class=""></path>
        </svg>`;

    // --- Dynamic Class Name Cache ---
    const classCache = {};

    function findActualClassName(scope, prefix) {
        if (classCache[prefix]) return classCache[prefix];
        const searchScope = scope || document.body;
        try {
            const element = searchScope.querySelector(`[class^="${prefix}"]`);
            if (element) {
                const classList = element.classList;
                for (let i = 0; i < classList.length; i++) {
                    if (classList[i].startsWith(prefix)) {
                        classCache[prefix] = classList[i];
                        return classList[i];
                    }
                }
            }
        } catch (e) { GM_log(`Error finding class with prefix ${prefix}: ${e}`); }
        return null;
    }

    function generateFilename(originalUrl, imageWrapper) {
        let dateStamp = '0000-00-00';
        let messageId = 'unknownMsgId';
        let fileIndexStr = '';

        const messageLi = imageWrapper.closest(MESSAGE_LI_SELECTOR);
        if (messageLi) {
            // Get date from message timestamp
            const timeElement = messageLi.querySelector('time[datetime]');
            if (timeElement && timeElement.dateTime) {
                try {
                    const messageDate = new Date(timeElement.dateTime);
                    if (!isNaN(messageDate.getTime())) {
                        const year = messageDate.getFullYear();
                        const month = String(messageDate.getMonth() + 1).padStart(2, '0');
                        const day = String(messageDate.getDate()).padStart(2, '0');
                        dateStamp = `${year}-${month}-${day}`;
                    } else { GM_log(`Filename Date: Failed parse: ${timeElement.dateTime}`); }
                } catch (e) { GM_log(`Filename Date: Error parsing: ${e}`); }
            } else { GM_log(`Filename Date: No time[datetime] found in msg ${messageLi.id}`); }

            // Get Message ID
            if (messageLi.id) {
                 const idParts = messageLi.id.split('-');
                 if (idParts.length > 0) messageId = idParts[idParts.length - 1];
            }

            // Calculate Index - UPDATED to count diverse media blocks
            const accessoriesContainer = messageLi.querySelector(MESSAGE_ACCESSORIES_SELECTOR);
            if (accessoriesContainer) {
                // --- FIX: Count all distinct top-level media blocks ---
                const allMediaBlocks = accessoriesContainer.querySelectorAll(ANY_MEDIA_BLOCK_SELECTOR);
                const totalCount = allMediaBlocks.length;
                // GM_log(`Filename Index: Found ${totalCount} media blocks in msg ${messageId}`);

                if (totalCount > 1) {
                    // --- FIX: Find the current media block (mosaic or inline embed) ---
                    const currentBlock = imageWrapper.closest(ANY_MEDIA_BLOCK_SELECTOR);
                    if (currentBlock) {
                        const index = Array.from(allMediaBlocks).indexOf(currentBlock) + 1;
                        if (index > 0) {
                            fileIndexStr = `_${index}`;
                            // GM_log(`Filename Index: Assigned index ${index} for msg ${messageId}`);
                        } else { GM_log(`Filename Index: Could not find current block in allMediaBlocks list for msg ${messageId}.`); }
                    } else { GM_log(`Filename Index: Could not find parent media block for imageWrapper in msg ${messageId}`); }
                }
            } else { GM_log(`Filename Index: Could not find accessories container in msg ${messageId}`); }

        } else { GM_log(`Filename: Could not find parent message LI.`); }


        let extension = 'dat'; // Default fallback
        try {
            const url = new URL(originalUrl);
            const pathname = url.pathname;

            // 1. Try extracting from the path
            const lastDotIndex = pathname.lastIndexOf('.');
            const lastSlashIndex = pathname.lastIndexOf('/'); // Ensure dot is in the filename part
            if (lastDotIndex > lastSlashIndex) {
                const extFromPath = pathname.substring(lastDotIndex + 1);
                // Basic validation: check if it looks like a typical extension (alphanumeric, reasonable length)
                if (/^[a-z0-9]{2,5}$/i.test(extFromPath)) {
                    extension = extFromPath.toLowerCase();
                    // GM_log(`Extracted extension from path: ${extension}`); // Debug log
                }
            }

            // 2. If path didn't yield a valid extension, try 'format' query parameter
            if (extension === 'dat') { // Only check format if path failed
                const formatParam = url.searchParams.get('format');
                if (formatParam && /^[a-z0-9]{2,5}$/i.test(formatParam)) {
                    extension = formatParam.toLowerCase();
                    // GM_log(`Extracted extension from format param: ${extension}`); // Debug log
                }
            }

        } catch (e) {
             GM_log(`Error parsing URL for extension: ${originalUrl}. Error: ${e}`);
             // Keep the default 'dat' on error
        }

        return `${dateStamp}_${messageId}${fileIndexStr}.${extension}`;
    }

    /**
     * Finds or creates the container where the download button should be placed.
     * Works for mosaic items, spoilers, and inline embeds.
     * @param {Element} imageWrapper - The image wrapper element.
     * @returns {Element|null} The container element (usually hoverButtonGroup) or null if creation fails.
     */
    function findButtonTargetContainer(imageWrapper) {
        // --- FIX: Find the parent media block (mosaic, inline embed, or spoiler) ---
        // For spoilers, we actually want the container *inside* the spoiler if possible,
        // or the spoiler itself as the fallback parent for the hover group.
        const parentSpoiler = imageWrapper.closest(SPOILER_CONTENT_SELECTOR);
        const parentMediaBlock = parentSpoiler || imageWrapper.closest(ANY_MEDIA_BLOCK_SELECTOR); // Prefer spoiler if present

        if (!parentMediaBlock) {
            GM_log('findButtonTargetContainer: Could not find parent media block (mosaic, embed, or spoiler).');
            return null;
        }
        // GM_log('findButtonTargetContainer: Found parent block:', parentMediaBlock);

        // 1. Try to find an EXISTING hoverButtonGroup within the parent block
        // Need to search carefully, could be direct child or deeper (e.g., inside imageContainer for inline)
        const existingGroup = parentMediaBlock.querySelector(HOVER_BUTTON_GROUP_SELECTOR);
        if (existingGroup) {
            // GM_log('findButtonTargetContainer: Found existing hoverButtonGroup.');
            return existingGroup;
        }

        // 2. If no existing group, CREATE one
        const newGroup = document.createElement('div');
        newGroup.classList.add('custom-dl-hover-group'); // Custom marker

        // Try to append it next to where other buttons might be, or as a direct child.
        // For inline embeds, inside the 'imageContainer__' seems appropriate if it exists.
        let appendTarget = parentMediaBlock; // Default target
        const imageContainer = imageWrapper.closest('div[class^="imageContainer"]');
        if (imageContainer && parentMediaBlock.contains(imageContainer)) {
             // If an imageContainer exists within our block, append the group there.
             // This handles the inline embed case better.
             appendTarget = imageContainer;
             // GM_log('findButtonTargetContainer: Appending new group to imageContainer.');
        } else {
            // GM_log('findButtonTargetContainer: Appending new group directly to parentMediaBlock.');
        }

        appendTarget.appendChild(newGroup);
        // GM_log('findButtonTargetContainer: Created and appended new hover group.');

        return newGroup;
    }

    /**
     * Extract correct image/video url from the anchor element.
     * @param {Element} linkElement - The image anchor element.
     */
    function getImageUrl(linkElement) {
      const href = linkElement.href;
      const dataSafeSrc = linkElement.getAttribute('data-safe-src');

      try {
        const pathname = new URL(href).pathname;
        const ext = pathname.slice((pathname.lastIndexOf(".") - 1 >>> 0) + 2);
        return ext ? href : dataSafeSrc;
      } catch(e) {
        return dataSafeSrc;
      }
    }


    /**
     * Attempts to add a download button to a given imageWrapper. Checks markers and existing buttons.
     * @param {Element} imageWrapper - The image wrapper element.
     * @param {boolean} forceCheck - If true, bypasses the IMAGE_PROCESSED_MARKER check (used after spoiler click).
     */
    function addDownloadButton(imageWrapper, forceCheck = false) {
        if (!imageWrapper || (!forceCheck && imageWrapper.hasAttribute(IMAGE_PROCESSED_MARKER))) {
            return;
        }
        imageWrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true');

        const originalLinkElement = imageWrapper.querySelector(ORIGINAL_LINK_SELECTOR);
        if (!originalLinkElement || !originalLinkElement.href) {
            return;
        }

        const imageUrl = getImageUrl(originalLinkElement);
        const targetContainer = findButtonTargetContainer(imageWrapper); // Should now find/create container

        if (!targetContainer) {
            // Log updated message
            GM_log(`AddButton: Failed to find or create a suitable hover buttons container for image: ${imageUrl}`);
            return; // Cannot proceed without a container
        }

        // Check if OUR button already exists in the found/created container
        if (targetContainer.querySelector(`.${DOWNLOAD_BUTTON_CLASS}`)) {
            // GM_log('AddButton: Download button already exists in target container.');
            return;
        }

        const filename = generateFilename(imageUrl, imageWrapper);
        // GM_log(`AddButton: Preparing button for ${filename} in`, targetContainer);

        const downloadButton = document.createElement('a');
        downloadButton.href = imageUrl;
        downloadButton.target = "_blank";
        downloadButton.rel = "noreferrer noopener";
        downloadButton.setAttribute('role', 'button');
        downloadButton.setAttribute('aria-label', 'Download Image');
        downloadButton.title = `Download ${filename}`;
        downloadButton.tabIndex = 0;

        NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => {
            const actualClass = findActualClassName(document.body, prefix);
            if (actualClass) downloadButton.classList.add(actualClass);
        });

        downloadButton.classList.add(DOWNLOAD_BUTTON_CLASS);
        downloadButton.innerHTML = DOWNLOAD_SVG_HTML;

        downloadButton.addEventListener('click', (event) => {
            event.preventDefault();
            event.stopPropagation();
            GM_log(`Attempting GM_download: ${imageUrl} as ${filename}`);
            try {
                GM_download({ url: imageUrl, name: filename, onerror: (err) => GM_log(`Download error: ${JSON.stringify(err)}`) });
            } catch (e) { GM_log(`Error initiating GM_download: ${e}`); }
        });

        targetContainer.appendChild(downloadButton);
        GM_log(`AddButton: Added button for ${filename}`);
    }

    /**
     * Attaches a click listener to a spoiler element if it hasn't been done yet.
     * The listener reveals the image and then calls addDownloadButton.
     * @param {Element} spoilerElement - The spoiler content element.
     */
    function handleSpoiler(spoilerElement) {
        if (!spoilerElement || spoilerElement.hasAttribute(SPOILER_LISTENER_MARKER)) {
            return;
        }
        const imageWrapperInside = spoilerElement.querySelector(IMAGE_WRAPPER_SELECTOR);
        if (!imageWrapperInside) {
            spoilerElement.setAttribute(SPOILER_LISTENER_MARKER, 'true'); // Mark anyway
            return;
        }
        spoilerElement.setAttribute(SPOILER_LISTENER_MARKER, 'true');
        spoilerElement.addEventListener('click', () => {
            setTimeout(() => {
                const revealedImageWrapper = spoilerElement.querySelector(IMAGE_WRAPPER_SELECTOR);
                if (revealedImageWrapper) {
                    addDownloadButton(revealedImageWrapper, true); // Force check after reveal
                } else { GM_log("HandleSpoiler: Could not find revealed image wrapper post-click."); }
            }, 200);
        }, { once: true });
    }

    /**
     * Processes a node to find image wrappers or spoilers containing images.
     * Handles regular images, spoiled images, and inline embeds.
     * @param {Node} node - The node to process.
     */
    function processNode(node) {
        if (node.nodeType === Node.ELEMENT_NODE) {
            // Find all image wrappers within this node that haven't been processed
            const imageWrappers = node.querySelectorAll(`${IMAGE_WRAPPER_SELECTOR}:not([${IMAGE_PROCESSED_MARKER}])`);
            imageWrappers.forEach(wrapper => {
                // If it's inside a spoiler, let the spoiler handler deal with it upon click
                 if (wrapper.closest(SPOILER_CONTENT_SELECTOR)) {
                     wrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true'); // Mark now, handle later
                 } else {
                     // Process directly (regular image or inline embed) with slight delay
                     setTimeout(() => addDownloadButton(wrapper), 150);
                 }
            });
            // Also check if the node itself is a non-spoiled wrapper
            if (node.matches(IMAGE_WRAPPER_SELECTOR) && !node.hasAttribute(IMAGE_PROCESSED_MARKER) && !node.closest(SPOILER_CONTENT_SELECTOR)) {
                setTimeout(() => addDownloadButton(node), 150);
            }

            // Find spoiler elements containing images that need listeners
            const spoilerElements = node.querySelectorAll(`${SPOILER_CONTENT_SELECTOR}:not([${SPOILER_LISTENER_MARKER}]):has(${IMAGE_WRAPPER_SELECTOR})`);
             spoilerElements.forEach(spoiler => {
                 setTimeout(() => handleSpoiler(spoiler), 150);
             });
             // Also check if the node itself is a spoiler needing handling
             if (node.matches(`${SPOILER_CONTENT_SELECTOR}:has(${IMAGE_WRAPPER_SELECTOR})`) && !node.hasAttribute(SPOILER_LISTENER_MARKER)) {
                  setTimeout(() => handleSpoiler(node), 150);
             }
        }
    }

    // --- Observer ---
    const observer = new MutationObserver((mutationsList) => {
        NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => findActualClassName(document.body, prefix)); // Ensure classes cached

        for (const mutation of mutationsList) {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(processNode);
            }
        }
    });

    // --- Initialization ---
    function initialize() {
         GM_log("Initializing Universal Downloader...");
         GM_log("Fetching initial dynamic class names...");
         NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => findActualClassName(document.body, prefix));

        GM_log("Starting MutationObserver.");
        observer.observe(document.body, { childList: true, subtree: true });

        GM_log("Performing initial scan...");
        // Scan for non-spoiled images/embeds
        document.querySelectorAll(`${IMAGE_WRAPPER_SELECTOR}:not([${IMAGE_PROCESSED_MARKER}])`).forEach(wrapper => {
            if (!wrapper.closest(SPOILER_CONTENT_SELECTOR)) {
                addDownloadButton(wrapper);
            } else {
                 wrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true'); // Mark spoiled ones
            }
        });
        // Scan for spoilers needing listeners
        document.querySelectorAll(`${SPOILER_CONTENT_SELECTOR}:not([${SPOILER_LISTENER_MARKER}]):has(${IMAGE_WRAPPER_SELECTOR})`).forEach(handleSpoiler);
    }

    setTimeout(initialize, 2000);

})();