Twitter/X Media Batch Downloader

Batch download all media images in original quality.

当前为 2025-01-08 提交的版本,查看 最新版本

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==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.1
// @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');
        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;
            }
            .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;
            }
            .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;
            }
            .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;
            }
            .progress-container {
                display: none;
                margin-top: 8px;
            }
            .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;
            }
        `;

        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(0, start + (distance * time));
            if (time < 1) {
                requestAnimationFrame(scroll);
            }
        }
        scroll();
    }

    async function scrollAndExtract() {
        if (isScrolling) return;
        isScrolling = true;
        
        const scrollStep = 1000;
        const scrollDuration = 1000;
        const waitTime = 1000;
        
        while (!shouldStop) {
            if (isPaused) {
                await new Promise(resolve => setTimeout(resolve, 500));
                continue;
            }

            await smoothScroll(scrollStep, scrollDuration);
            await new Promise(resolve => setTimeout(resolve, waitTime));
            
            const newUrlsFound = extractUrls();
            if (!newUrlsFound) {
                await smoothScroll(scrollStep * 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
    });

})();