// ==UserScript== // @name B站一键备注 // @namespace https://bbs.tampermonkey.net.cn/ // @homepage https://scriptcat.org/zh-CN/script-show-page/3059 // @version 1.0.3 // @license AGPL-3.0-or-later // @description Bilibili一键备注 | B站备注功能 // @author pxoxq // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @grant GM_deleteValue // @match https://*.bilibili.com/** // @match https://www.bilibili.com/video/** // @match https://message.bilibili.com/** // @run-at document-idle // ==/UserScript== const BPINK = '#FB7299' const HL_COLOR = '#FB7299' const HL_CLS = 'pxoxq-memo-hl' const GRN = '#42BB85' const LGRN = '#12ef03' const BBLUE = '#00AEEC' const ORANGE = '#F85F59' const EDT_BTN_CLS = 'pxoxq-memo-edit-btn' const EDT_IPT_CLS = 'pxoxq-memo-edit-input' const SPACE_UPNAME_CLS = 'pxoxq-space-upname' const MAX_TIMEOUT = 5 const HELP_LINK = 'https://scriptcat.org/zh-CN/script-show-page/3059' const CONF_KEY = { importM: 'importM', memoM: 'memoM', } const IMPORT_MODES = { IGNOER: 0, OVERWRITE: 1, MERGE: 2, } const CONF_INIT = { importM: 0, memoM: 1, } const INLINE_HL = { color: 'transparent', background: 'linear-gradient(64deg, #1493ff, #ff00af,rgb(207, 117, 0),rgb(0, 209, 35),rgb(157, 11, 255), blue,rgb(253, 106, 22), purple)', backgroundClip: 'text', } const CM_STYLE = ` .${HL_CLS} { color: transparent !important; background: linear-gradient(64deg, #1493ff, #ff00af,rgb(207, 117, 0),rgb(0, 209, 35),rgb(157, 11, 255), blue,rgb(253, 106, 22), purple) !important; background-clip: text !important; } .pxoxq-memo-edit-btn{ background: transparent; border: none; outline: none; color:${BPINK}; margin-left: 3px; } .pxoxq-memo-edit-btn:hover{ color:#9200fd; } .pxoxq-memo-edit-input{ border: none; outline: none; padding:unset; color: ${BPINK}; border-bottom: 1px solid ${BPINK}; } .pxoxq-space-upname::after{ content: ''; position: absolute; inset: 0; z-index: -1; border-radius: 5px 5px 0 0; background:rgba(255, 255, 255, 0.75);} ` async function asyncGetNodeOnce(selector, root, callback){ root = root || document return new Promise((resolve, reject) => { elmGetter.each(selector, root, node => { if(callback) callback(node) resolve(node) return false }) }) } function pxolog(msg, title='pxoxq'){ if(typeof(msg) == 'string' || typeof(msg) == 'number'){ console.log(`%c${title}: %c${msg}`, 'background:#000;color:white;', `color:${BPINK};`) }else{ console.log(`%c${title}: %c`, 'background:#000;color:white;', `color:${BPINK};`, msg) } } function isSameNode(a, b, excludeAtts=[]){ const aAtts = Array.from(a.attributes).filter(aa=>!excludeAtts.includes(aa.name)) const bAtts = Array.from(b.attributes).filter(bb=>!excludeAtts.includes(bb.name)) if(!excludeAtts.includes('innerText') && a.innerText !== b.innerText){ return false } if(!excludeAtts.includes('innerHTML') && a.innerHTML !== b.innerHTML){ return false } if(aAtts.length !== bAtts.length){ return false } for(let i=0; i!isSameNode(node, item, this.excludeAtts)) this.mp.delete(node) } } _removeSame(){ this._set = this._set.filter((item, idx)=> { const i = this._set.indexOf(item) return i === idx }) this._set.forEach(item => { this.mp.set(item, item.cloneNode(true)) }) } } var elmGetter = function() { const win = window.unsafeWindow || document.defaultView || window; const doc = win.document; const listeners = new WeakMap(); let mode = 'css'; let $; const elProto = win.Element.prototype; const matches = elProto.matches || elProto.matchesSelector || elProto.webkitMatchesSelector || elProto.mozMatchesSelector || elProto.oMatchesSelector; const MutationObs = win.MutationObserver || win.WebkitMutationObserver || win.MozMutationObserver; function addObserver(target, callback) { const observer = new MutationObs(mutations => { for (const mutation of mutations) { if (mutation.type === 'attributes') { callback(mutation.target); if (observer.canceled) return; } for (const node of mutation.addedNodes) { if (node instanceof Element) callback(node); if (observer.canceled) return; } } }); observer.canceled = false; observer.observe(target, {childList: true, subtree: true, attributes: true}); return () => { observer.canceled = true; observer.disconnect(); }; } function addFilter(target, filter) { let listener = listeners.get(target); if (!listener) { listener = { filters: new Set(), remove: addObserver(target, el => listener.filters.forEach(f => f(el))) }; listeners.set(target, listener); } listener.filters.add(filter); } function removeFilter(target, filter) { const listener = listeners.get(target); if (!listener) return; listener.filters.delete(filter); if (!listener.filters.size) { listener.remove(); listeners.delete(target); } } function query(all, selector, parent, includeParent, curMode) { switch (curMode) { case 'css': const checkParent = includeParent && matches.call(parent, selector); if (all) { const queryAll = parent.querySelectorAll(selector); return checkParent ? [parent, ...queryAll] : [...queryAll]; } return checkParent ? parent : parent.querySelector(selector); case 'jquery': let jNodes = $(includeParent ? parent : []); jNodes = jNodes.add([...parent.querySelectorAll('*')]).filter(selector); if (all) return $.map(jNodes, el => $(el)); return jNodes.length ? $(jNodes.get(0)) : null; case 'xpath': const ownerDoc = parent.ownerDocument || parent; selector += '/self::*'; if (all) { const xPathResult = ownerDoc.evaluate(selector, parent, null, 7, null); const result = []; for (let i = 0; i < xPathResult.snapshotLength; i++) { result.push(xPathResult.snapshotItem(i)); } return result; } return ownerDoc.evaluate(selector, parent, null, 9, null).singleNodeValue; } } function isJquery(jq) { return jq && jq.fn && typeof jq.fn.jquery === 'string'; } function getOne(selector, parent, timeout) { const curMode = mode; return new Promise(resolve => { const node = query(false, selector, parent, false, curMode); if (node) return resolve(node); let timer; const filter = el => { const node = query(false, selector, el, true, curMode); if (node) { removeFilter(parent, filter); timer && clearTimeout(timer); resolve(node); } }; addFilter(parent, filter); if (timeout > 0) { timer = setTimeout(() => { removeFilter(parent, filter); resolve(null); }, timeout); } }); } return { get currentSelector() { return mode; }, get(selector, ...args) { let parent = typeof args[0] !== 'number' && args.shift() || doc; if (mode === 'jquery' && parent instanceof $) parent = parent.get(0); const timeout = args[0] || 0; if (Array.isArray(selector)) { return Promise.all(selector.map(s => getOne(s, parent, timeout))); } return getOne(selector, parent, timeout); }, each(selector, ...args) { let parent = typeof args[0] !== 'function' && args.shift() || doc; if (mode === 'jquery' && parent instanceof $) parent = parent.get(0); const callback = args[0]; const curMode = mode; const refs = new WeakSet(); for (const node of query(true, selector, parent, false, curMode)) { refs.add(curMode === 'jquery' ? node.get(0) : node); if (callback(node, false) === false) return; } const filter = el => { for (const node of query(true, selector, el, true, curMode)) { const _el = curMode === 'jquery' ? node.get(0) : node; if (refs.has(_el)) break; refs.add(_el); if (callback(node, true) === false) { return removeFilter(parent, filter); } } }; addFilter(parent, filter); }, feach(selector, opt, ...args) { const {excludeAtts = []} = opt let parent = typeof args[0] !== 'function' && args.shift() || doc; if (mode === 'jquery' && parent instanceof $) parent = parent.get(0); const callback = args[0]; const curMode = mode; const refs = new NodeSet({excludeAtts}); for (const node of query(true, selector, parent, false, curMode)) { refs.add(curMode === 'jquery' ? node.get(0) : node); if (callback(node, false) === false) return; } const filter = el => { for (const node of query(true, selector, el, true, curMode)) { const _el = curMode === 'jquery' ? node.get(0) : node; if (refs.has(_el)) break; refs.add(_el); if (callback(node, true) === false) { return removeFilter(parent, filter); } } }; addFilter(parent, filter); }, create(domString, ...args) { const returnList = typeof args[0] === 'boolean' && args.shift(); const parent = args[0]; const template = doc.createElement('template'); template.innerHTML = domString; const node = template.content.firstElementChild; if (!node) return null; parent ? parent.appendChild(node) : node.remove(); if (returnList) { const list = {}; node.querySelectorAll('[id]').forEach(el => list[el.id] = el); list[0] = node; return list; } return node; }, selector(desc) { switch (true) { case isJquery(desc): $ = desc; return mode = 'jquery'; case !desc || typeof desc.toLowerCase !== 'function': return mode = 'css'; case desc.toLowerCase() === 'jquery': for (const jq of [window.jQuery, window.$, win.jQuery, win.$]) { if (isJquery(jq)) { $ = jq; break; }; } return mode = $ ? 'jquery' : 'css'; case desc.toLowerCase() === 'xpath': return mode = 'xpath'; default: return mode = 'css'; } } }; }(); function fixUrl(url){ const currUrl = new URL(window.location.href) ; if(url.startsWith('//')){ return `${currUrl.protocol}${url}`; }else if(url.startsWith('/')){ return `${currUrl.origin}${url}`; }else{ return url; } } function dateTimeNow(){ const now = new Date() const year = now.getFullYear() const month = (now.getMonth() + 1).toString().padStart(2, '0') const day = now.getDate().toString().padStart(2, '0') const hour = now.getHours().toString().padStart(2, '0') const minute = now.getMinutes().toString().padStart(2, '0') return `${year}-${month}-${day}_${hour}:${minute}` } class BMemo { constructor(bid, nickname, memo, avatar, info='') { this.bid = bid this.nickname = nickname this.memo = memo this.avatar = avatar this.info = info } } class MemoService{ static getCllKey(bid){ return `bmemo${String(bid)[0]}` } static getMemo({bid='', nickname=''}){ if(bid){ const memo = this.getMemoByid(bid) if(nickname && memo && memo.nickname !== nickname){ memo.nickname = nickname this.setMemo(memo) } return memo }else if(nickname){ return this.getMemoByNickname(nickname) }else{ return '' } } static getMemoByid(bid){ const memos = GM_getValue(this.getCllKey(bid), {}) return memos[bid] || '' } static getMemoByNickname(nickname){ for(let i=1;i<10;i++){ const memos = GM_getValue(`bmemo${i}`, {}) for(const bid in memos){ if(memos[bid].nickname === nickname){ return memos[bid] } } } return '' } static setMemo(memo){ const ckey = this.getCllKey(memo.bid) const memos = GM_getValue(ckey, {}) memos[memo.bid] = {...memos[memo.bid], ...memo} GM_setValue(ckey, memos) } static deleteMemo(bid){ const ckey = this.getCllKey(bid) const memos = GM_getValue(ckey, {}) delete memos[bid] GM_setValue(ckey, memos) } static getAllMemos(){ let memos = {} for(let i = 1; i < 10; i++){ const memo = GM_getValue(`bmemo${i}`, {}) Object.assign(memos, memo) } return memos } } class ConfService{ static getConf(key){ const conf = GM_getValue('bmemoConf', {}) return conf[key] || null } static setConf(key, value){ const conf = GM_getValue('bmemoConf', {}) conf[key] = value GM_setValue('bmemoConf', conf) } static getAllConf(){ return GM_getValue('bmemoConf', null) } } class BMemoUtils{ static getUserShow({nickname, memo}){ if(!memo){ return nickname } return this.userNameShow({nickname, memo}) || nickname } static userNameShow({nickname, memo}){ const mMode = ConfService.getConf(CONF_KEY.memoM) ?? CONF_INIT.memoM if(mMode == 0){ return '' }else if(mMode == 1){ return `${memo}(${nickname})` }else if(mMode == 2){ return `${nickname}(${memo})` }else if(mMode == 3){ return memo } } static saveMemo({bid, nickname, memo, avatar='', info = ''}){ if(bid && nickname){ if(memo){ MemoService.setMemo({bid, nickname, memo, avatar, info}) return 1 }else{ MemoService.deleteMemo(bid) return -1 } } return 0 } static importMemos(memos, mode){ const dataStatus = testData(memos) if(!dataStatus){ return null }else{ let failCnt = 0 let successCnt = 0 if(dataStatus == 1){ memos.forEach(memo => { const bid = memo.bid || '' const nickname = memo.nick_name || '' const mm = memo.bname || '' if(bid && mm){ saveOneM({bid, nickname, memo: mm}) successCnt++ }else{ failCnt++ } }) }else if(dataStatus == 2){ for(const k in memos){ const memo = memos[k] const bid = memo.bid || '' const nickname = memo.nickname || '' const mm = memo.memo || '' if(bid && mm){ saveOneM({bid, nickname, memo: mm}) successCnt++ }else{ failCnt++ } } } return {successCnt, failCnt} } function saveOneM(memo){ if(mode == 1){ BMemoUtils.saveMemo(memo) }else{ const _memo = MemoService.getMemoByid(memo.bid) if(_memo){ if(mode == 2){ BMemoUtils.saveMemo({..._memo, ...memo}) } }else{ BMemoUtils.saveMemo(memo) } } } function testData(data){ if(Array.isArray(data)){ for(let d of data){ if(typeof d != 'object'){ alert("数据列表中数据项格式错误") return 0 } } return 1 }else if(typeof data == 'object'){ for(const k in data){ if(typeof data[k] != 'object'){ alert("数据列表中数据项格式错误") return 0 } } return 2 }else{ alert("数据格式错误") return 0 } } } } class BSpaceUI{ static init(){ this.relationsHandler() this.nicknameHandler() this.favideosHandler() } static relationsHandler(){ elmGetter.each('.relation-content .items .item .relation-card-info > a', async (user)=>{ const newWrap = document.createElement('div') for(let i=0;i{ memo = MemoService.getMemoByid(bid) mInput.value = memo?.memo? memo.memo: nickname mInput.style.width = '130px' mInput.style.maxWidth = '160px' mInput.focus() nameDiv.style.width = '0' nameDiv.style.height = '0' }) mInput.addEventListener('blur', ()=>{ if(!mInput.readOnly){ handleSave() } }) mInput.addEventListener('keydown', (e)=>{ if(e.code === 'Enter'){ handleSave() } }) function handleSave(){ mInput.style.width = '0' nameDiv.style.width = 'auto' nameDiv.style.height = 'auto' let m = mInput.value?.trim() m = m == nickname ? '' : m const _memo = new BMemo(bid, nickname, m, avatar) memo = _memo const res = BMemoUtils.saveMemo(_memo) if(res == 1){ nameDiv.classList.add(HL_CLS) }else{ nameDiv.classList.remove(HL_CLS) } nameDiv.innerText = BMemoUtils.getUserShow({nickname, memo: memo.memo}) } newWrap.appendChild(mInput) user.insertAdjacentElement('beforebegin', newWrap) user.remove() }) } static nicknameHandler() { elmGetter.each('div.upinfo__main > div.upinfo-detail > div.upinfo-detail__top > div.nickname', async user => { const nickname = user.innerText const bid = new URL(location.href)?.pathname?.split('/')?.[1] let avatar = '' const aImg = await asyncGetNodeOnce(`div.upinfo__main > div.upinfo-avatar .b-avatar picture > img`) if(aImg){ avatar = aImg.src } const mInput = document.createElement('div') mInput.classList.add(SPACE_UPNAME_CLS) mInput.style.cssText = `padding: 6px 24px;position:relative;border:none;outline:none;border-bottom: 2px solid ${BPINK};font-size: 23px;text-align: center;border-radius: 8px 8px 0 0;font-weight: 600;` mInput.contentEditable = false let memo = MemoService.getMemo({bid, nickname}) let nameShow = BMemoUtils.getUserShow({memo: memo?.memo, nickname}) mInput.innerText = nameShow mInput.title = nameShow let isSaving = false if(memo){ mInput.classList.add(HL_CLS) if(memo.nickname != nickname || memo.avatar != avatar){ memo = {...memo, nickname, avatar} BMemoUtils.saveMemo(memo) } }else{ mInput.classList.remove(HL_CLS) } mInput.addEventListener('click', ()=>{ if(!mInput.contentEditable || mInput.contentEditable == 'false'){ memo = MemoService.getMemo({bid, nickname}) mInput.contentEditable = true mInput.focus() mInput.classList.remove(HL_CLS) mInput.innerText = memo?.memo ? memo.memo: nickname mInput.style.color = BPINK } }) mInput.addEventListener('blur', ()=>{ if(!isSaving){ handleSave() } }) mInput.addEventListener('keydown', (e)=>{ if(e.code === 'Enter'){ isSaving = true handleSave() } }) user.insertAdjacentElement('beforebegin', mInput) user.remove() function handleSave(){ mInput.contentEditable = false let m = mInput?.innerText?.trim() m = m == nickname ? '' : m let _memo = new BMemo(bid, nickname, m, avatar) memo = _memo const res = BMemoUtils.saveMemo(_memo) if(res == 1){ mInput.classList.add(HL_CLS) nameShow = BMemoUtils.userNameShow({nickname, memo: _memo.memo}) mInput.innerText = nameShow mInput.title = nameShow }else{ mInput.classList.remove(HL_CLS) mInput.innerText = nickname mInput.title = nickname } isSaving = false } }) } static favideosHandler() { elmGetter.each('.fav-list-main .items div.bili-video-card__subtitle > a', async user => { const bid = new URL(user.href)?.pathname?.split('/')?.pop() const contentSpan = user.querySelector('div:nth-child(2) > span') let contentL = contentSpan.innerText.split(' · ') const nickname = contentL[0] const memo = MemoService.getMemo({bid, nickname}) if(memo){ const nameShow = BMemoUtils.getUserShow({nickname, memo: memo?.memo}) contentSpan.innerHTML = `${nameShow} · ${contentL[1]}` contentSpan.classList.add(HL_CLS) }else{ contentSpan.classList.remove(HL_CLS) } }) } } class IndexUI{ static init(){ this.vCardHandler() this.favideosHandler() this.dynamicHandler() this.historyHandler() } static vCardHandler() { elmGetter.each('#i_cecream div.container.is-version8 div.bili-video-card__info div.bili-video-card__info--bottom > a', async user => { const unameSpan = user.querySelector('span.bili-video-card__info--author') const bid = new URL(user.href)?.pathname?.split('/')?.[1] const nickname = unameSpan.innerText const memo = MemoService.getMemo({bid, nickname}) if(memo){ const nameShow = BMemoUtils.getUserShow({nickname, memo: memo?.memo}) unameSpan.innerHTML = nameShow unameSpan.title = nameShow unameSpan.classList.add(HL_CLS) }else{ unameSpan.classList.remove(HL_CLS) } }) } static dynamicHandler() { elmGetter.each('#biliHeaderDynScrollCon div.header-content-panel div.header-dynamic__box--center div.dynamic-name-line div > a', async user => { const bid = new URL(user.href)?.pathname?.split('/')?.[1] const nickname = user.innerText let avatar = '' const wrap = user.closest('.header-dynamic-container') const aImg = await asyncGetNodeOnce('.header-dynamic-avatar .bili-avatar img', wrap) if(aImg){ avatar = fixUrl(aImg.dataset.src) } let memo = MemoService.getMemo({bid, nickname}) if(memo){ const nameShow = BMemoUtils.getUserShow({nickname, memo: memo?.memo}) user.innerHTML = nameShow user.title = nameShow user.classList.add(HL_CLS) if(memo.nickname != nickname || avatar != memo.avatar){ memo = {...memo, nickname, avatar} BMemoUtils.saveMemo(memo) } }else{ user.classList.remove(HL_CLS) } }) } static favideosHandler() { elmGetter.feach('#favorite-content-scroll > a > div.header-fav-card__info > span', {excludeAtts: ['title', 'innerText', 'innerHTML']}, async user => { const bid = new URL(fixUrl(user.getAttribute('href')))?.pathname?.split('/')?.[1] const nameSpan = user.querySelector('span') const nickname = nameSpan.innerText const memo = MemoService.getMemo({bid, nickname}) if(memo){ const nameShow = BMemoUtils.getUserShow({nickname, memo: memo?.memo}) nameSpan.innerText = nameShow nameSpan.title = nameShow nameSpan.classList.add(HL_CLS) }else{ nameSpan.classList.remove(HL_CLS) } }) } static historyHandler() { elmGetter.each('ul.right-entry .header-tabs-panel__content a .header-history-card__info .header-history-card__info--name', async user => { const nameSpan = user.querySelector('span') let bid = user.href ? new URL(user.href)?.pathname?.split('/')?.[1] : '' const nickname = nameSpan.innerText const memo = MemoService.getMemo({bid, nickname}) if(memo){ const nameShow = BMemoUtils.getUserShow({nickname, memo: memo?.memo}) nameSpan.innerText = nameShow nameSpan.title = nameShow nameSpan.classList.add(HL_CLS) }else{ nameSpan.classList.remove(HL_CLS) } }) } } class VideoPlayUI{ static init(){ setTimeout(()=>{ this.upHandler() }, 1800) this.rcmdHandler() this.commentUserHander() } static upHandler(){ elmGetter.each('#mirror-vdcon .right-container .up-panel-container .up-info-container .up-info--right .up-info__detail .up-detail-top > a:nth-child(1)', async user => { const bid = new URL(user.href)?.pathname?.split('/')?.[1] const nickname = user.innerText let avatar = '' const aImg = await asyncGetNodeOnce('.up-info--left img', user.closest('.up-info-container')) if(aImg){ avatar = aImg.src } const mInput = document.createElement('div') const mBtn = document.createElement('button') user.style.transition = 'all 0.3s linear' let memo = MemoService.getMemo({bid, nickname}) let nameShow if(memo){ nameShow = BMemoUtils.getUserShow({nickname, memo: memo?.memo}) user.innerText = nameShow user.classList.add(HL_CLS) if(nickname != memo.nickname || avatar != memo.avatar){ memo = {...memo, nickname, avatar} BMemoUtils.saveMemo(memo) } }else{ user.classList.remove(HL_CLS) } mInput.style.cssText = `width: 0px;transition: width 0.3s linear;border:none;border-bottom: 1px solid #FB7299;overflow: hidden;font-size:15px;` mInput.contentEditable = true mBtn.innerText = '备注' mBtn.style.marginRight = '4px' mBtn.style.fontSize = '15px' mBtn.classList.add(EDT_BTN_CLS) mBtn.addEventListener('click', () => { if(mBtn.innerText === '备注'){ memo = MemoService.getMemo({bid, nickname}) mInput.readOnly = false mInput.focus() mInput.innerText = memo?.memo ? memo.memo : nickname mInput.style.width = 'auto' mInput.style.height = 'auto' mInput.style.padding = '2px 5px' user.style.width = '0' mBtn.innerText = '保存' }else{ handleSave() } }) mInput.addEventListener('keydown', (e) => { if(e.code == 'Enter'){ handleSave() } }) mInput.addEventListener('blur', ()=> { if(!mInput.readOnly){ handleSave() } }) function handleSave(){ mInput.readOnly = true mBtn.innerText = '备注' user.style.width = 'auto' mInput.style.width = '0px' mInput.style.height = '0' mInput.style.padding = '0' let m = mInput?.innerText?.trim() m = m == nickname ? '' : m let _memo = new BMemo(bid, nickname, m, avatar) memo = _memo const res = BMemoUtils.saveMemo(_memo) if(res == 1){ user.classList.add(HL_CLS) nameShow = BMemoUtils.getUserShow({nickname, memo: _memo.memo}) user.innerText = nameShow }else{ user.classList.remove(HL_CLS) user.innerText = nickname } } user.insertAdjacentElement('afterend', mBtn) user.insertAdjacentElement('afterend', mInput) }) } static rcmdHandler() { elmGetter.each('#mirror-vdcon > div.right-container div.rcmd-tab div.recommend-list-v1 div.rec-list div.pic-box div.pic div picture img', async cover => { const card = cover.closest('div.card-box') const user = card.querySelector('.info .upname > a') const nameSpan = user.querySelector('.name') const nickname = nameSpan?.innerText const bid = new URL(user.href)?.pathname?.split('/')[1] const memo = MemoService.getMemo({bid, nickname}) if(memo){ nameSpan.innerText = BMemoUtils.userNameShow({nickname, memo: memo.memo}) nameSpan.classList.add(HL_CLS) }else{ nameSpan.classList.remove(HL_CLS) } } ) } static commentUserHander(){ function handleUser(user, {avatar=''}){ const bid = new URL(user.href)?.pathname?.split('/')[1] const nickname = user.innerText user.style.transition = 'all .3s' const mInput = document.createElement('input') const mBtn = document.createElement('button') let memo = MemoService.getMemo({bid, nickname}) if(memo){ mInput.value = memo.memo ?? nickname user.innerText = BMemoUtils.getUserShow({nickname, memo: memo.memo}) for(let s in INLINE_HL){ user.style[s] = INLINE_HL[s] } if(nickname != memo.nickname || avatar != memo.avatar){ memo = {...memo, avatar, nickname} BMemoUtils.saveMemo(memo) } }else{ mInput.value = nickname for(let s in INLINE_HL){ user.style[s] = 'unset' } } mInput.style.cssText = `width:0;border:none;outline:none;border-bottom:1px solid #FB7299;transition:all .3s;overflow:hidden;padding:0;` mBtn.innerText = '备注' mBtn.style.cssText = `border:none;outline:none;color:${BPINK};background:none;` mBtn.addEventListener('click',()=>{ if(mBtn.innerText === '备注'){ memo = MemoService.getMemo({bid, nickname}) mInput.style.width = '100px' mInput.style.padding = '2px 4px' mInput.readOnly = false mInput.focus() mInput.value = memo?.memo ?? nickname user.style.display = 'none' mBtn.innerText = '保存' }else{ handleSave() } }) mInput.addEventListener('keydown',(e)=>{ if(e.code == 'Enter'){ handleSave() } }) mInput.addEventListener('blur',()=>{ if(!mInput.readOnly){ handleSave() } }) function handleSave(){ mInput.style.width = '0' mInput.style.padding = '0' mInput.readOnly = true user.style.display = 'unset' mBtn.innerText = '备注' let m = mInput?.value?.trim() m = m == nickname ? '' : m let _memo = new BMemo(bid, nickname, m, avatar) memo = _memo const res = BMemoUtils.saveMemo(_memo) if(res == 1){ for(let s in INLINE_HL){ user.style[s] = INLINE_HL[s] } user.innerText = BMemoUtils.userNameShow({nickname, memo: _memo.memo}) }else{ for(let s in INLINE_HL){ user.style[s] = 'unset' } user.innerText = nickname } } user.parentNode?.insertAdjacentElement('afterend', mBtn) user.parentNode?.insertAdjacentElement('afterend', mInput) } elmGetter.each('bili-comments', async cmts => { const cRoot = cmts.shadowRoot elmGetter.each('bili-comment-thread-renderer', cRoot, async cmt => { const cmtRoot = cmt.shadowRoot elmGetter.each('bili-comment-renderer', cmtRoot, async topCmt => { const topCmtRoot = topCmt.shadowRoot let avatar = '' const aBox = await asyncGetNodeOnce('bili-avatar', topCmtRoot) const aImg = await asyncGetNodeOnce('#canvas > .layers:nth-child(2) > div.layer.center picture img', aBox.shadowRoot) if(aImg){ avatar = aImg.src } elmGetter.each('bili-comment-user-info', topCmtRoot, async uinfo => { const userInfoRoot = uinfo.shadowRoot elmGetter.each('#user-name a', userInfoRoot, async user => { handleUser(user, {avatar}) }) }) }) elmGetter.each('#replies bili-comment-replies-renderer', cmtRoot, async replies => { const repliesRoot = replies.shadowRoot elmGetter.each('bili-comment-reply-renderer', repliesRoot, async rpl => { const rplRoot = rpl.shadowRoot elmGetter.each('bili-comment-user-info', rplRoot, async uinfo => { const userInfoRoot = uinfo.shadowRoot let avatar = '' const aImg = uinfo.querySelector('#user-avatar img') if(aImg){ avatar = aImg.src } elmGetter.each('#user-name a', userInfoRoot, async user => { handleUser(user, {avatar}) }) }) }) }) }) }) } } class MsgUI{ static init() { this.whisperHandler() this.replyHandler() } static whisperHandler(){ elmGetter.each('#link-message-container .space-right .bili-im .left .list-container .list .name-box .name-value', async user => { let nickname = user.innerText const startT = new Date().getTime() if(nickname){ handleUser() }else{ const timer = setInterval(() => { if(nickname || new Date().getTime() - startT > MAX_TIMEOUT * 1e3){ handleUser() clearInterval(timer) }else{ nickname = user.innerText } }, 300) } function handleUser(){ const memo = MemoService.getMemo({nickname}) if(memo){ user.innerText = BMemoUtils.getUserShow(memo) user.classList.add(HL_CLS) }else{ user.classList.remove(HL_CLS) } } } ) } static replyHandler(){ elmGetter.each('#link-message-container .container .space-right .router-view .basic-list-item .center-box .line-1 span.name-field > a', async user => { const bid = new URL(user.href)?.pathname.split('/')[1] const nickname = user.innerText let memo = MemoService.getMemo({bid, nickname}) if(memo){ user.innerText = BMemoUtils.getUserShow(memo) user.classList.add(HL_CLS) }else{ user.classList.remove(HL_CLS) } const mInput = document.createElement('div') const mBtn = document.createElement('button') let isSaving = false mInput.style.cssText = `display:inline-block;outline:none;border-bottom: 1px solid ${BPINK};transition: all 0.3s ease;width: 0;padding: 0;height: 0;overflow: hidden;` user.style.transition = 'all 0.3s ease' const line1 = user.closest('.line-1') if(line1){ line1.style.display = 'flex' line1.style.gap = '4px' line1.style.alignItems = 'center' } mBtn.classList.add(EDT_BTN_CLS) mBtn.innerText = '备注' mBtn.addEventListener('click', (e) => { e.stopPropagation() e.preventDefault() memo = MemoService.getMemo({bid, nickname}) mInput.innerText = memo?.memo? memo.memo : nickname mInput.contentEditable = true mInput.style.width = 'auto' mInput.style.height = 'auto' mInput.style.padding = '2px 8px' mInput.focus() user.style.display = 'none' mBtn.style.display = 'none' }) mInput.addEventListener('blur', () => { if(!isSaving){ isSaving = true handleSave() } }) mInput.addEventListener('click', (e) => { e.stopPropagation() e.preventDefault() }) mInput.addEventListener('keydown', (e) =>{ if(e.code == 'Enter'){ isSaving = true handleSave() } }) user.insertAdjacentElement('afterend', mInput) user.insertAdjacentElement('afterend', mBtn) function handleSave(){ mInput.contentEditable = false isSaving = false mInput.style.width = '0' mInput.style.height = '0' mInput.style.padding = '0' user.style.display = 'inline' mBtn.style.display = 'inline' let m = mInput.innerText?.trim() m = m === nickname ? '' : m const _memo = new BMemo(bid, nickname, m) memo = {..._memo} const res = BMemoUtils.saveMemo(memo) if(res == 1){ user.innerText = BMemoUtils.getUserShow(memo) user.classList.add(HL_CLS) }else{ user.innerText = nickname user.classList.remove(HL_CLS) } } } ) } } class ManagerMenu{ static flushMemoTab static init(){ } static renderMenuAll(){ const menuH = '46vh' const menu = document.createElement('div') document.body.appendChild(menu) const menuBox = document.createElement('div') const toggleBtn = document.createElement('div') const helpInfo = document.createElement('div') menu.appendChild(menuBox) menu.appendChild(helpInfo) menuBox.appendChild(toggleBtn) menuBox.appendChild(this.renderOptsUI()) menuBox.appendChild(this.renderMemosUI()) const helpA = document.createElement('a') helpInfo.appendChild(helpA) helpA.href = HELP_LINK helpA.target = '_blank' helpA.innerText = '帮助' helpInfo.style.cssText = `position: absolute; right: 8px;top: 6px;` helpA.style.cssText = `color: ${BPINK};border: 2px solid ${BPINK};padding: 4px 6px;display: inline-block; border-radius: 3px;` menu.style.cssText = `position: fixed;height: ${menuH};left:0;right:0;bottom: -${menuH};z-index:9999; background: white; border-top:2px solid ${BPINK};transition: all .4s ease;` menuBox.style.cssText = `padding: 8px;position:relative;height: 100%;` toggleBtn.style.cssText = `position: absolute;top:-30px;left: 20px;width: 50px; opacity: .45; border-radius: 6px 6px 0 0;user-select: none;cursor: pointer;font-size: 20px; font-weight: 600;color:white; height: 30px;background: ${BPINK};text-align: center;line-height: 28px;` toggleBtn.innerText = 'o_o' toggleBtn.addEventListener('click',()=>{ if(toggleBtn.innerText === 'o_o'){ toggleBtn.innerText = 'O^O' menu.style.bottom = '0' toggleBtn.style.opacity = '1' this.flushMemoTab() }else{ toggleBtn.innerText = 'o_o' menu.style.bottom = `-${menuH}` toggleBtn.style.opacity = '.45' } }) } static renderOptsUI(){ const optsUI = document.createElement('div') optsUI.style.cssText = `border-bottom: 1px solid #ccc;` optsUI.appendChild(renderMemoModeOpt()) function renderMemoModeOpt(){ const memoModes = [ {label: '昵称', value: 0}, {label: '备注(昵称)', value: 1}, {label: '昵称(备注)', value: 2}, {label: '备注', value: 3}, ] const memoModeBox = document.createElement('div') const mmLabel = document.createElement('div') memoModeBox.appendChild(mmLabel) mmLabel.innerText = '昵称显示模式:' mmLabel.style.cssText = 'font-size: 17px; font-weight: 600;' memoModeBox.style.cssText = 'display: flex;align-items: center;gap: 18px;padding: 8px;font-size: 16px' const memoM = ConfService.getConf(CONF_KEY.memoM) memoModes.forEach(m => { const wrap = document.createElement('div') const label = document.createElement('label') const input = document.createElement('input') input.type = 'radio' input.name = 'memoMode' input.value = m.value input.id = 'pxo-memomode-' + m.value label.innerText = m.label label.style.userSelect = 'none' label.setAttribute('for', input.id) input.addEventListener('change', (e) => { ConfService.setConf(CONF_KEY.memoM, e.target.value) }) if(memoM == m.value){ input.checked = true } wrap.appendChild(input) wrap.appendChild(label) memoModeBox.appendChild(wrap) }) return memoModeBox } return optsUI } static renderMemosUI(){ const mWrap = document.createElement('div') const header = document.createElement('div') const memoTab = document.createElement('div') mWrap.appendChild(header) mWrap.appendChild(memoTab) const importBtn = document.createElement('button') const exportBtn = document.createElement('button') const memoTitle = document.createElement('div') const searchBox = document.createElement('div') const searchInput = document.createElement('input') const clearBtn = document.createElement('div') searchBox.appendChild(searchInput) searchBox.appendChild(clearBtn) header.appendChild(importBtn) header.appendChild(memoTitle) header.appendChild(searchBox) header.appendChild(exportBtn) searchBox.style.cssText = `border: 1px solid #ccc;padding: 6px 4px;border-radius: 4px;width: 200px;flex-shrink: 0;display: flex;align-items: center;gap: 4px;` searchInput.style.cssText = `border: none;outline: none;width: 100%;font-size: 16px;color: #333;padding: 0 6px` searchInput.placeholder = '搜索....' clearBtn.innerHTML = '×' clearBtn.style.cssText = `color: #ccc;font-size: 16px;cursor: pointer;text-align: center;border-radius: 50%; line-height: 20px; border: 1px solid #ccc;width:20px;height:20px;flex-shrink: 0;` header.style.cssText = 'display: flex;align-items: center;justify-content: space-between;gap: 8px;padding: 8px;border-bottom: 1px solid #ccc' memoTitle.innerText = '备注列表' memoTitle.style.cssText = 'font-size: 18px;font-weight: 600;flex-grow: 2;text-align: center;' importBtn.innerText = '导入' importBtn.style.cssText = `padding: 8px 18px;border:none;outline:none;background:${BPINK};border-radius: 4px;color: white;` exportBtn.innerText = '导出' exportBtn.style.cssText = importBtn.style.cssText exportBtn.addEventListener('click', () => { const memos = MemoService.getAllMemos() const mlist = JSON.stringify(memos, null, 2) const blob = new Blob([mlist], {type: 'application/json'}) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `B站备注备份_${dateTimeNow()}.json` a.click() URL.revokeObjectURL(url) }) { const importModes = [ {name: '跳过', value: 0}, {name: '覆盖', value: 1}, {name: '合并', value: 2}, ] const imptDog = document.createElement('dialog') document.body.appendChild(imptDog) importBtn.addEventListener('click', () => { imptDog.showModal() imptDog.style.display = 'block' }) imptDog.style.cssText = `display:none;border: 1px solid #ccc;border-radius: 5px;width: 500px;box-shadow: 2px 2px 4px 3px #00000027;` const dTitle = document.createElement('div') dTitle.style.cssText = 'font-size: 17px;font-weight: bold;margin-bottom: 8px;text-align: center;' dTitle.innerText = '批量导入备注' imptDog.appendChild(dTitle) const imptModeWrap = document.createElement('div') imptDog.appendChild(imptModeWrap) const iptTitle = document.createElement('div') imptModeWrap.appendChild(iptTitle) iptTitle.innerText = '导入时遇到重复的:' iptTitle.style.cssText = `font-size: 16px; font-weight: 600;` imptModeWrap.style.cssText = 'display: flex;margin-bottom: 8px;font-size: 16px;gap:18px;align-items:center;' let currMode = 1 importModes.forEach(mode => { const wrap = document.createElement('div') const label = document.createElement('label') const input = document.createElement('input') input.type = 'radio' input.name = 'imptMode' input.value = mode.value input.id = 'pxo-imptmode-' + mode.value input.checked = currMode == mode.value label.innerText = mode.name label.style.userSelect = 'none' label.setAttribute('for', input.id) input.addEventListener('change', (e) => { currMode = e.target.value }) wrap.appendChild(input) wrap.appendChild(label) imptModeWrap.appendChild(wrap) }) const imtIpt = document.createElement('textarea') imptDog.appendChild(imtIpt) imtIpt.style.cssText = `box-sizing: border-box;border: 1px solid #ccc;border-radius: 4px;padding:10px;` imtIpt.style.width = '100%' imtIpt.rows = 14 const optBtnWrap = document.createElement('div') optBtnWrap.style.cssText = `display:flex;align-items:center;justify-content:flex-end;gap:18px;` imptDog.appendChild(optBtnWrap) const cancelBtn = document.createElement('button') optBtnWrap.appendChild(cancelBtn) cancelBtn.innerText = '取消' cancelBtn.style.cssText = `border:1px solid ${BPINK};padding:3px 8px;background:${BPINK};color:white;font-size:16px;border-radius:4px;` cancelBtn.addEventListener('click', () => { imptDog.close() imptDog.style.display = 'none' }) const cfmBtn = document.createElement('button') optBtnWrap.appendChild(cfmBtn) cfmBtn.innerText = '导入' cfmBtn.style.cssText = cancelBtn.style.cssText cfmBtn.addEventListener('click', () => { const imptData = imtIpt.value let data try{ data = JSON.parse(imptData) }catch(e){ alert('输入内容格式错误') return false } if(data){ const res = BMemoUtils.importMemos(data, currMode) if(res){ imptDog.close() imptDog.style.display = 'none' flushMemoTab() alert(`${res.successCnt || 0} 条数据导入成功;${res.failCnt || 0} 条数据导入失败`) } } }) } let mFilter = null let filterTimer = null function searchHandler(e){ if(filterTimer) {clearTimeout(filterTimer)} filterTimer = setTimeout(() => { const searhKey = searchInput.value.trim() if(mFilter) mFilter(searhKey) }, 800) } clearBtn.addEventListener('click', () => { searchInput.value = '' searchInput.focus() searchHandler() }) { memoTab.style.cssText = `height:calc(45vh - 110px);overflow-y:scroll;` const {memoList, memoFilter} = this.renderMemoTab() mFilter = memoFilter searchInput.addEventListener('input', searchHandler) if(memoList){ memoTab.appendChild(memoList) } } this.flushMemoTab = () => flushMemoTab() function flushMemoTab(){ const {memoList, memoFilter} = ManagerMenu.renderMemoTab() mFilter = memoFilter memoTab.innerHTML = '' memoTab.appendChild(memoList) } return mWrap } static renderMemoTab() { const memos = MemoService.getAllMemos() const mp = new Map() const memoKeys = ['avatar', 'bid', 'nickname', 'memo'] const mLabels = { 'avatar': '头像', 'bid': 'BilibiliID', 'nickname': '昵称', 'memo': '备注' } const mwrap = document.createElement('div') mwrap.style.cssText = `display:flex;flex-wrap:wrap;width:100%;align-items:center;justify-content:space-between;` for(const bid in memos){ const memo = memos[bid] const row = document.createElement('div') mwrap.appendChild(row) mp.set(bid, row) row.style.cssText = `display: flex;align-items: center;gap: 8px; box-shadow: 1px 1px 2px 1px #00000017;gap: 40px; width: 45%;min-width: 410px;flex-wrap: wrap; padding: 8px 18px;border:1px solid #e9e9e9;border-radius: 5px;margin: 5px;` memoKeys.forEach(k => { const item = document.createElement('div') row.appendChild(item) if(k != 'avatar'){ const label = document.createElement('div') item.appendChild(label) label.innerText = mLabels[k] label.style.cssText = `font-size: 12px;color: #a1a0a0;margin-bottom: 9px;` }else{ const {avatar} = memo item.style.borderRadius = '50%' item.style.overflow = 'hidden' item.style.boxShadow = '1px 1px 3px 2px #00000021' item.style.flexShrink = '0' item.style.width = '50px' item.style.height = '50px' if(avatar){ const img = document.createElement('img') item.appendChild(img) img.src = memo[k] img.style.cssText = `width: 50px;height:50px` }else{ const fakeA = document.createElement('div') item.appendChild(fakeA) fakeA.innerText = memo.memo[0] || 'B' fakeA.style.cssText = `width: 50px;height: 50px;text-align: center;line-height: 48px; user-select: none; background: linear-gradient(45deg, ${BPINK}, blue);color: white;font-size: 24px;font-weight: 600;` } } if(k == 'nickname'){ item.style.cssText = 'min-width: 60px;max-width:110px;overflow: hidden;flex-shrink:0;' item.title = memo[k] const a = document.createElement('a') a.title = memo[k] a.innerText = memo[k] a.style.cssText = `font-size: 14px;color:${BBLUE};display:block;font-size: 15px;` a.href = 'https://space.bilibili.com/' + bid a.target= '_blank' item.appendChild(a) }else if(k == 'memo'){ const memoInput = document.createElement('input') memoInput.value = memo[k] memoInput.style.cssText = `border:none;outline:none;font-size: 15px;width:120px;color: ${BPINK};background:transparent;` memoInput.readOnly = true memoInput.title = '单击修改备注' memoInput.addEventListener('click', () => { memoInput.readOnly = false memoInput.style.borderBottom = `1px solid ${BPINK}` memoInput.focus() }) memoInput.addEventListener('blur', () => { memoInput.readOnly = true memoInput.style.borderBottom = 'none' let m = memoInput.value.trim() if(!m && !confirm(`确定删除该备注吗?【${memo.nickname} | ${memo.memo}】`)){ memoInput.value = memo.memo return false } const _memo = {...memo, memo: m} const res = BMemoUtils.saveMemo(_memo) if(res == -1){ row.remove() }else if(res == 0){ memoInput.value = memo.memo }else{ memo.memo = m } GM_setValue(bid, memo) }) item.appendChild(memoInput) }else if(k == 'bid'){ const val = document.createElement('div') val.innerText = memo[k] item.appendChild(val) } }) } function memoFilter(keyword = ''){ let kwd = keyword.trim() if(!kwd){ for(const k of mp.keys()){ mp.get(k).style.display = 'flex' } }else{ const keys = kwd.split(/\s+/) for(const k of mp.keys()){ const m = memos[k] const show = keys.some(k => (m.memo.includes(k) || m.nickname.includes(k) || m.bid.includes(k))) mp.get(k).style.display = show ? 'flex' : 'none' } } } return {memoList: mwrap, memoFilter} } } function init(){ GM_addStyle(CM_STYLE) const confs = ConfService.getAllConf() if(confs == null || (confs !== null && Object.keys(confs).length == 0)){ for(let k in CONF_INIT){ ConfService.setConf(k, CONF_INIT[k]) } } BSpaceUI.init() IndexUI.init() VideoPlayUI.init() MsgUI.init() ManagerMenu.renderMenuAll() } (function() { 'use strict'; init() })();