// ==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/fflate@0.8.2/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 https://greasyfork.org/users/1441951 // @downloadURL none // ==/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 = ` `; 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); })();