您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
记录b站发送的评论和弹幕,防止重要评论的丢失
// ==UserScript== // @name B站评论弹幕收藏夹 // @version 0.1.4 // @description 记录b站发送的评论和弹幕,防止重要评论的丢失 // @author naaammme // @match *://www.bilibili.com/* // @grant GM_addStyle // @run-at document-idle // @license AGPL-3.0-or-later // @icon https://www.bilibili.com/favicon.ico // @namespace https://greasyfork.org/users/1508061 // ==/UserScript== (function() { 'use strict'; // ===================================================工具函数===================================================== function generateUniqueKey(type, data) { switch(type) { case 'sentComment': if (!data.mainComment) return null; return data.mainComment.userName + '|||' + data.mainComment.content + '|||' + data.videoBvid; case 'danmaku': return data.text + '|||' + data.videoId + '|||' + Math.floor(data.timestamp / 60000); case 'comment': return data.key || (data.userName + '|||' + (data.content || data.text)); default: return null; } } function escapeHtml(unsafe) { return unsafe .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function generateContextId() { return 'ctx_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); } // ====================================================配置模块======================================================= // 配置常量= const CONFIG = { DB_NAME: 'BilibiliCollectorDB', DB_VERSION: 2, DEBUG: false, STORES: { COMMENTS: 'comments', DANMAKU: 'danmaku', SENT_COMMENTS: 'sent_comments' }, DEFAULT_LIMITS: { DISPLAY_COMMENTS: 50, DISPLAY_DANMAKU: 50, SENT_COMMENTS: 50 }, PAGINATION: { PAGE_SIZE: 10, MAX_PAGE_BUTTONS: 5 }, PERFORMANCE: { RETRY_BASE_DELAY: 300, // 基础重试延迟 RETRY_MAX_DELAY: 3000, // 最大重试延迟 RETRY_MULTIPLIER: 1.5, // 重试延迟递增倍数 MUTATION_THROTTLE: 300, // MutationObserver节流时间 SEARCH_TIMEOUT: 30000, // 搜索超时时间 RANDOM_FACTOR: 0.3, // 随机因子 MAX_CACHE_SIZE: 1000 } }; let db = null; let recordedThreads = new Map(); let recordedDanmaku = []; let sentComments = []; let currentVideoInfo = null; let replyContextMap = new Map(); let settings = { ...CONFIG.DEFAULT_LIMITS }; const domCache = new Map(); const cacheTimestamps = new Map(); let paginationState = { sentComments: { currentPage: 1, searchTerm: '', filteredData: [] }, comments: { currentPage: 1, searchTerm: '', filteredData: [] }, danmaku: { currentPage: 1, searchTerm: '', filteredData: [] } }; function setDB(database) { db = database; } function setSettings(newSettings) { settings = { ...settings, ...newSettings }; } function setCurrentVideoInfo(info) { currentVideoInfo = info; } function clearReplyContextMap() { replyContextMap.clear(); } function resetPaginationState(type) { if (type && paginationState[type]) { paginationState[type].currentPage = 1; paginationState[type].searchTerm = ''; paginationState[type].filteredData = []; } else { for (const key in paginationState) { paginationState[key].currentPage = 1; paginationState[key].searchTerm = ''; paginationState[key].filteredData = []; } } } function getCachedElement(key, selector, parent = document) { const cached = domCache.get(key); if (cached) { if (cached.isConnected) { return cached; } else { domCache.delete(key); cacheTimestamps.delete(key); if (CONFIG.DEBUG) console.log(`[DOM缓存] 清理失效元素: ${key}`); } } const element = parent.querySelector(selector); if (element) { if (domCache.size >= CONFIG.PERFORMANCE.MAX_CACHE_SIZE) { const oldestKeys = Array.from(cacheTimestamps.entries()) .sort((a, b) => a[1] - b[1]) .slice(0, 10) .map(([key]) => key); oldestKeys.forEach(oldKey => { domCache.delete(oldKey); cacheTimestamps.delete(oldKey); }); if (CONFIG.DEBUG) console.log(`[DOM缓存] 清理了 ${oldestKeys.length} 个最老的缓存项`); } domCache.set(key, element); cacheTimestamps.set(key, Date.now()); } return element; } function clearAllDomCache() { const size = domCache.size; domCache.clear(); cacheTimestamps.clear(); if (CONFIG.DEBUG) console.log(`[DOM缓存] 页面切换,清理了 ${size} 个缓存项`); } // 手动清理失效的DOM缓存(暂时不加入,未来用于调试) function cleanInvalidDomCache() { const invalidKeys = []; for (const [key, element] of domCache.entries()) { if (!element || !element.isConnected) { invalidKeys.push(key); } } invalidKeys.forEach(key => { domCache.delete(key); cacheTimestamps.delete(key); }); if (CONFIG.DEBUG && invalidKeys.length > 0) { console.log(`[DOM缓存] 手动清理了 ${invalidKeys.length} 个失效缓存`); } return invalidKeys.length; } function getRandomDelay(baseDelay) { const randomFactor = 1 + (Math.random() - 0.5) * CONFIG.PERFORMANCE.RANDOM_FACTOR * 2; return Math.round(baseDelay * randomFactor); } function getRetryDelay(attemptCount) { const delay = Math.min( CONFIG.PERFORMANCE.RETRY_BASE_DELAY * Math.pow(CONFIG.PERFORMANCE.RETRY_MULTIPLIER, attemptCount), CONFIG.PERFORMANCE.RETRY_MAX_DELAY ); return getRandomDelay(delay); } // ============================================数据模块================================================= class DatabaseManager { static async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(CONFIG.DB_NAME, CONFIG.DB_VERSION); request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; setDB(database); resolve(database); }; request.onupgradeneeded = (event) => { const database = event.target.result; setDB(database); if (!database.objectStoreNames.contains(CONFIG.STORES.COMMENTS)) { const commentStore = database.createObjectStore(CONFIG.STORES.COMMENTS, { keyPath: 'id' }); commentStore.createIndex('timestamp', 'timestamp', { unique: false }); } if (!database.objectStoreNames.contains(CONFIG.STORES.DANMAKU)) { const danmakuStore = database.createObjectStore(CONFIG.STORES.DANMAKU, { keyPath: 'id', autoIncrement: true }); danmakuStore.createIndex('timestamp', 'timestamp', { unique: false }); } if (!database.objectStoreNames.contains(CONFIG.STORES.SENT_COMMENTS)) { const sentStore = database.createObjectStore(CONFIG.STORES.SENT_COMMENTS, { keyPath: 'id', autoIncrement: true }); sentStore.createIndex('timestamp', 'createTime', { unique: false }); } }; }); } static async save(store, key, data) { const transaction = db.transaction([store], 'readwrite'); const objectStore = transaction.objectStore(store); if (store === CONFIG.STORES.COMMENTS) { return objectStore.put({ id: key, data: data, timestamp: Date.now() }); } else { return new Promise((resolve, reject) => { const request = objectStore.add(data); request.onsuccess = resolve; request.onerror = () => reject(request.error); }); } } static async update(store, data) { const transaction = db.transaction([store], 'readwrite'); const objectStore = transaction.objectStore(store); return objectStore.put(data); } static async delete(store, key) { const transaction = db.transaction([store], 'readwrite'); return transaction.objectStore(store).delete(key); } static async clear(store) { const transaction = db.transaction([store], 'readwrite'); return transaction.objectStore(store).clear(); } static async loadAll() { await Promise.all([ this.loadComments(), this.loadDanmaku(), this.loadSentComments() ]); } static async loadComments() { const transaction = db.transaction([CONFIG.STORES.COMMENTS], 'readonly'); const store = transaction.objectStore(CONFIG.STORES.COMMENTS); const comments = await new Promise((resolve, reject) => { const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); recordedThreads.clear(); comments.forEach(record => recordedThreads.set(record.id, record.data)); } static async loadDanmaku() { const transaction = db.transaction([CONFIG.STORES.DANMAKU], 'readonly'); const store = transaction.objectStore(CONFIG.STORES.DANMAKU); const result = await new Promise((resolve, reject) => { const request = store.openCursor(); const danmakuList = []; request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const danmaku = cursor.value; danmaku.id = cursor.key; danmakuList.push(danmaku); cursor.continue(); } else { resolve(danmakuList); } }; request.onerror = () => reject(request.error); }); recordedDanmaku.length = 0; recordedDanmaku.push(...result); } static async loadSentComments() { const transaction = db.transaction([CONFIG.STORES.SENT_COMMENTS], 'readonly'); const store = transaction.objectStore(CONFIG.STORES.SENT_COMMENTS); const result = await new Promise((resolve, reject) => { const request = store.openCursor(); const commentsList = []; request.onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const comment = cursor.value; comment.id = cursor.key; commentsList.push(comment); cursor.continue(); } else { resolve(commentsList); } }; request.onerror = () => reject(request.error); }); sentComments.length = 0; sentComments.push(...result); } } // =============================================设置模块=========================================================== class SettingsManager { static save() { localStorage.setItem('bilibili_collector_settings', JSON.stringify(settings)); } static load() { try { const saved = localStorage.getItem('bilibili_collector_settings'); if (saved) { const loadedSettings = { ...CONFIG.DEFAULT_LIMITS, ...JSON.parse(saved) }; setSettings(loadedSettings); } } catch (e) { console.error('加载设置失败:', e); setSettings({ ...CONFIG.DEFAULT_LIMITS }); } } static savePosition(position) { localStorage.setItem('bilibili_collector_float_position', JSON.stringify(position)); } static loadPosition() { try { const saved = localStorage.getItem('bilibili_collector_float_position'); return saved ? JSON.parse(saved) : null; } catch (e) { return null; } } } // ==============================================样式定义模块=================================================== const STYLES = ` #collector-float-btn{position:fixed;bottom:50px;right:50px;width:45px;height:45px;background:linear-gradient(45deg,#fb7299,#ff6b9d);border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:24px;color:#fff;cursor:move;box-shadow:0 4px 12px rgba(251,114,153,0.4);transition:all .3s ease;z-index:9999;user-select:none;touch-action:none} #collector-float-btn:hover{transform:scale(1.1);box-shadow:0 6px 20px rgba(251,114,153,0.6)} #collector-float-btn.dragging{transition:none;z-index:10000} #collector-panel{position:fixed;bottom:100px;right:30px;width:500px;max-height:70vh;background:#fff;border-radius:12px;box-shadow:0 8px 32px rgba(0,0,0,0.2);z-index:9998;font-family:sans-serif;font-size: 20px;display:flex;flex-direction:column;overflow:hidden;border:1px solid #e0e0e0} .panel-header{padding:15px 20px;background:linear-gradient(135deg,#fb7299,#ff6b9d);color:#fff;font-weight:600;display:flex;justify-content:space-between;align-items:center} .panel-close-btn{cursor:pointer;font-size:20px;opacity:0.8;transition:opacity .2s} .panel-close-btn:hover{opacity:1} .panel-content{padding:15px;overflow-y:auto;flex-grow:1;display:flex;flex-direction:column} .panel-tabs{display:flex;gap:5px;margin-bottom:15px} .tab-btn{flex:1;padding:10px;border:none;border-radius:6px;cursor:pointer;font-size:13px;background:#f5f5f5;transition:all .2s;font-weight:500} .tab-btn.active{background:#fb7299;color:#fff} .tab-btn:hover{background:#e0e0e0} .tab-btn.active:hover{background:#e55d80} .stats-bar{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px;padding:10px;background:#f8f9fa;border-radius:6px;border:1px solid #e9ecef} .btn{padding:6px 12px;border:none;border-radius:4px;cursor:pointer;font-size:12px;background:#00a1d6;color:#fff;margin-left:5px;transition:background .2s} .btn:hover{background:#0080a6} .btn.export{background:#52c41a} .btn.export:hover{background:#389e0d} .btn.danger{background:#dc3545} .btn.danger:hover{background:#c82333} .tab-content{background:#fff;border:1px solid #e9ecef;padding:15px;border-radius:6px;overflow-y:auto;flex-grow:1;max-height:400px} .history-item{margin-bottom:20px;background:white;border:1px solid #e8e8e8;border-radius:6px;overflow:hidden;cursor:pointer;transition:all 0.2s;position:relative} .history-item:hover{border-color:#00a1d6;box-shadow:0 2px 8px rgba(0,161,214,0.2)} .main-comment{padding:12px;border-bottom:1px solid #f0f0f0;background:#fafafa;position:relative} .reply-comment{padding:12px;margin-left:20px;border-left:3px solid #999;background:white;position:relative;border-bottom:1px solid #f5f5f5} .reply-comment:last-child{border-bottom:none} .third-level-comment{padding:12px;margin-left:40px;border-left:3px solid #999;background:#f9f9f9;position:relative;border-bottom:1px solid #f0f0f0} .third-level-comment:last-child{border-bottom:none} .comment-header{display:flex;align-items:center;gap:8px;margin-bottom:8px} .user-avatar{width:32px;height:32px;border-radius:50%;object-fit:cover} .user-name{font-weight:bold;color:#fb7299;text-decoration:none;font-size:14px} .user-name:hover{text-decoration:underline} .comment-content{margin-left:40px;margin-bottom:8px;line-height:1.4;color:#333} .comment-images{margin-left:40px;margin-bottom:8px;display:flex;flex-wrap:wrap;gap:6px} .comment-image{max-width:100px;max-height:100px;border-radius:4px;object-fit:cover;cursor:pointer} .comment-meta{margin-left:40px;font-size:11px;color:#999;display:flex;gap:15px} .history-time{font-size:11px;color:#999;margin-top:8px;text-align:right} .group-info{position:absolute;top:8px;right:8px;font-size:10px;color:#666;background:#e6f7ff;padding:2px 6px;border-radius:2px} .reply-indicator{font-size:10px;color:#666;background:#f0f0f0;padding:2px 4px;border-radius:2px;margin-left:8px} .third-level-count{font-size:10px;color:#52c41a;background:#f6ffed;padding:2px 4px;border-radius:2px;margin-left:8px} .jump-hint{position:absolute;bottom:5px;right:8px;font-size:10px;color:#999;opacity:0;transition:opacity 0.2s} .history-item:hover .jump-hint{opacity:1} .thread-container,.danmaku-item{position:relative;margin-bottom:15px;padding:12px;border:1px solid #e0e0e0;border-radius:8px;background:#fafafa;cursor:pointer;transition:all .2s} .thread-container:hover,.danmaku-item:hover{background:#f0f8ff;border-color:#00a1d6;transform:translateY(-1px);box-shadow:0 2px 8px rgba(0,161,214,0.1)} .delete-btn{position:absolute;top:8px;right:8px;width:24px;height:24px;border:none;background:#ff6b6b;color:#fff;border-radius:50%;cursor:pointer;font-size:14px;opacity:0;transition:all .2s;z-index:10} .thread-container:hover .delete-btn,.danmaku-item:hover .delete-btn,.history-item:hover .delete-btn{opacity:1} .delete-btn:hover{background:#ff5252;transform:scale(1.1)} .danmaku-text{font-weight:600;color:#333;margin-bottom:6px;word-break:break-word} .danmaku-meta{font-size:12px;color:#666} .danmaku-video-link{color:#00a1d6;text-decoration:none} .danmaku-video-link:hover{text-decoration:underline} .settings-section h4{margin:15px 0 10px 0;color:#333;font-size:16px} .setting-item{margin-bottom:15px;display:flex;align-items:center;gap:10px;flex-wrap:wrap} .setting-item label{min-width:120px;color:#666;font-weight:500} .setting-item input[type="number"]{padding:6px 10px;border:1px solid #ddd;border-radius:4px;width:80px} #save-settings-btn,#import-data-btn,#export-all-btn{background:#00a1d6;color:#fff;padding:8px 16px;border:none;border-radius:4px;cursor:pointer;transition:background .2s;margin-right:10px} #save-settings-btn:hover,#import-data-btn:hover,#export-all-btn:hover{background:#0080a6} #storage-info{font-size:14px;color:#333;background:#f8f9fa;padding:5px 10px;border-radius:4px} .comment-text{margin-top:6px;padding-left:42px;line-height:1.4;word-break:break-word} .comment-pictures-container{margin-left:42px;margin-top:10px;display:flex;flex-wrap:wrap;gap:8px} .recorded-reply-item{padding:10px;border-left:3px solid #999;margin-left:20px;margin-top:10px;background:#f8f9fa;border-radius:0 6px 6px 0} /* 上下文ID标识样式 */ .context-id-badge{position:absolute;top:2px;right:60px;font-size:9px;color:#999;background:#f0f0f0;padding:1px 4px;border-radius:2px;font-family:monospace} /* 分页和搜索样式 */ .search-pagination-container{margin-bottom:15px;border:1px solid #e9ecef;border-radius:6px;background:#f8f9fa} .search-container{padding:10px;border-bottom:1px solid #e9ecef} .search-input{width:100%;padding:8px 12px;border:1px solid #ddd;border-radius:4px;font-size:13px;outline:none;transition:border-color .2s} .search-input:focus{border-color:#00a1d6} .search-placeholder{color:#999;font-style:italic} .pagination-container{padding:10px;display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px} .pagination-info{font-size:12px;color:#666} .pagination-controls{display:flex;align-items:center;gap:5px} .pagination-btn{padding:6px 10px;border:1px solid #ddd;background:#fff;color:#333;border-radius:4px;cursor:pointer;font-size:12px;transition:all .2s;user-select:none} .pagination-btn:hover{background:#f5f5f5;border-color:#00a1d6} .pagination-btn.active{background:#00a1d6;color:#fff;border-color:#00a1d6} .pagination-btn.disabled{background:#f8f9fa;color:#ccc;cursor:not-allowed;border-color:#eee} .pagination-btn.disabled:hover{background:#f8f9fa;border-color:#eee} .page-size-selector{display:flex;align-items:center;gap:5px;font-size:12px;color:#666} .page-size-select{padding:4px 6px;border:1px solid #ddd;border-radius:4px;font-size:12px;outline:none} .page-size-select:focus{border-color:#00a1d6} .search-result-highlight{background:#fff3cd;padding:1px 2px;border-radius:2px} .no-results{text-align:center;color:#999;padding:40px 20px;font-size:14px} .no-results-icon{font-size:48px;margin-bottom:10px;opacity:0.5} `; // =========================================js显示模块================================================= class DisplayManager { static updateAll() { this.updateSentComments(); this.updateComments(); this.updateDanmaku(); UIManager.updateCurrentDisplayCounts(); } static updateSentComments() { const outputDiv = document.getElementById('sent-comments-output'); const countSpan = document.getElementById('sent-comment-count'); if (!outputDiv) return; const searchTerm = paginationState.sentComments.searchTerm.toLowerCase().trim(); let filteredData = sentComments; if (searchTerm) { filteredData = sentComments.filter(group => { const mainContent = group.mainComment?.content?.toLowerCase() || ''; const mainUserName = group.mainComment?.userName?.toLowerCase() || ''; const videoTitle = group.videoTitle?.toLowerCase() || ''; const replyMatches = group.replies.some(reply => { const replyContent = reply.content?.toLowerCase() || ''; const replyUserName = reply.userName?.toLowerCase() || ''; const thirdLevelMatches = reply.thirdLevelReplies?.some(third => { const thirdContent = third.content?.toLowerCase() || ''; const thirdUserName = third.userName?.toLowerCase() || ''; return thirdContent.includes(searchTerm) || thirdUserName.includes(searchTerm); }) || false; return replyContent.includes(searchTerm) || replyUserName.includes(searchTerm) || thirdLevelMatches; }); return mainContent.includes(searchTerm) || mainUserName.includes(searchTerm) || videoTitle.includes(searchTerm) || replyMatches; }); } paginationState.sentComments.filteredData = filteredData; const pageSize = UIManager.getPageSize('sentComments'); const currentPage = paginationState.sentComments.currentPage; const totalPages = Math.ceil(filteredData.length / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedData = filteredData.slice(startIndex, endIndex); countSpan.textContent = `发送记录: ${sentComments.length}组,最多保存${settings.SENT_COMMENTS}组`; this.updatePaginationInfo('sent-comments', filteredData.length, currentPage, totalPages, startIndex, endIndex); this.updatePaginationControls('sent-comments', currentPage, totalPages, 'sentComments'); if (filteredData.length === 0) { if (searchTerm) { outputDiv.innerHTML = this.renderNoResults('未找到匹配的评论记录', '🔍'); } else { outputDiv.innerHTML = '<div style="text-align:center;color:#999;padding:20px;">暂无记录</div>'; } return; } if (paginatedData.length === 0 && currentPage > 1) { paginationState.sentComments.currentPage = 1; this.updateSentComments(); return; } const highlightedHtml = paginatedData.map((group, index) => { return this.renderCommentGroup(group, startIndex + index, searchTerm); }).join(''); outputDiv.innerHTML = highlightedHtml; } static updateComments() { const outputDiv = document.getElementById('recorded-comments-output'); const countSpan = document.getElementById('comment-count'); if (!outputDiv) return; const threadsArray = Array.from(recordedThreads.entries()) .sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); const searchTerm = paginationState.comments.searchTerm.toLowerCase().trim(); let filteredData = threadsArray; if (searchTerm) { filteredData = threadsArray.filter(([key, thread]) => { const tempDiv = document.createElement('div'); tempDiv.innerHTML = thread.mainHTML + (thread.repliesHTML || []).join(''); const textContent = tempDiv.textContent.toLowerCase(); return textContent.includes(searchTerm); }); } paginationState.comments.filteredData = filteredData; const pageSize = UIManager.getPageSize('comments'); const currentPage = paginationState.comments.currentPage; const totalPages = Math.ceil(filteredData.length / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedData = filteredData.slice(startIndex, endIndex); const totalCount = recordedThreads.size; countSpan.textContent = `评论: ${totalCount}` + (searchTerm ? ` (搜索到${filteredData.length}组)` : ''); this.updatePaginationInfo('comments', filteredData.length, currentPage, totalPages, startIndex, endIndex); this.updatePaginationControls('comments', currentPage, totalPages, 'comments'); if (filteredData.length === 0) { if (searchTerm) { outputDiv.innerHTML = this.renderNoResults('未找到匹配的评论收藏', '🔍'); } else { outputDiv.innerHTML = '暂无收藏,请点击评论区的点赞按钮。'; } return; } if (paginatedData.length === 0 && currentPage > 1) { paginationState.comments.currentPage = 1; this.updateComments(); return; } let html = ''; paginatedData.forEach(([key, thread], index) => { const displayIndex = startIndex + index + 1;//暂时不使用 let mainHTML = thread.mainHTML; let repliesHTML = (thread.repliesHTML || []).join(''); if (searchTerm) { mainHTML = this.highlightSearchTerm(mainHTML, searchTerm); repliesHTML = this.highlightSearchTerm(repliesHTML, searchTerm); } html += `<div class="thread-container" data-video-url="${escapeHtml(thread.videoLink)}"> ${mainHTML} ${repliesHTML} <button class="delete-btn" data-key="${escapeHtml(key)}" data-type="comment">×</button> </div>`; }); outputDiv.innerHTML = html; } static updateDanmaku() { const outputDiv = document.getElementById('recorded-danmaku-output'); const countSpan = document.getElementById('danmaku-count'); if (!outputDiv) return; const sortedDanmaku = recordedDanmaku.sort((a, b) => b.timestamp - a.timestamp); const searchTerm = paginationState.danmaku.searchTerm.toLowerCase().trim(); let filteredData = sortedDanmaku; if (searchTerm) { filteredData = sortedDanmaku.filter(danmaku => { const text = danmaku.text?.toLowerCase() || ''; const videoTitle = danmaku.videoTitle?.toLowerCase() || ''; return text.includes(searchTerm) || videoTitle.includes(searchTerm); }); } paginationState.danmaku.filteredData = filteredData; const pageSize = UIManager.getPageSize('danmaku'); const currentPage = paginationState.danmaku.currentPage; const totalPages = Math.ceil(filteredData.length / pageSize); const startIndex = (currentPage - 1) * pageSize; const endIndex = startIndex + pageSize; const paginatedData = filteredData.slice(startIndex, endIndex); const totalCount = recordedDanmaku.length; countSpan.textContent = `弹幕: ${totalCount}` + (searchTerm ? ` (搜索到${filteredData.length}条)` : ''); this.updatePaginationInfo('danmaku', filteredData.length, currentPage, totalPages, startIndex, endIndex); this.updatePaginationControls('danmaku', currentPage, totalPages, 'danmaku'); if (filteredData.length === 0) { if (searchTerm) { outputDiv.innerHTML = this.renderNoResults('未找到匹配的弹幕记录', '🔍'); } else { outputDiv.innerHTML = '暂无弹幕记录,发送弹幕后会自动记录。'; } return; } if (paginatedData.length === 0 && currentPage > 1) { paginationState.danmaku.currentPage = 1; this.updateDanmaku(); return; } let html = ''; paginatedData.forEach((danmaku) => { let text = escapeHtml(danmaku.text); let videoTitle = escapeHtml(danmaku.videoTitle); if (searchTerm) { text = this.highlightSearchTerm(text, searchTerm); videoTitle = this.highlightSearchTerm(videoTitle, searchTerm); } html += `<div class="danmaku-item"> <div class="danmaku-text">${text}</div> <div class="danmaku-meta"> <span>${escapeHtml(danmaku.time)}</span> · <a href="${escapeHtml(danmaku.videoUrl)}" target="_blank" class="danmaku-video-link">${videoTitle}</a> </div> <button class="delete-btn" data-id="${danmaku.id}" data-type="danmaku">×</button> </div>`; }); outputDiv.innerHTML = html; } static updatePaginationInfo(type, totalCount, currentPage, totalPages, startIndex, endIndex) { const infoElement = document.getElementById(`${type}-pagination-info`); if (infoElement) { if (totalCount === 0) { infoElement.textContent = '显示 0 条记录'; } else { const start = startIndex + 1; const end = Math.min(endIndex, totalCount); infoElement.textContent = `显示 ${start}-${end} 条,共 ${totalCount} 条记录 (第${currentPage}/${totalPages}页)`; } } } static updatePaginationControls(type, currentPage, totalPages, stateKey) { const controlsElement = document.getElementById(`${type}-pagination-controls`); if (!controlsElement) return; if (totalPages <= 1) { controlsElement.innerHTML = ''; return; } let html = ''; const prevDisabled = currentPage <= 1 ? 'disabled' : ''; html += `<button class="pagination-btn ${prevDisabled}" data-type="${stateKey}" data-action="prev">‹ 上一页</button>`; const maxButtons = CONFIG.PAGINATION.MAX_PAGE_BUTTONS; let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2)); let endPage = Math.min(totalPages, startPage + maxButtons - 1); if (endPage - startPage + 1 < maxButtons) { startPage = Math.max(1, endPage - maxButtons + 1); } if (startPage > 1) { html += `<button class="pagination-btn" data-type="${stateKey}" data-page="1">1</button>`; if (startPage > 2) { html += `<span class="pagination-btn disabled">...</span>`; } } for (let page = startPage; page <= endPage; page++) { const activeClass = page === currentPage ? 'active' : ''; html += `<button class="pagination-btn ${activeClass}" data-type="${stateKey}" data-page="${page}">${page}</button>`; } if (endPage < totalPages) { if (endPage < totalPages - 1) { html += `<span class="pagination-btn disabled">...</span>`; } html += `<button class="pagination-btn" data-type="${stateKey}" data-page="${totalPages}">${totalPages}</button>`; } const nextDisabled = currentPage >= totalPages ? 'disabled' : ''; html += `<button class="pagination-btn ${nextDisabled}" data-type="${stateKey}" data-action="next">下一页 ›</button>`; controlsElement.innerHTML = html; } static renderNoResults(message, icon = '📝') { return `<div class="no-results"> <div class="no-results-icon">${icon}</div> <div>${message}</div> <div style="margin-top:10px;font-size:12px;color:#ccc;">尝试修改搜索关键词或清空搜索框</div> </div>`; } static highlightSearchTerm(text, searchTerm) { if (!searchTerm || !text) return text; const regex = new RegExp(`(${escapeHtml(searchTerm).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi'); return text.replace(regex, '<span class="search-result-highlight">$1</span>'); } static renderCommentGroup(group, index, searchTerm = '') { let mainCommentHTML = ''; if (group.mainComment) { const mainComment = group.mainComment; let mainImagesHTML = ''; if (mainComment.images && mainComment.images.length > 0) { const mainImages = mainComment.images.map(src => `<img class="comment-image" src="${escapeHtml(src)}">` ).join(''); mainImagesHTML = `<div class="comment-images">${mainImages}</div>`; } const totalReplies = group.replies.reduce((total, reply) => { return total + (reply.thirdLevelReplies ? reply.thirdLevelReplies.length : 0) + 1; }, 0); let userName = escapeHtml(mainComment.userName); let content = escapeHtml(mainComment.content); let videoTitle = escapeHtml(group.videoTitle); if (searchTerm) { userName = this.highlightSearchTerm(userName, searchTerm); content = this.highlightSearchTerm(content, searchTerm); videoTitle = this.highlightSearchTerm(videoTitle, searchTerm); } mainCommentHTML = ` <div class="main-comment"> <div class="group-info">第${index + 1}组 (${totalReplies}条回复)</div> ${group.contextId ? `<div class="context-id-badge">${group.contextId}</div>` : ''} <div class="jump-hint">双击跳转视频</div> <div class="comment-header"> <img class="user-avatar" src="${escapeHtml(mainComment.userAvatar || 'https://static.hdslb.com/images/member/noface.gif')}"> <a class="user-name" href="${escapeHtml(mainComment.userLink)}" target="_blank">${userName}</a> <span style="color: #999; font-size: 12px;">(主评论)</span> </div> <div class="comment-content">${content}</div> ${mainImagesHTML} <div class="comment-meta"> <span>日期: ${escapeHtml(mainComment.pubDate)}</span> <span>点赞: ${escapeHtml(mainComment.likeCount)}</span> <span>标题: ${videoTitle}</span> </div> <div class="history-time">首次记录: ${escapeHtml(group.createTime)}</div> </div> `; } const repliesHTML = group.replies.map((reply) => { return this.renderReply(reply, searchTerm); }).join(''); return ` <div class="history-item" data-video-url="${escapeHtml(group.videoUrl)}"> ${mainCommentHTML} ${repliesHTML} <button class="delete-btn" data-id="${group.id}" data-type="sent">×</button> </div> `; } static renderReply(reply, searchTerm = '') { let replyImagesHTML = ''; if (reply.images && reply.images.length > 0) { const replyImages = reply.images.map(src => `<img class="comment-image" src="${escapeHtml(src)}">` ).join(''); replyImagesHTML = `<div class="comment-images">${replyImages}</div>`; } let thirdLevelHTML = ''; if (reply.thirdLevelReplies && reply.thirdLevelReplies.length > 0) { thirdLevelHTML = reply.thirdLevelReplies.map(thirdLevel => { let thirdImagesHTML = ''; if (thirdLevel.images && thirdLevel.images.length > 0) { const thirdImages = thirdLevel.images.map(src => `<img class="comment-image" src="${escapeHtml(src)}">` ).join(''); thirdImagesHTML = `<div class="comment-images">${thirdImages}</div>`; } let thirdUserName = escapeHtml(thirdLevel.userName); let thirdContent = escapeHtml(thirdLevel.content); if (searchTerm) { thirdUserName = this.highlightSearchTerm(thirdUserName, searchTerm); thirdContent = this.highlightSearchTerm(thirdContent, searchTerm); } return ` <div class="third-level-comment"> <div class="comment-header"> <img class="user-avatar" src="${escapeHtml(thirdLevel.userAvatar || 'https://static.hdslb.com/images/member/noface.gif')}"> <a class="user-name" href="${escapeHtml(thirdLevel.userLink)}" target="_blank">${thirdUserName}</a> <span class="reply-indicator">回复 @${escapeHtml(reply.userName)}</span> </div> <div class="comment-content">${thirdContent}</div> ${thirdImagesHTML} <div class="comment-meta"> <span>日期: ${escapeHtml(thirdLevel.pubDate)}</span> <span>点赞: ${escapeHtml(thirdLevel.likeCount)}</span> </div> <div class="history-time">${escapeHtml(thirdLevel.time)}</div> </div> `; }).join(''); } const thirdLevelCount = reply.thirdLevelReplies ? reply.thirdLevelReplies.length : 0; const countBadge = thirdLevelCount > 0 ? `<span class="third-level-count">${thirdLevelCount}条回复</span>` : ''; let replyUserName = escapeHtml(reply.userName); let replyContent = escapeHtml(reply.content); if (searchTerm) { replyUserName = this.highlightSearchTerm(replyUserName, searchTerm); replyContent = this.highlightSearchTerm(replyContent, searchTerm); } return ` <div class="reply-comment"> <div class="comment-header"> <img class="user-avatar" src="${escapeHtml(reply.userAvatar || 'https://static.hdslb.com/images/member/noface.gif')}"> <a class="user-name" href="${escapeHtml(reply.userLink)}" target="_blank">${replyUserName}</a> <span style="color: #999; font-size: 12px;">(二级评论)</span> ${countBadge} </div> <div class="comment-content">${replyContent}</div> ${replyImagesHTML} <div class="comment-meta"> <span>日期: ${escapeHtml(reply.pubDate)}</span> <span>点赞: ${escapeHtml(reply.likeCount)}</span> </div> <div class="history-time">${escapeHtml(reply.time)}</div> </div> ${thirdLevelHTML} `; } static detailsToHTML(details, className = 'comment-entry') { if (!details) return ''; const avatarSrc = details.userAvatar || 'https://static.hdslb.com/images/member/noface.gif'; let html = `<div class="${className}"> <div class="comment-header"> <a href="${escapeHtml(details.userLink)}" target="_blank" onclick="event.stopPropagation()"> <img class="user-avatar" src="${escapeHtml(avatarSrc)}"> </a> <a class="user-name" href="${escapeHtml(details.userLink)}" target="_blank" onclick="event.stopPropagation()"> ${escapeHtml(details.userName)} </a> </div>`; if (details.text) html += `<div class="comment-text">${escapeHtml(details.text)}</div>`; if (details.imageSrcs?.length > 0) { html += `<div class="comment-pictures-container">`; details.imageSrcs.forEach(src => { html += `<img class="comment-image" src="${escapeHtml(src)}">`; }); html += `</div>`; } html += `<div class="comment-meta"> <span>日期: ${escapeHtml(details.pubDate)}</span> <span>点赞: ${escapeHtml(details.likeCount)}</span> </div></div>`; return html; } } // =====================================数据管理模块==================================================== class DataManager { static exportAll() { const data = { version: '15.2', type: 'all', comments: Array.from(recordedThreads.entries()), danmaku: recordedDanmaku, sentComments: sentComments, exportTime: new Date().toISOString(), counts: { comments: recordedThreads.size, danmaku: recordedDanmaku.length, sentComments: sentComments.length } }; this.downloadJSON(data, `bilibili_all_data_${Date.now()}.json`); } static exportSentComments() { const data = { version: '15.2', type: 'sent_comments_grouped', data: sentComments, exportTime: new Date().toISOString(), video: currentVideoInfo }; this.downloadJSON(data, `bilibili_sent_comments_${Date.now()}.json`); } static downloadJSON(data, filename) { const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'}); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } static async import(file) { try { const data = JSON.parse(await file.text()); let importCount = { comments: 0, danmaku: 0, sentComments: 0 }; let mergeCount = { sentComments: 0, danmaku: 0 }; if (data.type === 'all') { if (data.comments) { for (const [key, value] of data.comments) { await DatabaseManager.save('comments', key, value); importCount.comments++; } } if (data.danmaku) { for (const danmaku of data.danmaku) { const result = await this.importWithMerge('danmaku', danmaku); if (result.isNew) importCount.danmaku++; else mergeCount.danmaku++; } } if (data.sentComments) { for (const comment of data.sentComments) { const result = await this.importWithMerge('sentComment', comment); if (result.isNew) importCount.sentComments++; else mergeCount.sentComments++; } } } else if (data.type === 'comments') { for (const [key, value] of data.data) { await DatabaseManager.save('comments', key, value); importCount.comments++; } } else if (data.type === 'danmaku') { for (const danmaku of data.data) { const result = await this.importWithMerge('danmaku', danmaku); if (result.isNew) importCount.danmaku++; else mergeCount.danmaku++; } } else if (data.type === 'sent_comments_grouped' || data.type === 'sent_comments_grouped_optimized') { for (const comment of data.data) { const result = await this.importWithMerge('sentComment', comment); if (result.isNew) importCount.sentComments++; else mergeCount.sentComments++; } } await DatabaseManager.loadAll(); DisplayManager.updateAll(); let resultMessage = '数据导入完成!\n'; if (importCount.comments > 0) { resultMessage += `评论收藏: ${importCount.comments}条\n`; } if (importCount.danmaku > 0) { resultMessage += `弹幕记录: ${importCount.danmaku}条`; if (mergeCount.danmaku > 0) { resultMessage += `,合并${mergeCount.danmaku}条`; } resultMessage += '\n'; } if (importCount.sentComments > 0) { resultMessage += `评论记录: ${importCount.sentComments}组`; if (mergeCount.sentComments > 0) { resultMessage += `,合并${mergeCount.sentComments}组`; } } alert(resultMessage); } catch (e) { console.error('导入错误:', e); alert('数据导入失败: ' + e.message); } } static async importWithMerge(type, data) { const uniqueKey = generateUniqueKey(type, data); if (!uniqueKey) return { isNew: true }; if (type === 'sentComment') { const existingGroup = sentComments.find(group => generateUniqueKey('sentComment', group) === uniqueKey ); if (existingGroup) { if (data.replies && data.replies.length > 0) { for (const newReply of data.replies) { const existingReply = existingGroup.replies.find(reply => reply.content === newReply.content && reply.userName === newReply.userName ); if (!existingReply) { existingGroup.replies.push(newReply); } else if (newReply.thirdLevelReplies && newReply.thirdLevelReplies.length > 0) { if (!existingReply.thirdLevelReplies) { existingReply.thirdLevelReplies = []; } for (const thirdLevel of newReply.thirdLevelReplies) { const existingThird = existingReply.thirdLevelReplies.find(third => third.content === thirdLevel.content && third.userName === thirdLevel.userName ); if (!existingThird) { existingReply.thirdLevelReplies.push(thirdLevel); } } } } } existingGroup.lastUpdateTime = new Date().toLocaleString(); await DatabaseManager.update('sent_comments', existingGroup); return { isNew: false }; } } else if (type === 'danmaku') { const existingDanmaku = recordedDanmaku.find(d => generateUniqueKey('danmaku', d) === uniqueKey ); if (existingDanmaku) { existingDanmaku.time = data.time || existingDanmaku.time; existingDanmaku.videoTitle = data.videoTitle || existingDanmaku.videoTitle; await DatabaseManager.update('danmaku', existingDanmaku); return { isNew: false }; } } delete data.id; const store = type === 'sentComment' ? 'sent_comments' : 'danmaku'; await DatabaseManager.save(store, null, data); return { isNew: true }; } } // ========================================评论提取和监听========================================= class CommentExtractor { static extractDetails(commentRenderer) { if (!commentRenderer || !commentRenderer.shadowRoot) return null; try { const commentRoot = commentRenderer.shadowRoot; const richTextEl = commentRoot.querySelector('bili-rich-text'); if (!richTextEl || !richTextEl.shadowRoot) return null; const contentsEl = richTextEl.shadowRoot.querySelector('p#contents'); if (!contentsEl) return null; const text = contentsEl.textContent?.trim() || ''; const imageNodes = commentRoot.querySelector('bili-comment-pictures-renderer')?.shadowRoot?.querySelectorAll('div#content img'); const imageSrcs = imageNodes ? Array.from(imageNodes).map(img => img.src) : []; const uniqueKey = text || (imageSrcs.length > 0 ? imageSrcs[0] : null); if (!uniqueKey) return null; const userInfoHost = commentRoot.querySelector('bili-comment-user-info'); const userAnchor = userInfoHost?.shadowRoot?.querySelector('a'); const userName = userAnchor?.textContent.trim() || '未知用户'; const userLink = userAnchor?.href || '#'; const avatarRenderer = commentRoot.querySelector('bili-avatar'); const userAvatar = avatarRenderer?.shadowRoot?.querySelector('img')?.src; const actionsRoot = commentRoot.querySelector('bili-comment-action-buttons-renderer')?.shadowRoot; const pubDate = actionsRoot?.querySelector('div#pubdate')?.textContent.trim() || ''; const likeCount = actionsRoot?.querySelector('div#like span#count')?.textContent.trim() || '0'; return { key: uniqueKey, text, content: text, imageSrcs, images: imageSrcs, pubDate, likeCount, userName, userLink, userAvatar, videoLink: window.location.href }; } catch (error) { if (CONFIG.DEBUG) console.warn('[评论提取器] 提取失败:', error.message); return null; } } static generateCommentKey(comment) { if (!comment) return null; const content = comment.content || comment.text || ''; return `${comment.userName}|||${content}`; } static findExistingCommentGroup(mainComment) { if (!mainComment) return null; const key = this.generateCommentKey(mainComment); if (!key) return null; return sentComments.find(group => { if (!group.mainComment) return false; const groupKey = this.generateCommentKey(group.mainComment); return groupKey === key; }); } static findExistingSecondLevelComment(group, secondLevelComment) { if (!group || !secondLevelComment) return null; const key = this.generateCommentKey(secondLevelComment); if (!key) return null; return group.replies.find(reply => { if (reply.type !== '二级评论') return false; const replyKey = this.generateCommentKey(reply); return replyKey === key; }); } static cleanupOldComments() { if (sentComments.length > settings.SENT_COMMENTS) { sentComments.splice(settings.SENT_COMMENTS); } } } // =========================================核心!弹幕监听模块================================================= class DanmakuListener { static isMonitoring = false; static searchTimer = null; static updateTimer = null; static currentHandlers = null; static lastRecord = null; static retryCount = 0; static lastSearchTime = 0; static mutationObserver = null; static init() { if (CONFIG.DEBUG) console.log('[弹幕监听器] 开始初始化'); setTimeout(() => { this.setupDanmakuMonitoring(); }, getRandomDelay(500)); } static setupDanmakuMonitoring() { const inputSelectors = [ '.bpx-player-dm-input', '.bilibili-player-video-danmaku-input', 'input[placeholder*="弹幕"]' ]; const buttonSelectors = [ '.bpx-player-dm-btn-send', '.bilibili-player-video-btn-send' ]; const setupMonitoring = () => { if (this.isMonitoring) return; const now = Date.now(); if (now - this.lastSearchTime < CONFIG.PERFORMANCE.RETRY_BASE_DELAY) { return; } this.lastSearchTime = now; const playerContainer = getCachedElement('player-container', '.bpx-player-container') || getCachedElement('video-container', '.bilibili-player-video-wrap'); if (!playerContainer) { if (CONFIG.DEBUG) console.log('[弹幕监听器] 播放器容器未找到'); return; } let input = null; for (const selector of inputSelectors) { input = getCachedElement(`danmaku-input-${selector}`, selector, playerContainer); if (input) break; } let sendBtn = null; for (const selector of buttonSelectors) { sendBtn = getCachedElement(`danmaku-btn-${selector}`, selector, playerContainer); if (sendBtn) break; } if (input && sendBtn) { if (this.searchTimer) { clearInterval(this.searchTimer); this.searchTimer = null; if (CONFIG.DEBUG) console.log('[弹幕监听器] 弹幕元素已找到,停止搜索'); } this.isMonitoring = true; this.retryCount = 0; this.removeOldHandlers(); let lastKeyTime = 0; const handleKeydown = (e) => { if (e.key === 'Enter') { const now = Date.now(); if (now - lastKeyTime < 300) return; lastKeyTime = now; const text = e.target.value.trim(); if (text && currentVideoInfo) { setTimeout(() => { this.recordDanmaku(text, '回车键'); }, getRandomDelay(100)); } } }; let lastClickTime = 0; const handleClick = () => { const now = Date.now(); if (now - lastClickTime < 300) return; lastClickTime = now; const text = input.value.trim(); if (text && currentVideoInfo) { setTimeout(() => { this.recordDanmaku(text, '点击发送'); }, getRandomDelay(100)); } }; input.addEventListener('keydown', handleKeydown); sendBtn.addEventListener('click', handleClick); this.currentHandlers = { input: input, sendBtn: sendBtn, keydownHandler: handleKeydown, clickHandler: handleClick }; if (CONFIG.DEBUG) console.log('[弹幕监听器] 弹幕监控已启动'); this.setupMutationObserver(playerContainer); } else { if (CONFIG.DEBUG && this.retryCount % 5 === 0) { console.log('[弹幕监听器] 弹幕元素未找到,继续搜索...'); } this.retryCount++; } }; const startSearching = () => { if (this.searchTimer) return; setupMonitoring() let searchCount = 0; this.searchTimer = setInterval(() => { if (!this.isMonitoring && searchCount < 10) { setupMonitoring(); searchCount++; if (searchCount > 5) { clearInterval(this.searchTimer); this.searchTimer = setInterval(() => { if (!this.isMonitoring) { setupMonitoring(); } }, getRetryDelay(searchCount - 5)); } } else if (searchCount >= 10) { clearInterval(this.searchTimer); this.searchTimer = null; if (CONFIG.DEBUG) console.log('[弹幕监听器] 达到最大搜索次数,停止搜索'); } }, getRetryDelay(Math.min(searchCount, 3))); }; startSearching(); } static setupMutationObserver(container) { if (this.mutationObserver) { this.mutationObserver.disconnect(); this.mutationObserver = null; } let mutationThrottle = null; this.mutationObserver = new MutationObserver((mutations) => { if (mutationThrottle) return; mutationThrottle = setTimeout(() => { mutationThrottle = null; if (!this.currentHandlers || !this.currentHandlers.input || !this.currentHandlers.sendBtn) { return; } if (!document.contains(this.currentHandlers.input) || !document.contains(this.currentHandlers.sendBtn)) { if (CONFIG.DEBUG) console.log('[弹幕监听器] 弹幕元素已移除,重新搜索'); this.isMonitoring = false; this.removeOldHandlers(); setTimeout(() => { this.setupDanmakuMonitoring(); }, getRandomDelay(1000)); } }, CONFIG.PERFORMANCE.MUTATION_THROTTLE); }); const dmControl = container.querySelector('.bpx-player-control-bottom') || container.querySelector('.bilibili-player-video-control'); if (dmControl) { this.mutationObserver.observe(dmControl, { childList: true, subtree: false, //不深入查询 attributes: false }); } } static removeOldHandlers() { if (this.currentHandlers) { if (this.currentHandlers.input && this.currentHandlers.keydownHandler) { this.currentHandlers.input.removeEventListener('keydown', this.currentHandlers.keydownHandler); } if (this.currentHandlers.sendBtn && this.currentHandlers.clickHandler) { this.currentHandlers.sendBtn.removeEventListener('click', this.currentHandlers.clickHandler); } this.currentHandlers = null; } } static recordDanmaku(text, method) { if (!text || text.trim() === '') return; const now = Date.now(); const trimmedText = text.trim(); if (this.lastRecord && this.lastRecord.text === trimmedText && (now - this.lastRecord.timestamp) < 1000) { // 1秒内防重复 if (CONFIG.DEBUG) console.log(`[弹幕监听器] 弹幕去重: 忽略重复记录 "${trimmedText}" (${method})`); return; } const danmakuData = { text: trimmedText, videoId: currentVideoInfo.bvid, videoUrl: currentVideoInfo.url, videoTitle: currentVideoInfo.title, timestamp: now, time: new Date().toLocaleString() }; const isDuplicate = recordedDanmaku.some(record => record.text === text && (danmakuData.timestamp - record.timestamp) < 3000 ); if (!isDuplicate) { this.lastRecord = { text: trimmedText, timestamp: now }; DatabaseManager.save('danmaku', null, danmakuData).then((event) => { danmakuData.id = event.target.result; recordedDanmaku.unshift(danmakuData); if (recordedDanmaku.length > 1000) { recordedDanmaku = recordedDanmaku.slice(0, 1000); } if (!this.updateTimer) { this.updateTimer = setTimeout(() => { DisplayManager.updateDanmaku(); this.updateTimer = null; }, 500); } }).catch(error => { if (CONFIG.DEBUG) console.error('[弹幕监听器] 保存失败:', error); }); if (CONFIG.DEBUG) console.log(`[弹幕监听器] 弹幕记录: ${method} - "${text}"`); } } static reset() { if (CONFIG.DEBUG) console.log('[弹幕监听器] 重置状态'); this.isMonitoring = false; this.retryCount = 0; this.lastSearchTime = 0; if (this.searchTimer) { clearInterval(this.searchTimer); this.searchTimer = null; } if (this.updateTimer) { clearTimeout(this.updateTimer); this.updateTimer = null; } if (this.mutationObserver) { this.mutationObserver.disconnect(); this.mutationObserver = null; } this.removeOldHandlers(); this.lastRecord = null; } static cleanup() { this.reset(); if (CONFIG.DEBUG) console.log('[弹幕监听器] 清理完成'); } } // ============================================核心,评论监听模块====================================================== //未来考虑使用事件委托减少监听数量 class ListenerManager { static listeners = new Map(); static isInitialized = false; static mutationObservers = new Map(); static recentComments = new Map(); static lastRecordTime = 0; static lastMutationTime = 0; static pendingMutations = new Set(); static init() { if (this.isInitialized) { if (CONFIG.DEBUG) console.log('[监听器管理] 已初始化,跳过'); return; } setTimeout(() => { this.initReplyButtonListener(); this.initSendButtonListener(); this.initCommentClickListener(); this.initPageListener(); DanmakuListener.init(); this.isInitialized = true; if (CONFIG.DEBUG) console.log('[监听器管理] 初始化完成'); }, getRandomDelay(200)); } static initReplyButtonListener() { if (this.listeners.has('reply-button')) { document.removeEventListener('click', this.listeners.get('reply-button'), true); } const replyHandler = (event) => { const now = Date.now(); if (now - this.lastMutationTime < CONFIG.PERFORMANCE.MUTATION_THROTTLE) { return; } this.lastMutationTime = now; const path = event.composedPath(); const replyElement = path.find(el => el.nodeType === 1 && (el.id === 'reply' || (el.tagName === 'BUTTON' && el.textContent?.includes('回复'))) ); if (replyElement) { const commentRenderer = path.find(el => el.tagName === 'BILI-COMMENT-RENDERER' || el.tagName === 'BILI-COMMENT-REPLY-RENDERER' ); const threadRenderer = path.find(el => el.tagName === 'BILI-COMMENT-THREAD-RENDERER' ); if (commentRenderer && threadRenderer) { const parentCommentDetails = CommentExtractor.extractDetails(commentRenderer); if (parentCommentDetails) { const isMainComment = commentRenderer.tagName === 'BILI-COMMENT-RENDERER'; const isReplyComment = commentRenderer.tagName === 'BILI-COMMENT-REPLY-RENDERER'; let mainCommentDetails = null; if (isMainComment) { mainCommentDetails = parentCommentDetails; } else if (isReplyComment) { const mainCommentRenderer = threadRenderer.shadowRoot?.querySelector('bili-comment-renderer#comment'); if (mainCommentRenderer) { mainCommentDetails = CommentExtractor.extractDetails(mainCommentRenderer); } } const contextId = generateContextId(); const context = { id: contextId, parentComment: parentCommentDetails, mainComment: mainCommentDetails, isReplyToMain: isMainComment, isReplyToReply: isReplyComment, threadRenderer: threadRenderer, timestamp: Date.now() }; replyContextMap.set(contextId, context); if (replyContextMap.size > 50) { const oldestKey = replyContextMap.keys().next().value; if (replyContextMap.has(oldestKey)) { replyContextMap.delete(oldestKey); } } setTimeout(() => { if (replyContextMap.has(contextId)) { if (CONFIG.DEBUG) console.log(`[监听器管理] 清理过期上下文: ${contextId}`); replyContextMap.delete(contextId); } }, 1800000); if (CONFIG.DEBUG) console.log(`[监听器管理] 记录回复上下文 ID: ${contextId}`); } } } }; document.addEventListener('click', replyHandler, true); this.listeners.set('reply-button', replyHandler); if (CONFIG.DEBUG) console.log('[监听器管理] 回复按钮监听器绑定成功'); } static initSendButtonListener() { if (this.listeners.has('send-button')) { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', this.listeners.get('send-button'), true); } } let retryCount = 0; const checkAndBind = () => { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (!commentsHost || !commentsHost.shadowRoot) return false; const sendButtonHandler = async (event) => { const path = event.composedPath(); const sendButton = path.find(el => el.nodeType === 1 && el.tagName === 'BUTTON' && el.hasAttribute('data-v-risk') && el.getAttribute('data-v-risk') === 'fingerprint' ); if (!sendButton) return; const currentTime = Date.now(); if (currentTime - this.lastRecordTime < 500) { if (CONFIG.DEBUG) console.log('[监听器管理] 防抖:忽略重复的发送按钮点击'); return; } this.lastRecordTime = currentTime; const isReplyButton = path.some(el => el.id === 'reply-container'); let threadRenderer = null; if (isReplyButton) { threadRenderer = path.find(el => el.nodeType === 1 && el.tagName === 'BILI-COMMENT-THREAD-RENDERER' ); } const commentInfo = this.getCommentTextBeforeSend(sendButton, isReplyButton, threadRenderer); if (!commentInfo) { if (CONFIG.DEBUG) console.log('[监听器管理] 获取评论信息失败'); return; } const { text: commentText, isReply } = commentInfo; if (CONFIG.DEBUG) { console.log(`[监听器管理] 检测到发送按钮点击:`, { isReply, commentText: commentText.substring(0, 50) + (commentText.length > 50 ? '...' : ''), timestamp: new Date().toLocaleString() }); } const sendContext = { commentText, isReply, threadRenderer, timestamp: currentTime }; setTimeout(async () => { if (CONFIG.DEBUG) console.log('[监听器管理] 开始查找新评论...'); let commentDetails = null; if (isReply) { commentDetails = await this.findNewReplyComment(threadRenderer, commentText); if (CONFIG.DEBUG) { console.log(`[监听器管理] 回复评论查找结果:`, commentDetails ? '找到' : '未找到'); } } else { commentDetails = await this.findNewMainComment(commentText); if (CONFIG.DEBUG) { console.log(`[监听器管理] 主评论查找结果:`, commentDetails ? '找到' : '未找到'); } } await this.handleCommentRecord(commentDetails, commentText, isReply, sendContext); DisplayManager.updateSentComments(); }, getRandomDelay(300)); }; commentsHost.shadowRoot.addEventListener('click', sendButtonHandler, true); this.listeners.set('send-button', sendButtonHandler); if (CONFIG.DEBUG) console.log('[监听器管理] 发送按钮监听器绑定成功'); retryCount = 0; return true; }; if (!checkAndBind()) { const retry = () => { if (!checkAndBind() && retryCount < 10) { retryCount++; setTimeout(retry, getRetryDelay(retryCount)); } }; setTimeout(retry, getRetryDelay(0)); } } static initCommentClickListener() { if (this.listeners.has('comment-click')) { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', this.listeners.get('comment-click'), true); } } let retryCount = 0; const checkAndBind = () => { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (!commentsHost?.shadowRoot) return false; const commentClickHandler = async (event) => { const path = event.composedPath(); const likeButton = path.find(el => el.nodeType === 1 && el.id === 'like' && el.tagName === 'DIV'); if (!likeButton) return; const clickedRenderer = path.find(el => el.nodeType === 1 && (el.tagName === 'BILI-COMMENT-RENDERER' || el.tagName === 'BILI-COMMENT-REPLY-RENDERER')); if (!clickedRenderer) return; let dataChanged = false; if (clickedRenderer.tagName === 'BILI-COMMENT-REPLY-RENDERER') { const replyDetails = CommentExtractor.extractDetails(clickedRenderer); if (!replyDetails) return; const threadRenderer = path.find(el => el.nodeType === 1 && el.tagName === 'BILI-COMMENT-THREAD-RENDERER'); const mainCommentRenderer = threadRenderer?.shadowRoot?.querySelector('bili-comment-renderer#comment'); const mainCommentDetails = CommentExtractor.extractDetails(mainCommentRenderer); if (!mainCommentDetails) return; const mainKey = mainCommentDetails.key; if (!recordedThreads.has(mainKey)) { recordedThreads.set(mainKey, { videoLink: mainCommentDetails.videoLink, mainHTML: DisplayManager.detailsToHTML(mainCommentDetails), repliesHTML: [], timestamp: Date.now() }); dataChanged = true; } const replyHTML = DisplayManager.detailsToHTML(replyDetails, 'recorded-reply-item'); const thread = recordedThreads.get(mainKey); if (!thread.repliesHTML.includes(replyHTML)) { thread.repliesHTML.push(replyHTML); thread.timestamp = Date.now(); dataChanged = true; } } else if (clickedRenderer.tagName === 'BILI-COMMENT-RENDERER') { const mainCommentDetails = CommentExtractor.extractDetails(clickedRenderer); if (!mainCommentDetails) return; const mainKey = mainCommentDetails.key; if (!recordedThreads.has(mainKey)) { recordedThreads.set(mainKey, { videoLink: mainCommentDetails.videoLink, mainHTML: DisplayManager.detailsToHTML(mainCommentDetails), repliesHTML: [], timestamp: Date.now() }); dataChanged = true; } } if (dataChanged) { DisplayManager.updateComments(); const mainKey = clickedRenderer.tagName === 'BILI-COMMENT-RENDERER' ? CommentExtractor.extractDetails(clickedRenderer).key : CommentExtractor.extractDetails(path.find(el => el.nodeType === 1 && el.tagName === 'BILI-COMMENT-THREAD-RENDERER').shadowRoot.querySelector('bili-comment-renderer#comment')).key; await DatabaseManager.save('comments', mainKey, recordedThreads.get(mainKey)); } }; commentsHost.shadowRoot.addEventListener('click', commentClickHandler, true); this.listeners.set('comment-click', commentClickHandler); if (CONFIG.DEBUG) console.log('[监听器管理] 评论点击监听器绑定成功'); retryCount = 0; return true; }; if (!checkAndBind()) { const retry = () => { if (!checkAndBind() && retryCount < 10) { retryCount++; setTimeout(retry, getRetryDelay(retryCount)); } }; setTimeout(retry, getRetryDelay(0)); } } static resetListeners() { if (CONFIG.DEBUG) console.log('[监听器管理] 重置所有监听器状态'); this.listeners.forEach((handler, key) => { try { if (key === 'reply-button') { document.removeEventListener('click', handler, true); } else if (key === 'send-button' || key === 'comment-click') { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', handler, true); } } } catch (error) { if (CONFIG.DEBUG) console.warn(`[监听器管理] 清理监听器失败: ${key}`, error); } }); this.mutationObservers.forEach(observer => { try { observer.disconnect(); } catch (error) { if (CONFIG.DEBUG) console.warn('[监听器管理] 清理MutationObserver失败', error); } }); this.mutationObservers.clear(); this.listeners.clear(); this.isInitialized = false; this.recentComments.clear(); this.lastRecordTime = 0; DanmakuListener.reset(); } static initPageListener() { this.updateVideoInfo(); if (this.mutationObservers.has('page-observer')) { const oldObserver = this.mutationObservers.get('page-observer'); oldObserver.disconnect(); this.mutationObservers.delete('page-observer'); } let lastUrl = location.href; let mutationThrottle = null; const pageObserver = new MutationObserver(() => { if (mutationThrottle) return; mutationThrottle = setTimeout(() => { mutationThrottle = null; const url = location.href; if (url !== lastUrl) { lastUrl = url; if (CONFIG.DEBUG) console.log('[监听器管理] 页面URL变化,重新初始化'); this.updateVideoInfo(); clearReplyContextMap(); clearAllDomCache(); this.resetListeners(); setTimeout(() => { this.init(); }, getRandomDelay(500)); } }, CONFIG.PERFORMANCE.MUTATION_THROTTLE); }); const observeTarget = document.querySelector('head title') || document.head; if (observeTarget) { pageObserver.observe(observeTarget, { subtree: false, childList: true, characterData: true }); this.mutationObservers.set('page-observer', pageObserver); } // 备用,,,使用popstate事件 const popstateHandler = () => { const url = location.href; if (url !== lastUrl) { lastUrl = url; this.updateVideoInfo(); clearReplyContextMap(); clearAllDomCache(); this.resetListeners(); setTimeout(() => this.init(), getRandomDelay(300)); } }; window.addEventListener('popstate', popstateHandler); this.listeners.set('popstate', popstateHandler); } static updateVideoInfo() { const bvid = window.location.pathname.match(/\/video\/(BV[\w]+)/)?.[1]; if (bvid) { setCurrentVideoInfo({ bvid: bvid, url: window.location.href, title: document.title }); if (CONFIG.DEBUG) console.log(`[监听器管理] 更新视频信息: ${bvid}`); } } static getCommentTextBeforeSend(sendButton, isReply, threadRenderer = null) { try { if (isReply && threadRenderer) { const shadowRoot = threadRenderer.shadowRoot; if (!shadowRoot) return null; const replyContainer = shadowRoot.querySelector('#reply-container'); if (!replyContainer) return null; const text = this.extractTextFromReplyContainer(replyContainer); return text ? { text, isReply: true, threadRenderer } : null; } else { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (!commentsHost || !commentsHost.shadowRoot) return null; const header = commentsHost.shadowRoot.querySelector('#header'); if (!header) return null; const text = this.findEditorText(header); return text ? { text, isReply: false, threadRenderer: null } : null; } } catch (error) { if (CONFIG.DEBUG) console.error('[监听器管理] 获取评论文本失败:', error); return null; } } static extractTextFromReplyContainer(replyContainer) { const commentBox = replyContainer.querySelector('bili-comment-box'); if (!commentBox || !commentBox.shadowRoot) return null; const commentArea = commentBox.shadowRoot.querySelector('#comment-area'); if (!commentArea) return null; const bodyDiv = commentArea.querySelector('#body'); if (!bodyDiv) return null; const editorDiv = bodyDiv.querySelector('#editor'); if (!editorDiv) return null; const richTextarea = editorDiv.querySelector('bili-comment-rich-textarea'); if (!richTextarea || !richTextarea.shadowRoot) return null; const inputDiv = richTextarea.shadowRoot.querySelector('#input'); if (!inputDiv) return null; const brtRoot = inputDiv.querySelector('.brt-root'); if (!brtRoot) return null; const brtEditor = brtRoot.querySelector('.brt-editor'); if (!brtEditor) return null; return brtEditor.textContent?.trim() || brtEditor.innerText?.trim() || ''; } static findEditorText(element, depth = 0) { if (depth > 10) return null; const editor = element.querySelector('.brt-editor'); if (editor) { const text = editor.textContent?.trim() || editor.innerText?.trim(); if (text) return text; } const allElements = element.querySelectorAll('*'); for (const el of allElements) { if (el.shadowRoot) { const text = this.findEditorText(el.shadowRoot, depth + 1); if (text) return text; } } return null; } static async findNewReplyComment(threadRenderer, expectedText, maxWaitTime = 8000) { return new Promise((resolve) => { const startTime = Date.now(); const checkInterval = 300; const checkForNewReply = () => { try { if (!threadRenderer || !threadRenderer.shadowRoot) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewReply, checkInterval); } else { resolve(null); } return; } const repliesDiv = threadRenderer.shadowRoot.querySelector('#replies'); if (!repliesDiv) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewReply, checkInterval); } else { resolve(null); } return; } const repliesRenderer = repliesDiv.querySelector('bili-comment-replies-renderer'); if (!repliesRenderer || !repliesRenderer.shadowRoot) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewReply, checkInterval); } else { resolve(null); } return; } const expander = repliesRenderer.shadowRoot.querySelector('#expander'); if (!expander) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewReply, checkInterval); } else { resolve(null); } return; } const expanderContents = expander.querySelector('#expander-contents'); if (!expanderContents) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewReply, checkInterval); } else { resolve(null); } return; } const replyRenderers = expanderContents.querySelectorAll('bili-comment-reply-renderer'); for (let i = replyRenderers.length - 1; i >= 0; i--) { const renderer = replyRenderers[i]; const details = CommentExtractor.extractDetails(renderer); if (details) { let matchedText = ''; if (details.text === expectedText) { matchedText = details.text; } else { const replyMatch = details.text.match(/^回复\s+@[^:]+\s*:\s*(.+)$/); if (replyMatch && replyMatch[1].trim() === expectedText) { matchedText = replyMatch[1].trim(); } } if (matchedText) { resolve(details); return; } } } if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewReply, checkInterval); } else { resolve(null); } } catch (error) { if (CONFIG.DEBUG) console.error('[监听器管理] 查找回复评论时出错:', error); resolve(null); } }; checkForNewReply(); }); } static async findNewMainComment(expectedText, maxWaitTime = 8000) { return new Promise((resolve) => { const startTime = Date.now(); const checkInterval = 300; const checkForNewComment = () => { try { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (!commentsHost || !commentsHost.shadowRoot) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewComment, checkInterval); } else { if (CONFIG.DEBUG) console.log('[监听器管理] 超时未找到主评论'); resolve(null); } return; } const newDiv = commentsHost.shadowRoot.querySelector('#contents #new'); if (!newDiv) { if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewComment, checkInterval); } else { if (CONFIG.DEBUG) console.log('[监听器管理] 未找到#new容器'); resolve(null); } return; } const threadRenderers = newDiv.querySelectorAll('bili-comment-thread-renderer'); for (const threadRenderer of threadRenderers) { if (threadRenderer.shadowRoot) { const mainRenderer = threadRenderer.shadowRoot.querySelector('bili-comment-renderer#comment'); if (mainRenderer) { const details = CommentExtractor.extractDetails(mainRenderer); if (details && details.text === expectedText) { if (CONFIG.DEBUG) console.log('[监听器管理] 找到匹配的主评论'); resolve(details); return; } } } } if (Date.now() - startTime < maxWaitTime) { setTimeout(checkForNewComment, checkInterval); } else { if (CONFIG.DEBUG) console.log('[监听器管理] 未找到匹配的主评论内容'); resolve(null); } } catch (error) { if (CONFIG.DEBUG) console.error('[监听器管理] 查找主评论时出错:', error); resolve(null); } }; checkForNewComment(); }); } static async handleCommentRecord(commentDetails, commentText, isReply, sendContext) { const currentTime = new Date().toLocaleString(); let dataToSave = null; if (CONFIG.DEBUG) { console.log(`[监听器管理] 开始处理评论记录:`, { isReply, hasCommentDetails: !!commentDetails, commentText: commentText.substring(0, 50) + (commentText.length > 50 ? '...' : ''), videoBvid: currentVideoInfo?.bvid }); } if (isReply) { let pendingReplyContext = this.findBestReplyContext(commentText, sendContext); if (!pendingReplyContext) { if (CONFIG.DEBUG) console.log('[监听器管理] 警告:回复上下文匹配失败,尝试宽松匹配'); if (CONFIG.DEBUG) console.log('[监听器管理] 上下文Map内容:', Array.from(replyContextMap.entries())); if (CONFIG.DEBUG) console.log('[监听器管理] 发送上下文:', sendContext); pendingReplyContext = this.findLooseReplyContext(commentText, sendContext); } if (pendingReplyContext) { if (CONFIG.DEBUG) console.log(`[监听器管理] 使用回复上下文 ID: ${pendingReplyContext.id}`); dataToSave = await this.processReplyWithContext(pendingReplyContext, commentDetails, commentText, currentTime); replyContextMap.delete(pendingReplyContext.id); } else { if (CONFIG.DEBUG) console.log('[监听器管理] 彻底找不到上下文,跳过此回复'); if (CONFIG.DEBUG) console.log('[监听器管理] 回复内容:', commentText); if (CONFIG.DEBUG) console.log('[监听器管理] 评论详情:', commentDetails); return; } } else { const commentKey = this.generateMainCommentKey(commentText, currentVideoInfo?.bvid); if (this.isCommentAlreadyRecorded(commentKey, commentText)) { if (CONFIG.DEBUG) console.log('[监听器管理] 检测到重复的主评论,跳过记录'); return; } this.recentComments.set(commentKey, { text: commentText, timestamp: Date.now(), bvid: currentVideoInfo?.bvid }); this.cleanupRecentComments(); const newGroup = { mainComment: commentDetails ? { content: commentDetails.text, userName: commentDetails.userName, userLink: commentDetails.userLink, userAvatar: commentDetails.userAvatar, images: commentDetails.imageSrcs, pubDate: commentDetails.pubDate, likeCount: commentDetails.likeCount } : { content: commentText, userName: '获取中...', userLink: '#', userAvatar: '', images: [], pubDate: '刚刚', likeCount: '0' }, replies: [], createTime: currentTime, lastUpdateTime: currentTime, videoTitle: currentVideoInfo?.title || document.title, videoUrl: currentVideoInfo?.url || window.location.href, videoBvid: currentVideoInfo?.bvid || 'unknown' }; if (CONFIG.DEBUG) { console.log('[监听器管理] 创建新的主评论组:', { commentKey, hasDetails: !!commentDetails, videoTitle: newGroup.videoTitle }); } dataToSave = newGroup; sentComments.unshift(newGroup); } CommentExtractor.cleanupOldComments(); if (dataToSave) { try { await DatabaseManager.save('sent_comments', null, dataToSave).then((event) => { dataToSave.id = event.target.result; if (CONFIG.DEBUG) console.log(`[监听器管理] 成功保存评论记录,ID: ${dataToSave.id}`); }); } catch (error) { if (CONFIG.DEBUG) console.error('[监听器管理] 保存评论记录失败:', error); } } } static generateMainCommentKey(commentText, bvid) { return `main_${bvid}_${commentText.substring(0, 100)}`; } static isCommentAlreadyRecorded(commentKey, commentText) { if (this.recentComments.has(commentKey)) { const cached = this.recentComments.get(commentKey); if (Date.now() - cached.timestamp < 300000) { return true; } } const existingComment = sentComments.find(group => { if (!group.mainComment) return false; return group.mainComment.content === commentText && group.videoBvid === currentVideoInfo?.bvid; }); return !!existingComment; } static cleanupRecentComments() { const now = Date.now(); for (const [key, data] of this.recentComments.entries()) { if (now - data.timestamp > 300000) { // 5分钟过期 this.recentComments.delete(key); } } } static async processReplyWithContext(context, commentDetails, commentText, currentTime) { let existingGroup = null; if (context.mainComment) { existingGroup = CommentExtractor.findExistingCommentGroup(context.mainComment); } if (existingGroup) { if (context.isReplyToMain) { const replyRecord = { content: commentDetails?.text || commentText, type: '二级评论', time: currentTime, timestamp: Date.now(), userName: commentDetails?.userName || '获取中...', userLink: commentDetails?.userLink || '#', userAvatar: commentDetails?.userAvatar || '', images: commentDetails?.imageSrcs || [], pubDate: commentDetails?.pubDate || '刚刚', likeCount: commentDetails?.likeCount || '0', thirdLevelReplies: [] }; existingGroup.replies.push(replyRecord); } else if (context.isReplyToReply && context.parentComment) { const existingSecondLevel = CommentExtractor.findExistingSecondLevelComment(existingGroup, context.parentComment); if (existingSecondLevel) { if (!existingSecondLevel.thirdLevelReplies) { existingSecondLevel.thirdLevelReplies = []; } const thirdLevelRecord = { content: commentDetails?.text || commentText, type: '三级评论', time: currentTime, timestamp: Date.now(), userName: commentDetails?.userName || '获取中...', userLink: commentDetails?.userLink || '#', userAvatar: commentDetails?.userAvatar || '', images: commentDetails?.imageSrcs || [], pubDate: commentDetails?.pubDate || '刚刚', likeCount: commentDetails?.likeCount || '0' }; existingSecondLevel.thirdLevelReplies.push(thirdLevelRecord); } else { const replyRecord = { content: context.parentComment.content, type: '二级评论', time: currentTime, timestamp: Date.now(), userName: context.parentComment.userName, userLink: context.parentComment.userLink, userAvatar: context.parentComment.userAvatar, images: context.parentComment.imageSrcs, pubDate: context.parentComment.pubDate, likeCount: context.parentComment.likeCount, thirdLevelReplies: [{ content: commentDetails?.text || commentText, type: '三级评论', time: currentTime, timestamp: Date.now(), userName: commentDetails?.userName || '获取中...', userLink: commentDetails?.userLink || '#', userAvatar: commentDetails?.userAvatar || '', images: commentDetails?.imageSrcs || [], pubDate: commentDetails?.pubDate || '刚刚', likeCount: commentDetails?.likeCount || '0' }] }; existingGroup.replies.push(replyRecord); } } existingGroup.lastUpdateTime = currentTime; const index = sentComments.indexOf(existingGroup); if (index > 0) { sentComments.splice(index, 1); sentComments.unshift(existingGroup); } await DatabaseManager.update('sent_comments', existingGroup); return null; } else { const newGroup = { contextId: context.id, mainComment: context.mainComment ? { content: context.mainComment.content, userName: context.mainComment.userName, userLink: context.mainComment.userLink, userAvatar: context.mainComment.userAvatar, images: context.mainComment.imageSrcs, pubDate: context.mainComment.pubDate, likeCount: context.mainComment.likeCount } : null, replies: [], createTime: currentTime, lastUpdateTime: currentTime, videoTitle: currentVideoInfo?.title || document.title, videoUrl: currentVideoInfo?.url || window.location.href, videoBvid: currentVideoInfo?.bvid || 'unknown' }; if (context.isReplyToMain) { newGroup.replies.push({ content: commentDetails?.text || commentText, type: '二级评论', time: currentTime, timestamp: Date.now(), userName: commentDetails?.userName || '获取中...', userLink: commentDetails?.userLink || '#', userAvatar: commentDetails?.userAvatar || '', images: commentDetails?.imageSrcs || [], pubDate: commentDetails?.pubDate || '刚刚', likeCount: commentDetails?.likeCount || '0', thirdLevelReplies: [] }); } else if (context.isReplyToReply && context.parentComment) { newGroup.replies.push({ content: context.parentComment.content, type: '二级评论', time: currentTime, timestamp: Date.now(), userName: context.parentComment.userName, userLink: context.parentComment.userLink, userAvatar: context.parentComment.userAvatar, images: context.parentComment.imageSrcs, pubDate: context.parentComment.pubDate, likeCount: context.parentComment.likeCount, thirdLevelReplies: [{ content: commentDetails?.text || commentText, type: '三级评论', time: currentTime, timestamp: Date.now(), userName: commentDetails?.userName || '获取中...', userLink: commentDetails?.userLink || '#', userAvatar: commentDetails?.userAvatar || '', images: commentDetails?.imageSrcs || [], pubDate: commentDetails?.pubDate || '刚刚', likeCount: commentDetails?.likeCount || '0' }] }); } sentComments.unshift(newGroup); return newGroup; } } static findBestReplyContext(commentText, sendContext) { if (replyContextMap.size === 0) { return null; } let bestMatch = null; let bestScore = 0; for (const [contextId, context] of replyContextMap) {//暂时不用contextid let score = 0; const timeDiff = Math.abs(sendContext.timestamp - context.timestamp); if (timeDiff < 60000) { score += 20; score += Math.max(0, 20 - timeDiff / 1000); } if (context.mainComment && context.mainComment.content) { const mainContent = context.mainComment.content.substring(0, 100); if (this.isMainCommentVisibleOnPage(mainContent)) { score += 30; } } if (context.parentComment && context.parentComment.content) { const parentContent = context.parentComment.content; if (commentText.length > 0 && parentContent.length > 0) { score += 10; } } if (currentVideoInfo && currentVideoInfo.bvid) { score += 5; } if (score > bestScore) { bestScore = score; bestMatch = context; } } if (CONFIG.DEBUG) console.log(`[监听器管理] 最佳匹配分数: ${bestScore}`); return bestScore >= 30 ? bestMatch : null; } static isMainCommentVisibleOnPage(mainContent) { try { const commentsHost = getCachedElement('comments-host', 'bili-comments'); if (!commentsHost?.shadowRoot) return false; const allComments = commentsHost.shadowRoot.querySelectorAll('bili-comment-thread-renderer'); for (const thread of allComments) { const mainRenderer = thread.shadowRoot?.querySelector('bili-comment-renderer#comment'); if (mainRenderer) { const details = CommentExtractor.extractDetails(mainRenderer); if (details && details.content === mainContent) { return true; } } } return false; } catch (e) { return false; } } static findLooseReplyContext(commentText, sendContext) { if (replyContextMap.size === 1) { const context = Array.from(replyContextMap.values())[0]; const timeDiff = Math.abs(sendContext.timestamp - context.timestamp); if (timeDiff < 120000) { if (CONFIG.DEBUG) console.log('[监听器管理] 使用宽松匹配(唯一上下文)'); return context; } } return null; } } // ======================================数据迁移模块=================================================== class DataMigration { static async migrateFromLocalStorage() { const oldData = localStorage.getItem('bilibili_recorded_comments_v11'); if (oldData) { try { const dataArray = JSON.parse(oldData); for (const [key, value] of dataArray) { await DatabaseManager.save('comments', key, { ...value, repliesHTML: Array.from(value.repliesHTML || []) }); } localStorage.removeItem('bilibili_recorded_comments_v11'); console.log('成功迁移旧数据到 IndexedDB'); } catch (e) { console.error('迁移数据失败:', e); } } } } // =========================================ui管理模块========================================================== class UIManager { static create() { if (document.getElementById('collector-float-btn')) return; const html = ` <div id="collector-float-btn"> <span class="float-btn-icon">▲</span> </div> <div id="collector-panel" style="display: none;"> <div class="panel-header"> <span>B站收藏夹</span> <span class="panel-close-btn">×</span> </div> <div class="panel-content"> <div class="panel-tabs"> <button class="tab-btn active" data-tab="sent-comments">评论记录</button> <button class="tab-btn" data-tab="comments">评论收藏</button> <button class="tab-btn" data-tab="danmaku">弹幕记录</button> <button class="tab-btn" data-tab="settings">设置</button> </div> <div class="tab-content" id="sent-comments-tab"> <div class="stats-bar"> <span id="sent-comment-count">发送记录: 0</span> <button class="btn export" id="export-sent-btn">导出</button> <button class="btn danger" id="clear-sent-btn">清空</button> </div> <div class="search-pagination-container"> <div class="search-container"> <input type="text" class="search-input" id="sent-comments-search" placeholder="搜索评论内容、用户名或视频标题..."> </div> <div class="pagination-container"> <div class="pagination-info" id="sent-comments-pagination-info">显示 0 条记录</div> <div class="pagination-controls" id="sent-comments-pagination-controls"></div> <div class="page-size-selector"> <label>每页:</label> <select class="page-size-select" id="sent-comments-page-size"> <option value="5">5条</option> <option value="10" selected>10条</option> <option value="20">20条</option> <option value="50">50条</option> </select> </div> </div> </div> <div id="sent-comments-output">正在加载...</div> </div> <div class="tab-content" id="comments-tab" style="display:none;"> <div class="stats-bar"> <span id="comment-count">评论: 0</span> <button class="btn danger" id="clear-comments-btn">清空</button> </div> <div class="search-pagination-container"> <div class="search-container"> <input type="text" class="search-input" id="comments-search" placeholder="搜索评论内容或用户名..."> </div> <div class="pagination-container"> <div class="pagination-info" id="comments-pagination-info">显示 0 条记录</div> <div class="pagination-controls" id="comments-pagination-controls"></div> <div class="page-size-selector"> <label>每页:</label> <select class="page-size-select" id="comments-page-size"> <option value="5">5条</option> <option value="10" selected>10条</option> <option value="20">20条</option> <option value="50">50条</option> </select> </div> </div> </div> <div id="recorded-comments-output">正在加载...</div> </div> <div class="tab-content" id="danmaku-tab" style="display:none;"> <div class="stats-bar"> <span id="danmaku-count">弹幕: 0</span> <button class="btn danger" id="clear-danmaku-btn">清空</button> </div> <div class="search-pagination-container"> <div class="search-container"> <input type="text" class="search-input" id="danmaku-search" placeholder="搜索弹幕内容或视频标题..."> </div> <div class="pagination-container"> <div class="pagination-info" id="danmaku-pagination-info">显示 0 条记录</div> <div class="pagination-controls" id="danmaku-pagination-controls"></div> <div class="page-size-selector"> <label>每页:</label> <select class="page-size-select" id="danmaku-page-size"> <option value="5">5条</option> <option value="10" selected>10条</option> <option value="20">20条</option> <option value="50">50条</option> </select> </div> </div> </div> <div id="recorded-danmaku-output">正在加载...</div> </div> <div class="tab-content" id="settings-tab" style="display:none;"> <div class="settings-section"> <h4>显示设置</h4> <div class="setting-item"> <label>评论记录保存数量:</label> <input type="number" id="max-sent-input" min="1" max="500"> <span>组</span> </div> <div class="setting-item"> <label>评论收藏显示数量:</label> <input type="number" id="max-comments-input" min="1" max="200"> <span>条</span> </div> <div class="setting-item"> <label>弹幕显示数量:</label> <input type="number" id="max-danmaku-input" min="1" max="200"> <span>条</span> </div> <div class="setting-item"> <button id="save-settings-btn">保存设置</button> </div> <h4>数据管理</h4> <div class="setting-item"> <button id="export-all-btn">导出所有数据</button> <button id="import-data-btn">导入数据</button> <input type="file" id="import-file" accept=".json" style="display:none;"> </div> <div class="setting-item" style="display"> <button class="danger" id="clear-all-btn">清空所有数据</button> </div> </div> <div class="setting-item"> <div id="storage-info">计算存储中...</div> </div> </div> </div> </div> </div>`; document.body.insertAdjacentHTML('beforeend', html); this.addStyles(); this.bindEvents(); this.initFloatButton(); } static addStyles() { GM_addStyle(STYLES); } static bindEvents() { const panel = document.getElementById('collector-panel'); panel.querySelector('.panel-close-btn').addEventListener('click', () => { panel.style.display = 'none'; }); document.addEventListener('click', (e) => { const floatBtn = document.getElementById('collector-float-btn'); if (!panel.contains(e.target) && !floatBtn.contains(e.target) && panel.style.display !== 'none') { panel.style.display = 'none'; } }); document.querySelectorAll('.tab-btn').forEach(btn => { btn.addEventListener('click', () => { document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); document.querySelectorAll('.tab-content').forEach(content => { content.style.display = 'none'; }); document.getElementById(`${btn.dataset.tab}-tab`).style.display = 'block'; if (btn.dataset.tab === 'settings') { this.updateSettingsDisplay(); } }); }); this.bindDelegatedEvents(); this.bindButtonEvents(); this.bindSearchAndPagination(); } static bindDelegatedEvents() { document.addEventListener('click', (e) => { if (e.target.classList.contains('comment-image')) { e.stopPropagation(); window.open(e.target.src, '_blank'); } if (e.target.classList.contains('delete-btn')) { e.stopPropagation(); this.handleDeleteClick(e.target); } }); document.addEventListener('dblclick', (e) => { const historyItem = e.target.closest('.history-item'); const threadContainer = e.target.closest('.thread-container'); if (historyItem && !e.target.closest('.delete-btn') && !e.target.closest('a')) { const videoUrl = historyItem.dataset.videoUrl; if (videoUrl) window.open(videoUrl, '_blank'); } if (threadContainer && !e.target.closest('.delete-btn') && !e.target.closest('a')) { const videoUrl = threadContainer.dataset.videoUrl; if (videoUrl) window.open(videoUrl, '_blank'); } }); } static bindButtonEvents() { document.getElementById('save-settings-btn').addEventListener('click', () => { const newSentLimit = parseInt(document.getElementById('max-sent-input').value); const newCommentsLimit = parseInt(document.getElementById('max-comments-input').value); const newDanmakuLimit = parseInt(document.getElementById('max-danmaku-input').value); if (newSentLimit > 0 && newSentLimit <= 500) settings.SENT_COMMENTS = newSentLimit; if (newCommentsLimit > 0 && newCommentsLimit <= 200) settings.DISPLAY_COMMENTS = newCommentsLimit; if (newDanmakuLimit > 0 && newDanmakuLimit <= 200) settings.DISPLAY_DANMAKU = newDanmakuLimit; SettingsManager.save(); DisplayManager.updateAll(); alert('设置已保存!'); }); document.getElementById('export-all-btn').addEventListener('click', () => DataManager.exportAll()); document.getElementById('export-sent-btn').addEventListener('click', () => DataManager.exportSentComments()); const importBtn = document.getElementById('import-data-btn'); const importFile = document.getElementById('import-file'); importBtn.addEventListener('click', () => importFile.click()); importFile.addEventListener('change', async (e) => { const file = e.target.files[0]; if (file) { await DataManager.import(file); e.target.value = ''; } }); this.bindClearButtons(); } static bindSearchAndPagination() { ['sent-comments', 'comments', 'danmaku'].forEach(type => { const searchInput = document.getElementById(`${type}-search`); const pageSizeSelect = document.getElementById(`${type}-page-size`); if (searchInput) { let searchTimeout; searchInput.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { paginationState[type === 'sent-comments' ? 'sentComments' : type].searchTerm = e.target.value; paginationState[type === 'sent-comments' ? 'sentComments' : type].currentPage = 1; this.updateTabContent(type); }, 300); }); } if (pageSizeSelect) { pageSizeSelect.addEventListener('change', (e) => { const stateKey = type === 'sent-comments' ? 'sentComments' : type; paginationState[stateKey].currentPage = 1; this.updateTabContent(type); }); } }); document.addEventListener('click', (e) => { if (e.target.classList.contains('pagination-btn') && !e.target.classList.contains('disabled')) { const type = e.target.dataset.type; const action = e.target.dataset.action; const page = parseInt(e.target.dataset.page); if (type && paginationState[type]) { if (action === 'prev') { paginationState[type].currentPage = Math.max(1, paginationState[type].currentPage - 1); } else if (action === 'next') { const maxPage = Math.ceil(paginationState[type].filteredData.length / this.getPageSize(type)); paginationState[type].currentPage = Math.min(maxPage, paginationState[type].currentPage + 1); } else if (page) { paginationState[type].currentPage = page; } const tabType = type === 'sentComments' ? 'sent-comments' : type; this.updateTabContent(tabType); } } }); } static getPageSize(type) { const select = document.getElementById(`${type === 'sentComments' ? 'sent-comments' : type}-page-size`); return select ? parseInt(select.value) : CONFIG.PAGINATION.PAGE_SIZE; } static updateTabContent(type) { if (type === 'sent-comments') { DisplayManager.updateSentComments(); } else if (type === 'comments') { DisplayManager.updateComments(); } else if (type === 'danmaku') { DisplayManager.updateDanmaku(); } } static bindClearButtons() { document.getElementById('clear-sent-btn').addEventListener('click', async () => { if (confirm('确定要清空所有评论记录吗?')) { await DatabaseManager.clear(CONFIG.STORES.SENT_COMMENTS); sentComments.length = 0; resetPaginationState('sentComments'); DisplayManager.updateSentComments(); } }); document.getElementById('clear-comments-btn').addEventListener('click', async () => { if (confirm('确定要清空所有评论收藏吗?')) { await DatabaseManager.clear(CONFIG.STORES.COMMENTS); recordedThreads.clear(); resetPaginationState('comments'); DisplayManager.updateComments(); } }); document.getElementById('clear-danmaku-btn').addEventListener('click', async () => { if (confirm('确定要清空所有弹幕记录吗?')) { await DatabaseManager.clear(CONFIG.STORES.DANMAKU); recordedDanmaku.length = 0; resetPaginationState('danmaku'); DisplayManager.updateDanmaku(); } }); document.getElementById('clear-all-btn').addEventListener('click', async () => { if (confirm('确定要清空所有数据吗?此操作不可恢复!')) { await Promise.all([ DatabaseManager.clear(CONFIG.STORES.COMMENTS), DatabaseManager.clear(CONFIG.STORES.DANMAKU), DatabaseManager.clear(CONFIG.STORES.SENT_COMMENTS) ]); recordedThreads.clear(); recordedDanmaku.length = 0; sentComments.length = 0; replyContextMap.clear(); resetPaginationState(); DisplayManager.updateAll(); } }); } static async handleDeleteClick(button) { const type = button.dataset.type; const key = button.dataset.key || parseInt(button.dataset.id); let confirmMsg = ''; if (type === 'comment') confirmMsg = '确定要删除这条评论收藏吗?'; else if (type === 'danmaku') confirmMsg = '确定要删除这条弹幕记录吗?'; else if (type === 'sent') confirmMsg = '确定要删除这组评论记录吗?'; if (confirm(confirmMsg)) { let store = ''; if (type === 'comment') store = CONFIG.STORES.COMMENTS; else if (type === 'danmaku') store = CONFIG.STORES.DANMAKU; else if (type === 'sent') store = CONFIG.STORES.SENT_COMMENTS; await DatabaseManager.delete(store, key); if (type === 'comment') { recordedThreads.delete(key); DisplayManager.updateComments(); } else if (type === 'danmaku') { const index = recordedDanmaku.findIndex(d => d.id === key); if (index !== -1) recordedDanmaku.splice(index, 1); DisplayManager.updateDanmaku(); } else if (type === 'sent') { const index = sentComments.findIndex(c => c.id === key); if (index !== -1) sentComments.splice(index, 1); DisplayManager.updateSentComments(); } } } static initFloatButton() { const floatBtn = document.getElementById('collector-float-btn'); let isDragging = false; let startX, startY, initialX, initialY, dragStartTime; function dragStart(e) { dragStartTime = Date.now(); isDragging = true; floatBtn.classList.add('dragging'); const clientX = e.clientX || e.touches[0].clientX; const clientY = e.clientY || e.touches[0].clientY; startX = clientX; startY = clientY; const rect = floatBtn.getBoundingClientRect(); initialX = rect.left; initialY = rect.top; e.preventDefault(); } function drag(e) { if (!isDragging) return; e.preventDefault(); const clientX = e.clientX || e.touches[0].clientX; const clientY = e.clientY || e.touches[0].clientY; const deltaX = clientX - startX; const deltaY = clientY - startY; const newX = Math.max(0, Math.min(initialX + deltaX, window.innerWidth - 60)); const newY = Math.max(0, Math.min(initialY + deltaY, window.innerHeight - 60)); floatBtn.style.left = newX + 'px'; floatBtn.style.top = newY + 'px'; floatBtn.style.right = 'auto'; floatBtn.style.bottom = 'auto'; } function dragEnd() { if (!isDragging) return; isDragging = false; floatBtn.classList.remove('dragging'); if (Date.now() - dragStartTime < 200) { const panel = document.getElementById('collector-panel'); const isVisible = panel.style.display !== 'none'; panel.style.display = isVisible ? 'none' : 'block'; const icon = floatBtn.querySelector('.float-btn-icon'); icon.textContent = isVisible ? '▲' : '▼'; if (!isVisible) { DisplayManager.updateAll(); } } const rect = floatBtn.getBoundingClientRect(); SettingsManager.savePosition({ left: rect.left, top: rect.top }); } ['mousedown', 'touchstart'].forEach(event => floatBtn.addEventListener(event, dragStart)); ['mousemove', 'touchmove'].forEach(event => document.addEventListener(event, drag)); ['mouseup', 'touchend'].forEach(event => document.addEventListener(event, dragEnd)); const position = SettingsManager.loadPosition(); if (position) { const x = Math.max(0, Math.min(position.left, window.innerWidth - 60)); const y = Math.max(0, Math.min(position.top, window.innerHeight - 60)); floatBtn.style.left = x + 'px'; floatBtn.style.top = y + 'px'; floatBtn.style.right = 'auto'; floatBtn.style.bottom = 'auto'; } } static updateSettingsDisplay() { document.getElementById('max-sent-input').value = settings.SENT_COMMENTS; document.getElementById('max-comments-input').value = settings.DISPLAY_COMMENTS; document.getElementById('max-danmaku-input').value = settings.DISPLAY_DANMAKU; this.updateCurrentDisplayCounts(); this.updateStorageInfo(); } static updateCurrentDisplayCounts() { const commentsElement = document.getElementById('current-comments-display'); const danmakuElement = document.getElementById('current-danmaku-display'); if (commentsElement) { const actualCommentsCount = Math.min(recordedThreads.size, settings.DISPLAY_COMMENTS); commentsElement.textContent = actualCommentsCount; } if (danmakuElement) { const actualDanmakuCount = Math.min(recordedDanmaku.length, settings.DISPLAY_DANMAKU); danmakuElement.textContent = actualDanmakuCount; } } static async updateStorageInfo() { try { if (navigator.storage && navigator.storage.estimate) { const estimate = await navigator.storage.estimate(); const usage = (estimate.usage / 1024 / 1024).toFixed(2); const quota = (estimate.quota / 1024 / 1024).toFixed(2); const percentage = ((estimate.usage / estimate.quota) * 100).toFixed(1); document.getElementById('storage-info').innerHTML = `已用: ${usage}MB / ${quota}MB (${percentage}%)`; } else { document.getElementById('storage-info').textContent = '浏览器不支持存储估算'; } } catch (e) { document.getElementById('storage-info').textContent = '无法获取存储信息'; } } } // ======================================主程序入口============================================= async function init() { try { console.log('开始初始化 B站收藏夹'); SettingsManager.load(); const database = await DatabaseManager.init(); window.biliBiliDB = database; setDB(database); await DataMigration.migrateFromLocalStorage(); UIManager.create(); await DatabaseManager.loadAll(); resetPaginationState(); DisplayManager.updateAll(); ListenerManager.init(); console.log('B站收藏夹已成功加载'); } catch (error) { console.error('初始化失败:', error); } } // 启动脚本 init(); })();