您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.
当前为
- // ==UserScript==
- // @name Twitter/X Media Batch Downloader
- // @description Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.
- // @icon https://raw.githubusercontent.com/afkarxyz/Twitter-X-Media-Batch-Downloader/refs/heads/main/Archived/icon.svg
- // @version 2.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
- // @grant GM_setValue
- // @grant GM_getValue
- // @connect twitterxapis.vercel.app
- // @connect pbs.twimg.com
- // @connect video.twimg.com
- // @require https://cdn.jsdelivr.net/npm/jszip@3.7.1/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 gifIcon = createSVGIcon(
- "M160 432l192 0 0-112 0-128 0-112L160 80l0 112 0 128 0 112zM112 80L64 80c-8.8 0-16 7.2-16 16l0 72 64 0 0-88zm0 136l-64 0 0 80 64 0 0-80zm0 128l-64 0 0 72c0 8.8 7.2 16 16 16l48 0 0-88zM400 80l0 88 64 0 0-72c0-8.8-7.2-16-16-16l-48 0zm64 136l-64 0 0 80 64 0 0-80zm0 128l-64 0 0 88 48 0c8.8 0 16-7.2 16-16l0-72zM64 32l384 0c35.3 0 64 28.7 64 64l0 320c0 35.3-28.7 64-64 64L64 480c-35.3 0-64-28.7-64-64L0 96C0 60.7 28.7 32 64 32z",
- "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 iconClear = createSVGIcon(
- "M505 41c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0L335 143l-12.9-12.9c-20.2-20.2-51.4-24.6-76.3-10.7L16.4 246.9C6.3 252.5 0 263.2 0 274.8c0 8.5 3.4 16.6 9.3 22.6L214.7 502.7c6 6 14.1 9.3 22.6 9.3c11.6 0 22.3-6.3 27.9-16.4L392.6 266.2c13.9-25 9.5-56.1-10.7-76.3L369 177 505 41zM323.6 291.6l-90 162.1L137 357.1l18-53.9c2.1-6.3-3.9-12.2-10.1-10.1L90.9 311 58.4 278.5l162.1-90L323.6 291.6z",
- "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 getCachedUrls(username) {
- const cache = GM_getValue(`${username}_cache`, {})
- const downloadedUrls = GM_getValue(`${username}_downloaded`, {})
- return { cache, downloadedUrls }
- }
- function cacheUrls(username, metadata) {
- const { downloadedUrls } = getCachedUrls(username)
- const newUrls = []
- let newUrlCount = 0
- metadata.timeline.forEach(item => {
- if (!downloadedUrls[item.tweet_id]) {
- newUrls.push(item)
- newUrlCount++
- }
- })
- return {
- newUrls,
- newUrlCount
- }
- }
- function markUrlsAsDownloaded(username, urls) {
- const { downloadedUrls } = getCachedUrls(username)
- const updatedDownloadedUrls = { ...downloadedUrls }
- urls.forEach(item => {
- updatedDownloadedUrls[item.tweet_id] = true
- })
- GM_setValue(`${username}_downloaded`, updatedDownloadedUrls)
- }
- function clearCache(username) {
- GM_setValue(`${username}_cache`, {})
- GM_setValue(`${username}_downloaded`, {})
- }
- 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", () => {
- let hoverColor = color
- if (color === "#28a745") hoverColor = "#218838"
- else if (color === "#1da1f2") hoverColor = "#1991db"
- else if (color === "#dc3545") hoverColor = "#c82333"
- button.style.backgroundColor = hoverColor
- })
- 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 raw = response.responseText
- const data = JSON.parse(raw.replace(/"tweet_id":(\d+)/g, '"tweet_id":"$1"'))
- 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 formatNumber(num) {
- return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
- }
- 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: "GIF", icon: gifIcon, url: `https://twitterxapis.vercel.app/metadata/gif/${username}` },
- { name: "Video", icon: videoIcon, url: `https://twitterxapis.vercel.app/metadata/video/${username}` },
- {
- name: "Clear Cache",
- icon: iconClear,
- action: () => {
- createPopup(
- "Are you sure you want to clear the cache?",
- [
- {
- text: "Yes",
- onClick: () => {
- clearCache(username)
- createPopup(
- "Cache cleared successfully!",
- [
- {
- text: "OK",
- onClick: () => {},
- color: "#1da1f2"
- }
- ]
- )
- },
- color: "#dc3545"
- },
- {
- text: "No",
- onClick: () => {},
- color: "#1da1f2"
- }
- ]
- )
- }
- }
- ]
- 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)
- options.forEach(({ name, icon, url, action }) => {
- 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 () => {
- if (action) {
- action()
- return
- }
- 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}`)
- const { newUrls, newUrlCount } = cacheUrls(username, metadata)
- const isFirstDownload = newUrlCount === metadata.total_urls || newUrls.length === 0
- while (iconContainer.firstChild) {
- iconContainer.removeChild(iconContainer.firstChild)
- }
- buttonText.textContent = originalText
- const countText = document.createTextNode(` ${formatNumber(metadata.total_urls)}`)
- iconContainer.appendChild(icon.cloneNode(true))
- textContainer.appendChild(countText)
- const existingButtons = menu.querySelector('.confirmation-buttons')
- if (existingButtons) {
- existingButtons.remove()
- }
- const downloadOptions = document.createElement('div')
- downloadOptions.className = 'confirmation-buttons'
- downloadOptions.style.cssText = `
- display: flex;
- flex-direction: column;
- gap: 8px;
- margin-top: 12px;
- `
- if (newUrlCount > 0 && !isFirstDownload) {
- const downloadNewButton = document.createElement("button")
- downloadNewButton.textContent = `Download New (${formatNumber(newUrlCount)})`
- downloadNewButton.style.cssText = `
- width: 100%;
- padding: 6px 16px;
- border: none;
- border-radius: 4px;
- background-color: #28a745;
- color: white;
- cursor: pointer;
- font-size: 14px;
- text-align: center;
- transition: background-color 0.2s;
- `
- downloadNewButton.addEventListener("mouseenter", () => downloadNewButton.style.backgroundColor = "#218838")
- downloadNewButton.addEventListener("mouseleave", () => downloadNewButton.style.backgroundColor = "#28a745")
- downloadNewButton.addEventListener("click", () => {
- createPopup(
- `Do you want to download ${formatNumber(newUrlCount)} new files?`,
- [
- {
- text: "Download",
- onClick: () => {
- const newMetadata = {
- ...metadata,
- timeline: newUrls,
- total_urls: newUrlCount
- }
- menuOverlay.remove()
- controlPanel = createControlPanel()
- imageCounter = controlPanel.counter
- downloadMedia(newMetadata, icon, username)
- },
- color: "#28a745"
- },
- {
- text: "Cancel",
- onClick: () => {},
- color: "#dc3545"
- }
- ]
- )
- })
- downloadOptions.appendChild(downloadNewButton)
- }
- const downloadAllButton = document.createElement("button")
- downloadAllButton.textContent = isFirstDownload || !newUrls.length
- ? `Download (${formatNumber(metadata.total_urls)})`
- : `Download All (${formatNumber(metadata.total_urls)})`
- downloadAllButton.style.cssText = `
- width: 100%;
- 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;
- `
- downloadAllButton.addEventListener("mouseenter", () => downloadAllButton.style.backgroundColor = "#1991db")
- downloadAllButton.addEventListener("mouseleave", () => downloadAllButton.style.backgroundColor = "#1da1f2")
- downloadAllButton.addEventListener("click", () => {
- createPopup(
- `Do you want to download ${formatNumber(metadata.total_urls)} files?`,
- [
- {
- text: "Download",
- onClick: () => {
- menuOverlay.remove()
- controlPanel = createControlPanel()
- imageCounter = controlPanel.counter
- downloadMedia(metadata, icon, username)
- },
- color: "#1da1f2"
- },
- {
- text: "Cancel",
- onClick: () => {},
- color: "#dc3545"
- }
- ]
- )
- })
- downloadOptions.appendChild(downloadAllButton)
- const cancelButton = document.createElement("button")
- cancelButton.textContent = "Cancel"
- cancelButton.style.cssText = `
- width: 100%;
- 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", () => menuOverlay.remove())
- downloadOptions.appendChild(cancelButton)
- menu.appendChild(downloadOptions)
- 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) {
- if (url.includes("video.twimg.com")) return ".mp4"
- if (url.includes(".gif")) return ".gif"
- return ".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 }
- }
- function formatDownloadDate() {
- const now = new Date()
- const year = now.getFullYear()
- const month = String(now.getMonth() + 1).padStart(2, '0')
- const day = String(now.getDate()).padStart(2, '0')
- const hours = String(now.getHours()).padStart(2, '0')
- const minutes = String(now.getMinutes()).padStart(2, '0')
- const seconds = String(now.getSeconds()).padStart(2, '0')
- return `${year}${month}${day}_${hours}${minutes}${seconds}`
- }
- async function downloadMedia(metadata, icon, username) {
- if (isDownloading || !controlPanel?.panel) return
- isDownloading = true
- const { account_info, timeline, total_urls } = metadata
- const { name } = account_info
- const BATCH_SIZE = 5
- const FILES_PER_ZIP = 500
- 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(` ${formatNumber(total_urls)}`))
- let successfulDownloads = []
- const filenameCounts = new Map()
- const batches = []
- for (let i = 0; i < timeline.length; i += BATCH_SIZE) {
- if (!isDownloading) {
- console.log("Download cancelled")
- return
- }
- const batch = timeline.slice(i, i + BATCH_SIZE).map(async (item) => {
- if (!isDownloading) return false
- try {
- const blob = await downloadFile(item.url)
- const fileExt = getFileExtension(item.url)
- const formattedDate = formatDate(item.date)
- const baseFileName = `${name}_${formattedDate}_${item.tweet_id}`
- 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)
- }
- successfulDownloads.push({ blob, fileName, item })
- const progress = Math.round((successfulDownloads.length / total_urls) * 100)
- progressFill.style.width = `${progress}%`
- progressText.textContent = `Downloading: (${formatNumber(successfulDownloads.length)}/${formatNumber(total_urls)}) ${progress}%`
- return true
- } catch (error) {
- console.error("Error downloading media:", error, item.url)
- return false
- }
- })
- batches.push(Promise.all(batch))
- await new Promise((resolve) => setTimeout(resolve, 100))
- }
- for (const batch of batches) {
- if (!isDownloading) return
- await batch
- }
- if (successfulDownloads.length > 0 && isDownloading) {
- markUrlsAsDownloaded(username, successfulDownloads.map(download => download.item))
- while (imageCounter.firstChild) {
- imageCounter.removeChild(imageCounter.firstChild)
- }
- imageCounter.appendChild(zipIcon.cloneNode(true))
- imageCounter.appendChild(document.createTextNode(` ${formatNumber(successfulDownloads.length)}`))
- if (successfulDownloads.length === 1) {
- const { blob, fileName } = successfulDownloads[0]
- const downloadUrl = URL.createObjectURL(blob)
- const a = document.createElement("a")
- a.href = downloadUrl
- a.download = fileName
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(downloadUrl)
- } else {
- const totalParts = Math.ceil(successfulDownloads.length / FILES_PER_ZIP)
- const downloadDate = formatDownloadDate()
- for (let partIndex = 0; partIndex < totalParts; partIndex++) {
- if (!isDownloading) return
- const startIndex = partIndex * FILES_PER_ZIP
- const endIndex = Math.min((partIndex + 1) * FILES_PER_ZIP, successfulDownloads.length)
- const partFiles = successfulDownloads.slice(startIndex, endIndex)
- const zip = new JSZip()
- partFiles.forEach(({ blob, fileName }) => {
- zip.file(fileName, blob)
- })
- 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) * partFiles.length)
- const totalProcessed = startIndex + processedFiles
- progressFill.style.width = `${progress}%`
- progressText.textContent = `Creating ZIP Part ${partIndex + 1}/${totalParts}: (${formatNumber(processedFiles)}/${formatNumber(partFiles.length)}) ${progress}%`
- },
- )
- if (isDownloading) {
- const downloadUrl = URL.createObjectURL(zipBlob)
- const partSuffix = totalParts > 1 ? `_Part_${String(partIndex + 1).padStart(2, '0')}` : ''
- const a = document.createElement("a")
- a.href = downloadUrl
- a.download = `${name}_${downloadDate}_${successfulDownloads.length}${partSuffix}.zip`
- document.body.appendChild(a)
- a.click()
- document.body.removeChild(a)
- URL.revokeObjectURL(downloadUrl)
- await new Promise(resolve => setTimeout(resolve, 1000))
- }
- }
- }
- }
- 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 extractUsername() {
- const pathParts = window.location.pathname.split('/').filter(part => part);
- if (pathParts.length > 0) {
- return pathParts[0];
- }
- return null;
- }
- function insertDownloadIcon() {
- const usernameDivs = document.querySelectorAll('[data-testid="UserName"]')
- usernameDivs.forEach((usernameDiv) => {
- if (!usernameDiv.querySelector(".download-icon")) {
- const username = extractUsername()
- if (!username) return
- 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()
- 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或关注我们的公众号极客氢云获取最新地址