Twitter Media Downloader

Download images and videos from Twitter posts and pack them into a ZIP archive with metadata.

目前為 2024-08-27 提交的版本,檢視 最新版本

// ==UserScript==
// @name         Twitter Media Downloader
// @namespace    http://tampermonkey.net/
// @version      1.7
// @description  Download images and videos from Twitter posts and pack them into a ZIP archive with metadata.
// @author       Dramorian
// @license      MIT
// @match        https://twitter.com/*
// @match        https://x.com/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==

(function () {
    'use strict';

    const BASE_URL = 'https://x.com/i/api/graphql/QuBlQ6SxNAQCt6-kBiCXCQ/TweetDetail';

    function getCookie() {
        const cookies = document.cookie.split(';').reduce((acc, cookie) => {
            const [name, value] = cookie.split('=').map(part => part.trim());
            if (name) {
                acc[name] = value || ''; // Ensure empty string if value is undefined
            }
            return acc;
        }, {});

        return {
            lang: cookies.lang || 'en', // Default to 'en' if lang is not present
            ct0: cookies.ct0 || ''     // Default to empty string if ct0 is not present
        };
    }

    async function fetchTweetData(tweetId) {
        try {
            const url = buildTweetDataURL(tweetId);
            const headers = buildHeaders(getCookie());

            const response = await fetch(url, {method: 'GET', headers});

            if (!response.ok) {
                await logErrorResponse(response);
                return [];
            }

            const data = await response.json();
            return extractMediaFromTweetData(data, tweetId);
        } catch (error) {
            console.error('Failed to fetch tweet data:', error);
            return [];
        }
    }

    function buildTweetDataURL(tweetId) {
        const variables = {
            focalTweetId: tweetId,
            with_rux_injections: false,
            rankingMode: "Relevance",
            includePromotedContent: true,
            withCommunity: true,
            withQuickPromoteEligibilityTweetFields: true,
            withBirdwatchNotes: true,
            withVoice: true
        };

        const features = {
            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,
            communities_web_enable_tweet_community_results_fetch: true,
            c9s_tweet_anatomy_moderator_badge_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,
            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_enhance_cards_enabled: false
        };

        const fieldToggles = {
            withArticleRichContentState: true,
            withArticlePlainText: false,
            withGrokAnalyze: false,
            withDisallowedReplyControls: false
        };

        return encodeURI(`${BASE_URL}?variables=${JSON.stringify(variables)}&features=${JSON.stringify(features)}&fieldToggles=${JSON.stringify(fieldToggles)}`);
    }

    function buildHeaders(cookies) {
        return {
            'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': cookies.lang,
            'x-csrf-token': cookies.ct0
        };
    }

    async function logErrorResponse(response) {
        const text = await response.text();
        console.error(`Error: ${text}`);
    }

    function extractMediaFromTweetData(data, tweetId) {
        const tweetEntry = data.data?.threaded_conversation_with_injections_v2?.instructions?.[0]?.entries?.find(n => n.entryId === `tweet-${tweetId}`);
        const tweetResult = tweetEntry?.content?.itemContent?.tweet_results?.result;

        if (!tweetResult) {
            console.error('Tweet result not found');
            return [];
        }

        const media = tweetResult.legacy?.entities?.media;

        if (!media || media.length === 0) {
            return [];
        }

        return media.flatMap(item => {
            if (item.type === 'photo') {
                return [item.media_url_https + '?name=orig'];
            } else if (item.type === 'video') {
                return extractHighestQualityVideo(item);
            } else if (item.type === 'animated_gif') {
                return extractGifVariant(item);
            }
            return [];
        });
    }

    function extractHighestQualityVideo(item) {
        const highestQuality = item.video_info.variants
            .filter(variant => variant.content_type === 'video/mp4')
            .reduce((max, variant) => variant.bitrate > max.bitrate ? variant : max, {bitrate: 0});

        return [{
            url: highestQuality.url,
            bitrate: highestQuality.bitrate,
            content_type: highestQuality.content_type
        }];
    }

    function extractGifVariant(item) {
        const gifVariant = item.video_info.variants
            .find(variant => variant.content_type === 'video/mp4');

        return gifVariant ? [{
            url: gifVariant.url,
            bitrate: gifVariant.bitrate,
            content_type: gifVariant.content_type
        }] : [];
    }

    async function downloadMedia(tweetElement, mediaData) {
        const zip = new JSZip();
        const tweetLinkElement = tweetElement.querySelector('a[href*="/status/"]');
        const {authorHandle, tweetId, tweetLink} = extractTweetDetails(tweetLinkElement);

        const metadata = buildMetadata(tweetElement, tweetLink, authorHandle);
        await addMediaToZip(zip, mediaData, authorHandle, tweetId, metadata);

        await addMetadataAndSaveZip(zip, metadata, authorHandle, tweetId, tweetLink);
    }

    function extractTweetDetails(tweetLinkElement) {
        const tweetLink = tweetLinkElement.href;
        const tweetParts = tweetLink.match(/https:\/\/(?:x\.com|twitter\.com)\/([^\/]+)\/status\/(\d+)/);
        const authorHandle = tweetParts[1];
        const tweetId = tweetParts[2];

        return {
            authorHandle,
            tweetId,
            tweetLink: `https://x.com/${authorHandle}/status/${tweetId}`
        };
    }

    function buildMetadata(tweetElement, tweetLink, authorHandle) {
        const authorCommentElement = tweetElement.querySelector('div[lang]');
        const authorComment = authorCommentElement ? authorCommentElement.innerText : '';
        const dateElement = tweetElement.querySelector('time');
        const postDateTime = dateElement ? new Date(dateElement.getAttribute('datetime')) : new Date();

        let metadata = `${tweetLink}\n`;
        if (authorComment) {
            metadata += `${authorComment}\n`;
        }
        metadata += `@${authorHandle}\n${postDateTime.toLocaleString()}\n`;

        return metadata;
    }

    async function addMediaToZip(zip, mediaData, authorHandle, tweetId, metadata) {
        let mediaIndex = 1;

        for (const media of mediaData) {
            try {
                const {fileName, fileData} = await fetchMediaFile(media, authorHandle, tweetId, mediaIndex);
                zip.file(fileName, fileData);
                metadata += `${media.url || media}\n`;
                mediaIndex++;
            } catch (error) {
                console.error('Failed to fetch media:', error);
            }
        }
    }

    async function fetchMediaFile(media, authorHandle, tweetId, mediaIndex) {
        let fileName, fileData;

        if (media.content_type === 'video/mp4') {
            fileData = await fetch(media.url).then(res => res.blob());
            fileName = `${authorHandle}_${tweetId}_${mediaIndex}.mp4`;
        } else {
            fileData = await fetch(media).then(res => res.blob());
            fileName = `${authorHandle}_${tweetId}_${mediaIndex}.jpg`;
        }

        return {fileName, fileData};
    }

    async function addMetadataAndSaveZip(zip, metadata, authorHandle, tweetId, tweetLink) {
        zip.file('metadata.txt', metadata.trim());
        zip.file(`${authorHandle}_${tweetId}.url`, `[InternetShortcut]\nURL=${tweetLink}`);

        const content = await zip.generateAsync({type: 'blob'});
        saveAs(content, `${authorHandle}_${tweetId}.zip`);
    }


    function addDownloadButton(tweetElement) {
        const tweetLinkElement = getTweetLinkElement(tweetElement);
        if (!tweetLinkElement) return;

        const tweetId = extractTweetId(tweetLinkElement);
        if (!tweetId || !hasMedia(tweetElement)) return;

        if (isTweetProcessed(tweetId) || tweetElement.querySelector('.download-media-btn')) return;

        const button = createDownloadButton(tweetId);
        attachButtonToActionBar(tweetElement, button);
    }

    function getTweetLinkElement(tweetElement) {
        return tweetElement.querySelector('a[href*="/status/"]');
    }

    function extractTweetId(tweetLinkElement) {
        const tweetLink = tweetLinkElement.href;
        const tweetParts = tweetLink.match(/https:\/\/(?:x\.com|twitter\.com)\/([^\/]+)\/status\/(\d+)/);
        return tweetParts ? tweetParts[2] : null;
    }

    function hasMedia(tweetElement) {
        const mediaSelectors = [
            'a[href*="/photo/1"]',
            'div[role="progressbar"]',
            'button[data-testid="playButton"]',
        ];
        return mediaSelectors.some(selector => tweetElement.querySelector(selector));
    }

    function isTweetProcessed(tweetId) {
        const processedTweets = JSON.parse(localStorage.getItem('processedTweets') || '[]');
        return processedTweets.includes(tweetId);
    }

    function createDownloadButton(tweetId) {
        const button = document.createElement('button');
        button.className = 'download-media-btn';
        Object.assign(button.style, {
            marginLeft: '10px',
            padding: '6px',
            border: 'none',
            backgroundColor: 'transparent',
            cursor: 'pointer',
        });

        const isProcessed = isTweetProcessed(tweetId);
        button.innerHTML = createButtonSVG(isProcessed);
        button.dataset.tweetId = tweetId;
        button.addEventListener('click', () => onDownloadButtonClick(button));

        return button;
    }

    function createButtonSVG(isProcessed) {
        const strokeColor = isProcessed ? '#28a745' : '#1da1f2';
        return `
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download">
            <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
            <polyline points="7 10 12 15 17 10"></polyline>
            <line x1="12" y1="15" x2="12" y2="3"></line>
        </svg>
    `;
    }

    function attachButtonToActionBar(tweetElement, button) {
        const actionBar = tweetElement.querySelector('[role="group"]');
        if (actionBar) {
            actionBar.appendChild(button);
        }
    }


    async function onDownloadButtonClick(button) {
        const tweetId = button.dataset.tweetId;

        // Log and show loading animation
        console.log(`Fetching media for tweetId: ${tweetId}`);
        showLoadingAnimation(button);

        // Optionally disable the button during fetch
        button.disabled = true;

        try {
            const mediaData = await fetchTweetData(tweetId);

            if (mediaData.length === 0) {
                console.warn('No media found for this tweet.');
                return;
            }

            const tweetElement = button.closest('article');
            if (tweetElement) {
                await downloadMedia(tweetElement, mediaData);
                markTweetAsProcessed(tweetId);
                updateButtonIcon(button, true);
            }
        } catch (error) {
            console.error('Failed to fetch or download media:', error);
        } finally {
            // Re-enable the button even if there was an error
            button.disabled = false;
        }
    }

    function showLoadingAnimation(button) {
        button.innerHTML = `
        <svg width="24" height="24" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#1da1f2">
            <circle cx="15" cy="15" r="15">
                <animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s"
                    values="15;9;15" calcMode="linear" repeatCount="indefinite" />
                <animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s"
                    values="1;.5;1" calcMode="linear" repeatCount="indefinite" />
            </circle>
            <circle cx="60" cy="15" r="9" fill-opacity="0.3">
                <animate attributeName="r" from="9" to="9" begin="0s" dur="0.8s"
                    values="9;15;9" calcMode="linear" repeatCount="indefinite" />
                <animate attributeName="fill-opacity" from="0.5" to="0.5" begin="0s" dur="0.8s"
                    values=".5;1;.5" calcMode="linear" repeatCount="indefinite" />
            </circle>
            <circle cx="105" cy="15" r="15">
                <animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s"
                    values="15;9;15" calcMode="linear" repeatCount="indefinite" />
                <animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s"
                    values="1;.5;1" calcMode="linear" repeatCount="indefinite" />
            </circle>
        </svg>
    `;
    }

    function updateButtonIcon(button, isProcessed) {
        const strokeColor = isProcessed ? '#28a745' : '#1da1f2';
        button.innerHTML = `
        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download">
            <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
            <polyline points="7 10 12 15 17 10"></polyline>
            <line x1="12" y1="15" x2="12" y2="3"></line>
        </svg>
    `;
    }

    function markTweetAsProcessed(tweetId) {
        const processedTweets = JSON.parse(localStorage.getItem('processedTweets') || '[]');
        if (!processedTweets.includes(tweetId)) {
            processedTweets.push(tweetId);
            localStorage.setItem('processedTweets', JSON.stringify(processedTweets));
        }
    }


    const observer = new MutationObserver(handleMutations);

    function handleMutations(mutations) {
        mutations.forEach(mutation => {
            mutation.addedNodes.forEach(node => {
                if (node.nodeType === Node.ELEMENT_NODE) {
                    processAddedNode(node);
                }
            });
        });
    }

    function processAddedNode(node) {
        // Check if the node itself is a tweet element or contains tweet elements
        const tweetElements = node.matches('article') ? [node] : node.querySelectorAll('article');
        tweetElements.forEach(tweetElement => addDownloadButton(tweetElement));
    }

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });
})();

QingJ © 2025

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