您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
NodeSeek 私信记录本地缓存与WebDAV备份
// ==UserScript== // @name NodeSeek 私信优化脚本 // @namespace https://www.nodeseek.com/ // @version 1.0.0 // @description NodeSeek 私信记录本地缓存与WebDAV备份 // @author yuyan // @match https://www.nodeseek.com/notification* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect www.nodeseek.com // @connect dav.jianguoyun.com // @connect * // ==/UserScript== (function() { 'use strict'; /** * 工具函数集合 */ const Utils = { // Debug开关,设置为false可以减少日志输出,true显示详细日志 DEBUG: false, /** * 格式化日期为文件名安全的字符串 * @param {Date} date - 要格式化的日期对象 * @returns {string} 格式化后的日期字符串 */ formatDate(date) { return date.toISOString().replace(/[:.]/g, '-').slice(0, -5) + '-' + Date.now().toString().slice(-6); }, /** * 将UTC时间字符串转换为本地时间字符串 * @param {string} utcString - UTC时间字符串 * @returns {string} 本地时间字符串 */ parseUTCToLocal(utcString) { return new Date(utcString).toLocaleString(); }, /** * 记录日志信息 * @param {string} message - 日志消息 * @param {string} type - 日志类型,默认为'info' */ log(message, type = 'info') { if (!this.DEBUG && type === 'info') return; const typeStr = typeof type === 'string' ? type.toUpperCase() : 'INFO'; console.log(`[NodeSeek私信优化] ${typeStr}: ${message}`); }, /** * 记录调试信息(只在DEBUG模式下显示) * @param {string} message - 调试消息 * @param {*} data - 可选的数据对象 */ debug(message, data = null) { if (!this.DEBUG) return; if (data !== null) { console.log(`[NodeSeek私信优化] DEBUG: ${message}`, data); } else { console.log(`[NodeSeek私信优化] DEBUG: ${message}`); } }, /** * 记录错误信息 * @param {string} message - 错误消息 * @param {Error|null} error - 错误对象,可选 */ error(message, error = null) { console.error(`[NodeSeek私信优化] ERROR: ${message}`, error); }, /** * 开启调试模式 */ enableDebug() { this.DEBUG = true; console.log('[NodeSeek私信优化] 调试模式已开启'); }, /** * 关闭调试模式 */ disableDebug() { this.DEBUG = false; console.log('[NodeSeek私信优化] 调试模式已关闭'); } }; /** * IndexedDB 数据存储模块 * 用于本地存储聊天记录数据 */ class ChatDB { /** * 构造函数 * @param {number} userId - 用户ID */ constructor(userId) { this.userId = userId; this.dbName = `nodeseek_chat_${userId}`; this.version = 1; this.db = null; } /** * 初始化数据库连接 * @returns {Promise<void>} */ async init() { return new Promise((resolve, reject) => { const request = indexedDB.open(this.dbName, this.version); request.onerror = () => reject(request.error); request.onsuccess = () => { this.db = request.result; resolve(); }; request.onupgradeneeded = (event) => { const db = event.target.result; if (!db.objectStoreNames.contains('talk_messages')) { const store = db.createObjectStore('talk_messages', { keyPath: 'member_id' }); store.createIndex('created_at', 'created_at', { unique: false }); } if (!db.objectStoreNames.contains('metadata')) { db.createObjectStore('metadata', { keyPath: 'key' }); } }; }); } /** * 保存聊天消息数据 * @param {Object} memberData - 成员聊天数据 * @returns {Promise<void>} */ async saveTalkMessage(memberData) { const transaction = this.db.transaction(['talk_messages'], 'readwrite'); const store = transaction.objectStore('talk_messages'); return new Promise((resolve, reject) => { const request = store.put(memberData); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } /** * 获取指定成员的聊天消息 * @param {number} memberId - 成员ID * @returns {Promise<Object|undefined>} 聊天消息数据 */ async getTalkMessage(memberId) { const transaction = this.db.transaction(['talk_messages'], 'readonly'); const store = transaction.objectStore('talk_messages'); return new Promise((resolve, reject) => { const request = store.get(memberId); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } /** * 获取所有聊天消息 * @returns {Promise<Array>} 所有聊天消息数组 */ async getAllTalkMessages() { const transaction = this.db.transaction(['talk_messages'], 'readonly'); const store = transaction.objectStore('talk_messages'); return new Promise((resolve, reject) => { const request = store.getAll(); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } /** * 设置元数据 * @param {string} key - 键名 * @param {*} value - 值 * @returns {Promise<void>} */ async setMetadata(key, value) { const transaction = this.db.transaction(['metadata'], 'readwrite'); const store = transaction.objectStore('metadata'); return new Promise((resolve, reject) => { const request = store.put({ key, value }); request.onsuccess = () => resolve(); request.onerror = () => reject(request.error); }); } /** * 获取元数据 * @param {string} key - 键名 * @returns {Promise<*>} 元数据值 */ async getMetadata(key) { const transaction = this.db.transaction(['metadata'], 'readonly'); const store = transaction.objectStore('metadata'); return new Promise((resolve, reject) => { const request = store.get(key); request.onsuccess = () => resolve(request.result?.value); request.onerror = () => reject(request.error); }); } } /** * NodeSeek API 访问模块 * 用于与NodeSeek网站API进行交互 */ class NodeSeekAPI { /** * 构造函数 */ constructor() { this.baseUrl = 'https://www.nodeseek.com/api'; } /** * 发送HTTP请求 * @param {string} url - 请求URL * @param {Object} options - 请求选项 * @returns {Promise<Object>} 响应数据 */ async request(url, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Accept': 'application/json', 'Referer': 'https://www.nodeseek.com/', ...options.headers }, onload: (response) => { try { Utils.debug(`API响应状态: ${response.status}`); Utils.debug(`API响应内容: ${response.responseText.substring(0, 200)}...`); if (response.status !== 200) { reject(new Error(`HTTP错误: ${response.status} ${response.statusText}`)); return; } const data = JSON.parse(response.responseText); if (data.status === 404 && data.message === "USER NOT FOUND") { reject(new Error('用户未登录')); return; } resolve(data); } catch (e) { Utils.error(`响应解析失败,原始响应: ${response.responseText}`, e); reject(new Error(`响应解析失败: ${e.message}`)); } }, onerror: (error) => reject(error), ontimeout: () => reject(new Error('请求超时')) }); }); } /** * 获取当前用户ID * @returns {Promise<number>} 用户ID */ async getUserId() { try { Utils.debug('正在获取用户ID...'); const data = await this.request(`${this.baseUrl}/notification/message/with/5230`); Utils.debug('getUserId API响应:', data); if (data.success && data.msgArray && data.msgArray.length > 0) { const userId = data.msgArray[0].receiver_id; Utils.log(`获取到用户ID: ${userId}`); return userId; } Utils.error('API响应格式不正确或无数据', data); throw new Error('无法获取用户ID: API响应格式不正确'); } catch (error) { Utils.error('获取用户ID失败', error); throw error; } } /** * 获取与指定用户的聊天消息 * @param {number} userId - 用户ID * @returns {Promise<Object>} 聊天消息数据 */ async getChatMessages(userId) { const data = await this.request(`${this.baseUrl}/notification/message/with/${userId}`); return data; } /** * 获取消息列表 * @returns {Promise<Object>} 消息列表数据 */ async getMessageList() { const data = await this.request(`${this.baseUrl}/notification/message/list`); return data; } } /** * WebDAV 备份模块 * 用于将聊天记录备份到WebDAV服务器 */ class WebDAVBackup { /** * 构造函数 * @param {number} userId - 用户ID */ constructor(userId) { this.userId = userId; this.configKey = `webdav_config_${userId}`; } /** * 获取WebDAV配置 * @returns {Object|null} WebDAV配置对象 */ getConfig() { const config = GM_getValue(this.configKey, null); return config ? JSON.parse(config) : null; } /** * 保存WebDAV配置 * @param {Object} config - WebDAV配置对象 */ saveConfig(config) { GM_setValue(this.configKey, JSON.stringify(config)); } /** * 构建完整的WebDAV URL * @param {string} path - 文件路径 * @returns {string} 完整的URL */ buildFullUrl(path) { const config = this.getConfig(); if (!config) { throw new Error('WebDAV配置未设置'); } Utils.debug(`buildFullUrl 输入参数: path="${path}"`); Utils.debug(`WebDAV配置: serverUrl="${config.serverUrl}", backupPath="${config.backupPath}"`); // 如果path已经是完整的URL,直接返回 if (path.startsWith('http://') || path.startsWith('https://')) { Utils.debug(`path是完整URL,直接返回: ${path}`); return path; } const serverBase = config.serverUrl.replace(/\/$/, ''); Utils.debug(`处理后的serverBase: "${serverBase}"`); // 如果path是绝对路径(以/开头),需要检查是否与serverUrl重复 if (path.startsWith('/')) { // 检查serverUrl是否已经包含了path的开头部分 const serverPath = new URL(serverBase).pathname; Utils.debug(`serverUrl的路径部分: "${serverPath}"`); // 如果path已经包含了serverUrl的路径部分,避免重复 if (path.startsWith(serverPath) && serverPath !== '/') { const result = `${new URL(serverBase).origin}${path}`; Utils.debug(`避免路径重复,拼接结果: ${result}`); return result; } else { const result = `${serverBase}${path}`; Utils.debug(`path是绝对路径,拼接结果: ${result}`); return result; } } // 如果path是相对路径,需要拼接备份目录 // 注意:确保不会重复路径部分 const backupBase = config.backupPath.replace(/^\/+|\/+$/g, ''); // 去除首尾的斜杠 const fileName = path.replace(/^\/+/, ''); // 去除开头的斜杠 Utils.debug(`处理后的backupBase: "${backupBase}"`); Utils.debug(`处理后的fileName: "${fileName}"`); const result = `${serverBase}/${backupBase}/${fileName}`; Utils.debug(`最终拼接结果: ${result}`); return result; } async ensureDirectoryExists(directoryPath) { const config = this.getConfig(); if (!config) { throw new Error('WebDAV 配置未设置'); } const url = `${config.serverUrl.replace(/\/$/, '')}${directoryPath.replace(/\/$/, '')}/`; return new Promise((resolve, reject) => { // 首先检查目录是否存在 GM_xmlhttpRequest({ method: 'PROPFIND', url: url, headers: { 'Authorization': `Basic ${btoa(`${config.username}:${config.password}`)}`, 'Depth': '0' }, onload: (response) => { if (response.status >= 200 && response.status < 300) { // 目录已存在 Utils.log(`目录已存在: ${directoryPath}`); resolve(); } else if (response.status === 404) { // 目录不存在,尝试创建 Utils.log(`目录不存在,正在创建: ${directoryPath}`); GM_xmlhttpRequest({ method: 'MKCOL', url: url, headers: { 'Authorization': `Basic ${btoa(`${config.username}:${config.password}`)}` }, onload: (createResponse) => { if (createResponse.status >= 200 && createResponse.status < 300) { Utils.log(`目录创建成功: ${directoryPath}`); resolve(); } else { reject(new Error(`创建目录失败: ${createResponse.status} ${createResponse.statusText}`)); } }, onerror: (error) => reject(new Error(`创建目录网络错误: ${error?.message || '未知错误'}`)) }); } else { reject(new Error(`检查目录失败: ${response.status} ${response.statusText}`)); } }, onerror: (error) => reject(new Error(`检查目录网络错误: ${error?.message || '未知错误'}`)) }); }); } async uploadBackup(data, retryCount = 0) { const config = this.getConfig(); if (!config) { throw new Error('WebDAV 配置未设置'); } try { // 确保备份目录存在 await this.ensureDirectoryExists(config.backupPath); } catch (error) { throw new Error(`确保目录存在失败: ${error.message}`); } const filename = `nodeseek_chat_backup_${Utils.formatDate(new Date())}.json`; const url = `${config.serverUrl.replace(/\/$/, '')}${config.backupPath.replace(/\/$/, '')}/${filename}`; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'PUT', url: url, headers: { 'Authorization': `Basic ${btoa(`${config.username}:${config.password}`)}`, 'Content-Type': 'application/json' }, data: JSON.stringify(data), onload: async (response) => { if (response.status >= 200 && response.status < 300) { resolve(filename); } else if (response.status === 409) { if (retryCount < 3) { // 409冲突错误,可能是目录不存在或文件冲突,等待一段时间后重试 Utils.log(`备份冲突 (${response.status}),${1000 * (retryCount + 1)}ms后重试 (${retryCount + 1}/3)`); setTimeout(async () => { try { const result = await this.uploadBackup(data, retryCount + 1); resolve(result); } catch (error) { reject(error); } }, 1000 * (retryCount + 1)); } else { // 重试次数用完,提供更详细的错误信息 reject(new Error(`备份失败: 目录可能不存在或权限不足 (${response.status})。请检查WebDAV配置和目录权限。`)); } } else { const errorMsg = `备份失败: ${response.status} ${response.statusText}`; reject(new Error(errorMsg)); } }, onerror: (error) => reject(new Error(`备份上传网络错误: ${error?.message || '未知错误'}`)) }); }); } async listBackups() { const config = this.getConfig(); if (!config) return []; const url = `${config.serverUrl.replace(/\/$/, '')}${config.backupPath.replace(/\/$/, '')}/`; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'PROPFIND', url: url, headers: { 'Authorization': `Basic ${btoa(`${config.username}:${config.password}`)}`, 'Depth': '1' }, onload: (response) => { if (response.status >= 200 && response.status < 300) { Utils.debug(`备份列表响应: ${response.responseText.substring(0, 500)}...`); // 解析WebDAV响应,提取备份文件列表 const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/xml'); const files = Array.from(doc.querySelectorAll('response')) .map(response => { const href = response.querySelector('href')?.textContent; const lastModified = response.querySelector('getlastmodified')?.textContent; Utils.debug(`找到文件: href=${href}, lastModified=${lastModified}`); return { href, lastModified }; }) .filter(file => { const isBackupFile = file.href && file.href.includes('nodeseek_chat_backup_'); Utils.debug(`文件过滤: ${file.href} -> ${isBackupFile}`); return isBackupFile; }) .sort((a, b) => new Date(b.lastModified) - new Date(a.lastModified)); Utils.debug(`最终备份文件列表: ${files.length} 个文件`); resolve(files); } else { Utils.debug(`获取备份列表失败: ${response.status} - ${response.responseText}`); reject(new Error(`获取备份列表失败: ${response.status}`)); } }, onerror: (error) => reject(new Error(`获取备份列表网络错误: ${error?.message || '未知错误'}`)) }); }); } async cleanOldBackups() { try { const backups = await this.listBackups(); if (backups.length > 30) { const config = this.getConfig(); const toDelete = backups.slice(30); for (const backup of toDelete) { const deleteUrl = this.buildFullUrl(backup.href); await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'DELETE', url: deleteUrl, headers: { 'Authorization': `Basic ${btoa(`${config.username}:${config.password}`)}` }, onload: () => resolve(), onerror: (error) => reject(new Error(`删除备份文件网络错误: ${error?.message || '未知错误'}`)) }); }); } } } catch (error) { Utils.error('清理旧备份失败', error); } } } /** * UI 管理模块 * 负责用户界面的创建和管理 */ class UIManager { /** * 构造函数 */ constructor() { this.modals = new Set(); this.stylesLoaded = false; this.talkListObserver = null; this.lastTalkListPresent = false; } /** * 检测私信页面出现/消失的回调 * @param {boolean} isPresent - 私信页面是否存在 */ onMessagePageChange(isPresent) { if (isPresent) { Utils.debug('私信页面出现了'); this.addHistoryButton(); } else { Utils.debug('私信页面消失了'); this.removeHistoryButton(); } } /** * 检查私信页面状态 */ checkMessagePage() { const appSwitch = document.querySelector('.app-switch'); const messageLink = appSwitch?.querySelector('a[href="#/message?mode=list"]'); const isMessagePage = messageLink?.classList.contains('router-link-active'); if (isMessagePage !== this.lastTalkListPresent) { this.lastTalkListPresent = isMessagePage; this.onMessagePageChange(isMessagePage); } } /** * 初始化私信页面监听器 */ initTalkListObserver() { if (this.talkListObserver) { this.talkListObserver.disconnect(); } this.talkListObserver = new MutationObserver(() => { this.checkMessagePage(); }); this.talkListObserver.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] }); this.checkMessagePage(); } /** * 确保样式已加载 */ ensureStylesLoaded() { if (this.stylesLoaded || document.querySelector('#nodeseek-modal-styles')) { this.stylesLoaded = true; return; } const styles = document.createElement('style'); styles.id = 'nodeseek-modal-styles'; styles.textContent = ` .nodeseek-modal { position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } .nodeseek-modal-overlay { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.5); display: flex; align-items: center; justify-content: center; padding: 20px; box-sizing: border-box; } .nodeseek-modal-content { background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); overflow: hidden; width: 100%; display: flex; flex-direction: column; } .nodeseek-modal-header { padding: 16px 20px; border-bottom: 1px solid #e0e0e0; display: flex; justify-content: space-between; align-items: center; background: #f8f9fa; } .nodeseek-modal-header h3 { margin: 0; font-size: 18px; color: #333; } .nodeseek-modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #666; padding: 0; width: 30px; height: 30px; display: flex; align-items: center; justify-content: center; border-radius: 4px; } .nodeseek-modal-close:hover { background: #e0e0e0; color: #333; } .nodeseek-modal-body { padding: 20px; overflow-y: auto; flex: 1; } .nodeseek-btn { background: #007bff; color: white; border: none; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; margin: 0 4px; transition: background 0.2s; } .nodeseek-btn:hover { background: #0056b3; } .nodeseek-btn-secondary { background: #6c757d; } .nodeseek-btn-secondary:hover { background: #545b62; } .nodeseek-btn-success { background: #28a745; } .nodeseek-btn-success:hover { background: #1e7e34; } .nodeseek-form-group { margin-bottom: 16px; } .nodeseek-form-group label { display: block; margin-bottom: 4px; font-weight: 500; color: #333; } .nodeseek-form-group input, .nodeseek-form-group textarea { width: 100%; padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; box-sizing: border-box; } .nodeseek-form-group input:focus, .nodeseek-form-group textarea:focus { outline: none; border-color: #007bff; box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); } .nodeseek-chat-item { display: flex; align-items: center; padding: 12px; border-bottom: 1px solid #e0e0e0; transition: background 0.2s; } .nodeseek-chat-item:hover { background: #f8f9fa; } .nodeseek-chat-avatar { width: 40px; height: 40px; border-radius: 50%; margin-right: 12px; object-fit: cover; } .nodeseek-chat-info { flex: 1; min-width: 0; } .nodeseek-chat-name { font-weight: 500; color: #333; margin-bottom: 4px; } .nodeseek-chat-message { color: #666; font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .nodeseek-chat-time { color: #999; font-size: 12px; margin-left: 12px; white-space: nowrap; } .nodeseek-chat-actions { margin-left: 12px; } .nodeseek-history-btn { display: inline-block; background: #007bff; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-left: 8px; text-decoration: none; vertical-align: middle; transition: all 0.2s; line-height: 1.2; } .nodeseek-history-btn:hover { background: #0056b3; color: white; text-decoration: none; } `; document.head.appendChild(styles); this.stylesLoaded = true; } /** * 创建模态框 * @param {string} title - 模态框标题 * @param {string} content - 模态框内容HTML * @param {Object} options - 选项配置 * @returns {HTMLElement} 模态框元素 */ createModal(title, content, options = {}) { const modal = document.createElement('div'); modal.className = 'nodeseek-modal'; modal.innerHTML = ` <div class="nodeseek-modal-overlay"> <div class="nodeseek-modal-content" style="max-width: ${options.width || '600px'}; max-height: ${options.height || '80vh'};"> <div class="nodeseek-modal-header"> <h3>${title}</h3> <button class="nodeseek-modal-close">×</button> </div> <div class="nodeseek-modal-body"> ${content} </div> </div> </div> `; // 确保样式已加载 this.ensureStylesLoaded(); // 事件处理 const closeBtn = modal.querySelector('.nodeseek-modal-close'); const overlay = modal.querySelector('.nodeseek-modal-overlay'); const closeModal = () => { modal.remove(); this.modals.delete(modal); }; closeBtn.addEventListener('click', closeModal); overlay.addEventListener('click', (e) => { if (e.target === overlay) closeModal(); }); document.body.appendChild(modal); this.modals.add(modal); return modal; } /** * 显示WebDAV配置对话框 * @param {WebDAVBackup} webdavBackup - WebDAV备份实例 * @param {Function} onSave - 保存回调函数 */ showWebDAVConfig(webdavBackup, onSave) { const config = webdavBackup.getConfig() || {}; const content = ` <div class="nodeseek-form-group"> <label>服务器地址</label> <input type="url" id="webdav-server" value="${config.serverUrl || ''}" placeholder="https://dav.jianguoyun.com/dav/"> </div> <div class="nodeseek-form-group"> <label>用户名</label> <input type="text" id="webdav-username" value="${config.username || ''}" placeholder="用户名"> </div> <div class="nodeseek-form-group"> <label>密码</label> <input type="password" id="webdav-password" value="${config.password || ''}" placeholder="密码"> </div> <div class="nodeseek-form-group"> <label>备份路径</label> <input type="text" id="webdav-path" value="${config.backupPath || '/nodeseek_messages_backup/'}" placeholder="/nodeseek_messages_backup/"> </div> <div style="text-align: right; margin-top: 20px;"> <button class="nodeseek-btn nodeseek-btn-secondary" id="webdav-cancel">取消</button> <button class="nodeseek-btn nodeseek-btn-success" id="webdav-save">保存</button> </div> `; const modal = this.createModal('WebDAV 配置', content); modal.querySelector('#webdav-cancel').addEventListener('click', () => modal.remove()); modal.querySelector('#webdav-save').addEventListener('click', () => { const newConfig = { serverUrl: modal.querySelector('#webdav-server').value.trim(), username: modal.querySelector('#webdav-username').value.trim(), password: modal.querySelector('#webdav-password').value.trim(), backupPath: modal.querySelector('#webdav-path').value.trim() }; if (!newConfig.serverUrl || !newConfig.username || !newConfig.password) { alert('请填写完整的配置信息'); return; } webdavBackup.saveConfig(newConfig); modal.remove(); if (onSave) onSave(); }); } /** * 显示历史聊天记录 * @param {Array} chatData - 聊天数据数组 * @param {boolean} showLatest - 是否显示最新聊天,默认false * @returns {HTMLElement} 模态框元素 */ showHistoryChats(chatData, showLatest = false) { const sortedChats = chatData .filter(chat => showLatest || !chat.isLatest) .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); let content = ` <div style="margin-bottom: 16px; display: flex; gap: 8px; flex-wrap: wrap;"> <button class="nodeseek-btn" id="webdav-config-btn">WebDAV设置</button> <button class="nodeseek-btn nodeseek-btn-success" id="backup-now-btn">立即备份</button> <button class="nodeseek-btn nodeseek-btn-secondary" id="restore-btn">从WebDAV恢复</button> <label style="display: flex; align-items: center; margin-left: auto;"> <input type="checkbox" id="show-latest-toggle" ${showLatest ? 'checked' : ''} style="margin-right: 4px;"> 显示最新聊天 </label> </div> <div style="max-height: 400px; overflow-y: auto;"> `; if (sortedChats.length === 0) { content += '<div style="text-align: center; color: #666; padding: 40px;">暂无历史聊天记录</div>'; } else { sortedChats.forEach(chat => { const avatarUrl = `https://www.nodeseek.com/avatar/${chat.member_id}.png`; const chatUrl = `https://www.nodeseek.com/notification#/message?mode=talk&to=${chat.member_id}`; const timeStr = Utils.parseUTCToLocal(chat.created_at); content += ` <div class="nodeseek-chat-item"> <img class="nodeseek-chat-avatar" src="${avatarUrl}" alt="${chat.member_name}" onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIHZpZXdCb3g9IjAgMCA0MCA0MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGNpcmNsZSBjeD0iMjAiIGN5PSIyMCIgcj0iMjAiIGZpbGw9IiNlMGUwZTAiLz4KPHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4PSI4IiB5PSI4Ij4KPHBhdGggZD0iTTEyIDEyQzE0LjIwOTEgMTIgMTYgMTAuMjA5MSAxNiA4QzE2IDUuNzkwODYgMTQuMjA5MSA0IDEyIDRDOS43OTA4NiA0IDggNS43OTA4NiA4IDhDOCAxMC4yMDkxIDkuNzkwODYgMTIgMTIgMTJaIiBmaWxsPSIjOTk5Ii8+CjxwYXRoIGQ9Ik0xMiAxNEM5LjMzIDEzLjk5IDcuMDEgMTUuNjIgNiAxOEMxMC4wMSAyMCAxMy45OSAyMCAxOCAxOEMxNi45OSAxNS42MiAxNC42NyAxMy45OSAxMiAxNFoiIGZpbGw9IiM5OTkiLz4KPC9zdmc+Cjwvc3ZnPgo='"> <div class="nodeseek-chat-info"> <div class="nodeseek-chat-name">${chat.member_name} (ID: ${chat.member_id})</div> <div class="nodeseek-chat-message">${chat.content.replace(/<[^>]*>/g, '').substring(0, 50)}${chat.content.length > 50 ? '...' : ''}</div> </div> <div class="nodeseek-chat-time">${timeStr}</div> <div class="nodeseek-chat-actions"> <a href="${chatUrl}" target="_blank" class="nodeseek-btn" style="text-decoration: none; font-size: 12px; padding: 4px 8px;">打开对话</a> </div> </div> `; }); } content += '</div>'; return this.createModal('历史聊天记录', content, { width: '800px', height: '600px' }); } /** * 添加历史聊天按钮 */ addHistoryButton() { this.ensureStylesLoaded(); const existingBtn = document.querySelector('.nodeseek-history-btn'); if (existingBtn) existingBtn.remove(); const appSwitch = document.querySelector('.app-switch'); const messageLink = appSwitch?.querySelector('a[href="#/message?mode=list"]'); if (!appSwitch || !messageLink) { Utils.debug('app-switch 或私信链接元素不存在,无法添加按钮'); return; } const btn = document.createElement('a'); btn.className = 'nodeseek-history-btn'; btn.textContent = '历史私信'; btn.href = 'javascript:void(0)'; btn.addEventListener('click', (e) => { e.preventDefault(); window.chatBackup?.showHistoryChats(); }); // 将按钮插入到私信链接后面 messageLink.insertAdjacentElement('afterend', btn); Utils.debug('历史聊天按钮已添加到私信链接后面'); } /** * 移除历史聊天按钮 */ removeHistoryButton() { const btn = document.querySelector('.nodeseek-history-btn'); if (btn) btn.remove(); } /** * 显示提示消息 * @param {string} message - 提示消息内容 * @param {string} type - 消息类型:'success', 'error', 'warning', 'info' * @param {number} duration - 显示持续时间(毫秒),默认3000 */ showToast(message, type = 'success', duration = 3000) { // 移除已存在的提示 const existingToast = document.querySelector('.nodeseek-toast'); if (existingToast) existingToast.remove(); const toast = document.createElement('div'); toast.className = 'nodeseek-toast'; const bgColor = type === 'success' ? '#28a745' : type === 'error' ? '#dc3545' : type === 'warning' ? '#ffc107' : '#007bff'; toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: ${bgColor}; color: white; padding: 12px 20px; border-radius: 6px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 10001; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 300px; word-wrap: break-word; opacity: 0; transform: translateX(100%); transition: all 0.3s ease; `; toast.textContent = message; document.body.appendChild(toast); // 显示动画 setTimeout(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; }, 10); // 自动消失 setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(100%)'; setTimeout(() => { if (toast.parentNode) { toast.remove(); } }, 300); }, duration); } } /** * 主控制模块 * 负责协调各个模块的工作 */ class ChatBackup { /** * 构造函数 */ constructor() { this.api = new NodeSeekAPI(); this.db = null; this.webdav = null; this.ui = new UIManager(); this.userId = null; this.backupTimer = null; this.lastHash = ''; this.showLatestChats = GM_getValue('show_latest_chats', false); } /** * 初始化应用 * @returns {Promise<void>} */ async init() { try { Utils.debug('开始初始化脚本...'); // 检查是否在正确的域名 if (window.location.hostname !== 'www.nodeseek.com') { Utils.debug('不在NodeSeek域名,跳过初始化'); return; } // 获取用户ID this.userId = await this.api.getUserId(); // 初始化数据库和WebDAV this.db = new ChatDB(this.userId); await this.db.init(); this.webdav = new WebDAVBackup(this.userId); // 设置定时备份 this.setupAutoBackup(); // 监听页面变化 this.setupPageListener(); // 注册菜单命令 this.registerMenuCommands(); // 处理当前页面 this.handlePageChange(); // 初始化talk-list监听器 this.ui.initTalkListObserver(); Utils.log('NodeSeek私信优化脚本初始化完成'); } catch (error) { Utils.error('初始化失败', error); // 显示用户友好的错误提示 if (error.message.includes('用户未登录')) { console.warn('[NodeSeek私信优化] 请先登录NodeSeek账户'); } else if (error.message.includes('响应解析失败')) { console.warn('[NodeSeek私信优化] 网络请求失败,请检查网络连接或稍后重试'); } else { console.warn('[NodeSeek私信优化] 初始化失败,请刷新页面重试'); } } } /** * 设置自动备份 */ setupAutoBackup() { this.backupTimer = setInterval(() => { this.performBackup(); }, 6 * 60 * 60 * 1000); document.addEventListener('visibilitychange', () => { if (!document.hidden) { const lastBackup = GM_getValue(`last_backup_${this.userId}`, 0); const now = Date.now(); if (now - lastBackup > 6 * 60 * 60 * 1000) { this.performBackup(); } } }); } /** * 设置页面监听器 */ setupPageListener() { window.addEventListener('hashchange', () => { this.handlePageChange(); }); const observer = new MutationObserver(() => { if (window.location.hash !== this.lastHash) { this.lastHash = window.location.hash; this.handlePageChange(); } }); observer.observe(document.body, { childList: true, subtree: true }); } /** * 处理页面变化 * @returns {Promise<void>} */ async handlePageChange() { const hash = window.location.hash; this.lastHash = hash; try { if (hash.includes('mode=talk&to=')) { const match = hash.match(/to=(\d+)/); if (match) { const targetUserId = parseInt(match[1]); await this.handleChatPage(targetUserId); } } else if (hash.includes('mode=list')) { await this.handleMessageListPage(); } } catch (error) { Utils.error('页面处理失败', error); } } /** * 处理聊天页面 * @param {number} targetUserId - 目标用户ID * @returns {Promise<void>} */ async handleChatPage(targetUserId) { try { const response = await this.api.getChatMessages(targetUserId); if (response.success && response.msgArray && response.msgArray.length > 0) { const latestMessage = response.msgArray[response.msgArray.length - 1]; const talkTo = response.talkTo; const chatData = { member_id: talkTo.member_id, member_name: talkTo.member_name, content: latestMessage.content, created_at: latestMessage.created_at, sender_id: latestMessage.sender_id, receiver_id: latestMessage.receiver_id, message_id: latestMessage.id, viewed: latestMessage.viewed, updated_at: new Date().toISOString() }; // 检查是否需要更新 const existingData = await this.db.getTalkMessage(talkTo.member_id); if (!existingData || existingData.created_at !== latestMessage.created_at) { await this.db.saveTalkMessage(chatData); Utils.log(`更新聊天记录: ${talkTo.member_name}`); this.performBackup(); } } } catch (error) { if (error.message === '用户未登录') { Utils.log('用户未登录,停止操作'); return; } Utils.error('处理聊天页面失败', error); } } /** * 处理消息列表页面 * @returns {Promise<void>} */ async handleMessageListPage() { try { const response = await this.api.getMessageList(); if (response.success && response.msgArray) { let hasUpdates = false; const currentChatUserIds = new Set(); for (const msg of response.msgArray) { // 判断聊天对象 let chatUserId, chatUserName; if (msg.sender_id === this.userId) { chatUserId = msg.receiver_id; chatUserName = msg.receiver_name; } else { chatUserId = msg.sender_id; chatUserName = msg.sender_name; } currentChatUserIds.add(chatUserId); const chatData = { member_id: chatUserId, member_name: chatUserName, content: msg.content, created_at: msg.created_at, sender_id: msg.sender_id, receiver_id: msg.receiver_id, message_id: msg.max_id, viewed: msg.viewed, updated_at: new Date().toISOString(), isLatest: true }; // 检查是否需要更新 const existingData = await this.db.getTalkMessage(chatUserId); if (!existingData || existingData.created_at !== msg.created_at) { await this.db.saveTalkMessage(chatData); hasUpdates = true; } } // 更新其他聊天记录的isLatest标记 const allChats = await this.db.getAllTalkMessages(); for (const chat of allChats) { if (!currentChatUserIds.has(chat.member_id) && chat.isLatest) { chat.isLatest = false; await this.db.saveTalkMessage(chat); } } if (hasUpdates) { this.performBackup(); } } } catch (error) { if (error.message === '用户未登录') { Utils.log('用户未登录,停止操作'); return; } Utils.error('处理消息列表页面失败', error); } } /** * 执行备份操作 * @returns {Promise<void>} */ async performBackup() { try { const config = this.webdav.getConfig(); if (!config) { Utils.log('WebDAV未配置,跳过备份'); throw new Error('WebDAV未配置'); } const allChats = await this.db.getAllTalkMessages(); const metadata = { userId: this.userId, backupTime: new Date().toISOString(), totalChats: allChats.length }; const backupData = { metadata, chats: allChats }; const filename = await this.webdav.uploadBackup(backupData); await this.webdav.cleanOldBackups(); GM_setValue(`last_backup_${this.userId}`, Date.now()); Utils.log(`备份完成: ${filename}`); } catch (error) { Utils.error('备份失败', error); throw error; } } /** * 清空所有聊天数据 * @returns {Promise<void>} */ async clearAllChatData() { try { const transaction = this.db.db.transaction(['talk_messages'], 'readwrite'); const store = transaction.objectStore('talk_messages'); return new Promise((resolve, reject) => { const request = store.clear(); request.onsuccess = () => { Utils.debug('所有聊天记录已清空'); resolve(); }; request.onerror = () => reject(request.error); }); } catch (error) { Utils.error('清空聊天数据失败', error); throw error; } } /** * 显示历史聊天记录 * @returns {Promise<void>} */ async showHistoryChats() { try { const allChats = await this.db.getAllTalkMessages(); const modal = this.ui.showHistoryChats(allChats, this.showLatestChats); // 绑定事件 modal.querySelector('#webdav-config-btn').addEventListener('click', () => { this.ui.showWebDAVConfig(this.webdav, () => { Utils.log('WebDAV配置已保存'); this.ui.showToast('WebDAV配置已保存'); this.performBackup(); }); }); modal.querySelector('#backup-now-btn').addEventListener('click', async () => { try { await this.performBackup(); this.ui.showToast('备份完成'); } catch (error) { this.ui.showToast('备份失败: ' + error.message, 'error'); } }); modal.querySelector('#restore-btn').addEventListener('click', () => { this.showRestoreOptions(); }); modal.querySelector('#show-latest-toggle').addEventListener('change', (e) => { this.showLatestChats = e.target.checked; GM_setValue('show_latest_chats', this.showLatestChats); this.ui.showToast(e.target.checked ? '已显示最新聊天' : '已隐藏最新聊天'); modal.remove(); this.showHistoryChats(); }); } catch (error) { Utils.error('显示历史聊天失败', error); } } /** * 显示恢复选项 * @returns {Promise<void>} */ async showRestoreOptions() { try { Utils.debug('正在获取备份列表...'); const backups = await this.webdav.listBackups(); if (backups.length === 0) { this.ui.showToast('没有找到备份文件', 'warning'); return; } Utils.debug(`找到 ${backups.length} 个备份文件`); let content = ` <div style="margin-bottom: 16px; padding: 12px; background: #fff3cd; border: 1px solid #ffeaa7; border-radius: 4px; font-size: 14px;"> <strong>⚠️ 重要提示:</strong>恢复操作会<strong>完全覆盖</strong>现有的本地聊天数据,原有数据将被删除且无法恢复! </div> <div style="max-height: 300px; overflow-y: auto;"> `; backups.forEach((backup, index) => { const date = new Date(backup.lastModified).toLocaleString(); const fileName = backup.href.split('/').pop(); content += ` <div style="padding: 12px; border-bottom: 1px solid #eee; cursor: pointer; transition: background 0.2s;" data-backup="${backup.href}" onmouseover="this.style.background='#f8f9fa'" onmouseout="this.style.background='transparent'"> <div style="font-weight: 500; margin-bottom: 4px;">备份 ${index + 1}</div> <div style="font-size: 12px; color: #666; margin-bottom: 2px;">时间: ${date}</div> <div style="font-size: 11px; color: #999;">文件: ${fileName}</div> </div> `; }); content += '</div>'; const modal = this.ui.createModal('选择要恢复的备份', content, { width: '500px' }); modal.querySelectorAll('[data-backup]').forEach(item => { item.addEventListener('click', async () => { const backupPath = item.dataset.backup; const fileName = backupPath.split('/').pop(); // 确认对话框 if (confirm(`⚠️ 确定要恢复备份文件 "${fileName}" 吗?\n\n警告:此操作会完全覆盖现有的本地聊天数据!\n原有数据将被永久删除且无法恢复!\n\n请确认您真的要继续此操作。`)) { modal.remove(); // 显示恢复进度 this.ui.showToast('正在恢复备份,请稍候...', 'info', 10000); try { await this.restoreFromBackup(backupPath); } catch (error) { Utils.error('恢复过程中出错', error); } } }); }); } catch (error) { Utils.error('获取备份列表失败', error); let errorMessage = '获取备份列表失败'; if (error.message.includes('401')) { errorMessage = 'WebDAV认证失败,请检查用户名和密码'; } else if (error.message.includes('403')) { errorMessage = 'WebDAV权限不足,请检查账户权限'; } else if (error.message.includes('404')) { errorMessage = 'WebDAV备份目录不存在'; } else if (error.message.includes('网络')) { errorMessage = '网络连接失败,请检查网络连接'; } this.ui.showToast(errorMessage, 'error', 5000); } } /** * 从备份恢复数据 * @param {string} backupPath - 备份文件路径 * @returns {Promise<void>} */ async restoreFromBackup(backupPath) { try { const config = this.webdav.getConfig(); if (!config) { throw new Error('WebDAV配置未设置'); } // 构建正确的URL const url = this.webdav.buildFullUrl(backupPath); Utils.debug(`正在从以下URL恢复备份: ${url}`); const response = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Authorization': `Basic ${btoa(`${config.username}:${config.password}`)}`, 'Accept': 'application/json' }, onload: (response) => { Utils.debug(`恢复请求响应状态: ${response.status}`); Utils.debug(`恢复请求响应头: ${response.responseHeaders}`); if (response.status >= 200 && response.status < 300) { try { const data = JSON.parse(response.responseText); resolve(data); } catch (parseError) { Utils.error(`解析备份文件失败: ${parseError.message}`); Utils.debug(`原始响应内容: ${response.responseText.substring(0, 500)}`); reject(new Error(`备份文件格式错误: ${parseError.message}`)); } } else { let errorMessage = `HTTP错误 ${response.status}`; // 针对不同的HTTP状态码提供更具体的错误信息 switch (response.status) { case 401: errorMessage = '认证失败,请检查WebDAV用户名和密码'; break; case 403: errorMessage = '权限不足,无法访问备份文件'; break; case 404: errorMessage = '备份文件不存在或已被删除'; break; case 409: errorMessage = '文件访问冲突,请稍后重试'; break; case 500: errorMessage = 'WebDAV服务器内部错误'; break; default: errorMessage = `服务器返回错误: ${response.status} ${response.statusText}`; } Utils.debug(`详细错误信息: ${response.responseText}`); reject(new Error(errorMessage)); } }, onerror: (error) => { Utils.error('网络请求失败', error); reject(new Error('网络连接失败,请检查网络连接')); }, ontimeout: () => { reject(new Error('请求超时,请稍后重试')); }, timeout: 30000 // 30秒超时 }); }); if (response && response.chats && Array.isArray(response.chats)) { Utils.debug(`开始恢复 ${response.chats.length} 条聊天记录`); // 完全覆盖模式:先清空现有数据 Utils.debug('清空现有聊天记录...'); await this.clearAllChatData(); let successCount = 0; for (const chat of response.chats) { try { await this.db.saveTalkMessage(chat); successCount++; } catch (dbError) { Utils.error(`保存聊天记录失败 (ID: ${chat.member_id})`, dbError); } } const message = `恢复完成,已覆盖本地数据,共恢复 ${successCount} 条聊天记录`; Utils.log(message); this.ui.showToast(message); } else { throw new Error('备份文件格式不正确或不包含聊天数据'); } } catch (error) { Utils.error('恢复备份失败', error); // 显示用户友好的错误提示 let userMessage = error.message; if (error.message.includes('409') || error.message.includes('冲突')) { userMessage = '文件访问冲突,请稍后重试。如果问题持续存在,请检查WebDAV服务器状态。'; } this.ui.showToast(`恢复失败: ${userMessage}`, 'error', 5000); } } /** * 注册菜单命令 */ registerMenuCommands() { GM_registerMenuCommand('WebDAV 配置', () => { this.ui.showWebDAVConfig(this.webdav, () => { Utils.log('WebDAV配置已保存'); this.ui.showToast('WebDAV配置已保存'); this.performBackup(); }); }); GM_registerMenuCommand('立即备份', async () => { try { await this.performBackup(); this.ui.showToast('备份完成'); } catch (error) { this.ui.showToast('备份失败: ' + error.message, 'error'); } }); GM_registerMenuCommand('历史聊天记录', () => { this.showHistoryChats(); }); } } /** * 全局变量 */ let chatBackup; /** * 初始化脚本 */ function initScript() { try { Utils.debug('脚本开始加载...'); if (window.location.hostname !== 'www.nodeseek.com') { Utils.debug('不在NodeSeek域名,脚本不会运行'); return; } chatBackup = new ChatBackup(); window.chatBackup = chatBackup; if (document.readyState === 'loading') { Utils.debug('等待DOM加载完成...'); document.addEventListener('DOMContentLoaded', () => { Utils.debug('DOM加载完成,1秒后开始初始化'); setTimeout(() => chatBackup.init(), 1000); }); } else { Utils.debug('DOM已加载,1秒后开始初始化'); setTimeout(() => chatBackup.init(), 1000); } } catch (error) { Utils.error('脚本初始化失败', error); } } /** * 启动脚本 */ initScript(); })();