// ==UserScript==
// @name PonyTown 网页聊天记录存档器
// @namespace http://tampermonkey.net/
// @version 4.1
// @description 自动将 pony.town 的聊天记录保存到浏览器本地存储,并提供查看、复制和清除界面。支持结构化数据提取和Emoji格式化。
// @author doucx
// @match https://pony.town/*
// @grant GM_addStyle
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function() {
'use strict';
/*
* =================================================================
* 核心功能模块 (版本 4.1)
* 负责解析、处理和存储结构化数据。
* =================================================================
*/
const STORAGE_KEY = 'chatLogArchive_v4'; // 版本4使用新的键名,自动废弃旧数据
const SELF_NAME_KEY = 'chatLogArchiver_selfName';
/**
* 判断一个字符的 Unicode 码点是否位于私有使用区 (Private Use Area)。
* 私有使用区的字符通常无法在不同平台或字体间通用显示。
* @param {string} char - 要检查的单个字符。
* @returns {boolean} - 如果字符在私有使用区,则返回 true;否则返回 false。
*/
function isCharacterInPrivateUseArea(char) {
if (!char) return false;
// 获取字符的第一个码点。对于复杂的 emoji,一个字符可能由多个码点组成,
// 但通常用于自定义 emoji 的是单个码点。
const codePoint = char.codePointAt(0);
if (codePoint === undefined) return false;
// 检查码点是否落在以下任何一个 Unicode 私有使用区范围内:
// 1. 基本多文种平面私有使用区 (U+E000 到 U+F8FF)
const isInPUA = (codePoint >= 0xE000 && codePoint <= 0xF8FF);
// 2. 补充私有使用区-A (U+F0000 到 U+FFFFD)
const isInSupPUA_A = (codePoint >= 0xF0000 && codePoint <= 0xFFFFD);
// 3. 补充私有使用区-B (U+100000 到 U+10FFFD)
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 '';
}
// 特殊处理表示 Emoji 的 IMG 标签
if (node.tagName === 'IMG' && node.classList.contains('pixelart')) {
const alt = node.alt || '';
const label = node.getAttribute('aria-label');
// 优先使用 alt 内容,但要检查其是否为可显示的 emoji
// 如果 alt 不存在,或其字符在私有使用区,则认为它“无法正常显示”
if (alt && !isCharacterInPrivateUseArea(alt)) {
return alt; // alt 内容有效且可显示,直接使用
}
// 如果 alt 内容无效或无法显示,回退到使用 aria-label
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')) 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;
const fullTextContent = chatLineElement.textContent.trim(); // 使用textContent用于方向判断更可靠
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')) {
// "To [Name]" 或 "Thinks to [Name]" 表示是自己发送的
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 { // 'say' or 'think'
data.receiver = 'Local';
if (nameText) data.sender = nameText;
}
return data;
}
/**
* 通过分析元素背景颜色的频率,找出当前活跃的标签页。
*/
function findActiveTabByStyleAnomaly(htmlString) {
const container = document.createElement('div');
container.innerHTML = htmlString;
const tabs = container.querySelectorAll('a.chat-log-tab');
if (tabs.length < 2) return tabs.length === 1 ? tabs[0].textContent.trim() : null;
const styleFrequencies = new Map();
for (const tab of tabs) {
const bgColor = tab.style.backgroundColor;
styleFrequencies.set(bgColor, (styleFrequencies.get(bgColor) || 0) + 1);
}
let uniqueStyle = null;
for (const [style, count] of styleFrequencies.entries()) { if (count === 1) { uniqueStyle = style; break; } }
if (!uniqueStyle) return null;
for (const tab of tabs) { if (tab.style.backgroundColor === uniqueStyle) return tab.textContent.trim(); }
return null;
}
function locateChatElements() {
return {
tabs: document.querySelector('.chat-log-tabs'),
chatLog: document.querySelector('.chat-log-scroll-inner')
};
}
/**
* 智能合并两个消息数组,基于消息内容去重,并在不连续时插入标记。
*/
function mergeAndDeduplicateMessages(oldMessages, newMessages) {
if (!oldMessages || oldMessages.length === 0) return newMessages;
if (!newMessages || newMessages.length === 0) return oldMessages;
let overlapLength = 0;
const maxPossibleOverlap = Math.min(oldMessages.length, newMessages.length);
for (let i = maxPossibleOverlap; i > 0; i--) {
const suffixOfOld = oldMessages.slice(-i).map(msg => msg.content);
const prefixOfNew = newMessages.slice(0, i).map(msg => msg.content);
if (JSON.stringify(suffixOfOld) === JSON.stringify(prefixOfNew)) {
overlapLength = i;
break;
}
}
const messagesToAdd = newMessages.slice(overlapLength);
const discontinuityDetected = oldMessages.length > 0 && newMessages.length > 0 && overlapLength === 0;
if (messagesToAdd.length === 0) return oldMessages;
if (discontinuityDetected) {
console.warn('检测到聊天记录不连续,可能存在数据丢失。已插入警告标记。');
const discontinuityMark = {
time: new Date().toISOString().replace('T', ' ').slice(0, 16),
type: 'system',
sender: 'Archiver',
receiver: 'System',
content: '[警告 - 此处可能存在记录丢失]'
};
return oldMessages.concat([discontinuityMark], messagesToAdd);
}
return oldMessages.concat(messagesToAdd);
}
/**
* 从页面中提取当前聊天状态和结构化消息。
* 此函数现在包含优雅的日期处理逻辑,可正确处理跨午夜的聊天记录。
*/
function extractCurrentChatState() {
const elements = locateChatElements();
if (!elements.tabs || !elements.chatLog) {
console.error("提取失败:未能找到关键的聊天界面元素。");
return { current_tab: null, messages: [] };
}
const current_tab = findActiveTabByStyleAnomaly(elements.tabs.innerHTML);
const selfName = localStorage.getItem(SELF_NAME_KEY) || '';
const messages = [];
const chatLines = Array.from(elements.chatLog.children);
let currentDate = new Date(); // 从今天开始
let lastTimeParts = null; // 用于存储上一条消息的时间 { hours, minutes }
// 从后向前遍历消息,以正确推断日期
for (let i = chatLines.length - 1; i >= 0; i--) {
const element = chatLines[i];
const timeNode = element.querySelector('.chat-line-timestamp');
// 跳过没有时间戳的行(如 "More messages below")
if (!timeNode || !timeNode.textContent.includes(':')) {
continue;
}
const timeText = timeNode.textContent.trim(); // "HH:MM"
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) {
// messageData.time = precomputedTime; // 时间已在extractUsefulData中设置
messages.push(messageData);
}
}
// 因为是倒序添加的,所以需要反转数组以恢复正确的时间顺序
messages.reverse();
return { current_tab, messages };
}
function loadMessagesFromStorage() {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY)) || {};
} catch (e) {
console.error('读取存档失败,数据已损坏。', e);
return {};
}
}
function saveMessagesToStorage(messagesObject) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(messagesObject));
}
function updateChannelMessage(messagesObject, channelName, newMessagesArray) {
if (!channelName || !newMessagesArray || newMessagesArray.length === 0) return;
const oldMessages = messagesObject[channelName] || [];
messagesObject[channelName] = mergeAndDeduplicateMessages(oldMessages, newMessagesArray);
}
function saveCurrentChannelMessage() {
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() {
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-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.1</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-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);
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');
selfNameInput.value = localStorage.getItem(SELF_NAME_KEY) || '';
selfNameInput.addEventListener('change', () => {
localStorage.setItem(SELF_NAME_KEY, selfNameInput.value.trim());
console.log(`昵称已保存为: "${selfNameInput.value.trim()}"`);
});
function updateDisplay() {
const previouslySelected = channelSelector.value;
const messagesByChannel = loadMessagesFromStorage();
const channels = Object.keys(messagesByChannel);
channelSelector.innerHTML = '';
if (channels.length === 0) {
channelSelector.innerHTML = '<option>无记录</option>';
logDisplay.value = '--- 没有找到任何聊天记录 ---';
} 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];
const selectedChannelMessages = messagesByChannel[channelSelector.value] || [];
logDisplay.value = selectedChannelMessages
.map(msg => `${msg.time} ${msg.content}`)
.join('\n') || `--- 在频道 [${channelSelector.value}] 中没有记录 ---`;
}
}
toggleButton.addEventListener('click', () => {
const isVisible = uiContainer.style.display === 'flex';
if (!isVisible) {
saveCurrentChannelMessage();
updateDisplay();
}
uiContainer.style.display = isVisible ? 'none' : 'flex';
});
closeButton.addEventListener('click', () => { uiContainer.style.display = 'none'; });
channelSelector.addEventListener('change', updateDisplay);
refreshButton.addEventListener('click', () => {
saveCurrentChannelMessage();
updateDisplay();
console.log('记录已成功抓取并刷新!');
});
copyButton.addEventListener('click', () => {
if (logDisplay.value) {
navigator.clipboard.writeText(logDisplay.value).then(() => {
console.log('当前频道的记录已复制到剪贴板。');
});
}
});
copyAllButton.addEventListener('click', () => {
const messages = loadMessagesFromStorage();
const jsonString = JSON.stringify(messages, null, 2);
navigator.clipboard.writeText(jsonString).then(
() => console.log('所有频道的记录 (JSON格式) 已复制到剪贴板。'),
err => console.error('复制全部记录失败:', err)
);
});
clearButton.addEventListener('click', () => {
if (confirm('【警告】你确定要删除所有本地聊天记录吗?此操作不可逆!')) {
localStorage.removeItem(STORAGE_KEY);
updateDisplay();
console.log('所有本地记录已被清除。');
} else {
console.log('用户取消了清除操作。');
}
});
}
/*
* =================================================================
* 脚本主程序和入口
* =================================================================
*/
function main() {
console.log("PonyTown 聊天记录存档器 v4.1 已启动。");
createUI();
setTimeout(saveCurrentChannelMessage, 2000); // 延迟2秒首次执行,确保页面完全渲染
setInterval(saveCurrentChannelMessage, 15000); // 每15秒自动存档
console.log("自动存档已激活。可点击右下角悬浮图标进行手动操作。");
}
if (document.readyState === 'complete') {
main();
} else {
window.addEventListener('load', main);
}
})();