Greasy Fork is available in English.
单击图片点赞并下载,双击查看原图;自动获取最高画质视频下载。优化推特媒体浏览体验。
当前为
// ==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.1
// @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.2 Loaded (Logic Fixed)');
// ================= 样式注入 =================
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; }
/* Toast 提示框 */
#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;
}
// URL 清洗(去参)
function cleanUrl(url) {
if (!url) return '';
return url.split('?')[0];
}
// ================= DOM 识别 =================
// 改进:不仅支持 IMG,还支持视频容器的背景图或 Video 标签
function getInteractionData(element) {
// 1. 找到最近的推文容器(最外层)
const tweetArticle = element.closest('article[data-testid="tweet"]');
if (!tweetArticle) return null;
// 2. 获取最外层推文链接(作为 API 入口)
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;
// 3. 确定点击的媒体 URL(用于指纹识别)
let clickedSrc = null;
if (element.tagName === 'IMG') {
clickedSrc = element.src;
} else if (element.tagName === 'VIDEO') {
clickedSrc = element.poster;
} else {
// 尝试找背景图(某些视频播放器)
const bgStyle = window.getComputedStyle(element).backgroundImage;
if (bgStyle && bgStyle !== 'none') {
const bgMatch = bgStyle.match(/url\("?(.+?)"?\)/);
if (bgMatch) clickedSrc = bgMatch[1];
}
}
// 如果点击的是视频遮罩层,可能需要往里找 img
if (!clickedSrc) {
const internalImg = element.querySelector('img');
if (internalImg) clickedSrc = internalImg.src;
}
return {
id: match[1], // API 入口 ID
article: tweetArticle,
clickedSrc: clickedSrc // 指纹
};
}
// ================= 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, clickedSrc) => {
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 processLogic(data, tweetId, clickedSrc);
} catch (error) {
console.error('Fetch Error:', error);
return null;
}
};
// ================= 核心逻辑:指纹比对与命名决策 =================
function processLogic(data, rootTweetId, clickedSrc) {
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 (内层/原作者)
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. 匹配逻辑:用户到底点了谁?
const cleanClicked = cleanUrl(clickedSrc);
let target = 'outer'; // 默认为外层
if (innerEntity && hasMediaMatch(innerEntity.thumbnailUrls, cleanClicked)) {
target = 'inner';
} else if (outerEntity && hasMediaMatch(outerEntity.thumbnailUrls, cleanClicked)) {
target = 'outer';
} else {
// 兜底:如果指纹都没匹配上(比如视频封面差异),看谁有媒体
// 如果外层没媒体,内层有,那大概率点的是内层
if ((!outerEntity || outerEntity.mediaUrls.length === 0) && (innerEntity && innerEntity.mediaUrls.length > 0)) {
target = 'inner';
}
}
console.log(`🎯 点击识别: ${target.toUpperCase()}`);
// 4. 决策逻辑 (执行你的规则)
// 规则 A: 点击了外层
if (target === 'outer') {
if (!outerEntity || outerEntity.mediaUrls.length === 0) return null;
return {
mediaUrls: outerEntity.mediaUrls,
naming: { type: 'standalone', user: outerEntity.info }
};
}
// 规则 B & C: 点击了内层
if (target === 'inner') {
if (!innerEntity) return null;
// 判断外层是否有图片
const outerHasMedia = outerEntity && outerEntity.mediaUrls.length > 0;
if (outerHasMedia) {
// 规则 B: 外层有图,内层也有图 -> 视为独立下载内层
return {
mediaUrls: innerEntity.mediaUrls,
naming: { type: 'standalone', user: innerEntity.info }
};
} else {
// 规则 C: 外层没图,内层有图 -> 视为转发 (RT)
return {
mediaUrls: innerEntity.mediaUrls,
naming: {
type: 'retweet',
user: innerEntity.info,
via: outerEntity ? outerEntity.info : null
}
};
}
}
return null;
}
function hasMediaMatch(urls, clickedClean) {
if (!urls || !clickedClean) return false;
// 增加容错:有时 API 返回的 jpg,点击的是 webp,或者 format 参数不同
return urls.some(u => cleanUrl(u) === clickedClean);
}
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 [];
});
// 收集所有缩略图用于指纹比对
const thumbnailUrls = mediaEntities.map(item => item.media_url_https);
return {
mediaUrls,
thumbnailUrls,
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(interactionData) {
const { id, article, clickedSrc } = interactionData;
if (downloadedTweets.has(id + clickedSrc)) return; // 细粒度防抖
downloadedTweets.add(id + clickedSrc);
setTimeout(() => downloadedTweets.delete(id + clickedSrc), 2000);
showToast('🔍 正在解析...');
const result = await fetchTweetData(id, clickedSrc);
if (!result || result.mediaUrls.length === 0) {
showToast('❌ 未找到媒体文件');
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.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.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 = filename.substring(0, MAX_FILENAME_LENGTH - 5) + '.' + 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;
// 尝试找到内部的媒体元素用于定位
const mediaElement = target.querySelector('img, video') || target;
event.preventDefault();
event.stopPropagation();
const interactionData = getInteractionData(mediaElement);
if (!interactionData) return;
if (clickTimers.has(mediaElement)) {
clearTimeout(clickTimers.get(mediaElement));
clickTimers.delete(mediaElement);
// 双击:模拟原生点击 (打开大图)
const link = target.closest('a');
if (link) link.click();
else target.click(); // 视频可能没有链接,直接点容器
} else {
const timer = setTimeout(() => {
clickTimers.delete(mediaElement);
// 单击:执行点赞和下载
const likeButton = interactionData.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(interactionData);
} else {
showToast('💔 取消点赞');
}
}
}, 250);
clickTimers.set(mediaElement, timer);
}
}
function init() {
createToast();
// 使用 Capture 阶段 (true) 确保能捕获到,且覆盖更广的元素
document.addEventListener('click', handleInteraction, true);
console.log('✅ Twitter Enhancer Ready');
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => setTimeout(init, 500));
else setTimeout(init, 500);
})();