Twitter Media Downloader

Download all media images in original quality.

目前為 2025-01-08 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Twitter Media Downloader
// @description  Download all media images in original quality.
// @icon         https://www.google.com/s2/favicons?sz=64&domain=x.com
// @version      1.0
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/misc-scripts/
// @supportURL   https://github.com/afkarxyz/misc-scripts/issues
// @license      MIT
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        GM_xmlhttpRequest
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// ==/UserScript==

(function() {
    'use strict';

    let extractedUrls = new Set();
    let isScrolling = false;
    let isPaused = false;
    let isDownloading = false;
    let zip = new JSZip();
    let downloadedCount = 0;

    const downloadIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" style="vertical-align: middle; cursor: pointer;">
        <defs><style>.fa-secondary{opacity:.4}</style></defs>
        <path class="fa-secondary" fill="currentColor" d="M0 256C0 397.4 114.6 512 256 512s256-114.6 256-256c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 106-86 192-192 192S64 362 64 256c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/>
        <path class="fa-primary" fill="currentColor" d="M390.6 185.4c12.5 12.5 12.5 32.8 0 45.3l-112 112c-12.5 12.5-32.8 12.5-45.3 0l-112-112c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L224 242.7 224 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 210.7 57.4-57.4c12.5-12.5 32.8-12.5 45.3 0z"/>
    </svg>`;

    function createOverlayElements() {
        const overlay = document.createElement('div');
        overlay.id = 'media-downloader-overlay';
        overlay.style.cssText = `
            position: fixed;
            top: 10px;
            right: 10px;
            z-index: 9999;
            display: none;
            flex-direction: column;
            gap: 10px;
            align-items: flex-end;
            font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Ubuntu, "Helvetica Neue", sans-serif;
        `;

        const buttonContainer = document.createElement('div');
        buttonContainer.style.cssText = `
            display: flex;
            flex-direction: column;
            align-items: center;
            gap: 10px;
        `;

        const buttonRow = document.createElement('div');
        buttonRow.className = 'buttonRow';
        buttonRow.style.cssText = `
            display: flex;
            gap: 10px;
            justify-content: center;
        `;

        const statusText = document.createElement('div');
        statusText.id = 'download-status';
        statusText.style.cssText = `
            color: white;
            background-color: rgba(0, 0, 0, 0.7);
            padding: 0px 10px;
            border-radius: 4px;
            margin-top: 5px;
            display: none;
            font-family: inherit;
            font-size: 12px;
            text-align: center;
            width: 100%;
        `;

        const progressContainer = document.createElement('div');
        progressContainer.id = 'progress-container';
        progressContainer.style.cssText = `
            width: 200px;
            display: none;
            flex-direction: column;
            gap: 5px;
        `;

        const progressBar = document.createElement('div');
        progressBar.id = 'progress-bar';
        progressBar.style.cssText = `
            width: 100%;
            height: 2px;
            background-color: #f0f0f0;
            border-radius: 1px;
            position: relative;
            overflow: hidden;
        `;

        const progressFill = document.createElement('div');
        progressFill.id = 'progress-fill';
        progressFill.style.cssText = `
            width: 0%;
            height: 100%;
            background-color: #1da1f2;
            position: absolute;
            transition: width 0.3s;
        `;

        const progressStats = document.createElement('div');
        progressStats.style.cssText = `
            display: flex;
            justify-content: space-between;
            color: white;
            font-size: 12px;
            font-family: inherit;
        `;

        const progressCount = document.createElement('div');
        progressCount.id = 'progress-count';
        progressCount.style.cssText = `
            color: white;
            background-color: rgba(0, 0, 0, 0.7);
            padding: 2px 6px;
            border-radius: 4px;
        `;

        const progressPercent = document.createElement('div');
        progressPercent.id = 'progress-percent';
        progressPercent.style.cssText = `
            color: white;
            background-color: rgba(0, 0, 0, 0.7);
            padding: 2px 6px;
            border-radius: 4px;
        `;

        const pauseResumeButton = createButton('Pause', '#1da1f2');
        const stopButton = createButton('Stop', '#e0245e');

        pauseResumeButton.id = 'pause-resume-button';
        stopButton.id = 'stop-button';

        pauseResumeButton.addEventListener('click', () => {
            if (isDownloading) {
                isPaused = !isPaused;
                pauseResumeButton.textContent = isPaused ? 'Resume' : 'Pause';
                if (!isPaused) {
                    scrollAndExtract();
                }
            }
        });

        stopButton.addEventListener('click', () => {
            if (isDownloading) {
                isDownloading = false;
                isPaused = false;
                finishDownload();
            }
        });

        progressBar.appendChild(progressFill);
        progressStats.appendChild(progressCount);
        progressStats.appendChild(progressPercent);
        progressContainer.appendChild(progressBar);
        progressContainer.appendChild(progressStats);

        buttonRow.appendChild(pauseResumeButton);
        buttonRow.appendChild(stopButton);
        buttonContainer.appendChild(buttonRow);
        buttonContainer.appendChild(statusText);

        overlay.appendChild(buttonContainer);
        overlay.appendChild(progressContainer);
        document.body.appendChild(overlay);
    }

    function createButton(text, bgColor) {
        const button = document.createElement('button');
        button.textContent = text;
        const darkenColor = (color, amount) => {
            return '#' + color.replace(/^#/, '').replace(/../g, color => 
                ('0' + Math.min(255, Math.max(0, parseInt(color, 16) - amount)).toString(16)).substr(-2)
            );
        };
        const hoverColor = darkenColor(bgColor, 20);
        button.style.cssText = `
            padding: 5px 10px;
            background-color: ${bgColor};
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            width: 80px;
            font-family: inherit;
            text-align: center;
            transition: background-color 0.3s;
            font-size: 11px;
        `;
        button.addEventListener('mouseenter', () => {
            button.style.backgroundColor = hoverColor;
        });
        button.addEventListener('mouseleave', () => {
            button.style.backgroundColor = bgColor;
        });
        return button;
    }

    function updateStatus(message) {
        const statusElement = document.getElementById('download-status');
        if (statusElement) {
            statusElement.textContent = message;
            statusElement.style.display = 'block';
            statusElement.style.textAlign = 'center';
        }
    }

    function showProgressBar(progress, total) {
        const progressContainer = document.getElementById('progress-container');
        const progressFill = document.getElementById('progress-fill');
        const progressCount = document.getElementById('progress-count');
        const progressPercent = document.getElementById('progress-percent');
        const buttonRow = document.querySelector('#media-downloader-overlay .buttonRow');

        if (progressContainer && progressFill && progressCount && progressPercent && buttonRow) {
            progressContainer.style.display = 'flex';
            buttonRow.style.display = 'none';
            progressFill.style.width = `${progress}%`;
            progressPercent.textContent = `${Math.round(progress)}%`;
        }
    }

    function insertDownloadIcon() {
        const usernameDivs = document.querySelectorAll('[data-testid="UserName"]');

        usernameDivs.forEach(usernameDiv => {
            if (!usernameDiv.querySelector('.download-icon')) {
                let verifiedButton = usernameDiv.querySelector('[aria-label*="verified"], [aria-label*="Verified"]')?.closest('button');
                let targetElement = verifiedButton ? verifiedButton.parentElement : usernameDiv.querySelector('.css-1jxf684')?.closest('span');

                if (targetElement) {
                    const iconDiv = document.createElement('div');
                    iconDiv.className = 'download-icon css-175oi2r r-1awozwy r-xoduu5';
                    iconDiv.style.cssText = `
                        display: inline-flex;
                        align-items: center;
                        margin-left: 6px;
                        transition: transform 0.1s ease;
                    `;
                    iconDiv.innerHTML = downloadIconSvg;

                    iconDiv.addEventListener('mouseenter', () => {
                        iconDiv.style.transform = 'scale(1.1)';
                    });

                    iconDiv.addEventListener('mouseleave', () => {
                        iconDiv.style.transform = 'scale(1)';
                    });

                    const wrapperDiv = document.createElement('div');
                    wrapperDiv.style.cssText = `
                        display: inline-flex;
                        align-items: center;
                        gap: 4px;
                    `;
                    wrapperDiv.appendChild(iconDiv);

                    targetElement.parentNode.insertBefore(wrapperDiv, targetElement.nextSibling);

                    iconDiv.addEventListener('click', async (e) => {
                        e.stopPropagation();
                        extractedUrls.clear();
                        downloadedCount = 0;
                        zip = new JSZip();
                        isDownloading = true;
                        isPaused = false;

                        const overlay = document.getElementById('media-downloader-overlay');
                        const progressContainer = document.getElementById('progress-container');
                        const buttonContainer = document.querySelector('#media-downloader-overlay > div');
                        const buttonRow = document.querySelector('#media-downloader-overlay .buttonRow');

                        if (overlay) {
                            overlay.style.display = 'flex';
                            progressContainer.style.display = 'none';
                            buttonContainer.style.display = 'flex';
                            buttonRow.style.display = 'flex';
                            updateStatus('Starting download...');
                        }

                        await scrollAndExtract();
                    });
                }
            }
        });
    }

    function extractUrls() {
        const elements = document.querySelectorAll('div[data-testid="cellInnerDiv"]');
        let newUrlsFound = false;

        elements.forEach(element => {
            const style = element.getAttribute('style');
            if (style && style.includes('translateY')) {
                const imgElements = element.querySelectorAll('img[src*="https://pbs.twimg.com/media/"]');
                imgElements.forEach(img => {
                    const src = img.getAttribute('src');
                    if (src && src.includes('format=jpg&name=')) {
                        const largeSrc = src.replace(/name=\w+x\w+/, 'name=large');
                        if (!extractedUrls.has(largeSrc)) {
                            extractedUrls.add(largeSrc);
                            newUrlsFound = true;
                            downloadImage(largeSrc);
                        }
                    }
                });
            }
        });

        updateStatus(`${extractedUrls.size} Images Collected`);
        return newUrlsFound;
    }

    function downloadImage(url) {
        GM_xmlhttpRequest({
            method: 'GET',
            url: url,
            responseType: 'arraybuffer',
            onload: function(response) {
                const filename = `${url.split('/').pop().split('?')[0]}.jpg`;
                zip.file(filename, response.response);
                downloadedCount++;
            }
        });
    }

    async function smoothScroll(distance, duration) {
        const start = window.pageYOffset;
        const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();

        function scroll() {
            if (isPaused || !isDownloading) return;

            const now = 'now' in window.performance ? performance.now() : new Date().getTime();
            const time = Math.min(1, ((now - startTime) / duration));

            window.scrollTo(0, start + (distance * time));

            if (time < 1) {
                requestAnimationFrame(scroll);
            }
        }

        scroll();
        return new Promise(resolve => setTimeout(resolve, duration));
    }

    async function scrollAndExtract() {
        if (isScrolling || !isDownloading) return;
        isScrolling = true;

        const scrollStep = 1000;
        const scrollDuration = 1000;
        const waitTime = 1000;
        let consecutiveEmptyScrolls = 0;
        const maxEmptyScrolls = 3;

        while (isDownloading && !isPaused && consecutiveEmptyScrolls < maxEmptyScrolls) {
            await smoothScroll(scrollStep, scrollDuration);
            await new Promise(resolve => setTimeout(resolve, waitTime));

            if (!isDownloading || isPaused) break;

            const newUrlsFound = extractUrls();
            if (!newUrlsFound) {
                consecutiveEmptyScrolls++;
                await smoothScroll(scrollStep * 2, scrollDuration);
                await new Promise(resolve => setTimeout(resolve, waitTime));
                if (!extractUrls()) consecutiveEmptyScrolls++;
            } else {
                consecutiveEmptyScrolls = 0;
            }
        }

        isScrolling = false;
        if (isDownloading && !isPaused) {
            finishDownload();
        }
    }

    function finishDownload() {
        if (extractedUrls.size === 0) {
            updateStatus('No Images Found');
            return;
        }

        updateStatus('Zipping file...');
        showProgressBar(0, extractedUrls.size);

        const currentUrl = window.location.href;
        const match = currentUrl.match(/https:\/\/(?:x|twitter)\.com\/([^\/]+)/);
        const username = match ? match[1] : 'Unknown';

        zip.generateAsync({type:"blob"}, metadata => {
            showProgressBar(metadata.percent, extractedUrls.size);
        })
        .then(function(content) {
            const a = document.createElement('a');
            a.href = URL.createObjectURL(content);
            a.download = `${username}-${extractedUrls.size}.zip`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);

            setTimeout(() => {
                const overlay = document.getElementById('media-downloader-overlay');
                if (overlay) {
                    overlay.style.display = 'none';
                }
                extractedUrls.clear();
                isDownloading = false;
                isPaused = false;
            }, 2000);
        });
    }

    const observer = new MutationObserver(() => {
        insertDownloadIcon();
    });

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

    insertDownloadIcon();
    createOverlayElements();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址