Greasy Fork

Greasy Fork is available in English.

Twitter/X Media Downloader & Enhancer

单击图片点赞并下载,双击查看原图;自动视频下载。优化推特媒体浏览体验。

当前为 2025-11-20 提交的版本,查看 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name:zh-CN   Twitter/X 推特媒体下载与交互优化(点击图片下载图片并根据作者和推文链接以及标签进行重命名)
// @name         Twitter/X Media Downloader & Enhancer
// @namespace    http://tampermonkey.net/
// @version      2.5
// @description  单击图片点赞并下载,双击查看原图;自动视频下载。优化推特媒体浏览体验。
// @author       Gemini(反正不是我)
// @match        https://x.com/*
// @match        https://twitter.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant        GM_addStyle
// @license      MIT
// @run-at       document-start
// ==/UserScript==

(function() {
    'use strict';
    
    // ================= 配置区域 =================
    const CONFIG = {
        // 注意:Twitter 会定期更新这个 GraphQL ID。如果下载视频失效,通常需要更新此 ID。
        // 你可以在网页 Network 面板搜索 'TweetResultByRestId' 找到最新的 ID。
        GRAPHQL_ID: 'zAz9764BcLZOJ0JU2wrd1A', 
        TOAST_DURATION: 3000
    };

    console.log('🚀 Twitter Media Enhancer v2.5 Loaded');

    // ================= 样式注入 =================
    GM_addStyle(`
        /* 图片/视频交互动画 */
        [data-testid="tweetPhoto"] img {
            cursor: pointer !important;
            transition: transform 0.2s !important;
        }
        [data-testid="tweetPhoto"] img:hover {
            transform: scale(1.02);
        }
        @keyframes likeAnimation {
            0%, 100% { transform: scale(1); }
            50% { transform: scale(1.2); }
        }
        .wb-like-animation {
            animation: likeAnimation 0.3s ease !important;
        }

        /* 提示框样式 */
        #wb-download-toast {
            position: fixed;
            bottom: 20px;
            right: 20px;
            background: #1d9bf0;
            color: white;
            padding: 12px 20px;
            border-radius: 8px;
            box-shadow: 0 4px 12px rgba(0,0,0,0.2);
            z-index: 999999;
            font-size: 14px;
            display: none;
            max-width: 300px;
            line-height: 1.4;
            pointer-events: none; /* 防止遮挡点击 */
        }
        #wb-download-toast.show {
            display: block;
            animation: slideIn 0.3s ease;
        }
        @keyframes slideIn {
            from { transform: translateY(100px); opacity: 0; }
            to { transform: translateY(0); opacity: 1; }
        }
    `);

    // ================= 工具函数 =================
    let downloadToast = null;
    const downloadedTweets = new Set();

    function createToast() {
        if (document.getElementById('wb-download-toast')) return;
        downloadToast = document.createElement('div');
        downloadToast.id = 'wb-download-toast';
        document.body.appendChild(downloadToast);
    }

    function showToast(message, duration = CONFIG.TOAST_DURATION) {
        if (!downloadToast) createToast();
        downloadToast.innerHTML = message.replace(/\n/g, '<br>');
        downloadToast.classList.add('show');
        setTimeout(() => downloadToast.classList.remove('show'), duration);
    }

    function getCookie(name) {
        const value = `; ${document.cookie}`;
        const parts = value.split(`; ${name}=`);
        return parts.length === 2 ? parts.pop().split(';').shift() : null;
    }

    // ================= 核心逻辑:解析推文信息 =================
    function getTweetInfo(element) {
        const tweetArticle = element.closest('article[data-testid="tweet"]');
        if (!tweetArticle) return null;

        // 获取推文链接和ID
        const links = tweetArticle.querySelectorAll('a[href*="/status/"]');
        let tweetUrl = null;
        for (const link of links) {
            const href = link.getAttribute('href');
            if (href && href.includes('/status/')) {
                tweetUrl = 'https://x.com' + href;
                break;
            }
        }
        if (!tweetUrl) return null;

        const match = tweetUrl.match(/\/status\/(\d+)/);
        if (!match) return null;
        const tweetId = match[1];

        // 获取作者信息
        const authorLink = tweetArticle.querySelector('a[href^="/"][href*="/status/"]');
        let authorId = 'unknown';
        let authorNick = 'unknown';

        if (authorLink) {
            const href = authorLink.getAttribute('href');
            const authorMatch = href.match(/^\/([^/]+)\//);
            if (authorMatch) authorId = authorMatch[1];
            
            const userNameSpan = tweetArticle.querySelector('div[dir="ltr"] span');
            if (userNameSpan) authorNick = userNameSpan.textContent || authorId;
        }

        // 获取Tag
        const hashtags = [];
        tweetArticle.querySelectorAll('a[href*="/hashtag/"]').forEach(link => {
            const tag = link.textContent.replace('#', '');
            if (tag && !hashtags.includes(tag)) hashtags.push(tag);
        });

        return {
            tweetId,
            authorId,
            authorNick,
            hashtags: hashtags.join('-') || '',
            tweetUrl,
            article: tweetArticle
        };
    }

    // ================= API 请求构建 =================
    const API_BASE = `https://x.com/i/api/graphql/${CONFIG.GRAPHQL_ID}/TweetResultByRestId`;

    const createTweetUrl = (tweetId) => {
        const variables = {
            tweetId: tweetId,
            with_rux_injections: false,
            includePromotedContent: true,
            withCommunity: true,
            withQuickPromoteEligibilityTweetFields: true,
            withBirdwatchNotes: true,
            withVoice: true
        };

        const features = {
            "articles_preview_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": true,
            "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,
            "rweb_video_screen_enabled": true,
            "responsive_web_media_download_video_enabled": false,
            "responsive_web_enhance_cards_enabled": false
        };

        return `${API_BASE}?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}`;
    };

    const fetchTweetData = async (tweetId) => {
        const url = createTweetUrl(tweetId);
        const ct0 = getCookie('ct0') || '';
        const headers = {
            authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
            'x-twitter-active-user': 'yes',
            'x-csrf-token': ct0
        };

        try {
            const response = await fetch(url, { method: 'GET', headers });
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            const data = await response.json();
            return extractMediaFromTweet(data, tweetId);
        } catch (error) {
            console.error('Fetch Error:', error);
            return [];
        }
    };

    // ================= 媒体提取逻辑 =================
    const extractMediaFromTweet = (data, tweetId) => {
        let tweet = data?.data?.tweetResult?.result;
        // 处理会话线程中的推文
        if (!tweet) {
            const instructions = data?.data?.threaded_conversation_with_injections_v2?.instructions || [];
            const tweetEntry = instructions[0]?.entries?.find(e => e.entryId === `tweet-${tweetId}`);
            const tweetResult = tweetEntry?.content?.itemContent?.tweet_results?.result;
            tweet = tweetResult?.tweet || tweetResult;
        }

        if (!tweet) return [];

        // 处理被引用推文或普通推文
        const legacy = tweet.legacy || tweet.tweet?.legacy;
        if (!legacy) return [];

        const media = legacy.extended_entities?.media || legacy.entities?.media || [];
        
        return media.flatMap((item) => {
            if (item.type === 'photo') {
                return [item.media_url_https + '?name=orig'];
            } else if (item.type === 'video' || item.type === 'animated_gif') {
                const variants = item.video_info?.variants || [];
                // 筛选 mp4 并找最高码率
                const mp4s = variants.filter(v => v.content_type === 'video/mp4');
                const bestQuality = mp4s.reduce((max, v) => (v.bitrate > (max.bitrate || 0) ? v : max), {});
                return bestQuality.url ? [bestQuality.url] : [];
            }
            return [];
        });
    };

    // ================= 下载逻辑 =================
    async function downloadMedia(tweetInfo) {
        const { tweetId, authorId, authorNick, hashtags, article, tweetUrl } = tweetInfo;

        if (downloadedTweets.has(tweetId)) {
            console.log('⏭️ Already downloaded, skipping');
            return;
        }

        downloadedTweets.add(tweetId);
        setTimeout(() => downloadedTweets.delete(tweetId), 5000); // 5秒防抖

        let mediaUrls = await fetchTweetData(tweetId);

        // 回落机制:如果API获取失败,尝试从DOM获取图片
        if (mediaUrls.length === 0) {
            const images = article.querySelectorAll('[data-testid="tweetPhoto"] img');
            mediaUrls = Array.from(images).map(img => img.src.replace(/\?.*$/, '') + '?format=jpg&name=orig');
            
            if (mediaUrls.length === 0) {
                showToast('⚠️ 无法获取媒体\n链接已复制', 2000);
                navigator.clipboard.writeText(tweetUrl);
                return;
            }
        }

        showToast(`📥 开始下载 ${mediaUrls.length} 个文件...`);

        let count = 0;
        for (const url of mediaUrls) {
            count++;
            const isVideo = url.includes('.mp4');
            const ext = isVideo ? 'mp4' : 'jpg';
            const indexStr = mediaUrls.length > 1 ? `_${count}` : '';
            const tagStr = hashtags ? `-${hashtags}` : '';
            // 文件名格式:昵称-ID-推文ID-Tags_序号.后缀
            const filename = `${authorNick}-${authorId}-${tweetId}${tagStr}${indexStr}.${ext}`;

            try {
                const res = await fetch(url);
                const blob = await res.blob();
                const link = document.createElement('a');
                link.href = URL.createObjectURL(blob);
                link.download = filename;
                link.click();
                URL.revokeObjectURL(link.href);
                console.log(`✅ Downloaded: ${filename}`);
            } catch (err) {
                console.error('Download Failed:', err);
                showToast(`❌ 下载失败: ${count}`);
            }
        }
    }

    // ================= 事件监听 =================
    const clickTimers = new WeakMap();

    function handleImageClick(event) {
        const img = event.target;
        if (img.tagName !== 'IMG') return;
        
        const photoContainer = img.closest('[data-testid="tweetPhoto"]');
        if (!photoContainer) return;

        event.preventDefault();
        event.stopPropagation();

        const tweetInfo = getTweetInfo(img);
        if (!tweetInfo) return;

        if (clickTimers.has(img)) {
            // 双击:清除单击计时器,执行原生点击(查看大图)
            clearTimeout(clickTimers.get(img));
            clickTimers.delete(img);
            console.log('🖱️ Double Click - View Image');
            const link = img.closest('a');
            if (link) link.click();
        } else {
            // 单击:设置延时,执行点赞下载
            const timer = setTimeout(() => {
                clickTimers.delete(img);
                console.log('💖 Single Click - Like & Download');
                
                const likeButton = tweetInfo.article.querySelector('[data-testid="like"], [data-testid="unlike"]');
                if (likeButton) {
                    const isLiked = likeButton.getAttribute('data-testid') === 'unlike';
                    likeButton.click();
                    
                    // 动画效果
                    img.classList.add('wb-like-animation');
                    setTimeout(() => img.classList.remove('wb-like-animation'), 300);

                    if (!isLiked) {
                        downloadMedia(tweetInfo);
                    } else {
                        showToast('💔 取消点赞');
                    }
                }
            }, 250); // 延时 250ms 以等待双击判断
            clickTimers.set(img, timer);
        }
    }

    // 监听原生点赞按钮点击
    function setupLikeButtonListener() {
        document.addEventListener('click', (event) => {
            const likeButton = event.target.closest('[data-testid="like"]');
            if (likeButton && !event.target.closest('[data-testid="tweetPhoto"]')) {
                const tweetInfo = getTweetInfo(likeButton);
                if (tweetInfo) {
                    console.log('💖 Like Button Clicked');
                    setTimeout(() => downloadMedia(tweetInfo), 100);
                }
            }
        }, true);
    }

    // ================= 初始化 =================
    function init() {
        createToast();
        document.addEventListener('click', handleImageClick, true);
        setupLikeButtonListener();
        console.log('✅ Interactions Setup Complete');
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
    } else {
        setTimeout(init, 500);
    }

})();