// ==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.0.2
// @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.
// @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
// @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
// ==/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 isFirefox = navigator.userAgent.toLowerCase().includes('firefox'); // 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;
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) => {
const headers = !isFirefox ? { 'User-Agent': userAgent } : {}; // Firefox の場合は User-Agent ヘッダーを削除
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, 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);
})();