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