NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name BOOTH Orders - Download All
// @namespace https://booth.pm/
// @version 1.0.0
// @description Adds a "Download All" button to BOOTH orders page that downloads all files as a ZIP
// @author DjShinter
// @copyright 2025, DjShinter (https://shinter.dev)
// @license CC-BY-NC-SA-4.0; https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
// @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0.txt
// @homepageURL https://github.com/DjShinter/
// @supportURL https://github.com/DjShinter/BoothDL/issues
// @updateURL https://openuserjs.org/meta/djshinter/booth-download-all.meta.js
// @match https://accounts.booth.pm/orders/*
// @run-at document-idle
// @grant GM_xmlhttpRequest
// @connect booth.pm
// @connect *.booth.pm
// @connect booth.pximg.net
// @connect *
// @require https://cdn.jsdelivr.net/npm/fflate@0.8.2/umd/index.js
// ==/UserScript==
(function () {
'use strict';
const BUTTON_ID = 'booth-download-all-button';
const STATUS_ID = 'booth-download-all-status';
function log(...args) {
console.log('[BOOTH DL All]', ...args);
}
function findDownloadAnchors() {
const anchors = Array.from(
document.querySelectorAll('a[href^="https://booth.pm/downloadables/"]')
);
const seen = new Set();
return anchors.filter(a => {
const href = a.href;
if (!href || seen.has(href)) return false;
seen.add(href);
return true;
});
}
function getProductName() {
const productLink = document.querySelector('a.nav[href*="/items/"]');
if (productLink) {
let name = productLink.textContent.trim();
// Only remove truly invalid filesystem characters (Windows/Mac/Linux)
name = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_');
// Truncate if too long (Windows has 255 char limit)
if (name.length > 200) {
name = name.substring(0, 200);
}
return name.trim() || 'BOOTH_Download';
}
return 'BOOTH_Download';
}
function extractFilenameFromHeaders(headers, fallbackUrl) {
try {
const lines = headers.split('\n');
for (let line of lines) {
if (line.toLowerCase().includes('content-disposition')) {
// Try quoted filename
let match = line.match(/filename\*?=\s*"([^"]+)"/i);
if (match) {
let filename = match[1];
// Try to decode if URL-encoded
try {
filename = decodeURIComponent(filename);
} catch (e) {
// Already decoded or invalid encoding, keep as-is
}
filename = filename.split('?')[0];
return filename;
}
// Try UTF-8 encoded filename (RFC 5987)
match = line.match(/filename\*=UTF-8''([^;\s]+)/i);
if (match) {
let filename = match[1];
try {
filename = decodeURIComponent(filename);
} catch (e) {}
filename = filename.split('?')[0];
return filename;
}
// Try unquoted filename
match = line.match(/filename\*?=\s*([^;\s]+)/i);
if (match) {
let filename = match[1].replace(/['"]/g, '');
try {
filename = decodeURIComponent(filename);
} catch (e) {}
filename = filename.split('?')[0];
return filename;
}
}
}
} catch (e) {
console.error('Error extracting filename:', e);
}
let filename = (fallbackUrl || '').split('/').pop() || 'file.bin';
// Try to decode URL-encoded fallback filename
try {
filename = decodeURIComponent(filename);
} catch (e) {}
filename = filename.split('?')[0];
return filename;
}
async function downloadFile(url) {
log('Downloading:', url);
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'arraybuffer',
// Let GM_xmlhttpRequest follow redirects automatically
onload: function(response) {
if (response.status >= 200 && response.status < 300) {
const filename = extractFilenameFromHeaders(response.responseHeaders || '', response.finalUrl || url);
log('Downloaded:', filename, '(' + (response.response.byteLength / 1024 / 1024).toFixed(2) + ' MB)');
resolve({
filename: filename,
data: response.response
});
} else {
reject(new Error('Failed to download: ' + response.status));
}
},
onerror: function(error) {
reject(error);
}
// No timeout - let large files download as long as they need
});
});
}
function createButton() {
if (document.getElementById(BUTTON_ID)) return;
// Look for the spacing div before the list
const spacingDiv = document.querySelector('.u-mt-300');
if (!spacingDiv) {
log('Spacing div not found yet.');
return;
}
const lists = document.querySelectorAll('.list.list--collapse');
if (!lists.length) {
log('List container not found yet.');
return;
}
// Create a Booth-styled list item matching the exact structure
const listItem = document.createElement('div');
listItem.className = 'legacy-list-item';
const center = document.createElement('div');
center.className = 'legacy-list-item__center u-tpg-caption1';
center.style.cssText = 'flex: 1;';
// Left side - status only
const leftDiv = document.createElement('div');
leftDiv.className = 'min-w-0 u-text-wrap';
leftDiv.style.cssText = 'color: #505c6b; flex: 1;';
const statusDiv = document.createElement('div');
statusDiv.id = STATUS_ID;
statusDiv.style.fontSize = '12px';
statusDiv.style.color = '#505c6b';
statusDiv.style.display = 'none';
statusDiv.style.whiteSpace = 'pre-line';
leftDiv.appendChild(statusDiv);
// Right side - button wrapper matching Booth structure
const rightWrap = document.createElement('div');
rightWrap.className = 'flex items-center';
rightWrap.style.cssText = 'margin-top: 8px; flex: none;';
const spacer = document.createElement('div');
const actionDiv = document.createElement('div');
actionDiv.className = 'u-ml-500 u-mr-sp-500';
// Create the anchor button matching Booth's exact style
const btn = document.createElement('a');
btn.id = BUTTON_ID;
btn.href = 'javascript:void(0)';
btn.className = 'nav-reverse';
const icon = document.createElement('i');
icon.className = 'icon-download s-1x';
const label = document.createElement('span');
label.className = 'cmd-label';
label.textContent = 'Download All';
btn.appendChild(icon);
btn.appendChild(label);
actionDiv.appendChild(btn);
rightWrap.appendChild(spacer);
rightWrap.appendChild(actionDiv);
center.appendChild(leftDiv);
center.appendChild(rightWrap);
listItem.appendChild(center);
btn.addEventListener('click', async (e) => {
e.preventDefault();
try {
btn.style.pointerEvents = 'none';
btn.style.opacity = '0.6';
statusDiv.style.display = 'block';
const anchors = findDownloadAnchors();
if (anchors.length === 0) {
statusDiv.textContent = 'No downloadable items found.';
btn.style.pointerEvents = 'auto';
btn.style.opacity = '1';
return;
}
const productName = getProductName();
statusDiv.textContent = 'Found ' + anchors.length + ' files. Downloading...';
// Download all files in parallel
let completed = 0;
const downloadPromises = anchors.map(async (anchor) => {
try {
const file = await downloadFile(anchor.href);
completed++;
statusDiv.textContent = 'Downloaded ' + completed + '/' + anchors.length + ' files';
return file;
} catch (error) {
console.error('Failed to download ' + anchor.href + ':', error);
completed++;
return null;
}
});
const files = (await Promise.all(downloadPromises)).filter(f => f !== null);
if (files.length === 0) {
statusDiv.textContent = 'Failed to download any files.';
btn.style.pointerEvents = 'auto';
btn.style.opacity = '1';
return;
}
statusDiv.textContent = 'Creating ZIP with ' + files.length + ' files...';
log('Using fflate to create ZIP...');
// Use fflate (modern, fast ZIP library)
const zipData = {};
files.forEach(file => {
// fflate expects Uint8Array
zipData[file.filename] = new Uint8Array(file.data);
});
statusDiv.textContent = 'Compressing ZIP (no compression for speed)...';
// Create ZIP with fflate (synchronous, but fast)
const zipped = fflate.zipSync(zipData, {
level: 0 // No compression (files are already .zip files)
});
log('ZIP created! Size:', zipped.byteLength);
// Download the ZIP
const zipFilename = productName + '.zip';
const zipBlob = new Blob([zipped], { type: 'application/zip' });
const zipUrl = URL.createObjectURL(zipBlob);
const link = document.createElement('a');
link.href = zipUrl;
link.download = zipFilename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setTimeout(() => URL.revokeObjectURL(zipUrl), 1000);
statusDiv.textContent = '✓ Downloaded ' + zipFilename + ' (' + files.length + ' files)';
} catch (error) {
console.error('[BOOTH Download All] Error', error);
statusDiv.textContent = 'Error: ' + error.message + '\nCheck console for details.';
} finally {
btn.style.pointerEvents = 'auto';
btn.style.opacity = '1';
}
});
try {
// Insert after the spacing div
spacingDiv.parentNode.insertBefore(listItem, spacingDiv.nextSibling);
log('Inserted Download All button after spacing div.');
} catch (e) {
console.error('[BOOTH DL All] Failed to insert:', e);
}
}
function init() {
log('Init on', location.href);
createButton();
const observer = new MutationObserver(() => {
createButton();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
// Retry until button appears
let tries = 0;
const timer = setInterval(() => {
if (document.getElementById(BUTTON_ID) || tries >= 20) {
clearInterval(timer);
} else {
createButton();
tries++;
}
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();