您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download images and videos from Twitter posts and pack them into a ZIP archive with metadata.
当前为
// ==UserScript== // @name Twitter Media Downloader // @namespace http://tampermonkey.net/ // @version 1.7 // @description Download images and videos from Twitter posts and pack them into a ZIP archive with metadata. // @author Dramorian // @license MIT // @match https://twitter.com/* // @match https://x.com/* // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js // ==/UserScript== (function () { 'use strict'; const BASE_URL = 'https://x.com/i/api/graphql/QuBlQ6SxNAQCt6-kBiCXCQ/TweetDetail'; function getCookie() { const cookies = document.cookie.split(';').reduce((acc, cookie) => { const [name, value] = cookie.split('=').map(part => part.trim()); if (name) { acc[name] = value || ''; // Ensure empty string if value is undefined } return acc; }, {}); return { lang: cookies.lang || 'en', // Default to 'en' if lang is not present ct0: cookies.ct0 || '' // Default to empty string if ct0 is not present }; } async function fetchTweetData(tweetId) { try { const url = buildTweetDataURL(tweetId); const headers = buildHeaders(getCookie()); const response = await fetch(url, {method: 'GET', headers}); if (!response.ok) { await logErrorResponse(response); return []; } const data = await response.json(); return extractMediaFromTweetData(data, tweetId); } catch (error) { console.error('Failed to fetch tweet data:', error); return []; } } function buildTweetDataURL(tweetId) { const variables = { focalTweetId: tweetId, with_rux_injections: false, rankingMode: "Relevance", includePromotedContent: true, withCommunity: true, withQuickPromoteEligibilityTweetFields: true, withBirdwatchNotes: true, withVoice: true }; const features = { rweb_tipjar_consumption_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, communities_web_enable_tweet_community_results_fetch: true, c9s_tweet_anatomy_moderator_badge_enabled: true, articles_preview_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, creator_subscriptions_quote_tweet_preview_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_timestamps_enabled: true, longform_notetweets_rich_text_read_enabled: true, longform_notetweets_inline_media_enabled: true, responsive_web_enhance_cards_enabled: false }; const fieldToggles = { withArticleRichContentState: true, withArticlePlainText: false, withGrokAnalyze: false, withDisallowedReplyControls: false }; return encodeURI(`${BASE_URL}?variables=${JSON.stringify(variables)}&features=${JSON.stringify(features)}&fieldToggles=${JSON.stringify(fieldToggles)}`); } function buildHeaders(cookies) { return { 'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA', 'x-twitter-active-user': 'yes', 'x-twitter-client-language': cookies.lang, 'x-csrf-token': cookies.ct0 }; } async function logErrorResponse(response) { const text = await response.text(); console.error(`Error: ${text}`); } function extractMediaFromTweetData(data, tweetId) { const tweetEntry = data.data?.threaded_conversation_with_injections_v2?.instructions?.[0]?.entries?.find(n => n.entryId === `tweet-${tweetId}`); const tweetResult = tweetEntry?.content?.itemContent?.tweet_results?.result; if (!tweetResult) { console.error('Tweet result not found'); return []; } const media = tweetResult.legacy?.entities?.media; if (!media || media.length === 0) { return []; } return media.flatMap(item => { if (item.type === 'photo') { return [item.media_url_https + '?name=orig']; } else if (item.type === 'video') { return extractHighestQualityVideo(item); } else if (item.type === 'animated_gif') { return extractGifVariant(item); } return []; }); } function extractHighestQualityVideo(item) { const highestQuality = item.video_info.variants .filter(variant => variant.content_type === 'video/mp4') .reduce((max, variant) => variant.bitrate > max.bitrate ? variant : max, {bitrate: 0}); return [{ url: highestQuality.url, bitrate: highestQuality.bitrate, content_type: highestQuality.content_type }]; } function extractGifVariant(item) { const gifVariant = item.video_info.variants .find(variant => variant.content_type === 'video/mp4'); return gifVariant ? [{ url: gifVariant.url, bitrate: gifVariant.bitrate, content_type: gifVariant.content_type }] : []; } async function downloadMedia(tweetElement, mediaData) { const zip = new JSZip(); const tweetLinkElement = tweetElement.querySelector('a[href*="/status/"]'); const {authorHandle, tweetId, tweetLink} = extractTweetDetails(tweetLinkElement); const metadata = buildMetadata(tweetElement, tweetLink, authorHandle); await addMediaToZip(zip, mediaData, authorHandle, tweetId, metadata); await addMetadataAndSaveZip(zip, metadata, authorHandle, tweetId, tweetLink); } function extractTweetDetails(tweetLinkElement) { const tweetLink = tweetLinkElement.href; const tweetParts = tweetLink.match(/https:\/\/(?:x\.com|twitter\.com)\/([^\/]+)\/status\/(\d+)/); const authorHandle = tweetParts[1]; const tweetId = tweetParts[2]; return { authorHandle, tweetId, tweetLink: `https://x.com/${authorHandle}/status/${tweetId}` }; } function buildMetadata(tweetElement, tweetLink, authorHandle) { const authorCommentElement = tweetElement.querySelector('div[lang]'); const authorComment = authorCommentElement ? authorCommentElement.innerText : ''; const dateElement = tweetElement.querySelector('time'); const postDateTime = dateElement ? new Date(dateElement.getAttribute('datetime')) : new Date(); let metadata = `${tweetLink}\n`; if (authorComment) { metadata += `${authorComment}\n`; } metadata += `@${authorHandle}\n${postDateTime.toLocaleString()}\n`; return metadata; } async function addMediaToZip(zip, mediaData, authorHandle, tweetId, metadata) { let mediaIndex = 1; for (const media of mediaData) { try { const {fileName, fileData} = await fetchMediaFile(media, authorHandle, tweetId, mediaIndex); zip.file(fileName, fileData); metadata += `${media.url || media}\n`; mediaIndex++; } catch (error) { console.error('Failed to fetch media:', error); } } } async function fetchMediaFile(media, authorHandle, tweetId, mediaIndex) { let fileName, fileData; if (media.content_type === 'video/mp4') { fileData = await fetch(media.url).then(res => res.blob()); fileName = `${authorHandle}_${tweetId}_${mediaIndex}.mp4`; } else { fileData = await fetch(media).then(res => res.blob()); fileName = `${authorHandle}_${tweetId}_${mediaIndex}.jpg`; } return {fileName, fileData}; } async function addMetadataAndSaveZip(zip, metadata, authorHandle, tweetId, tweetLink) { zip.file('metadata.txt', metadata.trim()); zip.file(`${authorHandle}_${tweetId}.url`, `[InternetShortcut]\nURL=${tweetLink}`); const content = await zip.generateAsync({type: 'blob'}); saveAs(content, `${authorHandle}_${tweetId}.zip`); } function addDownloadButton(tweetElement) { const tweetLinkElement = getTweetLinkElement(tweetElement); if (!tweetLinkElement) return; const tweetId = extractTweetId(tweetLinkElement); if (!tweetId || !hasMedia(tweetElement)) return; if (isTweetProcessed(tweetId) || tweetElement.querySelector('.download-media-btn')) return; const button = createDownloadButton(tweetId); attachButtonToActionBar(tweetElement, button); } function getTweetLinkElement(tweetElement) { return tweetElement.querySelector('a[href*="/status/"]'); } function extractTweetId(tweetLinkElement) { const tweetLink = tweetLinkElement.href; const tweetParts = tweetLink.match(/https:\/\/(?:x\.com|twitter\.com)\/([^\/]+)\/status\/(\d+)/); return tweetParts ? tweetParts[2] : null; } function hasMedia(tweetElement) { const mediaSelectors = [ 'a[href*="/photo/1"]', 'div[role="progressbar"]', 'button[data-testid="playButton"]', ]; return mediaSelectors.some(selector => tweetElement.querySelector(selector)); } function isTweetProcessed(tweetId) { const processedTweets = JSON.parse(localStorage.getItem('processedTweets') || '[]'); return processedTweets.includes(tweetId); } function createDownloadButton(tweetId) { const button = document.createElement('button'); button.className = 'download-media-btn'; Object.assign(button.style, { marginLeft: '10px', padding: '6px', border: 'none', backgroundColor: 'transparent', cursor: 'pointer', }); const isProcessed = isTweetProcessed(tweetId); button.innerHTML = createButtonSVG(isProcessed); button.dataset.tweetId = tweetId; button.addEventListener('click', () => onDownloadButtonClick(button)); return button; } function createButtonSVG(isProcessed) { const strokeColor = isProcessed ? '#28a745' : '#1da1f2'; return ` <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <polyline points="7 10 12 15 17 10"></polyline> <line x1="12" y1="15" x2="12" y2="3"></line> </svg> `; } function attachButtonToActionBar(tweetElement, button) { const actionBar = tweetElement.querySelector('[role="group"]'); if (actionBar) { actionBar.appendChild(button); } } async function onDownloadButtonClick(button) { const tweetId = button.dataset.tweetId; // Log and show loading animation console.log(`Fetching media for tweetId: ${tweetId}`); showLoadingAnimation(button); // Optionally disable the button during fetch button.disabled = true; try { const mediaData = await fetchTweetData(tweetId); if (mediaData.length === 0) { console.warn('No media found for this tweet.'); return; } const tweetElement = button.closest('article'); if (tweetElement) { await downloadMedia(tweetElement, mediaData); markTweetAsProcessed(tweetId); updateButtonIcon(button, true); } } catch (error) { console.error('Failed to fetch or download media:', error); } finally { // Re-enable the button even if there was an error button.disabled = false; } } function showLoadingAnimation(button) { button.innerHTML = ` <svg width="24" height="24" viewBox="0 0 120 30" xmlns="http://www.w3.org/2000/svg" fill="#1da1f2"> <circle cx="15" cy="15" r="15"> <animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s" values="15;9;15" calcMode="linear" repeatCount="indefinite" /> <animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s" values="1;.5;1" calcMode="linear" repeatCount="indefinite" /> </circle> <circle cx="60" cy="15" r="9" fill-opacity="0.3"> <animate attributeName="r" from="9" to="9" begin="0s" dur="0.8s" values="9;15;9" calcMode="linear" repeatCount="indefinite" /> <animate attributeName="fill-opacity" from="0.5" to="0.5" begin="0s" dur="0.8s" values=".5;1;.5" calcMode="linear" repeatCount="indefinite" /> </circle> <circle cx="105" cy="15" r="15"> <animate attributeName="r" from="15" to="15" begin="0s" dur="0.8s" values="15;9;15" calcMode="linear" repeatCount="indefinite" /> <animate attributeName="fill-opacity" from="1" to="1" begin="0s" dur="0.8s" values="1;.5;1" calcMode="linear" repeatCount="indefinite" /> </circle> </svg> `; } function updateButtonIcon(button, isProcessed) { const strokeColor = isProcessed ? '#28a745' : '#1da1f2'; button.innerHTML = ` <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${strokeColor}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-download"> <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> <polyline points="7 10 12 15 17 10"></polyline> <line x1="12" y1="15" x2="12" y2="3"></line> </svg> `; } function markTweetAsProcessed(tweetId) { const processedTweets = JSON.parse(localStorage.getItem('processedTweets') || '[]'); if (!processedTweets.includes(tweetId)) { processedTweets.push(tweetId); localStorage.setItem('processedTweets', JSON.stringify(processedTweets)); } } const observer = new MutationObserver(handleMutations); function handleMutations(mutations) { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { processAddedNode(node); } }); }); } function processAddedNode(node) { // Check if the node itself is a tweet element or contains tweet elements const tweetElements = node.matches('article') ? [node] : node.querySelectorAll('article'); tweetElements.forEach(tweetElement => addDownloadButton(tweetElement)); } observer.observe(document.body, { childList: true, subtree: true }); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址