// ==UserScript== // @name B站评论弹幕工具-自动记录保存评论弹幕收藏评论 // @version 4.0 // @description 记录b站发送的评论和弹幕,防止重要评论的丢失 // @author naaammme // @match *://www.bilibili.com/* // @match *://game.bilibili.com/* // @match *://manga.bilibili.com/* // @match *://t.bilibili.com/* // @grant none // @run-at document-idle // @noframes // @icon https://www.bilibili.com/favicon.ico // ==/UserScript== (function (global) { 'use strict'; const ns = (global.__BiliCollector__ = global.__BiliCollector__ || {}); ns.VERSION = ns.VERSION || '4.0'; function isDebug() { return !!(ns.config && ns.config.DEBUG); } ns.logger = ns.logger || { log: (...args) => { if (isDebug()) console.log('[BiliCollector]', ...args); }, warn: (...args) => { if (isDebug()) console.warn('[BiliCollector]', ...args); }, error: (...args) => { console.error('[BiliCollector]', ...args); } }; ns.invariant = ns.invariant || function invariant(condition, message) { if (!condition) throw new Error(message || 'Invariant failed'); }; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns, 'BiliCollector namespace missing'); const CONFIG = { DB_NAME_PREFIX: 'BilibiliCollectorDB', DB_VERSION: 2, DEBUG: false, LS_PREFIX: 'bilibili_collector', dbName(uid) { return `${this.DB_NAME_PREFIX}_${uid}`; }, lsKey(name, uid) { return `${this.LS_PREFIX}_${name}_${uid}`; }, STORES: { COMMENTS: 'comments', DANMAKU: 'danmaku', SENT_COMMENTS: 'sent_comments' }, DEFAULT_LIMITS: { DISPLAY_COMMENTS: 200, DISPLAY_DANMAKU: 200, SENT_COMMENTS: 200, THEME: 'auto', UID_LOOKUP: false, ENABLE_SENT_COMMENTS: true, ENABLE_COMMENT_COLLECT: true, ENABLE_DANMAKU: true, ENABLE_HOTKEY_SEND: true, ENABLE_ANTIFRAUD: true, COMMENT_COLLAPSE_LENGTH: 200 }, PAGINATION: { PAGE_SIZE: 10, MAX_PAGE_BUTTONS: 5 }, PERFORMANCE: { RETRY_BASE_DELAY: 300, RETRY_MAX_DELAY: 3000, RETRY_MULTIPLIER: 1.5, MUTATION_THROTTLE: 300, SEARCH_TIMEOUT: 30000, RANDOM_FACTOR: 0.3, MAX_CACHE_SIZE: 50 }, SELECTORS: { PLAYER_CONTAINER: ['.bpx-player-container', '.bilibili-player-video-wrap'], DANMAKU_INPUT: ['.bpx-player-dm-input', '.bilibili-player-video-danmaku-input', 'input[placeholder*="弹幕"]'], DANMAKU_SEND_BTN: ['.bpx-player-dm-btn-send', '.bilibili-player-video-btn-send'], DANMAKU_SENDING_BAR: ['.bpx-player-sending-bar', '.bilibili-player-video-sendbar'], COMMENTS_HOST: 'bili-comments' } }; ns.config = CONFIG; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.config, 'BiliCollector config must be loaded before state'); const CONFIG = ns.config; const state = { db: null, currentUid: null, recordedThreads: new Map(), recordedDanmaku: [], sentComments: [], currentVideoInfo: null, lastReplyContext: null, settings: { ...CONFIG.DEFAULT_LIMITS }, domCache: new Map(), cacheTimestamps: new Map(), paginationState: { sentComments: { currentPage: 1, searchTerm: '', filteredData: [] }, comments: { currentPage: 1, searchTerm: '', filteredData: [] }, danmaku: { currentPage: 1, searchTerm: '', filteredData: [] } } }; state.setDb = function setDb(database) { state.db = database; }; state.setCurrentUid = function setCurrentUid(uid) { state.currentUid = uid; }; state.setSettings = function setSettings(newSettings) { state.settings = { ...state.settings, ...newSettings }; }; state.setCurrentVideoInfo = function setCurrentVideoInfo(info) { state.currentVideoInfo = info; }; state.clearReplyContext = function clearReplyContext() { state.lastReplyContext = null; }; state.resetPaginationState = function resetPaginationState(type) { const ps = state.paginationState; if (type && ps[type]) { ps[type].currentPage = 1; ps[type].searchTerm = ''; ps[type].filteredData = []; return; } for (const key in ps) { ps[key].currentPage = 1; ps[key].searchTerm = ''; ps[key].filteredData = []; } }; state.getCachedElement = function getCachedElement(key, selector, parent = document) { const cached = state.domCache.get(key); if (cached) { if (cached.isConnected) { return cached; } state.domCache.delete(key); state.cacheTimestamps.delete(key); ns.logger.log(`[DOM缓存] 清理失效元素: ${key}`); } const element = parent.querySelector(selector); if (element) { if (state.domCache.size >= CONFIG.PERFORMANCE.MAX_CACHE_SIZE) { const evictCount = Math.ceil(CONFIG.PERFORMANCE.MAX_CACHE_SIZE * 0.2); const oldestKeys = Array.from(state.cacheTimestamps.entries()) .sort((a, b) => a[1] - b[1]) .slice(0, evictCount) .map(([k]) => k); oldestKeys.forEach((oldKey) => { state.domCache.delete(oldKey); state.cacheTimestamps.delete(oldKey); }); ns.logger.log(`[DOM缓存] 清理了 ${oldestKeys.length} 个最老的缓存项`); } state.domCache.set(key, element); state.cacheTimestamps.set(key, Date.now()); } return element; }; state.clearAllDomCache = function clearAllDomCache() { const size = state.domCache.size; state.domCache.clear(); state.cacheTimestamps.clear(); ns.logger.log(`[DOM缓存] 页面切换,清理了 ${size} 个缓存项`); }; ns.state = state; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.utils = ns.utils || {}; function escapeHtml(input) { const unsafe = String(input ?? ''); return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } ns.utils.escapeHtml = escapeHtml; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.utils = ns.utils || {}; function generateUniqueKey(type, data) { if (!data) return null; switch (type) { case 'sentComment': { if (!data.mainComment) return null; if (data.mainComment.rpid) return data.mainComment.rpid; const u = data.mainComment.userName ?? ''; const c = data.mainComment.content ?? ''; const bvid = data.videoBvid ?? ''; return `${u}|||${c}|||${bvid}`; } case 'danmaku': { const text = data.text ?? ''; const vid = data.videoId ?? ''; const t = typeof data.timestamp === 'number' ? data.timestamp : 0; return `${text}|||${vid}|||${Math.floor(t / 60000)}`; } case 'comment': return data.rpid || data.key || `${data.userName ?? ''}|||${data.content || ''}`; default: return null; } } ns.utils.generateUniqueKey = generateUniqueKey; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.utils = ns.utils || {}; function getRandomDelay(baseDelay) { const { RANDOM_FACTOR } = ns.config.PERFORMANCE; const randomFactor = 1 + (Math.random() - 0.5) * RANDOM_FACTOR * 2; return Math.round(baseDelay * randomFactor); } function getRetryDelay(attemptCount) { const perf = ns.config.PERFORMANCE; const delay = Math.min( perf.RETRY_BASE_DELAY * Math.pow(perf.RETRY_MULTIPLIER, attemptCount), perf.RETRY_MAX_DELAY ); return getRandomDelay(delay); } ns.utils.getRandomDelay = getRandomDelay; ns.utils.getRetryDelay = getRetryDelay; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; class WbiSign { constructor() { this.mixinKeyEncTab = [ 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 36, 20, 34, 44, 52, ]; } getMixinKey = (orig) => this.mixinKeyEncTab.map((n) => orig[n]).join('').slice(0, 32); encWbi(params, img_key, sub_key) { const mixin_key = this.getMixinKey(img_key + sub_key); const curr_time = Math.round(Date.now() / 1000); const chr_filter = /[!'()*]/g; Object.assign(params, { wts: curr_time }); const query = Object.keys(params) .sort() .map((key) => { const value = params[key].toString().replace(chr_filter, ''); return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }) .join('&'); const wbi_sign = this.md5(query + mixin_key); return query + '&w_rid=' + wbi_sign; } md5(string) { function md5_RotateLeft(lValue, iShiftBits) { return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits)); } function md5_AddUnsigned(lX, lY) { const lX8 = lX & 0x80000000, lY8 = lY & 0x80000000; const lX4 = lX & 0x40000000, lY4 = lY & 0x40000000; const lResult = (lX & 0x3fffffff) + (lY & 0x3fffffff); if (lX4 & lY4) return lResult ^ 0x80000000 ^ lX8 ^ lY8; if (lX4 | lY4) { if (lResult & 0x40000000) return lResult ^ 0xc0000000 ^ lX8 ^ lY8; else return lResult ^ 0x40000000 ^ lX8 ^ lY8; } else return lResult ^ lX8 ^ lY8; } function md5_F(x, y, z) { return (x & y) | (~x & z); } function md5_G(x, y, z) { return (x & z) | (y & ~z); } function md5_H(x, y, z) { return x ^ y ^ z; } function md5_I(x, y, z) { return y ^ (x | ~z); } function md5_FF(a, b, c, d, x, s, ac) { a = md5_AddUnsigned(a, md5_AddUnsigned(md5_AddUnsigned(md5_F(b, c, d), x), ac)); return md5_AddUnsigned(md5_RotateLeft(a, s), b); } function md5_GG(a, b, c, d, x, s, ac) { a = md5_AddUnsigned(a, md5_AddUnsigned(md5_AddUnsigned(md5_G(b, c, d), x), ac)); return md5_AddUnsigned(md5_RotateLeft(a, s), b); } function md5_HH(a, b, c, d, x, s, ac) { a = md5_AddUnsigned(a, md5_AddUnsigned(md5_AddUnsigned(md5_H(b, c, d), x), ac)); return md5_AddUnsigned(md5_RotateLeft(a, s), b); } function md5_II(a, b, c, d, x, s, ac) { a = md5_AddUnsigned(a, md5_AddUnsigned(md5_AddUnsigned(md5_I(b, c, d), x), ac)); return md5_AddUnsigned(md5_RotateLeft(a, s), b); } function md5_ConvertToWordArray(string) { let lWordCount, lMessageLength = string.length; const lNumberOfWords_temp1 = lMessageLength + 8; const lNumberOfWords_temp2 = (lNumberOfWords_temp1 - (lNumberOfWords_temp1 % 64)) / 64; const lNumberOfWords = (lNumberOfWords_temp2 + 1) * 16; const lWordArray = Array(lNumberOfWords - 1); let lBytePosition = 0, lByteCount = 0; while (lByteCount < lMessageLength) { lWordCount = (lByteCount - (lByteCount % 4)) / 4; lBytePosition = (lByteCount % 4) * 8; lWordArray[lWordCount] = lWordArray[lWordCount] | (string.charCodeAt(lByteCount) << lBytePosition); lByteCount++; } lWordCount = (lByteCount - (lByteCount % 4)) / 4; lBytePosition = (lByteCount % 4) * 8; lWordArray[lWordCount] = lWordArray[lWordCount] | (0x80 << lBytePosition); lWordArray[lNumberOfWords - 2] = lMessageLength << 3; lWordArray[lNumberOfWords - 1] = lMessageLength >>> 29; return lWordArray; } function md5_WordToHex(lValue) { let WordToHexValue = '', WordToHexValue_temp = '', lByte, lCount; for (lCount = 0; lCount <= 3; lCount++) { lByte = (lValue >>> (lCount * 8)) & 255; WordToHexValue_temp = '0' + lByte.toString(16); WordToHexValue = WordToHexValue + WordToHexValue_temp.substr(WordToHexValue_temp.length - 2, 2); } return WordToHexValue; } function md5_Utf8Encode(string) { string = string.replace(/\r\n/g, '\n'); let utftext = ''; for (let n = 0; n < string.length; n++) { const c = string.charCodeAt(n); if (c < 128) utftext += String.fromCharCode(c); else if (c > 127 && c < 2048) { utftext += String.fromCharCode((c >> 6) | 192); utftext += String.fromCharCode((c & 63) | 128); } else { utftext += String.fromCharCode((c >> 12) | 224); utftext += String.fromCharCode(((c >> 6) & 63) | 128); utftext += String.fromCharCode((c & 63) | 128); } } return utftext; } let x = [], k, AA, BB, CC, DD, a, b, c, d; const S11 = 7, S12 = 12, S13 = 17, S14 = 22; const S21 = 5, S22 = 9, S23 = 14, S24 = 20; const S31 = 4, S32 = 11, S33 = 16, S34 = 23; const S41 = 6, S42 = 10, S43 = 15, S44 = 21; string = md5_Utf8Encode(string); x = md5_ConvertToWordArray(string); a = 0x67452301; b = 0xefcdab89; c = 0x98badcfe; d = 0x10325476; for (k = 0; k < x.length; k += 16) { AA = a; BB = b; CC = c; DD = d; a = md5_FF(a, b, c, d, x[k + 0], S11, 0xd76aa478); d = md5_FF(d, a, b, c, x[k + 1], S12, 0xe8c7b756); c = md5_FF(c, d, a, b, x[k + 2], S13, 0x242070db); b = md5_FF(b, c, d, a, x[k + 3], S14, 0xc1bdceee); a = md5_FF(a, b, c, d, x[k + 4], S11, 0xf57c0faf); d = md5_FF(d, a, b, c, x[k + 5], S12, 0x4787c62a); c = md5_FF(c, d, a, b, x[k + 6], S13, 0xa8304613); b = md5_FF(b, c, d, a, x[k + 7], S14, 0xfd469501); a = md5_FF(a, b, c, d, x[k + 8], S11, 0x698098d8); d = md5_FF(d, a, b, c, x[k + 9], S12, 0x8b44f7af); c = md5_FF(c, d, a, b, x[k + 10], S13, 0xffff5bb1); b = md5_FF(b, c, d, a, x[k + 11], S14, 0x895cd7be); a = md5_FF(a, b, c, d, x[k + 12], S11, 0x6b901122); d = md5_FF(d, a, b, c, x[k + 13], S12, 0xfd987193); c = md5_FF(c, d, a, b, x[k + 14], S13, 0xa679438e); b = md5_FF(b, c, d, a, x[k + 15], S14, 0x49b40821); a = md5_GG(a, b, c, d, x[k + 1], S21, 0xf61e2562); d = md5_GG(d, a, b, c, x[k + 6], S22, 0xc040b340); c = md5_GG(c, d, a, b, x[k + 11], S23, 0x265e5a51); b = md5_GG(b, c, d, a, x[k + 0], S24, 0xe9b6c7aa); a = md5_GG(a, b, c, d, x[k + 5], S21, 0xd62f105d); d = md5_GG(d, a, b, c, x[k + 10], S22, 0x2441453); c = md5_GG(c, d, a, b, x[k + 15], S23, 0xd8a1e681); b = md5_GG(b, c, d, a, x[k + 4], S24, 0xe7d3fbc8); a = md5_GG(a, b, c, d, x[k + 9], S21, 0x21e1cde6); d = md5_GG(d, a, b, c, x[k + 14], S22, 0xc33707d6); c = md5_GG(c, d, a, b, x[k + 3], S23, 0xf4d50d87); b = md5_GG(b, c, d, a, x[k + 8], S24, 0x455a14ed); a = md5_GG(a, b, c, d, x[k + 13], S21, 0xa9e3e905); d = md5_GG(d, a, b, c, x[k + 2], S22, 0xfcefa3f8); c = md5_GG(c, d, a, b, x[k + 7], S23, 0x676f02d9); b = md5_GG(b, c, d, a, x[k + 12], S24, 0x8d2a4c8a); a = md5_HH(a, b, c, d, x[k + 5], S31, 0xfffa3942); d = md5_HH(d, a, b, c, x[k + 8], S32, 0x8771f681); c = md5_HH(c, d, a, b, x[k + 11], S33, 0x6d9d6122); b = md5_HH(b, c, d, a, x[k + 14], S34, 0xfde5380c); a = md5_HH(a, b, c, d, x[k + 1], S31, 0xa4beea44); d = md5_HH(d, a, b, c, x[k + 4], S32, 0x4bdecfa9); c = md5_HH(c, d, a, b, x[k + 7], S33, 0xf6bb4b60); b = md5_HH(b, c, d, a, x[k + 10], S34, 0xbebfbc70); a = md5_HH(a, b, c, d, x[k + 13], S31, 0x289b7ec6); d = md5_HH(d, a, b, c, x[k + 0], S32, 0xeaa127fa); c = md5_HH(c, d, a, b, x[k + 3], S33, 0xd4ef3085); b = md5_HH(b, c, d, a, x[k + 6], S34, 0x4881d05); a = md5_HH(a, b, c, d, x[k + 9], S31, 0xd9d4d039); d = md5_HH(d, a, b, c, x[k + 12], S32, 0xe6db99e5); c = md5_HH(c, d, a, b, x[k + 15], S33, 0x1fa27cf8); b = md5_HH(b, c, d, a, x[k + 2], S34, 0xc4ac5665); a = md5_II(a, b, c, d, x[k + 0], S41, 0xf4292244); d = md5_II(d, a, b, c, x[k + 7], S42, 0x432aff97); c = md5_II(c, d, a, b, x[k + 14], S43, 0xab9423a7); b = md5_II(b, c, d, a, x[k + 5], S44, 0xfc93a039); a = md5_II(a, b, c, d, x[k + 12], S41, 0x655b59c3); d = md5_II(d, a, b, c, x[k + 3], S42, 0x8f0ccc92); c = md5_II(c, d, a, b, x[k + 10], S43, 0xffeff47d); b = md5_II(b, c, d, a, x[k + 1], S44, 0x85845dd1); a = md5_II(a, b, c, d, x[k + 8], S41, 0x6fa87e4f); d = md5_II(d, a, b, c, x[k + 15], S42, 0xfe2ce6e0); c = md5_II(c, d, a, b, x[k + 6], S43, 0xa3014314); b = md5_II(b, c, d, a, x[k + 13], S44, 0x4e0811a1); a = md5_II(a, b, c, d, x[k + 4], S41, 0xf7537e82); d = md5_II(d, a, b, c, x[k + 11], S42, 0xbd3af235); c = md5_II(c, d, a, b, x[k + 2], S43, 0x2ad7d2bb); b = md5_II(b, c, d, a, x[k + 9], S44, 0xeb86d391); a = md5_AddUnsigned(a, AA); b = md5_AddUnsigned(b, BB); c = md5_AddUnsigned(c, CC); d = md5_AddUnsigned(d, DD); } return (md5_WordToHex(a) + md5_WordToHex(b) + md5_WordToHex(c) + md5_WordToHex(d)).toLowerCase(); } } ns.utils.WbiSign = WbiSign; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.db = ns.db || {}; function requestToPromise(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function transactionToPromise(tx) { return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(true); tx.onabort = () => reject(tx.error || new Error('Transaction aborted')); tx.onerror = () => reject(tx.error || new Error('Transaction error')); }); } ns.db.idb = { requestToPromise, transactionToPromise }; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.config && ns.state && ns.db && ns.db.idb, 'DB module dependencies missing'); const { requestToPromise, transactionToPromise } = ns.db.idb; const CONFIG = ns.config; const state = ns.state; class DatabaseManager { static async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(CONFIG.dbName(state.currentUid), CONFIG.DB_VERSION); request.onblocked = () => { reject(new Error('IndexedDB 版本升级被阻塞:请关闭其他 B 站标签页后刷新')); }; request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; state.setDb(database); resolve(database); }; request.onupgradeneeded = (event) => { const database = event.target.result; state.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 assertReady() { ns.invariant(state.db, 'IndexedDB not initialized'); } static async save(store, key, data) { this.assertReady(); const tx = state.db.transaction([store], 'readwrite'); const objectStore = tx.objectStore(store); let result; if (store === CONFIG.STORES.COMMENTS) { const request = objectStore.put({ id: key, data, timestamp: Date.now() }); result = await requestToPromise(request); } else { const request = objectStore.add(data); result = await requestToPromise(request); } await transactionToPromise(tx); return result; } static async update(store, data) { this.assertReady(); const tx = state.db.transaction([store], 'readwrite'); const objectStore = tx.objectStore(store); const request = objectStore.put(data); const result = await requestToPromise(request); await transactionToPromise(tx); return result; } static async delete(store, key) { this.assertReady(); const tx = state.db.transaction([store], 'readwrite'); const objectStore = tx.objectStore(store); const request = objectStore.delete(key); await requestToPromise(request); await transactionToPromise(tx); return true; } static async clear(store) { this.assertReady(); const tx = state.db.transaction([store], 'readwrite'); const objectStore = tx.objectStore(store); const request = objectStore.clear(); await requestToPromise(request); await transactionToPromise(tx); return true; } static async loadAll() { await Promise.all([this.loadComments(), this.loadDanmaku(), this.loadSentComments()]); } static async loadComments() { this.assertReady(); const tx = state.db.transaction([CONFIG.STORES.COMMENTS], 'readonly'); const store = tx.objectStore(CONFIG.STORES.COMMENTS); const request = store.getAll(); const comments = await requestToPromise(request); state.recordedThreads.clear(); comments.forEach((record) => state.recordedThreads.set(record.id, record.data)); } static async loadDanmaku() { this.assertReady(); const tx = state.db.transaction([CONFIG.STORES.DANMAKU], 'readonly'); const store = tx.objectStore(CONFIG.STORES.DANMAKU); const request = store.getAll(); const list = await requestToPromise(request); list.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); state.recordedDanmaku.length = 0; state.recordedDanmaku.push(...list); } static async loadSentComments() { this.assertReady(); const tx = state.db.transaction([CONFIG.STORES.SENT_COMMENTS], 'readonly'); const store = tx.objectStore(CONFIG.STORES.SENT_COMMENTS); const request = store.getAll(); const list = await requestToPromise(request); list.sort((a, b) => (b.id || 0) - (a.id || 0)); state.sentComments.length = 0; state.sentComments.push(...list); } } ns.db.DatabaseManager = DatabaseManager; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.config, 'Settings dependencies missing'); const state = ns.state; const CONFIG = ns.config; class SettingsManager { static save() { localStorage.setItem(CONFIG.lsKey('settings', state.currentUid), JSON.stringify(state.settings)); } static load() { try { const saved = localStorage.getItem(CONFIG.lsKey('settings', state.currentUid)); if (saved) { const loadedSettings = { ...CONFIG.DEFAULT_LIMITS, ...JSON.parse(saved) }; state.setSettings(loadedSettings); } } catch (e) { ns.logger.error('加载设置失败:', e); state.setSettings({ ...CONFIG.DEFAULT_LIMITS }); } } static savePosition(position) { localStorage.setItem(CONFIG.lsKey('float_position', state.currentUid), JSON.stringify(position)); } static loadPosition() { try { const saved = localStorage.getItem(CONFIG.lsKey('float_position', state.currentUid)); return saved ? JSON.parse(saved) : null; } catch { return null; } } static savePanelState(panelState) { localStorage.setItem(CONFIG.lsKey('panel_state', state.currentUid), JSON.stringify(panelState)); } static loadPanelState() { try { const saved = localStorage.getItem(CONFIG.lsKey('panel_state', state.currentUid)); return saved ? JSON.parse(saved) : null; } catch { return null; } } } ns.settings = ns.settings || {}; ns.settings.SettingsManager = SettingsManager; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.ui = ns.ui || {}; const STYLES = ` :host{all:initial;font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.5;color:var(--ct);box-sizing:border-box} *,*::before,*::after{box-sizing:border-box;margin:0;padding:0} #collector-float-btn{position:fixed;bottom:50px;right:50px;width:46px;height:46px;background:#fb7299;border-radius:50%;display:flex;align-items:center;justify-content:center;cursor:move;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:9999;user-select:none;touch-action:none} #collector-float-btn:hover{opacity:.9} #collector-float-btn.dragging{z-index:10000} #collector-panel{ --cp:#fb7299;--cb:#e8e8e8;--cbg:#ffffff;--cs:#f5f5f5;--ct:#222;--cm:#888; --c-btn:#555;--c-btn-blue:#4393E2;--c-hover:#f0f0f0; --c-badge-pink-bg:#fde8ef;--c-badge-pink-text:#fb7299; --c-highlight-bg:#fde8ef;--c-highlight-text:#d81e66; --c-input-bg:#ffffff; position:fixed;bottom:min(100px,10vh);right:min(30px,3vw);width:min(520px,92vw);height:min(600px,75vh); background:var(--cbg);border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.1); z-index:9998;font-family:inherit;font-size:14px;display:flex;flex-direction:column; overflow:hidden;resize:both;min-width:320px;min-height:320px;max-width:96vw;max-height:92vh;color:var(--ct); } .panel-header{padding:12px 16px;background:var(--cp);color:#fff;font-weight:600;display:flex;justify-content:space-between;align-items:center} .panel-close-btn{cursor:pointer;font-size:20px;opacity:.85} .panel-close-btn:hover{opacity:1} .panel-content{padding:12px;overflow:auto;flex-grow:1;display:flex;flex-direction:column;gap:12px} .panel-tabs{display:flex;gap:4px} .tab-btn{flex:1;padding:8px;border:1px solid var(--cp);border-radius:6px;cursor:pointer;font-size:13px;background:var(--cs);color:var(--ct);font-weight:600} .tab-btn.active{background:var(--cp);color:#fff;border-color:var(--cp)} .tab-btn:hover:not(.active){background:var(--c-hover)} .stats-bar{display:flex;align-items:center;gap:8px;padding:8px 10px;background:var(--cs);border-radius:6px;flex-wrap:wrap} .stats-bar>span{flex:1;font-size:13px;color:var(--cm)} .btn{padding:6px 12px;border:none;border-radius:6px;cursor:pointer;font-size:12px;background:var(--c-btn);color:#fff} .btn:hover{opacity:.85} .btn.export{background:var(--c-btn-blue)} .btn.danger{background:var(--cp);color:#fff} `; const STYLES2 = ` .tab-content{padding:12px;border-radius:8px;overflow:auto;flex:1 1 0;min-height:0;background:var(--cbg)} .history-item{margin-bottom:10px;background:var(--cs);border-radius:8px;overflow:hidden;cursor:pointer;position:relative;transition:background .15s} .history-item:hover{background:var(--c-hover)} .main-comment{padding:12px;background:var(--cs);position:relative} .reply-comment{padding:10px 12px;margin-left:12px;border-left:2px solid var(--cb);position:relative;background:var(--cs)} .third-level-comment{padding:10px 12px;margin-left:24px;border-left:2px solid var(--cb)} .comment-header{display:flex;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap} .user-avatar{width:28px;height:28px;border-radius:50%;object-fit:cover} .user-name{font-weight:600;color:var(--cp);text-decoration:none;font-size:13px} .user-name:hover{text-decoration:underline} .comment-content{margin-left:36px;margin-bottom:6px;line-height:1.5;color:var(--ct);word-break:break-word;white-space:pre-wrap;position:relative} .comment-content.collapsed{max-height:6em;overflow:hidden;margin-bottom:0} .comment-content.collapsed::after{content:'';position:absolute;bottom:0;left:0;right:0;height:2em;background:linear-gradient(to bottom,transparent,var(--cs));pointer-events:none} .comment-content+.expand-btn{position:relative;display:flex;align-items:center;justify-content:center;margin:0 auto 0 36px;padding:2px 0;border:none;background:transparent;cursor:pointer;width:calc(100% - 36px);height:20px;transition:transform .2s;z-index:1} .comment-content.collapsed+.expand-btn{margin-top:-20px} .expand-btn:hover{transform:scale(1.15)} .expand-btn svg{width:20px;height:20px;transition:transform .2s;filter:drop-shadow(0 0 2px var(--cbg))} .expand-btn.expanded svg{transform:rotate(180deg)} .comment-images{margin-left:36px;margin-bottom:6px;display:flex;flex-wrap:wrap;gap:6px} .comment-image{max-width:90px;max-height:90px;border-radius:4px;object-fit:cover;cursor:pointer} .comment-meta{margin-left:36px;font-size:11px;color:var(--cm);display:flex;gap:10px;flex-wrap:wrap} .history-time{font-size:11px;color:var(--cm);text-align:right;margin-top:4px} .badge{font-size:11px;color:var(--cm);background:var(--cs);padding:1px 6px;border-radius:3px} .badge .at-link{color:var(--cp);text-decoration:none} .badge .at-link:hover{text-decoration:underline} .at-link{color:var(--cp);text-decoration:none} .at-link:hover{text-decoration:underline} .badge-pink{font-size:11px;color:var(--c-badge-pink-text);background:var(--c-badge-pink-bg);padding:1px 6px;border-radius:3px} .antifraud-status{font-size:11px;padding:1px 6px;border-radius:3px;margin-left:4px} .status-ok{color:#51cf66;background:rgba(81,207,102,0.1)} .status-shadowban{color:#ff6b6b;background:rgba(255,107,107,0.1)} .status-deleted{color:#ff6b6b;background:rgba(255,107,107,0.1)} .status-invisible{color:#ffa94d;background:rgba(255,169,77,0.1)} .status-suspicious{color:#ffa94d;background:rgba(255,169,77,0.1)} .status-error{color:#868e96;background:rgba(134,142,150,0.1)} .status-checking{color:#4dabf7;background:rgba(77,171,247,0.1)} .antifraud-check-btn{font-size:11px;padding:2px 8px;border:1px solid var(--cb);background:var(--cs);color:var(--ct);border-radius:4px;cursor:pointer;margin-left:8px} .antifraud-check-btn:hover{background:var(--c-hover);border-color:var(--cp)} .antifraud-check-btn:disabled{opacity:0.5;cursor:not-allowed} .group-info{position:absolute;top:8px;right:8px;font-size:11px;color:var(--cm);background:var(--cs);padding:1px 6px;border-radius:3px} .jump-hint{position:absolute;bottom:6px;right:8px;font-size:11px;color:var(--cm);opacity:0;transition:opacity .15s} .history-item:hover .jump-hint{opacity:1} .thread-container,.danmaku-item{position:relative;margin-bottom:10px;padding:10px 12px;background:var(--cs);border-radius:8px;cursor:pointer;transition:background .15s} .thread-container:hover,.danmaku-item:hover{background:var(--c-hover)} .delete-btn{position:absolute;top:6px;right:6px;width:22px;height:22px;border:none;background:var(--c-btn);color:#fff;border-radius:50%;cursor:pointer;font-size:13px;opacity:0;z-index:10;display:flex;align-items:center;justify-content:center} .thread-container:hover .delete-btn,.danmaku-item:hover .delete-btn,.history-item:hover .delete-btn{opacity:1} .delete-btn:hover{background:var(--cp)} .danmaku-text{font-weight:600;color:var(--ct);margin-bottom:4px;word-break:break-word;white-space:pre-wrap} .danmaku-meta{font-size:12px;color:var(--cm)} .danmaku-video-link{color:var(--cp);text-decoration:none} .danmaku-video-link:hover{text-decoration:underline} `; const STYLES3 = ` .settings-section h4{margin:12px 0 6px;color:var(--ct);font-size:14px;font-weight:600} .setting-item{margin-bottom:10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap} .setting-item label{min-width:140px;color:var(--cm);font-size:13px} .setting-item input[type="number"]{padding:5px 8px;border:1px solid var(--cb);border-radius:6px;width:80px;background:var(--c-input-bg);color:var(--ct);outline:none} .setting-item input[type="number"]:focus{border-color:var(--cp)} #save-settings-btn,#import-data-btn,#export-all-btn{background:var(--c-btn-blue);color:#fff;padding:8px 14px;border:none;border-radius:6px;cursor:pointer;text-align:center;font-size:13px} #save-settings-btn:hover,#import-data-btn:hover,#export-all-btn:hover{opacity:.9} #clear-all-btn{padding:8px 14px;font-size:13px;text-align:center} #storage-info{font-size:12px;color:var(--cm);background:var(--cs);padding:6px 10px;border-radius:6px} .settings-hint{margin:0;font-size:12px;color:var(--cm);line-height:1.5} .comment-text{padding-left:36px;margin-bottom:6px;line-height:1.5;word-break:break-word;color:var(--ct);white-space:pre-wrap;position:relative} .comment-text.collapsed{max-height:6em;overflow:hidden;margin-bottom:0} .comment-text.collapsed::after{content:'';position:absolute;bottom:0;left:0;right:0;height:2em;background:linear-gradient(to bottom,transparent,var(--cs));pointer-events:none} .comment-text+.expand-btn{position:relative;display:flex;align-items:center;justify-content:center;margin:0 auto 0 36px;padding:2px 0;border:none;background:transparent;cursor:pointer;width:calc(100% - 36px);height:20px;transition:transform .2s;z-index:1} .comment-text.collapsed+.expand-btn{margin-top:-20px} .comment-pictures-container{margin-left:36px;margin-top:8px;display:flex;flex-wrap:wrap;gap:6px} .recorded-reply-item{padding:8px;border-left:2px solid var(--cb);margin-left:16px;margin-top:8px;background:var(--cs)} .search-pagination-container{background:var(--cs);border-radius:8px;overflow:hidden;padding:8px} .search-container{margin-bottom:8px} .search-input{width:100%;padding:7px 10px;border:1px solid var(--cb);border-radius:6px;font-size:13px;outline:none;box-sizing:border-box;background:var(--c-input-bg);color:var(--ct)} .search-input:focus{border-color:var(--cp)} .pagination-container{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px} .pagination-info{font-size:12px;color:var(--cm)} .pagination-controls{display:flex;align-items:center;gap:4px;flex-wrap:wrap} .pagination-btn{padding:4px 8px;border:1px solid var(--cb);background:var(--cbg);color:var(--ct);border-radius:4px;cursor:pointer;font-size:12px} .pagination-btn:hover:not(.disabled):not(.active){border-color:var(--cp);color:var(--cp)} .pagination-btn.active{background:var(--cp);color:#fff;border-color:var(--cp)} .pagination-btn.disabled{color:var(--cm);cursor:not-allowed} .page-size-selector{display:flex;align-items:center;gap:4px;font-size:12px;color:var(--cm)} .page-size-select{padding:3px 5px;border:1px solid var(--cb);border-radius:4px;font-size:12px;outline:none;background:var(--c-input-bg);color:var(--ct)} .page-size-select:focus{border-color:var(--cp)} .search-result-highlight{background:var(--c-highlight-bg);color:var(--c-highlight-text);font-weight:600;padding:0 2px;border-radius:2px} .no-results{text-align:center;color:var(--cm);padding:32px 16px;font-size:14px} #theme-select{padding:5px 8px;border:1px solid var(--cb);border-radius:6px;font-size:13px;outline:none;background:var(--c-input-bg);color:var(--ct);cursor:pointer} #theme-select:focus{border-color:var(--cp)} @media(prefers-color-scheme:dark){ #collector-panel:not([data-theme="light"]){ --cbg:#1e1e1e;--cs:#232527;--ct:#e0e0e0;--cm:#999;--cb:#3a3a3a; --c-btn:#4a4a4a;--c-btn-blue:#4393E2;--c-hover:#333; --c-badge-pink-bg:#3d1f2a;--c-badge-pink-text:#ff6b9d; --c-highlight-bg:#4a1f2f;--c-highlight-text:#ff6b9d; --c-input-bg:#232527; } } #collector-panel[data-theme="dark"]{ --cbg:#1e1e1e;--cs:#232527;--ct:#e0e0e0;--cm:#999;--cb:#3a3a3a; --c-btn:#4a4a4a;--c-btn-blue:#4393E2;--c-hover:#333; --c-badge-pink-bg:#3d1f2a;--c-badge-pink-text:#ff6b9d; --c-highlight-bg:#4a1f2f;--c-highlight-text:#ff6b9d; --c-input-bg:#232527; } `; const STYLES_UID_LOOKUP = ` .feature-toggle-wrap{display:flex;align-items:center;gap:10px} .feature-toggle{position:relative;width:44px;height:24px;background:var(--cb);border-radius:12px;cursor:pointer;transition:background .2s;flex-shrink:0} .feature-toggle.on{background:var(--cp)} .feature-toggle::after{content:'';position:absolute;top:2px;left:2px;width:20px;height:20px;background:#fff;border-radius:50%;transition:transform .2s} .feature-toggle.on::after{transform:translateX(20px)} .feature-toggle-label{font-size:13px;color:var(--cm)} .uid-risk-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:100000;display:flex;align-items:center;justify-content:center;animation:uid-fade-in .2s} @keyframes uid-fade-in{from{opacity:0}to{opacity:1}} .uid-risk-modal{background:#fff;border-radius:12px;padding:24px;max-width:420px;width:90%;box-shadow:0 8px 32px rgba(0,0,0,.25);color:#222;font-size:13px;line-height:1.7} .uid-risk-modal h3{margin:0 0 12px;color:#e74c3c;font-size:16px;display:flex;align-items:center;gap:6px} .uid-risk-modal p{margin:0 0 8px} .uid-risk-modal .uid-risk-highlight{color:#e74c3c;font-weight:600} .uid-risk-modal .uid-risk-btns{display:flex;gap:10px;margin-top:16px;justify-content:flex-end} .uid-risk-modal .uid-risk-btn{padding:8px 20px;border:none;border-radius:6px;cursor:pointer;font-size:13px} .uid-risk-modal .uid-risk-btn.cancel{background:#f0f0f0;color:#333} .uid-risk-modal .uid-risk-btn.cancel:hover{background:#e0e0e0} .uid-risk-modal .uid-risk-btn.confirm{background:#e74c3c;color:#fff} .uid-risk-modal .uid-risk-btn.confirm:disabled{opacity:.4;cursor:not-allowed} @media(prefers-color-scheme:dark){ .uid-risk-modal{background:#1e1e1e;color:#e0e0e0} .uid-risk-modal .uid-risk-btn.cancel{background:#3a3a3a;color:#e0e0e0} .uid-risk-modal .uid-risk-btn.cancel:hover{background:#444} } `; ns.ui.STYLES = STYLES + STYLES2 + STYLES3 + STYLES_UID_LOOKUP; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.utils, 'DisplayManager dependencies missing'); const state = ns.state; const { escapeHtml } = ns.utils; const DEFAULT_AVATAR = 'https://static.hdslb.com/images/member/noface.gif'; const toHttps = (url) => (typeof url === 'string' ? url.replace(/^http:\/\//, 'https://') : ''); const $id = (id) => ns.ui.shadowRoot?.getElementById(id); class DisplayManager { static updateAll() { this.updateSentComments(); this.updateComments(); this.updateDanmaku(); ns.ui.UIManager?.updateCurrentDisplayCounts?.(); } static _shouldCollapse(text) { const limit = state.settings.COMMENT_COLLAPSE_LENGTH || 0; return limit > 0 && text && text.length > limit; } static _wrapCollapsible(content, className = 'comment-content') { if (!this._shouldCollapse(content)) { return `
${content}
`; } return ``; } static _imgs(srcs) { if (!srcs || !srcs.length) return ''; return `
${srcs.map((s) => { const url = escapeHtml(toHttps(s)); return ``; }).join('')}
`; } static _header(user, searchTerm, extra = '') { const avatar = escapeHtml(toHttps(user.userAvatar || DEFAULT_AVATAR)); const link = escapeHtml(user.userLink || '#'); let name = escapeHtml(user.userName || '未知用户'); if (searchTerm) name = this.highlightSearchTerm(name, searchTerm); return `
${name} ${extra}
`; } static _meta(item) { return `
日期: ${escapeHtml(item.pubDate)} 点赞: ${escapeHtml(String(item.likeCount))} ${item.location ? `${escapeHtml(item.location)}` : ''}
`; } static _renderAntiFraudStatus(status) { if (!status) return ''; const statusMap = { ok: { text: '✓ 正常', class: 'status-ok' }, shadowban: { text: '⚠ 仅自己可见', class: 'status-shadowban' }, deleted: { text: '✗ 已删除', class: 'status-deleted' }, invisible: { text: '⚠ 隐藏', class: 'status-invisible' }, suspicious: { text: '? 审核中', class: 'status-suspicious' }, error: { text: '✗ 检测失败', class: 'status-error' }, checking: { text: '⋯ 检测中', class: 'status-checking' } }; const info = statusMap[status] || { text: status, class: 'status-unknown' }; return `${info.text}`; } static getPaginationData(stateKey, filteredData) { const pageSize = ns.ui.UIManager.getPageSize(stateKey); const currentPage = state.paginationState[stateKey].currentPage; const totalPages = Math.max(1, Math.ceil(filteredData.length / pageSize)); const safePage = Math.min(currentPage, totalPages); const startIndex = (safePage - 1) * pageSize; const endIndex = startIndex + pageSize; return { pageSize, safePage, totalPages, startIndex, endIndex, paginatedData: filteredData.slice(startIndex, endIndex) }; } static updatePaginationInfo(type, totalCount, currentPage, totalPages, startIndex, endIndex) { const el = $id(`${type}-pagination-info`); if (!el) return; if (totalCount === 0) { el.textContent = '显示 0 条记录'; return; } el.textContent = `显示 ${startIndex + 1}-${Math.min(endIndex, totalCount)} 条,共 ${totalCount} 条记录 (第${currentPage}/${totalPages}页)`; } static updatePaginationControls(type, currentPage, totalPages, stateKey) { const el = $id(`${type}-pagination-controls`); if (!el) return; if (totalPages <= 1) { el.innerHTML = ''; return; } const btn = (label, attrs) => ``; const pgBtn = (page, active = false) => ``; const maxButtons = ns.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); let html = btn('‹ 上一页', `${currentPage <= 1 ? 'disabled' : ''}" data-type="${stateKey}" data-action="prev`); if (startPage > 1) { html += pgBtn(1); if (startPage > 2) html += `...`; } for (let p = startPage; p <= endPage; p++) html += pgBtn(p, p === currentPage); if (endPage < totalPages) { if (endPage < totalPages - 1) html += `...`; html += pgBtn(totalPages); } html += btn('下一页 ›', `${currentPage >= totalPages ? 'disabled' : ''}" data-type="${stateKey}" data-action="next`); el.innerHTML = html; } static _flattenReplies(group) { return Array.isArray(group.replies) ? group.replies : []; } static _replyBadge(reply, allReplies) { if (reply.root && reply.parent && reply.root !== '0') { if (reply.root !== reply.parent) { const parentReply = allReplies.find((r) => r.rpid === reply.parent); if (parentReply) { const name = escapeHtml(parentReply.userName); const link = escapeHtml(parentReply.userLink || '#'); return `回复 @${name}`; } } } return '回复'; } static _linkifyAtMentions(htmlContent, atMembers) { if (!atMembers || typeof atMembers !== 'object') return htmlContent; const entries = Object.entries(atMembers); if (entries.length === 0) return htmlContent; for (const [name, mid] of entries) { const escapedName = escapeHtml(name); const link = escapeHtml(`https://space.bilibili.com/${mid}`); const pattern = new RegExp(`@${escapedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`, 'g'); htmlContent = htmlContent.replace(pattern, `@${escapedName}`); } return htmlContent; } static updateSentComments() { const outputDiv = $id('sent-comments-output'); const countSpan = $id('sent-comment-count'); if (!outputDiv || !countSpan) return; const searchTerm = state.paginationState.sentComments.searchTerm.toLowerCase().trim(); let filteredData = state.sentComments; if (searchTerm) { filteredData = state.sentComments.filter((group) => { const mainContent = group.mainComment?.content?.toLowerCase() || ''; const mainUser = group.mainComment?.userName?.toLowerCase() || ''; const videoTitle = group.videoTitle?.toLowerCase() || ''; const replyMatches = this._flattenReplies(group).some((r) => { const rc = r.content?.toLowerCase() || ''; const ru = r.userName?.toLowerCase() || ''; return rc.includes(searchTerm) || ru.includes(searchTerm); }); return mainContent.includes(searchTerm) || mainUser.includes(searchTerm) || videoTitle.includes(searchTerm) || replyMatches; }); } state.paginationState.sentComments.filteredData = filteredData; const { safePage, totalPages, startIndex, endIndex, paginatedData } = this.getPaginationData('sentComments', filteredData); countSpan.textContent = searchTerm ? `发送记录: 搜索到${filteredData.length}组` : `发送记录: 已记录${state.sentComments.length} / 上限${state.settings.SENT_COMMENTS}组`; this.updatePaginationInfo('sent-comments', filteredData.length, safePage, totalPages, startIndex, endIndex); this.updatePaginationControls('sent-comments', safePage, totalPages, 'sentComments'); if (filteredData.length === 0) { outputDiv.innerHTML = searchTerm ? this.renderNoResults('未找到匹配的评论记录') : '
暂无记录
'; return; } if (paginatedData.length === 0 && safePage > 1) { state.paginationState.sentComments.currentPage = 1; return this.updateSentComments(); } outputDiv.innerHTML = paginatedData.map((group, idx) => this.renderCommentGroup(group, startIndex + idx, searchTerm)).join(''); } static updateComments() { const outputDiv = $id('recorded-comments-output'); const countSpan = $id('comment-count'); if (!outputDiv || !countSpan) return; const threadsArray = Array.from(state.recordedThreads.entries()).sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0)); const searchTerm = state.paginationState.comments.searchTerm.toLowerCase().trim(); let filteredData = threadsArray; if (searchTerm) { filteredData = threadsArray.filter(([, thread]) => { const mainContent = thread.mainComment?.content?.toLowerCase() || ''; const mainUser = thread.mainComment?.userName?.toLowerCase() || ''; const replyMatches = (thread.replies || []).some((r) => (r.content?.toLowerCase() || '').includes(searchTerm) || (r.userName?.toLowerCase() || '').includes(searchTerm) ); return mainContent.includes(searchTerm) || mainUser.includes(searchTerm) || replyMatches; }); } state.paginationState.comments.filteredData = filteredData; const { safePage, totalPages, startIndex, endIndex, paginatedData } = this.getPaginationData('comments', filteredData); countSpan.textContent = searchTerm ? `评论收藏: 搜索到${filteredData.length}组` : `评论收藏: 已记录${state.recordedThreads.size} / 上限${state.settings.DISPLAY_COMMENTS}组`; this.updatePaginationInfo('comments', filteredData.length, safePage, totalPages, startIndex, endIndex); this.updatePaginationControls('comments', safePage, totalPages, 'comments'); if (filteredData.length === 0) { outputDiv.innerHTML = searchTerm ? this.renderNoResults('未找到匹配的评论收藏') : '
暂无记录
'; return; } if (paginatedData.length === 0 && safePage > 1) { state.paginationState.comments.currentPage = 1; return this.updateComments(); } outputDiv.innerHTML = paginatedData.map(([key, thread]) => { const c = thread.mainComment; if (!c) return ''; let content = escapeHtml(c.content); content = this._linkifyAtMentions(content, c.atMembers); if (searchTerm) content = this.highlightSearchTerm(content, searchTerm); const mainHTML = `
${this._header(c, searchTerm)} ${this._wrapCollapsible(content, 'comment-text')} ${this._imgs(c.images)} ${this._meta(c)}
`; const repliesHTML = (thread.replies || []).map((r) => { let rc = escapeHtml(r.content); rc = this._linkifyAtMentions(rc, r.atMembers); if (searchTerm) rc = this.highlightSearchTerm(rc, searchTerm); return `
${this._header(r, searchTerm)} ${this._wrapCollapsible(rc, 'comment-text')} ${this._imgs(r.images)} ${this._meta(r)}
`; }).join(''); return `
${mainHTML}${repliesHTML}
`; }).join(''); } static updateDanmaku() { const outputDiv = $id('recorded-danmaku-output'); const countSpan = $id('danmaku-count'); if (!outputDiv || !countSpan) return; const sorted = [...state.recordedDanmaku].sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0)); const searchTerm = state.paginationState.danmaku.searchTerm.toLowerCase().trim(); let filteredData = sorted; if (searchTerm) { filteredData = sorted.filter((d) => (d.text || '').toLowerCase().includes(searchTerm) || (d.videoTitle || '').toLowerCase().includes(searchTerm) ); } state.paginationState.danmaku.filteredData = filteredData; const { safePage, totalPages, startIndex, endIndex, paginatedData } = this.getPaginationData('danmaku', filteredData); countSpan.textContent = searchTerm ? `视频弹幕: 搜索到${filteredData.length}条` : `视频弹幕: 已记录${state.recordedDanmaku.length} / 上限${state.settings.DISPLAY_DANMAKU}条`; this.updatePaginationInfo('danmaku', filteredData.length, safePage, totalPages, startIndex, endIndex); this.updatePaginationControls('danmaku', safePage, totalPages, 'danmaku'); if (filteredData.length === 0) { outputDiv.innerHTML = searchTerm ? this.renderNoResults('未找到匹配的弹幕记录') : '
暂无弹幕记录
'; return; } if (paginatedData.length === 0 && safePage > 1) { state.paginationState.danmaku.currentPage = 1; return this.updateDanmaku(); } outputDiv.innerHTML = paginatedData.map((danmaku) => { let text = escapeHtml(danmaku.text); let videoTitle = escapeHtml(danmaku.videoTitle); if (searchTerm) { text = this.highlightSearchTerm(text, searchTerm); videoTitle = this.highlightSearchTerm(videoTitle, searchTerm); } const videoTimeTag = danmaku.videoTime ? `发送时间:${escapeHtml(danmaku.videoTime)} · ` : ''; return `
${text}
${videoTimeTag}${escapeHtml(danmaku.time)} · ${videoTitle}
`; }).join(''); } static renderNoResults(message) { return `
${message}
尝试修改搜索关键词或清空搜索框
`; } static highlightSearchTerm(text, searchTerm) { if (!searchTerm || !text) return text; const escaped = String(searchTerm).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escaped})`, 'gi'); if (/<[^>]+>/.test(text)) { const tempDiv = document.createElement('div'); tempDiv.innerHTML = String(text); this._highlightInNode(tempDiv, regex); return tempDiv.innerHTML; } return String(text).replace(regex, '$1'); } static _highlightInNode(node, regex) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; if (regex.test(text)) { const span = document.createElement('span'); span.innerHTML = text.replace(regex, '$1'); node.parentNode.replaceChild(span, node); while (span.firstChild) { span.parentNode.insertBefore(span.firstChild, span); } span.parentNode.removeChild(span); } } else if (node.nodeType === Node.ELEMENT_NODE && node.tagName !== 'SCRIPT' && node.tagName !== 'STYLE') { Array.from(node.childNodes).forEach(child => this._highlightInNode(child, regex)); } } static renderCommentGroup(group, index, searchTerm = '') { const allReplies = this._flattenReplies(group); let mainCommentHTML = ''; if (group.mainComment) { const c = group.mainComment; let content = escapeHtml(c.content); content = this._linkifyAtMentions(content, c.atMembers); let videoTitle = escapeHtml(group.videoTitle); if (searchTerm) { content = this.highlightSearchTerm(content, searchTerm); videoTitle = this.highlightSearchTerm(videoTitle, searchTerm); } const statusBadge = this._renderAntiFraudStatus(c.antiFraudStatus); const checkBtn = state.settings.ENABLE_ANTIFRAUD ? `` : ''; mainCommentHTML = `
第${index + 1}组 (${allReplies.length}条回复)
双击跳转视频
${this._header(c, searchTerm, '主评论' + statusBadge)} ${this._wrapCollapsible(content, 'comment-content')} ${this._imgs(c.images)}
日期: ${escapeHtml(c.pubDate)} 点赞: ${escapeHtml(c.likeCount)} ${c.location ? `${escapeHtml(c.location)}` : ''} 标题: ${videoTitle} ${checkBtn}
`; } const repliesHTML = allReplies.map((r) => { const badge = this._replyBadge(r, allReplies); return this.renderReply(r, searchTerm, badge, group.id); }).join(''); return `
${mainCommentHTML}${repliesHTML}
`; } static renderReply(reply, searchTerm = '', badge = '回复', groupId = null) { let content = escapeHtml(reply.content); content = this._linkifyAtMentions(content, reply.atMembers); if (searchTerm) content = this.highlightSearchTerm(content, searchTerm); const statusBadge = this._renderAntiFraudStatus(reply.antiFraudStatus); const checkBtn = state.settings.ENABLE_ANTIFRAUD && groupId ? `` : ''; return `
${this._header(reply, searchTerm, badge + statusBadge)} ${this._wrapCollapsible(content, 'comment-content')} ${this._imgs(reply.images)} ${this._meta(reply)}
${escapeHtml(reply.time)}${checkBtn ? ' ' + checkBtn : ''}
`; } } ns.ui.DisplayManager = DisplayManager; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.config && ns.db, 'CommentExtractor dependencies missing'); const state = ns.state; const CONFIG = ns.config; const DatabaseManager = ns.db.DatabaseManager; class CommentExtractor { static extractDetails(renderer) { if (!renderer?.data) return null; const d = renderer.data; const rpid = d.rpid_str; if (!rpid) return null; const toHttps = (url) => (typeof url === 'string' ? url.replace(/^http:\/\//, 'https://') : ''); return { key: rpid, rpid, content: d.content?.message || '', images: d.content?.pictures?.map((p) => toHttps(p.img_src)) || [], location: d.reply_control?.location || '', pubDate: d.ctime ? new Date(d.ctime * 1000).toLocaleString('zh-CN') : '', likeCount: String(d.like ?? 0), userName: d.member?.uname || '未知用户', userAvatar: toHttps(d.member?.avatar), userLink: d.member?.mid ? `https://space.bilibili.com/${d.member.mid}` : '#', videoLink: `${window.location.href.split('#')[0]}#reply${rpid}`, oid: d.oid_str || String(d.oid || ''), root: d.root_str || String(d.root || ''), parent: d.parent_str || String(d.parent || ''), replyCount: d.rcount ?? 0, atMembers: d.content?.at_name_to_mid || {}, type: d.type ?? 0 }; } static toStorageFormat(details, fallbackText) { if (!details) { return { content: fallbackText || '', userName: '获取中...', userLink: '#', userAvatar: '', images: [], location: '', pubDate: '刚刚', likeCount: '0' }; } const { key, videoLink, ...record } = details; return record; } static generateCommentKey(comment) { if (!comment) return null; if (comment.rpid) return comment.rpid; const content = comment.content || ''; return `${comment.userName}|||${content}`; } static findExistingCommentGroup(mainComment) { if (!mainComment) return null; const key = this.generateCommentKey(mainComment); if (!key) return null; return state.sentComments.find((group) => { if (!group.mainComment) return false; const groupKey = this.generateCommentKey(group.mainComment); return groupKey === key; }); } static findExistingReply(group, comment) { if (!group || !comment) return null; const key = this.generateCommentKey(comment); if (!key) return null; return group.replies.find((reply) => { const replyKey = this.generateCommentKey(reply); return replyKey === key; }); } static async cleanupOldComments() { const max = state.settings.SENT_COMMENTS; if (state.sentComments.length > max) { const removed = state.sentComments.splice(max); for (const item of removed) { if (item.id != null) { try { await DatabaseManager.delete(CONFIG.STORES.SENT_COMMENTS, item.id); } catch (e) { ns.logger.error('[CommentExtractor] 删除多余DB记录失败:', e); } } } } } } ns.services = ns.services || {}; ns.services.CommentExtractor = CommentExtractor; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.services, 'CommentFindUtil dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const CommentExtractor = ns.services.CommentExtractor; class CommentFindUtil { static normalizeText(text) { if (!text) return ''; return text.replace(/[\u200B-\u200D\uFEFF\u00A0]/g, '').replace(/\r\n/g, '\n').trim(); } static findEditorText(element, depth = 0) { if (depth > 10) return null; const editor = element.querySelector('.brt-editor'); if (editor) { const text = editor.innerText?.trim(); if (text) return text; } for (const el of element.querySelectorAll('*')) { if (el.shadowRoot) { const text = this.findEditorText(el.shadowRoot, depth + 1); if (text) return text; } } return null; } static async findNewReply(threadRenderer, expectedText, maxWaitTime = 8000) { const normalizedExpected = this.normalizeText(expectedText); return new Promise((resolve) => { const startTime = Date.now(); const checkInterval = 300; const check = () => { const expanderContents = threadRenderer?.shadowRoot ?.querySelector('#replies bili-comment-replies-renderer') ?.shadowRoot?.querySelector('#expander #expander-contents'); if (expanderContents) { const renderers = expanderContents.querySelectorAll('bili-comment-reply-renderer'); for (let i = renderers.length - 1; i >= 0; i--) { const msg = renderers[i].data?.content?.message; if (!msg) continue; const normalizedMsg = this.normalizeText(msg); if (normalizedMsg === normalizedExpected || normalizedMsg.endsWith(normalizedExpected)) { resolve(CommentExtractor.extractDetails(renderers[i])); return; } } } if (Date.now() - startTime < maxWaitTime) setTimeout(check, checkInterval); else resolve(null); }; check(); }); } static async findNewMain(expectedText, maxWaitTime = 8000) { const normalizedExpected = this.normalizeText(expectedText); return new Promise((resolve) => { const startTime = Date.now(); const checkInterval = 300; const check = () => { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); const newDiv = commentsHost?.shadowRoot?.querySelector('#contents #new'); if (newDiv) { const threads = newDiv.querySelectorAll('bili-comment-thread-renderer'); for (const thread of threads) { const msg = thread.data?.content?.message; if (msg && this.normalizeText(msg) === normalizedExpected) { resolve(CommentExtractor.extractDetails(thread)); return; } } } if (Date.now() - startTime < maxWaitTime) setTimeout(check, checkInterval); else resolve(null); }; check(); }); } } ns.services.CommentFindUtil = CommentFindUtil; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.utils, 'AntiFraud dependencies missing'); const WbiSign = ns.utils.WbiSign; const wbiSign = new WbiSign(); const SORT_MODE_TIME = 2; async function getWbiKeys() { try { const resp = await fetch('https://api.bilibili.com/x/web-interface/nav', { credentials: 'include' }); const json = await resp.json(); const img_url = json.data?.wbi_img?.img_url || ''; const sub_url = json.data?.wbi_img?.sub_url || ''; return { img_key: img_url.slice(img_url.lastIndexOf('/') + 1, img_url.lastIndexOf('.')), sub_key: sub_url.slice(sub_url.lastIndexOf('/') + 1, sub_url.lastIndexOf('.')) }; } catch (e) { ns.logger.error('[反诈] 获取wbi keys失败', e); return { img_key: '', sub_key: '' }; } } async function getMainCommentList(oid, type, next, mode, isLogin, seek_rpid) { const { img_key, sub_key } = await getWbiKeys(); const params = { oid, type, mode, pagination_str: JSON.stringify({ offset: next || '' }) }; if (seek_rpid) params.seek_rpid = seek_rpid; const query = wbiSign.encWbi(params, img_key, sub_key); const url = `https://api.bilibili.com/x/v2/reply/wbi/main?${query}`; try { const resp = await fetch(url, { credentials: isLogin ? 'include' : 'omit' }); return await resp.json(); } catch (e) { ns.logger.error('[反诈] 获取评论列表失败', e); return { code: -1, message: e.message }; } } async function fetchBilibiliCommentReplies(oid, type, root, pn, sort, hasCookie) { const url = `https://api.bilibili.com/x/v2/reply/reply?oid=${oid}&type=${type}&root=${root}&pn=${pn}&ps=10&sort=${sort}`; try { const resp = await fetch(url, { credentials: hasCookie ? 'include' : 'omit' }); return await resp.json(); } catch (e) { ns.logger.error('[反诈] 获取回复失败', e); return { code: -1, message: e.message }; } } function findReplies(replies, rpid) { for (const comment of replies) { if (comment.rpid == rpid) return comment; if (comment.replies) { const found = findReplies(comment.replies, rpid); if (found) return found; } } return null; } async function findCommentInList(oid, type, rpid, ctime, maxPages = 30) { let nextOffset = ''; for (let page = 0; page < maxPages; page++) { const resp = await getMainCommentList(oid, type, nextOffset, SORT_MODE_TIME, false); if (resp.code != 0) return { error: true, code: resp.code, message: resp.message }; if (page === 0 && resp.data?.top_replies) { const found = findReplies(resp.data.top_replies, rpid); if (found) return { found: true, comment: found }; } const replies = resp.data?.replies || []; if (!replies || replies.length === 0) break; const found = findReplies(replies, rpid); if (found) return { found: true, comment: found }; if (replies[replies.length - 1].ctime < ctime) break; const pagination = resp.data?.cursor?.pagination_reply; if (pagination?.next_offset) nextOffset = pagination.next_offset; else break; } return { found: false }; } async function findReplyUsingSeekRpid(oid, type, rpid, root, isLogin) { const resp = await getMainCommentList(oid, type, '', SORT_MODE_TIME, isLogin, rpid); if (resp.code != 0) return null; let replies = resp.data?.replies || []; if (resp.data?.top_replies) replies = replies.concat(resp.data.top_replies); for (const comment of replies) { if (comment.rpid == root) { if (comment.replies) { const found = findReplies(comment.replies, rpid); if (found) return found; } } if (comment.rpid == rpid) return comment; if (comment.replies) { const found = findReplies(comment.replies, rpid); if (found) return found; } } return null; } class AntiFraudDetection { static async checkComment(ctx) { const { oid, type, rpid, root, ctime, message } = ctx; const sortByTime = 0; try { if (!root || root == 0 || root == rpid) { const result = await findCommentInList(oid, type, rpid, ctime || 0); if (result.error) return { verdict: 'error', message: `获取评论列表失败 code=${result.code}` }; if (result.found) { if (result.comment.invisible) { return { verdict: 'invisible', message: `评论被标记为invisible(前端隐藏):「${message}」` }; } return { verdict: 'ok', message: `评论正常显示:「${message}」` }; } const resp2 = await fetchBilibiliCommentReplies(oid, type, rpid, 0, sortByTime, true); if (resp2.code == 12022) { return { verdict: 'deleted', message: `评论被系统秒删:「${message}」` }; } if (resp2.code == 0) { const resp3 = await fetchBilibiliCommentReplies(oid, type, rpid, 0, sortByTime, false); if (resp3.code == 12022) { return { verdict: 'shadowban', message: `评论被ShadowBan(仅自己可见):「${message}」` }; } if (resp3.code == 0) { if (resp3.data?.root?.invisible) { return { verdict: 'invisible', message: `评论invisible:「${message}」` }; } return { verdict: 'suspicious', message: `评论疑似审核中或评论区被戒严:「${message}」` }; } return { verdict: 'error', message: `无账号回复列表异常 code=${resp3.code}` }; } return { verdict: 'error', message: `有账号回复列表异常 code=${resp2.code}` }; } else { const foundNoLogin = await findReplyUsingSeekRpid(oid, type, rpid, root, false); if (foundNoLogin) return { verdict: 'ok', message: `回复评论正常显示:「${message}」` }; const foundLogin = await findReplyUsingSeekRpid(oid, type, rpid, root, true); if (foundLogin) return { verdict: 'shadowban', message: `回复评论被ShadowBan:「${message}」` }; return { verdict: 'deleted', message: `回复评论被系统秒删:「${message}」` }; } } catch (e) { ns.logger.error('[反诈] 检测异常', e); return { verdict: 'error', message: `检测异常: ${e.message}` }; } } } ns.services.AntiFraudDetection = AntiFraudDetection; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.services && ns.db && ns.state, 'AntiFraudQueue dependencies missing'); const CONFIG = ns.config; const DatabaseManager = ns.db.DatabaseManager; const state = ns.state; class AntiFraudQueue { static async checkCommentStatus(groupId, commentType, replyRpid = null) { try { const group = state.sentComments.find(g => g.id === groupId); if (!group) { ns.logger.error('[反诈] 未找到评论组', groupId); return; } let targetComment = null; if (commentType === 'main') { targetComment = group.mainComment; } else if (commentType === 'reply' && replyRpid) { const allReplies = this._flattenReplies(group); targetComment = allReplies.find(r => r.rpid === replyRpid); } if (!targetComment) { ns.logger.error('[反诈] 未找到目标评论'); return; } targetComment.antiFraudStatus = 'checking'; ns.ui.DisplayManager.updateSentComments(); const ctx = { oid: targetComment.oid || group.mainComment?.oid, type: targetComment.type || group.mainComment?.type || 1, rpid: targetComment.rpid, root: targetComment.root || 0, ctime: targetComment.ctime || Math.floor(Date.now() / 1000), message: targetComment.content }; const result = await ns.services.AntiFraudDetection.checkComment(ctx); targetComment.antiFraudStatus = result.verdict; targetComment.antiFraudMessage = result.message; await DatabaseManager.update(CONFIG.STORES.SENT_COMMENTS, group); ns.ui.DisplayManager.updateSentComments(); ns.logger.log('[反诈] 检测完成', result); } catch (e) { ns.logger.error('[反诈] 检测异常', e); if (targetComment) { targetComment.antiFraudStatus = 'error'; ns.ui.DisplayManager.updateSentComments(); } } } static _flattenReplies(group) { return group.replies || []; } } ns.services.AntiFraudQueue = AntiFraudQueue; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.db && ns.services, 'CommentRecProc dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const DatabaseManager = ns.db.DatabaseManager; const CommentExtractor = ns.services.CommentExtractor; class CommentRecProc { static recentComments = new Map(); static lastRecordTime = 0; static async handleRecord(commentDetails, commentText, isReply, sendContext) { if (!state.settings.ENABLE_SENT_COMMENTS) return; const currentTime = new Date().toLocaleString(); let dataToSave = null; ns.logger.log('[监听器管理] 处理评论记录', { isReply, hasCommentDetails: !!commentDetails, commentText: commentText.substring(0, 50) + (commentText.length > 50 ? '...' : ''), videoBvid: state.currentVideoInfo?.bvid }); if (isReply) { const pendingReplyContext = state.lastReplyContext; if (!pendingReplyContext) { ns.logger.log('[监听器管理] 没有回复上下文,跳过此回复'); return; } ns.logger.log('[监听器管理] 使用回复上下文'); dataToSave = await this.processReply( pendingReplyContext, commentDetails, commentText, currentTime ); } else { const commentKey = this.generateMainKey(commentText, state.currentVideoInfo?.bvid); if (this.isDuplicate(commentKey, commentText)) { ns.logger.log('[监听器管理] 检测到重复的主评论,跳过记录'); return; } this.recentComments.set(commentKey, { text: commentText, timestamp: Date.now(), bvid: state.currentVideoInfo?.bvid }); this.cleanupRecentCache(); const newGroup = { mainComment: CommentExtractor.toStorageFormat(commentDetails, commentText), replies: [], lastUpdateTime: currentTime, videoTitle: state.currentVideoInfo?.title || document.title, videoUrl: commentDetails?.rpid ? `${(state.currentVideoInfo?.url || window.location.href).split('#')[0]}#reply${commentDetails.rpid}` : state.currentVideoInfo?.url || window.location.href, videoBvid: state.currentVideoInfo?.bvid || 'unknown' }; dataToSave = newGroup; state.sentComments.unshift(newGroup); } await CommentExtractor.cleanupOldComments(); if (dataToSave) { try { const id = await DatabaseManager.save(CONFIG.STORES.SENT_COMMENTS, null, dataToSave); dataToSave.id = id; ns.logger.log(`[监听器管理] 成功保存评论记录,ID: ${id}`); } catch (error) { ns.logger.error('[监听器管理] 保存评论记录失败:', error); } } } static generateMainKey(commentText, bvid) { return `main_${bvid}_${commentText.substring(0, 100)}`; } static isDuplicate(commentKey, _commentText) { if (this.recentComments.has(commentKey)) { const cached = this.recentComments.get(commentKey); if (Date.now() - cached.timestamp < 5000) { return true; } this.recentComments.delete(commentKey); } return false; } static cleanupRecentCache() { const now = Date.now(); for (const [key, data] of this.recentComments.entries()) { if (now - data.timestamp > 5000) { this.recentComments.delete(key); } } } static buildReplyRecord(commentDetails, commentText, currentTime) { return { ...CommentExtractor.toStorageFormat(commentDetails, commentText), time: currentTime, timestamp: Date.now(), }; } static buildMainFromContext(ctx) { if (!ctx) return null; return CommentExtractor.toStorageFormat(ctx); } static async processReply(context, commentDetails, commentText, currentTime) { let existingGroup = null; if (context.mainComment) { existingGroup = CommentExtractor.findExistingCommentGroup(context.mainComment); } if (existingGroup) { if (context.parentComment && context.parentComment.rpid !== context.mainComment?.rpid) { const parentExists = CommentExtractor.findExistingReply(existingGroup, context.parentComment); if (!parentExists) { existingGroup.replies.push(this.buildReplyRecord(context.parentComment, context.parentComment.content || '', currentTime)); } } existingGroup.replies.push(this.buildReplyRecord(commentDetails, commentText, currentTime)); existingGroup.lastUpdateTime = currentTime; const index = state.sentComments.indexOf(existingGroup); if (index > 0) { state.sentComments.splice(index, 1); state.sentComments.unshift(existingGroup); } await DatabaseManager.update(CONFIG.STORES.SENT_COMMENTS, existingGroup); return null; } const newGroup = { mainComment: this.buildMainFromContext(context.mainComment), replies: [], lastUpdateTime: currentTime, videoTitle: state.currentVideoInfo?.title || document.title, videoUrl: context.mainComment?.rpid ? `${(state.currentVideoInfo?.url || window.location.href).split('#')[0]}#reply${context.mainComment.rpid}` : state.currentVideoInfo?.url || window.location.href, videoBvid: state.currentVideoInfo?.bvid || 'unknown' }; if (context.parentComment && context.parentComment.rpid !== context.mainComment?.rpid) { newGroup.replies.push(this.buildReplyRecord(context.parentComment, context.parentComment.content || '', currentTime)); } newGroup.replies.push(this.buildReplyRecord(commentDetails, commentText, currentTime)); state.sentComments.unshift(newGroup); return newGroup; } static reset() { this.recentComments.clear(); this.lastRecordTime = 0; } } ns.services.CommentRecProc = CommentRecProc; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.services, 'ReplyCtxCapture dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const CommentExtractor = ns.services.CommentExtractor; class ReplyCtxCapture { static handler = null; static lastMutationTime = 0; static init() { if (this.handler) { document.removeEventListener('click', this.handler, true); } this.handler = (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) return; const commentRenderer = path.find( (el) => el?.tagName === 'BILI-COMMENT-THREAD-RENDERER' || el?.tagName === 'BILI-COMMENT-REPLY-RENDERER' ); const threadRenderer = path.find((el) => el?.tagName === 'BILI-COMMENT-THREAD-RENDERER'); if (!commentRenderer || !threadRenderer) return; const parentCommentDetails = CommentExtractor.extractDetails(commentRenderer); if (!parentCommentDetails) return; let mainCommentDetails = null; if (commentRenderer.tagName === 'BILI-COMMENT-THREAD-RENDERER') { mainCommentDetails = parentCommentDetails; } else { mainCommentDetails = CommentExtractor.extractDetails(threadRenderer); } const context = { parentComment: parentCommentDetails, mainComment: mainCommentDetails, threadRenderer, timestamp: Date.now() }; state.lastReplyContext = context; ns.logger.log('[监听器管理] 记录回复上下文'); }; document.addEventListener('click', this.handler, true); ns.logger.log('[监听器管理] 回复按钮监听器绑定成功'); } static cleanup() { if (this.handler) { document.removeEventListener('click', this.handler, true); this.handler = null; } this.lastMutationTime = 0; } } ns.services.ReplyCtxCapture = ReplyCtxCapture; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.utils && ns.services && ns.ui, 'CommentSendMon dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const { getRandomDelay, getRetryDelay } = ns.utils; const CommentFindUtil = ns.services.CommentFindUtil; const CommentRecProc = ns.services.CommentRecProc; const DisplayManager = ns.ui.DisplayManager; class CommentSendMon { static handler = null; static init() { if (this.handler) { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', this.handler, true); } } let retryCount = 0; const checkAndBind = () => { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (!commentsHost || !commentsHost.shadowRoot) return false; this.handler = 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 - CommentRecProc.lastRecordTime < 500) { ns.logger.log('[监听器管理] 防抖:忽略重复的发送按钮点击'); return; } CommentRecProc.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.getCommentText(sendButton, isReplyButton, threadRenderer); if (!commentInfo) { ns.logger.log('[监听器管理] 获取评论信息失败'); return; } const { text: commentText, isReply } = commentInfo; ns.logger.log('[监听器管理] 检测到发送按钮点击', { isReply, commentText: commentText.substring(0, 50) + (commentText.length > 50 ? '...' : ''), timestamp: new Date().toLocaleString() }); const sendContext = { commentText, isReply, threadRenderer, timestamp: currentTime }; setTimeout(async () => { ns.logger.log('[监听器管理] 开始查找新评论...'); let commentDetails = null; if (isReply) { commentDetails = await CommentFindUtil.findNewReply(threadRenderer, commentText); ns.logger.log(`[监听器管理] 回复评论查找结果: ${commentDetails ? '找到' : '未找到'}`); } else { commentDetails = await CommentFindUtil.findNewMain(commentText); ns.logger.log(`[监听器管理] 主评论查找结果: ${commentDetails ? '找到' : '未找到'}`); } await CommentRecProc.handleRecord(commentDetails, commentText, isReply, sendContext); DisplayManager.updateSentComments(); }, getRandomDelay(300)); }; commentsHost.shadowRoot.addEventListener('click', this.handler, true); ns.logger.log('[监听器管理] 发送按钮监听器绑定成功'); retryCount = 0; return true; }; if (!checkAndBind()) { const retry = () => { if (!checkAndBind() && retryCount < 10) { retryCount++; setTimeout(retry, getRetryDelay(retryCount)); } }; setTimeout(retry, getRetryDelay(0)); } } static getCommentText(_sendButton, isReply, threadRenderer = null) { if (isReply && threadRenderer) { const editor = threadRenderer.shadowRoot ?.querySelector('#reply-container bili-comment-box') ?.shadowRoot?.querySelector('#comment-area #body #editor bili-comment-rich-textarea') ?.shadowRoot?.querySelector('#input .brt-root .brt-editor'); const text = editor?.innerText?.trim() || ''; return text ? { text, isReply: true, threadRenderer } : null; } const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); const header = commentsHost?.shadowRoot?.querySelector('#header'); if (!header) return null; const text = CommentFindUtil.findEditorText(header); return text ? { text, isReply: false, threadRenderer: null } : null; } static cleanup() { if (this.handler) { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', this.handler, true); } this.handler = null; } } } ns.services.CommentSendMon = CommentSendMon; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.utils && ns.services, 'HotkeySendMon dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const { getRetryDelay } = ns.utils; const CommentRecProc = ns.services.CommentRecProc; class HotkeySendMon { static handler = null; static init() { if (this.handler) { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('keydown', this.handler, true); } } let retryCount = 0; const checkAndBind = () => { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (!commentsHost || !commentsHost.shadowRoot) return false; this.handler = (e) => { if (!e.ctrlKey || e.key !== 'Enter' || e.repeat) return; if (!state.settings.ENABLE_HOTKEY_SEND) return; const now = Date.now(); if (now - CommentRecProc.lastRecordTime < 500) return; ns.logger.log('[快捷发送] 检测到按键'); const path = e.composedPath(); const isEditor = path.some( (el) => el?.nodeType === 1 && el.classList?.contains('brt-editor') ); if (!isEditor) { ns.logger.log('[快捷发送] 焦点不在编辑器内,忽略'); return; } ns.logger.log('[快捷发送] 确认焦点在编辑器内'); const commentBox = path.find( (el) => el?.nodeType === 1 && el.tagName === 'BILI-COMMENT-BOX' ); ns.logger.log('[快捷发送] commentBox:', commentBox ? '找到' : '未找到'); const sendButton = commentBox?.shadowRoot?.querySelector('button[data-v-risk="fingerprint"]'); ns.logger.log('[快捷发送] sendButton:', sendButton ? '找到' : '未找到'); if (sendButton) { e.preventDefault(); sendButton.click(); ns.logger.log('[快捷发送] 已触发发送按钮点击'); } }; commentsHost.shadowRoot.addEventListener('keydown', this.handler, true); ns.logger.log('[监听器管理] 快捷发送监听器绑定成功'); retryCount = 0; return true; }; if (!checkAndBind()) { const retry = () => { if (!checkAndBind() && retryCount < 10) { retryCount++; setTimeout(retry, getRetryDelay(retryCount)); } }; setTimeout(retry, getRetryDelay(0)); } } static cleanup() { if (this.handler) { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('keydown', this.handler, true); } this.handler = null; } } } ns.services.HotkeySendMon = HotkeySendMon; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.utils && ns.db && ns.services && ns.ui, 'CommentLikeMon dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const { getRetryDelay } = ns.utils; const DatabaseManager = ns.db.DatabaseManager; const CommentExtractor = ns.services.CommentExtractor; const DisplayManager = ns.ui.DisplayManager; class CommentLikeMon { static handler = null; static init() { if (this.handler) { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', this.handler, true); } } let retryCount = 0; const checkAndBind = () => { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (!commentsHost?.shadowRoot) return false; this.handler = async (event) => { if (!state.settings.ENABLE_COMMENT_COLLECT) return; const path = event.composedPath(); const likeButton = path.find((el) => el?.nodeType === 1 && el.id === 'like' && el.tagName === 'DIV'); if (!likeButton) return; const replyRenderer = path.find( (el) => el?.nodeType === 1 && el.tagName === 'BILI-COMMENT-REPLY-RENDERER' ); const threadRenderer = path.find( (el) => el?.nodeType === 1 && el.tagName === 'BILI-COMMENT-THREAD-RENDERER' ); if (!threadRenderer) return; const clickedRenderer = replyRenderer || threadRenderer; if (clickedRenderer.data?.action === 1) { ns.logger.log('[监听器管理] 检测到取消点赞操作,跳过记录'); return; } let dataChanged = false; let mainKey = null; const mainCommentDetails = CommentExtractor.extractDetails(threadRenderer); if (!mainCommentDetails) return; mainKey = mainCommentDetails.key; if (!state.recordedThreads.has(mainKey)) { state.recordedThreads.set(mainKey, { videoLink: mainCommentDetails.videoLink, mainComment: CommentExtractor.toStorageFormat(mainCommentDetails), replies: [], timestamp: Date.now() }); dataChanged = true; } if (replyRenderer) { const replyDetails = CommentExtractor.extractDetails(replyRenderer); if (!replyDetails) return; const thread = state.recordedThreads.get(mainKey); if (replyDetails.parent && replyDetails.parent !== '0' && replyDetails.parent !== replyDetails.root) { const parentExists = thread.replies.some((r) => r.rpid === replyDetails.parent); if (!parentExists) { const expanderContents = threadRenderer?.shadowRoot ?.querySelector('#replies bili-comment-replies-renderer') ?.shadowRoot?.querySelector('#expander #expander-contents'); if (expanderContents) { const allRenderers = expanderContents.querySelectorAll('bili-comment-reply-renderer'); for (const r of allRenderers) { if (r.data?.rpid_str === replyDetails.parent) { const parentDetails = CommentExtractor.extractDetails(r); if (parentDetails) { thread.replies.push(CommentExtractor.toStorageFormat(parentDetails)); dataChanged = true; } break; } } } } } const replyKey = replyDetails.rpid || `${replyDetails.userName}|||${replyDetails.content}`; const alreadyExists = thread.replies.some((r) => { const existingKey = r.rpid || `${r.userName}|||${r.content}`; return existingKey === replyKey; }); if (!alreadyExists) { thread.replies.push(CommentExtractor.toStorageFormat(replyDetails)); thread.timestamp = Date.now(); dataChanged = true; } } if (dataChanged && mainKey) { DisplayManager.updateComments(); await DatabaseManager.save(CONFIG.STORES.COMMENTS, mainKey, state.recordedThreads.get(mainKey)); } }; commentsHost.shadowRoot.addEventListener('click', this.handler, true); ns.logger.log('[监听器管理] 评论点击监听器绑定成功'); retryCount = 0; return true; }; if (!checkAndBind()) { const retry = () => { if (!checkAndBind() && retryCount < 10) { retryCount++; setTimeout(retry, getRetryDelay(retryCount)); } }; setTimeout(retry, getRetryDelay(0)); } } static cleanup() { if (this.handler) { const commentsHost = state.getCachedElement('comments-host', CONFIG.SELECTORS.COMMENTS_HOST); if (commentsHost?.shadowRoot) { commentsHost.shadowRoot.removeEventListener('click', this.handler, true); } this.handler = null; } } } ns.services.CommentLikeMon = CommentLikeMon; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.config && ns.db && ns.db.DatabaseManager && ns.utils, 'DanmakuListener dependencies missing'); const state = ns.state; const CONFIG = ns.config; const DatabaseManager = ns.db.DatabaseManager; const { getRetryDelay } = ns.utils; function formatVideoTime(seconds) { const m = Math.floor(seconds / 60); const s = Math.floor(seconds % 60); return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; } class DanmakuListener { static isMonitoring = false; static lastRecord = null; static updateTimer = null; static lastKeyTime = 0; static lastClickTime = 0; static sendingBar = null; static keydownHandler = null; static clickHandler = null; static init() { if (this.isMonitoring) return; this.bindToSendingBar(); this.isMonitoring = true; } static bindToSendingBar() { let retryCount = 0; const checkAndBind = () => { const bar = document.querySelector(CONFIG.SELECTORS.DANMAKU_SENDING_BAR.join(',')); if (!bar) return false; this.sendingBar = bar; this.setupEventDelegation(bar); ns.logger.log('[弹幕监听器] 事件委托已绑定到弹幕发送栏'); retryCount = 0; return true; }; if (!checkAndBind()) { const retry = () => { if (!checkAndBind() && retryCount < 10) { retryCount++; setTimeout(retry, getRetryDelay(retryCount)); } }; setTimeout(retry, getRetryDelay(0)); } } static setupEventDelegation(bar) { this.keydownHandler = (e) => { if (e.key !== 'Enter') return; const isDanmakuInput = CONFIG.SELECTORS.DANMAKU_INPUT.some( (selector) => e.target.matches && e.target.matches(selector) ); if (!isDanmakuInput) return; const now = Date.now(); if (now - this.lastKeyTime < 300) return; this.lastKeyTime = now; const text = e.target.value.trim(); if (text && state.currentVideoInfo) { setTimeout(() => this.recordDanmaku(text, '回车键'), 100); } }; bar.addEventListener('keydown', this.keydownHandler); this.clickHandler = (e) => { const isSendBtn = CONFIG.SELECTORS.DANMAKU_SEND_BTN.some( (selector) => e.target.closest && e.target.closest(selector) ); if (!isSendBtn) return; const now = Date.now(); if (now - this.lastClickTime < 300) return; this.lastClickTime = now; const input = bar.querySelector(CONFIG.SELECTORS.DANMAKU_INPUT.join(',')); if (input) { const text = input.value.trim(); if (text && state.currentVideoInfo) { setTimeout(() => this.recordDanmaku(text, '点击发送'), 100); } } }; bar.addEventListener('click', this.clickHandler); } static getVideoProgress() { let videoTime = '未知时间'; let videoTimeMs = 0; const videoEl = document.querySelector('.bpx-player-video-wrap video, .bilibili-player-video video'); if (videoEl) { const currentSeconds = videoEl.currentTime; videoTimeMs = Math.floor(currentSeconds * 1000); videoTime = formatVideoTime(currentSeconds); } return { videoTime, videoTimeMs }; } static async recordDanmaku(text, method) { if (!state.settings.ENABLE_DANMAKU) return; 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) { ns.logger.log(`[弹幕监听器] 弹幕去重: 忽略重复记录 "${trimmedText}" (${method})`); return; } const videoInfo = state.currentVideoInfo; if (!videoInfo) return; const { videoTime, videoTimeMs } = this.getVideoProgress(); const danmakuData = { text: trimmedText, videoTime, videoTimeMs, videoId: videoInfo.bvid, videoUrl: videoInfo.url, videoTitle: videoInfo.title, timestamp: now, time: new Date().toLocaleString() }; const isDuplicate = state.recordedDanmaku.some( (record) => record.text === trimmedText && danmakuData.timestamp - (record.timestamp || 0) < 3000 ); if (isDuplicate) return; this.lastRecord = { text: trimmedText, timestamp: now }; try { const id = await DatabaseManager.save(CONFIG.STORES.DANMAKU, null, danmakuData); danmakuData.id = id; state.recordedDanmaku.unshift(danmakuData); if (state.recordedDanmaku.length > 1000) { state.recordedDanmaku.length = 1000; } if (!this.updateTimer) { this.updateTimer = setTimeout(() => { ns.ui.DisplayManager.updateDanmaku(); this.updateTimer = null; }, 500); } ns.logger.log(`[弹幕监听器] 弹幕记录: ${method} - "${trimmedText}" @${danmakuData.videoTime}`); } catch (error) { ns.logger.error('[弹幕监听器] 保存失败:', error); } } static reset() { this.lastRecord = null; if (this.updateTimer) { clearTimeout(this.updateTimer); this.updateTimer = null; } } static cleanup() { if (this.sendingBar) { if (this.keydownHandler) this.sendingBar.removeEventListener('keydown', this.keydownHandler); if (this.clickHandler) this.sendingBar.removeEventListener('click', this.clickHandler); this.sendingBar = null; this.keydownHandler = null; this.clickHandler = null; } this.isMonitoring = false; this.reset(); } } ns.services = ns.services || {}; ns.services.DanmakuListener = DanmakuListener; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant( ns && ns.state && ns.config && ns.utils && ns.services, 'ListenerMgr dependencies missing' ); const state = ns.state; const CONFIG = ns.config; const { getRandomDelay } = ns.utils; const ReplyCtxCapture = ns.services.ReplyCtxCapture; const CommentSendMon = ns.services.CommentSendMon; const HotkeySendMon = ns.services.HotkeySendMon; const CommentLikeMon = ns.services.CommentLikeMon; const CommentRecProc = ns.services.CommentRecProc; const DanmakuListener = ns.services.DanmakuListener; class ListenerMgr { static isInitialized = false; static mutationObservers = new Map(); static popstateHandler = null; static init() { if (this.isInitialized) { ns.logger.log('[监听器管理] 已初始化,跳过'); return; } setTimeout(() => { ReplyCtxCapture.init(); CommentSendMon.init(); HotkeySendMon.init(); CommentLikeMon.init(); this.initPageNav(); DanmakuListener.init(); this.isInitialized = true; ns.logger.log('[监听器管理] 初始化完成'); }, getRandomDelay(200)); } static reset() { ns.logger.log('[监听器管理] 重置所有监听器状态'); ReplyCtxCapture.cleanup(); CommentSendMon.cleanup(); HotkeySendMon.cleanup(); CommentLikeMon.cleanup(); if (this.popstateHandler) { window.removeEventListener('popstate', this.popstateHandler); this.popstateHandler = null; } this.mutationObservers.forEach((observer) => { observer.disconnect(); }); this.mutationObservers.clear(); this.isInitialized = false; CommentRecProc.reset(); DanmakuListener.reset(); } static initPageNav() { 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; ns.logger.log('[监听器管理] 页面URL变化,重新初始化'); this.updateVideoInfo(); state.clearReplyContext(); state.clearAllDomCache(); this.reset(); 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); } this.popstateHandler = () => { const url = location.href; if (url !== lastUrl) { lastUrl = url; this.updateVideoInfo(); state.clearReplyContext(); state.clearAllDomCache(); this.reset(); setTimeout(() => this.init(), getRandomDelay(300)); } }; window.addEventListener('popstate', this.popstateHandler); } static updateVideoInfo() { const pathname = window.location.pathname; const bvid = pathname.match(/\/video\/(BV[\w]+)/)?.[1]; const epid = pathname.match(/\/bangumi\/play\/(ep\d+)/)?.[1]; const ssid = pathname.match(/\/bangumi\/play\/(ss\d+)/)?.[1]; const videoId = bvid || epid || ssid; if (videoId) { state.setCurrentVideoInfo({ bvid: videoId, url: window.location.href, title: document.title }); ns.logger.log(`[监听器管理] 更新视频信息: ${videoId}`); } } } ns.services.ListenerMgr = ListenerMgr; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.config, 'UIDLookup dependencies missing'); const state = ns.state; const logger = ns.logger; const randInt = (min, max) => Math.floor(Math.random() * (max - min + 1)) + min; const sleep = ms => new Promise(r => setTimeout(r, ms)); const humanDelay = (base, jitter) => sleep(base + randInt(0, jitter)); const reasonWeights = { '引战': 8, '人身攻击': 7, '恶意刷屏': 6, '色情低俗': 4, '垃圾广告': 3, '视频无关': 3, '青少年不良信息': 2, '违法违禁': 2, '赌博诈骗': 1, '侵犯隐私': 1, '剧透': 1, '违法信息外链': 1, }; const skipReasons = ['其他', '其它']; function pickWeightedReason(labels) { let total = 0; const weighted = labels.map(item => { let w = 1; for (const key in reasonWeights) { if (item.text.includes(key)) { w = reasonWeights[key]; break; } } total += w; return { ...item, weight: w }; }); let rand = randInt(0, total - 1); for (const item of weighted) { if (rand < item.weight) return item; rand -= item.weight; } return weighted[0]; } async function fetchReportFeedbackUID(retries = 3) { const url = 'https://api.vc.bilibili.com/session_svr/v1/session_svr/new_sessions?size=10&mobi_app=web&build=0'; for (let attempt = 0; attempt < retries; attempt++) { if (attempt > 0) await humanDelay(1500, 1000); try { const res = await fetch(url, { credentials: 'include' }); const json = await res.json(); if (json.code !== 0) continue; const sessions = json.data?.session_list || []; for (const session of sessions) { const content = session.last_msg?.content; if (!content) continue; try { const parsed = JSON.parse(content); if (parsed.title !== '举报进展反馈') continue; for (const mod of (parsed.modules || [])) { if (mod.title === '举报对象') { const match = (mod.detail || '').match(/UID:\s*(\d+)/i); if (match) return match[1]; } } } catch (_) {} } } catch (err) { logger.error('请求反馈接口异常:', err.message); } } return null; } function waitForElement(selector, parent = document.body, timeout = 6000) { return new Promise(resolve => { const existing = parent.querySelector(selector); if (existing) return resolve(existing); let settled = false; const observer = new MutationObserver(() => { const el = parent.querySelector(selector); if (el && !settled) { settled = true; observer.disconnect(); resolve(el); } }); observer.observe(parent, { childList: true, subtree: true }); setTimeout(() => { if (!settled) { settled = true; observer.disconnect(); resolve(null); } }, timeout); }); } function getReasonLabels(container) { return Array.from(container.querySelectorAll('.bui-radio-item')) .map(el => { const textNode = el.querySelector('.bui-radio-text'); if (!textNode) return null; const text = textNode.textContent.trim(); if (skipReasons.some(k => text.includes(k))) return null; if (Object.keys(reasonWeights).some(k => text.includes(k))) { return { label: el, text }; } return null; }) .filter(Boolean); } let isProcessing = false; async function handleAutoReport(e) { e.stopPropagation(); e.preventDefault(); if (isProcessing) return; isProcessing = true; document.body.classList.add('plugin-auto-reporting'); try { const originalBtn = document.querySelector('.bpx-player-dm-tip-back'); if (!originalBtn) { logger.error('[UIDLookup] 找不到原版举报按钮'); return; } originalBtn.click(); const dialog = await waitForElement('.bpx-player-report-box'); if (!dialog) { logger.error('[UIDLookup] 举报面板加载超时'); return; } await humanDelay(300, 200); const allLabels = getReasonLabels(dialog); if (allLabels.length === 0) { logger.error('[UIDLookup] 未找到举报选项'); return; } const target = pickWeightedReason(allLabels); target.label.click(); await humanDelay(300, 200); const submitBtn = dialog.querySelector('.bpx-player-report-btn'); if (!submitBtn) { logger.error('[UIDLookup] 未找到提交按钮'); return; } if (submitBtn.classList.contains('bui-disabled')) await humanDelay(300, 200); submitBtn.click(); await humanDelay(400, 300); await humanDelay(1500, 1000); const uid = await fetchReportFeedbackUID(); if (uid) { window.open(`https://space.bilibili.com/${uid}`, '_blank'); } else { logger.warn('[UIDLookup] 未能提取到UID(系统未受理或延迟)'); } } finally { setTimeout(() => { document.body.classList.remove('plugin-auto-reporting'); isProcessing = false; }, 600); } } function createReportButton() { const btn = document.createElement('div'); btn.className = 'plugin-auto-report-btn'; btn.title = '一键查UID'; const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M13 2.05v8.46h5.81L7.54 22.84v-9.3H2.03L13 2.05z'); svg.appendChild(path); btn.appendChild(svg); btn.addEventListener('click', handleAutoReport); return btn; } function injectIfNeeded(tipContainer) { if (!tipContainer.querySelector('.plugin-auto-report-btn')) { tipContainer.appendChild(createReportButton()); } } let mainObserver = null; let urlObserver = null; function startObserve() { const existing = document.querySelector('.bpx-player-dm-tip'); if (existing) injectIfNeeded(existing); const playerWrap = document.querySelector('.bpx-player-container') || document.body; mainObserver = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; if (node.classList?.contains('bpx-player-dm-tip')) injectIfNeeded(node); const inner = node.querySelector?.('.bpx-player-dm-tip'); if (inner) injectIfNeeded(inner); } } }); mainObserver.observe(playerWrap, { childList: true, subtree: true }); let lastUrl = location.href; urlObserver = new MutationObserver(() => { if (location.href !== lastUrl) { lastUrl = location.href; destroy(); setTimeout(startObserve, 1000); } }); urlObserver.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true }); } function destroy() { if (mainObserver) { mainObserver.disconnect(); mainObserver = null; } if (urlObserver) { urlObserver.disconnect(); urlObserver = null; } document.querySelectorAll('.plugin-auto-report-btn').forEach(el => el.remove()); } let styleEl = null; const LOOKUP_STYLES = ` .plugin-auto-report-btn{position:absolute;right:0;top:-32px;width:28px;height:28px;cursor:pointer;display:flex;align-items:center;justify-content:center;background:#FFB800;border-radius:50%;border:2px solid #FFF;z-index:1000;box-shadow:0 2px 5px rgba(0,0,0,0.3);transition:transform .1s} .plugin-auto-report-btn:hover{transform:scale(1.15)} .plugin-auto-report-btn::after{content:'';position:absolute;bottom:-25px;left:-15px;right:-15px;height:35px;background:transparent} .plugin-auto-report-btn svg{width:16px;height:16px;fill:#FFF} body.plugin-auto-reporting .bpx-player-dialog-wrap{position:fixed!important;left:-9999px!important;top:-9999px!important;opacity:0!important} body.plugin-auto-reporting .bpx-player-report-box{position:fixed!important;left:-9999px!important;top:-9999px!important;opacity:0!important} `; function injectStyles() { if (styleEl) return; styleEl = document.createElement('style'); styleEl.textContent = LOOKUP_STYLES; document.head.appendChild(styleEl); } function removeStyles() { if (styleEl) { styleEl.remove(); styleEl = null; } } const UIDLookup = { enabled: false, enable() { if (this.enabled) return; this.enabled = true; injectStyles(); startObserve(); logger.log('[UIDLookup] 功能已启用'); }, disable() { if (!this.enabled) return; this.enabled = false; destroy(); removeStyles(); logger.log('[UIDLookup] 功能已禁用'); }, }; ns.services = ns.services || {}; ns.services.UIDLookup = UIDLookup; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.db && ns.db.DatabaseManager && ns.utils, 'DataManager dependencies missing'); const state = ns.state; const DatabaseManager = ns.db.DatabaseManager; const { generateUniqueKey } = ns.utils; class DataManager { static exportAll() { const data = { version: ns.VERSION, type: 'all', comments: Array.from(state.recordedThreads.entries()), danmaku: state.recordedDanmaku, sentComments: state.sentComments, exportTime: new Date().toISOString(), counts: { comments: state.recordedThreads.size, danmaku: state.recordedDanmaku.length, sentComments: state.sentComments.length } }; this.downloadJSON(data, `bilibili_all_data_${Date.now()}.json`); } static exportSentComments() { const data = { version: ns.VERSION, type: 'sent_comments_grouped', data: state.sentComments, exportTime: new Date().toISOString(), video: state.currentVideoInfo }; this.downloadJSON(data, `bilibili_sent_comments_${Date.now()}.json`); } static exportComments() { const data = { version: ns.VERSION, type: 'comments', data: Array.from(state.recordedThreads.entries()), exportTime: new Date().toISOString(), count: state.recordedThreads.size }; this.downloadJSON(data, `bilibili_comments_${Date.now()}.json`); } static exportDanmaku() { const data = { version: ns.VERSION, type: 'danmaku', data: state.recordedDanmaku, exportTime: new Date().toISOString(), count: state.recordedDanmaku.length }; this.downloadJSON(data, `bilibili_danmaku_${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()); const importCount = { comments: 0, danmaku: 0, sentComments: 0 }; const mergeCount = { danmaku: 0, sentComments: 0 }; if (data.type === 'all') { if (data.comments) { for (const [key, value] of data.comments) { await DatabaseManager.save(ns.config.STORES.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(ns.config.STORES.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++; } } else { throw new Error('不支持的导入文件类型'); } await DatabaseManager.loadAll(); if (ns.ui && ns.ui.DisplayManager) { ns.ui.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) { ns.logger.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 = state.sentComments.find((group) => generateUniqueKey('sentComment', group) === uniqueKey); if (existingGroup) { if (data.replies && data.replies.length > 0) { for (const newReply of data.replies) { const exists = existingGroup.replies.some( (reply) => reply.content === newReply.content && reply.userName === newReply.userName ); if (!exists) { existingGroup.replies.push(newReply); } } } existingGroup.lastUpdateTime = new Date().toLocaleString(); await DatabaseManager.update(ns.config.STORES.SENT_COMMENTS, existingGroup); return { isNew: false }; } } else if (type === 'danmaku') { const existingDanmaku = state.recordedDanmaku.find((d) => generateUniqueKey('danmaku', d) === uniqueKey); if (existingDanmaku) { existingDanmaku.time = data.time || existingDanmaku.time; existingDanmaku.videoTitle = data.videoTitle || existingDanmaku.videoTitle; await DatabaseManager.update(ns.config.STORES.DANMAKU, existingDanmaku); return { isNew: false }; } } const cleanData = { ...data }; delete cleanData.id; const store = type === 'sentComment' ? ns.config.STORES.SENT_COMMENTS : ns.config.STORES.DANMAKU; await DatabaseManager.save(store, null, cleanData); return { isNew: true }; } } ns.data = ns.data || {}; ns.data.DataManager = DataManager; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.state && ns.ui && ns.db && ns.data && ns.settings, 'UIManager dependencies missing'); const state = ns.state; const DatabaseManager = ns.db.DatabaseManager; const DataManager = ns.data.DataManager; const SettingsManager = ns.settings.SettingsManager; const DisplayManager = ns.ui.DisplayManager; const $ = (sel) => ns.ui.shadowRoot?.querySelector(sel); const $id = (id) => ns.ui.shadowRoot?.getElementById(id); class UIManager { static create() { if (ns.ui.shadowRoot?.getElementById('collector-float-btn')) return; const sp = (id, ph) => `
显示 0 条记录
`; const html = `
`; const host = document.createElement('div'); host.id = 'bili-collector-host'; document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); ns.ui.shadowRoot = shadow; const styleEl = document.createElement('style'); styleEl.textContent = ns.ui.STYLES; shadow.appendChild(styleEl); const container = document.createElement('div'); container.innerHTML = html; shadow.appendChild(container); this.bindEvents(); this.initFloatButton(); this.initPanelStatePersistence(); this.applyTheme(state.settings.THEME); } static applyTheme(theme) { const panel = $id('collector-panel'); if (!panel) return; panel.removeAttribute('data-theme'); if (theme === 'light' || theme === 'dark') { panel.setAttribute('data-theme', theme); } } static bindEvents() { const panel = $id('collector-panel'); const floatBtn = $id('collector-float-btn'); panel.querySelector('.panel-close-btn').addEventListener('click', () => { panel.style.display = 'none'; this.savePanelState(); }); document.addEventListener('click', (e) => { const path = e.composedPath(); if (!path.includes(panel) && !path.includes(floatBtn) && panel.style.display !== 'none') { panel.style.display = 'none'; this.savePanelState(); } }); floatBtn.addEventListener('click', (e) => { if (floatBtn.classList.contains('dragging') || floatBtn._wasDragged) { floatBtn._wasDragged = false; return; } e.stopPropagation(); panel.style.display = panel.style.display === 'none' ? 'flex' : 'none'; this.savePanelState(); }); panel.querySelectorAll('.tab-btn').forEach((btn) => { btn.addEventListener('click', () => { panel.querySelectorAll('.tab-btn').forEach((b) => b.classList.remove('active')); btn.classList.add('active'); const tabName = btn.dataset.tab; panel.querySelectorAll('.tab-content').forEach((c) => (c.style.display = 'none')); panel.querySelector(`#${tabName}-tab`).style.display = 'block'; if (tabName === 'settings') { this.updateStorageInfo(); this.updateCurrentDisplayCounts(); } this.savePanelState(); }); }); panel.querySelector('#export-sent-btn').addEventListener('click', () => DataManager.exportSentComments()); panel.querySelector('#export-comments-btn').addEventListener('click', () => DataManager.exportComments()); panel.querySelector('#export-danmaku-btn').addEventListener('click', () => DataManager.exportDanmaku()); [ ['#clear-sent-btn', '确定要清空所有评论记录吗?', async () => { state.sentComments.length = 0; await DatabaseManager.clear(ns.config.STORES.SENT_COMMENTS); state.resetPaginationState('sentComments'); DisplayManager.updateSentComments(); }], ['#clear-comments-btn', '确定要清空所有评论收藏吗?', async () => { state.recordedThreads.clear(); await DatabaseManager.clear(ns.config.STORES.COMMENTS); state.resetPaginationState('comments'); DisplayManager.updateComments(); }], ['#clear-danmaku-btn', '确定要清空所有弹幕记录吗?', async () => { state.recordedDanmaku.length = 0; await DatabaseManager.clear(ns.config.STORES.DANMAKU); state.resetPaginationState('danmaku'); DisplayManager.updateDanmaku(); }], ['#clear-all-btn', '确定要清空当前账号所有数据吗?此操作不可恢复!', async () => { state.recordedThreads.clear(); state.recordedDanmaku.length = 0; state.sentComments.length = 0; await DatabaseManager.clear(ns.config.STORES.COMMENTS); await DatabaseManager.clear(ns.config.STORES.DANMAKU); await DatabaseManager.clear(ns.config.STORES.SENT_COMMENTS); state.resetPaginationState(); DisplayManager.updateAll(); }], ].forEach(([sel, msg, fn]) => { panel.querySelector(sel).addEventListener('click', async () => { if (confirm(msg)) await fn(); }); }); function bindFeatureToggle(toggleId, statusId, settingKey, onEnable, onDisable) { const toggle = panel.querySelector(`#${toggleId}`); const status = panel.querySelector(`#${statusId}`); if (state.settings[settingKey] === false) { toggle.classList.remove('on'); toggle.setAttribute('aria-checked', 'false'); status.textContent = '已关闭'; } toggle.addEventListener('click', () => { const isOn = toggle.classList.toggle('on'); toggle.setAttribute('aria-checked', String(isOn)); status.textContent = isOn ? '已开启' : '已关闭'; state.setSettings({ [settingKey]: isOn }); SettingsManager.save(); if (isOn && onEnable) onEnable(); if (!isOn && onDisable) onDisable(); }); } bindFeatureToggle('toggle-sent-comments', 'toggle-sent-comments-status', 'ENABLE_SENT_COMMENTS'); bindFeatureToggle('toggle-comment-collect', 'toggle-comment-collect-status', 'ENABLE_COMMENT_COLLECT'); bindFeatureToggle('toggle-danmaku', 'toggle-danmaku-status', 'ENABLE_DANMAKU'); bindFeatureToggle('toggle-hotkey-send', 'toggle-hotkey-send-status', 'ENABLE_HOTKEY_SEND'); bindFeatureToggle('toggle-antifraud', 'toggle-antifraud-status', 'ENABLE_ANTIFRAUD'); const uidToggle = panel.querySelector('#uid-lookup-toggle'); const uidStatus = panel.querySelector('#uid-lookup-status'); const UIDLookup = ns.services?.UIDLookup; if (state.settings.UID_LOOKUP && UIDLookup) { uidToggle.classList.add('on'); uidToggle.setAttribute('aria-checked', 'true'); uidStatus.textContent = '已开启'; UIDLookup.enable(); } function showRiskModal() { const overlay = document.createElement('div'); overlay.className = 'uid-risk-overlay'; const shadowRoot = ns.ui.shadowRoot; overlay.innerHTML = `

⚠ 高风险 功能警告

此功能具有风险,请务必仔细阅读以下说明:

注意:此脚本每次查询都会产生一次真实举报记录。

⚠ 请勿高频操作!本工具仅提供查询弹幕发送者UID的思路,旨在提高查询效率,不鼓励任何滥用行为。

使用本功能时,请务必遵守B站社区规范。一切后果由使用者自行承担。

`; shadowRoot.appendChild(overlay); const cancelBtn = overlay.querySelector('#uid-risk-cancel'); const confirmBtn = overlay.querySelector('#uid-risk-confirm'); const countdownSpan = overlay.querySelector('#uid-risk-countdown'); let remaining = 3; const timer = setInterval(() => { remaining--; countdownSpan.textContent = remaining; if (remaining <= 0) { clearInterval(timer); confirmBtn.disabled = false; confirmBtn.textContent = '我已了解风险,确认开启'; } }, 1000); cancelBtn.addEventListener('click', () => { clearInterval(timer); overlay.remove(); }); confirmBtn.addEventListener('click', () => { clearInterval(timer); overlay.remove(); uidToggle.classList.add('on'); uidToggle.setAttribute('aria-checked', 'true'); uidStatus.textContent = '已开启'; state.setSettings({ UID_LOOKUP: true }); SettingsManager.save(); if (UIDLookup) UIDLookup.enable(); }); } uidToggle.addEventListener('click', () => { if (uidToggle.classList.contains('on')) { uidToggle.classList.remove('on'); uidToggle.setAttribute('aria-checked', 'false'); uidStatus.textContent = '已关闭'; state.setSettings({ UID_LOOKUP: false }); SettingsManager.save(); if (UIDLookup) UIDLookup.disable(); } else { showRiskModal(); } }); panel.addEventListener('click', async (e) => { if (e.target.closest('[data-expand-action]')) { const btn = e.target.closest('[data-expand-action]'); const contentDiv = btn.previousElementSibling; if (contentDiv?.dataset.collapsible) { const isCollapsed = contentDiv.classList.toggle('collapsed'); btn.classList.toggle('expanded', !isCollapsed); btn.setAttribute('aria-label', isCollapsed ? '展开' : '收起'); } e.stopPropagation(); return; } const checkBtn = e.target.closest('.antifraud-check-btn'); if (checkBtn) { e.stopPropagation(); const { groupId, commentType, replyRpid } = checkBtn.dataset; if (ns.services.AntiFraudQueue) { checkBtn.disabled = true; checkBtn.textContent = '检测中...'; await ns.services.AntiFraudQueue.checkCommentStatus( Number(groupId), commentType, replyRpid || null ); checkBtn.disabled = false; checkBtn.textContent = '检测'; } return; } const btn = e.target.closest('.delete-btn'); if (!btn) return; e.stopPropagation(); const { type, id, key } = btn.dataset; if (type === 'sent') { if (!confirm('删除这组评论记录?')) return; const idx = state.sentComments.findIndex((g) => g.id === Number(id)); if (idx !== -1) state.sentComments.splice(idx, 1); await DatabaseManager.delete(ns.config.STORES.SENT_COMMENTS, Number(id)); DisplayManager.updateSentComments(); } else if (type === 'comment') { if (!confirm('删除这组评论收藏?')) return; state.recordedThreads.delete(key); await DatabaseManager.delete(ns.config.STORES.COMMENTS, key); DisplayManager.updateComments(); } else if (type === 'danmaku') { if (!confirm('删除这条弹幕记录?')) return; const idx = state.recordedDanmaku.findIndex((d) => d.id === Number(id)); if (idx !== -1) state.recordedDanmaku.splice(idx, 1); await DatabaseManager.delete(ns.config.STORES.DANMAKU, Number(id)); DisplayManager.updateDanmaku(); } }); panel.addEventListener('dblclick', (e) => { const item = e.target.closest('[data-video-url]'); if (item?.dataset.videoUrl) window.open(item.dataset.videoUrl, '_blank', 'noopener,noreferrer'); }); const timers = {}; [ ['#sent-comments-search', 'sentComments', () => DisplayManager.updateSentComments()], ['#comments-search', 'comments', () => DisplayManager.updateComments()], ['#danmaku-search', 'danmaku', () => DisplayManager.updateDanmaku()], ].forEach(([sel, key, update]) => { panel.querySelector(sel).addEventListener('input', (e) => { state.paginationState[key].searchTerm = e.target.value; state.paginationState[key].currentPage = 1; clearTimeout(timers[key]); timers[key] = setTimeout(update, 300); }); }); panel.addEventListener('click', (e) => { const pgBtn = e.target.closest('.pagination-btn'); if (!pgBtn || pgBtn.classList.contains('disabled')) return; const { type, action, page } = pgBtn.dataset; const ps = state.paginationState[type]; if (!ps) return; if (action === 'prev') ps.currentPage = Math.max(1, ps.currentPage - 1); else if (action === 'next') ps.currentPage++; else if (page) ps.currentPage = parseInt(page, 10); if (type === 'sentComments') DisplayManager.updateSentComments(); else if (type === 'comments') DisplayManager.updateComments(); else if (type === 'danmaku') DisplayManager.updateDanmaku(); }); [ ['#sent-comments-page-size', 'sentComments', () => DisplayManager.updateSentComments()], ['#comments-page-size', 'comments', () => DisplayManager.updateComments()], ['#danmaku-page-size', 'danmaku', () => DisplayManager.updateDanmaku()], ].forEach(([sel, key, update]) => { panel.querySelector(sel).addEventListener('change', () => { state.paginationState[key].currentPage = 1; update(); }); }); const themeSelect = panel.querySelector('#theme-select'); themeSelect.value = state.settings.THEME; themeSelect.addEventListener('change', () => { this.applyTheme(themeSelect.value); }); const maxSentInput = panel.querySelector('#max-sent-input'); const maxCommentsInput = panel.querySelector('#max-comments-input'); const maxDanmakuInput = panel.querySelector('#max-danmaku-input'); const collapseLengthInput = panel.querySelector('#collapse-length-input'); maxSentInput.value = state.settings.SENT_COMMENTS; maxCommentsInput.value = state.settings.DISPLAY_COMMENTS; maxDanmakuInput.value = state.settings.DISPLAY_DANMAKU; collapseLengthInput.value = state.settings.COMMENT_COLLAPSE_LENGTH; panel.querySelector('#save-settings-btn').addEventListener('click', async () => { const collapseValue = parseInt(collapseLengthInput.value, 10); state.setSettings({ SENT_COMMENTS: Math.max(1, Math.min(2000, parseInt(maxSentInput.value, 10) || 200)), DISPLAY_COMMENTS: Math.max(1, Math.min(2000, parseInt(maxCommentsInput.value, 10) || 200)), DISPLAY_DANMAKU: Math.max(1, Math.min(2000, parseInt(maxDanmakuInput.value, 10) || 200)), COMMENT_COLLAPSE_LENGTH: isNaN(collapseValue) ? 200 : Math.max(0, Math.min(1000, collapseValue)), THEME: themeSelect.value, }); SettingsManager.save(); await ns.services.CommentExtractor.cleanupOldComments(); DisplayManager.updateAll(); this.updateStorageInfo(); alert('设置已保存'); }); panel.querySelector('#export-all-btn').addEventListener('click', () => DataManager.exportAll()); panel.querySelector('#import-data-btn').addEventListener('click', () => panel.querySelector('#import-file').click()); panel.querySelector('#import-file').addEventListener('change', async (e) => { const file = e.target.files?.[0]; if (!file) return; await DataManager.import(file); e.target.value = ''; }); } static initFloatButton() { const floatBtn = $id('collector-float-btn'); const savedPos = SettingsManager.loadPosition(); if (savedPos) { floatBtn.style.left = savedPos.left; floatBtn.style.top = savedPos.top; floatBtn.style.right = 'auto'; floatBtn.style.bottom = 'auto'; } let startX = 0, startY = 0, startLeft = 0, startTop = 0, isDragging = false, wasDragged = false; floatBtn.addEventListener('pointerdown', (e) => { startX = e.clientX; startY = e.clientY; const rect = floatBtn.getBoundingClientRect(); startLeft = rect.left; startTop = rect.top; isDragging = false; wasDragged = false; floatBtn.setPointerCapture(e.pointerId); const onPointerMove = (ev) => { const dx = ev.clientX - startX; const dy = ev.clientY - startY; if (!isDragging && (Math.abs(dx) > 5 || Math.abs(dy) > 5)) { isDragging = true; floatBtn.classList.add('dragging'); } if (isDragging) { floatBtn.style.left = Math.max(0, Math.min(window.innerWidth - rect.width, startLeft + dx)) + 'px'; floatBtn.style.top = Math.max(0, Math.min(window.innerHeight - rect.height, startTop + dy)) + 'px'; floatBtn.style.right = 'auto'; floatBtn.style.bottom = 'auto'; } ev.preventDefault(); }; const onPointerUp = () => { floatBtn.removeEventListener('pointermove', onPointerMove); floatBtn.removeEventListener('pointerup', onPointerUp); if (isDragging) { wasDragged = true; floatBtn._wasDragged = true; const r = floatBtn.getBoundingClientRect(); SettingsManager.savePosition({ left: r.left + 'px', top: r.top + 'px' }); } floatBtn.classList.remove('dragging'); }; floatBtn.addEventListener('pointermove', onPointerMove); floatBtn.addEventListener('pointerup', onPointerUp); }); } static getPageSize(type) { const map = { sentComments: 'sent-comments-page-size', comments: 'comments-page-size', danmaku: 'danmaku-page-size' }; const el = map[type] ? $id(map[type]) : null; const value = el ? parseInt(el.value, 10) : NaN; return Number.isFinite(value) ? value : ns.config.PAGINATION.PAGE_SIZE; } static async updateStorageInfo() { const infoEl = $id('storage-info'); if (!infoEl) return; if (!navigator.storage?.estimate) { infoEl.textContent = '浏览器不支持存储估算 API'; return; } try { const { usage, quota } = await navigator.storage.estimate(); const pct = ((usage / quota) * 100).toFixed(1); infoEl.textContent = `已用 ${(usage / 1048576).toFixed(2)}MB / 总共 ${(quota / 1048576).toFixed(2)}MB (${pct}%)`; } catch (e) { infoEl.textContent = '获取存储信息失败'; } } static updateCurrentDisplayCounts() { const c0 = $id('current-sent-display'); const c1 = $id('current-comments-display'); const c2 = $id('current-danmaku-display'); if (c0) c0.textContent = String(state.sentComments.length); if (c1) c1.textContent = String(state.recordedThreads.size); if (c2) c2.textContent = String(state.recordedDanmaku.length); } static initPanelStatePersistence() { const panel = $id('collector-panel'); const saved = SettingsManager.loadPanelState(); if (saved) { if (saved.width) panel.style.width = saved.width; if (saved.height) panel.style.height = saved.height; if (saved.visible) panel.style.display = 'flex'; if (saved.activeTab) { const btn = panel.querySelector(`.tab-btn[data-tab="${saved.activeTab}"]`); if (btn) btn.click(); } } if (typeof ResizeObserver === 'function') { let timer = null; new ResizeObserver(() => { clearTimeout(timer); timer = setTimeout(() => this.savePanelState(), 500); }).observe(panel); } } static savePanelState() { const panel = $id('collector-panel'); if (!panel) return; const activeBtn = panel.querySelector('.tab-btn.active'); SettingsManager.savePanelState({ visible: panel.style.display !== 'none', activeTab: activeBtn?.dataset?.tab || 'sent-comments', width: panel.style.width || '', height: panel.style.height || '', }); } } ns.ui.UIManager = UIManager; })(globalThis); (function (global) { 'use strict'; const ns = global.__BiliCollector__; ns.invariant(ns && ns.db && ns.settings && ns.ui && ns.services && ns.data, 'Main dependencies missing'); const DatabaseManager = ns.db.DatabaseManager; const SettingsManager = ns.settings.SettingsManager; const UIManager = ns.ui.UIManager; const DisplayManager = ns.ui.DisplayManager; const ListenerMgr = ns.services.ListenerMgr; const state = ns.state; function getUidFromCookie() { const match = document.cookie.match(/(?:^|;\s*)DedeUserID=(\d+)/); return match ? match[1] : null; } function showLoginNotice() { const host = document.createElement('div'); host.id = 'bili-collector-host'; document.body.appendChild(host); const shadow = host.attachShadow({ mode: 'open' }); ns.ui.shadowRoot = shadow; const style = document.createElement('style'); style.textContent = ` :host{all:initial} #collector-float-btn{position:fixed;right:20px;bottom:80px;z-index:99999;width:40px;height:40px; border-radius:50%;background:#fb7299;color:#fff;display:flex;align-items:center;justify-content:center; cursor:pointer;font-size:18px;box-shadow:0 2px 8px rgba(0,0,0,.12);opacity:.85; font-family:system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif} #collector-float-btn:hover{opacity:1} `; shadow.appendChild(style); const btn = document.createElement('div'); btn.id = 'collector-float-btn'; btn.setAttribute('aria-label', '打开收藏夹'); btn.title = 'B站收藏夹 未登录 请先登录'; btn.innerHTML = ''; btn.addEventListener('click', () => alert('请先登录B站账号后刷新页面')); shadow.appendChild(btn); } async function initApp() { try { const uid = getUidFromCookie(); if (!uid) { showLoginNotice(); return; } state.setCurrentUid(uid); SettingsManager.load(); await DatabaseManager.init(); await DatabaseManager.loadAll(); UIManager.create(); DisplayManager.updateAll(); UIManager.updateStorageInfo(); ListenerMgr.init(); } catch (e) { ns.logger.error('[BiliCollector] 初始化失败:', e); } } initApp(); })(globalThis);