您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Capture and persist chat messages with minimal lag.
// ==UserScript== // @name MilkyWay Idle — Persistent/Longer Chat History // @namespace mwi_chat_longer_history // @version 2.1.1 // @description Capture and persist chat messages with minimal lag. // @author Silky-Panda // @license MIT // @match https://www.milkywayidle.com/* // @grant GM_getValue // @grant GM_setValue // @grant GM_listValues // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const CHAT_CONTAINER_SELECTOR = '[class^="ChatHistory_chatHistory"]'; const MAX_VISIBLE_PER_TAB = 200; const MAX_SAVED_PER_TAB = 5000; const SAVE_DEBOUNCE_MS = 800; const NS = 'mwi.chat.persist.v1'; const USER_CHAR_ALIAS_KEY = `${NS}::userCharacterAlias`; const observers = new Map(); const cache = new Map(); const initializedContainers = new WeakSet(); const restoringFor = new WeakSet(); const log = (...args) => console.log('[MWI-Chat]', ...args); const warn = (...args) => console.warn('[MWI-Chat]', ...args); function normalizeTabForStorage(tabId) { if (tabId === 'tab:#UwU') { return 'tab:General'; } return tabId; } function debounceSave(storageKey) { const entry = cache.get(storageKey); if (!entry) return; entry.dirty = true; if (entry.saveTimer) return; entry.saveTimer = setTimeout(() => { entry.saveTimer = null; if (!entry.dirty) return; entry.dirty = false; if (entry.messages.length > MAX_SAVED_PER_TAB) { entry.messages = entry.messages.slice(-MAX_SAVED_PER_TAB); } try { GM_setValue(storageKey, JSON.stringify(entry.messages)); } catch (e) { warn('Failed to save chat log for', storageKey, e); } }, SAVE_DEBOUNCE_MS); } function getStorageKey(characterId, tabId) { const account = 'acc'; return `${NS}::${account}::${characterId}::${tabId}`; } function ensureCache(storageKey) { if (cache.has(storageKey)) return cache.get(storageKey); let messages = []; try { const raw = GM_getValue(storageKey, '[]'); messages = JSON.parse(raw); if (!Array.isArray(messages)) messages = []; } catch (e) { warn('Failed to parse stored messages for', storageKey, e); messages = []; } const entry = { messages, dirty: false, saveTimer: null }; cache.set(storageKey, entry); return entry; } function textOf(el) { return el.outerHTML || el.textContent || ''; } function getUserCharacterAlias() { try { const alias = GM_getValue(USER_CHAR_ALIAS_KEY, ''); return (alias && typeof alias === 'string') ? alias.trim() : ''; } catch { return ''; } } function setUserCharacterAlias(alias) { try { GM_setValue(USER_CHAR_ALIAS_KEY, (alias || '').trim()); } catch (e) { warn('Failed to set character alias', e); } } function detectCharacterId() { const alias = getUserCharacterAlias(); if (alias) return `char:${alias}`; const candidates = []; const queryList = [ '[class*="Character"][class*="Name"]', '[class*="character"][class*="name"]', '[data-testid*="character"][data-testid*="name"]', '[aria-label*="Character"]', '[class*="Profile"] [class*="name"]', '[class*="Header"] [class*="name"]', ]; for (const q of queryList) { document.querySelectorAll(q).forEach(el => candidates.push(el)); } if (candidates.length === 0) { const top = document.querySelector('header, [class*="TopBar"], [class*="Navbar"]'); if (top) { top.querySelectorAll('*').forEach(el => { const txt = (el.textContent || '').trim(); if (txt && txt.length >= 3 && txt.length <= 24) candidates.push(el); }); } } for (const el of candidates) { const name = (el.textContent || '').trim(); if (name && /^[\w\s\-'.]+$/.test(name) && name.length <= 24) { return `char:${name}`; } } return 'char:unknown'; } function detectTabId(chatContainer) { const tabPanel = chatContainer.closest('.TabPanel_tabPanel__tXMJF'); const panelsContainer = tabPanel && tabPanel.parentElement; if (tabPanel && panelsContainer) { const panels = Array.from(panelsContainer.querySelectorAll(':scope > .TabPanel_tabPanel__tXMJF')); const panelIndex = panels.indexOf(tabPanel); if (panelIndex >= 0) { const tabsRoot = panelsContainer.closest('.TabsComponent_tabsComponent__3PqGp, .Chat_tabsComponentContainer__3ZoKe'); if (tabsRoot) { const tablist = tabsRoot.querySelector('[role="tablist"]'); if (tablist) { const tabs = Array.from(tablist.querySelectorAll('button[role="tab"]')); const tab = tabs[panelIndex] || null; if (tab) { const badge = tab.querySelector('span.MuiBadge-root'); if (badge && badge.childNodes && badge.childNodes.length) { for (let i = 0; i < badge.childNodes.length; i++) { const n = badge.childNodes[i]; if (n.nodeType === Node.TEXT_NODE) { const name = (n.textContent || '').trim(); if (name) return `tab:${name}`; } } } const raw = (tab.textContent || '').trim(); const label = raw.split(/\s*(?=\d)/)[0].trim() || raw; if (label) return `tab:${label}`; } } } } } try { const selected = document.querySelector('[role="tab"][aria-selected="true"]'); if (selected) { const badge = selected.querySelector('span.MuiBadge-root'); if (badge && badge.childNodes) { for (let i = 0; i < badge.childNodes.length; i++) { const n = badge.childNodes[i]; if (n.nodeType === Node.TEXT_NODE) { const name = (n.textContent || '').trim(); if (name) return `tab:${name}`; } } } const raw = (selected.textContent || '').trim(); const label = raw.split(/\s*(?=\d)/).trim() || raw; if (label) return `tab:${label}`; } } catch (e) { } const all = Array.from(document.querySelectorAll(CHAT_CONTAINER_SELECTOR)); const idx = Math.max(0, all.indexOf(chatContainer)); return `tab:index${idx}`; } function trimVisible(container) { while (container.children.length > MAX_VISIBLE_PER_TAB) { container.removeChild(container.firstElementChild); } } function handleNewNodes(container, storageKey, nodes) { const entry = ensureCache(storageKey); const seen = new Set(entry.messages); let added = 0; for (const node of nodes) { if (node.nodeType !== 1) continue; const html = textOf(node); if (html && !seen.has(html)) { entry.messages.push(html); seen.add(html); added++; } } if (added) { debounceSave(storageKey); trimVisible(container); } } function attachObserver(chatContainer, characterId, tabId) { const effectiveTabId = normalizeTabForStorage(tabId); const storageKey = getStorageKey(characterId, effectiveTabId); if (observers.has(chatContainer)) return; const observer = new MutationObserver(mutations => { if (restoringFor.has(chatContainer)) return; const added = []; for (const m of mutations) { m.addedNodes && added.push(...m.addedNodes); } if (added.length) handleNewNodes(chatContainer, storageKey, added); }); observer.observe(chatContainer, { childList: true }); observers.set(chatContainer, observer); const entry = ensureCache(storageKey); if (entry.messages.length) { restoringFor.add(chatContainer); try { const existingSet = new Set(); chatContainer.querySelectorAll(':scope > *').forEach(node => { existingSet.add(node.outerHTML); }); const frag = document.createDocumentFragment(); const toShow = entry.messages.slice(-MAX_VISIBLE_PER_TAB); let restoredCount = 0; for (const html of toShow) { if (!existingSet.has(html)) { const wrapper = document.createElement('div'); wrapper.innerHTML = html; while (wrapper.firstChild) frag.appendChild(wrapper.firstChild); restoredCount++; } } if (restoredCount > 0) { chatContainer.insertBefore(frag, chatContainer.firstChild); log(`Restored ${restoredCount} messages without duplicates.`); } } catch (e) { warn('Failed restoring chat DOM for', storageKey, e); } finally { restoringFor.delete(chatContainer); } trimVisible(chatContainer); } } function initContainer(chatContainer) { if (!chatContainer || initializedContainers.has(chatContainer)) return; initializedContainers.add(chatContainer); const characterId = detectCharacterId(); const tabId = detectTabId(chatContainer); const effectiveTabId = normalizeTabForStorage(tabId); const storageKey = getStorageKey(characterId, effectiveTabId); const entry = ensureCache(storageKey); log('Init chat container', { characterId, effectiveTabId, storageKey, entry }); const seen = new Set(entry.messages); let added = 0; chatContainer.querySelectorAll(':scope > *').forEach(node => { const html = textOf(node); if (html && !seen.has(html)) { entry.messages.push(html); seen.add(html); added++; } }); if (added) { log(`Merged ${added} existing DOM messages for ${effectiveTabId}`); debounceSave(storageKey); } attachObserver(chatContainer, characterId, tabId); } function scanAndInit() { document.querySelectorAll(CHAT_CONTAINER_SELECTOR).forEach(initContainer); } function installRootObserver() { const rootObs = new MutationObserver(() => scanAndInit()); rootObs.observe(document.body, { childList: true, subtree: true }); scanAndInit(); } function exportAll() { const data = {}; for (const key of GM_listValues()) { if (!key.startsWith(NS)) continue; try { data[key] = JSON.parse(GM_getValue(key, '[]')); } catch { data[key] = GM_getValue(key, null); } } const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'mwi-chat-export.json'; a.click(); URL.revokeObjectURL(url); } function clearScope(predicate, label) { let count = 0; for (const key of GM_listValues()) { if (predicate(key)) { GM_deleteValue(key); count++; } } alert(`Cleared ${count} stored item(s) for ${label}.`); } function currentKeysFor(characterId) { const keys = []; for (const key of GM_listValues()) { if (key.startsWith(`${NS}::`) && key.includes(`::${characterId}::`)) { keys.push(key); } } return keys; } function registerMenus() { GM_registerMenuCommand('Set Character Alias (override auto-detect)', () => { const cur = getUserCharacterAlias(); const next = prompt('Enter a short alias/name for this character (used to namespace chat logs):', cur || ''); if (next != null) { setUserCharacterAlias(next); alert('Alias saved. Reload the page so this character ID is used going forward.'); } }); GM_registerMenuCommand('Export All Chat Logs (JSON)', exportAll); GM_registerMenuCommand('Clear ALL Stored Chat Logs', () => { if (confirm('Delete ALL stored chat logs for this script?')) { clearScope(k => k.startsWith(NS), 'ALL chats'); } }); GM_registerMenuCommand('Clear CURRENT Character Logs', () => { const charId = detectCharacterId(); if (confirm(`Delete stored chat logs for current character?\n\n${charId}`)) { const keys = currentKeysFor(charId); keys.forEach(GM_deleteValue); alert(`Cleared ${keys.length} tab(s) for ${charId}. Reload recommended.`); } }); } function start() { registerMenus(); installRootObserver(); log('Persistent chat ready.'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } })();