SaveAsZip for Discord

一键下载帖子内所有图片,并保存为ZIP文件。

  1. // ==UserScript==
  2. // @name SaveAsZip for Discord
  3. // @name:ja SaveAsZip for Discord
  4. // @name::zh-cn SaveAsZip for Discord
  5. // @name::zh-tw SaveAsZip for Discord
  6. // @description Download post images and save as a ZIP file.
  7. // @description:ja 投稿の画像をZIPファイルとして保存する。
  8. // @description:zh-cn 一键下载帖子内所有图片,并保存为ZIP文件。
  9. // @description:zh-tw 一鍵下載帖子内所有圖片,並保存為ZIP文件。
  10. // @version 0.13
  11. // @namespace none
  12. // @match https://discord.com/*
  13. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
  14. // @grant none
  15. // @license MIT
  16. // @run-at document-body
  17. // ==/UserScript==
  18. /* jshint esversion: 8 */
  19.  
  20. const preset_zip_name = '{username}_{datetime_local:YYYYMMDD-hhmmss}_{channel_id}_{message_id}_images.zip';
  21.  
  22. const token = getToken();
  23. const JSZip = window.JSZip;
  24. addStyle();
  25. addButton();
  26.  
  27. function addButton() {
  28. let observer = new MutationObserver(() => findContainer());
  29. observer.observe(document.body, {childList: true, subtree: true});
  30. }
  31.  
  32. function findContainer() {
  33. let containers = document.querySelectorAll('li div[class^="mediaAttachmentsContainer_"]:not(.zip-btn-added)');
  34. containers.forEach(container => addButtonTo(container));
  35. }
  36.  
  37. function addButtonTo(container) {
  38. container.classList.add('zip-btn-added');
  39. let btn = document.createElement('span');
  40. btn.classList.add('saveaszip');
  41. if (isGroupStart(container)) btn.classList.add('group-start');
  42. btn.innerHTML = '<label class="down-btn"><span class="btn-text">ZIP</span></label><label class="down-speed">0KB/S</label>';
  43. btn.onclick = () => SaveAsZip(btn, container);
  44. container.appendChild(btn);
  45. }
  46.  
  47. function isGroupStart(container) {
  48. let target_li = container.closest('li');
  49. while (true) {
  50. let is_group_start = target_li.querySelector(':scope > div[class*="groupStart"]');
  51. if (is_group_start) return true;
  52. target_li = target_li.previousElementSibling;
  53. if (!target_li) break;
  54. let has_media = target_li.querySelector('div[class^="mediaAttachmentsContainer_"]');
  55. if (has_media) break;
  56. }
  57. return false;
  58. }
  59.  
  60. async function SaveAsZip(btn, container) {
  61. if (btn.classList.contains('down')) return;
  62. else btn.classList.add('down');
  63. let btn_text = btn.querySelector('.btn-text');
  64. let btn_speed = btn.querySelector('.down-speed');
  65. const status = text => (btn_text.innerText = text);
  66. const speeds = text => (btn_speed.innerHTML = text);
  67. let invalid_chars = {'\\': '\', '\/': '/', '\|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': ''};
  68. let datetime_pattern = preset_zip_name.match(/{datetime(-local)?:[^{}]+}/) ? preset_zip_name.match(/{datetime(?:-local)?:([^{}]+)}/)[1].replace(/[\\/|<>*?:"]/g, v => invalid_chars[v]) : 'YYYYMMDD-hhmmss';
  69.  
  70. //get channel_id and message_id
  71. let anchor_li = container.closest('li');
  72. let anchor_li_is_flash = anchor_li.parentNode.classList.value.indexOf('backgroundFlash') >=0;
  73. let [channel_id, message_id] = anchor_li.id.split('-').slice(-2);
  74.  
  75. //get datetime in first message
  76. let datetime_utc = formatDate(anchor_li.querySelector('time').getAttribute('datetime'), datetime_pattern);
  77. let datetime_local = formatDate(anchor_li.querySelector('time').getAttribute('datetime'), datetime_pattern, true);
  78.  
  79. //get messages group
  80. let messages_group = [message_id];
  81. let anchor = anchor_li_is_flash ? anchor_li.parentNode : anchor_li;
  82. while (true) {
  83. let current = anchor.nextElementSibling;
  84. let current_is_flash = current.tagName == 'DIV' && current.classList.value.indexOf('backgroundFlash') >=0;
  85. if (current_is_flash) current = current.firstChild;
  86. if (current.tagName == 'LI' && !current.querySelector('h3')) {
  87. messages_group.push(current.id.split('-').pop());
  88. anchor = current_is_flash ? current.parentNode : current;
  89. } else break;
  90. }
  91.  
  92. //get post json
  93. let url = `https://discord.com/api/v9/channels/${channel_id}/messages?limit=50&around=${message_id}`;
  94. let json = await (await fetch(url, {headers: {'Authorization': token}})).json();
  95. if (!Array.isArray(json)) return console.error('error: get json failed');
  96. let message = json.find(message => message.id == message_id);
  97. let author_id = message.author.id;
  98. let author_name = message.author.global_name || message.author.username;
  99.  
  100. //extract post info
  101. let info = {
  102. channel_id: channel_id,
  103. message_id: message_id,
  104. datetime: datetime_utc,
  105. datetime_local: datetime_local,
  106. user_id: author_id,
  107. username: author_name
  108. };
  109.  
  110. //create zip and set filename
  111. let zip = new JSZip();
  112. let zip_name = preset_zip_name.replace(/{([^{}:]+)(:[^{}]+)?}/g, (match, name) => info[name]);
  113.  
  114. //find images
  115. let images = [];
  116. let images_size = 0, images_size_2;
  117. messages_group.forEach(message_id => {
  118. let message = json.find(message => message.id == message_id);
  119. if (message && message.author.id == author_id && message.attachments.length) {
  120. message.attachments.forEach(file => {
  121. if (file.content_type.indexOf('image') == 0) {
  122. file.message_id = message_id;
  123. file.timestamp = formatDate(message.timestamp, datetime_pattern);
  124. file.timestamp_local = formatDate(message.timestamp, datetime_pattern, true);
  125. images.push(file);
  126. images_size += file.size;
  127. }
  128. });
  129. }
  130. });
  131. images_size_2 = images_size < 1024000 ? Math.round(images_size / 1024) + 'KB' : (images_size / 1048576).toFixed(2) + 'MB';
  132.  
  133. //show download speed if images size over 10MB
  134. let received = 0, received_2, traffic = 0, traffic_buffer = [], traffic_update;
  135. if (images_size >= 10485760) {
  136. btn.classList.add('speed');
  137. traffic_update = setInterval(() => {
  138. traffic_buffer.push(traffic);
  139. let speed = traffic_buffer.reduce((a, b) => a + b, 0) / traffic_buffer.length / 1024;
  140. received_2 = received < 1024000 ? Math.round(received / 1024) + 'KB' : (received / 1048576).toFixed(2) + 'MB';
  141. speeds(`${received_2} of ${images_size_2} | ${speed < 1000 ? Math.round(speed) + ' KB/s' : (speed / 1024).toFixed(2) + ' MB/s'}`);
  142. if (traffic_buffer.length >= 5) traffic_buffer.shift();
  143. traffic = 0;
  144. }, 1000);
  145. }
  146.  
  147. //download image and add to zip
  148. for (let i = 0; i < images.length; i++) {
  149. status(`${i + 1}/${images.length}`);
  150. let image = images[i];
  151. let response = await fetch(image.url);
  152. let content_type = response.headers.get('content-type');
  153. const reader = response.body.getReader();
  154. let chunks = [];
  155. while (true) {
  156. const {done, value} = await reader.read();
  157. if (value) {
  158. chunks.push(value);
  159. received += value.length;
  160. traffic += value.length;
  161. }
  162. if (done) break;
  163. }
  164. let image_blob = new Blob(chunks, {type: content_type});
  165. zip.file(`${image.message_id}_${image.id}_${image.filename}`, image_blob);
  166. }
  167.  
  168. //download completed
  169. speeds('');
  170. btn.classList.remove('speed');
  171. clearInterval(traffic_update);
  172.  
  173. //save
  174. status('Save');
  175. let zip_blob = await zip.generateAsync({type: 'blob'});
  176. let zip_url = URL.createObjectURL(zip_blob);
  177. let link = document.createElement('a');
  178. link.href = zip_url;
  179. link.download = zip_name;
  180. link.dispatchEvent(new MouseEvent('click'));
  181. setTimeout(() => URL.revokeObjectURL(zip_url), 100);
  182.  
  183. //done
  184. btn.classList.remove('down');
  185. btn.classList.add('done');
  186. status('Done');
  187.  
  188. }
  189.  
  190. function getToken() {
  191. const iframe = document.createElement('iframe');
  192. iframe.style.display = 'none';
  193. const token = JSON.parse(document.body.appendChild(iframe).contentWindow.localStorage.token);
  194. iframe.remove();
  195. return token;
  196. }
  197.  
  198. function formatDate(i, o, tz) {
  199. let d = new Date(i);
  200. if (tz) d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
  201. let v = {YYYY: d.getUTCFullYear(), YY: d.getUTCFullYear() % 100, MM: d.getUTCMonth() + 1, DD: d.getUTCDate(), hh: d.getUTCHours(), mm: d.getUTCMinutes(), ss: d.getUTCSeconds()};
  202. return o.replace(/(YYYY|MM|DD|hh|mm|ss)/g, n => ('0' + v[n]).substr(-n.length));
  203. }
  204.  
  205. function addStyle() {
  206. let css = `
  207. .saveaszip {position: absolute; color: white; padding: 6px 4px; z-index: 99; left: 0; top: 0;}
  208. .saveaszip {display: flex; gap: 4px; align-items: center;}
  209. .saveaszip:not(.group-start) {display: none;}
  210. .saveaszip label {background: #0008; border: 1px solid #8888; border-radius: 6px; padding: 4px 12px;}
  211. .saveaszip:hover label.down-btn {background: #000a; border-color: #fff3;}
  212. .saveaszip.down label.down-btn {background: #000a; border-color: #fff3;}
  213. .saveaszip.done label.down-btn {background: #060a; border-color: #fff3;}
  214. .saveaszip label.down-speed {font-family: monospace; font-size: 14px; padding: 3px 6px;}
  215. .saveaszip:not(.speed) label.down-speed {display: none;}
  216. /* progress bar animation */
  217. .saveaszip.down .down-speed {background-image: linear-gradient(-45deg, #fff2 0%, #fff2 25%, #0000 25%, #0000 50%, #fff2 50%, #fff2 75%, #0000 75%, #0000 100%); background-size: 32px 32px; animation: progress 2s linear infinite;}
  218. @keyframes progress {0% {background-position:0 0} 100% {background-position:32px 32px}}
  219. `;
  220. document.head.insertAdjacentHTML('beforeend', `<style>${css}</style>`);
  221. }