Greasy Fork is available in English.
在 X (Twitter) 帖子底部添加下载按钮,一键下载所有图片(原图)和视频(最高画质)。
当前为
// ==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);
})();