// ==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.2 // @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 // @downloadURL none // ==/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.3 Loaded (Video Fix)'); // ================= 样式注入 ================= 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; } #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, '
'); 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; } // ================= DOM 交互识别 ================= function getInteractionData(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; return { id: match[1], article: tweetArticle, url: tweetUrl }; } // ================= 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 parseSmart(data, tweetId); } catch (error) { console.error('Fetch Error:', error); return null; } }; // ================= 核心解析逻辑 (简化版) ================= function parseSmart(data, rootTweetId) { 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) - 无论是 Retweet 还是 Quote 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. 决策逻辑 // 规则:如果外层有媒体,优先下载外层(视为独立推文) if (outerEntity && outerEntity.mediaUrls.length > 0) { console.log('🎯 命中外层媒体 (独立推文)'); return { mediaUrls: outerEntity.mediaUrls, naming: { type: 'standalone', user: outerEntity.info } }; } // 规则:如果外层无媒体,但内层有媒体,下载内层(视为转发) if (innerEntity && innerEntity.mediaUrls.length > 0) { console.log('🎯 命中内层媒体 (转发推文)'); return { mediaUrls: innerEntity.mediaUrls, naming: { type: 'retweet', user: innerEntity.info, via: outerEntity ? outerEntity.info : null } }; } return null; } 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 []; }); return { mediaUrls, 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(data) { const { id, article } = data; if (downloadedTweets.has(id)) return; downloadedTweets.add(id); setTimeout(() => downloadedTweets.delete(id), 3000); showToast('🔍 解析媒体中...'); const result = await fetchTweetData(id); if (!result || result.mediaUrls.length === 0) { // 视频下载失败回落提示 showToast('❌ 无效的媒体\n可能因为 API 变动'); 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 || 'unknown').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 || 'unknown').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 = `${safeNick}-${user.id}-${user.tweetId}${indexStr}.${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; event.preventDefault(); event.stopPropagation(); const data = getInteractionData(target); if (!data) return; // 简单的点击防抖逻辑 if (clickTimers.has(target)) { clearTimeout(clickTimers.get(target)); clickTimers.delete(target); // 双击:尝试触发默认行为(打开大图) // 注意:视频播放器通常没有链接,双击可能无效果,但这符合预期 const link = target.closest('a'); if (link) link.click(); } else { const timer = setTimeout(() => { clickTimers.delete(target); // 单击:点赞并下载 const likeButton = data.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(data); } else { showToast('💔 取消点赞'); } } }, 250); clickTimers.set(target, timer); } } function init() { createToast(); document.addEventListener('click', handleInteraction, true); // 使用捕获模式 console.log('✅ Twitter Enhancer Ready'); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500)); else setTimeout(init, 500); })();