// ==UserScript== // @name 小红书无水印下载 // @namespace https://github.com/meme-apps-2026/xhs-downloader // @version 0.1.8 // @description 一键下载小红书无水印原图/视频,右下角悬浮,直接下载原图 // @author meme // @match https://www.xiaohongshu.com/* // @match https://www.rednote.com/* // @icon https://www.xiaohongshu.com/favicon.ico // @grant GM_download // @grant GM_addStyle // @grant GM_xmlhttpRequest // @grant unsafeWindow // @grant GM_notification // @connect xhscdn.com // @connect *.xhscdn.com // @connect sns-img-bd.xhscdn.com // @connect sns-img-qc.xhscdn.com // @connect sns-webpic-qc.xhscdn.com // @connect sns-video-bd.xhscdn.com // @connect xiaohongshu.com // @connect *.xiaohongshu.com // @run-at document-idle // @license MIT // ==/UserScript== (function () { 'use strict'; const CSS = ` .xhs-dl-root{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif} .xhs-dl-float{position:fixed;right:20px;bottom:88px;z-index:999999;display:flex;flex-direction:column;align-items:flex-end;gap:10px} .xhs-dl-btn{display:inline-flex;align-items:center;gap:7px;padding:9px 14px;background:#fff;color:#111;border:1px solid #e5e5e5;border-radius:999px;font-size:13px;font-weight:600;cursor:pointer;box-shadow:0 4px 16px rgba(0,0,0,.12);transition:all .15s} .xhs-dl-btn:hover{background:#111;color:#fff;border-color:#111} .xhs-dl-btn svg{width:16px;height:16px} .xhs-dl-card{width:340px;background:#fff;border-radius:16px;box-shadow:0 16px 40px rgba(0,0,0,.18);overflow:hidden;border:1px solid #eee} .xhs-dl-card-head{padding:12px 14px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid #f0f0f0} .xhs-dl-card-head strong{font-size:13px} .xhs-dl-card-head span{font-size:12px;color:#888;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .xhs-dl-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:6px;padding:10px;max-height:320px;overflow:auto} .xhs-dl-thumb{position:relative;aspect-ratio:1;border-radius:10px;overflow:hidden;background:#f5f5f5;cursor:pointer;border:1px solid #eee} .xhs-dl-thumb img{width:100%;height:100%;object-fit:cover;display:block} .xhs-dl-thumb i{position:absolute;right:6px;bottom:6px;width:22px;height:22px;border-radius:999px;background:rgba(0,0,0,.6);color:#fff;display:grid;place-items:center} .xhs-dl-foot{padding:10px;display:flex;gap:8px;border-top:1px solid #f0f0f0} .xhs-dl-foot button{flex:1;padding:10px;border-radius:10px;border:1px solid #eee;background:#fff;font-size:13px;font-weight:600;cursor:pointer} .xhs-dl-foot button.primary{background:#111;color:#fff;border-color:#111} .xhs-dl-toast{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);background:#111;color:#fff;padding:10px 16px;border-radius:999px;font-size:13px;z-index:999999} .xhs-dl-log{padding:8px 14px;font-size:12px;color:#666;background:#fafafa;border-top:1px solid #f0f0f0;max-height:80px;overflow:auto} `; if (typeof GM_addStyle !== 'undefined') GM_addStyle(CSS); else { const s = document.createElement('style'); s.textContent = CSS; document.documentElement.appendChild(s); } const $ = (s, r = document) => r.querySelector(s); function extractCurrentNoteId() { const m = location.href.match(/\/(?:explore|discovery\/item)\/([^/?#]+)/); return m ? m[1] : ""; } function safeExtract(obj, path, def = undefined) { try { return path.split('.').reduce((o, k) => o?.[k], obj) ?? def; } catch { return def; } } function extractNoteInfo() { const w = (typeof unsafeWindow !== 'undefined' && unsafeWindow.__INITIAL_STATE__) ? unsafeWindow : window; const st = w.__INITIAL_STATE__; if (!st) return null; const d1 = safeExtract(st, 'noteData.data.noteData'); if (d1 && (d1.imageList || d1.video)) return d1; const noteId = extractCurrentNoteId(); const map = safeExtract(st, 'note.noteDetailMap'); if (map) { if (noteId && map[noteId]?.note) return map[noteId].note; const vals = Object.values(map); if (vals.length) { const hit = vals.find(v => v.note?.noteId === noteId || v.note?.id === noteId); if (hit?.note) return hit.note; if (vals[vals.length - 1]?.note) return vals[vals.length - 1].note; } } const d3 = safeExtract(st, 'note.data'); if (d3 && (d3.imageList || d3.video)) return d3; return null; } // 统一 token 提取,兼容所有域名 function tokenFromUrl(url) { if (!url) return ""; if (url.includes("xhscdn.com/")) return url.split("xhscdn.com/")[1].split("!")[0].split("?")[0].replace(/^\/+/, ""); if (url.includes("ci.xiaohongshu.com/")) return url.split("ci.xiaohongshu.com/")[1].split("?")[0].replace(/^\/+/, ""); return url.split("!")[0].split("?")[0]; } function generateImageUrls(note) { const list = note.imageList || []; const out = []; for (const item of list) { const raw = item.urlDefault || item.url || item.urlPre || ""; const token = tokenFromUrl(raw); if (!token) continue; // 预览与下载都用 sns-img-bd,预览加 referrer 绕过,下载用 GM_xhr 也走同一链接 // 若 token 已含 1042g008/xxx 形态,直接拼 out.push(`https://sns-img-bd.xhscdn.com/${token}`); } return out; } function generateDisplayUrls(note) { // 预览用原始缩略图,确保一定能显示(即使高清链接被拦) const list = note.imageList || []; return list.map(it => (it.urlDefault || it.url || "").replace(/^http:/, "https:")); } function generateVideoUrl(note) { try { const key = note.video?.consumer?.originVideoKey; if (key) return [`https://sns-video-bd.xhscdn.com/${key}`]; const streams = note.video?.media?.stream; if (streams) { const all = Object.values(streams).flat(); if (all.length) { all.sort((a, b) => (b.height || 0) - (a.height || 0)); const best = all[0]; return [best.backupUrls?.[0] || best.masterUrl].filter(Boolean); } } } catch {} return []; } function showToast(msg, ms = 2500) { let t = $('.xhs-dl-toast'); if (t) t.remove(); t = document.createElement('div'); t.className = 'xhs-dl-toast'; t.textContent = msg; document.body.appendChild(t); setTimeout(() => t.remove(), ms); } function downloadUrl(url, filename) { // blob: 链接必须走原生 a.click,GM_download 在 ScriptCat 下对 blob 失效 if (url.startsWith('blob:')) { const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove(); return; } if (typeof GM_download !== 'undefined') { try { GM_download({ url, name: filename, onload: () => showToast('下载完成: ' + filename), onerror: (e) => { throw e; } }); return; } catch (e) {} } const a = document.createElement('a'); a.href = url; a.download = filename; a.target = '_blank'; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); } function downloadBlob(blob, filename) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 3000); } // 用 GM_xmlhttpRequest 绕 CORS,带 referer function fetchBlobViaGM(url) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'undefined') { GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 15000, headers: { 'Referer': 'https://www.xiaohongshu.com/', 'Accept': 'image/*,*/*' }, onload: (r) => { if (r.status >= 200 && r.status < 300) resolve(new Blob([r.response], { type: r.responseHeaders?.match(/Content-Type:\s*([^\r\n]+)/i)?.[1] || 'image/jpeg' })); else reject(new Error('http ' + r.status)); }, onerror: (e) => { console.error('[xhs-dl] GM_xhr error', url, e); reject(e.error || new Error('GM_xhr failed')); }, ontimeout: () => reject(new Error('timeout')), }); } else { fetch(url, { referrerPolicy: 'no-referrer' }).then(r => { if (!r.ok) throw new Error('http ' + r.status); return r.blob(); }).then(resolve).catch(reject); } }); } async function downloadAllDirect(images, displayUrls, title) { const safe = title.replace(/[\\/:*?"<>|]/g, '_').slice(0, 30) || 'xiaohongshu'; const card = $('#xhs-dl-card'); const log = card.querySelector('.xhs-dl-log') || (() => { const d = document.createElement('div'); d.className = 'xhs-dl-log'; card.appendChild(d); return d; })(); let ok = 0, fail = 0; log.textContent = `开始下载 ${images.length} 张...`; for (let i = 0; i < images.length; i++) { const url = images[i]; const disp = displayUrls[i] || url; log.textContent = `下载 ${i + 1}/${images.length} ...`; try { let blob; try { blob = await fetchBlobViaGM(url); } catch (e) { if (disp !== url) blob = await fetchBlobViaGM(disp); else throw e; } const ext = (blob.type.split('/')[1] || 'jpg').replace('jpeg', 'jpg').split(';')[0].split('+')[0] || 'jpg'; const filename = `${safe}_${String(i + 1).padStart(2, '0')}.${ext}`; downloadBlob(blob, filename); ok++; log.textContent = `已触发 ${ok}/${images.length}(失败 ${fail})`; // 轻微间隔避免浏览器拦截多文件下载 await new Promise(r => setTimeout(r, 300)); } catch (e) { fail++; console.error('[xhs-dl] fetch fail', i, url, e); log.textContent = `第 ${i + 1} 张失败: ${e.message},跳过(成功 ${ok} 失败 ${fail})`; await new Promise(r => setTimeout(r, 200)); } } if (ok === 0) { showToast('全部下载失败,请刷新后重试'); log.textContent = '下载失败,无可用图片'; return; } showToast(`已触发下载 ${ok}/${images.length} 张` + (fail ? `,${fail} 张失败` : '')); log.textContent = `完成:成功 ${ok} 张,失败 ${fail} 张,已直接下载`; } function ensureUI() { if ($('#xhs-dl-float')) return; const root = document.createElement('div'); root.id = 'xhs-dl-float'; root.className = 'xhs-dl-root xhs-dl-float'; root.innerHTML = `
`; document.body.appendChild(root); const btn = $('#xhs-dl-main'); const card = $('#xhs-dl-card'); // 点击空白自动收起 setTimeout(() => { document.addEventListener('click', (e) => { if (!card || card.style.display === 'none') return; if (card.contains(e.target) || btn.contains(e.target)) return; card.style.display = 'none'; }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && card && card.style.display !== 'none') card.style.display = 'none'; }); }, 100); btn.addEventListener('click', async (e) => { e.stopPropagation(); const note = extractNoteInfo(); if (!note) { showToast('未读到帖子数据,请先点进帖子详情并刷新'); return; } const title = (note.title || note.desc || document.title || 'xiaohongshu').trim().slice(0, 40); const isVideo = note.type === 'video' || !!note.video?.consumer || !!note.video?.media; const images = !isVideo ? generateImageUrls(note) : []; const displayUrls = !isVideo ? generateDisplayUrls(note) : []; const videos = isVideo ? generateVideoUrl(note) : []; if (isVideo && videos.length) { card.style.display = ''; card.innerHTML = `