Twitter 媒体下载

一键下载视频/图片 | 并在批量下载时自动打包为一个ZIP文件下载

目前为 2025-03-26 提交的版本。查看 最新版本

  1. // ==UserScript==
  2. // @name Twitter/X Media Downloader
  3. // @name:ar تنزيل وسائل الإعلام على Twitter
  4. // @name:bg Изтегляне на медии в Twitter
  5. // @name:cs Stahování médií na Twitteru
  6. // @name:da Twitter Media Download
  7. // @name:de Twitter Media Download
  8. // @name:el Λήψη μέσων Twitter
  9. // @name:en Twitter Media Download
  10. // @name:eo Twitter amaskomunikila elŝuto
  11. // @name:es Descarga de medios de Twitter
  12. // @name:fi Twitter Media Download
  13. // @name:fr Téléchargement des médias Twitter
  14. // @name:fr-CA Téléchargement des médias Twitter
  15. // @name:he הורדת מדיה בטוויטר
  16. // @name:hr Twitter Media Download
  17. // @name:hu Twitter média letöltése
  18. // @name:id Unduh Media Twitter
  19. // @name:it Download dei media di Twitter
  20. // @name:ja Twitterメディアのダウンロード
  21. // @name:ka Twitter Media ჩამოტვირთვა
  22. // @name:ko 트위터 미디어 다운로드
  23. // @name:nb Twitter media nedlasting
  24. // @name:nl Twitter -media downloaden
  25. // @name:pl Pobieranie mediów na Twitterze
  26. // @name:pt-BR Download da mídia do Twitter
  27. // @name:ro Descărcare media Twitter
  28. // @name:ru Скачать Twitter Media
  29. // @name:sk Stiahnutie médií Twitter
  30. // @name:sr Твиттер Медиа Довнлоад
  31. // @name:sv Twitter media nedladdning
  32. // @name:th ดาวน์โหลดสื่อ Twitter
  33. // @name:tr Twitter Media İndir
  34. // @name:ug Twitter تاراتقۇلىرى چۈشۈرۈش
  35. // @name:uk Завантажити медіа Twitter
  36. // @name:vi Tải xuống phương tiện truyền thông Twitter
  37. // @name:zh Twitter 媒体下载
  38. // @name:zh-CN Twitter 媒体下载
  39. // @name:zh-HK Twitter 媒體下載
  40. // @name:zh-SG Twitter 媒体下载
  41. // @name:zh-TW Twitter 媒體下載
  42. // @description Download videos/pictures with one click | Automatically package them into a ZIP file for batch download
  43. // @description:ar قم بتنزيل مقاطع الفيديو/الصور بنقرة واحدة
  44. // @description:bg Изтеглете видеоклипове/снимки с едно щракване | и автоматично ги пакетирайте като Zip файл, за да изтеглите по време на изтегляне на партида
  45. // @description:cs Stáhněte si videa/obrázky s jedním kliknutím |
  46. // @description:da Download videoer/billeder med et klik |
  47. // @description:de Laden Sie Videos/Bilder mit einem Klick herunter und verpacken Sie sie automatisch als ZIP -Datei zum Herunterladen
  48. // @description:el Κατεβάστε βίντεο/εικόνες με ένα κλικ | και αυτόματα συσκευαστείτε ως αρχείο zip για λήψη κατά τη διάρκεια της λήψης παρτίδας
  49. // @description:en Download videos/pictures with one click | and automatically package them as a ZIP file to download during batch download
  50. // @description:eo Elŝutu filmetojn/bildojn per unu klako | kaj aŭtomate paku ilin kiel zip -dosieron por elŝuti dum Batch Download
  51. // @description:es Descargue videos/imágenes con un clic |
  52. // @description:fi Lataa videoita/kuvia yhdellä napsautuksella
  53. // @description:fr Télécharger des vidéos / photos en un clic | et les emballer automatiquement en tant que fichier zip à télécharger pendant le téléchargement par lots
  54. // @description:fr-CA Télécharger des vidéos / photos en un clic | et les emballer automatiquement en tant que fichier zip à télécharger pendant le téléchargement par lots
  55. // @description:he הורד סרטונים/תמונות בלחיצה אחת
  56. // @description:hr Preuzmite video/slike jednim klikom | i automatski ih pakirajte kao zip datoteku za preuzimanje tijekom
  57. // @description:hu Töltse le a videókat/képeket egy kattintással
  58. // @description:id Unduh video/gambar dengan satu klik | dan secara otomatis mengemasnya sebagai file zip untuk diunduh selama batch
  59. // @description:it Scarica video/immagini con un clic | e confezionarli automaticamente come file zip da scaricare durante il download batch
  60. // @description:ja ワンクリックでビデオ/写真をダウンロードし、バッチダウンロード中にダウンロードするzipファイルとして自動的にパッケージ化します
  61. // @description:ka ჩამოტვირთეთ ვიდეო/სურათები ერთი დაჭერით |
  62. // @description:ko 한 번의 클릭으로 비디오/사진을 다운로드하고 배치 다운로드 중에 자동으로 포장하십시오.
  63. // @description:nb Last ned videoer/bilder med ett klikk |
  64. // @description:nl Download video’s/afbeeldingen met één klik | en verpakking ze automatisch als een zip -bestand om te downloaden tijdens de batch -download
  65. // @description:pl Pobierz filmy/zdjęcia jednym kliknięciem |
  66. // @description:pt-BR Faça o download de vídeos/fotos com um clique | e o embalam automaticamente como um arquivo zip para download durante o download do lote
  67. // @description:ro Descărcați videoclipuri/imagini cu un singur clic |
  68. // @description:ru Скачать видео/изображения с одним щелчком
  69. // @description:sk Stiahnite si videá/obrázky jedným kliknutím |
  70. // @description:sr Преузмите видео записе / слике једним кликом | и аутоматски их паковати као зип датотеку за преузимање током серије
  71. // @description:sv Ladda ner videor/bilder med ett klick | och paketera dem automatiskt som en zip -fil för att ladda ner under nedladdning
  72. // @description:th ดาวน์โหลดวิดีโอ/รูปภาพด้วยคลิกเดียว |
  73. // @description:tr Videoları/resimleri tek tıklamayla indirin ve bunları toplu olarak indirmek için bir zip dosyası olarak paketleyin
  74. // @description:ug بىر چېكىش ئارقىلىق سىن / رەسىملەرنى چۈشۈرۈش | ۋە تۈركۈم چۈشۈرۈش جەريانىدا چۈشۈرۈش ئۈچۈن ئۇلارنى يۈكلەڭ
  75. // @description:uk Завантажте відео/зображення одним натисканням |
  76. // @description:vi Tải xuống video/hình ảnh chỉ bằng một cú nhấp chuột |
  77. // @description:zh 一键下载视频/图片 | 并在批量下载时自动打包为一个ZIP文件下载
  78. // @description:zh-CN 一键下载视频/图片 | 并在批量下载时自动打包为一个ZIP文件下载
  79. // @description:zh-HK 一鍵下載視頻/圖片 | 並在批量下載時自動打包為一個ZIP文件下載
  80. // @description:zh-SG 一键下载视频/图片 | 并在批量下载时自动打包为一个ZIP文件下载
  81. // @description:zh-TW 一鍵下載視頻/圖片 | 並在批量下載時自動打包為一個ZIP文件下載
  82. // @author 天音,Tiande,人民的勤务员 <china.qinwuyuan@gmail.com>
  83. // @namespace https://github.com/ChinaGodMan/UserScripts
  84. // @supportURL https://github.com/ChinaGodMan/UserScripts/issues
  85. // @homepageURL https://github.com/ChinaGodMan/UserScripts
  86. // @license MIT
  87. // @icon https://raw.githubusercontent.com/ChinaGodMan/UserScriptsHistory/main/scriptsIcon/x.svg
  88. // @compatible chrome
  89. // @compatible firefox
  90. // @compatible edge
  91. // @compatible opera
  92. // @compatible safari
  93. // @compatible kiwi
  94. // @compatible qq
  95. // @compatible via
  96. // @compatible brave
  97. // @grant GM_registerMenuCommand
  98. // @grant GM_setValue
  99. // @grant GM_getValue
  100. // @grant GM_download
  101. // @match https://x.com/*
  102. // @match https://twitter.com/*
  103. // @version 2025.03.15.0012
  104. // @created 2025-03-11 08:11:29
  105. // @modified 2025-03-11 08:11:29
  106. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
  107. // ==/UserScript==
  108. /**
  109. * File: twitter-media-downloader.user.js
  110. * Project: UserScripts
  111. * File Created: 2025/03/11,Tuesday 08:11:41
  112. * Author: 人民的勤务员@ChinaGodMan (china.qinwuyuan@gmail.com)
  113. * -----
  114. * Last Modified: 2025/03/15,Saturday 00:12:32
  115. * Modified By: 人民的勤务员@ChinaGodMan (china.qinwuyuan@gmail.com)
  116. * -----
  117. * License: MIT License
  118. * Copyright © 2024 - 2025 ChinaGodMan,Inc
  119. */
  120.  
  121.  
  122.  
  123. /* jshint esversion: 8 */
  124. const filename = 'twitter_{user-name}(@{user-id})_{date-time}_{status-id}_{file-type}'
  125. const TMD = (function () {
  126. let lang, host, history, show_sensitive, is_tweetdeck
  127. return {
  128. init: async function () {
  129. GM_registerMenuCommand((this.language[navigator.language] || this.language.en).settings, this.settings)
  130. lang = this.language[document.querySelector('html').lang] || this.language.en
  131. host = location.hostname
  132. is_tweetdeck = host.indexOf('tweetdeck') >= 0
  133. history = this.storage_obsolete()
  134. if (history.length) {
  135. this.storage(history)
  136. this.storage_obsolete(true)
  137. } else history = await this.storage()
  138. show_sensitive = GM_getValue('show_sensitive', false)
  139. document.head.insertAdjacentHTML('beforeend', '<style>' + this.css + (show_sensitive ? this.css_ss : '') + '</style>')
  140. let observer = new MutationObserver(ms => ms.forEach(m => m.addedNodes.forEach(node => this.detect(node))))
  141. observer.observe(document.body, { childList: true, subtree: true })
  142. },
  143. detect: function (node) {
  144. let article = node.tagName == 'ARTICLE' && node || node.tagName == 'DIV' && (node.querySelector('article') || node.closest('article'))
  145. if (article) this.addButtonTo(article)
  146. let listitems = node.tagName == 'LI' && node.getAttribute('role') == 'listitem' && [node] || node.tagName == 'DIV' && node.querySelectorAll('li[role="listitem"]')
  147. if (listitems) this.addButtonToMedia(listitems)
  148. },
  149. addButtonTo: function (article) {
  150. if (article.dataset.detected) return
  151. article.dataset.detected = 'true'
  152. let media_selector = [
  153. 'a[href*="/photo/1"]',
  154. 'div[role="progressbar"]',
  155. 'button[data-testid="playButton"]',
  156. 'a[href="/settings/content_you_see"]', //hidden content
  157. 'div.media-image-container', // for tweetdeck
  158. 'div.media-preview-container', // for tweetdeck
  159. 'div[aria-labelledby]>div:first-child>div[role="button"][tabindex="0"]' //for audio (experimental)
  160. ]
  161. let media = article.querySelector(media_selector.join(','))
  162. if (media) {
  163. let status_id = article.querySelector('a[href*="/status/"]').href.split('/status/').pop().split('/').shift()
  164. let btn_group = article.querySelector('div[role="group"]:last-of-type, ul.tweet-actions, ul.tweet-detail-actions')
  165. let btn_share = Array.from(btn_group.querySelectorAll(':scope>div>div, li.tweet-action-item>a, li.tweet-detail-action-item>a')).pop().parentNode
  166. let btn_down = btn_share.cloneNode(true)
  167. btn_down.querySelector('button').removeAttribute('disabled')
  168. if (is_tweetdeck) {
  169. btn_down.firstElementChild.innerHTML = '<svg viewBox="0 0 24 24" style="width: 18px; height: 18px;">' + this.svg + '</svg>'
  170. btn_down.firstElementChild.removeAttribute('rel')
  171. btn_down.classList.replace('pull-left', 'pull-right')
  172. } else {
  173. btn_down.querySelector('svg').innerHTML = this.svg
  174. }
  175. let is_exist = history.indexOf(status_id) >= 0
  176. this.status(btn_down, 'tmd-down')
  177. this.status(btn_down, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download)
  178. btn_group.insertBefore(btn_down, btn_share.nextSibling)
  179. btn_down.onclick = () => this.click(btn_down, status_id, is_exist)
  180. if (show_sensitive) {
  181. let btn_show = article.querySelector('div[aria-labelledby] div[role="button"][tabindex="0"]:not([data-testid]) > div[dir] > span > span')
  182. if (btn_show) btn_show.click()
  183. }
  184. }
  185. let imgs = article.querySelectorAll('a[href*="/photo/"]')
  186. if (imgs.length > 1) {
  187. let status_id = article.querySelector('a[href*="/status/"]').href.split('/status/').pop().split('/').shift()
  188. let btn_group = article.querySelector('div[role="group"]:last-of-type')
  189. let btn_share = Array.from(btn_group.querySelectorAll(':scope>div>div')).pop().parentNode
  190. imgs.forEach(img => {
  191. let index = img.href.split('/status/').pop().split('/').pop()
  192. let is_exist = history.indexOf(status_id) >= 0
  193. let btn_down = document.createElement('div')
  194. btn_down.innerHTML = '<div><div><svg viewBox="0 0 24 24" style="width: 18px; height: 18px;">' + this.svg + '</svg></div></div>'
  195. btn_down.classList.add('tmd-down', 'tmd-img')
  196. this.status(btn_down, 'download')
  197. img.parentNode.appendChild(btn_down)
  198. btn_down.onclick = e => {
  199. e.preventDefault()
  200. this.click(btn_down, status_id, is_exist, index)
  201. }
  202. })
  203. }
  204. },
  205. addButtonToMedia: function (listitems) {
  206. listitems.forEach(li => {
  207. if (li.dataset.detected) return
  208. li.dataset.detected = 'true'
  209. let status_id = li.querySelector('a[href*="/status/"]').href.split('/status/').pop().split('/').shift()
  210. let is_exist = history.indexOf(status_id) >= 0
  211. let btn_down = document.createElement('div')
  212. btn_down.innerHTML = '<div><div><svg viewBox="0 0 24 24" style="width: 18px; height: 18px;">' + this.svg + '</svg></div></div>'
  213. btn_down.classList.add('tmd-down', 'tmd-media')
  214. this.status(btn_down, is_exist ? 'completed' : 'download', is_exist ? lang.completed : lang.download)
  215. li.appendChild(btn_down)
  216. btn_down.onclick = () => this.click(btn_down, status_id, is_exist)
  217. })
  218. },
  219. click: async function (btn, status_id, is_exist, index) {
  220. if (btn.classList.contains('loading')) return
  221. this.status(btn, 'loading')
  222. let out = (await GM_getValue('filename', filename)).split('\n').join('')
  223. let save_history = await GM_getValue('save_history', true)
  224. let json = await this.fetchJson(status_id)
  225. let tweet = json.quoted_status_result?.result?.legacy?.media//此媒体存在,属于引用推文
  226. || json.quoted_status_result?.result?.legacy
  227. || json.legacy
  228. let user = json.core.user_results.result.legacy
  229. let invalid_chars = { '\\': '\', '\/': '/', '\|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': '' }
  230. let datetime = out.match(/\{date-time(-local)?:[^{}]+\}/) ? out.match(/\{date-time(?:-local)?:([^{}]+)\}/)[1].replace(/[\\/|<>*?:"]/g, v => invalid_chars[v]) : 'YYYYMMDD-hhmmss'
  231. let info = {}
  232. info['status-id'] = status_id
  233. info['user-name'] = user.name.replace(/([\\/|*?:"\u200b-\u200d\u2060\ufeff]|🔞)/g, v => invalid_chars[v])
  234. info['user-id'] = user.screen_name
  235. info['date-time'] = this.formatDate(tweet.created_at, datetime)
  236. info['date-time-local'] = this.formatDate(tweet.created_at, datetime, true)
  237. info['full-text'] = tweet.full_text.split('\n').join(' ').replace(/\s*https:\/\/t\.co\/\w+/g, '').replace(/[\\/|<>*?:"\u200b-\u200d\u2060\ufeff]/g, v => invalid_chars[v])
  238. let medias = tweet.extended_entities && tweet.extended_entities.media
  239. if (json?.card) {
  240. this.status(btn, 'failed', 'This tweet contains a link, which is not supported by this script.')
  241. return
  242. }
  243. if (!Array.isArray(medias)) {
  244. this.status(btn, 'failed', 'MEDIA_NOT_FOUND')
  245. return
  246. }
  247. if (index) medias = [medias[index - 1]]
  248. if (medias.length > 0) {
  249. let tasks = medias.map((media, i) => {
  250. info.url = media.type == 'photo' ? media.media_url_https + ':orig' : media.video_info.variants.filter(n => n.content_type == 'video/mp4').sort((a, b) => b.bitrate - a.bitrate)[0].url
  251. info.file = info.url.split('/').pop().split(/[:?]/).shift()
  252. info['file-name'] = info.file.split('.').shift()
  253. info['file-ext'] = info.file.split('.').pop()
  254. info['file-type'] = media.type.replace('animated_', '')
  255. info.out = (out.replace(/\.?\{file-ext\}/, '') + ((medias.length > 1 || index) && !out.match('{file-name}') ? '-' + (index ? index - 1 : i) : '') + '.{file-ext}').replace(/\{([^{}:]+)(:[^{}]+)?\}/g, (match, name) => info[name])
  256. return { url: info.url, name: info.out }
  257. })
  258. this.downloader.add(tasks, btn, save_history, is_exist, status_id, GM_getValue('enable_packaging', true))
  259. } else {
  260. this.status(btn, 'failed', 'MEDIA_NOT_FOUND')
  261. }
  262. }, downloader: (function () {
  263. let tasks = [], thread = 0, failed = 0, notifier, has_failed = false
  264. return {
  265. add: function (taskList, btn, save_history, is_exist, status_id, enable_packaging) {
  266. if (taskList.length > 1) {
  267. tasks.push(...taskList)
  268. this.update()
  269. if (enable_packaging) {
  270. let zip = new JSZip()
  271. let completedCount = 0
  272. taskList.forEach((task, i) => {
  273. thread++
  274. this.update()
  275. fetch(task.url)
  276. .then(response => response.blob())
  277. .then(blob => {
  278. zip.file(task.name, blob)
  279. tasks = tasks.filter(t => t.url !== task.url)
  280. thread--
  281. this.update()
  282. completedCount++
  283. if (completedCount === taskList.length) {
  284. zip.generateAsync({ type: 'blob' }).then(content => {
  285. let a = document.createElement('a')
  286. a.href = URL.createObjectURL(content)
  287. a.download = `${taskList[0].name}.zip`
  288. a.click()
  289. this.status(btn, 'completed', lang.completed)
  290. if (save_history && !is_exist) {
  291. history.push(status_id)
  292. this.storage(status_id)
  293. }
  294. })
  295. }
  296. })
  297. .catch(error => {
  298. failed++
  299. tasks = tasks.filter(t => t.url !== task.url)
  300. this.status(btn, 'failed', error.message)
  301. this.update()
  302. })
  303. })
  304. } else {
  305. taskList.forEach((task) => {
  306. thread++
  307. this.update()
  308.  
  309. GM_download({
  310. url: task.url,
  311. name: task.name,
  312. onload: () => {
  313. thread--
  314. tasks = tasks.filter(t => t.url !== task.url)
  315. this.status(btn, 'completed', lang.completed)
  316. if (save_history && !is_exist) {
  317. history.push(status_id)
  318. this.storage(status_id)
  319. }
  320. this.update()
  321. },
  322. onerror: result => {
  323. thread--
  324. failed++
  325. tasks = tasks.filter(t => t.url !== task.url)
  326. this.status(btn, 'failed', result.details.current)
  327. this.update()
  328. }
  329. })
  330. })
  331. }
  332. } else {
  333. tasks.push(taskList[0])
  334. thread++
  335. this.update()
  336. GM_download({
  337. url: taskList[0].url,
  338. name: taskList[0].name,
  339. onload: () => {
  340. thread--
  341. tasks = tasks.filter(t => t.url !== taskList[0].url)
  342. this.status(btn, 'completed', lang.completed)
  343.  
  344. if (save_history && !is_exist) {
  345. history.push(status_id)
  346. this.storage(status_id)
  347. }
  348. this.update()
  349. },
  350. onerror: result => {
  351. thread--
  352. failed++
  353. tasks = tasks.filter(t => t.url !== taskList[0].url)
  354. this.status(btn, 'failed', result.details.current)
  355. this.update()
  356. }
  357. })
  358. }
  359. },
  360. status: function (btn, css, title, style) {
  361. if (css) {
  362. btn.classList.remove('download', 'completed', 'loading', 'failed')
  363. btn.classList.add(css)
  364. }
  365. if (title) btn.title = title
  366. if (style) btn.style.cssText = style
  367. },
  368. storage: async function (value) {
  369. let data = await GM_getValue('download_history', [])
  370. let data_length = data.length
  371. if (value) {
  372. if (Array.isArray(value)) data = data.concat(value)
  373. else if (data.indexOf(value) < 0) data.push(value)
  374. } else return data
  375. if (data.length > data_length) GM_setValue('download_history', data)
  376. },
  377. update: function () {
  378. if (!notifier) {
  379. notifier = document.createElement('div')
  380. notifier.title = 'Twitter Media Downloader'
  381. notifier.classList.add('tmd-notifier')
  382. notifier.innerHTML = '<label>0</label>|<label>0</label>'
  383. document.body.appendChild(notifier)
  384. }
  385. if (failed > 0 && !has_failed) {
  386. has_failed = true
  387. notifier.innerHTML += '|'
  388. let clear = document.createElement('label')
  389. notifier.appendChild(clear)
  390. clear.onclick = () => {
  391. notifier.innerHTML = '<label>0</label>|<label>0</label>'
  392. failed = 0
  393. has_failed = false
  394. this.update()
  395. }
  396. }
  397. notifier.firstChild.innerText = thread
  398. notifier.firstChild.nextElementSibling.innerText = tasks.length - thread - failed
  399. if (failed > 0) notifier.lastChild.innerText = failed
  400. if (thread > 0 || tasks.length > 0 || failed > 0) notifier.classList.add('running')
  401. else notifier.classList.remove('running')
  402. }
  403. }
  404. })(),
  405. status: function (btn, css, title, style) {
  406. if (css) {
  407. btn.classList.remove('download', 'completed', 'loading', 'failed')
  408. btn.classList.add(css)
  409. }
  410. if (title) btn.title = title
  411. if (style) btn.style.cssText = style
  412. },
  413. settings: async function () {
  414. const $element = (parent, tag, style, content, css) => {
  415. let el = document.createElement(tag)
  416. if (style) el.style.cssText = style
  417. if (typeof content !== 'undefined') {
  418. if (tag == 'input') {
  419. if (content == 'checkbox') el.type = content
  420. else el.value = content
  421. } else el.innerHTML = content
  422. }
  423. if (css) css.split(' ').forEach(c => el.classList.add(c))
  424. parent.appendChild(el)
  425. return el
  426. }
  427. let wapper = $element(document.body, 'div', 'position: fixed; left: 0px; top: 0px; width: 100%; height: 100%; background-color: #0009; z-index: 10;')
  428. let wapper_close
  429. wapper.onmousedown = e => {
  430. wapper_close = e.target == wapper
  431. }
  432. wapper.onmouseup = e => {
  433. if (wapper_close && e.target == wapper) wapper.remove()
  434. }
  435. let dialog = $element(wapper, 'div', 'position: absolute; left: 50%; top: 50%; transform: translateX(-50%) translateY(-50%); width: fit-content; width: -moz-fit-content; background-color: #f3f3f3; border: 1px solid #ccc; border-radius: 10px; color: black;')
  436. let title = $element(dialog, 'h3', 'margin: 10px 20px;', lang.dialog.title)
  437. let options = $element(dialog, 'div', 'margin: 10px; border: 1px solid #ccc; border-radius: 5px;')
  438. let save_history_label = $element(options, 'label', 'display: block; margin: 10px;', lang.dialog.save_history)
  439. let save_history_input = $element(save_history_label, 'input', 'float: left;', 'checkbox')
  440. save_history_input.checked = await GM_getValue('save_history', true)
  441. save_history_input.onchange = () => {
  442. GM_setValue('save_history', save_history_input.checked)
  443. }
  444. let clear_history = $element(save_history_label, 'label', 'display: inline-block; margin: 0 10px; color: blue;', lang.dialog.clear_history)
  445. clear_history.onclick = () => {
  446. if (confirm(lang.dialog.clear_confirm)) {
  447. history = []
  448. GM_setValue('download_history', [])
  449. }
  450. }
  451. let show_sensitive_label = $element(options, 'label', 'display: block; margin: 10px;', lang.dialog.show_sensitive)
  452. let show_sensitive_input = $element(show_sensitive_label, 'input', 'float: left;', 'checkbox')
  453. show_sensitive_input.checked = await GM_getValue('show_sensitive', false)
  454. show_sensitive_input.onchange = () => {
  455. show_sensitive = show_sensitive_input.checked
  456. GM_setValue('show_sensitive', show_sensitive)
  457. }
  458. let show_enable_packaging = $element(options, 'label', 'display: block; margin: 10px;', lang.enable_packaging)
  459. let show_enable_packaging_input = $element(show_enable_packaging, 'input', 'float: left;', 'checkbox')
  460. show_enable_packaging_input.checked = await GM_getValue('enable_packaging', true)
  461. show_enable_packaging_input.onchange = () => {
  462. GM_setValue('enable_packaging', show_enable_packaging_input.checked)
  463. }
  464. let filename_div = $element(dialog, 'div', 'margin: 10px; border: 1px solid #ccc; border-radius: 5px;')
  465. let filename_label = $element(filename_div, 'label', 'display: block; margin: 10px 15px;', lang.dialog.pattern)
  466. let filename_input = $element(filename_label, 'textarea', 'display: block; min-width: 500px; max-width: 500px; min-height: 100px; font-size: inherit; background: white; color: black;', await GM_getValue('filename', filename))
  467. let filename_tags = $element(filename_div, 'label', 'display: table; margin: 10px;', `
  468. <span class="tmd-tag" title="user name">{user-name}</span>
  469. <span class="tmd-tag" title="The user name after @ sign.">{user-id}</span>
  470. <span class="tmd-tag" title="example: 1234567890987654321">{status-id}</span>
  471. <span class="tmd-tag" title="{date-time} : Posted time in UTC.\n{date-time-local} : Your local time zone.\n\nDefault:\nYYYYMMDD-hhmmss => 20201231-235959\n\nExample of custom:\n{date-time:DD-MMM-YY hh.mm} => 31-DEC-21 23.59">{date-time}</span><br>
  472. <span class="tmd-tag" title="Text content in tweet.">{full-text}</span>
  473. <span class="tmd-tag" title="Type of &#34;video&#34; or &#34;photo&#34; or &#34;gif&#34;.">{file-type}</span>
  474. <span class="tmd-tag" title="Original filename from URL.">{file-name}</span>
  475. `)
  476. filename_input.selectionStart = filename_input.value.length
  477. filename_tags.querySelectorAll('.tmd-tag').forEach(tag => {
  478. tag.onclick = () => {
  479. let ss = filename_input.selectionStart
  480. let se = filename_input.selectionEnd
  481. filename_input.value = filename_input.value.substring(0, ss) + tag.innerText + filename_input.value.substring(se)
  482. filename_input.selectionStart = ss + tag.innerText.length
  483. filename_input.selectionEnd = ss + tag.innerText.length
  484. filename_input.focus()
  485. }
  486. })
  487. let btn_save = $element(title, 'label', 'float: right;', lang.dialog.save, 'tmd-btn')
  488. btn_save.onclick = async () => {
  489. await GM_setValue('filename', filename_input.value)
  490. wapper.remove()
  491. }
  492. },
  493. fetchJson: async function (status_id) {
  494. let base_url = `https://${host}/i/api/graphql/NmCeCgkVlsRGS1cAwqtgmw/TweetDetail`
  495. let variables = {
  496. 'focalTweetId': status_id,
  497. 'with_rux_injections': false,
  498. 'includePromotedContent': true,
  499. 'withCommunity': true,
  500. 'withQuickPromoteEligibilityTweetFields': true,
  501. 'withBirdwatchNotes': true,
  502. 'withVoice': true,
  503. 'withV2Timeline': true
  504. }
  505. let features = {
  506. 'rweb_lists_timeline_redesign_enabled': true,
  507. 'responsive_web_graphql_exclude_directive_enabled': true,
  508. 'verified_phone_label_enabled': false,
  509. 'creator_subscriptions_tweet_preview_api_enabled': true,
  510. 'responsive_web_graphql_timeline_navigation_enabled': true,
  511. 'responsive_web_graphql_skip_user_profile_image_extensions_enabled': false,
  512. 'tweetypie_unmention_optimization_enabled': true,
  513. 'responsive_web_edit_tweet_api_enabled': true,
  514. 'graphql_is_translatable_rweb_tweet_is_translatable_enabled': true,
  515. 'view_counts_everywhere_api_enabled': true,
  516. 'longform_notetweets_consumption_enabled': true,
  517. 'responsive_web_twitter_article_tweet_consumption_enabled': false,
  518. 'tweet_awards_web_tipping_enabled': false,
  519. 'freedom_of_speech_not_reach_fetch_enabled': true,
  520. 'standardized_nudges_misinfo': true,
  521. 'tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled': true,
  522. 'longform_notetweets_rich_text_read_enabled': true,
  523. 'longform_notetweets_inline_media_enabled': true,
  524. 'responsive_web_media_download_video_enabled': false,
  525. 'responsive_web_enhance_cards_enabled': false
  526. }
  527. let url = encodeURI(`${base_url}?variables=${JSON.stringify(variables)}&features=${JSON.stringify(features)}`)
  528. let cookies = this.getCookie()
  529. let headers = {
  530. 'authorization': 'Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA',
  531. 'x-twitter-active-user': 'yes',
  532. 'x-twitter-client-language': cookies.lang,
  533. 'x-csrf-token': cookies.ct0
  534. }
  535. if (cookies.ct0.length == 32) headers['x-guest-token'] = cookies.gt
  536. let tweet_detail = await fetch(url, { headers: headers }).then(result => result.json())
  537. let tweet_entrie = tweet_detail.data.threaded_conversation_with_injections_v2.instructions[0].entries.find(n => n.entryId == `tweet-${status_id}`)
  538. let tweet_result = tweet_entrie.content.itemContent.tweet_results.result
  539. return tweet_result.tweet || tweet_result
  540. },
  541. getCookie: function (name) {
  542. let cookies = {}
  543. document.cookie.split(';').filter(n => n.indexOf('=') > 0).forEach(n => {
  544. n.replace(/^([^=]+)=(.+)$/, (match, name, value) => {
  545. cookies[name.trim()] = value.trim()
  546. })
  547. })
  548. return name ? cookies[name] : cookies
  549. },
  550. storage: async function (value) {
  551. let data = await GM_getValue('download_history', [])
  552. let data_length = data.length
  553. if (value) {
  554. if (Array.isArray(value)) data = data.concat(value)
  555. else if (data.indexOf(value) < 0) data.push(value)
  556. } else return data
  557. if (data.length > data_length) GM_setValue('download_history', data)
  558. },
  559. storage_obsolete: function (is_remove) {
  560. let data = JSON.parse(localStorage.getItem('history') || '[]')
  561. if (is_remove) localStorage.removeItem('history')
  562. else return data
  563. },
  564. formatDate: function (i, o, tz) {
  565. let d = new Date(i)
  566. if (tz) d.setMinutes(d.getMinutes() - d.getTimezoneOffset())
  567. let m = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
  568. let v = {
  569. YYYY: d.getUTCFullYear().toString(),
  570. YY: d.getUTCFullYear().toString(),
  571. MM: d.getUTCMonth() + 1,
  572. MMM: m[d.getUTCMonth()],
  573. DD: d.getUTCDate(),
  574. hh: d.getUTCHours(),
  575. mm: d.getUTCMinutes(),
  576. ss: d.getUTCSeconds(),
  577. h2: d.getUTCHours() % 12,
  578. ap: d.getUTCHours() < 12 ? 'AM' : 'PM'
  579. }
  580. return o.replace(/(YY(YY)?|MMM?|DD|hh|mm|ss|h2|ap)/g, n => ('0' + v[n]).substr(-n.length))
  581. },
  582.  
  583. language: {
  584. en: { download: 'Download', completed: 'Download Completed', settings: 'Settings', dialog: { title: 'Download Settings', save: 'Save', save_history: 'Remember download history', clear_history: '(Clear)', clear_confirm: 'Clear download history?', show_sensitive: 'Always show sensitive content', pattern: 'File Name Pattern' }, enable_packaging: 'Package multiple files into a ZIP' },
  585. ja: { download: 'ダウンロード', completed: 'ダウンロード完了', settings: '設定', dialog: { title: 'ダウンロード設定', save: '保存', save_history: 'ダウンロード履歴を保存する', clear_history: '(クリア)', clear_confirm: 'ダウンロード履歴を削除する?', show_sensitive: 'センシティブな内容を常に表示する', pattern: 'ファイル名パターン' }, enable_packaging: '複数ファイルを ZIP にパッケージ化する' },
  586. zh: { download: '下载', completed: '下载完成', settings: '设置', dialog: { title: '下载设置', save: '保存', save_history: '保存下载记录', clear_history: '(清除)', clear_confirm: '确认要清除下载记录?', show_sensitive: '自动显示敏感的内容', pattern: '文件名格式' }, enable_packaging: '多文件打包成 ZIP' },
  587. 'zh-Hant': { download: '下載', completed: '下載完成', settings: '設置', dialog: { title: '下載設置', save: '保存', save_history: '保存下載記錄', clear_history: '(清除)', clear_confirm: '確認要清除下載記錄?', show_sensitive: '自動顯示敏感的内容', pattern: '文件名規則' }, enable_packaging: '多文件打包成 ZIP' }
  588. },
  589. css: `
  590. .tmd-down {margin-left: 12px; order: 99;}
  591. .tmd-down:hover > div > div > div > div {color: rgba(29, 161, 242, 1.0);}
  592. .tmd-down:hover > div > div > div > div > div {background-color: rgba(29, 161, 242, 0.1);}
  593. .tmd-down:active > div > div > div > div > div {background-color: rgba(29, 161, 242, 0.2);}
  594. .tmd-down:hover svg {color: rgba(29, 161, 242, 1.0);}
  595. .tmd-down:hover div:first-child:not(:last-child) {background-color: rgba(29, 161, 242, 0.1);}
  596. .tmd-down:active div:first-child:not(:last-child) {background-color: rgba(29, 161, 242, 0.2);}
  597. .tmd-down.tmd-media {position: absolute; right: 0;}
  598. .tmd-down.tmd-media > div {display: flex; border-radius: 99px; margin: 2px;}
  599. .tmd-down.tmd-media > div > div {display: flex; margin: 6px; color: #fff;}
  600. .tmd-down.tmd-media:hover > div {background-color: rgba(255,255,255, 0.6);}
  601. .tmd-down.tmd-media:hover > div > div {color: rgba(29, 161, 242, 1.0);}
  602. .tmd-down.tmd-media:not(:hover) > div > div {filter: drop-shadow(0 0 1px #000);}
  603. .tmd-down g {display: none;}
  604. .tmd-down.download g.download, .tmd-down.completed g.completed, .tmd-down.loading g.loading,.tmd-down.failed g.failed {display: unset;}
  605. .tmd-down.loading svg {animation: spin 1s linear infinite;}
  606. @keyframes spin {0% {transform: rotate(0deg);} 100% {transform: rotate(360deg);}}
  607. .tmd-btn {display: inline-block; background-color: #1DA1F2; color: #FFFFFF; padding: 0 20px; border-radius: 99px;}
  608. .tmd-tag {display: inline-block; background-color: #FFFFFF; color: #1DA1F2; padding: 0 10px; border-radius: 10px; border: 1px solid #1DA1F2; font-weight: bold; margin: 5px;}
  609. .tmd-btn:hover {background-color: rgba(29, 161, 242, 0.9);}
  610. .tmd-tag:hover {background-color: rgba(29, 161, 242, 0.1);}
  611. .tmd-notifier {display: none; position: fixed; left: 16px; bottom: 16px; color: #000; background: #fff; border: 1px solid #ccc; border-radius: 8px; padding: 4px;}
  612. .tmd-notifier.running {display: flex; align-items: center;}
  613. .tmd-notifier label {display: inline-flex; align-items: center; margin: 0 8px;}
  614. .tmd-notifier label:before {content: " "; width: 32px; height: 16px; background-position: center; background-repeat: no-repeat;}
  615. .tmd-notifier label:nth-child(1):before {background-image:url("data:image/svg+xml;charset=utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22><path d=%22M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11%22 fill=%22none%22 stroke=%22%23666%22 stroke-width=%222%22 stroke-linecap=%22round%22 /></svg>");}
  616. .tmd-notifier label:nth-child(2):before {background-image:url("data:image/svg+xml;charset=utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22><path d=%22M12,2 a1,1 0 0 1 0,20 a1,1 0 0 1 0,-20 M12,5 v7 h6%22 fill=%22none%22 stroke=%22%23999%22 stroke-width=%222%22 stroke-linejoin=%22round%22 stroke-linecap=%22round%22 /></svg>");}
  617. .tmd-notifier label:nth-child(3):before {background-image:url("data:image/svg+xml;charset=utf8,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2216%22 height=%2216%22 viewBox=%220 0 24 24%22><path d=%22M12,0 a2,2 0 0 0 0,24 a2,2 0 0 0 0,-24%22 fill=%22%23f66%22 stroke=%22none%22 /><path d=%22M14.5,5 a1,1 0 0 0 -5,0 l0.5,9 a1,1 0 0 0 4,0 z M12,17 a2,2 0 0 0 0,5 a2,2 0 0 0 0,-5%22 fill=%22%23fff%22 stroke=%22none%22 /></svg>");}
  618. .tmd-down.tmd-img {position: absolute; right: 0; bottom: 0; display: none !important;}
  619. .tmd-down.tmd-img > div {display: flex; border-radius: 99px; margin: 2px; background-color: rgba(255,255,255, 0.6);}
  620. .tmd-down.tmd-img > div > div {display: flex; margin: 6px; color: #fff !important;}
  621. .tmd-down.tmd-img:not(:hover) > div > div {filter: drop-shadow(0 0 1px #000);}
  622. .tmd-down.tmd-img:hover > div > div {color: rgba(29, 161, 242, 1.0);}
  623. :hover > .tmd-down.tmd-img, .tmd-img.loading, .tmd-img.completed, .tmd-img.failed {display: block !important;}
  624. .tweet-detail-action-item {width: 20% !important;}
  625. `,
  626. css_ss: `
  627. /* show sensitive in media tab */
  628. li[role="listitem"]>div>div>div>div:not(:last-child) {filter: none;}
  629. li[role="listitem"]>div>div>div>div+div:last-child {display: none;}
  630. `,
  631. svg: `
  632. <g class="download"><path d="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l4,4 q1,1 2,0 l4,-4 M12,3 v11" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" /></g>
  633. <g class="completed"><path d="M3,14 v5 q0,2 2,2 h14 q2,0 2,-2 v-5 M7,10 l3,4 q1,1 2,0 l8,-11" fill="none" stroke="#1DA1F2" stroke-width="2" stroke-linecap="round" /></g>
  634. <g class="loading"><circle cx="12" cy="12" r="10" fill="none" stroke="#1DA1F2" stroke-width="4" opacity="0.4" /><path d="M12,2 a10,10 0 0 1 10,10" fill="none" stroke="#1DA1F2" stroke-width="4" stroke-linecap="round" /></g>
  635. <g class="failed"><circle cx="12" cy="12" r="11" fill="#f33" stroke="currentColor" stroke-width="2" opacity="0.8" /><path d="M14,5 a1,1 0 0 0 -4,0 l0.5,9.5 a1.5,1.5 0 0 0 3,0 z M12,17 a2,2 0 0 0 0,4 a2,2 0 0 0 0,-4" fill="#fff" stroke="none" /></g>
  636. `
  637. }
  638. })()
  639.  
  640. TMD.init()