Greasy Fork

Greasy Fork is available in English.

Twitter/X 推特媒体下载交互优化(点击图点赞并下载、据id链接标签重命名,指定文件夹)

单击图片点赞并下载,支持自定义文件夹归档(需要开启油猴高级设置在下载选项中打开浏览器api)。双击查看原图。

当前为 2026-01-02 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 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 renaming based on ID, URL, hashtags,Custom Folder)
// @namespace   http://tampermonkey.net/
// @version     4.1
// @description 单击图片点赞并下载,支持自定义文件夹归档(需要开启油猴高级设置在下载选项中打开浏览器api)。双击查看原图。
// @description:en Click images to like & download with auto-renaming and custom folder support (Requires enabling "Browser API" in Tampermonkey settings)
// @author      Gemini (Based on previous versions)
// @match       https://x.com/*
// @match       https://twitter.com/*
// @icon        https://www.google.com/s2/favicons?sz=64&domain=x.com
// @grant       GM_addStyle
// @grant       GM_download
// @grant       GM_info
// @license     MIT
// @run-at      document-start
// ==/UserScript==

(function() {
    'use strict';

    // ================= CONFIGURATION / 配置区域 =================

    // 1. Enable folder organization? (true: ON, false: OFF)
    //    If enabled, files will be saved to: Browser Default Download Folder / Your Folder Name /
    // 1. 是否开启文件夹归档功能? (true: 开启, false: 关闭)
    //    开启后,文件会下载到:浏览器默认下载目录 / 你的文件夹名 / 下
    const ENABLE_FOLDER = true;

    // 2. Custom folder name (Supports English and Chinese)
    //    Example: 'Twitter_Images'
    //    Note: Do not use special characters like \ / : * ? " < > |
    // 2. 自定义文件夹名称 (支持中文)
    //    注意:不要包含 \ / : * ? " < > | 等特殊符号
    const FOLDER_NAME = 'twitter';

    // 3. API Configuration (Do not modify unless Twitter updates)
    // 3. API 配置 (无需修改,除非推特更新了接口)
    const GRAPHQL_ID = 'zAz9764BcLZOJ0JU2wrd1A';
    const API_BASE = `https://x.com/i/api/graphql/${GRAPHQL_ID}/TweetResultByRestId`;
    const MAX_FILENAME_LENGTH = 200;

    // ================= END CONFIGURATION / 配置结束 =================

    console.log(`🚀 Twitter Enhancer v4.1 Loaded | Folder Mode: ${ENABLE_FOLDER ? 'ON (' + FOLDER_NAME + ')' : 'OFF'}`);

    // ================= 样式注入 (Styles) =================
    GM_addStyle(`
        /* Cursor pointer for images */
        [data-testid="tweetPhoto"] img {
            cursor: pointer !important;
            transition: transform 0.2s !important;
        }
        [data-testid="tweetPhoto"] img:hover {
            transform: scale(1.02);
        }

        /* Like Animation */
        @keyframes likeAnimation { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } }
        .wb-like-animation { animation: likeAnimation 0.3s ease !important; }

        /* Toast Notification */
        #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; } }
    `);

    // ================= 工具函数 (Utils) =================
    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;
        let mainId = null;

        for (const link of links) {
            const href = link.getAttribute('href');
            if (href && href.includes('/status/') && !href.includes('/i/status/')) {
                tweetUrl = 'https://x.com' + href;
                const match = href.match(/\/status\/(\d+)/);
                if (match) mainId = match[1];
                break;
            }
        }

        const sourceLink = tweetArticle.querySelector('a[href*="/i/status/"]');
        let sourceId = null;
        if (sourceLink) {
            const sourceMatch = sourceLink.getAttribute('href').match(/\/status\/(\d+)/);
            if (sourceMatch) {
                sourceId = sourceMatch[1];
            }
        }

        if (!mainId) return null;

        return {
            id: mainId,
            sourceId: sourceId,
            article: tweetArticle,
            fallbackUrl: tweetUrl
        };
    }

    // ================= API Logic =================
    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);
        // Using common public bearer token for Twitter Web Client
        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;
        }
    };

    const extractMedia = (legacy) => {
        if (!legacy) return [];
        const mediaEntities = legacy.extended_entities?.media || legacy.entities?.media || [];

        return 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');
                if (mp4s.length === 0) return [];
                if (item.type === 'animated_gif') {
                    return mp4s[0].url ? [mp4s[0].url] : [];
                } else {
                    mp4s.sort((a, b) => (b.bitrate || 0) - (a.bitrate || 0));
                    return mp4s[0].url ? [mp4s[0].url] : [];
                }
            }
            return [];
        });
    };

    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;

        const outerCore = rootTweet.core || rootTweet.tweet?.core;
        const outerLegacy = rootTweet.legacy || rootTweet.tweet?.legacy;

        if (outerLegacy && outerCore) {
            const outerMedia = extractMedia(outerLegacy);
            if (outerMedia.length > 0) {
                return {
                    mediaUrls: outerMedia,
                    via: null,
                    origin: {
                        nick: outerCore.user_results?.result?.legacy?.name || 'unknown',
                        id: outerCore.user_results?.result?.legacy?.screen_name || 'unknown',
                        tweetId: outerLegacy.id_str || inputTweetId,
                        hashtags: (outerLegacy.entities?.hashtags || []).map(t => t.text).join('-')
                    }
                };
            }
        }

        let innerTweet = null;
        if (rootTweet.legacy && rootTweet.legacy.retweeted_status_result) {
            innerTweet = rootTweet.legacy.retweeted_status_result.result;
        } else if (rootTweet.quoted_status_result) {
            innerTweet = rootTweet.quoted_status_result.result;
        }

        if (innerTweet) {
            const innerCore = innerTweet.core || innerTweet.tweet?.core;
            const innerLegacy = innerTweet.legacy || innerTweet.tweet?.legacy;
            if (innerCore && innerLegacy) {
                const innerMedia = extractMedia(innerLegacy);
                if (innerMedia.length > 0) {
                    return {
                        mediaUrls: innerMedia,
                        via: {
                            nick: outerCore?.user_results?.result?.legacy?.name || 'unknown',
                            id: outerCore?.user_results?.result?.legacy?.screen_name || 'unknown',
                            tweetId: outerLegacy?.id_str || inputTweetId
                        },
                        origin: {
                            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('-')
                        }
                    };
                }
            }
        }
        return null;
    };

    // ================= Download Logic =================
    async function downloadMedia(domData) {
        const { id: domTweetId, sourceId } = domData;
        const targetId = sourceId || domTweetId;

        if (downloadedTweets.has(targetId)) return;
        downloadedTweets.add(targetId);
        setTimeout(() => downloadedTweets.delete(targetId), 5000);

        showToast('🔍 Analyzing...');
        const apiResult = await fetchTweetData(targetId);

        if (!apiResult || apiResult.mediaUrls.length === 0) {
            showToast('⚠️ No media found');
            return;
        }

        const { mediaUrls, via, origin } = apiResult;
        showToast(`📥 Downloading ${mediaUrls.length} items...`);

        let count = 0;
        for (const url of mediaUrls) {
            count++;
            const ext = url.includes('.mp4') ? 'mp4' : 'jpg';
            const indexStr = mediaUrls.length > 1 ? `_${count}` : '';

            let prefix = '';
            if (via && !sourceId) {
                const safeViaNick = sanitize(via.nick);
                prefix = `RT [${safeViaNick}-${via.id}] - `;
            }

            const safeOrgNick = sanitize(origin.nick);
            const tagStr = origin.hashtags ? `-${origin.hashtags}` : '';

            let filename = `${prefix}[${safeOrgNick}-${origin.id}-${origin.tweetId}${tagStr}]${indexStr}.${ext}`;

            if (filename.length > MAX_FILENAME_LENGTH) {
                filename = `${prefix}[${safeOrgNick}-${origin.id}-${origin.tweetId}]${indexStr}.${ext}`;
            }

            triggerDownload(url, filename);
        }
    }

    function sanitize(str) {
        return str.replace(/[\\/:*?"<>|]/g, '_').substring(0, 30);
    }

    // ⬇️ Core Download Function using GM_download
    function triggerDownload(url, filename) {
        let finalPath = filename;

        // Apply folder path if enabled
        if (ENABLE_FOLDER && FOLDER_NAME) {
            finalPath = `${FOLDER_NAME}/${filename}`;
        }

        // Use GM_download if available
        if (typeof GM_download === 'function') {
            GM_download({
                url: url,
                name: finalPath,
                saveAs: false,
                onerror: (err) => {
                    console.error('GM_download Error:', err);
                    showToast('❌ GM_download Error (Check Tampermonkey Settings)');
                    fallbackDownload(url, filename);
                },
                onload: () => {
                    // console.log('Download complete');
                }
            });
        } else {
            // Fallback for missing permissions or incompatible managers
            fallbackDownload(url, filename);
        }
    }

    // Fallback method (No folder support)
    function fallbackDownload(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('Fallback Download Failed', err);
            showToast('❌ Download Failed');
        });
    }

    // ================= Event Listeners =================
    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('💔 Like Removed');
                }
            }, 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();
    }

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