小红书图片下载器

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

// ==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();                        // 移除首尾空格
    }
})();