PonyTown 网页聊天记录存档器

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

// ==UserScript==
// @name          PonyTown 网页聊天记录存档器
// @namespace     http://tampermonkey.net/
// @version       5.4.0
// @description   自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制、下载、数据统计和清除界面。支持结构化数据提取和Emoji格式化。
// @author        doucx
// @match         https://pony.town/*
// @match         https://*.pony.town/*
// @grant         GM_addStyle
// @run-at        document-idle
// @license       MIT
// ==/UserScript==

(function() {
  'use strict';

  // --- 全局配置与状态 ---
  const STORAGE_KEY_V5 = 'chatLogArchive_v5';
  const OLD_STORAGE_KEY_V4 = 'chatLogArchive_v4';
  const SELF_NAME_KEY = 'chatLogArchiver_selfName';

  const STORAGE_WARNING_THRESHOLD_MB = 3.5; // 存储警告阈值 (MB)

  // 定义被视为主服务器的域名列表,以启用精细化解析
  const MAIN_SERVER_HOSTS = ['pony.town'];

  // 内存缓存,作为脚本运行期间所有聊天记录的单一数据源
  let inMemoryChatState = {};
  // 消息监听器的实例,用于跟踪其状态
  let messageObserver = null;
  // 标签页变化监听器
  let tabObserver = null;
  // 当前活跃的聊天频道名称
  let currentActiveChannel = null;
  // 状态锁:当为 true 时,表示正在处理历史消息,应暂停实时消息的捕获
  let isInitializingChat = false;
  // 状态锁:当为 true 时,表示正在切换标签页,应暂停实时消息的捕获
  let isSwitchingTabs = false;

  /*
   * =================================================================
   * 数据迁移模块
   * =================================================================
   */

  /**
   * 检查并执行一次性的数据迁移,将 v4 版本的数据转换为 v5 格式。
   * 主要处理时间戳格式的转换,并将所有旧数据标记为历史记录。
   */
  function migrateDataV4toV5() {
    const oldDataRaw = localStorage.getItem(OLD_STORAGE_KEY_V4);
    if (!oldDataRaw) return;

    console.log("检测到旧版本(v4)数据,正在执行一次性迁移...");
    try {
      const oldData = JSON.parse(oldDataRaw);
      const newData = {};

      for (const channel in oldData) {
        newData[channel] = oldData[channel].map(msg => {
          const newMsg = { ...msg };
          try {
            // v4 的时间格式 "YYYY-MM-DD HH:MM" 是本地时间,我们将其近似转换为 ISO 格式的 UTC 时间
            const localDate = new Date(msg.time.replace(/-/g, '/'));
            newMsg.time = localDate.toISOString();
          } catch (e) {
            newMsg.time = new Date().toISOString(); // 转换失败时使用当前时间作为备用
          }
          newMsg.is_historical = true;
          return newMsg;
        });
      }

      localStorage.setItem(STORAGE_KEY_V5, JSON.stringify(newData));
      localStorage.removeItem(OLD_STORAGE_KEY_V4);
      console.log("数据迁移成功!");
    } catch (error) {
      console.error("数据迁移失败,旧数据可能已损坏,将予以保留。", error);
    }
  }

  /**
   * 计算脚本在 localStorage 中的存储占用空间。
   * @returns {number} - 占用的空间大小,单位是 MB。
   */
  function getStorageUsageInMB() {
    const data = localStorage.getItem(STORAGE_KEY_V5);
    if (!data) return 0;
    // 使用 Blob 来精确计算字符串的字节大小
    const sizeInBytes = new Blob([data]).size;
    return sizeInBytes / (1024 * 1024);
  }

  /*
   * =================================================================
   * 核心功能模块
   * =================================================================
   */

  /**
   * 防抖函数。
   * 当一个函数在短时间内被连续调用时,此函数可以确保它只在最后一次调用之后的一段“冷静期”后执行一次。
   * @param {Function} func - 需要进行防抖处理的函数。
   * @param {number} wait - “冷静期”的毫秒数。
   * @returns {Function} - 返回一个新的、经过防抖处理的函数。
   */
  function debounce(func, wait) {
    let timeout;
    return function(...args) {
      const context = this;
      clearTimeout(timeout);
      timeout = setTimeout(() => func.apply(context, args), wait);
    };
  }

  // --- 时间与数据格式化 ---

  /** 获取当前时间的 ISO 8601 UTC 标准格式字符串。*/
  function getISOTimestamp() {
    return new Date().toISOString();
  }

  /** 在UI界面中,将ISO UTC时间字符串格式化为用户本地时区的可读格式。*/
  function formatISOTimeForDisplay(isoString) {
    if (!isoString) return 'N/A';
    try {
      const date = new Date(isoString);
      if (isNaN(date.getTime())) return '日期无效';

      const year = date.getFullYear();
      const month = (date.getMonth() + 1).toString().padStart(2, '0');
      const day = date.getDate().toString().padStart(2, '0');
      const hours = date.getHours().toString().padStart(2, '0');
      const minutes = date.getMinutes().toString().padStart(2, '0');
      const seconds = date.getSeconds().toString().padStart(2, '0');

      return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
    } catch (e) {
      return '日期无效';
    }
  }

  // --- DOM 解析 ---

  /** 判断一个字符的 Unicode 码点是否位于私有使用区。*/
  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 节点中提取可见文本,并正确处理 Emoji 图片。*/
  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 hostname = window.location.hostname;
    const isMainServerMode = MAIN_SERVER_HOSTS.some(h => hostname === h || hostname.endsWith('.' + h));

    if (isMainServerMode) {
      // --- 主服务器精细解析模式 ---
      const data = { time: precomputedTime, type: 'unknown', sender: 'System', receiver: 'Local', content: '' };
      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')) {
        // 基于完整的消息内容判断私聊方向
        if (data.content.startsWith('To ') || data.content.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;

    } else {
      // --- 回落模式 (兼容私服) ---
      const rawContent = customTextContent(chatLineElement);
      if (!rawContent.trim()) return null;

      return {
        time: precomputedTime,
        is_fallback: true,
        type: '', sender: '', receiver: '',
        content: rawContent.trim()
      };
    }
  }

  /** 定位页面上的关键聊天元素。*/
  function locateChatElements() {
    return {
      tabs: document.querySelector('.chat-log-tabs'),
      chatLog: document.querySelector('.chat-log-scroll-inner'),
      chatLine: document.querySelector('.chat-line'),
      chatLogContainer: document.querySelector('.chat-log')
    };
  }

  /** 从 tabs 元素的 HTML 中解析出当前活跃的标签页名称。*/
  function findActiveTabByClass(htmlString) {
    if (!htmlString) return null;
    const container = document.createElement('div');
    container.innerHTML = htmlString;
    const activeTab = container.querySelector('a.chat-log-tab.active');
    return activeTab ? activeTab.textContent.trim() : null;
  }

  // --- 状态管理与持久化 ---

  /** 智能合并消息数组,用于处理聊天记录不连续的情况,例如在UI重现后。*/
  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 = i;
        break;
      }
    }
    let messagesToAdd;
    if (overlapLength > 0) {
      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: getISOTimestamp(), type: 'system', sender: 'Archiver', receiver: 'System',
        content: '[警告 - 此处可能存在记录丢失]', is_archiver: true
      };
      return oldMessages.concat([discontinuityMark], messagesToAdd);
    }
    return oldMessages.concat(messagesToAdd);
  }

  /** 扫描聊天框中已存在的消息,时间戳根据UI显示的 `HH:MM` 进行估算。*/
  function extractHistoricalChatState() {
    const elements = locateChatElements();
    if (!elements.tabs || !elements.chatLog) 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 localDateString = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(currentDate.getDate()).padStart(2, '0')} ${timeText}`;
      const isoTimeApproximation = new Date(localDateString.replace(/-/g, '/')).toISOString();

      const messageData = extractUsefulData(element, selfName, isoTimeApproximation);
      if (messageData && messageData.content) {
        messageData.is_historical = true; // 标记为历史消息
        messages.push(messageData);
      }
    }
    messages.reverse();
    return { current_tab, messages };
  }

  /**
   * 扫描当前聊天框中的可见消息,并将其与内存状态智能合并。
   * 这是一个可被多处调用的核心同步功能。
   */
  function scanAndMergeHistory() {
    console.log("正在扫描并合并历史消息...");
    const historicalState = extractHistoricalChatState();
    let dataChanged = false;

    if (historicalState.current_tab && historicalState.messages.length > 0) {
      const channelName = historicalState.current_tab;
      const messagesScannedCount = historicalState.messages.length; // Y: 检查了的历史记录总数

      const oldMessages = inMemoryChatState[channelName] || [];
      const oldMessageCount = oldMessages.length;

      const newMergedMessages = mergeAndDeduplicateMessages(oldMessages, historicalState.messages);
      const newMessageCount = newMergedMessages.length;

      const messagesAddedCount = newMessageCount - oldMessageCount; // X: 有效合并的新记录数

      if (messagesAddedCount > 0) {
        inMemoryChatState[channelName] = newMergedMessages;
        dataChanged = true;
        const newlyAddedHistoricalMessages = newMergedMessages.slice(-messagesAddedCount);
        newlyAddedHistoricalMessages.forEach(msg => {
          // 注意:这里的 channelName 就是当时扫描时的活跃频道
          addMessageToSyntheticChannelIfNeeded(msg, channelName);
        });

        // 使用新的日志格式
        console.log(`历史扫描 [${channelName}]: 合并了 ${messagesAddedCount}/${messagesScannedCount} 条新记录。`);
      } else {
        console.log(`历史扫描 [${channelName}]: 检查了 ${messagesScannedCount} 条记录,无新增内容。`);
      }
    }

    // 如果数据有变动,且UI是打开的,则刷新UI
    if (dataChanged) {
      const uiContainer = document.getElementById('log-archive-ui-container');
      const isUIPaused = uiContainer && uiContainer.querySelector('#log-archive-pause-button').textContent.includes('▶️');
      if (uiContainer && uiContainer.style.display === 'flex' && !isUIPaused) {
        const { updateUI: uiUpdateFn } = document.getElementById('log-archive-ui-container')._uiFunctions || {};
        if (uiUpdateFn) {
          uiUpdateFn(inMemoryChatState);
        }
      }
    }
  }

  /** 从 localStorage 加载存档。*/
  function loadMessagesFromStorage() {
    try {
      return JSON.parse(localStorage.getItem(STORAGE_KEY_V5)) || {};
    } catch (e) {
      console.error('读取存档失败,数据已损坏。', e); return {};
    }
  }

  /** 将内存中的存档保存到 localStorage。*/
  function saveMessagesToStorage(messagesObject) {
    console.info('存档已保存到 localStorage')
    localStorage.setItem(STORAGE_KEY_V5, JSON.stringify(messagesObject));
  }

  /**
   * (新功能) 根据条件将消息添加到合成频道。
   * 如果当前活跃频道是 'Local',并且消息是 party 或 whisper 类型,
   * 则将其复制一份到 'Party-Local' 或 'Whisper-Local' 频道。
   * @param {object} message - 消息数据对象。
   * @param {string} activeChannel - 消息产生时所在的活跃频道。
   */
  function addMessageToSyntheticChannelIfNeeded(message, activeChannel) {
    // 核心条件:当且仅当在 'Local' 频道时才触发
    if (activeChannel !== 'Local') {
      return;
    }

    let syntheticChannelName = null;
    if (message.type.includes('party')) {
      syntheticChannelName = 'Party-Local';
    } else if (message.type.includes('whisper')) {
      syntheticChannelName = 'Whisper-Local';
    }

    // 如果是 party 或 whisper 消息,则执行添加操作
    if (syntheticChannelName) {
      if (!inMemoryChatState[syntheticChannelName]) {
        inMemoryChatState[syntheticChannelName] = [];
      }
      // 创建消息的副本以避免任何潜在的引用问题
      inMemoryChatState[syntheticChannelName].push({ ...message });
      console.log(`消息已自动复制到合成频道 [${syntheticChannelName}]`);
    }
  }

  /*
   * =================================================================
   * 用户交互界面 (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(0, 0, 0, 0.65); 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: rgba(0, 0, 0, 0.2); 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-pause-button { background-color: #3a8c54; }
            #log-archive-pause-button.paused { background-color: #c89632; border-color: #e0aa40; }
            #log-archive-pause-button.paused:hover { background-color: #e0aa40; }
            #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: #3a8c54; }
            #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); }
            #log-archive-storage-warning { color: #ffcc00; font-weight: bold; font-size: 0.9em; margin-left: 20px; flex-shrink: 0; }
        `);

    let isUIPaused = false;

    const container = document.createElement('div');
    container.id = 'log-archive-ui-container';
    container.innerHTML = `
            <div id="log-archive-ui-header">
                <h2>聊天记录存档 v5.4.0</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-pause-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');
    const pauseButton = document.getElementById('log-archive-pause-button');

    let isStatsViewActive = false;

    selfNameInput.value = localStorage.getItem(SELF_NAME_KEY) || '';
    selfNameInput.addEventListener('change', () => {
      localStorage.setItem(SELF_NAME_KEY, selfNameInput.value.trim());
    });

    /**
     * 一个辅助函数,用于更新 textarea 的内容同时保留用户的选择或光标位置。
     * @param {function} updateFn - 一个无参数的函数,其作用是修改 logDisplay.value 的值。
     */
    function updateTextareaAndPreserveSelection(updateFn) {
      // 只有当用户正在与文本框交互时,保留选区才有意义。
      const isFocused = document.activeElement === logDisplay;
      let selectionStart, selectionEnd;

      if (isFocused) {
        selectionStart = logDisplay.selectionStart;
        selectionEnd = logDisplay.selectionEnd;
      }

      // 执行实际的 UI 更新
      updateFn();

      if (isFocused) {
        // 恢复之前的选区或光标位置
        logDisplay.setSelectionRange(selectionStart, selectionEnd);
      }
    }

    // --- 数据统计与格式化 ---

    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, total: totalMessagesInPeriod };
    }

    function calculateHourlyActivity(messages) {
      const hourlyCounts = new Array(24).fill(0);
      let totalMessagesInPeriod = 0;
      messages.forEach(msg => {
        try {
          // 关键修改:从 getUTCHours() 改为 getHours(),以使用用户本地时区进行统计。
          const hour = new Date(msg.time).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, total: totalMessagesInPeriod };
    }

    function formatTopTalkers(results) {
      const { data, total } = results;
      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, total } = results;
      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}] 中没有记录可供统计 ---`;
      }
      const filteredMessages = messages.filter(msg => !msg.is_fallback && !msg.is_archiver);
      if (filteredMessages.length === 0) {
        return `--- 在频道 [${channelName}] 中没有可供精细统计的用户消息 (可能均为私服记录) ---`;
      }
      let output = `--- [${channelName}] 频道统计报告 (分析 ${filteredMessages.length} 条消息) ---\n`;
      output += formatTopTalkers(calculateTopTalkers(filteredMessages));
      output += formatHourlyActivity(calculateHourlyActivity(filteredMessages));
      return output;
    }

    // --- UI 渲染与更新 ---

    /** 格式化单条消息以在 UI 中显示。*/
    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 = '📣 ';
      const displayTime = formatISOTimeForDisplay(msg.time);
      return `${displayTime} ${prefix}${msg.content}`;
    }

    function displayChatLog(messages, channelName) {
      updateTextareaAndPreserveSelection(() => {
        if (messages && messages.length > 0) {
          logDisplay.value = messages.map(formatMessageForDisplay).join('\n');
        } else {
          logDisplay.value = `--- 在频道 [${channelName}] 中没有记录 ---`;
        }
      });
    }

    function displayStatistics(messages, channelName) {
      updateTextareaAndPreserveSelection(() => {
        logDisplay.value = generateStatisticsText(messages, channelName);
      });
    }

    function renderCurrentView(messagesByChannel) {
      // const allMessages = loadMessagesFromStorage();
      const selectedChannel = channelSelector.value;
      const messages = messagesByChannel[selectedChannel] || [];
      if (isStatsViewActive) {
        displayStatistics(messages, selectedChannel);
      } else {
        displayChatLog(messages, selectedChannel);
      }
    }

    function updateUI(messagesByChannel) {
      // console.log("UI updated")
      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(messagesByChannel);
    }

    // --- 事件绑定 ---
    toggleButton.addEventListener('click', () => {
      const isVisible = uiContainer.style.display === 'flex';
      if (!isVisible) {
        updateUI(inMemoryChatState);
      }
      uiContainer.style.display = isVisible ? 'none' : 'flex';
    });

    closeButton.addEventListener('click', () => { uiContainer.style.display = 'none'; });
    channelSelector.addEventListener('change', () => renderCurrentView(inMemoryChatState));

    refreshButton.addEventListener('click', () => {
      if (isInitializingChat) {
        console.log("正在初始化,请稍后刷新...");
        return;
      }
      console.log("执行强制刷新...");
      scanAndMergeHistory();
      saveMessagesToStorage(inMemoryChatState);
      updateUI(inMemoryChatState);
      console.log("强制刷新完成。");
    });

// --- 【新增】当用户在显示区域按下鼠标时,自动暂停UI刷新 ---
    logDisplay.addEventListener('mousedown', () => {
        // 如果UI当前没有被暂停,则自动触发暂停
        if (!isUIPaused) {
            isUIPaused = true;
            pauseButton.textContent = '▶️ ';
            // 确保样式也同步更新
            pauseButton.classList.add('paused');
            console.log("UI 自动刷新因用户交互而暂停。");
        }
    });

    pauseButton.addEventListener('click', () => {
      isUIPaused = !isUIPaused; // 切换暂停状态
      pauseButton.classList.toggle('paused', isUIPaused);
      if (isUIPaused) {
        pauseButton.textContent = '▶️ ';
        console.log("UI 自动刷新已暂停。");
      } else {
        pauseButton.textContent = '⏸️ ';
        console.log("UI 自动刷新已恢复,正在更新至最新状态...");
        updateUI(inMemoryChatState); // 恢复时,立即执行一次刷新
      }
    });

    statsButton.addEventListener('click', () => {
      isStatsViewActive = !isStatsViewActive;
      statsButton.classList.toggle('active', isStatsViewActive);
      statsButton.textContent = isStatsViewActive ? '查看记录' : '查看统计';
      renderCurrentView(inMemoryChatState);
    });

    copyButton.addEventListener('click', () => {
      if (logDisplay.value) {
        navigator.clipboard.writeText(logDisplay.value).then(() => {
          console.log('当前显示内容已复制到剪贴板。');
          const originalText = copyButton.textContent;
          copyButton.textContent = '已复制!';
          setTimeout(() => copyButton.textContent = originalText, 1500);
        }).catch(err => {
          console.error('复制失败:', err);
          alert('复制失败,请手动复制。');
        });
      }
    });

    copyAllButton.addEventListener('click', () => {
      scanAndMergeHistory();
      saveMessagesToStorage(inMemoryChatState);
      updateUI(inMemoryChatState);
      const messages = JSON.stringify(inMemoryChatState, null, 2);
      navigator.clipboard.writeText(messages).then(() => {
        console.log('所有频道的记录 (JSON格式) 已复制到剪贴板。');
        const originalText = copyAllButton.textContent;
        copyAllButton.textContent = '已复制!';
        setTimeout(() => copyAllButton.textContent = originalText, 1500);
      }).catch(err => {
        console.error('复制失败:', err);
        alert('复制失败,请手动复制。');
      });
    });

    clearButton.addEventListener('click', () => {
      // 更新确认对话框的文本,使其更准确地描述操作效果。
      // 这不是一个“清空”,而是一个“重置”。
      if (confirm('【警告】此操作将清空所有本地存档,并以当前屏幕上可见的聊天记录作为新的起点。确定要重置吗?')) {
        console.log('正在执行存档重置...');

        // 步骤 1: 暂停消息监听,防止在重置过程中产生数据竞争。
        deactivateLogger();

        // 步骤 2: 清空后端存储和当前内存状态。
        localStorage.removeItem(STORAGE_KEY_V5);
        inMemoryChatState = {};

        // 步骤 3: 立即重新扫描屏幕上的“幽灵消息”,将其作为新的存档基础。
        // 这一步确保了我们的内存状态与用户所见的屏幕内容同步。
        scanAndMergeHistory();

        // 步骤 4: 立即将这个新的状态保存,完成重置。
        saveMessagesToStorage(inMemoryChatState);

        // 步骤 5: 重置并更新 UI 界面。
        isStatsViewActive = false;
        statsButton.classList.remove('active');
        statsButton.textContent = '查看统计';
        updateUI(inMemoryChatState);

        console.log('存档已重置为当前屏幕所见内容。');
        // 注意:消息监听器将由主循环的 uiObserver 在下一次检查时自动重新激活(如果聊天窗口可见)。
        // 这种方式更稳健,能适应各种边缘情况,例如用户在确认期间关闭了聊天窗口。
      }
    });

    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`);
    });

    container._uiFunctions = { updateUI: updateUI };

    // 检查存储空间并更新UI警告的函数
    function checkStorageUsage() {
      const usageMB = getStorageUsageInMB();
      const uiHeader = document.getElementById('log-archive-ui-header');
      let warningElement = document.getElementById('log-archive-storage-warning');

      // console.log(`当前存储占用: ${usageMB.toFixed(2)} MB`); // 用于调试,可以取消注释

      if (usageMB > STORAGE_WARNING_THRESHOLD_MB) {
        if (!warningElement) {
          warningElement = document.createElement('div');
          warningElement.id = 'log-archive-storage-warning';
          // 将警告信息添加到标题和控制按钮之间
          const controls = document.getElementById('log-archive-ui-controls');
          uiHeader.insertBefore(warningElement, controls);
        }
        warningElement.textContent = `⚠️ 存储占用过高 (${usageMB.toFixed(1)}MB),请及时下载或清空!`;
      } else {
        if (warningElement) {
          warningElement.remove();
        }
      }
    }


    return { updateUI, checkStorageUsage };
  }

  /*
   * =================================================================
   * 脚本主程序与生命周期管理
   * =================================================================
   */

  /** 处理 MutationObserver 捕获到的新消息节点。*/
  function handleNewChatMessage(node) {
    // 同时检查初始化锁和标签页切换锁
    if (isInitializingChat || isSwitchingTabs) return;
    if (node.nodeType !== Node.ELEMENT_NODE || !node.matches('.chat-line')) return;

    // 直接使用已缓存的当前频道,不再查询DOM
    if (!currentActiveChannel) return;

    const selfName = localStorage.getItem(SELF_NAME_KEY) || '';
    const preciseTime = getISOTimestamp();
    const messageData = extractUsefulData(node, selfName, preciseTime);

    if (messageData && messageData.content) {
      if (!inMemoryChatState[currentActiveChannel]) {
        inMemoryChatState[currentActiveChannel] = [];
      }
      inMemoryChatState[currentActiveChannel].push(messageData);
      addMessageToSyntheticChannelIfNeeded(messageData, currentActiveChannel);
    }

    const uiContainer = document.getElementById('log-archive-ui-container');
    const isUIPaused = uiContainer && uiContainer.querySelector('#log-archive-pause-button').textContent.includes('▶️');
    if (uiContainer && uiContainer.style.display === 'flex' && !isUIPaused) {
      const { updateUI: uiUpdateFn } = document.getElementById('log-archive-ui-container')._uiFunctions || {};
      if (uiUpdateFn) {
        uiUpdateFn(inMemoryChatState);
      }
    }
  }

  /**
   * 激活聊天记录器。在聊天UI出现时调用。
   * 包含防抖逻辑以正确处理历史消息的批量加载。
   */
  function activateLogger() {
    const { chatLog, tabs: tabsContainer } = locateChatElements();
    if (!chatLog || !tabsContainer || messageObserver) return;

    console.log("正在激活聊天记录器...");
    isInitializingChat = true;

    // --- 设置标签页切换监听器 ---
    const handleTabChange = () => {
      const newActiveTab = findActiveTabByClass(tabsContainer.innerHTML);
      if (newActiveTab && newActiveTab !== currentActiveChannel) {
        console.log(`标签页已切换: 从 [${currentActiveChannel}] -> [${newActiveTab}]`);
        currentActiveChannel = newActiveTab;

        // 1. 设置切换锁,立即屏蔽新消息记录
        isSwitchingTabs = true;

        // 2. 等待 DOM 渲染完成
        setTimeout(() => {
          console.log("标签页 DOM 已更新,开始扫描并合并历史记录...");

          // 3. 【核心改动】执行历史记录的扫描与合并
          scanAndMergeHistory();

          // 4. (可选优化) 如果 UI 窗口是打开的,自动切换到新频道并刷新
          // const uiContainer = document.getElementById('log-archive-ui-container');
          // if (uiContainer && uiContainer.style.display === 'flex') {
          //     const channelSelector = document.getElementById('log-archive-channel-selector');
          //     channelSelector.value = newActiveTab; // 自动选中新频道
          //     const { updateUI: uiUpdateFn } = uiContainer._uiFunctions || {};
          //     if (uiUpdateFn) {
          //         uiUpdateFn(inMemoryChatState); // 刷新整个UI
          //     }
          // }

          // 5. 解除锁定,允许记录实时消息
          isSwitchingTabs = false;
          console.log("标签页切换流程完成,已解除记录锁定。");
        }, 250); // 稍微增加延迟以确保 DOM 完全稳定
      }
    };

    // 立即确定初始的活动标签页
    currentActiveChannel = findActiveTabByClass(tabsContainer.innerHTML);
    console.log(`初始活动标签页为 [${currentActiveChannel}]`);

    tabObserver = new MutationObserver(handleTabChange);
    // 监视整个标签容器的子节点和属性变化,以捕获 a.active 的 class 变更
    tabObserver.observe(tabsContainer, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['class']
    });

    // --- 2. 设置消息监听器 (旧逻辑稍作调整) ---
    const finalizeInitialization = debounce(() => {
      console.log("历史消息加载稳定,开始扫描并合并...");
      scanAndMergeHistory();
      isInitializingChat = false;
      console.log("实时消息监听器已完全激活。");
    }, 500);

    messageObserver = new MutationObserver((mutationsList) => {
      let hasNewNodes = false;
      for (const mutation of mutationsList) {
        if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
          hasNewNodes = true;
          if (!isInitializingChat) {
            mutation.addedNodes.forEach(handleNewChatMessage);
          }
        }
      }
      if (isInitializingChat && hasNewNodes) {
        finalizeInitialization();
      }
    });

    messageObserver.observe(chatLog, { childList: true });
    finalizeInitialization();
  }

  /** 停用并清理聊天记录器,在聊天UI消失时调用。*/
  function deactivateLogger() {
    if (messageObserver) {
      messageObserver.disconnect();
      messageObserver = null;
    }
    // 新增:同时停用标签页监听器
    if (tabObserver) {
      tabObserver.disconnect();
      tabObserver = null;
    }
    isInitializingChat = false;
    isSwitchingTabs = false; // 确保锁被重置
    currentActiveChannel = null; // 重置当前频道
    console.log("所有监听器已停用。");
  }

  /** 脚本主入口函数。*/
  function main() {
    migrateDataV4toV5();
    inMemoryChatState = loadMessagesFromStorage();
    const { updateUI, checkStorageUsage } = createUI();

    console.log("PonyTown 聊天记录存档器 v5.4.0 正在等待游戏界面加载...");

    checkStorageUsage();
    // 监视整个页面,以检测聊天UI的出现和消失
    const uiObserver = new MutationObserver(() => {
      // 我们需要检查 .chat-log 容器,因为 style 属性在它上面
      const { chatLogContainer } = locateChatElements();

      if (chatLogContainer) {
        // 核心逻辑:检查容器的 display 样式是否为 'none'
        const isVisible = chatLogContainer.style.display !== 'none';

        if (isVisible && !messageObserver) {
          activateLogger();
        } else if (!isVisible && messageObserver) {
          deactivateLogger();
        }
      } else if (messageObserver) {
        // 作为保险措施,如果容器真的从DOM中移除了,也停用记录器
        deactivateLogger();
      }
    });

    uiObserver.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true
    });

    // 周期性地将内存状态持久化到 localStorage
    setInterval(() => {
      saveMessagesToStorage(inMemoryChatState);
      checkStorageUsage();
    }, 15000);

    // 确保在页面关闭前执行最后一次保存
    window.addEventListener('pagehide', () => {
      console.log('页面即将关闭,正在执行最终存档...');
      saveMessagesToStorage(inMemoryChatState);
    });
  }

  if (document.readyState === 'complete') {
    main();
  } else {
    window.addEventListener('load', main);
  }
})();

QingJ © 2025

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