您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Download media files from 4chan.org with their posted filenames.
// ==UserScript== // @name 4chan-dl // @namespace 0000xFFFF // @version 1.3.2 // @description Download media files from 4chan.org with their posted filenames. // @author 0000xFFFF // @license MIT // @match *://boards.4chan.org/*/thread/* // @match *://boards.4channel.org/*/thread/* // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js // @grant none // @icon data:image/ico;base64,AAABAAEAEBAAAAEAIAC+AAAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAQAAAAEAgGAAAAH/P/YQAAAIVJREFUeJxjYMAO/uPARIP/aWeMUTAxBqDYhsMAnK7BUIzNAEIuItoA2rmArDBQOWcoikWSGAP+a50ylcAwBF0jLoPgmrG5hGTNMMAsyELQCyzCrDgTFFhhIpJidM2JBFIlXEMilkBLICJZo8Q3sndAzkaWw2UAA5IEmNa7qCcGwtjkqAYAtUIYeAqEFoUAAAAASUVORK5CYII= // ==/UserScript== (function() { 'use strict'; function GM_addStyle(css) { const style = document.createElement("style"); style.textContent = css; (document.head || document.documentElement).appendChild(style); return style; } const fcdl_css = ` .fcdl_button_regular { padding: 12px 18px; display: flex; gap: 5px; background: #2d5016; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; font-weight: bold; box-shadow: 0 4px 15px rgba(0,0,0,0.3); transition: all 0.3s ease; white-space: nowrap; } .fcdl_button_regular:hover { background: #4a7c21; transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.4); } .fcdl_post_button { padding: 0 0 0 3px; margin: 0; background: transparent; color: white; border: none; cursor: pointer; opacity: 0.6; float: right; } .fcdl_main_container { display: flex; margin: 15px 0 15px 0; gap: 10px; } .fcdl_settings_container { display: flex; gap: 10px; justify-content: flex-end; align-items: center; } .fcdl_radio_label { display: flex; align-items: center; gap: 8px; cursor: pointer; overflow: hidden; } .fcdl_radio_input { cursor: pointer; accent-color: rgb(102, 204, 51); background-color black; display: none; } .fcdl_radio_span { height: 15px; width: 15px; border: 1px solid #555; border-radius: 50%; display: inline-block; position: relative; cursor: pointer; } .fcdl_radio_input:checked + .fcdl_radio_span { background-color: green; border-color: #4CAF50; } .fcdl_radio_input:checked + .fcdl_radio_span::after { content: ""; position: absolute; top: 3px; left: 3px; width: 9px; height: 9px; background: lime; border-radius: 100%; } .fcdl_progress_container { padding-left: 15px; display: flex; justify-content: flex-end; align-items: center; gap: 15px; font-family: arial, helvetica, sans-serif; color: white; font-size: 14px; } .fcdl_progress_bar { width: 200px; height: 8px; background: #333; border-radius: 4px; overflow: hidden; } .fcdl_progress_fill { height: 100%; background: linear-gradient(90deg, #4CAF50, #45a049); width: 0%; transition: width 0.3s ease; border-radius: 4px; } `; GM_addStyle(fcdl_css); const userscript_icon = "data:image/ico;base64,AAABAAEADg8AAAEAIAC4AAAAFgAAAIlQTkcNChoKAAAADUlIRFIAAAAOAAAADwgGAAAA1BT+dAAAAH9JREFUeJxjYMAE/3FgguB/2hljFIxPI4rpODRi2I6hCJtGXC4gWiP1bCTLj2Ro/M+gcs5QFJcGXAZonTKVAFtJjGYMTTDALMhC0KkswqwYCQGsIBFJEbqmRBypCK4wEUtgJOBJfijxhexskPOQ5dA1MiAJgGm9i3piIIxNDgQAf5IV/0loTT0AAAAASUVORK5CYII="; function loadSetting(name, def) { const raw = localStorage.getItem(name); if (raw === null) { localStorage.setItem(name, JSON.stringify(def)); return def; } return JSON.parse(raw); } function saveSetting(name, value) { localStorage.setItem(name, JSON.stringify(value)); } const config = { useOriginalNames: loadSetting("useOriginalNames", true), usePostIds: loadSetting("usePostIds", false), combineNames: loadSetting("combineNames", false), maxConcurrentDownloads: loadSetting("maxConcurrentDownloads", 5) }; function createDownloadButtons() { const postContainers = document.querySelectorAll(".postContainer"); postContainers.forEach((postContainer, index) => { const postInfos = postContainer.querySelectorAll(".postInfo"); postInfos.forEach((postInfo, index) => { const button = document.createElement("button"); button.title = "Download All as ZIP from this post down"; button.className = "fcdl_post_button"; const img = document.createElement("img") img.src = userscript_icon; button.appendChild(img); button.addEventListener('click', function(e) { e.preventDefault(); downloadAllImagesAsZip(postContainer.id.replace("pc", "")); }); postInfo.appendChild(button); }); }); } function createDownloadButton() { const button = document.createElement('button'); button.id = "4chan_dl_button"; button.className = "fcdl_button_regular"; const img = document.createElement("img"); img.src = userscript_icon; button.appendChild(img); const span = document.createElement("span"); span.innerHTML = "Download All As Zip"; button.appendChild(span); return button; } function createRadioButton({ id, name, label, title, checked = false, onChange }) { // Create label wrapper const labelEl = document.createElement("label"); labelEl.className = "fcdl_radio_label"; labelEl.setAttribute("for", id); labelEl.title = title; // Create input const input = document.createElement("input"); input.type = "radio"; input.id = id; input.name = name; input.checked = checked; input.className = "fcdl_radio_input"; input.title = title; // Hook event listener if (typeof onChange === "function") { input.addEventListener("change", () => { if (input.checked) { onChange(); } }); } // Custom span for styling const span = document.createElement("span"); span.className = "fcdl_radio_span"; // Visible text const textNode = document.createTextNode(label); textNode.title = title; // Assemble labelEl.appendChild(input); labelEl.appendChild(span); labelEl.appendChild(textNode); return labelEl; } function createSettings() { const container = document.createElement("div"); container.className = "fcdl_settings_container"; container.appendChild(createRadioButton({ id: "radioOriginalNames", name: "filenameOption", label: "Original Names", title: "Use the original filenames from the posts.", checked: config.useOriginalNames, onChange: () => { saveSetting("useOriginalNames", true); saveSetting("usePostIds", false); saveSetting("combineNames", false); config.useOriginalNames = true; config.usePostIds = false; config.combineNames = false; } })); container.appendChild(createRadioButton({ id: "radioPostIds", name: "filenameOption", label: "Post IDs", title: "Use post IDs as filenames.", checked: config.usePostIds, onChange: () => { saveSetting("useOriginalNames", false); saveSetting("usePostIds", true); saveSetting("combineNames", false); config.useOriginalNames = false; config.usePostIds = true; config.combineNames = false; } })); container.appendChild(createRadioButton({ id: "radioCombineNames", name: "filenameOption", label: "Combine", title: "Combine post IDs and original filenames. ({id}_{postname}.ext)", checked: config.combineNames, onChange: () => { saveSetting("useOriginalNames", false); saveSetting("usePostIds", false); saveSetting("combineNames", true); config.useOriginalNames = false; config.usePostIds = false; config.combineNames = true; } })); return container; } function createProgressIndicator() { document.querySelectorAll(".fcdl_progress_container").forEach((item, index) => { item.remove(); } ); const progressContainer = document.createElement('div'); progressContainer.className = "fcdl_progress_container"; const bodyColor = getComputedStyle(document.body).color; const progressText = document.createElement('div'); progressText.id = 'zip-progress-text'; progressText.textContent = 'Preparing download...'; progressText.style.color = bodyColor; const progressBar = document.createElement('div'); progressBar.className = "fcdl_progress_bar"; const progressFill = document.createElement('div'); progressFill.id = 'zip-progress-fill'; progressFill.className = "fcdl_progress_fill"; const progressPercent = document.createElement('div'); progressPercent.id = 'zip-progress-percent'; progressPercent.textContent = '0%'; progressPercent.style.color = bodyColor; progressContainer.appendChild(progressPercent); progressBar.appendChild(progressFill); progressContainer.appendChild(progressBar); progressContainer.appendChild(progressText); return progressContainer; } function postFileTextToMediaLink(fileText, index) { const link = fileText.querySelector('a'); if (link && link.href) { const url = link.href.startsWith('//') ? 'https:' + link.href : link.href; const isImage = /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?|$)/i.test(url); const isVideo = /\.(mp4|webm|mkv|avi|mov)(\?|$)/i.test(url); if (isImage || isVideo) { const postId = url.split('/').pop().split('?')[0]; let originalName = link.title.trim() || link.textContent.trim() || postId; // if 4chan-X is used fix the name fetching const fnfull = link.querySelector('.fnfull'); if (fnfull) { originalName = fnfull.textContent.trim(); } return { url: url, originalName: originalName, postId: postId, index: index + 1 }; } } return null; } function findMediaLinks(startFromThisPostId = "") { const mediaLinks = []; if (startFromThisPostId != "") { let found = false; const fileTexts = document.querySelectorAll('div.fileText'); fileTexts.forEach((fileText, index) => { if (fileText.id.replace("fT", "") == startFromThisPostId) { found = true; } if (found) { const mediaLink = postFileTextToMediaLink(fileText, index); if (mediaLink != null) { mediaLinks.push(mediaLink); } } }); } else { const fileTexts = document.querySelectorAll('div.fileText'); fileTexts.forEach((fileText, index) => { const mediaLink = postFileTextToMediaLink(fileText, index); if (mediaLink != null) { mediaLinks.push(mediaLink); } }); } return mediaLinks; } function findMediaLinksFromImgAndVideoElements() { const mediaLinks = []; const imgElements = document.querySelectorAll('img[src*="jpg"], img[src*="jpeg"], img[src*="png"], img[src*="gif"], img[src*="webp"], img[src*="bmp"]'); const videoElements = document.querySelectorAll('video[src*="mp4"], video[src*="webm"], video[src*="mkv"], video[src*="avi"], video[src*="mov"]'); const mediaElements = [...imgElements, ...videoElements]; mediaElements.forEach((img_or_vid, index) => { const url = img_or_vid.src; const filename = url.split('/').pop().split('?')[0]; mediaLinks.push({ url: url, originalName: filename, postId: filename, index: index + 1 }); }); return mediaLinks; } function generateFilename(imageData) { let filename; if (config.usePostIds) { filename = imageData.postId; } else if (config.combineNames) { const postIdBase = imageData.postId.split('.')[0]; filename = `${postIdBase}_${imageData.originalName}`; } else { filename = imageData.originalName; } filename = filename.replace(/[<>:"/\\|?*]/g, '_'); return filename; } function updateProgress(current, total, status = '', filename = '') { const progressText = document.getElementById('zip-progress-text'); const progressFill = document.getElementById('zip-progress-fill'); const progressPercent = document.getElementById('zip-progress-percent'); if (progressText && progressFill && progressPercent) { const percentage = Math.round((current / total) * 100); let displayText = status; if (filename) { displayText += ` - ${filename}`; } if (current <= total) { displayText = `${status} (${current}/${total})` + (filename ? ` - ${filename}` : ''); } progressText.textContent = displayText; progressFill.style.width = `${percentage}%`; progressPercent.textContent = `${percentage}%`; } } async function downloadAllImagesAsZip(startFromThisPostId = "") { const imageLinks = findMediaLinks(startFromThisPostId); if (imageLinks.length === 0) { alert('No images found on this page!\n\nMake sure your page has images in div.fileText elements or direct img tags.'); return; } const container = document.getElementById("4chan_dl_cont"); const progressIndicator = createProgressIndicator(); container.appendChild(progressIndicator); progressIndicator.style.display = 'flex'; console.log(`Found ${imageLinks.length} images to download`); const zip = new JSZip(); const downloadedFilenames = new Set(); let completed = 0; let successful = 0; updateProgress(0, imageLinks.length, 'Initializing', ''); const downloadImage = async (imageData) => { let filename = generateFilename(imageData); let counter = 1; const originalFilename = filename; while (downloadedFilenames.has(filename)) { const dotIndex = originalFilename.lastIndexOf('.'); if (dotIndex > 0) { const name = originalFilename.substring(0, dotIndex); const ext = originalFilename.substring(dotIndex); filename = `${name}_${counter}${ext}`; } else { filename = `${originalFilename}_${counter}`; } counter++; } downloadedFilenames.add(filename); try { updateProgress(completed + 1, imageLinks.length, 'Downloading', filename); const response = await fetch(imageData.url); if (!response.ok) { throw new Error(`HTTP ${response.status} - ${response.statusText}`); } const blob = await response.blob(); zip.file(filename, blob); successful++; console.log(`✓ Added to ZIP: ${filename}`); return { success: true, filename }; } catch (error) { console.error(`✗ Failed to download ${imageData.url}:`, error); return { success: false, filename, error: error.message }; } finally { completed++; updateProgress(completed, imageLinks.length, 'Downloading', filename); } }; const processDownloads = async () => { const promises = []; for (const imageData of imageLinks) { promises.push(downloadImage(imageData)); if (promises.length >= config.maxConcurrentDownloads) { await Promise.all(promises.splice(0, config.maxConcurrentDownloads)); } } if (promises.length > 0) { await Promise.all(promises); } }; try { await processDownloads(); completed = imageLinks.length; updateProgress(completed, imageLinks.length, 'Creating ZIP file', ''); const zipBlob = await zip.generateAsync({ type: "blob", compression: "DEFLATE", compressionOptions: { level: 6 } }); const now = new Date(); const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-'); const pageTitle = document.title.replace(/[<>:"/\\|?*]/g, '_').slice(0, 50); const zipFilename = `${pageTitle || 'images'}_${timestamp}.zip`; updateProgress(completed, imageLinks.length, 'Downloading ZIP', zipFilename); const downloadLink = document.createElement('a'); downloadLink.href = URL.createObjectURL(zipBlob); downloadLink.download = zipFilename; downloadLink.style.display = 'none'; document.body.appendChild(downloadLink); downloadLink.click(); document.body.removeChild(downloadLink); setTimeout(() => URL.revokeObjectURL(downloadLink.href), 5000); setTimeout(() => { //progressIndicator.style.display = 'none'; //container.removeChild(progressIndicator); const sizeInMB = (zipBlob.size / (1024 * 1024)).toFixed(2); const message = `✅ ZIP Download Complete!\n\n` + `📁 File: ${zipFilename}\n` + `📊 Total images: ${imageLinks.length}\n` + `✅ Successful: ${successful}\n` + `❌ Failed: ${imageLinks.length - successful}\n` + `💾 ZIP size: ${sizeInMB} MB`; alert(message); console.log(message); }, 1000); } catch (error) { console.error('Error creating ZIP:', error); progressIndicator.style.display = 'none'; document.body.removeChild(progressIndicator); alert(`❌ Error creating ZIP file:\n${error.message}`); } } async function init() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); return; } setTimeout(async () => { try { const containerDiv = document.createElement('div'); containerDiv.id = "4chan_dl_cont"; containerDiv.className = "fcdl_main_container"; const settingsContainer = createSettings(); const downloadButton = createDownloadButton(); downloadButton.addEventListener('click', function(e) { e.preventDefault(); downloadAllImagesAsZip(); }); containerDiv.appendChild(downloadButton); containerDiv.appendChild(settingsContainer); const threadElement = document.querySelector(".thread"); threadElement.parentElement.insertBefore(containerDiv, threadElement); const mediaLinks = findMediaLinks(); console.log(`Found ${mediaLinks.length} media files on page:`, mediaLinks); document.getElementById("4chan_dl_button").title = `Download All (${mediaLinks.length}) as ZIP`; createDownloadButtons(); } catch (error) { console.error('Error initializing userscript:', error); } }, 500); } init(); })();