X Helper

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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

})();