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.1
// @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.2 Loaded (Logic Fixed)');

    // ================= 样式注入 =================
    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; }

        /* Toast 提示框 */
        #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;
    }

    // URL 清洗(去参)
    function cleanUrl(url) {
        if (!url) return '';
        return url.split('?')[0];
    }

    // ================= DOM 识别 =================
    // 改进:不仅支持 IMG,还支持视频容器的背景图或 Video 标签
    function getInteractionData(element) {
        // 1. 找到最近的推文容器(最外层)
        const tweetArticle = element.closest('article[data-testid="tweet"]');
        if (!tweetArticle) return null;

        // 2. 获取最外层推文链接(作为 API 入口)
        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;

        // 3. 确定点击的媒体 URL(用于指纹识别)
        let clickedSrc = null;
        if (element.tagName === 'IMG') {
            clickedSrc = element.src;
        } else if (element.tagName === 'VIDEO') {
            clickedSrc = element.poster;
        } else {
            // 尝试找背景图(某些视频播放器)
            const bgStyle = window.getComputedStyle(element).backgroundImage;
            if (bgStyle && bgStyle !== 'none') {
                const bgMatch = bgStyle.match(/url\("?(.+?)"?\)/);
                if (bgMatch) clickedSrc = bgMatch[1];
            }
        }

        // 如果点击的是视频遮罩层,可能需要往里找 img
        if (!clickedSrc) {
             const internalImg = element.querySelector('img');
             if (internalImg) clickedSrc = internalImg.src;
        }

        return {
            id: match[1], // API 入口 ID
            article: tweetArticle,
            clickedSrc: clickedSrc // 指纹
        };
    }

    // ================= 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, clickedSrc) => {
        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 processLogic(data, tweetId, clickedSrc);
        } catch (error) {
            console.error('Fetch Error:', error);
            return null;
        }
    };

    // ================= 核心逻辑:指纹比对与命名决策 =================
    function processLogic(data, rootTweetId, clickedSrc) {
        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 (内层/原作者)
        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. 匹配逻辑:用户到底点了谁?
        const cleanClicked = cleanUrl(clickedSrc);
        
        let target = 'outer'; // 默认为外层
        if (innerEntity && hasMediaMatch(innerEntity.thumbnailUrls, cleanClicked)) {
            target = 'inner';
        } else if (outerEntity && hasMediaMatch(outerEntity.thumbnailUrls, cleanClicked)) {
            target = 'outer';
        } else {
            // 兜底:如果指纹都没匹配上(比如视频封面差异),看谁有媒体
            // 如果外层没媒体,内层有,那大概率点的是内层
            if ((!outerEntity || outerEntity.mediaUrls.length === 0) && (innerEntity && innerEntity.mediaUrls.length > 0)) {
                target = 'inner';
            }
        }

        console.log(`🎯 点击识别: ${target.toUpperCase()}`);

        // 4. 决策逻辑 (执行你的规则)
        
        // 规则 A: 点击了外层
        if (target === 'outer') {
            if (!outerEntity || outerEntity.mediaUrls.length === 0) return null;
            return {
                mediaUrls: outerEntity.mediaUrls,
                naming: { type: 'standalone', user: outerEntity.info }
            };
        }

        // 规则 B & C: 点击了内层
        if (target === 'inner') {
            if (!innerEntity) return null;

            // 判断外层是否有图片
            const outerHasMedia = outerEntity && outerEntity.mediaUrls.length > 0;

            if (outerHasMedia) {
                // 规则 B: 外层有图,内层也有图 -> 视为独立下载内层
                return {
                    mediaUrls: innerEntity.mediaUrls,
                    naming: { type: 'standalone', user: innerEntity.info }
                };
            } else {
                // 规则 C: 外层没图,内层有图 -> 视为转发 (RT)
                return {
                    mediaUrls: innerEntity.mediaUrls,
                    naming: { 
                        type: 'retweet', 
                        user: innerEntity.info, 
                        via: outerEntity ? outerEntity.info : null 
                    }
                };
            }
        }

        return null;
    }

    function hasMediaMatch(urls, clickedClean) {
        if (!urls || !clickedClean) return false;
        // 增加容错:有时 API 返回的 jpg,点击的是 webp,或者 format 参数不同
        return urls.some(u => cleanUrl(u) === clickedClean);
    }

    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 [];
        });

        // 收集所有缩略图用于指纹比对
        const thumbnailUrls = mediaEntities.map(item => item.media_url_https);

        return {
            mediaUrls,
            thumbnailUrls,
            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(interactionData) {
        const { id, article, clickedSrc } = interactionData;
        
        if (downloadedTweets.has(id + clickedSrc)) return; // 细粒度防抖
        downloadedTweets.add(id + clickedSrc);
        setTimeout(() => downloadedTweets.delete(id + clickedSrc), 2000);

        showToast('🔍 正在解析...');
        const result = await fetchTweetData(id, clickedSrc);

        if (!result || result.mediaUrls.length === 0) {
            showToast('❌ 未找到媒体文件');
            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.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.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 = filename.substring(0, MAX_FILENAME_LENGTH - 5) + '.' + 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;

        // 尝试找到内部的媒体元素用于定位
        const mediaElement = target.querySelector('img, video') || target;

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

        const interactionData = getInteractionData(mediaElement);
        if (!interactionData) return;

        if (clickTimers.has(mediaElement)) {
            clearTimeout(clickTimers.get(mediaElement));
            clickTimers.delete(mediaElement);
            // 双击:模拟原生点击 (打开大图)
            const link = target.closest('a');
            if (link) link.click(); 
            else target.click(); // 视频可能没有链接,直接点容器
        } else {
            const timer = setTimeout(() => {
                clickTimers.delete(mediaElement);
                
                // 单击:执行点赞和下载
                const likeButton = interactionData.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(interactionData);
                    } else {
                        showToast('💔 取消点赞');
                    }
                }
            }, 250);
            clickTimers.set(mediaElement, timer);
        }
    }

    function init() {
        createToast();
        // 使用 Capture 阶段 (true) 确保能捕获到,且覆盖更广的元素
        document.addEventListener('click', handleInteraction, true);
        console.log('✅ Twitter Enhancer Ready');
    }

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