457923979qq.com / ZAIKO guest-name 修改器

// ==UserScript==
// @name         ZAIKO guest-name 修改器
// @namespace    https://local/guest-name-editor
// @version      3.1.0
// @description  面板里自己填名字点确定,改写 [data-guest-name] 元素里显示的姓名文字;适配手机端,支持异步渲染、刷新后持续生效
// @author       you
// @license      MIT
// @match        *://zaiko.io/*
// @match        *://*.zaiko.io/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

(function () {
    'use strict';

    const IS_TOP = window.top === window.self;   // 面板只在顶层窗口显示,iframe 里照样替换
    const SELECTOR = '[data-guest-name]';
    const LS_KEY = '__gn_rules__' + location.host;
    const ORIG_ATTR = 'data-gn-original';        // 存原始文字,用于还原

    // 规则:原始显示文字 -> 新文字。
    // 注意 ZAIKO 这里 data-guest-name="" 是空的,名字在元素的文本里,所以按文字匹配而不是按属性值。
    let rules = loadRules();
    let alsoWriteAttr = false;                   // 是否同时把新名字写进 data-guest-name 属性

    /* ------------------------------ 替换逻辑 ------------------------------ */

    function loadRules() {
        try {
            const raw = localStorage.getItem(LS_KEY);
            return raw ? new Map(JSON.parse(raw)) : new Map();
        } catch (e) { return new Map(); }
    }

    function saveRules() {
        try { localStorage.setItem(LS_KEY, JSON.stringify([...rules])); } catch (e) { }
    }

    const getText = el => (el.textContent || '').trim();

    function textNodesOf(el) {
        const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
        const out = [];
        while (walker.nextNode()) out.push(walker.currentNode);
        return out;
    }

    // 把元素显示的文字换成 newText,尽量不破坏内部标签结构
    function setText(el, newText) {
        const nodes = textNodesOf(el);
        const solid = nodes.filter(n => n.nodeValue.trim() !== '');

        if (solid.length === 1) {
            // 最常见:只有一个文本节点,保留原来的前后空白
            const m = solid[0].nodeValue.match(/^(\s*)([\s\S]*?)(\s*)$/);
            solid[0].nodeValue = m[1] + newText + m[3];
        } else if (solid.length > 1) {
            solid[0].nodeValue = newText;
            solid.slice(1).forEach(n => { n.nodeValue = ''; });
        } else {
            el.textContent = newText;
        }
    }

    function applyTo(el) {
        const cur = getText(el);
        if (!rules.has(cur)) return false;

        const newName = rules.get(cur);
        if (newName === cur) return false;

        if (!el.hasAttribute(ORIG_ATTR)) el.setAttribute(ORIG_ATTR, cur);
        setText(el, newName);
        if (alsoWriteAttr) el.setAttribute('data-guest-name', newName);
        return true;
    }

    function runAll() {
        if (!rules.size) return 0;
        let n = 0;
        document.querySelectorAll(SELECTOR).forEach(el => { if (applyTo(el)) n++; });
        return n;
    }

    function restoreAll() {
        document.querySelectorAll('[' + ORIG_ATTR + ']').forEach(el => {
            setText(el, el.getAttribute(ORIG_ATTR));
            el.removeAttribute(ORIG_ATTR);
        });
        rules.clear();
        saveRules();
    }

    // 异步渲染 / 翻页 / 局部刷新后继续生效。
    // 改完文字后自身也会触发这个 observer,但那时文字已是新值、匹配不到规则,不会死循环。
    let timer = null;
    new MutationObserver(() => {
        if (timer) return;
        timer = setTimeout(() => { timer = null; runAll(); }, 100);
    }).observe(document.documentElement, {
        childList: true, subtree: true, characterData: true,
    });

    runAll();

    if (!IS_TOP) return;

    /* -------------------------------- 面板 -------------------------------- */

    // 全部加 !important:ZAIKO 自己的全局 CSS 会覆盖 button 的背景和文字颜色,
    // 不顶回去的话确定按钮会变成白底白字,等于看不见。
    const css = `
    #gn-panel{position:fixed!important;top:12px!important;right:12px!important;left:auto!important;
      z-index:2147483647!important;width:min(340px,calc(100vw - 24px))!important;
      background:#fff!important;color:#222!important;border:1px solid #d0d0d0!important;
      border-radius:10px!important;box-shadow:0 6px 24px rgba(0,0,0,.22)!important;
      font:14px/1.6 -apple-system,"Segoe UI",sans-serif!important;margin:0!important;padding:0!important}
    #gn-panel *{box-sizing:border-box!important;font-family:inherit!important;margin:0!important}
    #gn-head{padding:10px 12px!important;background:#f5f5f5!important;border-bottom:1px solid #e5e5e5!important;
      border-radius:10px 10px 0 0!important;cursor:move!important;touch-action:none!important;
      display:flex!important;align-items:center!important;justify-content:space-between!important}
    #gn-head b{font-size:14px!important;color:#222!important}
    #gn-min{cursor:pointer!important;padding:2px 10px!important;color:#666!important;font-size:18px!important;line-height:1!important}
    #gn-body{padding:12px!important;max-height:70vh!important;overflow:auto!important;
      -webkit-overflow-scrolling:touch!important}
    #gn-list{border:1px solid #e5e5e5!important;border-radius:6px!important;margin-bottom:10px!important;
      max-height:180px!important;overflow:auto!important}
    .gn-item{display:flex!important;align-items:center!important;gap:8px!important;
      padding:10px!important;border-bottom:1px solid #f2f2f2!important;cursor:pointer!important}
    .gn-item:last-child{border-bottom:0!important}
    .gn-item:active{background:#eef4ff!important}
    .gn-item input[type=checkbox]{width:18px!important;height:18px!important;flex:0 0 auto!important;
      margin:0!important;accent-color:#e8590c!important}
    .gn-item code{flex:1!important;word-break:break-all!important;font-size:13px!important;color:#0a58ca!important}
    .gn-empty{padding:12px!important;color:#999!important;font-size:13px!important}
    #gn-panel input[type=text]{width:100%!important;padding:10px!important;border:1px solid #bbb!important;
      border-radius:6px!important;font-size:16px!important;background:#fff!important;color:#222!important;
      -webkit-appearance:none!important;appearance:none!important}
    #gn-opt{margin:8px 0 0!important;font-size:13px!important;color:#666!important;
      display:flex!important;align-items:center!important;gap:6px!important}
    #gn-opt input[type=checkbox]{width:16px!important;height:16px!important;accent-color:#e8590c!important}
    #gn-btns{display:flex!important;gap:8px!important;margin-top:10px!important}
    #gn-panel button{flex:1!important;padding:11px 0!important;border:1px solid #ccc!important;
      background:#f0f0f0!important;color:#333!important;border-radius:6px!important;cursor:pointer!important;
      font-size:14px!important;font-weight:600!important;-webkit-appearance:none!important;appearance:none!important;
      text-shadow:none!important;opacity:1!important;min-height:42px!important}
    #gn-panel button:active{filter:brightness(.92)!important}
    #gn-panel #gn-ok{background:#e8590c!important;border-color:#c4490a!important;color:#fff!important;
      flex:1.4!important;font-size:15px!important}
    #gn-panel #gn-undo{background:#fff!important;color:#c92a2a!important;border-color:#e8a3a3!important}
    #gn-msg{margin-top:8px!important;font-size:13px!important;color:#555!important;min-height:20px!important}
    #gn-fab{position:fixed!important;top:12px!important;right:12px!important;z-index:2147483647!important;
      padding:11px 14px!important;background:#e8590c!important;color:#fff!important;border-radius:8px!important;
      cursor:pointer!important;font:600 14px/1 sans-serif!important;display:none!important;
      box-shadow:0 3px 12px rgba(0,0,0,.25)!important}
    #gn-fab.gn-show{display:block!important}
    #gn-panel.gn-hide{display:none!important}
    @media (max-width:600px){
      #gn-panel{top:8px!important;left:8px!important;right:8px!important;width:auto!important}
      #gn-body{max-height:60vh!important}
    }
    `;
    document.head.appendChild(document.createElement('style')).textContent = css;

    const panel = document.createElement('div');
    panel.id = 'gn-panel';
    panel.innerHTML = `
      <div id="gn-head"><b>guest-name 修改器</b><span id="gn-min" title="收起">—</span></div>
      <div id="gn-body">
        <div id="gn-list"></div>
        <input type="text" id="gn-input" placeholder="在这里填新名字,回车或点确定">
        <label id="gn-opt"><input type="checkbox" id="gn-attr"> 同时写入 data-guest-name 属性</label>
        <div id="gn-btns">
          <button id="gn-ok">确定</button>
          <button id="gn-scan">重新扫描</button>
          <button id="gn-undo">还原</button>
        </div>
        <div id="gn-msg"></div>
      </div>`;
    document.body.appendChild(panel);

    const fab = document.createElement('div');
    fab.id = 'gn-fab';
    fab.textContent = 'guest-name';
    document.body.appendChild(fab);

    const $ = id => document.getElementById(id);
    const msg = t => { $('gn-msg').textContent = t; };
    const escapeHtml = s => String(s).replace(/[&<>"]/g,
        c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

    // 扫描:列出页面上所有 [data-guest-name] 当前显示的名字
    function scan() {
        const els = [...document.querySelectorAll(SELECTOR)];
        const values = [...new Set(els.map(el =>
            el.getAttribute(ORIG_ATTR) !== null ? el.getAttribute(ORIG_ATTR) : getText(el)
        ))].filter(v => v !== '');
        const list = $('gn-list');

        if (!values.length) {
            list.innerHTML = '<div class="gn-empty">当前页面没找到有名字的 [data-guest-name]。'
                + '切到显示姓名的那一页再点「重新扫描」。</div>';
            return;
        }

        list.innerHTML = values.map(v => `
          <label class="gn-item">
            <input type="checkbox" class="gn-cb" data-v="${encodeURIComponent(v)}" checked>
            <code>${escapeHtml(v)}</code>
          </label>`).join('');
        msg(`找到 ${els.length} 个节点 / ${values.length} 个不同的名字`);
    }

    function apply() {
        const newName = $('gn-input').value.trim();
        if (!newName) { msg('请先填写新名字'); return; }

        const checked = [...document.querySelectorAll('.gn-cb:checked')]
            .map(cb => decodeURIComponent(cb.dataset.v));
        if (!checked.length) { msg('请至少勾选一个要改的名字'); return; }

        alsoWriteAttr = $('gn-attr').checked;
        checked.forEach(v => rules.set(v, newName));
        saveRules();
        const n = runAll();
        msg(`已改写 ${n} 个节点,刷新后仍生效(点「还原」撤销)`);
        scan();
    }

    $('gn-ok').onclick = apply;
    $('gn-input').addEventListener('keydown', e => { if (e.key === 'Enter') apply(); });
    $('gn-scan').onclick = scan;
    $('gn-undo').onclick = () => { restoreAll(); msg('已还原并清空规则'); scan(); };

    // 收起 / 展开(CSS 里带 !important,只能用 class 切换,改 style.display 无效)
    $('gn-min').onclick = () => { panel.classList.add('gn-hide'); fab.classList.add('gn-show'); };
    fab.onclick = () => { panel.classList.remove('gn-hide'); fab.classList.remove('gn-show'); };

    // 拖动面板:用 pointer 事件,鼠标和手指都支持
    (function drag() {
        const head = $('gn-head');
        let sx = 0, sy = 0, ox = 0, oy = 0, moving = false;

        head.addEventListener('pointerdown', e => {
            if (e.target.id === 'gn-min') return;
            moving = true;
            const r = panel.getBoundingClientRect();
            sx = e.clientX; sy = e.clientY; ox = r.left; oy = r.top;
            head.setPointerCapture(e.pointerId);
            e.preventDefault();
        });

        head.addEventListener('pointermove', e => {
            if (!moving) return;
            // 限制在视口内,手机上别拖出屏幕捡不回来
            const w = panel.offsetWidth, h = panel.offsetHeight;
            const x = Math.min(Math.max(0, ox + e.clientX - sx), window.innerWidth - w);
            const y = Math.min(Math.max(0, oy + e.clientY - sy), window.innerHeight - 40);
            panel.style.setProperty('left', x + 'px', 'important');
            panel.style.setProperty('top', y + 'px', 'important');
            panel.style.setProperty('right', 'auto', 'important');
            e.preventDefault();
        });

        const stop = e => {
            if (!moving) return;
            moving = false;
            try { head.releasePointerCapture(e.pointerId); } catch (err) { }
        };
        head.addEventListener('pointerup', stop);
        head.addEventListener('pointercancel', stop);
    })();

    scan();
})();