您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
[终极优化] 高性能、秒加载!给小红书笔记增加勾选功能,并在右侧用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(); })();