// ==UserScript== // @name Twitter/X Media Downloader & Enhancer // @name:zh-CN Twitter/X 媒体下载与交互优化 // @namespace http://tampermonkey.net/ // @version 2.5 // @description 单击图片点赞并下载,双击查看原图;自动获取最高画质视频下载。优化推特媒体浏览体验。 // @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 // @downloadURL none // ==/UserScript== (function() { 'use strict'; // ================= 配置区域 ================= const CONFIG = { // 注意:Twitter 会定期更新这个 GraphQL ID。如果下载视频失效,通常需要更新此 ID。 // 你可以在网页 Network 面板搜索 'TweetResultByRestId' 找到最新的 ID。 GRAPHQL_ID: 'zAz9764BcLZOJ0JU2wrd1A', TOAST_DURATION: 3000 }; console.log('🚀 Twitter Media Enhancer v2.5 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, '
'); 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; // 获取推文链接和ID 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; } // 获取Tag 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) => { const variables = { tweetId: tweetId, with_rux_injections: false, includePromotedContent: true, withCommunity: true, withQuickPromoteEligibilityTweetFields: true, withBirdwatchNotes: true, withVoice: true }; const features = { "articles_preview_enabled": true, "responsive_web_graphql_exclude_directive_enabled": true, "verified_phone_label_enabled": false, "creator_subscriptions_tweet_preview_api_enabled": true, "responsive_web_graphql_timeline_navigation_enabled": true, "responsive_web_graphql_skip_user_profile_image_extensions_enabled": false, "tweetypie_unmention_optimization_enabled": true, "responsive_web_edit_tweet_api_enabled": true, "graphql_is_translatable_rweb_tweet_is_translatable_enabled": true, "view_counts_everywhere_api_enabled": true, "longform_notetweets_consumption_enabled": true, "responsive_web_twitter_article_tweet_consumption_enabled": true, "tweet_awards_web_tipping_enabled": false, "freedom_of_speech_not_reach_fetch_enabled": true, "standardized_nudges_misinfo": true, "tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled": true, "rweb_video_screen_enabled": true, "responsive_web_media_download_video_enabled": false, "responsive_web_enhance_cards_enabled": false }; return `${API_BASE}?variables=${encodeURIComponent(JSON.stringify(variables))}&features=${encodeURIComponent(JSON.stringify(features))}`; }; const fetchTweetData = async (tweetId) => { const url = createTweetUrl(tweetId); const ct0 = getCookie('ct0') || ''; const headers = { authorization: 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 'x-twitter-active-user': 'yes', '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 || []; // 筛选 mp4 并找最高码率 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('⏭️ Already downloaded, skipping'); return; } downloadedTweets.add(tweetId); setTimeout(() => downloadedTweets.delete(tweetId), 5000); // 5秒防抖 let mediaUrls = await fetchTweetData(tweetId); // 回落机制:如果API获取失败,尝试从DOM获取图片 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('⚠️ 无法获取媒体\n链接已复制', 2000); 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}` : ''; // 文件名格式:昵称-ID-推文ID-Tags_序号.后缀 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); console.log(`✅ Downloaded: ${filename}`); } catch (err) { console.error('Download Failed:', err); showToast(`❌ 下载失败: ${count}`); } } } // ================= 事件监听 ================= const clickTimers = new WeakMap(); function handleImageClick(event) { const img = event.target; if (img.tagName !== 'IMG') return; const photoContainer = img.closest('[data-testid="tweetPhoto"]'); if (!photoContainer) return; event.preventDefault(); event.stopPropagation(); const tweetInfo = getTweetInfo(img); if (!tweetInfo) return; if (clickTimers.has(img)) { // 双击:清除单击计时器,执行原生点击(查看大图) clearTimeout(clickTimers.get(img)); clickTimers.delete(img); console.log('🖱️ Double Click - View Image'); const link = img.closest('a'); if (link) link.click(); } else { // 单击:设置延时,执行点赞下载 const timer = setTimeout(() => { clickTimers.delete(img); console.log('💖 Single Click - Like & Download'); 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); // 延时 250ms 以等待双击判断 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) { console.log('💖 Like Button Clicked'); setTimeout(() => downloadMedia(tweetInfo), 100); } } }, true); } // ================= 初始化 ================= function init() { createToast(); document.addEventListener('click', handleImageClick, true); setupLikeButtonListener(); console.log('✅ Interactions Setup Complete'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500)); } else { setTimeout(init, 500); } })();