Twitter/X Media Batch Downloader

Batch download all images and videos from a Twitter/X account in original quality.

当前为 2025-01-20 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Twitter/X Media Batch Downloader
// @description  Batch download all images and videos from a Twitter/X account in original quality.
// @icon         https://raw.githubusercontent.com/afkarxyz/Twitter-X-Media-Batch-Downloader/refs/heads/main/Archived/icon.svg
// @version      1.5
// @author       afkarxyz
// @namespace    https://github.com/afkarxyz/misc-scripts/
// @supportURL   https://github.com/afkarxyz/misc-scripts/issues
// @license      MIT
// @match        https://twitter.com/*
// @match        https://x.com/*
// @grant        GM_xmlhttpRequest
// @connect      twitterxapis.vercel.app
// @connect      pbs.twimg.com
// @connect      video.twimg.com
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// ==/UserScript==

;(() => {
  const mediaIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" width="16" height="16">
        <path fill="currentColor" d="M256 48c-8.8 0-16 7.2-16 16l0 224c0 8.7 6.9 15.8 15.6 16l69.1-94.2c4.5-6.2 11.7-9.8 19.4-9.8s14.8 3.6 19.4 9.8L380 232.4l56-85.6c4.4-6.8 12-10.9 20.1-10.9s15.7 4.1 20.1 10.9L578.7 303.8c7.6-1.3 13.3-7.9 13.3-15.8l0-224c0-8.8-7.2-16-16-16L256 48zM192 64c0-35.3 28.7-64 64-64L576 0c35.3 0 64 28.7 64 64l0 224c0 35.3-28.7 64-64 64l-320 0c-35.3 0-64-28.7-64-64l0-224zm-56 64l24 0 0 48 0 88 0 112 0 8 0 80 192 0 0-80 48 0 0 80 48 0c8.8 0 16-7.2 16-16l0-64 48 0 0 64c0 35.3-28.7 64-64 64l-48 0-24 0-24 0-192 0-24 0-24 0-48 0c-35.3 0-64-28.7-64-64L0 192c0-35.3 28.7-64 64-64l48 0 24 0zm-24 48l-48 0c-8.8 0-16 7.2-16 16l0 48 64 0 0-64zm0 288l0-64-64 0 0 48c0 8.8 7.2 16 16 16l48 0zM48 352l64 0 0-64-64 0 0 64zM304 80a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/>
    </svg>`

  const imageIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16">
        <path fill="currentColor" d="M448 80c8.8 0 16 7.2 16 16l0 319.8-5-6.5-136-176c-4.5-5.9-11.6-9.3-19-9.3s-14.4 3.4-19 9.3L202 340.7l-30.5-42.7C167 291.7 159.8 288 152 288s-15 3.7-19.5 10.1l-80 112L48 416.3l0-.3L48 96c0-8.8 7.2-16 16-16l384 0zM64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64L64 32zm80 192a48 48 0 1 0 0-96 48 48 0 1 0 0 96z"/>
    </svg>`

  const videoIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="16" height="16">
        <path fill="currentColor" d="M352 432l-192 0 0-112 0-40 192 0 0 40 0 112zm0-200l-192 0 0-40 0-112 192 0 0 112 0 40zM64 80l48 0 0 88-64 0 0-72c0-8.8 7.2-16 16-16zM48 216l64 0 0 80-64 0 0-80zm64 216l-48 0c-8.8 0-16-7.2-16-16l0-72 64 0 0 88zM400 168l0-88 48 0c8.8 0 16 7.2 16 16l0 72-64 0zm0 48l64 0 0 80-64 0 0-80zm0 128l64 0 0 72c0 8.8-7.2 16-16 16l-48 0 0-88zM448 32L64 32C28.7 32 0 60.7 0 96L0 416c0 35.3 28.7 64 64 64l384 0c35.3 0 64-28.7 64-64l0-320c0-35.3-28.7-64-64-64z"/>
    </svg>`

  const zipIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" width="16" height="16">
        <path fill="currentColor" d="M64 0C28.7 0 0 28.7 0 64L0 448c0 35.3 28.7 64 64 64l256 0c35.3 0 64-28.7 64-64l0-288-128 0c-17.7 0-32-14.3-32-32L224 0 64 0zM256 0l0 128 128 0L256 0zM96 48c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm0 64c0-8.8 7.2-16 16-16l32 0c8.8 0 16 7.2 16 16s-7.2 16-16 16l-32 0c-8.8 0-16-7.2-16-16zm-6.3 71.8c3.7-14 16.4-23.8 30.9-23.8l14.8 0c14.5 0 27.2 9.7 30.9 23.8l23.5 88.2c1.4 5.4 2.1 10.9 2.1 16.4c0 35.2-28.8 63.7-64 63.7s-64-28.5-64-63.7c0-5.5 .7-11.1 2.1-16.4l23.5-88.2zM112 336c-8.8 0-16 7.2-16 16s7.2 16 16 16l32 0c8.8 0 16-7.2 16-16s-7.2-16-16-16l-32 0z"/>
    </svg>`

  const downloadIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="18" height="18" style="vertical-align: middle; cursor: pointer;">
        <defs><style>.fa-secondary{opacity:.4}</style></defs>
        <path class="fa-secondary" fill="currentColor" d="M0 256C0 397.4 114.6 512 256 512s256-114.6 256-256c0-17.7-14.3-32-32-32s-32 14.3-32 32c0 106-86 192-192 192S64 362 64 256c0-17.7-14.3-32-32-32s-32 14.3-32 32z"/>
        <path class="fa-primary" fill="currentColor" d="M390.6 185.4c12.5 12.5 12.5 32.8 0 45.3l-112 112c-12.5 12.5-32.8 12.5-45.3 0l-112-112c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L224 242.7 224 32c0-17.7 14.3-32 32-32s32 14.3 32 32l0 210.7 57.4-57.4c12.5-12.5 32.8-12.5 45.3 0z"/>
    </svg>`

  const loadingIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" style="vertical-align: middle;">
        <path fill="currentColor" d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" opacity="0.25"/>
        <path fill="currentColor" d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z">
            <animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/>
        </path>
    </svg>`

  let controlPanel = null
  let imageCounter
  let isDownloading = false

  async function fetchMetadata(username, url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url || `https://twitterxapis.vercel.app/metadata/${username}`,
        headers: {
          Accept: "application/json",
        },
        onload: (response) => {
          try {
            const data = JSON.parse(response.responseText)
            if (data.timeline) {
              data.timeline = data.timeline.map((item, index) => ({
                ...item,
                tweet_id: item.tweet_id || `${index}`,
              }))
            }
            resolve(data)
          } catch (error) {
            reject(error)
          }
        },
        onerror: (error) => {
          reject(error)
        },
      })
    })
  }

  async function downloadFile(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url,
        responseType: "blob",
        headers: {
          Accept: "image/jpeg,image/*,video/*",
        },
        onload: (response) => {
          resolve(response.response)
        },
        onerror: (error) => {
          reject(error)
        },
      })
    })
  }

  function createCustomMenu(username) {
    const menuOverlay = document.createElement("div")
    menuOverlay.style.cssText = `
          position: fixed;
          top: 0;
          left: 0;
          width: 100%;
          height: 100%;
          background-color: rgba(0, 0, 0, 0.75);
          display: flex;
          justify-content: center;
          align-items: center;
          z-index: 10000;
      `

    const menu = document.createElement("div")
    menu.style.cssText = `
          background-color: rgba(35, 35, 35, 0.75);
          border-radius: 6px;
          width: 200px;
          padding: 12px;
          box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
          font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      `

    const title = document.createElement("h2")
    title.textContent = "Download Options"
    title.style.cssText = `
          margin-top: 0;
          margin-bottom: 15px;
          font-size: 16px;
          font-weight: bold;
          color: white;
          text-align: center;
      `

    const options = [
      { name: "Media", icon: mediaIcon, url: `https://twitterxapis.vercel.app/metadata/${username}` },
      { name: "Image", icon: imageIcon, url: `https://twitterxapis.vercel.app/metadata/image/${username}` },
      { name: "Video", icon: videoIcon, url: `https://twitterxapis.vercel.app/metadata/video/${username}` },
    ]

    options.forEach((option) => {
      const button = document.createElement("button")
      button.innerHTML = `${option.icon} ${option.name}`
      button.style.cssText = `
              display: flex;
              align-items: center;
              gap: 10px;
              margin-bottom: 10px;
              padding: 10px;
              width: 100%;
              border: none;
              background-color: rgba(255, 255, 255, 0.1);
              color: white;
              border-radius: 4px;
              cursor: pointer;
              transition: background-color 0.2s;
              font-size: 14px;
          `
      button.addEventListener("mouseenter", () => {
        button.style.backgroundColor = "rgba(255, 255, 255, 0.2)"
      })
      button.addEventListener("mouseleave", () => {
        button.style.backgroundColor = "rgba(255, 255, 255, 0.1)"
      })
      button.addEventListener("click", async () => {
        menuOverlay.remove()
        try {
          const iconDiv = document.querySelector(".download-icon")
          if (iconDiv) {
            iconDiv.innerHTML = loadingIcon
          }
          const metadata = await fetchMetadata(username, option.url)
          if (iconDiv) {
            iconDiv.innerHTML = downloadIcon
          }
          const controls = createControlPanel()
          controlPanel = controls
          imageCounter = controls.counter
          downloadMedia(metadata, option.icon)
        } catch (error) {
          console.error("Error fetching metadata:", error)
          alert("Failed to fetch media data. Please try again later.")
          const iconDiv = document.querySelector(".download-icon")
          if (iconDiv) {
            iconDiv.innerHTML = downloadIcon
          }
        }
      })
      menu.appendChild(button)
    })

    menu.insertBefore(title, menu.firstChild)
    menuOverlay.appendChild(menu)
    document.body.appendChild(menuOverlay)

    menuOverlay.addEventListener("click", (e) => {
      if (e.target === menuOverlay) {
        menuOverlay.remove()
      }
    })
  }

  function getFileExtension(url) {
    if (url.includes("video.twimg.com")) return ".mp4"
    return ".jpg"
  }

  function formatDate(dateString) {
    const date = new Date(dateString)
    const year = date.getFullYear()
    const month = String(date.getMonth() + 1).padStart(2, "0")
    const day = String(date.getDate()).padStart(2, "0")
    const hours = String(date.getHours()).padStart(2, "0")
    const minutes = String(date.getMinutes()).padStart(2, "0")
    const seconds = String(date.getSeconds()).padStart(2, "0")
    return `${year}${month}${day}_${hours}${minutes}${seconds}`
  }

  async function downloadMedia(metadata, icon) {
    if (isDownloading || !controlPanel?.panel) return
    isDownloading = true

    const zip = new JSZip()
    const { account_info, timeline, total_urls } = metadata
    const { name, nick } = account_info

    const progressContainer = controlPanel.panel.querySelector(".progress-container")
    const progressFill = progressContainer?.querySelector(".progress-fill")
    const progressText = progressContainer?.querySelector(".progress-text")
    const buttonsContainer = controlPanel.panel.querySelector(".buttons-container")

    if (!progressContainer || !progressFill || !progressText || !imageCounter) {
      console.error("Required elements not found")
      isDownloading = false
      return
    }

    buttonsContainer?.style && (buttonsContainer.style.display = "none")
    progressContainer.style.display = "block"
    imageCounter.innerHTML = `${icon || mediaIcon} ${total_urls}`

    let successfulDownloads = 0
    const batchSize = 5
    const batches = []

    const filenameCounts = new Map()

    for (let i = 0; i < timeline.length; i += batchSize) {
      const batch = timeline.slice(i, i + batchSize).map(async ({ url, date }) => {
        try {
          const blob = await downloadFile(url)
          const fileExt = getFileExtension(url)
          const formattedDate = formatDate(date)

          const baseFileName = `${name}_${formattedDate}`
          let fileName = baseFileName + fileExt

          if (filenameCounts.has(baseFileName)) {
            const count = filenameCounts.get(baseFileName) + 1
            filenameCounts.set(baseFileName, count)
            fileName = `${baseFileName}_${String(count).padStart(2, "0")}${fileExt}`
          } else {
            filenameCounts.set(baseFileName, 0)
          }

          zip.file(fileName, blob)
          successfulDownloads++

          const progress = Math.round((successfulDownloads / total_urls) * 100)
          progressFill.style.width = `${progress}%`
          progressText.textContent = `Downloading: (${successfulDownloads}/${total_urls}) ${progress}%`

          console.log(`Downloaded: ${fileName} (${successfulDownloads}/${total_urls})`)

          return true
        } catch (error) {
          console.error("Error downloading media:", error, url)
          return false
        }
      })
      batches.push(Promise.all(batch))

      await new Promise((resolve) => setTimeout(resolve, 100))
    }

    for (const batch of batches) {
      await batch
    }

    console.log(`Total successful downloads: ${successfulDownloads}`)
    console.log(`Total expected files: ${total_urls}`)

    if (successfulDownloads > 0) {
      imageCounter.innerHTML = `${zipIcon} ${successfulDownloads}`
      progressText.textContent = `Creating ZIP: (0/${successfulDownloads}) 0%`

      const zipBlob = await zip.generateAsync(
        {
          type: "blob",
          compression: "DEFLATE",
          compressionOptions: { level: 3 },
        },
        (metadata) => {
          const progress = Math.round(metadata.percent)
          const processedFiles = Math.round((progress / 100) * successfulDownloads)
          progressFill.style.width = `${progress}%`
          progressText.textContent = `Creating ZIP: (${processedFiles}/${successfulDownloads}) ${progress}%`
        },
      )

      const downloadUrl = URL.createObjectURL(zipBlob)
      const a = document.createElement("a")
      a.href = downloadUrl
      a.download = `${name}_(${nick})_${successfulDownloads}`
      document.body.appendChild(a)
      a.click()
      document.body.removeChild(a)
      URL.revokeObjectURL(downloadUrl)
    }

    isDownloading = false
    hideControlPanel()
  }

  function createControlPanel() {
    const styles = `
              .control-panel {
                  position: fixed;
                  top: 16px;
                  right: 16px;
                  display: flex;
                  flex-direction: column;
                  gap: 8px;
                  background-color: rgba(35, 35, 35, 0.75);
                  padding: 12px;
                  border-radius: 6px;
                  transform: translateX(calc(100% + 16px));
                  opacity: 0;
                  transition: transform 0.3s ease, opacity 0.3s ease;
                  z-index: 9999;
                  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
                  pointer-events: none;
                  width: 200px;
              }
              .control-panel.visible {
                  transform: translateX(0);
                  opacity: 1;
                  pointer-events: all;
              }
              .control-panel.hiding {
                  transform: translateX(calc(100% + 16px));
                  opacity: 0;
                  pointer-events: none;
              }
              .image-counter {
                  color: white;
                  text-align: center;
                  font-size: 14px;
                  display: flex;
                  align-items: center;
                  justify-content: center;
                  gap: 6px;
                  min-height: 20px;
              }
              .progress-container {
                  display: none;
                  margin-top: 8px;
                  width: 100%;
              }
              .progress-bar {
                  width: 100%;
                  height: 4px;
                  background-color: #1a1a1a;
                  border-radius: 2px;
              }
              .progress-fill {
                  width: 0%;
                  height: 100%;
                  background-color: #1da1f2;
                  border-radius: 2px;
                  transition: width 0.3s ease;
              }
              .progress-text {
                  color: white;
                  font-size: 12px;
                  text-align: center;
                  margin-top: 4px;
                  min-height: 16px;
              }
          `

    if (!document.querySelector("#control-panel-styles")) {
      const styleSheet = document.createElement("style")
      styleSheet.id = "control-panel-styles"
      styleSheet.textContent = styles
      document.head.appendChild(styleSheet)
    }

    const panel = document.createElement("div")
    panel.className = "control-panel"

    const counter = document.createElement("div")
    counter.className = "image-counter"
    counter.innerHTML = `${mediaIcon} 0`

    const progressContainer = document.createElement("div")
    progressContainer.className = "progress-container"
    progressContainer.innerHTML = `
              <div class="progress-bar">
                  <div class="progress-fill"></div>
              </div>
              <div class="progress-text">0%</div>
          `

    panel.appendChild(counter)
    panel.appendChild(progressContainer)
    document.body.appendChild(panel)

    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        panel.classList.add("visible")
      })
    })

    return {
      counter,
      panel,
    }
  }

  function hideControlPanel() {
    if (controlPanel?.panel) {
      controlPanel.panel.classList.remove("visible")
      controlPanel.panel.classList.add("hiding")

      controlPanel.panel.addEventListener("transitionend", function handler(e) {
        if (e.propertyName === "opacity") {
          controlPanel.panel.removeEventListener("transitionend", handler)
          controlPanel.panel.remove()
          controlPanel = null
        }
      })
    }
  }

  function insertDownloadIcon() {
    const usernameDivs = document.querySelectorAll('[data-testid="UserName"]')

    usernameDivs.forEach((usernameDiv) => {
      if (!usernameDiv.querySelector(".download-icon")) {
        const verifiedButton = usernameDiv
          .querySelector('[aria-label*="verified"], [aria-label*="Verified"]')
          ?.closest("button")

        const targetElement = verifiedButton
          ? verifiedButton.parentElement
          : usernameDiv.querySelector(".css-1jxf684")?.closest("span")

        if (targetElement) {
          const iconDiv = document.createElement("div")
          iconDiv.className = "download-icon css-175oi2r r-1awozwy r-xoduu5"
          iconDiv.style.cssText = `
                          display: inline-flex;
                          align-items: center;
                          margin-left: 6px;
                          margin-right: 6px;
                          gap: 6px;
                          padding: 0 3px;
                          transition: transform 0.2s, color 0.2s;
                      `
          iconDiv.innerHTML = downloadIcon

          iconDiv.addEventListener("mouseenter", () => {
            iconDiv.style.transform = "scale(1.1)"
            iconDiv.style.color = "#1DA1F2"
          })

          iconDiv.addEventListener("mouseleave", () => {
            iconDiv.style.transform = "scale(1)"
            iconDiv.style.color = ""
          })

          iconDiv.addEventListener("click", (e) => {
            e.stopPropagation()
            const username = window.location.pathname.split("/")[1]
            createCustomMenu(username)
          })

          const wrapperDiv = document.createElement("div")
          wrapperDiv.style.cssText = `
                          display: inline-flex;
                          align-items: center;
                          gap: 4px;
                      `
          wrapperDiv.appendChild(iconDiv)

          targetElement.parentNode.insertBefore(wrapperDiv, targetElement.nextSibling)
        }
      }
    })
  }

  function resetState() {
    imageCounter = null
    if (controlPanel?.panel) {
      controlPanel.panel.remove()
      controlPanel = null
    }
  }

  insertDownloadIcon()

  let lastUrl = location.href
  new MutationObserver(() => {
    const url = location.href
    if (url !== lastUrl) {
      lastUrl = url
      resetState()
      setTimeout(insertDownloadIcon, 1000)
    } else {
      insertDownloadIcon()
    }
  }).observe(document.body, {
    childList: true,
    subtree: true,
  })
})()