- // ==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);
- }
- })();