// ==UserScript==
// @name XenForo Message Media Downloader
// @version 1.5
// @author js2k
// @description Download all (1st party) media found within a message on XenForo forums in a zip file.
// @match https://forums.leakemup.com/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.5.0/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @noframes
// @connect self
// @run-at document-end
// ==/UserScript==
/* globals JSZip saveAs */
'use strict';
/**
* Determines if emoji should be allowed in the zip name.
* @type {boolean} If set to true, emoji in thread titles will be allowed in the zip name.
*/
const ALLOW_THREAD_TITLE_EMOJI = false;
/**
* Edit this to change the replacement for illegal characters.
* Bad things may happen if you set this to an empty string, and the
* resulting title after replacement contains illegal characters or phrases.
* @type {string} Illegal characters in the thread title will be replaced with this string.
*/
const ILLEGAL_CHAR_REPLACEMENT = '_';
/**
* Determines if a string is null or empty.
* @param {string} str The string to be tested.
* @returns {boolean} True if the string is null or empty, false if the string is not nul or empty.
*/
const isNullOrEmpty = (str) => {
return !str;
};
/**
* Gets the thread title, removes illegal characters.
* @returns {string} String containing the thread title with illegal characters replaced.
*/
const getThreadTitle = () => {
// Define file name regexps
const REGEX_EMOJI = /[\u{1f300}-\u{1f5ff}\u{1f900}-\u{1f9ff}\u{1f600}-\u{1f64f}\u{1f680}-\u{1f6ff}\u{2600}-\u{26ff}\u{2700}-\u{27bf}\u{1f1e6}-\u{1f1ff}\u{1f191}-\u{1f251}\u{1f004}\u{1f0cf}\u{1f170}-\u{1f171}\u{1f17e}-\u{1f17f}\u{1f18e}\u{3030}\u{2b50}\u{2b55}\u{2934}-\u{2935}\u{2b05}-\u{2b07}\u{2b1b}-\u{2b1c}\u{3297}\u{3299}\u{303d}\u{00a9}\u{00ae}\u{2122}\u{23f3}\u{24c2}\u{23e9}-\u{23ef}\u{25b6}\u{23f8}-\u{23fa}]/gu;
const REGEX_WINDOWS = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])$|([<>:"\/\\|?*])|(\.|\s)$/gi;
// Strip label buttons
let threadTitle = [...document.querySelector('.p-title').childNodes].reduce((title, child) => {
return child.nodeType === 3 && !isNullOrEmpty(child.textContent) ? (title += child.textContent) : '';
});
// Remove emoji from title
if (!ALLOW_THREAD_TITLE_EMOJI) {
threadTitle = threadTitle.replaceAll(REGEX_EMOJI, ILLEGAL_CHAR_REPLACEMENT);
}
// Remove illegal chars and names (Windows)
threadTitle = threadTitle.replaceAll(REGEX_WINDOWS, ILLEGAL_CHAR_REPLACEMENT);
threadTitle = threadTitle.trim();
return threadTitle;
};
/**
* Gets the post number of a message.
* @param {HTMLElement} messageReference Reference to the message containing media.
* @returns {number} The post number of the message.
*/
const getPostNumber = (messageRef) => {
// Define excess whitespace regexp
const REGEX_EXCESS = /[\n\r]+|[\s]{2,}/g;
// Get post number from message reference, normalize and trim whitespace
const postNumber = messageRef.querySelector('.message-attribution-opposite > li:last-child > a').textContent.replaceAll(REGEX_EXCESS, ' ').trim();
return postNumber;
};
/**
* Gets URLs of media found within a message.
* @param {HTMLElement} messageRef Reference to the message containing media.
* @returns {Array<string>} Array of URL strings.
*/
const getMessageMediaUrls = (messageRef) => {
// Get all media elements found within the message
const messageMedia = messageRef.querySelectorAll('.js-lbImage, .attachment-icon a, .lbContainer-zoomer, video');
// Get urls from message media elements
const urls = [...messageMedia].map((curr) => {
// Videos can have a 'source' element
if (curr.querySelector('source')) {
return curr.querySelector('source').src;
} else {
// Return href, or src if attachment
return curr.getAttribute('href') ? curr.href : curr.dataset.src;
}
});
return urls;
};
/**
* Gets the name of a file from the URL string.
* @param {string} url
* @returns {string} The file name.
*/
const getFileNameFromUrl = (url) => {
let fileName = url.replace(/\/$/, '').split('/').pop();
// If fileName equals e.g."3840x2160_image-jpg.124135", convert to 3840x2160_image.jpg
if (fileName.split('.').pop().match(/^[\d]+$/)) {
// Remove incorrect extension
fileName = fileName.split('.').slice(0, -1).join('.');
// Get proper extension
let ext = fileName.split('-').pop();
fileName = fileName.split('-').slice(0, -1).join('-');
fileName = `leakemup.com - ${fileName}.${ext}`;
}
return fileName;
};
/**
* Downloads all media found in a message.
* @param {HTMLElement} messageRef Reference to the message containing media elements.
*/
const downloadMessageMedia = async (messageRef) => {
const downloadLink = messageRef.querySelector('.message-attribution-opposite > li:first-child > a');
const zip = new JSZip();
const urls = getMessageMediaUrls(messageRef);
const total = urls.length;
let current = 0;
for await (const url of urls) {
current++;
const percent = 0;
downloadLink.textContent = `Downloading... ${current}/${total} (${percent}%)`;
let data = [];
let blob;
const response = await fetch(url, { mode: 'cors' });
if (response.ok) {
const contentLength = +response.headers.get('Content-Length');
let fileName = '';
// Get filename from response, else get filename from url string
try {
fileName = response.headers.get('Content-Disposition').split('filename=')[1].replace(/\"/g, '');
} catch (e) {
fileName = getFileNameFromUrl(url);
}
// If content length is known, update download percentage on progress
if (contentLength) {
const reader = response.body.getReader();
let receivedLength = 0;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
data.push(value);
receivedLength += value.length;
const newPercent = ((receivedLength / contentLength) * 100).toFixed(0);
downloadLink.textContent = downloadLink.textContent.replace(/\([\d]{1,3}%\)/g, `(${newPercent}%)`);
}
} else {
data = await response.arrayBuffer();
}
// Add file to zip
blob = new Blob(data);
zip.file(fileName, blob);
} else {
downloadLink.textContent = `Downloading... ${current}/${total} failed, skipping`;
continue;
}
}
// Generate and save zip
downloadLink.textContent = 'Generating zip...';
const threadTitle = getThreadTitle();
const postNum = getPostNumber(messageRef);
const zipName = `leakemup.com - ${postNum}.zip`;
await zip.generateAsync({ type: 'blob' }).then((blob) => {
downloadLink.textContent = 'Saving zip...';
saveAs(blob, zipName);
downloadLink.textContent = 'Download complete!';
});
// Reset download link text after 5 seconds
await new Promise((resolve) => setTimeout(resolve, 5000));
downloadLink.textContent = '⭳ Download';
};
/**
* Adds a download link to the attribution of a message.
* @param {HTMLElement} messageRef Reference to the message containing media.
*/
const addDownloadLink = (messageRef) => {
// Get message attribution opposite
const messageAttrOpp = messageRef.querySelector('.message-attribution-opposite');
// Get first list child
const messageAttrOppFirstChild = messageAttrOpp.querySelector('li:first-child');
// Create download link
const downloadLinkList = document.createElement('LI');
const downloadLinkAnchor = document.createElement('A');
const downloadLinkText = '⭳ Download';
downloadLinkAnchor.setAttribute('href', '#');
downloadLinkAnchor.textContent = downloadLinkText;
downloadLinkList.appendChild(downloadLinkAnchor);
const downloadLink = downloadLinkList;
// Add download link to message attribution opposite
messageAttrOpp.insertBefore(downloadLink, messageAttrOppFirstChild);
// Add click event listener to download link
downloadLink.addEventListener('click', (e) => {
e.preventDefault();
// Download media found in message
downloadMessageMedia(messageRef);
});
};
// Get all messages with media elements
const messages = [...document.querySelectorAll('.message, .message--post')].filter((message) => {
return message.querySelector('.js-lbImage, .attachment-icon a, .lbContainer-zoomer, video');
});
// Add download link to messages
messages.forEach((msg) => {
addDownloadLink(msg);
});