Greasy Fork

来自缓存

Greasy Fork is available in English.

X/Twitter 媒体批量下载器 (支持 iPhone/Android)

一键下载 X/Twitter 的图片、视频和 GIF,默认设置下以用户 ID 和帖子 ID 保存。您可以自定义下载文件的文件名。在 iPhone/Android 上,通过使用 ZIP 文件,您还可以一键下载附加的媒体。下载历史记录与书签同步。此外,可以选择利用 X/Twitter 的书签功能来实现在线同步下载历史记录。

当前为 2025-04-02 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 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 (Android/iPhone support)
// @name:zh-CN   X/Twitter 媒体批量下载器 (支持 iPhone/Android)
// @name:zh-TW   X/Twitter 媒體批量下載器 (支援 iPhone/Android)
// @version      1.3.0
// @description  X/Twitterの画像や動画、GIFをワンクリックでダウンロードして、デフォルトの設定ではユーザーIDとポストIDで保存します。ダウンロードされるファイルの名前は任意に変更できます。iPhone/Androidでもzipを利用することで添付されたメディアをワンクリックでダウンロードすることができます。また、ダウンロード履歴をブックマークと同期します。さらに、オプションでX/Twitterのブックマーク機能を利用することでダウンロード履歴のオンライン同期が可能です。
// @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 synced with bookmarks. Additionally, you can optionally synchronize your download history online using the X/Twitter bookmark feature.
// @description:zh-CN 一键下载 X/Twitter 的图片、视频和 GIF,默认设置下以用户 ID 和帖子 ID 保存。您可以自定义下载文件的文件名。在 iPhone/Android 上,通过使用 ZIP 文件,您还可以一键下载附加的媒体。下载历史记录与书签同步。此外,可以选择利用 X/Twitter 的书签功能来实现在线同步下载历史记录。
// @description:zh-TW 一鍵下載 X/Twitter 的圖片、影片和 GIF,預設設定下以使用者 ID 和貼文 ID 儲存。您可以自訂檔案的檔案名稱。在 iPhone/Android 上,透過使用 ZIP 檔案,您還可以一鍵下載附加的媒體。下載歷史記錄與書籤同步。此外,可以選擇利用 X/Twitter 的書籤功能來實現在線同步下載歷史記錄。
// @require      https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dayjs.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/plugin/utc.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 */

dayjs.extend(dayjs_plugin_utc);

(function () {
    "use strict";

    // === User Settings ===
    /**
    /**
     * Set whether to enable the online synchronization of download history using bookmarks.
     *
     * false (default): Disables online synchronization for download history. Download history is managed locally per browser.
     * true: Change this to true to enable online synchronization. Performing a download will add the tweet to your bookmarks, and already bookmarked tweets will be skipped. The history will be synchronized across devices via bookmarks.
     */

    const enableDownloadHistorykSync = false; // Change this to true to enable online synchronization for download history.

    // === 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|ipad|mobile/.test(navigator.userAgent.toLowerCase());
    const isAppleMobile = /iphone|ipad/.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';
    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 fetchBookmarkSearchTimeline = async (userId, postTime) => {
        const base_url = `https://${location.hostname}/i/api/graphql/E04kdKlD8PTU9yiCelJaUQ/BookmarkSearchTimeline`;

        const formattedSinceTime = dayjs(postTime).utc().format('YYYY-MM-DD_HH:mm:ss_UTC');
        const formattedUntilTime = dayjs(postTime).utc().add(1, 'second').format('YYYY-MM-DD_HH:mm:ss_UTC');

        const rawQuery = `from:${userId} since:${formattedSinceTime} until:${formattedUntilTime}`;
        const variables = { "rawQuery": rawQuery, "count":20};
        const features = {
            "rweb_video_screen_enabled": false,
            "profile_label_improvements_pcf_label_in_post_enabled": true,
            "rweb_tipjar_consumption_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,
            "premium_content_api_read_enabled": false,
            "communities_web_enable_tweet_community_results_fetch": true,
            "c9s_tweet_anatomy_moderator_badge_enabled": true,
            "responsive_web_grok_analyze_button_fetch_trends_enabled": false,
            "responsive_web_grok_analyze_post_followups_enabled": true,
            "responsive_web_jetfuel_frame": false,
            "responsive_web_grok_share_attachment_enabled": true,
            "articles_preview_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": true,
            "tweet_awards_web_tipping_enabled": false,
            "responsive_web_grok_analysis_button_from_backend": false,
            "creator_subscriptions_quote_tweet_preview_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,
            "rweb_video_timestamps_enabled": true,
            "longform_notetweets_rich_text_read_enabled": true,
            "longform_notetweets_inline_media_enabled": true,
            "responsive_web_grok_image_annotation_enabled": true,
            "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 getAlreadyBookmarkedMessage = () => {
        const lang = getCurrentLanguage();
        return lang === 'ja' ? "このツイートはすでにブックマーク済みです。" : "This tweet is already bookmarked.";
    };
    const status = (btn, css) => {
        btn.classList.remove('download', 'loading', 'failed', 'completed');
        if (css) btn.classList.add(css);
    };

    const getValidMediaElements = (cell) => {
        let validImages = [], validVideos = [], validGifs = [];

        const isTweetPhoto = (img) => img.parentElement && img.parentElement.dataset.testid === 'tweetPhoto';

        validImages = Array.from(cell.querySelectorAll("img[src^='https://pbs.twimg.com/media/']"))
            .filter(img => (
                !img.closest("div[tabindex='0'][role='link']") &&
                !img.closest("div[data-testid='previewInterstitial']") &&
                isTweetPhoto(img)
            ));

        const videoCandidates_videoTag = Array.from(cell.querySelectorAll("video"));
        videoCandidates_videoTag.forEach(video => {
            if (video.closest("div[tabindex='0'][role='link']")) return;
            if (!video.closest("div[data-testid='videoPlayer']")) 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_tw_video_thumb/") || video.poster?.includes("/media/")) {
                validVideos.push(video);
            }
        });

        const videoCandidates_imgTag = Array.from(cell.querySelectorAll("img[src]"));
        videoCandidates_imgTag.forEach(img => {
            if (img.closest("div[tabindex='0'][role='link']")) return;
            if (!img.closest("div[data-testid='previewInterstitial']")) return;
            if (isTweetPhoto(img)) return;
            if (img.src.startsWith("https://pbs.twimg.com/tweet_video_thumb/")) {
                validGifs.push(img);
            } else if (img.src.includes('/ext_tw_video_thumb/') || img.src.includes('/amplify_video_thumb/') || img.src.includes('/media/')) {
                validVideos.push(img);
            }
        });
        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);
        let gifURLs = mediaElems.gifs.map(gif => gif.src);
        let videoURLs = [];

        gifURLs = gifURLs.map(gifURL => {
            if (gifURL.startsWith("https://pbs.twimg.com/tweet_video_thumb/")) {
                const gifIdBaseUrl = gifURL.split('?')[0];
                const gifId = gifIdBaseUrl.split('/').pop();
                return `https://video.twimg.com/tweet_video/${gifId}.mp4`;
            }
            return gifURL;
        });

        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 checkBookmarkStatus = async (userId, postId, postTime) => {
        if (!enableDownloadHistorykSync) {
            return false;
        }
        try {
            const bookmarkData = await fetchBookmarkSearchTimeline(userId, postTime);
            if (!bookmarkData.data) return false;
            const instructions = bookmarkData.data.search_by_raw_query.bookmarks_search_timeline.timeline.instructions;
            if (!instructions) return false;

            for (const instruction of instructions) {
                if (instruction.type === 'TimelineAddEntries' && instruction.entries) {
                    for (const entry of instruction.entries) {
                        if (entry.entryId && entry.entryId === `tweet-${postId}`) {
                            return true;
                        }
                    }
                }
            }
            return false;
        } catch (error) {
            console.error("ブックマーク状態の確認に失敗:", error);
            return false;
        }
    };

    const clickBookmarkButton = (cell) => {
        if (!enableDownloadHistorykSync) {
            return;
        }
        const btn_group = cell.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions');
        if (btn_group) {
            const bookmarkButton = btn_group.querySelector('button[data-testid="bookmark"]');
            if (bookmarkButton) {
                bookmarkButton.click();
            }
        }
    };

    const waitForBookmarkStateChange = (cell) => {
        return new Promise(resolve => {
            if (!enableDownloadHistorykSync && !isAppleMobile) {
                resolve();
                return;
            }
            const btn_group = cell.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions');
            const bookmarkButton = btn_group ? btn_group.querySelector('button[data-testid="bookmark"]') : null;
            if (!bookmarkButton) {
                resolve();
                return;
            }
            const observer = new MutationObserver(mutations => {
                for (const mutation of mutations) {
                    if (mutation.type === 'attributes' && mutation.attributeName === 'data-testid') {
                        if (bookmarkButton.dataset.testid === 'removeBookmark') {
                            observer.disconnect();
                            setTimeout(() => resolve(), 500);
                        }
                    }
                }
            });
            observer.observe(document.body, { childList: true, subtree: true, attributes: true });
        });
    };

    const downloadZipArchive = async (blobs, filenameElements, mediaURLs, cell, bookmarkPromise) => {
        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;
        });

        const zipData = await new Promise((resolve, reject) => {
            fflate.zip(files, { level: 0 }, (err, zipData) => {
                if (err) {
                    console.error("ZIP archive creation failed:", err);
                    alert("ZIPファイルの作成に失敗しました。");
                    reject(err);
                } else {
                    resolve(zipData);
                }
            });
        });

        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;

        if (enableDownloadHistorykSync && isAppleMobile) {
            clickBookmarkButton(cell);
            await bookmarkPromise;
        }

        document.body.appendChild(a);
        a.click();
        a.remove();
        URL.revokeObjectURL(zipDataUrl);
    };

    const downloadBlobAsFile = async (blob, filename, cell, bookmarkPromise) => {
        const dataUrl = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.download = filename;
        a.href = dataUrl;

        if (enableDownloadHistorykSync && isAppleMobile) {
            clickBookmarkButton(cell);
            await bookmarkPromise;
        }

        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, cell, bookmarkPromise) => {
        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);
                await downloadBlobAsFile(blob, filename, cell, bookmarkPromise);
                markPostAsDownloadedIndexedDB(filenameElements.postId);
                setTimeout(() => {
                    status(btn_down, 'completed');
                    if (enableDownloadHistorykSync && !isAppleMobile) clickBookmarkButton(cell);
                }, 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) {
                    await downloadZipArchive(blobs, filenameElements, allMediaURLs, cell, bookmarkPromise);
                } else {
                    for (const [index, blob] of blobs.entries()) {
                    const mediaURL = allMediaURLs[index];
                    const mediaInfo = getMediaInfoFromUrl(mediaURL);
                    const ext = mediaInfo.ext;
                    const typeLabel = mediaInfo.typeLabel;
                    const filename = generateFilename(filenameElements, typeLabel, index + 1, ext);
                    await downloadBlobAsFile(blob, filename, cell, bookmarkPromise);
                    }
                }
                markPostAsDownloadedIndexedDB(filenameElements.postId);
                setTimeout(() => {
                    status(btn_down, 'completed');
                    if (enableDownloadHistorykSync && !isAppleMobile) clickBookmarkButton(cell);
                }, 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_bookmark = btn_share.previousElementSibling;
        let isBookmarked = false;
        if (enableDownloadHistorykSync) {
            if (btn_bookmark) {
                const bookmarkButtonTestId = btn_bookmark.querySelector('button[data-testid="bookmark"], button[data-testid="removeBookmark"]')?.dataset.testid;
                isBookmarked = bookmarkButtonTestId === 'removeBookmark';
            }
        }

        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) {
             if (downloadedPostsCache.has(filenameElements.postId)) {
                 status(btn_down, 'completed');
             }
             else if (enableDownloadHistorykSync && isBookmarked) {
                 status(btn_down, 'completed');
                 markPostAsDownloadedIndexedDB(filenameElements.postId);
             }
        }

        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;
            }

            if (enableDownloadHistorykSync) {
                const isAlreadyBookmarked = await checkBookmarkStatus(filenameElements.userId, filenameElements.postId, filenameElements.postTime);
                if (isAlreadyBookmarked) {
                    status(btn_down, 'completed');
                    alert(getAlreadyBookmarkedMessage());
                    markPostAsDownloadedIndexedDB(filenameElements.postId);
                    return;
                }
            }

            const bookmarkPromise = waitForBookmarkStateChange(cell);

            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, cell, bookmarkPromise);
        };
        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);

})();