Greasy Fork

来自缓存

Greasy Fork is available in English.

小红书无水印图片/视频/Live图下载器 (状态记忆版)

小红书图片和视频下载器。采用底层网络请求拦截技术,100% 抓取纯视频和 Live 图高清源文件;加入UI轮询保活机制;新增下载状态记忆功能,下载后按钮变为绿色打勾,二次进入不迷路。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         小红书无水印图片/视频/Live图下载器 (状态记忆版)
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  小红书图片和视频下载器。采用底层网络请求拦截技术,100% 抓取纯视频和 Live 图高清源文件;加入UI轮询保活机制;新增下载状态记忆功能,下载后按钮变为绿色打勾,二次进入不迷路。
// @author       你的名字 (Original author: pleia)
// @match        https://www.xiaohongshu.com/*
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        unsafeWindow
// @run-at       document-start
// @license      MIT
// @connect      *
// ==/UserScript==

(function() {
    'use strict';

    // ==========================================
    // 核心黑科技:网络请求拦截器
    // ==========================================
    const interceptedData = {}; // 存储抓取到的最新鲜的笔记数据

    // 1. 拦截 Fetch 请求
    const originalFetch = unsafeWindow.fetch || window.fetch;
    unsafeWindow.fetch = async function(...args) {
        const response = await originalFetch.apply(this, args);
        try {
            const url = args[0] instanceof Request ? args[0].url : args[0];
            if (url && (url.includes('/api/sns/web/v1/feed') || url.includes('/api/sns/web/v1/note/detail'))) {
                const clone = response.clone();
                clone.json().then(data => {
                    extractNotesFromApi(data);
                }).catch(e => console.error("解析Fetch JSON失败", e));
            }
        } catch(e) {}
        return response;
    };

    // 2. 拦截 XHR 请求 (兜底)
    const originalXHR = unsafeWindow.XMLHttpRequest.prototype.open;
    unsafeWindow.XMLHttpRequest.prototype.open = function(method, url, ...rest) {
        this.addEventListener('load', function() {
            if (url && typeof url === 'string' && (url.includes('/api/sns/web/v1/feed') || url.includes('/api/sns/web/v1/note/detail'))) {
                try {
                    const data = JSON.parse(this.responseText);
                    extractNotesFromApi(data);
                } catch(e) {}
            }
        });
        return originalXHR.call(this, method, url, ...rest);
    };

    // 提取并保存API返回的笔记数据
    function extractNotesFromApi(data) {
        if (!data || !data.data) return;
        let items = data.data.items || (Array.isArray(data.data) ? data.data : [data.data]);
        items.forEach(item => {
            let note = item.note_card || item;
            let id = note.id || note.note_id;
            if (id) {
                interceptedData[id] = note;
            }
        });
    }

    // ==========================================
    // UI 及下载逻辑 (防消失 + 状态记忆版)
    // ==========================================
    let cssInjected = false;

    // 轮询检查UI是否存在并更新状态
    function ensureUI() {
        if (!document.body) return; // 网页body还没加载完,先不急

        // 只注入一次CSS样式
        if (!cssInjected) {
            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; 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); }

                /* 下载完成后的绿色打勾样式 */
                .xhs-download-btn.downloaded { background: linear-gradient(135deg, #00b09b 0%, #96c93d 100%); animation: none; box-shadow: 0 4px 20px rgba(0, 176, 155, 0.4); }
                .xhs-download-btn.downloaded:hover { transform: scale(1.15); box-shadow: 0 6px 25px rgba(0, 176, 155, 0.5); }

                @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.8); 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; pointer-events: none; }
                .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; }

                /* 打勾成功图标 */
                .success-icon { width: 24px; height: 24px; position: relative; }
                .success-icon::after { content: ''; position: absolute; top: 4px; left: 8px; width: 6px; height: 12px; border: solid white; border-width: 0 3px 3px 0; transform: rotate(45deg); }
            `);
            cssInjected = true;
        }

        let downloadBtn = document.querySelector('.xhs-download-btn');
        let currentNoteId = getActiveNoteId();

        // 1. 如果按钮不存在,创建按钮
        if (!downloadBtn) {
            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';
            progressIndicator.id = 'xhs-progress-indicator';
            document.body.appendChild(progressIndicator);

            // 绑定点击事件
            downloadBtn.addEventListener('click', async function() {
                const indicator = document.getElementById('xhs-progress-indicator');
                const pageTitle = getSafeFileName(document.title);
                let noteId = getActiveNoteId();

                indicator.textContent = '正在读取笔记源数据...';
                indicator.classList.add('show');

                let mediaPairs = getMediaFromState();

                if (!mediaPairs || mediaPairs.length === 0) {
                    alert('未找到任何图片或视频!\n\n【重要提醒】如果是刚安装脚本,请按 F5 刷新一次当前网页!');
                    indicator.classList.remove('show');
                    return;
                }

                try {
                    await downloadPairs(mediaPairs, pageTitle, indicator);
                    indicator.textContent = '所有文件下载完成!';

                    // 下载成功后,记录状态并更新按钮 UI
                    if (noteId) {
                        GM_setValue(`xhs_downloaded_${noteId}`, true);
                        downloadBtn.classList.add('downloaded');
                        downloadBtn.innerHTML = '<div class="success-icon"></div>';
                    }
                } catch (error) {
                    console.error('下载失败:', error);
                    indicator.textContent = `下载失败: ${error.message}`;
                    alert(`下载过程中出现错误: ${error.message}`);
                }

                setTimeout(() => { indicator.classList.remove('show'); }, 3000);
            });
        }

        // 2. 监测页面跳转:如果进入了新笔记或旧笔记,刷新按钮状态
        if (downloadBtn && currentNoteId && downloadBtn.dataset.noteId !== currentNoteId) {
            downloadBtn.dataset.noteId = currentNoteId; // 记录当前绑定的笔记ID

            // 查询本地是否下载过
            let hasDownloaded = GM_getValue(`xhs_downloaded_${currentNoteId}`, false);
            if (hasDownloaded) {
                downloadBtn.classList.add('downloaded');
                downloadBtn.innerHTML = '<div class="success-icon"></div>';
            } else {
                downloadBtn.classList.remove('downloaded');
                downloadBtn.innerHTML = '<div class="download-icon"></div>';
            }
        }
    }

    // 每隔 1 秒检查一次按钮及状态
    setInterval(ensureUI, 1000);

    // ==========================================
    // 数据解析器
    // ==========================================

    function extractVideoFromObject(obj) {
        if (!obj) return null;
        let urls = [];
        let stringified = JSON.stringify(obj);

        let regex = /"(?:masterUrl|videoUrl|url|backupUrl)":"(https?:\/\/[^"]+)"/g;
        let match;
        while ((match = regex.exec(stringified)) !== null) {
            let url = match[1];
            url = url.replace(/\\u([\d\w]{4})/gi, function (m, grp) {
                return String.fromCharCode(parseInt(grp, 16));
            });
            url = url.replace(/\\\//g, '/');

            if (!url.includes('.jpg') && !url.includes('.png') && !url.includes('.webp') && !url.includes('.jpeg') && !url.includes('image')) {
                urls.push(url);
            }
        }

        let mp4Urls = urls.filter(u => !u.includes('.m3u8'));
        if (mp4Urls.length > 0) return mp4Urls[0];
        if (urls.length > 0) return urls[0];

        return null;
    }

    function getMediaFromState() {
        let noteId = getActiveNoteId();
        let noteDetail = null;

        if (noteId && interceptedData[noteId]) {
            noteDetail = interceptedData[noteId];
        } else {
            const state = unsafeWindow.__INITIAL_STATE__ || window.__INITIAL_STATE__;
            if (state?.note?.noteDetailMap) {
                noteDetail = state.note.noteDetailMap[noteId]?.note;
                if (!noteDetail) {
                    const keys = Object.keys(state.note.noteDetailMap);
                    if (keys.length > 0) noteDetail = state.note.noteDetailMap[keys[0]]?.note;
                }
            }
        }

        if (!noteDetail) return null;

        const results = [];

        if (noteDetail.type === 'video' || noteDetail.video) {
            let videoUrl = extractVideoFromObject(noteDetail.video || noteDetail);
            if (videoUrl) {
                results.push({ index: 1, imgUrl: null, videoUrl: fixUrl(videoUrl) });
                return results;
            }
        }

        if (noteDetail.imageList && noteDetail.imageList.length > 0) {
            noteDetail.imageList.forEach((item, index) => {
                let imgUrl = item.urlDefault || item.url || item.livePhotoFileUrl || item.infoList?.[0]?.url;
                let videoUrl = extractVideoFromObject(item.stream);

                if (imgUrl || videoUrl) {
                    results.push({
                        index: index + 1,
                        imgUrl: imgUrl ? fixUrl(imgUrl) : null,
                        videoUrl: videoUrl ? fixUrl(videoUrl) : null
                    });
                }
            });
            return results;
        }

        return results.length > 0 ? results : null;
    }

    // ==========================================
    // 执行与工具函数
    // ==========================================

    async function downloadPairs(pairs, pageTitle, indicator) {
        let totalFiles = 0;
        pairs.forEach(p => {
            if (p.imgUrl) totalFiles++;
            if (p.videoUrl) totalFiles++;
        });

        let currentFile = 0;

        for (let i = 0; i < pairs.length; i++) {
            const pair = pairs[i];
            const baseName = pairs.length === 1 ? pageTitle : `${pageTitle}_${pair.index}`;

            if (pair.imgUrl) {
                currentFile++;
                indicator.textContent = `正在下载第 ${currentFile}/${totalFiles} 个文件 (图片)...`;
                const ext = getExtension(pair.imgUrl, 'jpg');
                await downloadFile(pair.imgUrl, `${baseName}.${ext}`);
                await sleep(300);
            }

            if (pair.videoUrl) {
                currentFile++;
                indicator.textContent = `正在下载第 ${currentFile}/${totalFiles} 个文件 (纯视频)...`;
                let ext = getExtension(pair.videoUrl, 'mp4');
                if (ext !== 'mp4' && ext !== 'webm' && ext !== 'mov') ext = 'mp4';

                await downloadFile(pair.videoUrl, `${baseName}.${ext}`);
                await sleep(400);
            }
        }
    }

    function downloadFile(url, fileName) {
        return new Promise((resolve, reject) => {
            GM_download({ url: url, name: fileName, onerror: reject, onload: resolve });
        });
    }

    function getActiveNoteId() {
        const path = window.location.pathname;
        const match = path.match(/\/(explore|discovery\/item)\/([a-zA-Z0-9]+)/);
        return match ? match[2] : null;
    }

    function fixUrl(url) {
        if (!url) return null;
        if (url.startsWith('//')) return 'https:' + url;
        if (url.startsWith('/')) return window.location.origin + url;
        return url;
    }

    function getExtension(url, defaultExt) {
        try {
            const urlWithoutQuery = url.split('?')[0];
            const parts = urlWithoutQuery.split('.');
            if (parts.length > 1) {
                const ext = parts[parts.length - 1].toLowerCase();
                if (ext.length <= 5 && /^[a-z0-9]+$/.test(ext)) return ext;
            }
        } catch (e) {}
        return defaultExt;
    }

    function getSafeFileName(name) {
        return name.replace(/[<>:"/\\|?*]/g, '_').replace(/\s+/g, '_').substring(0, 80).trim();
    }

    function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }
})();