Discord Media Exporter

Preview and download all images & videos from a Discord channel in one ZIP, or copy an aria2c‐compatible URL list to clipboard (with example command).

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Discord Media Exporter
// @namespace    http://tampermonkey.net/
// @version      1.1.0
// @description  Preview and download all images & videos from a Discord channel in one ZIP, or copy an aria2c‐compatible URL list to clipboard (with example command).
// @license      CC-BY-NC-4.0
// @author       DestCom
// @match        https://discord.com/channels/*
// @grant        GM_xmlhttpRequest
// @connect      cdn.discordapp.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==

(function() {
    'use strict';

    //////////////////////////////////////////////////////
    // 1. CREATE “EXPORT MEDIA” BUTTON
    //////////////////////////////////////////////////////
    const exportBtn = document.createElement('button');
    exportBtn.textContent = '📥 Export Media';
    Object.assign(exportBtn.style, {
        position: 'fixed',
        top: '70px',
        right: '20px',
        zIndex: 1000,
        padding: '8px 12px',
        backgroundColor: '#7289DA',
        color: '#FFFFFF',
        border: 'none',
        borderRadius: '4px',
        fontSize: '14px',
        cursor: 'pointer',
        boxShadow: '0 2px 6px rgba(0,0,0,0.2)'
    });
    document.body.appendChild(exportBtn);

    //////////////////////////////////////////////////////
    // 2. BUILD THE MAIN MODAL (Media Preview)
    //////////////////////////////////////////////////////
    const modalOverlay = document.createElement('div');
    modalOverlay.id = 'dme-modal-overlay';
    Object.assign(modalOverlay.style, {
        position: 'fixed',
        top: '0',
        left: '0',
        width: '100%',
        height: '100%',
        backgroundColor: 'rgba(0,0,0,0.75)',
        display: 'none',
        justifyContent: 'center',
        alignItems: 'center',
        zIndex: 2000,
        overflowY: 'auto'
    });

    const modalContent = document.createElement('div');
    modalContent.id = 'dme-modal-content';
    Object.assign(modalContent.style, {
        backgroundColor: '#2F3136',
        borderRadius: '8px',
        padding: '16px',
        maxWidth: '90%',
        maxHeight: '90%',
        overflowY: 'auto',
        color: '#FFFFFF',
        boxSizing: 'border-box'
    });

    const modalHeader = document.createElement('div');
    Object.assign(modalHeader.style, {
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginBottom: '12px'
    });

    const headerTitle = document.createElement('h2');
    headerTitle.id = 'dme-modal-title';
    headerTitle.textContent = 'Media Preview (0/0)';
    Object.assign(headerTitle.style, {
        margin: '0',
        fontSize: '18px'
    });

    const closeBtn = document.createElement('button');
    closeBtn.innerHTML = '✖';
    Object.assign(closeBtn.style, {
        background: 'none',
        border: 'none',
        color: '#CCC',
        fontSize: '20px',
        cursor: 'pointer'
    });
    closeBtn.title = 'Close';

    modalHeader.appendChild(headerTitle);
    modalHeader.appendChild(closeBtn);

    const tableContainer = document.createElement('div');
    tableContainer.id = 'dme-table-container';
    Object.assign(tableContainer.style, {
        overflowX: 'auto',
        backgroundColor: '#2F3136'
    });

    const table = document.createElement('table');
    table.id = 'dme-media-table';
    Object.assign(table.style, {
        width: '100%',
        borderCollapse: 'collapse',
        color: '#FFFFFF'
    });

    const thead = document.createElement('thead');
    const headerRow = document.createElement('tr');
    ['#', 'Filename', 'Type', 'Include'].forEach(text => {
        const th = document.createElement('th');
        th.textContent = text;
        Object.assign(th.style, {
            borderBottom: '2px solid #40444B',
            padding: '8px',
            textAlign: 'left',
            fontSize: '14px'
        });
        headerRow.appendChild(th);
    });
    thead.appendChild(headerRow);

    const tbody = document.createElement('tbody');
    table.appendChild(thead);
    table.appendChild(tbody);
    tableContainer.appendChild(table);

    const modalFooter = document.createElement('div');
    Object.assign(modalFooter.style, {
        marginTop: '16px',
        textAlign: 'right'
    });

    // Download ZIP button
    const downloadSelectedBtn = document.createElement('button');
    downloadSelectedBtn.textContent = 'Download Selected Media';
    Object.assign(downloadSelectedBtn.style, {
        padding: '8px 14px',
        backgroundColor: '#43B581',
        color: '#FFFFFF',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer',
        fontSize: '14px',
        marginRight: '8px'
    });

    // Copy aria2c list button
    const copyAria2cBtn = document.createElement('button');
    copyAria2cBtn.textContent = 'Copy aria2c List';
    Object.assign(copyAria2cBtn.style, {
        padding: '8px 14px',
        backgroundColor: '#7289DA',
        color: '#FFFFFF',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer',
        fontSize: '14px'
    });

    modalFooter.appendChild(downloadSelectedBtn);
    modalFooter.appendChild(copyAria2cBtn);

    modalContent.appendChild(modalHeader);
    modalContent.appendChild(tableContainer);
    modalContent.appendChild(modalFooter);
    modalOverlay.appendChild(modalContent);
    document.body.appendChild(modalOverlay);

    //////////////////////////////////////////////////////
    // 3. BUILD THE STATUS MODAL (Success/Failure + Aria2c example)
    //////////////////////////////////////////////////////
    const statusOverlay = document.createElement('div');
    statusOverlay.id = 'dme-status-overlay';
    Object.assign(statusOverlay.style, {
        position: 'fixed',
        top: '0',
        left: '0',
        width: '100%',
        height: '100%',
        backgroundColor: 'rgba(0,0,0,0.75)',
        display: 'none',
        justifyContent: 'center',
        alignItems: 'center',
        zIndex: 3000,
        overflowY: 'auto'
    });

    const statusContent = document.createElement('div');
    statusContent.id = 'dme-status-content';
    Object.assign(statusContent.style, {
        backgroundColor: '#2F3136',
        borderRadius: '8px',
        padding: '16px',
        maxWidth: '450px',
        color: '#FFFFFF',
        boxSizing: 'border-box',
        textAlign: 'left'
    });

    const statusTextarea = document.createElement('textarea');
    statusTextarea.readOnly = true;
    Object.assign(statusTextarea.style, {
        width: '100%',
        height: '150px',
        backgroundColor: '#1E1F22',
        color: '#FFFFFF',
        border: '1px solid #40444B',
        borderRadius: '4px',
        padding: '8px',
        fontSize: '14px',
        resize: 'vertical',
        boxSizing: 'border-box',
        whiteSpace: 'pre-wrap'
    });

    const statusOkBtn = document.createElement('button');
    statusOkBtn.textContent = 'OK';
    Object.assign(statusOkBtn.style, {
        padding: '8px 14px',
        backgroundColor: '#43B581',
        color: '#FFFFFF',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer',
        fontSize: '14px',
        display: 'block',
        margin: '12px auto 0'
    });

    statusContent.appendChild(statusTextarea);
    statusContent.appendChild(statusOkBtn);
    statusOverlay.appendChild(statusContent);
    document.body.appendChild(statusOverlay);

    //////////////////////////////////////////////////////
    // 4. UTILITIES
    //////////////////////////////////////////////////////
    function sleep(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }

    function getMessageContainer() {
        return document.querySelector('[aria-label="Messages"]');
    }

    async function loadAllMessages() {
        const container = getMessageContainer();
        if (!container) return;
        await sleep(500);

        let previousHeight = -1;
        for (let i = 0; i < 50; i++) {
            const currentHeight = container.scrollHeight;
            if (currentHeight === previousHeight) break;
            previousHeight = currentHeight;
            container.scrollTop = 0;
            await sleep(1000);
        }
        await sleep(2000);
    }

    function collectMediaLinks() {
        const urls = new Set();

        function sanitize(raw) {
            return raw.replace(/[&;]+$/,'');
        }

        document.querySelectorAll('a').forEach(a => {
            const href = a.getAttribute('href');
            if (href && href.includes('cdn.discordapp.com/attachments')) {
                const full = href.split('?')[0] + (href.includes('?') ? href.slice(href.indexOf('?')) : '');
                urls.add(sanitize(full));
            }
        });

        document.querySelectorAll('video').forEach(v => {
            const src = v.getAttribute('src');
            if (src && src.includes('cdn.discordapp.com/attachments')) {
                const full = src.split('?')[0] + (src.includes('?') ? src.slice(src.indexOf('?')) : '');
                urls.add(sanitize(full));
            }
        });

        document.querySelectorAll('source').forEach(sourceElem => {
            const src = sourceElem.getAttribute('src');
            if (src && src.includes('cdn.discordapp.com/attachments')) {
                const full = src.split('?')[0] + (src.includes('?') ? src.slice(src.indexOf('?')) : '');
                urls.add(sanitize(full));
            }
        });

        document.querySelectorAll('img').forEach(img => {
            const src = img.getAttribute('src');
            if (src && src.includes('cdn.discordapp.com/attachments')) {
                const full = src.split('?')[0] + (src.includes('?') ? src.slice(src.indexOf('?')) : '');
                urls.add(sanitize(full));
            }
        });

        return Array.from(urls);
    }

    function isImageUrl(url) {
        return /\.(jpe?g|png|webp|gif)(?:\?|$)/i.test(url);
    }

    function isVideoUrl(url) {
        return /\.(mp4|webm|mov)(?:\?|$)/i.test(url);
    }

    function updateHeaderCount(selectedCount, totalCount) {
        headerTitle.textContent = `Media Preview (${selectedCount}/${totalCount})`;
    }

    function openModal() {
        modalOverlay.style.display = 'flex';
    }

    function closeModal() {
        modalOverlay.style.display = 'none';
        tbody.innerHTML = '';
    }

    function openStatus(messageText) {
        statusTextarea.value = messageText;
        statusOverlay.style.display = 'flex';
        statusTextarea.select();
    }

    function closeStatus() {
        statusOverlay.style.display = 'none';
    }

    // Promisified GM_xmlhttpRequest to fetch ArrayBuffer
    function gmFetchArrayBuffer(url) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: url,
                responseType: 'arraybuffer',
                onload: res => {
                    if (res.status >= 200 && res.status < 300 && res.response) {
                        resolve(res.response);
                    } else {
                        reject(new Error(`Status ${res.status}`));
                    }
                },
                onerror: err => reject(err),
                ontimeout: () => reject(new Error('Timeout')),
            });
        });
    }

    //////////////////////////////////////////////////////
    // 5. DOWNLOAD LOGIC WITH STATUS
    //////////////////////////////////////////////////////
    async function downloadCheckedMedia() {
        const checkedRows = Array.from(tbody.querySelectorAll('tr')).filter(tr => {
            const checkbox = tr.querySelector('input[type="checkbox"]');
            return checkbox && checkbox.checked;
        });

        if (checkedRows.length === 0) {
            openStatus('No files selected to download.');
            return;
        }

        const zip = new JSZip();
        let successCount = 0;
        let failureCount = 0;

        downloadSelectedBtn.disabled = true;
        downloadSelectedBtn.textContent = `⏳ Zipping 0/${checkedRows.length}…`;

        for (let i = 0; i < checkedRows.length; i++) {
            const row = checkedRows[i];
            const url = row.dataset.url;
            try {
                const arrBuf = await gmFetchArrayBuffer(url);
                const blob = new Blob([arrBuf]);
                const filename = new URL(url).pathname.split('/').pop();
                zip.file(filename, blob);
                successCount++;
            } catch (err) {
                console.warn('[DME] Failed to fetch', url, err);
                failureCount++;
            }
            downloadSelectedBtn.textContent = `⏳ Zipping ${Math.min(successCount + failureCount, checkedRows.length)}/${checkedRows.length}…`;
        }

        if (successCount > 0) {
            const zipBlob = await zip.generateAsync({ type: 'blob' });
            saveAs(zipBlob, 'discord_media.zip');
            openStatus(
`✅ ${successCount} file(s) downloaded successfully, ${failureCount} failed.

— Example aria2c command —
aria2c --input-file="./urls.txt" \\
       --dir="./discord_media" \\
       --max-concurrent-downloads=5 \\
       --split=4 \\
       --max-connection-per-server=4 \\
       --header="Referer: https://discord.com/" \\
       -c`
            );
        } else {
            openStatus('No files could be downloaded. Please check your network or permissions.');
        }

        downloadSelectedBtn.textContent = 'Download Selected Media';
        downloadSelectedBtn.disabled = false;
        closeModal();
    }

    //////////////////////////////////////////////////////
    // 6. COPY ARIA2C LIST LOGIC
    //////////////////////////////////////////////////////
    function copyAria2cList() {
        const rows = Array.from(tbody.querySelectorAll('tr'));
        if (rows.length === 0) {
            openStatus('No media to list.');
            return;
        }

        let lines = '';
        rows.forEach(tr => {
            const url = tr.dataset.url;
            const filename = new URL(url).pathname.split('/').pop();
            lines += `${url}\n  out=${filename}\n`;
        });

        navigator.clipboard.writeText(lines.trim())
            .then(() => {
                openStatus(
`✅ Copied ${rows.length} URLs to clipboard.

— Example aria2c command —
aria2c --input-file="./urls.txt" \\
       --dir="./discord_media" \\
       --max-concurrent-downloads=5 \\
       --split=4 \\
       --max-connection-per-server=4 \\
       --header="Referer: https://discord.com/" \\
       -c`
                );
            })
            .catch(() => openStatus('Failed to copy to clipboard.'));
    }

    //////////////////////////////////////////////////////
    // 7. EVENT BINDINGS
    //////////////////////////////////////////////////////
    exportBtn.addEventListener('click', async () => {
        exportBtn.disabled = true;
        exportBtn.textContent = '⏳ Loading messages…';

        await loadAllMessages();

        exportBtn.textContent = '⏳ Collecting media links…';
        const mediaLinks = collectMediaLinks();

        if (mediaLinks.length === 0) {
            openStatus('No media found in this channel.');
            exportBtn.textContent = '📥 Export Media';
            exportBtn.disabled = false;
            return;
        }

        tbody.innerHTML = '';
        const totalCount = mediaLinks.length;
        let selectedCount = totalCount;
        updateHeaderCount(selectedCount, totalCount);

        mediaLinks.forEach((url, index) => {
            const tr = document.createElement('tr');
            tr.dataset.url = url;

            const tdIndex = document.createElement('td');
            tdIndex.textContent = (index + 1).toString();
            Object.assign(tdIndex.style, {
                borderBottom: '1px solid #40444B',
                padding: '6px 8px',
                fontSize: '14px',
                width: '40px'
            });

            const tdFilename = document.createElement('td');
            tdFilename.textContent = new URL(url).pathname.split('/').pop();
            Object.assign(tdFilename.style, {
                borderBottom: '1px solid #40444B',
                padding: '6px 8px',
                fontSize: '14px'
            });

            const tdType = document.createElement('td');
            tdType.textContent = isImageUrl(url) ? 'Image' : (isVideoUrl(url) ? 'Video' : 'Unknown');
            Object.assign(tdType.style, {
                borderBottom: '1px solid #40444B',
                padding: '6px 8px',
                fontSize: '14px',
                width: '80px'
            });

            const tdCheck = document.createElement('td');
            Object.assign(tdCheck.style, {
                borderBottom: '1px solid #40444B',
                padding: '6px 8px',
                textAlign: 'center',
                width: '60px'
            });
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = true;
            checkbox.addEventListener('change', () => {
                selectedCount = Array.from(tbody.querySelectorAll('input[type="checkbox"]'))
                                    .filter(cb => cb.checked).length;
                updateHeaderCount(selectedCount, totalCount);
            });
            tdCheck.appendChild(checkbox);

            tr.appendChild(tdIndex);
            tr.appendChild(tdFilename);
            tr.appendChild(tdType);
            tr.appendChild(tdCheck);
            tbody.appendChild(tr);
        });

        openModal();
        exportBtn.textContent = '📥 Export Media';
        exportBtn.disabled = false;

        downloadSelectedBtn.onclick = async () => {
            await downloadCheckedMedia();
        };
        copyAria2cBtn.onclick = () => {
            copyAria2cList();
        };
    });

    closeBtn.addEventListener('click', closeModal);
    modalOverlay.addEventListener('click', e => {
        if (e.target === modalOverlay) closeModal();
    });

    statusOkBtn.addEventListener('click', closeStatus);
    statusOverlay.addEventListener('click', e => {
        if (e.target === statusOverlay) closeStatus();
    });

})();