X Helper

Download photos and videos from X (Twitter) — intercepts API responses to get real MP4 URLs.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
    }

})();