Discord 图片助手

该脚本为 Discord 网页版中的图片添加“保存原图”和“复制链接”按钮,以便用户更方便地下载和分享图片。

// ==UserScript==
// @name         Discord 图片助手
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  该脚本为 Discord 网页版中的图片添加“保存原图”和“复制链接”按钮,以便用户更方便地下载和分享图片。
// @author       Your Name
// @match        https://discord.com/*
// @grant        GM_download
// @grant        GM_setClipboard
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const PROCESSED_TARGET_MARKER = 'data-image-enhancer-target-processed';
    const BUTTON_CONTAINER_CLASS = 'custom-image-buttons-container';

    const imageParentSelectors = [
        'div[class*="visualMediaItemContainer_"]',
        'div[class*="imageContent-"]',
        'div[class*="imageContainer-"]',
        'div[class*="imageWrapper-"]',
        'div[class*="clickableWrapper-"]',
        'div[class*="embedMedia-"]',
        'div[class*="attachmentContentContainer-"]',
        'div[class*="mediaMosaicSrc-"]',
        'div[class*="mediaAttachmentsContainer-"]',
        'div[class*="messageAttachment-"]',
        'figure[class*="imageContainer-"]'
    ];

    function getImageUrl(element) {
        if (element.tagName === 'IMG' && element.src) {
            return element.src;
        }
        if (element.tagName === 'A' && element.href) {
            const imgElement = element.querySelector('img');
            if (imgElement && imgElement.src && (imgElement.src.includes('discordapp.com') || imgElement.src.includes('discordapp.net'))) {
                return imgElement.src;
            }
            if (element.href.match(/\.(jpeg|jpg|gif|png|webp|avif)(#.*)?$/i) || element.href.includes('discordapp.com') || element.href.includes('discordapp.net')) {
                return element.href;
            }
        }
        if (element.style && element.style.backgroundImage) {
            const bgImage = element.style.backgroundImage;
            const match = bgImage.match(/url\("?([^"]+)"?\)/);
            if (match && match[1]) {
                return match[1];
            }
        }
        const childImg = element.querySelector('img[src*="cdn.discordapp.com"], img[src*="media.discordapp.net"]');
        if (childImg && childImg.src) {
            return childImg.src;
        }
        return null;
    }

    function getShareableCdnUrl(url) {
        if (!url || typeof url !== 'string') {
            return null;
        }
        try {
            const originalUrl = new URL(url);
            const cdnHostname = 'cdn.discordapp.com';
            let finalPathname = originalUrl.pathname;

            if (!finalPathname.startsWith('/')) {
                finalPathname = '/' + finalPathname;
            }

            let newUrlString = `https://${cdnHostname}${finalPathname}`;

            const paramsToKeep = ['ex', 'is', 'hm'];
            const newSearchParams = new URLSearchParams();
            let paramsKept = false;

            originalUrl.searchParams.forEach((value, key) => {
                if (paramsToKeep.includes(key.toLowerCase())) {
                    if (value) {
                        newSearchParams.append(key, value);
                        paramsKept = true;
                    }
                }
            });

            if (paramsKept) {
                newUrlString += '?' + newSearchParams.toString();
            }
            return newUrlString;

        } catch (e) {
            console.warn('[DEBUG] getShareableCdnUrl: Failed to parse or process URL:', url, e);
            return url;
        }
    }

    function addButtonsToImageWrapper(imageElementWrapper, detectedImageUrl) {
        if (!imageElementWrapper || !imageElementWrapper.parentNode) {
            console.error('[DEBUG] addButtonsToImageWrapper: Target wrapper or its parent is invalid.');
            return;
        }

        const shareableCdnUrl = getShareableCdnUrl(detectedImageUrl);

        if (!shareableCdnUrl) {
            console.warn('[DEBUG] addButtonsToImageWrapper: Could not derive a shareable CDN URL from:', detectedImageUrl);
            return;
        }

        let existingButtonContainer = imageElementWrapper.nextElementSibling;
        if (existingButtonContainer && existingButtonContainer.classList.contains(BUTTON_CONTAINER_CLASS) && existingButtonContainer.dataset.imageUrl === shareableCdnUrl) {
            if (!imageElementWrapper.hasAttribute(PROCESSED_TARGET_MARKER)) {
                imageElementWrapper.setAttribute(PROCESSED_TARGET_MARKER, 'true');
            }
            return;
        }
        if (existingButtonContainer && existingButtonContainer.classList.contains(BUTTON_CONTAINER_CLASS)) {
            existingButtonContainer.remove();
        }

        let buttonContainer = document.createElement('div');
        buttonContainer.className = BUTTON_CONTAINER_CLASS;
        buttonContainer.setAttribute('data-image-url', shareableCdnUrl);

        const downloadBtn = document.createElement('button');
        downloadBtn.innerHTML = '保存原图';
        downloadBtn.className = 'custom-image-button custom-download-button';
        downloadBtn.onclick = async (e) => {
            e.stopPropagation(); e.preventDefault();
            downloadBtn.disabled = true;
            downloadBtn.innerHTML = '保存中...';
            const response = await fetch(shareableCdnUrl);
            const blob = await response.blob();
            const objectUrl = URL.createObjectURL(blob);
            GM_download(objectUrl, "discord_image.png");
            setTimeout(() => {
                downloadBtn.innerHTML = '已保存!';
                downloadBtn.disabled = false;
            }, 2000);
        };

        const copyLinkBtn = document.createElement('button');
        copyLinkBtn.innerHTML = '复制链接';
        copyLinkBtn.className = 'custom-image-button custom-copy-link-button';
        copyLinkBtn.onclick = (e) => {
            e.stopPropagation(); e.preventDefault();
            copyLinkBtn.disabled = true;
            copyLinkBtn.innerHTML = '已复制!';
            GM_setClipboard(shareableCdnUrl);
            setTimeout(() => {
                copyLinkBtn.innerHTML = '复制链接';
                copyLinkBtn.disabled = false;
            }, 2000);
        };

        buttonContainer.appendChild(downloadBtn);
        buttonContainer.appendChild(copyLinkBtn);

        imageElementWrapper.parentNode.insertBefore(buttonContainer, imageElementWrapper.nextSibling);
        imageElementWrapper.setAttribute(PROCESSED_TARGET_MARKER, 'true');
    }

    function scanForImages() {
        const potentialWrappers = document.querySelectorAll(imageParentSelectors.join(', '));

        potentialWrappers.forEach((potentialWrapper) => {
            let elementForUrlExtraction =
                potentialWrapper.querySelector('img[src*="discordapp.com"], img[src*="discordapp.net"]') ||
                potentialWrapper.querySelector('a[href*="discordapp.com"], a[href*="discordapp.net"]') ||
                potentialWrapper;

            const currentDetectedUrlInDom = getImageUrl(elementForUrlExtraction);
            if (currentDetectedUrlInDom) {
                addButtonsToImageWrapper(potentialWrapper, currentDetectedUrlInDom);
            }
        });
    }

    const observer = new MutationObserver((mutationsList) => {
        let shouldScan = false;
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        shouldScan = true;
                        break;
                    }
                }
            }
        }
        if (shouldScan) {
            scanForImages();
        }
    });

    function startObserver() {
        scanForImages();
        observer.observe(document.body, {
            childList: true,
            subtree: true
        });
    }

    // 添加样式
    const style = document.createElement('style');
    style.innerHTML = `
        /* 自定义按钮容器 */
        .custom-image-buttons-container {
          display: flex;
          flex-direction: row;
          gap: 8px;
          background-color: #202225;
          padding: 6px 8px;
          border-radius: 5px;
          box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
          pointer-events: auto;
          margin-top: 8px;
          margin-bottom: 8px;
          justify-self: center;
        }

        /* 通用按钮样式 */
        .custom-image-button {
          color: #FFFFFF;
          border: 1px solid rgba(0,0,0,0.2);
          padding: 8px 12px;
          border-radius: 4px;
          cursor: pointer;
          font-family: "gg sans", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
          font-size: 13px;
          font-weight: 600;
          transition: background-color 0.15s ease, color 0.15s ease, transform 0.1s ease, border-color 0.15s ease;
          display: flex;
          align-items: center;
          justify-content: center;
          min-width: 90px;
          box-sizing: border-box;
          text-align: center;
          line-height: 1.2;
        }

        /* 按钮禁用状态 */
        .custom-image-button:disabled {
          background-color: #9e9e9e;
          cursor: not-allowed;
          color: #b0b0b0;
        }

        /* 悬停效果 */
        .custom-image-button:hover:not(:disabled) {
          transform: translateY(-1px);
          box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }

        /* 按钮点击时效果 */
        .custom-image-button:active:not(:disabled) {
          transform: translateY(0px);
          box-shadow: inset 0 1px 2px rgba(0,0,0,0.1);
        }

        /* 保存原图按钮 - 绿色 */
        .custom-download-button {
          background-color: #72b572;
          border-color: #5f985f;
        }

        .custom-download-button:hover {
          background-color: #65a565;
          border-color: #508750;
          color: #FFFFFF;
        }

        .custom-download-button:active {
          background-color: #589558;
        }

        /* 复制链接按钮 - 紫色 */
        .custom-copy-link-button {
          background-color: #9b84d7;
          border-color: #836fc0;
        }

        .custom-copy-link-button:hover {
          background-color: #8c73c6;
          border-color: #725ea9;
          color: #FFFFFF;
        }

        .custom-copy-link-button:active {
          background-color: #7d63b5;
        }
    `;
    document.head.appendChild(style);

    startObserver();
})();