Greasy Fork

Greasy Fork is available in English.

X Media Downloader

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X Media Downloader
// @namespace    
// @version      1.1.0
// @description  在 X (Twitter) 帖子底部添加下载按钮,一键下载所有图片(原图)和视频(最高画质)。
// @description:en One-click download of all images (original quality) and videos (highest quality) from X (Twitter) tweets.
// @author       VoidMuser
// @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
// @run-at       document-start
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

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

    // ==========================================
    // 1. 核心数据存储
    // ==========================================
    // 使用 Map 存储推文 ID 对应的媒体信息,性能优于 Object
    const mediaMap = new Map();

    // ==========================================
    // 2. 工具函数
    // ==========================================

    /**
     * 从链接中提取推文 ID
     * 例如:https://x.com/user/status/123456 -> 123456
     */
    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)];
    }

    /**
     * 从 URL 获取文件扩展名 (默认 jpg)
     * 自动去除 URL 参数干扰
     */
    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;
    }

    /**
     * 将图片 URL 转换为原图链接
     * 逻辑:将 name=xxx 参数替换为 name=orig
     */
    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;
        }
    }

    /**
     * 文件名净化
     * 1. 替换 Windows 非法字符
     * 2. 限制长度防止报错
     */
    function sanitizeFilename(name) {
        let safeName = (name || 'media').replace(/[\/\\\?\%\*\:\|"<>\r\n]/g, '_').trim();
        // 限制长度为 80 字符 (预留后缀和ID的空间)
        if (safeName.length > 80) {
            safeName = safeName.substring(0, 80);
        }
        return safeName || 'media';
    }

    /**
     * 生成下载文件名
     * 格式:推文内容摘要_推文ID.扩展名
     */
    function buildFilenameBase(mediaInfo, tweetId) {
        const text = mediaInfo.text || '';
        // 移除推文中的短链接 (https://t.co/...),让文件名更干净
        const cleanText = text.replace(/https:\/\/t\.co\/\w+/g, '').trim();
        if (cleanText) {
            return `${sanitizeFilename(cleanText)}_${tweetId}`;
        }
        return `tweet_${tweetId}`;
    }

    /**
     * 封装 GM_download 为 Promise,便于异步控制
     */
    function gmDownload(url, filename) {
        return new Promise((resolve, reject) => {
            GM_download({
                url,
                name: filename,
                saveAs: true, // 设置为 true 可避免浏览器将下载视为跨域攻击
                onload: resolve,
                onerror: (err) => {
                    console.error('下载出错:', err);
                    reject(err);
                }
            });
        });
    }

    // ==========================================
    // 3. API 数据解析 (递归查找)
    // ==========================================

    /** 处理响应文本 */
    function processResponseBody(text) {
        try {
            const data = JSON.parse(text);
            traverseForMedia(data);
        } catch (e) {}
    }

    /**
     * 递归遍历 JSON 对象
     * 目的:无论 X 如何修改数据层级,只要包含媒体信息就能找到
     */
    function traverseForMedia(obj) {
        if (!obj || typeof obj !== 'object') return;

        // 检查标准结构
        if (obj.extended_entities?.media) {
            collectMediaFromNode(obj, obj.extended_entities.media);
        }
        // 检查 GraphQL legacy 结构 (X 网页端常用)
        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]);
            }
        }
    }

    /**
     * 提取媒体信息并存入 Map
     */
    function collectMediaFromNode(node, mediaArray) {
        if (!mediaArray || !mediaArray.length) return;

        // 尝试获取所有可能的 ID (包括转发、引用等场景)
        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 || '';

        // 为每个相关 ID 存储媒体信息
        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 || [];
                    // 筛选 mp4 格式,并按码率降序排列(取最大值)
                    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 });
                    }
                }
            });
        });
    }

    // ==========================================
    // 4. 网络请求拦截 (Hook)
    // ==========================================

    // 匹配 Twitter/X 的 API 接口
    const API_REGEX = /(api\.)?(twitter|x)\.com\/(i\/api\/)?(2|media|graphql|1\.1)\//i;

    /** 拦截 fetch 请求 */
    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;
        };
    }

    /** 拦截 XMLHttpRequest (兼容旧版逻辑) */
    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);
        };
    }

    // ==========================================
    // 5. UI 注入 (DOM 操作)
    // ==========================================

    // 注入样式
    GM_addStyle(`
        .xmd-btn {
            display: flex;
            align-items: center;
            justify-content: center;
            width: 34px;
            height: 34px;
            border-radius: 999px;
            cursor: pointer;
            transition: background 0.2s;
            color: rgb(113, 118, 123); /* X 默认灰色 */
            margin-left: 2px;
        }
        .xmd-btn:hover {
            background-color: rgba(29, 155, 240, 0.1);
            color: rgb(29, 155, 240); /* X 默认蓝色 */
        }
        .xmd-btn svg {
            width: 20px;
            height: 20px;
            fill: currentColor;
        }
        /* 状态样式 */
        .xmd-loading { opacity: 0.5; pointer-events: none; }
        .xmd-success { color: rgb(0, 186, 124) !important; } /* 绿色 */
        .xmd-error { color: rgb(249, 24, 128) !important; } /* 红色 */

        /* 旋转动画 */
        @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
        .xmd-spin svg { animation: spin 1s linear infinite; }
    `);

    // 下载图标 SVG
    const DOWNLOAD_ICON = `<svg viewBox="0 0 24 24"><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>`;

    /** 监听 DOM 变化,自动为新加载的推文添加按钮 */
    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;

        // 找到底部操作栏 (评论、转推、点赞所在的 group)
        const group = article.querySelector('div[role="group"]');
        if (!group) return;

        // 创建按钮
        const btn = document.createElement('div');
        btn.className = 'xmd-btn';
        btn.innerHTML = DOWNLOAD_ICON;
        btn.title = "下载媒体";

        // 绑定点击事件
        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. 从 DOM 中提取推文链接,并获取 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;

        // 设置加载状态
        btn.classList.add('xmd-loading', 'xmd-spin');

        // 2. 收集下载任务
        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));
            });
        });

        // 异常处理:如果缓存中没有找到数据 (通常是滚动太快,API 尚未拦截到)
        if (tasks.length === 0) {
            btn.classList.remove('xmd-loading', 'xmd-spin');
            btn.classList.add('xmd-error');
            setTimeout(() => btn.classList.remove('xmd-error'), 2000);
            return;
        }

        // 3. 执行下载
        try {
            await Promise.all(tasks.map(t => t()));
            // 成功状态
            btn.classList.remove('xmd-loading', 'xmd-spin');
            btn.classList.add('xmd-success');
        } catch (err) {
            // 失败状态
            btn.classList.remove('xmd-loading', 'xmd-spin');
            btn.classList.add('xmd-error');
        }

        // 2秒后恢复初始状态
        setTimeout(() => {
            btn.classList.remove('xmd-success', 'xmd-error');
        }, 2000);
    }

    // ==========================================
    // 6. 启动脚本
    // ==========================================
    hookFetch();
    hookXHR();

    // 延迟启动 DOM 监听,确保页面框架已加载
    setTimeout(observeArticles, 1000);

})();