// ==UserScript== // @name Fixed Site Name Replacer2 // @namespace local.codex.fixed-site-replacer // @version 0.6.1 // @description 本地替换 Microsoft 登录页、Outlook 和 Bandai Parks 页面上的显示文字。(iOS Safari Userscripts / 油猴双兼容;触发:长按 2 秒或三击顶部区域) // @match https://login.microsoftonline.com/* // @match https://outlook.live.com/* // @match https://parks2.bandainamco-am.co.jp/* // @grant none // @run-at document-start // ==/UserScript== (() => { "use strict"; const STORAGE_KEY = "codex.fixedSite.nameReplacer.config.v1"; const SUPPORTED_HOSTS = [ "login.microsoftonline.com", "outlook.live.com", "parks2.bandainamco-am.co.jp", ]; const PANEL_ID = "codex-msmail-name-panel"; const STYLE_ID = "codex-msmail-name-style"; const SESSION_DURATION_MS = 4 * 60 * 60 * 1000; const PANEL_HOLD_MS = 2000; const PANEL_HOLD_ZONE_PX = 100; const TRIPLE_CLICK_WINDOW_MS = 500; const TRIPLE_CLICK_MAX_SPREAD = 40; const FAST_SCAN_WINDOW_MS = 4000; const FAST_SCAN_INTERVAL_MS = 120; const DEFAULT_RULES = [ { enabled: true, original: "", replacement: "", mode: "normal" }, { enabled: true, original: "", replacement: "", mode: "normal" }, ]; let originalTextMap = new WeakMap(); let originalValueMap = new WeakMap(); const touchedTextNodes = new Set(); const touchedElements = new Set(); let observer = null; let applying = false; let statusUpdater = null; let holdTimer = null; let topHoldStartY = 0; let threeFingerHold = false; let tripleClickTimes = []; let fastScanInterval = null; let fastScanStopTimer = null; const defaultConfig = { enabled: false, fontAdjust: false, panelVisible: false, bodyCollapsed: false, sessionStartedAt: null, rules: DEFAULT_RULES, }; const cloneRules = (rules) => (Array.isArray(rules) ? rules : DEFAULT_RULES).map((rule) => ({ enabled: rule.enabled !== false, original: String(rule.original || ""), replacement: String(rule.replacement || ""), mode: rule.mode === "regex" ? "regex" : "normal", })); const readStoredConfig = () => { if (typeof GM_getValue === "function") { return GM_getValue(STORAGE_KEY, "{}"); } return localStorage.getItem(STORAGE_KEY) || "{}"; }; const writeStoredConfig = (value) => { if (typeof GM_setValue === "function") { GM_setValue(STORAGE_KEY, value); return; } localStorage.setItem(STORAGE_KEY, value); }; const loadConfig = () => { try { const saved = JSON.parse(readStoredConfig()); return { ...defaultConfig, ...saved, panelVisible: false, sessionStartedAt: typeof saved.sessionStartedAt === "number" && saved.sessionStartedAt > 0 ? saved.sessionStartedAt : null, rules: cloneRules(saved.rules), }; } catch { return { ...defaultConfig, rules: cloneRules(defaultConfig.rules), }; } }; let config = loadConfig(); config.panelVisible = false; const saveConfig = () => { writeStoredConfig( JSON.stringify({ ...config, rules: cloneRules(config.rules), }) ); }; const normalize = (value) => value.replace(/\s+/g, " ").trim(); const siteAuthorized = () => { return SUPPORTED_HOSTS.includes(location.hostname); }; const isOutlookPage = () => location.hostname === "outlook.live.com"; const shouldSkipNode = (node) => { const element = node instanceof Element ? node : node?.parentElement instanceof Element ? node.parentElement : null; if (!element) return false; if (element.closest(`#${PANEL_ID}`)) return true; if (["SCRIPT", "STYLE", "NOSCRIPT"].includes(element.tagName)) return true; return Boolean(element.closest('iframe, [contenteditable="true"]')); }; const getReplacementRoots = () => { if (!document.body) return []; return [document.body]; }; const hasValidSession = () => typeof config.sessionStartedAt === "number" && config.sessionStartedAt > 0; const getSessionExpiresAt = () => hasValidSession() ? config.sessionStartedAt + SESSION_DURATION_MS : 0; const getRemainingMs = () => hasValidSession() ? Math.max(0, getSessionExpiresAt() - Date.now()) : 0; const formatRemaining = (ms) => { const totalSeconds = Math.ceil(ms / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; }; const hasExpired = () => hasValidSession() && getRemainingMs() <= 0; const isReplacementActive = () => config.enabled && siteAuthorized() && hasValidSession() && !hasExpired(); const replaceByRule = (input, rule) => { if (!rule.enabled || !rule.original || !rule.replacement) { return input; } if (rule.mode === "regex") { try { return input.replace(new RegExp(rule.original, "g"), rule.replacement); } catch { return input; } } return input.split(rule.original).join(rule.replacement); }; const replaceText = (text) => { if (!isReplacementActive()) return text; return config.rules.reduce((next, rule) => replaceByRule(next, rule), text); }; const rememberOriginalText = (node, value) => { touchedTextNodes.add(node); if (!originalTextMap.has(node)) { originalTextMap.set(node, value); } }; const rememberOriginalValue = (element, key, value) => { touchedElements.add(element); let item = originalValueMap.get(element); if (!item) { item = {}; originalValueMap.set(element, item); } if (!(key in item)) { item[key] = value; } }; const restoreTouchedContent = () => { touchedTextNodes.forEach((node) => { if (node.isConnected) { restoreNode(node); } }); touchedElements.forEach((element) => { if (element.isConnected) { restoreElement(element); } }); touchedTextNodes.clear(); touchedElements.clear(); originalTextMap = new WeakMap(); originalValueMap = new WeakMap(); }; const restoreNode = (node) => { if (!originalTextMap.has(node)) return; const original = originalTextMap.get(node); if (node.nodeValue !== original) { node.nodeValue = original; } }; const restoreElement = (element) => { const item = originalValueMap.get(element); if (!item) return; if ("value" in item && typeof element.value === "string" && element.value !== item.value) { element.value = item.value; } if ( "placeholder" in item && typeof element.placeholder === "string" && element.placeholder !== item.placeholder ) { element.placeholder = item.placeholder; } }; const updateTextNode = (node) => { const current = node.nodeValue; if (!current || !normalize(current)) return; if (!isReplacementActive()) return; rememberOriginalText(node, current); const base = originalTextMap.get(node) || current; const next = replaceText(base); if (next !== current) { node.nodeValue = next; } }; const updateElementValue = (element) => { if (!(element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement)) { return; } if (shouldSkipNode(element)) return; if (!isReplacementActive()) return; if (typeof element.placeholder === "string") { rememberOriginalValue(element, "placeholder", element.placeholder); const nextPlaceholder = replaceText( originalValueMap.get(element)?.placeholder || element.placeholder ); if (nextPlaceholder !== element.placeholder) { element.placeholder = nextPlaceholder; } } }; const walkAndReplace = (root) => { if (!root) return; const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT, { acceptNode(node) { if (!node.parentElement) return NodeFilter.FILTER_REJECT; if (shouldSkipNode(node)) return NodeFilter.FILTER_REJECT; return NodeFilter.FILTER_ACCEPT; }, } ); let textNode = walker.nextNode(); while (textNode) { updateTextNode(textNode); textNode = walker.nextNode(); } if (root instanceof Element) { updateElementValue(root); root.querySelectorAll("input, textarea").forEach(updateElementValue); } }; const applyFontAdjust = () => { const active = Boolean(config.fontAdjust && isReplacementActive()); document.documentElement.classList.toggle("codex-msmail-font-adjust", active); }; const updateStatusText = (message) => { const statusNode = document.querySelector(`#${PANEL_ID} [data-role="status"]`); if (!statusNode) return; if (message) { statusNode.textContent = message; return; } if (!config.enabled) { statusNode.textContent = "替换功能已关闭,页面正常显示。"; return; } if (!hasValidSession()) { statusNode.textContent = "尚未开始计时,点击保存并应用后开始 4 小时倒计时。"; return; } if (hasExpired()) { statusNode.textContent = "已超过 4 小时,替换功能自动失效。"; return; } statusNode.textContent = `替换功能开启中,剩余时间: ${formatRemaining(getRemainingMs())}`; }; const syncStatusLoop = () => { if (statusUpdater) { clearInterval(statusUpdater); } statusUpdater = window.setInterval(() => { if (config.enabled && hasExpired()) { disableReplacement("已超过 4 小时,替换功能自动失效。"); return; } updateStatusText(); }, 1000); }; const applyReplacements = (statusMessage = "") => { if (applying) return; applying = true; try { applyFontAdjust(); if (isReplacementActive()) { getReplacementRoots().forEach(walkAndReplace); } updateStatusText(statusMessage); } finally { applying = false; } }; const stopFastScanLoop = () => { if (fastScanInterval) { clearInterval(fastScanInterval); fastScanInterval = null; } if (fastScanStopTimer) { clearTimeout(fastScanStopTimer); fastScanStopTimer = null; } }; const startFastScanLoop = () => { stopFastScanLoop(); const tick = () => { if (document.body) { applyReplacements(); } }; tick(); fastScanInterval = window.setInterval(tick, FAST_SCAN_INTERVAL_MS); fastScanStopTimer = window.setTimeout(() => { stopFastScanLoop(); }, FAST_SCAN_WINDOW_MS); }; const startObserver = () => { if (observer) observer.disconnect(); observer = new MutationObserver((mutations) => { if (applying) return; for (const mutation of mutations) { if (mutation.type === "characterData") { updateTextNode(mutation.target); continue; } mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { updateTextNode(node); } else if (node.nodeType === Node.ELEMENT_NODE) { walkAndReplace(node); } }); } }); observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, }); }; const ensureStyles = () => { if (document.getElementById(STYLE_ID)) return; const style = document.createElement("style"); style.id = STYLE_ID; style.textContent = ` .codex-msmail-font-adjust body, .codex-msmail-font-adjust input, .codex-msmail-font-adjust button, .codex-msmail-font-adjust textarea, .codex-msmail-font-adjust select { letter-spacing: 0.02em !important; } #${PANEL_ID} { position: fixed; top: max(12px, env(safe-area-inset-top)); left: 12px; right: 12px; z-index: 2147483647; background: rgba(247, 244, 237, 0.98); color: #2e2a26; border: 1px solid rgba(60, 49, 38, 0.16); border-radius: 10px; box-shadow: 0 18px 40px rgba(27, 22, 18, 0.18); padding: 12px; font: 13px/1.35 -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB", sans-serif; backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); max-height: calc(100vh - max(24px, env(safe-area-inset-top)) - max(24px, env(safe-area-inset-bottom))); overflow: hidden; } #${PANEL_ID}[hidden] { display: none !important; } #${PANEL_ID}.is-collapsed .codex-body { display: none; } #${PANEL_ID} .codex-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } #${PANEL_ID} .codex-header-main { min-width: 0; } #${PANEL_ID} .codex-title { font-size: 18px; font-weight: 700; border: 0; background: transparent; color: #2e2a26; padding: 0; text-align: left; } #${PANEL_ID} .codex-close { border: 0; background: transparent; color: #4b433b; font-size: 14px; padding: 2px 4px; } #${PANEL_ID} .codex-meta { margin-top: 4px; color: #6d6256; display: grid; gap: 2px; word-break: break-all; } #${PANEL_ID} .codex-body { margin-top: 12px; display: grid; gap: 10px; max-height: calc(100vh - 180px); overflow-y: auto; overflow-x: hidden; padding-right: 2px; -webkit-overflow-scrolling: touch; } #${PANEL_ID} button, #${PANEL_ID} input, #${PANEL_ID} textarea, #${PANEL_ID} select { font: inherit; } #${PANEL_ID} .codex-action-row { display: flex; gap: 8px; flex-wrap: wrap; } #${PANEL_ID} .codex-btn { border: 0; border-radius: 999px; padding: 8px 12px; background: #ddd4c7; color: #352f29; } #${PANEL_ID} .codex-btn.primary { background: #c6b091; color: #201915; } #${PANEL_ID} .codex-input { width: 100%; box-sizing: border-box; border: 1px solid #d5c8b8; border-radius: 8px; padding: 9px 10px; background: rgba(255, 255, 255, 0.9); color: #2f2924; } #${PANEL_ID} .codex-check-row { display: flex; gap: 12px; flex-wrap: wrap; color: #433b34; } #${PANEL_ID} .codex-check { display: inline-flex; align-items: center; gap: 6px; padding: 6px 10px; border-radius: 999px; background: rgba(255, 255, 255, 0.68); } #${PANEL_ID} .codex-table { display: grid; gap: 8px; } #${PANEL_ID} .codex-rules-scroll { overflow-x: auto; overflow-y: visible; -webkit-overflow-scrolling: touch; padding-bottom: 4px; } #${PANEL_ID} .codex-rule-guide { display: grid; gap: 6px; padding: 10px 12px; border-radius: 8px; background: rgba(255, 255, 255, 0.62); color: #5c5248; } #${PANEL_ID} .codex-guide-strong { font-weight: 700; color: #2f2924; } #${PANEL_ID} .codex-table-head, #${PANEL_ID} .codex-rule-row { display: grid; grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr) 58px; gap: 8px; align-items: center; } #${PANEL_ID}.is-advanced .codex-table-head, #${PANEL_ID}.is-advanced .codex-rule-row { grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr) 112px 58px; } #${PANEL_ID} .codex-col-mode, #${PANEL_ID} .codex-mode-block { display: none; } #${PANEL_ID}.is-advanced .codex-col-mode, #${PANEL_ID}.is-advanced .codex-mode-block { display: block; } #${PANEL_ID} .codex-table-head { color: #5d5348; font-weight: 600; } #${PANEL_ID} .codex-rule-row { background: rgba(255, 255, 255, 0.48); border-radius: 14px; padding: 12px; border: 1px solid rgba(190, 176, 157, 0.5); } #${PANEL_ID} .codex-field-block { display: grid; gap: 4px; min-width: 0; } #${PANEL_ID} .codex-field-label { font-size: 11px; font-weight: 700; letter-spacing: 0.01em; } #${PANEL_ID} .codex-field-label.original { color: #8a5a26; } #${PANEL_ID} .codex-field-label.replacement { color: #1f6b45; } #${PANEL_ID} .codex-rule-row .codex-input.original { border-color: #d9bf9a; background: rgba(255, 248, 240, 0.95); } #${PANEL_ID} .codex-rule-row .codex-input.replacement { border-color: #9fc7ae; background: rgba(244, 255, 248, 0.95); } #${PANEL_ID} .codex-small-btn { border: 0; border-radius: 8px; background: #e4d9cb; color: #443b32; padding: 11px 8px; } #${PANEL_ID} .codex-status { color: #75685a; font-size: 12px; } #${PANEL_ID} .codex-rule-toggle { display: flex; align-items: center; justify-content: center; } #${PANEL_ID} .codex-rule-toggle input { width: 22px; height: 22px; } #${PANEL_ID} .codex-mode-block { display: grid; gap: 4px; } #${PANEL_ID} .codex-mode-label { font-size: 11px; font-weight: 700; color: #555048; } @media (max-width: 520px) { #${PANEL_ID} .codex-table-head, #${PANEL_ID} .codex-rule-row { grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr) 58px; } #${PANEL_ID}.is-advanced .codex-table-head, #${PANEL_ID}.is-advanced .codex-rule-row { grid-template-columns: 40px minmax(0, 1fr) minmax(0, 1fr) 104px 52px; } #${PANEL_ID} .codex-rule-row { gap: 10px; } } `; document.head.appendChild(style); }; const escapeHtml = (value) => value .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); const buildMetaText = () => [ `当前网站: ${location.hostname}`, ]; // 从会员页面 HTML 中提取用户信息字段(通用解析器) const extractMemberInfo = (html) => { const doc = new DOMParser().parseFromString(html, "text/html"); const fields = []; const push = (label, value) => { const v = String(value || "").replace(/\s+/g, " ").trim(); if (v && v.length <= 80 && !fields.some((f) => f.value === v)) { fields.push({ label: String(label || "").replace(/\s+/g, " ").trim(), value: v }); } }; // 1) dl > dt + dd 结构 doc.querySelectorAll("dl").forEach((dl) => { const dt = dl.querySelector("dt"); const dd = dl.querySelector("dd"); if (dt && dd) push(dt.textContent, dd.textContent); }); // 2) table 中 th + td 结构 doc.querySelectorAll("tr").forEach((tr) => { const th = tr.querySelector("th"); const td = tr.querySelector("td"); if (th && td) push(th.textContent, td.textContent); }); // 3) 输入框 value(文本类) doc.querySelectorAll("input").forEach((inp) => { const t = (inp.type || "text").toLowerCase(); if (["text", "email", "tel", "search"].includes(t) && inp.value) { const label = inp.getAttribute("aria-label") || inp.getAttribute("title") || inp.name || "入力値"; push(label, inp.value); } }); // 4) 常见日文会员字段名:找到标签元素,取其相邻值 const knownLabels = ["会員番号", "会員No", "会員ID", "氏名", "お名前", "フリガナ", "ニックネーム", "メールアドレス", "電話番号", "生年月日", "性別", "住所"]; const labelCandidates = doc.querySelectorAll("div, span, p, label, th, dt"); knownLabels.forEach((lb) => { const el = Array.from(labelCandidates).find((e) => e.textContent.replace(/\s+/g, "").trim() === lb); if (!el) return; const sibling = el.nextElementSibling; if (sibling) push(lb, sibling.textContent); }); // 5) 页面内嵌脚本变量 member_data(会員番号 / 生年月日 / 性別) const memberDataMatch = html.match(/var\s+member_data\s*=\s*(\{[\s\S]*?\})(?:\s*;)?\s*(?:<\/script>|$)/i); if (memberDataMatch) { try { const data = JSON.parse(memberDataMatch[1]); if (data.member_id) push("会員番号", data.member_id); if (data.birth) push("生年月日", data.birth); if (data.sex) push("性別", data.sex); } catch (e) { // 忽略解析失败 } } // 6) ポイント(現在のポイント:Nポイント,值前后可能夹着标签) const pointMatch = html.match(/現在のポイント[::][^0-9]*([\d,]+)[^0-9]*ポイント/); if (pointMatch) push("現在のポイント", pointMatch[1]); // 7) 页面标题 h1「XXX さんのマイページ」→ 昵称 const titleMatch = html.match(/