Twitter/X Media Batch Downloader

Batch download all media images in original quality.

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

// ==UserScript==
// @name         Twitter/X Media Batch Downloader
// @description  Batch download all media images in original quality.
// @icon         https://www.google.com/s2/favicons?sz=64&domain=x.com
// @version      1.2
// @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
// @connect      pbs.twimg.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// ==/UserScript==

(function() {
    'use strict';

    const imageIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16" style="vertical-align: middle; cursor: pointer;">
        <path fill="currentColor" d="M448 80c8.8 0 16 7.2 16 16l0 319.8-5-6.5-136-176c-4.5-5.9-11.6-9.3-19-9.3s-14.4 3.4-19 9.3L202 340.7l-30.5-42.7C167 291.7 159.8 288 152 288s-15 3.7-19.5 10.1l-80 112L48 416.3l0-.3L48 96c0-8.8 7.2-16 16-16l384 0zM64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm80 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/>
    </svg>`;

    const zipIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16">
        <path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM96 48c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm-6.3 71.8c3.7-14 16.4-23.8 30.9-23.8l14.8 0c14.5 0 27.2 9.7 30.9 23.8l23.5 88.2c1.4 5.4 2.1 10.9 2.1 16.4c0 35.2-28.8 63.7-64 63.7s-64-28.5-64-63.7c0-5.5 .7-11.1 2.1-16.4l23.5-88.2zM112 336c-8.8 0-16 7.2-16 16s7.2 16 16 16l32 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-32 0z"/>
    </svg>`;

    const downloadIcon = `<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>`;

    const pauseResumeIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16">
        <path fill="currentColor" d="M116.5 71.4c-9.5-7.9-22.8-9.7-34.1-4.4S64 83.6 64 96l0 320c0 12.4 7.2 23.7 18.4 29s24.5 3.6 34.1-4.4l192-160c7.3-6.1 11.5-15.1 11.5-24.6s-4.2-18.5-11.5-24.6l-192-160zM448 96c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-320zm128 0c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 320c0 17.7 14.3 32 32 32s32-14.3 32-32l0-320z"/>
    </svg>`;

    const stopIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16">
        <path fill="currentColor" d="M0 128C0 92.7 28.7 64 64 64H320c35.3 0 64 28.7 64 64V384c0 35.3-28.7 64-64 64H64c-35.3 0-64-28.7-64-64V128z"/>
    </svg>`;

    let extractedUrls = [];
    let isScrolling = false;
    let isPaused = false;
    let shouldStop = false;
    let imageCounter;
    let controlPanel = null;
    let isDownloading = false;

    async function downloadImages() {
        if (isDownloading) return;
        isDownloading = true;
    
        const zip = new JSZip();
        const username = window.location.pathname.split('/')[1];
        
        const progressContainer = controlPanel.panel.querySelector('.progress-container');
        const progressFill = progressContainer.querySelector('.progress-fill');
        const progressText = progressContainer.querySelector('.progress-text');
        const buttonsContainer = controlPanel.panel.querySelector('.buttons-container');
        
        buttonsContainer.style.display = 'none';
        
        progressContainer.style.display = 'block';
        imageCounter.innerHTML = `${zipIcon} ${extractedUrls.length}`;
    
        let successfulDownloads = 0;
        const totalImages = extractedUrls.length;
        
        const batchSize = 5;
        const batches = [];
        
        for (let i = 0; i < extractedUrls.length; i += batchSize) {
            const batch = extractedUrls.slice(i, i + batchSize).map(async (url, batchIndex) => {
                try {
                    const response = await fetch(url, {
                        method: 'GET',
                        credentials: 'omit',
                        headers: {
                            'Accept': 'image/jpeg,image/*',
                        }
                    });
                    
                    if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
                    
                    const blob = await response.blob();
                    const fileNumber = (i + batchIndex + 1).toString();
                    zip.file(`${username}_${fileNumber}.jpg`, blob);
                    successfulDownloads++;
                    
                    const progress = Math.round((successfulDownloads / totalImages) * 100);
                    progressFill.style.width = `${progress}%`;
                    progressText.textContent = `Downloading: (${successfulDownloads}/${totalImages}) ${progress}%`;
                    
                    return true;
                } catch (error) {
                    console.error('Error downloading image:', error);
                    return false;
                }
            });
            batches.push(Promise.all(batch));
        }
    
        for (const batch of batches) {
            await batch;
        }
    
        if (successfulDownloads > 0) {
            progressText.textContent = `Creating ZIP: (0/${successfulDownloads}) 0%`;
            const zipBlob = await zip.generateAsync({
                type: 'blob',
                compression: 'DEFLATE',
                compressionOptions: { level: 3 }
            }, metadata => {
                const progress = Math.round(metadata.percent);
                const processedImages = Math.round((progress / 100) * successfulDownloads);
                progressFill.style.width = `${progress}%`;
                progressText.textContent = `Creating ZIP: (${processedImages}/${successfulDownloads}) ${progress}%`;
            });
    
            const downloadUrl = URL.createObjectURL(zipBlob);
            const a = document.createElement('a');
            a.href = downloadUrl;
            a.download = `${username}_${successfulDownloads}.zip`;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(downloadUrl);
        }
    
        isDownloading = false;
        hideControlPanel();
    }
    
    function createControlPanel() {
        const styles = `
            .control-panel {
                position: fixed;
                top: 16px;
                right: 16px;
                display: flex;
                flex-direction: column;
                gap: 8px;
                background-color: rgba(35, 35, 35, 0.75);
                padding: 12px;
                border-radius: 12px;
                transform: translateX(calc(100% + 16px));
                opacity: 0;
                transition: transform 0.3s ease, opacity 0.3s ease;
                z-index: 9999;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
                pointer-events: none;
                width: 250px; 
            }
            .control-panel.visible {
                transform: translateX(0);
                opacity: 1;
                pointer-events: all;
            }
            .control-panel.hiding {
                transform: translateX(calc(100% + 16px));
                opacity: 0;
                pointer-events: none;
            }
            .buttons-container {
                display: flex;
                gap: 8px;
                margin-bottom: 8px;
                transition: display 0.3s ease;
                justify-content: center; 
                width: 100%; 
            }
            .control-button {
                padding: 8px 16px;
                border: none;
                border-radius: 6px;
                font-family: inherit;
                cursor: pointer;
                transition: background-color 0.2s ease;
                width: 120px;
                text-align: center;
                display: flex;
                align-items: center;
                justify-content: center;
                gap: 6px;
                color: white;
                font-size: 14px;
                flex: 1; 
                max-width: 120px; 
            }
            .pause-button {
                background-color: #1da1f2;
            }
            .pause-button:hover {
                background-color: #1a91da;
            }
            .stop-button {
                background-color: #dc2626;
            }
            .stop-button:hover {
                background-color: #b31f1f;
            }
            .image-counter {
                color: white;
                text-align: center;
                font-size: 14px;
                display: flex;
                align-items: center;
                justify-content: center;
                gap: 6px;
                min-height: 20px; 
            }
            .progress-container {
                display: none;
                margin-top: 8px;
                width: 100%; 
            }
            .progress-bar {
                width: 100%;
                height: 4px;
                background-color: #1a1a1a;
                border-radius: 2px;
            }
            .progress-fill {
                width: 0%;
                height: 100%;
                background-color: #1da1f2;
                border-radius: 2px;
                transition: width 0.3s ease;
            }
            .progress-text {
                color: white;
                font-size: 12px;
                text-align: center;
                margin-top: 4px;
                min-height: 16px; 
            }
        `;
    
        if (!document.querySelector('#control-panel-styles')) {
            const styleSheet = document.createElement('style');
            styleSheet.id = 'control-panel-styles';
            styleSheet.textContent = styles;
            document.head.appendChild(styleSheet);
        }
    
        const panel = document.createElement('div');
        panel.className = 'control-panel';
    
        const buttonsContainer = document.createElement('div');
        buttonsContainer.className = 'buttons-container';
    
        const pauseButton = document.createElement('button');
        pauseButton.className = 'control-button pause-button';
        pauseButton.innerHTML = `${pauseResumeIcon}Pause`;
        
        pauseButton.onclick = () => {
            isPaused = !isPaused;
            pauseButton.innerHTML = `${pauseResumeIcon}${isPaused ? 'Resume' : 'Pause'}`;
        };
        
        const stopButton = document.createElement('button');
        stopButton.className = 'control-button stop-button';
        stopButton.innerHTML = `${stopIcon}Stop`;
        stopButton.onclick = () => {
            shouldStop = true;
        };
    
        const counter = document.createElement('div');
        counter.className = 'image-counter';
        counter.innerHTML = `${imageIcon} 0`;
    
        const progressContainer = document.createElement('div');
        progressContainer.className = 'progress-container';
        progressContainer.innerHTML = `
            <div class="progress-bar">
                <div class="progress-fill"></div>
            </div>
            <div class="progress-text">0%</div>
        `;
    
        buttonsContainer.appendChild(pauseButton);
        buttonsContainer.appendChild(stopButton);
        panel.appendChild(buttonsContainer);
        panel.appendChild(counter);
        panel.appendChild(progressContainer);
        document.body.appendChild(panel);
        
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                panel.classList.add('visible');
            });
        });
    
        return {
            counter,
            panel
        };
    }

    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=360x360')) {
                        const largeSrc = src.replace('name=360x360', 'name=large');
                        if (!extractedUrls.includes(largeSrc)) {
                            extractedUrls.push(largeSrc);
                            newUrlsFound = true;
                            imageCounter.innerHTML = `${imageIcon} ${extractedUrls.length}`;
                        }
                    }
                });
            }
        });
        
        return newUrlsFound;
    }

    async function smoothScroll(distance, duration) {
        const start = window.pageYOffset;
        const startTime = 'now' in window.performance ? performance.now() : new Date().getTime();
        
        function scroll() {
            const now = 'now' in window.performance ? performance.now() : new Date().getTime();
            const time = Math.min(1, ((now - startTime) / duration));
            
            window.scrollTo({
                top: start + (distance * time),
                behavior: 'auto'
            });
            
            if (time < 1) {
                requestAnimationFrame(scroll);
            }
        }
        scroll();
    }

    async function scrollAndExtract() {
        if (isScrolling) return;
        isScrolling = true;
        
        const getScrollStep = () => Math.max(window.innerHeight || document.documentElement.clientHeight);
        const scrollDuration = 1000;
        const waitTime = 1000;
        
        while (!shouldStop) {
            if (isPaused) {
                await new Promise(resolve => setTimeout(resolve, 500));
                continue;
            }
    
            const currentScrollStep = getScrollStep();
    
            await smoothScroll(currentScrollStep, scrollDuration);
            await new Promise(resolve => setTimeout(resolve, waitTime));
            
            const newUrlsFound = extractUrls();
            if (!newUrlsFound) {
                await smoothScroll(currentScrollStep * 2, scrollDuration);
                await new Promise(resolve => setTimeout(resolve, waitTime));
                if (!extractUrls()) break;
            }
        }
        
        isScrolling = false;
        console.log('Finished extracting. Total unique URLs:', extractedUrls.size);
        if (shouldStop || !isPaused) {
            downloadImages();
        }
    }

    function hideControlPanel() {
        if (controlPanel?.panel) {
            controlPanel.panel.classList.remove('visible');
            controlPanel.panel.classList.add('hiding');
            
            controlPanel.panel.addEventListener('transitionend', function handler(e) {
                if (e.propertyName === 'opacity') {
                    controlPanel.panel.removeEventListener('transitionend', handler);
                    controlPanel.panel.remove();
                    controlPanel = null;
                }
            });
        }
    }

    function resetState() {
        extractedUrls = [];  
        isScrolling = false;
        isPaused = false;
        shouldStop = false;
        imageCounter = null;
        if (controlPanel?.panel) {
            controlPanel.panel.remove();
            controlPanel = null;
        }
    }

    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: 4px;
                        margin-right: 4px;
                        gap: 4px;
                        padding: 0 2px;
                        transition: transform 0.2s, color 0.2s;
                    `;
                    iconDiv.innerHTML = downloadIcon;

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

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

                    iconDiv.addEventListener('click', async (e) => {
                        e.stopPropagation();
                        const mediaTab = Array.from(document.querySelectorAll('[role="tab"]'))
                            .find(el => el.textContent.includes('Media'));
                        
                        if (mediaTab) {
                            mediaTab.click();
                            await new Promise(resolve => setTimeout(resolve, 1000));
                            initializeImageExtractor();
                        }
                    });

                    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);
                }
            }
        });
    }

    function initializeImageExtractor() {
        const controls = createControlPanel();
        controlPanel = controls;
        imageCounter = controls.counter;
        scrollAndExtract();
    }

    insertDownloadIcon();

    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            resetState();
            setTimeout(insertDownloadIcon, 1000);
        } else {
            insertDownloadIcon();
        }
    }).observe(document.body, {
        childList: true,
        subtree: true
    });

})();

QingJ © 2025

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