PonyTown 网页聊天记录存档器

自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制和清除界面。

当前为 2025-07-29 提交的版本,查看 最新版本

// ==UserScript==
// @name          PonyTown 网页聊天记录存档器
// @namespace     http://tampermonkey.net/
// @version       3.6
// @description   自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制和清除界面。
// @author        doucx
// @match         https://pony.town/*
// @grant         GM_addStyle
// @run-at        document-idle
// @license MIT
// ==/UserScript==

(function() {
    'use strict';
    /*
     * =================================================================
     * 核心功能模块
     * 负责解析、处理和存储数据。
     * =================================================================
     */

    /**
     * 将包含HTML的字符串转换为纯文本。
     * - 忽略 style="display: none;" 的元素。
     * - 将 <img> 标签转换为其 alt 属性的文本。
     * @param {string} htmlString - 包含HTML的输入字符串。
     * @returns {string} - 转换后的纯文本。
     */
    function convertChatHtmlToPlainText(htmlString) {
        const container = document.createElement('div');
        container.innerHTML = htmlString;

        function extractVisibleText(node) {
            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') {
                    return node.alt || '';
                }
                let text = '';
                for (const child of node.childNodes) {
                    text += extractVisibleText(child);
                }
                return text;
            }
            return '';
        }
        return extractVisibleText(container);
    }

    /**
     * 通过分析元素背景颜色的频率,找出当前活跃的标签页。
     * 这是一种不依赖特定颜色值的通用异常检测方法。
     * @param {string} htmlString - 包含多个标签页链接的HTML字符串。
     * @returns {string|null} - 活跃标签页的文本内容,如果未找到则返回null。
     */
    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;
    }

    /**
     * 在页面DOM中定位聊天相关的关键元素容器。
     * @returns {{tabs: HTMLElement|null, chatLog: HTMLElement|null}} 包含已定位元素的对象。
     */
    function locateChatElements() {
        const tabsSelector = '.chat-log-tabs';
        const chatLogSelector = '.chat-log-scroll-inner';
        const tabsContainer = document.querySelector(tabsSelector);
        const chatLogContainer = document.querySelector(chatLogSelector);
        return {
            tabs: tabsContainer,
            chatLog: chatLogContainer
        };
    }

    /**
     * 智能合并两个消息数组,自动去除重叠部分,并在数据不连续时插入警告标记。
     * @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;

        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 (suffixOfOld.length === prefixOfNew.length && suffixOfOld.every((val, index) => val === prefixOfNew[index])) {
                overlapLength = i;
                break;
            }
        }

        const messagesToAdd = newMessages.slice(overlapLength);

        // Discontinuity check: if there's old data and new data, but no overlap, assume discontinuity
        const discontinuityDetected = oldMessages.length > 0 && newMessages.length > 0 && overlapLength === 0;

        if (messagesToAdd.length === 0) return oldMessages;

        let finalMessages;
        if (discontinuityDetected) {
            console.warn('检测到聊天记录不连续,可能存在数据丢失。已插入警告标记。');
            finalMessages = oldMessages.concat([{ content: '[警告 - 此处可能存在记录丢失]' }], messagesToAdd);
        } else {
            finalMessages = oldMessages.concat(messagesToAdd);
        }
        return finalMessages;
    }


    /**
     * 从页面中提取当前活跃的聊天标签和对应的聊天记录文本。
     * @returns {{current_tab: string|null, messages: Array<Object>}} 包含当前标签和消息数组的对象。
     */
    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 messages = [];
        for (const element of elements.chatLog.children) {
            const plainText = convertChatHtmlToPlainText(element.innerHTML);
            if (plainText) { // Only add if there's actual text content
                messages.push({ content: plainText });
            }
        }
        return { current_tab, messages };
    }

    /**
     * 负责从本地存储加载、保存和更新聊天记录。
     */
    const STORAGE_KEY = 'chatLogArchive'; // 使用一个通用的键名

    function loadMessagesFromStorage() {
        const storedData = localStorage.getItem(STORAGE_KEY);
        if (!storedData) return {};
        try {
            // Attempt to parse existing data. If it's old format (string), convert it.
            const parsedData = JSON.parse(storedData);
            const convertedData = {};
            for (const channel in parsedData) {
                if (typeof parsedData[channel] === 'string') {
                    // Convert old string format to new array of objects format
                    convertedData[channel] = parsedData[channel].split('\n').filter(line => line.trim() !== '').map(line => ({ content: line }));
                } else {
                    convertedData[channel] = parsedData[channel];
                }
            }
            return convertedData;
        } catch (error) {
            console.error('读取存档失败:数据可能已损坏或格式不兼容。将尝试初始化新数据结构。', error);
            // If parsing fails, it might be corrupted or an old, unconvertible format. Start fresh.
            return {};
        }
    }

    function saveMessagesToStorage(messagesObject) {
        const jsonString = JSON.stringify(messagesObject);
        localStorage.setItem(STORAGE_KEY, jsonString);
    }

    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() {
        // --- 1. 添加界面样式 ---
        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.95);
                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;
            }
            #log-archive-ui-header h2 {
                margin: 0;
                font-size: 1.2em;
                color: #8af;
            }
            #log-archive-ui-controls {
                display: flex;
                flex-wrap: wrap; /* 允许换行 */
                gap: 10px;
            }
            #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; /* Prevent manual resizing */
            }
            .log-archive-ui-button {
                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-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);
            }
        `);

        // --- 2. 创建界面HTML结构 ---
        const container = document.createElement('div');
        container.id = 'log-archive-ui-container';

        container.innerHTML = `
            <div id="log-archive-ui-header">
                <h2>聊天记录存档</h2>
                <div id="log-archive-ui-controls">
                    <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);

        // --- 3. 获取界面中所有可交互元素的引用 ---
        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');

        // --- 4. 绑定事件处理器 ---
        /**
         * 刷新整个UI,包括下拉菜单和文本显示区域。
         */
        function updateDisplay() {
            const previouslySelected = channelSelector.value;
            const messagesByChannel = loadMessagesFromStorage(); // Now loads objects
            const channels = Object.keys(messagesByChannel);
            channelSelector.innerHTML = '';

            if (channels.length === 0) {
                const option = document.createElement('option');
                option.textContent = '无记录';
                channelSelector.appendChild(option);
                logDisplay.value = '--- 没有找到任何聊天记录 ---';
            } else {
                channels.forEach(channel => {
                    const option = document.createElement('option');
                    option.value = channel;
                    option.textContent = channel;
                    channelSelector.appendChild(option);
                });
                // Refresh and try to restore the previous selection, or default to the first channel
                channelSelector.value = previouslySelected && channels.includes(previouslySelected) ? previouslySelected : channels[0];

                const selectedChannelMessages = messagesByChannel[channelSelector.value] || [];
                logDisplay.value = selectedChannelMessages.map(msg => msg.content).join('\n') || `--- 在频道 [${channelSelector.value}] 中没有记录 ---`;
            }
        }

        // Click the floating button to show or hide the UI
        toggleButton.addEventListener('click', () => {
            const isVisible = uiContainer.style.display === 'flex';
            if (!isVisible) {
                // Before opening the UI, force a save to get the latest data
                console.log("UI打开指令触发,强制执行一次保存...");
                saveCurrentChannelMessage();
                // Then refresh the UI display
                updateDisplay();
            }
            uiContainer.style.display = isVisible ? 'none' : 'flex';
        });

        // Close button
        closeButton.addEventListener('click', () => {
            uiContainer.style.display = 'none';
        });

        // Change dropdown, update text display
        channelSelector.addEventListener('change', updateDisplay);

        // "Refresh Now" button
        refreshButton.addEventListener('click', () => {
            console.log("“立刻保存并刷新”指令触发...");
            saveCurrentChannelMessage();
            updateDisplay();
            console.log('记录已成功抓取并刷新!');
        });

        // "Copy Current" button
        copyButton.addEventListener('click', () => {
            logDisplay.select();
            document.execCommand('copy');
            console.log('当前频道的记录已复制到剪贴板。');
        });

        // "Copy All" button
        copyAllButton.addEventListener('click', () => {
            const messages = loadMessagesFromStorage();
            const jsonString = JSON.stringify(messages, null, 2);
            navigator.clipboard.writeText(jsonString).then(() => {
                console.log('所有频道的记录 (JSON格式) 已复制到剪贴板。');
            }).catch(err => {
                console.error('复制全部记录失败:', err);
                alert('复制全部记录失败。请检查浏览器权限或手动复制控制台中的JSON数据。');
            });
        });

        // "Clear All Records" button
        clearButton.addEventListener('click', () => {
            // Use console.warn to give a prominent warning, but not interrupt the user
            console.warn('清除操作需要用户确认。请在弹出的对话框中选择。');
            const confirmation = confirm('【警告】你确定要删除所有本地聊天记录吗?此操作不可逆!');
            if (confirmation) {
                localStorage.removeItem(STORAGE_KEY);
                updateDisplay();
                console.log('所有本地记录已被清除。');
            } else {
                console.log('用户取消了清除操作。');
            }
        });
    }

    /*
     * =================================================================
     * 脚本主程序和入口
     * =================================================================
     */
    function main() {
        console.log("PonyTown 聊天记录存档器已启动。");

        // Initialize user interface
        createUI();

        // Perform an immediate archive after page load, in case the user quickly leaves
        saveCurrentChannelMessage();

        // Set up timed task to auto-archive every 30 seconds
        setInterval(saveCurrentChannelMessage, 15000); // 30000 milliseconds = 30 seconds

        console.log("自动存档已激活,每15秒运行一次。可点击右下角悬浮图标进行手动操作。");
    }

    // Wait until the page is fully loaded before executing the main function,
    // ensuring all elements to be monitored are generated.
    window.addEventListener('load', main);

})();

QingJ © 2025

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