PonyTown 网页聊天记录存档器

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

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

// ==UserScript==
// @name         PonyTown 网页聊天记录存档器
// @namespace    http://tampermonkey.net/
// @version      3.3
// @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 {string} oldText - 已存在的旧文本。
     * @param {string} newText - 需要追加的新文本。
     * @returns {string} - 合并后的完整文本。
     */
    function mergeAndDeduplicateText(oldText, newText) {
        if (!oldText) return newText;
        if (!newText) return oldText;
        const oldLines = oldText.split('\n');
        const newLines = newText.split('\n');
        let overlapLength = 0;
        const maxPossibleOverlap = Math.min(oldLines.length, newLines.length);
        for (let i = maxPossibleOverlap; i > 0; i--) {
            const suffixOfOld = oldLines.slice(-i);
            const prefixOfNew = newLines.slice(0, i);
            if (suffixOfOld.toString() === prefixOfNew.toString()) {
                overlapLength = i;
                break;
            }
        }
        // 当旧文本和新文本都存在,但没有任何重叠时,判定为数据不连续。
        const discontinuityDetected = oldLines.length > 0 && newLines.length > 0 && overlapLength === 0;
        const linesToAdd = newLines.slice(overlapLength);
        if (linesToAdd.length === 0) return oldText;
        let finalLines;
        if (discontinuityDetected) {
            console.warn('检测到聊天记录不连续,可能存在数据丢失。已插入警告标记。');
            finalLines = oldLines.concat(['[警告 - 此处可能存在记录丢失]'], linesToAdd);
        } else {
            finalLines = oldLines.concat(linesToAdd);
        }
        return finalLines.join('\n');
    }

    /**
     * 从页面中提取当前活跃的聊天标签和对应的聊天记录文本。
     * @returns {{current_tab: string|null, plaintext: string}} 包含当前标签和文本的对象。
     */
    function extractCurrentChatState() {
        const elements = locateChatElements();
        if (!elements.tabs || !elements.chatLog) {
            console.error("提取失败:未能找到关键的聊天界面元素。");
            return { current_tab: null, plaintext: '' };
        }
        const current_tab = findActiveTabByStyleAnomaly(elements.tabs.innerHTML);
        const lines = [];
        for (const element of elements.chatLog.children) {
            lines.push(convertChatHtmlToPlainText(element.innerHTML));
        }
        const plaintext = lines.join('\n');
        return { current_tab, plaintext };
    }

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

    function loadMessagesFromStorage() {
        const storedData = localStorage.getItem(STORAGE_KEY);
        if (!storedData) return {};
        try {
            return JSON.parse(storedData);
        } catch (error) {
            console.error('读取存档失败:数据可能已损坏。', error);
            return {};
        }
    }

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

    function updateChannelMessage(messagesObject, channelName, newMessagesText) {
        if (!channelName || !newMessagesText) return;
        const oldText = messagesObject[channelName] || '';
        messagesObject[channelName] = mergeAndDeduplicateText(oldText, newMessagesText);
    }

    /**
     * 主保存程序,执行一次完整的“提取-更新-保存”流程。
     */
    function saveCurrentChannelMessage() {
        console.log("正在执行存档任务...");
        const chatState = extractCurrentChatState();
        if (chatState.current_tab && chatState.plaintext) {
            let allMyMessages = loadMessagesFromStorage();
            updateChannelMessage(allMyMessages, chatState.current_tab, chatState.plaintext);
            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;
            }
            .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 messages = loadMessagesFromStorage();
            const channels = Object.keys(messages);
            channelSelector.innerHTML = '';
            if (channels.length === 0) {
                const option = document.createElement('option');
                option.textContent = '无记录';
                channelSelector.appendChild(option);
            } else {
                channels.forEach(channel => {
                    const option = document.createElement('option');
                    option.value = channel;
                    option.textContent = channel;
                    channelSelector.appendChild(option);
                });
                // 刷新后尝试恢复之前的选择
                channelSelector.value = previouslySelected;
            }

            const newlySelectedChannel = channelSelector.value;
            logDisplay.value = messages[newlySelectedChannel] || `--- 在频道 [${newlySelectedChannel}] 中没有记录 ---`;
        }

        // 点击悬浮按钮,显示或隐藏UI
        toggleButton.addEventListener('click', () => {
            const isVisible = uiContainer.style.display === 'flex';
            if (!isVisible) {
                // 在打开UI前,先强制执行一次保存以获取最新数据
                console.log("UI打开指令触发,强制执行一次保存...");
                saveCurrentChannelMessage();
                // 然后刷新UI显示
                updateDisplay();
            }
            uiContainer.style.display = isVisible ? 'none' : 'flex';
        });

        // 关闭按钮
        closeButton.addEventListener('click', () => {
            uiContainer.style.display = 'none';
        });

        // 切换下拉菜单,更新文本显示
        channelSelector.addEventListener('change', updateDisplay);

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

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

        // “复制全部”按钮
        copyAllButton.addEventListener('click', () => {
            const messages = loadMessagesFromStorage();
            const jsonString = JSON.stringify(messages, null, 2);
            navigator.clipboard.writeText(jsonString).then(() => {
                console.log('所有频道的记录 (JSON格式) 已复制到剪贴板。');
            });
        });

        // “清除全部记录”按钮
        clearButton.addEventListener('click', () => {
            // 使用 console.warn 给出醒目提示,但不打断用户
            console.warn('清除操作需要用户确认。请在弹出的对话框中选择。');
            const confirmation = confirm('【警告】你确定要删除所有本地聊天记录吗?此操作不可逆!');
            if (confirmation) {
                localStorage.removeItem(STORAGE_KEY);
                updateDisplay();
                console.log('所有本地记录已被清除。');
            } else {
                console.log('用户取消了清除操作。');
            }
        });
    }

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

        // 初始化用户界面
        createUI();

        // 页面加载后立即执行一次存档,以防用户快速离开
        saveCurrentChannelMessage();

        // 设置定时任务,每30秒自动存档一次
        setInterval(saveCurrentChannelMessage, 30000); // 30000 毫秒 = 30 秒

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

    // 等待页面完全加载完毕后再执行主函数,确保所有需要监控的元素都已生成
    window.addEventListener('load', main);

})();

QingJ © 2025

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