// ==UserScript== // @name 新杀账号切换器 // @namespace sgs-account-switcher // @version 1.0.0 // @description 新三国杀(一将成名官服网页版)账号切换器:不论微信/QQ/手机号还是账号密码登录,存过一次即可一键换号,仅登录页生效 // @match *://web.sanguosha.com/login/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_cookie // @grant unsafeWindow // @run-at document-idle // @noframes // ==/UserScript== /* * 新杀账号切换器 · 1.0.0 * * 作用范围:只对新三国杀一将成名官服网页版(web.sanguosha.com)的登录页生效。 * * 解决的问题 * 微信、QQ、手机号这类方式登录,每换一个号就要重新走一遍:扫码、收验证码、 * 过验证……号一多,光登录就来回折腾。 * * 这个脚本就是冲着这一步来的:把当前账号的登录状态整体打包存成快照, * 之后点一下就换到另一个号,不用再扫一次码、再收一次验证码。 * * 它存的是「登录成功之后留在网页里的状态」(登录令牌、用户信息这些), * 跟当初用哪种方式登进去没关系——登录方式只决定第一次怎么进去, * 进去之后留下来的是同一套东西,所以微信登的、QQ 登的都能存、都能切。 * * 存号的流程(注意:换号前要先切回未登录) * 登录页一次只能停在一个账号上,登着 A 号的时候没法直接登 B 号, * 所以存第二个号之前,得先用「未登录」快照把页面清回未登录状态: * * ① 正常登进 A 号 → 点「保存账号」存下 A * ② 打开「切换账号」→ 点列表里的「未登录」(标着「内置」那条)回到干净登录页 * ③ 在干净登录页上登进 B 号 → 再点「保存账号」存下 B * ④ 以后 A、B 之间点一下就切,不用再重新登录 * * 「未登录」这条任何时候都能点,用它退出登录不会丢已存的账号。 * * 它的内容可以自己刷新:在没登录的状态下点「保存账号」,不会存出一个空账号, * 而是把当前页面存成新的「未登录」基准。游戏页面改版后,内置的那份可能不再适用, * 在登录页重刷一次即可生效,不必等脚本更新。 * * 做什么 * 保存账号 把当前页面的登录状态存成快照,同一账号只留一份,重复保存会覆盖 * 切换账号 账号列表点一行就切过去;也能改名、删除、导入导出、清空 * * 附带的排查工具 * 页面检测 看看网页里存了哪些数据、元素都摆在哪。切换出问题时用它对着看, * 平时用不到。 * * 快照里存什么 * 登录凭证(登录令牌、用户信息)、以及网页存储的其它键值。 * 有三类东西刻意不存,理由见「不该跟着快照走的三类东西」那组常量: * · 一次性会话凭证——当场生成才有意义,存下来是过期数据 * · 设备指纹——是本机的身份,装别人的会让风控认错人 * · 统计埋点——是别人的数据,跟着账号走会污染记录 * 这三条从存入到取出共四道关口:采集时不收,导入时筛掉,写回浏览器前再统一过一遍, * 导出时最后再剔一次——所以无论是快照库还是导出文件,都不会带着它们流出去。 * 话虽如此,四道筛子防的是「经手过的数据不干净」,不是「库里的东西天然干净」: * 手工改过的、别人给的快照文件里可能本来就带着这些键,导入后会长在库里, * 切号不受影响(写回前会被筛掉),但它们会跟着导出文件流出去—— * 导出时那道筛子就是为了堵这个。 * * 入口在哪 * 装好后打开登录页,右下角会出现一个小窗:点「保存账号」存当前号, * 点「切换账号」打开面板选要切的号。油猴菜单里有同样的一对入口。 * * 一处环境限制 * 登录状态若存在 httpOnly Cookie 里,读写都要靠 GM_cookie, * 而 Tampermonkey 仅在测试版支持 httpOnly。稳定版上这类 Cookie * 会读写失败,脚本会照实报出来,不会假装成功。 */ (function () { 'use strict'; const win = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; const doc = win.document; if (win.top !== win.self) return; /* ==================== 常量 ==================== */ const SNAPSHOT_SCHEMA = 'sgs-snapshot/v1'; const BUNDLE_SCHEMA = 'sgs-snapshot-bundle/v1'; const INTERNAL_PREFIX = '__sgsSwitcher__'; const CORE_AUTH_KEYS = [ /^\$SGS_LOGIN_TOKEN$/, /^SGS_USERINFO_KEY$/, /^SGS_USER_CHOOSE_GAME$/, /^sgsGame\./i, /SGS_?LOGIN/i, /SGS_?USERINFO/i, /SGS_?TOKEN/i, ]; const WIDE_AUTH_KEYS = [ /login/i, /token/i, /auth/i, /session/i, /passport/i, /userinfo/i, /user_?id/i, /^uid$/i, /account/i, /open_?id(?![a-z])/i, ]; const NOISE_KEYS = [ /setting/i, /config/i, /cache/i, /guide/i, /music/i, /sound/i, /volume/i, /skin/i, /theme/i, /bgm/i, /resolution/i, /quality/i, /lang/i, /locale/i, /notice/i, /tip/i, /record/i, /history/i, /rank/i, /shop/i, /bag/i, /mail/i, /task/i, /activity/i, /cdkey/i, /version/i, /last[_-]?login/i, /first[_-]?login/i, /login_?(time|count|times)/i, ]; // ===== 内置快照:未登录默认账号 ===== // 登录页一次只能停在一个账号上,换号前得先回到未登录状态。这份快照就是那个 // 「回到干净登录页」的入口,任何时候都可用,点它不会丢掉已存的账号。 // // 下面的数据是干净的:不该跟着快照走的三类键在写进源码前就剔除了,不含敏感值。 // 但用户可以在未登录状态下点「保存账号」把它刷新成当前页面的样子(见 saveGuestBaseline), // 刷出来的那份存在脚本自己的变量里,是数据、不是代码——所以取用时统一走 // normalizeSnapshot 过一遍筛子。它同时也管着导入的外部快照,两处共用同一套规则。 const BUILT_IN_SNAPSHOTS = [{"schema":"sgs-snapshot/v1","label":"未登录","time":"2026-09-16T10:50:47.641Z","url":"https://web.sanguosha.com/login/x/index","origin":"https://web.sanguosha.com","frames":[{"label":"主窗口","localStorage":{"LOCAL_BACKGROUND_IMG_VIDEO":"{\"100\":{\"web\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202410/0coLot69DUNgwx31.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202410/CEsPccNQggPgZIQ2.png\",\"update_time\":1729731544},\"client\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202410/0coLot69DUNgwx31.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202410/CEsPccNQggPgZIQ2.png\",\"update_time\":1729731544}},\"1100\":{\"web\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923},\"client\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923}},\"1100_unity\":{\"web\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923},\"client\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923}},\"100_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2026020318330318.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2026020318330798.png\"},\"1100_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2026020410342150.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2026020410342680.png\"},\"100client_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2024112213513089.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2025092318382396.png\"},\"1100_unity_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2026072105191328.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2026072105205224.png\"}}"},"sessionStorage":{}}],"localStorage":{"LOCAL_BACKGROUND_IMG_VIDEO":"{\"100\":{\"web\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202410/0coLot69DUNgwx31.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202410/CEsPccNQggPgZIQ2.png\",\"update_time\":1729731544},\"client\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202410/0coLot69DUNgwx31.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202410/CEsPccNQggPgZIQ2.png\",\"update_time\":1729731544}},\"1100\":{\"web\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923},\"client\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923}},\"1100_unity\":{\"web\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923},\"client\":{\"mp4\":\"https://wwwoss.sanguosha.com/uploads/video/202608/8gx7yniSXPxz6Fdk.mp4\",\"img\":\"https://wwwoss.sanguosha.com/uploads/202608/aDIyGSQeQ3nlSt6b.png\",\"update_time\":1787307923}},\"100_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2026020318330318.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2026020318330798.png\"},\"1100_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2026020410342150.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2026020410342680.png\"},\"100client_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2024112213513089.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2025092318382396.png\"},\"1100_unity_game\":{\"img\":\"https://wwwoss.sanguosha.com/10018/2026072105191328.png\",\"flag_img\":\"https://wwwoss.sanguosha.com/10018/2026072105205224.png\"}}"},"sessionStorage":{},"cookies":[],"cookieDoc":"","account":{"nickname":"","name":""},"accountId":"","savedAt":1789555847645,"id":1}]; // ===== 不该跟着快照走的三类东西 ===== // 这三类语义完全不同,必须分开对待,否则切换结果会对不上: // ① 设备指纹:标识本机(SGS_DEVICEID / SGSDEVICEID / _SPARE 系列) // · 不写入:把别人的设备 ID 装到本机,风控会认错人 // · 不清除:清掉之后游戏会立即重新生成一个,切换等于没做,还可能被当成新设备 const DEVICE_COOKIE_RE = /^(sgs_?deviceid(_?spare)?|deviceid|clientid|spare)/i; // ② 统计 / 埋点:百度统计的 Hm_lvt / Hm_lpvt、HMACCOUNT、trace 之类 // · 不写入:别人的统计 ID 跟本机没关系,装过来只会污染数据 // · 要清除:切号就该让统计归零重新计,否则新旧账号的来访记录混在一起 // 注意这条同时管 Cookie 和 localStorage / sessionStorage 里的同名键, // 两侧的统计值必须一起处理,只清 Cookie 不管存储等于没清(游戏会从存储里读回来)。 const STATS_KEY_RE = /^(hm_|hmaccount|trace)/i; // 不跟着快照走的 Cookie:设备指纹(是本机的)和统计值(是别人的)都不写回浏览器 const isSkippedCookie = (name) => !name || DEVICE_COOKIE_RE.test(name) || STATS_KEY_RE.test(name); // ③ 一次性会话凭证:滑块、风控握手这类值当场生成才有效,写死别人的值会让验证失败。 // 游戏自己会重建,所以既不存进快照,也不写回浏览器。 const ONE_SHOT_KEYS = /^(point|slider|__imt_handshake_page_id|__imt_[a-z_]*)$/i; const DEFAULT_SETTINGS = { showWidget: true, autoReload: true, quickSwitch: true, }; const RELOAD_DELAY_MS = 800; // 超过这个体积的非凭证缓存不入快照(可再生,游戏会自动重建) const BIG_CACHE_SKIP = 512 * 1024; const DB_NAME = 'sgs-switcher'; const DB_STORE = 'snapshots'; const DB_VER = 1; const DIAG_CACHE_KEY = 'sgs_diag_last_result'; const DIAG_UI_CACHE_KEY = 'sgs_ui_last_result'; const WIDGET_ID = 'sgs-mini-widget'; // 脚本自己的界面,扫描页面布局时要排除掉 const SELF_UI_SELECTOR = `#${WIDGET_ID}, [data-sgsx]`; const FONT = '-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif'; // ===== 文案等级标准(全脚本统一,改文案前先看这里) ===== // 一级 · 标题:弹窗/面板大标题,15px 加粗(全脚本唯一允许加粗的地方) // 二级 · 小节:区块小标题(列表 / 设置 / 操作),14px 正常字色,不加粗不置灰 // 三级 · 条目:账号名、按钮、开关行文字,13px 常规字重,正常字色 // 四级 · 辅助:时间、徽标、说明文字,12px 灰字 rgba(128,128,128,.9) // 规矩:字号随等级递减(15 > 14 > 13 > 12);只有一级标题加粗;灰色只给四级辅助用。 // 字号和灰色统一由下面的常量提供,界面样式不要在别处手写,改也只改这里。 const T = { title: 'font-weight:600;font-size:15px', // 一级 section: 'font-size:14px', // 二级 item: 'font-size:13px', // 三级 aux: 'font-size:12px;color:rgba(128,128,128,.9)', // 四级 badge: 'font-size:12px;color:rgba(128,128,128,.9);background:#f0f0f0;padding:2px 10px;border-radius:10px', }; const Z_WIDGET = 2147483645; const Z_MASK = 2147483646; /* ==================== 通用工具 ==================== */ const enc = new TextEncoder(); const byteLen = (s) => enc.encode(String(s == null ? '' : s)).length; const pad2 = (n) => String(n).padStart(2, '0'); function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])); } function sizeText(n) { if (n < 1024) return n + 'B'; if (n < 1048576) return (n / 1024).toFixed(1) + 'KB'; return (n / 1048576).toFixed(2) + 'MB'; } function fmtTime(v) { if (!v) return '-'; const d = typeof v === 'number' ? new Date(v) : new Date(String(v).replace(' ', 'T')); if (isNaN(d.getTime())) return String(v); return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`; } // ===== 导出命名:最小命名法 ===== // 规则:<是什么>-<时间>.<后缀>,全脚本一个来源 // 中文短名(账号快照/数据检查/页面检查)一看就懂是啥,时间只到分钟(秒级对辨认没意义) // 不加前缀——文件名本身就是给人看的,多一段英文缩写反而碍眼 function fileStamp() { const d = new Date(); return d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + '_' + pad2(d.getHours()) + pad2(d.getMinutes()); } function outName(what, ext) { return what + '-' + fileStamp() + '.' + (ext || 'txt'); } function tryParse(v) { if (typeof v !== 'string') return null; const s = v.trim(); if (!s || (s[0] !== '{' && s[0] !== '[' && s[0] !== '"')) return null; try { return JSON.parse(s); } catch (e) { return null; } } function isAuthKey(k) { if (!k || typeof k !== 'string') return false; if (NOISE_KEYS.some((re) => re.test(k))) return false; if (CORE_AUTH_KEYS.some((re) => re.test(k))) return true; return WIDE_AUTH_KEYS.some((re) => re.test(k)); } // 统一按钮样式:弹窗、面板、浮窗共用一套,避免每处各拼一串 cssText function mkButton(text, onClick, opts) { const o = opts || {}; const b = doc.createElement('button'); b.textContent = text; b.style.cssText = [ 'padding:' + (o.small ? '4px 8px' : '7px 14px'), 'font-size:' + (o.small ? '12px' : '13px'), 'border-radius:' + (o.small ? '4px' : '6px'), 'cursor:pointer', 'background:#fff', 'flex:0 0 auto', o.danger ? 'border:1px solid #f9a;color:#b00' : 'border:1px solid #ccc;color:#333', ].join(';'); b.onclick = onClick; return b; } // 统一下载入口(快照 JSON 与检测文本走同一条路) function downloadFile(filename, text, mime) { const type = mime || 'text/plain;charset=utf-8'; const clickLink = (href, revoke) => { try { const a = doc.createElement('a'); a.href = href; a.download = filename; a.style.display = 'none'; (doc.body || doc.documentElement).appendChild(a); a.click(); win.setTimeout(() => { try { a.remove(); } catch (e) {} if (revoke) { try { win.URL.revokeObjectURL(href); } catch (e) {} } }, 300); return true; } catch (e) { return false; } }; try { const blob = new win.Blob([text], { type }); const url = win.URL.createObjectURL(blob); if (url) return clickLink(url, true); } catch (e) {} try { return clickLink('data:' + type + ';charset=utf-8,' + encodeURIComponent(text), false); } catch (e) {} return false; } function downloadJSON(obj, filename) { return downloadFile(filename, JSON.stringify(obj, null, 2), 'application/json'); } /* ==================== 设置 ==================== */ function getSettings() { let s = Object.assign({}, DEFAULT_SETTINGS); try { const saved = typeof GM_getValue === 'function' ? GM_getValue('settings', null) : null; if (saved && typeof saved === 'object') s = Object.assign(s, saved); } catch (e) {} return s; } function saveSettings(patch) { const s = Object.assign(getSettings(), patch); try { if (typeof GM_setValue === 'function') GM_setValue('settings', s); } catch (e) {} return s; } let settings = getSettings(); /* ==================== 采集 ==================== */ function collectTargets() { const list = [{ win, label: '主窗口', src: win.location.href }]; let n = 0; try { doc.querySelectorAll('iframe').forEach((f) => { try { const w = f.contentWindow; if (!w || !w.localStorage) return; void w.localStorage.length; // 只给「能访问」的框架编号:跨源框架读不了,若按 querySelectorAll 下标编号 // 会把序号挤歪,页面结构一变快照就对不上框架 n += 1; list.push({ win: w, label: '框架#' + n, src: f.src || '' }); } catch (e) {} }); } catch (e) {} return list; } // 不该进快照、也不该写回浏览器的键:一次性凭证 + 统计埋点 function isSkippedKey(k) { if (!k || typeof k !== 'string') return true; if (k.indexOf(INTERNAL_PREFIX) === 0) return true; // 滑块/握手这类一次性凭证只当场有效,存下来也是过期数据,还会干扰验证 if (ONE_SHOT_KEYS.test(k)) return true; // 统计值和 Cookie 侧保持同一待遇:不跟账号走,切号时归零重建 if (STATS_KEY_RE.test(k)) return true; return false; } function stripSkipped(obj) { const out = {}; for (const [k, v] of Object.entries(obj || {})) { if (isSkippedKey(k)) continue; out[k] = v; } return out; } function dumpStore(store) { const out = {}; try { for (let i = 0; i < store.length; i++) { const k = store.key(i); if (isSkippedKey(k)) continue; const v = store.getItem(k); // 大体积非凭证缓存(如皮肤配置目录)不入快照,避免写满 localStorage 配额 if (!isAuthKey(k) && byteLen(v) > BIG_CACHE_SKIP) continue; out[k] = v; } } catch (e) {} return out; } function dumpCookies() { const arr = []; try { if (!doc.cookie) return arr; doc.cookie.split(';').forEach((pair) => { const idx = pair.indexOf('='); if (idx < 0) return; const name = pair.slice(0, idx).trim(); if (!name) return; arr.push({ name, value: pair.slice(idx + 1).trim(), domain: win.location.hostname, path: '/' }); }); } catch (e) {} return arr; } function parseCookieDoc(d) { if (!d) return []; return String(d).split(';').map((s) => { const i = s.indexOf('='); if (i < 0) return null; const name = s.slice(0, i).trim(); if (!name) return null; return { name, value: s.slice(i + 1).trim(), domain: win.location.hostname, path: '/' }; }).filter(Boolean); } /* ==================== 账号识别(只取 昵称 / 账号,不含 UID / 区服) ==================== */ const LOGIN_NAME_RE = /^(inputUserId|accountName|userName|username|loginName|realName)$/i; const NICK_RE = /^(displayName|nickName|nickname|nick|selfNickname|roleName|playerName)$/i; // 「账号」只从上面 LOGIN_NAME_RE 命中的字段取。 // sndaId / userId / uid / accountId / roleId / gameUid / server 是游戏编号和区服,不是登录账号, // 取错了会把编号当成账号显示给人看,所以全部忽略。 function pickFrom(obj, re) { if (!obj || typeof obj !== 'object') return ''; for (const k of Object.keys(obj)) { if (!re.test(k)) continue; const v = obj[k]; if (typeof v === 'string' && v) return v; if (typeof v === 'number') return String(v); } return ''; } // 主窗口的数据优先(frames[0] 是主窗口,最后覆盖进去) function mergeFrames(snap, field) { if (Array.isArray(snap.frames) && snap.frames.length) { const out = {}; for (let i = snap.frames.length - 1; i >= 0; i--) Object.assign(out, snap.frames[i][field] || {}); return out; } return snap[field] || {}; } const mergedLS = (snap) => mergeFrames(snap, 'localStorage'); const mergedSS = (snap) => mergeFrames(snap, 'sessionStorage'); function identifyAccount(snap) { // 只返回 { nickname, name }——显示和认亲都用这两样,编号一律不参与 const info = { nickname: '', name: '' }; if (!snap) return info; const ls = mergedLS(snap); const ss = mergedSS(snap); // 1) SGS_USERINFO_KEY const ui = tryParse(ls['SGS_USERINFO_KEY']) || tryParse(ss['SGS_USERINFO_KEY']); if (ui && typeof ui === 'object') { info.name = pickFrom(ui, LOGIN_NAME_RE) || ''; info.nickname = pickFrom(ui, NICK_RE) || ''; } // 2) SGS_USER_CHOOSE_GAME 的 key 是登录账号 const ch = tryParse(ls['SGS_USER_CHOOSE_GAME']) || tryParse(ss['SGS_USER_CHOOSE_GAME']); if (ch && typeof ch === 'object') { const keys = Object.keys(ch); if (keys.length && !info.name) info.name = keys[0]; } // 3) 通用扫描补昵称/账号 if (!info.nickname || !info.name) { const pairs = [...Object.entries(ls), ...Object.entries(ss)]; for (const [k, v] of pairs) { if (/recentGamePlayer|friend|guild|skin-config|protectTime|SGS_USERINFO_KEY|SGS_USER_CHOOSE_GAME/i.test(k)) continue; if (typeof v !== 'string') continue; const o = tryParse(v); if (!o || typeof o !== 'object') continue; if (!info.name) info.name = pickFrom(o, LOGIN_NAME_RE) || ''; if (!info.nickname) info.nickname = pickFrom(o, NICK_RE) || ''; if (info.name && info.nickname) break; } } // 登录账号(ID)是稳定不变的,原样保留;昵称只是显示用 return info; } // 显示规则:昵称 · 账号(两个都有且不一样才一起显示,缺哪个显示哪个) function accountText(info) { if (!info) return ''; const nick = info.nickname || ''; const name = info.name || ''; if (nick && name && nick !== name) return nick + ' · ' + name; return nick || name; } // ===== 从游戏自己的数据库里找昵称 ===== // 登录页的账号数据里往往只有登录账号,游戏昵称存在网页数据库里,读得到就用它命名 const NICK_FIELDS = /^(displayName|nickName|nickname|nick|selfNickname|roleName|playerName|userName)$/i; function findNick(v, accountName, depth) { if (depth > 4) return ''; if (typeof v === 'string') { const o = tryParse(v); return o ? findNick(o, accountName, depth + 1) : ''; } if (!v || typeof v !== 'object') return ''; for (const k of Object.keys(v)) { if (NICK_FIELDS.test(k)) { const val = v[k]; if (typeof val === 'string' && val && val.length <= 40 && val !== accountName) return val; } } for (const k of Object.keys(v)) { const child = v[k]; if (child && typeof child === 'object') { const r = findNick(child, accountName, depth + 1); if (r) return r; } } return ''; } async function guessNickname(accountName) { // 先从页面直接读:登录页上有 #nickName / .nickname 元素,里面就是游戏昵称 try { const els = doc.querySelectorAll('#nickName, .nickname, .account-info .nickname'); for (const el of els) { const t = String(el.textContent || '').trim(); if (t && t.length <= 40 && t !== accountName) return t; } } catch (e) {} // 页面上读不到,再查游戏自己的数据库 try { if (!win.indexedDB || !win.indexedDB.databases) return ''; const dbs = await win.indexedDB.databases(); const targets = dbs.filter((d) => /gameuserinfo|generalinfo|jnd-account-state|jnd-runtime-cache/i.test(d.name || '')); for (const d of targets) { let db = null; try { db = await new Promise((res, rej) => { const req = win.indexedDB.open(d.name); req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); const storeNames = Array.from(db.objectStoreNames || []); for (const sn of storeNames) { const vals = await new Promise((res) => { try { const tx = db.transaction(sn, 'readonly'); const req = tx.objectStore(sn).getAll(); req.onsuccess = () => res((req.result || []).slice(0, 30)); req.onerror = () => res([]); } catch (e) { res([]); } }); for (const v of vals) { const nick = findNick(v, accountName, 0); if (nick) return nick; } } } catch (e) {} finally { try { if (db) db.close(); } catch (e) {} } } } catch (e) {} return ''; } /* ==================== 快照构造 / 归一化 ==================== */ async function makeSnapshot(label) { const frames = collectTargets().map((t) => ({ label: t.label, src: t.src || '', localStorage: dumpStore(t.win.localStorage), sessionStorage: dumpStore(t.win.sessionStorage), })); const snap = { schema: SNAPSHOT_SCHEMA, label: label || '快照 ' + new Date().toLocaleString(), time: new Date().toISOString(), url: win.location.href, origin: win.location.origin, frames, localStorage: frames[0] ? frames[0].localStorage : {}, sessionStorage: frames[0] ? frames[0].sessionStorage : {}, cookies: await collectCookies(), cookieDoc: doc.cookie || '', }; snap.account = identifyAccount(snap); // 尽量拿到游戏昵称,拿不到就保持登录账号 const nick = await guessNickname(snap.account.name || snap.account.nickname || ''); if (nick) snap.account.nickname = nick; // 稳定身份:登录账号,认亲只认它 snap.accountId = snap.account.name || snap.account.nickname || ''; return snap; } function filterInternal(obj) { const out = {}; for (const [k, v] of Object.entries(obj || {})) { if (String(k).indexOf(INTERNAL_PREFIX) === 0) continue; out[k] = v; } return out; } function finalize(raw) { const frames = Array.isArray(raw.frames) ? raw.frames.map((f) => ({ label: f.label || '主窗口', src: f.src || '', // 导入的数据跟采集侧同一口径:一次性凭证、统计埋点在这里就剔掉, // 否则它们会跟着进快照库,导出时又原样带出去 localStorage: stripSkipped(filterInternal(f.localStorage)), sessionStorage: stripSkipped(filterInternal(f.sessionStorage)), })) : null; // frames 是空数组时不能当成「有 frames」用,否则 frames[0] 是 undefined,读属性会崩 const head = (frames && frames.length) ? frames[0] : null; const snap = { schema: SNAPSHOT_SCHEMA, label: raw.label || '导入快照', time: raw.time || new Date().toISOString(), url: raw.url || win.location.href, origin: raw.origin || win.location.origin, frames, localStorage: head ? (head.localStorage || {}) : stripSkipped(filterInternal(raw.localStorage)), sessionStorage: head ? (head.sessionStorage || {}) : stripSkipped(raw.sessionStorage || {}), cookieDoc: raw.cookieDoc || '', cookies: [], }; const ck = raw.cookies || raw.cookieAll || parseCookieDoc(raw.cookieDoc); // 导入的数据同样按「不该跟着快照走的三类东西」筛一遍,跟采集侧口径一致 snap.cookies = (Array.isArray(ck) ? ck : []).filter((c) => c && c.name && !isSkippedCookie(c.name)); // 账号信息一律现算,不采信原始 JSON 里可能存在的 gameUid / server / id 字段 snap.account = identifyAccount(snap); snap.accountId = snap.account.name || snap.account.nickname || ''; return snap; } function normalizeSnapshot(raw) { if (!raw || typeof raw !== 'object') throw new Error('不是 JSON 对象'); if (raw.schema === SNAPSHOT_SCHEMA || raw.frames || raw.localStorage || raw.sessionStorage) return finalize(raw); if (raw.cookieDoc || raw.cookieAll) return finalize(raw); throw new Error('缺少 localStorage / frames / cookieDoc / cookieAll'); } function normalizeBundle(raw) { if (!raw) return null; if (raw.schema === BUNDLE_SCHEMA && Array.isArray(raw.snapshots)) return raw.snapshots.map(normalizeSnapshot); if (!raw.schema && Array.isArray(raw)) return raw.map(normalizeSnapshot); if (!raw.schema && Array.isArray(raw.snapshots)) return raw.snapshots.map(normalizeSnapshot); return null; } function looseParse(text) { let s = String(text || '').replace(/^\uFEFF/, '').trim(); const fence = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(s); if (fence) s = fence[1].trim(); try { return JSON.parse(s); } catch (e) {} try { return JSON.parse(s.replace(/,\s*([}\]])/g, '$1')); } catch (e2) { throw new Error('JSON 解析失败:' + e2.message); } } /* ==================== 未登录基准 ==================== */ // 「未登录」这条快照有两个来源,取用时优先用存下来的那份: // ① 刷新过的——在未登录状态下点「保存账号」会用当前页面覆盖它(见 saveGuestBaseline); // ② 源码里内置的那份——还没刷新过、或存的那份损坏了,用它兜底。 // 刷新过的存在脚本自己的变量里(GM 存储),所以是数据而非代码,取用时统一走 // normalizeSnapshot 过一遍筛子;规则与「不该跟着快照走的三类东西」同源。 // 页面改版后内置的那份会失效,在登录页重刷一次即可,不必等脚本更新。 const GUEST_KEY = 'sgs_guest_baseline'; let builtInCache = null; // 取当前生效的「未登录」原始数据:优先刷新的那份,没有就用源码内置的 function readGuestBaseline() { try { const saved = typeof GM_getValue === 'function' ? GM_getValue(GUEST_KEY, null) : null; if (saved) { const s = typeof saved === 'string' ? JSON.parse(saved) : saved; if (s && (s.frames || s.localStorage)) return s; } } catch (e) {} return (BUILT_IN_SNAPSHOTS || [])[0] || null; } // 未登录状态下点「保存账号」会走到这里:把当前页面存成新的「未登录」基准。 // 身份字段(id / account)一律丢掉——它代表的是「没有账号」,不是某一个账号。 function saveGuestBaseline(snap) { const store = JSON.parse(JSON.stringify(snap)); delete store.id; delete store.account; delete store.accountId; store.label = '未登录'; store.builtIn = true; try { if (typeof GM_setValue === 'function') GM_setValue(GUEST_KEY, JSON.stringify(store)); builtInCache = null; return true; } catch (e) { return false; } } // 给界面用的「未登录」快照,缓存起来避免每次开面板都重新解析 function builtInSnapshots() { if (builtInCache) return builtInCache; const raw = readGuestBaseline(); builtInCache = raw ? [normalizeSnapshot(raw)] : []; return builtInCache; } /* ==================== 写入 / 恢复 ==================== */ function sortEntries(entries) { // 凭证优先、小的优先:配额不够时先保证登录凭证落盘 return entries.slice().sort((a, b) => { const av = isAuthKey(a[0]) ? 0 : 1; const bv = isAuthKey(b[0]) ? 0 : 1; if (av !== bv) return av - bv; return String(a[1] == null ? '' : a[1]).length - String(b[1] == null ? '' : b[1]).length; }); } function writeStore(store, obj) { const failed = []; for (const [k, v] of sortEntries(Object.entries(obj || {}))) { const val = v == null ? '' : String(v); try { store.setItem(k, val); if (store.getItem(k) !== val) failed.push({ key: k, val, size: byteLen(val), error: 'verify-failed' }); } catch (e) { failed.push({ key: k, val, size: byteLen(val), error: String((e && e.name) || e) }); } } return failed; } function retryWithEviction(store, failed) { const still = []; let freed = 0, retried = 0; const victims = []; try { for (let i = 0; i < store.length; i++) { const k = store.key(i); if (!k || isAuthKey(k)) continue; victims.push([k, byteLen(store.getItem(k) || '')]); } } catch (e) {} victims.sort((a, b) => b[1] - a[1]); for (const f of failed) { if (!isAuthKey(f.key)) { still.push(f); continue; } const val = f.val == null ? '' : f.val; let ok = false; while (victims.length && !ok) { const [k, size] = victims.shift(); try { store.removeItem(k); freed += size; } catch (e) {} try { store.setItem(f.key, val); ok = store.getItem(f.key) === val; } catch (e) {} if (freed > 4 * 1024 * 1024) break; } if (ok) retried++; else still.push(f); } return { still, freed, retried }; } function cookieDomainVariants() { const host = win.location.hostname; const set = new Set([host, '.' + host]); const parts = host.split('.'); for (let i = 0; i < parts.length - 1; i++) { const d = parts.slice(i).join('.'); set.add(d); set.add('.' + d); } return [...set]; } function pathVariants() { const p = win.location.pathname || '/'; const set = new Set(['/']); let cur = ''; for (const s of p.split('/').filter(Boolean)) { cur += '/' + s; set.add(cur); set.add(cur + '/'); } return [...set]; } // ===== GM_cookie 薄封装 ===== // document.cookie 既看不见也删不掉 httpOnly Cookie,而登录态很可能就藏在其中: // 只靠它来清,切号 / 退出登录都不会干净。所以能用 GM_cookie 就优先用,用不了再退回它。 // 注意:Tampermonkey 官方文档写明 httpOnly 仅测试版支持,稳定版读不到也写不了; // 另外部分环境需要在扩展设置里放开「Allow scripts to access cookies」。 // 因此下述失败均为功能降级,不代表脚本异常。 const gmCookie = (typeof GM_cookie !== 'undefined' && GM_cookie) ? GM_cookie : null; const cookieUrl = (c) => win.location.origin + ((c && c.path && c.path !== '/') ? c.path : '/'); // 兜底超时:接口调不通时不能让切换一直卡着。清 Cookie 是批量并发的,这个上限即总耗时上限。 const GM_TIMEOUT_MS = 600; function gmCookieList() { if (!gmCookie || typeof gmCookie.list !== 'function') return Promise.resolve(null); return new Promise((resolve) => { let settled = false; const finish = (v) => { if (!settled) { settled = true; resolve(v); } }; try { gmCookie.list({}, (list, err) => finish(err || !Array.isArray(list) ? null : list)); win.setTimeout(() => finish(null), GM_TIMEOUT_MS); } catch (e) { finish(null); } }); } function gmCookieDelete(c) { if (!gmCookie || typeof gmCookie.delete !== 'function') return Promise.resolve(false); return new Promise((resolve) => { let settled = false; const finish = (v) => { if (!settled) { settled = true; resolve(v !== false); } }; try { gmCookie.delete({ name: c.name, url: cookieUrl(c) }, (err) => finish(!err)); // 有些版本出错时不回调,给个兜底超时 win.setTimeout(() => finish(false), GM_TIMEOUT_MS); } catch (e) { resolve(false); } }); } function gmCookieSet(c) { if (!gmCookie || typeof gmCookie.set !== 'function') return Promise.resolve(false); return new Promise((resolve) => { let settled = false; const finish = (v) => { if (!settled) { settled = true; resolve(v !== false); } }; try { const d = { name: c.name, value: c.value == null ? '' : c.value, url: cookieUrl(c), path: c.path || '/', }; if (c.secure) d.secure = true; if (c.httpOnly) d.httpOnly = true; if (c.sameSite) d.sameSite = c.sameSite; // 过期时间只在还有效时才带上:把已经过期的日期写回去,浏览器会直接扔掉这条 const exp = c.expires != null ? (typeof c.expires === 'number' ? c.expires : Math.floor(new Date(c.expires).getTime() / 1000)) : null; if (exp && exp * 1000 > Date.now() + 60000) d.expirationDate = exp; gmCookie.set(d, (err) => finish(!err)); win.setTimeout(() => finish(false), GM_TIMEOUT_MS); } catch (e) { resolve(false); } }); } // 用过期把看得见的 Cookie 清掉(httpOnly 的清不掉,交给 gmCookieDelete) function expireCookie(c) { const doms = cookieDomainVariants(), paths = pathVariants(); const dead = '=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; doms.forEach((d) => { doc.cookie = `${c.name}${dead}; path=/; domain=${d}`; }); paths.forEach((pt) => { doc.cookie = `${c.name}${dead}; path=${pt}`; }); doc.cookie = `${c.name}${dead}; path=/`; } // 采集要存进快照的 Cookie:document.cookie 看不见 httpOnly,而登录态常常就藏在 httpOnly 里, // 存不下来就切不回去。GM_cookie 能读到全部(BETA 版才含 httpOnly),读不到就退回 document.cookie。 // 设备指纹和统计埋点在这里就剔掉:它们不该跟着快照走,收进来只会让快照库和导出文件藏着脏数据。 async function collectCookies() { const byName = new Map(); const put = (c) => { if (!c || !c.name) return; if (isSkippedCookie(c.name)) return; byName.set(c.name, c); }; dumpCookies().forEach(put); const all = await gmCookieList(); if (all) all.forEach((c) => put({ name: c.name, value: c.value, domain: c.domain || win.location.hostname, path: c.path || '/', secure: !!c.secure, httpOnly: !!c.httpOnly, sameSite: c.sameSite || '', expires: c.expirationDate != null ? c.expirationDate : null, })); return [...byName.values()]; } // 清掉当前所有 Cookie,只有设备指纹留着 // 能拿到 httpOnly 列表时连隐藏的一起清;统计 Cookie 照清(切号归零重建) async function clearAllCookies() { const all = await gmCookieList(); const list = all || dumpCookies(); const seen = new Set(); // 删除请求并发发出:逐个 await 的话,每个 Cookie 最坏要等一次超时, // 十几个 Cookie 会让切换多等十几秒 const jobs = []; for (const c of list) { if (!c || !c.name || seen.has(c.name)) continue; // 设备指纹是本机身份,清掉反而会被当成新设备,留着 if (DEVICE_COOKIE_RE.test(c.name)) continue; seen.add(c.name); if (all) jobs.push(gmCookieDelete(c).catch(() => false)); expireCookie(c); } if (jobs.length) await Promise.all(jobs); return seen.size; } function adaptDomain(d) { if (!d) return null; const host = win.location.hostname; const bare = String(d).replace(/^\./, ''); if (host === bare) return null; if (host.endsWith('.' + bare)) return '.' + bare; return null; } async function restoreCookies(list) { const failed = []; for (const c of (list || [])) { if (!c || !c.name) continue; // 设备指纹不写(是本机的),统计值不写(是别人的),两者都不计入失败 if (isSkippedCookie(c.name)) continue; // httpOnly 只能靠 GM_cookie 写回,写不进去要如实记录。 // 稳定版 Tampermonkey 不支持 httpOnly,这里必然失败——属于环境限制,不是逻辑错误; // 但登录态若真的依赖这条 Cookie,切换就会失败,所以仍然照实报出来。 if (c.httpOnly) { const ok = await gmCookieSet(c); if (!ok) failed.push({ name: c.name, error: 'httpOnly 写不回去(需要 Tampermonkey 测试版)' }); continue; } try { if (/^__Host-/i.test(c.name)) { if (win.location.protocol === 'https:') doc.cookie = `${c.name}=${c.value}; path=/; secure`; else failed.push({ name: c.name, error: 'needs-https' }); continue; } let str = `${c.name}=${c.value == null ? '' : c.value}; path=${c.path || '/'}`; const d = adaptDomain(c.domain); if (d) str += `; domain=${d}`; if (c.secure) str += '; secure'; if (c.sameSite) str += `; samesite=${c.sameSite}`; if (c.expires != null) { const dd = typeof c.expires === 'number' ? new Date(c.expires * 1000) : new Date(c.expires); if (!isNaN(dd.getTime())) str += `; expires=${dd.toUTCString()}`; } else if (c.maxAge != null) str += `; max-age=${c.maxAge}`; doc.cookie = str; } catch (e) { failed.push({ name: c.name, error: String(e) }); } } return failed; } function readProtectTime() { let best = null; const now = Date.now(); try { for (let i = 0; i < win.localStorage.length; i++) { const k = win.localStorage.key(i); if (!k || !/protectTime/i.test(k)) continue; const o = tryParse(win.localStorage.getItem(k)); if (!o) continue; let t = null, sysTime = null; if (o.protectTime && typeof o.protectTime === 'object') { t = o.protectTime.leftTime; sysTime = o.protectTime.systemTime; } else if (o.protectTime != null) t = o.protectTime; else if (o.leftTime != null) t = o.leftTime; t = Number(t); if (!(t > 0)) continue; if (sysTime && now - Number(sysTime) > t * 1000) continue; if (best == null || t > best) best = t; } } catch (e) {} return best; } // 快照里的框架 → 当前页面的窗口:先按名字,再按地址,最后按下标兜底 function pickTarget(targets, frame, idx) { if (!targets.length) return null; const byLabel = targets.find((x) => x.label === frame.label); if (byLabel) return byLabel; if (idx === 0) return targets[0]; if (frame.src) { const bySrc = targets.find((x) => x.src && x.src === frame.src); if (bySrc) return bySrc; } return targets[idx] || null; } async function applySnapshot(snapshot) { const result = { failedLS: [], failedSS: [], failedCK: [], evicted: 0, retried: 0, plan: {}, notes: [] }; const targets = collectTargets(); const frames = (Array.isArray(snapshot.frames) && snapshot.frames.length) ? snapshot.frames : [{ label: '主窗口', src: '', localStorage: snapshot.localStorage || {}, sessionStorage: snapshot.sessionStorage || {} }]; for (let idx = 0; idx < frames.length; idx++) { const f = frames[idx]; const t = pickTarget(targets, f, idx); if (!t) { result.notes.push('未找到框架「' + f.label + '」,已跳过'); continue; } const ls = t.win.localStorage, ss = t.win.sessionStorage; // 写回前统一过滤:不管快照来自自己保存、导入还是内置,一次性凭证和统计值一律不落盘。 // 出口只有这一处,规则变了不会漏掉某个来源。 const lsToWrite = stripSkipped(f.localStorage); const ssToWrite = stripSkipped(f.sessionStorage); result.plan.ls = (result.plan.ls || 0) + Object.keys(lsToWrite).length; result.plan.ss = (result.plan.ss || 0) + Object.keys(ssToWrite).length; let fls = writeStore(ls, lsToWrite); let fss = writeStore(ss, ssToWrite); // 快照里没有的键一律清掉,避免上一个账号的残留混进来 const deleteOthers = (store, keep) => { const del = []; try { for (let i = 0; i < store.length; i++) { const k = store.key(i); if (!k || k.indexOf(INTERNAL_PREFIX) === 0) continue; if (!keep.has(k)) del.push(k); } } catch (e) {} del.forEach((k) => { try { store.removeItem(k); } catch (e) {} }); }; deleteOthers(ls, new Set(Object.keys(lsToWrite))); deleteOthers(ss, new Set(Object.keys(ssToWrite))); const evictRetry = (store, failed) => { const auth = failed.filter((x) => isAuthKey(x.key)); const rest = failed.filter((x) => !isAuthKey(x.key)); if (!auth.length) return failed; const r = retryWithEviction(store, auth); result.evicted += r.freed; result.retried += r.retried; return rest.concat(r.still); }; if (fls.length) fls = evictRetry(ls, fls); if (fss.length) fss = evictRetry(ss, fss); result.failedLS = result.failedLS.concat(fls); result.failedSS = result.failedSS.concat(fss); } result.plan.ckCleared = await clearAllCookies(); // 设备 Cookie 和统计 Cookie 都不写回,不进候选名单 const ckList = (snapshot.cookies || []).filter((c) => c && c.name && !isSkippedCookie(c.name)); result.failedCK = await restoreCookies(ckList); // 统计口径是「实际写成功的条数」:候选总数减去失败数 result.plan.ckTotal = ckList.length; result.plan.ckAdded = result.plan.ckTotal - result.failedCK.length; return result; } /* ==================== 快照库(IndexedDB) ==================== */ let dbPromise = null; function openDB() { if (dbPromise) return dbPromise; dbPromise = new Promise((resolve, reject) => { const req = win.indexedDB.open(DB_NAME, DB_VER); req.onupgradeneeded = () => { const db = req.result; if (!db.objectStoreNames.contains(DB_STORE)) { db.createObjectStore(DB_STORE, { keyPath: 'id', autoIncrement: true }); } }; req.onsuccess = () => resolve(req.result); req.onerror = () => reject(req.error); }).catch((e) => { // 打开失败时清掉缓存,否则一次失败会让后续所有快照操作永久失效 dbPromise = null; throw e; }); return dbPromise; } // 所有快照库操作都走这一个事务包装,省掉 5 段几乎一样的 Promise 代码 function dbTx(mode, run) { return openDB().then((db) => new Promise((resolve, reject) => { let out; const tx = db.transaction(DB_STORE, mode); try { out = run(tx.objectStore(DB_STORE)); } catch (e) { reject(e); return; } const isReq = out && typeof out === 'object' && 'readyState' in out; tx.oncomplete = () => resolve(isReq ? out.result : out); tx.onerror = () => reject(tx.error || new Error('事务失败')); tx.onabort = () => reject(tx.error || new Error('事务被中断')); })); } const dbAdd = (snap) => dbTx('readwrite', (s) => s.add(snap)); const dbPut = (snap) => dbTx('readwrite', (s) => s.put(snap)); const dbDelete = (id) => dbTx('readwrite', (s) => s.delete(id)); const dbClear = () => dbTx('readwrite', (s) => s.clear()); const dbAll = () => dbTx('readonly', (s) => s.getAll()).then((r) => r || []); // ===== 同一个账号只留一份 ===== // 保存 / 导入时,库里已有同一账号就直接覆盖,不新建条目。 // 认亲只认登录账号:账号对得上就算同一个,昵称、游戏编号一概不参与。 function stableIds(s) { const acc = s.account || identifyAccount(s); const out = []; if (s.accountId) out.push(String(s.accountId).toLowerCase()); if (acc && acc.name) out.push(String(acc.name).toLowerCase()); return [...new Set(out)]; } // 当前页面登录着哪个账号:切走之前要先确认它已经存过,否则就是丢号 function currentAccountId() { try { const acc = identifyAccount({ localStorage: dumpStore(win.localStorage), sessionStorage: dumpStore(win.sessionStorage), }); return acc.name || acc.nickname || ''; } catch (e) { return ''; } } async function hasSnapshot(id) { if (!id) return false; const key = String(id).toLowerCase(); try { const all = await dbAll(); return all.some((s) => stableIds(s).some((x) => x === key)); } catch (e) { return false; } } // 这份快照本身带不带登录凭证(不带说明它是「退出登录」那类快照) function snapshotHasAuth(snap) { if (!snap) return false; const ls = mergedLS(snap) || {}; const ss = mergedSS(snap) || {}; return !!(ls['$SGS_LOGIN_TOKEN'] || ss['$SGS_LOGIN_TOKEN']); } async function saveDedup(snap) { // 没登录凭证的快照不是「某个账号」,存进列表只会多出一条跟内置「未登录」重复的空记录。 // 手动保存和导入都从这里过,所以在这一层统一改成刷新未登录基准。 if (!snapshotHasAuth(snap)) { return saveGuestBaseline(snap) ? 'guest' : 'guest-failed'; } const all = await dbAll(); const myStable = stableIds(snap); const dupe = myStable.length ? all.find((s) => stableIds(s).some((x) => myStable.includes(x))) : null; snap.savedAt = Date.now(); if (dupe) { snap.id = dupe.id; await dbPut(snap); return 'updated'; } await dbAdd(snap); return 'added'; } /* ==================== 导入 / 导出 ==================== */ async function importRaw(raw, sourceName) { const bundle = normalizeBundle(raw); if (bundle) { let added = 0, updated = 0, guest = 0; for (const s of bundle) { const how = await saveDedup(s); if (how === 'updated') updated++; else if (how === 'guest' || how === 'guest-failed') guest++; else added++; } await refreshMenu(); const lines = [`共 ${bundle.length} 个快照:新建 ${added} 个,替换 ${updated} 个`]; if (guest) lines.push(`其中 ${guest} 个没有登录凭证,已用来刷新「未登录」基准,未占用账号位`); lines.push(`来源:${sourceName}`); await alertModal('导入完成', lines.join('\n')); return; } const snap = normalizeSnapshot(raw); if (!raw.label || snap.label === '导入快照') { snap.label = accountText(snap.account) || sourceName || '导入快照'; } const how = await saveDedup(snap); await refreshMenu(); if (how === 'guest') { await alertModal('导入完成', '这份快照没有登录凭证,已用来刷新「未登录」基准,未占用账号位。'); return; } if (how === 'guest-failed') { await alertModal('导入失败', '写入失败,可能是脚本存储空间不足。'); return; } const name = accountText(snap.account) || snap.label; await alertModal('导入完成', how === 'updated' ? `${name}\n这个账号之前存过,已用新数据覆盖。` : `已存进快照库:${name}`); } async function importText(text, sourceName) { try { await importRaw(looseParse(text), sourceName); } catch (e) { await alertModal('导入失败', `${sourceName}:${e.message}`); } } function pickFile() { return new Promise((resolve) => { const inp = doc.createElement('input'); inp.type = 'file'; inp.accept = 'application/json,.json'; inp.multiple = true; inp.style.display = 'none'; doc.body.appendChild(inp); let done = false; const finish = (v) => { if (done) return; done = true; try { inp.remove(); } catch (e) {} resolve(v); }; inp.onchange = () => finish(Array.from(inp.files || [])); // 用户关掉选择框时立刻返回,不必等超时 inp.addEventListener('cancel', () => finish(null)); win.setTimeout(() => finish(null), 180000); inp.click(); }); } async function readClipboard() { try { if (win.navigator && win.navigator.clipboard && win.navigator.clipboard.readText) { return await win.navigator.clipboard.readText(); } } catch (e) {} return null; } /* ==================== 弹窗 ==================== */ // 弹窗栈:嵌套弹窗时按 ESC 只关最上面那一层 const modalStack = []; function modal(opts) { return new Promise((resolve) => { const o = opts || {}; let settled = false; const mask = doc.createElement('div'); mask.setAttribute('data-sgsx', '1'); mask.style.cssText = `position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:${Z_MASK};` + `display:flex;align-items:center;justify-content:center;font-family:${FONT}`; const box = doc.createElement('div'); box.style.cssText = 'background:#fff;color:#222;border-radius:10px;padding:16px;width:520px;' + 'max-width:92vw;max-height:86vh;overflow:auto;box-shadow:0 12px 32px rgba(0,0,0,.3);font-size:13px'; mask.appendChild(box); doc.body.appendChild(mask); modalStack.push(mask); const done = (v) => { if (settled) return; settled = true; const i = modalStack.indexOf(mask); if (i >= 0) modalStack.splice(i, 1); try { doc.removeEventListener('keydown', onKey, true); } catch (e) {} try { mask.remove(); } catch (e) {} resolve(v); }; const rerender = () => { if (settled) return; box.innerHTML = ''; // render 可能是 async,用 Promise 包一层才能接住它抛的错 Promise.resolve() .then(() => o.render(box, done, rerender)) .catch((e) => { console.error('[新杀账号切换器] 面板渲染出错', e); done(null); }); }; function onKey(e) { if (e.key !== 'Escape') return; if (modalStack[modalStack.length - 1] !== mask) return; e.preventDefault(); done(null); } doc.addEventListener('keydown', onKey, true); mask.addEventListener('click', (e) => { if (e.target === mask) done(null); }); if (typeof o.render === 'function') { rerender(); return; } let html = ''; if (o.title) html += `
${escapeHtml(o.title)}
`; if (o.message) html += `
${escapeHtml(o.message)}
`; if (o.list && o.list.length) { html += '
'; o.list.forEach((item, i) => { html += `
` + escapeHtml(item.label) + (item.sub ? `
${escapeHtml(item.sub)}
` : '') + '
'; }); html += '
'; } if (o.input === 'textarea') { html += ``; } else if (o.input === 'text') { html += ``; } const btns = o.buttons || [{ label: '确定', value: true, primary: true }]; html += '
'; btns.forEach((b, i) => { const bg = b.primary ? '#c0392b' : '#f2f2f2'; const fg = b.primary ? '#fff' : '#333'; const bd = b.primary ? '#c0392b' : '#ccc'; html += ``; }); html += '
'; box.innerHTML = html; const inputEl = box.querySelector('#sgsx-in'); if (inputEl) win.setTimeout(() => { try { inputEl.focus(); if (inputEl.select) inputEl.select(); } catch (e) {} }, 30); box.querySelectorAll('.sgsx-btn').forEach((btn) => { btn.onclick = () => { const b = btns[Number(btn.getAttribute('data-i'))]; if (inputEl && b && (b.useInput || b.primary)) { done(inputEl.value); return; } done(b.value !== undefined ? b.value : true); }; }); box.querySelectorAll('.sgsx-item').forEach((el) => { el.onmouseenter = () => { el.style.background = '#fafafa'; }; el.onmouseleave = () => { el.style.background = ''; }; el.onclick = () => done(o.list[Number(el.getAttribute('data-i'))].value); }); }); } function alertModal(title, message) { return modal({ title, message, buttons: [{ label: '确定', value: true, primary: true }], }); } /* ==================== 业务动作 ==================== */ async function actionSaveCurrent() { try { // 先做快照(里面会尽力读游戏昵称),再用最终名字提示 const snap = await makeSnapshot(''); const base = accountText(snap.account) || ('账号 ' + new Date().toLocaleString()); snap.label = base; const how = await saveDedup(snap); await refreshMenu(); if (how === 'guest') { await alertModal('未登录状态已更新', '当前没有登录任何账号,已把这份干净状态存成「未登录」基准。\n' + '以后切换账号时用它退出登录,效果就是现在这个样子。'); } else if (how === 'guest-failed') { await alertModal('保存失败', '写入失败,可能是脚本存储空间不足。'); } else if (how === 'updated') { await alertModal('已保存', base + '\n这个账号之前存过,已用新数据覆盖。'); } else { await alertModal('已保存', base); } } catch (e) { await alertModal('保存失败', String(e)); } } async function actionImport() { const pick = await modal({ title: '导入快照', message: '选择导入方式:', list: [ { label: '📁 从电脑选文件', sub: '支持快照文件或检测结果文件', value: 'file' }, { label: '📋 从剪贴板读取', sub: '刚复制过内容就直接读', value: 'clipboard' }, { label: '✍️ 手动粘贴', sub: '打开输入框自己贴', value: 'paste' }, ], buttons: [{ label: '取消', value: null }], }); if (pick === 'file') { const files = await pickFile(); if (!files || !files.length) return; for (const f of files) { try { await importText(await f.text(), f.name); } catch (e) { await alertModal('读取失败', e.message); } } } else if (pick === 'clipboard') { const t = await readClipboard(); if (!t) { await alertModal('读取失败', '剪贴板为空或权限被拒绝'); return; } await importText(t, '剪贴板'); } else if (pick === 'paste') { const pasted = await modal({ title: '粘贴内容', message: '把复制好的快照内容贴到下面:', input: 'textarea', placeholder: '粘贴到这里', buttons: [ { label: '导入', value: true, primary: true, useInput: true }, { label: '取消', value: null }, ], }); if (pasted) await importText(pasted, '粘贴导入'); } } // 导出前筛掉不该跟着账号走的三类键(见顶部「不该跟着快照走的三类东西」)。 // 只删、不改:不做归一化、不重算账号信息——昵称是保存时从游戏库里读出来的, // 重算一次就丢了。快照库本身保持原样:库里带着这些键不影响切号(写回浏览器前 // 还会再筛一遍),但它们不该跟着导出文件流到别人手上,所以在这里拦一道。 // 深拷贝之后再洗,不动库里那份。 function exportableSnapshot(s) { const out = JSON.parse(JSON.stringify(s)); (out.frames || []).forEach((f) => { if (f.localStorage) f.localStorage = stripSkipped(f.localStorage); if (f.sessionStorage) f.sessionStorage = stripSkipped(f.sessionStorage); }); if (out.localStorage) out.localStorage = stripSkipped(out.localStorage); if (out.sessionStorage) out.sessionStorage = stripSkipped(out.sessionStorage); if (Array.isArray(out.cookies)) { out.cookies = out.cookies.filter((c) => c && c.name && !isSkippedCookie(c.name)); } return out; } async function actionExportAll() { const all = await dbAll(); if (!all.length) { await alertModal('快照库是空的', '还没有保存任何快照'); return; } const snapshots = all.map(exportableSnapshot); const ok = downloadJSON( { schema: BUNDLE_SCHEMA, version: 3, exportedAt: new Date().toISOString(), snapshots }, outName('账号快照', 'json') ); await alertModal(ok ? '已导出' : '导出失败', ok ? `共导出 ${all.length} 个快照` : '浏览器把下载拦下了,允许下载后再试一次'); } async function actionSettingsAndManage() { const rows = [ { label: '页面小窗', get: () => settings.showWidget ? '开' : '关', toggle: () => { settings = saveSettings({ showWidget: !settings.showWidget }); updateWidget(); }, }, { label: '快捷切换', get: () => settings.quickSwitch ? '开' : '关', toggle: () => { settings = saveSettings({ quickSwitch: !settings.quickSwitch }); }, }, { label: '切换后自动刷新', get: () => settings.autoReload ? '开' : '关', toggle: () => { settings = saveSettings({ autoReload: !settings.autoReload }); }, }, ]; await modal({ render: async (box, done, rerender) => { box.innerHTML = ''; const title = doc.createElement('div'); title.style.cssText = T.title + ';margin-bottom:12px'; title.textContent = '账号与设置'; box.appendChild(title); // 顶部:切换账号(点一个就切过去) let all = []; try { all = await dbAll(); } catch (e) {} all.sort((a, b) => (b.savedAt || 0) - (a.savedAt || 0)); // 二级·列表 const listTitle = doc.createElement('div'); listTitle.style.cssText = T.section + ';margin-bottom:8px'; listTitle.textContent = '列表'; box.appendChild(listTitle); const swList = doc.createElement('div'); swList.style.cssText = 'border:1px solid #eee;border-radius:8px;margin-bottom:14px;overflow:hidden'; // 一行账号:普通快照和内置快照长得一样,区别只在有没有徽标、有没有改名/删除 const appendRow = (o) => { const row = doc.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:10px;padding:10px 12px' + (o.last ? '' : ';border-bottom:1px solid rgba(128,128,128,.22)'); row.onmouseenter = () => { row.style.background = 'rgba(128,128,128,.14)'; }; row.onmouseleave = () => { row.style.background = ''; }; // 左边一整块 = 点一下切过去;名字一级(三级字)、时间徽标(四级灰) const main = doc.createElement('div'); main.style.cssText = 'flex:1;min-width:0;cursor:pointer'; const line1 = doc.createElement('div'); line1.style.cssText = 'display:flex;align-items:center;gap:6px;min-width:0'; const t1 = doc.createElement('div'); t1.style.cssText = T.item + ';white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0'; t1.textContent = '▶ ' + o.name; line1.appendChild(t1); if (o.badge) { const badge = doc.createElement('div'); badge.textContent = o.badge; badge.style.cssText = T.badge + ';flex:0 0 auto'; line1.appendChild(badge); } main.appendChild(line1); if (o.sub) { const t2 = doc.createElement('div'); t2.style.cssText = T.aux + ';margin-top:3px'; t2.textContent = o.sub; main.appendChild(t2); } main.onclick = () => { done(null); guardApply(() => doApply(o.snapshot))(); }; row.appendChild(main); (o.actions || []).forEach((a) => { const b = mkButton(a.text, a.onClick, { small: true, danger: a.danger }); b.style.cssText += a.danger ? ';border-color:rgba(192,57,43,.45);background:transparent;color:#e06c5a;border-radius:6px' : ';border-color:rgba(128,128,128,.4);background:transparent;color:inherit;border-radius:6px'; row.appendChild(b); }); swList.appendChild(row); }; all.forEach((s) => { const acc = s.account || identifyAccount(s); const nm = accountText(acc) || s.label || ('#' + s.id); appendRow({ name: nm, sub: fmtTime(s.time || s.savedAt), snapshot: s, actions: [ // 改名 / 删除直接在这一行操作,不需要另开页面 { text: '改名', onClick: async () => { const name = await modal({ title: '改名', message: '现在叫:' + nm, input: 'text', value: nm, buttons: [ { label: '保存', value: true, primary: true, useInput: true }, { label: '取消', value: null }, ], }); if (name == null) return; const nl = String(name).trim(); if (!nl) return; s.label = nl; await dbPut(s); rerender(); } }, { text: '删除', danger: true, onClick: async () => { const ok = await modal({ title: '确认删除', message: '要删除:' + nm, buttons: [ { label: '删除', value: true, primary: true }, { label: '取消', value: null }, ], }); if (!ok) return; await dbDelete(s.id); rerender(); } }, ], }); }); // 内置默认账号:未登录(永远在,点它回到干净的登录页) const builtins = builtInSnapshots(); builtins.forEach((bs, i) => { appendRow({ name: bs.label || '未登录', badge: '内置', snapshot: bs, last: i === builtins.length - 1, }); }); box.appendChild(swList); // 二级·设置 const setTitle = doc.createElement('div'); setTitle.style.cssText = T.section + ';margin-bottom:4px'; setTitle.textContent = '设置'; box.appendChild(setTitle); rows.forEach((r) => { const row = doc.createElement('div'); row.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:10px 4px;border-bottom:1px solid #f0f0f0;cursor:pointer'; const label = doc.createElement('div'); label.textContent = r.label; label.style.fontSize = '13px'; const val = doc.createElement('div'); val.textContent = r.get(); val.style.cssText = T.badge; row.appendChild(label); row.appendChild(val); row.onclick = () => { // 只改本行的「开/关」徽标,不整页重画,避免面板闪一下 r.toggle(); val.textContent = r.get(); }; box.appendChild(row); }); // 二级·操作 const opsTitle = doc.createElement('div'); opsTitle.style.cssText = T.section + ';margin:16px 0 8px'; opsTitle.textContent = '操作'; box.appendChild(opsTitle); const ops = doc.createElement('div'); ops.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap'; // 导入 / 导出后留在本页并刷新列表,不关闭面板 ops.appendChild(mkButton('📥 导入快照', async () => { await actionImport(); rerender(); })); ops.appendChild(mkButton('📤 导出全部', async () => { await actionExportAll(); rerender(); })); ops.appendChild(mkButton('🗑 清空快照', async () => { const snapshotCount = (await dbAll()).length; if (!snapshotCount) { await alertModal('快照库是空的', '没有可清空的快照'); return; } const ok = await modal({ title: '清空全部', message: `共 ${snapshotCount} 个快照将被永久删除,不可恢复。`, buttons: [ { label: '确认清空', value: true, primary: true }, { label: '取消', value: null }, ], }); if (!ok) return; await dbClear(); rerender(); }, { danger: true })); // 完成 固定在最右边 const btnDone = mkButton('完成', () => done(null)); btnDone.style.marginLeft = 'auto'; ops.appendChild(btnDone); box.appendChild(ops); }, }); await refreshMenu(); } // 切换账号独立防重入:面板本身已被 guardAction 包住,这里不能用同一个锁 let applyBusy = false; function guardApply(fn) { return async () => { if (applyBusy) return; applyBusy = true; try { await fn(); } finally { applyBusy = false; } }; } async function doApply(snapshot) { const info = snapshot.account || identifyAccount(snapshot); const target = accountText(info) || snapshot.label || ('#' + snapshot.id); const pt = readProtectTime(); if (pt && Number(pt) > 0) { const go = await modal({ title: '检测到账号保护时间', message: `剩余约 ${pt} 秒,此时切换可能失败。\n建议等保护时间结束再切。`, buttons: [ { label: '仍要继续', value: true, primary: true }, { label: '取消', value: null }, ], }); if (!go) return; } // 当前账号还没存进快照库就切走,等于永久丢号——这是唯一必须拦一下的情况 const curId = currentAccountId(); const targetId = snapshot.accountId || (info && info.name) || ''; let warn = ''; if (curId && curId !== targetId && !(await hasSnapshot(curId))) { warn = '\n⚠ 当前登录的账号还没存过,切走就找不回来了。'; } // 快捷切换开着就直接切;但只要有丢号风险,哪怕开着也得问一句 if (!settings.quickSwitch || warn) { const ok = await modal({ title: '切换账号', message: `▶ ${target}\n${fmtTime(snapshot.time || snapshot.savedAt)}${warn}`, buttons: [ { label: '切换', value: true, primary: true }, { label: '取消', value: null }, ], }); if (!ok) return; } const res = await applySnapshot(snapshot); const lines = []; const plan = res.plan || {}; const failCount = res.failedLS.length + res.failedSS.length + res.failedCK.length; lines.push(`已恢复:本地存储 ${plan.ls || 0} 项、临时存储 ${plan.ss || 0} 项、Cookie ${plan.ckAdded || 0}/${plan.ckTotal || 0} 项`); if (failCount === 0) { lines.push('全部成功,0 失败'); } else { lines.push(`有 ${failCount} 项没恢复成功`); if (res.failedLS.length) { lines.push('没成功的:' + res.failedLS.slice(0, 3).map((f) => `${f.key}(${sizeText(f.size)})`).join('、')); } if (res.failedCK.length) lines.push(`有 ${res.failedCK.length} 个 Cookie 没写进去`); } if (res.evicted) lines.push(`为塞下数据清了 ${sizeText(res.evicted)} 旧缓存`); if (res.plan.ckCleared) lines.push(`清掉了 ${res.plan.ckCleared} 个旧 Cookie`); if (res.notes && res.notes.length) lines.push(res.notes.join(';')); const okAuth = win.localStorage.getItem('$SGS_LOGIN_TOKEN') && win.localStorage.getItem('SGS_USERINFO_KEY'); // 切到「未登录」本来就该没有凭证,这时「异常」是预期结果,不算失败 const authBad = snapshotHasAuth(snapshot) && !okAuth; lines.push('登录凭证:' + (!snapshotHasAuth(snapshot) ? '已清除(未登录)' : (okAuth ? '正常' : '异常'))); lines.push('当前账号:' + target); await refreshMenu(); // 快捷切换:一切顺利则不打断,直接刷新生效; // 但只要有任何一项没恢复成功,就必须提示——静默失败比弹窗更糟。 if (!settings.quickSwitch || failCount > 0 || authBad) { await alertModal(failCount > 0 || authBad ? '切换可能没成功' : '切换完成', lines.join('\n')); } if (settings.autoReload) { win.setTimeout(() => win.location.reload(), RELOAD_DELAY_MS); } } /* ==================== 页面检测模块 ==================== */ // 下载名统一走 outName(见顶部「导出命名」) function diagCollect() { return new Promise((resolve) => { const r = { url: win.location.href, time: new Date().toLocaleString(), cookieDoc: doc.cookie, cookieAll: null, localStorage: {}, sessionStorage: {}, indexedDB: [], }; const dumpInto = (store, out) => { try { for (let i = 0; i < store.length; i++) { const k = store.key(i); if (k == null) continue; out[k] = store.getItem(k); } } catch (e) {} }; dumpInto(win.localStorage, r.localStorage); dumpInto(win.sessionStorage, r.sessionStorage); const finish = () => { if (win.indexedDB && win.indexedDB.databases) { win.indexedDB.databases().then((dbs) => { r.indexedDB = dbs.map((d) => d.name + ' (v' + d.version + ')'); resolve(r); }).catch((e) => { r.indexedDB = ['err: ' + e.message]; resolve(r); }); } else { r.indexedDB = ['不支持 databases()']; resolve(r); } }; if (typeof GM_cookie !== 'undefined' && GM_cookie.list) { GM_cookie.list({}, (cookies, error) => { if (error) { r.cookieAll = 'GM_cookie 错误: ' + error; } else { r.cookieAll = cookies.map((c) => ({ name: c.name, value: c.value, httpOnly: !!c.httpOnly, secure: !!c.secure, domain: c.domain, path: c.path, })); } finish(); }); } else { r.cookieAll = 'GM_cookie 不可用'; finish(); } }); } // 只跑检测、不弹窗,结果交给面板显示 async function diagRunStorage() { const result = await diagCollect(); const text = JSON.stringify(result, null, 2); try { GM_setValue(DIAG_CACHE_KEY, text); } catch (e) {} win.__sgsDiag = result; const summary = '本地存储 ' + Object.keys(result.localStorage).length + ' 项、' + '临时存储 ' + Object.keys(result.sessionStorage).length + ' 项、' + '网页数据库 ' + result.indexedDB.length + ' 个'; return { text, summary }; } function uiDepth(el) { let d = 0; let p = el; while (p && p !== doc.body) { d++; p = p.parentElement; if (d > 30) break; } return d; } function uiScan() { const vw = win.innerWidth; const vh = win.innerHeight; const items = []; const all = doc.querySelectorAll('body *'); for (const el of all) { // 跳过脚本自己的浮窗 / 弹窗 / 结果面板 if (el.closest && el.closest(SELF_UI_SELECTOR)) continue; const rect = el.getBoundingClientRect(); const style = win.getComputedStyle(el); if (rect.width < 2 || rect.height < 2) continue; if (style.display === 'none' || style.visibility === 'hidden') continue; if (parseFloat(style.opacity) < 0.05) continue; if (rect.bottom < 0 || rect.top > vh) continue; if (rect.right < 0 || rect.left > vw) continue; const tag = el.tagName.toLowerCase(); const id = el.id ? '#' + el.id : ''; let cls = ''; if (typeof el.className === 'string' && el.className.trim()) { cls = '.' + el.className.trim().split(/\s+/).slice(0, 3).join('.'); } let textPreview = ''; if (el.children.length === 0) { const txt = (el.textContent || '').trim(); if (txt) textPreview = txt.slice(0, 20); } items.push({ sel: tag + id + cls, text: textPreview, x: Math.round(rect.left), y: Math.round(rect.top), w: Math.round(rect.width), h: Math.round(rect.height), z: style.zIndex === 'auto' ? '' : style.zIndex, pos: style.position, depth: uiDepth(el), }); } return { viewport: { w: vw, h: vh, dpr: win.devicePixelRatio }, url: win.location.href, count: items.length, items: items, }; } function uiFormat(data) { const lines = []; lines.push('=== UI 布局扫描 ==='); lines.push('URL: ' + data.url); lines.push('屏幕:' + data.viewport.w + ' x ' + data.viewport.h + ' (DPR ' + data.viewport.dpr + ')'); lines.push('可见元素数: ' + data.count); lines.push(''); lines.push('选择器 | 文字 | 位置(x,y) | 尺寸(w,h) | position | z-index'); lines.push('-'.repeat(80)); const sorted = data.items.slice().sort((a, b) => (b.w * b.h) - (a.w * a.h)); for (const it of sorted) { lines.push( it.sel + ' | ' + (it.text || '') + ' | (' + it.x + ',' + it.y + ')' + ' | ' + it.w + 'x' + it.h + ' | ' + it.pos + ' | ' + it.z ); } return lines.join('\n'); } // 只跑扫描、不弹窗,结果交给面板显示 async function diagRunLayout() { const data = uiScan(); const text = uiFormat(data); try { GM_setValue(DIAG_UI_CACHE_KEY, text); } catch (e) {} win.__sgsUI = data; const summary = '可见元素 ' + data.count + ' 个、屏幕 ' + data.viewport.w + ' x ' + data.viewport.h; return { text, summary }; } /* ==================== 页面检测面板 ==================== */ // 常驻不关:四个操作点了就地出结果,看完按「返回」回到操作页, // 不用每点一项就从油猴菜单重新进一次 async function actionDiag() { const storage = { text: '', summary: '' }; const layout = { text: '', summary: '' }; try { storage.text = GM_getValue(DIAG_CACHE_KEY, '') || ''; } catch (e) {} try { layout.text = GM_getValue(DIAG_UI_CACHE_KEY, '') || ''; } catch (e) {} // 'menu' = 操作页;'storage' / 'layout' = 结果页 let view = 'menu'; let notice = ''; await modal({ title: '页面检测', render: async (box, done, rerender) => { box.innerHTML = ''; const title = doc.createElement('div'); title.style.cssText = T.title + ';margin-bottom:12px'; title.textContent = view === 'menu' ? '页面检测' : (view === 'storage' ? '存储检测结果' : '页面布局扫描结果'); box.appendChild(title); if (view === 'menu') { box.style.width = '520px'; const ops = [ { label: '🔄 重新检测存储', sub: '看看网页里现在存了哪些东西', run: async () => { const r = await diagRunStorage(); storage.text = r.text; storage.summary = r.summary; notice = '已检测:' + r.summary + '\n点「📄 查看检测结果」看完整内容,也能用「导入快照」存进快照库。'; }, }, { label: '📄 查看检测结果', sub: '看刚才检测的内容,可全选复制或下载', run: async () => { if (!storage.text) { notice = '还没有检测结果,先点「🔄 重新检测存储」。'; return; } view = 'storage'; }, }, { label: '🧪 测试页面布局', sub: '看看页面元素都摆在哪、多大', run: async () => { const r = await diagRunLayout(); layout.text = r.text; layout.summary = r.summary; notice = '已扫描:' + r.summary + '\n点「🖼 查看布局结果」看完整内容。'; }, }, { label: '🖼 查看布局结果', sub: '看刚才扫描的内容', run: async () => { if (!layout.text) { notice = '还没有扫描结果,先点「🧪 测试页面布局」。'; return; } view = 'layout'; }, }, ]; const list = doc.createElement('div'); list.style.cssText = 'border:1px solid #eee;border-radius:8px;margin-bottom:12px;overflow:hidden'; ops.forEach((op, i) => { const row = doc.createElement('div'); row.style.cssText = 'padding:10px 12px;cursor:pointer' + (i === ops.length - 1 ? '' : ';border-bottom:1px solid rgba(128,128,128,.22)'); row.onmouseenter = () => { row.style.background = 'rgba(128,128,128,.14)'; }; row.onmouseleave = () => { row.style.background = ''; }; const l = doc.createElement('div'); l.style.cssText = T.item; l.textContent = op.label; const s = doc.createElement('div'); s.style.cssText = T.aux + ';margin-top:3px'; s.textContent = op.sub; row.appendChild(l); row.appendChild(s); row.onclick = async () => { await op.run(); rerender(); }; list.appendChild(row); }); box.appendChild(list); if (notice) { const n = doc.createElement('div'); n.style.cssText = T.aux + ';white-space:pre-wrap;line-height:1.6;background:#f6f6f6;' + 'border-radius:8px;padding:10px 12px;margin-bottom:12px'; n.textContent = notice; box.appendChild(n); } const bar = doc.createElement('div'); bar.style.cssText = 'display:flex;justify-content:flex-end'; const btnDone = mkButton('完成', () => done(null)); btnDone.style.background = '#f2f2f2'; bar.appendChild(btnDone); box.appendChild(bar); return; } // ===== 结果页 ===== const cur = view === 'storage' ? storage : layout; const fname = view === 'storage' ? outName('数据检查', 'txt') : outName('页面检查', 'txt'); box.style.width = 'min(880px, 94vw)'; if (cur.summary) { const s = doc.createElement('div'); s.style.cssText = T.aux + ';margin-bottom:8px'; s.textContent = cur.summary; box.appendChild(s); } const ta = doc.createElement('textarea'); ta.value = cur.text; ta.readOnly = true; ta.style.cssText = 'width:100%;height:min(52vh,440px);box-sizing:border-box;' + 'font-family:monospace;font-size:12px;line-height:1.5;background:#fafafa;color:#333;' + 'border:1px solid #ddd;border-radius:6px;padding:10px;resize:none;white-space:pre'; box.appendChild(ta); const bar = doc.createElement('div'); bar.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:12px'; bar.appendChild(mkButton('全选', () => { ta.focus(); ta.select(); })); bar.appendChild(mkButton('下载', () => downloadFile(fname, ta.value))); bar.appendChild(mkButton('← 返回', () => { view = 'menu'; notice = ''; rerender(); })); const warn = doc.createElement('span'); // 只有存储检测含登录凭证;布局扫描只是元素坐标,挂这句反而是误报 warn.textContent = view === 'storage' ? '⚠ 含登录凭证,勿分享' : ''; warn.style.cssText = T.aux + ';color:#c0392b;margin-left:auto'; bar.appendChild(warn); const btnDone = mkButton('完成', () => done(null)); btnDone.style.marginLeft = '8px'; bar.appendChild(btnDone); box.appendChild(bar); win.setTimeout(() => { try { ta.focus(); ta.select(); } catch (e) {} }, 30); }, }); } /* ==================== 页面小窗 ==================== */ // 右下角常驻小圆标,点一下直接开面板,不用再去点油猴菜单 function updateWidget() { let w = doc.getElementById(WIDGET_ID); // 页面重渲染可能把小窗从 DOM 里摘走,这时要补回来 if (w && !w.isConnected) w = null; if (!settings.showWidget) { if (w) w.remove(); return; } if (w) return; // 容器:竖着放两颗胶囊(保存账号 / 切换账号) w = doc.createElement('div'); w.id = WIDGET_ID; w.style.cssText = [ 'position:fixed', 'right:18px', 'bottom:18px', `z-index:${Z_WIDGET}`, 'display:flex', 'flex-direction:column', 'align-items:stretch', 'gap:8px', `font:13px/1 ${FONT}`, ].join(';'); const mkPill = (text, onClick) => { const p = doc.createElement('div'); p.textContent = text; p.style.cssText = [ 'padding:8px 14px', 'background:rgba(28,28,30,.92)', 'color:#fff', 'border:1px solid rgba(255,255,255,.25)', 'border-radius:999px', 'text-align:center', 'cursor:pointer', 'user-select:none', 'box-shadow:0 4px 14px rgba(0,0,0,.3)', 'opacity:.85', ].join(';'); p.onmouseenter = () => { p.style.opacity = '1'; }; p.onmouseleave = () => { p.style.opacity = '.85'; }; p.onclick = onClick; return p; }; w.appendChild(mkPill('💾 保存账号', () => runAction(actionSaveCurrent))); w.appendChild(mkPill('⚙ 切换账号', () => runAction(actionSettingsAndManage))); (doc.body || doc.documentElement).appendChild(w); } /* ==================== 菜单 ==================== */ const menuIds = []; function reg(label, fn) { try { if (typeof GM_registerMenuCommand !== 'function') return; const id = GM_registerMenuCommand(label, fn); if (id != null) menuIds.push(id); } catch (e) {} } function unregAll() { if (typeof GM_unregisterMenuCommand !== 'function') return; while (menuIds.length) { const id = menuIds.pop(); try { GM_unregisterMenuCommand(id); } catch (e) {} } } // 防重复触发:同一个动作未结束前,重复点击不会再打开一个相同窗口 // 菜单和小窗共用这把锁,保证两处入口行为一致 let actionBusy = false; function guardAction(fn) { return async () => { if (actionBusy) return; actionBusy = true; try { await fn(); } finally { actionBusy = false; } }; } const runAction = (fn) => guardAction(fn)(); async function refreshMenu() { unregAll(); settings = getSettings(); reg('💾 保存账号', guardAction(actionSaveCurrent)); reg('🩺 页面检测', guardAction(actionDiag)); reg('⚙️ 账号设置', guardAction(actionSettingsAndManage)); updateWidget(); } /* ==================== 启动 ==================== */ function boot() { settings = getSettings(); refreshMenu().catch(() => {}); } boot(); })();