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