// ==UserScript== // @name 山西农大教务评教自动化 // @namespace sxau-xspj-autopilot // @version 1.2 // @description 自动完成山西农业大学教务系统的学生评教:差异化打分 + 主观题 + 自动提交 + 进度持久化 + 悬浮控制面板 // @author Dan-Lin616 // @match http://jwglxt.sxau.edu.cn/* // @match https://jwglxt.sxau.edu.cn/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== /* * ========== 使用说明 ========== * 1. 把脚本保存成 .user.js,拖进 Tampermonkey(油猴)/ ScriptCat(脚本猫) 安装 * 2. 浏览器登录 https://jwglxt.sxau.edu.cn/jsxsd/ * 3. 进入「教学评价 → 学生评价」(首次需手动展开本期评教活动) * 4. 列表页加载后,悬浮面板会自动出现,点击「开始」即可 * 5. 脚本会:扫待评课程 → 自动跳第一条 → 填表 → 提交 → 回列表 → 跳下一条 * 6. 主观评价话术在 CONFIG 里改;想暂停就点「急停」 * * ⚠️ 关键规则(脚本已内置遵守): * "不得将所有指标都评为相同等级,否则无法完成评教" * → 策略:9 项选「优秀」+ 1 项(随机)选「良好」 * * 跨页面状态存在 GM_setValue,崩了重开会自动续跑 * ============================== */ (() => { 'use strict'; // ============= 可调参数 ============= const CONFIG = { // 主观评价话术池(每门课随机抽一句) SUBJECTIVE_POOL: [ '老师认真负责', '老师备课充分,重点突出,受益匪浅', '老师授课条理清晰,课堂氛围活跃', '老师注重启发式教学,引导我们思考', '老师严格要求又关怀学生,教学效果显著' ], // 评教选项:每条 [text, weight_label] // 这里的 value 不重要(脚本按 id 后缀 _1/_2/_3/_4/_5 选) OPTIONS: [ { idx: 1, label: '优秀' }, { idx: 2, label: '良好' }, { idx: 3, label: '中等' }, { idx: 4, label: '合格' }, { idx: 5, label: '差' } ], // 默认全选 OPTION 的 idx(1=优秀) DEFAULT_LEVEL: 1, // 随机把哪一项降到下一档(每门课随机一项 idx=2 = 良好) // 这样保证:全 9 优 + 1 良 ≠ 全同,过"请不要选相同一项"校验 DIFFER_INDEX: 2, // 操作间隔(毫秒):表单填好后,提交前等一下 ACTION_DELAY: () => 1500 + Math.random() * 1500, // 1.5~3.0s // 跨页面跳转后,等下页面完全加载再开干的额外等待 NAV_DELAY: () => 2000 + Math.random() * 2000 // 2.0~4.0s }; // ============= 工具函数 ============= const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const rand = (n) => Math.floor(Math.random() * n); const pick = (arr) => arr[rand(arr.length)]; const STATE_KEY = 'sxau_xspj_state'; const loadState = () => { try { const raw = GM_getValue(STATE_KEY, null); return raw ? JSON.parse(raw) : null; } catch (e) { console.warn('[SXAU] loadState parse error', e); return null; } }; const saveState = (s) => { try { GM_setValue(STATE_KEY, JSON.stringify(s)); } catch (e) { console.error('[SXAU] saveState error', e); } }; const clearState = () => GM_deleteValue(STATE_KEY); const log = (...args) => console.log('%c[SXAU评教]', 'color:#0a8;color:bold', ...args); // ============= 悬浮控制面板 ============= GM_addStyle(` #sxau-panel { position: fixed; right: 16px; bottom: 16px; z-index: 999999; background: #fff; color: #333; border: 1px solid #e4e7eb; border-radius: 8px; padding: 12px 14px; min-width: 220px; max-width: 300px; font: 12px/1.5 -apple-system, "Microsoft YaHei", sans-serif; box-shadow: 0 2px 12px rgba(0,0,0,0.08); } #sxau-panel.dragging { user-select: none; cursor: grabbing; box-shadow: 0 8px 24px rgba(0,0,0,0.15); } #sxau-panel h4 { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: #222; cursor: grab; padding: 2px 0; user-select: none; } #sxau-panel h4:active { cursor: grabbing; } #sxau-panel .row { display: flex; gap: 6px; margin: 8px 0 0; flex-wrap: wrap; } #sxau-panel button { background: #f5f6f7; color: #333; border: 1px solid #dcdfe6; border-radius: 4px; padding: 5px 10px; cursor: pointer; font-size: 12px; transition: all .15s; } #sxau-panel button:hover { background: #ecf5ff; color: #409eff; border-color: #c6e2ff; } #sxau-panel button.primary { background: #409eff; color: #fff; border-color: #409eff; } #sxau-panel button.primary:hover { background: #66b1ff; border-color: #66b1ff; color: #fff; } #sxau-panel button.danger { color: #f56c6c; border-color: #fbc4c4; background: #fef0f0; } #sxau-panel button.danger:hover { background: #f78989; color: #fff; border-color: #f78989; } #sxau-panel .progress { font-size: 12px; color: #666; margin: 4px 0; } #sxau-panel .progress b { color: #409eff; font-weight: 600; } #sxau-panel .current { font-size: 12px; color: #333; margin: 4px 0; word-break: break-all; } #sxau-panel .status { font-size: 11px; color: #999; margin-top: 4px; } `); // ============= 面板拖拽 ============= const enableDrag = (panel) => { const handle = panel.querySelector('h4'); if (!handle) return; let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0; handle.addEventListener('mousedown', (e) => { // 点标题区域才能拖(避免误触按钮) if (e.target.tagName === 'BUTTON') return; dragging = true; panel.classList.add('dragging'); const rect = panel.getBoundingClientRect(); // 切换到 left/top 定位,便于拖拽 panel.style.left = rect.left + 'px'; panel.style.top = rect.top + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; startX = e.clientX; startY = e.clientY; startLeft = rect.left; startTop = rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; let nx = startLeft + dx; let ny = startTop + dy; // 限制在视口内 const maxX = window.innerWidth - panel.offsetWidth; const maxY = window.innerHeight - panel.offsetHeight; nx = Math.max(0, Math.min(nx, maxX)); ny = Math.max(0, Math.min(ny, maxY)); panel.style.left = nx + 'px'; panel.style.top = ny + 'px'; }); document.addEventListener('mouseup', () => { if (!dragging) return; dragging = false; panel.classList.remove('dragging'); }); }; const renderPanel = (state) => { const old = document.getElementById('sxau-panel'); if (old) old.remove(); const total = state?.total ?? 0; const done = state?.done ?? 0; const cur = state?.current || '—'; const div = document.createElement('div'); div.id = 'sxau-panel'; div.innerHTML = `

评教助手

进度 ${done} / ${total}
${cur}
${state?.phase || '空闲'}
`; document.body.appendChild(div); document.getElementById('sxau-start').onclick = () => bootstrap(true); document.getElementById('sxau-stop').onclick = () => { const s = loadState() || {}; s.stop = true; s.phase = '已急停'; saveState(s); setStatus('已急停(点「开始」恢复)'); }; document.getElementById('sxau-reset').onclick = () => { if (confirm('确定要清空所有进度?下次将从列表页重新开始扫描。')) { clearState(); location.reload(); } }; document.getElementById('sxau-dry').onclick = () => { const s = loadState() || {}; s.dryRun = true; s.stop = false; saveState(s); bootstrap(true); }; enableDrag(div); }; const setStatus = (msg) => { const el = document.getElementById('sxau-status'); if (el) el.textContent = '状态:' + msg; log(msg); }; // ============= 路由 ============= const ROUTES = { isList: () => location.pathname.includes('/xspj/xspj_list.do'), isEdit: () => location.pathname.includes('/xspj/xspj_edit.do'), isSave: () => location.pathname.includes('/xspj/xspj_save.do') }; // ============= 入口:开始 / 续跑 ============= const bootstrap = (userTriggered = false) => { const s = loadState() || {}; s.stop = false; if (!s.dryRun) s.dryRun = false; if (ROUTES.isList()) { // 列表页:扫待评课程,构建队列,跳第一条 const queue = scanUnfinishedCourses(); if (queue.length === 0) { if (userTriggered) alert('本学期没有待评课程了 🎉'); s.phase = '全部完成'; s.current = '(无)'; saveState(s); renderPanel(s); return; } s.queue = queue; s.idx = 0; s.total = queue.length; s.done = s.done || 0; s.phase = '待跳首页'; saveState(s); renderPanel(s); jumpNext(s); } else if (ROUTES.isEdit()) { // 编辑页:填表 → 提交 s.phase = '填表中'; s.current = decodeURIComponent(new URL(location.href).searchParams.get('jx0404id') || '') + ' · ' + (document.querySelector('table.layui-table th')?.innerText?.match(/课程名称:([^\s]+)/)?.[1] || ''); saveState(s); renderPanel(s); fillAndSubmit(); } else { log('当前不在评教页面。请进入「教学评价 → 学生评价」列表页。'); } }; // ============= 列表页:扫未评课程 ============= const scanUnfinishedCourses = () => { // 未评链接的特征:href 含 xspj_edit.do 且不含 type=view // 行内的隐藏 input issavestr == "否" const links = Array.from(document.querySelectorAll('a[href*="xspj_edit.do"]')); const list = []; for (const a of links) { const href = a.getAttribute('href') || ''; if (href.includes('type=view')) continue; // 已评 if (a.textContent.trim() !== '评价') continue; // 操作列文本必须为"评价" // 反查行:找同一行内的 issavestr 是否为"否" let tr = a.closest('tr'); if (!tr) continue; const savedFlag = tr.querySelector('input[name="issavestr"]')?.value; if (savedFlag === '是') continue; // 提取课程名/教师,便于日志 const tds = tr.querySelectorAll('td'); const courseName = tds[2]?.innerText.trim() || '?'; const teacher = tds[3]?.innerText.trim() || '?'; list.push({ href, courseName, teacher }); } log(`扫到 ${list.length} 门待评课程`); return list; }; const jumpNext = async (s) => { if (s.stop) { setStatus('已急停'); return; } if (s.idx >= s.queue.length) { setStatus('✅ 全部完成!'); s.phase = '全部完成'; saveState(s); renderPanel(s); return; } const item = s.queue[s.idx]; s.current = `${item.courseName} · ${item.teacher}`; s.phase = `跳转中 ${s.idx + 1}/${s.queue.length}`; saveState(s); renderPanel(s); await sleep(CONFIG.NAV_DELAY()); if (s.stop) return; log('跳转:', item.href); location.href = item.href; }; // ============= 编辑页:填表 + 提交 ============= const fillAndSubmit = async () => { // 只读查看页(type=view)跳过 if (location.search.includes('type=view')) { setStatus('这是只读查看页,跳过'); return; } await sleep(CONFIG.ACTION_DELAY()); const s = loadState() || {}; if (s.stop) { setStatus('已急停'); return; } // 1) 收集所有指标序号(按 DOM 出现顺序) const xhs = Array.from(document.querySelectorAll('input[name="pj06xh"]')).map(el => el.value); if (xhs.length === 0) { setStatus('⚠️ 未找到 pj06xh,可能表单已提交或结构变化'); return; } // 2) 默认全部优秀,再随机挑一项降为良好(满足差异化校验) xhs.forEach(xh => { const radio = document.getElementById(`pj0601id_${xh}_${CONFIG.DEFAULT_LEVEL}`); if (radio && !radio.checked) { radio.checked = true; radio.dispatchEvent(new Event('click', { bubbles: true })); radio.dispatchEvent(new Event('change', { bubbles: true })); } }); const diffIdx = xhs[rand(xhs.length)]; if (diffIdx !== undefined) { const radio = document.getElementById(`pj0601id_${diffIdx}_${CONFIG.DIFFER_INDEX}`); if (radio) { radio.checked = true; radio.dispatchEvent(new Event('click', { bubbles: true })); radio.dispatchEvent(new Event('change', { bubbles: true })); } log(`差异化:第 ${diffIdx} 项选「良好」`); } // 3) 填主观题 const ta = document.querySelector('textarea[name="jynr"]'); if (ta) { ta.value = pick(CONFIG.SUBJECTIVE_POOL); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } else { log('⚠️ 未找到 jynr 主观题框'); } // 4) 客户端校验重做:保证不是全同(理论上不会触发,兜底) const checked = xhs.map(xh => { const all = Array.from(document.querySelectorAll(`input[name="pj0601id_${xh}"]`)); const c = all.find(r => r.checked); return c ? c.id.split('_').pop() : null; }); const distinct = new Set(checked).size; if (distinct === 1) { log('⚠️ 校验:全同,强制再降一项'); const k = rand(xhs.length); const r = document.getElementById(`pj0601id_${xhs[k]}_${CONFIG.DIFFER_INDEX}`); if (r) { r.checked = true; r.dispatchEvent(new Event('click', { bubbles: true })); } } // 5) 提交 const issubmit = document.getElementById('issubmit'); if (issubmit) issubmit.value = '1'; const form = document.getElementById('Form1'); if (!form) { setStatus('⚠️ 未找到 Form1,无法提交'); return; } if (s.dryRun) { setStatus('【试跑】已填表,未提交。请点「重置」恢复。'); log('Dry run 模式:跳过提交'); return; } s.phase = '提交中'; saveState(s); renderPanel(s); // 用 fetch 代替 form.submit(),避免 ifrmHidden 加载服务端响应触发 alert // 响应 HTML 不会被执行(fetch 只取文本),无人值守 const submitAsync = async () => { try { const fd = new FormData(form); const body = new URLSearchParams(); for (const [k, v] of fd.entries()) body.append(k, v); const resp = await fetch(form.action, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString(), credentials: 'same-origin', redirect: 'follow' }); log(`提交完成,HTTP ${resp.status}`); } catch (e) { log('提交异常:' + e.message); } // 跳回列表 const listUrl = s.listUrl || ( location.origin + '/jsxsd/xspj/xspj_list.do?' + new URLSearchParams({ pj01id: document.getElementById('pj01id')?.value || '', pj0502id: document.getElementById('pj0502id')?.value || '', xnxq01id: document.getElementById('xnxq01id')?.value || '', xstskz: '1', ifypjxx: '0', dqpageIndex: '1' }).toString() ); s.listUrl = listUrl; s.idx = (s.idx || 0) + 1; s.done = (s.done || 0) + 1; saveState(s); renderPanel(s); setStatus('已提交,回列表'); setTimeout(() => location.href = listUrl, CONFIG.ACTION_DELAY()); }; await submitAsync(); }; // ============= 页面进入时:根据路由做事 ============= const init = () => { // 拦截系统/服务端的弹窗,让流程无人值守继续 // 原因:xspj_save.do 的响应里嵌了 alert('提交成功'),会卡住自动化 window.alert = (msg) => { log('alert 拦截:' + msg); }; window.confirm = () => { log('confirm 拦截:自动确认'); return true; }; // 非评教页面(list/edit/save)一律静默退出,不在无关网页弹面板 if (!ROUTES.isList() && !ROUTES.isEdit() && !ROUTES.isSave()) { return; } const s = loadState(); renderPanel(s); if (ROUTES.isList()) { if (!s) { setStatus('空闲,点「开始」扫描'); return; } s.listUrl = location.href; saveState(s); renderPanel(s); // 续跑:有队列、没急停、还有剩余课程 if (s.queue && !s.stop && s.idx < s.queue.length) { setStatus('自动续跑中…'); setTimeout(() => jumpNext(s), CONFIG.NAV_DELAY()); } else if (s.queue && s.idx >= s.queue.length) { setStatus('✅ 全部完成!'); } } else if (ROUTES.isEdit()) { // 编辑页:有进行中的队列就自动填表提交 if (s && s.queue && !s.stop && s.idx < s.queue.length) { setStatus('自动填表中…'); setTimeout(() => fillAndSubmit(), CONFIG.ACTION_DELAY()); } } }; // 等待 DOM 就绪 if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();