Greasy Fork

Greasy Fork is available in English.

Twitter/X Media Batch Downloader

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

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

您需要先安装一个扩展,例如 篡改猴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.9
// @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
// @grant        GM_setValue
// @grant        GM_getValue
// @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==

;(() => {
  function createSVGIcon(pathD, viewBox = "0 0 640 512") {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    svg.setAttribute("viewBox", viewBox)
    svg.setAttribute("width", "16")
    svg.setAttribute("height", "16")

    const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
    path.setAttribute("fill", "currentColor")
    path.setAttribute("d", pathD)

    svg.appendChild(path)
    return svg
  }

  const mediaIcon = createSVGIcon(
    "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",
  )

  const imageIcon = createSVGIcon(
    "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",
    "0 0 512 512",
  )

  const videoIcon = createSVGIcon(
    "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",
    "0 0 512 512",
  )

  const zipIcon = createSVGIcon(
    "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",
    "0 0 384 512",
  )

  function createDownloadIcon() {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    svg.setAttribute("viewBox", "0 0 512 512")
    svg.setAttribute("width", "18")
    svg.setAttribute("height", "18")
    svg.style.verticalAlign = "middle"
    svg.style.cursor = "pointer"

    const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs")
    const style = document.createElementNS("http://www.w3.org/2000/svg", "style")
    style.textContent = ".fa-secondary{opacity:.4}"
    defs.appendChild(style)
    svg.appendChild(defs)

    const secondaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
    secondaryPath.setAttribute("class", "fa-secondary")
    secondaryPath.setAttribute("fill", "currentColor")
    secondaryPath.setAttribute(
      "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",
    )
    svg.appendChild(secondaryPath)

    const primaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
    primaryPath.setAttribute("class", "fa-primary")
    primaryPath.setAttribute("fill", "currentColor")
    primaryPath.setAttribute(
      "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.appendChild(primaryPath)

    return svg
  }

  const downloadIcon = createDownloadIcon()

  function createLoadingIcon() {
    const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
    svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
    svg.setAttribute("viewBox", "0 0 24 24")
    svg.setAttribute("width", "20")
    svg.setAttribute("height", "20")
    svg.style.verticalAlign = "middle"

    const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path")
    path1.setAttribute("fill", "currentColor")
    path1.setAttribute("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")
    path1.setAttribute("opacity", "0.25")
    svg.appendChild(path1)

    const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path")
    path2.setAttribute("fill", "currentColor")
    path2.setAttribute(
      "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",
    )

    const animateTransform = document.createElementNS("http://www.w3.org/2000/svg", "animateTransform")
    animateTransform.setAttribute("attributeName", "transform")
    animateTransform.setAttribute("dur", "0.75s")
    animateTransform.setAttribute("repeatCount", "indefinite")
    animateTransform.setAttribute("type", "rotate")
    animateTransform.setAttribute("values", "0 12 12;360 12 12")

    path2.appendChild(animateTransform)
    svg.appendChild(path2)

    return svg
  }

  const loadingIcon = createLoadingIcon()

  let controlPanel = null
  let imageCounter
  let isDownloading = false
  let errorPopup = null

  function createPopup(message, buttons = [], isError = false) {
    if (errorPopup) {
      errorPopup.remove()
    }
  
    const overlay = document.createElement("div")
    overlay.style.cssText = `
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      background-color: rgba(0, 0, 0, 0.5);
      z-index: 10000;
    `
  
    const popup = document.createElement("div")
    popup.style.cssText = `
      position: fixed;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      background-color: rgba(35, 35, 35, 0.9);
      padding: 20px;
      border-radius: 8px;
      z-index: 10001;
      width: 300px;
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
      color: white;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
    `
  
    const title = document.createElement("h3")
    title.textContent = isError ? "Check Your Auth Token!" : "Confirmation"
    title.style.cssText = `
      margin: 0 0 12px 0;
      font-size: 18px;
      font-weight: bold;
      text-align: center;
      color: ${isError ? '#ff4444' : '#ffffff'};
    `
    popup.appendChild(title)
  
    const messageElement = document.createElement("p")
    messageElement.style.cssText = `
      margin: 0 0 12px 0;
      font-size: 14px;
      line-height: 1.4;
      text-align: center;
    `
    
    const messageParts = message.split(/<br\s*\/?>/i)
    messageParts.forEach((part, index) => {
      messageElement.appendChild(document.createTextNode(part))
      
      if (index < messageParts.length - 1) {
        messageElement.appendChild(document.createElement("br"))
      }
    })
    
    popup.appendChild(messageElement)
  
    if (isError) {
      const link = document.createElement("a")
      link.href = "https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader?tab=readme-ov-file#how-to-obtain-auth-token"
      link.target = "_blank"
      link.textContent = "How to Obtain Auth Token"
      link.style.cssText = `
        display: block;
        text-align: center;
        color: #1da1f2;
        text-decoration: none;
        font-size: 14px;
        margin-bottom: 12px;
      `
      popup.appendChild(link)
    }
  
    const buttonsContainer = document.createElement("div")
    buttonsContainer.style.cssText = `
      display: flex;
      gap: 8px;
      justify-content: center;
    `
  
    buttons.forEach(({ text, onClick, color }) => {
      const button = document.createElement("button")
      button.textContent = text
      button.style.cssText = `
        width: 120px;
        padding: 8px;
        background-color: ${color};
        border: none;
        border-radius: 4px;
        color: white;
        font-size: 14px;
        text-align: center;
        cursor: pointer;
        transition: background-color 0.2s;
      `
      
      button.addEventListener("mouseenter", () => {
        button.style.backgroundColor = color === "#1da1f2" ? "#1991db" : "#c82333"
      })
      button.addEventListener("mouseleave", () => {
        button.style.backgroundColor = color
      })
      
      button.addEventListener("click", () => {
        onClick()
        overlay.remove()
        errorPopup = null
      })
      buttonsContainer.appendChild(button)
    })
  
    popup.appendChild(buttonsContainer)
    overlay.appendChild(popup)
    document.body.appendChild(overlay)
    errorPopup = overlay
  
    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) {
        overlay.remove()
        errorPopup = null
      }
    })
  }

  async function fetchMetadata(username, url) {
    const authToken = GM_getValue("auth_token", "")
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: url || `https://twitterxapis.vercel.app/metadata/${username}/${authToken}`,
        headers: { Accept: "application/json" },
        onload: (response) => {
          try {
            if (response.responseText.toLowerCase().startsWith("<!doctype")) {
              reject(new Error("Invalid authentication token"))
              return
            }

            const data = JSON.parse(response.responseText)
            if (data.error === "None") {
              reject(new Error("Invalid authentication token"))
              return
            }
            if (data.timeline) {
              data.timeline = data.timeline.map((item, index) => ({
                ...item,
                tweet_id: item.tweet_id || `${index}`,
              }))
            }
            resolve(data)
          } catch (error) {
            reject(new Error("Invalid authentication token"))
          }
        },
        onerror: () => reject(new Error("Invalid authentication token")),
      })
    })
  }

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

  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.5);
      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.9);
      border-radius: 6px;
      width: 240px;
      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 tokenInput = document.createElement("input")
    tokenInput.type = "text"
    tokenInput.value = GM_getValue("auth_token", "")
    tokenInput.placeholder = "Enter Auth Token"
    tokenInput.style.cssText = `
      width: 100%;
      padding: 8px;
      margin-bottom: 8px;
      background-color: rgba(255, 255, 255, 0.1);
      border: none;
      border-radius: 4px;
      color: white;
      font-size: 14px;
      box-sizing: border-box;
    `
  
    tokenInput.addEventListener("input", (e) => GM_setValue("auth_token", e.target.value))
  
    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}` },
    ]
  
    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;
    `
    menu.appendChild(title)
    menu.appendChild(tokenInput)
  
    const createConfirmationButtons = (metadata, selectedIcon) => {
      const buttonsContainer = document.createElement("div")
      buttonsContainer.style.cssText = `
        display: flex;
        gap: 8px;
        margin-top: 12px;
        justify-content: center;
      `
  
      const downloadButton = document.createElement("button")
      downloadButton.textContent = "Download"
      downloadButton.style.cssText = `
        width: 120px;
        padding: 6px 16px;
        border: none;
        border-radius: 4px;
        background-color: #1da1f2;
        color: white;
        cursor: pointer;
        font-size: 14px;
        text-align: center;
        transition: background-color 0.2s;
      `
      downloadButton.addEventListener("mouseenter", () => downloadButton.style.backgroundColor = "#1991db")
      downloadButton.addEventListener("mouseleave", () => downloadButton.style.backgroundColor = "#1da1f2")
      downloadButton.addEventListener("click", () => {
        createPopup(
          `Do you want to download ${metadata.total_urls} files?`,
          [
            {
              text: "Download",
              onClick: () => {
                menuOverlay.remove()
                controlPanel = createControlPanel()
                imageCounter = controlPanel.counter
                downloadMedia(metadata, selectedIcon)
              },
              color: "#1da1f2"
            },
            {
              text: "Cancel",
              onClick: () => {},
              color: "#dc3545"
            }
          ]
        )
      })
  
      const cancelButton = document.createElement("button")
      cancelButton.textContent = "Cancel"
      cancelButton.style.cssText = `
        width: 120px;
        padding: 6px 16px;
        border: none;
        border-radius: 4px;
        background-color: #dc3545;
        color: white;
        cursor: pointer;
        font-size: 14px;
        text-align: center;
        transition: background-color 0.2s;
      `
      cancelButton.addEventListener("mouseenter", () => cancelButton.style.backgroundColor = "#c82333")
      cancelButton.addEventListener("mouseleave", () => cancelButton.style.backgroundColor = "#dc3545")
      cancelButton.addEventListener("click", () => {
        createPopup(
          "Do you want to cancel?",
          [
            {
              text: "Yes",
              onClick: () => menuOverlay.remove(),
              color: "#dc3545"
            },
            {
              text: "No",
              onClick: () => {},
              color: "#1da1f2"
            }
          ]
        )
      })
  
      buttonsContainer.appendChild(downloadButton)
      buttonsContainer.appendChild(cancelButton)
      return buttonsContainer
    }
  
    options.forEach(({ name, icon, url }) => {
      const button = document.createElement("button")
      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;
      `
  
      const iconContainer = document.createElement("div")
      iconContainer.style.cssText = `
        display: flex;
        align-items: center;
        justify-content: center;
        width: 16px;
        height: 16px;
      `
      const iconClone = icon.cloneNode(true)
      iconContainer.appendChild(iconClone)
  
      const textContainer = document.createElement("div")
      textContainer.style.cssText = `
        display: flex;
        align-items: center;
        gap: 4px;
      `
      const buttonText = document.createTextNode(name)
      textContainer.appendChild(buttonText)
  
      button.appendChild(iconContainer)
      button.appendChild(textContainer)
  
      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 () => {
        try {
          const buttonText = textContainer.firstChild
          const originalText = buttonText.textContent
          buttonText.textContent = "Fetching..."
    
          while (iconContainer.firstChild) {
            iconContainer.removeChild(iconContainer.firstChild)
          }
          iconContainer.appendChild(loadingIcon.cloneNode(true))
    
          const allButtons = menu.querySelectorAll('button')
          allButtons.forEach(btn => btn.disabled = true)
    
          const authToken = GM_getValue("auth_token", "")
          const metadata = await fetchMetadata(username, `${url}/${authToken}`)
    
          while (iconContainer.firstChild) {
            iconContainer.removeChild(iconContainer.firstChild)
          }
          buttonText.textContent = originalText
          const countText = document.createTextNode(` ${metadata.total_urls}`)
          iconContainer.appendChild(icon.cloneNode(true))
          textContainer.appendChild(countText)
    
          const existingButtons = menu.querySelector('.confirmation-buttons')
          if (existingButtons) {
            existingButtons.remove()
          }
    
          const confirmationButtons = createConfirmationButtons(metadata, icon)
          confirmationButtons.classList.add('confirmation-buttons')
          menu.appendChild(confirmationButtons)
    
          allButtons.forEach(btn => btn.disabled = false)
        } catch (error) {
          console.error("Error fetching metadata:", error)
          createPopup(
            "It might be invalid or expired.<br>Also, ensure that your account is still logged in.",
            [
              {
                text: "Close",
                onClick: () => {},
                color: "#1da1f2"
              }
            ],
            true
          )
          
          while (iconContainer.firstChild) {
            iconContainer.removeChild(iconContainer.firstChild)
          }
          const buttonText = textContainer.firstChild
          buttonText.textContent = name
          iconContainer.appendChild(iconClone)
    
          const allButtons = menu.querySelectorAll('button')
          allButtons.forEach(btn => btn.disabled = false)
        }
      })
      menu.appendChild(button)
    })
  
    menuOverlay.appendChild(menu)
    document.body.appendChild(menuOverlay)
    menuOverlay.addEventListener("click", (e) => {
      if (e.target === menuOverlay) menuOverlay.remove()
    })
  }

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

  function formatDate(dateString) {
    const date = new Date(dateString)
    const pad = (num) => String(num).padStart(2, "0")
    return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
  }

  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;
    }
    .cancel-button {
      background-color: #dc3545;
      color: white;
      border: none;
      border-radius: 4px;
      padding: 6px 12px;
      font-size: 12px;
      text-align: center;
      cursor: pointer;
      transition: background-color 0.2s;
      margin-top: 12px;
      display: block;
      margin-left: auto;
      margin-right: auto;
      width: 80px;
    }
    .cancel-button:hover {
      background-color: #c82333;
    }`

    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.appendChild(mediaIcon.cloneNode(true))
    counter.appendChild(document.createTextNode(" 0"))

    const progressContainer = document.createElement("div")
    progressContainer.className = "progress-container"

    const progressBar = document.createElement("div")
    progressBar.className = "progress-bar"

    const progressFill = document.createElement("div")
    progressFill.className = "progress-fill"

    const progressText = document.createElement("div")
    progressText.className = "progress-text"
    progressText.textContent = "0%"

    const cancelButton = document.createElement("button")
    cancelButton.className = "cancel-button"
    cancelButton.textContent = "Cancel"
    cancelButton.addEventListener("click", () => {
      isDownloading = false
      hideControlPanel()
    })

    progressBar.appendChild(progressFill)
    progressContainer.appendChild(progressBar)
    progressContainer.appendChild(progressText)
    progressContainer.appendChild(cancelButton)

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

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

    return { counter, panel }
  }

  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
    }

    if (buttonsContainer?.style) buttonsContainer.style.display = "none"
    progressContainer.style.display = "block"
    while (imageCounter.firstChild) {
      imageCounter.removeChild(imageCounter.firstChild)
    }
    imageCounter.appendChild(icon.cloneNode(true))
    imageCounter.appendChild(document.createTextNode(` ${total_urls}`))

    let successfulDownloads = 0
    const filenameCounts = new Map()
    const batchSize = 5
    const batches = []

    for (let i = 0; i < timeline.length; i += batchSize) {
      if (!isDownloading) {
        console.log("Download cancelled")
        return
      }

      const batch = timeline.slice(i, i + batchSize).map(async ({ url, date }) => {
        if (!isDownloading) return false

        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}%`

          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))
    }

    if (!isDownloading) return

    for (const batch of batches) {
      if (!isDownloading) return
      await batch
    }

    if (successfulDownloads > 0 && isDownloading) {
      while (imageCounter.firstChild) {
        imageCounter.removeChild(imageCounter.firstChild)
      }
      imageCounter.appendChild(zipIcon.cloneNode(true))
      imageCounter.appendChild(document.createTextNode(` ${successfulDownloads}`))

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

      if (isDownloading) {
        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 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.appendChild(downloadIcon.cloneNode(true))

          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,
  })
})()