Twitter DL

Download twitter videos directly from your browser! (CLICK "ALWAYS ALLOW" IF PROMPTED!)

当前为 2023-07-15 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter DL
  3. // @version 1.0.2
  4. // @description Download twitter videos directly from your browser! (CLICK "ALWAYS ALLOW" IF PROMPTED!)
  5. // @author realcoloride
  6. // @license MIT
  7. // @namespace https://twitter.com/*
  8. // @match https://twitter.com/*
  9. // @connect twitterpicker.com
  10. // @connect twimg.com
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=twitter.com
  12. // @grant GM.xmlHttpRequest
  13. // ==/UserScript==
  14.  
  15. (function() {
  16. let injectedTweets = [];
  17. const checkFrequency = 150; // in milliseconds
  18. const apiEndpoint = "https://api.twitterpicker.com/tweet/mediav2?id=";
  19. const downloadText = "Download"
  20.  
  21. const style =
  22. `.dl-video {
  23. padding: 6px;
  24. padding-left: 5px;
  25. padding-right: 5px;
  26. margin-left: 5px;
  27. margin-bottom: 2px;
  28. border-color: black;
  29. border-style: none;
  30. border-radius: 10px;
  31. color: white;
  32.  
  33. background-color: rgba(39, 39, 39, 0.46);
  34. font-family: Arial, Helvetica, sans-serif;
  35. font-size: xx-small;
  36.  
  37. cursor: pointer;
  38. }
  39.  
  40. .dl-hq {
  41. background-color: rgba(28, 199, 241, 0.46);
  42. }
  43. .dl-lq {
  44. background-color: rgba(185, 228, 138, 0.46);
  45. }`;
  46.  
  47. // Styles
  48. function injectStyles() {
  49. const styleElement = document.createElement("style");
  50. styleElement.textContent = style;
  51.  
  52. document.head.appendChild(styleElement);
  53. }
  54. injectStyles();
  55.  
  56. // Snippet extraction
  57. function getRetweetFrame(tweetElement) {
  58. let retweetFrame = null;
  59. const candidates = tweetElement.querySelectorAll(`[id^="id__"]`);
  60.  
  61. candidates.forEach((candidate) => {
  62. const candidateFrame = candidate.querySelector('div[tabindex="0"][role="link"]');
  63.  
  64. if (candidateFrame)
  65. retweetFrame = candidateFrame;
  66. });
  67.  
  68. return retweetFrame;
  69. }
  70. function getTopBar(tweetElement, isRetweet) {
  71. // I know its kind of bad but it works
  72.  
  73. let element = tweetElement;
  74.  
  75. if (isRetweet) {
  76. const retweetFrame = getRetweetFrame(tweetElement);
  77. const videoPlayer = tweetElement.querySelector('[data-testid="videoPlayer"]');
  78. const videoPlayerOnRetweet = retweetFrame.querySelector('[data-testid="videoPlayer"]')
  79.  
  80. const isVideoOnRetweet = (videoPlayer == videoPlayerOnRetweet);
  81. if (videoPlayerOnRetweet && isVideoOnRetweet) element = retweetFrame;
  82. else if (videoPlayerOnRetweet == null) element = tweetElement;
  83. }
  84.  
  85. const userName = element.querySelector('[data-testid="User-Name"]');
  86. if (isRetweet && element != tweetElement) return userName.parentNode.parentNode;
  87. return userName.parentNode.parentNode.parentNode;
  88. }
  89.  
  90. // Fetching
  91. async function getMediasFromTweetId(id) {
  92. const url = `${apiEndpoint}${id}`;
  93.  
  94. const request = await GM.xmlHttpRequest({
  95. method: "GET",
  96. url: url
  97. });
  98. const result = JSON.parse(request.responseText);
  99. let foundMedias = [];
  100. const medias = result.media;
  101.  
  102. if (medias) {
  103. const videos = medias.videos;
  104.  
  105. if (videos.length > 0) {
  106. for (let i = 0; i < videos.length; i++) {
  107. const video = videos[i];
  108. const variants = video.variants;
  109. if (!variants || variants.length == 0) continue;
  110.  
  111. // Check variant medias
  112. let videoContestants = {};
  113.  
  114. variants.forEach((variant) => {
  115. const isVideo = (variant.content_type.startsWith("video"));
  116.  
  117. if (isVideo) {
  118. const bitrate = variant.bitrate;
  119. const url = variant.url;
  120. videoContestants[url] = bitrate;
  121. };
  122. })
  123.  
  124. // Sort by lowest to highest bitrate
  125. const sortedContestants = Object.values(videoContestants).sort((a, b) => a - b);
  126. const findContestant = (value) => {
  127. const entry = Object.entries(videoContestants).find(([key, val]) => val === value);
  128. return entry ? entry[0] : null;
  129. };
  130.  
  131. let lowQualityVideo = null;
  132. let highQualityVideo = null;
  133.  
  134. for (let k = 0; k < sortedContestants.length; k++) {
  135. const bitrate = sortedContestants[k];
  136. const url = findContestant(bitrate);
  137.  
  138. if (url) {
  139. lowQualityVideo = findContestant(sortedContestants[0]);
  140.  
  141. if (sortedContestants.length > 1) // If has atleast 2 entries
  142. highQualityVideo = findContestant(sortedContestants[sortedContestants.length - 1]);
  143. }
  144. }
  145.  
  146. let mediaInformation = {
  147. "hq" : highQualityVideo,
  148. "lq" : lowQualityVideo
  149. }
  150. foundMedias.push(mediaInformation);
  151. }
  152. }
  153. }
  154.  
  155. return foundMedias;
  156. }
  157.  
  158. // Downloading
  159. function getFileNameFromUrl(url) {
  160. const path = url.split('/'); // Split the URL by '/'
  161. const lastSegment = path[path.length - 1]; // Get the last segment of the URL
  162. const fileName = lastSegment.split('?')[0]; // Remove any query parameters
  163. return fileName;
  164. }
  165. async function downloadFile(button, url, mode) {
  166. const baseText = `${downloadText} (${mode.toUpperCase()})`;
  167. button.disabled = true;
  168. button.innerText = "Downloading...";
  169. console.log(`[TwitterDL] Downloading Tweet URL (${mode.toUpperCase()}): ${url}`);
  170. function finish() {
  171. if (button.innerText == baseText) return;
  172.  
  173. button.disabled = false;
  174. button.innerText = baseText;
  175. }
  176.  
  177. GM.xmlHttpRequest({
  178. method: 'GET',
  179. url: url,
  180. responseType: 'blob',
  181. onload: function(response) {
  182. const blob = response.response;
  183. const link = document.createElement('a');
  184.  
  185. link.href = URL.createObjectURL(blob);
  186. link.setAttribute('download', getFileNameFromUrl(url));
  187. link.click();
  188.  
  189. URL.revokeObjectURL(link.href);
  190. button.innerText = 'Downloaded!';
  191. button.disabled = false;
  192.  
  193. setTimeout(finish, 1000);
  194. },
  195. onerror: function(error) {
  196. console.error('[TwitterDL] Download Error:', error);
  197. button.innerText = 'Download Failed';
  198. setTimeout(finish, 1000);
  199. },
  200. onprogress: function(progressEvent) {
  201. if (progressEvent.lengthComputable) {
  202. const percentComplete = Math.round((progressEvent.loaded / progressEvent.total) * 100);
  203. button.innerText = `Downloading: ${percentComplete}%`;
  204. } else
  205. button.innerText = 'Downloading...';
  206. }
  207. });
  208. }
  209. function createDownloadButton(tweetId, tag, isRetweet) {
  210. const button = document.createElement("button");
  211. button.hidden = true;
  212.  
  213. getMediasFromTweetId(tweetId).then((mediaInformation) => {
  214. const video = mediaInformation[0];
  215. if (!video) return;
  216. const url = video[tag];
  217.  
  218. button.classList.add("dl-video", `dl-${tag}`);
  219. button.innerText = `${downloadText} (${tag.toUpperCase()})`;
  220. button.setAttribute("href", url);
  221. button.setAttribute("download", "");
  222. button.addEventListener('click', async() => {
  223. await downloadFile(button, url, tag);
  224. });
  225.  
  226. button.hidden = false;
  227. });
  228.  
  229. return button;
  230. }
  231. function createDownloadButtons(tweetElement) {
  232. const tweetInformation = getTweetInformation(tweetElement);
  233. if (!tweetInformation) return;
  234. const tweetId = tweetInformation.id;
  235. getMediasFromTweetId(tweetId).then((mediaInformation) => {
  236. const video = mediaInformation[0];
  237. if (!video) return;
  238.  
  239. const retweetFrame = getRetweetFrame(tweetElement);
  240. const isRetweet = (retweetFrame != null);
  241.  
  242. let lowQualityButton;
  243. let highQualityButton;
  244. if (video["lq"]) lowQualityButton = createDownloadButton(tweetId, "lq", isRetweet);
  245. if (video["hq"]) highQualityButton = createDownloadButton(tweetId, "hq", isRetweet);
  246. const videoPlayer = isRetweet ? tweetElement.querySelector('[data-testid="videoPlayer"]') : null;
  247. const videoPlayerOnRetweet = isRetweet ? retweetFrame.querySelector('[data-testid="videoPlayer"]') : null;
  248.  
  249. const topBar = getTopBar(tweetElement, isRetweet);
  250. const threeDotsElement = topBar.lastChild
  251.  
  252. const isVideoOnRetweet = (videoPlayer == videoPlayerOnRetweet);
  253. if (!lowQualityButton && !highQualityButton) return;
  254.  
  255. // Order: HQ then LQ
  256. if (videoPlayer != null && isRetweet && isVideoOnRetweet) {
  257. // Add a little side dot
  258. addSideTextToRetweet(tweetElement, " · ", 6, 5);
  259.  
  260. if (highQualityButton) topBar.appendChild(highQualityButton);
  261. if (lowQualityButton) topBar.appendChild(lowQualityButton);
  262. } else {
  263. if (lowQualityButton) topBar.insertBefore(lowQualityButton, threeDotsElement);
  264. if (highQualityButton) topBar.insertBefore(highQualityButton, lowQualityButton);
  265. }
  266. })
  267. }
  268. function addSideTextToRetweet(tweetElement, text, forcedMargin, forcedWidth) {
  269. const timeElement = tweetElement.querySelector("time");
  270. const computedStyles = window.getComputedStyle(timeElement);
  271.  
  272. // Make a new text based on the font and color
  273. const textElement = timeElement.cloneNode(true);
  274. textElement.innerText = text;
  275. textElement.setAttribute("datetime", "");
  276.  
  277. for (const property of computedStyles) {
  278. textElement.style[property] = computedStyles.getPropertyValue(property);
  279. }
  280.  
  281. textElement.style.overflow = "visible";
  282. textElement.style["padding-left"] = "4px";
  283. textElement.style["margin-left"] = forcedMargin || 0;
  284.  
  285. const tweetAvatarElement = tweetElement.querySelectorAll('[data-testid="Tweet-User-Avatar"]')[1];
  286. const targetTweetBar = tweetAvatarElement.parentNode;
  287.  
  288. targetTweetBar.appendChild(textElement);
  289.  
  290. const contentWidth = textElement.scrollWidth;
  291. textElement.style.width = (forcedWidth || contentWidth) + 'px';
  292. injectedFallbacks.push(tweetElement);
  293. }
  294.  
  295. // Page information gathering
  296. function getTweetsInPage() {
  297. return document.getElementsByTagName("article");
  298. }
  299. let injectedFallbacks = [];
  300. function getTweetInformation(tweetElement) {
  301. let information = {};
  302.  
  303. // ID
  304. // Check the tweet timestamp, it has a link with the id at the end
  305. // In case something goes wrong, a fallback text is shown
  306. let id = null;
  307.  
  308. const retweetFrame = getRetweetFrame(tweetElement);
  309. const isRetweet = (retweetFrame != null);
  310. const videoPlayer = isRetweet ? retweetFrame.querySelector('[data-testid="videoPlayer"]') : null;
  311.  
  312. const isPost = (isStatusUrl(window.location.href));
  313.  
  314. try {
  315. if (isRetweet && isPost) {
  316. const hasRetweetVideoPlayer = (videoPlayer != null);
  317. if (hasRetweetVideoPlayer)
  318. id = (window.location.href).split("/").pop();
  319. } else {
  320. const timeElement = tweetElement.querySelector("time");
  321. const timeHref = timeElement.parentNode;
  322. const tweetUrl = timeHref.href;
  323. id = tweetUrl.split("/").pop();
  324. }
  325. } catch (error) {
  326. try {
  327. if (injectedFallbacks.includes(tweetElement)) return;
  328.  
  329. const retweetFrame = getRetweetFrame(tweetElement);
  330. const videoPlayer = retweetFrame.querySelector('[data-testid="videoPlayer"]');
  331. if (!videoPlayer != null && retweetFrame != null && isStatusUrl(window.location.href)) return;
  332. console.log("[TwitterDL] Twitter quote retweets from statuses are not supported yet. Throwing fallback");
  333.  
  334. addSideTextToRetweet(tweetElement, " · Open to Download");
  335. } catch (error) {}
  336. }
  337.  
  338. if (!id) return;
  339. information.id = id;
  340.  
  341. // VideoPlayer element
  342. const videoPlayerElement = tweetElement.querySelector('[data-testid="videoPlayer"]');
  343. information.videoPlayer = videoPlayerElement;
  344.  
  345. // Play button
  346. return information;
  347. }
  348.  
  349. // Page injection
  350. async function injectAll() {
  351. const tweets = getTweetsInPage();
  352. for (let i = 0; i < tweets.length; i++) {
  353. const tweet = tweets[i];
  354. const alreadyInjected = injectedTweets.includes(tweet);
  355.  
  356. if (!alreadyInjected) {
  357. const videoPlayer = tweet.querySelector('[data-testid="videoPlayer"]');
  358. const isVideo = (videoPlayer != null);
  359. if (!isVideo) continue;
  360.  
  361. createDownloadButtons(tweet);
  362. injectedTweets.push(tweet);
  363. }
  364. }
  365. }
  366. function checkForInjection() {
  367. const tweets = getTweetsInPage();
  368. const shouldInject = (injectedTweets.length != tweets.length);
  369.  
  370. if (shouldInject) injectAll();
  371. }
  372.  
  373. function isStatusUrl(url) {
  374. const statusUrlRegex = /^https?:\/\/twitter\.com\/\w+\/status\/\d+$/;
  375. return statusUrlRegex.test(url);
  376. }
  377. function isValidUrl(url) {
  378. const tweetUrlRegex = /^https?:\/\/twitter\.com\/\w+(\/\w+)*$/ ;
  379. return tweetUrlRegex.test(url) || isStatusUrl(window.location.href);
  380. }
  381. if (isValidUrl(window.location.href)) {
  382. console.log("[TwitterDL] by (real)coloride - 2023 // Loading... ");
  383.  
  384. setInterval(async() => {
  385. try {
  386. checkForInjection();
  387. } catch (error) {
  388. console.error("[TwitterDL] Fatal error: ", error);
  389. }
  390. }, checkFrequency);
  391. }
  392. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址