X Helper

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

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

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

})();