// ==UserScript== // @name 国开答题助手 // @namespace https://github.com/leye/ouchn-helper // @version 1.0.0 // @description 国家开放大学(lms.ouchn.cn)AI 自动答题脚本 · 对接 DeepSeek V4 / V4 Pro // @author 叶屿 // @license GPL-3.0 // @antifeature payment AI 答题功能需要观看广告获取,用户需自备 DeepSeek API Key // @match *://lms.ouchn.cn/* // @match *://*.lms.ouchn.cn/* // @match *://*.ouchn.cn/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_addStyle // @connect api.deepseek.com // @connect qsy.iano.cn // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ============== 配置 ============== const Config = { version: '1.0.0', // DeepSeek 官方端点 deepseekBaseUrl: 'https://api.deepseek.com/v1/chat/completions', // v4 = 极速版(chat,秒回);v4pro = 思考版(reasoner,更准更慢) models: { v4: { name: 'DeepSeek V4(极速)', modelId: 'deepseek-chat', maxTokens: 512, hint: '日常题秒回,单选/填空首选' }, v4pro: { name: 'DeepSeek V4 Pro(思考)', modelId: 'deepseek-reasoner', maxTokens: 8192, hint: '推理型,多选/计算/分析题更准(思考占 tokens 多,需 8K+)' }, }, // 答题节奏(避免被风控;可在设置里改) interQuestionMs: 1800, // 题间间隔 afterClickMs: 600, // 点击后等待 aiTimeoutMs: 60000, // AI 单次调用最长 60s(思考版可能久) aiRetryMax: 2, // 失败重试次数 aiMinIntervalMs: 500, // 两次 AI 调用最小间隔 // 微信 wechat: 'C919irt', // 验证码 verifyUrl: 'https://qsy.iano.cn/index.php?s=/api/code/verify', verifyQrUrl: 'https://qsy.iano.cn/yzm.png', // 公告 announceUrl: 'https://qsy.iano.cn/index.php?s=/admin/unified_manage/scriptannouncementapi', }; // ============== Utils ============== const Utils = { sleep: (ms) => new Promise(r => setTimeout(r, ms)), safeText: (el) => { if (!el) return ''; return (el.innerText || el.textContent || '').replace(/\s+/g, ' ').trim(); }, // 触发 React/Vue 框架能感知到的事件 fireInputEvents(el) { try { el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); el.dispatchEvent(new Event('blur', { bubbles: true })); } catch (_) {} }, // 框架反查 setter(React 等会拦截 .value 赋值) nativeSet(el, value) { const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) setter.call(el, value); else el.value = value; this.fireInputEvents(el); }, safeClick(el) { if (!el) return false; try { if (typeof el.click === 'function') el.click(); el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); el.dispatchEvent(new MouseEvent('click', { bubbles: true })); return true; } catch (_) { return false; } }, // 把 radio/checkbox 切换到目标状态。多策略 fallback: // 1) 点击包装(label / 选项 div)— 最贴近真人操作 // 2) 点击 input 自己 // 3) 原生 setter + dispatch change(React 控件兜底) // 每步后检查状态是否符合期望,符合就 return true async toggleChecked(input, node, want) { if (!input) return false; if (!!input.checked === want) return true; const tryAndVerify = async (clickTarget) => { if (!clickTarget) return false; this.safeClick(clickTarget); await this.sleep(120); return !!input.checked === want; }; // 1) 包装(label / div) if (node && node !== input && await tryAndVerify(node)) return true; // 2) input 本身 if (await tryAndVerify(input)) return true; // 3) 原生 setter + dispatch try { const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked')?.set; if (setter) { setter.call(input, want); input.dispatchEvent(new Event('click', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } await this.sleep(120); if (!!input.checked === want) return true; } catch (_) {} return false; }, async copyText(text) { try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); return true; } } catch (_) {} try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none;'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch (_) { return false; } }, // 简单的字符归一化(用于答案 ↔ 选项匹配) normalize(s) { return String(s || '') .replace(/^\s*[A-Ha-h]\s*[.、.::))]\s*/, '') // 剥 "A." / "B、" .toLowerCase() .replace(/[\s.、,。,;;::!!??'"'""\(\)()\-_]/g, ''); }, isOwnPanel(el) { return !!el?.closest?.('#ouchn-panel, #ouchn-settings, [data-ouchn-helper]'); }, }; // ============== Store(GM 存储)============== const Store = { get(k, def) { try { const v = GM_getValue(k); return v === undefined ? def : v; } catch (_) { return def; } }, set(k, v) { try { GM_setValue(k, v); } catch (_) {} }, del(k) { try { GM_deleteValue(k); } catch (_) {} }, getApiKey() { return this.get('ouchn_dsk_key', ''); }, setApiKey(v) { this.set('ouchn_dsk_key', v || ''); }, getModel() { return this.get('ouchn_model', 'v4'); }, setModel(v) { this.set('ouchn_model', v); }, getAutoNext() { return this.get('ouchn_autoNext', true); }, setAutoNext(v) { this.set('ouchn_autoNext', !!v); }, getInterMs() { return this.get('ouchn_interMs', Config.interQuestionMs); }, setInterMs(v) { this.set('ouchn_interMs', Math.max(500, parseInt(v) || Config.interQuestionMs)); }, // ---- 验证码 ---- getVerifyValidUntil() { return parseInt(this.get('ouchn_verifyUntil', 0), 10) || 0; }, setVerifyValidUntil(ts) { this.set('ouchn_verifyUntil', ts || 0); }, isVerifyValid() { return this.getVerifyValidUntil() > Math.floor(Date.now() / 1000); }, getValidUntilStr() { const t = this.getVerifyValidUntil(); return t ? new Date(t * 1000).toLocaleString('zh-CN', { hour12: false }) : ''; }, }; // ============== Verify:4 位验证码 ============== const Verify = { async verifyCode(code) { return new Promise((resolve, reject) => { const c = String(code || '').trim(); if (!c || c.length !== 4) return reject('请输入 4 位验证码'); if (typeof GM_xmlhttpRequest !== 'function') return reject('GM_xmlhttpRequest 不可用'); GM_xmlhttpRequest({ method: 'POST', url: Config.verifyUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: 'code=' + encodeURIComponent(c), timeout: 15000, onload: (resp) => { try { const d = JSON.parse(resp.responseText); if (d.code === 1 && d.data && d.data.valid) { const validUntil = parseInt(d.data.valid_until, 10); Store.setVerifyValidUntil(validUntil); const str = d.data.valid_until_str || new Date(validUntil * 1000).toLocaleString('zh-CN'); resolve({ validUntil, validUntilStr: str }); } else { reject(d.msg || '验证码无效或已过期'); } } catch (_) { reject('验证响应解析失败'); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时'), }); }); }, }; // ============== Announce:远程公告 ============== const Announce = { fetch(panel) { if (!panel || typeof GM_xmlhttpRequest !== 'function') return; try { GM_xmlhttpRequest({ method: 'GET', url: Config.announceUrl, timeout: 8000, onload: (resp) => { try { const res = JSON.parse(resp.responseText); if (res.code !== 1 || !res.data || !res.data.enabled) return; this.render(panel, res.data); } catch (_) { /* silent */ } }, onerror: () => {}, ontimeout: () => {}, }); } catch (_) {} }, render(panel, data) { const root = panel.querySelector('#oc-announce'); const titleEl = panel.querySelector('#oc-announce-title'); const contentEl = panel.querySelector('#oc-announce-content'); const closeEl = panel.querySelector('#oc-announce-close'); if (!root || !titleEl || !contentEl) return; const type = ['info','warning','error','success'].includes(data.type) ? data.type : 'info'; const icon = { info: '📢', warning: '⚠️', error: '❌', success: '✅' }[type]; const iconEl = root.querySelector('.oc-announce-icon'); if (iconEl) iconEl.textContent = icon; titleEl.textContent = data.title || '公告'; // content 允许富文本,但只信任
这些标签 contentEl.innerHTML = this._sanitize(data.content || ''); root.classList.remove('info', 'warning', 'error', 'success'); root.classList.add(type, 'show'); if (closeEl) closeEl.onclick = () => { root.classList.remove('show'); }; }, _sanitize(html) { // 简易白名单:只保留
const tmp = document.createElement('div'); tmp.innerHTML = String(html); tmp.querySelectorAll('*').forEach(el => { const tag = el.tagName.toLowerCase(); if (!['br','b','i','u','strong','em','span'].includes(tag)) { el.replaceWith(document.createTextNode(el.textContent || '')); return; } // 清掉所有事件属性 [...el.attributes].forEach(attr => { if (/^on/i.test(attr.name) || attr.name === 'style') el.removeAttribute(attr.name); }); }); return tmp.innerHTML; }, }; // ============== 状态 ============== let isRunning = false; let stopRequested = false; let isPaused = false; let stats = { total: 0, success: 0, failed: 0, skipped: 0 }; let lastAiCallAt = 0; // 暂停时阻塞,直到恢复或停止 async function waitWhilePaused(logger) { let told = false; while (isPaused && !stopRequested) { if (!told) { logger?.('⏸ 已暂停(点击 ▶️ 继续 恢复 / ⏹ 停止 终止)', 'warn'); told = true; } await Utils.sleep(300); } } // ============== AI: DeepSeek ============== const AI = { // 题型常量 TYPE_SINGLE: 'single', TYPE_MULTI: 'multi', TYPE_JUDGE: 'judge', TYPE_FILL: 'fill', TYPE_ESSAY: 'essay', TYPE_UNKNOWN: 'unknown', buildPrompt(question, options, type) { const optStr = options?.length ? options.map((o, i) => `${String.fromCharCode(65 + i)}. ${o}`).join('\n') : ''; switch (type) { case this.TYPE_SINGLE: return `你是答题助手。下面是一道单选题,请只输出正确选项的"原文内容"(不要带 A./B./字母编号),如果不确定就选最合理的一项。 【题目】${question} 【选项】 ${optStr} 要求: - 只输出一个选项的原文,不解释 - 不要带字母编号 - 不要带 "答案:" 等前缀`; case this.TYPE_MULTI: return `你是答题助手。下面是一道多选题。 【题目】${question} 【选项】 ${optStr} 输出格式(必须严格遵守,否则视为错答): 最后一行必须是:答案:X||Y||Z 其中 X/Y/Z 是正确选项的"原文内容"(不要字母 ABCD 编号,不要解释,不要省略号)。 示例: 答案:塑性和韧性||硬度和强度||化学成分`; case this.TYPE_JUDGE: return `你是答题助手。下面是一道判断题,请只回答"正确"或"错误"。 【题目】${question} 【选项参考】 ${optStr || '正确 / 错误'} 要求:只输出 "正确" 或 "错误" 两字,不要其他内容`; case this.TYPE_FILL: return `你是答题助手。下面是一道填空题,请直接给出答案。 【题目】${question} 要求: - 若题目含多个空,用 "||" 分隔每空答案 - 不要带空号 / 序号 / 引号 / 答案前缀 - 简洁、准确`; case this.TYPE_ESSAY: return `你是答题助手。下面是一道简答/论述题,请用简洁专业的中文作答。 【题目】${question} 要求: - 200-400 字 - 分点作答(用 "1." "2." 标记或自然分段) - 不要寒暄、不要题目重述`; default: return `请回答下面的题目:\n${question}\n\n${optStr ? '【可选项】\n' + optStr : ''}`; } }, async call(question, options, type, logger) { const apiKey = Store.getApiKey(); if (!apiKey) throw new Error('未配置 DeepSeek API Key,请到 ⚙️ 设置填写'); // 节流 const since = Date.now() - lastAiCallAt; if (since < Config.aiMinIntervalMs) { await Utils.sleep(Config.aiMinIntervalMs - since); } const modelKey = Store.getModel(); const modelConf = Config.models[modelKey] || Config.models.v4; const prompt = this.buildPrompt(question, options, type); const body = { model: modelConf.modelId, messages: [{ role: 'user', content: prompt }], max_tokens: modelConf.maxTokens, temperature: 0.1, stream: false, }; let lastErr; for (let attempt = 1; attempt <= Config.aiRetryMax + 1; attempt++) { try { lastAiCallAt = Date.now(); const raw = await this._request(apiKey, body); const msg = raw?.choices?.[0]?.message || {}; let content = msg.content || ''; const reasoning = msg.reasoning_content || ''; const finish = raw?.choices?.[0]?.finish_reason; // ⚠️ reasoner 截断兜底:思考耗尽 tokens 没产出正式答案 // → 自动换 deepseek-chat(非思考模型)重新跑一次,确保拿到答案 if (modelConf.modelId === 'deepseek-reasoner' && finish === 'length' && !content) { logger?.(` ⚠️ V4 Pro 思考超出 ${modelConf.maxTokens} tokens 仍未给出答案,自动降级到 V4 重试...`); const fbBody = { model: 'deepseek-chat', messages: [{ role: 'user', content: prompt }], max_tokens: 512, temperature: 0.1, stream: false, }; await Utils.sleep(Config.aiMinIntervalMs); lastAiCallAt = Date.now(); const fbRaw = await this._request(apiKey, fbBody); const fbContent = fbRaw?.choices?.[0]?.message?.content || ''; if (fbContent) { const answers = this._parseAnswer(fbContent, type); answers._raw = fbContent; answers._fallback = 'v4'; return answers; } // 兜底也失败,继续走正常流程(用 reasoning 凑答案) } // 正常路径:优先用 content(正式答案),content 空时退而求其次用 reasoning let used = content || reasoning; if (!used) throw new Error('AI 返回内容为空'); // content 没"答案:"行而 reasoning 有,合并以便提取 if (content && reasoning && !/答案[::]/i.test(content) && /答案[::]/i.test(reasoning)) { used = reasoning + '\n' + content; } const answers = this._parseAnswer(used, type); answers._raw = used; return answers; } catch (e) { lastErr = e; logger?.(` ⚠️ AI 第 ${attempt} 次失败:${e.message || e}`); if (attempt <= Config.aiRetryMax) { await Utils.sleep(1000 * attempt); } } } throw lastErr || new Error('AI 调用失败'); }, _request(apiKey, body) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') { return reject(new Error('GM_xmlhttpRequest 不可用,请用 Tampermonkey 安装本脚本')); } GM_xmlhttpRequest({ method: 'POST', url: Config.deepseekBaseUrl, timeout: Config.aiTimeoutMs, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey, }, data: JSON.stringify(body), onload: (resp) => { if (resp.status >= 200 && resp.status < 300) { try { resolve(JSON.parse(resp.responseText)); } catch (e) { reject(new Error('解析响应失败:' + e.message)); } } else { let msg = `HTTP ${resp.status}`; try { const j = JSON.parse(resp.responseText); msg += ' · ' + (j.error?.message || j.message || resp.responseText.slice(0, 200)); } catch (_) { msg += ' · ' + resp.responseText.slice(0, 200); } reject(new Error(msg)); } }, onerror: () => reject(new Error('网络请求失败')), ontimeout: () => reject(new Error('请求超时')), }); }); }, _parseAnswer(content, type) { let raw = String(content || '').trim(); // ESSAY 直接整段返回 if (type === this.TYPE_ESSAY) return [raw]; // 1) 优先抓"答案:..." 整行(reasoner 模型按 prompt 要求会输出这一行) // 支持 全角/半角冒号、答案/正确答案/答 前缀 let txt = ''; const ansLineMatch = raw.match(/(?:^|\n)\s*(?:正确答案|答案|答)\s*[::]\s*([^\n]+)/i); if (ansLineMatch) { txt = ansLineMatch[1].trim(); } else { // 2) 没明确 "答案:" 行 → 取最后一行非空(reasoner 思考完通常把答案放最后) const lines = raw.split('\n').map(s => s.trim()).filter(Boolean); txt = lines[lines.length - 1] || raw; } // 3) 清理常见前缀/包裹 txt = txt .replace(/^[\*\#\-\>\s]+/, '') .replace(/^["'「『((]+/, '') .replace(/["'」』))]+$/, '') .replace(/^答案[::]\s*/i, '') .replace(/^正确答案[::]\s*/i, '') .replace(/^答[::]\s*/i, '') .replace(/^选\s*[::]\s*/i, '') .trim(); // JUDGE 提取"正确/错误" if (type === this.TYPE_JUDGE) { if (/正确|对|是|true|^t$|✓|right|yes/i.test(txt)) return ['正确']; if (/错误|错|否|false|^f$|✗|wrong|no/i.test(txt)) return ['错误']; return [txt]; } // 4) 多答案分隔符兼容:|| / | / 、 / , / , / ;/ ; / 顿号 const splitRe = /\s*(?:\|\||[、,,;;])\s*/; let parts = splitRe.test(txt) ? txt.split(splitRe).map(s => s.trim()).filter(Boolean) : [txt]; // 5) 剥离每项前可能的字母编号:如 "A. 塑性和韧性" → "塑性和韧性" parts = parts.map(p => p.replace(/^[A-Ha-h]\s*[.\.\、\:\:\)\)]\s*/, '').trim()).filter(Boolean); // 单选只取一个 if (type === this.TYPE_SINGLE) return parts.slice(0, 1); return parts; }, }; // ============== Detector:找题目 / 选项 / 题型 ============== const Detector = { // 一个容器是否"看起来像一道完整题目" _looksLikeQuestion(el) { if (!el || Utils.isOwnPanel(el)) return false; const radios = el.querySelectorAll('input[type="radio"]'); const checks = el.querySelectorAll('input[type="checkbox"]'); const texts = el.querySelectorAll('input[type="text"], textarea'); // 单选/多选/判断至少要 2 个 radio/checkbox;填空/简答至少 1 个 text/textarea if (radios.length >= 2) return true; if (checks.length >= 2) return true; if (texts.length >= 1 && radios.length === 0 && checks.length === 0) return true; return false; }, // 同源 iframe 内的 document 集合(按顶层 + 嵌套 iframe 收集) _allDocs() { const docs = [document]; const scan = (doc) => { try { doc.querySelectorAll('iframe').forEach(f => { try { const d = f.contentDocument; if (d && !docs.includes(d)) { docs.push(d); scan(d); } } catch (_) { /* 跨域 */ } }); } catch (_) {} }; scan(document); return docs; }, // 找出页面上所有题目容器(启发式,覆盖常见 LMS DOM) findQuestions() { const candidates = new Set(); const docs = this._allDocs(); // 题目容器特征:明确语义类名 const selectors = [ '.question:not(.questions)', '.exam-question', '.quiz-question', '[class*="question-item"]', '[class*="QuestionItem"]', '[class*="exam-item"]:not([class*="exam-item-list"])', '[class*="quiz-item"]:not([class*="quiz-item-list"])', '[data-question-id]', '[data-questionid]', '[data-quiz-id]', // Moodle 风格 '.que', '.formulation', // 国开学习网常用包装 '[class*="single"][class*="choice"]', '[class*="multi"][class*="choice"]', '[class*="judge"]', '[class*="fill"]', ]; docs.forEach(doc => { selectors.forEach(sel => { try { doc.querySelectorAll(sel).forEach(el => { if (!this._looksLikeQuestion(el)) return; const rect = el.getBoundingClientRect(); if (rect.height > window.innerHeight * 4) return; candidates.add(el); }); } catch (_) { /* 非法 CSS */ } }); }); // ---- 核心兜底:从每个 radio/checkbox 向上爬 DOM,找包含 ≥2 个同类输入的最小父级 ---- // 这种方法不依赖 input.name 属性,适用于 React/Vue LMS(很多框架 radio 无 name) const allInputs = []; docs.forEach(doc => { try { doc.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(i => { if (!Utils.isOwnPanel(i) && i.offsetParent !== null) allInputs.push(i); }); } catch (_) {} }); const seenInputs = new WeakSet(); for (const inp of allInputs) { if (seenInputs.has(inp)) continue; // Step 1: 找最小的父级,里面 radio/checkbox ≥ 2 let p = inp.parentElement; let groupP = null; const bodyOfInp = inp.ownerDocument?.body; while (p && p !== bodyOfInp && !Utils.isOwnPanel(p)) { const cnt = p.querySelectorAll('input[type="radio"], input[type="checkbox"]').length; if (cnt >= 2) { groupP = p; break; } p = p.parentElement; } if (!groupP) continue; const groupInputs = Array.from(groupP.querySelectorAll('input[type="radio"], input[type="checkbox"]')); // Step 2: 再向上爬,找到包含题干文字的容器(停止条件:上一层包含了其他题的 radio) let container = groupP; const optTxt = groupInputs .map(i => Utils.safeText(i.closest('label, li, [class*="option"]') || i)) .join(''); const optLen = optTxt.length; for (let depth = 0; depth < 6; depth++) { const next = container.parentElement; if (!next || next === bodyOfInp || Utils.isOwnPanel(next)) break; // 上一层是否包含了别的题的 radio?是则停止 const upRadios = next.querySelectorAll('input[type="radio"], input[type="checkbox"]').length; if (upRadios > groupInputs.length) break; const stemLen = Utils.safeText(next).length; container = next; // 上层文本明显多于选项总和 → 已经包含题干,可以用这一层 if (stemLen - optLen > 8) break; } candidates.add(container); groupInputs.forEach(i => seenInputs.add(i)); } // 文本框填空题:向上爬找上下文容器 docs.forEach(doc => { try { doc.querySelectorAll('input[type="text"], textarea').forEach(inp => { if (Utils.isOwnPanel(inp) || !inp.offsetParent) return; const bodyOfInp = inp.ownerDocument?.body; let wrap = inp.parentElement; for (let depth = 0; depth < 6 && wrap && wrap !== bodyOfInp; depth++) { if (Utils.isOwnPanel(wrap)) break; if (Utils.safeText(wrap).length > 20) break; wrap = wrap.parentElement; } if (wrap && !Utils.isOwnPanel(wrap)) candidates.add(wrap); }); } catch (_) {} }); // 去重嵌套:保留**最外层**容器 const arr = Array.from(candidates); return arr.filter(a => !arr.some(b => b !== a && b.contains(a))); }, // ---- 调试:扫描页面,输出关键统计 ---- diagnose() { const docs = this._allDocs(); const allRadios = [], allChecks = [], allTexts = [], allTareas = []; docs.forEach(doc => { try { doc.querySelectorAll('input[type="radio"]').forEach(r => allRadios.push(r)); doc.querySelectorAll('input[type="checkbox"]').forEach(c => allChecks.push(c)); doc.querySelectorAll('input[type="text"]').forEach(t => allTexts.push(t)); doc.querySelectorAll('textarea').forEach(t => allTareas.push(t)); } catch (_) {} }); // 顶层 iframe(含跨域) const topIframes = Array.from(document.querySelectorAll('iframe')); // 收集 radio 的常见 class 名 const sampleClasses = new Set(); const namedCount = allRadios.filter(r => r.name).length; const visibleRadios = allRadios.filter(r => r.offsetParent !== null).length; allRadios.slice(0, 5).forEach(r => { const wrap = r.closest('label, [class]'); if (wrap?.className) sampleClasses.add(String(wrap.className).slice(0, 80)); }); const iframeInfo = topIframes.map(f => ({ src: (f.src || '').slice(0, 80), sameOrigin: (() => { try { return !!f.contentDocument; } catch (_) { return false; } })(), rect: f.getBoundingClientRect(), })).filter(i => i.rect.width > 100); return { docsScanned: docs.length, radios: allRadios.length, visibleRadios, namedRadios: namedCount, checkboxes: allChecks.length, texts: allTexts.length, textareas: allTareas.length, iframes: topIframes.length, sameOriginIframes: iframeInfo.filter(i => i.sameOrigin).length, sampleWrapperClasses: Array.from(sampleClasses), iframeUrls: iframeInfo.map(i => i.src), questionsFound: this.findQuestions().length, url: location.href.slice(0, 120), }; }, // 提取题干文本 extractStem(container) { // 1) 优先找有明确语义的标题元素 const stemSel = [ '[class*="stem"]', '[class*="Stem"]', '[class*="qContent"]', '[class*="question-content"]', '[class*="QuestionContent"]', '[class*="title"]:not([class*="option"])', '[class*="Title"]:not([class*="Option"])', '.qtext', '.question-text', '.formulation', '.q-title', 'h3', 'h4', ]; for (const s of stemSel) { try { const el = container.querySelector(s); if (el && !el.querySelector('input[type="radio"], input[type="checkbox"]')) { const t = Utils.safeText(el); if (t.length > 5) return t; } } catch (_) {} } // 2) 克隆容器,剥掉选项/输入区/题型标签后取剩余文本 const clone = container.cloneNode(true); clone.querySelectorAll( 'input, textarea, select, button, ' + '[class*="option"], [class*="Option"], ' + '[class*="answer-input"], [class*="Answer"], ' + '[class*="choice"], [class*="Choice"], ' + '.answers, label' ).forEach(n => n.remove()); let t = Utils.safeText(clone); // 剥掉常见的"单选题 (2分)" / "多选题 (2分)" / "判断题" 等元数据 t = t.replace(/^(单选题|多选题|判断题|填空题|简答题|论述题)\s*[\((]?\s*\d*\s*分?\s*[\))]?\s*/g, '').trim(); // 剥前导题号 "1." / "1、" / "第1题" t = t.replace(/^\s*第?\s*\d+\s*[.、.::题]\s*/, '').trim(); if (t.length >= 5) return t.slice(0, 800); // 3) 兜底:取 container.innerText 但 排除选项文本 const optsText = Array.from(container.querySelectorAll( 'label, [class*="option"], [class*="Option"], [class*="choice"]' )).map(n => Utils.safeText(n)).join(' '); let fullTxt = Utils.safeText(container); if (optsText) { // 逐个剥掉每个选项文本 Array.from(container.querySelectorAll('label, [class*="option"]')).forEach(n => { const ot = Utils.safeText(n); if (ot) fullTxt = fullTxt.replace(ot, ' '); }); } fullTxt = fullTxt.replace(/\s+/g, ' ') .replace(/^(单选题|多选题|判断题|填空题|简答题|论述题)\s*[\((]?\s*\d*\s*分?\s*[\))]?\s*/g, '') .replace(/^\s*第?\s*\d+\s*[.、.::题]\s*/, '') .trim(); return fullTxt.slice(0, 800) || '(题干为空,请人工核对)'; }, // 提取选项 [{ text, node, input }] extractOptions(container) { const out = []; // 优先按 radio/checkbox 反查 const inputs = container.querySelectorAll('input[type="radio"], input[type="checkbox"]'); inputs.forEach(inp => { if (Utils.isOwnPanel(inp)) return; // 找最近的选项包装:label / li / [class*="option"] const wrap = inp.closest('label, li, [class*="option"], [class*="Option"], .answer') || inp.parentElement; if (!wrap) return; let text = Utils.safeText(wrap); // 剥掉前缀字母 text = text.replace(/^\s*[A-Ha-h]\s*[.、.::))]\s*/, '').trim(); if (text) out.push({ text, node: wrap, input: inp }); }); if (out.length) return out; // 文本框 / textarea const textInputs = container.querySelectorAll('input[type="text"], textarea'); textInputs.forEach(inp => { if (Utils.isOwnPanel(inp)) return; out.push({ text: '__FILL__', node: inp, input: inp, isFill: true }); }); return out; }, // 推断题型 detectType(container, opts) { const txt = Utils.safeText(container); // 判断题(包含"对/错"或"正确/错误"且只有 2 个选项) if (opts.length === 2) { const optTxt = opts.map(o => o.text).join('|'); if (/^(对|错|正确|错误|是|否|T|F|True|False)$/i.test(opts[0].text) || /对错|正确.*错误|true.*false|判断/i.test(txt + optTxt)) { return AI.TYPE_JUDGE; } } if (/判断题|true.*or.*false/i.test(txt)) return AI.TYPE_JUDGE; // 多选 if (opts.some(o => o.input?.type === 'checkbox')) return AI.TYPE_MULTI; if (/多选题|多项选择|all\s+that\s+apply/i.test(txt)) return AI.TYPE_MULTI; // 单选 if (opts.some(o => o.input?.type === 'radio')) return AI.TYPE_SINGLE; if (/单选题|单项选择/i.test(txt)) return AI.TYPE_SINGLE; // 填空 / 简答 if (opts.some(o => o.isFill)) { const ta = opts.find(o => o.input?.tagName === 'TEXTAREA'); if (ta || /简答|论述|分析|阐述/i.test(txt)) return AI.TYPE_ESSAY; return AI.TYPE_FILL; } return AI.TYPE_UNKNOWN; }, // 判断当前题目是否已经作答 isAnswered(container, opts) { // radio/checkbox: 任一已选 if (opts.some(o => o.input?.checked)) return true; // text/textarea: 已填值 if (opts.some(o => o.isFill && o.input?.value?.trim())) return true; return false; }, // 找"下一题" / "提交" 按钮 findNextButton() { // 优先按文字 const nextRe = /^(下一题|下一页|下一步|Next|继续|next)\s*[>›→»]?$/i; const submitRe = /^(提交|提交答案|确认提交|Submit)$/i; const candidates = document.querySelectorAll('button, a, [role="button"], input[type="submit"]'); let next = null, submit = null; for (const btn of candidates) { if (Utils.isOwnPanel(btn)) continue; const t = Utils.safeText(btn) || btn.value || ''; if (!next && nextRe.test(t) && btn.offsetParent !== null) next = btn; if (!submit && submitRe.test(t) && btn.offsetParent !== null) submit = btn; } return next || submit; }, }; // ============== Solver:填答案 ============== const Solver = { // 把 AI 答案匹配到选项索引集合(仅对 single/multi/judge) _matchAnswersToIndices(opts, answers, type) { const targets = []; if (type === AI.TYPE_JUDGE) { const ans = String(answers[0] || ''); const isTrue = /正确|对|是|true|t|✓|right|yes/i.test(ans); for (let i = 0; i < opts.length; i++) { const t = opts[i].text; if (isTrue && /^(正确|对|是|true|T|✓|right|yes)$/i.test(t)) { targets.push(i); break; } if (!isTrue && /^(错误|错|否|false|F|✗|wrong|no)$/i.test(t)) { targets.push(i); break; } } return targets; } // SINGLE / MULTI const used = new Set(); for (const ans of answers) { const ansN = Utils.normalize(ans); if (!ansN) continue; let best = -1, bestScore = 0; for (let i = 0; i < opts.length; i++) { if (used.has(i)) continue; const optN = Utils.normalize(opts[i].text); if (!optN) continue; let s = 0; if (optN === ansN) s = 100; else if (optN.includes(ansN) || ansN.includes(optN)) { const minL = Math.min(optN.length, ansN.length); const maxL = Math.max(optN.length, ansN.length); s = (minL / maxL) * 80; } else { const m = String(ans).match(/^\s*([A-Ha-h])\b/); if (m && i === (m[1].toUpperCase().charCodeAt(0) - 65)) s = 60; } if (s > bestScore) { bestScore = s; best = i; } } if (best >= 0 && bestScore >= 50) { targets.push(best); used.add(best); } } // —— Fallback:上面没匹配到任何选项,扫描原始 AI 文本回捞 —— // 适用于 reasoner 思考被截断 / AI 输出格式跑偏的情况 if (!targets.length && answers._raw) { const rawN = Utils.normalize(answers._raw); if (rawN) { // 优先策略 A:从原文里找 "正确答案是 ABC" / "选 ABCD" / "答案:A、B、C" 这种字母枚举 const letterEnum = answers._raw.match(/(?:正确答案|答案|选|应选|选项)(?:是|为|应该是|应为|包括)?\s*[::]?\s*([A-Ha-h](?:[\s,,、和与及]*[A-Ha-h]){0,7})/); if (letterEnum) { const letters = letterEnum[1].match(/[A-Ha-h]/g) || []; letters.forEach(L => { const idx = L.toUpperCase().charCodeAt(0) - 65; if (idx >= 0 && idx < opts.length && !targets.includes(idx)) targets.push(idx); }); } // 策略 B:扫描每个选项文本是否作为子串出现在 AI 原文中 // ⚠️ 仅在原文较短(< 200 字)时启用,避免 reasoner 思考文本中提到所有选项造成误选 if (!targets.length && rawN.length < 200) { for (let i = 0; i < opts.length; i++) { const optN = Utils.normalize(opts[i].text); if (!optN || optN.length < 3) continue; if (rawN.includes(optN) && !targets.includes(i)) targets.push(i); } } } } if (type === AI.TYPE_SINGLE) return targets.slice(0, 1); return targets; }, // 根据 AI 答案应用到 DOM(异步:状态同步式,逐项 verify) async applyAnswer(opts, answers, type) { if (!answers?.length) return { success: false, msg: '无答案' }; // 填空 / 简答 if (type === AI.TYPE_FILL || type === AI.TYPE_ESSAY) { const fills = opts.filter(o => o.isFill); if (!fills.length) return { success: false, msg: '无填空框' }; if (type === AI.TYPE_ESSAY) { Utils.nativeSet(fills[0].input, answers[0]); return { success: true, msg: `已填入 ${answers[0].length} 字` }; } let count = 0; for (let i = 0; i < fills.length; i++) { const a = answers[i] || answers[answers.length - 1] || ''; if (a) { Utils.nativeSet(fills[i].input, a); count++; } } return { success: count > 0, msg: `已填 ${count}/${fills.length} 空` }; } // radio/checkbox 类:先匹配出目标索引集合 const targets = this._matchAnswersToIndices(opts, answers, type); if (!targets.length) return { success: false, msg: 'AI 答案未匹配任何选项' }; const wantSet = new Set(targets); // 状态同步:逐个 option 把 checked 校正到目标态 let toCheck = 0, toUncheck = 0, kept = 0, failed = 0; // 先取消错选(很重要!多选/单选状态同步前先清不需要的,避免单选互斥冲突) for (let i = 0; i < opts.length; i++) { const opt = opts[i]; if (!opt.input || opt.isFill) continue; const want = wantSet.has(i); const have = !!opt.input.checked; if (!want && have) { const ok = await Utils.toggleChecked(opt.input, opt.node, false); if (ok) toUncheck++; else failed++; } } // 再勾选目标 for (let i = 0; i < opts.length; i++) { const opt = opts[i]; if (!opt.input || opt.isFill) continue; const want = wantSet.has(i); const have = !!opt.input.checked; if (want && have) { kept++; continue; } if (want && !have) { const ok = await Utils.toggleChecked(opt.input, opt.node, true); if (ok) toCheck++; else failed++; } } const total = wantSet.size; const ok = toCheck + kept; const parts = []; if (toCheck) parts.push(`新选 ${toCheck}`); if (kept) parts.push(`保持 ${kept}`); if (toUncheck) parts.push(`取消 ${toUncheck}`); if (failed) parts.push(`失败 ${failed}`); return { success: ok > 0 && failed === 0, msg: `${ok}/${total} 项已选中${parts.length ? '(' + parts.join(',') + ')' : ''}`, }; }, }; // ============== Runner:自动答题主循环 ============== const Runner = { pause() { isPaused = true; }, resume() { isPaused = false; }, isPaused() { return isPaused; }, async runOne(container, idx, total, logger) { await waitWhilePaused(logger); if (stopRequested) return { ok: false, msg: '已停止' }; const stem = Detector.extractStem(container); const opts = Detector.extractOptions(container); const type = Detector.detectType(container, opts); const typeLabel = { single: '单选', multi: '多选', judge: '判断', fill: '填空', essay: '简答', unknown: '未知' }[type]; logger(`📝 [${idx + 1}/${total}] ${typeLabel}题(${opts.length} 项)`); logger(` 题干:${stem.slice(0, 80)}${stem.length > 80 ? '…' : ''}`); // 防呆:单选/多选/判断 至少要有 2 个选项 if ((type === AI.TYPE_SINGLE || type === AI.TYPE_MULTI || type === AI.TYPE_JUDGE) && opts.length < 2) { logger(` ⚠️ ${typeLabel}题但只有 ${opts.length} 个选项,怀疑识别错误,跳过`); stats.skipped++; return { ok: false, msg: '选项不足' }; } if (type === AI.TYPE_UNKNOWN || !opts.length) { logger(` ⚠️ 无法识别题型,跳过`); stats.skipped++; return { ok: false, msg: '题型未识别' }; } // 显示当前已选状态(便于核对) const checkedNow = opts.filter(o => o.input?.checked).map(o => o.text.slice(0, 12)); if (checkedNow.length) { logger(` 📌 当前已选:${checkedNow.join(' / ')}`); } try { container.scrollIntoView({ behavior: 'smooth', block: 'center' }); await Utils.sleep(200); } catch (_) {} try { const answers = await AI.call(stem, opts.map(o => o.text), type, logger); const fbTag = answers._fallback ? `[降级→${answers._fallback}] ` : ''; logger(` 🤖 AI:${fbTag}${answers.join(' / ').slice(0, 100)}`); await Utils.sleep(Config.afterClickMs); const r = await Solver.applyAnswer(opts, answers, type); if (r.success) { logger(` ✅ ${r.msg}`); stats.success++; return { ok: true }; } else { logger(` ❌ ${r.msg}`); stats.failed++; return { ok: false, msg: r.msg }; } } catch (e) { logger(` ❌ AI 调用失败:${e.message || e}`); stats.failed++; return { ok: false, msg: String(e) }; } }, async runAll(logger) { if (isRunning) { logger('⚠️ 已经在运行中'); return; } isRunning = true; stopRequested = false; isPaused = false; stats = { total: 0, success: 0, failed: 0, skipped: 0 }; logger('🚀 开始自动答题'); try { let page = 1; while (!stopRequested) { const questions = Detector.findQuestions(); if (!questions.length) { logger(`⚠️ 第 ${page} 页未找到题目,尝试 "下一题"...`); const next = Detector.findNextButton(); if (next) { logger(` 点击 "${Utils.safeText(next).slice(0, 20)}"`); Utils.safeClick(next); await Utils.sleep(2000); page++; continue; } logger('✅ 找不到更多题目,结束'); break; } logger(`📚 第 ${page} 页:${questions.length} 道题`); stats.total += questions.length; for (let i = 0; i < questions.length; i++) { if (stopRequested) break; await this.runOne(questions[i], i, questions.length, logger); await Utils.sleep(Store.getInterMs()); } if (stopRequested) break; // 一页处理完,看看是否需要进下一页 if (!Store.getAutoNext()) { logger('⏸ 已关闭自动翻页,停留在当前页'); break; } const next = Detector.findNextButton(); if (!next) { logger('✅ 没有 "下一题/提交" 按钮,结束'); break; } logger(`➡️ 进入下一页(${Utils.safeText(next).slice(0, 20)})`); Utils.safeClick(next); await Utils.sleep(2000); page++; } } catch (e) { logger(`❌ 主循环异常:${e.message || e}`); } finally { isRunning = false; logger(`🎉 完成:成功 ${stats.success} · 失败 ${stats.failed} · 跳过 ${stats.skipped} · 共 ${stats.total}`); } }, async runCurrentPage(logger) { if (isRunning) { logger('⚠️ 已经在运行中'); return; } isRunning = true; stopRequested = false; isPaused = false; stats = { total: 0, success: 0, failed: 0, skipped: 0 }; logger('🎯 只刷当前页'); try { const questions = Detector.findQuestions(); if (!questions.length) { logger('⚠️ 当前页未找到题目'); return; } stats.total = questions.length; for (let i = 0; i < questions.length; i++) { if (stopRequested) break; await this.runOne(questions[i], i, questions.length, logger); await Utils.sleep(Store.getInterMs()); } } finally { isRunning = false; logger(`🎉 完成:成功 ${stats.success} · 失败 ${stats.failed} · 跳过 ${stats.skipped} · 共 ${stats.total}`); } }, stop() { stopRequested = true; isPaused = false; }, }; // ============== UI ============== const UI = { panelEl: null, ballEl: null, logEl: null, init() { this.injectStyles(); this.createBall(); }, injectStyles() { if (document.getElementById('ouchn-styles')) return; const style = document.createElement('style'); style.id = 'ouchn-styles'; style.textContent = ` #ouchn-ball,#ouchn-panel,#ouchn-settings{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; } #ouchn-ball{ position:fixed;right:20px;bottom:80px;width:48px;height:48px;border-radius:50%; background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;font-size:22px; display:flex;align-items:center;justify-content:center;cursor:pointer; box-shadow:0 8px 20px rgba(59,130,246,.4);z-index:99998; transition:transform .15s;user-select:none; } #ouchn-ball:hover{transform:scale(1.08);} #ouchn-ball.running{animation:ouchn-pulse 1.2s ease-in-out infinite;} @keyframes ouchn-pulse{0%,100%{box-shadow:0 8px 20px rgba(59,130,246,.4)}50%{box-shadow:0 8px 30px rgba(139,92,246,.7)}} @keyframes ouchn-slidein{from{transform:translateY(20px) scale(.96);opacity:0}to{transform:translateY(0) scale(1);opacity:1}} #ouchn-panel{ position:fixed;right:24px;bottom:140px;width:380px;max-height:80vh;display:flex;flex-direction:column; background:#0f172a;color:#e2e8f0;border-radius:16px;z-index:99999; box-shadow:0 24px 60px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.06); animation:ouchn-slidein .25s cubic-bezier(.34,1.56,.64,1);overflow:hidden; } .oc-header{ padding:14px 16px;display:flex;align-items:center;justify-content:space-between; background:linear-gradient(135deg,rgba(59,130,246,.15),rgba(139,92,246,.15)); border-bottom:1px solid rgba(255,255,255,.06);cursor:move;user-select:none; } .oc-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px;color:#f1f5f9;} .oc-dot{width:8px;height:8px;border-radius:50%;background:#10b981;box-shadow:0 0 8px #10b981;} .oc-dot.running{background:#06b6d4;animation:ouchn-pulse 1.2s ease-in-out infinite;} .oc-iconbtn{ background:rgba(255,255,255,.08);color:#e2e8f0;border:none;width:28px;height:28px; border-radius:7px;cursor:pointer;font-size:13px;display:inline-flex;align-items:center; justify-content:center;transition:background .15s; } .oc-iconbtn:hover{background:rgba(255,255,255,.16);} /* 微信反馈条 */ .oc-wechat-tip{ padding:7px 14px;font-size:11px;line-height:1.5;color:#fbbf24; background:linear-gradient(90deg,rgba(245,158,11,.12),rgba(245,158,11,.04)); border-bottom:1px solid rgba(245,158,11,.18); display:flex;align-items:center;gap:6px;flex-wrap:wrap; } .oc-wechat-copy{ display:inline-flex;align-items:center;gap:4px;padding:1px 8px; background:rgba(255,255,255,.08);border:1px solid rgba(245,158,11,.3); border-radius:5px;cursor:pointer;font-weight:600;color:#fcd34d; transition:all .15s;user-select:none; } .oc-wechat-copy:hover{background:rgba(245,158,11,.18);border-color:#f59e0b;color:#fff;} .oc-wechat-copy.copied{background:rgba(16,185,129,.18);border-color:#10b981;color:#34d399;} /* 公告条(远程拉取,默认隐藏) */ .oc-announce{ display:none;padding:8px 14px;font-size:11px;line-height:1.55; border-bottom:1px solid rgba(59,130,246,.22); align-items:flex-start;gap:6px;position:relative; } .oc-announce.show{display:flex;animation:ocAnnounceIn .35s ease;} @keyframes ocAnnounceIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}} .oc-announce.info {color:#93c5fd;background:linear-gradient(90deg,rgba(59,130,246,.16),rgba(59,130,246,.04));border-color:rgba(59,130,246,.25);} .oc-announce.warning{color:#fcd34d;background:linear-gradient(90deg,rgba(245,158,11,.16),rgba(245,158,11,.04));border-color:rgba(245,158,11,.3);} .oc-announce.error {color:#fca5a5;background:linear-gradient(90deg,rgba(239,68,68,.16),rgba(239,68,68,.04));border-color:rgba(239,68,68,.3);} .oc-announce.success{color:#86efac;background:linear-gradient(90deg,rgba(34,197,94,.16),rgba(34,197,94,.04));border-color:rgba(34,197,94,.3);} .oc-announce-icon{flex-shrink:0;font-size:13px;line-height:1.3;} .oc-announce-body{flex:1;min-width:0;} .oc-announce-title{font-weight:600;margin-bottom:2px;font-size:11.5px;} .oc-announce-content{font-size:11px;color:rgba(255,255,255,.85);line-height:1.55;word-break:break-word;} .oc-announce-content b{color:#fde68a;font-weight:600;} .oc-announce-close{ position:absolute;top:4px;right:8px;cursor:pointer;color:rgba(255,255,255,.5); font-size:14px;line-height:1;padding:2px;user-select:none; } .oc-announce-close:hover{color:#fff;} .oc-body{padding:14px;overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:12px;} .oc-body::-webkit-scrollbar{width:6px} .oc-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px} .oc-section{ background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06); border-radius:10px;padding:12px; } .oc-section-title{font-size:11px;color:#94a3b8;font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;} .oc-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;} .oc-row:last-child{margin-bottom:0;} .oc-label{font-size:12px;color:#cbd5e1;flex:1;font-weight:500;} .oc-input,.oc-select{ width:100%;padding:7px 10px;background:#1e293b;color:#f1f5f9;border:1px solid rgba(255,255,255,.1); border-radius:7px;font-size:12px;box-sizing:border-box;font-family:inherit; transition:border-color .15s; } .oc-input:focus,.oc-select:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.2);} .oc-select{cursor:pointer;} .oc-btn{ padding:9px 14px;background:linear-gradient(135deg,#3b82f6,#8b5cf6);color:#fff;border:none; border-radius:7px;font-size:13px;font-weight:600;cursor:pointer; font-family:inherit;display:inline-flex;align-items:center;justify-content:center;gap:6px; transition:transform .12s,box-shadow .15s; } .oc-btn:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(59,130,246,.35);} .oc-btn:active{transform:translateY(0);} .oc-btn-ghost{background:rgba(255,255,255,.08);color:#cbd5e1;} .oc-btn-ghost:hover{background:rgba(255,255,255,.16);box-shadow:none;} .oc-btn-danger{background:linear-gradient(135deg,#ef4444,#dc2626);} .oc-btn-success{background:linear-gradient(135deg,#10b981,#059669);} .oc-btn-sm{padding:5px 9px;font-size:11px;} .oc-btn:disabled{opacity:.5;cursor:not-allowed;} .oc-switch{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;} .oc-switch input{opacity:0;width:0;height:0;} .oc-switch-slider{position:absolute;cursor:pointer;inset:0;background:#334155;border-radius:20px;transition:.2s;} .oc-switch-slider:before{ content:'';position:absolute;height:14px;width:14px;left:3px;bottom:3px;background:#fff; border-radius:50%;transition:.2s; } .oc-switch input:checked + .oc-switch-slider{background:linear-gradient(135deg,#3b82f6,#8b5cf6);} .oc-switch input:checked + .oc-switch-slider:before{transform:translateX(16px);} .oc-key-row{position:relative;} .oc-key-toggle{ position:absolute;right:8px;top:50%;transform:translateY(-50%); background:none;border:none;color:#94a3b8;cursor:pointer;font-size:14px;padding:0 4px; } .oc-key-toggle:hover{color:#3b82f6;} .oc-model-hint{font-size:10px;color:#64748b;margin-top:4px;line-height:1.4;} #ouchn-log{ height:160px;overflow-y:auto;background:#020617;border:1px solid rgba(255,255,255,.06); border-radius:8px;padding:8px 10px;font-family:'JetBrains Mono','Cascadia Code',Consolas,monospace; font-size:11px;color:#94a3b8;line-height:1.5; } #ouchn-log::-webkit-scrollbar{width:5px} #ouchn-log::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px} #ouchn-log > div{padding:1px 0;word-break:break-all;} #ouchn-log .log-ok{color:#10b981;} #ouchn-log .log-warn{color:#f59e0b;} #ouchn-log .log-err{color:#ef4444;} #ouchn-log .log-ai{color:#a78bfa;} .oc-footer{ padding:8px 14px;font-size:10px;color:#64748b;display:flex;align-items:center;justify-content:space-between; background:rgba(0,0,0,.2);border-top:1px solid rgba(255,255,255,.04); } .oc-stat{display:flex;gap:10px;font-size:11px;padding:6px 10px;background:rgba(0,0,0,.2);border-radius:6px;} .oc-stat span{display:flex;align-items:center;gap:3px;} .oc-stat .ok{color:#10b981;} .oc-stat .fail{color:#ef4444;} .oc-stat .skip{color:#f59e0b;} /* 验证码区块 */ .oc-verify{ background:linear-gradient(135deg,rgba(16,185,129,.08),rgba(59,130,246,.08)); border:1px solid rgba(16,185,129,.25); } .oc-verify.locked{ background:linear-gradient(135deg,rgba(245,158,11,.08),rgba(239,68,68,.05)); border-color:rgba(245,158,11,.3); } .oc-verify-grid{display:flex;gap:10px;align-items:flex-start;} .oc-verify-qr-wrap{position:relative;flex-shrink:0;} .oc-verify-qr{ width:110px;height:110px;border-radius:6px;background:#fff;display:block; object-fit:contain;cursor:zoom-in;border:1px solid rgba(255,255,255,.12);padding:4px; image-rendering:crisp-edges;transition:box-shadow .15s; } .oc-verify-qr:hover{box-shadow:0 0 0 2px rgba(59,130,246,.4);} /* hover 放大预览:浮在二维码上方 */ .oc-verify-qr-zoom{ position:absolute;top:50%;left:118px;transform:translateY(-50%) scale(.92); width:220px;height:220px;border-radius:8px;background:#fff;padding:8px; box-shadow:0 12px 32px rgba(0,0,0,.6),0 0 0 1px rgba(255,255,255,.15); opacity:0;pointer-events:none;transition:opacity .18s, transform .18s;z-index:10; object-fit:contain;image-rendering:crisp-edges; } .oc-verify-qr-wrap:hover .oc-verify-qr-zoom{ opacity:1;transform:translateY(-50%) scale(1); } .oc-verify-qr-tip{ font-size:9px;color:#94a3b8;text-align:center;margin-top:3px;line-height:1.2; } .oc-verify-right{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0;} .oc-verify-hint{font-size:10px;color:#94a3b8;line-height:1.5;} .oc-verify-input-group{display:flex;gap:6px;} .oc-verify-input{ flex:1;padding:7px 10px;background:#1e293b;color:#f1f5f9; border:1px solid rgba(255,255,255,.1);border-radius:7px; font-size:14px;font-weight:600;letter-spacing:4px;text-align:center; font-family:inherit; } .oc-verify-input:focus{outline:none;border-color:#10b981;box-shadow:0 0 0 3px rgba(16,185,129,.2);} .oc-verify-input:disabled{opacity:.6;cursor:not-allowed;} .oc-verify-status{font-size:11px;line-height:1.4;} .oc-verify-status.ok{color:#34d399;} .oc-verify-status.err{color:#f87171;} .oc-verify-status.warn{color:#fbbf24;} .oc-btn-warn{background:linear-gradient(135deg,#f59e0b,#d97706);} .oc-btn-info{background:linear-gradient(135deg,#0ea5e9,#0284c7);} `; document.head.appendChild(style); }, createBall() { const ball = document.createElement('div'); ball.id = 'ouchn-ball'; ball.setAttribute('data-ouchn-helper', '1'); ball.title = '国开答题助手'; ball.textContent = '🎓'; ball.onclick = () => { if (this.panelEl) { const hidden = this.panelEl.style.display === 'none'; this.panelEl.style.display = hidden ? 'flex' : 'none'; } else { this.createPanel(); } }; document.body.appendChild(ball); this.ballEl = ball; }, createPanel() { const panel = document.createElement('div'); panel.id = 'ouchn-panel'; panel.setAttribute('data-ouchn-helper', '1'); const curModel = Store.getModel(); const apiKey = Store.getApiKey(); const autoNext = Store.getAutoNext(); const interMs = Store.getInterMs(); panel.innerHTML = `
🎓 国开答题助手 v${Config.version}
⚠️ BUG 反馈 / 购码请加微信 ${Config.wechat} 📋
📢
公告
×
${Store.isVerifyValid() ? '✅ 已验证' : '🔐 验证码激活'}
扫码获取验证码 扫码大图
悬停放大 / 点击新窗口
扫码看广告 → 获取 4 位验证码 → 输入下方激活,验证后免费使用 24 小时
${Store.isVerifyValid() ? `✅ 有效期至 ${Store.getValidUntilStr()}` : '⏳ 未验证,启动答题前需先激活'}
🤖 AI 模型
选择模型
${Config.models[curModel].hint}
DeepSeek API Key
→ 前往 DeepSeek 申请 Key
⚙️ 答题设置
答完自动翻页
题间间隔(毫秒)
📜 运行日志
`; document.body.appendChild(panel); this.panelEl = panel; this.logEl = panel.querySelector('#ouchn-log'); // 拖动 (() => { const header = panel.querySelector('.oc-header'); let dragging = false, ox = 0, oy = 0; header.addEventListener('mousedown', (e) => { if (e.target.closest('.oc-iconbtn')) return; dragging = true; ox = e.clientX - panel.getBoundingClientRect().left; oy = e.clientY - panel.getBoundingClientRect().top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!dragging) return; let x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, e.clientX - ox)); let y = Math.max(0, Math.min(window.innerHeight - 40, e.clientY - oy)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { dragging = false; }); })(); this.bindEvents(panel); Announce.fetch(panel); this.addLog('✅ 国开答题助手已就绪'); if (!apiKey) this.addLog('⚠️ 请先在上方填入 DeepSeek API Key', 'warn'); if (Store.isVerifyValid()) { this.addLog(`🔓 验证有效期至 ${Store.getValidUntilStr()}`); } else { this.addLog('🔒 未验证,扫码获取 4 位验证码后激活', 'warn'); } }, bindEvents(panel) { panel.querySelector('#oc-close').onclick = () => { panel.style.display = 'none'; }; panel.querySelector('#oc-min').onclick = () => { panel.style.display = 'none'; }; // 微信复制 const wxEl = panel.querySelector('#oc-wechat'); wxEl.onclick = async () => { const ok = await Utils.copyText(Config.wechat); wxEl.classList.add('copied'); wxEl.textContent = ok ? `✅ 已复制 ${Config.wechat}` : '❌ 复制失败'; setTimeout(() => { wxEl.classList.remove('copied'); wxEl.textContent = `${Config.wechat} 📋`; }, 1800); }; // 模型切换 const modelEl = panel.querySelector('#oc-model'); const hintEl = panel.querySelector('#oc-model-hint'); modelEl.onchange = () => { Store.setModel(modelEl.value); hintEl.textContent = Config.models[modelEl.value].hint; this.addLog(`✅ 模型:${Config.models[modelEl.value].name}`); }; // Key 输入 const keyEl = panel.querySelector('#oc-key'); keyEl.onblur = () => { const v = keyEl.value.trim(); Store.setApiKey(v); if (v) this.addLog('✅ API Key 已保存'); }; panel.querySelector('#oc-key-toggle').onclick = () => { keyEl.type = keyEl.type === 'password' ? 'text' : 'password'; }; // 自动翻页 const autoEl = panel.querySelector('#oc-autoNext'); autoEl.onchange = () => { Store.setAutoNext(autoEl.checked); this.addLog(`✅ 自动翻页:${autoEl.checked ? '开' : '关'}`); }; // 题间间隔 const interEl = panel.querySelector('#oc-interMs'); interEl.onblur = () => { Store.setInterMs(interEl.value); interEl.value = Store.getInterMs(); this.addLog(`✅ 题间间隔:${interEl.value} ms`); }; // 开始 / 暂停 / 继续 / 停止 / 当前页 const startBtn = panel.querySelector('#oc-start'); const onePageBtn = panel.querySelector('#oc-onepage'); const pauseBtn = panel.querySelector('#oc-pause'); const resumeBtn = panel.querySelector('#oc-resume'); const stopBtn = panel.querySelector('#oc-stop'); const statEl = panel.querySelector('#oc-stat'); // running=false 时只显示 启动 按钮组;running=true 时显示 暂停/继续/停止 const showRunning = (running, paused = false) => { startBtn.style.display = running ? 'none' : 'inline-flex'; onePageBtn.style.display = running ? 'none' : 'inline-flex'; pauseBtn.style.display = running && !paused ? 'inline-flex' : 'none'; resumeBtn.style.display = running && paused ? 'inline-flex' : 'none'; stopBtn.style.display = running ? 'inline-flex' : 'none'; statEl.style.display = (running || stats.total > 0) ? 'flex' : 'none'; const dot = this.panelEl.querySelector('.oc-dot'); dot.classList.toggle('running', running); dot.style.background = paused ? '#f59e0b' : ''; dot.style.boxShadow = paused ? '0 0 8px #f59e0b' : ''; this.ballEl?.classList.toggle('running', running); }; const updateStat = () => { panel.querySelector('#oc-stat-total').textContent = stats.total; panel.querySelector('#oc-stat-ok').textContent = stats.success; panel.querySelector('#oc-stat-fail').textContent = stats.failed; panel.querySelector('#oc-stat-skip').textContent = stats.skipped; }; const logger = (msg, level) => { this.addLog(msg, level); updateStat(); statEl.style.display = 'flex'; }; // 启动前的前置校验:API Key + 验证码 const preflight = () => { if (!Store.getApiKey()) { this.addLog('❌ 请先填写 DeepSeek API Key', 'err'); return false; } if (!Store.isVerifyValid()) { this.addLog('❌ 请先在顶部"验证码激活"区扫码并输入 4 位验证码', 'err'); // 闪烁提示 const sec = panel.querySelector('#oc-verify-section'); if (sec) { sec.scrollIntoView({ behavior: 'smooth', block: 'center' }); sec.style.transition = 'box-shadow .3s'; sec.style.boxShadow = '0 0 0 3px rgba(245,158,11,.5)'; setTimeout(() => { sec.style.boxShadow = ''; }, 1500); } return false; } return true; }; startBtn.onclick = async () => { if (!preflight()) return; showRunning(true, false); try { await Runner.runAll(logger); } finally { showRunning(false); updateStat(); } }; onePageBtn.onclick = async () => { if (!preflight()) return; showRunning(true, false); try { await Runner.runCurrentPage(logger); } finally { showRunning(false); updateStat(); } }; pauseBtn.onclick = () => { Runner.pause(); showRunning(true, true); this.addLog('⏸ 已暂停(点击 ▶️ 继续 恢复)', 'warn'); }; resumeBtn.onclick = () => { Runner.resume(); showRunning(true, false); this.addLog('▶️ 已继续答题'); }; stopBtn.onclick = () => { Runner.stop(); this.addLog('⏹ 已请求停止,等待当前题完成...', 'warn'); }; panel.querySelector('#oc-clear').onclick = () => { this.logEl.innerHTML = ''; }; // ---- 验证码 ---- const verifySection = panel.querySelector('#oc-verify-section'); const verifyTitle = panel.querySelector('#oc-verify-title'); const verifyCodeEl = panel.querySelector('#oc-verify-code'); const verifyBtn = panel.querySelector('#oc-verify-btn'); const verifyStatus = panel.querySelector('#oc-verify-status'); const verifyQr = panel.querySelector('#oc-verify-qr'); const setVerifySuccess = (untilStr) => { verifySection.classList.remove('locked'); verifyTitle.textContent = '✅ 已验证'; verifyCodeEl.disabled = true; verifyBtn.disabled = true; verifyBtn.style.opacity = '.5'; verifyBtn.style.cursor = 'not-allowed'; verifyStatus.innerHTML = `✅ 有效期至 ${untilStr}`; }; const doVerify = async () => { const code = (verifyCodeEl.value || '').trim(); if (code.length !== 4) { verifyStatus.innerHTML = '❌ 请输入 4 位验证码'; return; } verifyBtn.disabled = true; const originalText = verifyBtn.textContent; verifyBtn.textContent = '验证中...'; verifyStatus.innerHTML = '⏳ 验证中...'; try { const r = await Verify.verifyCode(code); this.addLog(`✅ 验证成功,有效期至 ${r.validUntilStr}`); setVerifySuccess(r.validUntilStr); verifyCodeEl.value = ''; } catch (err) { verifyStatus.innerHTML = `❌ ${err}`; this.addLog(`❌ 验证失败:${err}`, 'err'); verifyBtn.disabled = false; verifyBtn.textContent = originalText; } }; verifyBtn.onclick = doVerify; verifyCodeEl.onkeydown = (e) => { if (e.key === 'Enter') doVerify(); }; verifyCodeEl.oninput = () => { // 限制只能输 4 位数字/字母 verifyCodeEl.value = (verifyCodeEl.value || '').replace(/[^0-9A-Za-z]/g, '').slice(0, 4); }; // 点击二维码放大查看 verifyQr.onclick = () => { const w = window.open(Config.verifyQrUrl, '_blank', 'width=400,height=400'); if (!w) this.addLog('💡 浏览器拦截了弹窗,请允许后再试', 'warn'); }; // 调试扫描 panel.querySelector('#oc-diag').onclick = () => { const info = Detector.diagnose(); this.addLog(`🔍 ===== 页面诊断 =====`); this.addLog(` URL: ${info.url}`); this.addLog(` 扫描了 ${info.docsScanned} 个 document(顶层 + 同源 iframe)`); this.addLog(` radio: ${info.radios}(可见 ${info.visibleRadios},有 name ${info.namedRadios})`); this.addLog(` checkbox: ${info.checkboxes} · text: ${info.texts} · textarea: ${info.textareas}`); this.addLog(` iframe: ${info.iframes}(同源 ${info.sameOriginIframes})`); if (info.iframeUrls.length) this.addLog(` iframe URLs: ${info.iframeUrls.join(' | ')}`, 'warn'); if (info.sampleWrapperClasses.length) { this.addLog(` 选项 class 样本:`); info.sampleWrapperClasses.forEach(c => this.addLog(` · ${c}`)); } this.addLog(` 📊 当前检测到 ${info.questionsFound} 道题`, info.questionsFound > 0 ? 'ok' : 'err'); console.log('[国开诊断]', info); if (info.radios === 0 && info.iframes > 0) { this.addLog(` ⚠️ 题目可能在 iframe 内,需要扩大 @match 范围`, 'warn'); } }; // 高亮检测到的题目 panel.querySelector('#oc-highlight').onclick = () => { // 清除旧的高亮 document.querySelectorAll('[data-oc-highlight]').forEach(el => { el.style.outline = ''; el.removeAttribute('data-oc-highlight'); }); const qs = Detector.findQuestions(); this.addLog(`👁 高亮 ${qs.length} 道题(红框 = 题目容器)`); qs.forEach((q, i) => { q.style.outline = '3px solid #ef4444'; q.style.outlineOffset = '2px'; q.setAttribute('data-oc-highlight', String(i + 1)); }); if (qs[0]) qs[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => { document.querySelectorAll('[data-oc-highlight]').forEach(el => { el.style.outline = ''; el.removeAttribute('data-oc-highlight'); }); }, 5000); }; }, addLog(msg, level) { if (!this.logEl) { console.log('[国开助手]', msg); return; } const div = document.createElement('div'); const time = new Date().toLocaleTimeString('zh-CN', { hour12: false }); let cls = ''; if (level === 'ok' || /✅|🎉/.test(msg)) cls = 'log-ok'; else if (level === 'warn' || /⚠️|⏭|⏸/.test(msg)) cls = 'log-warn'; else if (level === 'err' || /❌/.test(msg)) cls = 'log-err'; else if (/🤖|AI/.test(msg)) cls = 'log-ai'; div.className = cls; div.textContent = `[${time}] ${msg}`; this.logEl.appendChild(div); this.logEl.scrollTop = this.logEl.scrollHeight; // 限制条数 while (this.logEl.children.length > 500) this.logEl.removeChild(this.logEl.firstChild); }, }; // ============== 启动 ============== function boot() { if (window.__OUCHN_HELPER_LOADED__) return; window.__OUCHN_HELPER_LOADED__ = true; // 只在顶层窗口显示面板(避免 iframe 内重复出现) if (window.top !== window.self) { console.log('[国开答题助手] 在 iframe 内加载,跳过 UI'); return; } UI.init(); console.log('[国开答题助手] v' + Config.version + ' 已加载'); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', boot); } else { boot(); } })();