Greasy Fork is available in English.
小红书图片和视频下载器。采用底层网络请求拦截技术,100% 抓取纯视频和 Live 图高清源文件;加入UI轮询保活机制;新增下载状态记忆功能,下载后按钮变为绿色打勾,二次进入不迷路。
// ==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)); }
})();