// ==UserScript== // @name 小红书无水印下载器 by星月云 // @namespace https://github.com/JoeanAmier/XHS-Downloader // @version 2.0.0 // @description 纯浏览器端小红书作品下载:从 window.__INITIAL_STATE__ 解析图片/视频直链,直接 fetch+blob 落盘。 // @author WorkBuddy // @match *://www.xiaohongshu.com/* // @match *://www.rednote.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_setClipboard // @grant unsafeWindow // @run-at document-idle // @license GPL-2.0 // ==/UserScript== (function () { "use strict"; const GM = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; const DOC = document; const SITE = location.hostname.includes("rednote") ? "rednote" : "xiaohongshu"; const STORE = { imageFmt: GM_getValue("xhs_image_fmt", "jpeg"), // jpeg / webp / png / heic / avif videoPref: GM_getValue("xhs_video_pref", "resolution"), // resolution / bitrate / size autoScroll: GM_getValue("xhs_auto_scroll", false), scrollTimes: GM_getValue("xhs_scroll_times", 30), delay: GM_getValue("xhs_delay", 400), // 作品之间的间隔(ms),防限流 }; const IMG_REGEX = { xiaohongshu: /https?:\/\/sns-webpic-qc\.xhscdn\.com\/\d+\/[0-9a-z]+\/(\S+)!/, rednote: /https?:\/\/sns-web-i10\.rednotecdn\.com\/\d+\/[0-9a-z]+\/(\S+)!/, }; const PREF_KEY = { resolution: "height", bitrate: "videoBitrate", size: "size" }; /* ---------------- 工具 ---------------- */ const $ = (s, r = DOC) => r.querySelector(s); const $$ = (s, r = DOC) => Array.from(r.querySelectorAll(s)); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function getState() { try { return GM.__INITIAL_STATE__ || null; } catch (e) { return null; } } function safeGet(o, p) { return p.split(".").reduce((a, k) => (a == null ? undefined : a[k]), o); } function safeArr(o, p) { const v = safeGet(o, p); return Array.isArray(v) ? v : []; } // 收集本页所有笔记对象(noteCard 形态) function collectNotes() { const s = getState(); const notes = []; if (!s) return notes; const push = (it) => it && it.noteCard && notes.push(it.noteCard); safeArr(s.feed, "feeds._rawValue").forEach(push); safeArr(s.search, "feeds._rawValue").forEach(push); safeArr(s.user, "notes._rawValue").forEach((it) => push({ noteCard: it })); const board = safeGet(s, "board.boardFeedsMap._rawValue"); if (board) Object.values(board).forEach((b) => safeArr(b, "notes").forEach((it) => it && it.noteCard ? notes.push(it.noteCard) : it && it.noteId && notes.push(it) ) ); const nd = safeGet(s, "noteData.data.noteData"); if (nd) notes.push(nd); const ndm = safeGet(s, "note.noteDetailMap"); if (ndm) Object.values(ndm).forEach((v) => v && v.note && notes.push(v.note)); // 去重 const seen = new Set(); return notes.filter((n) => { const id = n.noteId || n.id; if (!id || seen.has(id)) return false; seen.add(id); return true; }); } function extractCurrentNoteId() { const m = location.pathname.match(/\/(?:explore|discovery\/item)\/([0-9a-zA-Z]+)/); return m ? m[1] : null; } // 优先精确匹配详情页作品,避免误取信息流卡片(卡片往往只有 1 张封面图) function getDetailNote() { const s = getState(); if (!s) return null; const id = extractCurrentNoteId(); if (id) { const n = safeGet(s, `note.noteDetailMap.${id}.note`); if (n) return n; } const nd = safeGet(s, "noteData.data.noteData"); if (nd) return nd; const ndm = safeGet(s, "note.noteDetailMap"); if (ndm) { const vals = Object.values(ndm) .map((v) => (v && v.note ? v.note : null)) .filter(Boolean); if (vals.length) return vals[vals.length - 1]; } return null; } // 当前详情页作品:带重试,等待页面 __INITIAL_STATE__ 水合完成(避免只拿到 1 张封面的卡片) async function currentNote() { for (let i = 0; i < 15; i++) { const n = getDetailNote(); if (n && (Array.isArray(n.imageList) ? n.imageList.length : 0) >= 1) return n; if (n && n.video) return n; await sleep(300); } return getDetailNote(); } function getMeta(note) { if (!note) return { title: "", author: "", id: "" }; return { title: note.title || note.displayTitle || "", author: (note.user && (note.user.nickname || note.user.nickName)) || note.nickName || "", id: note.noteId || note.id || "", }; } function sanitize(name) { return ( (name || "") .replace(/[\\/:*?"<>|\r\n\t]+/g, "_") .replace(/\s+/g, " ") .trim() .slice(0, 60) || "xhs" ); } function baseName(note) { const m = getMeta(note); return `${sanitize(m.title)}_${sanitize(m.author)}_${m.id}`.replace( /_+/g, "_" ); } /* ---------------- 直链生成 ---------------- */ // 为每张图生成多个候选直链,按优先级依次尝试,最大化成功率 function imageCandidates(note, fmt) { const imgs = note.imageList || []; const re = IMG_REGEX[SITE] || IMG_REGEX.xiaohongshu; const out = []; imgs.forEach((item, i) => { const url = item.urlDefault || item.url || item.urlPre; if (!url) return; let token = null; const m = url.match(re); if (m && m[1]) token = m[1]; else token = url.split("/").slice(5).join("/").split("!")[0]; const candidates = []; if (token) { candidates.push(`https://ci.xiaohongshu.com/${token}?imageView2/format/${fmt}`); candidates.push(`https://sns-img-bd.xhscdn.com/${token}`); } // 原始 CDN 直链(去掉 ! 变换后缀,取未处理的原图;同域最稳,作为兜底) const raw = url.split("!")[0]; if (raw && raw.startsWith("http")) candidates.push(raw); if (candidates.length) out.push({ index: i + 1, candidates }); }); return out; } function videoUrl(note, pref) { try { const key = safeGet(note, "video.consumer.originVideoKey"); if (key) return `https://sns-video-bd.xhscdn.com/${key}`; const streams = Object.values( safeGet(note, "video.media.stream") || {} ).flat(); if (!streams.length) return null; const k = PREF_KEY[pref] || "height"; streams.sort((a, b) => (b[k] || 0) - (a[k] || 0)); return streams[0].backupUrls?.[0] || streams[0].masterUrl || null; } catch (e) { return null; } } /* ---------------- 下载核心 ---------------- */ let busy = false; // link 可为字符串或候选数组,按顺序尝试每个地址 async function downloadFile(link, name, retries = 3) { const urls = Array.isArray(link) ? link : [link]; for (const u of urls) { for (let i = 1; i <= retries; i++) { try { const res = await fetch(u, { method: "GET", headers: { accept: "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", "accept-language": "zh-CN,zh;q=0.9", }, referrerPolicy: "no-referrer", }); if (!res.ok) { console.warn(`下载失败 ${res.status} ${u} (第${i}次)`); continue; } const blob = await res.blob(); if (!blob || blob.size === 0) { console.warn(`空文件 ${u}`); continue; } triggerDownload(name, blob); return true; } catch (e) { console.warn(`下载异常 ${u}`, e); } await sleep(300); } } return false; } function triggerDownload(name, blob) { const url = URL.createObjectURL(blob); const a = DOC.createElement("a"); a.href = url; a.download = name; DOC.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } async function downloadNote(note) { const name = baseName(note); if (note.type === "video") { const u = videoUrl(note, STORE.videoPref); if (u) { if (await downloadFile([u], `${name}.mp4`)) toast(`视频已下载:${name}`); else toast(`视频下载失败:${name}`, true); } else toast(`视频地址解析失败:${name}`, true); } else { const imgs = imageCandidates(note, STORE.imageFmt); if (!imgs.length) { toast(`未找到图片:${name}`, true); return; } let ok = 0; for (const im of imgs) { setCnt(`下载图片 ${im.index}/${imgs.length}`); if (await downloadFile(im.candidates, `${name}_${im.index}.${STORE.imageFmt}`)) ok++; await sleep(200); } toast(`图片下载完成:${ok}/${imgs.length} 张 — ${name}`); } await sleep(STORE.delay); } /* ---------------- 自动滚动加载更多 ---------------- */ async function autoScroll(times) { toast(`滚动加载中(${times} 次)…`); for (let i = 0; i < times; i++) { window.scrollTo(0, DOC.body.scrollHeight); await sleep(350 + Math.random() * 250); } window.scrollTo(0, 0); await sleep(300); } /* ---------------- UI ---------------- */ let panel, toastEl; function buildUI() { const css = ` #xhs-dl2{position:fixed;top:12px;right:12px;z-index:999999;width:280px;background:#fff; border:1px solid #ff2e4d33;border-radius:12px;box-shadow:0 6px 24px #0002; font:13px/1.5 -apple-system,"PingFang SC","Microsoft YaHei",sans-serif;color:#222;} #xhs-dl2 .hd{display:flex;align-items:center;gap:8px;padding:10px 12px;cursor:move; background:linear-gradient(90deg,#ff2e4d,#ff7a45);color:#fff;border-radius:12px 12px 0 0;font-weight:600;} #xhs-dl2 .bd{padding:10px 12px;display:flex;flex-direction:column;gap:8px;} #xhs-dl2 .row{display:flex;gap:6px;} #xhs-dl2 button{flex:1;border:none;border-radius:8px;padding:8px 6px;cursor:pointer; background:#ff2e4d;color:#fff;font-size:12px;font-weight:600;} #xhs-dl2 button.alt{background:#f2f2f2;color:#333;} #xhs-dl2 button:disabled{opacity:.5;cursor:not-allowed;} #xhs-dl2 select,#xhs-dl2 input[type=number]{border:1px solid #ddd;border-radius:6px;padding:4px;font-size:12px;} #xhs-dl2 label{font-size:12px;color:#555;display:flex;align-items:center;gap:6px;} #xhs-dl2 .cnt{font-size:12px;color:#ff2e4d;font-weight:600;min-height:16px;} #xhs-dl2 textarea{width:100%;height:70px;resize:vertical;border:1px solid #ddd;border-radius:8px; padding:6px;font:11px/1.4 monospace;box-sizing:border-box;} #xhs-toast2{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:1000000; background:#222d;color:#fff;padding:8px 16px;border-radius:20px;font:13px sans-serif;opacity:0; transition:opacity .25s;pointer-events:none;max-width:80vw;text-align:center;} #xhs-toast2.show{opacity:1;} #xhs-toast2.err{background:#c0392bdd;}`; const style = DOC.createElement("style"); style.textContent = css; DOC.head.appendChild(style); panel = DOC.createElement("div"); panel.id = "xhs-dl2"; panel.innerHTML = `
小红书无水印下载器 by星月云
就绪
`; DOC.body.appendChild(panel); toastEl = DOC.createElement("div"); toastEl.id = "xhs-toast2"; DOC.body.appendChild(toastEl); // 初始化控件值 $("#xhs-fmt").value = STORE.imageFmt; $("#xhs-vpref").value = STORE.videoPref; $("#xhs-auto").checked = STORE.autoScroll; $("#xhs-st").value = STORE.scrollTimes; $("#xhs-fmt").onchange = (e) => { STORE.imageFmt = e.target.value; GM_setValue("xhs_image_fmt", STORE.imageFmt); }; $("#xhs-vpref").onchange = (e) => { STORE.videoPref = e.target.value; GM_setValue("xhs_video_pref", STORE.videoPref); }; $("#xhs-auto").onchange = (e) => { STORE.autoScroll = e.target.checked; GM_setValue("xhs_auto_scroll", STORE.autoScroll); }; $("#xhs-st").onchange = (e) => { STORE.scrollTimes = +e.target.value || 30; GM_setValue("xhs_scroll_times", STORE.scrollTimes); }; $("#xhs-one").onclick = async () => { if (busy) return; busy = true; setCnt("识别作品中…"); const n = await currentNote(); if (!n) { busy = false; return toast("未识别到当前作品,请刷新页面后重试", true); } setCnt("下载本篇中…"); await downloadNote(n); setCnt("本篇下载完成"); busy = false; }; $("#xhs-all").onclick = async () => { if (busy) return; if ($("#xhs-auto").checked) await autoScroll(+$("#xhs-st").value || 30); const notes = collectNotes(); if (!notes.length) return toast("本页未识别到作品", true); busy = true; let ok = 0; for (let i = 0; i < notes.length; i++) { setCnt(`下载中 ${i + 1}/${notes.length}`); await downloadNote(notes[i]); ok++; } setCnt(`完成:共 ${ok} 个作品`); busy = false; }; $("#xhs-copy").onclick = () => { const links = collectNotes() .map((n) => { const id = n.noteId || n.id; const tk = n.xsecToken; return id ? `https://www.xiaohongshu.com/explore/${id}${ tk ? "?xsec_token=" + encodeURIComponent(tk) : "" }` : null; }) .filter(Boolean); if (!links.length) return toast("无链接可复制", true); GM_setClipboard(links.join("\n"), "text"); toast(`已复制 ${links.length} 条链接`); }; makeDraggable(panel, $(".hd", panel)); } function setCnt(t) { const el = $("#xhs-cnt"); if (el) el.textContent = t; } let toastTimer; function toast(msg, err) { if (!toastEl) return; toastEl.textContent = msg; toastEl.className = "show" + (err ? " err" : ""); clearTimeout(toastTimer); toastTimer = setTimeout(() => (toastEl.className = ""), 2400); } function makeDraggable(el, handle) { let sx, sy, ox, oy, drag = false; handle.onmousedown = (e) => { drag = true; sx = e.clientX; sy = e.clientY; const r = el.getBoundingClientRect(); ox = r.left; oy = r.top; e.preventDefault(); }; DOC.onmousemove = (e) => { if (!drag) return; el.style.left = ox + (e.clientX - sx) + "px"; el.style.top = oy + (e.clientY - sy) + "px"; el.style.right = "auto"; }; DOC.onmouseup = () => (drag = false); } /* ---------------- 启动 ---------------- */ function init() { if (!DOC.body) return setTimeout(init, 400); buildUI(); if (/xiaohongshu\.com\/(?:explore|discovery\/item)\//.test(location.href)) { setTimeout(() => setCnt("已进入作品页,点“下载本篇”"), 600); } } init(); })();