Last active
May 19, 2025 16:52
-
-
Save 3nly/907b94181d75a39c5effb622266360df to your computer and use it in GitHub Desktop.
4chan Multi-Image Downloader (Individual & ZIP Buttons, Configurable, Original Filename, Timeout)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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