您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Greasy Fork is available in English.
Download twitter videos directly from your browser! (CLICK "ALWAYS ALLOW" IF PROMPTED!)
// ==UserScript== // @name Twitter DL - Click "Always Allow"! // @version 1.1.2 // @description Download twitter videos directly from your browser! (CLICK "ALWAYS ALLOW" IF PROMPTED!) // @author realcoloride // @license MIT // @namespace https://twitter.com/* // @namespace https://x.com/* // @match https://twitter.com/* // @match https://x.com/* // @match https://pro.twitter.com/* // @match https://pro.x.com/* // @connect twitter-video-download.com // @connect twimg.com // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com // @grant GM.xmlHttpRequest // ==/UserScript== (function() { let injectedTweets = []; const checkFrequency = 150; // in milliseconds const apiEndpoint = "https://twitter-video-download.com/fr/tweet/"; const downloadText = "Download" const style = `.dl-video { padding: 6px; padding-left: 5px; padding-right: 5px; margin-left: 5px; margin-bottom: 2px; border-color: black; border-style: none; border-radius: 10px; color: white; background-color: rgba(39, 39, 39, 0.46); font-family: Arial, Helvetica, sans-serif; font-size: xx-small; cursor: pointer; } .dl-hq { background-color: rgba(28, 199, 241, 0.46); } .dl-lq { background-color: rgba(185, 228, 138, 0.46); } .dl-gif { background-color: rgba(219, 117, 22, 0.46); } `; // Styles function injectStyles() { const styleElement = document.createElement("style"); styleElement.textContent = style; document.head.appendChild(styleElement); } injectStyles(); // Snippet extraction function getRetweetFrame(tweetElement) { let retweetFrame = null; const candidates = tweetElement.querySelectorAll(`[id^="id__"]`); candidates.forEach((candidate) => { const candidateFrame = candidate.querySelector('div[tabindex="0"][role="link"]'); if (candidateFrame) retweetFrame = candidateFrame; }); return retweetFrame; } function getTopBar(tweetElement, isRetweet) { // I know its kind of bad but it works let element = tweetElement; if (isRetweet) { const retweetFrame = getRetweetFrame(tweetElement); const videoPlayer = tweetElement.querySelector('[data-testid="videoPlayer"]'); const videoPlayerOnRetweet = retweetFrame.querySelector('[data-testid="videoPlayer"]') const isVideoOnRetweet = (videoPlayer == videoPlayerOnRetweet); if (videoPlayerOnRetweet && isVideoOnRetweet) element = retweetFrame; else if (videoPlayerOnRetweet == null) element = tweetElement; } const userName = element.querySelector('[data-testid="User-Name"]'); if (isRetweet && element != tweetElement) return userName.parentNode.parentNode; return userName.parentNode.parentNode.parentNode; } // Fetching async function getMediasFromTweetId(tweetInformation) { const id = tweetInformation.id; const payload = { "url": `${apiEndpoint}${id}`, "headers": { "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", "accept-language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", "cache-control": "max-age=0", "sec-ch-ua": "\"Not/A)Brand\";v=\"99\", \"Google Chrome\";v=\"115\", \"Chromium\";v=\"115\"", "sec-ch-ua-mobile": "?0", "sec-ch-ua-platform": "\"Windows\"", "sec-fetch-dest": "document", "sec-fetch-mode": "navigate", "sec-fetch-site": "same-origin", "sec-fetch-user": "?1", "upgrade-insecure-requests": "1" }, "referrer": "https://twitter-video-download.com/en", "referrerPolicy": "strict-origin-when-cross-origin", "body": null, "method": "GET", "mode": "cors", "credentials": "omit" }; const request = await GM.xmlHttpRequest(payload); let lq = null; let hq = null; try { const regex = /https:\/\/[a-zA-Z0-9_-]+\.twimg\.com\/[a-zA-Z0-9_\-./]+\.mp4/g; const text = request.responseText; const links = text.match(regex); // Calculate the size of a video based on resolution function calculateSize(resolution) { const parts = resolution.split("x"); const width = parseInt(parts[0]); const height = parseInt(parts[1]); return width * height; } if (!links) return null; // Map links to objects with resolution and size const linkObjects = links.map(link => { const resolutionMatch = link.match(/\/(\d+x\d+)\//); const resolution = resolutionMatch ? resolutionMatch[1] : ""; const size = calculateSize(resolution); return { link, resolution, size }; }); // Sort linkObjects based on size in descending order linkObjects.sort((a, b) => a.size - b.size); // Create a Set to track seen links and store unique links const uniqueLinks = new Set(); const deduplicatedLinks = []; for (const obj of linkObjects) { if (!uniqueLinks.has(obj.link)) { uniqueLinks.add(obj.link); deduplicatedLinks.push(obj.link); } } if (tweetInformation.isGif && tweetInformation.tabIndex == "-1" || links[0].startsWith('https://video.twimg.com/tweet_video/') ) { lq = links[0]; } else { lq = deduplicatedLinks[0]; if (deduplicatedLinks.length > 1) hq = deduplicatedLinks[deduplicatedLinks.length-1]; // first quality is VERY bad so if can swap to second (medium) then its better if (deduplicatedLinks.length > 2) lq = deduplicatedLinks[1]; } } catch (error) { console.error(error); return null; } return {lq, hq}; } // Downloading async function downloadFile(button, url, mode, filename) { const baseText = `${downloadText} (${mode.toUpperCase()})`; button.disabled = true; button.innerText = "Downloading..."; console.log(`[TwitterDL] Downloading Tweet URL (${mode.toUpperCase()}): ${url}`); function finish() { if (button.innerText == baseText) return; button.disabled = false; button.innerText = baseText; } GM.xmlHttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: function(response) { const blob = response.response; const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.setAttribute('download', filename); link.click(); URL.revokeObjectURL(link.href); button.innerText = 'Downloaded!'; button.disabled = false; setTimeout(finish, 1000); }, onerror: function(error) { console.error('[TwitterDL] Download Error:', error); button.innerText = 'Download Failed'; setTimeout(finish, 1000); }, onprogress: function(progressEvent) { if (progressEvent.lengthComputable) { const percentComplete = Math.round((progressEvent.loaded / progressEvent.total) * 100); button.innerText = `Downloading: ${percentComplete}%`; } else button.innerText = 'Downloading...'; } }); } function createDownloadButton(tweetInformation, url, tag) { const button = document.createElement("button"); button.hidden = true; const username = tweetInformation.username; const filename = `TwitterDL_${username}_${tweetInformation.id}`; button.classList.add("dl-video", `dl-${tag}`); button.innerText = `${downloadText} (${tag.toUpperCase()})`; button.setAttribute("href", url); button.setAttribute("download", ""); button.addEventListener('click', async() => { await downloadFile(button, url, tag, filename); }); button.hidden = false; return button; } function createDownloadButtons(tweetElement) { const tweetInformation = getTweetInformation(tweetElement); if (!tweetInformation) return; getMediasFromTweetId(tweetInformation).then((medias) => { if (!medias) return; const retweetFrame = getRetweetFrame(tweetElement); const isRetweet = (retweetFrame != null); let lowQualityButton; let highQualityButton; const lq = medias.lq; const hq = medias.hq; if (lq) lowQualityButton = createDownloadButton(tweetInformation, lq, tweetInformation.isGif ? "gif" : "lq"); if (hq && !tweetInformation.isGif) highQualityButton = createDownloadButton(tweetInformation, hq, "hq"); const videoPlayer = isRetweet ? tweetElement.querySelector('[data-testid="videoPlayer"]') : null; const videoPlayerOnRetweet = isRetweet ? retweetFrame.querySelector('[data-testid="videoPlayer"]') : null; const topBar = getTopBar(tweetElement, isRetweet); const threeDotsElement = topBar.lastChild const isVideoOnRetweet = (videoPlayer == videoPlayerOnRetweet); if (!lowQualityButton && !highQualityButton) return; // Order: HQ then LQ if (videoPlayer != null && isRetweet && isVideoOnRetweet) { // Add a little side dot addSideTextToRetweet(tweetElement, " · ", 6, 5); if (highQualityButton) topBar.appendChild(highQualityButton); if (lowQualityButton) topBar.appendChild(lowQualityButton); } else { if (lowQualityButton) topBar.insertBefore(lowQualityButton, threeDotsElement); if (highQualityButton) topBar.insertBefore(highQualityButton, lowQualityButton); } }) } function addSideTextToRetweet(tweetElement, text, forcedMargin, forcedWidth) { const timeElement = tweetElement.querySelector("time"); const computedStyles = window.getComputedStyle(timeElement); // Make a new text based on the font and color const textElement = timeElement.cloneNode(true); textElement.innerText = text; textElement.setAttribute("datetime", ""); for (const property of computedStyles) { textElement.style[property] = computedStyles.getPropertyValue(property); } textElement.style.overflow = "visible"; textElement.style["padding-left"] = "4px"; textElement.style["margin-left"] = forcedMargin || 0; const tweetAvatarElement = tweetElement.querySelectorAll('[data-testid="Tweet-User-Avatar"]')[1]; const targetTweetBar = tweetAvatarElement.parentNode; targetTweetBar.appendChild(textElement); const contentWidth = textElement.scrollWidth; textElement.style.width = (forcedWidth || contentWidth) + 'px'; injectedFallbacks.push(tweetElement); } // Page information gathering function getTweetsInPage() { return document.getElementsByTagName("article"); } let injectedFallbacks = []; function getTweetInformation(tweetElement) { let information = {}; // ID // Check the tweet timestamp, it has a link with the id at the end // In case something goes wrong, a fallback text is shown let id = null; let username = null; let tweetUrl = null; let isGif = false; let tabIndex = null; const retweetFrame = getRetweetFrame(tweetElement); const isRetweet = (retweetFrame != null); const videoPlayer = isRetweet ? retweetFrame.querySelector('[data-testid="videoPlayer"]') : null; const isPost = (isStatusUrl(window.location.href)); tabIndex = tweetElement.getAttribute('tabindex'); const regex = /https:\/\/(?:pro\.)?x\.com\/([^\/]+)\/status\/(\d+)/; function setInfo(url) { const match = url.match(regex); id = match[2]; username = match[1]; tweetUrl = url; } const url = window.location.href; try { setInfo(url); } catch {} function fallback(reason) { if (injectedFallbacks.includes(tweetElement)) return; console.log("[TwitterDL] Twitter quote retweets from statuses are not supported yet, sorry! Throwing fallback... \nScope: " + reason); addSideTextToRetweet(tweetElement, " · Open to Download"); } try { if (isRetweet) { if (isPost) { const hasRetweetVideoPlayer = (videoPlayer != null); if (hasRetweetVideoPlayer) fallback("isretweet, ispost, hasretweetvideoplayer"); } else fallback("isretweet"); } else { const timeElement = tweetElement.querySelector("time"); const timeHref = timeElement.parentNode; const tweetUrl = timeHref.href; if (tweetUrl) setInfo(tweetUrl); else fallback("no time info"); } } catch (error) { fallback("internal error: " + error); console.error(error); } // VideoPlayer element const videoPlayerElement = tweetElement.querySelector('[data-testid="videoPlayer"]'); const spanElement = videoPlayerElement.querySelector('div[dir="ltr"] > span'); if (spanElement) isGif = spanElement.innerText == "GIF"; if (!id) return; information.id = id; information.username = username; information.url = tweetUrl; information.videoPlayer = videoPlayerElement; information.isGif = isGif; information.tabIndex = tabIndex; // Play button return information; } // Page injection async function injectAll() { const tweets = getTweetsInPage(); for (let i = 0; i < tweets.length; i++) { const tweet = tweets[i]; const alreadyInjected = injectedTweets.includes(tweet); if (!alreadyInjected) { const videoPlayer = tweet.querySelector('[data-testid="videoPlayer"]'); const isVideo = (videoPlayer != null); if (!isVideo) continue; createDownloadButtons(tweet); injectedTweets.push(tweet); } } } function checkForInjection() { const tweets = getTweetsInPage(); const shouldInject = (injectedTweets.length != tweets.length); if (shouldInject) injectAll(); } function isStatusUrl(url) { const statusUrlRegex = /^https?:\/\/(pro\.x|x)\.com\/\w+\/status\/\d+$/; return statusUrlRegex.test(url); } function isValidUrl(url) { const tweetUrlRegex = /^https?:\/\/(pro\.x|x)\.com\/\w+(\/\w+)*$/; return tweetUrlRegex.test(url) || isStatusUrl(window.location.href); } if (isValidUrl(window.location.href)) { console.log("[TwitterDL] by (real)coloride - 2023 // Loading... "); setInterval(async() => { try { checkForInjection(); } catch (error) { console.error("[TwitterDL] Fatal error: ", error); } }, checkFrequency); } })();