Greasy Fork

来自缓存

Greasy Fork is available in English.

X/Twitter メディア一括ダウンローダー(iPhone/Android 対応)

X/Twitterの画像や動画、GIFをワンクリックでダウンロードして、ユーザーIDとポストIDで保存します。iPhone/Androidでもzipを利用することで添付されたメディアをワンクリックでダウンロードすることができます。

当前为 2025-03-05 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         X/Twitter メディア一括ダウンローダー(iPhone/Android 対応)
// @name:en      One-Click X/Twitter Media Downloader (iPhone/Android support)
// @version      1.0.0
// @description  X/Twitterの画像や動画、GIFをワンクリックでダウンロードして、ユーザーIDとポストIDで保存します。iPhone/Androidでもzipを利用することで添付されたメディアをワンクリックでダウンロードすることができます。
// @description:en  Download images, videos, and GIFs from X/Twitter with one click, saving them with the user ID and tweet ID. On Android/iPhone, all attached media can be downloaded at once via a ZIP archive.
// @require      https://cdn.jsdelivr.net/npm/[email protected]/umd/index.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 http://greasyfork.icu/users/1441951
// ==/UserScript==
/*jshint esversion: 11 */

(function () {
    "use strict";

    let mediaBlobs = [];
    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/130.0.0.0 Safari/537.36';

    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;
        const link = cell.querySelector('article[data-testid="tweet"] a[href*="/status/"]');
        return link?.href || "";
    };
    const extractTweetInfo = (url) => {
        const absUrl = url.startsWith('http') ? url : (location.origin + url);
        const match = absUrl.match(/^https?:\/\/(?:twitter\.com|x\.com)\/([^\/]+)\/status\/(\d+)/);
        return match ? { user: match[1], tweetId: match[2] } : null;
    };

    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 formatMatch = url.match(/format=([a-zA-Z0-9]+)/);
            const ext = formatMatch ? formatMatch[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/')) ext = 'mp4'; // GIFはmp4固定
            else {
                const path = url.split('?')[0];
                const extMatch = path.match(/\.([a-zA-Z0-9]+)$/);
                if (extMatch) ext = extMatch[1];
            }
            const typeLabel = url.includes('tweet_video') ? 'gif' : 'video';
            return { ext: ext, typeLabel: typeLabel };
        }
        return { ext: 'jpg', typeLabel: 'img' }; // デフォルト
    };

    const fetchTweetDetailWithGraphQL = async (status_id) => {
        const base_url = `https://${location.hostname}/i/api/graphql/NmCeCgkVlsRGS1cAwqtgmw/TweetDetail`;
        const variables = {
            "focalTweetId": status_id, "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 cookies = getCookie();
        const headers = {
            'authorization': `Bearer ${bearerToken}`, 'x-twitter-active-user': 'yes', 'x-twitter-client-language': cookies.lang, 'x-csrf-token': cookies.ct0,
            ...(cookies.ct0?.length === 32 && cookies.gt ? { 'x-guest-token': cookies.gt } : {})
        };
        const tweet_detail = await fetch(url, { headers }).then(res => res.json());
        const tweet_entrie = tweet_detail.data.threaded_conversation_with_injections_v2.instructions[0].entries.find(n => n.entryId === `tweet-${status_id}`);
        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;
        return tweet_obj;
    };

    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 {display: unset;}
    .tmd-down.loading svg {animation: spin 1s linear infinite;}
    @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');
        if (css) btn.classList.add(css);
    };

    const getValidMediaElements = (cell) => {
        const mainTweetUrl = getMainTweetUrl(cell);
        const mainInfo = extractTweetInfo(mainTweetUrl);
        let validImages = [], validVideos = [], validGifs = [];
        if (mainInfo) {
            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 getMediaURLs = async (cell, userName) => {
        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 tweetInfo = extractTweetInfo(getMainTweetUrl(cell));
            if (tweetInfo) {
                const tweetDetail = await fetchTweetDetailWithGraphQL(tweetInfo.tweetId);
                const extEntities = tweetDetail?.extended_entities || tweetDetail?.legacy?.extended_entities;
                if (extEntities?.media) {
                    videoURLs = extEntities.media
                        .filter(media => media.type === 'video' || media.type === 'animated_gif')
                        .map(media => {
                            const variants = media.video_info.variants.filter(variant => variant.content_type === 'video/mp4');
                            const maxBitrateVariant = variants.reduce((prev, current) => (prev.bitrate > current.bitrate) ? prev : current, variants[0]);
                            return maxBitrateVariant?.url;
                        })
                        .filter(url => url);
                }
            }
        }
        return { imageURLs: imageURLs, gifURLs: gifURLs, videoURLs: videoURLs };
    };

    const downloadZipArchive = async (blobs, userName, tweetId, mediaURLs) => {
        const files = {};
        const filenames = blobs.map((_, index) => {
            const mediaInfo = getMediaInfoFromUrl(mediaURLs[index]);
            const ext = mediaInfo.ext;
            const typeLabel = mediaInfo.typeLabel;
            return `${userName}_${tweetId}-${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");
            a.download = `${userName}_${tweetId}-medias.zip`;
            a.href = zipDataUrl;
            document.body.appendChild(a);
            a.click();
            a.remove();
            URL.revokeObjectURL(zipDataUrl);
        });
    };
    const downloadBlobAsFile = async (blob, url, filename) => {
        const dataUrl = URL.createObjectURL(blob);
        const mediaInfo = getMediaInfoFromUrl(url);
        const ext = mediaInfo.ext;
        const a = document.createElement("a");
        a.download = `${filename}.${ext}`;
        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, userName) => {
        try {
            const response = await fetch(mediaSrcURL, { credentials: 'omit', headers: { 'User-Agent': userAgent } });
            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, userName, tweetId, btn_down, allMediaURLs) => {
        const mediaCount = imageURLs.length + gifURLs.length + videoURLs.length;

        if (mediaCount === 1) {
            let mediaURL, mediaTypeLabel;
            if (imageURLs.length === 1) { mediaURL = imageURLs[0]; mediaTypeLabel = 'img'; }
            else if (gifURLs.length === 1) { mediaURL = gifURLs[0]; mediaTypeLabel = 'gif'; }
            else if (videoURLs.length === 1) { mediaURL = videoURLs[0]; mediaTypeLabel = 'video'; }
            const blob = await downloadMediaWithFetchStream(mediaURL, userName);
            if (blob) {
                const filename = `${userName}_${tweetId}-${mediaTypeLabel}1`;
                downloadBlobAsFile(blob, mediaURL, filename);
                status(btn_down, 'download');
            } else {
                status(btn_down, 'failed');
                setTimeout(() => status(btn_down, 'download'), 3000);
            }
        } else if (mediaCount > 1) { // mediaCount > 1 の場合のみ複数ダウンロード処理
            const downloadPromises = [...imageURLs, ...gifURLs, ...videoURLs].map(url => downloadMediaWithFetchStream(url, userName));
            const blobs = (await Promise.all(downloadPromises)).filter(blob => blob);

            if (blobs.length === mediaCount) {
                if (isMobile) {
                    downloadZipArchive(blobs, userName, tweetId, allMediaURLs);
                } else {
                    blobs.forEach((blob, index) => {
                        const mediaURL = allMediaURLs[index];
                        const mediaInfo = getMediaInfoFromUrl(mediaURL);
                        const filename = `${userName}_${tweetId}-${mediaInfo.typeLabel}${index + 1}`;
                        downloadBlobAsFile(blob, mediaURL, filename);
                    });
                }
                setTimeout(() => status(btn_down, 'download'), 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="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11" fill="none" stroke="currentColor" stroke-width="2" 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>
        `;

        btn_down.onclick = async () => {
            if (btn_down.classList.contains('loading')) return;
            status(btn_down, 'loading');
            mediaBlobs = [];

            const tweetInfo = extractTweetInfo(getMainTweetUrl(cell));
            const userName = tweetInfo ? tweetInfo.user : "";
            const mediaData = await getMediaURLs(cell, userName);
            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;
            }

            const tweetIdMatch = getMainTweetUrl(cell).match(/\/status\/(\d+)/);
            const tweetId = tweetIdMatch ? tweetIdMatch[1] : "unknown";

            downloadMedia(imageURLs, gifURLs, videoURLs, userName, tweetId, 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);
            const tweetInfo = extractTweetInfo(tweetUrl);
            if (!tweetInfo) 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);

})();