京东评论

发表于 视频

// ==UserScript==
// @name         京东/JD评论图+商品列表图片下载器 评论分组修复版 v3.6
// @namespace    jd-comment-and-goods-image-downloader
// @version      3.6
// @description  京东评论图下载 + 商品列表首图下载:专门适配京东评价弹层/Virtuoso虚拟列表;只滚评论弹层,不滚商详页;严格按单条评论分组;支持自定义滑动次数
// @match        *://*.jd.com/*
// @match        *://*.jd.hk/*
// @match        *://jd.com/*
// @match        *://jd.hk/*
// @grant        GM_download
// @connect      360buyimg.com
// @connect      *.360buyimg.com
// @connect      jvod.300hu.com
// ==/UserScript==

(function () {
    'use strict';

    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));

    const commentGroupCache = new Map();
    const watchedScrollers = new WeakSet();

    let autoCollecting = false;
    let scanTimer = null;
    let goodsRefreshTimer = null;
    let autoWatchTimer = null;

    const JD_FORCED_COMMENT_SCROLL_XPATH = '/html/body/div[8]/div';

    const JD_RATE_OVERLAY_SELECTOR = [
        '.jdc-page-overlay[class*="rateListBox"]',
        '.jdc-page-overlay',
        '[class*="rateListBox"]'
    ].join(',');

    const JD_RATE_CARD_SELECTOR = [
        '.jdc-pc-rate-card',
        '[class*="rate-card"]',
        '[class*="rateCard"]',
        '[class*="listItem"]'
    ].join(',');

    const JD_RATE_IMAGE_AREA_SELECTOR = [
        '.jdc-pc-rate-card-images',
        '.jd-content-pc-media-list',
        '.jd-content-pc-media-list-item',
        '.jdc-image'
    ].join(',');

    const JD_COMMENT_ROOT_SELECTOR = [
        '#comment-root',
        '.comment-root',
        '#comment',
        '#comments-list',
        '.comments-list',
        '.J-comments-list',
        '.comment-list',
        '.comment-container',
        '.comments-container',
        '.J-comment',
        '.review-list',
        '.reviews-list',
        '.evaluate-list',
        '.evaluation-list',
        '[id*="comment"]',
        '[class*="comment"]',
        '[class*="Comment"]',
        '[class*="review"]',
        '[class*="evaluate"]',
        '[class*="evaluation"]'
    ].join(',');

    const JD_COMMENT_ITEM_SELECTOR = [
        '.jdc-pc-rate-card',
        '[class*="rate-card"]',
        '[class*="rateCard"]',
        '[class*="listItem"]',
        'ul.list > li.item',
        '.list > li.item',
        '.list > .item',
        'li.item',
        '.comment-item',
        '.J-comment-item',
        '.comment-con',
        '.comment-column',
        '.comment-box',
        '.review-item',
        '.evaluate-item',
        '.evaluation-item',
        '[class*="comment-item"]',
        '[class*="CommentItem"]',
        '[class*="review-item"]',
        '[class*="evaluate-item"]',
        '[class*="evaluation-item"]'
    ].join(',');

    const JD_COMMENT_IMAGE_AREA_SELECTOR = [
        '.jdc-pc-rate-card-images',
        '.jd-content-pc-media-list',
        '.jd-content-pc-media-list-item',
        '.jdc-image',
        '.imgs',
        '.pic-list',
        '.image-list',
        '.photo-list',
        '.shaidan',
        '[class*="shaidan"]',
        '[class*="photo-list"]',
        '[class*="pic-list"]',
        '[class*="image-list"]',
        '[class*="comment-image"]',
        '[class*="comment-pic"]',
        '[class*="comment-photo"]'
    ].join(',');

    const JD_IMAGE_SELECTOR = [
        'img[src*="360buyimg"]',
        'img[data-src*="360buyimg"]',
        'img[data-lazy-img*="360buyimg"]',
        'img[data-original*="360buyimg"]',
        'img[src*="jvod.300hu.com"]',
        'img[data-src*="jvod.300hu.com"]',
        'img[data-lazy-img*="jvod.300hu.com"]'
    ].join(',');

    function getElementByXPath(xpath) {
        try {
            return document.evaluate(
                xpath,
                document,
                null,
                XPathResult.FIRST_ORDERED_NODE_TYPE,
                null
            ).singleNodeValue;
        } catch (e) {
            return null;
        }
    }

    function fullUrl(url) {
        if (!url) return '';
        if (url.startsWith('//')) return 'https:' + url;
        return url;
    }

    function cleanName(name, maxLen = 255) {
        return (name || '未命名')
            .replace(/[\\/:*?"<>|]/g, '')
            .replace(/[\r\n\t]/g, '')
            .replace(/\s+/g, ' ')
            .trim()
            .slice(0, maxLen);
    }

    function getRootFolderName() {
        const input = document.querySelector('#jd-folder-name-input');
        return cleanName(input?.value?.trim() || '京东图片下载');
    }

    function makeGroupFolderName(rootFolder, indexOrId) {
        return cleanName(`${rootFolder} ${String(indexOrId).padStart(2, '0')}`);
    }

    function normalizeUrl(url) {
        return fullUrl(url || '').split('?')[0].split('#')[0];
    }

    function getHDImage(url) {
        if (!url) return '';

        url = normalizeUrl(url);

        if (/jvod\.300hu\.com/i.test(url)) {
            return url;
        }

        url = url.replace(/\/shaidan\/s\d+x\d+_jfs\//i, '/shaidan/jfs/');
        url = url.replace(/\/shaidan\/n\d+\/s\d+x\d+_jfs\//i, '/shaidan/jfs/');
        url = url.replace(/\/shaidan\/s\d+x\d+_\//i, '/shaidan/');
        url = url.replace(/\/n\d+\/jfs\//i, '/jfs/');
        url = url.replace(/\/s\d+x\d+_jfs\//i, '/jfs/');
        url = url.replace(/(\.jpg|\.jpeg|\.png|\.webp|\.gif)\.(dpg|avif|webp)$/i, '$1');
        url = url.replace(/!(cc_)?[^/]+$/i, '');

        const match = url.match(/.*?\.(jpg|jpeg|png|webp|gif)/i);
        return match ? match[0] : url;
    }

    function getGoodsHDImage(url) {
        if (!url) return '';

        url = normalizeUrl(url);
        url = url.replace(/\/[^/]+\/jfs\//i, '/pcpubliccms/jfs/');
        url = url.replace(/\/s\d+x\d+_jfs\//i, '/pcpubliccms/jfs/');
        url = url.replace(/(\.jpg|\.jpeg|\.png|\.webp|\.gif)\.(dpg|avif|webp)$/i, '$1');
        url = url.replace(/!(cc_)?[^/]+$/i, '');

        const match = url.match(/.*?\.(jpg|jpeg|png|webp|gif)/i);
        return match ? match[0] : url;
    }

    function getImageSrc(img) {
        return img?.getAttribute('data-lazy-img') ||
            img?.getAttribute('data-original') ||
            img?.getAttribute('data-src') ||
            img?.getAttribute('src') ||
            img?.currentSrc ||
            img?.src ||
            '';
    }

    function isValidImageUrl(url) {
        if (!url) return false;

        const raw = fullUrl(url);
        const hd = getHDImage(raw);

        if (!/(360buyimg\.com|jvod\.300hu\.com)/i.test(raw)) return false;
        if (/default\.image|imagetools|headimg|avatar|icon|arrow|logo|sprite|blank/i.test(raw)) return false;

        return /\.(jpg|jpeg|png|webp|gif)$/i.test(hd);
    }

    function isImageSizeOk(img) {
        const src = getImageSrc(img);

        if (/shaidan/i.test(src)) return true;
        if (/jvod\.300hu\.com/i.test(src)) return true;
        if (/s300x300_jfs/i.test(src)) return true;

        const w = img.naturalWidth || img.width || img.clientWidth || 0;
        const h = img.naturalHeight || img.height || img.clientHeight || 0;

        if (!w && !h) return true;

        return w >= 60 && h >= 60;
    }

    function getFileExt(url) {
        const match = url.match(/\.(jpg|jpeg|png|webp|gif)$/i);
        return match ? match[1].toLowerCase() : 'jpg';
    }

    function addTask(tasks, seen, groupCounters, rawUrl, folder, prefix = '', useGoodsHD = false) {
        if (!rawUrl || !folder) return;

        const hdUrl = useGoodsHD ? getGoodsHDImage(rawUrl) : getHDImage(rawUrl);

        if (!isValidImageUrl(hdUrl)) return;
        if (seen.has(folder + '|' + hdUrl)) return;

        seen.add(folder + '|' + hdUrl);

        if (!groupCounters[folder]) groupCounters[folder] = 0;
        groupCounters[folder]++;

        const ext = getFileExt(hdUrl);
        const index = String(groupCounters[folder]).padStart(2, '0');
        const filename = `${folder}/${prefix ? prefix + '-' : ''}${index}.${ext}`;

        tasks.push({
            url: hdUrl,
            filename
        });
    }

    function isInsideScriptPanel(el) {
        return !!el?.closest?.('#jd-hd-download-panel');
    }

    function isInsideGoodsArea(el) {
        return !!el?.closest?.(`
            #J_GoodsList,
            .goods-list,
            .J-goods-list,
            .gl-warp,
            .gl-item,
            .jSubObject,
            .p-img,
            .jPic,
            .p-name,
            .jDesc,
            .jGoodsInfo,
            .shop-list,
            .search-result,
            .m-list,
            [class*="goods"],
            [class*="Goods"],
            [class*="product"],
            [class*="Product"],
            [class*="sku"],
            [class*="Sku"]
        `);
    }

    function isBadCommentCandidate(el) {
        if (!el) return true;
        if (isInsideScriptPanel(el)) return true;
        if (isInsideGoodsArea(el)) return true;
        return false;
    }

    function getTextForJudge(el) {
        if (!el) return '';

        return (el.textContent || '')
            .replace(/\s+/g, ' ')
            .trim()
            .slice(0, 1200);
    }

    function getRateOverlayRoot() {
        const byXpath = getElementByXPath(JD_FORCED_COMMENT_SCROLL_XPATH);

        if (
            byXpath &&
            byXpath instanceof Element &&
            (
                byXpath.querySelector('.jdc-pc-rate-card') ||
                byXpath.querySelector('[data-virtuoso-scroller="true"]') ||
                /商品评价|全部评价|图\/视频|好评|中评|差评/.test(byXpath.textContent || '')
            )
        ) {
            return byXpath;
        }

        const overlays = [...document.querySelectorAll(JD_RATE_OVERLAY_SELECTOR)]
            .filter(el => {
                if (isInsideScriptPanel(el)) return false;

                const text = el.textContent || '';

                return (
                    /商品评价|全部评价|图\/视频|好评|中评|差评/.test(text) ||
                    el.querySelector('.jdc-pc-rate-card') ||
                    el.querySelector('[data-virtuoso-scroller="true"]')
                );
            });

        return overlays[0] || null;
    }

    function getForcedCommentScroller() {
        const root = getRateOverlayRoot();

        if (!root) return null;

        const candidates = [
            root.querySelector('[data-virtuoso-scroller="true"]'),
            root.querySelector('[data-testid="virtuoso-item-list"]')?.parentElement,
            root.querySelector('[class*="_list_"]'),
            root.querySelector('[class*="_rateListContainer_"]'),
            root
        ].filter(Boolean);

        const scrollable = candidates.find(el => {
            if (!(el instanceof Element)) return false;
            return el.scrollHeight > el.clientHeight + 20;
        });

        return scrollable || root;
    }

    function lockDetailPageScroll() {
        const root = getRateOverlayRoot();

        if (root) {
            document.documentElement.classList.add('jd-rate-layer-open');
            document.body.classList.add('jd-rate-layer-open');
        } else {
            document.documentElement.classList.remove('jd-rate-layer-open');
            document.body.classList.remove('jd-rate-layer-open');
        }
    }

    function getCommentRoots() {
        const set = new Set();

        const overlayRoot = getRateOverlayRoot();
        const forcedScroller = getForcedCommentScroller();

        if (overlayRoot) set.add(overlayRoot);
        if (forcedScroller) set.add(forcedScroller);

        [...document.querySelectorAll(JD_COMMENT_ROOT_SELECTOR)].forEach(el => {
            if (isInsideScriptPanel(el)) return;
            if (isInsideGoodsArea(el)) return;

            const mark = `${el.id || ''} ${el.className || ''}`.toLowerCase();
            const text = el.textContent || '';

            const hasCommentMark =
                /comment|review|evaluate|evaluation|rate/i.test(mark) ||
                /评价|评论|晒单|追评|全部评价|有图评价|晒图|商品评价/i.test(text);

            if (hasCommentMark) {
                set.add(el);
            }
        });

        return [...set].sort((a, b) => {
            const ai = a.querySelectorAll(JD_IMAGE_SELECTOR).length;
            const bi = b.querySelectorAll(JD_IMAGE_SELECTOR).length;
            return bi - ai;
        });
    }

    function getLooseImages(container) {
        if (!container) return [];
        if (isBadCommentCandidate(container)) return [];

        const urls = [];

        [...container.querySelectorAll(JD_IMAGE_SELECTOR)].forEach(img => {
            if (isInsideScriptPanel(img)) return;
            if (isInsideGoodsArea(img)) return;
            if (!isImageSizeOk(img)) return;

            const src = getImageSrc(img);

            if (!isValidImageUrl(src)) return;

            urls.push(getHDImage(src));
        });

        return [...new Set(urls)];
    }

    function getImages(commentItem) {
        const card = commentItem?.closest?.('.jdc-pc-rate-card') || commentItem;

        return getLooseImages(card);
    }

    function getImagesFromArea(area) {
        return getLooseImages(area);
    }

    function hasEnoughCommentText(el) {
        const text = getTextForJudge(el);

        if (!text) return false;

        if (/评价|评论|晒单|追评|买家|用户|客服回复|颜色|尺码|味道|质量|物流|包装|口感|效果|很好|不错|满意|差评|中评|好评|晒图|图片|视频|商家/.test(text)) {
            return true;
        }

        return text.length >= 8;
    }

    function countLooseImages(el) {
        return getLooseImages(el).length;
    }

    function countNestedKnownCommentItems(el) {
        if (!el) return 0;

        return [...el.querySelectorAll(JD_COMMENT_ITEM_SELECTOR)]
            .filter(item => item !== el)
            .length;
    }

    function isTooBigListContainer(el, root) {
        if (!el) return true;
        if (root && el === root) return true;

        const imgCount = countLooseImages(el);
        const nestedItemCount = countNestedKnownCommentItems(el);
        const rect = el.getBoundingClientRect();

        if (el.matches?.('[data-virtuoso-scroller="true"], [data-testid="virtuoso-item-list"], .jdc-page-overlay')) {
            return true;
        }

        if (nestedItemCount >= 2) return true;
        if (imgCount > 40) return true;
        if (rect.height > window.innerHeight * 0.95 && imgCount > 3) return true;

        return false;
    }

    function scoreCommentCandidate(el, root) {
        if (!el) return -999;
        if (isBadCommentCandidate(el)) return -999;
        if (root && !root.contains(el)) return -999;

        if (el.matches?.('.jdc-pc-rate-card')) {
            const imgs = getLooseImages(el);
            return imgs.length ? 100 : -999;
        }

        const imgs = getLooseImages(el);

        if (!imgs.length) return -999;
        if (imgs.length > 40) return -999;

        const rect = el.getBoundingClientRect();

        if (rect.width < 80 || rect.height < 40) return -999;
        if (isTooBigListContainer(el, root)) return -999;

        const mark = `${el.className || ''} ${el.id || ''}`.toLowerCase();
        const text = getTextForJudge(el);

        const selectorOk = el.matches?.(JD_COMMENT_ITEM_SELECTOR);
        const strongClassOk = /comment|review|evaluate|evaluation|reply|feed|rate/.test(mark);
        const weakClassOk = /item|row|cell|card/.test(mark);
        const textOk = hasEnoughCommentText(el);
        const hasOldImageArea = !!el.querySelector(JD_COMMENT_IMAGE_AREA_SELECTOR);

        let score = 0;

        if (selectorOk) score += 20;
        if (strongClassOk) score += 18;
        if (textOk) score += 14;
        if (weakClassOk && textOk) score += 8;
        if (imgs.length >= 2) score += 5;
        if (hasOldImageArea) score += 3;

        if (!text || text.length < 4) score -= 10;
        if (rect.height < 80 && imgs.length === 1) score -= 6;

        return score;
    }

    function isLikelySingleCommentContainer(el, root) {
        return scoreCommentCandidate(el, root) >= 10;
    }

    function findNearestCommentContainerFromImage(img, root) {
        if (!img) return null;
        if (isBadCommentCandidate(img)) return null;

        const directCard = img.closest('.jdc-pc-rate-card');

        if (directCard && (!root || root.contains(directCard))) {
            const urls = getLooseImages(directCard);

            if (urls.length) {
                return directCard;
            }
        }

        const directRateItem = img.closest(JD_RATE_CARD_SELECTOR);

        if (directRateItem && (!root || root.contains(directRateItem))) {
            const urls = getLooseImages(directRateItem);

            if (urls.length && urls.length <= 40) {
                return directRateItem;
            }
        }

        let node = img.parentElement;
        const candidates = [];

        for (let i = 0; node && i < 14; i++, node = node.parentElement) {
            if (node === document.body || node === document.documentElement) break;
            if (root && !root.contains(node)) break;
            if (isBadCommentCandidate(node)) continue;

            const score = scoreCommentCandidate(node, root);

            if (score >= 10) {
                candidates.push({ node, score });
            }
        }

        if (candidates.length) {
            candidates.sort((a, b) => b.score - a.score);
            return candidates[0].node;
        }

        const item = img.closest(JD_COMMENT_ITEM_SELECTOR);

        if (!item) return null;
        if (isBadCommentCandidate(item)) return null;
        if (root && !root.contains(item)) return null;

        const imgs = getLooseImages(item);

        if (!imgs.length || imgs.length > 40) return null;

        return item;
    }

    function looksLikeComment(el) {
        return isLikelySingleCommentContainer(el, null);
    }

    function getCommentElements() {
        const result = new Set();
        const roots = getCommentRoots();

        roots.forEach(root => {
            if (isBadCommentCandidate(root)) return;

            root.querySelectorAll('.jdc-pc-rate-card').forEach(card => {
                const urls = getImages(card);

                if (urls.length && urls.length <= 40) {
                    result.add(card);
                }
            });

            const imgs = [...root.querySelectorAll(JD_IMAGE_SELECTOR)]
                .filter(img => {
                    if (isInsideScriptPanel(img)) return false;
                    if (isInsideGoodsArea(img)) return false;
                    if (!isImageSizeOk(img)) return false;

                    const src = getImageSrc(img);

                    return isValidImageUrl(src);
                });

            imgs.forEach(img => {
                const comment = findNearestCommentContainerFromImage(img, root);

                if (!comment) return;

                const urls = getImages(comment);

                if (!urls.length) return;
                if (urls.length > 40) return;

                result.add(comment);
            });

            [...root.querySelectorAll(JD_COMMENT_ITEM_SELECTOR)].forEach(item => {
                if (isLikelySingleCommentContainer(item, root)) {
                    result.add(item);
                }
            });
        });

        return [...result];
    }

    function getComments() {
        return getCommentElements()
            .filter(el => {
                const imgs = getImages(el);
                return imgs.length > 0 && imgs.length <= 40;
            });
    }

    function makeCommentGroupKey(urls) {
        return urls.map(getHDImage).sort().join('|');
    }

    function upsertCommentGroup(comment) {
        const urls = getImages(comment);

        if (!urls.length || urls.length > 40) return false;

        const key = makeCommentGroupKey(urls);

        if (!key) return false;

        const existed = commentGroupCache.get(key);

        if (!existed || urls.length > existed.urls.length) {
            commentGroupCache.set(key, {
                key,
                urls,
                count: urls.length,
                firstSeen: existed?.firstSeen || Date.now(),
                lastSeen: Date.now()
            });

            return !existed;
        }

        existed.lastSeen = Date.now();
        return false;
    }

    function scanVisibleCommentsToCache() {
        let added = 0;

        getComments().forEach(comment => {
            if (upsertCommentGroup(comment)) {
                added++;
            }
        });

        updateStatus();

        return added;
    }

    function getMinFilterCount() {
        const input = document.querySelector('#jd-min-images-input');
        return Math.max(1, Number(input?.value || 1) || 1);
    }

    function getMaxCollectRounds() {
        const input = document.querySelector('#jd-max-rounds-input');
        const value = Number(input?.value || 80) || 80;

        return Math.min(1000, Math.max(1, value));
    }

    function getCachedCommentGroups(minCount = 1) {
        scanVisibleCommentsToCache();

        return [...commentGroupCache.values()]
            .filter(item => item.count >= minCount)
            .sort((a, b) => {
                return b.count - a.count || a.firstSeen - b.firstSeen;
            });
    }

    function updateStatus(extraText = '') {
        const el = document.querySelector('#jd-cache-status');

        if (!el) return;

        const groups = [...commentGroupCache.values()];
        const imageCount = groups.reduce((sum, group) => sum + group.urls.length, 0);
        const minCount = getMinFilterCount();
        const filtered = groups.filter(group => group.urls.length >= minCount).length;
        const maxRounds = document.querySelector('#jd-max-rounds-input')?.value || 80;

        el.textContent = `已缓存 ${groups.length} 组 / ${imageCount} 张;${minCount} 张以上 ${filtered} 组;滑动 ${maxRounds} 次${extraText ? ';' + extraText : ''}`;
    }

    function scheduleScan(delay = 250) {
        clearTimeout(scanTimer);
        scanTimer = setTimeout(scanVisibleCommentsToCache, delay);
    }

    function isScrollable(el) {
        if (!el) return false;
        if (el === document || el === document.body || el === document.documentElement) return false;
        if (!(el instanceof Element)) return false;

        const style = getComputedStyle(el);
        const overflowY = style.overflowY || '';

        return /(auto|scroll|overlay)/i.test(overflowY) &&
            el.scrollHeight > el.clientHeight + 60;
    }

    function findCommentScrollers() {
        const set = new Set();

        const forcedScroller = getForcedCommentScroller();

        if (forcedScroller) {
            set.add(forcedScroller);

            const root = getRateOverlayRoot();

            if (root) {
                [...root.querySelectorAll('*')].forEach(el => {
                    if (isScrollable(el) && !isInsideGoodsArea(el)) {
                        set.add(el);
                    }
                });
            }

            return [...set];
        }

        const roots = getCommentRoots();

        roots.forEach(root => {
            if (isBadCommentCandidate(root)) return;

            let node = root;

            while (node && node !== document.body) {
                if (isScrollable(node) && !isInsideGoodsArea(node)) {
                    set.add(node);
                }

                node = node.parentElement;
            }

            [...root.querySelectorAll('*')].forEach(el => {
                if (isScrollable(el) && !isInsideGoodsArea(el)) {
                    set.add(el);
                }
            });
        });

        return [...set];
    }

    function bindCommentScrollers() {
        const forcedScroller = getForcedCommentScroller();

        if (forcedScroller && !watchedScrollers.has(forcedScroller)) {
            watchedScrollers.add(forcedScroller);

            forcedScroller.addEventListener('scroll', () => {
                scheduleScan(80);
            }, { passive: true });

            forcedScroller.addEventListener('wheel', e => {
                e.stopPropagation();
                scheduleScan(80);
            }, { passive: true });

            forcedScroller.addEventListener('touchmove', e => {
                e.stopPropagation();
                scheduleScan(80);
            }, { passive: true });
        }

        findCommentScrollers().forEach(scroller => {
            if (watchedScrollers.has(scroller)) return;

            watchedScrollers.add(scroller);

            scroller.addEventListener('scroll', () => {
                scheduleScan(120);
            }, { passive: true });

            scroller.addEventListener('wheel', e => {
                if (getRateOverlayRoot()) e.stopPropagation();
                scheduleScan(120);
            }, { passive: true });

            scroller.addEventListener('touchmove', e => {
                if (getRateOverlayRoot()) e.stopPropagation();
                scheduleScan(120);
            }, { passive: true });
        });
    }

    function scrollOneCommentStep() {
        const forcedScroller = getForcedCommentScroller();

        if (forcedScroller) {
            const maxTop = forcedScroller.scrollHeight - forcedScroller.clientHeight;

            if (maxTop > 0) {
                const step = Math.max(420, Math.round(forcedScroller.clientHeight * 0.82));
                const oldTop = forcedScroller.scrollTop;

                forcedScroller.scrollTop = Math.min(oldTop + step, maxTop);

                forcedScroller.dispatchEvent(new Event('scroll', { bubbles: true }));
                forcedScroller.dispatchEvent(new WheelEvent('wheel', {
                    bubbles: true,
                    cancelable: true,
                    deltaY: step
                }));

                scheduleScan(120);

                return forcedScroller.scrollTop !== oldTop;
            }

            scheduleScan(120);
            return false;
        }

        const scrollers = findCommentScrollers()
            .filter(scroller => scroller !== window)
            .filter(scroller => !isInsideGoodsArea(scroller))
            .sort((a, b) => {
                const av = (a.scrollHeight - a.clientHeight) - a.scrollTop;
                const bv = (b.scrollHeight - b.clientHeight) - b.scrollTop;
                return bv - av;
            });

        const target = scrollers.find(scroller => {
            return scroller.scrollTop + scroller.clientHeight < scroller.scrollHeight - 10;
        });

        if (target) {
            const maxTop = target.scrollHeight - target.clientHeight;
            const step = Math.max(420, Math.round(target.clientHeight * 0.82));

            target.scrollTop = Math.min(target.scrollTop + step, maxTop);
            target.dispatchEvent(new Event('scroll', { bubbles: true }));

            scheduleScan(120);
            return true;
        }

        return false;
    }

    function injectStyle() {
        if (document.querySelector('#jd-hd-style')) return;

        const style = document.createElement('style');
        style.id = 'jd-hd-style';

        style.innerHTML = `
            html.jd-rate-layer-open,
            body.jd-rate-layer-open {
                overflow: hidden !important;
            }

            #jd-hd-download-panel {
                position: fixed;
                right: 18px;
                top: 18px;
                z-index: 999999999;
                width: 820px;
                max-height: calc(100vh - 36px);
                overflow: hidden;
                border-radius: 20px;
                background: rgba(255,255,255,.96);
                backdrop-filter: blur(18px);
                box-shadow: 0 18px 46px rgba(0,0,0,.14), 0 8px 24px rgba(227,24,55,.22);
                border: 1px solid rgba(227,24,55,.22);
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, "Microsoft YaHei", sans-serif;
                color: #222;
            }

            .jd-panel-top {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 14px 16px;
                cursor: move;
                user-select: none;
                background: linear-gradient(135deg, #e1251b, #ff5b5b);
                color: #fff;
            }

            .jd-panel-title {
                display: flex;
                align-items: center;
                gap: 10px;
            }

            .jd-logo {
                width: 38px;
                height: 38px;
                border-radius: 13px;
                background: rgba(255,255,255,.22);
                color: #fff;
                display: flex;
                align-items: center;
                justify-content: center;
                font-weight: 900;
                font-size: 19px;
            }

            .jd-title-main {
                font-size: 17px;
                font-weight: 900;
            }

            .jd-title-sub {
                font-size: 12px;
                opacity: .9;
                margin-top: 3px;
            }

            .jd-panel-body {
                padding: 14px 16px 16px;
                max-height: calc(100vh - 105px);
                overflow-y: auto !important;
                background: linear-gradient(180deg, #fff5f5 0%, #ffffff 120px);
            }

            .jd-folder-row {
                display: flex;
                gap: 8px;
                align-items: center;
                margin-bottom: 10px;
            }

            #jd-folder-name-input,
            #jd-min-images-input,
            #jd-max-rounds-input {
                height: 38px;
                border-radius: 13px;
                border: 1px solid rgba(227,24,55,.24);
                background: #fff;
                padding: 0 13px;
                font-size: 13px;
                outline: none;
            }

            #jd-folder-name-input {
                flex: 1;
            }

            #jd-min-images-input {
                width: 70px;
            }

            #jd-max-rounds-input {
                width: 86px;
            }

            .jd-folder-preview {
                width: 150px;
                height: 38px;
                line-height: 38px;
                border-radius: 13px;
                background: #fff1f1;
                color: #e1251b;
                font-size: 12px;
                font-weight: 800;
                text-align: center;
                overflow: hidden;
                white-space: nowrap;
                text-overflow: ellipsis;
                border: 1px solid rgba(227,24,55,.18);
            }

            .jd-btn-row {
                display: grid;
                grid-template-columns: repeat(7, 1fr);
                gap: 8px;
            }

            .jd-btn {
                height: 40px;
                border: none;
                outline: none;
                border-radius: 13px;
                font-size: 13px;
                font-weight: 800;
                cursor: pointer;
                transition: all .18s ease;
            }

            .jd-btn:hover {
                transform: translateY(-1px);
                filter: brightness(1.04);
            }

            .jd-btn:disabled {
                opacity: .55;
                cursor: wait;
                transform: none;
            }

            .jd-btn.primary {
                color: #fff;
                background: linear-gradient(135deg, #ff5b5b, #e1251b);
            }

            .jd-btn.success {
                color: #fff;
                background: linear-gradient(135deg, #20c997, #12b886);
            }

            .jd-btn.blue {
                color: #fff;
                background: linear-gradient(135deg, #4dabf7, #228be6);
            }

            .jd-btn.purple {
                color: #fff;
                background: linear-gradient(135deg, #9775fa, #7048e8);
            }

            .jd-btn.light {
                color: #e1251b;
                background: #fff;
                border: 1px solid rgba(227,24,55,.26);
            }

            .jd-cache-status,
            .jd-filter-tip {
                margin: 10px 0 0;
                padding: 8px 10px;
                border-radius: 12px;
                color: #9a1616;
                background: #fff0f0;
                font-size: 12px;
                line-height: 1.5;
            }

            .jd-cache-status {
                color: #0f5f46;
                background: #effaf5;
                border: 1px solid #c5f1dd;
            }

            .jd-download-label {
                display: inline-flex;
                align-items: center;
                gap: 8px;
                padding: 7px 12px;
                margin-bottom: 10px;
                border-radius: 999px;
                background: #fff7f7;
                color: #e1251b;
                font-size: 13px;
                font-weight: 800;
                border: 1px solid rgba(227,24,55,.25);
                cursor: pointer;
                user-select: none;
            }

            .jd-download-label input,
            .jd-filter-check,
            .jd-single-check,
            .jd-goods-check {
                accent-color: #e1251b;
            }

            #jd-filter-result-panel,
            #jd-goods-result-panel {
                margin-top: 12px;
                height: calc(100vh - 260px);
                max-height: calc(100vh - 260px);
                overflow-y: auto !important;
                overflow-x: hidden;
                background: #fffafa;
                border: 1px solid #ffd0d0;
                border-radius: 18px;
                padding: 12px;
            }

            .jd-filter-header,
            .jd-goods-header {
                position: sticky;
                top: 0;
                z-index: 2;
                background: linear-gradient(135deg,#e1251b,#ff5b5b);
                color: #fff;
                padding: 11px 13px;
                border-radius: 13px;
                font-weight: 900;
                margin-bottom: 12px;
            }

            .jd-filter-close,
            .jd-goods-close,
            .jd-goods-select-visible {
                float: right;
                border: none;
                background: #fff;
                color: #e1251b;
                border-radius: 999px;
                padding: 4px 11px;
                cursor: pointer;
                font-weight: 800;
                margin-left: 6px;
            }

            .jd-filter-card {
                margin-bottom: 13px;
                padding: 11px;
                border: 1px solid #ffd0d0;
                border-radius: 15px;
                background: #fff;
            }

            .jd-filter-title {
                display: flex;
                justify-content: space-between;
                align-items: center;
                font-size: 13px;
                font-weight: 900;
                color: #e1251b;
                margin-bottom: 10px;
            }

            .jd-filter-title label {
                display: flex;
                align-items: center;
                gap: 6px;
                cursor: pointer;
            }

            .jd-filter-pics {
                display: grid;
                grid-template-columns: repeat(4, 1fr);
                gap: 9px;
            }

            .jd-filter-pic-wrap {
                position: relative;
                border-radius: 12px;
                overflow: hidden;
                background: #fff;
                border: 1px solid #ffe0e0;
            }

            .jd-filter-pic-wrap img {
                display: block;
                width: 100%;
                aspect-ratio: 3 / 4;
                object-fit: cover;
                background: #fff;
            }

            .jd-filter-remove {
                position: absolute;
                right: 6px;
                top: 6px;
                z-index: 2;
                width: 24px;
                height: 24px;
                border: none;
                border-radius: 50%;
                background: rgba(0,0,0,.62);
                color: #fff;
                cursor: pointer;
                font-size: 18px;
                line-height: 24px;
                font-weight: 900;
                text-align: center;
                padding: 0;
            }

            .jd-filter-pic-wrap.jd-filter-pic-removed {
                opacity: .28;
                filter: grayscale(1);
            }

            .jd-filter-pic-wrap.jd-filter-pic-removed::after {
                content: "不下载";
                position: absolute;
                left: 50%;
                top: 50%;
                transform: translate(-50%, -50%);
                z-index: 3;
                padding: 5px 9px;
                border-radius: 999px;
                background: rgba(0,0,0,.72);
                color: #fff;
                font-size: 12px;
                font-weight: 900;
                white-space: nowrap;
            }

            .jd-goods-row {
                display: grid;
                grid-template-columns: 28px 58px 120px 1fr 70px;
                align-items: center;
                gap: 10px;
                min-height: 62px;
                margin-bottom: 9px;
                padding: 8px 10px;
                border-radius: 14px;
                border: 1px solid #ffd8d8;
                background: #fff;
                cursor: pointer;
            }

            .jd-goods-row:hover {
                background: #fff7f7;
            }

            .jd-goods-row img {
                width: 54px;
                height: 54px;
                object-fit: cover;
                border-radius: 10px;
                border: 1px solid #ffe1e1;
                background: #fff;
            }

            .jd-goods-sku {
                font-size: 12px;
                color: #e1251b;
                font-weight: 900;
                overflow: hidden;
                white-space: nowrap;
                text-overflow: ellipsis;
            }

            .jd-goods-name {
                font-size: 13px;
                color: #222;
                line-height: 1.35;
                overflow: hidden;
                display: -webkit-box;
                -webkit-line-clamp: 2;
                -webkit-box-orient: vertical;
            }

            .jd-goods-count {
                font-size: 12px;
                color: #888;
                text-align: right;
                white-space: nowrap;
            }
        `;

        document.head.appendChild(style);
    }

    function makePanelDraggable(panel, handle) {
        let dragging = false;
        let startX = 0;
        let startY = 0;
        let startLeft = 0;
        let startTop = 0;

        handle.addEventListener('mousedown', e => {
            if (e.target.closest('button, input, label')) return;

            dragging = true;
            startX = e.clientX;
            startY = e.clientY;

            const rect = panel.getBoundingClientRect();

            startLeft = rect.left;
            startTop = rect.top;

            panel.style.left = `${startLeft}px`;
            panel.style.top = `${startTop}px`;
            panel.style.right = 'auto';
            panel.style.bottom = 'auto';

            document.body.style.userSelect = 'none';
        });

        document.addEventListener('mousemove', e => {
            if (!dragging) return;

            panel.style.left = `${Math.max(0, startLeft + e.clientX - startX)}px`;
            panel.style.top = `${Math.max(0, startTop + e.clientY - startY)}px`;
        });

        document.addEventListener('mouseup', () => {
            dragging = false;
            document.body.style.userSelect = '';
        });
    }

    function getAliveUrlsFromFilterCard(card) {
        let urls = [];

        try {
            urls = JSON.parse(card.dataset.urls || '[]');
        } catch (e) {
            urls = [];
        }

        const removed = new Set();

        try {
            JSON.parse(card.dataset.removedUrls || '[]').forEach(url => removed.add(url));
        } catch (e) {}

        return urls.filter(url => !removed.has(url));
    }

    function setFilterCardRemovedUrl(card, url, removed) {
        let removedUrls = [];

        try {
            removedUrls = JSON.parse(card.dataset.removedUrls || '[]');
        } catch (e) {
            removedUrls = [];
        }

        const set = new Set(removedUrls);

        if (removed) {
            set.add(url);
        } else {
            set.delete(url);
        }

        card.dataset.removedUrls = JSON.stringify([...set]);
    }

    function updateFilterCardCount(card) {
        const alive = getAliveUrlsFromFilterCard(card);

        card.querySelectorAll('[data-jd-alive-count]').forEach(el => {
            el.textContent = String(alive.length);
        });
    }

    function getGoodsListItems() {
        const root = document.querySelector('#J_GoodsList, .goods-list, .J-goods-list') || document;

        return [...root.querySelectorAll('li.gl-item, li.jSubObject, .gl-warp > li')]
            .filter(item => {
                const sku = getGoodsSku(item);
                const name = getGoodsName(item);
                const imgs = getGoodsImages(item);

                return sku && name && imgs.length;
            });
    }

    function getGoodsSku(item) {
        return item.querySelector('.jPic a[data-sku]')?.getAttribute('data-sku') ||
            item.querySelector('a[data-sku]')?.getAttribute('data-sku') ||
            item.querySelector('.jScroll li[sid]')?.getAttribute('sid') ||
            item.getAttribute('data-sku') ||
            '';
    }

    function getLiveGoodsSku(item) {
        return extractSkuFromGoodsLink(getGoodsLink(item)) || getGoodsSku(item);
    }

    function getGoodsLink(item) {
        return fullUrl(
            item.querySelector('.jPic a[href], .p-img a[href], .jDesc a[href], .p-name a[href], a[href*="item.jd.com"], a[href*="item.jd.hk"]')?.getAttribute('href') ||
            item.getAttribute('href') ||
            ''
        );
    }

    function extractSkuFromGoodsLink(href) {
        if (!href) return '';

        const url = fullUrl(href);

        const match = url.match(/(?:item\.jd\.(?:com|hk)\/|\/product\/)(\d+)\.html/i) ||
            url.match(/[?&](?:sku|skuId|wareId|itemId)=(\d+)/i) ||
            url.match(/\/(\d{5,})(?:\.html|[/?#]|$)/);

        return match ? match[1] : '';
    }

    function getSelectedSpecText() {
        const selected = [...document.querySelectorAll('.specification-item-sku--selected .specification-item-sku-text')]
            .map(el => cleanName(el.textContent || '', 40))
            .filter(Boolean);

        return selected.join(' ');
    }

    function getGoodsSnapshot(item) {
        const sku = getLiveGoodsSku(item);
        const name = getGoodsName(item);
        const images = getGoodsImages(item);
        const link = getGoodsLink(item);
        const specText = getSelectedSpecText();

        return { sku, name, images, link, specText };
    }

    function getGoodsName(item) {
        const desc = item.querySelector('.jDesc a, .p-name a, .p-name em, .jGoodsInfo .jDesc a');

        return cleanName(
            desc?.getAttribute('title') ||
            desc?.textContent ||
            '未命名商品',
            120
        );
    }

    function getGoodsImages(item) {
        const cover = getImageSrc(item.querySelector('.jPic img, .p-img img'));

        if (!isValidImageUrl(cover)) return [];

        return [getGoodsHDImage(cover)];
    }

    function setGoodsHeaderText(count) {
        const headerText = document.querySelector('#jd-goods-header-text');

        if (headerText) {
            headerText.textContent = `商品列表:${count} 个商品,只取首图大图`;
        }
    }

    function renderGoodsRows(resultPanel, goods, keepChecked = true) {
        const checkedKeys = new Set();
        const checkedIndexes = new Set();

        if (keepChecked) {
            resultPanel.querySelectorAll('.jd-goods-row').forEach((row, index) => {
                const check = row.querySelector('.jd-goods-check');

                if (check?.checked) {
                    checkedKeys.add(row.dataset.jdGoodsKey || row.dataset.jdGoodsSku || row.dataset.jdGoodsName || '');
                    checkedIndexes.add(index);
                }
            });
        }

        resultPanel.querySelectorAll('.jd-goods-row').forEach(row => row.remove());

        goods.forEach((item, index) => {
            const info = getGoodsSnapshot(item);
            const thumb = info.images[0] || '';
            const key = info.sku || `${info.name}|${info.link}|${thumb}`;

            const row = document.createElement('label');

            row.className = 'jd-goods-row';
            row.dataset.jdGoodsIndex = String(index);
            row.dataset.jdGoodsKey = key;
            row.dataset.jdGoodsSku = info.sku;
            row.dataset.jdGoodsName = info.name;
            row.dataset.jdGoodsImages = JSON.stringify(info.images);
            row.dataset.jdGoodsLink = info.link;
            row.dataset.jdGoodsSpec = info.specText;

            row.innerHTML = `
                <input type="checkbox" class="jd-goods-check" ${checkedKeys.has(key) || checkedIndexes.has(index) ? 'checked' : ''}>
                <img src="${thumb}" loading="lazy">
                <div class="jd-goods-sku" title="${info.sku}">${info.sku}</div>
                <div class="jd-goods-name" title="${info.name}">${info.name}</div>
                <div class="jd-goods-count" title="${info.specText || '当前规格'}">${info.images.length} 图</div>
            `;

            resultPanel.appendChild(row);
        });

        setGoodsHeaderText(goods.length);
    }

    function refreshGoodsListPanel(keepChecked = true) {
        const resultPanel = document.querySelector('#jd-goods-result-panel');

        if (!resultPanel) return;

        const goods = getGoodsListItems();

        renderGoodsRows(resultPanel, goods, keepChecked);
    }

    function scheduleGoodsRefresh(delay = 650) {
        if (!document.querySelector('#jd-goods-result-panel')) return;

        clearTimeout(goodsRefreshTimer);

        goodsRefreshTimer = setTimeout(() => {
            refreshGoodsListPanel(true);
        }, delay);
    }

    function mutationTouchesGoodsOrSpec(mutations) {
        return mutations.some(mutation => {
            const target = mutation.target;

            if (!(target instanceof Element)) return false;

            return target.closest('.page-right-spec, .specifications-panel-content, #J_GoodsList, .goods-list, .J-goods-list') ||
                [...mutation.addedNodes, ...mutation.removedNodes].some(node =>
                    node instanceof Element &&
                    node.matches?.('.page-right-spec, .specifications-panel-content, #J_GoodsList, .goods-list, .J-goods-list, li.gl-item, li.jSubObject, .gl-warp > li')
                );
        });
    }

    function addPanel() {
        if (document.querySelector('#jd-hd-download-panel')) return;

        injectStyle();

        const panel = document.createElement('div');
        panel.id = 'jd-hd-download-panel';

        panel.innerHTML = `
            <div class="jd-panel-top">
                <div class="jd-panel-title">
                    <span class="jd-logo">京</span>
                    <div>
                        <div class="jd-title-main">京东图片下载器 评论分组修复版 v3.6</div>
                        <div class="jd-title-sub">只滚评价弹层 · 不滚商详页 · 从 jdc-pc-rate-card 分组 · 可设置滑动次数</div>
                    </div>
                </div>
            </div>

            <div class="jd-panel-body">
                <div class="jd-folder-row">
                    <input id="jd-folder-name-input" placeholder="输入主文件夹名,最长255字符">
                    <input id="jd-min-images-input" type="number" min="1" value="1" title="筛选至少多少张图片的评论分组">
                    <input id="jd-max-rounds-input" type="number" min="1" max="1000" value="80" title="采集更多时最多自动滑动多少次">
                    <div class="jd-folder-preview" id="jd-folder-preview">京东图片下载</div>
                </div>

                <div class="jd-btn-row">
                    <button class="jd-btn primary" id="jd-collect-more-btn">采集更多</button>
                    <button class="jd-btn primary" id="jd-scan-group-btn">评论分组</button>
                    <button class="jd-btn blue" id="jd-filter-group-btn">筛选</button>
                    <button class="jd-btn blue" id="jd-scan-img-btn">图集选择</button>
                    <button class="jd-btn purple" id="jd-goods-list-btn">商品列表</button>
                    <button class="jd-btn success" id="jd-download-btn">下载选中</button>
                    <button class="jd-btn light" id="jd-select-all-btn">全选/反选</button>
                </div>

                <div class="jd-cache-status" id="jd-cache-status">已缓存 0 组 / 0 张;1 张以上 0 组;滑动 80 次</div>

                <div class="jd-filter-tip">
                    当前逻辑:评论弹层打开时,只滚动评价弹层/Virtuoso列表,不再滚动商详页。第一个数字框是“至少几张图才显示”,第二个数字框是“最多自动滑动多少次”。
                </div>

                <div id="jd-filter-body"></div>
            </div>
        `;

        document.body.appendChild(panel);

        makePanelDraggable(panel, panel.querySelector('.jd-panel-top'));

        const input = document.querySelector('#jd-folder-name-input');
        const preview = document.querySelector('#jd-folder-preview');
        const minInput = document.querySelector('#jd-min-images-input');
        const roundsInput = document.querySelector('#jd-max-rounds-input');

        input.addEventListener('input', () => {
            preview.textContent = getRootFolderName();
        });

        minInput.addEventListener('input', () => {
            updateStatus();
        });

        roundsInput.addEventListener('input', () => {
            updateStatus();
        });

        document.querySelector('#jd-collect-more-btn').onclick = collectMoreComments;
        document.querySelector('#jd-scan-group-btn').onclick = markComments;
        document.querySelector('#jd-filter-group-btn').onclick = filterCommentGroups;
        document.querySelector('#jd-scan-img-btn').onclick = markComments;
        document.querySelector('#jd-goods-list-btn').onclick = showGoodsListPanel;
        document.querySelector('#jd-download-btn').onclick = downloadSelected;
        document.querySelector('#jd-select-all-btn').onclick = toggleAll;

        document.addEventListener('click', e => {
            if (e.target.closest('.specification-item-sku, .page-right-spec, .specifications-panel-content')) {
                scheduleGoodsRefresh(900);

                setTimeout(() => {
                    scheduleGoodsRefresh(1600);
                }, 0);
            }
        }, true);

        bindCommentScrollers();
        scanVisibleCommentsToCache();
    }

    async function collectMoreComments() {
        if (autoCollecting) return;

        autoCollecting = true;

        const btn = document.querySelector('#jd-collect-more-btn');

        if (btn) {
            btn.disabled = true;
            btn.textContent = '采集中...';
        }

        const maxRounds = getMaxCollectRounds();

        let idleRounds = 0;
        let lastCount = commentGroupCache.size;
        let lastImageCount = [...commentGroupCache.values()].reduce((sum, group) => sum + group.urls.length, 0);

        for (let round = 1; round <= maxRounds; round++) {
            lockDetailPageScroll();
            bindCommentScrollers();
            scanVisibleCommentsToCache();

            updateStatus(`自动采集第 ${round}/${maxRounds} 轮`);

            const currentCount = commentGroupCache.size;
            const currentImageCount = [...commentGroupCache.values()].reduce((sum, group) => sum + group.urls.length, 0);

            if (currentCount === lastCount && currentImageCount === lastImageCount) {
                idleRounds++;
            } else {
                idleRounds = 0;
            }

            lastCount = currentCount;
            lastImageCount = currentImageCount;

            if (idleRounds >= 14) {
                break;
            }

            const moved = scrollOneCommentStep();

            if (!moved && getRateOverlayRoot()) {
                await sleep(500);
                scanVisibleCommentsToCache();
                idleRounds++;
            } else {
                await sleep(850);
                scanVisibleCommentsToCache();
            }
        }

        scanVisibleCommentsToCache();

        updateStatus('采集完成');

        if (btn) {
            btn.disabled = false;
            btn.textContent = '采集更多';
        }

        autoCollecting = false;

        alert(`采集完成:当前缓存 ${commentGroupCache.size} 个评论图片分组。现在点“筛选”。`);
    }

    function markComments() {
        const comments = getComments();
        let validCount = 0;

        comments.forEach(comment => {
            upsertCommentGroup(comment);

            if (comment.querySelector('.jd-download-check')) return;

            const imgs = getImages(comment);

            if (!imgs.length) return;

            validCount++;

            const box = document.createElement('label');

            box.className = 'jd-download-label';
            box.innerHTML = `
                <input type="checkbox" class="jd-download-check jd-group-check">
                <span>本组 ${imgs.length} 张原图</span>
            `;

            comment.prepend(box);
        });

        updateStatus();

        alert(`当前屏评论分组标记完成:找到 ${validCount} 个评论图片分组;缓存共 ${commentGroupCache.size} 组。`);
    }

    function filterCommentGroups() {
        const body = document.querySelector('#jd-filter-body');

        if (!body) return;

        body.innerHTML = '';

        const minCount = getMinFilterCount();
        const groups = getCachedCommentGroups(minCount);

        if (!groups.length) {
            alert(`缓存里没有找到 ${minCount} 张以上的评论图片分组。请先点“采集更多”,或手动滑动评论区域加载更多。`);
            return;
        }

        const resultPanel = document.createElement('div');

        resultPanel.id = 'jd-filter-result-panel';

        resultPanel.innerHTML = `
            <div class="jd-filter-header">
                筛选结果:${groups.length} 组评论图,显示 ${minCount} 张以上
                <button id="jd-close-filter-panel" class="jd-filter-close">清空</button>
            </div>
        `;

        groups.forEach((group, index) => {
            const card = document.createElement('div');

            card.className = 'jd-filter-card';
            card.dataset.urls = JSON.stringify(group.urls);
            card.dataset.removedUrls = JSON.stringify([]);

            const pics = group.urls.map(url => `
                <div class="jd-filter-pic-wrap" data-jd-filter-url="${url}">
                    <button type="button" class="jd-filter-remove" title="排除这张,不下载">×</button>
                    <img src="${url}" loading="lazy">
                </div>
            `).join('');

            card.innerHTML = `
                <div class="jd-filter-title">
                    <label>
                        <input type="checkbox" class="jd-filter-check">
                        第 ${index + 1} 组 · 剩余 <span data-jd-alive-count>${group.count}</span> 张
                    </label>
                    <span>本评论 ${group.count} 张</span>
                </div>
                <div class="jd-filter-pics">${pics}</div>
            `;

            resultPanel.appendChild(card);

            card.querySelectorAll('.jd-filter-remove').forEach(btn => {
                btn.addEventListener('click', e => {
                    e.preventDefault();
                    e.stopPropagation();

                    const wrap = btn.closest('.jd-filter-pic-wrap');

                    if (!wrap) return;

                    const url = wrap.dataset.jdFilterUrl;
                    const removed = !wrap.classList.contains('jd-filter-pic-removed');

                    wrap.classList.toggle('jd-filter-pic-removed', removed);

                    btn.textContent = removed ? '↺' : '×';
                    btn.title = removed ? '恢复这张,参与下载' : '排除这张,不下载';

                    setFilterCardRemovedUrl(card, url, removed);
                    updateFilterCardCount(card);
                });
            });
        });

        body.appendChild(resultPanel);

        document.querySelector('#jd-close-filter-panel').onclick = () => {
            body.innerHTML = '';
        };

        updateStatus();

        alert(`筛选完成:从缓存显示 ${groups.length} 个 ${minCount} 张以上评论图片分组。`);
    }

    function showGoodsListPanel() {
        const body = document.querySelector('#jd-filter-body');

        if (!body) return;

        body.innerHTML = '';

        const goods = getGoodsListItems();

        if (!goods.length) {
            alert('没有找到商品列表。请确认页面存在 #J_GoodsList,并且商品已加载。');
            return;
        }

        const resultPanel = document.createElement('div');

        resultPanel.id = 'jd-goods-result-panel';

        resultPanel.innerHTML = `
            <div class="jd-goods-header">
                <span id="jd-goods-header-text">商品列表:${goods.length} 个商品,只取首图大图</span>
                <button id="jd-close-goods-panel" class="jd-goods-close">清空</button>
                <button id="jd-select-goods-visible" class="jd-goods-select-visible">全选商品</button>
            </div>
        `;

        renderGoodsRows(resultPanel, goods, false);

        body.appendChild(resultPanel);

        document.querySelector('#jd-close-goods-panel').onclick = () => {
            body.innerHTML = '';
        };

        document.querySelector('#jd-select-goods-visible').onclick = e => {
            e.preventDefault();
            e.stopPropagation();

            const checks = [...document.querySelectorAll('.jd-goods-check')];
            const hasUnchecked = checks.some(c => !c.checked);

            checks.forEach(c => {
                c.checked = hasUnchecked;
            });

            e.target.textContent = hasUnchecked ? '反选商品' : '全选商品';
        };

        alert(`商品列表扫描完成:找到 ${goods.length} 个商品。商品图只在这里显示,不会混入评论筛选。`);
    }

    function toggleAll() {
        const checks = [
            ...document.querySelectorAll('.jd-download-check'),
            ...document.querySelectorAll('.jd-filter-check'),
            ...document.querySelectorAll('.jd-goods-check')
        ];

        if (!checks.length) {
            alert('请先点击“评论分组”“筛选”或“商品列表”');
            return;
        }

        const hasUnchecked = checks.some(c => !c.checked);

        checks.forEach(c => {
            c.checked = hasUnchecked;
        });
    }

    async function runDownloadTasks(tasks, doneMessage) {
        if (!tasks.length) {
            alert('没有可下载图片');
            return;
        }

        for (let i = 0; i < tasks.length; i++) {
            const task = tasks[i];

            console.log('下载:', task.url, task.filename);

            GM_download({
                url: task.url,
                name: task.filename,
                saveAs: false,
                onload: () => {
                    console.log('下载完成:', task.filename);
                },
                onerror: err => {
                    console.warn('下载失败:', task.url, err);
                }
            });

            await sleep(420);
        }

        alert(doneMessage || `已开始下载 ${tasks.length} 张图片`);
    }

    async function downloadSelected() {
        const rootFolder = getRootFolderName();
        const tasks = [];
        const seen = new Set();
        const groupCounters = {};

        const selectedGroups = [...document.querySelectorAll('.jd-group-check:checked')]
            .map(input => input.closest('.jdc-pc-rate-card') || input.closest(JD_COMMENT_ITEM_SELECTOR))
            .filter(Boolean);

        selectedGroups.forEach((comment, groupIndex) => {
            const groupName = makeGroupFolderName(rootFolder, groupIndex + 1);
            const folder = `${rootFolder}/评论分组/${groupName}`;

            getImages(comment).forEach(url => {
                addTask(tasks, seen, groupCounters, url, folder);
            });
        });

        const selectedFilterGroups = [...document.querySelectorAll('.jd-filter-check:checked')]
            .map(input => input.closest('.jd-filter-card'))
            .filter(Boolean);

        selectedFilterGroups.forEach((card, groupIndex) => {
            const urls = getAliveUrlsFromFilterCard(card);

            if (!urls.length) return;

            const groupName = makeGroupFolderName(rootFolder, groupIndex + 1);
            const folder = `${rootFolder}/筛选分组/${groupName}`;

            urls.forEach(url => {
                addTask(tasks, seen, groupCounters, url, folder);
            });
        });

        const selectedGoods = [...document.querySelectorAll('.jd-goods-check:checked')]
            .map(input => input.closest('.jd-goods-row'))
            .filter(Boolean);

        selectedGoods.forEach(row => {
            const sku = row.dataset.jdGoodsSku || '';
            const name = row.dataset.jdGoodsName || '未命名商品';
            let images = [];

            try {
                images = JSON.parse(row.dataset.jdGoodsImages || '[]');
            } catch (e) {
                images = [];
            }

            const folderName = cleanName(`${sku}----${name}`, 180);
            const folder = `${rootFolder}/商品列表/${folderName}`;

            images.forEach(url => {
                addTask(tasks, seen, groupCounters, url, folder, '首图大图', true);
            });
        });

        await runDownloadTasks(
            tasks,
            `已开始下载 ${tasks.length} 张图片。\n评论图按单条 jdc-pc-rate-card 分组。\n商品图只来自“商品列表”。`
        );
    }

    function startAutoWatch() {
        if (autoWatchTimer) return;

        autoWatchTimer = setInterval(() => {
            addPanel();
            lockDetailPageScroll();
            bindCommentScrollers();
            scanVisibleCommentsToCache();
        }, 1000);
    }

    document.addEventListener('wheel', e => {
        const root = getRateOverlayRoot();

        if (!root) return;

        if (root.contains(e.target)) {
            e.stopPropagation();
        }
    }, {
        capture: true,
        passive: true
    });

    document.addEventListener('touchmove', e => {
        const root = getRateOverlayRoot();

        if (!root) return;

        if (root.contains(e.target)) {
            e.stopPropagation();
        }
    }, {
        capture: true,
        passive: true
    });

    addPanel();

    const observer = new MutationObserver(mutations => {
        addPanel();
        lockDetailPageScroll();
        bindCommentScrollers();
        scheduleScan(150);

        if (mutationTouchesGoodsOrSpec(mutations)) {
            scheduleGoodsRefresh(700);
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    bindCommentScrollers();
    startAutoWatch();

})();


发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。