Last active
July 15, 2023 15:13
-
-
Save tizee/e5ad9746ff8cd17c4303672ea704952d to your computer and use it in GitHub Desktop.
Twitter video download script for Tampermonkey
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name twitter video downloader | |
// @namespace http://tampermonkey.net/ | |
// @version 1.0 | |
// @description download twitter video | |
// @author tizee | |
// @match https://twitter.com/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com | |
// @grant GM_xmlhttpRequest | |
// ==/UserScript== | |
(function () { | |
"use strict"; | |
function getVideoResolutionQuality(videoUrl) { | |
const videoRegex = | |
/^https:\/\/video.twimg.com\/(ext_tw_video|amplify_video)\/\d+\/(pu\/)?vid\/(?<resolution>\w+)\/.*$/; | |
console.debug(videoUrl); | |
const res = videoUrl.match(videoRegex).groups.resolution; | |
// there are only 2 number strings | |
return res.split("x").reduce((acc, cur) => { | |
return Number.parseInt(acc) * Number.parseInt(cur); | |
}); | |
} | |
async function getTweetMedia(tweetId = "") { | |
const url = `https://api.twitterpicker.com/tweet/mediav2?id=${tweetId}`; | |
return GM_xmlhttpRequest({ | |
method: "GET", | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
url, | |
onload: function (response) { | |
const json = JSON.parse(response.responseText); | |
console.debug("json", json); | |
const videos = json.media.videos; | |
if (videos.length) { | |
const variants = videos[0].variants; | |
// download the best quality mp4 variant by default | |
if (variants.length) { | |
const video = videos[0].variants.reduce((acc, cur) => { | |
if ( | |
acc.content_type == "video/mp4" && | |
cur.content_type == "video/mp4" | |
) { | |
return getVideoResolutionQuality(acc.url) > | |
getVideoResolutionQuality(cur.url) | |
? acc | |
: cur; | |
} else { | |
return acc.content_type == "video/mp4" ? acc : cur; | |
} | |
}); | |
const link = document.createElement("a"); | |
link.setAttribute("href", video.url); | |
link.setAttribute("download", `${tweetId}.mp4`); | |
link.target = "_blank"; | |
console.debug("best quality video", video.url); | |
link.click(); | |
} | |
} | |
}, | |
}); | |
} | |
// download button | |
const style = document.createElement("style"); | |
style.innerHTML = ` | |
.tweet-download-button { | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
outline-style: none; | |
color: rgb(113,118,123); | |
background-color: rgba(0,0,0,0); | |
transition-duration: 0.2s; | |
transition-property: background-color,color, box-shadow; | |
} | |
.tweet-download-button:hover { | |
background-color: rgba(29,155,240,0.1); | |
color: rgb(29,155,240); | |
cursor: pointer; | |
} | |
`; | |
document.body.appendChild(style); | |
const regex = /^https:\/\/twitter\.com\/(\w+$|home$|\w+\/status\/\d+$)/; | |
const statusRegex = /^https:\/\/twitter\.com\/\w+\/status\/\d+$/; | |
const tweetSet = new WeakSet(); | |
// watch new added Tweet cards | |
function watchTweetNodes(mutationList) { | |
mutationList.forEach(function (mutationRecord) { | |
mutationRecord.addedNodes.forEach(function (node) { | |
if (!node.querySelector || typeof node.querySelector != "function") { | |
return; | |
} | |
const tweet = node.querySelector(`article[data-testid="tweet"]`); | |
if (tweet == null) { | |
return; | |
} | |
// prevent duplication | |
if (tweetSet.has(tweet)) { | |
return; | |
} | |
let tweetId = ""; | |
const tweetUser = tweet.querySelector( | |
`div[data-testid="User-Name"] a[aria-label]` | |
); | |
// timeline or conversation tweets | |
if (tweetUser) { | |
tweetId = tweetUser | |
.getAttribute("href") | |
.substring(1) | |
.split("/status/") | |
.at(1); | |
} | |
// status tweet | |
if (tweetId == "" && statusRegex.test(window.location.href)) { | |
tweetId = window.location.href.split("/status/").at(1); | |
} | |
const downloadButton = document.createElement("div"); | |
downloadButton.dataset.id = tweetId; | |
downloadButton.classList.add("tweet-download-button"); | |
downloadButton.innerHTML = ` | |
<svg xmlns="http://www.w3.org/2000/svg" width="1.5em" height="1.5em" viewBox="0 0 24 24" fill="none"> | |
<path d="M11 5C11 4.44772 11.4477 4 12 4C12.5523 4 13 4.44772 13 5V12.1578L16.2428 8.91501L17.657 10.3292L12.0001 15.9861L6.34326 10.3292L7.75748 8.91501L11 12.1575V5Z" fill="currentColor"></path> | |
<path d="M4 14H6V18H18V14H20V18C20 19.1046 19.1046 20 18 20H6C4.89543 20 4 19.1046 4 18V14Z" fill="currentColor"></path> | |
</svg> | |
`; | |
downloadButton.addEventListener("click", async (e) => { | |
e.preventDefault(); | |
e.stopPropagation(); | |
await getTweetMedia(tweetId); | |
}); | |
const groupRow = tweet.querySelector(`div[role="group"]`); | |
groupRow.appendChild(downloadButton); | |
tweetSet.add(tweet); | |
}); | |
}); | |
} | |
const observer = new MutationObserver(function (mutationList, observer) { | |
if (!regex.test(window.location.href)) { | |
return; | |
} | |
// add download button to new added tweet card | |
watchTweetNodes(mutationList); | |
}); | |
observer.observe(document.documentElement, { | |
childList: true, | |
subtree: true, | |
}); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment