// ==UserScript== // @name 武汉理工大学心理测评自动答题 // @name:en WHUT Psychology Test Auto Answer // @namespace https://github.com/fieldlu/whut-psy-test // @version 1.0.0 // @description 武汉理工大学心理测评自动答题助手,支持答题策略切换与连续答题 // @author fieldlu // @license MIT // @homepageURL https://github.com/fieldlu/whut-psy-test // @supportURL https://github.com/fieldlu/whut-psy-test/issues // @updateURL https://raw.githubusercontent.com/fieldlu/whut-psy-test/main/whut-psy-test.user.js // @downloadURL https://raw.githubusercontent.com/fieldlu/whut-psy-test/main/whut-psy-test.user.js // @match https://xlt.whut.edu.cn/stu/* // @icon https://xlt.whut.edu.cn/stu/favicon.ico // @grant GM_addStyle // @grant GM_registerMenuCommand // @grant GM_notification // @run-at document-end // ==/UserScript== /** * ============================================================ * 武汉理工大学心理测评自动答题助手 * ============================================================ * * 功能简介: * 1. 自动识别页面中的 Vant 单选题目,按指定策略选择选项; * 2. 自动点击“同意”“开始测评”“确认”“重新答题”等按钮; * 3. 支持“答当前题”和“连续答题”两种模式; * 4. 可随时切换答题策略(中立 / 积极 / 消极 / 随机)。 * * 使用方式: * 安装脚本后打开 https://xlt.whut.edu.cn/stu/* 页面, * 页面右上角会出现悬浮控制面板,按需点击按钮即可。 * * 免责声明: * 本脚本仅供学习交流使用,请遵守学校相关规定。 * 心理测评结果可能影响学校判断,建议尽量按真实感受作答。 * 使用本脚本产生的一切后果由使用者自行承担。 * * 来源说明: * 此脚本可能由多人修改或整理,若您是原作者且不希望被分享, * 请联系删除;若希望署名,也请联系补充。 * ============================================================ */ (function() { 'use strict'; console.log('[心理测评] v1.0 加载'); // ==================== 配置 ==================== const CONFIG = { api: 'https://xlt.whut.edu.cn/xytr-api', secret: 'UHgyffFWSwuLq5Cs', watchInterval: 800, // 连续答题轮询间隔 debounceDelay: 250, // MutationObserver 防抖 }; // ==================== 状态 ==================== let _running = false; // 总开关(答当前题 / 连续答题时开启) let _continuous = false; // 连续模式 let _answered = 0; let _watchTimer = null; let _suppressAuto = false; // 答题期间抑制 autoConsent // ==================== 工具 ==================== function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function updateStatus(s) { const el = document.querySelector('.w-panel .s'); if (el) el.textContent = s; } // ==================== 签名 ==================== async function sha256(str) { const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); return Array.from(new Uint8Array(b)).map(x => x.toString(16).padStart(2, '0')).join(''); } function sortKeys(o) { if (!o || typeof o !== 'object') return o; if (Array.isArray(o)) return o.map(sortKeys); return Object.keys(o).sort().reduce((r, k) => { r[k] = sortKeys(o[k]); return r; }, {}); } async function genSign(data) { return sha256(JSON.stringify(sortKeys(data)) + CONFIG.secret); } async function apiPost(url, data) { const ts = Math.floor(Date.now() / 1000); const sign = await genSign(data); const token = localStorage.getItem('wxToken') || ''; const jsession = localStorage.getItem('JSESSIONID') || ''; return fetch(CONFIG.api + url, { method: 'POST', headers: { 'Content-Type': 'application/json;charset=UTF-8', 'Authorization': 'Bearer ' + token, 'timestamp': '' + ts, 'sign': sign, 'JSESSIONID': jsession, }, body: JSON.stringify(data), credentials: 'include' }).then(r => r.json()).catch(() => ({})); } // ==================== 策略选择 ==================== function pickByStrategy(opts, strategy) { if (!opts || !opts.length) { console.warn('[WHUT] pickByStrategy: options为空'); return null; } let idx; switch(strategy) { case 'first': idx = 0; break; case 'last': idx = opts.length - 1; break; case 'middle': idx = Math.floor((opts.length - 1) / 2); break; case 'random': idx = Math.floor(Math.random() * opts.length); break; default: idx = Math.floor((opts.length - 1) / 2); break; } return opts[idx] || null; } function getStrategy() { const sel = document.getElementById('w-sel'); return sel ? sel.value : 'middle'; } // ==================== 网络拦截: 捕获题目数据 ==================== let _loadedTopics = null; function setupInterceptor() { // 匹配所有可能的 API URL 模式(题目加载和提交) const API_PATTERN = /xytr-api|\/topic|\/instruction|psychology/i; // XHR 拦截 const _open = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(m, u) { this._url = u; return _open.apply(this, arguments); }; const _send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function(b) { this.addEventListener('load', function() { try { const url = this._url || ''; if (API_PATTERN.test(url) && this.responseText) { const d = JSON.parse(this.responseText); if (tryCaptureTopics(d, url, 'XHR')) return; } } catch(e) {} }); return _send.apply(this, arguments); }; // Fetch 拦截 const _fetch = window.fetch; window.fetch = function(u, opts) { return _fetch.apply(this, arguments).then(r => { const url = (typeof u === 'string' ? u : u.url) || ''; if (API_PATTERN.test(url)) { r.clone().text().then(t => { try { tryCaptureTopics(JSON.parse(t), url, 'Fetch'); } catch(e) {} }); } return r; }); }; } function tryCaptureTopics(d, url, source) { if (!d || typeof d !== 'object') return false; const candidates = [d.data, d.rows, d.records, d.result, d.list, d.items, d]; for (const c of candidates) { let list = c; if (list && list.records) list = list.records; if (Array.isArray(list) && list.length > 0) { const first = list[0]; if (first.topicId || first.id || first.questionId || first.options || first.optionList) { _loadedTopics = list; console.log('[WHUT]', source, '捕获', list.length, '题:', url.slice(-50)); updateStatus('已加载 ' + list.length + ' 题'); return true; } } } return false; } // ==================== API 模式(首选)=========================== async function answerViaAPI(topics, strategy) { console.log('[WHUT] API模式 策略:', strategy, '题目:', topics.length); if (topics.length > 0) { const t0 = topics[0]; console.log('[WHUT] 首题字段:', Object.keys(t0).join(',')); const opts0 = t0.options || t0.optionList || t0.answerList || t0.items || []; console.log('[WHUT] 首题选项类型:', typeof opts0, Array.isArray(opts0) ? '数组' : (typeof opts0 === 'object' ? '对象' : typeof opts0), '选项数:', opts0?.length ?? Object.keys(opts0 || {}).length); } const answers = []; for (const t of topics) { const tid = t.topicId || t.id || t.questionId; let opts = t.options || t.optionList || t.answerList || t.items || []; if (!Array.isArray(opts) && typeof opts === 'object') opts = Object.values(opts); if (!tid || !opts || opts.length === 0) continue; const pick = pickByStrategy(opts, strategy); if (!pick) continue; answers.push({ topicId: tid, optionId: pick.optionId || pick.id || '', optionCode: pick.optionCode || pick.code || pick.optionId || pick.id || '', optionValue: pick.optionValue || pick.value || pick.content || pick.label || '', sort: t.sort || t.orderNum || 0, formId: t.formId || t.checkFormId || '', pformId: t.pformId || '' }); } if (answers.length === 0) { console.warn('[WHUT] API: 0个有效答案'); return 0; } console.log('[WHUT] API首条答案:', JSON.stringify(answers[0])); updateStatus('API提交 ' + answers.length + ' 题...'); // 批量提交 const r = await apiPost('/studentInfo/submitAnswerBather', answers); if (r.code === 200 || r.code === 0) { const formId = answers[0].formId; if (formId) apiPost('/psychologyPersonalTest/psyFromAddTest', { formId }).catch(() => {}); GM_notification({ text: '提交 ' + answers.length + ' 题', timeout: 2000 }); return answers.length; } // 逐个提交 let ok = 0; for (let i = 0; i < answers.length; i++) { const rr = await apiPost('/psychologyPersonalTest/submitAnswer', answers[i]); if (rr.code === 200 || rr.code === 0) ok++; updateStatus('提交 ' + (i + 1) + '/' + answers.length); await sleep(50); } if (ok > 0) { if (answers[0].formId) apiPost('/psychologyPersonalTest/psyFromAddTest', { formId: answers[0].formId }).catch(() => {}); GM_notification({ text: '答题完成!', timeout: 2000 }); } return ok; } // ==================== DOM 点击答题 ============================ function clickRadio(el) { try { if (!el || el.disabled || el.classList.contains('van-radio--disabled')) return false; // 取选项信息用于日志 const groupEl = el.closest('.van-radio-group'); const allOpts = groupEl ? groupEl.querySelectorAll('.van-radio') : []; const elIndex = Array.from(allOpts).indexOf(el); const label = el.querySelector('.van-radio__label')?.textContent?.trim() || ''; const radioInput = el.querySelector('input[type="radio"]'); console.log('[WHUT] 点选项, 位置:', elIndex, '/', allOpts.length, '标签:', label.slice(0, 10), 'value:', radioInput?.value); // 点图标触发Vant事件(避开 label @click.stop) const icon = el.querySelector('.van-radio__icon'); if (icon) icon.click(); else el.click(); // 通过 Vue 模型设值(兼容 Vant 2 currentValue / Vant 4 modelValue) const name = radioInput?.value; const groupKey = 'currentValue' in (groupEl?.__vue__ || {}) ? 'currentValue' : 'modelValue'; if (name && groupEl) { const vm = groupEl.__vue__ || groupEl.__vueParentComponent?.exposed; if (vm && groupKey in vm && vm[groupKey] !== name) { console.log('[WHUT] 设Vue值:', groupKey, '=', name); vm[groupKey] = name; let n = 0; const iv = setInterval(() => { if (vm[groupKey] === name || n++ > 10) { clearInterval(iv); return; } vm[groupKey] = name; }, 80); } } return true; } catch(e) { console.warn('[WHUT] clickRadio:', e); return false; } } function answerDOM(strategy) { const allRadios = document.querySelectorAll('.van-radio:not(.van-radio--disabled)'); if (allRadios.length === 0) return 0; const groups = document.querySelectorAll('.van-radio-group'); // 特殊结构:每选项独立 group(radios数 = groups数)→ 所有radio属于同一题 if (groups.length > 0 && allRadios.length === groups.length) { const pick = pickByStrategy(allRadios, strategy); if (pick && clickRadio(pick)) { console.log('[WHUT] 特殊结构: 从', allRadios.length, '个中选1'); return 1; } return 0; } // 标准结构:每个 group 含多个 radio → 每 group 选一个 if (groups.length > 0) { let total = 0; groups.forEach(g => { const opts = g.querySelectorAll('.van-radio:not(.van-radio--disabled)'); if (opts.length === 0) return; const pick = pickByStrategy(opts, strategy); if (!pick) return; if (clickRadio(pick)) total++; }); if (total > 0) return total; } // 兜底:裸 van-radio const pick = pickByStrategy(allRadios, strategy); if (pick && clickRadio(pick)) return 1; return 0; } // ==================== 答题主流程 ==================== function doAnswer() { _running = true; _continuous = false; _answered = 0; const strategy = getStrategy(); console.log('[WHUT] 答当前题, 策略:', strategy); updateStatus('答题中...'); processRound(strategy).then(count => { console.log('[WHUT] 完成, 答题数:', count); if (count > 0) updateStatus('已答 ' + count + ' 题'); _running = false; }); } function doContinuous() { _running = true; _continuous = true; _answered = 0; const strategy = getStrategy(); console.log('[WHUT] 连续答题, 策略:', strategy); updateStatus('持续答题中...'); processRound(strategy); startWatching(); } function stopAll() { _running = false; _continuous = false; if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null; } updateStatus('已停止'); console.log('[WHUT] 已停止'); } // 处理一轮答题 async function processRound(strategy) { console.log('[WHUT] processRound 策略:', strategy); _suppressAuto = true; try { const count = answerDOM(strategy); if (count > 0) { _answered += count; updateStatus('已答 ' + _answered + ' 题'); console.log('[WHUT] DOM答题:', count, '题'); return count; } updateStatus('未发现题目'); return 0; } finally { _suppressAuto = false; } } // 连续答题轮询(每次轮询重新读下拉框,支持中途换策略) function startWatching() { if (_watchTimer) clearInterval(_watchTimer); _watchTimer = setInterval(() => { if (!_continuous) { clearInterval(_watchTimer); _watchTimer = null; return; } const strategy = getStrategy(); processRound(strategy).then(count => { if (count > 0) updateStatus('已答 ' + _answered + ' 题'); }); }, CONFIG.watchInterval); } // ==================== 自动翻页控制 ==================== let _consentTimer = null; let _obs = null; function autoConsent() { if (!_running || _suppressAuto) return; const texts = ['同意', '开始测评', '开始测试', '进入测评', '我已阅读', '知道了', '确认', '重新答题']; document.querySelectorAll('button, span, a, div[role=button]').forEach(el => { // 跳过 Vant radio/checkbox 内部的标签(如 稍同意、很同意 等选项文本) if (el.closest('.van-radio, .van-checkbox, .van-radio__label, .van-checkbox__label')) return; const t = el.textContent.trim(); if (texts.some(x => t === x || t.includes(x))) { el.click(); console.log('[WHUT] 自动点击:', t); } }); } function startObserver() { if (_obs) return; let timer = null; _obs = new MutationObserver(() => { if (timer) return; timer = setTimeout(() => { timer = null; autoConsent(); const radios = document.querySelectorAll('.van-radio').length; if (radios > 0 && document.querySelector('.w-panel .s')?.textContent === '就绪') { updateStatus('Vant就绪'); } }, CONFIG.debounceDelay); }); _obs.observe(document.body, { childList: true, subtree: true }); // 页面加载后首次自动点同意 _consentTimer = setTimeout(autoConsent, 500); } // ==================== UI ==================== GM_addStyle(` .w-panel{position:fixed;top:70px;right:10px;z-index:99999;background:linear-gradient(135deg,#667eea,#764ba2);color:#fff;padding:12px 16px;border-radius:10px;box-shadow:0 4px 20px rgba(0,0,0,.25);font:13px/1.5 'Microsoft YaHei',sans-serif;min-width:180px} .w-panel .t{font-weight:700;margin-bottom:6px} .w-panel .s{font-size:11px;opacity:.9;padding:4px 6px;background:rgba(255,255,255,.12);border-radius:4px;margin-bottom:6px;min-height:18px} .w-panel .b{display:flex;flex-direction:column;gap:4px} .w-panel .b button{padding:5px 10px;border:none;border-radius:4px;cursor:pointer;font-size:12px;text-align:center;color:#fff} .w-panel .b .ok{background:#27ae60} .w-panel .b .ok:hover{background:#219a52} .w-panel .b .go{background:#e67e22} .w-panel .b .go:hover{background:#d35400} .w-panel .b .stop{background:#e74c3c} .w-panel .b .stop:hover{background:#c0392b} .w-panel .b .dbg{background:#666} .w-panel .b .dbg:hover{background:#555} .w-panel select{padding:3px 6px;border-radius:3px;border:none;font-size:11px;margin-top:4px;width:100%} `); function createUI() { if (document.querySelector('.w-panel')) return; const p = document.createElement('div'); p.className = 'w-panel'; p.innerHTML = `
🧠 测评助手 v1
就绪
`; document.body.appendChild(p); document.getElementById('w-do').onclick = doAnswer; document.getElementById('w-go').onclick = doContinuous; document.getElementById('w-stop').onclick = stopAll; document.getElementById('w-debug').onclick = debugInfo; // 拖拽 let dx, dy, drag = false; p.querySelector('.t').onmousedown = e => { drag = true; dx = e.clientX - p.offsetLeft; dy = e.clientY - p.offsetTop; }; document.onmousemove = e => { if (drag) { p.style.left = (e.clientX - dx) + 'px'; p.style.top = (e.clientY - dy) + 'px'; p.style.right = 'auto'; } }; document.onmouseup = () => drag = false; } function debugInfo() { const info = []; const vR = document.querySelectorAll('.van-radio').length; const vRG = document.querySelectorAll('.van-radio-group').length; info.push('van-radio: ' + vR + ', van-group: ' + vRG); info.push('input[radio]: ' + document.querySelectorAll('input[type=radio]').length); info.push('API数据: ' + (_loadedTopics ? _loadedTopics.length + '题' : '无')); info.push('运行中: ' + _running + ', 连续: ' + _continuous); info.push('已回答: ' + _answered + ' 题'); // 显示选项文本 const labels = []; document.querySelectorAll('.van-radio__label').forEach(el => labels.push(el.textContent.trim().slice(0, 15))); if (labels.length > 0) info.push('选项: ' + labels.slice(0, 5).join(' | ')); alert(info.join('\n')); } // ==================== 菜单命令 ==================== GM_registerMenuCommand('▶ 连续答题', doContinuous); GM_registerMenuCommand('⏹ 停止', stopAll); GM_registerMenuCommand('🔍 调试', debugInfo); // ==================== 启动 ==================== function init() { createUI(); startObserver(); console.log('[心理测评] v1.0 就绪'); } if (document.readyState === 'complete') init(); else window.addEventListener('load', init); })();