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.9
// @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.0 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 = 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;
    }

    function getTweetIdFromDom(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+)/);
        return match ? { id: match[1], article: tweetArticle, fallbackUrl: tweetUrl } : null;
    }

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

    // ================= 核心:多层级解析 (v3.0) =================
    const parseTweetData = (data, inputTweetId) => {
        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-${inputTweetId}`);
            rootTweet = tweetEntry?.content?.itemContent?.tweet_results?.result;
        }
        if (!rootTweet) return null;

        // 1. 获取“外层”作者信息 (展示者/引用者)
        const outerCore = rootTweet.core || rootTweet.tweet?.core;
        const outerLegacy = rootTweet.legacy || rootTweet.tweet?.legacy;

        if (!outerCore || !outerLegacy) return null;

        const outerInfo = {
            nick: outerCore.user_results?.result?.legacy?.name || 'unknown',
            id: outerCore.user_results?.result?.legacy?.screen_name || 'unknown',
            tweetId: outerLegacy.id_str || inputTweetId
        };

        // 2. 寻找“内层”推文 (媒体源头)
        let innerTweet = rootTweet;
        let isLayered = false; // 标记是否有转推/引用关系

        if (rootTweet.legacy && rootTweet.legacy.retweeted_status_result) {
            innerTweet = rootTweet.legacy.retweeted_status_result.result;
            isLayered = true;
            console.log('🔄 识别为转推 (Retweet)');
        } else if (rootTweet.quoted_status_result) {
            innerTweet = rootTweet.quoted_status_result.result;
            isLayered = true;
            console.log('💬 识别为引用 (Quote)');
        }

        // 3. 获取“内层”作者与媒体信息
        const innerCore = innerTweet.core || innerTweet.tweet?.core;
        const innerLegacy = innerTweet.legacy || innerTweet.tweet?.legacy;

        if (!innerCore || !innerLegacy) return null; // 内层推文可能已被删除或受限

        const innerInfo = {
            nick: innerCore.user_results?.result?.legacy?.name || 'unknown',
            id: innerCore.user_results?.result?.legacy?.screen_name || 'unknown',
            tweetId: innerLegacy.id_str || inputTweetId,
            hashtags: (innerLegacy.entities?.hashtags || []).map(t => t.text).join('-')
        };

        // 提取媒体
        const mediaEntities = innerLegacy.extended_entities?.media || innerLegacy.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 [];
        });

        if (mediaUrls.length === 0) return null;

        return {
            mediaUrls,
            // 如果外层ID和内层ID不同,说明存在引用/转推关系
            via: (isLayered && outerInfo.id !== innerInfo.id) ? outerInfo : null,
            origin: innerInfo
        };
    };

    // ================= 下载逻辑 =================
    async function downloadMedia(domData) {
        const { id: domTweetId, article, fallbackUrl } = domData;
        if (downloadedTweets.has(domTweetId)) return;
        downloadedTweets.add(domTweetId);
        setTimeout(() => downloadedTweets.delete(domTweetId), 5000);

        showToast('🔍 分析推文来源...');
        const apiResult = await fetchTweetData(domTweetId);

        // 降级方案
        if (!apiResult || apiResult.mediaUrls.length === 0) {
            console.warn('⚠️ API 失败,DOM 降级');
            const images = article.querySelectorAll('[data-testid="tweetPhoto"] img');
            const urls = Array.from(images).map(img => img.src.replace(/\?.*$/, '') + '?format=jpg&name=orig');
            if (urls.length === 0) {
                showToast('❌ 无媒体\n链接已复制');
                navigator.clipboard.writeText(fallbackUrl);
                return;
            }
            showToast(`⚠️ 降级下载 ${urls.length} 张图片...`);
            urls.forEach((url, i) => triggerDownload(url, `twitter_img_${domTweetId}_${i}.jpg`));
            return;
        }

        // 成功方案:构建文件名
        const { mediaUrls, via, origin } = apiResult;
        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}` : '';

            // 1. 构建前缀 (Via 部分)
            let prefix = '';
            if (via) {
                const safeViaNick = sanitize(via.nick);
                prefix = `RT [${safeViaNick}-${via.id}] - `;
            }

            // 2. 构建主体 (Original 部分)
            const safeOrgNick = sanitize(origin.nick);
            const tagStr = origin.hashtags ? `-${origin.hashtags}` : '';

            // 3. 组合文件名
            // 格式: RT [转发者-ID] - [原作者-ID-推文ID-标签]
            let filename = `${prefix}[${safeOrgNick}-${origin.id}-${origin.tweetId}${tagStr}]${indexStr}.${ext}`;

            // 4. 长度安全检查 (如果太长,裁切标签部分)
            if (filename.length > MAX_FILENAME_LENGTH) {
                console.log('⚠️ 文件名过长,执行截断:', filename);
                // 保留核心ID部分,丢弃过长的标签
                const baseName = `${prefix}[${safeOrgNick}-${origin.id}-${origin.tweetId}]${indexStr}.${ext}`;
                filename = baseName;
            }

            triggerDownload(url, filename);
        }
    }

    // 清理非法字符
    function sanitize(str) {
        return str.replace(/[\\/:*?"<>|]/g, '_').substring(0, 30); // 限制名字最长30字符
    }

    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('Download Failed', err);
            showToast('❌ 下载失败');
        });
    }

    // ================= 事件监听 =================
    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 domData = getTweetIdFromDom(img);
        if (!domData) 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 = domData.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(domData);
                    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 domData = getTweetIdFromDom(likeButton);
                if (domData) setTimeout(() => downloadMedia(domData), 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);
})();