// ==UserScript==
// @name SaveAsZip for Discord
// @name:ja SaveAsZip for Discord
// @name::zh-cn SaveAsZip for Discord
// @name::zh-tw SaveAsZip for Discord
// @description Download post images and save as a ZIP file.
// @description:ja 投稿の画像をZIPファイルとして保存する。
// @description:zh-cn 一键下载帖子内所有图片,并保存为ZIP文件。
// @description:zh-tw 一鍵下載帖子内所有圖片,並保存為ZIP文件。
// @version 0.13
// @namespace none
// @match https://discord.com/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
// @grant none
// @license MIT
// @run-at document-body
// ==/UserScript==
/* jshint esversion: 8 */
const preset_zip_name = '{username}_{datetime_local:YYYYMMDD-hhmmss}_{channel_id}_{message_id}_images.zip';
const token = getToken();
const JSZip = window.JSZip;
addStyle();
addButton();
function addButton() {
let observer = new MutationObserver(() => findContainer());
observer.observe(document.body, {childList: true, subtree: true});
}
function findContainer() {
let containers = document.querySelectorAll('li div[class^="mediaAttachmentsContainer_"]:not(.zip-btn-added)');
containers.forEach(container => addButtonTo(container));
}
function addButtonTo(container) {
container.classList.add('zip-btn-added');
let btn = document.createElement('span');
btn.classList.add('saveaszip');
if (isGroupStart(container)) btn.classList.add('group-start');
btn.innerHTML = '<label class="down-btn"><span class="btn-text">ZIP</span></label><label class="down-speed">0KB/S</label>';
btn.onclick = () => SaveAsZip(btn, container);
container.appendChild(btn);
}
function isGroupStart(container) {
let target_li = container.closest('li');
while (true) {
let is_group_start = target_li.querySelector(':scope > div[class*="groupStart"]');
if (is_group_start) return true;
target_li = target_li.previousElementSibling;
if (!target_li) break;
let has_media = target_li.querySelector('div[class^="mediaAttachmentsContainer_"]');
if (has_media) break;
}
return false;
}
async function SaveAsZip(btn, container) {
if (btn.classList.contains('down')) return;
else btn.classList.add('down');
let btn_text = btn.querySelector('.btn-text');
let btn_speed = btn.querySelector('.down-speed');
const status = text => (btn_text.innerText = text);
const speeds = text => (btn_speed.innerHTML = text);
let invalid_chars = {'\\': '\', '\/': '/', '\|': '|', '<': '<', '>': '>', ':': ':', '*': '*', '?': '?', '"': '"', '\u200b': '', '\u200c': '', '\u200d': '', '\u2060': '', '\ufeff': '', '🔞': ''};
let datetime_pattern = preset_zip_name.match(/{datetime(-local)?:[^{}]+}/) ? preset_zip_name.match(/{datetime(?:-local)?:([^{}]+)}/)[1].replace(/[\\/|<>*?:"]/g, v => invalid_chars[v]) : 'YYYYMMDD-hhmmss';
//get channel_id and message_id
let anchor_li = container.closest('li');
let anchor_li_is_flash = anchor_li.parentNode.classList.value.indexOf('backgroundFlash') >=0;
let [channel_id, message_id] = anchor_li.id.split('-').slice(-2);
//get datetime in first message
let datetime_utc = formatDate(anchor_li.querySelector('time').getAttribute('datetime'), datetime_pattern);
let datetime_local = formatDate(anchor_li.querySelector('time').getAttribute('datetime'), datetime_pattern, true);
//get messages group
let messages_group = [message_id];
let anchor = anchor_li_is_flash ? anchor_li.parentNode : anchor_li;
while (true) {
let current = anchor.nextElementSibling;
let current_is_flash = current.tagName == 'DIV' && current.classList.value.indexOf('backgroundFlash') >=0;
if (current_is_flash) current = current.firstChild;
if (current.tagName == 'LI' && !current.querySelector('h3')) {
messages_group.push(current.id.split('-').pop());
anchor = current_is_flash ? current.parentNode : current;
} else break;
}
//get post json
let url = `https://discord.com/api/v9/channels/${channel_id}/messages?limit=50&around=${message_id}`;
let json = await (await fetch(url, {headers: {'Authorization': token}})).json();
if (!Array.isArray(json)) return console.error('error: get json failed');
let message = json.find(message => message.id == message_id);
let author_id = message.author.id;
let author_name = message.author.global_name || message.author.username;
//extract post info
let info = {
channel_id: channel_id,
message_id: message_id,
datetime: datetime_utc,
datetime_local: datetime_local,
user_id: author_id,
username: author_name
};
//create zip and set filename
let zip = new JSZip();
let zip_name = preset_zip_name.replace(/{([^{}:]+)(:[^{}]+)?}/g, (match, name) => info[name]);
//find images
let images = [];
let images_size = 0, images_size_2;
messages_group.forEach(message_id => {
let message = json.find(message => message.id == message_id);
if (message && message.author.id == author_id && message.attachments.length) {
message.attachments.forEach(file => {
if (file.content_type.indexOf('image') == 0) {
file.message_id = message_id;
file.timestamp = formatDate(message.timestamp, datetime_pattern);
file.timestamp_local = formatDate(message.timestamp, datetime_pattern, true);
images.push(file);
images_size += file.size;
}
});
}
});
images_size_2 = images_size < 1024000 ? Math.round(images_size / 1024) + 'KB' : (images_size / 1048576).toFixed(2) + 'MB';
//show download speed if images size over 10MB
let received = 0, received_2, traffic = 0, traffic_buffer = [], traffic_update;
if (images_size >= 10485760) {
btn.classList.add('speed');
traffic_update = setInterval(() => {
traffic_buffer.push(traffic);
let speed = traffic_buffer.reduce((a, b) => a + b, 0) / traffic_buffer.length / 1024;
received_2 = received < 1024000 ? Math.round(received / 1024) + 'KB' : (received / 1048576).toFixed(2) + 'MB';
speeds(`${received_2} of ${images_size_2} | ${speed < 1000 ? Math.round(speed) + ' KB/s' : (speed / 1024).toFixed(2) + ' MB/s'}`);
if (traffic_buffer.length >= 5) traffic_buffer.shift();
traffic = 0;
}, 1000);
}
//download image and add to zip
for (let i = 0; i < images.length; i++) {
status(`${i + 1}/${images.length}`);
let image = images[i];
let response = await fetch(image.url);
let content_type = response.headers.get('content-type');
const reader = response.body.getReader();
let chunks = [];
while (true) {
const {done, value} = await reader.read();
if (value) {
chunks.push(value);
received += value.length;
traffic += value.length;
}
if (done) break;
}
let image_blob = new Blob(chunks, {type: content_type});
zip.file(`${image.message_id}_${image.id}_${image.filename}`, image_blob);
}
//download completed
speeds('');
btn.classList.remove('speed');
clearInterval(traffic_update);
//save
status('Save');
let zip_blob = await zip.generateAsync({type: 'blob'});
let zip_url = URL.createObjectURL(zip_blob);
let link = document.createElement('a');
link.href = zip_url;
link.download = zip_name;
link.dispatchEvent(new MouseEvent('click'));
setTimeout(() => URL.revokeObjectURL(zip_url), 100);
//done
btn.classList.remove('down');
btn.classList.add('done');
status('Done');
}
function getToken() {
const iframe = document.createElement('iframe');
iframe.style.display = 'none';
const token = JSON.parse(document.body.appendChild(iframe).contentWindow.localStorage.token);
iframe.remove();
return token;
}
function formatDate(i, o, tz) {
let d = new Date(i);
if (tz) d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
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()};
return o.replace(/(YYYY|MM|DD|hh|mm|ss)/g, n => ('0' + v[n]).substr(-n.length));
}
function addStyle() {
let css = `
.saveaszip {position: absolute; color: white; padding: 6px 4px; z-index: 99; left: 0; top: 0;}
.saveaszip {display: flex; gap: 4px; align-items: center;}
.saveaszip:not(.group-start) {display: none;}
.saveaszip label {background: #0008; border: 1px solid #8888; border-radius: 6px; padding: 4px 12px;}
.saveaszip:hover label.down-btn {background: #000a; border-color: #fff3;}
.saveaszip.down label.down-btn {background: #000a; border-color: #fff3;}
.saveaszip.done label.down-btn {background: #060a; border-color: #fff3;}
.saveaszip label.down-speed {font-family: monospace; font-size: 14px; padding: 3px 6px;}
.saveaszip:not(.speed) label.down-speed {display: none;}
/* progress bar animation */
.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;}
@keyframes progress {0% {background-position:0 0} 100% {background-position:32px 32px}}
`;
document.head.insertAdjacentHTML('beforeend', `<style>${css}</style>`);
}