PonyTown 网页聊天记录存档器

自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制、下载、数据统计和清除界面。支持结构化数据提取和Emoji格式化。

目前為 2025-08-21 提交的版本,檢視 最新版本

// ==UserScript==
// @name          PonyTown 网页聊天记录存档器
// @namespace     http://tampermonkey.net/
// @version       4.6
// @description   自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制、下载、数据统计和清除界面。支持结构化数据提取和Emoji格式化。
// @author        doucx
// @match         https://pony.town/*
// @grant         GM_addStyle
// @run-at        document-idle
// @license       MIT
// ==/UserScript==
 
(function() {
    'use strict';
    /*
     * =================================================================
     * 核心功能模块 (版本 4.6)
     * 负责解析、处理和存储结构化数据。
     * =================================================================
     */
 
    const STORAGE_KEY = 'chatLogArchive_v4';
    const SELF_NAME_KEY = 'chatLogArchiver_selfName';
 
    /**
     * 获取当前的本地日期和时间字符串,格式为 'YYYY-MM-DD HH:MM'。
     * 该函数基于用户设备(运行代码的计算机)的时区返回时间。
     *
     * @returns {string} 格式化的本地日期时间字符串。
     *                   例如:如果当前是2023年10月27日 下午2点35分,则返回 "2023-10-27 14:35"。
     */
    function getLocalDateTimeString() {
            const now = new Date();
            const year = now.getFullYear();
            const month = (now.getMonth() + 1).toString().padStart(2, '0');
            const day = now.getDate().toString().padStart(2, '0');
            const hours = now.getHours().toString().padStart(2, '0');
            const minutes = now.getMinutes().toString().padStart(2, '0');
            const seconds = now.getSeconds().toString().padStart(2, '0');
            const localFormattedTime = `${year}-${month}-${day} ${hours}:${minutes}`;
            return localFormattedTime;
    }
 
    /**
     * 判断一个字符的 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-announcement')) data.type = 'announcement';
        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;
    }
 
    /**
     * 通过查找包含 'active' 类的元素来确定当前活跃的标签页。
     * 这种方法比分析样式更直接、更健壮。
     */
    function findActiveTabByClass(htmlString) {
        // 1. 创建一个临时的 DOM 元素来解析 HTML 字符串
        const container = document.createElement('div');
        container.innerHTML = htmlString;
 
        // 2. 使用 CSS 选择器直接找到同时包含 'chat-log-tab' 和 'active' 类的 a 元素
        // querySelector 会返回第一个匹配的元素,或者在没有匹配时返回 null。
        const activeTab = container.querySelector('a.chat-log-tab.active');
 
        // 3. 如果找到了活跃的标签页,返回其文本内容(去除前后空格);否则返回 null
        if (activeTab) {
            return activeTab.textContent.trim();
        } else {
            return null;
        }
    }
 
    function locateChatElements() {
        return {
            tabs: document.querySelector('.chat-log-tabs'),
            chatLog: document.querySelector('.chat-log-scroll-inner')
        };
    }
 
    /**
     * 智能合并两个消息数组,基于消息内容去重,并在不连续时插入标记。
     * @param {Array<Object>} oldMessages - 旧的消息数组
     * @param {Array<Object>} newMessages - 新的消息数组
     * @returns {Array<Object>} 合并和去重后的消息数组
     */
    function mergeAndDeduplicateMessages(oldMessages, newMessages) {
        if (!oldMessages || oldMessages.length === 0) return newMessages;
        if (!newMessages || newMessages.length === 0) return oldMessages;
 
        // 过滤掉由本工具自身插入的消息(例如警告标记),仅基于核心用户消息进行重叠比较。
        const oldUserMessages = oldMessages.filter(msg => !msg.is_archiver);
        const newUserMessages = newMessages.filter(msg => !msg.is_archiver);
 
        let overlapLength = 0;
        const maxPossibleOverlap = Math.min(oldUserMessages.length, newUserMessages.length);
 
        for (let i = maxPossibleOverlap; i > 0; i--) {
            // 使用过滤后的数组来寻找重叠部分
            const suffixOfOld = oldUserMessages.slice(-i).map(msg => msg.content);
            const prefixOfNew = newUserMessages.slice(0, i).map(msg => msg.content);
 
            if (JSON.stringify(suffixOfOld) === JSON.stringify(prefixOfNew)) {
                // overlapLength 代表的是“用户消息”的重叠数量
                overlapLength = i;
                break;
            }
        }
 
        let messagesToAdd;
        if (overlapLength > 0) {
            // 由于 overlapLength 是基于过滤后的“用户消息”计算的,我们不能直接用它来切片原始的 newMessages 数组。
            // 必须在原始数组中找到最后一个重叠的用户消息的精确索引,以确保其后的所有新消息(包括非用户消息)都被正确添加。
            const lastOverlappingUserMessage = newUserMessages[overlapLength - 1];
            const lastOverlappingIndexInNew = newMessages.findIndex(msg => msg === lastOverlappingUserMessage);
 
            messagesToAdd = newMessages.slice(lastOverlappingIndexInNew + 1);
        } else {
            // 如果没有重叠,则需要添加所有新消息
            messagesToAdd = newMessages;
        }
 
        const discontinuityDetected = oldMessages.length > 0 && newMessages.length > 0 && overlapLength === 0;
 
        if (messagesToAdd.length === 0) {
            // 如果没有需要添加的新消息,直接返回旧数组
            return oldMessages;
        }
 
        if (discontinuityDetected) {
            console.warn('检测到聊天记录不连续,可能存在数据丢失。已插入警告标记。');
 
            const discontinuityMark = {
                time: getLocalDateTimeString(),
                type: 'system',
                sender: 'Archiver',
                receiver: 'System',
                content: '[警告 - 此处可能存在记录丢失]',
                is_archiver: true // 关键属性,用于在下次合并时忽略此条警告消息。
            };
 
            return oldMessages.concat([discontinuityMark], messagesToAdd);
        }
 
        return oldMessages.concat(messagesToAdd);
    }
 
    /**
     * 从页面中提取当前聊天状态和结构化消息。
     * 此函数现在包含优雅的日期处理逻辑,可正确处理跨午夜的聊天记录。
     */
    function extractCurrentChatState() {
        const elements = locateChatElements();
        if (!elements.tabs || !elements.chatLog) {
            console.warn("提取失败:未能找到关键的聊天界面元素。");
            return { current_tab: null, messages: [] };
        }
        const current_tab = findActiveTabByClass(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);
    }
 
    // TODO 在关闭页面等情况时执行保存。
    function saveCurrentChannelMessage() {
        // console.log("正在执行存档任务...");
        const chatState = extractCurrentChatState();
        if (chatState.current_tab && chatState.messages.length > 0) {
            let allMyMessages = loadMessagesFromStorage();
            // TODO 将更新和存档分开(聊天框变化时更新,每十五秒存一次档),为以后基于聊天框变化时更新消息与添加秒级别时间戳做准备
            // TODO 先初始化一个数组,向里面填入内容,基于该数组进行合并。目的:在之后检测聊天框变化时不产生大量的磁盘更新。
            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-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.6</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') {
                    counts.set(msg.sender, (counts.get(msg.sender) || 0) + 1);
                    totalMessagesInPeriod++;
                }
            });
 
            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++;
                } 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) {
            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) {
            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}] 中没有记录可供统计 ---`;
            }
 
            // 过滤掉所有 is_archiver 的消息
            const filteredMessages = messages.filter(msg => !msg.is_archiver);
 
            // 如果过滤后没有消息,也提示用户
            if (filteredMessages.length === 0) {
                return `--- 在频道 [${channelName}] 中没有可供统计的用户消息 ---`;
            }
 
            // 报告标题中的消息数量应反映实际分析的数量
            let output = `--- [${channelName}] 频道统计报告 (分析 ${filteredMessages.length} 条消息) ---\n`;
 
            // 2. 将过滤后的 filteredMessages 传递给数据处理函数
            output += formatTopTalkers(calculateTopTalkers(filteredMessages));
            output += formatHourlyActivity(calculateHourlyActivity(filteredMessages));
 
            return output;
        }
 
        // ======================= 视图渲染逻辑重构 =======================
 
        function formatMessageForDisplay(msg) {
            let prefix = '';
            if (msg.type.includes('party')) prefix = '👥 ';
            else if (msg.type.includes('whisper')) prefix = '💬 ';
            else if (msg.type.includes('announcement')) 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() {
        createUI();
        console.log("PonyTown 聊天记录存档器 v4.6 正在等待游戏界面加载...");
 
        // TODO 聊天框变化时更新,并添加秒级别时间戳
        const startupPoller = setInterval(() => {
            const { tabs, chatLog } = locateChatElements();
            if (tabs && chatLog) {
                clearInterval(startupPoller);
                console.log("游戏界面已加载,正在启动存档循环...");
                saveCurrentChannelMessage();
                setInterval(() => {
                    // console.log("正在执行后台自动存档...");
                    saveCurrentChannelMessage();
                }, 15000);
                console.log("自动存档已激活");
            }
        }, 500);
    }
 
    if (document.readyState === 'complete') {
        main();
    } else {
        window.addEventListener('load', main);
    }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址