// ==UserScript== // @name Bilibili UP Notes // @name:zh-CN 哔哩哔哩UP主备注 // @namespace ckylin-script-bilibili-up-notes // @version 0.7.0 // @description A simple script to add notes to Bilibili UPs. // @description:zh-CN 一个可以给哔哩哔哩UP主添加备注的脚本。 // @author CKylinMC // @match https://*.bilibili.com/* // @grant unsafeWindow // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addStyle // @grant GM_registerMenuCommand // @license Apache-2.0 // @run-at document-end // @icon https://www.bilibili.com/favicon.ico // @require https://update.greasyfork.icu/scripts/564901/1749919/CKUI.js // @downloadURL none // ==/UserScript== (function (unsafeWindow, document) { // #region helpers if (typeof (GM_addStyle) === 'undefined') { unsafeWindow.GM_addStyle = function (css) { const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } } const logger = { log(...args) { console.log('[BiliUPNotes]', ...args); }, error(...args) { console.error('[BiliUPNotes]', ...args); }, warn(...args) { console.warn('[BiliUPNotes]', ...args); }, } const pages = { isPlayPage() { return unsafeWindow.location.pathname.startsWith('/video/') || unsafeWindow.location.pathname.startsWith('/list/'); }, isProfilePage() { return unsafeWindow.location.hostname.startsWith('space.bilibili.com'); } } const runtime = { cardtaskId: null, uptaskId: null }; const selectors = { markup: { symbolclass: '.ckupnotes-symbol', idclass: '.ckupnotes-identifier' }, card: { root: 'div.bili-user-profile', avatar: 'picture.b-img__inner>img', avatarLink: 'a.bili-user-profile-view__avatar', infoRoot: 'div.bili-user-profile-view__info', userName: 'a.bili-user-profile-view__info__uname', bodyRoot: 'div.bili-user-profil1e__info__body', signBox: 'div.bili-user-profile-view__info__signature', footerRoot: 'div.bili-user-profile-view__info__footer', button: 'div.bili-user-profile-view__info__button' }, cardModern: { shadowRoot: 'bili-user-profile', readyDom: 'div#view', avatarLink: 'a#avatar', avatar: 'img#face', bodyBox: 'div#body', userNameBox: 'div#title', userName: 'a#name', bodyRoot: 'div#content', signBox: 'div#sign', footerRoot: 'div#action', }, userCard: { root: 'div.usercard-wrap', avatarLink: 'a.face', avatar: 'img.bili-avatar-img', bodyRoot: 'div.info', nameBox: 'div.user', userName: 'a.name', signBox: 'div.sign', footerRoot: 'div.btn-box' }, play: { upInfoBox: 'div.up-info-container', upAvatar: 'img.bili-avatar-img', upAvatarLink: 'a.up-avatar', upDetailBox: 'div.up-detail', upName: 'a.up-name', upDesc: 'div.up-description', upBtnBox: 'div.upinfo-btn-panel', upDetailTopBox: 'div.up-detail-top', subBtn: 'div.follow-btn', videoTitle: '.video-title' }, profile: { sidebarBox: 'div.aside', dynamicSidebarBox: 'div.space-dynamic__right', avatarImg: 'div.avatar div.b-avatar__layer__res>picture>img' } }; class Utils{ static _c(name) { return "ckupnotes-" + name; } static wait(ms = 0) { return new Promise(resolve => setTimeout(resolve, ms)); } static $(selector, root = document) { return root.querySelector(selector); } static $all(selector, root = document) { return Array.from(root.querySelectorAll(selector)); } static $child(parent, selector) { if (typeof parent === 'string') { return document.querySelector(parent+' '+selector); } return parent.querySelector(selector); } static $childAll(parent, selector) { if (typeof parent === 'string') { return Array.from(document.querySelectorAll(parent+' '+selector)); } return Array.from(parent.querySelectorAll(selector)); } static removeTailingSlash(str) { return str.replace(/\/+$/, ''); } static fixUrlProtocol(url) { if (url.startsWith('http://') || url.startsWith('https://')) { return url; } else if (url.startsWith('//')) { return unsafeWindow.location.protocol + url; } else if (url.startsWith('data:')) { return url; } else if (url.startsWith('/')) { return unsafeWindow.location.origin + url; } else { return unsafeWindow.location.origin + Utils.removeTailingSlash(unsafeWindow.location.pathname) + '/' + url; } } static waitForElementFirstAppearForever(selector, root = document) { return new Promise(resolve => { const element = root.querySelector(selector); if (element) { resolve(element); return; } const observer = new MutationObserver(mutations => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; const el = node.matches(selector) ? node : node.querySelector(selector); if (el) { resolve(el); observer.disconnect(); return; } } } }); observer.observe(root, { childList: true, subtree: true }); }); } static waitForElementFirstAppearForeverWithTimeout(selector, root = document, timeout = 5000) { return new Promise(resolve => { const element = root.querySelector(selector); if (element) { resolve(element); return; } let done = false; const observer = new MutationObserver(mutations => { if (done) return; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (!(node instanceof HTMLElement)) continue; const el = node.matches(selector) ? node : node.querySelector(selector); if (el) { done = true; resolve(el); observer.disconnect(); return; } } } }); observer.observe(root, { childList: true, subtree: true }); if (timeout > 0) { setTimeout(() => { if (done) return; done = true; observer.disconnect(); resolve(null); }, timeout); } }); } static registerOnElementAttrChange(element, attr, callback) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'attributes' && mutation.attributeName === attr) { callback(mutation); } }); }); observer.observe(element, { attributes: true }); return observer; } static registerOnElementContentChange(element, callback) { const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { if (mutation.type === 'characterData') { callback(mutation); } }); }); observer.observe(element, { characterData: true, subtree: true }); return observer; } static registerOnceElementRemoved(element, callback, root = null) { if (!element) return null; if (!element.isConnected) { callback?.(element); return null; } const parent = root || element.parentNode || element.getRootNode?.(); if (!parent) { callback?.(element); return null; } let done = false; const observer = new MutationObserver(mutations => { if (done) return; if (!element.isConnected) { done = true; observer.disconnect(); callback?.(element); return; } }); observer.observe(parent, { childList: true }); return observer; } static formatDate(timestamp) { return (Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false, }).format(new Date(+timestamp))).replace(/\//g, '-').replace(',', ''); } static daysBefore(timestamp) { const target = new Date(+timestamp); const now = Date.now(); const diff = now - target.getTime(); return Math.floor(diff / (1000 * 60 * 60 * 24)); } static download(filename, text) { const element = document.createElement('a'); element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text)); element.setAttribute('download', filename); element.style.display = 'none'; document.body.appendChild(element); element.click(); document.body.removeChild(element); } static get ui() { return unsafeWindow.ckui; } static get currentUid() { if (pages.isProfilePage()) { const match = unsafeWindow.location.pathname.match(/\/space\.bilibili\.com\/(\d+)/); if (match) { return match[1]; } else { const uid = document.querySelector('.vui_icon.sic-fsp-uid_line.icon')?.nextSibling?.textContent || null; return uid; } } // on play page if(pages.isPlayPage()) { const upAvatarLink = Utils.$(selectors.play.upAvatarLink); if (upAvatarLink) { const link = upAvatarLink.getAttribute('href') || ''; const match2 = link.match(/\/space\.bilibili\.com\/(\d+)/); if (match2) { return match2[1]; } } } return null; } static get currentVID() { if (!pages.isPlayPage()) return null; // method referenced Bilibili Evolved if (unsafeWindow.aid || unsafeWindow.bvid) { return 'av'+unsafeWindow.aid || unsafeWindow.bvid; } const selector = '.av-link,.bv-link,.bvid-link'; const avEl = document.querySelector(selector); if (avEl) { const vid = avEl.innerText?.trim?.() || ''; if (vid.toLowerCase().startsWith('av') || vid.toLowerCase().startsWith('bv')) { return vid; } if (vid.match(/^\d+/)) { return 'av' + vid; } } return null; } } // #endregion helpers // #region store-v2 class GMStore { static _serialize(value) { return JSON.stringify({ v: value }); } static _deserialize(value) { if (value === null || typeof value === 'undefined') return null; if (typeof value !== 'string') return value; try { const parsed = JSON.parse(value); if (parsed && Object.prototype.hasOwnProperty.call(parsed, 'v')) { return parsed.v; } return parsed; } catch { return value; } } static get(key, fallback = null) { const raw = GM_getValue(key, null); if (raw === null || typeof raw === 'undefined') return fallback; const val = this._deserialize(raw); return (val === null || typeof val === 'undefined') ? fallback : val; } static set(key, value) { GM_setValue(key, this._serialize(value)); } static delete(key) { GM_deleteValue(key); } static has(key) { return GM_listValues().includes(key); } static list() { return GM_listValues(); } } class Store{ static datastore = GMStore; static settingsstore = GMStore; static setDataStore(storeName) { switch (storeName) { case 'GMStore': this.datastore = GMStore; break; default: throw new Error(`Unknown store: ${storeName}`); } } static set(key, value) { return this.datastore.set(key, value); } static get(key, fallback = null) { return this.datastore.get(key, fallback); } static delete(key) { return this.datastore.delete(key); } static has(key) { return this.datastore.has(key); } static list() { return this.datastore.list(); } static readSettings() { const settings = this.get('settings', {}); return settings; } static readSetting(key, fallback = null) { const settings = this.readSettings(); return (settings && Object.prototype.hasOwnProperty.call(settings, key)) ? settings[key] : fallback; } static setSettings(settings) { return this.set('settings', settings); } static setSetting(key, value) { const settings = this.readSettings() || {}; settings[key] = value; return this.setSettings(settings); } static deleteSetting(key) { const settings = this.readSettings(); if (settings && Object.prototype.hasOwnProperty.call(settings, key)) { delete settings[key]; return this.setSettings(settings); } } static _u(uid) { return (uid ? ((''+uid).trim?.() || uid) : null) } static hasUser(_uid) { const uid = this._u(_uid); if (!uid) return false; return this.has(`u:${uid}`); } static getUser(_uid, fallback = null) { const uid = this._u(_uid); if (!uid) return fallback; return this.get(`u:${uid}`, fallback); } static setUser(_uid, user) { const uid = this._u(_uid); if (!uid) return; return this.set(`u:${uid}`, user); } static delUser(_uid) { const uid = this._u(_uid); if (!uid) return; return this.delete(`u:${uid}`); } static listUsers() { return this.list().filter(key => key.startsWith('u:')).map(key => key.substring(2)); } } class User { uid = ""; uname = ""; uavatar = ""; alias = ""; notes = ""; tags = []; followInfo = null; externalInfo = null; extras = null; static LoadOrCreate(uid) { let user = Store.getUser(uid, null); if (user) { return User.fromJson(user); } else { user = new User(); user.uid = uid; user.save(); return user; } } static fromUID(uid) { const result = Store.getUser(uid, null); if (result) { return User.fromJson(result); } else { return null; } } static fromJson(jsonStr) { try { const obj = JSON.parse(jsonStr); const user = new User(); user.uid = obj.uid || ""; user.uname = obj.uname || ""; user.uavatar = obj.uavatar || ""; user.alias = obj.a || ""; user.notes = (obj.n !== null && obj.n !== undefined) ? String(obj.n) : ""; user.tags = obj.t || []; user.followInfo = obj.f || null; user.externalInfo = obj.s || null; user.extras = obj.e || null; return user; } catch { return null; } } toObj() { return { uid: this.uid, uname: this.uname, uavatar: this.uavatar, a: this.alias, n: this.notes, t: this.tags, f: this.followInfo, s: this.externalInfo, e: this.extras } } toJSON() { return JSON.stringify(this.toObj()); } toString() { return `[UP ${this.uid} - ${this.uname}${this.alias ? ` (${this.alias})` : ''}]`; } save() { return Store.setUser(this.uid, this.toJSON()); } remove() { return Store.delUser(this.uid); } getTags() { return this.tags || []; } setTags(tags) { this.tags = tags || []; } addTag(tag) { if (!this.tags) this.tags = []; if (!this.tags.includes(tag)) { this.tags.push(tag); } } removeTag(tag) { if (!this.tags) return; this.tags = this.tags.filter(t => t !== tag); } setFollowInfo({ timestamp, videoId, videoName, upName }) { this.followInfo = { t: timestamp, vi: videoId, vn: videoName, un: upName } } getFollowInfo() { if (!this.followInfo) return null; return { timestamp: this.followInfo.t, videoId: this.followInfo.vi, videoName: this.followInfo.vn, upName: this.followInfo.un } } removeFollowInfo() { this.followInfo = null; } setExternalInfo({ sourceName, sourceUrl, timestamp }) { this.externalInfo = { s: sourceName, u: sourceUrl, t: timestamp } } getExternalInfo() { if (!this.externalInfo) return null; return { sourceName: this.externalInfo.s, sourceUrl: this.externalInfo.u, timestamp: this.externalInfo.t } } setExtra(key, value) { if (!this.extras) this.extras = {}; this.extras[key] = value; } getExtra(key, fallback = null) { if (!this.extras) return fallback; return (Object.prototype.hasOwnProperty.call(this.extras, key)) ? this.extras[key] : fallback; } refresh() { return User.fromUID(this.uid).then(user => { if (user) { this.uname = user.uname; this.uavatar = user.uavatar; this.alias = user.alias; this.notes = user.notes; this.tags = user.tags; this.followInfo = user.followInfo; this.externalInfo = user.externalInfo; this.extras = user.extras; } return this; }); } } function migrationCheckV2() { const keys = Store.list(); let need = false; for (const key of keys) { if (key.startsWith('upalias_') || key.startsWith('upnotes_')) { need = true; break; } } return need; } function doMigrationV2() { const keys = Store.list(); for (const key of keys) { if (key.startsWith('upalias_')) { const uid = key.substring('upalias_'.length); const user = User.LoadOrCreate(uid); user.alias = Store.get(key, ''); user.save(); Store.delete(key); logger.log(`Migrated alias for UID ${uid}`); } else if (key.startsWith('upnotes_')) { const uid = key.substring('upnotes_'.length); const user = User.LoadOrCreate(uid); user.notes = Store.get(key, ''); user.save(); Store.delete(key); logger.log(`Migrated notes for UID ${uid}`); } } } // #endregion store-v2 // #region cores class UPNotesManager { static _u(uid) { return (uid ? ((''+uid).trim?.() || uid) : "not-a-uid") } static getAliasForUID(_uid, fallback = null) { const uid = UPNotesManager._u(_uid); const user = User.fromUID(uid); if (user) { return user.alias || fallback; } else return fallback; } static setAliasForUID(_uid, alias) { const uid = UPNotesManager._u(_uid); const user = User.LoadOrCreate(uid); user.alias = alias; user.save(); } static deleteAliasForUID(_uid) { const uid = UPNotesManager._u(_uid); const user = User.fromUID(uid); if (user) { user.alias = ""; user.save(); } } static getNotesForUID(_uid, fallback = null) { const uid = UPNotesManager._u(_uid); const user = User.fromUID(uid); if (user) { return user.notes || fallback; } else return fallback; } static setNotesForUID(_uid, notes) { const uid = UPNotesManager._u(_uid); const user = User.LoadOrCreate(uid); user.notes = notes; user.save(); } static deleteNotesForUID(_uid) { const uid = UPNotesManager._u(_uid); const user = User.fromUID(uid); if (user) { user.notes = ""; user.save(); } } static callUIForEditing(_uid, _displayName = "?", _avatarUrl = null, closeCallback = null) { const uid = UPNotesManager._u(_uid); const displayName = _displayName?.trim?.() || _displayName; const avatarUrl = _avatarUrl?.trim?.() || _avatarUrl; const user = User.LoadOrCreate(uid); user.uname = displayName || user.uname; user.uavatar = avatarUrl || user.uavatar; const form = Utils.ui.form() .input({ label: 'UP 别名', name: 'alias', placeholder: '请输入 UP 别名', value: user.alias }) .textarea({ label: 'UP 备注', name: 'notes', placeholder: '请输入 UP 备注', value: user.notes }) .tags({ label: '分类标签', name: 'tags', placeholder: '对 UP 进行标签归类', value: user.tags || [], maxTags: 10, validator(tag, tags) { if (tag.length < 1 || tag.length > 20) { return '标签长度应在 1-20 字符之间'; } return true; } }) .checkbox({ label: '勾选并保存以删除关注记录', name: 'deleteFollowInfo', value: false, }) .button({ label: '保存', primary: true, onClick: (values) => { const newAlias = values.alias.trim(); const newNotes = values.notes.trim(); const tags = values.tags || []; const deleteFollowInfo = values.deleteFollowInfo || false; if (deleteFollowInfo) { user.removeFollowInfo(); } user.alias = newAlias; user.notes = newNotes; user.setTags(tags); user.save(); Utils.ui.success('保存成功'); floatWindow.close(); if (closeCallback) { closeCallback(); } } }) .button({ label: '取消', onClick: () => { floatWindow.close(); } }); const floatWindow = Utils.ui.floatWindow({ title: `编辑备注 ${displayName} (UID: ${uid})`, content: form.render(), width: '450px', shadow: true, ...(avatarUrl ? { icon: Utils.fixUrlProtocol(avatarUrl), iconShape: 'circle', iconWidth: '24px', } : {}) }); floatWindow.show(); floatWindow.moveToMouse?.(); } static callUIForRemoving(_uid, _displayName = "", _avatarUrl = null) { const uid = UPNotesManager._u(_uid); const displayName = _displayName?.trim?.() || _displayName; const avatarUrl = _avatarUrl?.trim?.() || _avatarUrl; const user = User.fromUID(uid); if(!user) return Utils.ui.error('未找到该 UP 主的备注信息,无需删除。'); Utils.ui.confirm( `确定要删除 ${displayName} (UID: ${uid}) 的 UP 备注吗?`, '确认删除 UP 备注', null, avatarUrl ? { icon: Utils.fixUrlProtocol(avatarUrl), iconShape: 'circle', iconWidth: '24px', } : {} ).then(res => { if (res) { user.remove(); Utils.ui.success('删除成功'); } }); } } // #endregion cores // #region integrations class FoManPlugin_Provider{ static hasAlias(uid) { return UPNotesManager.getAliasForUID(uid, null) !== null; } static getAlias(uid, fallback = null) { return UPNotesManager.getAliasForUID(uid, fallback); } static setAlias(uid, alias) { UPNotesManager.setAliasForUID(uid, alias); } static removeAlias(uid) { UPNotesManager.deleteAliasForUID(uid); } } class FoManPlugin_Actions{ static async setFor(uid, displayName = null) { UPNotesManager.callUIForEditing(uid, displayName); } static async removeFor(uid, displayName = null) { UPNotesManager.callUIForRemoving(uid, displayName); } } // #endregion integrations // #region import-export class ImportValidator { static validate(data) { try { if (!data?.meta || data.meta.fmt !== 'v2') throw new Error('不支持的数据格式'); if (!data.content || typeof data.content !== 'object') throw new Error('缺少内容数据'); return { valid: true, data }; } catch (e) { return { valid: false, error: `验证失败: ${e.message}` }; } } static validateBackup(data) { try { if (data?.type !== 'backup' || !data.data) throw new Error('不是有效的备份格式'); return { valid: true, data }; } catch (e) { return { valid: false, error: `备份验证失败: ${e.message}` }; } } } class ImportMerger { static prepareMerge(importData, options = {}) { const { mergeMode = 'smart', externalInfo = null } = options; const content = importData.data.content; return Object.entries(content).map(([uid, importUserData]) => { const existingUser = User.fromUID(uid); const hasData = importUserData.alias || importUserData.notes || importUserData.tags?.length > 0; let action = 'skip'; if (!existingUser) action = 'create'; else if (mergeMode === 'overwrite') action = 'overwrite'; else if (mergeMode === 'smart' && hasData) action = 'merge'; return { uid, importData: importUserData, existingUser, action, externalInfo }; }); } static executeTask(task) { try { if (task.action === 'skip') return { success: true, action: 'skip', uid: task.uid }; const user = task.action === 'create' ? User.LoadOrCreate(task.uid) : task.existingUser; const data = task.importData; if (task.action === 'overwrite') { user.alias = data.alias || ''; user.notes = data.notes != null ? String(data.notes) : ''; user.tags = Array.isArray(data.tags) ? data.tags : []; } else if (task.action === 'merge') { if (data.alias) user.alias = data.alias; if (data.notes != null && data.notes !== '') user.notes = String(data.notes); if (Array.isArray(data.tags) && data.tags.length) { user.tags = [...new Set([...(user.tags || []), ...data.tags])]; } } if (task.externalInfo) user.setExternalInfo(task.externalInfo); user.save(); return { success: true, action: task.action, uid: task.uid }; } catch (e) { return { success: false, action: task.action, uid: task.uid, error: e.message }; } } } class ImportProgressUI { constructor() { this.data = { current: 0, total: 0, created: 0, updated: 0, skipped: 0, failed: 0 }; this.elements = {}; } create() { if (this.window) return this.window.show(), this; const h = Utils.ui.h; const stat = (label, key, color) => h('div', { style: `background: var(--ckui-bg-secondary); padding: 12px; border-radius: 6px; border-left: 3px solid ${color};` }, [ h('div', { style: 'font-size: 12px; color: var(--ckui-text-secondary);' }, [label]), h('div', { 'data-stat': key, style: 'font-size: 20px; font-weight: 600;' }, ['0']) ]); this.window = Utils.ui.floatWindow({ id: 'ckupnotes-import-progress', title: '数据导入', content: h('div', {}, [ h('div', { style: 'background: var(--ckui-bg-secondary); border-radius: 8px; height: 24px; margin-bottom: 8px;' }, [ h('div', { 'data-bind': 'bar', style: 'height: 100%; background: linear-gradient(90deg, #3b82f6, #2563eb); transition: width 0.3s; width: 0%;' }) ]), h('div', { 'data-bind': 'text', style: 'text-align: center; font-size: 18px; font-weight: 600; margin-bottom: 16px;' }, ['0%']), h('div', { style: 'display: grid; grid-template-columns: 1fr 1fr; gap: 12px;' }, [ stat('新建', 'created', '#10b981'), stat('更新', 'updated', '#3b82f6'), stat('跳过', 'skipped', '#f59e0b'), stat('失败', 'failed', '#ef4444') ]) ]), width: '450px', closable: false, shadow: true }); return this; } show() { this.window?.show(); if (this.window && this.window.container && !this.elements.bar) { const c = this.window.container; this.elements = { bar: c.querySelector('[data-bind="bar"]'), text: c.querySelector('[data-bind="text"]'), stats: { created: c.querySelector('[data-stat="created"]'), updated: c.querySelector('[data-stat="updated"]'), skipped: c.querySelector('[data-stat="skipped"]'), failed: c.querySelector('[data-stat="failed"]') } }; } return this; } update(data) { Object.assign(this.data, data); const pct = this.data.total > 0 ? Math.round(this.data.current / this.data.total * 100) : 0; if (this.elements.bar) this.elements.bar.style.width = pct + '%'; if (this.elements.text) this.elements.text.textContent = pct + '%'; ['created', 'updated', 'skipped', 'failed'].forEach(k => { if (this.elements.stats && this.elements.stats[k]) { this.elements.stats[k].textContent = this.data[k] || 0; } }); } close() { this.window?.close(); this.window = null; this.elements = {}; } } class DataImporter { static async importWithProgress(jsonString, options = {}) { const { mergeMode = 'smart', batchSize = 50, batchDelay = 10, sourceUrl = null } = options; let progressUI = null; try { const jsonData = JSON.parse(jsonString); const validation = ImportValidator.validate(jsonData); if (!validation.valid) { Utils.ui.notification.error('格式验证失败', validation.error); return { success: false, error: validation.error }; } const meta = jsonData.meta || {}; const infoLines = [ meta.author && `作者:${meta.author}`, meta.version && `版本:${meta.version}`, meta.exportTime && `导出时间:${Utils.formatDate(meta.exportTime)}`, meta.count && `数据条数:${meta.count}` ].filter(Boolean); const confirmContent = Utils.ui.h('div', {}, [ Utils.ui.h('div', { style: 'line-height: 1.8;' }, infoLines.map(line => Utils.ui.h('div', {}, [line])) ), Utils.ui.h('div', { style: 'margin-top: 12px; padding: 12px; background: var(--ckui-bg-secondary); border-radius: 4px; font-size: 13px;' }, ['导入后将记录数据来源信息到每个UP主。'] ) ]); const confirmed = await Utils.ui.confirm({ title: '确认导入分享数据', content: confirmContent }); if (!confirmed) return { success: false, error: '用户取消导入' }; const externalInfo = { sourceName: meta.author || '未知来源', sourceUrl: meta.website || sourceUrl || '本地文件', timestamp: Date.now() }; const tasks = ImportMerger.prepareMerge(validation, { mergeMode, externalInfo }); if (!tasks.length) { Utils.ui.notification.info('无数据', '没有需要导入的数据'); return { success: true, stats: { total: 0 } }; } progressUI = new ImportProgressUI().create().show(); await this._executeTasks(tasks, progressUI, batchSize, batchDelay); progressUI.close(); Utils.ui.notification.success('导入完成', `成功: ${progressUI.data.created} 新建, ${progressUI.data.updated} 更新, ${progressUI.data.skipped} 跳过` ); return { success: true, stats: progressUI.data }; } catch (e) { progressUI?.close(); Utils.ui.notification.error('导入失败', e.message); logger.error('导入失败:', e); return { success: false, error: e.message }; } } static async importBackupWithProgress(jsonString, options = {}) { const { batchSize = 50, batchDelay = 10 } = options; let progressUI = null; try { const jsonData = JSON.parse(jsonString); const validation = ImportValidator.validateBackup(jsonData); if (!validation.valid) { Utils.ui.notification.error('格式验证失败', validation.error); return { success: false, error: validation.error }; } const userData = jsonData.data; const uids = Object.keys(userData); if (!uids.length) { Utils.ui.notification.info('无数据', '没有需要导入的数据'); return { success: true, stats: { total: 0 } }; } progressUI = new ImportProgressUI().create().show(); const stats = { total: uids.length, created: 0, updated: 0, skipped: 0, failed: 0 }; progressUI.update({ total: uids.length, current: 0 }); for (let i = 0; i < uids.length; i += batchSize) { const batch = uids.slice(i, i + batchSize); batch.forEach(uid => { try { const existingUser = User.fromUID(uid); Store.setUser(uid, userData[uid]); stats[existingUser ? 'updated' : 'created']++; } catch (e) { stats.failed++; } }); progressUI.update({ current: Math.min(i + batchSize, uids.length), ...stats }); if (i + batchSize < uids.length) await Utils.wait(batchDelay); } progressUI.close(); Utils.ui.notification.success('备份导入完成', `成功: ${stats.created} 新建, ${stats.updated} 更新${stats.failed ? `, ${stats.failed} 失败` : ''}` ); return { success: true, stats }; } catch (e) { progressUI?.close(); Utils.ui.notification.error('导入失败', e.message); logger.error('备份导入失败:', e); return { success: false, error: e.message }; } } static async _executeTasks(tasks, progressUI, batchSize, batchDelay) { const stats = { created: 0, updated: 0, skipped: 0, failed: 0 }; progressUI.update({ total: tasks.length, current: 0, ...stats }); for (let i = 0; i < tasks.length; i += batchSize) { const batch = tasks.slice(i, i + batchSize); batch.forEach(task => { const result = ImportMerger.executeTask(task); stats[result.success ? result.action : 'failed']++; }); progressUI.update({ current: Math.min(i + batchSize, tasks.length), ...stats }); if (i + batchSize < tasks.length) await Utils.wait(batchDelay); } } } // #endregion import-export // #region settingspage function openSettings() { if (!Utils.ui) return; const settings = Object.assign({ enableIntegrationOnUnfollow: true, enableRecordFollowInfo: true, }, Store.readSettings() || {}); const form = Utils.ui.form() .checkbox({ label: '启用 与关注管理器的集成', name: 'enableIntegrationOnUnfollow', value: !!settings.enableIntegrationOnUnfollow, onChange: (value, allValues) => { Store.setSetting('enableIntegrationOnUnfollow', !!value); } }) .html(`能够在关注管理器中显示 UP 主别名,允许快速修改。`) .checkbox({ label: '启用 记录关注信息', name: 'enableRecordFollowInfo', value: !!settings.enableRecordFollowInfo, onChange: (value, allValues) => { Store.setSetting('enableRecordFollowInfo', !!value); } }) .html(`当在播放页右上角点击关注时,记录 UP 主的关注时间、关联视频等信息,并显示在 UP 卡片中。`) .button({ label: '备份', onClick() { const users = Store.listUsers(); const backup = { type: 'backup', version: '2.0', exportTime: Date.now(), data: Object.fromEntries(users.map(uid => [uid, Store.getUser(uid)]).filter(([, v]) => v)) }; Utils.download(`bilibili_upnotes_backup_${Date.now()}.json`, JSON.stringify(backup, null, 2)); Utils.ui.notification.success('备份成功', `已备份 ${users.length} 条完整数据`); } }) .button({ label: '分享', onClick() { const users = Store.listUsers().map(uid => User.fromUID(uid)).filter(Boolean); const share = { meta: { fmt: 'v2', author: 'BiliUPNotes User', version: '1.0', exportTime: Date.now(), count: users.length }, content: Object.fromEntries(users.map(u => [u.uid, { alias: u.alias || '', notes: u.notes || '', tags: u.tags || [] }])) }; Utils.download(`bilibili_upnotes_share_${Date.now()}.json`, JSON.stringify(share, null, 2)); Utils.ui.notification.success('分享数据已导出', `已导出 ${users.length} 条简化数据`); } }) .space() .button({ label: '从 URL 导入', onClick: async () => { const url = await Utils.ui.prompt('请输入包含 UP 备注数据的 URL(需返回 JSON 格式数据)', '', 'https://example.com/path/to.json'); if (!url) return; try { Utils.ui.notification.info('正在获取数据', '请稍候...'); const resp = await fetch(url); if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); await DataImporter.importWithProgress(await resp.text(), { mergeMode: 'smart', sourceUrl: url }); } catch (e) { Utils.ui.notification.error('导入失败', e.message); } } }) .button({ label: '从文件导入', onClick: () => { const input = document.createElement('input'); input.type = 'file'; input.accept = '.json'; input.onchange = async (e) => { const file = e.target.files?.[0]; if (!file) return; try { Utils.ui.notification.info('正在读取文件', '请稍候...'); const text = await file.text(); const data = JSON.parse(text); if (data.type === 'backup') { await DataImporter.importBackupWithProgress(text); } else { await DataImporter.importWithProgress(text, { sourceUrl: '本地文件: ' + file.name }); } } catch (e) { Utils.ui.notification.error('导入失败', e.message); } }; input.click(); } }) .space() .html(`Tips: 可以将分享内容发布到 JSONBin.io 这样的网站,并给他人提供访问链接来分享 UP 备注数据。`); const win = Utils.ui.floatWindow({ id: 'ckupnotes-settings', title: 'UP 备注 - 功能设置', content: form.render(), width: '400px', shadow: true, }); win.show(); } // #endregion // #region onAnyPage function injectCssOnAnyPage() { GM_addStyle(` .ckupnotes-usercard-btn{ border: 1px solid var(--text3); color: var(--text2); background-color: transparent; } .ckupnotes-usercard-btn:hover{ color: var(--brand_blue); border-color: var(--brand_blue); } .ckupnotes-tagrow{ margin-top: 4px; } .ckupnotes-tag{ display: inline-block; padding: 2px 6px; margin-right: 4px; background-color: var(--bg2); color: var(--text2); border-radius: 4px; font-size: 12px; } `); } function tagRowMaker(tags) { const row = document.createElement('div'); row.classList.add('ckupnotes-tagrow', selectors.markup.idclass.replace(".", "")); tags.forEach(tag => { const tagEl = document.createElement('div'); tagEl.classList.add('ckupnotes-tag'); tagEl.textContent = tag; row.appendChild(tagEl); }); return row; } function followInfoBlockMaker(user) { const followInfo = user.getFollowInfo(); if (!followInfo) return null; const block = document.createElement('div'); block.classList.add('ckupnotes-followinfo', selectors.markup.idclass.replace(".", "")); block.textContent = `关注于 `; const dateSpan = document.createElement('span'); dateSpan.innerText = Utils.formatDate(followInfo.timestamp); dateSpan.title = Utils.daysBefore(followInfo.timestamp) + '天前'; block.appendChild(dateSpan); const vidLink = document.createElement('a'); vidLink.href=`https://www.bilibili.com/video/${followInfo.videoId}`; vidLink.target = '_blank'; vidLink.textContent = `《${followInfo.videoName ||'未知'}》`; block.appendChild(vidLink); if(user.uname && followInfo.upName && user.uname !== followInfo.upName) { block.textContent += `(UP:${followInfo.upName})`; } return block; } function externalInfoBlockMaker(user) { const externalInfo = user.getExternalInfo(); if (!externalInfo) return null; const block = document.createElement('div'); block.classList.add('ckupnotes-externalinfo', selectors.markup.idclass.replace(".", "")); block.textContent = `信息来自 ${externalInfo.sourceName} 于 ${Utils.formatDate(externalInfo.timestamp)}`; if (externalInfo.sourceUrl) { const link = document.createElement('a'); link.href = Utils.fixUrlProtocol(externalInfo.sourceUrl); link.target = '_blank'; link.style.marginLeft = '8px'; link.textContent = '[查看来源]'; block.appendChild(link); } return block; } function registerOnAnyPage() { logger.log('Registering UP Card observer on any page...'); injectCssOnAnyPage(); Utils.waitForElementFirstAppearForever(selectors.card.root).then(onFirstCardShown); Utils.waitForElementFirstAppearForever(selectors.cardModern.shadowRoot).then(onFirstModernCardShown); Utils.waitForElementFirstAppearForever(selectors.userCard.root).then(onFirstUserCardShown); } function onFirstCardShown(cardElement) { logger.log('First UP Card note appeared.'); onCardShown(cardElement); Utils.registerOnElementAttrChange( cardElement, 'style', () => { if (!cardElement.style.display || cardElement.style.display !== 'none') { onCardShown(cardElement); } } ); } function onFirstModernCardShown(cardElement) { logger.log('First Modern UP Card note appeared.'); Utils.registerOnElementAttrChange(cardElement, 'style', () => { if (!cardElement.style.display || cardElement.style.display !== 'none') { onModernCardShown(); } }); } function onFirstUserCardShown(cardElement) { logger.log('First User Card note appeared.'); Utils.registerOnElementAttrChange(cardElement, 'style', () => { if (!cardElement.style.display || cardElement.style.display !== 'none') { onUserCardShown(); } }); } async function onCardShown() { const thisCardTaskId = (''+Date.now()) + Math.random(); try { runtime.cardtaskId = thisCardTaskId; const cardElement = Utils.$(selectors.card.root); const cardBody = Utils.$child(cardElement, selectors.card.bodyRoot); if (!cardBody) { return; } await Utils.wait(150); // 等待内容加载 const els = Utils.$childAll(cardElement, selectors.markup.idclass); els.forEach(element => { element.remove(); }); if(runtime.cardtaskId !== thisCardTaskId) { logger.log('A newer card task has started, aborting this one.(note)'); return; } const avatarLinkEl = Utils.$child(cardElement, selectors.card.avatarLink); const link = avatarLinkEl?.getAttribute('href') || ''; // value = `//space.bilibili.com/652239032/dynamic` // extract UID const match = link.match(/\/space\.bilibili\.com\/(\d+)/); if (!match) return logger.log('UID not found in avatar link, aborting.(note)'); const uid = match[1]; logger.log(`Extracted UID: ${uid} (note)`); const user = User.fromUID(uid) || {}; let alias = user.alias || ''; let notes = user.notes || ''; logger.log(`UP Card Shown - UID: ${uid}, Alias: ${alias}, Notes: ${notes}`); const userNameEl = Utils.$child(cardElement, selectors.card.userName); const username = userNameEl.textContent || ''; if (alias) { const span = document.createElement('span'); span.classList.add(selectors.markup.symbolclass.replace(".", ""), selectors.markup.idclass.replace(".", "")); span.textContent = ` (${alias})`; userNameEl.appendChild(span); } else { logger.log('No alias found.(note)'); } const bodyRootEl = Utils.$child(cardElement, selectors.card.bodyRoot); if (notes) { const notesEl = document.createElement('div'); notesEl.classList.add(selectors.card.signBox.replace("div.", ""), selectors.markup.idclass.replace(".", "")); notesEl.style.marginTop = '4px'; notesEl.style.fontStyle = 'italic'; notesEl.textContent = notes; bodyRootEl.appendChild(notesEl); logger.log('Notes added to UP Card.(note)'); } else { logger.log('No notes found.(note)'); } if (user.tags && user.tags.length > 0) { const tagRow = tagRowMaker(user.tags); bodyRootEl.appendChild(tagRow); logger.log('Tags added to UP Card.(note)'); } if (user.followInfo) { const followInfoBlock = followInfoBlockMaker(user); if (followInfoBlock) { bodyRootEl.appendChild(followInfoBlock); logger.log('Follow info added to UP Card.(note)'); } } if (user.externalInfo) { const externalInfoBlock = externalInfoBlockMaker(user); if (externalInfoBlock) { bodyRootEl.appendChild(externalInfoBlock); logger.log('External info added to UP Card.(note)'); } } const footerRootEl = Utils.$child(cardElement, selectors.card.footerRoot); if (footerRootEl) { const btn = document.createElement('div'); btn.classList.add(selectors.card.button.replace("div.", ""), selectors.markup.idclass.replace(".", ""), 'ckupnotes-usercard-btn'); btn.textContent = '编辑备注'; btn.style.cursor = 'pointer'; btn.style.marginLeft = '8px'; footerRootEl.appendChild(btn); btn.addEventListener('click', () => { const avatarEl = Utils.$child(cardElement, selectors.card.avatar); const avatarImgSrc = avatarEl?.getAttribute('src') || null; UPNotesManager.callUIForEditing(uid, username, avatarImgSrc); }); } } finally { if(runtime.cardtaskId === thisCardTaskId) runtime.cardtaskId = null; } } async function onModernCardShown() { const cardElement = Utils.$(selectors.cardModern.shadowRoot); if (!cardElement) return; const shadowroot = cardElement.shadowRoot; if (!shadowroot) return; const thisCardTaskId = ('' + Date.now()) + Math.random(); try { runtime.cardtaskId = thisCardTaskId; await Utils.waitForElementFirstAppearForever(selectors.cardModern.readyDom, shadowroot, 2000); if (runtime.cardtaskId !== thisCardTaskId) { logger.log('A newer card task has started, aborting this one.(modern)'); return; } const els = Utils.$childAll(shadowroot, selectors.markup.idclass); els.forEach(element => { element.remove(); }); const avatarLinkEl = Utils.$child(shadowroot, selectors.cardModern.avatarLink); const link = avatarLinkEl?.getAttribute('href') || ''; const match = link.match(/\/space\.bilibili\.com\/(\d+)/); if (!match) return logger.log('UID not found in avatar link, aborting.(modern)'); const uid = match[1]; logger.log(`Extracted UID: ${uid} (modern)`); const user = User.fromUID(uid) || {}; let alias = user.alias || ''; let notes = user.notes || ''; let followInfo = user.followInfo || null; let externalInfo = user.externalInfo || null; logger.log(`Modern UP Card Shown - UID: ${uid}, Alias: ${alias}, Notes: ${notes}`); const userNameEl = Utils.$child(shadowroot, selectors.cardModern.userName); const username = userNameEl?.textContent || ''; if (alias) { const span = document.createElement('span'); span.classList.add(selectors.markup.symbolclass.replace(".", ""), selectors.markup.idclass.replace(".", "")); span.textContent = ` (${alias})`; userNameEl.appendChild(span); } else { logger.log('No alias found.(modern)'); } const bodyRootEl = Utils.$child(shadowroot, selectors.cardModern.bodyRoot); if (notes) { const notesEl = document.createElement('div'); notesEl.classList.add(selectors.cardModern.signBox.replace("div.", ""), selectors.markup.idclass.replace(".", "")); notesEl.style.marginTop = '4px'; notesEl.style.fontStyle = 'italic'; notesEl.textContent = notes; bodyRootEl.appendChild(notesEl); logger.log('Notes added to Modern UP Card.(modern)'); } else { logger.log('No notes found.(modern)'); } if(user.tags && user.tags.length > 0) { const tagRow = tagRowMaker(user.tags); bodyRootEl.appendChild(tagRow); logger.log('Tags added to Modern UP Card.(modern)'); } if (followInfo) { const followInfoBlock = followInfoBlockMaker(user); if (followInfoBlock) { bodyRootEl.appendChild(followInfoBlock); logger.log('Follow info added to Modern UP Card.(modern)'); } } if (externalInfo) { const externalInfoBlock = externalInfoBlockMaker(user); if (externalInfoBlock) { bodyRootEl.appendChild(externalInfoBlock); logger.log('External info added to Modern UP Card.(modern)'); } } const footerRootEl = Utils.$child(shadowroot, selectors.cardModern.footerRoot); if (footerRootEl) { const btn = document.createElement('button'); btn.classList.add(selectors.markup.idclass.replace(".", ""), 'ckupnotes-usercard-btn'); btn.textContent = '编辑备注'; btn.style.cursor = 'pointer'; btn.style.marginLeft = '8px'; footerRootEl.appendChild(btn); btn.addEventListener('click', () => { const avatarEl = Utils.$child(shadowroot, selectors.cardModern.avatar); const avatarImgSrc = avatarEl?.getAttribute('src') || null; UPNotesManager.callUIForEditing(uid, username, avatarImgSrc); }); } // inject custom styles into shadowdom const styleEl = document.createElement('style'); styleEl.textContent = ` .ckupnotes-usercard-btn{ border: 1px solid var(--text3); color: var(--text2); background-color: transparent; } .ckupnotes-usercard-btn:hover{ color: var(--brand_blue); border-color: var(--brand_blue); } .ckupnotes-tagrow{ margin-top: 4px; } .ckupnotes-tag{ display: inline-block; padding: 2px 6px; margin-right: 4px; background-color: var(--bg2); color: var(--text2); border-radius: 4px; font-size: 12px; } `; styleEl.classList.add(selectors.markup.idclass.replace(".", "")); shadowroot.appendChild(styleEl); } finally { if (runtime.cardtaskId === thisCardTaskId) runtime.cardtaskId = null; } } async function onUserCardShown() { const cardElement = Utils.$(selectors.userCard.root); if (!cardElement) return; const thisCardTaskId = ('' + Date.now()) + Math.random(); try { runtime.cardtaskId = thisCardTaskId; await Utils.wait(300); // wait for content load if (runtime.cardtaskId !== thisCardTaskId) { logger.log('A newer card task has started, aborting this one.(usercard)'); return; } const els = Utils.$childAll(cardElement, selectors.markup.idclass); els.forEach(element => { element.remove(); }); logger.log('Processing User Card...(usercard)'); const userNameLink = Utils.$child(cardElement, selectors.userCard.userName); const link = userNameLink?.getAttribute('href') || ''; const match = link.match(/\/space\.bilibili\.com\/(\d+)/); if (!match) return logger.log('UID not found in avatar link, aborting.(usercard)'); const uid = match[1]; logger.log(`Extracted UID: ${uid} (usercard)`); const user = User.fromUID(uid) || {}; let alias = user.alias || ''; let notes = user.notes || ''; let followInfo = user.followInfo || null; let externalInfo = user.externalInfo || null; logger.log(`User Card Shown - UID: ${uid}, Alias: ${alias}, Notes: ${notes}`); const userNameEl = Utils.$child(cardElement, selectors.userCard.userName); const displayName = userNameEl?.textContent || ''; if (alias) { const span = document.createElement('span'); span.classList.add(selectors.markup.symbolclass.replace(".", ""), selectors.markup.idclass.replace(".", "")); span.textContent = ` (${alias})`; userNameEl.appendChild(span); } else { logger.log('No alias found.(usercard)'); } const bodyRootEl = Utils.$child(cardElement, selectors.userCard.bodyRoot); if (notes) { const notesEl = document.createElement('div'); notesEl.classList.add(selectors.userCard.signBox.replace("div.", ""), selectors.markup.idclass.replace(".", "")); notesEl.style.marginTop = '4px'; notesEl.style.fontStyle = 'italic'; notesEl.textContent = notes; bodyRootEl.appendChild(notesEl); logger.log('Notes added to User Card.(usercard)'); } else { logger.log('No notes found.(usercard)'); } if(user.tags && user.tags.length > 0) { const tagRow = tagRowMaker(user.tags); bodyRootEl.appendChild(tagRow); logger.log('Tags added to User Card.(usercard)'); } if (followInfo) { const followInfoBlock = followInfoBlockMaker(user); if (followInfoBlock) { bodyRootEl.appendChild(followInfoBlock); logger.log('Follow info added to User Card.(usercard)'); } } if (externalInfo) { const externalInfoBlock = externalInfoBlockMaker(user); if (externalInfoBlock) { bodyRootEl.appendChild(externalInfoBlock); logger.log('External info added to User Card.(usercard)'); } } const footerRootEl = Utils.$child(cardElement, selectors.userCard.footerRoot); if (footerRootEl) { const btn = document.createElement('div'); btn.classList.add('ckupnotes-usercard-btn', selectors.markup.idclass.replace(".", "")); btn.textContent = '备注'; btn.style.cursor = 'pointer'; btn.style.padding = '5px 6px'; btn.style.borderRadius = '4px'; btn.style.flex = '1'; btn.style.textAlign = 'center'; footerRootEl.appendChild(btn); btn.addEventListener('click', () => { const avatarEl = Utils.$child(cardElement, selectors.userCard.avatar); const avatarImgSrc = avatarEl?.getAttribute('src') || null; UPNotesManager.callUIForEditing(uid, displayName, avatarImgSrc); }); } } finally { if (runtime.cardtaskId === thisCardTaskId) runtime.cardtaskId = null; } } // #endregion onAnyPage // #region playpage function injectCssOnPlayPage() { GM_addStyle(` .ckupnotes-play-up-btn { margin-left: 2px; color: var(--text2); font-size: 13px; transition: color .3s; flex-shrink: 0; } .ckupnotes-play-up-btn:hover { color: var(--brand_blue); } `); } function registerOnPlayPage() { logger.log('Registering UP Info Box observer on play page...'); injectCssOnPlayPage(); Utils.waitForElementFirstAppearForever(selectors.play.upInfoBox).then(onFirstTimeUpInfoBoxShown); } function onFirstTimeUpInfoBoxShown() { logger.log('First UP Info Box appeared on play page.'); onUpInfoBoxShown(); Utils.registerOnElementContentChange( Utils.$(selectors.play.upInfoBox), () => { onUpInfoBoxShown(); } ); } async function onUpInfoBoxShown() { logger.log('UP Info Box shown on play page.'); const thisUpTaskId = ('' + Date.now()) + Math.random(); try { runtime.uptaskId = thisUpTaskId; await Utils.wait(500); // wait for content load if (runtime.uptaskId !== thisUpTaskId) { logger.log('A newer UP task has started, aborting this one.(play)'); return; } const upInfoBox = Utils.$(selectors.play.upInfoBox); const els = Utils.$all(selectors.markup.idclass, upInfoBox); els.forEach(element => { element.remove(); }); const upAvatarLinkEl = Utils.$(selectors.play.upAvatarLink, upInfoBox); const link = upAvatarLinkEl?.getAttribute('href') || ''; const match = link.match(/\/space\.bilibili\.com\/(\d+)/); if (!match) return logger.log('UID not found in avatar link, aborting.(play)'); const uid = match[1]; logger.log(`Extracted UID: ${uid} (play)`); const user = User.fromUID(uid) || {}; let alias = user.alias || ''; let notes = user.notes || ''; logger.log(`UP Info Box Shown - UID: ${uid}, Alias: ${alias}, Notes: ${notes}`); const upNameEl = Utils.$(selectors.play.upName, upInfoBox); const username = upNameEl.textContent || ''; if (alias) { const span = document.createElement('span'); span.classList.add(selectors.markup.symbolclass.replace(".", ""), selectors.markup.idclass.replace(".", "")); span.textContent = ` (${alias})`; upNameEl.appendChild(span); } else { logger.log('No alias found.(play)'); } const upDescEl = Utils.$(selectors.play.upDesc, upInfoBox); if (notes) { const notesEl = document.createElement('div'); notesEl.classList.add(selectors.markup.symbolclass.replace(".", ""), selectors.markup.idclass.replace(".", "")); notesEl.style.marginTop = '4px'; notesEl.style.fontStyle = 'italic'; notesEl.textContent = notes; upDescEl.appendChild(notesEl); logger.log('Notes added to UP Info Box.(play)'); } else { logger.log('No notes found.(play)'); } const upDetailTopBoxEl = Utils.$(selectors.play.upDetailTopBox, upInfoBox); if (upDetailTopBoxEl) { const btn = document.createElement('div'); btn.classList.add('ckupnotes-play-up-btn', selectors.markup.idclass.replace(".", "")); btn.textContent = '编辑备注'; btn.style.cursor = 'pointer'; btn.style.marginLeft = '8px'; upDetailTopBoxEl.appendChild(btn); btn.addEventListener('click', () => { const upAvatarImgEl = Utils.$(selectors.play.upAvatarImg, upInfoBox); const avatarImgSrc = upAvatarImgEl?.getAttribute('src') || null; UPNotesManager.callUIForEditing(uid, username, avatarImgSrc, ()=>onUpInfoBoxShown()); }); } const subButton = Utils.$(selectors.play.subBtn, upInfoBox); if (subButton) { logger.log('Registering follow/unfollow button listener on play page.'); subButton.removeEventListener('click', onSubBtn); subButton.addEventListener('click', onSubBtn); } else { logger.log('Follow/unfollow button not found, cannot register listener.(play)'); } if (!Utils.$(".ckupnote-upinfo-probe", upInfoBox)) { logger.log('Creating probe element for UP Info Box reset detection.(play)'); const probe = document.createElement('span'); probe.style.display = 'none'; probe.classList.add("ckupnote-upinfo-probe"); upInfoBox.appendChild(probe); if(!Utils.registerOnceElementRemoved(probe, () => { logger.log('Element reset, re-triggering up info box processing.(play)'); Utils.wait(500).then(() => onUpInfoBoxShown()); }, document.body)) { logger.log('Probe create failed: element already been removed.(play)'); } else logger.log('Probe created', probe); } else { logger.log('Probe element already exists, no need to create.(play)'); } } catch (e) { logger.error('Error occurred while processing UP Info Box on play page:', e); } finally { if (runtime.uptaskId === thisUpTaskId) runtime.uptaskId = null; } } async function onSubBtn(event) { logger.log('Follow/Unfollow button clicked on play page.'); await Utils.wait(500); try { const upInfoBox = Utils.$(selectors.play.upInfoBox); const upAvatarLinkEl = Utils.$(selectors.play.upAvatarLink, upInfoBox); const link = upAvatarLinkEl?.getAttribute('href') || ''; const match = link.match(/\/space\.bilibili\.com\/(\d+)/); if (!match) return logger.log('UID not found in avatar link, aborting.(play)'); const uid = match[1]; logger.log(`Extracted UID: ${uid} (play)`); const user = User.fromUID(uid) || {}; let notes = user.notes || ''; const upNameEl = Utils.$(selectors.play.upName, upInfoBox); let username = upNameEl.textContent || '?'; username = username?.trim?.() || username; user.uname = username; const vidNameEl = Utils.$(selectors.play.videoTitle); let vidName = vidNameEl?.textContent || '?'; vidName = vidName?.trim?.() || vidName; // const formatedDate = (Intl.DateTimeFormat('zh-CN', { // year: 'numeric', // month: '2-digit', // day: '2-digit', // hour: '2-digit', // minute: '2-digit', // hour12: false, // }).format(new Date())).replace(/\//g, '-').replace(',', ''); const subBtn = Utils.$(selectors.play.subBtn, upInfoBox); if (subBtn) { logger.log('Processing follow/unfollow action on play page.'); if (subBtn.classList.contains('following')) { // just followed // UPNotesManager.setNotesForUID(uid, // (notes ? notes + '\n' : '') + `[${formatedDate}] 在《${vidName}》关注了 "${username}"` // ); user.setFollowInfo({ timestamp: "" + (+new Date()), videoName: vidName, videoId: Utils.currentVID || '', upName: username, }); user.save(); Utils.ui?.success(`关注操作已记录到 ${username} 的备注`); } else if (subBtn.classList.contains('not-follow')) { // just unfollowed // not supported } else { logger.log('Follow button state unrecognized, no action taken.(play)'); } } } finally { } } // #endregion playpage // #region userprofilepage function injectCssOnUserProfilePage() { GM_addStyle(` .ckupnotes-profile-aside-card { background-color: var(--bg2); border-radius: 6px; width: 100%; padding: 20px 16px 24px; } .ckupnotes-profile-aside-card-line{ margin: 4px 0; } .ckupnotes-profile-aside-card-button{ width: 100%; margin-top: 12px; padding: 4px 0; border: 1px solid var(--text3); color: var(--text2); background-color: transparent; cursor: pointer; border-radius: 4px; } .ckupnotes-profile-aside-card-button:hover{ color: var(--brand_blue); border-color: var(--brand_blue); } `); } function registerOnUserProfilePage() { logger.log('Registering User Profile Page observer...'); injectCssOnUserProfilePage(); Utils.waitForElementFirstAppearForever(selectors.profile.sidebarBox).then(injectOnSidebarBox); Utils.waitForElementFirstAppearForever(selectors.profile.dynamicSidebarBox).then(injectOnDynamicSidebarBox); } async function injectOnSidebarBox(sidebarBox) { logger.log('User Profile Page sidebar box appeared.'); await Utils.wait(200); // wait for content load const uid = Utils.currentUid; if (!uid) { logger.warn('Cannot extract UID on profile page, aborting.'); return; } const user = User.fromUID(uid) || {}; const alias = user.alias || ''; const notes = user.notes || ''; const followInfo = user.followInfo || null; const externalInfo = user.externalInfo || null; const username = Utils.$('div.nickname')?.textContent || ''; const existingCard = Utils.$('.ckupnotes-profile-aside-card', sidebarBox); if (existingCard) { existingCard.remove(); } const card = document.createElement('div'); card.classList.add('ckupnotes-profile-aside-card'); const title = document.createElement('div'); title.textContent = 'UP 备注信息'; title.style.fontSize = '16px'; title.style.fontWeight = 'bold'; card.appendChild(title); const aliasLine = document.createElement('div'); aliasLine.classList.add('ckupnotes-profile-aside-card-line'); aliasLine.textContent = `别名: ${alias || '无'}`; card.appendChild(aliasLine); const notesLine = document.createElement('div'); notesLine.classList.add('ckupnotes-profile-aside-card-line'); notesLine.textContent = `备注: ${notes || '无'}`; card.appendChild(notesLine); if (user.tags && user.tags.length > 0) { const tagRow = tagRowMaker(user.tags); card.appendChild(tagRow); } if (followInfo) { const followInfoBlock = followInfoBlockMaker(user); if (followInfoBlock) { card.appendChild(followInfoBlock); } } if (externalInfo) { const externalInfoBlock = externalInfoBlockMaker(user); if (externalInfoBlock) { card.appendChild(externalInfoBlock); } } const editButton = document.createElement('button'); editButton.classList.add('ckupnotes-profile-aside-card-button'); editButton.textContent = '编辑备注'; editButton.addEventListener('click', () => { const avatarImgSrc = Utils.$(selectors.profile.avatarImg, sidebarBox)?.getAttribute('src') || ''; UPNotesManager.callUIForEditing(uid, username, avatarImgSrc, ()=>injectOnSidebarBox(sidebarBox)); }); card.appendChild(editButton); const wrap = document.createElement('div'); wrap.classList.add('home-aside-section'); wrap.appendChild(card); sidebarBox.prepend(wrap); } async function injectOnDynamicSidebarBox(sidebarBox) { logger.log('User Profile Page sidebar box appeared.'); await Utils.wait(200); // wait for content load const uid = Utils.currentUid; if (!uid) { logger.warn('Cannot extract UID on profile page, aborting.'); return; } const user = User.fromUID(uid) || {}; const alias = user.alias || ''; const notes = user.notes || ''; const followInfo = user.followInfo || null; const externalInfo = user.externalInfo || null; const username = Utils.$('div.nickname')?.textContent || ''; const existingCard = Utils.$('.ckupnotes-profile-aside-card', sidebarBox); if (existingCard) { existingCard.remove(); } const card = document.createElement('div'); card.classList.add('ckupnotes-profile-aside-card'); const title = document.createElement('div'); title.textContent = 'UP 备注信息'; title.style.fontSize = '16px'; title.style.fontWeight = 'bold'; card.appendChild(title); const aliasLine = document.createElement('div'); aliasLine.classList.add('ckupnotes-profile-aside-card-line'); aliasLine.textContent = `别名: ${alias || '无'}`; card.appendChild(aliasLine); const notesLine = document.createElement('div'); notesLine.classList.add('ckupnotes-profile-aside-card-line'); notesLine.textContent = `备注: ${notes || '无'}`; card.appendChild(notesLine); if (user.tags && user.tags.length > 0) { const tagRow = tagRowMaker(user.tags); card.appendChild(tagRow); } if (followInfo) { const followInfoBlock = followInfoBlockMaker(user); if (followInfoBlock) { card.appendChild(followInfoBlock); } } if (externalInfo) { const externalInfoBlock = externalInfoBlockMaker(user); if (externalInfoBlock) { card.appendChild(externalInfoBlock); } } const editButton = document.createElement('button'); editButton.classList.add('ckupnotes-profile-aside-card-button'); editButton.textContent = '编辑备注'; editButton.addEventListener('click', () => { const avatarImgSrc = Utils.$(selectors.profile.avatarImg, sidebarBox)?.getAttribute('src') || ''; UPNotesManager.callUIForEditing(uid, username, avatarImgSrc, ()=>injectOnDynamicSidebarBox(sidebarBox)); }); card.appendChild(editButton); const wrap = document.createElement('div'); wrap.classList.add('dynamic-aside-section'); wrap.appendChild(card); sidebarBox.prepend(wrap); } // #endregion userprofilepage // #region init function migrationCheckAndMigrate() { logger.log('Checking for old data to migrate...'); if (migrationCheckV2()) { logger.log('Old data detected, starting migration to new format (v2)...'); Utils.ui?.info('检测到旧版数据,正在进行数据迁移,请稍候...'); doMigrationV2(); Utils.ui?.success('迁移成功!'); } } function createMenu() { GM_registerMenuCommand('UP备注设置', () => { openSettings(); }); } function init() { logger.log('Initializing Bilibili UP Notes script...'); createMenu(); migrationCheckAndMigrate(); // 注册任意页面事件 registerOnAnyPage(); // 注册播放页面事件 if (pages.isPlayPage()) { registerOnPlayPage(); } // 注册个人主页事件 if (pages.isProfilePage()) { registerOnUserProfilePage(); } try { if(typeof(unsafeWindow.FoManPlugins) === 'undefined') { unsafeWindow.FoManPlugins = {}; } unsafeWindow.FoManPlugins.UpAlias = { provider: FoManPlugin_Provider, actions: FoManPlugin_Actions } }catch(e) { logger.error('Failed to register as FoMan plugin:', e); } Utils.ui?.trackMouseEvent?.(); unsafeWindow.ckupnotes = { settingsWindow: ()=>openSettings(), } logger.log('Bilibili UP Notes script initialized.'); } init(); // #endregion init }) (unsafeWindow,document);