您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制和清除界面。支持结构化数据提取和Emoji格式化。
// ==UserScript== // @name PonyTown 网页聊天记录存档器 // @namespace http://tampermonkey.net/ // @version 4.2 // @description 自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制和清除界面。支持结构化数据提取和Emoji格式化。 // @author doucx // @match https://pony.town/* // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; /* * ================================================================= * 核心功能模块 (版本 4.2) * 负责解析、处理和存储结构化数据。 * ================================================================= */ const STORAGE_KEY = 'chatLogArchive_v4'; // 版本4使用新的键名,自动废弃旧数据 const SELF_NAME_KEY = 'chatLogArchiver_selfName'; /** * 判断一个字符的 Unicode 码点是否位于私有使用区 (Private Use Area)。 * 私有使用区的字符通常无法在不同平台或字体间通用显示。 * @param {string} char - 要检查的单个字符。 * @returns {boolean} - 如果字符在私有使用区,则返回 true;否则返回 false。 */ function isCharacterInPrivateUseArea(char) { if (!char) return false; const codePoint = char.codePointAt(0); if (codePoint === undefined) return false; const isInPUA = (codePoint >= 0xE000 && codePoint <= 0xF8FF); const isInSupPUA_A = (codePoint >= 0xF0000 && codePoint <= 0xFFFFD); const isInSupPUA_B = (codePoint >= 0x100000 && codePoint <= 0x10FFFD); return isInPUA || isInSupPUA_A || isInSupPUA_B; } /** * 递归地从一个 DOM 节点及其子节点中提取可见的文本内容。 * - 忽略 style="display: none;" 的元素。 * - 优先使用 <img> alt 属性中的 Emoji。 * - 如果 alt 中的 Emoji 无法正常显示(例如,在 Unicode 私有使用区),则回退使用 :aria-label: 格式。 * @param {Node} node - 要开始提取的 DOM 节点。 * @returns {string} - 提取并格式化后的纯文本。 */ function customTextContent(node) { if (!node) return ''; if (node.nodeType === Node.TEXT_NODE) { return node.textContent; } if (node.nodeType === Node.ELEMENT_NODE) { if (node.style.display === 'none') { return ''; } if (node.tagName === 'IMG' && node.classList.contains('pixelart')) { const alt = node.alt || ''; const label = node.getAttribute('aria-label'); if (alt && !isCharacterInPrivateUseArea(alt)) { return alt; } if (label) { return `:${label}:`; } return ''; } let text = ''; for (const child of node.childNodes) { text += customTextContent(child); } return text; } return ''; } /** * 从单个聊天行元素中提取结构化的有用信息。 * @param {HTMLElement} chatLineElement - 代表一行聊天记录的DOM元素 (div.chat-line)。 * @param {string} selfName - 用户在设置中输入的自己的昵称,用于判断私聊方向。 * @param {string} precomputedTime - 由调用者提供的、已格式化为 "YYYY-MM-DD HH:MM" 的完整时间字符串。 * @returns {{time: string, type: string, sender: string, receiver: string, content: string}|null} */ function extractUsefulData(chatLineElement, selfName, precomputedTime) { if (!chatLineElement || !precomputedTime) return null; const data = { time: precomputedTime, type: 'unknown', sender: 'System', receiver: 'Local', content: '' }; const timeNode = chatLineElement.querySelector('.chat-line-timestamp'); if (!timeNode) return null; const cl = chatLineElement.classList; if (cl.contains('chat-line-whisper-thinking')) data.type = 'whisper-think'; else if (cl.contains('chat-line-whisper')) data.type = 'whisper'; else if (cl.contains('chat-line-party-thinking')) data.type = 'party-think'; else if (cl.contains('chat-line-party')) data.type = 'party'; else if (cl.contains('chat-line-thinking')) data.type = 'think'; else if (cl.contains('chat-line-meta-line')) data.type = 'system'; else if (cl.contains('chat-line')) data.type = 'say'; const container = chatLineElement.cloneNode(true); container.querySelectorAll('.chat-line-timestamp, .chat-line-lead').forEach(el => el.remove()); data.content = customTextContent(container).replace(/\s+/g, ' ').trim(); const nameNode = chatLineElement.querySelector('.chat-line-name'); const nameText = nameNode ? customTextContent(nameNode).replace(/^\[|\]$/g, '').trim() : null; if (data.type === 'system') return data; if (data.type.includes('party')) { data.receiver = 'Party'; if (nameText) data.sender = nameText; } else if (data.type.includes('whisper')) { const halfTextContext = customTextContent(container).replace(/\s+/g, ' ').trim(); if (halfTextContext.startsWith('To ') || halfTextContext.startsWith('Thinks to ')) { data.sender = selfName || 'Me (未设置)'; data.receiver = nameText || 'Unknown'; } else { data.sender = nameText || 'Unknown'; data.receiver = selfName || 'Me (未设置)'; } } else { data.receiver = 'Local'; if (nameText) data.sender = nameText; } return data; } /** * 通过分析元素背景颜色的频率,找出当前活跃的标签页。 */ function findActiveTabByStyleAnomaly(htmlString) { const container = document.createElement('div'); container.innerHTML = htmlString; const tabs = container.querySelectorAll('a.chat-log-tab'); if (tabs.length < 2) return tabs.length === 1 ? tabs[0].textContent.trim() : null; const styleFrequencies = new Map(); for (const tab of tabs) { const bgColor = tab.style.backgroundColor; styleFrequencies.set(bgColor, (styleFrequencies.get(bgColor) || 0) + 1); } let uniqueStyle = null; for (const [style, count] of styleFrequencies.entries()) { if (count === 1) { uniqueStyle = style; break; } } if (!uniqueStyle) return null; for (const tab of tabs) { if (tab.style.backgroundColor === uniqueStyle) return tab.textContent.trim(); } return null; } function locateChatElements() { return { tabs: document.querySelector('.chat-log-tabs'), chatLog: document.querySelector('.chat-log-scroll-inner') }; } /** * 智能合并两个消息数组,基于消息内容去重,并在不连续时插入标记。 */ function mergeAndDeduplicateMessages(oldMessages, newMessages) { if (!oldMessages || oldMessages.length === 0) return newMessages; if (!newMessages || newMessages.length === 0) return oldMessages; let overlapLength = 0; const maxPossibleOverlap = Math.min(oldMessages.length, newMessages.length); for (let i = maxPossibleOverlap; i > 0; i--) { const suffixOfOld = oldMessages.slice(-i).map(msg => msg.content); const prefixOfNew = newMessages.slice(0, i).map(msg => msg.content); if (JSON.stringify(suffixOfOld) === JSON.stringify(prefixOfNew)) { overlapLength = i; break; } } const messagesToAdd = newMessages.slice(overlapLength); const discontinuityDetected = oldMessages.length > 0 && newMessages.length > 0 && overlapLength === 0; if (messagesToAdd.length === 0) return oldMessages; if (discontinuityDetected) { console.warn('检测到聊天记录不连续,可能存在数据丢失。已插入警告标记。'); const discontinuityMark = { time: new Date().toISOString().replace('T', ' ').slice(0, 16), type: 'system', sender: 'Archiver', receiver: 'System', content: '[警告 - 此处可能存在记录丢失]' }; return oldMessages.concat([discontinuityMark], messagesToAdd); } return oldMessages.concat(messagesToAdd); } /** * 从页面中提取当前聊天状态和结构化消息。 * 此函数现在包含优雅的日期处理逻辑,可正确处理跨午夜的聊天记录。 */ function extractCurrentChatState() { const elements = locateChatElements(); if (!elements.tabs || !elements.chatLog) { console.error("提取失败:未能找到关键的聊天界面元素。"); return { current_tab: null, messages: [] }; } const current_tab = findActiveTabByStyleAnomaly(elements.tabs.innerHTML); const selfName = localStorage.getItem(SELF_NAME_KEY) || ''; const messages = []; const chatLines = Array.from(elements.chatLog.children); let currentDate = new Date(); let lastTimeParts = null; for (let i = chatLines.length - 1; i >= 0; i--) { const element = chatLines[i]; const timeNode = element.querySelector('.chat-line-timestamp'); if (!timeNode || !timeNode.textContent.includes(':')) { continue; } const timeText = timeNode.textContent.trim(); const [hours, minutes] = timeText.split(':').map(Number); if (lastTimeParts && (hours > lastTimeParts.hours || (hours === lastTimeParts.hours && minutes > lastTimeParts.minutes))) { currentDate.setDate(currentDate.getDate() - 1); } lastTimeParts = { hours, minutes }; const dateString = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')}`; const precomputedTime = `${dateString} ${timeText}`; const messageData = extractUsefulData(element, selfName, precomputedTime); if (messageData && messageData.content) { messages.push(messageData); } } messages.reverse(); return { current_tab, messages }; } function loadMessagesFromStorage() { try { return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {}; } catch (e) { console.error('读取存档失败,数据已损坏。', e); return {}; } } function saveMessagesToStorage(messagesObject) { localStorage.setItem(STORAGE_KEY, JSON.stringify(messagesObject)); } function updateChannelMessage(messagesObject, channelName, newMessagesArray) { if (!channelName || !newMessagesArray || newMessagesArray.length === 0) return; const oldMessages = messagesObject[channelName] || []; messagesObject[channelName] = mergeAndDeduplicateMessages(oldMessages, newMessagesArray); } function saveCurrentChannelMessage() { console.log("正在执行存档任务..."); const chatState = extractCurrentChatState(); if (chatState.current_tab && chatState.messages.length > 0) { let allMyMessages = loadMessagesFromStorage(); updateChannelMessage(allMyMessages, chatState.current_tab, chatState.messages); saveMessagesToStorage(allMyMessages); console.log(`频道 [${chatState.current_tab}] 的记录已更新。`); } else { console.log("未找到有效的聊天标签或内容,跳过本次存档。"); } } /* * ================================================================= * 用户交互界面 (UI) 模块 * ================================================================= */ function createUI() { GM_addStyle(` #log-archive-ui-container { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 70vw; height: 80vh; background-color: rgba(30, 30, 40, 0.85); border: 2px solid #5a6673; border-radius: 8px; box-shadow: 0 0 20px rgba(0,0,0,0.5); z-index: 99999; display: none; flex-direction: column; padding: 15px; font-family: monospace; color: #e0e0e0; } #log-archive-ui-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; flex-shrink: 0; flex-wrap: wrap; gap: 10px; } #log-archive-ui-header h2 { margin: 0; font-size: 1.2em; color: #8af; flex-shrink: 0; margin-right: 15px; } #log-archive-ui-controls { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } #log-archive-ui-log-display { width: 100%; height: 100%; background-color: #111; border: 1px solid #444; color: #ddd; font-size: 0.9em; padding: 10px; white-space: pre-wrap; word-wrap: break-word; overflow-y: auto; flex-grow: 1; resize: none; } .log-archive-ui-button, #log-archive-self-name-input { padding: 8px 12px; background-color: #4a545e; color: #fff; border: 1px solid #6c7886; border-radius: 4px; cursor: pointer; transition: background-color 0.2s; } .log-archive-ui-button:hover { background-color: #6c7886; } #log-archive-self-name-input { cursor: text; background-color: #2a3036; } #log-archive-refresh-button { background-color: #3a8c54; } #log-archive-refresh-button:hover { background-color: #4da669; } #log-archive-clear-button { background-color: #8c3a3a; } #log-archive-clear-button:hover { background-color: #a64d4d; } #log-archive-ui-toggle-button { position: fixed; bottom: 50px; right: 20px; width: 50px; height: 50px; background-color: #8af; color: #111; border-radius: 50%; border: none; font-size: 24px; line-height: 50px; text-align: center; cursor: pointer; z-index: 99998; box-shadow: 0 2px 10px rgba(0,0,0,0.3); } `); const container = document.createElement('div'); container.id = 'log-archive-ui-container'; container.innerHTML = ` <div id="log-archive-ui-header"> <h2>聊天记录存档 v4.2</h2> <div id="log-archive-ui-controls"> <input type="text" id="log-archive-self-name-input" placeholder="输入你的昵称..."> <select id="log-archive-channel-selector" class="log-archive-ui-button"></select> <button id="log-archive-refresh-button" class="log-archive-ui-button">立刻刷新</button> <button id="log-archive-copy-button" class="log-archive-ui-button">复制当前</button> <button id="log-archive-copy-all-button" class="log-archive-ui-button">复制全部 (JSON)</button> <button id="log-archive-clear-button" class="log-archive-ui-button">清除全部记录</button> <button id="log-archive-close-button" class="log-archive-ui-button">关闭</button> </div> </div> <textarea id="log-archive-ui-log-display" readonly></textarea> `; document.body.appendChild(container); const toggleButton = document.createElement('div'); toggleButton.id = 'log-archive-ui-toggle-button'; toggleButton.textContent = '📜'; document.body.appendChild(toggleButton); const uiContainer = document.getElementById('log-archive-ui-container'); const channelSelector = document.getElementById('log-archive-channel-selector'); const logDisplay = document.getElementById('log-archive-ui-log-display'); const copyButton = document.getElementById('log-archive-copy-button'); const copyAllButton = document.getElementById('log-archive-copy-all-button'); const clearButton = document.getElementById('log-archive-clear-button'); const closeButton = document.getElementById('log-archive-close-button'); const refreshButton = document.getElementById('log-archive-refresh-button'); const selfNameInput = document.getElementById('log-archive-self-name-input'); selfNameInput.value = localStorage.getItem(SELF_NAME_KEY) || ''; selfNameInput.addEventListener('change', () => { localStorage.setItem(SELF_NAME_KEY, selfNameInput.value.trim()); console.log(`昵称已保存为: "${selfNameInput.value.trim()}"`); }); /** * 根据消息类型格式化单条消息以供显示。 * @param {object} msg - 单条消息对象 { time, type, content, ... } * @returns {string} - 格式化后的字符串 */ function formatMessageForDisplay(msg) { let prefix = ''; // 使用 includes 可以同时匹配 'party' 和 'party-think' if (msg.type.includes('party')) { prefix = '👥 '; // 使用 includes 可以同时匹配 'whisper' 和 'whisper-think' } else if (msg.type.includes('whisper')) { prefix = '💬 '; } return `${msg.time} ${prefix}${msg.content}`; } function updateDisplay() { const previouslySelected = channelSelector.value; const messagesByChannel = loadMessagesFromStorage(); const channels = Object.keys(messagesByChannel); channelSelector.innerHTML = ''; if (channels.length === 0) { channelSelector.innerHTML = '<option>无记录</option>'; logDisplay.value = '--- 没有找到任何聊天记录 ---'; } else { channels.forEach(channel => { const option = document.createElement('option'); option.value = channel; option.textContent = `${channel} (${messagesByChannel[channel].length})`; channelSelector.appendChild(option); }); channelSelector.value = previouslySelected && channels.includes(previouslySelected) ? previouslySelected : channels[0]; const selectedChannelMessages = messagesByChannel[channelSelector.value] || []; logDisplay.value = selectedChannelMessages .map(formatMessageForDisplay) .join('\n') || `--- 在频道 [${channelSelector.value}] 中没有记录 ---`; } } toggleButton.addEventListener('click', () => { const isVisible = uiContainer.style.display === 'flex'; if (!isVisible) { saveCurrentChannelMessage(); updateDisplay(); } uiContainer.style.display = isVisible ? 'none' : 'flex'; }); closeButton.addEventListener('click', () => { uiContainer.style.display = 'none'; }); channelSelector.addEventListener('change', updateDisplay); refreshButton.addEventListener('click', () => { saveCurrentChannelMessage(); updateDisplay(); console.log('记录已成功抓取并刷新!'); }); copyButton.addEventListener('click', () => { if (logDisplay.value) { navigator.clipboard.writeText(logDisplay.value).then(() => { console.log('当前频道的记录已复制到剪贴板。'); }); } }); copyAllButton.addEventListener('click', () => { const messages = loadMessagesFromStorage(); const jsonString = JSON.stringify(messages, null, 2); navigator.clipboard.writeText(jsonString).then( () => console.log('所有频道的记录 (JSON格式) 已复制到剪贴板。'), err => console.error('复制全部记录失败:', err) ); }); clearButton.addEventListener('click', () => { if (confirm('【警告】你确定要删除所有本地聊天记录吗?此操作不可逆!')) { localStorage.removeItem(STORAGE_KEY); updateDisplay(); console.log('所有本地记录已被清除。'); } else { console.log('用户取消了清除操作。'); } }); } /* * ================================================================= * 脚本主程序和入口 * ================================================================= */ function main() { console.log("PonyTown 聊天记录存档器 v4.2 已启动。"); createUI(); setTimeout(saveCurrentChannelMessage, 2000); // 延迟2秒首次执行,确保页面完全渲染 setInterval(saveCurrentChannelMessage, 15000); // 每15秒自动存档 console.log("自动存档已激活。可点击右下角悬浮图标进行手动操作。"); } if (document.readyState === 'complete') { main(); } else { window.addEventListener('load', main); } })();