小红书无水印下载

发表于 视频

// ==UserScript==
// @name         小红书无水印下载【路径可设置·UI美化版】
// @namespace    http://tampermonkey.net/
// @version      2026.5.5
// @description  支持面板设置下载路径;美化UI;无水印视频/封面/原图一键下载;标题实时监控自动同步;小于20K图片自动过滤
// @author       修复版
// @match        *://*.xiaohongshu.com/*
// @match        https://www.xiaohongshu.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_registerMenuCommand
// @connect      xhscdn.com
// @connect      xiaohongshu.com
// @connect      sns-video-bd.xhscdn.com
// @connect      sns-webpic-qc.xhscdn.com
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    const CONFIG = {
        panelLeft: '20px',
        panelTop: '120px',
        panelWidth: '340px',

        downloadDelayMs: 500,
        clipboardPollIntervalMs: 800,
        loadWaitMs: 1500,

        qualityPriority: ['4K', '2K', '1080P', '720P', '480P', '360P', '流畅'],
        retryTimes: 3,
        debugMode: true,

        minImageSizeKB: 20,
        titleChangeDebounceMs: 300,
        titleForceOverride: true
    };

    let rootDirHandle = null;
    let currentFolderName = '';
    let isDownloading = false;
    let lastProcessedClipboard = '';

    let panel = null;
    let skuInput = null;
    let productInput = null;
    let statusDiv = null;
    let pathDiv = null;
    let choosePathBtn = null;

    let lastPageTitle = '';
    let titleObserver = null;
    let clipboardTimer = null;
    let routeObserver = null;

    function log(...args) {
        if (CONFIG.debugMode) console.log('【小红书下载器】', ...args);
    }

    function errorLog(...args) {
        console.error('【小红书下载器-错误】', ...args);
    }

    function generateRandomSuffix() {
        const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
        let result = '';
        for (let i = 0; i < 6; i++) {
            result += chars.charAt(Math.floor(Math.random() * chars.length));
        }
        return result;
    }

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

    function getPageTitle() {
        let title = document.title;
        if (!title) return '';
        title = title.replace(/\s*[-–]\s*小红书\s*$/i, '');
        title = title.replace(/\s*[-–]\s*xiaohongshu\s*$/i, '');
        return title.trim();
    }

    function setStatus(text, type = 'normal') {
        if (!statusDiv) return;

        statusDiv.textContent = text;
        statusDiv.className = `status-text status-${type}`;

        log('状态:', text);
    }

    function updateStatus(text) {
        setStatus(text, 'normal');
    }

    function updatePathDisplay(text, type = 'normal') {
        if (!pathDiv) return;

        pathDiv.textContent = text || '未设置下载路径';
        pathDiv.className = `path-display path-${type}`;
    }

    function autoFillProductFromTitle() {
        if (productInput && !productInput.value.trim()) {
            const pageTitle = getPageTitle();
            if (pageTitle) {
                productInput.value = pageTitle;
                lastPageTitle = pageTitle;
                log('自动填充页面标题:', pageTitle);
                setStatus(`📄 自动使用标题: ${pageTitle.substring(0, 40)}${pageTitle.length > 40 ? '…' : ''}`, 'success');
            }
        }
    }

    function cleanMediaUrl(url, type = 'image') {
        if (!url) return '';

        let cleanUrl = url
            .replace(/&quot;/g, '')
            .replace(/&#039;/g, '')
            .replace(/\\u002F/g, '/')
            .replace(/\\\//g, '/')
            .replace(/^\/\//, 'https://')
            .trim();

        if (type === 'video') {
            cleanUrl = cleanUrl.replace(/!.*$/, '');
            if (cleanUrl.startsWith('sns-video')) cleanUrl = 'https://' + cleanUrl;
        } else {
            cleanUrl = cleanUrl
                .replace(/!(webp|jpe?g|png|avif)\?.*$/, '')
                .replace(/!q\d+\.(jpg|webp|png)$/, '!q100.jpg')
                .replace(/\/s\d+x\d+\//, '/')
                .replace(/\/format\/webp|\/format\/avif/, '')
                .replace(/\/image_process\/.*$/, '');

            if (cleanUrl.startsWith('sns-webpic')) cleanUrl = 'https://' + cleanUrl;
        }

        return cleanUrl;
    }

    function handleTitleChange() {
        const currentTitle = getPageTitle();

        if (!currentTitle || currentTitle === lastPageTitle || !productInput) return;

        lastPageTitle = currentTitle;

        if (CONFIG.titleForceOverride || !productInput.value.trim()) {
            productInput.value = currentTitle;
            log('检测到标题变化,已自动更新商品名:', currentTitle);
            setStatus(`📄 标题已更新: ${currentTitle.substring(0, 40)}${currentTitle.length > 40 ? '…' : ''}`, 'success');
        } else {
            log('检测到标题变化,但输入框已有内容,未覆盖');
        }
    }

    function startTitleMonitor() {
        stopTitleMonitor();

        lastPageTitle = getPageTitle();

        const titleElement = document.querySelector('head title');

        if (!titleElement) {
            log('未找到 title 元素,降级为轮询监控');
            titleObserver = setInterval(handleTitleChange, CONFIG.titleChangeDebounceMs);
            return;
        }

        titleObserver = new MutationObserver(() => {
            clearTimeout(window.titleChangeTimeout);
            window.titleChangeTimeout = setTimeout(handleTitleChange, CONFIG.titleChangeDebounceMs);
        });

        titleObserver.observe(titleElement, {
            childList: true,
            subtree: true,
            characterData: true,
            attributes: true
        });

        log('页面标题实时监控已启动');
    }

    function stopTitleMonitor() {
        if (titleObserver) {
            if (titleObserver instanceof MutationObserver) {
                titleObserver.disconnect();
            } else {
                clearInterval(titleObserver);
            }
            titleObserver = null;
        }

        clearTimeout(window.titleChangeTimeout);
        log('页面标题监控已停止');
    }

    function getNoteData() {
        let noteData = null;

        const dataSources = [
            () => window.__INITIAL_STATE__?.note?.note,
            () => window.__INITIAL_STATE__?.feed?.note,
            () => window.__INITIAL_STATE__?.discovery?.item?.note,
            () => window.__REDUX_STATE__?.note?.currentNote,
            () => window._SSR_DATA?.data?.noteDetail?.note,
            () => window.__INITIAL_STATE__?.search?.result?.notes?.[0],
            () => window.__INITIAL_STATE__?.user?.notes?.[0],
            () => {
                const scripts = document.querySelectorAll('script');

                for (const script of scripts) {
                    if (script.innerHTML.includes('window.__INITIAL_STATE__')) {
                        try {
                            const match = script.innerHTML.match(/window\.__INITIAL_STATE__\s*=\s*(\{.*?\});?\s*<\/script>/s);
                            if (match) {
                                const state = JSON.parse(match[1]);
                                return state?.note?.note || state?.feed?.note;
                            }
                        } catch (e) {}
                    }
                }

                return null;
            }
        ];

        for (const getSource of dataSources) {
            try {
                const data = getSource();
                if (data && (data.video || data.imageList)) {
                    noteData = data;
                    break;
                }
            } catch (e) {}
        }

        return noteData;
    }

    function getVideoMedia() {
        let videoInfo = {
            videoUrl: '',
            coverUrl: '',
            quality: ''
        };

        try {
            const noteData = getNoteData();

            if (noteData?.video) {
                const video = noteData.video;
                const media = video.media || video;
                const streamList = media.stream?.h264 || media.videoStream?.h264 || [];

                for (const quality of CONFIG.qualityPriority) {
                    const targetStream = streamList.find(item =>
                        (item.qualityName && item.qualityName.includes(quality)) ||
                        (item.resolutionName && item.resolutionName.includes(quality)) ||
                        item.videoQuality === quality
                    );

                    if (targetStream) {
                        videoInfo.videoUrl = cleanMediaUrl(
                            targetStream.masterUrl || targetStream.url || targetStream.playUrl,
                            'video'
                        );
                        videoInfo.quality = quality;
                        break;
                    }
                }

                if (!videoInfo.videoUrl && streamList.length) {
                    const firstStream = streamList[0];
                    videoInfo.videoUrl = cleanMediaUrl(
                        firstStream.masterUrl || firstStream.url || firstStream.playUrl,
                        'video'
                    );
                    videoInfo.quality = firstStream.qualityName || '默认';
                }

                if (!videoInfo.videoUrl && (media.originVideoKey || media.videoId)) {
                    const videoKey = media.originVideoKey || media.videoId;
                    videoInfo.videoUrl = cleanMediaUrl(`https://sns-video-bd.xhscdn.com/${videoKey}`, 'video');
                    videoInfo.quality = '原始无水印';
                }

                if (noteData.imageList?.length) {
                    const firstImage = noteData.imageList[0];
                    videoInfo.coverUrl = cleanMediaUrl(
                        firstImage.urlDefault || firstImage.url || firstImage.originUrl || firstImage.originalUrl,
                        'image'
                    );
                }
            }
        } catch (err) {}

        if (!videoInfo.videoUrl) {
            try {
                const playerInstances = [
                    window.player,
                    window.xgplayer,
                    window.videoPlayer,
                    document.querySelector('.xgplayer')?.player,
                    document.querySelector('#video-track')?.player
                ];

                for (const player of playerInstances) {
                    if (player) {
                        const src = player.currentSrc || player.src || player.config?.url || player.playUrl;

                        if (src && src.includes('xhscdn.com')) {
                            videoInfo.videoUrl = cleanMediaUrl(src, 'video');

                            const poster = player.poster || player.config?.poster;
                            if (poster) videoInfo.coverUrl = cleanMediaUrl(poster, 'image');

                            break;
                        }
                    }
                }
            } catch (err) {}
        }

        if (!videoInfo.videoUrl) {
            try {
                const videoEls = document.querySelectorAll('video');

                for (const video of videoEls) {
                    const src = video.src || video.currentSrc;

                    if (src && src.includes('xhscdn.com') && !src.includes('blob:')) {
                        videoInfo.videoUrl = cleanMediaUrl(src, 'video');

                        if (video.poster) {
                            videoInfo.coverUrl = cleanMediaUrl(video.poster, 'image');
                        }

                        break;
                    }
                }
            } catch (err) {}
        }
        if (!videoInfo.videoUrl) {
            try {
                const performanceEntries = performance.getEntriesByType('resource');

                for (const entry of performanceEntries) {
                    const url = entry.name;

                    if (
                        url.includes('xhscdn.com') &&
                        (url.includes('.mp4') || url.includes('m3u8')) &&
                        !url.includes('watermark')
                    ) {
                        videoInfo.videoUrl = cleanMediaUrl(url, 'video');
                        break;
                    }
                }
            } catch (err) {}
        }

        if (!videoInfo.coverUrl) {
            try {
                const posterSelectors = [
                    '.xgplayer-poster',
                    '.note-first-image',
                    '.video-cover',
                    '.note-content img:first-child',
                    '.swiper-slide img:first-child'
                ];

                for (const selector of posterSelectors) {
                    const el = document.querySelector(selector);

                    if (el) {
                        const bgMatch = el.style.backgroundImage?.match(/url\(["']?(.*?)["']?\)/);
                        const src = bgMatch?.[1] || el.src || el.dataset.src;

                        if (src) {
                            videoInfo.coverUrl = cleanMediaUrl(src, 'image');
                            break;
                        }
                    }
                }
            } catch (err) {}
        }

        return videoInfo.videoUrl ? videoInfo : null;
    }

    function getImageList() {
        const imageSet = new Set();

        try {
            const noteData = getNoteData();

            if (noteData?.imageList?.length) {
                noteData.imageList.forEach(img => {
                    const originUrl =
                        img.originUrl ||
                        img.originalUrl ||
                        img.urlDefault ||
                        img.url;

                    if (originUrl) {
                        const cleanUrl = cleanMediaUrl(originUrl, 'image');
                        if (cleanUrl) imageSet.add(cleanUrl);
                    }
                });
            }
        } catch (err) {
            errorLog('获取笔记图片列表失败', err);
        }

        return Array.from(imageSet);
    }

    function getFullMediaList() {
        const mediaList = [];
        const usedCoverUrls = new Set();
        const videoInfo = getVideoMedia();

        if (videoInfo) {
            mediaList.push({
                type: 'video',
                url: videoInfo.videoUrl,
                quality: videoInfo.quality,
                coverUrl: videoInfo.coverUrl
            });

            if (videoInfo.coverUrl) {
                usedCoverUrls.add(videoInfo.coverUrl);
            }
        } else {
            const imageList = getImageList();

            imageList.forEach(url => {
                if (!usedCoverUrls.has(url)) {
                    mediaList.push({
                        type: 'image',
                        url: url
                    });
                }
            });
        }

        return mediaList;
    }

    async function fetchMediaWithRetry(url, retryLeft = CONFIG.retryTimes) {
        try {
            return await new Promise((resolve, reject) => {
                GM_xmlhttpRequest({
                    method: 'GET',
                    url: url,
                    responseType: 'blob',
                    timeout: 300000,
                    headers: {
                        Referer: 'https://www.xiaohongshu.com/',
                        Origin: 'https://www.xiaohongshu.com',
                        'User-Agent': navigator.userAgent,
                        Accept: '*/*'
                    },
                    anonymous: true,
                    onload: res => {
                        if (res.status >= 200 && res.status < 300) {
                            resolve(res.response);
                        } else {
                            reject(new Error(`HTTP ${res.status}`));
                        }
                    },
                    onerror: err => reject(err),
                    ontimeout: () => reject(new Error('下载超时'))
                });
            });
        } catch (err) {
            if (retryLeft > 0) {
                await new Promise(r => setTimeout(r, 1500));
                return fetchMediaWithRetry(url, retryLeft - 1);
            }

            throw err;
        }
    }

    function getFileExtension(url, type) {
        if (type === 'video') return 'mp4';

        const match = url.match(/\.(jpg|jpeg|png|webp|avif|bmp)(?:[?#!]|$)/i);
        return match ? match[1].toLowerCase() : 'jpg';
    }

    const DB_CONFIG = {
        name: 'XHS_Downloader_DB',
        version: 1,
        store: 'root_directory'
    };

    function saveRootHandle(handle) {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version);

            request.onupgradeneeded = e => {
                const db = e.target.result;

                if (!db.objectStoreNames.contains(DB_CONFIG.store)) {
                    db.createObjectStore(DB_CONFIG.store);
                }
            };

            request.onsuccess = e => {
                const db = e.target.result;
                const tx = db.transaction(DB_CONFIG.store, 'readwrite');
                const store = tx.objectStore(DB_CONFIG.store);
                const putReq = store.put(handle, 'root');

                putReq.onsuccess = () => {
                    db.close();
                    resolve();
                };

                putReq.onerror = () => {
                    db.close();
                    reject(putReq.error);
                };
            };

            request.onerror = () => reject(request.error);
        });
    }

    function loadRootHandle() {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(DB_CONFIG.name, DB_CONFIG.version);

            request.onupgradeneeded = e => {
                const db = e.target.result;

                if (!db.objectStoreNames.contains(DB_CONFIG.store)) {
                    db.createObjectStore(DB_CONFIG.store);
                }
            };

            request.onsuccess = e => {
                const db = e.target.result;

                if (!db.objectStoreNames.contains(DB_CONFIG.store)) {
                    db.close();
                    resolve(null);
                    return;
                }

                const tx = db.transaction(DB_CONFIG.store, 'readonly');
                const store = tx.objectStore(DB_CONFIG.store);
                const getReq = store.get('root');

                getReq.onsuccess = () => {
                    const handle = getReq.result;
                    db.close();
                    resolve(handle || null);
                };

                getReq.onerror = () => {
                    db.close();
                    reject(getReq.error);
                };
            };

            request.onerror = () => reject(request.error);
        });
    }

    async function chooseRootDirectory() {
        if (!window.showDirectoryPicker) {
            throw new Error('当前浏览器不支持设置下载路径,请使用 Chrome 或 Edge 最新版');
        }

        const handle = await window.showDirectoryPicker({
            mode: 'readwrite',
            startIn: 'downloads'
        });

        await saveRootHandle(handle);

        rootDirHandle = handle;
        currentFolderName = handle.name || '已选择目录';

        updatePathDisplay(`📂 ${currentFolderName}`, 'success');
        setStatus('✅ 下载路径设置成功', 'success');

        return handle;
    }

    async function restoreRootDirectory() {
        try {
            const savedHandle = await loadRootHandle();

            if (!savedHandle) {
                updatePathDisplay('未设置下载路径', 'warning');
                return null;
            }

            const permission = await savedHandle.queryPermission({
                mode: 'readwrite'
            });

            rootDirHandle = savedHandle;
            currentFolderName = savedHandle.name || '已保存目录';

            if (permission === 'granted') {
                updatePathDisplay(`📂 ${currentFolderName}`, 'success');
                return savedHandle;
            }

            updatePathDisplay(`📂 ${currentFolderName},需重新授权`, 'warning');
            return savedHandle;
        } catch (err) {
            errorLog('读取下载路径失败', err);
            updatePathDisplay('下载路径读取失败,请重新设置', 'error');
            return null;
        }
    }

    async function getRootDirectory() {
        if (rootDirHandle) {
            const permission = await rootDirHandle.queryPermission({
                mode: 'readwrite'
            });

            if (permission === 'granted') {
                return rootDirHandle;
            }

            if (permission === 'prompt') {
                const newPermission = await rootDirHandle.requestPermission({
                    mode: 'readwrite'
                });

                if (newPermission === 'granted') {
                    updatePathDisplay(`📂 ${rootDirHandle.name || '已授权目录'}`, 'success');
                    return rootDirHandle;
                }
            }
        }

        const savedHandle = await restoreRootDirectory();

        if (savedHandle) {
            const permission = await savedHandle.queryPermission({
                mode: 'readwrite'
            });

            if (permission === 'granted') {
                rootDirHandle = savedHandle;
                return rootDirHandle;
            }

            if (permission === 'prompt') {
                const newPermission = await savedHandle.requestPermission({
                    mode: 'readwrite'
                });

                if (newPermission === 'granted') {
                    rootDirHandle = savedHandle;
                    updatePathDisplay(`📂 ${savedHandle.name || '已授权目录'}`, 'success');
                    return rootDirHandle;
                }
            }
        }

        setStatus('⚠️ 请先设置下载路径', 'warning');
        return await chooseRootDirectory();
    }

    async function downloadMediaToFolder(sku, productName, mediaList, onProgress) {
        if (!productName) {
            throw new Error('商品名不能为空');
        }

        if (!mediaList || mediaList.length === 0) {
            throw new Error('未找到媒体文件');
        }

        const rootDir = await getRootDirectory();

        const safeProductName = sanitizeFileName(productName);
        const randomSuffix = generateRandomSuffix();

        let folderName;

        if (sku && sku.trim() !== '') {
            const safeSku = sanitizeFileName(sku);
            folderName = `${safeSku}----${safeProductName}${randomSuffix}`;
        } else {
            folderName = `${safeProductName}${randomSuffix}`;
        }

        let targetDir;

        try {
            targetDir = await rootDir.getDirectoryHandle(folderName, {
                create: true
            });
        } catch (err) {
            throw new Error(`创建文件夹失败: ${err.message}`);
        }

        try {
            const titleFile = await targetDir.getFileHandle('skutitle.txt', {
                create: true
            });

            const writable = await titleFile.createWritable();
            await writable.write(productName);
            await writable.close();
        } catch (err) {
            errorLog('创建说明文件失败', err);
        }

        let successCount = 0;
        let failCount = 0;
        let skipSmallCount = 0;
        let index = 0;

        for (const media of mediaList) {
            index++;

            if (onProgress) {
                onProgress(index, mediaList.length, successCount, failCount);
            }

            try {
                if (media.type === 'video') {
                    const videoBlob = await fetchMediaWithRetry(media.url);

                    const fileName = `${String(index).padStart(3, '0')}_video_${media.quality || 'default'}.mp4`;
                    const fileHandle = await targetDir.getFileHandle(fileName, {
                        create: true
                    });

                    const writable = await fileHandle.createWritable();
                    await writable.write(videoBlob);
                    await writable.close();

                    successCount++;

                    if (media.coverUrl) {
                        let coverBlob;

                        try {
                            coverBlob = await fetchMediaWithRetry(media.coverUrl);
                        } catch (err) {
                            failCount++;
                            continue;
                        }

                        const coverSizeKB = coverBlob.size / 1024;

                        if (coverSizeKB < CONFIG.minImageSizeKB) {
                            skipSmallCount++;
                            continue;
                        }

                        const ext = getFileExtension(media.coverUrl, 'image');
                        const coverFileName = `${String(index).padStart(3, '0')}_video_cover.${ext}`;

                        const coverHandle = await targetDir.getFileHandle(coverFileName, {
                            create: true
                        });

                        const coverWritable = await coverHandle.createWritable();
                        await coverWritable.write(coverBlob);
                        await coverWritable.close();

                        successCount++;
                    }
                } else if (media.type === 'image') {
                    let imageBlob;

                    try {
                        imageBlob = await fetchMediaWithRetry(media.url);
                    } catch (err) {
                        failCount++;
                        continue;
                    }

                    const imgSizeKB = imageBlob.size / 1024;

                    if (imgSizeKB < CONFIG.minImageSizeKB) {
                        skipSmallCount++;
                        continue;
                    }

                    const ext = getFileExtension(media.url, 'image');
                    const randomStr = Math.random().toString(36).substring(2, 8);
                    const fileName = `${String(index).padStart(3, '0')}_${randomStr}.${ext}`;

                    const fileHandle = await targetDir.getFileHandle(fileName, {
                        create: true
                    });

                    const writable = await fileHandle.createWritable();
                    await writable.write(imageBlob);
                    await writable.close();

                    successCount++;
                }

                await new Promise(r => setTimeout(r, CONFIG.downloadDelayMs));
            } catch (err) {
                errorLog(`第 ${index} 个文件失败`, err);
                failCount++;
            }
        }

        return {
            successCount,
            failCount,
            skipSmallCount,
            folderName
        };
    }
    async function startDownload(sku, productName) {
        if (isDownloading) {
            setStatus('⚠️ 正在下载中,请稍候...', 'warning');
            return;
        }

        isDownloading = true;
        setStatus('🔍 正在提取媒体文件...', 'normal');

        await new Promise(r => setTimeout(r, CONFIG.loadWaitMs));

        try {
            const mediaList = getFullMediaList();

            if (mediaList.length === 0) {
                throw new Error('未找到视频或图片,请确认当前页面是笔记详情页,并等待加载完成');
            }

            const hasVideo = mediaList.some(m => m.type === 'video');

            let effectiveSku = sku;

            if (hasVideo) {
                effectiveSku = '';
                log('检测到视频笔记,忽略 SKU,仅用商品名作为文件夹名');
            } else if (!effectiveSku || effectiveSku.trim() === '') {
                throw new Error('纯图片笔记需要填写 SKU 编码');
            }

            setStatus(`📦 找到 ${mediaList.length} 个文件,准备下载...`, 'normal');

            const result = await downloadMediaToFolder(
                effectiveSku,
                productName,
                mediaList,
                (cur, total, succ, fail) => {
                    setStatus(`⬇️ ${cur}/${total} | 成功:${succ} | 失败:${fail}`, 'normal');
                }
            );

            let msg = `✅ 下载完成:成功 ${result.successCount} 个,失败 ${result.failCount} 个`;

            if (result.skipSmallCount > 0) {
                msg += `,过滤小图 ${result.skipSmallCount} 个`;
            }

            msg += ` | 文件夹:${result.folderName}`;

            setStatus(msg, 'success');
        } catch (err) {
            errorLog('下载失败', err);
            setStatus(`❌ 下载失败:${err.message}`, 'error');
        } finally {
            isDownloading = false;
        }
    }

    function parseClipboardContent(text) {
        if (!text) return null;

        const trimmed = text.trim();
        const match = trimmed.match(/^([^\n-]+?)----([^\n]+)$/);

        if (match && match[1] && match[2]) {
            return {
                sku: match[1].trim(),
                productName: match[2].trim()
            };
        }

        return null;
    }

    async function readClipboard() {
        try {
            if (navigator.clipboard && navigator.clipboard.readText) {
                return await navigator.clipboard.readText();
            }
        } catch (err) {}

        return null;
    }

    function startClipboardMonitor() {
        if (clipboardTimer) clearInterval(clipboardTimer);

        clipboardTimer = setInterval(async () => {
            if (isDownloading) return;

            const text = await readClipboard();

            if (!text || text === lastProcessedClipboard) return;

            const parsed = parseClipboardContent(text);

            if (parsed) {
                lastProcessedClipboard = text;

                if (skuInput) skuInput.value = parsed.sku;
                if (productInput) productInput.value = parsed.productName;

                setStatus(`📋 剪贴板触发:${parsed.sku} / ${parsed.productName}`, 'normal');

                await startDownload(parsed.sku, parsed.productName);
            }
        }, CONFIG.clipboardPollIntervalMs);
    }

    function createPanel() {
        if (panel) return;

        GM_addStyle(`
            #xhs-download-panel {
                position: fixed;
                left: ${CONFIG.panelLeft};
                top: ${CONFIG.panelTop};
                width: ${CONFIG.panelWidth};
                z-index: 99999999;
                padding: 0;
                border-radius: 18px;
                overflow: hidden;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
                background: rgba(255, 255, 255, 0.96);
                box-shadow:
                    0 18px 50px rgba(15, 23, 42, 0.18),
                    0 2px 8px rgba(15, 23, 42, 0.08);
                border: 1px solid rgba(255, 255, 255, 0.75);
                backdrop-filter: blur(16px);
                color: #111827;
            }

            #xhs-download-panel * {
                box-sizing: border-box;
            }

            #xhs-download-panel .panel-top {
                padding: 16px 16px 14px;
                background:
                    radial-gradient(circle at 20% 20%, rgba(255, 36, 66, 0.18), transparent 32%),
                    linear-gradient(135deg, #ff2442 0%, #ff6b86 100%);
                color: #fff;
            }

            #xhs-download-panel .panel-header {
                display: flex;
                align-items: center;
                justify-content: space-between;
                gap: 10px;
            }

            #xhs-download-panel .panel-title {
                display: flex;
                flex-direction: column;
                gap: 2px;
            }

            #xhs-download-panel .panel-title-main {
                font-size: 16px;
                font-weight: 800;
                letter-spacing: 0.2px;
            }

            #xhs-download-panel .panel-title-sub {
                font-size: 12px;
                opacity: 0.86;
            }

            #xhs-download-panel .close-btn {
                width: 28px;
                height: 28px;
                display: inline-flex;
                align-items: center;
                justify-content: center;
                border-radius: 999px;
                cursor: pointer;
                font-size: 20px;
                line-height: 1;
                color: rgba(255,255,255,0.9);
                background: rgba(255,255,255,0.16);
                transition: all .2s ease;
            }

            #xhs-download-panel .close-btn:hover {
                background: rgba(255,255,255,0.28);
                transform: rotate(90deg);
            }

            #xhs-download-panel .panel-body {
                padding: 16px;
            }

            #xhs-download-panel .input-group {
                margin-bottom: 13px;
            }

            #xhs-download-panel .input-group label {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 6px;
                color: #374151;
                font-size: 13px;
                font-weight: 700;
            }

            #xhs-download-panel .input-hint {
                color: #9ca3af;
                font-size: 11px;
                font-weight: 500;
            }

            #xhs-download-panel .input-group input {
                width: 100%;
                height: 38px;
                padding: 8px 11px;
                border: 1px solid #e5e7eb;
                border-radius: 10px;
                outline: none;
                background: #f9fafb;
                color: #111827;
                font-size: 13px;
                transition: all .2s ease;
            }

            #xhs-download-panel .input-group input:focus {
                border-color: #ff2442;
                background: #fff;
                box-shadow: 0 0 0 4px rgba(255,36,66,0.10);
            }

            #xhs-download-panel .path-card {
                margin: 4px 0 14px;
                padding: 12px;
                border-radius: 14px;
                background: #f9fafb;
                border: 1px solid #eef2f7;
            }

            #xhs-download-panel .path-card-title {
                display: flex;
                justify-content: space-between;
                align-items: center;
                margin-bottom: 8px;
                font-size: 13px;
                font-weight: 800;
                color: #374151;
            }

            #xhs-download-panel .path-display {
                min-height: 32px;
                display: flex;
                align-items: center;
                padding: 8px 10px;
                margin-bottom: 10px;
                border-radius: 10px;
                font-size: 12px;
                line-height: 1.35;
                word-break: break-all;
                background: #fff;
                border: 1px dashed #d1d5db;
                color: #6b7280;
            }

            #xhs-download-panel .path-success {
                color: #047857;
                border-color: #a7f3d0;
                background: #ecfdf5;
            }

            #xhs-download-panel .path-warning {
                color: #92400e;
                border-color: #fde68a;
                background: #fffbeb;
            }

            #xhs-download-panel .path-error {
                color: #b91c1c;
                border-color: #fecaca;
                background: #fef2f2;
            }

            #xhs-download-panel .path-btn {
                width: 100%;
                height: 34px;
                border: none;
                border-radius: 10px;
                cursor: pointer;
                font-size: 13px;
                font-weight: 800;
                color: #374151;
                background: #e5e7eb;
                transition: all .2s ease;
            }

            #xhs-download-panel .path-btn:hover {
                background: #d1d5db;
                transform: translateY(-1px);
            }

            #xhs-download-panel .main-btn {
                width: 100%;
                height: 42px;
                margin-bottom: 9px;
                border: none;
                border-radius: 12px;
                cursor: pointer;
                font-size: 14px;
                font-weight: 900;
                color: #fff;
                background: linear-gradient(135deg, #ff2442, #ff5d73);
                box-shadow: 0 10px 20px rgba(255, 36, 66, 0.24);
                transition: all .2s ease;
            }

            #xhs-download-panel .main-btn:hover {
                transform: translateY(-1px);
                box-shadow: 0 14px 26px rgba(255, 36, 66, 0.28);
            }

            #xhs-download-panel .sub-btn-row {
                display: grid;
                grid-template-columns: 1fr 1fr;
                gap: 8px;
                margin-bottom: 12px;
            }

            #xhs-download-panel .sub-btn {
                height: 34px;
                border: none;
                border-radius: 10px;
                cursor: pointer;
                color: #4b5563;
                background: #f3f4f6;
                font-weight: 800;
                font-size: 12px;
                transition: all .2s ease;
            }

            #xhs-download-panel .sub-btn:hover {
                background: #e5e7eb;
            }

            #xhs-download-panel .status-box {
                padding: 10px 11px;
                border-radius: 12px;
                background: #f9fafb;
                border: 1px solid #eef2f7;
            }

            #xhs-download-panel .status-label {
                margin-bottom: 5px;
                font-size: 12px;
                font-weight: 800;
                color: #6b7280;
            }

            #xhs-download-panel .status-text {
                font-size: 12px;
                line-height: 1.45;
                word-break: break-all;
                color: #4b5563;
            }

            #xhs-download-panel .status-success {
                color: #047857;
            }

            #xhs-download-panel .status-warning {
                color: #b45309;
            }

            #xhs-download-panel .status-error {
                color: #dc2626;
            }

            @media (prefers-color-scheme: dark) {
                #xhs-download-panel {
                    background: rgba(17, 24, 39, 0.94);
                    color: #f9fafb;
                    border-color: rgba(255,255,255,0.08);
                }

                #xhs-download-panel .panel-body,
                #xhs-download-panel .path-card,
                #xhs-download-panel .status-box {
                    background: rgba(31, 41, 55, 0.72);
                    border-color: rgba(255,255,255,0.08);
                }

                #xhs-download-panel .input-group label,
                #xhs-download-panel .path-card-title {
                    color: #e5e7eb;
                }

                #xhs-download-panel .input-group input,
                #xhs-download-panel .path-display {
                    background: rgba(17, 24, 39, 0.85);
                    border-color: rgba(255,255,255,0.1);
                    color: #f9fafb;
                }

                #xhs-download-panel .sub-btn,
                #xhs-download-panel .path-btn {
                    background: rgba(75,85,99,0.7);
                    color: #f9fafb;
                }
            }
        `);

        panel = document.createElement('div');
        panel.id = 'xhs-download-panel';

        panel.innerHTML = `
            <div class="panel-top">
                <div class="panel-header">
                    <div class="panel-title">
                        <div class="panel-title-main">📥 小红书无水印下载</div>
                        <div class="panel-title-sub">路径可设置 · 视频 / 封面 / 原图</div>
                    </div>
                    <span class="close-btn" id="panel-close-btn">&times;</span>
                </div>
            </div>

            <div class="panel-body">
                <div class="input-group">
                    <label>
                        SKU 编码
                        <span class="input-hint">视频可留空</span>
                    </label>
                    <input type="text" id="xhs-sku-input" placeholder="例如:A001">
                </div>

                <div class="input-group">
                    <label>
                        商品名称
                        <span class="input-hint">自动读取标题</span>
                    </label>
                    <input type="text" id="xhs-product-input" placeholder="自动填充页面标题">
                </div>

                <div class="path-card">
                    <div class="path-card-title">
                        <span>下载路径</span>
                    </div>
                    <div class="path-display path-warning" id="xhs-path-display">未设置下载路径</div>
                    <button class="path-btn" id="xhs-choose-path-btn">📂 设置 / 修改下载路径</button>
                </div>

                <button class="main-btn" id="download-btn">🚀 开始下载</button>

                <div class="sub-btn-row">
                    <button class="sub-btn" id="debug-btn">🔍 调试</button>
                    <button class="sub-btn" id="refill-title-btn">📄 读取标题</button>
                </div>

                <div class="status-box">
                    <div class="status-label">状态</div>
                    <div class="status-text" id="status-text">就绪</div>
                </div>
            </div>
        `;

        document.body.appendChild(panel);

        skuInput = document.getElementById('xhs-sku-input');
        productInput = document.getElementById('xhs-product-input');
        statusDiv = document.getElementById('status-text');
        pathDiv = document.getElementById('xhs-path-display');
        choosePathBtn = document.getElementById('xhs-choose-path-btn');

        const closeBtn = document.getElementById('panel-close-btn');
        const downloadBtn = document.getElementById('download-btn');
        const debugBtn = document.getElementById('debug-btn');
        const refillTitleBtn = document.getElementById('refill-title-btn');

        closeBtn.onclick = () => {
            panel.style.display = 'none';
            setStatus('面板已隐藏,可从油猴菜单重新显示', 'normal');
        };

        choosePathBtn.onclick = async () => {
            try {
                await chooseRootDirectory();
            } catch (err) {
                errorLog('设置路径失败', err);
                setStatus(`❌ 设置路径失败:${err.message}`, 'error');
            }
        };

        downloadBtn.onclick = async () => {
            let sku = skuInput.value.trim();
            let product = productInput.value.trim();

            if (!product) {
                const title = getPageTitle();

                if (title) {
                    product = title;
                    productInput.value = title;
                    setStatus(`📄 自动填充标题:${title.substring(0, 40)}${title.length > 40 ? '…' : ''}`, 'success');
                }
            }

            if (!product) {
                setStatus('⚠️ 请填写商品名称', 'warning');
                return;
            }

            await startDownload(sku, product);
        };

        debugBtn.onclick = () => {
            setStatus('🔍 调试信息已输出到控制台', 'normal');
            console.log('【调试信息】当前页面媒体列表', getFullMediaList());
            console.log('【调试信息】当前页面笔记数据', getNoteData());
            console.log('【调试信息】当前保存路径', rootDirHandle);
        };

        refillTitleBtn.onclick = () => {
            const title = getPageTitle();

            if (title) {
                productInput.value = title;
                lastPageTitle = title;
                setStatus(`📄 已读取标题:${title.substring(0, 40)}${title.length > 40 ? '…' : ''}`, 'success');
            } else {
                setStatus('⚠️ 当前页面未读取到标题', 'warning');
            }
        };

        skuInput.addEventListener('keydown', e => {
            if (e.key === 'Enter') {
                productInput.focus();
            }
        });

        productInput.addEventListener('keydown', async e => {
            if (e.key === 'Enter') {
                let sku = skuInput.value.trim();
                let product = productInput.value.trim();

                if (!product) {
                    const title = getPageTitle();

                    if (title) {
                        product = title;
                        productInput.value = title;
                    }
                }

                if (product) {
                    await startDownload(sku, product);
                }
            }
        });

        autoFillProductFromTitle();

        restoreRootDirectory();

        GM_registerMenuCommand('显示下载面板', () => {
            if (panel) panel.style.display = 'block';
            setStatus('面板已显示', 'normal');
        });

        GM_registerMenuCommand('设置下载路径', async () => {
            try {
                await chooseRootDirectory();
            } catch (err) {
                setStatus(`❌ 设置路径失败:${err.message}`, 'error');
            }
        });

        GM_registerMenuCommand('重新初始化', init);
    }

    function isSupportedPage() {
        const url = window.location.href;

        return (
            /\/note\/[a-f0-9]{24}/i.test(url) ||
            /\/discovery\/item\//.test(url) ||
            /\/explore\/[a-f0-9]{24}/i.test(url) ||
            /\/search_result/.test(url) ||
            /\/user\/profile/.test(url) ||
            /\/creator\/content\/note/.test(url)
        );
    }

    function init() {
        if (isSupportedPage()) {
            createPanel();
            startClipboardMonitor();
            startTitleMonitor();

            restoreRootDirectory().catch(err => {
                errorLog('恢复下载路径失败', err);
            });
        } else {
            if (panel) {
                panel.remove();
            }

            panel = null;

            if (clipboardTimer) {
                clearInterval(clipboardTimer);
            }

            clipboardTimer = null;

            stopTitleMonitor();
        }
    }

    let lastUrl = location.href;

    routeObserver = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            setTimeout(init, 800);
        }
    });

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

    window.addEventListener('DOMContentLoaded', init);
    window.addEventListener('load', init);

    window.addEventListener('beforeunload', () => {
        stopTitleMonitor();

        if (clipboardTimer) {
            clearInterval(clipboardTimer);
        }

        if (routeObserver) {
            routeObserver.disconnect();
        }
    });

    setTimeout(init, 500);
    setTimeout(init, 2000);
})();


发表评论:

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