// ==UserScript== // @name 中南林一键自动教评(全能稳定版) // @namespace https://github.com/jinnianliuxing/ // @version 2.2.0 // @description 智能自动评价:黑名单跳过出错课程、网络断连保护、随机拟人延迟、双端自适应 // @author IKUN-91-张 & AI助手 // @match https://jxzlpt.csuft.edu.cn/* // @match *://jxzlpt-443.webvpn.csuft.edu.cn/* // @match https://https-jxzlpt-csuft-edu-cn-443.webvpn.csuft.edu.cn/* // @icon https://raw.githubusercontent.com/jinnianliuxing/my-script-icons/refs/heads/main/IMG_202507232298_120x120.png // @grant none // @run-at document-end // ==/UserScript== (function() { 'use strict'; // ================= 配置区域 ================= const CONFIG = { VERSION: "3.0.0", // 每次操作的基础延迟 (毫秒) DELAY: { SCAN: 1500, // 扫描列表间隔 DIALOG_OPEN: 800, // 等待弹窗打开 FILL: 500, // 填表间隔 SUBMIT: 2000, // 填写完毕到点击提交的思考时间 CONFIRM: 3000 // 提交后等待结果的时间 }, // 随机波动范围 (0.5 表示波动 +/- 50%) JITTER: 0.5, // 最大重试次数 MAX_RETRIES: 3 }; // ================= 全局状态管理 ================= const STATE = { isRunning: false, isPaused: false, // 因网络等原因临时暂停 panelCreated: false, currentCourseId: null, failedCourseIds: new Set(), // 黑名单:存储出错的课程ID timers: [], // 集中管理定时器 retryCount: 0 }; // DOM 元素缓存 let UI = { panel: null, status: null, counter: null, btnStart: null, btnPause: null }; // ================= 工具函数 ================= // 设备检测 const isMobile = () => (window.innerWidth <= 768) || /Android|webOS|iPhone|iPad|iPod|BlackBerry/i.test(navigator.userAgent); // 拟人化随机延迟 const randomDelay = (baseTime) => { const jitter = baseTime * CONFIG.JITTER; const delay = baseTime + (Math.random() * jitter * 2 - jitter); // base +/- jitter return Math.max(200, Math.floor(delay)); // 至少 200ms }; // 集中定时器管理 (用于一键清除) const setSmartTimeout = (callback, delay) => { const timerId = setTimeout(() => { // 执行后从数组移除 STATE.timers = STATE.timers.filter(t => t !== timerId); callback(); }, delay); STATE.timers.push(timerId); return timerId; }; const clearAllTimers = () => { STATE.timers.forEach(t => clearTimeout(t)); STATE.timers = []; }; // 查找可见的对话框 (增强鲁棒性) const getVisibleDialog = () => { // 1. 优先找 ID const idDialog = document.getElementById('pjcz'); if (idDialog && window.getComputedStyle(idDialog).display !== 'none') { return idDialog; } // 2. 模糊查找 (适配可能的 ID 变更或框架差异) const dialogs = document.querySelectorAll('.el-dialog, .modal-content, div[role="dialog"]'); for (let d of dialogs) { if (window.getComputedStyle(d).display !== 'none' && d.innerText.includes('评价')) { return d; } } return null; }; // 关闭所有弹窗 const closeAllDialogs = () => { const closeBtns = document.querySelectorAll('.el-dialog__close, .close, button[aria-label="Close"]'); closeBtns.forEach(btn => btn.click()); // 兜底:如果有点不掉的遮罩 const dialog = getVisibleDialog(); if (dialog) { // 尝试找取消按钮 const cancelBtn = Array.from(dialog.querySelectorAll('button')).find(b => b.textContent.includes('取消')); if (cancelBtn) cancelBtn.click(); } }; // ================= 核心逻辑 ================= // 1. 扫描并点击评价 const scanAndEvaluate = () => { if (!STATE.isRunning || STATE.isPaused) return; // 获取所有评价按钮 const allBtns = Array.from(document.querySelectorAll('.btn_theme')); // 筛选出:未评价 且 不在黑名单中 的按钮 const targetBtn = allBtns.find(btn => { const row = btn.closest('tr'); // 健壮性检查:确保能找到行 if (!row) return false; const id = btn.getAttribute('data-id') || row.getAttribute('data-id'); const isEvaluated = !row.innerText.includes('未评价') && !btn.innerText.includes('评价'); // 如果已经在黑名单,或者已评价,则跳过 if (STATE.failedCourseIds.has(id) || isEvaluated) return false; return true; }); // 更新计数器 updateCounter(); if (!targetBtn) { const remaining = document.querySelectorAll('.wpj').length; const failedCount = STATE.failedCourseIds.size; if (failedCount > 0 && remaining > 0) { updateStatus(`扫描完成。跳过了 ${failedCount} 个异常课程。`, '#fff3e0'); stopProcess(false); } else if (remaining === 0) { updateStatus('🎉 所有课程评价完毕!', '#e8f5e9'); stopProcess(false); } else { // 还有未评价但没找到按钮?可能是翻页了或者 DOM 没加载完 updateStatus('未找到可点击按钮,等待重试...', '#fff3e0'); setSmartTimeout(scanAndEvaluate, 3000); } return; } // 记录当前操作的 ID const row = targetBtn.closest('tr'); STATE.currentCourseId = targetBtn.getAttribute('data-id') || row.getAttribute('data-id'); STATE.retryCount = 0; updateStatus(`正在打开课程 (ID: ${STATE.currentCourseId})...`, '#e3f2fd'); // 点击按钮 targetBtn.click(); // 进入等待弹窗阶段 checkDialogOpen(); }; // 2. 检测弹窗是否打开 const checkDialogOpen = (attempts = 0) => { if (!STATE.isRunning) return; const dialog = getVisibleDialog(); if (dialog) { updateStatus('弹窗已打开,准备填写...', '#e8f5e9'); setSmartTimeout(fillForm, randomDelay(CONFIG.DELAY.FILL)); } else { if (attempts >= 10) { // 约 5秒打不开 handleError('弹窗打开超时'); } else { setSmartTimeout(() => checkDialogOpen(attempts + 1), 500); } } }; // 3. 填写表单 const fillForm = () => { if (!STATE.isRunning) return; const dialog = getVisibleDialog(); if (!dialog) return handleError('填写时弹窗丢失'); // 查找输入框 const inputs = Array.from(dialog.querySelectorAll('input[type="text"], input[type="number"]')) .filter(input => !input.readOnly && input.offsetParent); // 确保可见且可写 if (inputs.length === 0) { // 可能是纯单选框,或者加载慢 // 这里简单处理:如果是空表单,可能是异常 return handleError('未找到评分项'); } // 随机选择一个打低分 (让数据真实) const randomLowIndex = Math.floor(Math.random() * inputs.length); inputs.forEach((input, index) => { // 尝试获取满分值 let maxScore = 10; // 默认 // 简单的上下文查找满分逻辑 (向后找文本节点) let nextNode = input.nextSibling; let lookAhead = 3; while(nextNode && lookAhead > 0) { if (nextNode.textContent && /\d+/.test(nextNode.textContent)) { const match = nextNode.textContent.match(/(\d+)/); if(match) maxScore = parseInt(match[1]); break; } nextNode = nextNode.nextSibling; lookAhead--; } // 设定分数 const score = (index === randomLowIndex && maxScore > 5) ? maxScore - 1 : maxScore; // 触发 React/Vue 的数据绑定 input.value = score; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); input.dispatchEvent(new Event('blur', { bubbles: true })); }); // 填写评语 const textarea = dialog.querySelector('textarea'); if (textarea) { const comments = [ '老师备课充分,授课重点突出,条理清晰。', '教学内容丰富,课堂气氛活跃,互动良好。', '讲解深入浅出,能有效引导学生思考。', '课程设计合理,理论联系实际,收获很大。', '老师治学严谨,对学生要求严格,负责任。' ]; const randomComment = comments[Math.floor(Math.random() * comments.length)]; textarea.value = randomComment; textarea.dispatchEvent(new Event('input', { bubbles: true })); } updateStatus('填写完毕,准备提交...', '#fff3e0'); setSmartTimeout(clickSubmit, randomDelay(CONFIG.DELAY.SUBMIT)); }; // 4. 点击提交 const clickSubmit = () => { if (!STATE.isRunning) return; const dialog = getVisibleDialog(); if (!dialog) return handleError('提交时弹窗丢失'); // 查找提交按钮 const btns = Array.from(dialog.querySelectorAll('button, .el-button')); const submitBtn = btns.find(b => { const txt = b.innerText.trim(); return txt === '提交' || txt === '确定' || txt === '保存'; }); if (!submitBtn) return handleError('未找到提交按钮'); updateStatus('正在提交...', '#e3f2fd'); submitBtn.click(); // 等待结果验证 setSmartTimeout(verifySubmission, randomDelay(CONFIG.DELAY.CONFIRM)); }; // 5. 验证提交结果 const verifySubmission = () => { if (!STATE.isRunning) return; // 检查是否有错误提示 const dialog = getVisibleDialog(); // Case A: 弹窗已经消失 -> 成功 if (!dialog) { updateStatus('提交成功!准备下一个...', '#e8f5e9'); setSmartTimeout(scanAndEvaluate, randomDelay(CONFIG.DELAY.SCAN)); return; } // Case B: 弹窗还在,检查是否有错误信息 const errorEl = dialog.querySelector('.el-form-item__error, .error-msg'); const textContent = dialog.innerText; if (errorEl || textContent.includes('失败') || textContent.includes('错误') || textContent.includes('必填')) { // 发生验证错误 handleError(`提交验证失败: ${errorEl ? errorEl.innerText : '未知错误'}`); } else { // 可能是网速慢卡住了,或者系统还没反应 // 强制关闭并尝试下一个,避免死等 updateStatus('提交响应超时,强制跳过', '#ffebee'); closeAllDialogs(); setSmartTimeout(scanAndEvaluate, randomDelay(CONFIG.DELAY.SCAN)); } }; // ================= 错误处理 ================= const handleError = (reason) => { console.warn(`[教评脚本] 课程 ${STATE.currentCourseId} 出错: ${reason}`); STATE.retryCount++; // 如果重试次数未超标,尝试重新填写 (针对网络波动) if (STATE.retryCount <= 1 && reason.includes('超时')) { updateStatus(`操作超时,正在重试...`, '#fff8e1'); setSmartTimeout(fillForm, 1000); // 重试从填表开始 return; } // 超过重试次数,加入黑名单 if (STATE.currentCourseId) { STATE.failedCourseIds.add(STATE.currentCourseId); } updateStatus(`课程出错 (${reason}),已加入黑名单跳过`, '#ffcdd2'); closeAllDialogs(); // 稍作休息后继续 setSmartTimeout(scanAndEvaluate, 2000); }; // ================= 网络监控 ================= const handleOffline = () => { if (STATE.isRunning) { STATE.isRunning = false; STATE.isPaused = true; clearAllTimers(); updateStatus('⚠ 网络断开!脚本已紧急暂停保护现场', '#ff5252', true); if (UI.btnStart) UI.btnStart.innerText = '▶ 恢复网络后点此继续'; } }; const handleOnline = () => { if (STATE.isPaused) { updateStatus('✔ 网络已恢复,请点击启动按钮继续', '#b9f6ca', true); } }; // ================= UI 界面构建 ================= const createPanel = () => { if (STATE.panelCreated) return; STATE.panelCreated = true; // 1. 面板容器 const panel = document.createElement('div'); panel.style.cssText = ` position: fixed; top: 50px; right: 30px; width: ${isMobile() ? '90%' : '300px'}; background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; border: 1px solid #e0e0e0; transition: all 0.3s ease; `; if (isMobile()) { panel.style.left = '5%'; panel.style.right = 'auto'; } // 2. 标题栏 (可拖动) const header = document.createElement('div'); header.innerText = `智能教评 v${CONFIG.VERSION}`; header.style.cssText = ` background: #2196F3; color: white; padding: 12px; border-radius: 8px 8px 0 0; font-weight: bold; cursor: move; text-align: center; user-select: none; `; panel.appendChild(header); // 3. 内容区 const content = document.createElement('div'); content.style.padding = '15px'; // 状态栏 const statusBox = document.createElement('div'); statusBox.id = 'csuft-status'; statusBox.innerText = '准备就绪'; statusBox.style.cssText = ` padding: 10px; background: #f5f5f5; border-radius: 4px; margin-bottom: 10px; font-size: 13px; line-height: 1.4; color: #333; border-left: 4px solid #2196F3; `; UI.status = statusBox; content.appendChild(statusBox); // 计数器 const counterBox = document.createElement('div'); counterBox.style.fontSize = '12px'; counterBox.style.marginBottom = '15px'; counterBox.style.color = '#666'; counterBox.style.display = 'flex'; counterBox.style.justifyContent = 'space-between'; counterBox.innerHTML = ` 待评: 0 已跳过: 0 `; UI.counter = counterBox; content.appendChild(counterBox); // 按钮组 const btnGroup = document.createElement('div'); btnGroup.style.display = 'flex'; btnGroup.style.gap = '10px'; const btnBaseStyle = ` flex: 1; padding: 10px 0; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; color: white; transition: opacity 0.2s; `; const btnStart = document.createElement('button'); btnStart.innerText = '▶ 开始评价'; btnStart.style.cssText = btnBaseStyle + 'background: #4CAF50;'; btnStart.onclick = startProcess; UI.btnStart = btnStart; const btnStop = document.createElement('button'); btnStop.innerText = '⏹ 暂停'; btnStop.style.cssText = btnBaseStyle + 'background: #f44336;'; btnStop.onclick = () => stopProcess(true); UI.btnPause = btnStop; btnGroup.appendChild(btnStart); btnGroup.appendChild(btnStop); content.appendChild(btnGroup); panel.appendChild(content); document.body.appendChild(panel); UI.panel = panel; // 拖拽逻辑 let isDragging = false; let startX, startY, initialLeft, initialTop; header.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const rect = panel.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; panel.style.cursor = 'grabbing'; }); header.addEventListener('touchstart', (e) => { isDragging = true; const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; const rect = panel.getBoundingClientRect(); initialLeft = rect.left; initialTop = rect.top; }, {passive: false}); const onMove = (clientX, clientY) => { if (!isDragging) return; const dx = clientX - startX; const dy = clientY - startY; panel.style.left = `${initialLeft + dx}px`; panel.style.top = `${initialTop + dy}px`; panel.style.right = 'auto'; // 清除 right 属性以允许自由移动 }; document.addEventListener('mousemove', e => onMove(e.clientX, e.clientY)); document.addEventListener('touchmove', e => { if(isDragging) e.preventDefault(); // 防止滚动 const touch = e.touches[0]; onMove(touch.clientX, touch.clientY); }, {passive: false}); const onEnd = () => { isDragging = false; if(panel) panel.style.cursor = 'default'; }; document.addEventListener('mouseup', onEnd); document.addEventListener('touchend', onEnd); // 初始化计数 updateCounter(); } const updateStatus = (text, bgColor, isWhiteText = false) => { if (UI.status) { UI.status.innerText = text; UI.status.style.background = bgColor; UI.status.style.color = isWhiteText ? 'white' : '#333'; UI.status.style.borderLeftColor = isWhiteText ? 'white' : '#2196F3'; } }; const updateCounter = () => { const wait = document.querySelectorAll('.wpj').length; // 假设未评价有这个类,或者需根据实际情况调整 // 如果页面结构没有 .wpj,用按钮数量估算 const btns = document.querySelectorAll('.btn_theme').length; if (UI.counter) { const waitEl = UI.counter.querySelector('#count-wait'); const failEl = UI.counter.querySelector('#count-fail'); if (waitEl) waitEl.innerText = wait || btns; // 粗略统计 if (failEl) failEl.innerText = STATE.failedCourseIds.size; } }; // ================= 流程控制 ================= const startProcess = () => { if (!navigator.onLine) { alert('当前无网络连接,无法启动!'); return; } if (STATE.isRunning) return; STATE.isRunning = true; STATE.isPaused = false; UI.btnStart.style.opacity = '0.5'; UI.btnStart.innerText = '运行中...'; updateStatus('正在启动扫描程序...', '#e3f2fd'); scanAndEvaluate(); }; const stopProcess = (manual = false) => { STATE.isRunning = false; clearAllTimers(); UI.btnStart.style.opacity = '1'; UI.btnStart.innerText = '▶ 继续评价'; if (manual) { updateStatus('已暂停。点击“继续评价”恢复。', '#fff3e0'); } }; // ================= 初始化入口 ================= const init = () => { // 仅在评价页面运行 if (!window.location.href.includes('/dfpj')) return; console.log(`[教评脚本] v${CONFIG.VERSION} 已加载`); // 监听网络状态 window.addEventListener('online', handleOnline); window.addEventListener('offline', handleOffline); // 监听 DOM 变化以自动注入面板 (防止单页应用路由跳转后面板消失) const observer = new MutationObserver(() => { if (document.querySelector('.btn_theme') && !STATE.panelCreated) { createPanel(); } }); observer.observe(document.body, { childList: true, subtree: true }); // 初始尝试 if (document.querySelector('.btn_theme')) { createPanel(); } }; // 启动 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();