NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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();
})();