NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript== // @name Bilibili用户备注 // @namespace https://github.com/pxoxq // @version 0.3.0 // @description B站用户备注脚本| Bilibili用户备注 // @license AGPL-3.0-or-later // @copyright 2023, pxoxq (https://openuserjs.org/users/pxoxq) // @author pxoxq // @match https://space.bilibili.com/** // @icon https://www.bilibili.com/favicon.ico // @grant GM_addElement // @grant GM_addStyle // @grant window.onurlchange // @require https://code.jquery.com/jquery-3.7.1.min.js // @require https://scriptcat.org/lib/513/2.0.0/ElementGetter.js // @updateURL https://openuserjs.org/meta/pxoxq/Bilibili用户备注.meta.js // @downloadURL https://openuserjs.org/install/pxoxq/Bilibili用户备注.user.js // ==/UserScript== // ==========防抖函数============= function pxoDebounce(func, delay) { let timer = null; function _debounce(...arg) { timer && clearTimeout(timer); timer = setTimeout(() => { func.apply(this, arg); timer = null; }, delay); } return _debounce; } class DateUtils { static getCurrDateTimeStr() { let date = new Date(); let year = date.getFullYear(); let month = date.getMonth() + 1; let day = date.getDate(); let hour = date.getHours(); let minutes = date.getMinutes(); let sec = date.getSeconds(); return `${year}${month}${day}${hour}${minutes}${sec}`; } } /* ======================================= IndexedDB 开始 ======================================= */ class MyIndexedDB { request; db; dbName; dbVersion; store; constructor(dbName, dbVersion, store) { this.dbName = dbName; this.dbVersion = dbVersion; this.store = store; } // 直接 await MyIndexedDB.create(xxxx) 获取实例对象 static async create(dbName, dbVersion, store) { const obj = new MyIndexedDB(dbName, dbVersion, store); obj.db = await obj.getConnection(); return obj; } // 通过 new MyIndexedDB(xxx) 获取实例对象后,还需要 await initDB() 一下 async initDB() { return new Promise((resolve, rej) => { this.getConnection().then((res) => { this.db = res; resolve(this); }); }); } // 控制台打印错误 consoleError(msg) { console.log(`[myIndexedDB]: ${msg}`); } // 获取连接;直接挂到 this.db 上 // 需要注意,第一次的话,会初始化好 db、 store。但是之后就不会初始化 store,需要判断获取 getConnection = async () => { return new Promise((resolve, rej) => { // console.log("连接到数据库: "+`--${this.dbName}-- --${this.dbVersion}--`) // 打开数据库,没有则新建 this.request = indexedDB.open(this.dbName, this.dbVersion); this.request.onerror = (e) => { console.error( `连接 ${this.dbName} [IndexedDB] 失败. version: [${this.dbVersion}]`, e ); }; this.request.onupgradeneeded = async (event) => { const db = event.target.result; await this.createAndInitStore( db, this.store.conf.storeName, this.store.data, this.store.conf.uniqueIndex, this.store.conf.normalIndex ); // await this.createAndInitStore(db); resolve(db); }; this.request.onsuccess = (e) => { const db = e.target.result; resolve(db); }; }); }; // 创建存储桶并初始化数据,默认是自增id async createAndInitStore( db = this.db, storeName = "", datas = [], uniqueIndex = [], normalIndex = [] ) { if (!storeName || !datas) return; return new Promise((resolve, rej) => { // 自增id const store = db.createObjectStore(storeName, { keyPath: "id", autoIncrement: true, }); // 设置两类索引 uniqueIndex.forEach((item) => { store.createIndex(item, item, { unique: true }); }); normalIndex.forEach((item) => { store.createIndex(item, item, { unique: false }); }); // 初始填充数据 store.transaction.oncomplete = (e) => { const rwStore = this.getCustomRWstore(storeName, db); datas.forEach((item) => { rwStore.add(item); }); resolve(0); }; }); } // 获取所有数据 async getAllDatas() { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); const req = rwStore.getAll(); req.onsuccess = (e) => { resolve(req?.result); }; }); } // 添加一条数据 async addOne(item) { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); const req = rwStore.add(item); req.onsuccess = () => { resolve(true); }; req.onerror = () => { rej(false); }; }); } // 根据uid获取一条数据 async getOne(id = 0) { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); const req = rwStore.get(id); req.onsuccess = () => { resolve(req.result); }; }); } // 查询一条数据, 字段column包含value子串 async queryOneLike(column, value) { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); rwStore.openCursor().onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const item = { ...cursor.value }; if (item[column] && item[column].indexOf(value) > -1) { item.id = cursor.key; resolve(item); } cursor.continue(); } else { resolve(false); } }; }); } // 查询一条数据, 字段column等于value async queryOneEq(column, value) { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); rwStore.openCursor().onsuccess = (event) => { const cursor = event.target.result; if (cursor) { const item = { ...cursor.value }; if (item[column] == value) { item.id = cursor.key; resolve(item); } cursor.continue(); } else { resolve(false); } }; }); } // 更新一条数据 async updateOne(item) { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); const req = rwStore.put(item); req.onsuccess = () => { resolve(true); }; req.onerror = (e) => { console.log(req); console.log(e); rej(false); }; }); } // 删除一条数据 async delOne(id) { return new Promise((resolve, rej) => { const rwStore = this.getCustomRWstore(); const req = rwStore.delete(id); req.onsuccess = () => { resolve(true); }; req.onerror = (e) => { rej(false); }; }); } // 获取读写权限的存储桶 store。默认是this上挂的storename getCustomRWstore(storeName = this.store.conf.storeName, db = this.db) { return db.transaction(storeName, "readwrite").objectStore(storeName); } // 状态值为 done 时表示连接上了。db挂到了this上 requestState() { return this.request.readyState; } isReady() { return this.request.readyState == "done"; } // 关闭数据库链接 closeDB() { this.db && this.db.close(); } static setDBVersion(version) { localStorage.setItem("pxoxq-dbv", version); } static getDBVersion() { const v = localStorage.getItem("pxoxq-dbv"); return v; } } /* ======================================= IndexedDB 结束 ======================================= */ /* ======================================= 配置数据库表 结束 ======================================= */ class ConfigDB { static simplifyIdx = false; static autoWideMode = false; static playerHeight = 700; static memoMode = 0; static importMode = 0; static Keys = { simplifyIdx: "simplifyIdx", autoWideMode: "autoWideMode", playerHeight: "playerHeight", memoMode: "memoMode", importMode: "importMode", }; static dbConfig = { DB_NAME: "bilibili_pxo", DB_V: MyIndexedDB.getDBVersion() ?? 2, store: { conf: { storeName: "conf", }, }, }; static async connnectDB(func) { const myDb = await MyIndexedDB.create( this.dbConfig.DB_NAME, this.dbConfig.DB_V, this.dbConfig.store ); const result = await func(myDb); myDb.closeDB(); return result; } static async getConf() { const res = await this.connnectDB(async (db) => { const rrr = db.getOne("bconf"); return rrr; }); return res; } static async updateConf(conf) { const res = await this.connnectDB(async (db) => { const rrr = await db.updateOne(conf); return rrr; }); return res; } static async updateOne(key, val) { const res = await this.connnectDB(async (db) => { const config = await this.getConf(); config[key] = val; const rrr = db.updateOne(config); return rrr; }); return res; } static async updateSimplifyIdx(val) { return await this.updateOne(this.Keys.simplifyIdx, val); } static async updateAutoWideMode(val) { return await this.updateOne(this.Keys.autoWideMode, val); } static async updatePlayerHeight(val) { return await this.updateOne(this.Keys.playerHeight, val); } static async updateMemoMode(val) { return await this.updateOne(this.Keys.memoMode, val); } static async updateImportMode(val) { return await this.updateOne(this.Keys.importMode, val); } } /* ======================================= 配置数据库表 结束 ======================================= */ /*========================================= 哔站昵称功能对IndexedDB 进行的封装 开始 ==========================================*/ class BilibiliMemoDB { static dbConfig = { DB_NAME: "bilibili_pxo", DB_V: MyIndexedDB.getDBVersion() ?? 2, store: { conf: { storeName: "my_friends", }, }, }; static async connectDB(func) { const db = await MyIndexedDB.create( this.dbConfig.DB_NAME, this.dbConfig.DB_V, this.dbConfig.store ); const result = await func(db); db.closeDB(); return result; } static async addOne(uid) { const res = await this.connectDB(async (db) => { const rrr = await db.addOne(uid); return rrr; }); return res; } static async getOne(uid) { const res = await this.connectDB(async (db) => { const rrr = await db.getOne(uid); return rrr; }); return res; } static async queryEq(column, value) { const res = await this.connectDB(async (db) => { const rrr = await db.queryOneEq(column, value); return rrr; }); return res; } static async queryLike(column, value) { const res = await this.connectDB(async (db) => { const rrr = await db.queryOneLike(column, value); return rrr; }); return res; } static async getOneByBid(bid) { const res = await this.queryEq("bid", bid); return res; } static async getAll() { const res = await this.connectDB(async (db) => { const rrr = await db.getAllDatas(); return rrr; }); return res; } static async updateByIdAndMemo(id, memo) { const item = await this.getOne(id); item.nick_name = memo; const res = await this.updateOne(item); return res; } static async addOrUpdateMany(datas, ignore_mode = true) { for (const data of datas) { const _item = await this.getOneByBid(data.bid); if (_item) { if (!ignore_mode) { _item.nick_name = data.nick_name; _item.bname = data.bname; await this.updateOne(_item); } } else { if (!data.bid) continue; else { const _itm = { bid: data.bid, bname: data.bname, nick_name: data.nick_name, }; await this.addOne(_itm); } } } return 1; } static async updateOne(item) { const res = await this.connectDB(async (db) => { const rrr = await db.updateOne(item); return rrr; }); return res; } static async delByBid(bid) { const _item = this.getOneByBid(bid); if (_item) { return await this.delOne(_item.id); } else { return false; } } static async delOne(id) { const res = await this.connectDB(async (db) => { const rrr = await db.delOne(id); return rrr; }); } } /*========================================= 哔站昵称功能对IndexedDB 进行的封装 结束 ==========================================*/ /* ======================================= 所有数据库表初始化 开始 ======================================= */ class DBInit { static dbName = "bilibili_pxo"; static dbV = "1"; static storeList = [ { name: "B站备注表", conf: { uniqueIndex: ["bid"], normalIndex: ["nick_name"], DB_NAME: "bilibili_pxo", storeName: "my_friends", }, data: [], }, { name: "配置项表", conf: { DB_NAME: "bilibili_pxo", storeName: "conf", }, data: [ { id: "bconf", simplifyIdx: false, autoWideMode: false, playerHeight: 700, memoMode: 0, importMode: 0, }, ], }, ]; static async initAllDB() { for (let idx = 0; idx < this.storeList.length; idx++) { const myDb = await MyIndexedDB.create( this.dbName, idx * 1 + 1, this.storeList[idx] ); MyIndexedDB.setDBVersion(idx * 1 + 1); setTimeout(() => { myDb.closeDB(); }, 100); } } } /* ======================================= 所有数据库表初始化 结束 ======================================= */ /* ======================================= 菜单UI部分 结束 ======================================= */ class BMenu { static menuStyle = ` @media (max-width: 1190px){ div#pxoxq-b-menu .pxoxq-menu-wrap{ display: block; overflow-y: scroll; scrollbar-width: thin; height: 340px; } #pxoxq-b-menu .pxoxq-menu-wrap::-webkit-scrollbar{ width: 5px; } #pxoxq-b-menu .pxoxq-menu-wrap::-webkit-scrollbar-thumb{ background-color: #FC6296; border-radius: 6px; } } /* 菜单最外层 */ #pxoxq-b-menu{ text-align: initial; font-size: 15px; z-index: 999; position: fixed; left: 0; right: 0; bottom: 0px; height: 340px; padding: 8px 10px; background-color: white; transition: all .24s linear; border-top: 1px solid #c3c3c3; } #pxoxq-b-menu.pxoxq-hide{ padding: unset; height: 0; } #pxoxq-b-menu button{ background-color: #FC6296; border: 1px solid white; color: white; font-size: 13px; padding: 1px 6px; border-radius: 5px; } #pxoxq-b-menu button:hover{ border: 1px solid #c5c5c5; } #pxoxq-b-menu button:active{ opacity: .7; } #pxoxq-b-menu .pxoxq-tag{ position: absolute; width: 24px; text-align: center; color: white; padding: 0px 6px; left: 2px; top: -21px; background-color: #FC6296; border-radius: 4px 4px 0 0; user-select: none; transition: all .3s linear; } #pxoxq-b-menu .pxoxq-tag:hover{ letter-spacing: 3px; } #pxoxq-b-menu .pxoxq-tag:active{ opacity: .5; } #pxoxq-b-menu .pxoxq-menu-wrap{ display: flex; } #pxoxq-b-menu .pxoxq-menu-col { height: 340px; min-height: 340px; overflow-y: scroll; scrollbar-width: thin; } #pxoxq-b-menu .pxoxq-menu-col::-webkit-scrollbar{ width: 5px; } #pxoxq-b-menu .pxoxq-menu-col::-webkit-scrollbar-thumb{ background-color: #FC6296; border-radius: 6px; } #pxoxq-b-menu .pxoxq-menu-wrap .pxoxq-setting-wrap{ flex-grow: 1; } #pxoxq-b-menu .setting-row:not(.import-row) { padding: 4px 0; display: flex; gap: 3px; } #pxoxq-b-menu .setting-row .pxoxq-label{ font-weight: 600; color: rgb(100, 100, 100); } #pxoxq-b-menu .pxoxq-setting-wrap .setting-box{ display: flex; gap: 22px; } #pxoxq-b-menu .setting-row .pxoxq-inline-label{ display: inline-block; margin-right: 20px; } #pxoxq-player-h{ width: 300px; } #pxoxq-b-menu .setting-row.memo-mode-row{ display: flex; padding-bottom: 10px; } #pxoxq-b-menu .setting-item-import{ display: flex; margin-bottom: 10px; } #pxoxq-b-menu .frd-import-btn{ margin-left: 40px; } /* 右边部分 */ #pxoxq-b-menu .pxoxq-menu-wrap .pxoxq-frd-wrap{ border-left: 1px solid #d1d1d1; padding-left: 10px; } #pxoxq-b-menu .pxoxq-right-header{ display: flex; padding-bottom: 6px; margin-bottom: 5px; border-bottom: 1px dotted #b2b2b2; } #pxoxq-b-menu .pxoxq-right-header .pxoxq-right-title{ font-size: 18px; flex-grow: 1; text-align: center; font-weight: 600; color: #4b4b4b; } /* 右边表格部分 */ #pxoxq-b-menu .pxoxq-frd-tab{ white-space: nowrap; height: 340px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-tbody{ height: 280px; overflow-y: scroll; scrollbar-width: thin; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-tbody::-webkit-scrollbar{ width: 4px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-tbody::-webkit-scrollbar-thumb{ background-color: #FC6296; border-radius: 5px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-thead{ font-weight: 600; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-tr{ border-bottom: 1px solid #dadada; /* text-align: center; */ } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-tr .pxoxq-cell{ display: inline-block; text-align: center; font-size: 14px; padding: 2px 3px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-col-1{ width: 30px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-col-2{ width: 120px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-col-3{ width: 120px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-col-4{ width: 180px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-col-5{ width: 100px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-memo-ipt{ outline: none; border: unset; text-align: center; padding: 2px 3px; } #pxoxq-b-menu .pxoxq-frd-tab .pxoxq-memo-ipt.active{ border-bottom: 1px solid #ffb3e3; color:#FC6296; } `; static wrapId = "pxoxq-b-menu"; static saveDelay = 200; static importJson = ""; static init() { this.injectMemuHtml(); this.injectStyle(); } static injectMemuHtml() { // 参数初始化 const wrap = $("#pxoxq-b-menu"); ConfigDB.getConf().then(async (_conf) => { const friendTab = await this.genFriendTab(); const leftMenu = ` <h3>备注模块设置</h3> <div class="setting-row memo-mode-row"> <div class="pxoxq-label pxoxq-inline-label">备注显示模式</div> <div class="pxoxq-radio-item"> <input class="pxoxq-memo-mode" ${ _conf.memoMode == 0 ? "checked" : "" } value="0" type="radio" name="memo-mode" id="nope"> <label for="nope">关闭备注功能</label> </div> <div class="pxoxq-radio-item"> <input class="pxoxq-memo-mode" ${ _conf.memoMode == 1 ? "checked" : "" } value="1" type="radio" name="memo-mode" id="nick-first"> <label for="nick-first">昵称(备注)</label> </div> <div class="pxoxq-radio-item"> <input class="pxoxq-memo-mode" ${ _conf.memoMode == 2 ? "checked" : "" } value="2" type="radio" name="memo-mode" id="memo-first"> <label for="memo-first">备注(昵称)</label> </div> <div class="pxoxq-radio-item"> <input class="pxoxq-memo-mode" ${ _conf.memoMode == 3 ? "checked" : "" } value="3" type="radio" name="memo-mode" id="just-memo"> <label for="just-memo">备注</label> </div> </div> <div class="setting-row import-row"> <div class="pxoxq-setting-item setting-item-import"> <div class="pxoxq-label pxoxq-inline-label">导入数据</div> <input class="pxoxq-import-mode" ${ _conf.importMode == 0 ? "checked" : "" } id="ignore-same" value="0" type="radio" checked name="import-mode"> <label for="ignore-same">跳过重复项</label> <input class="pxoxq-import-mode" ${ _conf.importMode == 1 ? "checked" : "" } id="update-same" value="1" type="radio" name="import-mode"> <label for="update-same">覆盖重复项</label> <button class="frd-import-btn" type="button">导入</button> </div> <div class="pxoxq-setting-item"> <textarea placeholder="请输入数据..." name="pxoxq-frd-json" id="pxoxq-frd-json" cols="80" rows="10"></textarea> </div> </div> `; if (wrap && wrap.length > 0) { this.flushConfTab(); this.flushFrdTab(); } else { const _html = ` <div id="pxoxq-b-menu" class="pxoxq-hide"> <div class="pxoxq-tag">:)</div> <div class="pxoxq-menu-wrap"> <div class="pxoxq-menu-col pxoxq-setting-wrap"> ${leftMenu} </div> <div class="pxoxq-frd-wrap"> <div class="pxoxq-right-header"> <div class="pxoxq-right-title">昵称数据</div> <button class="pxoxq-export-frd-btn" type="button">导出当前数据</button> </div> <div class="pxoxq-tab-wrap"> <div class="pxoxq-frd-tab"> <div class="pxoxq-tr pxoxq-thead"> <div class="pxoxq-cell pxoxq-col-1">ID</div> <div class="pxoxq-cell pxoxq-col-2">BilibiliID</div> <div class="pxoxq-cell pxoxq-col-3">昵称</div> <div class="pxoxq-cell pxoxq-col-4">备注</div> <div class="pxoxq-cell pxoxq-col-5">操作</div> </div> <div class="pxoxq-tbody"> ${friendTab} </div> </div> </div> </div> </div> </div> `; $("body").append(_html); this.addListener(); } }); } static async genFriendTab() { const friends = await BilibiliMemoDB.getAll(); let _html = ""; for (const friend of friends) { _html += ` <div class="pxoxq-tr pxoxq-frd-row pxoxq-frd-${friend.id}"> <div class="pxoxq-cell pxoxq-col-1">${friend.id}</div> <div class="pxoxq-cell pxoxq-col-2" title="${friend.bid}">${friend.bid}</div> <div class="pxoxq-cell pxoxq-col-3">${friend.bname}</div> <div class="pxoxq-cell pxoxq-col-4"> <input class="pxoxq-memo-ipt pxoxq-memo-ipt-${friend.id}" data-id="${friend.id}" type="text" value="${friend.nick_name}" readonly> </div> <div class="pxoxq-cell pxoxq-col-5"> <button class="pxoxq-memo-edit-btn pxoxq-memo-edit-btn-${friend.id}" data-id="${friend.id}" type="button">编辑</button> <button class="pxoxq-memo-del-btn" data-id="${friend.id}" type="button">删除</button> </div> </div> `; } return _html; } static flushFrdTab() { this.genFriendTab().then((_tabHtml) => { $("#pxoxq-b-menu .pxoxq-frd-tab .pxoxq-tbody").html(_tabHtml); }); } static flushConfTab() { ConfigDB.getConf().then((_conf) => { const mmRadios = $(".pxoxq-memo-mode"); for (const item of mmRadios) { if (item.value == _conf.memoMode) { item.checked = true; } else { item.checked = false; } } const modeRadios = $(".pxoxq-import-mode"); for (const item of modeRadios) { if (item.value == _conf.memoMode) { item.checked = true; } else { item.checked = false; } } }); } static injectStyle() { GM_addStyle(this.menuStyle); } static addListener() { const wrapIdSelector = `#${this.wrapId}`; // 面板展开、折叠 $("body").on( "click", wrapIdSelector + " .pxoxq-tag", pxoDebounce(this.toggleMenuHandler, this.saveDelay) ); // 备注模式选框 $("body").on( "click", ".pxoxq-memo-mode", pxoDebounce(this.memoModeHandler, this.saveDelay) ); // 导入数据模式 $("body").on( "click", ".pxoxq-import-mode", pxoDebounce(this.importModeHandler, this.saveDelay) ); // 导入数据 $("body").on("click", ".frd-import-btn", this.importFriendHandler); // 导出数据 $("body").on( "click", ".pxoxq-export-frd-btn", pxoDebounce(this.exportFrdHandler, this.saveDelay * 2) ); // 双击比编辑 $("body").on("dblclick", "input.pxoxq-memo-ipt", this.editMemoHandler); // 编辑按钮编辑 $("body").on( "click", ".pxoxq-memo-edit-btn", pxoDebounce(this.editMemoHandler, this.saveDelay) ); // 保存昵称(更新 $("body").on( "click", ".pxoxq-memo-save-btn", pxoDebounce(this.updateMemoHandler, this.saveDelay) ); // 删除备注 $("body").on( "click", ".pxoxq-memo-del-btn", pxoDebounce(this.delMemoHandler, this.saveDelay) ); } // 折叠、打开面板 static toggleMenuHandler() { $("#pxoxq-b-menu").toggleClass("pxoxq-hide"); // 刷新面板数据 if ( document .getElementById("pxoxq-b-menu") .classList.value.indexOf("pxoxq-hide") < 0 ) { BMenu.flushConfTab(); BMenu.flushFrdTab(); } else { } } static delMemoHandler() { const id = parseInt(this.dataset.id); const memo = $(".pxoxq-memo-ipt-" + id).val(); if (confirm(`是否要删除备注【${memo}】?`)) { BilibiliMemoDB.delOne(id); $(".pxoxq-frd-tab .pxoxq-frd-" + id).remove(); } } static updateMemoHandler() { const id = this.dataset.id; let editBtn = $(".pxoxq-memo-edit-btn-" + id); const memoInput = $(".pxoxq-memo-ipt-" + id); // 都需编辑按钮复原 $(editBtn).text("编辑"); $(editBtn).removeClass("pxoxq-memo-save-btn"); memoInput[0].readOnly = true; $(memoInput).removeClass("active"); const val = memoInput[0].value; BilibiliMemoDB.updateByIdAndMemo(parseInt(id), val); } static editMemoHandler() { const id = this.dataset.id; // pxoxq-memo-ipt-2 let editBtn = $(".pxoxq-memo-edit-btn-" + id); const memoInput = $(".pxoxq-memo-ipt-" + id); if (!memoInput[0].readOnly) { return; } // 都需要给编辑按钮变个东西 $(editBtn).text("保存"); $(editBtn).addClass("pxoxq-memo-save-btn"); memoInput[0].readOnly = false; $(memoInput).addClass("active"); } // 导出数据 static exportFrdHandler() { BilibiliMemoDB.getAll().then((_datas) => { const json_str = JSON.stringify(_datas); const dataURI = "data:text/plain;charset=utf-8," + encodeURIComponent(json_str); const link = document.createElement("a"); link.href = dataURI; link.download = `${DateUtils.getCurrDateTimeStr()}.txt`; link.click(); }); } // 导入数据 static importFriendHandler() { const textNode = $("#pxoxq-frd-json"); const val = $(textNode).val(); if (!/\S+/.test(val)) return; ConfigDB.getConf().then(async (_conf) => { try { const datas = JSON.parse(val); if (Array.isArray(datas)) { const ignore_mode = _conf.importMode == 1 ? false : true; await BilibiliMemoDB.addOrUpdateMany(datas, ignore_mode); BMenu.flushFrdTab(); alert("导入成功"); } else { throw Error("数据格式错误!"); } } catch (e) { alert("导入失败:" + e); } }); } static importModeHandler() { ConfigDB.updateImportMode(this.value); } static memoModeHandler() { MemoGlobalConf.mode = this.value; ConfigDB.updateMemoMode(this.value); } } /* ======================================= 菜单UI部分 结束 ======================================= */ /*............................................................................................ Memo部分 开始 ............................................................................................*/ /* ============================================= 一些配置参数 开始 =============================================*/ const memoClassPrefix = "pxo-memo"; const MemoGlobalConf = { mode: 1, // 【模式】 0:昵称替换成备注; 1:昵称(备注); 2:(备注)昵称 myFriends: [], // 好友信息列表 memoClassPrefix, fansInputBlurDelay: 280, // 输入框防抖延迟 fansInputBlurTimer: "", fansLoopTimer: "", memoStyle: ` .content .be-pager li{ z-index: 999; position: relative; } .pxo-frd{ color: #3fb9ffd4; font-weight:600; letter-spacing: 2px; border: 1px solid #ff88a973; border-radius: 6px; background: #ffa9c1a4; margin-top:-2px; padding: 2px 5px;} .h #h-name { background: #ffffffbd; padding: 5px 10px; border-radius: 6px; letter-spacing: 3px; line-height: 22px; font-size: 20px; box-shadow: 1px 1px 2px 2px #ffffff40; border: 1px solid #fff; color: #e87b99; overflow: hidden; transition:all .53s linear; } .h #h-name.hide{ width:0px; padding:0px; height:0px; border:none; } .h .homepage-memo-input{ border: none; outline:none; overflow:hidden; padding: 5px 6px; border-bottom:2px solid #ff0808; width: 230px; font-size: 17px; line-height: 22px; vertical-align: middle; background: #ffffffbd;; color: #f74979; font-weight:600; margin-right: 8px; transition:all .53s linear; border-radius: 5px 5px 0 0; } .h .homepage-memo-input.hide{ width: 0px; padding: 0; border:none; } .${memoClassPrefix}-setting-box{ display: inline-block; vertical-align:top; margin-top:-2px; line-height:20px; margin-left:18px; } .${memoClassPrefix}-setting-box div.btn{ padding:2px 5px; user-select:none; display:inline-block; overflow: hidden; letter-spacing:2px; background:#e87b99cc; border:none; border-radius:5px; color:white; margin:0 3px; transition:all .53s linear; } .${memoClassPrefix}-setting-box div.btn.hide{ height: 0px; width: 0px; opacity: 0.2; padding:0px; } .${memoClassPrefix}-setting-box div.btn:hover{ box-shadow: 1px 1px 2px 1px #80808024; outline: .5px solid #e87b99fc; } .${memoClassPrefix}-setting-box input{ border: none; outline:none; overflow:hidden; padding: 2px 3px; border-bottom:1px solid #c0c0c0; width: 190px; font-size: 16px; line-height: 18px; color: #ff739a; font-weight:600; vertical-align:top; transition:all .25s linear; } .${memoClassPrefix}-setting-box input.hide{ width:0px; padding:0px; } `, }; /* ============================================= 一些配置参数 结束 =============================================*/ /* ============================================= 定制日志输出 开始 =============================================*/ class MyLog { static prefix = "[BilibiliMemo]"; static genMsg(msg, type = "") { return `${this.prefix} ${type}: ${msg}`; } static error(msg) { console.error(this.genMsg(msg, "error")); } static warn(msg) { console.warn(this.genMsg(msg, "warn")); } static success(msg) { console.info(this.genMsg(msg, "success")); } static log(msg, ...arg) { console.log(this.genMsg(msg), ...arg); } } /* ============================================= 定制日志输出 结束 =============================================*/ /* ============================================= html 注入部分 开始 =============================================*/ class BilibiliMemoInjectoin { // 个人主页 替换 以及初始化 static async injectUserHome(bid) { const user = await this.getUserInfoByBid(bid); elmGetter.get("#h-name").then((uname) => { if (!uname) return; let nickName = uname.innerHTML; if (user) { $(uname).html(this.getNameStr(nickName, user.nick_name)); $(uname).attr("data-id", user.id); } $(uname).attr("data-bid", bid); $(uname).attr("data-bname", nickName); // 添加备注模块 const inputNode = `<input data-bname="${nickName}" data-bid="${bid}" class='${MemoGlobalConf.memoClassPrefix}-input hide homepage-memo-input'/>`; $(uname).after(inputNode); }); } // 个人主页 替换 更新 static injectOneHomePage(user) { if (user) { const nickName = $(".h #h-name").attr("data-bname"); $("#h-name").html(this.getNameStr(nickName, user.nick_name)); $("#h-name").attr("data-id", user.id); } } // 个人关注、粉丝页替换 以及初始化 static injectFanList() { elmGetter.each(".relation-list > li > div.content > a", async (user) => { try { let url = user.href; let uid = url.split("/")[3]; const cPrefix = MemoGlobalConf.memoClassPrefix; if (!$(user.children).attr("data-bid")) { const userInfo = await this.getUserInfoByBid(uid); let nickName = $(user.children).html(); $(user.children).attr("data-bname", nickName); $(user.children).attr("data-bid", uid); if (userInfo) { $(user.children).html( this.getNameStr(nickName, userInfo.nick_name) ); $(user.children).attr("data-id", userInfo.id); $(user).addClass("pxo-frd"); $(user).addClass("pxo-frd-" + uid); } // 注入备注模块代码 const memoBlock = `<div class='${cPrefix}-setting-${uid} ${cPrefix}-setting-box'> <input data-bname="${nickName}" data-bid='${uid}' class='${cPrefix}-input-${uid} hide'/> <div data-bid='${uid}' class='${cPrefix}-btn-bz btn bz-btn-${uid}'>备注</div> <div data-bid='${uid}' class='${cPrefix}-btn-cfm op-btn-${uid} btn cfm-btn-${uid} hide'>确认</div> <div data-bid='${uid}' class='${cPrefix}-btn-cancel op-btn-${uid} btn cancel-btn-${uid} hide'>取消</div> <div data-bid='${uid}' class='${cPrefix}-btn-del op-btn-${uid} btn del-btn-${uid} hide'>清除备注</div> </div>`; $(user).after(memoBlock); } } catch (e) { MyLog.error(e); } }); } // 个人关注、粉丝页替换 单个 static injectOneFans(user, userANode) { if (user && userANode) { const nickName = $(userANode.children).attr("data-bname"); $(userANode.children).html(this.getNameStr(nickName, user.nick_name)); $(userANode.children).attr("data-id", user.id); $(userANode).addClass("pxo-frd"); $(userANode).addClass("pxo-frd-" + user.bid); } } static replaceMemo(uri) { /* uri 一共有几种形式: https://space.bilibili.com/28563843/fans/follow https://space.bilibili.com/28563843/fans/follow?tagid=-1 https://space.bilibili.com/28563843/fans/fans https://space.bilibili.com/472118057/?spm_id_from=333.999.0.0 1、换页是页内刷新,需要给页码搞个点击事件 2、个人页形式跟其他不太一样 */ const uType = this.judgeUri(uri); // MyLog.success(`类型是:[${uType}] ${uri}`); switch (uType) { case "-1": MyLog.warn("Uri获取失败"); break; case "+1": //粉丝关注 BilibiliMemoInjectoin.injectFanList(); break; default: // 个人主页 BilibiliMemoInjectoin.injectUserHome(uType); } } static judgeUri(uri) { /* -1 uri为空 +x +1:粉丝、关注 | +* 后续 xxxx 纯数字,个人主页 */ if (!uri) return "-1"; const uri_parts = uri.split("/"); // 0-https 1-'' 2-host 3-bid 4-fans/query 5-fans/follow // 这是 space 域下的处理,之后可能扩展到其他更多页面模块 if (uri_parts[2] && "space.bilibili.com" == uri_parts[2]) { // 粉丝、关注列表 【归一类,处理方式一样】 if ( uri_parts.length > 4 && uri_parts[4] == "fans" && /(?=fans)|(?=follow)/.test(uri_parts[5]) ) { return "+1"; } // 个人主页 else { return uri_parts[3].split("?")[0]; } } return "-1"; } // 根据bid获取用户信息 直接从数据库取吧 static async getUserInfoByBid(bid) { const res = await BilibiliMemoDB.getOneByBid(bid); return res; } // 根据昵称、备注获取最终显示名 static getNameStr(nickName, remark) { // span 标签用于判断是否已经替换过 if (nickName.indexOf("<span>") > 0) { return nickName; } let res = ""; if (MemoGlobalConf.mode == 1) { res = remark; } else if (MemoGlobalConf.mode == 2) { res = nickName + `(${remark})`; } else if (MemoGlobalConf.mode == 3) { res = remark + `(${nickName})`; } return res + "<span>"; } // 注入css样式到头部 static injectCSS(css) { GM_addStyle(css); } } /* ============================================= html 注入部分 结束 =============================================*/ /* ============================================= 通用函数部分 开始 =============================================*/ class BMemoUtils { // 关注、粉丝列表页 备注编辑模块 编辑模式 / 正常模式 static toggleMemoBox(bid, editMode = true) { if (editMode) { $(`.btn.op-btn-${bid}`).removeClass("hide"); $(`.${MemoGlobalConf.memoClassPrefix}-input-${bid}`).removeClass("hide"); $(`.btn.bz-btn-${bid}`).addClass("hide"); } else { $(`.btn.op-btn-${bid}`).addClass("hide"); $(`.${MemoGlobalConf.memoClassPrefix}-input-${bid}`).addClass("hide"); $(`.btn.bz-btn-${bid}`).removeClass("hide"); } } // 个人主页 编辑模式 / 正常模式 static toggleUserHomeEditMode(editMode = true) { if (editMode) { $(".h #h-name").addClass("hide"); $(".homepage-memo-input").removeClass("hide"); } else { $(".h #h-name").removeClass("hide"); $(".homepage-memo-input").addClass("hide"); } } // 个人空间主页 编辑模式初始化 static homePageEditModeHandler(bid) { this.toggleUserHomeEditMode(); const inputNode = $(".homepage-memo-input")[0]; const bName = $(inputNode).attr("data-bname"); $(inputNode).focus(); BilibiliMemoDB.getOneByBid(bid).then((user) => { if (user) { $(inputNode).val(user.nick_name); } else { $(inputNode).val(bName); } }); } // 个人空间主页 编辑确认 static homePageSetMemoHandler(bid) { const inputNode = $(".homepage-memo-input")[0]; const bName = $(inputNode).attr("data-bname"); const val = $(inputNode).val(); const val_reg = /\S.*\S/; if (val && val_reg.test(val)) { const memo = val_reg.exec(val)[0]; BilibiliMemoDB.getOneByBid(bid).then(async (user) => { if (user) { if (memo != user.nick_name) { user.nick_name = memo; user.bname = bName; await BilibiliMemoDB.updateOne(user); BilibiliMemoInjectoin.injectOneHomePage(user); } this.toggleUserHomeEditMode(false); } else { if (memo != bName) { user = { bid, nick_name: memo, bname: bName, }; await BilibiliMemoDB.addOne(user); user = await BilibiliMemoDB.getOneByBid(bid); BilibiliMemoInjectoin.injectOneHomePage(user); } this.toggleUserHomeEditMode(false); } }); } } // 删除备注 static delMemoHandler(bid) { BilibiliMemoDB.getOneByBid(bid).then(async (_item) => { if (_item) { if (confirm(`是否删除备注【${_item.nick_name}】?`)) { await BilibiliMemoDB.delOne(_item.id); $("a.pxo-frd-" + bid).removeClass("pxo-frd"); const nameSpan = $("a.pxo-frd-" + bid + " span.fans-name"); $(nameSpan).text(nameSpan[0].dataset.bname); } } }); } // 粉丝、关注页 编辑模式初始化 static editModeHandler(bid) { const inputNode = $(`.${MemoGlobalConf.memoClassPrefix}-input-${bid}`)[0]; BilibiliMemoDB.getOneByBid(bid).then((user) => { const val = $(inputNode).val(); if (!/\S+/.test(val)) { if (user) { $(inputNode).val(user.nick_name); } else { $(inputNode).val($(inputNode).attr("data-bname")); } } }); this.toggleMemoBox(bid); $(inputNode).focus(); } // 粉丝、关注页编辑确认 static setMemoHandler(bid) { const inputNode = $(`.${MemoGlobalConf.memoClassPrefix}-input-${bid}`)[0]; const val = $(inputNode).val(); const val_reg = /\S.*\S/; const bName = $(inputNode).attr("data-bname"); if (val_reg.test(val)) { const memo = val_reg.exec(val)[0]; const userANode = $(inputNode).parent().siblings("a")[0]; BilibiliMemoInjectoin.getUserInfoByBid(bid).then(async (user) => { if (user) { if (user.nick_name != memo) { user.nick_name = memo; user.bname = bName; await BilibiliMemoDB.updateOne(user); BilibiliMemoInjectoin.injectOneFans(user, userANode); } this.toggleMemoBox(bid, false); } else { if (memo != bName) { user = { bid, nick_name: memo, bname: bName, }; await BilibiliMemoDB.addOne(user); user = await BilibiliMemoDB.getOneByBid(bid); BilibiliMemoInjectoin.injectOneFans(user, userANode); } this.toggleMemoBox(bid, false); } }); } } } /* ============================================= 通用函数部分 结束 =============================================*/ /*-----------------初始化 开始-----------------*/ async function BilibiliMemoInit() { // 注入样式 BilibiliMemoInjectoin.injectCSS(MemoGlobalConf.memoStyle); // 个人主页双击修改事件 $("body").on("dblclick", `.h #h-name`, function (event) { const bid = event.currentTarget.dataset.bid; BMemoUtils.homePageEditModeHandler(bid); }); // 个人主页搜索框失去焦点事件 $("body").on("focusout", ".homepage-memo-input", function (event) { const bid = event.currentTarget.dataset.bid; BMemoUtils.homePageSetMemoHandler(bid); }); // 粉丝、关注页 备注按钮点击事件: $("body").on( "click", `.${MemoGlobalConf.memoClassPrefix}-setting-box div.${MemoGlobalConf.memoClassPrefix}-btn-bz`, function (event) { const bid = event.currentTarget.dataset.bid; BMemoUtils.editModeHandler(bid); } ); // 删除备注按钮点击事件 $("body").on( "click", `.${MemoGlobalConf.memoClassPrefix}-setting-box div.${MemoGlobalConf.memoClassPrefix}-btn-del`, function (event) { const bid = event.currentTarget.dataset.bid; BMemoUtils.delMemoHandler(bid); } ); // 粉丝、关注页确认按钮点击事件 $("body").on( "click", `.${MemoGlobalConf.memoClassPrefix}-setting-box .${MemoGlobalConf.memoClassPrefix}-btn-cfm`, function (event) { clearTimeout(MemoGlobalConf.fansInputBlurTimer); const bid = event.currentTarget.dataset.bid; BMemoUtils.setMemoHandler(bid); } ); // 粉丝、关注页取消按钮点击事件 $("body").on( "click", `.${MemoGlobalConf.memoClassPrefix}-setting-box .${MemoGlobalConf.memoClassPrefix}-btn-cancel`, function (event) { clearTimeout(MemoGlobalConf.fansInputBlurTimer); const bid = event.currentTarget.dataset.bid; BMemoUtils.toggleMemoBox(bid, false); } ); // 粉丝、关注页输入框市区焦点事件 $("body").on( "focusout", `.${MemoGlobalConf.memoClassPrefix}-setting-box input`, function (event) { clearTimeout(MemoGlobalConf.fansInputBlurTimer); MemoGlobalConf.fansInputBlurTimer = setTimeout(() => { const bid = event.currentTarget.dataset.bid; BMemoUtils.toggleMemoBox(bid, false); }, MemoGlobalConf.fansInputBlurDelay); } ); } /*-----------------初始化 结束-----------------*/ /*........................................................................................................................................ Memo部分 结束 ........................................................................................................................................*/ async function flushConf() { const _conf = await ConfigDB.getConf(); MemoGlobalConf.mode = _conf.memoMode; return true; } /*+++++++++++++++++++++++++++++++++++++ 主程序初始化 开始 +++++++++++++++++++++++++++++++++++++*/ async function bilibiliCustomInit() { if (!MyIndexedDB.getDBVersion()) { await DBInit.initAllDB(); } // 从数据库获取数据,刷新配置参数 await flushConf(); BMenu.init(); if (MemoGlobalConf.mode == 0) return; const uri = window.location.href; BilibiliMemoInit().then((r) => { BilibiliMemoInjectoin.replaceMemo(uri); }); } /*+++++++++++++++++++++++++++++++++++++ 主程序初始化 结束 +++++++++++++++++++++++++++++++++++++*/ (function () { bilibiliCustomInit().then((res) => { console.log("init over"); }); })();