Greasy Fork

Greasy Fork is available in English.

Twitter/X 推特媒体下载与交互优化(点击图片点赞并下载图片并根据id、推文链接、标签重命名)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Twitter/X 推特媒体下载与交互优化(点击图片点赞并下载图片并根据id、推文链接、标签重命名)
// @name:en      Twitter/X Media Downloader(Click images,download auto-renaming based on ID, URL, and hashtags)
// @namespace    http://tampermonkey.net/
// @version      3.2
// @description  单击图片点赞并下载,双击查看原图;自动获取最高画质视频下载。优化推特媒体浏览体验。
// @description:en Click images to like & download with auto-renaming based on ID, tweet URL, and hashtags.
// @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 GRAPHQL_ID = 'zAz9764BcLZOJ0JU2wrd1A';
    const API_BASE = `https://x.com/i/api/graphql/${GRAPHQL_ID}/TweetResultByRestId`;
    const MAX_FILENAME_LENGTH = 200;

    console.log('🚀 Twitter Media Enhancer v3.3 Loaded (Video Fix)');

    // ================= 样式注入 =================
    GM_addStyle(`
        [data-testid="tweetPhoto"], [data-testid="videoPlayer"] { cursor: pointer !important; }
        @keyframes likeAnimation { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.1); } }
        .wb-like-animation { animation: likeAnimation 0.2s 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 = 3000) {
        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;
    }

    // ================= DOM 交互识别 =================
    function getInteractionData(element) {
        // 向上寻找最近的推文容器
        const tweetArticle = element.closest('article[data-testid="tweet"]');
        if (!tweetArticle) return null;

        // 获取推文链接(作为唯一标识)
        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;

        return {
            id: match[1],
            article: tweetArticle,
            url: tweetUrl
        };
    }

    // ================= API 请求 =================
    // 使用最稳定的参数组合
    const createTweetUrl = (tweetId) => {
        const variables = { tweetId, with_rux_injections: false, rankingMode: 'Relevance', includePromotedContent: true, withCommunity: true, withQuickPromoteEligibilityTweetFields: true, withBirdwatchNotes: true, withVoice: true };
        const features = { "articles_preview_enabled": true, "c9s_tweet_anatomy_moderator_badge_enabled": true, "communities_web_enable_tweet_community_results_fetch": false, "creator_subscriptions_quote_tweet_preview_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": false, "freedom_of_speech_not_reach_fetch_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, "longform_notetweets_consumption_enabled": false, "longform_notetweets_inline_media_enabled": true, "longform_notetweets_rich_text_read_enabled": false, "premium_content_api_read_enabled": false, "profile_label_improvements_pcf_label_in_post_enabled": true, "responsive_web_edit_tweet_api_enabled": false, "responsive_web_enhance_cards_enabled": false, "responsive_web_graphql_exclude_directive_enabled": false, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "responsive_web_graphql_timeline_navigation_enabled": false, "responsive_web_grok_analysis_button_from_backend": false, "responsive_web_grok_analyze_button_fetch_trends_enabled": false, "responsive_web_grok_analyze_post_followups_enabled": false, "responsive_web_grok_image_annotation_enabled": false, "responsive_web_grok_share_attachment_enabled": false, "responsive_web_grok_show_grok_translated_post": false, "responsive_web_jetfuel_frame": false, "responsive_web_media_download_video_enabled": false, "responsive_web_twitter_article_tweet_consumption_enabled": true, "rweb_tipjar_consumption_enabled": true, "rweb_video_screen_enabled": false, "standardized_nudges_misinfo": true, "tweet_awards_web_tipping_enabled": false, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "tweetypie_unmention_optimization_enabled": false, "verified_phone_label_enabled": false, "view_counts_everywhere_api_enabled": true };
        const fieldToggles = { withArticleRichContentState: true, withArticlePlainText: false, withGrokAnalyze: false, withDisallowedReplyControls: false };
        return `${API_BASE}?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}&fieldToggles=${encodeURIComponent(JSON.stringify(fieldToggles))}`;
    };

    const fetchTweetData = async (tweetId) => {
        const url = createTweetUrl(tweetId);
        const headers = {
            authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': getCookie('lang') || 'en',
            'x-csrf-token': getCookie('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 parseSmart(data, tweetId);
        } catch (error) {
            console.error('Fetch Error:', error);
            return null;
        }
    };

    // ================= 核心解析逻辑 (简化版) =================
    function parseSmart(data, rootTweetId) {
        let rootTweet = data?.data?.tweetResult?.result;
        if (!rootTweet) {
            const instructions = data?.data?.threaded_conversation_with_injections_v2?.instructions || [];
            const tweetEntry = instructions[0]?.entries?.find(e => e.entryId === `tweet-${rootTweetId}`);
            rootTweet = tweetEntry?.content?.itemContent?.tweet_results?.result;
        }
        if (!rootTweet) return null;

        // 1. 提取外层 (Outer)
        const outerEntity = extractEntity(rootTweet, rootTweetId);

        // 2. 提取内层 (Inner) - 无论是 Retweet 还是 Quote
        let innerEntity = null;
        if (rootTweet.legacy && rootTweet.legacy.retweeted_status_result) {
            innerEntity = extractEntity(rootTweet.legacy.retweeted_status_result.result, rootTweetId);
        } else if (rootTweet.quoted_status_result) {
            innerEntity = extractEntity(rootTweet.quoted_status_result.result, rootTweetId);
        }

        // 3. 决策逻辑
        // 规则:如果外层有媒体,优先下载外层(视为独立推文)
        if (outerEntity && outerEntity.mediaUrls.length > 0) {
            console.log('🎯 命中外层媒体 (独立推文)');
            return {
                mediaUrls: outerEntity.mediaUrls,
                naming: { type: 'standalone', user: outerEntity.info }
            };
        }

        // 规则:如果外层无媒体,但内层有媒体,下载内层(视为转发)
        if (innerEntity && innerEntity.mediaUrls.length > 0) {
            console.log('🎯 命中内层媒体 (转发推文)');
            return {
                mediaUrls: innerEntity.mediaUrls,
                naming: { 
                    type: 'retweet', 
                    user: innerEntity.info, 
                    via: outerEntity ? outerEntity.info : null 
                }
            };
        }

        return null;
    }

    function extractEntity(tweetObj, defaultId) {
        if (!tweetObj || !tweetObj.legacy || !tweetObj.core) return null;
        const legacy = tweetObj.legacy;
        const core = tweetObj.core;
        const userInfo = core.user_results?.result?.legacy || {};
        
        const mediaEntities = legacy.extended_entities?.media || legacy.entities?.media || [];
        const mediaUrls = mediaEntities.flatMap((item) => {
            if (item.type === 'photo') return [item.media_url_https + '?name=orig'];
            if (item.type === 'video' || item.type === 'animated_gif') {
                const variants = item.video_info?.variants || [];
                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 [];
        });

        return {
            mediaUrls,
            info: {
                nick: userInfo.name || 'unknown',
                id: userInfo.screen_name || 'unknown',
                tweetId: legacy.id_str || defaultId,
                hashtags: (legacy.entities?.hashtags || []).map(t => t.text).join('-')
            }
        };
    }

    // ================= 下载执行 =================
    async function startDownload(data) {
        const { id, article } = data;
        
        if (downloadedTweets.has(id)) return;
        downloadedTweets.add(id);
        setTimeout(() => downloadedTweets.delete(id), 3000);

        showToast('🔍 解析媒体中...');
        const result = await fetchTweetData(id);

        if (!result || result.mediaUrls.length === 0) {
            // 视频下载失败回落提示
            showToast('❌ 无效的媒体\n可能因为 API 变动');
            return;
        }

        const { mediaUrls, naming } = result;
        const user = naming.user;

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

        let count = 0;
        for (const url of mediaUrls) {
            count++;
            const ext = url.includes('.mp4') ? 'mp4' : 'jpg';
            const indexStr = mediaUrls.length > 1 ? `_${count}` : '';
            const safeNick = (user.nick || 'unknown').replace(/[\\/:*?"<>|]/g, '_').substring(0, 40);
            const tagStr = user.hashtags ? `-${user.hashtags}` : '';

            let filename = '';

            if (naming.type === 'retweet' && naming.via) {
                // 格式: RT [转发者-ID] - [原作者-ID-TweetID]
                const safeVia = (naming.via.nick || 'unknown').replace(/[\\/:*?"<>|]/g, '_').substring(0, 20);
                filename = `RT [${safeVia}-${naming.via.id}] - [${safeNick}-${user.id}-${user.tweetId}${tagStr}]${indexStr}.${ext}`;
            } else {
                // 格式: [作者-ID-TweetID]
                filename = `${safeNick}-${user.id}-${user.tweetId}${tagStr}${indexStr}.${ext}`;
            }

            if (filename.length > MAX_FILENAME_LENGTH) {
                filename = `${safeNick}-${user.id}-${user.tweetId}${indexStr}.${ext}`;
            }

            triggerDownload(url, filename);
        }
    }

    function triggerDownload(url, filename) {
        fetch(url).then(res => res.blob()).then(blob => {
            const link = document.createElement('a');
            link.href = URL.createObjectURL(blob);
            link.download = filename;
            link.click();
            URL.revokeObjectURL(link.href);
        }).catch(err => console.error('DL Fail', err));
    }

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

    function handleInteraction(event) {
        // 监听图片或视频播放器容器
        const target = event.target.closest('[data-testid="tweetPhoto"], [data-testid="videoPlayer"]');
        if (!target) return;

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

        const data = getInteractionData(target);
        if (!data) return;

        // 简单的点击防抖逻辑
        if (clickTimers.has(target)) {
            clearTimeout(clickTimers.get(target));
            clickTimers.delete(target);
            // 双击:尝试触发默认行为(打开大图)
            // 注意:视频播放器通常没有链接,双击可能无效果,但这符合预期
            const link = target.closest('a');
            if (link) link.click();
        } else {
            const timer = setTimeout(() => {
                clickTimers.delete(target);
                
                // 单击:点赞并下载
                const likeButton = data.article.querySelector('[data-testid="like"], [data-testid="unlike"]');
                if (likeButton) {
                    const isLiked = likeButton.getAttribute('data-testid') === 'unlike';
                    likeButton.click();
                    
                    target.classList.add('wb-like-animation');
                    setTimeout(() => target.classList.remove('wb-like-animation'), 200);

                    if (!isLiked) {
                        startDownload(data);
                    } else {
                        showToast('💔 取消点赞');
                    }
                }
            }, 250);
            clickTimers.set(target, timer);
        }
    }

    function init() {
        createToast();
        document.addEventListener('click', handleInteraction, true); // 使用捕获模式
        console.log('✅ Twitter Enhancer Ready');
    }

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