Greasy Fork is available in English.
Download all media content (images, videos) from Twitter/X posts with one click.
当前为
// ==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
});
});
}
})();