您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制、下载、数据统计和清除界面。支持结构化数据提取和Emoji格式化。
当前为
// ==UserScript== // @name PonyTown 网页聊天记录存档器 // @namespace http://tampermonkey.net/ // @version 4.4 // @description 自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制、下载、数据统计和清除界面。支持结构化数据提取和Emoji格式化。 // @author doucx // @match https://pony.town/* // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; /* * ================================================================= * 核心功能模块 (版本 4.4) * 负责解析、处理和存储结构化数据。 * ================================================================= */ const STORAGE_KEY = 'chatLogArchive_v4'; const SELF_NAME_KEY = 'chatLogArchiver_selfName'; // ... (从 isCharacterInPrivateUseArea 到 saveCurrentChannelMessage 的所有函数保持不变) ... 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; } 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 ''; } 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) { 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) { 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() { const chatState = extractCurrentChatState(); if (chatState.current_tab && chatState.messages.length > 0) { let allMyMessages = loadMessagesFromStorage(); updateChannelMessage(allMyMessages, chatState.current_tab, chatState.messages); saveMessagesToStorage(allMyMessages); } } /* * ================================================================= * 用户交互界面 (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-download-button { background-color: #3a6a8c; } #log-archive-download-button:hover { background-color: #4d86a6; } #log-archive-stats-button { background-color: #5d4a7b; } /* 新按钮的颜色 */ #log-archive-stats-button:hover { background-color: #7b65a0; } #log-archive-stats-button.active { background-color: #3a8c54; border-color: #4da669; color: #fff; } /* 激活状态样式 */ #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.4</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-stats-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-download-button" class="log-archive-ui-button">下载</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'); const downloadButton = document.getElementById('log-archive-download-button'); const statsButton = document.getElementById('log-archive-stats-button'); // 获取新按钮 // --- 状态管理 --- let isStatsViewActive = false; // 追踪当前是否为统计视图 selfNameInput.value = localStorage.getItem(SELF_NAME_KEY) || ''; selfNameInput.addEventListener('change', () => { localStorage.setItem(SELF_NAME_KEY, selfNameInput.value.trim()); }); // ======================= 新增统计功能模块 ======================= // --- 数据处理层 --- function calculateTopTalkers(messages) { const counts = new Map(); let totalMessagesInPeriod = 0; // 新增:用于统计总消息数 messages.forEach(msg => { if (msg.sender && msg.sender !== 'System' && msg.sender !== 'Archiver') { counts.set(msg.sender, (counts.get(msg.sender) || 0) + 1); totalMessagesInPeriod++; // 每计入一条消息,总数加1 } }); const data = Array.from(counts.entries()) .map(([name, count]) => ({ name, count })) .sort((a, b) => b.count - a.count); // 返回包含数据和总消息数的对象 return { data: data, total: totalMessagesInPeriod }; } function calculateHourlyActivity(messages) { const hourlyCounts = new Array(24).fill(0); let totalMessagesInPeriod = 0; // 新增:用于统计总消息数 messages.forEach(msg => { try { const hour = new Date(msg.time.replace(/-/g, '/')).getHours(); // 兼容性更好的日期解析 hourlyCounts[hour]++; totalMessagesInPeriod++; // 每计入一条消息,总数加1 } catch (e) { /* 忽略无效时间 */ } }); const data = hourlyCounts.map((count, hour) => ({ hour, count })) .filter(item => item.count > 0) .sort((a, b) => b.count - a.count); // 返回包含数据和总消息数的对象 return { data: data, total: totalMessagesInPeriod }; } // --- 格式化层 --- function formatTopTalkers(results) { // 参数名改为 results,因为它现在包含 data 和 total const data = results.data; const total = results.total; let text = '\n\n===== 最活跃用户 (TOP 10) =====\n\n'; if (data.length === 0 || total === 0) return text + '无用户发言记录。'; return text + data.slice(0, 10).map(item => { const percentage = (item.count / total * 100).toFixed(1); // 计算百分比,保留一位小数 // 格式化字符串,添加百分比 return `${item.name.padEnd(20, ' ')} | ${item.count} 条消息 (${percentage}%)`; }).join('\n'); } function formatHourlyActivity(results) { // 参数名改为 results,因为它现在包含 data 和 total const data = results.data; const total = results.total; let text = '\n\n===== 聊天峰值时间段 =====\n\n'; if (data.length === 0 || total === 0) return text + '无有效时间记录。'; return text + data.map(item => { const hourStr = String(item.hour).padStart(2, '0'); const nextHourStr = String((item.hour + 1) % 24).padStart(2, '0'); const percentage = (item.count / total * 100).toFixed(1); // 计算百分比,保留一位小数 // 格式化字符串,添加百分比 return `${hourStr}:00 - ${nextHourStr}:00 `.padEnd(16, ' ') + `| ${item.count} 条消息 (${percentage}%)`; }).join('\n'); } // --- 主聚合函数 --- function generateStatisticsText(messages, channelName) { if (!messages || messages.length === 0) { return `--- 在频道 [${channelName}] 中没有记录可供统计 ---`; } let output = `--- [${channelName}] 频道统计报告 (${messages.length}条消息) ---\n`; // 模块化调用 output += formatTopTalkers(calculateTopTalkers(messages)); output += formatHourlyActivity(calculateHourlyActivity(messages)); // 未来可在此处添加更多统计模块的调用 return output; } // ======================= 视图渲染逻辑重构 ======================= function formatMessageForDisplay(msg) { let prefix = ''; if (msg.type.includes('party')) prefix = '👥 '; else if (msg.type.includes('whisper')) prefix = '💬 '; return `${msg.time} ${prefix}${msg.content}`; } function displayChatLog(messages, channelName) { if (messages && messages.length > 0) { logDisplay.value = messages.map(formatMessageForDisplay).join('\n'); } else { logDisplay.value = `--- 在频道 [${channelName}] 中没有记录 ---`; } } function displayStatistics(messages, channelName) { logDisplay.value = generateStatisticsText(messages, channelName); } function renderCurrentView() { const allMessages = loadMessagesFromStorage(); const selectedChannel = channelSelector.value; const messages = allMessages[selectedChannel] || []; if (isStatsViewActive) { displayStatistics(messages, selectedChannel); } else { displayChatLog(messages, selectedChannel); } } function updateUI() { const previouslySelected = channelSelector.value; const messagesByChannel = loadMessagesFromStorage(); const channels = Object.keys(messagesByChannel); channelSelector.innerHTML = ''; if (channels.length === 0) { channelSelector.innerHTML = '<option>无记录</option>'; } 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]; } renderCurrentView(); // 核心:更新完选择器后,根据当前视图状态渲染内容 } // ======================= 事件绑定 ======================= toggleButton.addEventListener('click', () => { const isVisible = uiContainer.style.display === 'flex'; if (!isVisible) { saveCurrentChannelMessage(); // 仅在打开时刷新一次 updateUI(); } uiContainer.style.display = isVisible ? 'none' : 'flex'; }); closeButton.addEventListener('click', () => { uiContainer.style.display = 'none'; }); channelSelector.addEventListener('change', renderCurrentView); // 切换频道时,重渲染视图 refreshButton.addEventListener('click', () => { saveCurrentChannelMessage(); updateUI(); // 刷新后,更新整个UI }); statsButton.addEventListener('click', () => { isStatsViewActive = !isStatsViewActive; // 切换状态 statsButton.classList.toggle('active', isStatsViewActive); // 更新按钮样式 statsButton.textContent = isStatsViewActive ? '查看记录' : '查看统计'; renderCurrentView(); // 重渲染视图 }); copyButton.addEventListener('click', () => { if (logDisplay.value) { navigator.clipboard.writeText(logDisplay.value).then(() => { console.log('当前显示内容已复制到剪贴板。'); }); } }); copyAllButton.addEventListener('click', () => { const messages = JSON.stringify(loadMessagesFromStorage(), null, 2); navigator.clipboard.writeText(messages).then(() => console.log('所有频道的记录 (JSON格式) 已复制到剪贴板。')); }); clearButton.addEventListener('click', () => { if (confirm('【警告】你确定要删除所有本地聊天记录吗?此操作不可逆!')) { localStorage.removeItem(STORAGE_KEY); isStatsViewActive = false; // 重置视图状态 statsButton.classList.remove('active'); statsButton.textContent = '查看统计'; updateUI(); console.log('所有本地记录已被清除。'); } }); downloadButton.addEventListener('click', () => { const allMessages = loadMessagesFromStorage(); if (Object.keys(allMessages).length === 0) { alert('没有可供下载的记录。'); return; } const now = new Date(); const timestamp = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; const baseFilename = `pt-saver-${timestamp}`; let allTextContent = ''; for (const channelName in allMessages) { allTextContent += `\n\n==================== 频道: ${channelName} ====================\n\n`; allTextContent += allMessages[channelName].map(formatMessageForDisplay).join('\n'); } function triggerDownload(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } triggerDownload(JSON.stringify(allMessages, null, 2), `${baseFilename}.json`, 'application/json'); triggerDownload(allTextContent.trim(), `${baseFilename}.txt`, 'text/plain'); console.log(`已触发下载:${baseFilename}.json 和 ${baseFilename}.txt`); }); } /* * ================================================================= * 脚本主程序和入口 * ================================================================= */ function main() { console.log("PonyTown 聊天记录存档器 v4.4 正在等待游戏界面加载..."); const startupPoller = setInterval(() => { const { tabs, chatLog } = locateChatElements(); if (tabs && chatLog) { clearInterval(startupPoller); console.log("游戏界面已加载,正在启动存档器..."); createUI(); saveCurrentChannelMessage(); setInterval(() => { console.log("正在执行后台自动存档..."); saveCurrentChannelMessage(); }, 15000); console.log("自动存档已激活。可点击右下角悬浮图标进行手动操作。"); } }, 500); } if (document.readyState === 'complete') { main(); } else { window.addEventListener('load', main); } })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址