{"id":"7qARSkeKZs","url":"https://pastebin.ca/7qARSkeKZs","raw_url":"https://raw.anybin.ca/7qARSkeKZs","visibility":"public","access":"public","created_at":1786465125863,"expires_at":1787069925863,"fetch_limit":null,"fetches_used":0,"reads_remaining":null,"size_bytes":20961,"syntax_hint":null,"title":null,"filename":null,"change_note":null,"cipher":null,"cipher_meta":null,"parent_id":null,"root_id":"7qARSkeKZs","version":1,"owner_id":null,"recipient_id":null,"body":"// ==UserScript==\n// @name         X / Twitter Media Downloader\n// @namespace    Violentmonkey Scripts\n// @version      1.1.2\n// @description  Download highest quality photos, videos, and GIFs on X/Twitter (desktop & mobile, including quoted tweets)\n// @match        https://x.com/*\n// @match        https://twitter.com/*\n// @match        https://*.x.com/*\n// @match        https://*.twitter.com/*\n// @grant        GM_download\n// @grant        GM_xmlhttpRequest\n// @connect      twimg.com\n// @connect      twitter.com\n// @connect      x.com\n// @require      https://cdn.jsdelivr.net/npm/gifshot@0.4.5/build/gifshot.min.js\n// @run-at       document-start\n// ==/UserScript==\n\n(function () {\n    'use strict';\n\n    const tweetMediaMap = new Map();\n\n    // Intercept media metadata from main page context\n    window.addEventListener('message', (e) => {\n        if (e.data && e.data.type === 'X_MEDIA_DATA') {\n            tweetMediaMap.set(e.data.tweetId, e.data.mediaList);\n        }\n    });\n\n    // Inject interceptor into main page context to capture GraphQL responses\n    function injectInterceptor() {\n        const script = document.createElement('script');\n        script.textContent = `(${function () {\n            function parseTwitterApi(json) {\n                if (!json || typeof json !== 'object') return;\n\n                function traverse(obj) {\n                    if (!obj || typeof obj !== 'object') return;\n\n                    if (obj.tweet_results?.result) {\n                        extractFromTweet(obj.tweet_results.result);\n                    }\n                    if (obj.tweetResult?.result) {\n                        extractFromTweet(obj.tweetResult.result);\n                    }\n                    if (obj.itemResult?.tweet_results?.result) {\n                        extractFromTweet(obj.itemResult.tweet_results.result);\n                    }\n                    if (obj.__typename === 'Tweet' || obj.__typename === 'TweetWithVisibilityResults') {\n                        extractFromTweet(obj);\n                    }\n\n                    for (let k in obj) {\n                        if (Object.prototype.hasOwnProperty.call(obj, k)) {\n                            traverse(obj[k]);\n                        }\n                    }\n                }\n\n                function extractFromTweet(tweetObj) {\n                    if (!tweetObj) return;\n                    if (tweetObj.__typename === 'TweetWithVisibilityResults' && tweetObj.tweet) {\n                        tweetObj = tweetObj.tweet;\n                    }\n\n                    const legacy = tweetObj.legacy;\n                    const tweetId = legacy?.id_str || tweetObj.rest_id;\n                    let mediaList = legacy?.extended_entities?.media || legacy?.entities?.media;\n\n                    if (tweetId && mediaList && mediaList.length > 0) {\n                        window.postMessage({ type: 'X_MEDIA_DATA', tweetId, mediaList }, '*');\n                    }\n\n                    // Handle Retweets\n                    if (legacy?.retweeted_status_result?.result) {\n                        extractFromTweet(legacy.retweeted_status_result.result);\n                    }\n\n                    // Handle Quoted Tweets explicitly\n                    if (tweetObj.quoted_status_result?.result) {\n                        extractFromTweet(tweetObj.quoted_status_result.result);\n                    }\n                }\n\n                traverse(json);\n            }\n\n            const origOpen = XMLHttpRequest.prototype.open;\n            XMLHttpRequest.prototype.open = function () {\n                this.addEventListener('load', function () {\n                    try {\n                        if (this.responseText && (this.responseURL.includes('/graphql/') || this.responseURL.includes('/2/'))) {\n                            parseTwitterApi(JSON.parse(this.responseText));\n                        }\n                    } catch (e) { }\n                });\n                origOpen.apply(this, arguments);\n            };\n\n            const origFetch = window.fetch;\n            window.fetch = async function (...args) {\n                const response = await origFetch.apply(this, args);\n                try {\n                    const clone = response.clone();\n                    const url = clone.url || (args[0] && typeof args[0] === 'string' ? args[0] : '');\n                    if (url.includes('/graphql/') || url.includes('/2/')) {\n                        clone.json().then(data => parseTwitterApi(data)).catch(() => { });\n                    }\n                } catch (e) { }\n                return response;\n            };\n        }})();`;\n        (document.head || document.documentElement).appendChild(script);\n        script.remove();\n    }\n\n    injectInterceptor();\n\n    // Media Downloader via GM_download / GM_xmlhttpRequest\n    function downloadMedia(url, filename) {\n        return new Promise((resolve, reject) => {\n            if (typeof GM_download === 'function') {\n                try {\n                    GM_download({\n                        url: url,\n                        name: filename,\n                        onload: () => resolve(),\n                        onerror: (err) => {\n                            console.warn('GM_download error, falling back to XHR:', err);\n                            downloadViaXHR(url, filename).then(resolve).catch(reject);\n                        },\n                        ontimeout: () => {\n                            downloadViaXHR(url, filename).then(resolve).catch(reject);\n                        }\n                    });\n                    return;\n                } catch (e) {\n                    console.warn('GM_download failed:', e);\n                }\n            }\n            downloadViaXHR(url, filename).then(resolve).catch(reject);\n        });\n    }\n\n    function downloadViaXHR(url, filename) {\n        return new Promise((resolve, reject) => {\n            if (typeof GM_xmlhttpRequest === 'function') {\n                GM_xmlhttpRequest({\n                    method: 'GET',\n                    url: url,\n                    responseType: 'blob',\n                    onload: (res) => {\n                        if (res.status >= 200 && res.status < 300) {\n                            downloadBlob(res.response, filename);\n                            resolve();\n                        } else {\n                            reject(new Error(`HTTP ${res.status}`));\n                        }\n                    },\n                    onerror: reject,\n                    ontimeout: () => reject(new Error('Timeout'))\n                });\n            } else {\n                fetch(url)\n                    .then(res => {\n                        if (!res.ok) throw new Error(`HTTP ${res.status}`);\n                        return res.blob();\n                    })\n                    .then(blob => {\n                        downloadBlob(blob, filename);\n                        resolve();\n                    })\n                    .catch(reject);\n            }\n        });\n    }\n\n    function downloadBlob(blob, filename) {\n        const url = URL.createObjectURL(blob);\n        const a = document.createElement('a');\n        a.href = url;\n        a.download = filename;\n        document.body.appendChild(a);\n        a.click();\n        document.body.removeChild(a);\n        setTimeout(() => URL.revokeObjectURL(url), 10000);\n    }\n\n    function dataURItoBlob(dataURI) {\n        const byteString = atob(dataURI.split(',')[1]);\n        const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];\n        const ab = new ArrayBuffer(byteString.length);\n        const ia = new Uint8Array(ab);\n        for (let i = 0; i < byteString.length; i++) {\n            ia[i] = byteString.charCodeAt(i);\n        }\n        return new Blob([ab], { type: mimeString });\n    }\n\n    function processAndDownloadGif(videoUrl, btn) {\n        btn.textContent = '⏳ Converting...';\n\n        gifshot.createGIF({\n            video: [videoUrl],\n            numFrames: 36,\n            interval: 0.08,\n            sampleInterval: 10,\n            gifWidth: 480,\n            numWorkers: 2\n        }, async function (obj) {\n            if (!obj.error) {\n                const blob = dataURItoBlob(obj.image);\n                downloadBlob(blob, `x_gif_${Date.now()}.gif`);\n                btn.textContent = '✅ Saved GIF';\n            } else {\n                console.warn('GIF conversion error, saving as MP4:', obj.error);\n                try {\n                    await downloadMedia(videoUrl, `x_gif_${Date.now()}.mp4`);\n                    btn.textContent = '✅ Saved MP4';\n                } catch (e) {\n                    btn.textContent = '❌ Failed';\n                }\n            }\n            setTimeout(() => {\n                btn.textContent = '⬇️ DL';\n                btn.dataset.loading = 'false';\n            }, 3000);\n        });\n    }\n\n    function findStatusIdInContainer(container) {\n        if (!container) return null;\n\n        // Priority 1: Check time elements\n        const timeLinks = container.querySelectorAll('time');\n        for (let timeEl of timeLinks) {\n            const link = timeEl.closest('a[href*=\"/status/\"]');\n            if (link) {\n                const href = link.getAttribute('href') || '';\n                const match = href.match(/status\\/(\\d{15,20})/);\n                if (match) return match[1];\n            }\n        }\n\n        // Priority 2: Check any status link\n        const links = container.querySelectorAll('a[href*=\"/status/\"]');\n        for (let link of links) {\n            const href = link.getAttribute('href') || '';\n            const match = href.match(/status\\/(\\d{15,20})/);\n            if (match) return match[1];\n        }\n\n        return null;\n    }\n\n    // Resolves Tweet ID for both standard tweets and quoted tweets (including video-player testids)\n    function getTweetId(element) {\n        if (!element) return null;\n\n        // 1. Look inside media container for testids like \"video-player-mini-ui-2086876306000482413\"\n        const testIdNodes = [element, ...element.querySelectorAll('[data-testid]')];\n        for (const node of testIdNodes) {\n            const testId = node.getAttribute?.('data-testid') || '';\n            if (testId.includes('video-player') || testId.includes('tweet') || testId.includes('media')) {\n                const match = testId.match(/(\\d{15,20})/);\n                if (match) return match[1];\n            }\n        }\n\n        // 2. Search status links inside current element\n        const statusIdInSubtree = findStatusIdInContainer(element);\n        if (statusIdInSubtree) return statusIdInSubtree;\n\n        // 3. Ascend DOM tree level-by-level\n        let current = element.parentElement;\n        while (current && current !== document.body) {\n            const testIdNodesCurrent = [current, ...current.querySelectorAll('[data-testid]')];\n            for (const node of testIdNodesCurrent) {\n                const testId = node.getAttribute?.('data-testid') || '';\n                if (testId.includes('video-player') || testId.includes('tweet') || testId.includes('media')) {\n                    const match = testId.match(/(\\d{15,20})/);\n                    if (match) return match[1];\n                }\n            }\n\n            const statusId = findStatusIdInContainer(current);\n            if (statusId) return statusId;\n\n            current = current.parentElement;\n        }\n\n        // 4. Fallback to current URL\n        const pageMatch = window.location.pathname.match(/status\\/(\\d{15,20})/);\n        if (pageMatch) return pageMatch[1];\n\n        return null;\n    }\n\n    // Fetches media details from cache or Twitter Syndication API fallback\n    async function getMediaInfoForTweet(tweetId) {\n        if (!tweetId) return null;\n\n        if (tweetMediaMap.has(tweetId)) {\n            return tweetMediaMap.get(tweetId);\n        }\n\n        try {\n            const syndicationUrl = `https://cdn.syndication.twimg.com/tweet-result?id=${tweetId}&token=x`;\n            const res = await new Promise((resolve, reject) => {\n                if (typeof GM_xmlhttpRequest === 'function') {\n                    GM_xmlhttpRequest({\n                        method: 'GET',\n                        url: syndicationUrl,\n                        onload: (r) => {\n                            if (r.status === 200) {\n                                try { resolve(JSON.parse(r.responseText)); } catch (e) { reject(e); }\n                            } else {\n                                reject(r);\n                            }\n                        },\n                        onerror: reject,\n                        ontimeout: reject\n                    });\n                } else {\n                    fetch(syndicationUrl).then(r => r.json()).then(resolve).catch(reject);\n                }\n            });\n\n            const mediaList = res?.mediaDetails || [];\n            if (mediaList.length > 0) {\n                tweetMediaMap.set(tweetId, mediaList);\n                return mediaList;\n            }\n        } catch (e) {\n            console.warn('Syndication API fallback error:', e);\n        }\n\n        return null;\n    }\n\n    function extractBestVideoFromMediaList(mediaList) {\n        if (!mediaList || !Array.isArray(mediaList)) return null;\n\n        let bestVideoUrl = null;\n        let maxBitrate = -1;\n        let isGif = false;\n\n        for (const m of mediaList) {\n            const variants = m.video_info?.variants || [];\n            const currentIsGif = m.type === 'animated_gif';\n\n            for (const v of variants) {\n                if ((v.content_type === 'video/mp4' || v.url?.includes('.mp4')) && v.url) {\n                    const bitrate = v.bitrate || 0;\n                    if (bitrate >= maxBitrate) {\n                        maxBitrate = bitrate;\n                        bestVideoUrl = v.url;\n                        isGif = currentIsGif;\n                    }\n                }\n            }\n        }\n\n        if (bestVideoUrl) {\n            return { url: bestVideoUrl, isGif };\n        }\n        return null;\n    }\n\n    async function handleDownload(container, btn) {\n        if (btn.dataset.loading === 'true') return;\n        btn.dataset.loading = 'true';\n\n        try {\n            // 1. Photo handling\n            const img = container.querySelector('img[src*=\"twimg.com/media/\"]');\n            if (img && !container.querySelector('video')) {\n                let src = img.src;\n                try {\n                    const u = new URL(src);\n                    const format = u.searchParams.get('format') || 'jpg';\n                    u.searchParams.set('format', format);\n                    u.searchParams.set('name', 'orig');\n                    src = u.toString();\n                } catch (e) { }\n\n                btn.textContent = '⏳ Saving...';\n                await downloadMedia(src, `x_photo_${Date.now()}.${src.includes('format=png') ? 'png' : 'jpg'}`);\n                btn.textContent = '✅ Saved';\n                setTimeout(() => { btn.textContent = '⬇️ DL'; btn.dataset.loading = 'false'; }, 3000);\n                return;\n            }\n\n            // 2. Video / GIF handling\n            const video = container.querySelector('video');\n            if (video) {\n                btn.textContent = '⏳ Resolving...';\n                const tweetId = getTweetId(container);\n                let videoData = null;\n\n                if (tweetId) {\n                    const mediaList = await getMediaInfoForTweet(tweetId);\n                    if (mediaList) {\n                        videoData = extractBestVideoFromMediaList(mediaList);\n                    }\n                }\n\n                // Fallback direct src\n                if (!videoData) {\n                    let src = video.src || video.currentSrc || '';\n                    if (!src || src.startsWith('blob:')) {\n                        const sourceEl = video.querySelector('source');\n                        if (sourceEl) src = sourceEl.src;\n                    }\n                    if (src && !src.startsWith('blob:')) {\n                        const isGif = src.includes('tweet_video') || video.loop || video.hasAttribute('data-is-gif');\n                        videoData = { url: src, isGif };\n                    }\n                }\n\n                if (!videoData || !videoData.url) {\n                    alert('Could not resolve video URL. Please try playing the video and click DL again.');\n                    btn.textContent = '❌ Failed';\n                    setTimeout(() => { btn.textContent = '⬇️ DL'; btn.dataset.loading = 'false'; }, 3000);\n                    return;\n                }\n\n                if (videoData.isGif || videoData.url.includes('tweet_video')) {\n                    processAndDownloadGif(videoData.url, btn);\n                } else {\n                    btn.textContent = '⏳ Saving...';\n                    await downloadMedia(videoData.url, `x_video_${Date.now()}.mp4`);\n                    btn.textContent = '✅ Saved';\n                    setTimeout(() => { btn.textContent = '⬇️ DL'; btn.dataset.loading = 'false'; }, 3000);\n                }\n            }\n        } catch (err) {\n            console.error('Download error:', err);\n            btn.textContent = '❌ Error';\n            setTimeout(() => { btn.textContent = '⬇️ DL'; btn.dataset.loading = 'false'; }, 3000);\n        }\n    }\n\n    function createDownloadButton() {\n        const btn = document.createElement('button');\n        btn.className = 'x-oneclick-dl-btn';\n        btn.textContent = '⬇️ DL';\n        btn.title = 'Download Media';\n\n        Object.assign(btn.style, {\n            position: 'absolute',\n            top: '10px',\n            right: '10px',\n            zIndex: '99999',\n            backgroundColor: 'rgba(0, 0, 0, 0.75)',\n            color: '#ffffff',\n            border: '1px solid rgba(255, 255, 255, 0.3)',\n            borderRadius: '20px',\n            padding: '6px 12px',\n            fontSize: '13px',\n            fontWeight: 'bold',\n            cursor: 'pointer',\n            backdropFilter: 'blur(4px)',\n            webkitBackdropFilter: 'blur(4px)',\n            boxShadow: '0 2px 8px rgba(0,0,0,0.4)',\n            userSelect: 'none',\n            webkitUserSelect: 'none',\n            touchAction: 'manipulation'\n        });\n\n        return btn;\n    }\n\n    function scanAndAttachButtons() {\n        const selectors = [\n            'div[data-testid=\"tweetPhoto\"]',\n            'div[data-testid=\"videoPlayer\"]',\n            'div[data-testid=\"videoComponent\"]',\n            'div[data-testid=\"tweetVideo\"]',\n            'div[data-testid=\"swipeable-media\"]',\n            'div[data-testid=\"slides\"]'\n        ];\n\n        const mediaContainers = document.querySelectorAll(selectors.join(', '));\n\n        mediaContainers.forEach(container => {\n            if (container.dataset.hasDlBtn) return;\n\n            const computedStyle = window.getComputedStyle(container);\n            if (computedStyle.position === 'static') {\n                container.style.position = 'relative';\n            }\n\n            const btn = createDownloadButton();\n\n            const stopEvents = (e) => {\n                e.stopPropagation();\n            };\n\n            btn.addEventListener('pointerdown', stopEvents);\n            btn.addEventListener('touchstart', stopEvents, { passive: true });\n            btn.addEventListener('click', (e) => {\n                stopEvents(e);\n                e.preventDefault();\n                handleDownload(container, btn);\n            });\n\n            container.appendChild(btn);\n            container.dataset.hasDlBtn = 'true';\n        });\n\n        const fullScreenVideos = document.querySelectorAll('video');\n        fullScreenVideos.forEach(v => {\n            const parent = v.parentElement;\n            if (parent && !parent.dataset.hasDlBtn && !parent.closest('[data-has-dl-btn=\"true\"]')) {\n                const computedStyle = window.getComputedStyle(parent);\n                if (computedStyle.position === 'static') {\n                    parent.style.position = 'relative';\n                }\n                const btn = createDownloadButton();\n                btn.addEventListener('pointerdown', (e) => e.stopPropagation());\n                btn.addEventListener('click', (e) => {\n                    e.stopPropagation();\n                    e.preventDefault();\n                    handleDownload(parent, btn);\n                });\n                parent.appendChild(btn);\n                parent.dataset.hasDlBtn = 'true';\n            }\n        });\n    }\n\n    function init() {\n        const observer = new MutationObserver(() => scanAndAttachButtons());\n        observer.observe(document.body, { childList: true, subtree: true });\n        scanAndAttachButtons();\n    }\n\n    if (document.readyState === 'loading') {\n        document.addEventListener('DOMContentLoaded', init);\n    } else {\n        init();\n    }\n})();"}