// ==UserScript== // @name 柳大师 - 学习助手(v10.5.0) // @namespace local.liudashi.study-helper // @version 10.5.0 // @description 任务卡片 + 三级答案匹配 + 模拟真人点击 + 原生setter填空 // @author 柳大师 // @match *://*.chaoxing.com/* // @match *://*.fanya.chaoxing.com/* // @match *://i.chaoxing.com/* // @match *://passport2.chaoxing.com/* // @match *://*.zhihuishu.com/* // @match *://*.icourse163.org/* // @match *://*.xuetangx.com/* // @match *://*.icve.com.cn/* // @match *://*.ulearning.cn/* // @match *://*.cnmooc.org/* // @match *://*.edu.cn/* // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect open.bigmodel.cn // @connect api.deepseek.com // @connect dashscope.aliyuncs.com // @connect api.gomooc.net // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; if (window._sh_script_injected) return; window._sh_script_injected = true; const BRAND = '柳大师'; const VERSION = '10.5.0'; const IS_TOP = window.top === window.self; // ==================== 工具 ==================== function getGreeting() { const h = new Date().getHours(); if (h < 6) return '凌晨好'; if (h < 11) return '上午好'; if (h < 13) return '中午好'; if (h < 18) return '下午好'; return '晚上好'; } const sleep = ms => new Promise(r => setTimeout(r, ms)); const rand = (a, b) => a + Math.random() * (b - a); const log = (...a) => console.log(`[${BRAND}]`, ...a); // ==================== 跨 frame 锁 ==================== const LOCK_KEY = 'sh_video_lock_v5'; const FRAME_ID = 'f_' + Math.random().toString(36).slice(2, 10) + '_' + Date.now(); let HOST = null, ROOT = null, BALL_EL = null, PANEL_EL = null; function readLock() { try { const r = localStorage.getItem(LOCK_KEY); if (!r) return null; const d = JSON.parse(r); return (Date.now() - d.heartbeat > 30000) ? null : d; } catch (e) { return null; } } function writeLock(d) { try { localStorage.setItem(LOCK_KEY, JSON.stringify(d)); } catch (e) {} } function clearLock() { try { if (readLock()?.frameId === FRAME_ID) localStorage.removeItem(LOCK_KEY); } catch (e) {} } function acquireLock(src) { const now = Date.now(); let d = readLock(); if (!d) { writeLock({ frameId: FRAME_ID, src: src || '', heartbeat: now }); return true; } if (d.frameId === FRAME_ID) { d.heartbeat = now; writeLock(d); return true; } return false; } function lockHeldByOther() { const d = readLock(); return d && d.frameId !== FRAME_ID; } setInterval(() => { const d = readLock(); if (d?.frameId === FRAME_ID) { d.heartbeat = Date.now(); writeLock(d); } }, 10000); // ==================== AI 服务商 ==================== const AI_PROVIDERS = { zhipu: { name: '智谱 GLM-4-Flash', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4-flash', tip: '完全免费 · 手机号注册', registerUrl: 'https://open.bigmodel.cn/' }, deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/v1/chat/completions', model: 'deepseek-chat', tip: '注册送额度', registerUrl: 'https://platform.deepseek.com/' }, qwen: { name: '通义千问', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', model: 'qwen-turbo', tip: '新用户免费额度', registerUrl: 'https://dashscope.console.aliyun.com/' }, }; const CONFIG = { enabled: GM_getValue('sh_enabled', true), autoPlay: GM_getValue('sh_autoPlay', true), autoNext: GM_getValue('sh_autoNext', true), autoAnswer: GM_getValue('sh_autoAnswer', true), aiEnabled: GM_getValue('sh_aiEnabled', true), autoSubmit: GM_getValue('sh_autoSubmit', false), stealthMode: GM_getValue('sh_stealthMode', true), muteFallback: GM_getValue('sh_muteFallback', true), playbackRate: GM_getValue('sh_playbackRate', 1.5), nextDelay: GM_getValue('sh_nextDelay', 10), darkMode: GM_getValue('sh_darkMode', false), aiProvider: GM_getValue('sh_aiProvider', 'zhipu'), aiKeys: GM_getValue('sh_aiKeys', {}), enableFreeBank: GM_getValue('sh_enableFreeBank', true), panelExpanded: GM_getValue('sh_panelExpanded', false), ballPos: GM_getValue('sh_ballPos', { side: 'right', top: 100 }), activeView: GM_getValue('sh_activeView', 'home'), }; function saveConfig() { try { Object.keys(CONFIG).forEach(k => GM_setValue('sh_' + k, CONFIG[k])); } catch (e) {} } function domGet(id) { try { return ROOT?.getElementById(id); } catch (e) { return null; } } function domQueryAll(sel) { try { return ROOT ? Array.from(ROOT.querySelectorAll(sel)) : []; } catch (e) { return []; } } // ==================== 本地题库 ==================== function loadBank() { try { return GM_getValue('sh_qbank', {}); } catch (e) { return {}; } } function saveBank(b) { try { GM_setValue('sh_qbank', b); } catch (e) {} } function hashQ(t) { const c = (t || '').replace(/\s+/g, '').slice(0, 200); let h = 0; for (let i = 0; i < c.length; i++) { h = ((h << 5) - h) + c.charCodeAt(i); h |= 0; } return 'q_' + Math.abs(h).toString(36); } function queryBank(t) { const b = loadBank(); const e = b[hashQ(t)]; return e ? e.a : null; } function recordBank(t, a, type, src) { if (!t || !a) return false; const b = loadBank(); const k = hashQ(t); const ex = b[k]; if (ex && ex.src === 'system' && ex.a === a) return false; if (ex && ex.src === 'system' && src === 'ai') return false; b[k] = { q: t.slice(0, 300), a, type: type || 'radio', t: Date.now(), src: src || 'ai' }; const keys = Object.keys(b); if (keys.length > 5000) { keys.sort((x, y) => (b[x].t || 0) - (b[y].t || 0)); keys.slice(0, 1000).forEach(kk => delete b[kk]); } saveBank(b); return true; } function deleteBankEntry(k) { const b = loadBank(); if (b[k]) { delete b[k]; saveBank(b); return true; } return false; } function clearBank() { saveBank({}); log('🗑 本地题库已清空'); } function listBank(f) { const b = loadBank(); let arr = Object.entries(b).map(([k, v]) => ({ key: k, ...v })); if (f) { const s = f.toLowerCase(); arr = arr.filter(it => (it.q || '').toLowerCase().includes(s) || (it.a || '').toLowerCase().includes(s)); } return arr.sort((a, b) => (b.t || 0) - (a.t || 0)); } function bankStats() { const b = loadBank(); let sys = 0, ai = 0, man = 0; Object.values(b).forEach(v => { if (v.src === 'system') sys++; else if (v.src === 'manual') man++; else ai++; }); return { total: Object.keys(b).length, sys, ai, man }; } // ==================== 任务卡片 ==================== const TASK_CARD_SELECTORS = ['.ans-job-item', '.posCatalog_select', '.chapter_item', '.catalog_points', '.jobItem', '[class*="ans-job"]', '[class*="job-item"]']; const COMPLETED_MARKERS = ['.icon_Completed', '.ans-job-icon-come', '.ans-job-icon.ans-job-icon-come', '.icon-dui', '.icon-success', '[class*="complete"]', '[class*="finish"]', '[class*="done"]', '[class*="success"]', '[class*="dui"]', '[class*="passed"]', '[class*="checked"]']; function isGreenColor(str) { if (!str) return false; if (str.includes('green') || str.includes('success') || str.includes('complete') || str.includes('done')) return true; const m = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (m) { const [r, g, b] = m.slice(1, 4).map(Number); return g > 120 && g > r * 1.15 && g > b * 1.15; } return false; } function isCardCompleted(card) { if (!card) return false; for (const sel of COMPLETED_MARKERS) { const marks = card.querySelectorAll(sel); for (const mark of marks) { const st = getComputedStyle(mark); if (isGreenColor(st.color) || isGreenColor(st.backgroundColor) || isGreenColor(st.borderColor)) return true; try { const bf = getComputedStyle(mark, '::before'); if (/["']?[✓✔√]/.test(bf.content || '')) return true; if (isGreenColor(bf.color) || isGreenColor(bf.backgroundColor)) return true; } catch (e) {} const lb = (mark.getAttribute('aria-label') || mark.getAttribute('title') || '').trim(); if (/完成|已学|已看/.test(lb)) return true; } } const textEls = card.querySelectorAll('span, div, em, i, p, b, strong, label'); for (const el of textEls) { const t = (el.textContent || '').trim(); if (t.length > 15) continue; if (/^(任务点已完成|已完成|已学完|完成学习|学习完成|通过|已通过)$/.test(t)) { const st = getComputedStyle(el); if (isGreenColor(st.color)) return true; const p = el.parentElement; if (p) { const ps = getComputedStyle(p); if (isGreenColor(ps.color) || isGreenColor(ps.backgroundColor)) return true; } } } return false; } function getCardType(card) { if (!card) return 'unknown'; if (card.querySelector('video')) return 'video'; const ifr = card.querySelectorAll('iframe'); for (const f of ifr) { if (/video|media|player|ananas/i.test(f.src || '')) return 'video'; } const text = card.textContent || ''; if (/测验|答题|选择题|判断题|考试|考核/.test(text)) return 'quiz'; if (/文档|阅读|课件|pdf|PPT/i.test(text)) return 'doc'; if (/视频|视屏|课程视频|观看/.test(text)) return 'video'; return 'unknown'; } function scanTaskCards() { const cardSet = new Set(); for (const sel of TASK_CARD_SELECTORS) { try { document.querySelectorAll(sel).forEach(el => cardSet.add(el)); } catch (e) {} } const cards = [...cardSet].sort((a, b) => { const p = a.compareDocumentPosition(b); if (p & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (p & Node.DOCUMENT_POSITION_PRECEDING) return 1; return 0; }); const queue = []; for (const card of cards) { const rect = card.getBoundingClientRect(); if (rect.width < 50 || rect.height < 20) continue; if (isCardCompleted(card)) { const v = card.querySelector('video'); if (v) v.dataset.shTaskDone = '1'; continue; } const type = getCardType(card); if (type === 'unknown' && !card.querySelector('video')) { card.dataset.shSkipped = '1'; continue; } queue.push({ card, type, video: card.querySelector('video'), text: (card.textContent || '').trim().slice(0, 60) }); } return queue; } // ==================== 任务状态机 ==================== let CURRENT_TASK = null, IS_SWITCHING = false, VIDEO_ENDED_AT = 0, LAST_ACTION = 0, LAST_SCAN_AT = 0; const SCAN_INTERVAL = 3000; function findVideoInCard(card) { if (!card) return null; let v = card.querySelector('video'); if (v) return v; const ifr = card.querySelectorAll('iframe'); for (const f of ifr) { try { const d = f.contentDocument || f.contentWindow?.document; if (d) { v = d.querySelector('video'); if (v) return v; } } catch (e) {} } return null; } async function playVideoElement(v) { if (!v) return; applyRateDynamic(v, CONFIG.playbackRate); try { await v.play(); updateStatus('播放中'); } catch (e) { if (CONFIG.muteFallback && !v.muted) { v.muted = true; try { await v.play(); updateStatus('播放中(静音)'); } catch (e2) {} } } } async function pollLoop() { if (!CONFIG.enabled || !CONFIG.autoPlay || IS_SWITCHING) return; if (lockHeldByOther()) { document.querySelectorAll('video').forEach(v => { if (!v.paused) try { v.pause(); } catch (e) {} }); return; } const now = Date.now(); if (now - LAST_SCAN_AT > SCAN_INTERVAL) { LAST_SCAN_AT = now; if (CURRENT_TASK && CURRENT_TASK.card && isCardCompleted(CURRENT_TASK.card)) { await finishTask(CURRENT_TASK, true); return; } } if (!CURRENT_TASK) { const queue = scanTaskCards(); if (!queue.length) { if (document.querySelector('.TiMu, [class*="question"]')) return; if (IS_TOP) await goNext(); return; } const head = queue[0]; if (head.type === 'video') { const v = findVideoInCard(head.card) || head.video; if (!v) { if (head.card) head.card.dataset.shTried = '1'; return; } if (!acquireLock(v.currentSrc || 'video')) return; CURRENT_TASK = head; CURRENT_TASK.video = v; VIDEO_ENDED_AT = 0; try { head.card.click(); } catch (e) {} await sleep(1500); await playVideoElement(v); return; } else if (head.type === 'quiz') { CURRENT_TASK = head; CURRENT_TASK.video = null; try { head.card.click(); } catch (e) {} return; } else if (head.type === 'doc') { if (head.card) head.card.dataset.shTried = '1'; return; } return; } const task = CURRENT_TASK; if (task.type === 'quiz') { if (task.card && isCardCompleted(task.card)) await finishTask(task, true); return; } const v = task.video; if (!v || !document.contains(v)) { if (task.card && isCardCompleted(task.card)) await finishTask(task, true); else releaseTask(); return; } const cardDone = task.card && isCardCompleted(task.card); const ended = v.ended || (v.duration && v.currentTime / v.duration >= 0.99); if (ended && !VIDEO_ENDED_AT) { VIDEO_ENDED_AT = Date.now(); updateStatus('等待任务点变绿'); } if (cardDone) { const n = Date.now(); if (n - LAST_ACTION < 1000) return; LAST_ACTION = n; await finishTask(task); return; } if (ended && VIDEO_ENDED_AT && Date.now() - VIDEO_ENDED_AT > 90000) { await finishTask(task, true); return; } if (v.paused && !ended) { try { await v.play(); applyRateDynamic(v, CONFIG.playbackRate); updateStatus('播放中'); } catch (e) { v.muted = true; try { await v.play(); } catch (e2) {} } } else if (!ended) { applyRateDynamic(v, CONFIG.playbackRate); updateStatus('播放中'); } } async function finishTask(task, force = false) { if (!task) return; if (task.card) try { task.card.dataset.shTaskDone = '1'; } catch (e) {} if (task.video) try { task.video.dataset.shTaskDone = '1'; task.video.pause(); } catch (e) {} const delay = 3000 + Math.random() * 12000; updateStatus(`延时 ${(delay / 1000).toFixed(0)}s`); clearLock(); releaseTask(); await sleep(delay); setTimeout(() => pollLoop().catch(() => {}), 100); } function releaseTask() { if (CURRENT_TASK?.video) try { CURRENT_TASK.video.dataset.shActive = '0'; } catch (e) {} CURRENT_TASK = null; VIDEO_ENDED_AT = 0; } // ==================== 跳转下一节 ==================== let lastNext = 0; async function goNext() { const now = Date.now(); if (now - lastNext < 30000) return; lastNext = now; IS_SWITCHING = true; updateStatus('切换中...'); const cooldown = CONFIG.stealthMode ? rand(180000, 480000) : 0; await sleep(CONFIG.nextDelay * 1000 + cooldown); tryClickNext(); setTimeout(() => { IS_SWITCHING = false; setTimeout(() => pollLoop().catch(() => {}), 3000); }, 8000); } function tryClickNext() { const sels = ['.next', '.next-btn', '.nextSection', '.next-section', '.chapter-next', '.section-next', '#nextBtn', '[class*="nextBtn"]', '[class*="next"] button', '.nextChapter', '.next-chapter', '[title*="下一"]', 'a[href*="next"]']; for (const s of sels) { const b = document.querySelector(s); if (b && b.offsetParent !== null && !b.disabled) { b.click(); return true; } } for (const el of document.querySelectorAll('a,button,span,div[onclick]')) { const t = (el.textContent || '').trim(); if (t && t.length <= 20 && /^下一[节章课]|继续学习|next$/i.test(t) && el.offsetParent !== null) { el.click(); return true; } } return false; } // ==================== 免费题库 ==================== async function queryFreeBank(q) { if (!CONFIG.enableFreeBank) return null; return new Promise(res => { GM_xmlhttpRequest({ method: 'GET', url: `https://api.gomooc.net/api.php?question=${encodeURIComponent(q)}`, timeout: 5000, onload: r => { try { const d = JSON.parse(r.responseText); const a = d.answer || (d.data && d.data.answer); res(a || null); } catch (e) { res(null); } }, onerror: () => res(null), ontimeout: () => res(null) }); }); } // ==================== AI 调用 ==================== async function callAI(q, opts, type) { if (!CONFIG.aiEnabled) { log('⏸ AI 已暂停'); return null; } const prov = AI_PROVIDERS[CONFIG.aiProvider]; const key = (CONFIG.aiKeys || {})[CONFIG.aiProvider]; if (!prov) { log('❌ 未知 AI 服务商'); return null; } if (!key) { log(`❌ 未配置 ${prov.name} 的 Key`); return null; } const sys = `你是一位精通中国大学课程的资深教授,熟悉大学英语、数学、工科、理科、文科、党史、形势与政策、国家安全教育。 【重要提示】学习通、智慧树等平台有时会使用字体反爬技术,导致从网页复制的题目文字出现"假字"或"乱码"。 例如:"含扒步骤庹窟机" 实际上应该是 "异步电动机"。请你根据上下文语义,自动纠正这些乱码字符,推测出真正的题目,然后严谨解答。 要求: 1. 用简体中文回答 2. 英语题用正确英文 3. 数学题用标准符号 4. 选择题只返回选项字母(如A或AB) 5. 填空题多空用||分隔 6. 简答题简明扼要`; let usr; if (type === 'blank') usr = `填空题,多空用||分隔:\n${q}\n答案:`; else if (type === 'short') usr = `简答题:\n${q}\n答案:`; else usr = `选择题,只返回选项字母:\n${q}\n${opts.map((o, i) => `${String.fromCharCode(65 + i)}. ${o.text || o}`).join('\n')}\n答案:`; log(`🤖 调用 ${prov.name} | ${q.slice(0, 40)}...`); return new Promise(res => { GM_xmlhttpRequest({ method: 'POST', url: prov.url, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${key}` }, data: JSON.stringify({ model: prov.model, messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }], max_tokens: 400, temperature: 0.1 }), timeout: 20000, onload: r => { log('📥 AI 状态:', r.status); try { const d = JSON.parse(r.responseText); if (d.error) { log('❌ AI 错误:', JSON.stringify(d.error).slice(0, 200)); res(null); return; } const t = d.choices[0].message.content.trim(); log('✅ AI 返回:', t); res(type === 'blank' || type === 'short' ? t : t.replace(/[^A-D]/g, '')); } catch (e) { log('❌ 解析失败:', e.message); res(null); } }, onerror: () => { log('❌ AI 网络错误'); res(null); }, ontimeout: () => { log('❌ AI 超时'); res(null); } }); }); } // ============================================================ // 【答题核心】完整实现 // ============================================================ function inViewport(el) { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; } function isQuestionText(t) { if (!t || t.length < 3) return false; const cn = t.match(/[\u4e00-\u9fa5]/g); if (cn && cn.length / t.length >= 0.1) return true; return /^[a-zA-Z0-9\s.,;:'"!?()\-]+$/.test(t) && t.length > 8; } const Q_SELS = ['.TiMu', '.question-item', '.topic-item', '[class*="question"]', '[class*="exam-item"]', '.pop-question', '.quiz-item', '.ans-video-question', '[class*="anser"]', '[class*="qt-content"]', '.questionLi', '[class*="Zy_TItle"]']; /** * 【核心函数 1】提取题目:题干 + 选项文本 + 选项 DOM 绑定 * 返回 { element, text, type, options: [{letter, text, inputEl, labelEl}], inputEls } */ function extractQuestion(item) { if (!item || !inViewport(item)) return null; // 1. 题干 const tEl = item.querySelector('.Zy_TItle,.question-title,.title,.stem,[class*="title"],[class*="stem"],[class*="topic"]'); if (!tEl) return null; let qt = tEl.textContent.trim().replace(/^\d+[\.、]\s*/, '').replace(/【.*?】/g, '').trim(); if (!isQuestionText(qt)) return null; // 2. 题型判断 const radios = item.querySelectorAll('input[type="radio"]'); const checks = item.querySelectorAll('input[type="checkbox"]'); const textInputs = item.querySelectorAll('input[type="text"], textarea, .blank-input, .fill-input'); let type = 'radio', inputEls = []; if (checks.length > 0 && radios.length === 0) { type = 'checkbox'; inputEls = [...checks]; } else if (radios.length > 0) { type = 'radio'; inputEls = [...radios]; } else if (textInputs.length > 0) { type = 'blank'; inputEls = [...textInputs]; } else type = 'short'; // 3. 选项文本 + DOM 绑定(关键) const options = []; if (type === 'radio' || type === 'checkbox') { inputEls.forEach((inp, idx) => { // 找选项的 label 容器 const labelEl = inp.closest('label') || inp.parentElement; let optText = '', letter = ''; // 优先从 .option-letter / .letter 元素里找字母 const letterEl = labelEl?.querySelector('.option-letter,.letter,.num,[class*="letter"]'); if (letterEl) { letter = letterEl.textContent.trim().replace(/[^A-Da-d]/g, '').toUpperCase(); // 从 label 里去掉字母,剩下的就是选项文字 const clone = labelEl.cloneNode(true); const lc = clone.querySelector('.option-letter,.letter,.num,[class*="letter"]'); if (lc) lc.remove(); optText = clone.textContent.trim(); } else if (labelEl) { // 有些平台直接把 "A. 苹果" 写在 label 里 const raw = labelEl.textContent.trim(); const m = raw.match(/^([A-Da-d])[\.、\s、]*(.+)$/); if (m) { letter = m[1].toUpperCase(); optText = m[2].trim(); } else { optText = raw; } } // 清洗选项文字 optText = optText.replace(/\s+/g, ' ').trim(); if (!letter) letter = String.fromCharCode(65 + idx); options.push({ letter, text: optText, inputEl: inp, labelEl }); }); } else if (type === 'blank') { inputEls.forEach((inp, idx) => { options.push({ letter: String(idx + 1), text: '', inputEl: inp, labelEl: inp.parentElement }); }); } return { element: item, text: qt, type, options, inputEls }; } /** * 【核心函数 2】三级答案匹配 * 1. 判断题特判(对/错) * 2. 字母匹配(A/B/C/D) * 3. 文字模糊匹配(去标点对比) * 返回:匹配成功的选项索引数组,如 [0, 2] 表示选 A 和 C */ function parseAnswer(answerText, question) { if (!answerText) return []; const raw = String(answerText).trim().toUpperCase(); // ---- 第一级:判断题特判 ---- if (question.type === 'radio' && question.options.length === 2) { const optTexts = question.options.map(o => o.text); const isJudge = optTexts.some(t => /对|错|是|否|正确|错误|T|F|√|×/.test(t)); if (isJudge) { if (/[√✔对是正确T]/.test(raw)) return [0]; if (/[×✘错否错误F]/.test(raw)) return [1]; } } // ---- 第二级:字母匹配 ---- const letters = raw.match(/[A-D]/g); if (letters && letters.length > 0) { const idxs = [...new Set(letters)].map(l => l.charCodeAt(0) - 65).filter(i => i >= 0 && i < question.options.length); // 如果字母有匹配,但选项文字明显对不上(比如题目乱码),也先返回字母匹配 if (idxs.length > 0) return idxs; } // ---- 第三级:文字模糊匹配 ---- const clean = s => String(s || '').replace(/[\s,。、,.\-_()()【】\[\];;::""''!!??]/g, ''); const ansClean = clean(answerText); if (!ansClean) return []; const matched = []; question.options.forEach((opt, idx) => { const oc = clean(opt.text); if (!oc) return; // 完全一致 / 一方包含另一方(长度至少 2 个字符才做包含匹配) if (oc === ansClean) { matched.push(idx); return; } if (oc.length >= 2 && (oc.includes(ansClean) || ansClean.includes(oc))) matched.push(idx); }); return matched; } /** * 【核心函数 3】模拟真人点击选项(完整事件链) */ function humanClickOption(option) { const inp = option.inputEl; const label = option.labelEl || inp.closest('label') || inp.parentElement; if (!inp) return; try { inp.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {} // 计算点击坐标(带随机抖动) const target = label || inp; const rect = target.getBoundingClientRect(); const cx = rect.left + rect.width / 2 + rand(-4, 4); const cy = rect.top + rect.height / 2 + rand(-3, 3); const eventInit = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy }; // 完整的鼠标事件链 try { inp.dispatchEvent(new MouseEvent('mouseover', eventInit)); } catch (e) {} try { inp.dispatchEvent(new MouseEvent('mousedown', eventInit)); } catch (e) {} try { inp.dispatchEvent(new MouseEvent('mouseup', eventInit)); } catch (e) {} try { inp.dispatchEvent(new MouseEvent('click', eventInit)); } catch (e) {} // 很多平台点击事件绑在 label 上 if (label && label !== inp) { try { label.click(); } catch (e) {} } // 手动触发 change / input 事件(让 React/Vue 感知) try { inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) {} try { inp.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} // 兜底:200ms 后检查,未选中就强制选中 setTimeout(() => { if (inp.type === 'radio' || inp.type === 'checkbox') { if (!inp.checked) { try { inp.checked = true; inp.dispatchEvent(new Event('change', { bubbles: true })); } catch (e) {} } } }, 200); } /** * 【核心函数 4】用原生 setter 填入文字(绕过 React/Vue 拦截) */ function fillBlankInput(inputEl, text) { if (!inputEl || text == null) return; try { inputEl.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch (e) {} inputEl.focus(); // 关键:用原型上的原生 setter,绕过框架对 value 的劫持 const proto = inputEl.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; try { if (nativeSetter) nativeSetter.call(inputEl, String(text)); else inputEl.value = String(text); } catch (e) { inputEl.value = String(text); } // 触发原生事件 inputEl.dispatchEvent(new Event('input', { bubbles: true })); inputEl.dispatchEvent(new Event('change', { bubbles: true })); // 模拟 Enter(部分平台需要) try { inputEl.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'Enter' })); } catch (e) {} // 失焦 setTimeout(() => { try { inputEl.blur(); } catch (e) {} }, 100); } // ==================== 验证码检测 ==================== function isCaptchaShown() { const bt = document.body ? document.body.innerText : ''; if (/9010|操作异常|请输入.*验证码|人机验证|滑动验证/.test(bt)) return true; return false; } function tryAutoSubmit() { const sels = ['.submit', '.submitBtn', '.submit-btn', '#submitBtn', '#submit', '[class*="submit"]:not([class*="submitted"])', 'button[type="submit"]']; for (const s of sels) { const btns = document.querySelectorAll(s); for (const b of btns) { const t = (b.textContent || '').trim(); if (!t || t.length > 10) continue; if (/提交|交卷|保存/.test(t) && b.offsetParent !== null && !b.disabled) { try { b.click(); } catch (e) {} return true; } } } return false; } // ==================== 答题主流程 ==================== let CAPTCHA_PAUSED = false; async function autoAnswer(manual = false) { if (!CONFIG.enabled || (!CONFIG.autoAnswer && !manual) || window._shAnswering) return; window._shAnswering = true; let totalFound = 0, totalDone = 0, totalFailed = 0; try { // 验证码检测 if (isCaptchaShown()) { if (!CAPTCHA_PAUSED) { CAPTCHA_PAUSED = true; log('🛑 检测到验证码,答题暂停'); updateStatus('请处理验证码'); } return; } else if (CAPTCHA_PAUSED) { CAPTCHA_PAUSED = false; log('✅ 验证码解除,恢复答题'); } // 收集题目容器 const itemSet = new Set(); for (const sel of Q_SELS) { try { document.querySelectorAll(sel).forEach(it => itemSet.add(it)); } catch (e) {} } const allItems = [...itemSet].filter(it => inViewport(it)); // 过滤出待答题 const toAnswer = []; for (const item of allItems) { if (item.dataset.shAnswered === 'done') continue; if (item.dataset.shSubmitted === '1') continue; const q = extractQuestion(item); if (!q) continue; // 已作答检查 if (q.type === 'radio' || q.type === 'checkbox') { if (q.inputEls.some(i => i.checked)) { item.dataset.shSubmitted = '1'; continue; } } else if (q.type === 'blank') { if (q.inputEls.some(i => (i.value || '').trim().length > 0)) { item.dataset.shSubmitted = '1'; continue; } } toAnswer.push({ item, q }); } totalFound = toAnswer.length; if (totalFound > 0) log(`📝 待答题目:${totalFound} 道`); if (!totalFound) { if (manual) alert(`扫描完成\n\n视口内候选:${allItems.length}\n待答题目:0\n\n如果页面上有题目但显示 0,请把 Console 日志发我`); return; } // 逐题处理(一题一题来,不并发) for (const { item, q } of toAnswer) { log(`📝 [${q.type}] ${q.text.slice(0, 40)}...`); if (q.type === 'radio' || q.type === 'checkbox') { log(` 选项数: ${q.options.length} | 选项: ${q.options.map(o => o.letter + '.' + o.text.slice(0, 8)).join(' / ')}`); } if (window.shCollapseUI) window.shCollapseUI(); if (CONFIG.stealthMode) await sleep(rand(3000, 6000)); // 读题延迟 // 三层查询 let ans = queryBank(q.text); let src = 'local'; if (!ans) { ans = await queryFreeBank(q.text); src = 'free'; } if (!ans) { ans = await callAI(q.text, q.options, q.type); src = 'ai'; } if (!ans) { log('❌ 三层都没命中'); item.dataset.shSubmitted = '1'; totalFailed++; continue; } log(`✅ [${src}] 答案: ${ans}`); recordBank(q.text, ans, q.type, src); if (CONFIG.stealthMode) await sleep(rand(1000, 2000)); // ==================== 选择题:匹配 + 点击 ==================== if (q.type === 'radio' || q.type === 'checkbox') { const indices = parseAnswer(ans, q); if (indices.length === 0) { log('⚠️ 答案无法匹配任何选项,跳过此题'); item.dataset.shSubmitted = '1'; totalFailed++; continue; } log(`📌 匹配到选项: ${indices.map(i => q.options[i]?.letter || i).join(',')}`); for (const idx of indices) { const opt = q.options[idx]; if (!opt || !opt.inputEl) continue; // 单选:已选中的不用再点 if (q.type === 'radio' && opt.inputEl.checked) break; // 多选:已选中的跳过 if (q.type === 'checkbox' && opt.inputEl.checked) continue; humanClickOption(opt); if (CONFIG.stealthMode) await sleep(rand(400, 900)); } } // ==================== 填空题:多空依次填入 ==================== else if (q.type === 'blank') { // 答案按 || 分隔多个空 const answers = String(ans).split(/\|\||,|,/).map(s => s.trim()).filter(s => s.length > 0); log(`📌 填空 ${q.inputEls.length} 个空,答案: