Twitter/X Media Batch Downloader

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

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

  1. // ==UserScript==
  2. // @name Twitter/X Media Batch Downloader
  3. // @description Batch download all images and videos from a Twitter/X account, including withheld accounts, in original quality.
  4. // @icon https://raw.githubusercontent.com/afkarxyz/Twitter-X-Media-Batch-Downloader/refs/heads/main/Archived/icon.svg
  5. // @version 2.5
  6. // @author afkarxyz
  7. // @namespace https://github.com/afkarxyz/misc-scripts/
  8. // @supportURL https://github.com/afkarxyz/misc-scripts/issues
  9. // @license MIT
  10. // @match https://twitter.com/*
  11. // @match https://x.com/*
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_setValue
  14. // @grant GM_getValue
  15. // @connect twitterxapis.vercel.app
  16. // @connect pbs.twimg.com
  17. // @connect video.twimg.com
  18. // @require https://cdn.jsdelivr.net/npm/jszip@3.7.1/dist/jszip.min.js
  19. // ==/UserScript==
  20.  
  21. ;(() => {
  22. function createSVGIcon(pathD, viewBox = "0 0 640 512") {
  23. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  24. svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
  25. svg.setAttribute("viewBox", viewBox)
  26. svg.setAttribute("width", "16")
  27. svg.setAttribute("height", "16")
  28.  
  29. const path = document.createElementNS("http://www.w3.org/2000/svg", "path")
  30. path.setAttribute("fill", "currentColor")
  31. path.setAttribute("d", pathD)
  32.  
  33. svg.appendChild(path)
  34. return svg
  35. }
  36.  
  37. const mediaIcon = createSVGIcon(
  38. "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",
  39. )
  40.  
  41. const imageIcon = createSVGIcon(
  42. "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",
  43. "0 0 512 512",
  44. )
  45.  
  46. const gifIcon = createSVGIcon(
  47. "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",
  48. "0 0 512 512"
  49. )
  50.  
  51. const videoIcon = createSVGIcon(
  52. "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",
  53. "0 0 512 512",
  54. )
  55.  
  56. const iconClear = createSVGIcon(
  57. "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",
  58. "0 0 512 512"
  59. )
  60.  
  61. const zipIcon = createSVGIcon(
  62. "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",
  63. "0 0 384 512",
  64. )
  65.  
  66. function createDownloadIcon() {
  67. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  68. svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
  69. svg.setAttribute("viewBox", "0 0 512 512")
  70. svg.setAttribute("width", "18")
  71. svg.setAttribute("height", "18")
  72. svg.style.verticalAlign = "middle"
  73. svg.style.cursor = "pointer"
  74.  
  75. const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs")
  76. const style = document.createElementNS("http://www.w3.org/2000/svg", "style")
  77. style.textContent = ".fa-secondary{opacity:.4}"
  78. defs.appendChild(style)
  79. svg.appendChild(defs)
  80.  
  81. const secondaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
  82. secondaryPath.setAttribute("class", "fa-secondary")
  83. secondaryPath.setAttribute("fill", "currentColor")
  84. secondaryPath.setAttribute(
  85. "d",
  86. "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",
  87. )
  88. svg.appendChild(secondaryPath)
  89.  
  90. const primaryPath = document.createElementNS("http://www.w3.org/2000/svg", "path")
  91. primaryPath.setAttribute("class", "fa-primary")
  92. primaryPath.setAttribute("fill", "currentColor")
  93. primaryPath.setAttribute(
  94. "d",
  95. "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",
  96. )
  97. svg.appendChild(primaryPath)
  98.  
  99. return svg
  100. }
  101.  
  102. const downloadIcon = createDownloadIcon()
  103.  
  104. function createLoadingIcon() {
  105. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  106. svg.setAttribute("xmlns", "http://www.w3.org/2000/svg")
  107. svg.setAttribute("viewBox", "0 0 24 24")
  108. svg.setAttribute("width", "20")
  109. svg.setAttribute("height", "20")
  110. svg.style.verticalAlign = "middle"
  111.  
  112. const path1 = document.createElementNS("http://www.w3.org/2000/svg", "path")
  113. path1.setAttribute("fill", "currentColor")
  114. 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")
  115. path1.setAttribute("opacity", "0.25")
  116. svg.appendChild(path1)
  117.  
  118. const path2 = document.createElementNS("http://www.w3.org/2000/svg", "path")
  119. path2.setAttribute("fill", "currentColor")
  120. path2.setAttribute(
  121. "d",
  122. "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",
  123. )
  124.  
  125. const animateTransform = document.createElementNS("http://www.w3.org/2000/svg", "animateTransform")
  126. animateTransform.setAttribute("attributeName", "transform")
  127. animateTransform.setAttribute("dur", "0.75s")
  128. animateTransform.setAttribute("repeatCount", "indefinite")
  129. animateTransform.setAttribute("type", "rotate")
  130. animateTransform.setAttribute("values", "0 12 12;360 12 12")
  131.  
  132. path2.appendChild(animateTransform)
  133. svg.appendChild(path2)
  134.  
  135. return svg
  136. }
  137.  
  138. const loadingIcon = createLoadingIcon()
  139.  
  140. let controlPanel = null
  141. let imageCounter
  142. let isDownloading = false
  143. let errorPopup = null
  144.  
  145. function getCachedUrls(username) {
  146. const cache = GM_getValue(`${username}_cache`, {})
  147. const downloadedUrls = GM_getValue(`${username}_downloaded`, {})
  148. return { cache, downloadedUrls }
  149. }
  150. function cacheUrls(username, metadata) {
  151. const { downloadedUrls } = getCachedUrls(username)
  152. const newUrls = []
  153. let newUrlCount = 0
  154. metadata.timeline.forEach(item => {
  155. if (!downloadedUrls[item.tweet_id]) {
  156. newUrls.push(item)
  157. newUrlCount++
  158. }
  159. })
  160. return {
  161. newUrls,
  162. newUrlCount
  163. }
  164. }
  165. function markUrlsAsDownloaded(username, urls) {
  166. const { downloadedUrls } = getCachedUrls(username)
  167. const updatedDownloadedUrls = { ...downloadedUrls }
  168. urls.forEach(item => {
  169. updatedDownloadedUrls[item.tweet_id] = true
  170. })
  171. GM_setValue(`${username}_downloaded`, updatedDownloadedUrls)
  172. }
  173. function clearCache(username) {
  174. GM_setValue(`${username}_cache`, {})
  175. GM_setValue(`${username}_downloaded`, {})
  176. }
  177.  
  178. function createPopup(message, buttons = [], isError = false) {
  179. if (errorPopup) {
  180. errorPopup.remove()
  181. }
  182.  
  183. const overlay = document.createElement("div")
  184. overlay.style.cssText = `
  185. position: fixed;
  186. top: 0;
  187. left: 0;
  188. width: 100%;
  189. height: 100%;
  190. background-color: rgba(0, 0, 0, 0.5);
  191. z-index: 10000;
  192. `
  193.  
  194. const popup = document.createElement("div")
  195. popup.style.cssText = `
  196. position: fixed;
  197. top: 50%;
  198. left: 50%;
  199. transform: translate(-50%, -50%);
  200. background-color: rgba(35, 35, 35, 0.9);
  201. padding: 20px;
  202. border-radius: 8px;
  203. z-index: 10001;
  204. width: 300px;
  205. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  206. color: white;
  207. box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  208. `
  209.  
  210. const title = document.createElement("h3")
  211. title.textContent = isError ? "Check Your Auth Token!" : "Confirmation"
  212. title.style.cssText = `
  213. margin: 0 0 12px 0;
  214. font-size: 18px;
  215. font-weight: bold;
  216. text-align: center;
  217. color: ${isError ? '#ff4444' : '#ffffff'};
  218. `
  219. popup.appendChild(title)
  220.  
  221. const messageElement = document.createElement("p")
  222. messageElement.style.cssText = `
  223. margin: 0 0 12px 0;
  224. font-size: 14px;
  225. line-height: 1.4;
  226. text-align: center;
  227. `
  228.  
  229. const messageParts = message.split(/<br\s*\/?>/i)
  230. messageParts.forEach((part, index) => {
  231. messageElement.appendChild(document.createTextNode(part))
  232.  
  233. if (index < messageParts.length - 1) {
  234. messageElement.appendChild(document.createElement("br"))
  235. }
  236. })
  237.  
  238. popup.appendChild(messageElement)
  239.  
  240. if (isError) {
  241. const link = document.createElement("a")
  242. link.href = "https://github.com/afkarxyz/Twitter-X-Media-Batch-Downloader?tab=readme-ov-file#how-to-obtain-auth-token"
  243. link.target = "_blank"
  244. link.textContent = "How to Obtain Auth Token"
  245. link.style.cssText = `
  246. display: block;
  247. text-align: center;
  248. color: #1da1f2;
  249. text-decoration: none;
  250. font-size: 14px;
  251. margin-bottom: 12px;
  252. `
  253. popup.appendChild(link)
  254. }
  255.  
  256. const buttonsContainer = document.createElement("div")
  257. buttonsContainer.style.cssText = `
  258. display: flex;
  259. gap: 8px;
  260. justify-content: center;
  261. `
  262.  
  263. buttons.forEach(({ text, onClick, color }) => {
  264. const button = document.createElement("button")
  265. button.textContent = text
  266. button.style.cssText = `
  267. width: 120px;
  268. padding: 8px;
  269. background-color: ${color};
  270. border: none;
  271. border-radius: 4px;
  272. color: white;
  273. font-size: 14px;
  274. text-align: center;
  275. cursor: pointer;
  276. transition: background-color 0.2s;
  277. `
  278. button.addEventListener("mouseenter", () => {
  279. let hoverColor = color
  280. if (color === "#28a745") hoverColor = "#218838"
  281. else if (color === "#1da1f2") hoverColor = "#1991db"
  282. else if (color === "#dc3545") hoverColor = "#c82333"
  283. button.style.backgroundColor = hoverColor
  284. })
  285. button.addEventListener("mouseleave", () => {
  286. button.style.backgroundColor = color
  287. })
  288. button.addEventListener("click", () => {
  289. onClick()
  290. overlay.remove()
  291. errorPopup = null
  292. })
  293. buttonsContainer.appendChild(button)
  294. })
  295.  
  296. popup.appendChild(buttonsContainer)
  297. overlay.appendChild(popup)
  298. document.body.appendChild(overlay)
  299. errorPopup = overlay
  300.  
  301. overlay.addEventListener("click", (e) => {
  302. if (e.target === overlay) {
  303. overlay.remove()
  304. errorPopup = null
  305. }
  306. })
  307. }
  308.  
  309. async function fetchMetadata(username, url) {
  310. const authToken = GM_getValue("auth_token", "")
  311. return new Promise((resolve, reject) => {
  312. GM_xmlhttpRequest({
  313. method: "GET",
  314. url: url || `https://twitterxapis.vercel.app/metadata/${username}/${authToken}`,
  315. headers: { Accept: "application/json" },
  316. onload: (response) => {
  317. try {
  318. if (response.responseText.toLowerCase().startsWith("<!doctype")) {
  319. reject(new Error("Invalid authentication token"))
  320. return
  321. }
  322. const raw = response.responseText
  323. const data = JSON.parse(raw.replace(/"tweet_id":(\d+)/g, '"tweet_id":"$1"'))
  324. if (data.error === "None") {
  325. reject(new Error("Invalid authentication token"))
  326. return
  327. }
  328. if (data.timeline) {
  329. data.timeline = data.timeline.map((item, index) => ({
  330. ...item,
  331. tweet_id: item.tweet_id || `${index}`
  332. }))
  333. }
  334. resolve(data)
  335. } catch (error) {
  336. reject(new Error("Invalid authentication token"))
  337. }
  338. },
  339. onerror: () => reject(new Error("Invalid authentication token")),
  340. })
  341. })
  342. }
  343.  
  344. async function downloadFile(url) {
  345. return new Promise((resolve, reject) => {
  346. GM_xmlhttpRequest({
  347. method: "GET",
  348. url,
  349. responseType: "blob",
  350. headers: { Accept: "image/jpeg,image/*,video/*" },
  351. onload: (response) => resolve(response.response),
  352. onerror: reject,
  353. })
  354. })
  355. }
  356.  
  357. function formatNumber(num) {
  358. return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
  359. }
  360. function createCustomMenu(username) {
  361. const menuOverlay = document.createElement("div")
  362. menuOverlay.style.cssText = `
  363. position: fixed;
  364. top: 0;
  365. left: 0;
  366. width: 100%;
  367. height: 100%;
  368. background-color: rgba(0, 0, 0, 0.5);
  369. display: flex;
  370. justify-content: center;
  371. align-items: center;
  372. z-index: 10000;
  373. `
  374.  
  375. const menu = document.createElement("div")
  376. menu.style.cssText = `
  377. background-color: rgba(35, 35, 35, 0.9);
  378. border-radius: 6px;
  379. width: 240px;
  380. padding: 12px;
  381. box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  382. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  383. `
  384.  
  385. const tokenInput = document.createElement("input")
  386. tokenInput.type = "text"
  387. tokenInput.value = GM_getValue("auth_token", "")
  388. tokenInput.placeholder = "Enter Auth Token"
  389. tokenInput.style.cssText = `
  390. width: 100%;
  391. padding: 8px;
  392. margin-bottom: 8px;
  393. background-color: rgba(255, 255, 255, 0.1);
  394. border: none;
  395. border-radius: 4px;
  396. color: white;
  397. font-size: 14px;
  398. box-sizing: border-box;
  399. `
  400.  
  401. tokenInput.addEventListener("input", (e) => GM_setValue("auth_token", e.target.value))
  402.  
  403. const options = [
  404. { name: "Media", icon: mediaIcon, url: `https://twitterxapis.vercel.app/metadata/${username}` },
  405. { name: "Image", icon: imageIcon, url: `https://twitterxapis.vercel.app/metadata/image/${username}` },
  406. { name: "GIF", icon: gifIcon, url: `https://twitterxapis.vercel.app/metadata/gif/${username}` },
  407. { name: "Video", icon: videoIcon, url: `https://twitterxapis.vercel.app/metadata/video/${username}` },
  408. {
  409. name: "Clear Cache",
  410. icon: iconClear,
  411. action: () => {
  412. createPopup(
  413. "Are you sure you want to clear the cache?",
  414. [
  415. {
  416. text: "Yes",
  417. onClick: () => {
  418. clearCache(username)
  419. createPopup(
  420. "Cache cleared successfully!",
  421. [
  422. {
  423. text: "OK",
  424. onClick: () => {},
  425. color: "#1da1f2"
  426. }
  427. ]
  428. )
  429. },
  430. color: "#dc3545"
  431. },
  432. {
  433. text: "No",
  434. onClick: () => {},
  435. color: "#1da1f2"
  436. }
  437. ]
  438. )
  439. }
  440. }
  441. ]
  442.  
  443. const title = document.createElement("h2")
  444. title.textContent = "Download Options"
  445. title.style.cssText = `
  446. margin-top: 0;
  447. margin-bottom: 15px;
  448. font-size: 16px;
  449. font-weight: bold;
  450. color: white;
  451. text-align: center;
  452. `
  453. menu.appendChild(title)
  454. menu.appendChild(tokenInput)
  455.  
  456. options.forEach(({ name, icon, url, action }) => {
  457. const button = document.createElement("button")
  458. button.style.cssText = `
  459. display: flex;
  460. align-items: center;
  461. gap: 10px;
  462. margin-bottom: 10px;
  463. padding: 10px;
  464. width: 100%;
  465. border: none;
  466. background-color: rgba(255, 255, 255, 0.1);
  467. color: white;
  468. border-radius: 4px;
  469. cursor: pointer;
  470. transition: background-color 0.2s;
  471. font-size: 14px;
  472. `
  473.  
  474. const iconContainer = document.createElement("div")
  475. iconContainer.style.cssText = `
  476. display: flex;
  477. align-items: center;
  478. justify-content: center;
  479. width: 16px;
  480. height: 16px;
  481. `
  482. const iconClone = icon.cloneNode(true)
  483. iconContainer.appendChild(iconClone)
  484.  
  485. const textContainer = document.createElement("div")
  486. textContainer.style.cssText = `
  487. display: flex;
  488. align-items: center;
  489. gap: 4px;
  490. `
  491. const buttonText = document.createTextNode(name)
  492. textContainer.appendChild(buttonText)
  493.  
  494. button.appendChild(iconContainer)
  495. button.appendChild(textContainer)
  496.  
  497. button.addEventListener("mouseenter", () => (button.style.backgroundColor = "rgba(255, 255, 255, 0.2)"))
  498. button.addEventListener("mouseleave", () => (button.style.backgroundColor = "rgba(255, 255, 255, 0.1)"))
  499.  
  500. button.addEventListener("click", async () => {
  501. if (action) {
  502. action()
  503. return
  504. }
  505.  
  506. try {
  507. const buttonText = textContainer.firstChild
  508. const originalText = buttonText.textContent
  509. buttonText.textContent = "Fetching..."
  510.  
  511. while (iconContainer.firstChild) {
  512. iconContainer.removeChild(iconContainer.firstChild)
  513. }
  514. iconContainer.appendChild(loadingIcon.cloneNode(true))
  515.  
  516. const allButtons = menu.querySelectorAll('button')
  517. allButtons.forEach(btn => btn.disabled = true)
  518.  
  519. const authToken = GM_getValue("auth_token", "")
  520. const metadata = await fetchMetadata(username, `${url}/${authToken}`)
  521. const { newUrls, newUrlCount } = cacheUrls(username, metadata)
  522.  
  523. const isFirstDownload = newUrlCount === metadata.total_urls || newUrls.length === 0
  524.  
  525. while (iconContainer.firstChild) {
  526. iconContainer.removeChild(iconContainer.firstChild)
  527. }
  528. buttonText.textContent = originalText
  529. const countText = document.createTextNode(` ${formatNumber(metadata.total_urls)}`)
  530. iconContainer.appendChild(icon.cloneNode(true))
  531. textContainer.appendChild(countText)
  532.  
  533. const existingButtons = menu.querySelector('.confirmation-buttons')
  534. if (existingButtons) {
  535. existingButtons.remove()
  536. }
  537.  
  538. const downloadOptions = document.createElement('div')
  539. downloadOptions.className = 'confirmation-buttons'
  540. downloadOptions.style.cssText = `
  541. display: flex;
  542. flex-direction: column;
  543. gap: 8px;
  544. margin-top: 12px;
  545. `
  546. if (newUrlCount > 0 && !isFirstDownload) {
  547. const downloadNewButton = document.createElement("button")
  548. downloadNewButton.textContent = `Download New (${formatNumber(newUrlCount)})`
  549. downloadNewButton.style.cssText = `
  550. width: 100%;
  551. padding: 6px 16px;
  552. border: none;
  553. border-radius: 4px;
  554. background-color: #28a745;
  555. color: white;
  556. cursor: pointer;
  557. font-size: 14px;
  558. text-align: center;
  559. transition: background-color 0.2s;
  560. `
  561. downloadNewButton.addEventListener("mouseenter", () => downloadNewButton.style.backgroundColor = "#218838")
  562. downloadNewButton.addEventListener("mouseleave", () => downloadNewButton.style.backgroundColor = "#28a745")
  563. downloadNewButton.addEventListener("click", () => {
  564. createPopup(
  565. `Do you want to download ${formatNumber(newUrlCount)} new files?`,
  566. [
  567. {
  568. text: "Download",
  569. onClick: () => {
  570. const newMetadata = {
  571. ...metadata,
  572. timeline: newUrls,
  573. total_urls: newUrlCount
  574. }
  575. menuOverlay.remove()
  576. controlPanel = createControlPanel()
  577. imageCounter = controlPanel.counter
  578. downloadMedia(newMetadata, icon, username)
  579. },
  580. color: "#28a745"
  581. },
  582. {
  583. text: "Cancel",
  584. onClick: () => {},
  585. color: "#dc3545"
  586. }
  587. ]
  588. )
  589. })
  590. downloadOptions.appendChild(downloadNewButton)
  591. }
  592. const downloadAllButton = document.createElement("button")
  593. downloadAllButton.textContent = isFirstDownload || !newUrls.length
  594. ? `Download (${formatNumber(metadata.total_urls)})`
  595. : `Download All (${formatNumber(metadata.total_urls)})`
  596. downloadAllButton.style.cssText = `
  597. width: 100%;
  598. padding: 6px 16px;
  599. border: none;
  600. border-radius: 4px;
  601. background-color: #1da1f2;
  602. color: white;
  603. cursor: pointer;
  604. font-size: 14px;
  605. text-align: center;
  606. transition: background-color 0.2s;
  607. `
  608. downloadAllButton.addEventListener("mouseenter", () => downloadAllButton.style.backgroundColor = "#1991db")
  609. downloadAllButton.addEventListener("mouseleave", () => downloadAllButton.style.backgroundColor = "#1da1f2")
  610. downloadAllButton.addEventListener("click", () => {
  611. createPopup(
  612. `Do you want to download ${formatNumber(metadata.total_urls)} files?`,
  613. [
  614. {
  615. text: "Download",
  616. onClick: () => {
  617. menuOverlay.remove()
  618. controlPanel = createControlPanel()
  619. imageCounter = controlPanel.counter
  620. downloadMedia(metadata, icon, username)
  621. },
  622. color: "#1da1f2"
  623. },
  624. {
  625. text: "Cancel",
  626. onClick: () => {},
  627. color: "#dc3545"
  628. }
  629. ]
  630. )
  631. })
  632. downloadOptions.appendChild(downloadAllButton)
  633.  
  634. const cancelButton = document.createElement("button")
  635. cancelButton.textContent = "Cancel"
  636. cancelButton.style.cssText = `
  637. width: 100%;
  638. padding: 6px 16px;
  639. border: none;
  640. border-radius: 4px;
  641. background-color: #dc3545;
  642. color: white;
  643. cursor: pointer;
  644. font-size: 14px;
  645. text-align: center;
  646. transition: background-color 0.2s;
  647. `
  648. cancelButton.addEventListener("mouseenter", () => cancelButton.style.backgroundColor = "#c82333")
  649. cancelButton.addEventListener("mouseleave", () => cancelButton.style.backgroundColor = "#dc3545")
  650. cancelButton.addEventListener("click", () => menuOverlay.remove())
  651. downloadOptions.appendChild(cancelButton)
  652.  
  653. menu.appendChild(downloadOptions)
  654. allButtons.forEach(btn => btn.disabled = false)
  655.  
  656. } catch (error) {
  657. console.error("Error fetching metadata:", error)
  658. createPopup(
  659. "It might be invalid or expired.<br>Also, ensure that your account is still logged in.",
  660. [
  661. {
  662. text: "Close",
  663. onClick: () => {},
  664. color: "#1da1f2"
  665. }
  666. ],
  667. true
  668. )
  669.  
  670. while (iconContainer.firstChild) {
  671. iconContainer.removeChild(iconContainer.firstChild)
  672. }
  673. const buttonText = textContainer.firstChild
  674. buttonText.textContent = name
  675. iconContainer.appendChild(iconClone)
  676.  
  677. const allButtons = menu.querySelectorAll('button')
  678. allButtons.forEach(btn => btn.disabled = false)
  679. }
  680. })
  681. menu.appendChild(button)
  682. })
  683.  
  684. menuOverlay.appendChild(menu)
  685. document.body.appendChild(menuOverlay)
  686. menuOverlay.addEventListener("click", (e) => {
  687. if (e.target === menuOverlay) menuOverlay.remove()
  688. })
  689. }
  690.  
  691. function getFileExtension(url) {
  692. if (url.includes("video.twimg.com")) return ".mp4"
  693. if (url.includes(".gif")) return ".gif"
  694. return ".jpg"
  695. }
  696.  
  697. function formatDate(dateString) {
  698. const date = new Date(dateString)
  699. const pad = (num) => String(num).padStart(2, "0")
  700. return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
  701. }
  702.  
  703. function createControlPanel() {
  704. const styles = `
  705. .control-panel {
  706. position: fixed;
  707. top: 16px;
  708. right: 16px;
  709. display: flex;
  710. flex-direction: column;
  711. gap: 8px;
  712. background-color: rgba(35, 35, 35, 0.75);
  713. padding: 12px;
  714. border-radius: 6px;
  715. transform: translateX(calc(100% + 16px));
  716. opacity: 0;
  717. transition: transform 0.3s ease, opacity 0.3s ease;
  718. z-index: 9999;
  719. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  720. pointer-events: none;
  721. width: 200px;
  722. }
  723. .control-panel.visible {
  724. transform: translateX(0);
  725. opacity: 1;
  726. pointer-events: all;
  727. }
  728. .control-panel.hiding {
  729. transform: translateX(calc(100% + 16px));
  730. opacity: 0;
  731. pointer-events: none;
  732. }
  733. .image-counter {
  734. color: white;
  735. text-align: center;
  736. font-size: 14px;
  737. display: flex;
  738. align-items: center;
  739. justify-content: center;
  740. gap: 6px;
  741. min-height: 20px;
  742. }
  743. .progress-container {
  744. display: none;
  745. margin-top: 8px;
  746. width: 100%;
  747. }
  748. .progress-bar {
  749. width: 100%;
  750. height: 4px;
  751. background-color: #1a1a1a;
  752. border-radius: 2px;
  753. }
  754. .progress-fill {
  755. width: 0%;
  756. height: 100%;
  757. background-color: #1da1f2;
  758. border-radius: 2px;
  759. transition: width 0.3s ease;
  760. }
  761. .progress-text {
  762. color: white;
  763. font-size: 12px;
  764. text-align: center;
  765. margin-top: 4px;
  766. min-height: 16px;
  767. }
  768. .cancel-button {
  769. background-color: #dc3545;
  770. color: white;
  771. border: none;
  772. border-radius: 4px;
  773. padding: 6px 12px;
  774. font-size: 12px;
  775. text-align: center;
  776. cursor: pointer;
  777. transition: background-color 0.2s;
  778. margin-top: 12px;
  779. display: block;
  780. margin-left: auto;
  781. margin-right: auto;
  782. width: 80px;
  783. }
  784. .cancel-button:hover {
  785. background-color: #c82333;
  786. }`
  787.  
  788. if (!document.querySelector("#control-panel-styles")) {
  789. const styleSheet = document.createElement("style")
  790. styleSheet.id = "control-panel-styles"
  791. styleSheet.textContent = styles
  792. document.head.appendChild(styleSheet)
  793. }
  794.  
  795. const panel = document.createElement("div")
  796. panel.className = "control-panel"
  797.  
  798. const counter = document.createElement("div")
  799. counter.className = "image-counter"
  800. counter.appendChild(mediaIcon.cloneNode(true))
  801. counter.appendChild(document.createTextNode(" 0"))
  802.  
  803. const progressContainer = document.createElement("div")
  804. progressContainer.className = "progress-container"
  805.  
  806. const progressBar = document.createElement("div")
  807. progressBar.className = "progress-bar"
  808.  
  809. const progressFill = document.createElement("div")
  810. progressFill.className = "progress-fill"
  811.  
  812. const progressText = document.createElement("div")
  813. progressText.className = "progress-text"
  814. progressText.textContent = "0%"
  815.  
  816. const cancelButton = document.createElement("button")
  817. cancelButton.className = "cancel-button"
  818. cancelButton.textContent = "Cancel"
  819. cancelButton.addEventListener("click", () => {
  820. isDownloading = false
  821. hideControlPanel()
  822. })
  823.  
  824. progressBar.appendChild(progressFill)
  825. progressContainer.appendChild(progressBar)
  826. progressContainer.appendChild(progressText)
  827. progressContainer.appendChild(cancelButton)
  828.  
  829. panel.appendChild(counter)
  830. panel.appendChild(progressContainer)
  831. document.body.appendChild(panel)
  832.  
  833. requestAnimationFrame(() => {
  834. requestAnimationFrame(() => {
  835. panel.classList.add("visible")
  836. })
  837. })
  838.  
  839. return { counter, panel }
  840. }
  841.  
  842. function formatDownloadDate() {
  843. const now = new Date()
  844. const year = now.getFullYear()
  845. const month = String(now.getMonth() + 1).padStart(2, '0')
  846. const day = String(now.getDate()).padStart(2, '0')
  847. const hours = String(now.getHours()).padStart(2, '0')
  848. const minutes = String(now.getMinutes()).padStart(2, '0')
  849. const seconds = String(now.getSeconds()).padStart(2, '0')
  850. return `${year}${month}${day}_${hours}${minutes}${seconds}`
  851. }
  852.  
  853. async function downloadMedia(metadata, icon, username) {
  854. if (isDownloading || !controlPanel?.panel) return
  855. isDownloading = true
  856.  
  857. const { account_info, timeline, total_urls } = metadata
  858. const { name } = account_info
  859. const BATCH_SIZE = 5
  860. const FILES_PER_ZIP = 500
  861.  
  862. const progressContainer = controlPanel.panel.querySelector(".progress-container")
  863. const progressFill = progressContainer?.querySelector(".progress-fill")
  864. const progressText = progressContainer?.querySelector(".progress-text")
  865. const buttonsContainer = controlPanel.panel.querySelector(".buttons-container")
  866.  
  867. if (!progressContainer || !progressFill || !progressText || !imageCounter) {
  868. console.error("Required elements not found")
  869. isDownloading = false
  870. return
  871. }
  872.  
  873. if (buttonsContainer?.style) buttonsContainer.style.display = "none"
  874. progressContainer.style.display = "block"
  875. while (imageCounter.firstChild) {
  876. imageCounter.removeChild(imageCounter.firstChild)
  877. }
  878. imageCounter.appendChild(icon.cloneNode(true))
  879. imageCounter.appendChild(document.createTextNode(` ${formatNumber(total_urls)}`))
  880.  
  881. let successfulDownloads = []
  882. const filenameCounts = new Map()
  883. const batches = []
  884.  
  885. for (let i = 0; i < timeline.length; i += BATCH_SIZE) {
  886. if (!isDownloading) {
  887. console.log("Download cancelled")
  888. return
  889. }
  890. const batch = timeline.slice(i, i + BATCH_SIZE).map(async (item) => {
  891. if (!isDownloading) return false
  892. try {
  893. const blob = await downloadFile(item.url)
  894. const fileExt = getFileExtension(item.url)
  895. const formattedDate = formatDate(item.date)
  896. const baseFileName = `${name}_${formattedDate}_${item.tweet_id}`
  897. let fileName = baseFileName + fileExt
  898. if (filenameCounts.has(baseFileName)) {
  899. const count = filenameCounts.get(baseFileName) + 1
  900. filenameCounts.set(baseFileName, count)
  901. fileName = `${baseFileName}_${String(count).padStart(2, "0")}${fileExt}`
  902. } else {
  903. filenameCounts.set(baseFileName, 0)
  904. }
  905. successfulDownloads.push({ blob, fileName, item })
  906. const progress = Math.round((successfulDownloads.length / total_urls) * 100)
  907. progressFill.style.width = `${progress}%`
  908. progressText.textContent = `Downloading: (${formatNumber(successfulDownloads.length)}/${formatNumber(total_urls)}) ${progress}%`
  909. return true
  910. } catch (error) {
  911. console.error("Error downloading media:", error, item.url)
  912. return false
  913. }
  914. })
  915. batches.push(Promise.all(batch))
  916. await new Promise((resolve) => setTimeout(resolve, 100))
  917. }
  918.  
  919. for (const batch of batches) {
  920. if (!isDownloading) return
  921. await batch
  922. }
  923.  
  924. if (successfulDownloads.length > 0 && isDownloading) {
  925. markUrlsAsDownloaded(username, successfulDownloads.map(download => download.item))
  926.  
  927. while (imageCounter.firstChild) {
  928. imageCounter.removeChild(imageCounter.firstChild)
  929. }
  930. imageCounter.appendChild(zipIcon.cloneNode(true))
  931. imageCounter.appendChild(document.createTextNode(` ${formatNumber(successfulDownloads.length)}`))
  932.  
  933. if (successfulDownloads.length === 1) {
  934. const { blob, fileName } = successfulDownloads[0]
  935. const downloadUrl = URL.createObjectURL(blob)
  936. const a = document.createElement("a")
  937. a.href = downloadUrl
  938. a.download = fileName
  939. document.body.appendChild(a)
  940. a.click()
  941. document.body.removeChild(a)
  942. URL.revokeObjectURL(downloadUrl)
  943. } else {
  944. const totalParts = Math.ceil(successfulDownloads.length / FILES_PER_ZIP)
  945. const downloadDate = formatDownloadDate()
  946.  
  947. for (let partIndex = 0; partIndex < totalParts; partIndex++) {
  948. if (!isDownloading) return
  949.  
  950. const startIndex = partIndex * FILES_PER_ZIP
  951. const endIndex = Math.min((partIndex + 1) * FILES_PER_ZIP, successfulDownloads.length)
  952. const partFiles = successfulDownloads.slice(startIndex, endIndex)
  953.  
  954. const zip = new JSZip()
  955. partFiles.forEach(({ blob, fileName }) => {
  956. zip.file(fileName, blob)
  957. })
  958.  
  959. const zipBlob = await zip.generateAsync(
  960. {
  961. type: "blob",
  962. compression: "DEFLATE",
  963. compressionOptions: { level: 3 },
  964. },
  965. (metadata) => {
  966. if (!isDownloading) return
  967. const progress = Math.round(metadata.percent)
  968. const processedFiles = Math.round((progress / 100) * partFiles.length)
  969. const totalProcessed = startIndex + processedFiles
  970. progressFill.style.width = `${progress}%`
  971. progressText.textContent = `Creating ZIP Part ${partIndex + 1}/${totalParts}: (${formatNumber(processedFiles)}/${formatNumber(partFiles.length)}) ${progress}%`
  972. },
  973. )
  974.  
  975. if (isDownloading) {
  976. const downloadUrl = URL.createObjectURL(zipBlob)
  977. const partSuffix = totalParts > 1 ? `_Part_${String(partIndex + 1).padStart(2, '0')}` : ''
  978. const a = document.createElement("a")
  979. a.href = downloadUrl
  980. a.download = `${name}_${downloadDate}_${successfulDownloads.length}${partSuffix}.zip`
  981. document.body.appendChild(a)
  982. a.click()
  983. document.body.removeChild(a)
  984. URL.revokeObjectURL(downloadUrl)
  985.  
  986. await new Promise(resolve => setTimeout(resolve, 1000))
  987. }
  988. }
  989. }
  990. }
  991.  
  992. isDownloading = false
  993. hideControlPanel()
  994. }
  995.  
  996. function hideControlPanel() {
  997. if (controlPanel?.panel) {
  998. controlPanel.panel.classList.remove("visible")
  999. controlPanel.panel.classList.add("hiding")
  1000.  
  1001. controlPanel.panel.addEventListener("transitionend", function handler(e) {
  1002. if (e.propertyName === "opacity") {
  1003. controlPanel.panel.removeEventListener("transitionend", handler)
  1004. controlPanel.panel.remove()
  1005. controlPanel = null
  1006. }
  1007. })
  1008. }
  1009. }
  1010.  
  1011. function extractUsername() {
  1012. const pathParts = window.location.pathname.split('/').filter(part => part);
  1013. if (pathParts.length > 0) {
  1014. return pathParts[0];
  1015. }
  1016. return null;
  1017. }
  1018. function insertDownloadIcon() {
  1019. const usernameDivs = document.querySelectorAll('[data-testid="UserName"]')
  1020. usernameDivs.forEach((usernameDiv) => {
  1021. if (!usernameDiv.querySelector(".download-icon")) {
  1022. const username = extractUsername()
  1023. if (!username) return
  1024. const verifiedButton = usernameDiv
  1025. .querySelector('[aria-label*="verified"], [aria-label*="Verified"]')
  1026. ?.closest("button")
  1027.  
  1028. const targetElement = verifiedButton
  1029. ? verifiedButton.parentElement
  1030. : usernameDiv.querySelector(".css-1jxf684")?.closest("span")
  1031.  
  1032. if (targetElement) {
  1033. const iconDiv = document.createElement("div")
  1034. iconDiv.className = "download-icon css-175oi2r r-1awozwy r-xoduu5"
  1035. iconDiv.style.cssText = `
  1036. display: inline-flex;
  1037. align-items: center;
  1038. margin-left: 6px;
  1039. margin-right: 6px;
  1040. gap: 6px;
  1041. padding: 0 3px;
  1042. transition: transform 0.2s, color 0.2s;
  1043. `
  1044. iconDiv.appendChild(downloadIcon.cloneNode(true))
  1045.  
  1046. iconDiv.addEventListener("mouseenter", () => {
  1047. iconDiv.style.transform = "scale(1.1)"
  1048. iconDiv.style.color = "#1DA1F2"
  1049. })
  1050.  
  1051. iconDiv.addEventListener("mouseleave", () => {
  1052. iconDiv.style.transform = "scale(1)"
  1053. iconDiv.style.color = ""
  1054. })
  1055.  
  1056. iconDiv.addEventListener("click", (e) => {
  1057. e.stopPropagation()
  1058. createCustomMenu(username)
  1059. })
  1060.  
  1061. const wrapperDiv = document.createElement("div")
  1062. wrapperDiv.style.cssText = `
  1063. display: inline-flex;
  1064. align-items: center;
  1065. gap: 4px;
  1066. `
  1067. wrapperDiv.appendChild(iconDiv)
  1068. targetElement.parentNode.insertBefore(wrapperDiv, targetElement.nextSibling)
  1069. }
  1070. }
  1071. })
  1072. }
  1073.  
  1074. function resetState() {
  1075. imageCounter = null
  1076. if (controlPanel?.panel) {
  1077. controlPanel.panel.remove()
  1078. controlPanel = null
  1079. }
  1080. }
  1081.  
  1082. insertDownloadIcon()
  1083.  
  1084. let lastUrl = location.href
  1085. new MutationObserver(() => {
  1086. const url = location.href
  1087. if (url !== lastUrl) {
  1088. lastUrl = url
  1089. resetState()
  1090. setTimeout(insertDownloadIcon, 1000)
  1091. } else {
  1092. insertDownloadIcon()
  1093. }
  1094. }).observe(document.body, {
  1095. childList: true,
  1096. subtree: true,
  1097. })
  1098. })()

QingJ © 2025

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