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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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();
    }

})();