Greasy Fork

Greasy Fork is available in English.

X 媒体下载器 (X Media Downloader)

在 X (Twitter) 帖子底部添加下载按钮,一键下载所有图片(原图)和视频(最高画质)。

当前为 2025-11-22 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X 媒体下载器 (X Media Downloader)
// @namespace    
// @version      1.2.0
// @description  在 X (Twitter) 帖子底部添加下载按钮,一键下载所有图片(原图)和视频(最高画质)。
// @description:en One-click download of all images (original quality) and videos (highest quality) from X (Twitter) tweets.
// @author       User & Gemini
// @match        https://x.com/*
// @match        https://twitter.com/*
// @icon         https://abs.twimg.com/favicons/twitter.3.ico
// @grant        GM_download
// @grant        GM_addStyle
// @connect      twitter.com
// @connect      x.com
// @connect      pbs.twimg.com
// @connect      video.twimg.com
// @author       VoidMuser
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    console.log('✅ X 媒体下载器已加载 ');

    // ==========================================
    // 1. 核心数据存储
    // ==========================================
    const mediaMap = new Map();

    // ==========================================
    // 2. 图标与样式定义 (SVG)
    // ==========================================

    // 下载图标 (箭头)
    const ICON_DOWNLOAD = `<svg viewBox="0 0 24 24" class="xmd-icon-main"><path d="M12 15.586l-4.293-4.293-1.414 1.414L12 18.414l5.707-5.707-1.414-1.414z"></path><path d="M11 2h2v14h-2z"></path><path d="M5 20h14v2H5z"></path></svg>`;

    // 加载中圆环 (用于动画)
    // r=10, circle length approx 63. This ring sits on top.
    const ICON_LOADING_RING = `
        <svg viewBox="0 0 24 24" class="xmd-ring-svg">
            <circle cx="12" cy="12" r="10" fill="none" stroke="#00ba7c" stroke-width="2.5" stroke-linecap="round"></circle>
        </svg>
    `;

    // 成功图标 (钩)
    const ICON_SUCCESS = `<svg viewBox="0 0 24 24" class="xmd-icon-result"><path d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"></path></svg>`;

    // 失败图标 (叉)
    const ICON_ERROR = `<svg viewBox="0 0 24 24" class="xmd-icon-result"><path d="M13.414 12l4.293-4.293-1.414-1.414L12 10.586 7.707 6.293 6.293 7.707 10.586 12l-4.293 4.293 1.414 1.414L12 13.414l4.293 4.293 1.414-1.414L13.414 12z"></path></svg>`;

    GM_addStyle(`
        /* 按钮容器 */
        .xmd-btn {
            position: relative;
            display: flex;
            align-items: center;
            justify-content: center;
            width: 34px;
            height: 34px;
            border-radius: 50%; /* 圆形 */
            cursor: pointer;
            transition: all 0.2s ease;
            color: rgb(113, 118, 123); /* 默认灰色 */
            margin-left: 2px;
            overflow: hidden; /* 确保背景色填满圆形 */
        }
        
        /* 悬停效果 */
        .xmd-btn:hover:not(.xmd-loading):not(.xmd-success):not(.xmd-error) {
            background-color: rgba(29, 155, 240, 0.1);
            color: rgb(29, 155, 240);
        }

        /* 图标通用样式 */
        .xmd-btn svg {
            width: 20px;
            height: 20px;
            fill: currentColor;
            transition: opacity 0.2s;
        }

        /* --- 状态:加载中 --- */
        .xmd-btn.xmd-loading {
            pointer-events: none;
        }
        
        /* 加载时:原图标变淡变灰 */
        .xmd-btn.xmd-loading .xmd-icon-main {
            opacity: 0.3;
            color: rgb(180, 180, 180);
        }

        /* 加载环的位置 */
        .xmd-ring-svg {
            position: absolute;
            top: 0;
            left: 0;
            width: 100% !important; /* 撑满容器 */
            height: 100% !important;
            transform: rotate(-90deg); /* 从12点方向开始 */
            opacity: 0;
            pointer-events: none;
        }

        /* 加载环动画显示 */
        .xmd-btn.xmd-loading .xmd-ring-svg {
            opacity: 1;
        }

        /* 圆环的描边动画:stroke-dasharray 控制虚线长短 */
        .xmd-btn.xmd-loading circle {
            stroke-dasharray: 63; /* 圆周长 2*PI*10 ≈ 63 */
            stroke-dashoffset: 63; /* 初始完全隐藏 */
            animation: xmd-fill-circle 1.5s ease-in-out infinite;
        }

        @keyframes xmd-fill-circle {
            0% { stroke-dashoffset: 63; }
            100% { stroke-dashoffset: 0; }
        }

        /* --- 状态:成功 (满屏绿底白钩) --- */
        .xmd-btn.xmd-success {
            background-color: rgb(0, 186, 124) !important; /* 绿色背景 */
            color: white !important;
            transform: scale(1.1); /* 轻微放大 */
        }

        /* --- 状态:失败 (满屏红底白叉) --- */
        .xmd-btn.xmd-error {
            background-color: rgb(249, 24, 128) !important; /* 红色背景 */
            color: white !important;
            transform: scale(1.1);
        }

        /* 结果图标动画 (弹出效果) */
        .xmd-icon-result {
            animation: xmd-pop 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
        }

        @keyframes xmd-pop {
            0% { transform: scale(0); opacity: 0; }
            100% { transform: scale(1); opacity: 1; }
        }
    `);

    // ==========================================
    // 3. 工具函数
    // ==========================================
    
    // ... (保持原有的 ID 提取、文件名处理、下载封装等函数不变,此处省略以节省篇幅,完全复用之前的逻辑) ...
    function extractStatusId(url) {
        if (!url) return null;
        const m = url.match(/\/status\/(\d+)/);
        return m ? m[1] : null;
    }

    function unique(arr) { return [...new Set(arr)]; }

    function getFileExtFromUrl(url, fallback = 'jpg') {
        try {
            const u = new URL(url);
            const parts = u.pathname.split('.');
            if (parts.length > 1) {
                return parts.pop().replace(/[^a-zA-Z0-9]/g, '') || fallback;
            }
        } catch (e) {}
        return fallback;
    }

    function toOriginalImageUrl(url) {
        if (!url) return url;
        try {
            const u = new URL(url);
            u.searchParams.set('name', 'orig');
            return u.toString();
        } catch (e) { return url; }
    }

    function sanitizeFilename(name) {
        let safeName = (name || 'media').replace(/[\/\\\?\%\*\:\|"<>\r\n]/g, '_').trim();
        if (safeName.length > 80) safeName = safeName.substring(0, 80);
        return safeName || 'media';
    }

    function buildFilenameBase(mediaInfo, tweetId) {
        const text = mediaInfo.text || '';
        const cleanText = text.replace(/https:\/\/t\.co\/\w+/g, '').trim();
        if (cleanText) return `${sanitizeFilename(cleanText)}_${tweetId}`;
        return `tweet_${tweetId}`;
    }

    function gmDownload(url, filename) {
        return new Promise((resolve, reject) => {
            GM_download({
                url,
                name: filename,
                saveAs: true,
                onload: resolve,
                onerror: reject
            });
        });
    }

    // ==========================================
    // 4. API 数据解析 
    // ==========================================

    function processResponseBody(text) {
        try {
            const data = JSON.parse(text);
            traverseForMedia(data);
        } catch (e) {}
    }

    function traverseForMedia(obj) {
        if (!obj || typeof obj !== 'object') return;
        if (obj.extended_entities?.media) {
            collectMediaFromNode(obj, obj.extended_entities.media);
        } else if (obj.legacy?.extended_entities?.media) {
            collectMediaFromNode(obj.legacy, obj.legacy.extended_entities.media);
        }
        for (const key in obj) {
            if (obj[key] && typeof obj[key] === 'object') traverseForMedia(obj[key]);
        }
    }

    function collectMediaFromNode(node, mediaArray) {
        if (!mediaArray || !mediaArray.length) return;
        const idCandidates = [node.id_str, node.rest_id, node.conversation_id_str, node.legacy?.id_str].filter(Boolean);
        if (!idCandidates.length) return;

        const fullText = node.full_text || node.legacy?.full_text || node.text || '';

        idCandidates.forEach(tweetId => {
            if (!mediaMap.has(tweetId)) {
                mediaMap.set(tweetId, { id: tweetId, text: fullText, photos: [], videos: [] });
            }
            const existing = mediaMap.get(tweetId);
            mediaArray.forEach(m => {
                if (m.type === 'photo') {
                    const url = toOriginalImageUrl(m.media_url_https || m.media_url);
                    if (!existing.photos.includes(url)) existing.photos.push(url);
                } else if (m.type === 'video' || m.type === 'animated_gif') {
                    const variants = m.video_info?.variants || [];
                    const best = variants.filter(v => v.content_type === 'video/mp4').sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0))[0];
                    if (best && !existing.videos.some(v => v.url === best.url)) existing.videos.push({ url: best.url, bitrate: best.bitrate });
                }
            });
        });
    }

    // ==========================================
    // 5. 网络请求拦截
    // ==========================================
    const API_REGEX = /(api\.)?(twitter|x)\.com\/(i\/api\/)?(2|media|graphql|1\.1)\//i;

    function hookFetch() {
        const originalFetch = window.fetch;
        window.fetch = async function (...args) {
            const response = await originalFetch.apply(this, args);
            const url = args[0] instanceof Request ? args[0].url : args[0];
            if (API_REGEX.test(url) && response.ok) {
                 const clone = response.clone();
                 clone.text().then(processResponseBody).catch(()=>{});
            }
            return response;
        };
    }

    function hookXHR() {
        const originalOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function(method, url) {
            this._url = url;
            return originalOpen.apply(this, arguments);
        };
        const originalSend = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.send = function() {
            this.addEventListener('load', function() {
                if (API_REGEX.test(this._url) && this.responseText) processResponseBody(this.responseText);
            });
            return originalSend.apply(this, arguments);
        };
    }

    // ==========================================
    // 6. UI 注入与交互
    // ==========================================

    function observeArticles() {
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.addedNodes.length) {
                    document.querySelectorAll('article:not([data-xmd-init])').forEach(initArticle);
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    function initArticle(article) {
        article.setAttribute('data-xmd-init', 'true');
        const hasMedia = article.querySelector('[data-testid="videoPlayer"], [data-testid="tweetPhoto"]');
        if (!hasMedia) return;
        const group = article.querySelector('div[role="group"]');
        if (!group) return;

        const btn = document.createElement('div');
        btn.className = 'xmd-btn';
        btn.title = "下载媒体";
        
        // 初始 HTML:图标 + 隐藏的加载环
        btn.innerHTML = ICON_DOWNLOAD + ICON_LOADING_RING;

        btn.onclick = (e) => {
            e.preventDefault();
            e.stopPropagation();
            handleDownload(article, btn);
        };

        group.appendChild(btn);
    }

    async function handleDownload(article, btn) {
        if (btn.classList.contains('xmd-loading')) return;

        // 1. 获取 ID
        const links = Array.from(article.querySelectorAll('a[href*="/status/"]'));
        const tweetIds = unique(links.map(a => extractStatusId(a.href)).filter(Boolean));
        if (tweetIds.length === 0) return;

        // 2. 切换到【加载中】状态
        // (CSS 会自动淡化 ICON_DOWNLOAD,并显示 ICON_LOADING_RING 开始动画)
        btn.classList.add('xmd-loading');

        const tasks = [];
        const seenUrls = new Set();

        tweetIds.forEach(id => {
            const data = mediaMap.get(id);
            if (!data) return;
            const baseName = buildFilenameBase(data, id);
            let index = 0;
            const allMedia = [
                ...data.photos.map(url => ({ type: 'img', url })),
                ...data.videos.map(v => ({ type: 'vid', url: v.url }))
            ];
            allMedia.forEach(m => {
                if (seenUrls.has(m.url)) return;
                seenUrls.add(m.url);
                index++;
                const ext = m.type === 'img' ? getFileExtFromUrl(m.url) : 'mp4';
                const filename = allMedia.length > 1 ? `${baseName}_${index}.${ext}` : `${baseName}.${ext}`;
                tasks.push(() => gmDownload(m.url, filename));
            });
        });

        // 延迟函数(用于展示成功/失败状态)
        const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms));
        // 最小加载时间 (让用户看清动画,防止闪烁)
        const minLoadTime = wait(600);

        try {
            if (tasks.length === 0) {
                throw new Error("No media found");
            }
            
            // 执行下载并等待最小时间
            await Promise.all([Promise.all(tasks.map(t => t())), minLoadTime]);
            
            // 3. 切换到【成功】状态
            // 移除加载类,添加成功类
            btn.classList.remove('xmd-loading');
            btn.classList.add('xmd-success');
            // 替换内容为钩
            btn.innerHTML = ICON_SUCCESS;

        } catch (err) {
            // 3. 切换到【失败】状态
            await minLoadTime; // 确保至少转了一会儿
            btn.classList.remove('xmd-loading');
            btn.classList.add('xmd-error');
            // 替换内容为叉
            btn.innerHTML = ICON_ERROR;
        }

        // 4. 【恢复】状态
        // 停留 1.5 秒后恢复初始样貌
        await wait(1500);
        
        // 移除所有状态类
        btn.classList.remove('xmd-success', 'xmd-error');
        // 恢复初始 HTML (箭头 + 隐藏环)
        btn.innerHTML = ICON_DOWNLOAD + ICON_LOADING_RING;
    }

    // ==========================================
    // 7. 启动
    // ==========================================
    hookFetch();
    hookXHR();
    setTimeout(observeArticles, 1000);

})();