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).

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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();
    });

})();