// ==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 += `