// ==UserScript==
// @name B站评论弹幕收藏夹
// @version 0.1.3
// @description 记录b站发送的评论和弹幕,防止重要评论的丢失
// @author naaammme
// @match *://www.bilibili.com/*
// @grant GM_addStyle
// @run-at document-idle
// ==/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, "'");
}
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 = '
暂无记录
';
}
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 += `
${mainHTML}
${repliesHTML}
`;
});
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 += ``;
});
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 += ``;
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 += ``;
if (startPage > 2) {
html += ``;
}
}
for (let page = startPage; page <= endPage; page++) {
const activeClass = page === currentPage ? 'active' : '';
html += ``;
}
if (endPage < totalPages) {
if (endPage < totalPages - 1) {
html += ``;
}
html += ``;
}
const nextDisabled = currentPage >= totalPages ? 'disabled' : '';
html += ``;
controlsElement.innerHTML = html;
}
static renderNoResults(message, icon = '📝') {
return `
${icon}
${message}
尝试修改搜索关键词或清空搜索框
`;
}
static highlightSearchTerm(text, searchTerm) {
if (!searchTerm || !text) return text;
const regex = new RegExp(`(${escapeHtml(searchTerm).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
return text.replace(regex, '$1');
}
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 =>
``
).join('');
mainImagesHTML = ``;
}
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 = `
第${index + 1}组 (${totalReplies}条回复)
${group.contextId ? `
${group.contextId}
` : ''}
双击跳转视频
${mainImagesHTML}
首次记录: ${escapeHtml(group.createTime)}
`;
}
const repliesHTML = group.replies.map((reply) => {
return this.renderReply(reply, searchTerm);
}).join('');
return `
${mainCommentHTML}
${repliesHTML}
`;
}
static renderReply(reply, searchTerm = '') {
let replyImagesHTML = '';
if (reply.images && reply.images.length > 0) {
const replyImages = reply.images.map(src =>
``
).join('');
replyImagesHTML = ``;
}
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 =>
``
).join('');
thirdImagesHTML = ``;
}
let thirdUserName = escapeHtml(thirdLevel.userName);
let thirdContent = escapeHtml(thirdLevel.content);
if (searchTerm) {
thirdUserName = this.highlightSearchTerm(thirdUserName, searchTerm);
thirdContent = this.highlightSearchTerm(thirdContent, searchTerm);
}
return `
`;
}).join('');
}
const thirdLevelCount = reply.thirdLevelReplies ? reply.thirdLevelReplies.length : 0;
const countBadge = thirdLevelCount > 0 ? `${thirdLevelCount}条回复` : '';
let replyUserName = escapeHtml(reply.userName);
let replyContent = escapeHtml(reply.content);
if (searchTerm) {
replyUserName = this.highlightSearchTerm(replyUserName, searchTerm);
replyContent = this.highlightSearchTerm(replyContent, searchTerm);
}
return `
${thirdLevelHTML}
`;
}
static detailsToHTML(details, className = 'comment-entry') {
if (!details) return '';
const avatarSrc = details.userAvatar || 'https://static.hdslb.com/images/member/noface.gif';
let html = `
`;
if (details.text) html += ``;
if (details.imageSrcs?.length > 0) {
html += ``;
}
html += `
`;
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 = `
▲
`;
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();
})();