/** * 小红书采集器前端逻辑 */ // ========== 状态管理 ========== const state = { isRunning: false, mode: 'single', maxThreads: 3, totalUsers: 0, completedUsers: 0, failedUsers: 0, activeThreads: 0, usersStatus: {}, threadStatus: {}, eventSource: null, autoScrollLogs: true, logFilter: 'all', logs: [] }; // ========== DOM 元素 ========== const elements = { startBtn: null, stopBtn: null, clearLogsBtn: null, exportDataBtn: null, themeToggle: null, themeIcon: null, modeRadios: null, threadControl: null, threadCount: null, progressBar: null, progressText: null, totalUsers: null, completedUsers: null, failedUsers: null, activeThreadsStat: null, activeThreads: null, userList: null, userCount: null, threadMonitorPanel: null, threadList: null, logContainer: null, logFilter: null, logSearch: null, screenshotPanel: null, screenshotImage: null, closeScreenshot: null, // 账号管理相关 addAccountBtn: null, refreshAccountsBtn: null, accountStatus: null, loginStatusIndicator: null, loginStatusText: null, // 模态框相关 accountModal: null, closeAccountModal: null, loginBtn: null, logoutBtn: null, loginInfo: null, modalLoginStatus: null, loginTime: null, modalUserList: null, modalUserCount: null, clearAllUsersBtn: null, newUserIdInput: null, addUserIdBtn: null, importFromTxtBtn: null, exportToTxtBtn: null, // 登录二维码模态框 loginQrModal: null, closeLoginQrModal: null, qrcodeContainer: null, qrLoginStatus: null, qrExpireTime: null, // Toast toast: null }; // ========== 初始化 ========== function init() { // 获取 DOM 元素 elements.startBtn = document.getElementById('startBtn'); elements.stopBtn = document.getElementById('stopBtn'); elements.clearLogsBtn = document.getElementById('clearLogsBtn'); elements.exportDataBtn = document.getElementById('exportDataBtn'); elements.themeToggle = document.getElementById('themeToggle'); elements.themeIcon = document.getElementById('themeIcon'); elements.modeRadios = document.querySelectorAll('input[name="mode"]'); elements.threadControl = document.getElementById('threadControl'); elements.threadCount = document.getElementById('threadCount'); elements.progressBar = document.getElementById('progressBar'); elements.progressText = document.getElementById('progressText'); elements.totalUsers = document.getElementById('totalUsers'); elements.completedUsers = document.getElementById('completedUsers'); elements.failedUsers = document.getElementById('failedUsers'); elements.activeThreadsStat = document.getElementById('activeThreadsStat'); elements.activeThreads = document.getElementById('activeThreads'); elements.userList = document.getElementById('userList'); elements.userCount = document.getElementById('userCount'); elements.threadMonitorPanel = document.getElementById('threadMonitorPanel'); elements.threadList = document.getElementById('threadList'); elements.logContainer = document.getElementById('logContainer'); elements.logFilter = document.getElementById('logFilter'); elements.logSearch = document.getElementById('logSearch'); elements.screenshotPanel = document.getElementById('screenshotPanel'); elements.screenshotImage = document.getElementById('screenshotImage'); elements.closeScreenshot = document.getElementById('closeScreenshot'); // 账号管理相关 elements.addAccountBtn = document.getElementById('addAccountBtn'); elements.refreshAccountsBtn = document.getElementById('refreshAccountsBtn'); elements.accountStatus = document.getElementById('accountStatus'); elements.loginStatusIndicator = document.getElementById('loginStatusIndicator'); elements.loginStatusText = document.getElementById('loginStatusText'); // 模态框相关 elements.accountModal = document.getElementById('accountModal'); elements.closeAccountModal = document.getElementById('closeAccountModal'); elements.loginBtn = document.getElementById('loginBtn'); elements.logoutBtn = document.getElementById('logoutBtn'); elements.loginInfo = document.getElementById('loginInfo'); elements.modalLoginStatus = document.getElementById('modalLoginStatus'); elements.loginTime = document.getElementById('loginTime'); elements.modalUserList = document.getElementById('modalUserList'); elements.modalUserCount = document.getElementById('modalUserCount'); elements.clearAllUsersBtn = document.getElementById('clearAllUsersBtn'); elements.newUserIdInput = document.getElementById('newUserIdInput'); elements.addUserIdBtn = document.getElementById('addUserIdBtn'); elements.importFromTxtBtn = document.getElementById('importFromTxtBtn'); elements.exportToTxtBtn = document.getElementById('exportToTxtBtn'); // 登录二维码模态框 elements.loginQrModal = document.getElementById('loginQrModal'); elements.closeLoginQrModal = document.getElementById('closeLoginQrModal'); elements.qrcodeContainer = document.getElementById('qrcodeContainer'); elements.qrLoginStatus = document.getElementById('qrLoginStatus'); elements.qrExpireTime = document.getElementById('qrExpireTime'); // Toast elements.toast = document.getElementById('toast'); // 加载主题 loadTheme(); // 加载登录状态 loadAccountStatus(); // 加载初始数据 loadInitialData(); // 绑定事件 bindEvents(); // 连接 SSE connectEventStream(); } // ========== 事件绑定 ========== function bindEvents() { // 启动按钮 elements.startBtn.addEventListener('click', startScraping); // 停止按钮 elements.stopBtn.addEventListener('click', stopScraping); // 清空日志 elements.clearLogsBtn.addEventListener('click', clearLogs); // 导出数据 elements.exportDataBtn.addEventListener('click', exportData); // 主题切换 elements.themeToggle.addEventListener('click', toggleTheme); // 模式切换 elements.modeRadios.forEach(radio => { radio.addEventListener('change', (e) => { state.mode = e.target.value; updateModeUI(); }); }); // 线程数变化 elements.threadCount.addEventListener('change', (e) => { state.maxThreads = parseInt(e.target.value); }); // 日志过滤 elements.logFilter.addEventListener('change', (e) => { state.logFilter = e.target.value; renderLogs(); }); // 日志搜索 elements.logSearch.addEventListener('input', debounce((e) => { searchLogs(e.target.value); }, 300)); // 关闭截图 elements.closeScreenshot.addEventListener('click', () => { elements.screenshotPanel.style.display = 'none'; }); // 日志容器滚动监听(自动滚动控制) elements.logContainer.addEventListener('scroll', () => { const { scrollTop, scrollHeight, clientHeight } = elements.logContainer; state.autoScrollLogs = scrollTop + clientHeight >= scrollHeight - 10; }); // 账号管理事件 elements.addAccountBtn.addEventListener('click', openAccountModal); elements.refreshAccountsBtn.addEventListener('click', refreshAccounts); elements.closeAccountModal.addEventListener('click', closeAccountModal); elements.loginBtn.addEventListener('click', openLoginQrModal); elements.logoutBtn.addEventListener('click', logout); elements.clearAllUsersBtn.addEventListener('click', clearAllUsers); elements.addUserIdBtn.addEventListener('click', addUserId); elements.importFromTxtBtn.addEventListener('click', importFromTxt); elements.exportToTxtBtn.addEventListener('click', exportToTxt); // 新用户输入框回车事件 elements.newUserIdInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { addUserId(); } }); // 登录二维码模态框关闭 elements.closeLoginQrModal.addEventListener('click', closeLoginQrModal); // 点击模态框外部关闭 window.addEventListener('click', (e) => { if (e.target === elements.accountModal) { closeAccountModal(); } if (e.target === elements.loginQrModal) { closeLoginQrModal(); } }); } // ========== 加载初始数据 ========== async function loadInitialData() { try { // 加载状态 const statusRes = await fetch('/api/status'); const statusData = await statusRes.json(); updateStatus(statusData); // 加载用户列表 const usersRes = await fetch('/api/users'); const usersData = await usersRes.json(); renderUserList(usersData.users); // 加载历史日志 const logsRes = await fetch('/api/logs?limit=100'); const logsData = await logsRes.json(); state.logs = logsData.logs; renderLogs(); } catch (error) { console.error('加载初始数据失败:', error); } } // ========== 启动采集 ========== async function startScraping() { try { const response = await fetch('/api/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: state.mode, max_threads: state.maxThreads }) }); const data = await response.json(); if (response.ok) { state.isRunning = true; updateButtons(); addLog('SUCCESS', data.message); } else { addLog('ERROR', data.detail || '启动失败'); } } catch (error) { addLog('ERROR', `启动失败: ${error.message}`); } } // ========== 停止采集 ========== async function stopScraping() { try { const response = await fetch('/api/stop', { method: 'POST' }); const data = await response.json(); if (response.ok) { state.isRunning = false; updateButtons(); addLog('WARNING', data.message); } } catch (error) { addLog('ERROR', `停止失败: ${error.message}`); } } // ========== 清空日志 ========== async function clearLogs() { try { const response = await fetch('/api/logs/clear', { method: 'POST' }); const data = await response.json(); if (response.ok) { state.logs = []; renderLogs(); addLog('SUCCESS', '日志已清空'); } } catch (error) { addLog('ERROR', `清空日志失败: ${error.message}`); } } // ========== 导出数据 ========== async function exportData() { try { // 下载日志 window.location.href = '/api/logs/download'; addLog('SUCCESS', '正在下载日志文件...'); } catch (error) { addLog('ERROR', `导出失败: ${error.message}`); } } // ========== 搜索日志 ========== async function searchLogs(keyword) { if (!keyword) { // 加载所有日志 const logsRes = await fetch('/api/logs?limit=100'); const logsData = await logsRes.json(); state.logs = logsData.logs; } else { // 搜索日志 const searchRes = await fetch(`/api/logs/search?keyword=${encodeURIComponent(keyword)}&limit=100`); const searchData = await searchRes.json(); state.logs = searchData.logs; } renderLogs(); } // ========== SSE 事件流 ========== function connectEventStream() { if (state.eventSource) { state.eventSource.close(); } state.eventSource = new EventSource('/api/events'); state.eventSource.onmessage = (event) => { if (event.data.startsWith(': heartbeat')) { return; // 忽略心跳 } try { const data = JSON.parse(event.data); handleEvent(data); } catch (error) { console.error('解析事件失败:', error); } }; state.eventSource.onerror = (error) => { console.error('SSE 连接错误:', error); // 5秒后重连 setTimeout(() => { if (!state.eventSource || state.eventSource.readyState === EventSource.CLOSED) { connectEventStream(); } }, 5000); }; } // ========== 处理事件 ========== function handleEvent(event) { switch (event.type) { case 'log': addLog(event.level, event.message, event.thread_id, event.timestamp, false); break; case 'progress': updateProgress(event); break; case 'user_status': updateUserStatus(event); break; case 'thread_status': updateThreadStatus(event); break; case 'screenshot': showScreenshot(event.path); break; case 'finished': state.isRunning = false; updateButtons(); addLog('SUCCESS', '所有采集任务完成'); break; case 'error': addLog('ERROR', event.message); break; } } // ========== 更新状态 ========== function updateStatus(data) { state.isRunning = data.is_running; state.totalUsers = data.total_users; state.completedUsers = data.completed_users; state.failedUsers = data.failed_users; state.activeThreads = data.active_threads; state.usersStatus = data.users_status || {}; updateButtons(); updateProgress(); renderUserListFromStatus(); } // ========== 更新进度 ========== function updateProgress(data) { if (data) { state.completedUsers = data.completed || state.completedUsers; state.failedUsers = data.failed || state.failedUsers; state.activeThreads = data.active_threads || state.activeThreads; } const total = state.totalUsers; const completed = state.completedUsers; const failed = state.failedUsers; const progress = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0; elements.progressBar.style.width = `${progress}%`; elements.progressText.textContent = `${progress}%`; elements.totalUsers.textContent = total; elements.completedUsers.textContent = completed; elements.failedUsers.textContent = failed; elements.activeThreads.textContent = state.activeThreads; // 多线程模式显示活跃线程 if (state.mode === 'multi') { elements.activeThreadsStat.style.display = 'flex'; } } // ========== 更新用户状态 ========== function updateUserStatus(data) { state.usersStatus[data.user_id] = { status: data.status, note_count: data.note_count, thread_id: data.thread_id }; renderUserListFromStatus(); } // ========== 更新线程状态 ========== function updateThreadStatus(data) { state.threadStatus[data.thread_id] = { status: data.status, current_user: data.current_user }; renderThreadList(); } // ========== 渲染用户列表 ========== function renderUserList(users) { if (!users || users.length === 0) { elements.userList.innerHTML = `

暂无用户数据

请在 data/users.txt 中添加用户 ID

`; elements.userCount.textContent = '0'; return; } elements.userCount.textContent = users.length; elements.userList.innerHTML = users.map(user => { const status = user.status || 'pending'; const noteCount = user.note_count || 0; return `
${user.id}
${getStatusText(status)}
${noteCount > 0 ? `已采集 ${noteCount} 条笔记` : '等待采集'}
`; }).join(''); } // ========== 从状态渲染用户列表 ========== function renderUserListFromStatus() { const users = Object.keys(state.usersStatus).map(userId => ({ id: userId, ...state.usersStatus[userId] })); renderUserList(users); } // ========== 渲染线程列表 ========== function renderThreadList() { const threads = Object.entries(state.threadStatus); if (threads.length === 0) { elements.threadList.innerHTML = `

暂无线程活动

`; return; } elements.threadList.innerHTML = threads.map(([threadId, status]) => { const statusClass = status.status; const statusText = status.status === 'busy' ? '处理中' : status.status === 'idle' ? '空闲' : '错误'; const currentUser = status.current_user || '-'; return `
${threadId} ${statusText}
${currentUser}
`; }).join(''); } // ========== 添加日志 ========== function addLog(level, message, threadId = null, timestamp = null, save = true) { const logEntry = { timestamp: timestamp || new Date().toISOString(), level, message, thread_id: threadId }; state.logs.push(logEntry); // 限制日志数量 if (state.logs.length > 1000) { state.logs = state.logs.slice(-1000); } renderLogs(); } // ========== 渲染日志 ========== function renderLogs() { let logs = state.logs; // 过滤 if (state.logFilter !== 'all') { logs = logs.filter(log => log.level === state.logFilter); } elements.logContainer.innerHTML = logs.map(log => { const time = formatTime(log.timestamp); const thread = log.thread_id ? `[${log.thread_id}]` : ''; return `
${time} ${log.level} ${thread}${escapeHtml(log.message)}
`; }).join(''); // 自动滚动到底部 if (state.autoScrollLogs) { elements.logContainer.scrollTop = elements.logContainer.scrollHeight; } } // ========== 显示截图 ========== function showScreenshot(path) { elements.screenshotImage.src = path; elements.screenshotPanel.style.display = 'block'; addLog('WARNING', `错误截图已生成: ${path}`); } // ========== 更新按钮状态 ========== function updateButtons() { elements.startBtn.disabled = state.isRunning; elements.stopBtn.disabled = !state.isRunning; } // ========== 更新模式 UI ========== function updateModeUI() { if (state.mode === 'multi') { elements.threadControl.style.display = 'flex'; elements.threadMonitorPanel.style.display = 'block'; document.querySelector('.main-content').classList.add('with-threads'); } else { elements.threadControl.style.display = 'none'; elements.threadMonitorPanel.style.display = 'none'; document.querySelector('.main-content').classList.remove('with-threads'); } } // ========== 主题相关 ========== function loadTheme() { const savedTheme = localStorage.getItem('theme') || 'light'; if (savedTheme === 'dark') { document.body.classList.add('dark-theme'); elements.themeIcon.textContent = '☀️'; } } function toggleTheme() { document.body.classList.toggle('dark-theme'); const isDark = document.body.classList.contains('dark-theme'); elements.themeIcon.textContent = isDark ? '☀️' : '🌙'; localStorage.setItem('theme', isDark ? 'dark' : 'light'); } // ========== 工具函数 ========== function formatTime(isoString) { const date = new Date(isoString); return date.toLocaleTimeString('zh-CN', { hour12: false }); } function getStatusText(status) { const statusMap = { 'pending': '待处理', 'processing': '进行中', 'completed': '已完成', 'failed': '失败' }; return statusMap[status] || status; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // ========== 账号管理功能 ========== // 打开账号管理模态框 function openAccountModal() { elements.accountModal.classList.add('show'); loadAccountStatus(); renderModalUserList(); } // 关闭账号管理模态框 function closeAccountModal() { elements.accountModal.classList.remove('show'); } // 打开登录二维码模态框 async function openLoginQrModal() { elements.loginQrModal.classList.add('show'); elements.qrLoginStatus.textContent = '正在模拟登录...'; try { const response = await fetch('/api/login/qrcode', { method: 'POST' }); const data = await response.json(); if (response.ok) { // 显示登录成功信息 elements.qrcodeContainer.innerHTML = `
✅

${data.message}

登录状态已保存到本地

${data.expire_in_hours ? `

有效期:${data.expire_in_hours}小时

` : ''}
`; elements.qrLoginStatus.textContent = '登录成功!'; // 开始检查登录状态 checkLoginStatus(); // 3秒后自动关闭 setTimeout(() => { closeLoginQrModal(); closeAccountModal(); showToast('登录成功', 'success'); }, 3000); } else { elements.qrcodeContainer.innerHTML = '

登录失败

'; elements.qrLoginStatus.textContent = '登录失败'; } } catch (error) { elements.qrcodeContainer.innerHTML = '

网络错误

'; elements.qrLoginStatus.textContent = '网络错误'; } } // 关闭登录二维码模态框 function closeLoginQrModal() { elements.loginQrModal.classList.remove('show'); } // 检查登录状态 async function checkLoginStatus() { try { const response = await fetch('/api/login/status'); const data = await response.json(); if (data.logged_in) { elements.qrLoginStatus.textContent = '登录成功!'; setTimeout(() => { closeLoginQrModal(); closeAccountModal(); updateLoginStatus(true, data.login_time, data.expire_time); showToast('登录成功', 'success'); }, 1000); } else { // 继续检查 setTimeout(checkLoginStatus, 2000); } } catch (error) { // 网络错误,继续检查 setTimeout(checkLoginStatus, 2000); } } // 退出登录 async function logout() { try { const response = await fetch('/api/logout', { method: 'POST' }); const data = await response.json(); if (response.ok) { updateLoginStatus(false); showToast('已退出登录', 'success'); closeAccountModal(); } else { showToast('退出失败', 'error'); } } catch (error) { showToast('退出失败', 'error'); } } // 更新登录状态显示 function updateLoginStatus(isLoggedIn, loginTime = null, expireTime = null) { const indicator = elements.loginStatusIndicator; const statusText = elements.loginStatusText; const modalStatus = elements.modalLoginStatus; if (isLoggedIn) { indicator.className = 'status-indicator online'; statusText.textContent = '已登录'; modalStatus.textContent = '已登录'; modalStatus.style.color = 'var(--success-color)'; if (loginTime) { elements.loginTime.textContent = new Date(loginTime).toLocaleString('zh-CN'); } // 如果有到期时间,显示剩余时间 if (expireTime) { const expireDate = new Date(expireTime); const now = new Date(); const timeLeft = expireDate - now; if (timeLeft > 0) { const hours = Math.floor(timeLeft / (1000 * 60 * 60)); const minutes = Math.floor((timeLeft % (1000 * 60 * 60)) / (1000 * 60)); elements.loginTime.textContent = new Date(loginTime).toLocaleString('zh-CN'); elements.loginTime.innerHTML += `
(${hours}小时${minutes}分钟后过期)`; } } } else { indicator.className = 'status-indicator offline'; statusText.textContent = '未登录'; modalStatus.textContent = '未登录'; modalStatus.style.color = 'var(--error-color)'; elements.loginTime.textContent = '-'; } } // 加载账号状态 async function loadAccountStatus() { try { const response = await fetch('/api/login/status'); const data = await response.json(); updateLoginStatus(data.logged_in, data.login_time, data.expire_time); } catch (error) { updateLoginStatus(false); } } // 刷新账号列表 async function refreshAccounts() { try { const response = await fetch('/api/users'); const data = await response.json(); renderUserList(data.users); renderModalUserList(); showToast('账号列表已刷新', 'success'); } catch (error) { showToast('刷新失败', 'error'); } } // 渲染模态框中的用户列表 function renderModalUserList() { // 先从主用户列表获取数据 const userItems = elements.userList.querySelectorAll('.user-item'); const users = Array.from(userItems).map(item => ({ id: item.dataset.userId, note_count: item.querySelector('.user-meta')?.textContent.match(/已采集 (\d+) 条笔记/)?.[1] || 0 })); elements.modalUserCount.textContent = users.length; if (users.length === 0) { elements.modalUserList.innerHTML = `

暂无用户数据

请在 data/users.txt 中添加用户 ID

`; return; } elements.modalUserList.innerHTML = users.map(user => ` `).join(''); } // 添加用户ID async function addUserId() { const input = elements.newUserIdInput; const userId = input.value.trim(); if (!userId) { showToast('请输入用户ID', 'warning'); return; } // 验证用户ID格式(简单验证) if (!/^[a-f0-9]{24}$/i.test(userId)) { showToast('用户ID格式不正确', 'warning'); return; } try { const response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: userId }) }); const data = await response.json(); if (response.ok) { input.value = ''; renderModalUserList(); showToast('用户添加成功', 'success'); } else { showToast(data.detail || '添加失败', 'error'); } } catch (error) { showToast('添加失败', 'error'); } } // 删除单个用户 async function removeUser(userId) { try { const response = await fetch(`/api/users/${userId}`, { method: 'DELETE' }); const data = await response.json(); if (response.ok) { renderModalUserList(); showToast('用户已删除', 'success'); } else { showToast(data.detail || '删除失败', 'error'); } } catch (error) { showToast('删除失败', 'error'); } } // 清空所有用户 async function clearAllUsers() { if (!confirm('确定要清空所有用户吗?此操作不可恢复!')) { return; } try { const response = await fetch('/api/users', { method: 'DELETE' }); const data = await response.json(); if (response.ok) { renderUserList([]); renderModalUserList(); showToast('所有用户已清空', 'success'); } else { showToast(data.detail || '清空失败', 'error'); } } catch (error) { showToast('清空失败', 'error'); } } // 从txt文件导入用户 async function importFromTxt() { try { // 在实际应用中,这里可以添加文件上传功能 // 这里先模拟从users.txt读取 const response = await fetch('/api/users/import/txt'); const data = await response.json(); if (response.ok) { renderUserList(data.users); renderModalUserList(); showToast(`成功导入 ${data.count} 个用户`, 'success'); } else { showToast(data.detail || '导入失败', 'error'); } } catch (error) { showToast('导入失败', 'error'); } } // 导出到txt文件 async function exportToTxt() { try { window.location.href = '/api/users/export/txt'; showToast('正在导出用户列表...', 'success'); } catch (error) { showToast('导出失败', 'error'); } } // 显示Toast提示 function showToast(message, type = 'info') { const toast = elements.toast; toast.textContent = message; toast.className = `toast ${type}`; toast.classList.add('show'); setTimeout(() => { toast.classList.remove('show'); }, 3000); } // ========== 启动 ========== document.addEventListener('DOMContentLoaded', init);