Download photos and videos from X (Twitter) — intercepts API responses to get real MP4 URLs.
// ==UserScript==
// @name X Helper
// @namespace https://github.com/
// @version 2.0.0
// @description Download photos and videos from X (Twitter) — intercepts API responses to get real MP4 URLs.
// @author X Helper
// @match https://x.com/*
// @match https://twitter.com/*
// @grant GM_addStyle
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
// ─── Media cache: tweetId → { username, images: [...], videos: [...] } ───────
const mediaCache = {};
// ─── Intercept fetch & XHR to capture X API video/image data ─────────────────
function processApiResponse(url, text) {
try {
if (!text || text[0] !== '{') return;
const json = JSON.parse(text);
extractMediaFromJson(json);
} catch (_) {}
}
function extractMediaFromJson(obj) {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) { obj.forEach(extractMediaFromJson); return; }
// Tweet result with legacy extended_entities
if (obj.rest_id && obj.legacy?.extended_entities?.media) {
const username =
obj.core?.user_results?.result?.legacy?.screen_name ||
obj.legacy?.user_id_str || 'unknown';
processMedia(obj.rest_id, username, obj.legacy.extended_entities.media);
}
// Older flat shape
if (obj.id_str && obj.extended_entities?.media) {
const username = obj.user?.screen_name || 'unknown';
processMedia(obj.id_str, username, obj.extended_entities.media);
}
for (const key of Object.keys(obj)) {
if (obj[key] && typeof obj[key] === 'object') {
extractMediaFromJson(obj[key]);
}
}
}
function processMedia(tweetId, username, mediaArray) {
if (!tweetId || !Array.isArray(mediaArray)) return;
if (!mediaCache[tweetId]) {
mediaCache[tweetId] = { username, images: [], videos: [] };
}
const cache = mediaCache[tweetId];
if (username && username !== 'unknown') cache.username = username;
for (const item of mediaArray) {
if (item.type === 'photo') {
const url = item.media_url_https + '?format=jpg&name=large';
if (!cache.images.find(i => i.url === url)) {
cache.images.push({
url,
thumb: item.media_url_https + '?format=jpg&name=small',
ext: 'jpg',
});
}
} else if (item.type === 'video' || item.type === 'animated_gif') {
const variants = item.video_info?.variants || [];
const mp4s = variants
.filter(v => v.content_type === 'video/mp4')
.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
if (mp4s.length > 0 && !cache.videos.find(v => v.url === mp4s[0].url)) {
cache.videos.push({
url: mp4s[0].url,
thumb: item.media_url_https,
ext: 'mp4',
isGif: item.type === 'animated_gif',
allQualities: mp4s,
});
}
}
}
}
// ── Intercept fetch ──────────────────────────────────────────────────────────
const _fetch = window.fetch;
window.fetch = async function (...args) {
const res = await _fetch.apply(this, args);
const url = (typeof args[0] === 'string' ? args[0] : args[0]?.url) || '';
if (isApiUrl(url)) {
res.clone().text().then(t => processApiResponse(url, t)).catch(() => {});
}
return res;
};
// ── Intercept XHR ────────────────────────────────────────────────────────────
const _xhrOpen = XMLHttpRequest.prototype.open;
const _xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._xhUrl = url;
return _xhrOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (...args) {
if (isApiUrl(this._xhUrl)) {
this.addEventListener('load', function () {
processApiResponse(this._xhUrl, this.responseText);
});
}
return _xhrSend.apply(this, args);
};
function isApiUrl(url) {
if (!url) return false;
return url.includes('TweetDetail') ||
url.includes('TweetResultByRestId') ||
url.includes('HomeTimeline') ||
url.includes('HomeLatestTimeline') ||
url.includes('UserTweets') ||
url.includes('SearchTimeline') ||
url.includes('Likes') ||
url.includes('Bookmarks') ||
url.includes('ListLatestTweetsTimeline') ||
url.includes('ConversationTimelineByTweetId') ||
url.includes('/2/timeline') ||
url.includes('/statuses/show');
}
// ─── Inject styles (called after DOMContentLoaded) ────────────────────────────
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
.xh-btn-wrapper {
position: absolute; top: 8px; right: 8px;
display: flex; flex-direction: column; gap: 6px;
z-index: 10; opacity: 0; transition: opacity 0.15s;
pointer-events: none;
}
[data-xh-media]:hover .xh-btn-wrapper,
.xh-btn-wrapper:hover { opacity: 1; pointer-events: auto; }
.xh-btn {
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: 50%; border: none;
cursor: pointer; background: rgba(0,0,0,0.72); color: #fff;
backdrop-filter: blur(4px);
transition: background 0.15s, transform 0.1s;
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
}
.xh-btn:hover { background: rgba(29,155,240,0.92); transform: scale(1.1); }
.xh-btn svg { pointer-events: none; }
.xh-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.65);
z-index: 999999; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); animation: xh-fi 0.15s ease;
}
@keyframes xh-fi { from { opacity:0 } to { opacity:1 } }
.xh-modal {
background: #15202b; border: 1px solid #2f3336; border-radius: 16px;
padding: 20px; max-width: 560px; width: 92vw; max-height: 82vh;
overflow-y: auto; color: #e7e9ea;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
box-shadow: 0 20px 60px rgba(0,0,0,0.7);
animation: xh-su 0.18s ease;
}
@keyframes xh-su { from { transform:translateY(16px);opacity:0 } to { transform:none;opacity:1 } }
.xh-modal-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px;
}
.xh-modal-title { font-size: 18px; font-weight: 700; }
.xh-modal-close {
background: transparent; border: none; color: #71767b; cursor: pointer;
border-radius: 50%; width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center; transition: background 0.15s;
}
.xh-modal-close:hover { background: rgba(255,255,255,0.08); color: #e7e9ea; }
.xh-tweet-info { font-size: 12px; color: #71767b; margin-bottom: 14px; }
.xh-tweet-info a { color: #1d9bf0; text-decoration: none; }
.xh-tweet-info a:hover { text-decoration: underline; }
.xh-section-label {
font-size: 13px; font-weight: 700; color: #71767b;
text-transform: uppercase; letter-spacing: .05em; margin: 14px 0 8px;
}
.xh-media-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); gap: 10px; }
.xh-media-card {
position: relative; border-radius: 10px; overflow: hidden;
background: #1e2732; cursor: pointer; border: 2px solid transparent;
transition: border-color 0.15s, transform 0.1s;
}
.xh-media-card:hover { border-color: #1d9bf0; transform: scale(1.02); }
.xh-media-card img { width: 100%; height: 130px; object-fit: cover; display: block; }
.xh-card-overlay {
position: absolute; inset: 0;
background: linear-gradient(transparent 55%,rgba(0,0,0,.75));
display: flex; flex-direction: column; justify-content: flex-end; padding: 8px;
}
.xh-card-label { font-size: 11px; font-weight: 700; color: #fff; }
.xh-card-actions { position: absolute; top: 6px; right: 6px; display: flex; gap: 4px; }
.xh-card-btn {
background: rgba(0,0,0,.7); border: none; border-radius: 50%;
width: 28px; height: 28px; color: #fff; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: background .15s;
}
.xh-card-btn:hover { background: #1d9bf0; }
.xh-type-badge {
position: absolute; top: 6px; left: 6px;
background: rgba(0,0,0,.7); border-radius: 4px;
padding: 2px 6px; font-size: 10px; font-weight: 700; color: #fff; text-transform: uppercase;
}
.xh-quality-select {
position: absolute; bottom: 8px; left: 8px;
width: calc(100% - 16px); z-index: 2;
padding: 4px 6px; background: #1e2732; border: 1px solid #2f3336;
color: #e7e9ea; border-radius: 6px; font-size: 12px; cursor: pointer;
}
.xh-dl-all {
margin-top: 16px; width: 100%; padding: 10px; background: #1d9bf0;
border: none; border-radius: 50px; color: #fff;
font-size: 15px; font-weight: 700; cursor: pointer; transition: background .15s;
}
.xh-dl-all:hover { background: #1a8cd8; }
.xh-empty { text-align: center; padding: 32px 0; color: #71767b; font-size: 15px; }
`;
document.head.appendChild(style);
}
// ─── SVG Icons ────────────────────────────────────────────────────────────────
const ICON = {
download: `<svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 24 24" width="16" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M18 15v3H6v-3H4v3c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2v-3h-2zm-1-4-1.41-1.41L13 12.17V4h-2v8.17L8.41 9.59 7 11l5 5 5-5z"/></svg>`,
newtab: `<svg xmlns="http://www.w3.org/2000/svg" height="16" viewBox="0 0 24 24" width="16" fill="currentColor"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M19 19H5V5h7V3H5a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7h-2v7zM14 3v2h3.59L8.76 13.83l1.41 1.41L19 6.41V10h2V3z"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 0 24 24" width="18" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
};
// ─── Utilities ────────────────────────────────────────────────────────────────
function triggerDownload(url, filename) {
const a = document.createElement('a');
a.href = url; a.download = filename;
a.target = '_blank'; a.rel = 'noopener';
document.body.appendChild(a); a.click();
setTimeout(() => a.remove(), 300);
}
function makeFilename(username, tweetId, idx, ext) {
return `${username}_${tweetId}_${idx + 1}.${ext}`;
}
// ─── Modal ────────────────────────────────────────────────────────────────────
function closeModal() { document.querySelector('.xh-overlay')?.remove(); }
function openModal(tweetId) {
closeModal();
const cache = mediaCache[tweetId];
const username = cache?.username || 'unknown';
const images = cache?.images || [];
const videos = cache?.videos || [];
const allMedia = [
...images.map((img, i) => ({ ...img, type: 'image', label: `Photo ${i + 1}` })),
...videos.map((vid, i) => ({ ...vid, type: 'video', label: vid.isGif ? `GIF ${i + 1}` : `Video ${i + 1}` })),
];
const overlay = document.createElement('div');
overlay.className = 'xh-overlay';
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
const modal = document.createElement('div');
modal.className = 'xh-modal';
const header = document.createElement('div');
header.className = 'xh-modal-header';
header.innerHTML = `<div class="xh-modal-title">Download Media</div>`;
const closeBtn = document.createElement('button');
closeBtn.className = 'xh-modal-close';
closeBtn.innerHTML = ICON.close;
closeBtn.addEventListener('click', closeModal);
header.appendChild(closeBtn);
const info = document.createElement('div');
info.className = 'xh-tweet-info';
info.innerHTML = `@${username} · <a href="https://x.com/${username}/status/${tweetId}" target="_blank" rel="noopener">View Tweet</a>`;
modal.appendChild(header);
modal.appendChild(info);
if (allMedia.length === 0) {
const empty = document.createElement('div');
empty.className = 'xh-empty';
empty.textContent = 'No media captured yet — scroll through the tweet to load it, then try again.';
modal.appendChild(empty);
} else {
if (images.length) {
const lbl = document.createElement('div');
lbl.className = 'xh-section-label';
lbl.textContent = `Photos (${images.length})`;
modal.appendChild(lbl);
const grid = document.createElement('div');
grid.className = 'xh-media-grid';
images.forEach((img, i) => grid.appendChild(makeImageCard(img, username, tweetId, i)));
modal.appendChild(grid);
}
if (videos.length) {
const lbl = document.createElement('div');
lbl.className = 'xh-section-label';
lbl.textContent = `Videos / GIFs (${videos.length})`;
modal.appendChild(lbl);
const grid = document.createElement('div');
grid.className = 'xh-media-grid';
videos.forEach((vid, i) => grid.appendChild(makeVideoCard(vid, username, tweetId, images.length + i)));
modal.appendChild(grid);
}
if (allMedia.length > 1) {
const dlAll = document.createElement('button');
dlAll.className = 'xh-dl-all';
dlAll.textContent = `Download All (${allMedia.length})`;
dlAll.addEventListener('click', () => {
allMedia.forEach((item, i) => {
setTimeout(() => triggerDownload(item.url, makeFilename(username, tweetId, i, item.ext)), i * 300);
});
closeModal();
});
modal.appendChild(dlAll);
}
}
overlay.appendChild(modal);
document.body.appendChild(overlay);
document.addEventListener('keydown', function esc(e) {
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', esc); }
});
}
function makeImageCard(img, username, tweetId, idx) {
const card = document.createElement('div');
card.className = 'xh-media-card';
const image = document.createElement('img');
image.src = img.thumb || img.url; image.alt = `Photo ${idx + 1}`;
card.appendChild(image);
const ov = document.createElement('div');
ov.className = 'xh-card-overlay';
ov.innerHTML = `<div class="xh-card-label">Photo ${idx + 1}</div>`;
card.appendChild(ov);
const badge = document.createElement('div');
badge.className = 'xh-type-badge'; badge.textContent = 'IMG';
card.appendChild(badge);
card.appendChild(makeCardActions(
() => triggerDownload(img.url, makeFilename(username, tweetId, idx, img.ext)),
() => window.open(img.url, '_blank', 'noopener')
));
card.addEventListener('click', () => triggerDownload(img.url, makeFilename(username, tweetId, idx, img.ext)));
return card;
}
function makeVideoCard(vid, username, tweetId, idx) {
const card = document.createElement('div');
card.className = 'xh-media-card';
const image = document.createElement('img');
image.src = vid.thumb; image.alt = `Video ${idx + 1}`;
card.appendChild(image);
let selectedUrl = vid.url;
if (vid.allQualities?.length > 1) {
const sel = document.createElement('select');
sel.className = 'xh-quality-select';
vid.allQualities.forEach((q, qi) => {
const opt = document.createElement('option');
const res = q.url.match(/\/(\d+x\d+)\//)?.[1] || '';
const kbps = q.bitrate ? `${Math.round(q.bitrate / 1000)}kbps` : '';
opt.value = q.url;
opt.textContent = (`${res} ${kbps}`).trim() || `Quality ${qi + 1}`;
if (qi === 0) opt.selected = true;
sel.appendChild(opt);
});
sel.addEventListener('change', e => { selectedUrl = e.target.value; });
sel.addEventListener('click', e => e.stopPropagation());
card.appendChild(sel);
}
const ov = document.createElement('div');
ov.className = 'xh-card-overlay';
ov.innerHTML = `<div class="xh-card-label">${vid.isGif ? 'GIF' : 'Video'} ${idx + 1}</div>`;
card.appendChild(ov);
const badge = document.createElement('div');
badge.className = 'xh-type-badge'; badge.textContent = vid.isGif ? 'GIF' : 'VID';
card.appendChild(badge);
card.appendChild(makeCardActions(
() => triggerDownload(selectedUrl, makeFilename(username, tweetId, idx, 'mp4')),
() => window.open(selectedUrl, '_blank', 'noopener')
));
card.addEventListener('click', () => triggerDownload(selectedUrl, makeFilename(username, tweetId, idx, 'mp4')));
return card;
}
function makeCardActions(onDownload, onNewTab) {
const wrap = document.createElement('div');
wrap.className = 'xh-card-actions';
const dl = document.createElement('button');
dl.className = 'xh-card-btn'; dl.title = 'Download'; dl.innerHTML = ICON.download;
dl.addEventListener('click', e => { e.stopPropagation(); onDownload(); });
const nt = document.createElement('button');
nt.className = 'xh-card-btn'; nt.title = 'Open in new tab'; nt.innerHTML = ICON.newtab;
nt.addEventListener('click', e => { e.stopPropagation(); onNewTab(); });
wrap.appendChild(dl); wrap.appendChild(nt);
return wrap;
}
// ─── Button injection ─────────────────────────────────────────────────────────
function getTweetId(article) {
const a = article.querySelector('a[href*="/status/"]');
if (!a) return null;
const m = a.href.match(/\/status\/(\d+)/);
return m ? m[1] : null;
}
function injectButtons(article) {
if (article.dataset.xhDone) return;
const tweetId = getTweetId(article);
if (!tweetId) return;
const mediaEls = article.querySelectorAll(
'[data-testid="tweetPhoto"], [data-testid="videoComponent"], [data-testid="videoPlayer"]'
);
if (!mediaEls.length) return;
article.dataset.xhDone = '1';
mediaEls.forEach(el => {
if (el.dataset.xhMedia) return;
el.dataset.xhMedia = '1';
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
const wrapper = document.createElement('div');
wrapper.className = 'xh-btn-wrapper';
const dlBtn = document.createElement('button');
dlBtn.className = 'xh-btn'; dlBtn.title = 'Download media'; dlBtn.innerHTML = ICON.download;
dlBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
const cache = mediaCache[tweetId];
if (!cache || (cache.images.length + cache.videos.length === 0)) {
// Fallback: grab high-res images from DOM
const domImgs = collectDomImages(article);
if (domImgs.length === 1) {
triggerDownload(domImgs[0].url, makeFilename('tweet', tweetId, 0, 'jpg'));
} else if (domImgs.length > 1) {
mediaCache[tweetId] = { username: 'tweet', images: domImgs, videos: [] };
openModal(tweetId);
} else {
alert('Media not captured yet — scroll to the tweet to let it fully load, then try again.');
}
return;
}
const total = cache.images.length + cache.videos.length;
if (total === 1) {
const item = cache.videos[0] || cache.images[0];
triggerDownload(item.url, makeFilename(cache.username, tweetId, 0, item.ext));
} else {
openModal(tweetId);
}
});
const ntBtn = document.createElement('button');
ntBtn.className = 'xh-btn'; ntBtn.title = 'Open in new tab'; ntBtn.innerHTML = ICON.newtab;
ntBtn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
const cache = mediaCache[tweetId];
if (!cache) return;
const item = cache.videos[0] || cache.images[0];
if (item) window.open(item.url, '_blank', 'noopener');
});
wrapper.appendChild(dlBtn);
wrapper.appendChild(ntBtn);
el.appendChild(wrapper);
});
}
function collectDomImages(article) {
const seen = new Set(); const result = [];
article.querySelectorAll('img[src*="pbs.twimg.com/media/"]').forEach(img => {
const base = img.src.split('?')[0];
if (seen.has(base)) return; seen.add(base);
const url = new URL(img.src);
url.searchParams.set('name', 'large');
url.searchParams.set('format', 'jpg');
result.push({ url: url.href, thumb: img.src, ext: 'jpg' });
});
return result;
}
function scanAndInject() {
document.querySelectorAll('article[data-testid="tweet"]').forEach(injectButtons);
}
// ─── Boot ─────────────────────────────────────────────────────────────────────
function boot() {
injectStyles();
scanAndInject();
new MutationObserver(() => requestAnimationFrame(scanAndInject))
.observe(document.body, { childList: true, subtree: true });
let lastHref = location.href;
setInterval(() => {
if (location.href !== lastHref) { lastHref = location.href; setTimeout(scanAndInject, 600); }
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', boot);
} else {
boot();
}
})();