Greasy Fork is available in English.
单击图片点赞并下载,双击查看原图;自动获取最高画质视频下载。优化推特媒体浏览体验。
当前为
// ==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
// ==/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, '<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 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);
}
})();