Discord Image Downloader

Adds a download button to images and GIFs in Discord.

目前为 2025-04-08 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Discord Image Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.05
  5. // @description Adds a download button to images and GIFs in Discord.
  6. // @author Yukiteru
  7. // @match https://discord.com/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=discord.com
  9. // @grant GM_download
  10. // @grant GM_log
  11. // @run-at document-idle
  12. // @license MIT
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. 'use strict';
  17.  
  18. GM_log('Discord Universal Image Downloader script started.');
  19.  
  20. // --- Configuration ---
  21. const MESSAGE_LI_SELECTOR = 'li[id^="chat-messages-"]';
  22. const MESSAGE_ACCESSORIES_SELECTOR = 'div[id^="message-accessories-"]';
  23.  
  24. // Selectors for different types of media containers within accessories
  25. const MOSAIC_ITEM_SELECTOR = 'div[class^="mosaicItem"]'; // Grid items (attachments, some embeds)
  26. const SPOILER_CONTENT_SELECTOR = 'div[class^="spoilerContent"]'; // Spoiler overlay (often inside mosaicItem)
  27. const INLINE_MEDIA_EMBED_SELECTOR = 'div[class^="inlineMediaEmbed"]';// Simple inline embeds (like the new example)
  28. // Combined selector for any top-level distinct media block
  29. const ANY_MEDIA_BLOCK_SELECTOR = `${MOSAIC_ITEM_SELECTOR}, ${INLINE_MEDIA_EMBED_SELECTOR}`;
  30.  
  31. // Selectors for elements *within* a media block
  32. const IMAGE_WRAPPER_SELECTOR = 'div[class^="imageWrapper"]'; // Actual image container (consistent across types)
  33. const ORIGINAL_LINK_SELECTOR = 'a[class^="originalLink"]'; // Link with source URL (consistent)
  34. const HOVER_BUTTON_GROUP_SELECTOR = 'div[class^="hoverButtonGroup"]';// Target container for buttons (may need creation)
  35. // const IMAGE_CONTENT_SELECTOR = 'div[class^="imageContent"]'; // Common parent, less specific now
  36.  
  37. // Markers
  38. const IMAGE_PROCESSED_MARKER = 'data-dl-img-processed';
  39. const SPOILER_LISTENER_MARKER = 'data-dl-spoiler-listener-added';
  40. const DOWNLOAD_BUTTON_CLASS = 'discord-native-dl-button';
  41.  
  42. // Native Button Styling
  43. const NATIVE_ANCHOR_CLASSES_PREFIXES = ['anchor_', 'anchorUnderlineOnHover_', 'hoverButton_'];
  44. const DOWNLOAD_SVG_HTML = `
  45. <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">
  46. <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>
  47. </svg>`;
  48.  
  49. // --- Dynamic Class Name Cache ---
  50. const classCache = {};
  51.  
  52. function findActualClassName(scope, prefix) {
  53. if (classCache[prefix]) return classCache[prefix];
  54. const searchScope = scope || document.body;
  55. try {
  56. const element = searchScope.querySelector(`[class^="${prefix}"]`);
  57. if (element) {
  58. const classList = element.classList;
  59. for (let i = 0; i < classList.length; i++) {
  60. if (classList[i].startsWith(prefix)) {
  61. classCache[prefix] = classList[i];
  62. return classList[i];
  63. }
  64. }
  65. }
  66. } catch (e) { GM_log(`Error finding class with prefix ${prefix}: ${e}`); }
  67. return null;
  68. }
  69.  
  70. function generateFilename(originalUrl, imageWrapper) {
  71. let dateStamp = '0000-00-00';
  72. let messageId = 'unknownMsgId';
  73. let fileIndexStr = '';
  74.  
  75. const messageLi = imageWrapper.closest(MESSAGE_LI_SELECTOR);
  76. if (messageLi) {
  77. // Get date from message timestamp
  78. const timeElement = messageLi.querySelector('time[datetime]');
  79. if (timeElement && timeElement.dateTime) {
  80. try {
  81. const messageDate = new Date(timeElement.dateTime);
  82. if (!isNaN(messageDate.getTime())) {
  83. const year = messageDate.getFullYear();
  84. const month = String(messageDate.getMonth() + 1).padStart(2, '0');
  85. const day = String(messageDate.getDate()).padStart(2, '0');
  86. dateStamp = `${year}-${month}-${day}`;
  87. } else { GM_log(`Filename Date: Failed parse: ${timeElement.dateTime}`); }
  88. } catch (e) { GM_log(`Filename Date: Error parsing: ${e}`); }
  89. } else { GM_log(`Filename Date: No time[datetime] found in msg ${messageLi.id}`); }
  90.  
  91. // Get Message ID
  92. if (messageLi.id) {
  93. const idParts = messageLi.id.split('-');
  94. if (idParts.length > 0) messageId = idParts[idParts.length - 1];
  95. }
  96.  
  97. // Calculate Index - UPDATED to count diverse media blocks
  98. const accessoriesContainer = messageLi.querySelector(MESSAGE_ACCESSORIES_SELECTOR);
  99. if (accessoriesContainer) {
  100. // --- FIX: Count all distinct top-level media blocks ---
  101. const allMediaBlocks = accessoriesContainer.querySelectorAll(ANY_MEDIA_BLOCK_SELECTOR);
  102. const totalCount = allMediaBlocks.length;
  103. // GM_log(`Filename Index: Found ${totalCount} media blocks in msg ${messageId}`);
  104.  
  105. if (totalCount > 1) {
  106. // --- FIX: Find the current media block (mosaic or inline embed) ---
  107. const currentBlock = imageWrapper.closest(ANY_MEDIA_BLOCK_SELECTOR);
  108. if (currentBlock) {
  109. const index = Array.from(allMediaBlocks).indexOf(currentBlock) + 1;
  110. if (index > 0) {
  111. fileIndexStr = `_${index}`;
  112. // GM_log(`Filename Index: Assigned index ${index} for msg ${messageId}`);
  113. } else { GM_log(`Filename Index: Could not find current block in allMediaBlocks list for msg ${messageId}.`); }
  114. } else { GM_log(`Filename Index: Could not find parent media block for imageWrapper in msg ${messageId}`); }
  115. }
  116. } else { GM_log(`Filename Index: Could not find accessories container in msg ${messageId}`); }
  117.  
  118. } else { GM_log(`Filename: Could not find parent message LI.`); }
  119.  
  120.  
  121. let extension = 'dat'; // Default fallback
  122. try {
  123. const url = new URL(originalUrl);
  124. const pathname = url.pathname;
  125.  
  126. // 1. Try extracting from the path
  127. const lastDotIndex = pathname.lastIndexOf('.');
  128. const lastSlashIndex = pathname.lastIndexOf('/'); // Ensure dot is in the filename part
  129. if (lastDotIndex > lastSlashIndex) {
  130. const extFromPath = pathname.substring(lastDotIndex + 1);
  131. // Basic validation: check if it looks like a typical extension (alphanumeric, reasonable length)
  132. if (/^[a-z0-9]{2,5}$/i.test(extFromPath)) {
  133. extension = extFromPath.toLowerCase();
  134. // GM_log(`Extracted extension from path: ${extension}`); // Debug log
  135. }
  136. }
  137.  
  138. // 2. If path didn't yield a valid extension, try 'format' query parameter
  139. if (extension === 'dat') { // Only check format if path failed
  140. const formatParam = url.searchParams.get('format');
  141. if (formatParam && /^[a-z0-9]{2,5}$/i.test(formatParam)) {
  142. extension = formatParam.toLowerCase();
  143. // GM_log(`Extracted extension from format param: ${extension}`); // Debug log
  144. }
  145. }
  146.  
  147. } catch (e) {
  148. GM_log(`Error parsing URL for extension: ${originalUrl}. Error: ${e}`);
  149. // Keep the default 'dat' on error
  150. }
  151.  
  152. return `${dateStamp}_${messageId}${fileIndexStr}.${extension}`;
  153. }
  154.  
  155. /**
  156. * Finds or creates the container where the download button should be placed.
  157. * Works for mosaic items, spoilers, and inline embeds.
  158. * @param {Element} imageWrapper - The image wrapper element.
  159. * @returns {Element|null} The container element (usually hoverButtonGroup) or null if creation fails.
  160. */
  161. function findButtonTargetContainer(imageWrapper) {
  162. // --- FIX: Find the parent media block (mosaic, inline embed, or spoiler) ---
  163. // For spoilers, we actually want the container *inside* the spoiler if possible,
  164. // or the spoiler itself as the fallback parent for the hover group.
  165. const parentSpoiler = imageWrapper.closest(SPOILER_CONTENT_SELECTOR);
  166. const parentMediaBlock = parentSpoiler || imageWrapper.closest(ANY_MEDIA_BLOCK_SELECTOR); // Prefer spoiler if present
  167.  
  168. if (!parentMediaBlock) {
  169. GM_log('findButtonTargetContainer: Could not find parent media block (mosaic, embed, or spoiler).');
  170. return null;
  171. }
  172. // GM_log('findButtonTargetContainer: Found parent block:', parentMediaBlock);
  173.  
  174. // 1. Try to find an EXISTING hoverButtonGroup within the parent block
  175. // Need to search carefully, could be direct child or deeper (e.g., inside imageContainer for inline)
  176. const existingGroup = parentMediaBlock.querySelector(HOVER_BUTTON_GROUP_SELECTOR);
  177. if (existingGroup) {
  178. // GM_log('findButtonTargetContainer: Found existing hoverButtonGroup.');
  179. return existingGroup;
  180. }
  181.  
  182. // 2. If no existing group, CREATE one
  183. // GM_log('findButtonTargetContainer: No existing hoverButtonGroup found. Creating one.');
  184.  
  185. // const groupClass = findActualClassName(document.body, 'hoverButtonGroup'); // Prefix lookup
  186. // if (!groupClass) {
  187. // GM_log('findButtonTargetContainer: Could not resolve class name for hoverButtonGroup. Cannot create group.');
  188. // return null;
  189. // }
  190.  
  191. const newGroup = document.createElement('div');
  192. // TODO newGroup.classList.add(groupClass);
  193. newGroup.classList.add('hoverButton__06ab4');
  194. newGroup.classList.add('custom-dl-hover-group'); // Custom marker
  195.  
  196. // --- FIX: Append the new group intelligently ---
  197. // Try to append it next to where other buttons might be, or as a direct child.
  198. // For inline embeds, inside the 'imageContainer__' seems appropriate if it exists.
  199. let appendTarget = parentMediaBlock; // Default target
  200. const imageContainer = imageWrapper.closest('div[class^="imageContainer"]');
  201. if (imageContainer && parentMediaBlock.contains(imageContainer)) {
  202. // If an imageContainer exists within our block, append the group there.
  203. // This handles the inline embed case better.
  204. appendTarget = imageContainer;
  205. // GM_log('findButtonTargetContainer: Appending new group to imageContainer.');
  206. } else {
  207. // GM_log('findButtonTargetContainer: Appending new group directly to parentMediaBlock.');
  208. }
  209.  
  210. appendTarget.appendChild(newGroup);
  211. // GM_log('findButtonTargetContainer: Created and appended new hover group.');
  212.  
  213. return newGroup;
  214. }
  215.  
  216. /**
  217. * Extract correct image/video url from the anchor element.
  218. * @param {Element} linkElement - The image anchor element.
  219. */
  220. function getImageUrl(linkElement) {
  221. const href = linkElement.href;
  222. const dataSafeSrc = linkElement.getAttribute('data-safe-src');
  223.  
  224. try {
  225. const pathname = new URL(href).pathname;
  226. const ext = pathname.slice((pathname.lastIndexOf(".") - 1 >>> 0) + 2);
  227. return ext ? href : dataSafeSrc;
  228. } catch(e) {
  229. return dataSafeSrc;
  230. }
  231. }
  232.  
  233.  
  234. /**
  235. * Attempts to add a download button to a given imageWrapper. Checks markers and existing buttons.
  236. * @param {Element} imageWrapper - The image wrapper element.
  237. * @param {boolean} forceCheck - If true, bypasses the IMAGE_PROCESSED_MARKER check (used after spoiler click).
  238. */
  239. function addDownloadButton(imageWrapper, forceCheck = false) {
  240. if (!imageWrapper || (!forceCheck && imageWrapper.hasAttribute(IMAGE_PROCESSED_MARKER))) {
  241. return;
  242. }
  243. imageWrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true');
  244.  
  245. const originalLinkElement = imageWrapper.querySelector(ORIGINAL_LINK_SELECTOR);
  246. if (!originalLinkElement || !originalLinkElement.href) {
  247. return;
  248. }
  249.  
  250. const imageUrl = getImageUrl(originalLinkElement);
  251. const targetContainer = findButtonTargetContainer(imageWrapper); // Should now find/create container
  252.  
  253. if (!targetContainer) {
  254. // Log updated message
  255. GM_log(`AddButton: Failed to find or create a suitable hover buttons container for image: ${imageUrl}`);
  256. return; // Cannot proceed without a container
  257. }
  258.  
  259. // Check if OUR button already exists in the found/created container
  260. if (targetContainer.querySelector(`.${DOWNLOAD_BUTTON_CLASS}`)) {
  261. // GM_log('AddButton: Download button already exists in target container.');
  262. return;
  263. }
  264.  
  265. const filename = generateFilename(imageUrl, imageWrapper);
  266. // GM_log(`AddButton: Preparing button for ${filename} in`, targetContainer);
  267.  
  268. const downloadButton = document.createElement('a');
  269. downloadButton.href = imageUrl;
  270. downloadButton.target = "_blank";
  271. downloadButton.rel = "noreferrer noopener";
  272. downloadButton.setAttribute('role', 'button');
  273. downloadButton.setAttribute('aria-label', 'Download Image');
  274. downloadButton.title = `Download ${filename}`;
  275. downloadButton.tabIndex = 0;
  276.  
  277. NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => {
  278. const actualClass = findActualClassName(document.body, prefix);
  279. if (actualClass) downloadButton.classList.add(actualClass);
  280. });
  281.  
  282. downloadButton.classList.add(DOWNLOAD_BUTTON_CLASS);
  283. downloadButton.innerHTML = DOWNLOAD_SVG_HTML;
  284.  
  285. downloadButton.addEventListener('click', (event) => {
  286. event.preventDefault();
  287. event.stopPropagation();
  288. GM_log(`Attempting GM_download: ${imageUrl} as ${filename}`);
  289. try {
  290. GM_download({ url: imageUrl, name: filename, onerror: (err) => GM_log(`Download error: ${JSON.stringify(err)}`) });
  291. } catch (e) { GM_log(`Error initiating GM_download: ${e}`); }
  292. });
  293.  
  294. targetContainer.appendChild(downloadButton);
  295. GM_log(`AddButton: Added button for ${filename}`);
  296. }
  297.  
  298. /**
  299. * Attaches a click listener to a spoiler element if it hasn't been done yet.
  300. * The listener reveals the image and then calls addDownloadButton.
  301. * @param {Element} spoilerElement - The spoiler content element.
  302. */
  303. function handleSpoiler(spoilerElement) {
  304. if (!spoilerElement || spoilerElement.hasAttribute(SPOILER_LISTENER_MARKER)) {
  305. return;
  306. }
  307. const imageWrapperInside = spoilerElement.querySelector(IMAGE_WRAPPER_SELECTOR);
  308. if (!imageWrapperInside) {
  309. spoilerElement.setAttribute(SPOILER_LISTENER_MARKER, 'true'); // Mark anyway
  310. return;
  311. }
  312. spoilerElement.setAttribute(SPOILER_LISTENER_MARKER, 'true');
  313. spoilerElement.addEventListener('click', () => {
  314. setTimeout(() => {
  315. const revealedImageWrapper = spoilerElement.querySelector(IMAGE_WRAPPER_SELECTOR);
  316. if (revealedImageWrapper) {
  317. addDownloadButton(revealedImageWrapper, true); // Force check after reveal
  318. } else { GM_log("HandleSpoiler: Could not find revealed image wrapper post-click."); }
  319. }, 200);
  320. }, { once: true });
  321. }
  322.  
  323. /**
  324. * Processes a node to find image wrappers or spoilers containing images.
  325. * Handles regular images, spoiled images, and inline embeds.
  326. * @param {Node} node - The node to process.
  327. */
  328. function processNode(node) {
  329. if (node.nodeType === Node.ELEMENT_NODE) {
  330. // Find all image wrappers within this node that haven't been processed
  331. const imageWrappers = node.querySelectorAll(`${IMAGE_WRAPPER_SELECTOR}:not([${IMAGE_PROCESSED_MARKER}])`);
  332. imageWrappers.forEach(wrapper => {
  333. // If it's inside a spoiler, let the spoiler handler deal with it upon click
  334. if (wrapper.closest(SPOILER_CONTENT_SELECTOR)) {
  335. wrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true'); // Mark now, handle later
  336. } else {
  337. // Process directly (regular image or inline embed) with slight delay
  338. setTimeout(() => addDownloadButton(wrapper), 150);
  339. }
  340. });
  341. // Also check if the node itself is a non-spoiled wrapper
  342. if (node.matches(IMAGE_WRAPPER_SELECTOR) && !node.hasAttribute(IMAGE_PROCESSED_MARKER) && !node.closest(SPOILER_CONTENT_SELECTOR)) {
  343. setTimeout(() => addDownloadButton(node), 150);
  344. }
  345.  
  346. // Find spoiler elements containing images that need listeners
  347. const spoilerElements = node.querySelectorAll(`${SPOILER_CONTENT_SELECTOR}:not([${SPOILER_LISTENER_MARKER}]):has(${IMAGE_WRAPPER_SELECTOR})`);
  348. spoilerElements.forEach(spoiler => {
  349. setTimeout(() => handleSpoiler(spoiler), 150);
  350. });
  351. // Also check if the node itself is a spoiler needing handling
  352. if (node.matches(`${SPOILER_CONTENT_SELECTOR}:has(${IMAGE_WRAPPER_SELECTOR})`) && !node.hasAttribute(SPOILER_LISTENER_MARKER)) {
  353. setTimeout(() => handleSpoiler(node), 150);
  354. }
  355. }
  356. }
  357.  
  358. // --- Observer ---
  359. const observer = new MutationObserver((mutationsList) => {
  360. NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => findActualClassName(document.body, prefix)); // Ensure classes cached
  361.  
  362. for (const mutation of mutationsList) {
  363. if (mutation.type === 'childList') {
  364. mutation.addedNodes.forEach(processNode);
  365. }
  366. }
  367. });
  368.  
  369. // --- Initialization ---
  370. function initialize() {
  371. GM_log("Initializing Universal Downloader...");
  372. GM_log("Fetching initial dynamic class names...");
  373. NATIVE_ANCHOR_CLASSES_PREFIXES.forEach(prefix => findActualClassName(document.body, prefix));
  374.  
  375. GM_log("Starting MutationObserver.");
  376. observer.observe(document.body, { childList: true, subtree: true });
  377.  
  378. GM_log("Performing initial scan...");
  379. // Scan for non-spoiled images/embeds
  380. document.querySelectorAll(`${IMAGE_WRAPPER_SELECTOR}:not([${IMAGE_PROCESSED_MARKER}])`).forEach(wrapper => {
  381. if (!wrapper.closest(SPOILER_CONTENT_SELECTOR)) {
  382. addDownloadButton(wrapper);
  383. } else {
  384. wrapper.setAttribute(IMAGE_PROCESSED_MARKER, 'true'); // Mark spoiled ones
  385. }
  386. });
  387. // Scan for spoilers needing listeners
  388. document.querySelectorAll(`${SPOILER_CONTENT_SELECTOR}:not([${SPOILER_LISTENER_MARKER}]):has(${IMAGE_WRAPPER_SELECTOR})`).forEach(handleSpoiler);
  389. }
  390.  
  391. setTimeout(initialize, 2000);
  392.  
  393. })();