// ==UserScript==
// @name B站评论弹幕工具-自动记录保存评论弹幕收藏评论
// @version 4.4
// @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.4';
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: 5000,
DISPLAY_DANMAKU: 5000,
SENT_COMMENTS: 5000,
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}
#reset-settings-btn{background:var(--c-btn);color:#fff;padding:8px 14px;border:none;border-radius:6px;cursor:pointer;text-align:center;font-size:13px}
#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 `${content}
`;
}
static _imgs(srcs) {
if (!srcs || !srcs.length) return '';
return ``;
}
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 ``;
}
static _meta(item) {
return ``;
}
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 = ``;
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 ``;
}).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)}
`;
}
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 ``;
}
}
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 extractEditorContent(editor) {
if (!editor) return null;
const html = editor.innerHTML;
if (!html) return null;
const text = html.replace(/
]+alt="([^"]*)"[^>]*>/gi, '$1').replace(/<[^>]+>/g, '').trim();
return text || null;
}
static findEditorText(element, depth = 0) {
if (depth > 10) return null;
const editor = element.querySelector('.brt-editor');
if (editor) {
const text = this.extractEditorContent(editor);
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 pagination = (!next || next === '' || next === 0) ? '{"offset":""}' : `{"offset":"${next}"}`;
const params = { oid, type, mode, plat: 1, web_location: 1315875, pagination_str: pagination, 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 { img_key, sub_key } = await getWbiKeys();
const params = { oid, type, root, pn, sort };
const query = wbiSign.encWbi(params, img_key, sub_key);
const url = `https://api.bilibili.com/x/v2/reply/reply?${query}`;
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 = CommentFindUtil.extractEditorContent(editor);
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) => `
`;
const html = `
弹幕: 0
${sp('danmaku', '搜索弹幕内容或视频标题...')}
正在加载...
显示设置
组
组
条
字符 (0不折叠)
数据太多可能影响搜索和加载速度你可以导出备份并清理。
注意:减少保存数量会立即删除超出部分的旧数据,务必先导出备份!
数据管理
0
0
功能开关
关闭功能后对应的监听和记录将停止,已保存的数据不受影响。
高级功能
开启后可在评论记录检查发送的评论是否被ShadowBan(仅自己可见)或删除。最好在评论发送后等待5秒以上再检查以确保准确性。
开启后可在弹幕悬浮面板查询发送者UID。此功能具有风险,请谨慎使用。
`;
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(9999, parseInt(maxSentInput.value, 10) || 5000)),
DISPLAY_COMMENTS: Math.max(1, Math.min(9999, parseInt(maxCommentsInput.value, 10) || 5000)),
DISPLAY_DANMAKU: Math.max(1, Math.min(9999, parseInt(maxDanmakuInput.value, 10) || 5000)),
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('#reset-settings-btn').addEventListener('click', () => {
if (!confirm('确定要恢复所有设置为默认值吗?')) return;
const defaults = ns.config.DEFAULT_LIMITS;
maxSentInput.value = defaults.SENT_COMMENTS;
maxCommentsInput.value = defaults.DISPLAY_COMMENTS;
maxDanmakuInput.value = defaults.DISPLAY_DANMAKU;
collapseLengthInput.value = defaults.COMMENT_COLLAPSE_LENGTH;
themeSelect.value = defaults.THEME;
this.applyTheme(defaults.THEME);
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);