小红书图片下载器

小红书图片下载器,相同哈希值的图片只保留一张

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.

Necesitarás 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.

Necesitará instalar una extensión como Tampermonkey para 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)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

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

// ==UserScript==
// @name         小红书图片下载器
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  小红书图片下载器,相同哈希值的图片只保留一张
// @author       pleia
// @match        https://www.xiaohongshu.com/*
// @grant        GM_download
// @grant        GM_addStyle
// @grant        unsafeWindow
// @license      MIT; https://opensource.org/licenses/MIT
// @connect      *
// ==/UserScript==

(function() {
    'use strict';

    // 添加必要的库 - JSZip 和 SparkMD5
    const script1 = document.createElement('script');
    script1.src = 'https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js';

    const script2 = document.createElement('script');
    script2.src = 'https://cdnjs.cloudflare.com/ajax/libs/spark-md5/3.0.2/spark-md5.min.js';

    let scriptsLoaded = 0;
    const onScriptLoaded = () => {
        scriptsLoaded++;
        if (scriptsLoaded === 2) {
            init();
        }
    };

    script1.onload = onScriptLoaded;
    script2.onload = onScriptLoaded;

    document.head.appendChild(script1);
    document.head.appendChild(script2);

    // 添加下载按钮样式
    GM_addStyle(`
        .xhs-download-btn {
            position: fixed;
            bottom: 50px;
            right: 50px;
            background: linear-gradient(135deg, #ff2442 0%, #ff768a 100%);
            color: white;
            border: none;
            border-radius: 50%;
            width: 60px;
            height: 60px;
            font-size: 18px;
            cursor: pointer;
            box-shadow: 0 4px 20px rgba(255, 36, 66, 0.4);
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 9999;
            transition: all 0.3s ease;
            transform-origin: center;
            animation: pulse 2s infinite;
        }

        .xhs-download-btn:hover {
            transform: scale(1.15);
            box-shadow: 0 6px 25px rgba(255, 36, 66, 0.5);
            animation: none;
        }

        .xhs-download-btn:active {
            transform: scale(0.95);
            box-shadow: 0 2px 10px rgba(255, 36, 66, 0.3);
        }

        @keyframes pulse {
            0% {
                box-shadow: 0 0 0 0 rgba(255, 36, 66, 0.4);
            }
            70% {
                box-shadow: 0 0 0 10px rgba(255, 36, 66, 0);
            }
            100% {
                box-shadow: 0 0 0 0 rgba(255, 36, 66, 0);
            }
        }

        .download-progress {
            position: fixed;
            bottom: 120px;
            right: 50px;
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            padding: 10px 15px;
            border-radius: 25px;
            font-size: 14px;
            box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
            opacity: 0;
            transition: opacity 0.3s ease;
            z-index: 9998;
        }

        .download-progress.show {
            opacity: 1;
        }

        /* 下载图标样式 */
        .download-icon {
            width: 24px;
            height: 24px;
            position: relative;
        }

        .download-icon::before {
            content: '';
            position: absolute;
            top: 0;
            left: 50%;
            transform: translateX(-50%);
            width: 16px;
            height: 16px;
            border: 2px solid white;
            border-radius: 2px;
        }

        .download-icon::after {
            content: '';
            position: absolute;
            top: 10px;
            left: 50%;
            transform: translateX(-50%);
            width: 0;
            height: 0;
            border-left: 6px solid transparent;
            border-right: 6px solid transparent;
            border-top: 8px solid white;
        }

        .download-icon span {
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            width: 12px;
            height: 2px;
            background-color: white;
        }
    `);

    // 初始化函数
    function init() {
        // 创建下载按钮
        const downloadBtn = document.createElement('button');
        downloadBtn.className = 'xhs-download-btn';
        downloadBtn.innerHTML = '<div class="download-icon"></div>';
        document.body.appendChild(downloadBtn);

        // 创建进度提示元素
        const progressIndicator = document.createElement('div');
        progressIndicator.className = 'download-progress';
        document.body.appendChild(progressIndicator);

        // 点击按钮时执行下载操作
        downloadBtn.addEventListener('click', async function() {
            // 获取并处理页面标题作为文件名基础
            const pageTitle = getSafeFileName(document.title);

            // 尝试查找所有img-container元素
            const imgContainers = document.querySelectorAll('.img-container');

            // 如果找到img-container
            if (imgContainers.length > 0) {
                // 获取所有可见图片
                const visibleImages = getVisibleImages(imgContainers);

                // 如果没有可见图片
                if (visibleImages.length === 0) {
                    alert('未找到可见图片!');
                    return;
                }

                // 计算图片哈希值并去重
                progressIndicator.textContent = '正在计算图片哈希值...';
                progressIndicator.classList.add('show');

                const uniqueImages = await getUniqueImagesByHash(visibleImages, progressIndicator);

                // 如果只有一张图片,直接下载
                if (uniqueImages.length === 1) {
                    progressIndicator.textContent = '准备下载单张图片...';

                    try {
                        await downloadSingleImage(uniqueImages[0].element, progressIndicator, pageTitle);
                        progressIndicator.textContent = '图片下载完成!';
                        setTimeout(() => {
                            progressIndicator.classList.remove('show');
                        }, 3000);
                    } catch (error) {
                        console.error('下载图片失败:', error);
                        progressIndicator.textContent = `下载失败: ${error.message}`;
                        setTimeout(() => {
                            progressIndicator.classList.remove('show');
                        }, 3000);
                        alert(`下载图片失败: ${error.message}`);
                    }

                    return;
                }

                // 多张图片,打包下载
                progressIndicator.textContent = `找到 ${uniqueImages.length} 张不重复图片,准备打包下载...`;

                try {
                    await downloadAndZipUniqueImages(uniqueImages, progressIndicator, pageTitle);
                    progressIndicator.textContent = '所有不重复图片打包下载完成!';
                    setTimeout(() => {
                        progressIndicator.classList.remove('show');
                    }, 3000);
                } catch (error) {
                    console.error('打包下载失败:', error);
                    progressIndicator.textContent = `打包下载失败: ${error.message}`;
                    setTimeout(() => {
                        progressIndicator.classList.remove('show');
                    }, 3000);
                    alert(`打包下载失败: ${error.message}`);
                }

                return;
            }

            // 如果没有找到img-container
            alert('未找到图片容器元素!');
        });
    }

    // 获取所有可见图片
    function getVisibleImages(imgContainers) {
        const visibleImages = [];

        imgContainers.forEach(container => {
            const images = container.querySelectorAll('img');

            images.forEach(img => {
                // 过滤掉不可见的图片
                if (isElementVisible(img)) {
                    visibleImages.push(img);
                }
            });
        });

        return visibleImages;
    }

    // 检查元素是否可见
    function isElementVisible(el) {
        if (!el) return false;

        const style = window.getComputedStyle(el);
        if (style.display === 'none') return false;
        if (style.visibility !== 'visible') return false;
        if (style.opacity < 0.1) return false;

        const rect = el.getBoundingClientRect();
        if (rect.width === 0 || rect.height === 0) return false;

        // 检查元素是否在视窗内
        const viewHeight = Math.max(document.documentElement.clientHeight, window.innerHeight);
        return !(rect.bottom < 0 || rect.top - viewHeight >= 0);
    }

    // 计算图片哈希值并去重
    async function getUniqueImagesByHash(images, progressIndicator) {
        const hashSet = new Set();
        const uniqueImages = [];
        const totalImages = images.length;

        for (let i = 0; i < totalImages; i++) {
            const img = images[i];
            progressIndicator.textContent = `正在计算图片哈希值 ${i + 1}/${totalImages}`;

            try {
                const hash = await getImageHash(img);
                if (!hashSet.has(hash)) {
                    hashSet.add(hash);
                    uniqueImages.push({
                        element: img,
                        hash: hash
                    });
                }
            } catch (error) {
                console.error(`计算图片哈希失败: ${error}`);
                // 计算失败的情况下,默认保留图片
                uniqueImages.push({
                    element: img,
                    hash: null
                });
            }
        }

        return uniqueImages;
    }

    // 计算图片的MD5哈希值
    async function getImageHash(imgElement) {
        const src = imgElement.src || imgElement.dataset.src;
        if (!src) {
            throw new Error('无法获取图片源');
        }

        // 处理图片URL,确保是完整URL
        let imageUrl = src;
        if (!imageUrl.startsWith('http')) {
            if (imageUrl.startsWith('//')) {
                imageUrl = 'https:' + imageUrl;
            } else {
                const baseUrl = window.location.origin;
                imageUrl = new URL(imageUrl, baseUrl).href;
            }
        }

        // 下载图片并计算哈希
        const response = await fetch(imageUrl);
        const blob = await response.blob();

        return new Promise((resolve, reject) => {
            const fileReader = new FileReader();
            const spark = new SparkMD5.ArrayBuffer();

            fileReader.onload = function(e) {
                spark.append(e.target.result);
                resolve(spark.end());
            };

            fileReader.onerror = function() {
                reject(new Error('读取图片失败'));
            };

            fileReader.readAsArrayBuffer(blob);
        });
    }

    // 下载单张图片
    async function downloadSingleImage(imgElement, progressIndicator, pageTitle) {
        const src = imgElement.src || imgElement.dataset.src;
        if (!src) {
            throw new Error('无法获取图片源');
        }

        // 处理图片URL,确保是完整URL
        let imageUrl = src;
        if (!imageUrl.startsWith('http')) {
            if (imageUrl.startsWith('//')) {
                imageUrl = 'https:' + imageUrl;
            } else {
                const baseUrl = window.location.origin;
                imageUrl = new URL(imageUrl, baseUrl).href;
            }
        }

        try {
            // 尝试获取图片扩展名
            let fileExtension = 'jpg';
            const urlParts = imageUrl.split('.');
            if (urlParts.length > 1) {
                const lastPart = urlParts[urlParts.length - 1].toLowerCase();
                if (lastPart.length <= 5) { // 简单验证是否是扩展名
                    fileExtension = lastPart;
                }
            }

            // 生成图片文件名,使用页面标题
            const fileName = `${pageTitle}.${fileExtension}`;

            // 更新进度提示
            progressIndicator.textContent = '正在下载图片...';

            // 使用GM_download API下载图片
            await new Promise((resolve, reject) => {
                GM_download({
                    url: imageUrl,
                    name: fileName,
                    onerror: reject,
                    onload: resolve
                });
            });

            console.log(`成功下载图片 ${fileName}`);
        } catch (error) {
            console.error(`下载图片失败: ${error}`);
            throw error;
        }
    }

    // 下载并打包不重复的图片
    async function downloadAndZipUniqueImages(uniqueImages, progressIndicator, pageTitle) {
        const zip = new JSZip();
        const totalImages = uniqueImages.length;
        let processedImages = 0;

        // 遍历下载所有不重复图片
        for (let i = 0; i < totalImages; i++) {
            const imgInfo = uniqueImages[i];
            const img = imgInfo.element;
            const src = img.src || img.dataset.src;

            if (!src) continue;

            // 处理图片URL,确保是完整URL
            let imageUrl = src;
            if (!imageUrl.startsWith('http')) {
                if (imageUrl.startsWith('//')) {
                    imageUrl = 'https:' + imageUrl;
                } else {
                    const baseUrl = window.location.origin;
                    imageUrl = new URL(imageUrl, baseUrl).href;
                }
            }

            try {
                // 更新进度提示
                progressIndicator.textContent = `正在下载 ${processedImages + 1}/${totalImages}`;

                // 下载图片并添加到zip
                const response = await fetch(imageUrl);
                const blob = await response.blob();

                // 尝试获取图片扩展名
                let fileExtension = 'jpg';
                const urlParts = imageUrl.split('.');
                if (urlParts.length > 1) {
                    const lastPart = urlParts[urlParts.length - 1].toLowerCase();
                    if (lastPart.length <= 5) { // 简单验证是否是扩展名
                        fileExtension = lastPart;
                    }
                }

                // 生成图片文件名,使用页面标题、序号和哈希值
                const fileName = imgInfo.hash
                    ? `${pageTitle}_${processedImages + 1}_${imgInfo.hash.substring(0, 8)}.${fileExtension}`
                    : `${pageTitle}_${processedImages + 1}.${fileExtension}`;

                zip.file(fileName, blob);

                processedImages++;
                console.log(`成功添加图片到zip: ${fileName}`);
            } catch (error) {
                console.error(`下载图片失败: ${error}`);
                // 继续处理其他图片,不中断整个过程
            }
        }

        // 生成并下载zip文件
        if (processedImages > 0) {
            progressIndicator.textContent = '正在生成ZIP文件...';

            // 生成ZIP文件,使用页面标题
            const zipFileName = `${pageTitle}.zip`;
            const content = await zip.generateAsync({ type: 'blob' });

            // 创建下载链接
            const url = URL.createObjectURL(content);
            const a = document.createElement('a');
            a.href = url;
            a.download = zipFileName;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
        } else {
            throw new Error('没有成功下载任何图片');
        }
    }

    // 处理文件名,移除不合法字符
    function getSafeFileName(name) {
        // 移除或替换不合法的文件名字符
        return name
            .replace(/[<>:"/\\|?*]/g, '_')  // 替换不合法字符为下划线
            .replace(/\s+/g, '_')           // 替换空格为下划线
            .substring(0, 80)               // 限制最大长度
            .trim();                        // 移除首尾空格
    }
})();