Twitter/X Media Batch Downloader

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

目前为 2025-01-20 提交的版本。查看 最新版本

// ==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.6
// @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: 300px;
              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;
              box-sizing: border-box;
          `;

    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 tokenContainer = document.createElement("div");
    tokenContainer.style.cssText = `
              margin-bottom: 15px;
              padding: 10px;
              background-color: rgba(255, 255, 255, 0.1);
              border-radius: 4px;
              box-sizing: border-box;
              width: 100%;
          `;

    const tokenLabel = document.createElement("label");
    tokenLabel.textContent = "Auth Token";
    tokenLabel.style.cssText = `
              display: block;
              margin-bottom: 5px;
              color: white;
              text-align: center;
              font-size: 14px;
              word-wrap: break-word;
          `;

    const tokenInput = document.createElement("input");
    tokenInput.type = "text";
    tokenInput.placeholder = "Enter Your Auth Token";
    tokenInput.value = localStorage.getItem("twitter_auth_token") || "";
    tokenInput.style.cssText = `
              width: 100%;
              padding: 8px;
              border: none;
              border-radius: 4px;
              background-color: rgba(35, 35, 35, 0.9);
              color: white;
              font-size: 14px;
              box-sizing: border-box;
              overflow-wrap: break-word;
              word-wrap: break-word;
              word-break: break-all;
          `;

    tokenInput.addEventListener("input", (e) => {
      const value = e.target.value.trim();
      if (value === "") {
        localStorage.removeItem("twitter_auth_token");
      } else {
        localStorage.setItem("twitter_auth_token", value);
      }
    });

    tokenInput.addEventListener("blur", (e) => {
      const value = e.target.value.trim();
      if (value === "") {
        localStorage.removeItem("twitter_auth_token");
      } else {
        localStorage.setItem("twitter_auth_token", value);
      }
    });

    tokenContainer.appendChild(tokenLabel);
    tokenContainer.appendChild(tokenInput);

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

    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 () => {
        const token = tokenInput.value.trim();
        if (!token) {
          alert("Please Enter an Auth Token");
          return;
        }

        menuOverlay.remove();
        try {
          const iconDiv = document.querySelector(".download-icon");
          if (iconDiv) {
            iconDiv.innerHTML = loadingIcon;
          }
          const metadata = await fetchMetadata(username, option.getUrl(token));
          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);
          localStorage.removeItem("twitter_auth_token");
          alert(
            "Failed to fetch media data. Please check your Auth Token and try again."
          );
          const iconDiv = document.querySelector(".download-icon");
          if (iconDiv) {
            iconDiv.innerHTML = downloadIcon;
          }
        }
      });
      menu.appendChild(button);
    });

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

    if (!tokenInput.value) {
      tokenInput.focus();
    }

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

QingJ © 2025

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