X/Twitter 媒體批量下載器(支援 iPhone/Android)

一鍵下載 X/Twitter 的圖片、影片和 GIF,預設設定下以使用者 ID 和貼文 ID 儲存。您可以自訂檔案的檔案名稱。在 iPhone/Android 上,透過使用 ZIP 檔案,您還可以一鍵下載附加的媒體。下載歷史也會自動儲存。

目前為 2025-03-19 提交的版本,檢視 最新版本

// ==UserScript==
// @name         X/Twitter メディア一括ダウンローダー(iPhone/Android 対応)
// @name:en      One-Click X/Twitter Media Downloader (Android/iPhone support)
// @name:zh-CN   X/Twitter 媒体批量下载器(支持 iPhone/Android)
// @name:zh-TW   X/Twitter 媒體批量下載器(支援 iPhone/Android)
// @version      1.2.1
// @description  X/Twitterの画像や動画、GIFをワンクリックでダウンロードして、デフォルトの設定ではユーザーIDとポストIDで保存します。ダウンロードされるファイルの名前は任意に変更できます。iPhone/Androidでもzipを利用することで添付されたメディアをワンクリックでダウンロードすることができます。また、ダウンロード履歴も自動的に保存されます。
// @description:en  Download images, videos, and GIFs from X/Twitter with one click, and save them with user ID and post ID in default settings. You can customize the filenames of downloaded files. On Android/iPhone, all attached media can be downloaded at once via a ZIP archive. Download history is also saved automatically.
// @description:zh-CN 一键下载 X/Twitter 的图片、视频和 GIF,默认设置下以用户 ID 和帖子 ID 保存。您可以自定义下载文件的文件名。在 iPhone/Android 上,通过使用 ZIP 文件,您还可以一键下载附加的媒体。下载历史也会自动保存。
// @description:zh-TW 一鍵下載 X/Twitter 的圖片、影片和 GIF,預設設定下以使用者 ID 和貼文 ID 儲存。您可以自訂檔案的檔案名稱。在 iPhone/Android 上,透過使用 ZIP 檔案,您還可以一鍵下載附加的媒體。下載歷史也會自動儲存。
// @require      https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dayjs.min.js
// @author       Azuki
// @license      MIT
// @match        https://twitter.com/*
// @match        https://x.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=twitter.com
// @grant        none
// @namespace    https://gf.qytechs.cn/users/1441951
// ==/UserScript==
/*jshint esversion: 11 */

(function () {
    "use strict";

    // === Filename generation function (User-editable) ===
    /**
     * Function to generate filenames.
     * You can customize the filename format by editing formattedPostTime and the return line as needed.
     *
     * Caution: Please avoid using invalid characters in filenames.
     *
     * Default filename format: userId_postId-mediaTypeSequentialNumber.extension
     *
     * Elements available for filenames (filenameElements):
     *   - userName: Username
     *   - userId: User ID
     *   - postId: Post ID
     *   - postTime: Post time (ISO 8601 format). You can change the default format YYYYMMDD_HHmmss. See dayjs documentation (https://day.js.org/docs/en/display/format) for details.
     *   - mediaTypeLabel: Media type (img, video, gif)
     *   - index: Sequential number (for multiple media)
     */

    const generateFilename = (filenameElements, mediaTypeLabel, index, ext) => {
        const { userId, userName, postId, postTime } = filenameElements;
        const formattedPostTime = dayjs(postTime).format('YYYYMMDD_HHmmss'); // Edit this line
        return `${userId}_${postId}-${mediaTypeLabel}${index}.${ext}`; // Edit this line
    };

    const DB_NAME = 'DownloadHistoryDB';
    const DB_VERSION = 1;
    const STORE_NAME = 'downloadedPosts';

    let dbPromise = null;
    let downloadedPostsCache = new Set();

    const openDB = () => {
        if (dbPromise) return dbPromise;
        dbPromise = new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_NAME, DB_VERSION);
            request.onupgradeneeded = function(event) {
                const db = request.result;
                if (!db.objectStoreNames.contains(STORE_NAME)) {
                    db.createObjectStore(STORE_NAME, { keyPath: 'postId' });
                }
            };
            request.onsuccess = function() {
                resolve(request.result);
            };
            request.onerror = function() {
                reject(request.error);
            };
        });
        return dbPromise;
    };

    const loadDownloadedPostsCache = () => {
        getDownloadedPostIdsIndexedDB()
          .then(ids => {
              downloadedPostsCache = new Set(ids);
          })
          .catch(err => console.error("IndexedDB 読み込みエラー:", err));
    };

    const getDownloadedPostIdsIndexedDB = () => {
        return openDB().then(db => {
            return new Promise((resolve, reject) => {
                const transaction = db.transaction(STORE_NAME, 'readonly');
                const store = transaction.objectStore(STORE_NAME);
                const request = store.getAllKeys();
                request.onsuccess = function() {
                    resolve(request.result);
                };
                request.onerror = function() {
                    reject(request.error);
                };
            });
        });
    };

    const markPostAsDownloadedIndexedDB = (postId) => {
        return openDB().then(db => {
            return new Promise((resolve, reject) => {
                const transaction = db.transaction(STORE_NAME, 'readwrite');
                const store = transaction.objectStore(STORE_NAME);
                const request = store.put({ postId: postId });
                request.onsuccess = function() {

                    downloadedPostsCache.add(postId);
                    resolve();
                };
                request.onerror = function() {
                    reject(request.error);
                };
            });
        });
    };

    loadDownloadedPostsCache();

    const isMobile = /android|iphone|mobile/.test(navigator.userAgent.toLowerCase());
    const bearerToken = 'AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA';
    const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'; // UserAgent を更新
    const isFirefox = navigator.userAgent.toLowerCase().includes('firefox');

    const getCurrentLanguage = () => document.documentElement.lang || 'en';
    const getMainTweetUrl = (cell) => {
        let timeEl = cell.querySelector('article[data-testid="tweet"] a[href*="/status/"][role="link"] time');
        if (timeEl && timeEl.parentElement) return timeEl.parentElement.href;
        return cell.querySelector('article[data-testid="tweet"] a[href*="/status/"]')?.href || "";
    };
    const getCookie = (name) => {
        const cookies = Object.fromEntries(document.cookie.split(';').filter(n => n.includes('=')).map(n => n.split('=').map(decodeURIComponent).map(s => s.trim())));
        return name ? cookies[name] : cookies;
    };
    const getMediaInfoFromUrl = (url) => {
        if (url.includes('pbs.twimg.com/media/')) {
            const extMatch = url.match(/format=([a-zA-Z0-9]+)/);
            const ext = extMatch ? extMatch[1] : 'jpg';
            return { ext: ext, typeLabel: 'img' };
        } else if (url.includes('video.twimg.com/ext_tw_video/') || url.includes('video.twimg.com/tweet_video/') || url.includes('video.twimg.com/amplify_video/')) {
            let ext = 'mp4';
            if (!url.includes('pbs.twimg.com/tweet_video/')) {
                const pathMatch = url.split('?')[0].match(/\.([a-zA-Z0-9]+)$/);
                if (pathMatch) ext = pathMatch[1];
            }
            const typeLabel = url.includes('tweet_video') ? 'gif' : 'video';
            return { ext: ext, typeLabel: typeLabel };
        }
        return { ext: 'jpg', typeLabel: 'img' };
    };

    const fetchTweetDetailWithGraphQL = async (postId) => {
        const base_url = `https://${location.hostname}/i/api/graphql/NmCeCgkVlsRGS1cAwqtgmw/TweetDetail`;
        const variables = {
            "focalTweetId": postId,
             "with_rux_injections": false, "includePromotedContent": true, "withCommunity": true,
            "withQuickPromoteEligibilityTweetFields": true, "withBirdwatchNotes": true, "withVoice": true, "withV2Timeline": true
        };
        const features = {
            "rweb_lists_timeline_redesign_enabled": true, "responsive_web_graphql_exclude_directive_enabled": true, "verified_phone_label_enabled": false,
            "creator_subscriptions_tweet_preview_api_enabled": true, "responsive_web_graphql_timeline_navigation_enabled": true,
            "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "tweetypie_unmention_optimization_enabled": true,
            "responsive_web_edit_tweet_api_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true,
            "view_counts_everywhere_api_enabled": true, "longform_notetweets_consumption_enabled": true,
            "responsive_web_twitter_article_tweet_consumption_enabled": false, "tweet_awards_web_tipping_enabled": false,
            "freedom_of_speech_not_reach_fetch_enabled": true, "standardized_nudges_misinfo": true,
            "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "longform_notetweets_rich_text_read_enabled": true,
            "longform_notetweets_inline_media_enabled": true, "responsive_web_media_download_video_enabled": false, "responsive_web_enhance_cards_enabled": false
        };
        const url = encodeURI(`${base_url}?variables=${JSON.stringify(variables)}&features=${JSON.stringify(features)}`);
        const headers = {
            'authorization': `Bearer ${bearerToken}`,
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': getCookie('lang'),
            'x-csrf-token': getCookie('ct0'),
            ...(getCookie('ct0')?.length === 32 && getCookie('gt') ? { 'x-guest-token': getCookie('gt') } : {})
        };
        return fetch(url, { headers }).then(res => res.json());
    };

    const twdlcss = `
   span[id^="ezoic-pub-ad-placeholder-"], .ez-sidebar-wall, span[data-ez-ph-id], .ez-sidebar-wall-ad, .ez-sidebar-wall {display:none !important}
    .tmd-down {margin-left: 2px !important; order: 99; justify-content: inherit; display: inline-grid; transform: rotate(0deg) scale(1) translate3d(0px, 0px, 0px);}
    .tmd-down:hover > div > div > div > div {color: rgba(29, 161, 242, 1.0);}
    .tmd-down:hover > div > div > div > div > div {background-color: rgba(29, 161, 242, 0.1);}
    .tmd-down:active > div > div > div > div > div {background-color: rgba(29, 161, 242, 0.2);}
    .tmd-down:hover svg {color: rgba(29, 161, 242, 1.0);}
    .tmd-down:hover div:first-child:not(:last-child) {background-color: rgba(29, 161, 242, 0.1);}
    .tmd-down:active div:first-child:not(:last-child) {background-color: rgba(29, 161, 242, 0.2);}
    .tmd-down g {display: none;}
    .tmd-down.download g.download, .tmd-down.loading g.loading, .tmd-down.failed g.failed, .tmd-down.completed g.completed {display: unset;}
    .tmd-down.loading svg g.loading {animation: spin 1s linear infinite !important; transform-box: fill-box; transform-origin: center;}
    @keyframes spin {0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);}}
    .tweet-detail-action-item {width: 20% !important;}
    `;
    const newStyle = document.createElement('style');
    newStyle.id = 'twdlcss';
    newStyle.innerHTML = twdlcss;
    document.head.parentNode.insertBefore(newStyle, document.head);

    const getNoImageMessage = () => {
        const lang = getCurrentLanguage();
        return lang === 'ja' ? "このツイートには画像または動画がありません!" : "There is no image or video in this tweet!";
    };
    const status = (btn, css) => {
        btn.classList.remove('download', 'loading', 'failed', 'completed');
        if (css) btn.classList.add(css);
    };

    const getValidMediaElements = (cell) => {
        let validImages = [], validVideos = [], validGifs = [];
        validImages = Array.from(cell.querySelectorAll("img[src*='name=']")).filter(img => !img.closest("div[tabindex='0'][role='link']") && !img.src.includes("card_img"));
        const videoCandidates = Array.from(cell.querySelectorAll("video"));
        videoCandidates.forEach(video => {
            if (video.closest("div[tabindex='0'][role='link']")) return;
            if (video.src?.startsWith("https://video.twimg.com/tweet_video")) validGifs.push(video);
            else if (video.poster?.includes("/ext_tw_video_thumb/") || video.poster?.includes("/amplify_video_thumb/") || video.poster?.includes("/media/")) validVideos.push(video);
        });
        return { images: validImages, videos: validVideos, gifs: validGifs };
    };

    const getTweetFilenameElements = (url, cell) => {
        const match = url.match(/^https?:\/\/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/);
        if (!match) return null;

        const userNameContainer = cell.querySelector("div[data-testid='User-Name'] div[dir='ltr'] span");
        const postTimeElement = cell.querySelector("article[data-testid='tweet'] a[href*='/status/'][role='link'] time");

        let userName = 'unknown';
        if (userNameContainer) {
            userName = '';
            userNameContainer.querySelectorAll('*').forEach(el => {
                userName += el.nodeName === 'IMG' ? el.alt : (el.nodeName === 'SPAN' ? el.textContent : '');
            });
            userName = userName.trim();
        }

        return {
            userId: match[1],
            userName: userName || 'unknown',
            postId: match[2],
            postTime: postTimeElement?.getAttribute('datetime') || 'unknown'
        };
    };

    const getMediaURLs = async (cell, filenameElements) => {
        const mediaElems = getValidMediaElements(cell);
        const imageURLs = mediaElems.images.map(img => img.src.includes("name=") ? img.src.replace(/name=.*/ig, 'name=4096x4096') : img.src);
        const gifURLs = mediaElems.gifs.map(gif => gif.src);
        let videoURLs = [];

        if (mediaElems.videos.length > 0) {
            const tweet_detail_res = await fetchTweetDetailWithGraphQL(filenameElements.postId);
            if (!tweet_detail_res.data) return { imageURLs: [], gifURLs: [], videoURLs: [] };
            const tweet_entrie = tweet_detail_res.data.threaded_conversation_with_injections_v2.instructions[0].entries.find(n => n.entryId === `tweet-${filenameElements.postId}`);
            if (!tweet_entrie) return { imageURLs: [], gifURLs: [], videoURLs: [] };
            const tweet_result = tweet_entrie.content.itemContent.tweet_results.result;
            const tweet_obj = tweet_result.tweet || tweet_result;
            tweet_obj.extended_entities = tweet_obj.extended_entities || tweet_obj.legacy?.extended_entities;
            const extEntities = tweet_obj.extended_entities;

            if (extEntities?.media) {
                videoURLs = extEntities.media
                    .filter(media => (media.type === 'video' || media.type === 'animated_gif') && media.video_info?.variants)
                    .map(media => media.video_info.variants.filter(variant => variant.content_type === 'video/mp4').reduce((prev, current) => (prev.bitrate > current.bitrate) ? prev : current, media.video_info.variants[0])?.url)
                    .filter(url => url);
            }
        }
        return { imageURLs: imageURLs, gifURLs: gifURLs, videoURLs: videoURLs };
    };

    const downloadZipArchive = async (blobs, filenameElements, mediaURLs) => {
        const files = {};
        const filenames = blobs.map((_, index) => {
            const mediaInfo = getMediaInfoFromUrl(mediaURLs[index]);
            const ext = mediaInfo.ext;
            const typeLabel = mediaInfo.typeLabel;
            return generateFilename(filenameElements, typeLabel, index + 1, ext);
        });
        const uint8Arrays = await Promise.all(blobs.map(blob => blobToUint8Array(blob)));
        uint8Arrays.forEach((uint8Array, index) => {
            files[filenames[index]] = uint8Array;
        });

        fflate.zip(files, { level: 0 }, (err, zipData) => {
            if (err) {
                console.error("ZIP archive creation failed:", err);
                alert("ZIPファイルの作成に失敗しました。");
                return;
            }
            const zipBlob = new Blob([zipData], { type: 'application/zip' });
            const zipDataUrl = URL.createObjectURL(zipBlob);
            const a = document.createElement("a");
            const zipFilename = generateFilename(filenameElements, 'medias', '', 'zip');
            a.download = zipFilename;
            a.href = zipDataUrl;
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(zipDataUrl);
        });
    };

    const downloadBlobAsFile = async (blob, filename) => {
        const dataUrl = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.download = filename;
        a.href = dataUrl;
        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(dataUrl);
    };

    const blobToUint8Array = async (blob) => new Uint8Array(await blob.arrayBuffer());
    const downloadMediaWithFetchStream = async (mediaSrcURL) => {
        const headers = !isFirefox ? { 'User-Agent': userAgent } : {};
        try {
            const response = await fetch(mediaSrcURL, { credentials: 'omit', headers: headers });
            if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
            return await response.blob();
        } catch (error) {
            console.error("Download failed:", error);
            return null;
        }
    };

    const downloadMedia = async (imageURLs, gifURLs, videoURLs, filenameElements, btn_down, allMediaURLs) => {
        const mediaCount = imageURLs.length + gifURLs.length + videoURLs.length;

        if (mediaCount === 1) {
            let mediaURL;
            if (imageURLs.length === 1) mediaURL = imageURLs[0];
            else if (gifURLs.length === 1) mediaURL = gifURLs[0];
            else mediaURL = videoURLs[0];
            const blob = await downloadMediaWithFetchStream(mediaURL);
            if (blob) {
                const mediaInfo = getMediaInfoFromUrl(mediaURL);
                const ext = mediaInfo.ext;
                const typeLabel = mediaInfo.typeLabel;
                const filename = generateFilename(filenameElements, typeLabel, 1, ext);
                downloadBlobAsFile(blob, filename);
                markPostAsDownloadedIndexedDB(filenameElements.postId)
                setTimeout(() => status(btn_down, 'completed'), 300);
            } else {
                status(btn_down, 'failed');
                setTimeout(() => status(btn_down, 'download'), 3000);
            }
        } else if (mediaCount > 1) {
            const downloadPromises = [...imageURLs, ...gifURLs, ...videoURLs].map(url => downloadMediaWithFetchStream(url));
            const blobs = (await Promise.all(downloadPromises)).filter(blob => blob);

            if (blobs.length === mediaCount) {
                if (isMobile) {
                    downloadZipArchive(blobs, filenameElements, allMediaURLs);
                } else {
                    blobs.forEach((blob, index) => {
                        const mediaURL = allMediaURLs[index];
                        const mediaInfo = getMediaInfoFromUrl(mediaURL);
                        const ext = mediaInfo.ext;
                        const typeLabel = mediaInfo.typeLabel;
                        const filename = generateFilename(filenameElements, typeLabel, index + 1, ext);
                        downloadBlobAsFile(blob, filename);
                    });
                }
                markPostAsDownloadedIndexedDB(filenameElements.postId)
                setTimeout(() => status(btn_down, 'completed'), 300);
            } else {
                status(btn_down, 'failed');
                setTimeout(() => status(btn_down, 'download'), 3000);
            }
        }
    };

    const createDownloadButton = async (cell) => {

        let btn_group = cell.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions');
        if (!btn_group) return;
        let btn_share = Array.from(btn_group.querySelectorAll(':scope>div>div, li.tweet-action-item>a, li.tweet-detail-action-item>a')).pop().parentNode;
        if (!btn_share) return;

        let btn_down = btn_share.cloneNode(true);
        btn_down.classList.add('tmd-down', 'download');
        const btnElem = btn_down.querySelector('button');
        if (btnElem) btnElem.removeAttribute('disabled');

         const lang = getCurrentLanguage();
        if (btn_down.querySelector('button')) btn_down.querySelector('button').title = lang === 'ja' ? '画像と動画をダウンロード' : 'Download images and videos';

        btn_down.querySelector('svg').innerHTML = `
            <g class="download"><path d="M12 16 17.7 10.3 16.29 8.88 13 12.18 V2.59 h-2 v9.59 L7.7 8.88 6.29 10.3 Z M21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z" fill="currentColor" stroke="currentColor" stroke-width="0.20" stroke-linecap="round" /></g>
            <g class="loading"><circle cx="12" cy="12" r="10" fill="none" stroke="#1DA1F2" stroke-width="4" opacity="0.4" /><path d="M12,2 a10,10 0 0 1 10,10" fill="none" stroke="#1DA1F2" stroke-width="4" stroke-linecap="round" /></g>
            <g class="failed"><circle cx="12" cy="12" r="11" fill="#f33" stroke="currentColor" stroke-width="2" opacity="0.8" /><path d="M14,5 a1,1 0 0 0 -4,0 l0.5,9.5 a1.5,1.5 0 0 0 3,0 z M12,17 a2,2 0 0 0 0,4 a2,2 0 0 0 0,-4" fill="#fff" stroke="none" /></g>
            <g class="completed"><path d="M21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z M 7 10 l 3 4 q 1 1 2 0 l 8 -11 l -1.65 -1.2 l -7.35 10.1063 l -2.355 -3.14" fill="rgba(29, 161, 242, 1)" stroke="#1DA1F2" stroke-width="0.20" stroke-linecap="round" /></g>
        `;

        const filenameElements = getTweetFilenameElements(getMainTweetUrl(cell), cell);
        if (filenameElements && downloadedPostsCache.has(filenameElements.postId)) {
            status(btn_down, 'completed');
        }

        btn_down.onclick = async () => {
            if (btn_down.classList.contains('loading') || btn_down.classList.contains('completed')) return;
            status(btn_down, 'loading');

            const mainTweetUrl = getMainTweetUrl(cell);
            const filenameElements = getTweetFilenameElements(mainTweetUrl, cell);
            if (!filenameElements) {
                alert("ツイート情報を取得できませんでした。");
                status(btn_down, 'download');
                return;
            }
            const mediaData = await getMediaURLs(cell, filenameElements);
            const imageURLs = mediaData.imageURLs;
            const gifURLs = mediaData.gifURLs;
            const videoURLs = mediaData.videoURLs;
            const mediaCount = imageURLs.length + gifURLs.length + videoURLs.length;
            const mediaUrls = [...imageURLs, ...gifURLs, ...videoURLs];

            if (mediaCount === 0) {
                alert(getNoImageMessage());
                status(btn_down, 'download');
                return;
            }
            downloadMedia(imageURLs, gifURLs, videoURLs, filenameElements, btn_down, mediaUrls);
        };
        if (btn_group) btn_group.insertBefore(btn_down, btn_share.nextSibling);
    };

    const processArticles = () => {
        const cells = document.querySelectorAll('[data-testid="cellInnerDiv"]');
        cells.forEach(cell => {
            const mainTweet = cell.querySelector('article[data-testid="tweet"]');
            if (!mainTweet) return;
            const tweetUrl = getMainTweetUrl(cell);
            if (!getTweetFilenameElements(tweetUrl, cell)) return;
            const mediaElems = getValidMediaElements(cell);
            const mediaCount = mediaElems.images.length + mediaElems.videos.length + mediaElems.gifs.length;
            if (!cell.querySelector('.tmd-down') && mediaCount > 0) createDownloadButton(cell);
        });
    };

    const observer = new MutationObserver(processArticles);
    observer.observe(document.body, { childList: true, subtree: true });
    window.addEventListener('load', processArticles);
    window.addEventListener('popstate', processArticles);

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址