Skip to content

Instantly share code, notes, and snippets.

@ricky9w
Created August 2, 2024 13:45
Show Gist options
  • Save ricky9w/f00b7511274669703e623e5180c33bd5 to your computer and use it in GitHub Desktop.
Save ricky9w/f00b7511274669703e623e5180c33bd5 to your computer and use it in GitHub Desktop.
下载小红书无水印图文/视频作品
// ==UserScript==
// @name XHS-Downloader
// @namespace https://github.com/ricky9w
// @version 1.4.1
// @description 下载小红书无水印图文/视频作品
// @author JoeanAmier (improved and compiled by ricky9w & Claude AI)
// @match http*://www.xiaohongshu.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @grant GM_setClipboard
// @credit https://github.com/JoeanAmier/XHS-Downloader/blob/master/static/XHS-Downloader.js
// ==/UserScript==
(function() {
const generateVideoUrl = note => {
try {
return [`https://sns-video-bd.xhscdn.com/${note.video.consumer.originVideoKey}`];
} catch (error) {
console.error("Error generating video URL:", error);
return [];
}
};
const generateImageUrl = note => {
const regex = /http:\/\/sns-webpic-qc\.xhscdn.com\/\d+\/[0-9a-z]+\/(\S+)!/;
return note.imageList.map(item => {
const match = item.urlDefault.match(regex);
return match ? `https://ci.xiaohongshu.com/${match[1]}?imageView2/2/w/format/png` : null;
}).filter(Boolean);
};
const downloadFile = async (url, filename, progressCallback, abortSignal) => {
try {
const response = await fetch(url, { signal: abortSignal });
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
const contentLength = response.headers.get('Content-Length');
const total = parseInt(contentLength, 10);
let loaded = 0;
const reader = response.body.getReader();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.length;
progressCallback(loaded / total);
if (abortSignal.aborted) {
throw new Error('Download cancelled');
}
}
const blob = new Blob(chunks);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = blobUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(blobUrl);
return true;
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Download cancelled: ${filename}`);
} else {
console.error(`Download failed (${filename}):`, error);
}
return false;
}
};
const extractNoteInfo = () => {
const match = window.location.href.match(/\/explore\/([^?]+)/);
return match ? unsafeWindow.__INITIAL_STATE__.note.noteDetailMap[match[1]] : null;
};
const extractLinks = (extractor) => {
const ids = extractor();
return [...ids].map(id => `https://www.xiaohongshu.com/explore/${id}`).join(" ");
};
const createButton = (text, onClick) => {
const button = document.createElement('button');
button.textContent = text;
button.addEventListener('click', onClick);
return button;
};
const createProgressUI = () => {
const progressContainer = document.createElement('div');
progressContainer.id = 'xhsProgressContainer';
progressContainer.style.cssText = `
background-color: white;
padding: 10px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.1);
margin-bottom: 10px;
font-size: 12px;
max-height: 300px;
overflow-y: auto;
width: 250px;
display: none;
`;
return progressContainer;
};
const createTaskProgressUI = (taskId, title, onCancel) => {
const taskContainer = document.createElement('div');
taskContainer.id = `task-${taskId}`;
taskContainer.style.cssText = `
margin-bottom: 10px;
border-bottom: 1px solid #eee;
padding-bottom: 5px;
`;
taskContainer.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center;">
<h3 style="margin: 0 0 5px 0; font-size: 14px; font-weight: normal; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1;" title="${title}">${title}</h3>
<button class="cancel-button" style="font-size: 12px; padding: 2px 5px; background-color: #ff4757; color: white; border: none; border-radius: 3px; cursor: pointer;">取消</button>
</div>
<div class="files-container"></div>
`;
taskContainer.querySelector('.cancel-button').addEventListener('click', onCancel);
return taskContainer;
};
const updateTaskProgressUI = (taskContainer, files, progress) => {
const filesContainer = taskContainer.querySelector('.files-container');
filesContainer.innerHTML = files.map((file, index) => `
<div style="margin-bottom: 5px;">
<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${file}">${file}</div>
<div style="width:100%;height:5px;background-color:#ddd;border-radius:2px;">
<div style="width:${progress[index] * 100}%;height:100%;background-color:#4CAF50;border-radius:2px;"></div>
</div>
</div>
`).join('');
};
let taskIdCounter = 0;
let activeTaskCount = 0;
const addButtons = () => {
const container = document.createElement('div');
container.id = 'xhsFunctionContainer';
container.style.cssText = `
position: fixed;
bottom: 20px;
left: 20px;
z-index: 2147483647;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 10px;
`;
const progressContainer = createProgressUI();
container.appendChild(progressContainer);
const buttons = [
createButton("下载无水印文件", async () => {
const note = extractNoteInfo()?.note;
if (!note) return alert("无法获取作品信息");
const urls = note.type === "normal" ? generateImageUrl(note) : generateVideoUrl(note);
const baseFileName = note.title || 'download';
const fileExtension = note.type === "normal" ? 'png' : 'mp4';
const files = urls.map((_, i) =>
urls.length === 1 ? `${baseFileName}.${fileExtension}` : `${baseFileName}_${i + 1}.${fileExtension}`
);
const progress = new Array(urls.length).fill(0);
const taskId = taskIdCounter++;
const abortController = new AbortController();
const taskContainer = createTaskProgressUI(taskId, baseFileName, () => {
abortController.abort();
taskContainer.remove();
activeTaskCount--;
if (activeTaskCount === 0) {
progressContainer.style.display = 'none';
}
});
progressContainer.appendChild(taskContainer);
activeTaskCount++;
progressContainer.style.display = 'block';
updateTaskProgressUI(taskContainer, files, progress);
for (let [i, url] of urls.entries()) {
if (abortController.signal.aborted) break;
await downloadFile(url, files[i], (p) => {
progress[i] = p;
updateTaskProgressUI(taskContainer, files, progress);
}, abortController.signal);
}
if (!abortController.signal.aborted) {
// 立即隐藏取消按钮
const cancelButton = taskContainer.querySelector('.cancel-button');
if (cancelButton) {
cancelButton.style.display = 'none';
}
setTimeout(() => {
taskContainer.remove();
activeTaskCount--;
if (activeTaskCount === 0) {
progressContainer.style.display = 'none';
}
}, 2000);
}
}),
createButton("提取作品链接", () => {
const links = extractLinks(() => new Set(unsafeWindow.__INITIAL_STATE__.user.notes._rawValue[0].map(({id}) => id)));
GM_setClipboard(links, "text");
alert('作品链接已复制到剪贴板!');
})
];
buttons.forEach(button => {
button.style.cssText = `
padding: 10px 20px;
background-color: #ff2442;
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: background-color 0.3s;
display: block;
`;
button.addEventListener('mouseover', () => {
button.style.backgroundColor = '#ff4757';
});
button.addEventListener('mouseout', () => {
button.style.backgroundColor = '#ff2442';
});
container.appendChild(button);
});
document.body.appendChild(container);
};
window.addEventListener('load', addButtons);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment