/** * 小红书采集器前端逻辑 */ // ========== 状态管理 ========== 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 }; // ========== 初始化 ========== 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'); // 加载主题 loadTheme(); // 加载初始数据 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; }); } // ========== 加载初始数据 ========== 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); }; } // ========== 启动 ========== document.addEventListener('DOMContentLoaded', init);