// ==UserScript== // @name xiaohongshu_link // @namespace http://tampermonkey.net/ // @version 0.1.3 // @description 下载小红书用户主页已加载的数据和封面 // @author xxmdmst // @match https://www.xiaohongshu.com/* // @icon https://www.xiaohongshu.com/favicon.ico // @grant none // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.6.0/jszip.min.js // @license MIT // @downloadURL none // ==/UserScript== (function () { 'use strict'; let table; function initGbkTable() { // https://en.wikipedia.org/wiki/GBK_(character_encoding)#Encoding const ranges = [ [0xA1, 0xA9, 0xA1, 0xFE], [0xB0, 0xF7, 0xA1, 0xFE], [0x81, 0xA0, 0x40, 0xFE], [0xAA, 0xFE, 0x40, 0xA0], [0xA8, 0xA9, 0x40, 0xA0], [0xAA, 0xAF, 0xA1, 0xFE], [0xF8, 0xFE, 0xA1, 0xFE], [0xA1, 0xA7, 0x40, 0xA0], ]; const codes = new Uint16Array(23940); let i = 0; for (const [b1Begin, b1End, b2Begin, b2End] of ranges) { for (let b2 = b2Begin; b2 <= b2End; b2++) { if (b2 !== 0x7F) { for (let b1 = b1Begin; b1 <= b1End; b1++) { codes[i++] = b2 << 8 | b1 } } } } table = new Uint16Array(65536); table.fill(0xFFFF); const str = new TextDecoder('gbk').decode(codes); for (let i = 0; i < str.length; i++) { table[str.charCodeAt(i)] = codes[i] } } function str2gbk(str, opt = {}) { if (!table) { initGbkTable() } const NodeJsBufAlloc = typeof Buffer === 'function' && Buffer.allocUnsafe; const defaultOnAlloc = NodeJsBufAlloc ? (len) => NodeJsBufAlloc(len) : (len) => new Uint8Array(len); const defaultOnError = () => 63; const onAlloc = opt.onAlloc || defaultOnAlloc; const onError = opt.onError || defaultOnError; const buf = onAlloc(str.length * 2); let n = 0; for (let i = 0; i < str.length; i++) { const code = str.charCodeAt(i); if (code < 0x80) { buf[n++] = code; continue } const gbk = table[code]; if (gbk !== 0xFFFF) { buf[n++] = gbk; buf[n++] = gbk >> 8 } else if (code === 8364) { buf[n++] = 0x80 } else { const ret = onError(i, str); if (ret === -1) { break } if (ret > 0xFF) { buf[n++] = ret; buf[n++] = ret >> 8 } else { buf[n++] = ret } } } return buf.subarray(0, n) } const toast = (msg, duration) => { duration = isNaN(duration) ? 3000 : duration; let toastDom = document.createElement('pre'); toastDom.textContent = msg; toastDom.style.cssText = 'padding:2px 15px;min-height: 36px;line-height: 36px;text-align: center;transform: translate(-50%);border-radius: 4px;color: rgb(255, 255, 255);position: fixed;top: 50%;left: 50%;z-index: 9999999;background: rgb(0, 0, 0);font-size: 16px;' document.body.appendChild(toastDom); setTimeout(function () { const d = 0.5; toastDom.style.transition = `transform ${d}s ease-in, opacity ${d}s ease-in`; toastDom.style.opacity = '0'; setTimeout(function () { document.body.removeChild(toastDom) }, d * 1000); }, duration); } function formatName(name) { return name.replace(/[\/:*?"<>|\s]+/g, "").slice(0, 27).replace(/\.\d+$/g, ""); } function copyText(text, node) { let oldText = node.textContent; navigator.clipboard.writeText(text).then(r => { node.textContent = "复制成功"; toast("复制成功\n" + text.slice(0, 20) + (text.length > 20 ? "..." : ""), 2000); }).catch((e) => { node.textContent = "复制失败"; toast("复制失败", 2000); }) setTimeout(() => node.textContent = oldText, 2000); } let msg_pre; window.notes = new Map(); function interceptResponse() { const originalSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function () { originalSend.apply(this, arguments); const self = this; let func = this.onreadystatechange; this.onreadystatechange = (e) => { if (func) { func.apply(self, e); } if (self.readyState !== 4 || self.responseURL.indexOf("/api/sns/web/v1/feed") === -1) return; let json = JSON.parse(self.response); if (!json.success) return; load_data(); for (let item of json.data.items) { console.log(item); let noteCard = item.note_card; let interactInfo = noteCard.interact_info; let old_data = notes.get(item.id); let data = { "id": item.id, "nickname": noteCard.user.nickname, "title": noteCard.title, "desc": noteCard.desc, "image_list": noteCard.image_list.map(image => image.url_default), "liked_count": interactInfo.liked_count, "collected_count": interactInfo.collected_count, "comment_count": interactInfo.comment_count, "share_count": interactInfo.share_count, "tag_list": noteCard.tag_list.map(tag => tag.name), "time": noteCard.time, "time_str": new Date(noteCard.time).toLocaleString(), "cover": old_data.cover, "url": old_data.url, }; notes.set(item.id, data); // let video_url; // if (noteCard.type === "video") { // let backup_urls = noteCard.video.media.stream.h264[0].backup_urls; // for (video_url of backup_urls) { // if (video_url.indexOf("?") > -1) { // window.video_url = video_url; // break; // } // } // } } } }; } interceptResponse(); function createButton(title) { let parent = document.querySelector(".info-right-area-more-container .dropdown-items"); let menu = parent.children[0].cloneNode(true); let button = menu.querySelector("span"); button.textContent = title; parent.appendChild(menu); return button; } function txt2file(txt, filename) { const blob = new Blob([txt], {type: 'text/plain'}); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename.replace(/[\/:*?"<>|]/g, ""); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } function downloadData(encoding) { let text = "作品ID,昵称,标题,描述,点赞,收藏,评论,分享,标签,发布时间,封面,链接\n"; window.notes.values().forEach(item => { text += [ item.id, item.nickname, '"' + item.title.replace(/,/g, ',').replace(/"/g, '""') + '"', '"' + item.desc.replace(/,/g, ',').replace(/"/g, '""') + '"', item.liked_count, item.collected_count, item.comment_count, item.share_count, item.tag_list.join("#"), item.time_str, item.cover, item.url ].join(",") + "\n" }); if (encoding === "gbk") text = str2gbk(text); txt2file(text, document.title + ".csv"); } function load_data() { for (let items of window.__INITIAL_STATE__["user"]["notes"]._rawValue) { for (let item of items) { if (notes.has(item.id)) { continue; } let noteCard = item["noteCard"]; notes.set(item.id, { "id": item.id, "nickname": noteCard.user.nickname, "title": noteCard.displayTitle, "desc": "", "image_list": [], "liked_count": noteCard.interactInfo.likedCount, "collected_count": "", "comment_count": "", "share_count": "", "tag_list": [], "time": "", "time_str": "", "cover": noteCard.cover.urlDefault, "url": `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${noteCard.xsecToken}`, }); } } return notes; } function createDownloadLink(blob, filename, ext, prefix = "") { if (filename === null) { filename = document.title; } const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = prefix + formatName(filename) + "." + ext; link.click(); URL.revokeObjectURL(url); } async function downloadCover(node) { load_data(); if (node.disabled) { toast("下载正在处理中,请不要重复点击按钮!"); return; } node.disabled = true; try { const zip = new JSZip(); msg_pre.textContent = `下载封面并打包中...`; // let user_aweme_list = Array.from(all_aweme_map.values()).sort((a, b) => b.create_time - a.create_time); let note_data_list = Array.from(notes.values()); let promises = note_data_list.map((note, i) => { if (note.image_list.length > 1) { let note_name = formatName(note.title); msg_pre.textContent = `${i + 1}/${note_data_list.length} ` + note_name; let folder = zip.folder(`${i + 1}.` + note_name); return Promise.all(note.image_list.map((link, index) => { return fetch(link) .then((res) => res.arrayBuffer()) .then((buffer) => { folder.file(`image_${index + 1}.png`, buffer); }); })); } else { let note_name = formatName(note.title) + ".png"; return fetch(note.cover) .then(response => response.arrayBuffer()) .then(buffer => zip.file(`${i + 1}.` + note_name, buffer)) .then(() => msg_pre.textContent = `${i + 1}/${note_data_list.length} ` + note_name) } }); Promise.all(promises).then(() => { return zip.generateAsync({type: "blob"}) }).then((content) => { createDownloadLink(content, null, "zip", "【封面】"); msg_pre.textContent = "封面打包完成"; node.disabled = false; }) } finally { node.disabled = false; } } function createMsgBox() { msg_pre = document.createElement('pre'); msg_pre.textContent = ''; msg_pre.style.color = 'blue'; msg_pre.style.position = 'fixed'; msg_pre.style.right = '5px'; msg_pre.style.top = '42px'; msg_pre.style.zIndex = '503'; msg_pre.style.opacity = "0.4"; document.body.appendChild(msg_pre); } let domLoadedTimer; function createAllButton() { let b1 = createButton("下载笔记数据", "40px"); b1.onclick = (e) => { load_data(); downloadData("gbk"); }; let b2 = createButton("下载封面图片", "61px"); b2.onclick = (e) => { load_data(); downloadCover(b2).then(r => { }); }; let b3 = createButton("复制作者信息", "82px"); b3.onclick = (e) => { let basicInfo = window.__INITIAL_STATE__.user.userPageData._rawValue.basicInfo; let interactions = window.__INITIAL_STATE__.user.userPageData._rawValue.interactions; let text = [ "昵称:" + basicInfo.nickname, "小红书号:" + basicInfo.redId, "IP属地:" + basicInfo.ipLocation, "简介:" + basicInfo.desc, "性别:" + (basicInfo.gender === 1 ? "女" : "男"), interactions.map(item => item.count + item.name).join(" ") ].join("\n") copyText(text, b3); }; } const checkElementLoaded = () => { const element = document.querySelector(".info-right-area-more-container .dropdown-items .menu-item span"); if (element) { console.log('加载完毕'); msg_pre.textContent = "按钮加载完成,点击...按钮使用\n注意:只有点开过的作品才能下载完整数据"; clearInterval(domLoadedTimer); domLoadedTimer = null; createAllButton(); load_data(); } }; window.onload = () => { createMsgBox(); domLoadedTimer = setInterval(checkElementLoaded, 300); } })();