// ==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 // @downloadURL none // ==/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 = ``; /** 监听 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); })();