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