Bilibili UP Notes

A simple script to add notes to Bilibili UPs.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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.greatest.deepsurf.us/scripts/564901/1749919/CKUI.js
// ==/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(`<span>能够在关注管理器中显示 UP 主别名,允许快速修改。</span>`)
            .checkbox({
                label: '启用 记录关注信息',
                name: 'enableRecordFollowInfo',
                value: !!settings.enableRecordFollowInfo,
                onChange: (value, allValues) => {
                    Store.setSetting('enableRecordFollowInfo', !!value);
                }
            })
            .html(`<span>当在播放页右上角点击关注时,记录 UP 主的关注时间、关联视频等信息,并显示在 UP 卡片中。</span>`)
            .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);