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