Greasy Fork

Greasy Fork is available in English.

X Media Downloader

Download all media content (images, videos) from Twitter/X posts with one click.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name        X Media Downloader
// @namespace   http://tampermonkey.net/
// @version     1.0
// @author      Ksanadu
// @match       https://twitter.com/*
// @match       https://x.com/*
// @grant       GM_download
// @grant       GM_xmlhttpRequest
// @run-at      document-start
// @license     MIT
// @description Download all media content (images, videos) from Twitter/X posts with one click.
// ==/UserScript==

(function() {
    'use strict';

    const CLASS_NAME = 'x-batch-downloader';
    const SVG_ICON = `
        <svg viewBox="0 0 24 24" style="width: 100%; height: 100%; display: block;">
            <path d="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11"
                  fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" />
        </svg>
    `;

    const style = document.createElement('style');
    style.innerHTML = `
        .${CLASS_NAME} {
            position: absolute !important;
            bottom: 6px !important;
            left: 6px !important;
            z-index: 2147483647 !important;

            display: flex !important;
            align-items: center !important;
            justify-content: center !important;
            width: 28px !important;
            height: 28px !important;
            padding: 5px !important;
            box-sizing: border-box !important;
            border-radius: 4px !important;

            background-color: rgba(0, 0, 0, 0.6) !important;
            border: 1px solid rgba(255, 255, 255, 0.3) !important;
            color: #ffffff !important;

            cursor: pointer !important;
            pointer-events: auto !important;
            transition: transform 0.2s !important;
        }
        .${CLASS_NAME}:hover {
            background-color: rgba(29, 161, 242, 0.9) !important;
            transform: scale(1.1);
        }
        .x-batch-loading {
            opacity: 0.7;
            animation: x-spin 1s linear infinite;
        }
        @keyframes x-spin { 100% { transform: rotate(360deg); } }
    `;
    document.head.appendChild(style);

    function globalScan() {
        document.querySelectorAll('video').forEach(video => {
            const container = video.closest('div[data-testid="videoComponent"]') ||
                              video.closest('div[data-testid="videoPlayer"]') ||
                              video.parentNode;
            injectButton(container);
        });

        document.querySelectorAll('img[src*="format"]').forEach(img => {
            if (img.src.includes('/profile_images/') || img.src.includes('emoji')) return;

            let container = img.closest('div[data-testid="tweetPhoto"]');

            if (!container) {
                const link = img.closest('a[href*="/status/"]');
                if (link) container = img.parentNode;
            }

            if (!container && img.naturalWidth > 50) container = img.parentNode;

            if (container) injectButton(container);
        });
    }

    function injectButton(container) {
        if (!container || container.querySelector(`.${CLASS_NAME}`)) return;

        const rect = container.getBoundingClientRect();
        if (rect.width < 50 || rect.height < 50) return;

        const computedStyle = window.getComputedStyle(container);
        if (computedStyle.position === 'static') container.style.position = 'relative';

        const btn = document.createElement('div');
        btn.className = CLASS_NAME;
        btn.innerHTML = SVG_ICON;
        btn.title = 'Batch Download';

        btn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            startBatchDownload(btn, container);
        };

        container.appendChild(btn);
    }

    setInterval(globalScan, 1500);
    const observer = new MutationObserver(() => globalScan());
    if (document.body) observer.observe(document.body, { childList: true, subtree: true });

    async function startBatchDownload(btn, container) {
        const svg = btn.querySelector('svg');
        svg.classList.add('x-batch-loading');

        try {
            let statusId = 'unknown';
            let userName = 'twitter';

            let article = container.closest('article');
            let link = container.closest('a[href*="/status/"]');

            if (article) {
                const idLink = article.querySelector('a[href*="/status/"]');
                if (idLink) statusId = idLink.href.split('/status/').pop().split('/')[0];
                const userEl = article.querySelector('div[data-testid="User-Name"] a');
                if (userEl) userName = userEl.getAttribute('href').replace('/', '');
            } else if (link) {
                const parts = link.href.split('/');
                const statusIndex = parts.indexOf('status');
                if (statusIndex > -1) {
                    statusId = parts[statusIndex + 1];
                    userName = parts[statusIndex - 1];
                }
            }

            let mediaList = getFullTweetMedia(container) ||
                            getFullTweetMedia(link) ||
                            getFullTweetMedia(article);

            if (!mediaList || mediaList.length === 0) {
                mediaList = tryExtractFromDOM(container);
            }

            if (mediaList && mediaList.length > 0) {
                const uniqueList = mediaList.filter((v,i,a)=>a.findIndex(t=>(t.url===v.url))===i);
                await downloadFiles(uniqueList, statusId, userName);
            } else {
                alert('No media found.');
            }

        } catch (err) {
            console.error(err);
            alert('Error: ' + err.message);
        } finally {
            svg.classList.remove('x-batch-loading');
        }
    }

    function getFullTweetMedia(domNode) {
        if (!domNode) return null;
        const key = Object.keys(domNode).find(k => k.startsWith('__reactFiber$'));
        if (!key) return null;

        let fiber = domNode[key];
        let attempts = 0;
        let foundMedia = null;

        while (fiber && attempts < 40) {
            const props = fiber.memoizedProps;

            if (props?.tweet?.extended_entities?.media) {
                return parseMedia(props.tweet.extended_entities.media);
            }
            if (props?.data?.tweet?.extended_entities?.media) {
                return parseMedia(props.data.tweet.extended_entities.media);
            }
            if (props?.item?.content?.tweet?.extended_entities?.media) {
                 return parseMedia(props.item.content.tweet.extended_entities.media);
            }

            if (!foundMedia && props?.media?.media_url_https) {
                foundMedia = parseMedia([props.media]);
            }

            fiber = fiber.return;
            attempts++;
        }

        return foundMedia;
    }

    function parseMedia(mediaArray) {
        return mediaArray.map(media => {
            if (media.type === 'photo') {
                return { url: media.media_url_https + ':orig', ext: 'jpg' };
            } else if (media.type === 'video' || media.type === 'animated_gif') {
                const variants = media.video_info.variants
                    .filter(n => n.content_type === 'video/mp4')
                    .sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
                if (variants.length > 0) return { url: variants[0].url, ext: 'mp4' };
            }
            return null;
        }).filter(Boolean);
    }

    function tryExtractFromDOM(container) {
        const results = [];
        container.querySelectorAll('img[src*="format"]').forEach(img => {
            const u = new URL(img.src);
            if (u.pathname.includes('/media/')) {
                const format = u.searchParams.get('format') || 'jpg';
                results.push({ url: `${u.origin}${u.pathname}?format=${format}&name=orig`, ext: format });
            }
        });
        container.querySelectorAll('video').forEach(v => {
            if (v.src && v.src.startsWith('http')) results.push({ url: v.src, ext: 'mp4' });
        });
        return results;
    }

    async function downloadFiles(list, id, user) {
        for (let i = 0; i < list.length; i++) {
            const item = list[i];
            const name = `twitter_${user}_${id}_${i+1}.${item.ext}`;
            await downloadAsBlob(item.url, name);
        }
    }

    function downloadAsBlob(url, filename) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET", url, responseType: "blob",
                onload: res => {
                    if (res.status === 200) {
                        const u = URL.createObjectURL(res.response);
                        const a = document.createElement('a');
                        a.href = u; a.download = filename;
                        document.body.appendChild(a); a.click(); document.body.removeChild(a);
                        setTimeout(() => URL.revokeObjectURL(u), 1000); resolve();
                    } else reject(new Error(res.status));
                }, onerror: reject
            });
        });
    }

})();