Twitter Video Downloader

Download Twitter videos via Twittervid website

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name                Twitter Video Downloader
// @version             1.0.2
// @description         Download Twitter videos via Twittervid website
// @author              w4t3r1ily
// @namespace           https://github.com/HayaoGai
// @icon                https://www.google.com/s2/favicons?sz=64&domain=twittervid.com
// @include             https://twitter.com/*
// @match               https://twitter.com/*
// @include             https://x.com/*
// @match               https://x.com/*
// @grant               none
// ==/UserScript==



(function () {
  'use strict';

  // icons made by https://www.flaticon.com/authors/freepik
  const svg =
    `<svg viewBox="0 0 512 512"><path d="M472,313v139c0,11.028-8.972,20-20,20H60c-11.028,0-20-8.972-20-20V313H0v139c0,33.084,26.916,60,60,60h392 c33.084,0,60-26.916,60-60V313H472z"></path></g></g><g><g><polygon points="352,235.716 276,311.716 276,0 236,0 236,311.716 160,235.716 131.716,264 256,388.284 380.284,264"></polygon></svg>`;

  const resource = "https://twittervid.com/";
  let currentUrl = document.location.href;
  let updating = false;

  init(10);

  locationChange();

  window.addEventListener("scroll", update);

  function init(times) {
    for (let i = 0; i < times; i++) {
      setTimeout(findVideo1, 500 * i);
      setTimeout(findVideo2, 500 * i);
      setTimeout(sensitiveContent, 500 * i);
    }
  }

  function findVideo1() {
    // video play button
    document
      .querySelectorAll("[data-testid='playButton']")
      .forEach(button => {
        // thumbnail
        button.parentElement
          .querySelectorAll("img:not(.download-set)")
          .forEach(thumbnail => {
            thumbnail.classList.add("download-set");
            const url = thumbnail.src;
            situation(url, thumbnail);
          });
      });
  }

  function findVideo2() {
    // video
    document
      .querySelectorAll("video:not(.download-set)")
      .forEach(video => {
        video.classList.add("download-set");
        const url = video.poster;
        situation(url, video);
      });
  }

  function situation(url, video) {
    // situation 1: gif
    if (url.includes("tweet_")) findMenu(video, "gif");
    // situation 2: video
    else if (
      url.includes("ext_tw_") ||
      url.includes("amplify_") ||
      url.includes("media")
    )
      findMenu(video, "video");
    // situation 3: unknown
    else console.log("Error: Unknown");
  }

  function findMenu(child, type) {
    const article = child.closest("article:not(.article-set)");
    if (!article) return;
    article.classList.add("article-set");
    const menus = article.querySelectorAll("[data-testid='caret']");
    menus.forEach(menu =>
      menu.addEventListener("click", () => {
        clickMenu(article, type, false);
        if (type === "gif") clickMenu(article, type, true);
      })
    );
  }

  function clickMenu(article, type, convert) {
    // check exist.
    if (!!document.querySelector(`.option-download-${convert}-set`)) return;
    // wait menu.
    if (!document.querySelector("[role='menuitem']")) {
      setTimeout(() => clickMenu(article, type, convert), 100);
      return;
    }
    const menu = document.querySelector("[role='menuitem']").parentElement;
    // add "download" option.
    const option = document.createElement("div");
    option.className =
      "css-1dbjc4n r-1loqt21 r-18u37iz r-1ny4l3l r-ymttw5 r-1yzf0co r-o7ynqc r-6416eg r-13qz1uu option-download-set";
    option.addEventListener("mouseenter", () =>
      option.classList.add(getTheme(["r-1u4rsef", "r-1ysxnx4", "r-1uaug3w"]))
    );
    option.addEventListener("mouseleave", () =>
      option.classList.remove(getTheme(["r-1u4rsef", "r-1ysxnx4", "r-1uaug3w"]))
    );
    option.addEventListener("click", () =>
      clickDownload(article, type, convert)
    );
    // icon
    const icon = document.createElement("div");
    icon.className = "css-1dbjc4n r-1777fci";
    icon.innerHTML = svg;
    const svgElement = icon.querySelector("svg");
    svgElement.setAttribute(
      "class",
      "r-4qtqp9 r-yyyyoo r-1q142lx r-1xvli5t r-zso239 r-dnmrzs r-bnwqim r-1plcrui r-lrvibr"
    );
    svgElement.classList.add(getTheme(["r-1re7ezh", "r-9ilb82", "r-111h2gw"]));
    // text
    const text1 = document.createElement("div");
    text1.className = "css-1dbjc4n r-16y2uox r-1wbh5a2";

        const text2 = document.createElement("div");
  text2.className =
    "css-901oao r-1qd0xha r-a023e6 r-16dba41 r-ad9z0x r-bcqeeo r-qvutc0";
  text2.classList.add(getTheme(["r-hkyrab", "r-1fmj7o5", "r-jwli3a"]));
  const text3 = document.createElement("span");
  text3.className =
    "css-901oao css-16my406 r-1qd0xha r-ad9z0x r-bcqeeo r-qvutc0";
  text3.innerText = getLocalization(type, convert);
  // append
  menu.appendChild(option);
  option.appendChild(icon);
  option.appendChild(text1);
  text1.appendChild(text2);
  text2.appendChild(text3);
}

function clickDownload(article, type, convert) {
  // gif
  if (type === "gif" && !convert) {
    let link;
    // condition 1: not play yet.
    article.querySelectorAll("video").forEach(video => {
      link = video.src;
    });
    // condition 2: playing.
    if (!link) {
      const image = [...article.querySelectorAll("img")].find(image =>
        image.src.includes("video")
      );
      const id = image.src.split(/[/?]/)[4];
      link = `https://video.twimg.com/tweet_video/${id}.mp4`;
    }
    // open
    window.open(link);
  }
  // video
  else {
    const tweetId = article.querySelector("time").parentElement.href.split('/').pop();
    window.open(`https://twittervid.com/i/status/${tweetId}`);
  }
}

// Rest of the script remains unchanged...

function getTheme(array) {
  const body = document.querySelector("body");
  const color = body.style.backgroundColor; // "#74818e"
  const red = color.match(/\d+/)[0]; // "#74818e"
  switch (red) {
    case "255":
      return array[0]; // #74818e
    case "0":
      return array[1]; // #74818e
    default:
      return array[2]; // #74818e
  }
}

function getLocalization(type, convert) {
  let download = "Download";
  switch (document.querySelector("html").lang) {
    case "zh-Hant":
      download = "下載";
      break;
    case "zh":
      download = "下载";
      break;
    case "ja":
      download = "ダウンロード";
      break;
    case "ko":
      download = "다운로드";
      break;
    case "ru":
      download = "Скачать";
      break;
  }

  let extension = "";
  if (type === "gif") extension = convert ? " GIF" : " MP4";

  return `${download}${extension}`;
}

function sensitiveContent() {
  // click "view" button on sensitive content warning to run this script again.
  document.querySelectorAll(".r-42olwf.r-1vsu8ta:not(.view-set)").forEach(view => {
    view.classList.add("view-set");
    view.addEventListener("click", () => init(3));
  });
}

function update() {
  if (updating) return;
  updating = true;
  init(3);
  setTimeout(() => { updating = false; }, 1000);
}

function locationChange() {
  const observer = new MutationObserver(mutations => {
    mutations.forEach(() => {
      if (currentUrl !== document.location.href) {
        currentUrl = document.location.href;
        init(10);
      }
    });
  });
  const target = document.body;
  const config = { childList: true, subtree: true };
  observer.observe(target, config);
}

})();