// ==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);
})();