小红书笔记勾选收藏 (终极优化版)

[终极优化] 高性能、秒加载!给小红书笔记增加勾选功能,并在右侧用Handsontable表格显示已选笔记。

// ==UserScript==
// @name         小红书笔记勾选收藏 (终极优化版)
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  [终极优化] 高性能、秒加载!给小红书笔记增加勾选功能,并在右侧用Handsontable表格显示已选笔记。
// @author       qjj
// @match        https://www.xiaohongshu.com/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/handsontable.full.min.js
// @resource     HOT_CSS https://cdn.bootcdn.net/ajax/libs/handsontable/15.2.0/handsontable.full.min.css
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-idle
// @license        MIT
// ==/UserScript==

(function () {
  'use strict';

  // --- 1. 立即注入CSS ---
  const hotCss = GM_getResourceText('HOT_CSS');
  GM_addStyle(hotCss);
  GM_addStyle(`

 /* --- 方案一:精致对勾动画 --- */


.xhs-checkbox {
    /* 移除原生input外观 */
    -webkit-appearance: none;
    -moz-appearance: none;
    appearance: none;

    /* 基础样式 */
    position: absolute;
    top: 10px;
    left: 10px;
    z-index: 100;
    cursor: pointer;

    /* 尺寸和形状 */
    width: 22px;
    height: 22px;
    border-radius: 50%; /* 圆形 */
    border: 2px solid rgba(0, 0, 0, 0.2); /* 默认边框 */
    background-color: rgba(255, 255, 255, 0.7); /* 半透明背景 */

    /* 过渡动画 */
    transition: all 0.2s ease-in-out;
}



/* 选中状态的样式 */
.xhs-checkbox.checked {
    background-color: #ff2442; /* 小红书红色 */
    border-color: #ff2442;
}

/* 使用 ::after 伪元素来创建“对勾” */
.xhs-checkbox.checked::after {
    content: '';
    position: absolute;
    left: 7px;
    top: 3px;

    /* 对勾的形状 */
    width: 6px;
    height: 12px;
    border: solid white;
    border-width: 0 3px 3px 0;

    /* 旋转和动画 */
    transform: rotate(45deg);
    animation: checkmark-animation 0.2s ease-in-out forwards;
}

/* 对勾出现的动画 */
@keyframes checkmark-animation {
    0% {
        height: 0;
        width: 0;
        opacity: 0;
    }
    50% {
        height: 0;
        width: 6px; /* 先画短边 */
        opacity: 0.5;
    }
    100% {
        height: 12px; /* 再画长边 */
        width: 6px;
        opacity: 1;
    }
}

    .xhs-note-card { position: relative; }
    .hot-table-wrapper {
        position: fixed; top: 60px; right: 0px; width: 700px;
        height: calc(100vh - 120px); background-color: white; z-index: 9999;
        border-radius: 8px 0 0 8px; box-shadow: -2px 0 10px rgba(0,0,0,0.1);
        transition: right 0.3s ease; overflow: hidden; display: flex; flex-direction: column;
    }
    .hot-table-wrapper.minimized { height: 40px !important; overflow: hidden; }
    .hot-table-header {
        padding: 12px 16px; background-color: #ff2442; color: white;
        font-weight: bold; display: flex; justify-content: space-between; align-items: center;
    }
    .hot-table-btns span {
        color: white; cursor: pointer; font-size: 12px; padding: 4px 8px;
        border-radius: 4px; transition: background-color 0.2s; margin-left: 10px;
    }
    .hot-table-btns span:hover { background-color: rgba(255,255,255,0.2); }
    .hot-table-toggle {
        position: absolute; left: -40px; top: 20px; width: 40px; height: 40px;
        background-color: #ff2442; color: white; border-radius: 8px 0 0 8px;
        display: flex; align-items: center; justify-content: center;
        cursor: pointer; box-shadow: -2px 2px 5px rgba(0,0,0,0.1);
    }
    .hot-table-content { flex: 1; overflow-y: auto; padding: 0; }
    .hot-empty-state { text-align: center; padding: 30px 0; color: #999; }
    .hot-delete-btn {
        background: none; border: none; color: #ff2442; cursor: pointer;
        font-size: 14px; padding: 4px 8px; border-radius: 4px; transition: all 0.2s;
    }
    .hot-delete-btn:hover { background-color: #ff2442; color: white; transform: scale(1.1); }
    .hot-table-wrapper .handsontable .htCore th { background-color: #f8f9fa; font-weight: bold; text-align: center; }
    .hot-table-wrapper .handsontable .htCore td { vertical-align: middle; }
    .hot-pagination {
        display: flex; justify-content: space-between; align-items: center;
        padding: 10px 16px; background-color: #f8f9fa; border-top: 1px solid #dee2e6;
    }
    .page-info { font-size: 14px; color: #666; }
    .page-controls { display: flex; gap: 8px; }
    .page-btn {
        padding: 6px 12px; border: 1px solid #ddd; background-color: white; color: #333;
        border-radius: 4px; cursor: pointer; font-size: 12px; transition: all 0.2s;
    }
    .page-btn:hover:not(:disabled) { background-color: #ff2442; color: white; border-color: #ff2442; }
    .page-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    .page-btn.active { background-color: #ff2442; color: white; border-color: #ff2442; }
  `);

  // --- 2. 立即注册中文语言包 ---
  if (window.Handsontable && window.Handsontable.languages) {
    window.Handsontable.languages.registerLanguageDictionary('zh-CN', {
      language: 'zh-CN',
      dictionary: {
        'Copy': '复制', 'Cut': '剪切', 'Paste': '粘贴', 'Undo': '撤销', 'Redo': '重做',
        'Insert row above': '在上方插入行', 'Insert row below': '在下方插入行', 'Remove row': '删除行',
        'Insert column left': '在左侧插入列', 'Insert column right': '在右侧插入列', 'Remove column': '删除列',
        'Clear column': '清空列', 'Select all': '全选', 'No data available': '暂无数据'
        // ... (可以添加更多翻译)
      }
    });
  }

  // --- 全局变量 ---
  let selectedNotes = [];
  let currentPage = 0;
  const pageSize = 20;
  let hot = null;

  // --- IndexedDB 存储封装 ---
  const indexedDBStorage = {
    dbName: "XHSNotesDB",
    storeName: "notes",
    version: 3,
    _db: null,
    async openDB() {
      if (this._db) return this._db;
      return new Promise((resolve, reject) => {
        const request = indexedDB.open(this.dbName, this.version);
        request.onupgradeneeded = (event) => {
          const db = event.target.result;
          if (!db.objectStoreNames.contains(this.storeName)) {
            db.createObjectStore(this.storeName, { keyPath: "id" });
          }
        };
        request.onsuccess = (event) => {
          this._db = event.target.result;
          resolve(this._db);
        };
        request.onerror = (event) => reject(event.target.error);
      });
    },
    async addNote(note) { const db = await this.openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, "readwrite"); tx.objectStore(this.storeName).add(note).onsuccess = () => resolve(note); tx.onerror = (e) => reject(e.target.error); }); },
    async deleteNote(noteId) { const db = await this.openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, "readwrite"); tx.objectStore(this.storeName).delete(noteId).onsuccess = resolve; tx.onerror = (e) => reject(e.target.error); }); },
    async getAllNotes() { const db = await this.openDB(); return new Promise((resolve) => { const tx = db.transaction(this.storeName, "readonly"); tx.objectStore(this.storeName).getAll().onsuccess = (e) => resolve(e.target.result); tx.onerror = () => resolve([]); }); },
    async updateNote(note) { const db = await this.openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, "readwrite"); tx.objectStore(this.storeName).put(note).onsuccess = () => resolve(note); tx.onerror = (e) => reject(e.target.error); }); },
    async clearAllNotes() { const db = await this.openDB(); return new Promise((resolve, reject) => { const tx = db.transaction(this.storeName, "readwrite"); tx.objectStore(this.storeName).clear().onsuccess = resolve; tx.onerror = (e) => reject(e.target.error); }); }
  };

  // --- UI & 表格核心函数 ---
  function createHotTableWrapper() {
    let wrapper = document.querySelector('.hot-table-wrapper');
    if (wrapper) return wrapper;
    wrapper = document.createElement('div');
    wrapper.className = 'hot-table-wrapper';
    wrapper.innerHTML = `
      <div class="hot-table-toggle">≡</div>
      <div class="hot-table-header">
        已选笔记 (<span class="note-count">0</span>)
        <div class="hot-table-btns">
          <span class="click-all" title="勾选当前页面所有可见笔记">全选当前页</span>
          <span class="delete-selected" title="删除表格中高亮选中的行">删除选中</span>
          <span class="download-btn" title="将所有已选笔记数据下载为Excel文件">下载数据</span>
          <span class="paste-btn" title="从剪贴板粘贴数据到“账号”列">粘贴数据</span>
          <span class="minimize-btn" title="最小化">—</span>
          <span class="restore-btn" title="恢复">+</span>
          <span class="clear-all" title="清空所有已选笔记和数据">清空</span>
        </div>
      </div>
      <div class="hot-table-content">
        <div id="hot-table"></div>
        <div class="hot-pagination">
          <span class="page-info">第 <span class="current-page">1</span> 页 / 共 <span class="total-pages">1</span> 页</span>
          <div class="page-controls">
            <button class="page-btn" data-action="first">首页</button>
            <button class="page-btn" data-action="prev">上一页</button>
            <button class="page-btn" data-action="next">下一页</button>
            <button class="page-btn" data-action="last">末页</button>
          </div>
        </div>
      </div>
    `;
    document.body.appendChild(wrapper);

    // 事件绑定
    wrapper.querySelector('.hot-table-toggle').addEventListener('click', () => wrapper.classList.toggle('open'));
    wrapper.querySelector('.minimize-btn').addEventListener('click', () => wrapper.classList.add('minimized'));
    wrapper.querySelector('.restore-btn').addEventListener('click', () => wrapper.classList.remove('minimized'));
    wrapper.querySelector('.clear-all').addEventListener('click', clearAllNotes);
    wrapper.querySelector('.click-all').addEventListener('click', selectAllVisibleNotes);
    wrapper.querySelector('.delete-selected').addEventListener('click', deleteSelectedHotRows);
    wrapper.querySelector('.download-btn').addEventListener('click', downloadAllData);
    wrapper.querySelector('.paste-btn').addEventListener('click', handlePasteData);
    wrapper.querySelectorAll('.page-btn').forEach(btn => btn.addEventListener('click', handlePageChange));
    return wrapper;
  }

  function renderHotTable() {
    const container = document.getElementById('hot-table');
    if (!container) return;

    const currentPageData = getCurrentPageData();

    if (hot) {
      hot.loadData(currentPageData);
    } else {
      hot = new Handsontable(container, {
        data: currentPageData,
        colHeaders: ['封面', '标题', '作者', '账号', '商品', '操作'],
        columns: [
          { data: 'cover', renderer: (instance, td, row, col, prop, value) => { td.innerHTML = `<img src="${value}" style="width:40px;height:40px;object-fit:cover;border-radius:4px;">`; }, readOnly: true, width: 60 },
          { data: 'title', readOnly: true, width: 180 },
          { data: 'author', readOnly: true, width: 100 },
          { data: 'account', width: 100 },
          { data: 'product', width: 100 },
          { data: 'id', renderer: (instance, td, row, col, prop, value) => { td.innerHTML = `<button class="hot-delete-btn" title="删除此行" data-note-id="${value}">🗑️</button>`; td.style.textAlign = 'center'; }, readOnly: true, width: 60 }
        ],
        stretchH: 'all', width: '100%', height: '100%', licenseKey: 'non-commercial-and-evaluation',
        rowHeights: 44, manualRowResize: true, manualColumnResize: true, wordWrap: false,
        language: 'zh-CN',
        afterChange: async (changes, source) => {
          if (source === 'loadData' || !changes) return;
          for (const [rowIndex, prop, oldVal, newVal] of changes) {
            if (oldVal !== newVal) {
              const actualIndex = currentPage * pageSize + rowIndex;
              if (selectedNotes[actualIndex]) {
                selectedNotes[actualIndex][prop] = newVal;
                await indexedDBStorage.updateNote(selectedNotes[actualIndex]);
              }
            }
          }
        },
        afterOnCellMouseDown: (event, coords) => {
          if (coords.col === 5 && event.target.classList.contains('hot-delete-btn')) {
            const noteId = event.target.dataset.noteId;
            deleteNoteById(noteId);
          }
        },
      });
    }
    updateNoteCount();
    updatePagination();
  }

  // --- 数据与状态更新函数 ---
  function getCurrentPageData() { return selectedNotes.slice(currentPage * pageSize, (currentPage + 1) * pageSize); }
  function updateNoteCount() { const el = document.querySelector('.note-count'); if (el) el.textContent = selectedNotes.length; }
  function updateCheckboxState(noteId, isChecked) {
    const checkbox = document.querySelector(`.xhs-checkbox[data-id="${noteId}"]`);
    if (checkbox) {
        checkbox.checked = isChecked;
        checkbox.classList.toggle('checked', isChecked);
    }
  }

  // --- 分页逻辑 ---
  function updatePagination() {
    const wrapper = document.querySelector('.hot-table-wrapper');
    if (!wrapper) return;
    const totalPages = Math.ceil(selectedNotes.length / pageSize) || 1;
    wrapper.querySelector('.current-page').textContent = currentPage + 1;
    wrapper.querySelector('.total-pages').textContent = totalPages;
    wrapper.querySelectorAll('.page-btn').forEach(btn => {
      const action = btn.dataset.action;
      btn.disabled = (action === 'first' || action === 'prev') ? currentPage === 0 : currentPage >= totalPages - 1;
    });
  }

  function handlePageChange(e) {
    const action = e.target.dataset.action;
    const totalPages = Math.ceil(selectedNotes.length / pageSize) || 1;
    const oldPage = currentPage;
    switch (action) {
      case 'first': currentPage = 0; break;
      case 'prev': currentPage = Math.max(0, currentPage - 1); break;
      case 'next': currentPage = Math.min(totalPages - 1, currentPage + 1); break;
      case 'last': currentPage = totalPages - 1; break;
    }
    if (oldPage !== currentPage) renderHotTable();
  }

  // --- 核心交互逻辑 ---
  async function toggleNoteSelection(e) {
    e.stopPropagation(); e.preventDefault();
    const checkbox = e.currentTarget;
    const note = {
      id: checkbox.dataset.id, title: checkbox.dataset.title, author: checkbox.dataset.author,
      cover: checkbox.dataset.cover, href: checkbox.dataset.href, account: '', product: '', isUse: 0
    };
    const isCurrentlyChecked = checkbox.classList.contains('checked');

    try {
      if (isCurrentlyChecked) {
        await indexedDBStorage.deleteNote(note.id);
        selectedNotes = selectedNotes.filter(n => n.id !== note.id);
      } else {
        await indexedDBStorage.addNote(note);
        selectedNotes.push(note);
      }
      updateCheckboxState(note.id, !isCurrentlyChecked);
      renderHotTable();
    } catch (error) { console.error('切换笔记选中状态失败:', error); alert('操作失败,请重试!'); }
  }

  async function deleteNoteById(noteId) {
    const note = selectedNotes.find(n => n.id === noteId);
    if (note && confirm(`确定要删除笔记 "${note.title}" 吗?`)) {
      try {
        await indexedDBStorage.deleteNote(noteId);
        selectedNotes = selectedNotes.filter(n => n.id !== noteId);
        const totalPages = Math.ceil(selectedNotes.length / pageSize);
        if (currentPage >= totalPages && currentPage > 0) currentPage = totalPages - 1;
        renderHotTable();
        updateCheckboxState(noteId, false);
      } catch (error) { console.error('删除笔记失败:', error); alert('删除失败,请重试!'); }
    }
  }

  async function deleteSelectedHotRows() {
    if (!hot) return;
    const selectedRanges = hot.getSelected();
    if (!selectedRanges || selectedRanges.length === 0) return alert('请先在表格中选择要删除的行!');

    const rowsToDelete = new Set();
    selectedRanges.forEach(range => {
      for (let i = Math.min(range[0], range[2]); i <= Math.max(range[0], range[2]); i++) {
        rowsToDelete.add(i);
      }
    });

    if (confirm(`确定要删除表格中选中的 ${rowsToDelete.size} 行笔记吗?`)) {
        const noteIdsToDelete = Array.from(rowsToDelete).map(rowIndex => getCurrentPageData()[rowIndex]?.id).filter(Boolean);
        for (const noteId of noteIdsToDelete) {
            await indexedDBStorage.deleteNote(noteId);
            updateCheckboxState(noteId, false);
        }
        selectedNotes = selectedNotes.filter(note => !noteIdsToDelete.includes(note.id));
        renderHotTable();
    }
  }

  function selectAllVisibleNotes() {
    document.querySelectorAll('.note-item:not(:has(.xhs-checkbox.checked)) .xhs-checkbox').forEach(cb => cb.click());
  }

  async function clearAllNotes() {
    if (confirm("警告:此操作将永久清空所有已选笔记数据,无法恢复!\n\n确定要继续吗?")) {
      await indexedDBStorage.clearAllNotes();
      selectedNotes = [];
      currentPage = 0;
      renderHotTable();
      document.querySelectorAll('.xhs-checkbox.checked').forEach(cb => updateCheckboxState(cb.dataset.id, false));
    }
  }

  async function downloadAllData() {
    if (selectedNotes.length === 0) return alert('暂无数据可下载!');
    try {
      const excelData = selectedNotes.map(note => ({
        '笔记ID': note.id || '', '标题': note.title || '', '作者': note.author || '',
        '封面链接': note.cover || '', '笔记链接': note.href || '', '账号': note.account || '',
        '商品': note.product || '', '使用状态': note.isUse || "未使用",
        '导出时间': new Date().toISOString()
      }));
      const csvContent = [
        Object.keys(excelData[0]).join(','),
        ...excelData.map(row => Object.values(row).map(val => `"${String(val).replace(/"/g, '""')}"`).join(','))
      ].join('\n');
      const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
      const link = document.createElement('a');
      link.href = URL.createObjectURL(blob);
      link.download = `小红书笔记数据_${new Date().toISOString().slice(0, 10)}.csv`;
      link.click();
      URL.revokeObjectURL(link.href);
    } catch (error) { console.error('下载数据失败:', error); alert('下载失败: ' + error.message); }
  }

  async function handlePasteData() {
    try {
      const text = await navigator.clipboard.readText();
      const lines = text.split(/\r?\n/).filter(line => line.trim() !== '');
      if (lines.length === 0) return;

      const currentPageData = getCurrentPageData();
      for (let i = 0; i < Math.min(lines.length, currentPageData.length); i++) {
        const actualIndex = currentPage * pageSize + i;
        if (selectedNotes[actualIndex]) {
            selectedNotes[actualIndex].account = lines[i].trim();
            await indexedDBStorage.updateNote(selectedNotes[actualIndex]);
        }
      }
      renderHotTable();
    } catch (error) { alert('粘贴数据失败: ' + error.message); }
  }

  // --- DOM 观察与动态添加 ---
  function addCheckboxToCard(card) {
      if (card.querySelector('.xhs-checkbox')) return;
      const link = card.querySelector('a + a[href*="/user/"]');
      const noteId = link?.href.split("/").pop().split("?")[0];
      if (noteId) {
          const title = card.querySelector('[class*="title"]')?.textContent.trim() || card.querySelector('img')?.alt || '未知标题';
          const author = card.querySelector('[class*="author-name"]')?.textContent.trim() || '未知作者';
          const cover = card.querySelector('img')?.src;
          const href = link.href;

          const checkbox = document.createElement('input');
          checkbox.type = 'checkbox';
          checkbox.className = 'xhs-checkbox';
          Object.assign(checkbox.dataset, { id: noteId, title, author, cover, href });

          checkbox.checked = selectedNotes.some(note => note.id === noteId);
          checkbox.classList.toggle('checked', checkbox.checked);

          checkbox.addEventListener('click', toggleNoteSelection);


        checkbox.addEventListener('mouseenter', (e) => {
            e.stopPropagation(); // 阻止 mouseenter 事件冒泡到父级卡片
        });

        checkbox.addEventListener('mouseleave', (e) => {
            e.stopPropagation(); // 阻止 mouseleave 事件冒泡到父级卡片
        });


          card.classList.add('xhs-note-card');
          card.appendChild(checkbox);
      }
  }

  function setupMutationObserver() {
    const observerCallback = (mutationsList) => {
      for (const mutation of mutationsList) {
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
          mutation.addedNodes.forEach(node => {
            if (node.nodeType === 1) { // 确保是元素节点
              if (node.matches('.note-item')) addCheckboxToCard(node);
              else node.querySelectorAll('.note-item').forEach(addCheckboxToCard);
            }
          });
        }
      }
    };
    const observer = new MutationObserver(observerCallback);
    const intervalId = setInterval(() => {
      const targetNode = document.querySelector('#exploreFeeds,.feeds-container, .note-list-container, .content-container');
      if (targetNode) {
        clearInterval(intervalId);
        observer.observe(targetNode, { childList: true, subtree: true });
        // 首次加载时,也处理一下已存在的笔记
        targetNode.querySelectorAll('.note-item').forEach(addCheckboxToCard);
      }
    }, 500);
    setTimeout(() => clearInterval(intervalId), 15000); // 15秒后超时
  }

  // --- 初始化函数 ---
  async function init() {
    console.log('小红书助手脚本 v1.0 初始化...');
    const notesPromise = indexedDBStorage.getAllNotes();
    createHotTableWrapper();
    setupMutationObserver();
    selectedNotes = await notesPromise;
    console.log(`从IndexedDB加载了 ${selectedNotes.length} 条笔记`);
    renderHotTable();
    console.log('脚本初始化完毕。');
  }

  init();

})();