Booru Image DL (w/ Zoom + Hotkey)

Downloader with bulk, hover zoom, search suggestions, folder selection, and "D" hotkey.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Booru Image DL (w/ Zoom + Hotkey)
// @namespace    http://tampermonkey.net/
// @version      1.6
// @description  Downloader with bulk, hover zoom, search suggestions, folder selection, and "D" hotkey.
// @author       JohnPork
// @match        *://*.booru.org/*
// @connect      img.booru.org
// @connect      raw.githubusercontent.com
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @require      https://code.jquery.com/jquery-2.1.4.min.js
// @license      MIT
// ==/UserScript==

/* globals jQuery */
(function () {
    "use strict";

    // --- Configuration ---
    const STORAGE_KEY_DOWNLOADED = "booruDownloader_downloaded_v1";
    const STORAGE_KEY_FOLDER = "booruDownloader_folder_path";
    const MAX_CONCURRENT = 2;
    const MAX_PREVIEW_HEIGHT = 600; // Max height of hover preview

    // --- Icons ---
    const ICON_DOWNLOAD = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/></svg>`;
    const ICON_LOADING = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor" class="booru-dl-spinner"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/></svg>`;
    const ICON_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`;
    const ICON_SETTINGS = `<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" fill="currentColor"><path d="M0 0h24v24H0z" fill="none"/><path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .43-.17.47-.41l.36-2.54c.59-.24 1.13-.57 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>`;

    let downloadedSet = loadDownloadedSet();
    let downloadFolder = GM_getValue(STORAGE_KEY_FOLDER, null);

    let queue = [];
    let active = 0;

    // Globals for Hover/Hotkey logic
    let zoomContainer = null;
    let zoomImage = null;
    let currentHoveredBtn = null; // Tracks which image is currently hovered

    // --- Persistence ---

    function loadDownloadedSet() {
        try {
            const raw = GM_getValue(STORAGE_KEY_DOWNLOADED, "[]");
            return new Set(JSON.parse(raw));
        } catch (e) {
            console.error("Booru downloader load error:", e);
        }
        return new Set();
    }

    function saveDownloadedSet() {
        const arr = Array.from(downloadedSet);
        try {
            GM_setValue(STORAGE_KEY_DOWNLOADED, JSON.stringify(arr));
        } catch (e) {
            console.error("Booru downloader save error:", e);
        }
    }

    // --- Folder Management ---

    function configureFolder() {
        const current = downloadFolder || "";
        const msg = "Enter a subfolder name to save images.\n\n" +
                    "NOTE: Due to browser security, this must be inside your default Downloads folder.\n" +
                    "Example: 'MyBooru' will save to 'Downloads/MyBooru/'\n\n" +
                    "Leave empty to save directly in Downloads.";

        let input = prompt(msg, current);
        if (input !== null) {
            input = input.replace(/^[/\\]+|[/\\]+$/g, '').trim();
            downloadFolder = input;
            GM_setValue(STORAGE_KEY_FOLDER, downloadFolder);
            alert(`Download folder set to: ${downloadFolder ? downloadFolder : "(Default Downloads)"}`);
        }
    }

    // --- URL Logic ---

    function getFullImageUrl(thumbUrl) {
        if (!thumbUrl) return null;
        try {
            const u = new URL(thumbUrl);
            u.hostname = "img.booru.org";
            let p = u.pathname;
            p = p.replace("/thumbnails//", "//images/");
            p = p.replace("/thumbnail_", "/");
            u.pathname = p;
            return u.toString();
        } catch (e) {
            console.error("Booru downloader could not convert URL:", thumbUrl, e);
            return null;
        }
    }

    function getImageKey(fullUrl) {
        try {
            const u = new URL(fullUrl);
            const file = (u.pathname.split("/").pop() || "").trim();
            if (!file) return fullUrl;
            return file.split(".")[0] || fullUrl;
        } catch (e) {
            return fullUrl;
        }
    }

    // --- Download Queue ---

    function isDownloaded(key) {
        return downloadedSet.has(key);
    }

    function markAsDownloaded(key) {
        downloadedSet.add(key);
        saveDownloadedSet();
    }

    function enqueueDownload(url, filename) {
        return new Promise((resolve, reject) => {
            queue.push({ url, filename, resolve, reject });
            processQueue();
        });
    }

    function processQueue() {
        if (active >= MAX_CONCURRENT) return;
        const job = queue.shift();
        if (!job) return;

        active++;
        startDownload(job.url, job.filename)
            .then(() => job.resolve())
            .catch(err => job.reject(err))
            .finally(() => {
                active--;
                processQueue();
            });
    }

    function startDownload(url, filename) {
        return new Promise((resolve, reject) => {
            let finalPath = filename;
            if (downloadFolder && downloadFolder.length > 0) {
                finalPath = `${downloadFolder}/${filename}`;
            }

            if (typeof GM_download === "function") {
                GM_download({
                    url,
                    name: finalPath,
                    saveAs: false,
                    onload: () => resolve(),
                    onerror: err => reject(err)
                });
            } else {
                try {
                    const a = document.createElement("a");
                    a.href = url;
                    a.download = filename;
                    document.body.appendChild(a);
                    a.click();
                    document.body.removeChild(a);
                    resolve();
                } catch (e) {
                    reject(e);
                }
            }
        });
    }

    // --- Styles ---

    function injectStyles() {
        const css = `
        @keyframes booru-dl-spin {
            from { transform: rotate(0deg); }
            to { transform: rotate(360deg); }
        }
        span.thumb {
            position: relative;
            display: inline-block;
        }
        .booru-dl-topbar {
            display: flex;
            justify-content: flex-start;
            align-items: center;
            margin-bottom: 1rem;
            padding: 8px 0;
            gap: 1rem;
            font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        }
        .booru-dl-pill {
            font-family: inherit;
            font-size: 13px;
            padding: 8px 16px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.12);
            background: linear-gradient(135deg, #111827, #020617);
            color: #e5e7eb;
            cursor: pointer;
            display: inline-flex;
            align-items: center;
            gap: 6px;
            box-shadow: 0 4px 14px rgba(15, 23, 42, 0.4);
            transition: background 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease, opacity 0.15s ease;
        }
        .booru-dl-pill:hover {
            transform: translateY(-1px);
            box-shadow: 0 8px 24px rgba(15, 23, 42, 0.7);
            background: linear-gradient(135deg, #111827, #0f172a);
        }
        .booru-dl-pill:active {
            transform: translateY(0);
            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.7);
        }
        .booru-dl-pill[disabled] {
            cursor: default;
            opacity: 0.6;
            box-shadow: none;
            transform: none;
        }
        .booru-dl-bulk-count {
            font-size: 12px;
            opacity: 0.8;
            font-weight: 500;
        }
        .booru-dl-icon-btn {
            padding: 8px;
            border-radius: 50%;
        }
        .thumb .booru-dl-wrapper {
            position: absolute;
            top: 4px;
            right: 4px;
            z-index: 10;
        }
        .booru-dl-btn {
            width: 28px;
            height: 28px;
            border-radius: 999px;
            border: 1px solid rgba(255, 255, 255, 0.1);
            background: rgba(15, 23, 42, 0.85);
            color: #e5e7eb;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.6);
            transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
            padding: 0;
        }
        .booru-dl-btn:hover {
            box-shadow: 0 6px 16px rgba(15, 23, 42, 0.9);
            background: rgba(30, 41, 59, 0.95);
            transform: scale(1.1);
        }
        .booru-dl-btn:active {
            transform: scale(0.95);
            box-shadow: 0 1px 4px rgba(15, 23, 42, 0.8);
        }
        .booru-dl-btn[disabled] {
            cursor: default;
            opacity: 0.75;
            transform: none;
            box-shadow: 0 2px 8px rgba(15, 23, 42, 0.6);
        }
        .booru-dl-btn svg, .booru-dl-pill svg {
            width: 16px;
            height: 16px;
        }
        .booru-dl-btn .booru-dl-spinner {
            animation: booru-dl-spin 1s linear infinite;
        }
        .booru-dl-btn-downloading {
            background: rgba(55, 65, 81, 0.9);
        }
        .booru-dl-btn-downloaded {
            background: #16a34a;
            border-color: #16a34a;
            color: #ecfdf5;
        }
        .booru-zoom-container {
            position: fixed;
            max-width: 95vw;
            max-height: 95vh;
            padding: 6px;
            background: rgba(15, 23, 42, 0.96);
            border-radius: 8px;
            box-shadow: 0 18px 45px rgba(15, 23, 42, 0.95);
            display: none;
            z-index: 9999;
            pointer-events: none;
        }
        .booru-zoom-container img {
            display: block;
            max-width: 100%;
            max-height: ${MAX_PREVIEW_HEIGHT}px;
            width: auto;
            object-fit: contain;
        }
        `;
        GM_addStyle(css);
    }

    // --- Hover Zoom & Hotkey Tracking ---

    function ensureZoomElements() {
        if (zoomContainer && zoomImage) return;
        zoomContainer = document.createElement("div");
        zoomContainer.className = "booru-zoom-container";
        zoomImage = document.createElement("img");
        zoomContainer.appendChild(zoomImage);
        document.body.appendChild(zoomContainer);
    }

    function showZoom(fullUrl) {
        ensureZoomElements();
        zoomImage.src = fullUrl;
        zoomContainer.style.display = "block";
    }

    function updateZoomPosition(e) {
        if (!zoomContainer || zoomContainer.style.display === 'none') return;
        const x = e.clientX;
        const y = e.clientY;
        const vw = document.documentElement.clientWidth;
        const vh = document.documentElement.clientHeight;
        const offset = 15;

        zoomContainer.style.left = 'auto';
        zoomContainer.style.right = 'auto';
        zoomContainer.style.top = 'auto';
        zoomContainer.style.bottom = 'auto';
        zoomContainer.style.transform = 'none';

        if (x > vw / 2) zoomContainer.style.right = `${vw - x + offset}px`;
        else zoomContainer.style.left = `${x + offset}px`;

        if (y > vh / 2) zoomContainer.style.bottom = `${vh - y + offset}px`;
        else zoomContainer.style.top = `${y + offset}px`;
    }

    function hideZoom() {
        if (zoomContainer) {
            zoomContainer.style.display = "none";
            zoomImage.src = '';
        }
    }

    // --- Button Logic ---

    function setButtonDownloading(btn) {
        btn.disabled = true;
        btn.classList.remove("booru-dl-btn-download", "booru-dl-btn-downloaded");
        btn.classList.add("booru-dl-btn-downloading");
        btn.innerHTML = ICON_LOADING;
        btn.title = "Downloading";
    }

    function setButtonDownloaded(btn) {
        btn.disabled = true;
        btn.classList.remove("booru-dl-btn-download", "booru-dl-btn-downloading");
        btn.classList.add("booru-dl-btn-downloaded");
        btn.innerHTML = ICON_CHECK;
        btn.title = "Downloaded";
    }

    function setButtonReady(btn) {
        btn.disabled = false;
        btn.classList.remove("booru-dl-btn-downloading", "booru-dl-btn-downloaded");
        btn.classList.add("booru-dl-btn-download");
        btn.innerHTML = ICON_DOWNLOAD;
        btn.title = "Download image";
    }

    function triggerButtonDownload(btn) {
        if (!btn) return Promise.resolve();
        const fullUrl = btn.dataset.fullUrl;
        const key = btn.dataset.key;
        if (!fullUrl || !key) return Promise.resolve();

        if (isDownloaded(key)) {
            setButtonDownloaded(btn);
            return Promise.resolve();
        }

        const filename = fullUrl.split("/").pop() || (key + ".jpg");
        setButtonDownloading(btn);

        return enqueueDownload(fullUrl, filename)
            .then(() => {
                markAsDownloaded(key);
                setButtonDownloaded(btn);
            })
            .catch(err => {
                console.error("Download error:", err);
                setButtonReady(btn);
                btn.title = "Retry download";
            });
    }

    function setupThumb(img) {
        if (!img || img.dataset.booruDlAttached === "1") return;

        const fullUrl = getFullImageUrl(img.src);
        if (!fullUrl) return;
        const key = getImageKey(fullUrl);

        const spanThumb = img.closest("span.thumb");
        if (!spanThumb) return;

        let wrapper = spanThumb.querySelector(".booru-dl-wrapper");
        if (!wrapper) {
            wrapper = document.createElement("div");
            wrapper.className = "booru-dl-wrapper";
            spanThumb.appendChild(wrapper);
        }

        const btn = document.createElement("button");
        btn.type = "button";
        btn.className = "booru-dl-btn booru-dl-btn-download";
        btn.dataset.fullUrl = fullUrl;
        btn.dataset.key = key;

        if (isDownloaded(key)) setButtonDownloaded(btn);
        else setButtonReady(btn);

        btn.addEventListener("click", function (e) {
            e.stopPropagation(); // Prevent navigating to image page
            if (btn.classList.contains("booru-dl-btn-downloaded")) return;
            triggerButtonDownload(btn);
        });

        wrapper.appendChild(btn);

        // --- Mouse Events (Zoom + Hotkey Tracking) ---
        img.addEventListener("mouseenter", function () {
            showZoom(fullUrl);
            currentHoveredBtn = btn; // Set current hover target for hotkey
        });
        img.addEventListener("mousemove", updateZoomPosition);
        img.addEventListener("mouseleave", function () {
            hideZoom();
            if (currentHoveredBtn === btn) {
                currentHoveredBtn = null; // Clear target
            }
        });

        img.dataset.booruDlAttached = "1";
    }

    // --- Toolbar ---

    function addToolbar(content) {
        if (!content || content.querySelector(".booru-dl-topbar")) return;

        const topbar = document.createElement("div");
        topbar.className = "booru-dl-topbar";

        const bulkBtn = document.createElement("button");
        bulkBtn.type = "button";
        bulkBtn.className = "booru-dl-pill";
        bulkBtn.textContent = "Download all on page";

        const countSpan = document.createElement("span");
        countSpan.className = "booru-dl-bulk-count";

        const settingsBtn = document.createElement("button");
        settingsBtn.type = "button";
        settingsBtn.className = "booru-dl-pill booru-dl-icon-btn";
        settingsBtn.innerHTML = ICON_SETTINGS;
        settingsBtn.title = "Set Download Folder";

        bulkBtn.addEventListener("click", function () { bulkDownloadVisible(bulkBtn, countSpan); });
        settingsBtn.addEventListener("click", function() { configureFolder(); });

        topbar.appendChild(bulkBtn);
        topbar.appendChild(settingsBtn);
        topbar.appendChild(countSpan);

        content.insertBefore(topbar, content.firstChild);
    }

    function bulkDownloadVisible(bulkBtn, countSpan) {
        const buttons = Array.from(document.querySelectorAll(".thumb .booru-dl-btn[data-key]"));
        const targets = buttons.filter(btn => !btn.classList.contains("booru-dl-btn-downloaded"));

        if (!targets.length) {
            countSpan.textContent = "All images already downloaded";
            return;
        }
        countSpan.textContent = "Queued " + targets.length + " images";
        bulkBtn.disabled = true;
        let remaining = targets.length;

        function updateText() {
            if (remaining <= 0) {
                bulkBtn.disabled = false;
                countSpan.textContent = "Bulk download finished";
            } else {
                countSpan.textContent = remaining + " remaining";
            }
        }

        targets.forEach(btn => {
            if (btn.dataset.booruDlBulkQueued === "1") return;
            btn.dataset.booruDlBulkQueued = "1";
            triggerButtonDownload(btn).finally(() => {
                remaining -= 1;
                updateText();
            });
        });
        updateText();
    }

    function observeThumbs(content) {
        if (!window.MutationObserver) return;
        const observer = new MutationObserver(mutations => {
            mutations.forEach(m => {
                for (let i = 0; i < m.addedNodes.length; i++) {
                    const node = m.addedNodes[i];
                    if (node.matches && node.matches("span.thumb img")) {
                        setupThumb(node);
                    } else if (node.querySelectorAll) {
                        const imgs = node.querySelectorAll("span.thumb img");
                        for (let j = 0; j < imgs.length; j++) {
                            setupThumb(imgs[j]);
                        }
                    }
                }
            });
        });
        observer.observe(content, { childList: true, subtree: true });
    }

    // --- Search Suggestions ---

    function initSearchSuggestions() {
        if (!window.location.hostname.includes('blacked.booru.org')) return;
        if (window.location.search.includes("page=post") && window.location.search.includes("s=add")) return;

        const tagInput = document.getElementById('stags') || document.getElementById('tags');
        if (!tagInput) return;

        const suggestionsDatalist = document.createElement('datalist');
        suggestionsDatalist.id = "datalist";
        const historyDatalist = document.createElement('datalist');
        historyDatalist.id = "history";

        const ul = document.querySelector('.space');
        if (ul) ul.append(suggestionsDatalist, historyDatalist);
        else document.body.append(suggestionsDatalist, historyDatalist);

        let allTags = [];
        GM_xmlhttpRequest({
            method: "GET",
            url: 'https://raw.githubusercontent.com/Ankhanon/animated-octo-waffle/master/alltagsmarkedit.txt',
            onload: function(response) {
                if (response.status !== 200) return;
                allTags = response.responseText.match(/(?<=").*(?=")/g) || [];
            }
        });

        tagInput.setAttribute('list', "datalist");

        function appendOptions(arg, container) {
            const node = document.createElement("option");
            node.value = arg;
            container.appendChild(node);
        }

        tagInput.addEventListener("keyup", function(event) {
             const val = tagInput.value;
             if (val.length < 2) return;

             if (event.which == 32 && val.endsWith(', ')) {
                  appendOptions(val, historyDatalist);
                  let opts = [];
                  for(let i=0; i<historyDatalist.options.length; i++) opts.push(historyDatalist.options[i].value);
                  tagInput.placeholder = opts.join(" ");
                  tagInput.value = "";
                  return;
             }
             const match = val.match(/(?:.(?<!,|, ))+$/);
             if (!match) return;
             const currentWord = match[0].toLowerCase();

             suggestionsDatalist.innerHTML = "";
             let found = 0;
             for (let i = 0; i < allTags.length; i++) {
                 if (found >= 10) break;
                 if (allTags[i].toLowerCase().startsWith(currentWord)) {
                     appendOptions(allTags[i], suggestionsDatalist);
                     found++;
                 }
             }
        });
    }

    // --- Main Init ---

    function init() {
        const content = document.querySelector("div.content");
        if (!content) return;

        injectStyles();
        addToolbar(content);
        const thumbs = content.querySelectorAll("span.thumb img");
        thumbs.forEach(setupThumb);
        observeThumbs(content);
        initSearchSuggestions();

        if (downloadFolder === null) {
            setTimeout(configureFolder, 500);
        }

        // --- Hotkey Listener ---
        document.addEventListener("keydown", function(e) {
            // Check if key is "d" or "D"
            if (e.key === "d" || e.key === "D") {
                // Do not trigger if user is typing in an input field
                const activeTag = document.activeElement ? document.activeElement.tagName : "";
                if (activeTag === "INPUT" || activeTag === "TEXTAREA") return;

                // Check if we are hovering a valid image
                if (currentHoveredBtn) {
                    // console.log("Hotkey download triggered for:", currentHoveredBtn.dataset.key);
                    triggerButtonDownload(currentHoveredBtn);
                }
            }
        });
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", init);
    } else {
        init();
    }
})();