Skip to content

Instantly share code, notes, and snippets.

@3nly
Last active May 19, 2025 16:52
Show Gist options
  • Select an option

  • Save 3nly/907b94181d75a39c5effb622266360df to your computer and use it in GitHub Desktop.

Select an option

Save 3nly/907b94181d75a39c5effb622266360df to your computer and use it in GitHub Desktop.
4chan Multi-Image Downloader (Individual & ZIP Buttons, Configurable, Original Filename, Timeout)
// ==UserScript==
// @name 4chan Multi-Image Downloader (Individual & ZIP Buttons, Configurable, Original Filename)
// @namespace 4chan.org
// @version 1.0.0
// @description ALT/CTRL/SHIFT+Click thumbnails to select images, then download all selected at once (individually or as ZIP), with configurable buttons and original filename support
// @author 3nly
// @match http://boards.4chan.org/*
// @match https://boards.4chan.org/*
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @grant none
// ==/UserScript==
(function () {
'use strict';
// ==============================
// CONFIGURABLE SHORTCUT KEYS
// ==============================
// Set to true to require that key for selection.
// Example: { alt: true, ctrl: false, shift: false, meta: false }
// means ALT+Click selects/unselects.
// E.g. to use CTRL+SHIFT+Click, set { ctrl: true, shift: true, alt: false, meta: false }
const SELECTION_SHORTCUT = {
alt: true,
ctrl: false,
shift: false,
meta: false // 'meta' is the Windows key on Windows, Command on Mac
};
// ==============================
// CONFIGURABLE DOWNLOAD BUTTONS
// ==============================
// Set to true to show the button, false to hide it.
const SHOW_INDIVIDUAL_DOWNLOAD_BUTTON = true;
const SHOW_ZIP_DOWNLOAD_BUTTON = true;
// ==============================
// CONFIGURABLE ORIGINAL FILENAME
// ==============================
// Set to true to download using the original filename.
const USE_ORIGINAL_FILENAME = true;
// ==============================
// ==============================
// CONFIGURABLE TIMEOUT FOR LARGE BATCHES
// ==============================
// If more than this number of images are selected, wait X seconds between fetches.
const TIMEOUT_THRESHOLD = 20; // number of images fetched before timeout is added
const TIMEOUT_MS = 1000; // time between every fetch, in ms
// ==============================
// --- CONFIGURABLE SELECTORS ---
const thumbSelector = 'a.fileThumb';
// --- STYLE FOR SELECTED THUMBNAILS AND BUTTONS ---
const style = document.createElement('style');
style.textContent = `
.img-selected-glow {
box-shadow: 0 0 10px 4px #00e6ff, 0 0 20px 8px #00e6ff inset !important;
border-radius: 6px !important;
outline: 2px solid #00e6ff !important;
transition: box-shadow 0.2s, outline 0.2s;
}
.multi-img-download-btn {
position: fixed;
top: 30%;
right: 32px;
z-index: 99999;
background: #00e6ff;
color: #222;
border: none;
border-radius: 8px;
padding: 12px 20px;
font-size: 1.2em;
font-weight: bold;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0,0,0,0.2);
opacity: 0.95;
margin-bottom: 16px;
display: none;
}
.multi-img-download-btn:hover {
background: #00b3cc;
}
#multi-img-download-btn-individual {
top: 5%;
}
#multi-img-download-btn-zip {
top: 12%;
}
#multi-img-download-status {
position: fixed;
top: 20%;
right: 32px;
z-index: 99999;
background: #fff;
color: #222;
border: 1px solid #00e6ff;
border-radius: 8px;
padding: 10px 18px;
font-size: 1em;
box-shadow: 0 2px 12px rgba(0,0,0,0.1);
opacity: 0.97;
display: none;
}
`;
document.head.appendChild(style);
// --- STATE ---
const selectedThumbs = new Set();
// --- STATUS INDICATOR ---
let statusDiv = document.createElement('div');
statusDiv.id = 'multi-img-download-status';
document.body.appendChild(statusDiv);
function showStatus(msg) {
statusDiv.textContent = msg;
statusDiv.style.display = 'block';
}
function hideStatus() {
statusDiv.style.display = 'none';
}
// --- FILENAME RESOLVER ---
function getOriginalFilename(thumb) {
let postContainer = thumb.closest('.post, .thread, .postContainer') || document;
let original = null;
let allOriginals = postContainer.querySelectorAll('.fileText-original a');
for (let origA of allOriginals) {
if (origA.href === thumb.href) {
original = origA.textContent.trim();
break;
}
}
// If not found, try the first .fileText-original in the post
if (!original) {
let origSpan = postContainer.querySelector('.fileText-original a');
if (origSpan) {
original = origSpan.textContent.trim();
}
}
// Fallback: use filename from href
if (!original) {
original = thumb.href.split('/').pop().split('?')[0];
}
return original;
}
function getFilename(thumb, index) {
if (USE_ORIGINAL_FILENAME) {
return getOriginalFilename(thumb) || thumb.href.split('/').pop().split('?')[0] || `image${index}`;
} else {
return thumb.href.split('/').pop().split('?')[0] || `image${index}`;
}
}
// --- BUTTONS ---
let downloadBtnIndividual = null;
let downloadBtnZip = null;
// --- INDIVIDUAL DOWNLOAD BUTTON ---
if (SHOW_INDIVIDUAL_DOWNLOAD_BUTTON) {
downloadBtnIndividual = document.createElement('button');
downloadBtnIndividual.id = 'multi-img-download-btn-individual';
downloadBtnIndividual.className = 'multi-img-download-btn';
downloadBtnIndividual.textContent = 'Download Individually';
document.body.appendChild(downloadBtnIndividual);
downloadBtnIndividual.onclick = async function () {
if (selectedThumbs.size === 0) return;
downloadBtnIndividual.disabled = true;
if (downloadBtnZip) downloadBtnZip.disabled = true;
downloadBtnIndividual.textContent = 'Downloading...';
let i = 0;
const thumbsArray = Array.from(selectedThumbs);
const needDelay = thumbsArray.length > TIMEOUT_THRESHOLD;
for (const thumb of thumbsArray) {
const filename = getFilename(thumb, ++i);
showStatus(`Downloading ${filename} (${i}/${thumbsArray.length})...`);
try {
const resp = await fetch(thumb.href);
const blob = await resp.blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 1000);
} catch (e) {
console.error('Failed to download', thumb.href, e);
}
if (needDelay && i < thumbsArray.length) {
await sleep(TIMEOUT_MS);
}
}
hideStatus();
// Reset button state BEFORE clearing selection (so updateButtonVisibility hides them)
downloadBtnIndividual.disabled = false;
downloadBtnIndividual.textContent = 'Download Individually';
if (downloadBtnZip) downloadBtnZip.disabled = false;
clearSelection();
};
}
// --- ZIP DOWNLOAD BUTTON ---
if (SHOW_ZIP_DOWNLOAD_BUTTON) {
downloadBtnZip = document.createElement('button');
downloadBtnZip.id = 'multi-img-download-btn-zip';
downloadBtnZip.className = 'multi-img-download-btn';
downloadBtnZip.textContent = 'Download as ZIP';
document.body.appendChild(downloadBtnZip);
downloadBtnZip.onclick = async function () {
if (selectedThumbs.size === 0) return;
if (downloadBtnIndividual) downloadBtnIndividual.disabled = true;
downloadBtnZip.disabled = true;
downloadBtnZip.textContent = 'Zipping...';
// JSZip via @require
const zip = new window.JSZip();
let i = 0;
const thumbsArray = Array.from(selectedThumbs);
const needDelay = thumbsArray.length > TIMEOUT_THRESHOLD;
for (const thumb of thumbsArray) {
const filename = getFilename(thumb, ++i);
showStatus(`Fetching ${filename} (${i}/${thumbsArray.length})...`);
try {
const resp = await fetch(thumb.href);
const blob = await resp.blob();
zip.file(filename, blob);
} catch (e) {
console.error('Failed to fetch', thumb.href, e);
}
if (needDelay && i < thumbsArray.length) {
await sleep(TIMEOUT_MS);
}
}
showStatus('Generating ZIP...');
const zipBlob = await zip.generateAsync({ type: 'blob' });
const zipName = `images_${Date.now()}.zip`;
const a = document.createElement('a');
a.href = URL.createObjectURL(zipBlob);
a.download = zipName;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(a.href);
a.remove();
}, 1000);
hideStatus();
// Reset button state BEFORE clearing selection (so updateButtonVisibility hides them)
downloadBtnZip.disabled = false;
downloadBtnZip.textContent = 'Download as ZIP';
if (downloadBtnIndividual) downloadBtnIndividual.disabled = false;
clearSelection();
};
}
// --- CLEAR SELECTION ---
function clearSelection() {
for (const thumb of selectedThumbs) {
const img = thumb.querySelector('img');
if (img) img.classList.remove('img-selected-glow');
}
selectedThumbs.clear();
updateButtonVisibility();
}
// --- SELECTION HANDLER ---
function toggleThumbSelection(thumb) {
const img = thumb.querySelector('img');
if (selectedThumbs.has(thumb)) {
selectedThumbs.delete(thumb);
img.classList.remove('img-selected-glow');
} else {
selectedThumbs.add(thumb);
img.classList.add('img-selected-glow');
}
updateButtonVisibility();
}
// --- BUTTON VISIBILITY HANDLER ---
function updateButtonVisibility() {
const show = selectedThumbs.size > 0;
if (downloadBtnIndividual) downloadBtnIndividual.style.display = show ? 'block' : 'none';
if (downloadBtnZip) downloadBtnZip.style.display = show ? 'block' : 'none';
}
// --- SHORTCUT CHECKER ---
function isSelectionShortcut(event) {
return (
(!!SELECTION_SHORTCUT.alt === event.altKey) &&
(!!SELECTION_SHORTCUT.ctrl === event.ctrlKey) &&
(!!SELECTION_SHORTCUT.shift === event.shiftKey) &&
(!!SELECTION_SHORTCUT.meta === event.metaKey)
);
}
// --- EVENT LISTENER ---
document.addEventListener('click', function (e) {
// Only handle configured shortcut + Click on .fileThumb
if (isSelectionShortcut(e)) {
let thumb = e.target.closest(thumbSelector);
if (thumb) {
e.preventDefault();
toggleThumbSelection(thumb);
}
}
}, true);
// --- Remove selection if image is removed from DOM ---
const threadDiv = document.querySelector('.thread');
if (threadDiv) {
const observer = new MutationObserver(() => {
for (const thumb of Array.from(selectedThumbs)) {
if (!threadDiv.contains(thumb)) {
selectedThumbs.delete(thumb);
}
}
updateButtonVisibility();
});
observer.observe(threadDiv, { childList: true, subtree: true });
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment