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      2.7
// @description  单击图片点赞并下载,双击查看原图;自动获取最高画质视频下载。优化推特媒体浏览体验。
// @description:en Click images to like & download with auto-renaming based on ID, tweet URL, and hashtags.
// @author       你的名字
// @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 = {
        // 原始有效的 Query ID
        GRAPHQL_ID: 'zAz9764BcLZOJ0JU2wrd1A',
        TOAST_DURATION: 3000
    };

    console.log('🚀 Twitter Media Enhancer v2.6 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;

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

        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) => {
        // 必须包含完整的 variables
        const variables = {
            tweetId: tweetId,
            with_rux_injections: false,
            rankingMode: 'Relevance',
            includePromotedContent: true,
            withCommunity: true,
            withQuickPromoteEligibilityTweetFields: true,
            withBirdwatchNotes: true,
            withVoice: true
        };

        // 必须包含完整的 features,否则 API 会报错
        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
        };

        // 必须包含 fieldToggles (之前漏了这里!)
        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 ct0 = getCookie('ct0') || '';
        const headers = {
            authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
            'x-twitter-active-user': 'yes',
            'x-twitter-client-language': getCookie('lang') || 'en',
            '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 || [];
                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('⏭️ Skipped'); return; }
        downloadedTweets.add(tweetId);
        setTimeout(() => downloadedTweets.delete(tweetId), 5000);

        let mediaUrls = await fetchTweetData(tweetId);

        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('⚠️ 无法获取媒体,链接已复制');
                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}` : '';
            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);
            } catch (err) {
                console.error('DL Failed', err);
            }
        }
    }

    const clickTimers = new WeakMap();
    function handleImageClick(event) {
        const img = event.target;
        if (img.tagName !== 'IMG' || !img.closest('[data-testid="tweetPhoto"]')) return;
        event.preventDefault(); event.stopPropagation();
        const tweetInfo = getTweetInfo(img);
        if (!tweetInfo) return;

        if (clickTimers.has(img)) {
            clearTimeout(clickTimers.get(img));
            clickTimers.delete(img);
            const link = img.closest('a');
            if (link) link.click();
        } else {
            const timer = setTimeout(() => {
                clickTimers.delete(img);
                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);
            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) setTimeout(() => downloadMedia(tweetInfo), 100);
            }
        }, true);
    }

    function init() {
        createToast();
        document.addEventListener('click', handleImageClick, true);
        setupLikeButtonListener();
        console.log('✅ Twitter Enhancer Ready');
    }

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