Bulk Image Copy Sidebar

Collect & bulk-copy image URLs. Auto-fetch via Load More / Scroll / Pagination. Detects <img>, lazy-loads, CSS background-images. Click 🖼️ at bottom-right.

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         Bulk Image Copy Sidebar
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Collect & bulk-copy image URLs. Auto-fetch via Load More / Scroll / Pagination. Detects <img>, lazy-loads, CSS background-images. Click 🖼️ at bottom-right.
// @author       You
// @match        *://*/*
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    // ===========================================
    // 1. STATE
    // ===========================================
    const state = {
        images: new Map(),
        seenSet: new Set(),
        isFetching: false,
        fetchTarget: 0,
        fetchStopped: false,
        minWidth: 0,
        minHeight: 0,
        extFilter: '',
        domainFilter: '',
        searchQuery: '',
        sidebarVisible: false,
        lastUrl: location.href,
        _mouseX: 0,
        _mouseY: 0,
        _paginationUrl: null
    };

    const SB_WIDTH = 420;

    // ===========================================
    // 2. HELPERS
    // ===========================================
    function normalizeUrl(url) {
        try {
            const u = new URL(url);
            u.hash = '';
            return u.href.replace(/\/$/, '');
        } catch { return url; }
    }

    function extractUrlsFromCss(cssVal) {
        if (!cssVal || !/url\(/i.test(cssVal)) return [];
        const urls = [];
        const re = /url\(\s*["']?\s*([^"'\s)]+)\s*["']?\s*\)/gi;
        let m;
        while ((m = re.exec(cssVal)) !== null) {
            const u = m[1].trim();
            if (u && !u.startsWith('data:')) urls.push(u);
        }
        return urls;
    }

    function getFilenameFromUrl(url) {
        try { return new URL(url).pathname.split('/').pop() || url; } catch { return url; }
    }

    function escape(s) { return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }

    function dim(n) { return n > 0 ? n : '—'; }

    function delay(ms) { return new Promise(r => setTimeout(r, ms)); }

    function isImageUrl(str) {
        if (typeof str !== 'string' || str.length < 10) return false;
        if (!str.startsWith('http://') && !str.startsWith('https://') && !str.startsWith('//')) return false;
        const path = str.split('?')[0].split('#')[0];
        const ext = path.split('.').pop().toLowerCase();
        return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'avif', 'bmp', 'ico', 'tiff', 'tif'].indexOf(ext) !== -1;
    }

    function findAllImageUrls(obj, depth) {
        depth = depth || 0;
        var results = new Set();
        if (depth > 8 || !obj || typeof obj !== 'object') return results;
        if (Array.isArray(obj)) {
            for (var i = 0; i < obj.length; i++) {
                var sub = findAllImageUrls(obj[i], depth + 1);
                sub.forEach(function (u) { results.add(u); });
            }
        } else {
            for (var key in obj) {
                var val = obj[key];
                if (typeof val === 'string' && isImageUrl(val)) {
                    results.add(val);
                } else if (typeof val === 'object' && val !== null) {
                    var sub = findAllImageUrls(val, depth + 1);
                    sub.forEach(function (u) { results.add(u); });
                }
            }
        }
        return results;
    }

    var _dimQueue = {}; // prevent duplicate dimension fetches

    function enrichImageDimensions(normUrl) {
        var entry = state.images.get(normUrl);
        if (!entry || (entry.width > 0 && entry.height > 0)) return;
        if (_dimQueue[normUrl]) return;
        _dimQueue[normUrl] = true;

        var img = new Image();
        img.onload = function () {
            entry.width = img.naturalWidth || img.width || 0;
            entry.height = img.naturalHeight || img.height || 0;
            delete _dimQueue[normUrl];
            renderSidebar();
        };
        img.onerror = function () { delete _dimQueue[normUrl]; };
        img.src = normUrl;
    }

    function addImageToState(url, source, alt, w, h) {
        if (!url || url.startsWith('data:')) return false;
        if (url.startsWith('//')) url = location.protocol + url;
        var norm = normalizeUrl(url);
        if (state.seenSet.has(norm)) return false;
        state.seenSet.add(norm);
        state.images.set(norm, {
            url: norm,
            source: source || 'img',
            alt: alt || '',
            width: w || 0,
            height: h || 0,
            timestamp: Date.now(),
            selected: false
        });
        // Enrich dimensions in background for images without known size
        if ((w || 0) === 0 && (h || 0) === 0) enrichImageDimensions(norm);
        return true;
    }

    // ===========================================
    // 3. IMAGE HARVEST ENGINE
    // ===========================================
    function harvestImages(rootNode) {
        const root = rootNode || document;
        let added = 0;
        const now = Date.now();

        function addImage(url, source, alt, w, h) {
            if (addImageToState(url, source, alt, w, h)) added++;
        }

        // --- A: <img> tags ---
        const imgEls = root.querySelectorAll('img');
        for (const img of imgEls) {
            let url = img.getAttribute('content')
                || img.getAttribute('src')
                || img.getAttribute('data-src')
                || img.getAttribute('data-lazy-src')
                || img.getAttribute('data-original')
                || img.getAttribute('data-highres')
                || '';
            if (!url && img.srcset) {
                const first = img.srcset.split(',')[0].trim().split(' ')[0];
                if (first) url = first;
            }
            if (url) {
                addImage(url, 'img', img.alt,
                    img.naturalWidth || img.width || 0,
                    img.naturalHeight || img.height || 0);
            }
        }

        // --- B: <picture> <source> elements ---
        const sourceEls = root.querySelectorAll('picture source');
        for (const s of sourceEls) {
            const srcset = s.getAttribute('srcset');
            if (srcset) {
                const first = srcset.split(',')[0].trim().split(' ')[0];
                if (first) addImage(first, 'picture', '', 0, 0);
            }
        }

        // --- C: CSS background-image (inline + computed) ---
        try {
            const bgEls = root.querySelectorAll('[style*="background"]');
            let bgCount = 0;
            for (const el of bgEls) {
                if (bgCount > 300) break;
                let cssVal = '';
                if (el.style && el.style.backgroundImage && el.style.backgroundImage !== 'none') {
                    cssVal = el.style.backgroundImage;
                } else if (el.style && el.style.background && /url\(/i.test(el.style.background)) {
                    cssVal = el.style.background;
                }
                if (!cssVal || !/url\(/i.test(cssVal)) {
                    try {
                        const comp = getComputedStyle(el).backgroundImage;
                        if (comp && comp !== 'none' && /url\(/i.test(comp)) cssVal = comp;
                    } catch (e) { /* skip */ }
                }
                if (cssVal && /url\(/i.test(cssVal)) {
                    const urls = extractUrlsFromCss(cssVal);
                    for (const url of urls) {
                        addImage(url, 'bg', '', 0, 0);
                        bgCount++;
                    }
                }
            }
        } catch (e) {
            console.warn('[ImgCopy] CSS bg scan error:', e);
        }

        return added;
    }

    // ===========================================
    // 4. IMAGE API HOOK (intercept fetch/XHR for JSON API responses)
    // ===========================================
    var _hookInstalled = false;
    var _apiCallPatterns = []; // { key, baseUrl, param, currentValue } for auto-fetch

    function recordApiCallPattern(requestUrl) {
        if (!requestUrl || typeof requestUrl !== 'string') return;
        try {
            var url = new URL(requestUrl, location.origin);
            var pageParams = ['page', 'p', 'offset', 'start', 'cursor', 'index', 'skip', 'from', 'pos'];
            for (var pi = 0; pi < pageParams.length; pi++) {
                var param = pageParams[pi];
                if (url.searchParams.has(param)) {
                    var val = url.searchParams.get(param);
                    var numVal = parseInt(val, 10);
                    if (!isNaN(numVal)) {
                        var search = url.search;
                        var patternKey = url.origin + url.pathname;
                        // Remove existing pattern with same key
                        for (var i = _apiCallPatterns.length - 1; i >= 0; i--) {
                            if (_apiCallPatterns[i].key === patternKey) _apiCallPatterns.splice(i, 1);
                        }
                        _apiCallPatterns.push({
                            key: patternKey,
                            baseUrl: url.origin + url.pathname + search,
                            param: param,
                            currentValue: numVal
                        });
                        return;
                    }
                }
            }
            // If no numeric page param found, try detecting page in path: /page/2 or /p/2
            var pathMatch = url.pathname.match(/\/(?:page|p|offset|start)\/(\d+)/i);
            if (pathMatch) {
                var numVal2 = parseInt(pathMatch[1], 10);
                if (!isNaN(numVal2)) {
                    var patternKey2 = url.pathname.replace(/\/(?:page|p|offset|start)\/\d+/i, '/:page');
                    var baseKey = url.origin + patternKey2;
                    var fullBase = url.origin + url.pathname.replace(/\/\d+\/?$/, '/') + url.search;
                    for (var j = _apiCallPatterns.length - 1; j >= 0; j--) {
                        if (_apiCallPatterns[j].key === baseKey) _apiCallPatterns.splice(j, 1);
                    }
                    _apiCallPatterns.push({
                        key: baseKey,
                        baseUrl: fullBase,
                        param: '_path_',
                        currentValue: numVal2,
                        pathPattern: url.pathname.replace(/\d+/, ':num')
                    });
                }
            }
        } catch (e) { /* ignore invalid URLs */ }
    }

    function setupAPIHooks() {
        if (_hookInstalled) return;
        _hookInstalled = true;

        // --- Hook window.fetch ---
        var originalFetch = window.fetch;
        window.fetch = function (input, init) {
            return originalFetch.call(window, input, init).then(function (response) {
                try {
                    var ct = (response.headers && response.headers.get('content-type')) || '';
                    if (ct.indexOf('json') !== -1) {
                        var clone = response.clone();
                        clone.text().then(function (body) {
                            if (!body || body.length < 50) return;
                            try {
                                var data = JSON.parse(body);
                                var urls = findAllImageUrls(data);
                                if (urls.size > 0) {
                                    var reqUrl = (typeof input === 'string' ? input : (input && input.url)) || '';
                                    if (reqUrl) recordApiCallPattern(reqUrl);
                                    var added = 0;
                                    urls.forEach(function (url) {
                                        if (addImageToState(url, 'api')) added++;
                                    });
                                    if (added > 0) {
                                        console.log('[ImgCopy] API hook: +' + added + ' images');
                                        renderSidebar();
                                    }
                                }
                            } catch (e) { /* not JSON or no image URLs */ }
                        }).catch(function () {});
                    }
                } catch (e) { /* skip */ }
                return response;
            });
        };

        // --- Hook XMLHttpRequest ---
        var origOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function (method, url) {
            this._icUrl = (typeof url === 'string') ? url : (url ? url.toString() : '');
            return origOpen.apply(this, arguments);
        };
        var origSend = XMLHttpRequest.prototype.send;
        XMLHttpRequest.prototype.send = function () {
            if (this._icUrl) {
                this.addEventListener('load', function () {
                    try {
                        var ct = this.getResponseHeader('content-type') || '';
                        if (ct.indexOf('json') !== -1 && this.responseText && this.responseText.length > 50) {
                            var data = JSON.parse(this.responseText);
                            var urls = findAllImageUrls(data);
                            if (urls.size > 0) {
                                if (this._icUrl) recordApiCallPattern(this._icUrl);
                                var added = 0;
                                urls.forEach(function (url) {
                                    if (addImageToState(url, 'api')) added++;
                                });
                                if (added > 0) {
                                    console.log('[ImgCopy] XHR hook: +' + added + ' images');
                                    renderSidebar();
                                }
                            }
                        }
                    } catch (e) { /* skip */ }
                });
            }
            return origSend.apply(this, arguments);
        };

        console.log('[ImgCopy] API hooks installed (fetch + XHR)');
    }

    // ===========================================
    // 5. AUTO-FETCH ENGINE
    // ===========================================

    // --- Strategy detection helpers ---
    function findLoadMoreButton() {
        // Exact text patterns for load-more buttons (short, focused)
        var textPatterns = [/^(?:load|show|view|see)\s+(?:more|all)$/i, /^load\s+more\s+(?:results|items|content)$/i, /^show\s+more\s+(?:results|images|items|photos)$/i];

        // Priority: look for structural/class-based selectors first (most reliable)
        var structSelectors = [
            '[class*="load-more"]', '[class*="loadMore"]',
            '[class*="show-more"]', '[class*="showMore"]',
            '[class*="pagination"]', '[class*="page-navi"]',
            '[data-testid*="load"]', '[data-testid*="more"]',
            '[id*="load-more"]', '[id*="loadMore"]',
            'nav a[rel="next"]', 'nav button[rel="next"]'
        ];
        for (var si = 0; si < structSelectors.length; si++) {
            var found = document.querySelectorAll(structSelectors[si]);
            for (var fi = 0; fi < found.length; fi++) {
                var el = found[fi];
                if (el.offsetParent === null) continue;
                var t = el.textContent.trim();
                if (t.length > 0 && t.length < 60 && textPatterns.some(function (p) { return p.test(t); })) return el;
            }
        }

        // Fallback: scan visible buttons/anchors only — strict text match, exclude single-word "More"
        var generic = document.querySelectorAll('button, a[role="button"], a');
        for (var gi = 0; gi < generic.length; gi++) {
            var g = generic[gi];
            if (g.offsetParent === null) continue;
            var txt = g.textContent.trim();
            if (txt.length < 3 || txt.length > 60) continue;
            if (txt === 'More' || txt === 'more') continue; // almost always a menu, not load-more
            if (textPatterns.some(function (p) { return p.test(txt); })) return g;
        }

        // Only check aria-label if it explicitly says "load more" or "show more"
        var ariaEls = document.querySelectorAll('[aria-label*="load more" i], [aria-label*="show more" i], [aria-label*="load additional" i]');
        for (var ai = 0; ai < ariaEls.length; ai++) {
            if (ariaEls[ai].offsetParent !== null) return ariaEls[ai];
        }

        return null;
    }

    function findNextPageLinkInDoc(doc) {
        const d = doc || document;
        let href = '';
        const link = d.querySelector('link[rel="next"]');
        if (link) href = link.getAttribute('href');
        if (!href) {
            const anchors = d.querySelectorAll('a');
            const patterns = [/^next$/i, /^›$/, /^→$/, /^»$/, /^next\s*page/i, /^下一页/i];
            for (const a of anchors) {
                const text = a.textContent.trim();
                if (patterns.some(p => p.test(text)) && a.href) { href = a.href; break; }
            }
        }
        if (!href) {
            const aria = d.querySelectorAll('a[aria-label*="next" i], a[rel="next"]');
            for (const a of aria) { if (a.href) { href = a.href; break; } }
        }
        if (href && !href.startsWith('http')) {
            try { href = new URL(href, location.origin).href; } catch { return null; }
        }
        return href || null;
    }

    function isPageScrollable() {
        const sh = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight);
        return sh > window.innerHeight + 50;
    }

    // --- Fetch strategies ---
    async function tryLoadMore() {
        var btn = findLoadMoreButton();
        if (!btn) return 0;
        updateFetchUI('running', 'Load More...');
        btn.click();
        await delay(1500);
        return harvestImages();
    }

    async function tryScroll() {
        if (!isPageScrollable()) return 0;
        updateFetchUI('running', 'Scrolling...');
        var before = document.body.scrollHeight;
        window.scrollTo({ top: before, behavior: 'smooth' });
        await delay(1200);
        var added = harvestImages();
        var grew = document.body.scrollHeight > before + 50;
        return { added: added, grew: grew };
    }

    async function tryPagination() {
        var nextUrl = state._paginationUrl || findNextPageLinkInDoc(document);
        if (!nextUrl) return 0;
        state._paginationUrl = null;
        try {
            updateFetchUI('running', 'Next page...');
            var resp = await fetch(nextUrl, { headers: { 'Accept': 'text/html' } });
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            var html = await resp.text();
            var doc = new DOMParser().parseFromString(html, 'text/html');
            var added = harvestImages(doc);
            state._paginationUrl = findNextPageLinkInDoc(doc);
            return added;
        } catch (e) {
            console.warn('[ImgCopy] Pagination error:', e);
            return 0;
        }
    }

    async function fetchNextApiPage() {
        if (_apiCallPatterns.length === 0) return 0;
        var pattern = _apiCallPatterns[_apiCallPatterns.length - 1];
        var nextValue = pattern.currentValue + 1;
        var nextUrl = '';
        try {
            if (pattern.param === '_path_') {
                nextUrl = pattern.baseUrl.replace(/:num/, nextValue);
            } else {
                var url = new URL(pattern.baseUrl);
                url.searchParams.set(pattern.param, nextValue);
                nextUrl = url.href;
            }
        } catch (e) { return 0; }

        updateFetchUI('running', 'API page ' + nextValue + '...');
        try {
            var resp = await fetch(nextUrl, { credentials: 'same-origin' });
            if (!resp.ok) return 0;
            var ct = resp.headers.get('content-type') || '';
            if (ct.indexOf('json') === -1) return 0;
            var data = await resp.json();
            var urls = findAllImageUrls(data);
            var added = 0;
            urls.forEach(function (url) {
                if (addImageToState(url, 'api')) added++;
            });
            if (added > 0) pattern.currentValue = nextValue;
            return added;
        } catch (e) {
            console.warn('[ImgCopy] API page error:', e);
            return 0;
        }
    }

    // --- Main fetch control ---
    async function startAutoFetch() {
        if (state.isFetching) return;
        var input = document.getElementById('ic-sidebar-target');
        state.fetchTarget = (input ? parseInt(input.value, 10) : 0) || 0;
        state.isFetching = true;
        state.fetchStopped = false;
        state._paginationUrl = null;

        updateFetchUI('running');

        harvestImages();
        renderSidebar();

        var emptyCycleCount = 0;
        var maxEmpty = 3;

        while (!state.fetchStopped) {
            if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) {
                updateFetchUI('done', 'Target reached: ' + state.fetchTarget);
                break;
            }

            var actionTaken = false;

            // --- Strategy 1: Load More ---
            var loadBtn = findLoadMoreButton();
            if (loadBtn && !state.fetchStopped) {
                updateFetchUI('running', 'Load More...');
                loadBtn.click();
                await delay(1500);
                var added = harvestImages();
                if (added > 0) {
                    actionTaken = true;
                    renderSidebar();
                    if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
                    // Try load more again immediately
                    var nextBtn = findLoadMoreButton();
                    if (nextBtn && !state.fetchStopped) continue;
                }
            }

            if (state.fetchStopped || (state.fetchTarget > 0 && state.images.size >= state.fetchTarget)) break;

            // --- Strategy 2: Scroll ---
            if (!actionTaken && isPageScrollable() && !state.fetchStopped) {
                var scrollTries = 0;
                while (scrollTries < 20 && !state.fetchStopped) {
                    updateFetchUI('running', 'Scrolling...');
                    var before = document.body.scrollHeight;
                    window.scrollTo({ top: before, behavior: 'smooth' });
                    await delay(1200);
                    var added2 = harvestImages();
                    if (added2 > 0) {
                        actionTaken = true;
                        renderSidebar();
                        if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
                    }
                    if (document.body.scrollHeight <= before + 50) break;
                    scrollTries++;
                }
            }

            if (state.fetchStopped || (state.fetchTarget > 0 && state.images.size >= state.fetchTarget)) break;

            // --- Strategy 3: DOM Pagination ---
            if (!actionTaken && !state.fetchStopped) {
                var pageUrl = state._paginationUrl || findNextPageLinkInDoc(document);
                if (pageUrl) {
                    state._paginationUrl = null;
                    updateFetchUI('running', 'Next page...');
                    try {
                        var resp = await fetch(pageUrl, { headers: { 'Accept': 'text/html' } });
                        if (resp.ok) {
                            var doc = new DOMParser().parseFromString(await resp.text(), 'text/html');
                            var added3 = harvestImages(doc);
                            state._paginationUrl = findNextPageLinkInDoc(doc);
                            if (added3 > 0) {
                                actionTaken = true;
                                renderSidebar();
                                if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
                                // More pages?
                                if (state._paginationUrl && !state.fetchStopped) continue;
                            }
                        }
                    } catch (e) { console.warn('[ImgCopy] Pagination error:', e); }
                }
            }

            if (state.fetchStopped || (state.fetchTarget > 0 && state.images.size >= state.fetchTarget)) break;

            // --- Strategy 4: API Pattern (new - learns from intercepted API calls) ---
            if (!actionTaken && _apiCallPatterns.length > 0 && !state.fetchStopped) {
                var pageCount = 0;
                while (_apiCallPatterns.length > 0 && !state.fetchStopped) {
                    var added4 = await fetchNextApiPage();
                    if (added4 > 0) {
                        actionTaken = true;
                        pageCount++;
                        renderSidebar();
                        if (state.fetchTarget > 0 && state.images.size >= state.fetchTarget) break;
                        await delay(600);
                    } else {
                        // No more images from API, try next pattern
                        _apiCallPatterns.pop();
                        if (_apiCallPatterns.length > 0) continue;
                        break;
                    }
                }
            }

            // --- Single-cycle: stop after one round ---
            if (state.fetchTarget === 0) {
                if (!actionTaken) updateFetchUI('done', 'Nothing more to load');
                else updateFetchUI('done', 'One cycle complete');
                break;
            }

            // --- Target mode: empty cycle handling ---
            if (!actionTaken) {
                emptyCycleCount++;
                if (emptyCycleCount >= maxEmpty) {
                    updateFetchUI('done', 'No more images available');
                    break;
                }
                await delay(1000);
            } else {
                emptyCycleCount = 0;
            }
        }

        state.isFetching = false;
        if (!state.fetchStopped) updateFetchUI('done', 'Complete');
        renderSidebar();
    }

    function stopAutoFetch() {
        state.fetchStopped = true;
        state.isFetching = false;
        updateFetchUI('stopped');
    }

    function updateFetchUI(status, message) {
        const btn = document.getElementById('ic-sidebar-fetch-btn');
        const statusEl = document.getElementById('ic-sidebar-strategy');
        if (!btn) return;
        if (status === 'running') {
            btn.innerHTML = '⏹ Stop';
            btn.style.background = '#dc2626';
            btn.dataset.running = 'true';
            if (statusEl && message) statusEl.textContent = message;
        } else if (status === 'done') {
            btn.innerHTML = '▶ Start Fetch';
            btn.style.background = '#059669';
            btn.dataset.running = 'false';
            if (statusEl) {
                statusEl.textContent = '✅ ' + (message || 'Done');
                setTimeout(() => { if (statusEl) statusEl.textContent = 'Strategy: Auto-detect'; }, 3000);
            }
        } else if (status === 'stopped') {
            btn.innerHTML = '▶ Start Fetch';
            btn.style.background = '#059669';
            btn.dataset.running = 'false';
            if (statusEl) statusEl.textContent = '⏸ Stopped';
        }
    }

    // ===========================================
    // 5. SIDEBAR UI
    // ===========================================
    function createSidebar() {
        if (document.getElementById('ic-sidebar')) return;

        const sb = document.createElement('div');
        sb.id = 'ic-sidebar';
        sb.style.cssText = [
            'position:fixed;top:0;right:0;width:' + SB_WIDTH + 'px;height:100vh;',
            'background:#f8f9fa;z-index:999999;box-shadow:-4px 0 20px rgba(0,0,0,0.15);',
            'display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;',
            'font-size:13px;color:#1f2937;transition:transform 0.25s ease;',
            'transform:translateX(' + SB_WIDTH + 'px);'
        ].join('');
        document.body.appendChild(sb);

        // Header
        const hdr = document.createElement('div');
        hdr.style.cssText = 'padding:12px 16px;background:#059669;color:#fff;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;';
        hdr.innerHTML = '<strong style="font-size:15px;">🖼️ Image Copy</strong><button id="ic-close-btn" style="background:none;border:none;color:#fff;font-size:18px;cursor:pointer;padding:2px 6px;">✕</button>';
        sb.appendChild(hdr);

        // Fetch controls
        const fetchRow = document.createElement('div');
        fetchRow.style.cssText = 'padding:10px 14px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;flex-direction:column;gap:8px;flex-shrink:0;';
        fetchRow.innerHTML = [
            '<div style="display:flex;gap:8px;align-items:center;">',
            '<input type="number" id="ic-sidebar-target" placeholder="Target (0 = one cycle)" min="0"',
            ' style="flex:1;padding:7px 10px;border:1px solid #d1d5db;border-radius:6px;font-size:12px;outline:none;box-sizing:border-box;">',
            '<button id="ic-sidebar-fetch-btn" style="background:#059669;color:#fff;border:none;padding:7px 16px;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;white-space:nowrap;">▶ Start Fetch</button>',
            '</div>',
            '<div id="ic-sidebar-strategy" style="font-size:11px;color:#6b7280;">Strategy: Auto-detect</div>'
        ].join('\n');
        sb.appendChild(fetchRow);

        // Filters
        const flt = document.createElement('div');
        flt.style.cssText = 'padding:10px 14px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;flex-direction:column;gap:6px;flex-shrink:0;';
        flt.innerHTML = [
            '<div style="display:flex;gap:6px;">',
            '<input type="number" id="ic-filter-minw" placeholder="Min W" style="width:70px;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
            '<input type="number" id="ic-filter-minh" placeholder="Min H" style="width:70px;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
            '<input type="text" id="ic-filter-ext" placeholder="Ext (e.g. jpg,png)" style="flex:1;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
            '</div>',
            '<div style="display:flex;gap:6px;">',
            '<input type="text" id="ic-filter-domain" placeholder="Domain (!prefix to exclude)" style="flex:1;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
            '<input type="text" id="ic-filter-search" placeholder="🔍 Search file name..." style="flex:1;padding:5px 7px;border:1px solid #d1d5db;border-radius:5px;font-size:11px;outline:none;">',
            '</div>'
        ].join('\n');
        sb.appendChild(flt);

        // Action bar
        const act = document.createElement('div');
        act.style.cssText = 'padding:8px 14px;background:#fff;border-bottom:1px solid #e5e7eb;display:flex;justify-content:space-between;align-items:center;flex-shrink:0;';
        act.innerHTML = [
            '<label style="font-size:12px;cursor:pointer;display:flex;align-items:center;gap:5px;color:#374151;">',
            '<input type="checkbox" id="ic-select-all" style="cursor:pointer;width:15px;height:15px;accent-color:#059669;"> Select All',
            '</label>',
            '<div style="display:flex;gap:6px;">',
            '<button id="ic-clear-btn" style="background:#6b7280;color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:pointer;font-weight:600;font-size:11px;">🗑️ Clear</button>',
            '<button id="ic-copy-btn" style="background:#059669;color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-weight:600;font-size:12px;">📋 Copy URLs</button>',
            '</div>'
        ].join('\n');
        sb.appendChild(act);

        // Stats
        const st = document.createElement('div');
        st.id = 'ic-stats';
        st.style.cssText = 'padding:4px 14px;background:#f3f4f6;font-size:11px;color:#6b7280;display:flex;justify-content:space-between;flex-shrink:0;border-bottom:1px solid #e5e7eb;';
        st.innerHTML = '<span>No images</span><span>0 selected</span>';
        sb.appendChild(st);

        // Image list
        const list = document.createElement('div');
        list.id = 'ic-list';
        list.style.cssText = 'flex:1;overflow-y:auto;padding:8px 10px;background:#f0f2f2;';
        sb.appendChild(list);

        // --- Bind events ---
        document.getElementById('ic-close-btn').onclick = hideSidebar;

        document.getElementById('ic-sidebar-fetch-btn').addEventListener('click', function () {
            if (this.dataset.running === 'true') stopAutoFetch();
            else startAutoFetch();
        });

        ['ic-filter-minw', 'ic-filter-minh', 'ic-filter-ext', 'ic-filter-domain', 'ic-filter-search'].forEach(function (id) {
            document.getElementById(id).addEventListener('input', renderSidebar);
        });

        document.getElementById('ic-select-all').addEventListener('change', function () {
            const checked = this.checked;
            getFilteredImages().forEach(function (img) { img.selected = checked; });
            renderSidebar();
        });

        document.getElementById('ic-copy-btn').addEventListener('click', copySelectedUrls);
        document.getElementById('ic-clear-btn').addEventListener('click', clearAllImages);

        renderSidebar();
    }

    function showSidebar() {
        const sb = document.getElementById('ic-sidebar');
        if (!sb) return;
        state.sidebarVisible = true;
        sb.style.transform = 'translateX(0)';
        const toggle = document.getElementById('ic-toggle-btn');
        if (toggle) toggle.style.display = 'none';
    }

    function hideSidebar() {
        const sb = document.getElementById('ic-sidebar');
        if (!sb) return;
        state.sidebarVisible = false;
        sb.style.transform = 'translateX(' + SB_WIDTH + 'px)';
        const toggle = document.getElementById('ic-toggle-btn');
        if (toggle) toggle.style.display = 'flex';
    }

    function createBottomToggle() {
        if (document.getElementById('ic-toggle-btn')) return;
        const btn = document.createElement('button');
        btn.id = 'ic-toggle-btn';
        btn.innerHTML = '🖼️';
        btn.title = 'Open Image Copy Sidebar';
        btn.style.cssText = [
            'position:fixed;bottom:20px;right:20px;',
            'width:48px;height:48px;border-radius:50%;',
            'background:#059669;color:#fff;border:none;',
            'cursor:pointer;z-index:999998;font-size:20px;',
            'box-shadow:0 4px 12px rgba(0,0,0,0.25);',
            'display:flex;align-items:center;justify-content:center;',
            'transition:transform 0.2s, opacity 0.2s;',
            'opacity:0.9;'
        ].join('');
        btn.onmouseenter = function () { this.style.transform = 'scale(1.1)'; this.style.opacity = '1'; };
        btn.onmouseleave = function () { this.style.transform = 'scale(1)'; this.style.opacity = '0.9'; };
        btn.onclick = function () {
            if (!document.getElementById('ic-sidebar')) createSidebar();
            showSidebar();
            setTimeout(function () {
                const fetchBtn = document.getElementById('ic-sidebar-fetch-btn');
                if (fetchBtn && fetchBtn.dataset.running !== 'true') startAutoFetch();
            }, 300);
        };
        document.body.appendChild(btn);
    }

    // ===========================================
    // 6. FILTERING & RENDERING
    // ===========================================
    function getFilteredImages() {
        let arr = Array.from(state.images.values());

        if (state.searchQuery) {
            const q = state.searchQuery.toLowerCase();
            arr = arr.filter(function (img) {
                return getFilenameFromUrl(img.url).toLowerCase().includes(q) || img.alt.toLowerCase().includes(q);
            });
        }
        if (state.minWidth > 0) arr = arr.filter(function (img) { return img.width >= state.minWidth || img.width === 0; });
        if (state.minHeight > 0) arr = arr.filter(function (img) { return img.height >= state.minHeight || img.height === 0; });
        if (state.extFilter) {
            const exts = state.extFilter.split(',').map(function (s) { return s.trim().toLowerCase().replace(/^\./, ''); });
            if (exts.length > 0 && exts[0] !== '') {
                arr = arr.filter(function (img) {
                    const ext = img.url.split('?')[0].split('.').pop().toLowerCase();
                    return exts.indexOf(ext) !== -1;
                });
            }
        }
        if (state.domainFilter) {
            const parts = state.domainFilter.split(',');
            for (let pi = 0; pi < parts.length; pi++) {
                const p = parts[pi].trim();
                if (!p) continue;
                const exclude = p.charAt(0) === '!';
                const domain = exclude ? p.slice(1).trim() : p;
                if (!domain) continue;
                arr = arr.filter(function (img) {
                    try {
                        const host = new URL(img.url).hostname;
                        const match = host.indexOf(domain) !== -1;
                        return exclude ? !match : match;
                    } catch { return true; }
                });
            }
        }

        arr.sort(function (a, b) { return a.timestamp - b.timestamp; });
        return arr;
    }

    function renderSidebar() {
        const list = document.getElementById('ic-list');
        if (!list) return;

        syncFilterState();
        const visible = getFilteredImages();
        const total = state.images.size;
        const selCount = 0;
        let selected = 0;
        state.images.forEach(function (img) { if (img.selected) selected++; });

        const statsEl = document.getElementById('ic-stats');
        if (statsEl) {
            statsEl.innerHTML = '<span>📸 <b>' + visible.length + '</b> / ' + total + ' images</span><span>✅ <b>' + selected + '</b> selected</span>';
        }

        const scrollPos = list.scrollTop;
        list.innerHTML = '';

        if (visible.length === 0) {
            list.innerHTML = '<div style="text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px;">' +
                (total === 0
                    ? 'No images found yet.<br>Click 🖼️ (bottom-right)<br>to open &amp; start fetching.'
                    : 'No images match your filters.') +
                '</div>';
            return;
        }

        for (let vi = 0; vi < visible.length; vi++) {
            const img = visible[vi];
            const item = document.createElement('div');
            const isSel = img.selected;
            var srcBadge = '';
            if (img.source === 'bg') srcBadge = '<span style="background:#dbeafe;color:#1e40af;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px;">bg</span>';
            else if (img.source === 'api') srcBadge = '<span style="background:#fef3c7;color:#92400e;font-size:9px;padding:1px 5px;border-radius:3px;font-weight:600;margin-left:4px;">api</span>';

            item.style.cssText = 'display:flex;gap:10px;padding:8px 10px;margin-bottom:6px;background:' +
                (isSel ? '#f0fdf4' : '#fff') + ';border-radius:8px;box-shadow:0 1px 3px rgba(0,0,0,0.08);' +
                'cursor:pointer;align-items:center;border-left:3px solid ' + (isSel ? '#059669' : '#d1d5db') + ';';
            item.onclick = function (pin) {
                return function (e) {
                    if (e.target.tagName === 'INPUT' || e.target.tagName === 'A') return;
                    pin.selected = !pin.selected;
                    renderSidebar();
                };
            }(img);

            const filename = getFilenameFromUrl(img.url);
            const dims = dim(img.width) + '×' + dim(img.height) + 'px';

            item.innerHTML = [
                '<input type="checkbox" ' + (isSel ? 'checked' : '') + ' style="width:16px;height:16px;cursor:pointer;accent-color:#059669;flex-shrink:0;">',
                '<img src="' + escape(img.url) + '" style="width:50px;height:50px;border-radius:6px;object-fit:cover;background:#e5e7eb;flex-shrink:0;" loading="lazy" onerror="this.style.display=\'none\'">',
                '<div style="flex:1;min-width:0;overflow:hidden;">',
                '<div style="font-size:11px;font-weight:600;color:#374151;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;" title="' + escape(filename) + '">' + escape(filename) + srcBadge + '</div>',
                '<div style="font-size:10px;color:#9ca3af;margin-top:1px;">' + dims + '</div>',
                '<div style="font-size:9px;color:#6b7280;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:1px;" title="' + escape(img.url) + '">' + escape(img.url) + '</div>',
                '</div>'
            ].join('');

            const cb = item.querySelector('input[type="checkbox"]');
            cb.onchange = function (pin) {
                return function (e) { pin.selected = e.target.checked; renderSidebar(); };
            }(img);

            list.appendChild(item);
        }

        list.scrollTop = scrollPos;

        const selAll = document.getElementById('ic-select-all');
        if (visible.length > 0) {
            selAll.checked = visible.every(function (i) { return i.selected; });
            selAll.indeterminate = visible.some(function (i) { return i.selected; }) && !visible.every(function (i) { return i.selected; });
        } else {
            selAll.checked = false;
            selAll.indeterminate = false;
        }
    }

    function syncFilterState() {
        state.searchQuery = document.getElementById('ic-filter-search') ? document.getElementById('ic-filter-search').value : '';
        state.minWidth = parseInt(document.getElementById('ic-filter-minw') ? document.getElementById('ic-filter-minw').value : '', 10) || 0;
        state.minHeight = parseInt(document.getElementById('ic-filter-minh') ? document.getElementById('ic-filter-minh').value : '', 10) || 0;
        state.extFilter = document.getElementById('ic-filter-ext') ? document.getElementById('ic-filter-ext').value : '';
        state.domainFilter = document.getElementById('ic-filter-domain') ? document.getElementById('ic-filter-domain').value : '';
    }

    // ===========================================
    // 7. COPY
    // ===========================================
    function copySelectedUrls() {
        const selected = [];
        state.images.forEach(function (img) { if (img.selected) selected.push(img); });
        const urls = selected.length > 0 ? selected.map(function (i) { return i.url; }) : getFilteredImages().map(function (i) { return i.url; });

        if (urls.length === 0) {
            const btn = document.getElementById('ic-copy-btn');
            const orig = btn.innerHTML;
            btn.innerHTML = '⚠️ Nothing to copy';
            btn.style.background = '#dc2626';
            setTimeout(function () { btn.innerHTML = orig; btn.style.background = '#059669'; }, 2000);
            return;
        }

        navigator.clipboard.writeText(urls.join('\n')).then(function () {
            const btn = document.getElementById('ic-copy-btn');
            const orig = btn.innerHTML;
            btn.innerHTML = '✅ ' + urls.length + ' URLs copied';
            btn.style.background = '#137333';
            setTimeout(function () { btn.innerHTML = orig; btn.style.background = '#059669'; }, 2500);
        }).catch(function (err) {
            console.error('[ImgCopy] Copy failed:', err);
            alert('Copy failed: ' + err.message);
        });
    }

    function clearAllImages() {
        if (state.images.size === 0) return;
        state.images.clear();
        state.seenSet.clear();
        state._paginationUrl = null;
        renderSidebar();
        var btn = document.getElementById('ic-clear-btn');
        var orig = btn.innerHTML;
        btn.innerHTML = '✅ Cleared';
        btn.style.background = '#dc2626';
        setTimeout(function () { btn.innerHTML = orig; btn.style.background = '#6b7280'; }, 1500);
    }

    // ===========================================
    // 8. KEYBOARD SHORTCUTS
    // ===========================================

    // Track mouse for peek-through
    document.addEventListener('mousemove', function (e) {
        state._mouseX = e.clientX;
        state._mouseY = e.clientY;
    });

    document.addEventListener('keydown', function (e) {
        // Opt+C -> Peek-through copy image under cursor
        if ((e.altKey || e.metaKey) && e.key.toLowerCase() === 'c') {
            const result = findImageAtPoint(state._mouseX, state._mouseY);
            if (result.image) {
                let url = result.image.getAttribute('content') || result.image.getAttribute('src');
                if (!url) {
                    const srcset = result.image.getAttribute('srcset');
                    if (srcset) url = srcset.split(',')[0].trim().split(' ')[0];
                }
                if (url) {
                    navigator.clipboard.writeText(url)
                        .then(function () { console.log('[ImgCopy] Copied:', url); })
                        .catch(function (err) { console.error('[ImgCopy] Clipboard error:', err); });
                }
            }
            result.overlays.forEach(function (o) { o.element.style.pointerEvents = o.original; });
        }
    });

    function findImageAtPoint(x, y, maxIter) {
        maxIter = maxIter || 10;
        let iter = 0;
        let image = null;
        const overlays = [];
        while (iter < maxIter) {
            const el = document.elementFromPoint(x, y);
            if (!el) break;
            if (el.tagName === 'IMG') { image = el; break; }
            const desc = el.querySelector('img');
            if (desc) { image = desc; break; }
            const orig = el.style.pointerEvents;
            overlays.push({ element: el, original: orig });
            el.style.pointerEvents = 'none';
            iter++;
        }
        return { image: image, overlays: overlays };
    }

    // ===========================================
    // 9. MUTATION OBSERVER
    // ===========================================
    let harvestTimer = null;

    function scheduleHarvest() {
        if (harvestTimer) clearTimeout(harvestTimer);
        harvestTimer = setTimeout(function () {
            const added = harvestImages();
            if (added > 0) renderSidebar();
        }, 600);
    }

    function startObserver() {
        try {
            const obs = new MutationObserver(function () { scheduleHarvest(); });
            obs.observe(document.body, { childList: true, subtree: true, attributes: false });
        } catch (e) {
            console.warn('[ImgCopy] Observer error:', e);
        }
    }

    // ===========================================
    // 10. URL CHANGE DETECTION
    // ===========================================
    setInterval(function () {
        if (location.href !== state.lastUrl) {
            state.lastUrl = location.href;
            state.images.clear();
            state.seenSet.clear();
            state._paginationUrl = null;
            setTimeout(function () {
                harvestImages();
                if (document.getElementById('ic-sidebar')) renderSidebar();
            }, 800);
        }
    }, 1000);

    // ===========================================
    // 11. INIT
    // ===========================================
    function init() {
        if (window.__icInit) return;
        window.__icInit = true;

        setupAPIHooks();

        var ci = setInterval(function () {
            if (document.body) {
                clearInterval(ci);
                createBottomToggle();
                startObserver();
                harvestImages();
            }
        }, 300);
    }

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

})();