小红书视频插件

小红书视频插件

// ==UserScript==
// @name         小红书无水印下载【2026.5标题实时监控修复版】
// @namespace    http://tampermonkey.net/
// @version      2026.5.4
// @description  修复提取失效问题,适配小红书最新结构,新增标题实时监控自动同步;无水印视频/封面/原图一键下载;视频笔记自动以商品名作为文件夹名;小于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',
        downloadDelayMs: 500,
        clipboardPollIntervalMs: 800,
        loadWaitMs: 1500,
        qualityPriority: ['4K', '2K', '1080P', '720P', '480P', '360P', '流畅'],
        retryTimes: 3,
        debugMode: true,
        minImageSizeKB: 20,                 // 小于该值的图片不下载
        titleChangeDebounceMs: 300,         // 标题变化防抖间隔
        titleForceOverride: true,            // 标题变化时是否强制覆盖输入框内容,false=仅空值填充,true=强制覆盖
    };

    // ========== 全局变量 ==========
    let rootDirHandle = null;
    let isDownloading = false;
    let lastProcessedClipboard = '';
    let panel = null;
    let skuInput = null;
    let productInput = null;
    let statusDiv = 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 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 autoFillProductFromTitle() {
        if (productInput && !productInput.value.trim()) {
            const pageTitle = getPageTitle();
            if (pageTitle) {
                productInput.value = pageTitle;
                lastPageTitle = pageTitle;
                log('自动填充页面标题:', pageTitle);
                updateStatus(`📄 自动使用标题: ${pageTitle.substring(0, 40)}${pageTitle.length > 40 ? '…' : ''}`);
            }
        }
    }

    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);
            updateStatus(`📄 标题已更新,自动填充: ${currentTitle.substring(0, 40)}${currentTitle.length > 40 ? '…' : ''}`);
        } else {
            log('检测到标题变化,输入框已有内容,未执行覆盖', { 原标题: lastPageTitle, 新标题: currentTitle });
        }
    }

    // 启动标题实时监控
    function startTitleMonitor() {
        // 先销毁已存在的监控,避免重复创建
        stopTitleMonitor();

        // 初始化标题缓存
        lastPageTitle = getPageTitle();

        const titleElement = document.querySelector('head title');
        if (!titleElement) {
            log('未找到title元素,降级为轮询监控');
            // 降级方案:轮询检测标题变化
            titleObserver = setInterval(handleTitleChange, CONFIG.titleChangeDebounceMs);
            return;
        }

        // 创建MutationObserver监控title元素变化
        titleObserver = new MutationObserver(() => {
            // 防抖处理,避免频繁触发
            clearTimeout(window.titleChangeTimeout);
            window.titleChangeTimeout = setTimeout(handleTitleChange, CONFIG.titleChangeDebounceMs);
        });

        // 监控title元素的文本、子节点、属性全维度变化
        titleObserver.observe(titleElement, {
            childList: true,
            subtree: true,
            characterData: true,
            attributes: true
        });

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

    // 停止标题监控
    function stopTitleMonitor() {
        if (titleObserver) {
            // 区分MutationObserver和轮询定时器的销毁
            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();
            // 🎯 只从接口数据里拿图片,不碰页面DOM里的杂图
            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 {
            // 🎯 图片笔记:只加 noteData 里的核心图片
            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); }
            else 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';
    }

    // ========== IndexedDB 存储 ==========
    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 = () => 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 getRootDirectory() {
        if (rootDirHandle) { const permission = await rootDirHandle.queryPermission({ mode: 'readwrite' }); if (permission === 'granted') return rootDirHandle; }
        const savedHandle = await loadRootHandle();
        if (savedHandle) {
            const permission = await savedHandle.queryPermission({ mode: 'readwrite' });
            if (permission === 'granted') { rootDirHandle = savedHandle; return rootDirHandle; }
            else if (permission === 'prompt') { await savedHandle.requestPermission({ mode: 'readwrite' }); const newPermission = await savedHandle.queryPermission({ mode: 'readwrite' }); if (newPermission === 'granted') { rootDirHandle = savedHandle; return rootDirHandle; } }
        }
        if (!window.showDirectoryPicker) throw new Error('浏览器不支持,请使用Chrome/Edge最新版');
        const newHandle = await window.showDirectoryPicker({ mode: 'readwrite', startIn: 'downloads' });
        await saveRootHandle(newHandle); rootDirHandle = newHandle; updateStatus('✅ 根目录选择成功'); return rootDirHandle;
    }

    // ========== 批量下载逻辑 ==========
    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 = productName.replace(/[\\/:*?"<>|]/g, '_').trim();
        const randomSuffix = generateRandomSuffix();

        // 文件夹命名:有SKU则加前缀,视频场景SKU为空,仅用商品名
        let folderName;
        if (sku && sku.trim() !== '') {
            const safeSku = sku.replace(/[\\/:*?"<>|]/g, '_').trim();
            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}`); }

        // 创建 skutitle.txt
        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, failCount = 0, skipSmallCount = 0, 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) { updateStatus('⚠️ 正在下载中...'); return; }
        isDownloading = true;
        updateStatus('🔍 提取媒体文件...');
        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');
            // 视频场景:忽略SKU,仅用商品名作为文件夹名;纯图片场景:校验SKU
            let effectiveSku = sku;
            if (hasVideo) {
                effectiveSku = '';
                log('检测到视频,忽略SKU,仅用商品名作为文件夹名');
            } else if (!effectiveSku || effectiveSku.trim() === '') {
                throw new Error('纯图片笔记需要填写SKU编码');
            }

            updateStatus(`📦 找到${mediaList.length}个文件 (${hasVideo ? '含视频' : '纯图片'}),准备下载...`);
            const result = await downloadMediaToFolder(effectiveSku, productName, mediaList, (cur, total, succ, fail) => {
                updateStatus(`⬇️ ${cur}/${total} | 成功:${succ} | 失败:${fail}`);
            });

            let msg = `✅ 完成!成功${result.successCount}个,失败${result.failCount}个`;
            if (result.skipSmallCount > 0) msg += `,过滤小图片${result.skipSmallCount}个(<${CONFIG.minImageSizeKB}KB)`;
            msg += ` | 文件夹: ${result.folderName}`;
            updateStatus(msg);
            setTimeout(() => { if (!isDownloading) updateStatus('就绪'); }, 8000);
        } catch (err) {
            errorLog('下载失败', err);
            updateStatus(`❌ 失败: ${err.message}`);
        } 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;
                updateStatus(`📋 剪贴板触发: ${parsed.sku} / ${parsed.productName}`);
                await startDownload(parsed.sku, parsed.productName);
            }
        }, CONFIG.clipboardPollIntervalMs);
    }

    // ========== UI面板 ==========
    function updateStatus(text) { if (statusDiv) statusDiv.textContent = text; log('状态:', text); }

    function createPanel() {
        if (panel) return;
        GM_addStyle(`
            #xhs-download-panel {
                position: fixed; left: ${CONFIG.panelLeft}; top: ${CONFIG.panelTop}; width: 280px; background: #fff;
                border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.15); padding: 16px; z-index: 99999999;
                font-family: system-ui, sans-serif; font-size: 14px; border: 1px solid #eee;
            }
            #xhs-download-panel .panel-header { display: flex; justify-content: space-between; margin-bottom: 16px; }
            #xhs-download-panel .panel-title { font-weight: bold; color: #ff2442; display: flex; align-items: center; gap: 4px; }
            #xhs-download-panel .close-btn { cursor: pointer; font-size: 20px; color: #999; }
            #xhs-download-panel .close-btn:hover { color: #ff2442; }
            #xhs-download-panel .input-group { margin-bottom: 12px; }
            #xhs-download-panel .input-group label { display: block; margin-bottom: 4px; font-weight: 500; }
            #xhs-download-panel .input-group input { width: 100%; padding: 8px 10px; border: 1px solid #ddd; border-radius: 6px; box-sizing: border-box; }
            #xhs-download-panel .input-group input:focus { border-color: #ff2442; outline: none; }
            #xhs-download-panel .btn-group { display: flex; gap: 8px; margin-bottom: 8px; }
            #xhs-download-panel .download-btn { flex: 1; background: #ff2442; color: #fff; border: none; padding: 10px; border-radius: 6px; font-weight: bold; cursor: pointer; }
            #xhs-download-panel .download-btn:hover { background: #e01e37; }
            #xhs-download-panel .debug-btn { width: 30%; background: #666; color: #fff; border: none; border-radius: 6px; cursor: pointer; }
            #xhs-download-panel .debug-btn:hover { background: #444; }
            #xhs-download-panel .status-text { margin-top: 12px; font-size: 12px; color: #666; word-break: break-all; line-height: 1.4; }
        `);
        panel = document.createElement('div'); panel.id = 'xhs-download-panel';
        panel.innerHTML = `
            <div class="panel-header"><span class="panel-title">📥 小红书无水印下载</span><span class="close-btn" id="panel-close-btn">&times;</span></div>
            <div class="input-group"><label>SKU编码</label><input type="text" id="xhs-sku-input" placeholder="视频笔记可留空"></div>
            <div class="input-group"><label>商品名称</label><input type="text" id="xhs-product-input" placeholder="自动填充页面标题"></div>
            <div class="btn-group"><button class="download-btn" id="download-btn">开始下载</button><button class="debug-btn" id="debug-btn">调试</button></div>
            <div class="status-text" id="status-text">就绪</div>
        `;
        document.body.appendChild(panel);
        skuInput = document.getElementById('xhs-sku-input');
        productInput = document.getElementById('xhs-product-input');
        statusDiv = document.getElementById('status-text');
        const closeBtn = document.getElementById('panel-close-btn');
        const downloadBtn = document.getElementById('download-btn');
        const debugBtn = document.getElementById('debug-btn');

        closeBtn.onclick = () => { panel.style.display = 'none'; updateStatus('面板已隐藏,可从油猴菜单重新显示'); };
        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; updateStatus(`📄 自动填充标题: ${title.substring(0, 40)}...`); } }
            if (!product) { updateStatus('⚠️ 请填写商品名称'); return; }
            await startDownload(sku, product);
        };
        debugBtn.onclick = () => { updateStatus('🔍 调试信息已输出至控制台'); console.log('【调试信息】当前页面媒体列表', getFullMediaList()); console.log('【调试信息】当前页面笔记数据', getNoteData()); };
        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();
        GM_registerMenuCommand('显示下载面板', () => { if (panel) panel.style.display = 'block'; updateStatus('面板已显示'); });
        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();
            getRootDirectory().catch(e => log('目录权限预加载失败', e));
        }
        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);
})();

54.jpg

评论

我要评论

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