X Helper

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

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

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

})();