Greasy Fork is available in English.

4chan-dl

Download media files from 4chan.org with their posted filenames.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();

})();