Why

管理收藏夹和自己的数据

How

前辈脚本:小红书转发 - 源代码

Tampermonkey 隐私友好

包含范围

Xiaohongshu

Twitter

DONE

Bilibili

TODO

试错历程(xiaohongshu 为例)

收集笔记

问题是点击两个按钮什么事情都没有发生。

// ==UserScript==
// @name         小红书本地导出
// @namespace    https://yournamespace.com
// @version      4.3
// @description  本地化导出小红书笔记为Markdown并打包下载
// @match        https://www.xiaohongshu.com/*
// @grant        unsafeWindow
// @grant        GM_addStyle
// @grant        GM.xmlHttpRequest
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @license      MIT
// @icon         https://www.xiaohongshu.com/favicon.ico
// ==/UserScript==
 
(function () {
  "use strict";
  console.log('[小红书导出] 脚本开始执行');
 
  // 修正后的样式
  GM_addStyle(`
    .export-panel {
      position: fixed;
      top: 200px;
      right: 20px;
      background: white !important;
      color: #333 !important;
      border: 1px solid #eee;
      padding: 15px;
      border-radius: 8px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
      z-index: 99999;
      width: 300px;
    }
    .export-btn {
      background: #056b00 !important;
      color: white !important;
      border: none;
      padding: 8px 15px;
      border-radius: 4px;
      cursor: pointer;
      margin: 5px;
    }
  `);
 
  let capturedNotes = [];
  let zip = new JSZip();
 
  // 创建导出面板
  function createExportPanel() {
    const panel = document.createElement('div');
    panel.className = 'export-panel';
    panel.innerHTML = `
      <h3>已捕获笔记:<span id="noteCount">0</span></h3>
      <button class="export-btn" id="exportMd">导出Markdown</button>
      <button class="export-btn" id="exportZip">打包下载</button>
    `;
    document.body.appendChild(panel);
  }
 
  function updateNoteCount() {
    document.getElementById('noteCount').textContent = capturedNotes.length;
  }
 
  // 新版接口处理逻辑
  function interceptRequests() {
    const originalOpen = XMLHttpRequest.prototype.open;
    const collectPattern = /\/api\/sns\/web\/v2\/note\/collect\/page/;
    const feedPattern = /\/api\/sns\/web\/v1\/feed/;
 
    const originalResponseText = Object.getOwnPropertyDescriptor(
      XMLHttpRequest.prototype,
      "responseText"
    );
 
    XMLHttpRequest.prototype.open = function (method, url) {
      originalOpen.apply(this, arguments);
 
      const xhr = this;
      const handleResponse = (response) => {
        try {
          const data = JSON.parse(response);
          console.log('[接口响应]', data);
 
          // 处理收藏接口
          if (collectPattern.test(url)) {
            console.log('[处理收藏接口]', data.data?.notes);
            data.data?.notes?.forEach(note => {
              const processed = processCollectNote(note);
              if (processed) {
                capturedNotes.push(processed);
                updateNoteCount();
              }
            });
          }
          // 处理动态流接口
          else if (feedPattern.test(url)) {
            console.log('[处理动态流接口]', data.data?.items);
            data.data?.items?.forEach(item => {
              if (item.note) {
                const processed = processNote(item.note);
                capturedNotes.push(processed);
                updateNoteCount();
              }
            });
          }
        } catch (e) {
          console.error('响应处理失败:', e);
        }
      };
 
      if (originalResponseText?.get) {
        Object.defineProperty(xhr, "responseText", {
          get: function () {
            const response = originalResponseText.get.call(this);
            if (collectPattern.test(url) || feedPattern.test(url)) {
              handleResponse(response);
            }
            return response;
          },
          configurable: true
        });
      }
    };
  }
 
  // 处理收藏接口的笔记结构
  function processCollectNote(rawNote) {
    try {
      return {
        id: rawNote.note_id,
        title: rawNote.display_title || '无标题',
        desc: '',
        time: Date.now(),
        user: {
          nickname: rawNote.user?.nickname || '匿名用户',
          userId: rawNote.user?.user_id || '未知用户'
        },
        images: [{
          url: rawNote.cover?.url_default || '',
          name: ''
        }],
        video: rawNote.type === 'video' ? {
          url: `https://sns-video-bd.xhscdn.com/${rawNote.video?.consumer?.originVideoKey}`,
          name: ''
        } : null
      };
    } catch (e) {
      console.error('收藏笔记处理失败:', e);
      return null;
    }
  }
 
  // 处理动态流笔记结构
  function processNote(rawNote) {
    return {
      id: rawNote.id,
      title: rawNote.title || '无标题',
      desc: rawNote.desc || '无描述',
      time: rawNote.time || Date.now(),
      user: {
        nickname: rawNote.user?.nickname || '匿名用户',
        userId: rawNote.user?.userId || '未知用户'
      },
      images: rawNote.imageList?.map(img => ({
        url: img.urlDefault || '',
        name: ''
      })) || [],
      video: rawNote.video ? {
        url: `https://sns-video-bd.xhscdn.com/${rawNote.video.consumer.originVideoKey}`,
        name: ''
      } : null
    };
  }
 
  // 生成Markdown
  function generateMarkdown(note) {
    return `# ${note.title}\n\n` +
      `**作者**: ${note.user.nickname} (ID: ${note.user.userId})\n\n` +
      `${note.desc}\n\n` +
      `${note.images.map(img => `![图片](${img.name})`).join('\n')}\n` +
      `${note.video ? `\n[视频](${note.video.name})` : ''}`;
  }
 
  // 初始化
  function init() {
    createExportPanel();
    interceptRequests();
 
    document.getElementById('exportMd').addEventListener('click', async () => {
      for (const note of capturedNotes) {
        const folder = zip.folder(note.id);
 
        // 下载图片
        for (const [index, img] of note.images.entries()) {
          if (img.url) {
            try {
              const response = await GM.xmlHttpRequest({
                method: "GET",
                url: img.url,
                responseType: "blob"
              });
              folder.file(`image_${index+1}.jpg`, response.response);
              img.name = `image_${index+1}.jpg`;
            } catch (error) {
              console.error('图片下载失败:', error);
            }
          }
        }
 
        // 下载视频
        if (note.video?.url) {
          try {
            const response = await GM.xmlHttpRequest({
              method: "GET",
              url: note.video.url,
              responseType: "blob"
            });
            folder.file(`video.mp4`, response.response);
            note.video.name = `video.mp4`;
          } catch (error) {
            console.error('视频下载失败:', error);
          }
        }
 
        folder.file('note.md', generateMarkdown(note));
      }
    });
 
    document.getElementById('exportZip').addEventListener('click', () => {
      zip.generateAsync({type:"blob"}).then(content => {
        saveAs(content, "xhs-notes.zip");
      });
    });
  }
 
  init();
})();
 

调整了监听器添加逻辑,调整了下载 header

仍然没有用

// ==UserScript==
// @name         小红书本地导出
// @namespace    https://yournamespace.com
// @version      4.3
// @description  本地化导出小红书笔记为Markdown并打包下载
// @match        https://www.xiaohongshu.com/*
// @grant        unsafeWindow
// @grant        GM_addStyle
// @grant        GM.xmlHttpRequest
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @license      MIT
// @icon         https://www.xiaohongshu.com/favicon.ico
// ==/UserScript==

(function () {
  "use strict";
  console.log('[小红书导出] 脚本开始执行');


  // 调试标记
  let isGenerating = false;

  // 初始化检查
  console.log('JSZip可用性:', typeof JSZip !== 'undefined' ? '✅' : '❌');
  console.log('FileSaver可用性:', typeof saveAs !== 'undefined' ? '✅' : '❌');

  // 修正后的样式
  GM_addStyle(`
    .export-panel {
      position: fixed;
      top: 200px;
      right: 20px;
      background: white !important;
      color: #333 !important;
      border: 1px solid #eee;
      padding: 15px;
      border-radius: 8px;
      box-shadow: 0 2px 10px rgba(0,0,0,0.1);
      z-index: 99999;
      width: 300px;
    }
    .export-btn {
      background: #056b00 !important;
      color: white !important;
      border: none;
      padding: 8px 15px;
      border-radius: 4px;
      cursor: pointer;
      margin: 5px;
    }
  `);

  let capturedNotes = [];
  let zip = new JSZip();

  // 创建导出面板
  function createExportPanel() {
    const panel = document.createElement('div');
    panel.className = 'export-panel';
    panel.innerHTML = `
      <h3>已捕获笔记:<span id="noteCount">0</span></h3>
      <button class="export-btn" id="exportMd">导出Markdown</button>
      <button class="export-btn" id="exportZip">打包下载</button>
    `;
    document.body.appendChild(panel);


    // 添加事件监听(修复版)
    setTimeout(() => {
      document.getElementById('exportMd').addEventListener('click', handleExportMd);
      document.getElementById('exportZip').addEventListener('click', handleExportZip);
      console.log('按钮事件监听器已绑定');
    }, 1000);

  }

  function updateNoteCount() {
    document.getElementById('noteCount').textContent = capturedNotes.length;
  }

  // 新版接口处理逻辑
  function interceptRequests() {
    const originalOpen = XMLHttpRequest.prototype.open;
    const collectPattern = /\/api\/sns\/web\/v2\/note\/collect\/page/;
    const feedPattern = /\/api\/sns\/web\/v1\/feed/;

    const originalResponseText = Object.getOwnPropertyDescriptor(
      XMLHttpRequest.prototype,
      "responseText"
    );

    XMLHttpRequest.prototype.open = function (method, url) {
      originalOpen.apply(this, arguments);

      const xhr = this;
      const handleResponse = (response) => {
        try {
          const data = JSON.parse(response);
          console.log('[接口响应]', data);

          // 处理收藏接口
          if (collectPattern.test(url)) {
            console.log('[处理收藏接口]', data.data?.notes);
            data.data?.notes?.forEach(note => {
              const processed = processCollectNote(note);
              if (processed) {
                capturedNotes.push(processed);
                updateNoteCount();
              }
            });
          }
          // 处理动态流接口
          else if (feedPattern.test(url)) {
            console.log('[处理动态流接口]', data.data?.items);
            data.data?.items?.forEach(item => {
              if (item.note) {
                const processed = processNote(item.note);
                capturedNotes.push(processed);
                updateNoteCount();
              }
            });
          }
        } catch (e) {
          console.error('响应处理失败:', e);
        }
      };

      if (originalResponseText?.get) {
        Object.defineProperty(xhr, "responseText", {
          get: function () {
            const response = originalResponseText.get.call(this);
            if (collectPattern.test(url) || feedPattern.test(url)) {
              handleResponse(response);
            }
            return response;
          },
          configurable: true
        });
      }
    };
  }

  // 处理收藏接口的笔记结构
  function processCollectNote(rawNote) {
    try {
      return {
        id: rawNote.note_id,
        title: rawNote.display_title || '无标题',
        desc: '',
        time: Date.now(),
        user: {
          nickname: rawNote.user?.nickname || '匿名用户',
          userId: rawNote.user?.user_id || '未知用户'
        },
        images: [{
          url: rawNote.cover?.url_default || '',
          name: ''
        }],
        video: rawNote.type === 'video' ? {
          url: `https://sns-video-bd.xhscdn.com/${rawNote.video?.consumer?.originVideoKey}`,
          name: ''
        } : null
      };
    } catch (e) {
      console.error('收藏笔记处理失败:', e);
      return null;
    }
  }

  // 处理动态流笔记结构
  function processNote(rawNote) {
    return {
      id: rawNote.id,
      title: rawNote.title || '无标题',
      desc: rawNote.desc || '无描述',
      time: rawNote.time || Date.now(),
      user: {
        nickname: rawNote.user?.nickname || '匿名用户',
        userId: rawNote.user?.userId || '未知用户'
      },
      images: rawNote.imageList?.map(img => ({
        url: img.urlDefault || '',
        name: ''
      })) || [],
      video: rawNote.video ? {
        url: `https://sns-video-bd.xhscdn.com/${rawNote.video.consumer.originVideoKey}`,
        name: ''
      } : null
    };
  }

  // 生成Markdown
  function generateMarkdown(note) {
    const artile = `# ${note.title}\n\n` +
      `**作者**: ${note.user.nickname} (ID: ${note.user.userId})\n\n` +
      `${note.desc}\n\n` +
      `${note.images.map(img => `![图片](${img.name})`).join('\n')}\n` +
      `${note.video ? `\n[视频](${note.video.name})` : ''}`;
      console.log("按模板导出笔记:" + artile)
    return artile
  }

  // 初始化
  function init() {
    createExportPanel();
    interceptRequests();

    // document.getElementById('exportMd').addEventListener('click', async () => {
    //   console.log("检测到点击导出 Markdown");
    //   for (const note of capturedNotes) {
    //     const folder = zip.folder(note.id);

        // 下载图片
        // for (const [index, img] of note.images.entries()) {
        //   if (img.url) {
        //     try {
        //       const response = await GM.xmlHttpRequest({
        //         method: "GET",
        //         url: img.url,
        //         responseType: "blob"
        //       });
        //       folder.file(`image_${index+1}.jpg`, response.response);
        //       img.name = `image_${index+1}.jpg`;
        //     } catch (error) {
        //       console.error('图片下载失败:', error);
        //     }
        //   }
        // }

        // 下载视频
        // if (note.video?.url) {
        //   try {
        //     const response = await GM.xmlHttpRequest({
        //       method: "GET",
        //       url: note.video.url,
        //       responseType: "blob"
        //     });
        //     folder.file(`video.mp4`, response.response);
        //     note.video.name = `video.mp4`;
        //   } catch (error) {
        //     console.error('视频下载失败:', error);
        //   }
        // }

    //     folder.file('note.md', generateMarkdown(note));
    //   }
    // });

    // document.getElementById('exportZip').addEventListener('click', () => {
    //   console.log("检测到点击导出 ZIP");
    //   zip.generateAsync({type:"blob"}).then(content => {
    //     saveAs(content, "xhs-notes.zip");
    //   });
    // });
  }



  async function handleExportMd() {
    console.log('开始导出Markdown');
    if (capturedNotes.length === 0) {
      return alert('请先浏览内容确保捕获到笔记');
    }

    try {
      for (const note of capturedNotes) {
        const folder = zip.folder(note.id);
        // 添加媒体文件下载逻辑
        // await processMedia(note, folder);
        // 生成Markdown文件
        folder.file('note.md', generateMarkdown(note));
      }
      alert('Markdown准备完成,请点击打包下载');
    } catch (error) {
      console.error('导出失败:', error);
      alert('导出失败,请检查控制台日志');
    }
  }

  function handleExportZip() {
    console.log('开始生成ZIP');
    if (!zip.files || Object.keys(zip.files).length === 0) {
      return alert('请先点击导出Markdown');
    }

    zip.generateAsync({ type: 'blob' }).then(content => {
      const filename = `xhs-notes-${Date.now()}.zip`;
      const link = document.createElement('a');
      link.href = URL.createObjectURL(content);
      link.download = filename;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(link.href);
      console.log('ZIP文件已生成:', filename);
    }).catch(error => {
      console.error('ZIP生成失败:', error);
      alert('打包失败,请检查控制台日志');
    });
  }


  // 在媒体下载部分修改GM.xmlHttpRequest调用
  async function downloadMedia(url, filename) {
    try {
      const response = await GM.xmlHttpRequest({
        method: "GET",
        url: url,
        headers: {
          'Referer': 'https://www.xiaohongshu.com/',
          'User-Agent': navigator.userAgent,
          'Origin': 'https://www.xiaohongshu.com',
          'Accept': 'image/webp,image/apng,image/*,*/*;q=0.8',
          'Sec-Fetch-Dest': 'image',
          'Sec-Fetch-Mode': 'no-cors',
          'Sec-Fetch-Site': 'same-site'
        },
        responseType: "blob",
        anonymous: true // 重要:不发送cookie
      });

      if (response.status >= 200 && response.status < 300) {
        return response.response;
      }
      throw new Error(`HTTP ${response.status}`);
    } catch (error) {
      console.error('下载失败:', url, error);
      return null;
    }
  }

  // 修改后的处理函数
  async function processMedia(note) {
    const mediaFolder = zip.folder(note.id);

    // 处理图片
    for (const [index, img] of note.images.entries()) {
      if (img.url) {
        const blob = await downloadMedia(img.url);
        if (blob) {
          mediaFolder.file(`image_${index+1}.jpg`, blob);
          img.name = `image_${index+1}.jpg`;
        }
      }
    }

    // 处理视频
    if (note.video?.url) {
      const blob = await downloadMedia(note.video.url);
      if (blob) {
        mediaFolder.file(`video.mp4`, blob);
        note.video.name = `video.mp4`;
      }
    }
  }

  init();
})();

打包下载 ZIP

跟文件大小和数量没有关系

// ==UserScript==
// @name         数据导出为 ZIP
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  捕获当前页面数据,生成 Markdown 文件并打包为 ZIP 下载
// @author       你
// @match        https://www.xiaohongshu.com/*
// @grant        none
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// ==/UserScript==
 
(function() {
    'use strict';
 
    // 创建 ZIP 并下载
    function exportToZip() {
        const data = captureData();
        const zip = new JSZip();
 
        for(var i =0;i<10000;i++){
            zip.file(`meta-${i}.json`, JSON.stringify(data, null, 2));
        }
        
        zip.generateAsync({ type: 'blob' }).then(content => {
            saveAs(content, `${data.title.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
        });
    }
    
    exportToZip()
})();

跑通 Demo

// ==UserScript==
// @name         数据导出为 ZIP(修复版)
// @namespace    http://tampermonkey.net/
// @version      2.0
// @match        https://www.xiaohongshu.com/*
// @grant        unsafeWindow
// ==/UserScript==
 
(function() {
    'use strict';
 
    // 动态注入 JSZip 到页面环境
    const injectScript = (url, callback) => {
        const script = document.createElement('script');
        script.src = url;
        script.onload = callback;
        document.head.appendChild(script);
    };
 
    // 第一步:加载 JSZip
    injectScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', () => {
        // 第二步:加载 FileSaver
        injectScript('https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js', () => {
            // 确保库已加载
            const { JSZip, saveAs } = unsafeWindow;
 
            // 创建 ZIP 实例
            const zip = new JSZip();
 
            // 添加示例文件
            for (let i = 0; i < 10; i++) {
                zip.file(`meta-${i}.json`, 'debug content');
            }
 
            // 生成并下载
            zip.generateAsync({ type: 'blob' }).then(content => {
                saveAs(content, 'xhs.zip');
            });
        });
    });
})();

另一种方式注入还是没有办法

// ==UserScript==
// @name         数据导出为 ZIP(修复版)
// @namespace    http://tampermonkey.net/
// @version      2.0
// @match        https://x.com/*
// @grant        unsafeWindow
// @grant        GM.xmlHttpRequest
// @grant        GM_addStyle
// ==/UserScript==

(function() {
    'use strict';

       // 1. 通过GM.xmlHttpRequest获取脚本内容
    const loadScript = (url, callback) => {
        GM.xmlHttpRequest({
            method: "GET",
            url: url,
            onload: (res) => {
                // 2. 创建Blob URL绕过CSP
                const blob = new Blob([res.response], { type: "text/javascript" });
                const url = URL.createObjectURL(blob);
                const script = document.createElement('script');
                script.src = url;
                script.onload = () => URL.revokeObjectURL(url);
                document.head.appendChild(script);
                callback();
            }
        });
    };

      // 3. 链式加载库
    loadScript('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js', () => {
        loadScript('https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js', () => {

            // 确保库已加载
            const { JSZip, saveAs } = unsafeWindow;

            // 创建 ZIP 实例
            const zip = new JSZip();

            // 添加示例文件
            for (let i = 0; i < 10; i++) {
                zip.file(`meta-${i}.json`, 'debug content');
            }

            // 生成并下载
            zip.generateAsync({ type: 'blob' }).then(content => {
                saveAs(content, 'xhs.zip');
            });

        });
    });



})();

Problems

xsec_token 过期,生成的未必是永久链接 wontfix

https://www.xiaohongshu.com/explore/66fa0075000000002c014d4c?xsec_token=AB1VctWn-DyPKogC3hpr1NlSKFmr1heOIW3HEn7naWRFU=

问题:grant closed

最后定位到是 的问题,只有在 @grant none 的时候,zip.generateAsync 才能生成文件成功

只要引入了 unsafeWindow/GM_addStyle/GM.xmlHttpRequest 或者删除 @grant none 都会失效。

相关的 ISSUE

CSP (Content Security Policy) Limit

FF 上已经把 Missing Content Security Policy enable/disable 移除掉了

但是可以通过拓展临时禁用一下

Reference