// ==UserScript== // @name 国开答题助手 // @namespace https://github.com/leye/ouchn-helper // @version 1.1.0 // @description 国家开放大学(lms.ouchn.cn)AI 答题助手 · DeepSeek / 豆包 / 智谱 / 自定义接口,独立配置及连接测试,支持图文公告 // @author 叶屿 // @license GPL-3.0 // @antifeature payment AI 答题功能需要验证码(免费看广告获取)或激活码(付费),用户需自备所选服务的 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 ark.cn-beijing.volces.com // @connect open.bigmodel.cn // @connect * // @connect qsy.iano.cn // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // v1.1.0 (2026-10-15):AI 服务支持 DeepSeek、豆包、智谱及自定义兼容接口, // 分别保存 Key/模型并迁移旧配置;新增显式保存、配置校验和手动连接测试。 // 公告支持安全图文、正文折叠、关闭记忆/重开及缓存;优化窄屏布局。 // ============== 配置 ============== const Config = { version: '1.1.0', // 官方模型与端点核对日期:2026-10-15。各来源分别保存配置。 aiProviders: { deepseek: { name: 'DeepSeek', url: 'https://api.deepseek.com/chat/completions', model: 'deepseek-flash', hint: 'Flash 快速作答,Pro 使用思考模式;按官方用量计费。', models: [{ id: 'deepseek-flash', name: 'DeepSeek V4.1 Flash' }, { id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' }], keyUrl: 'https://platform.deepseek.com/api_keys', }, doubao: { name: '豆包', url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', model: '', hint: '填写火山方舟 API Key 和已开通的 Model ID,或 ep- 开头的推理接入点 ID。', keyUrl: 'https://console.volcengine.com/ark/', }, zhipu: { name: '智谱', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', model: 'glm-4.7-flash', hint: '使用智谱开放平台 API Key,模型需要在当前账号下可调用。', models: [{ id: 'glm-4.7-flash', name: 'GLM-4.7-Flash' }, { id: 'glm-4.7', name: 'GLM-4.7' }, { id: 'glm-5.3', name: 'GLM-5.3' }], keyUrl: 'https://bigmodel.cn/usercenter/proj-mgmt/apikeys', }, custom: { name: '自定义接口', url: '', model: '', hint: '支持 OpenAI Chat Completions 兼容接口;填写 Base URL 或完整 /chat/completions 地址。', }, }, // 答题节奏(避免被风控;可在设置里改) interQuestionMs: 1800, // 题间间隔 afterClickMs: 600, // 点击后等待 aiTimeoutMs: 90000, // AI 单次调用最长 90s(思考版可能久) 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 (_) {} }, _migrateAiConfig() { if (this.get('ouchn_ai_migrated', false)) return; const saved = this.get('ouchn_ai_config_deepseek', null); if (!saved || typeof saved !== 'object' || Array.isArray(saved)) { const oldKey = String(this.get('ouchn_dsk_key', '') || '').trim(); const oldModel = String(this.get('ouchn_model', 'v4') || 'v4'); const aliases = { v4: 'deepseek-flash', v4pro: 'deepseek-v4-pro', 'deepseek-chat': 'deepseek-flash', 'deepseek-reasoner': 'deepseek-v4-pro', 'deepseek-v4-flash': 'deepseek-flash' }; this.set('ouchn_ai_config_deepseek', { apiKey: oldKey, model: aliases[oldModel] || oldModel, url: Config.aiProviders.deepseek.url }); } if (!Object.prototype.hasOwnProperty.call(Config.aiProviders, this.get('ouchn_ai_provider', ''))) this.set('ouchn_ai_provider', 'deepseek'); this.set('ouchn_ai_migrated', true); }, getAiConfig(provider) { this._migrateAiConfig(); const wanted = provider === undefined ? this.get('ouchn_ai_provider', 'deepseek') : provider; const key = Object.prototype.hasOwnProperty.call(Config.aiProviders, wanted) ? wanted : 'deepseek'; const defaults = Config.aiProviders[key]; const value = this.get('ouchn_ai_config_' + key, {}); const saved = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; return { provider: key, apiKey: String(saved.apiKey || '').trim(), model: saved.model === undefined ? defaults.model : String(saved.model || '').trim(), // 官方来源固定使用官方域名,自定义来源才允许配置中转地址。 url: key === 'custom' ? String(saved.url || '').trim() : defaults.url, }; }, setAiConfig(config) { this._migrateAiConfig(); const key = config?.provider; if (!Object.prototype.hasOwnProperty.call(Config.aiProviders, key)) throw new Error('请选择有效的 AI 来源'); const prior = this.getAiConfig(key); const next = { apiKey: config.apiKey === undefined ? prior.apiKey : String(config.apiKey || '').trim(), model: config.model === undefined ? prior.model : String(config.model || '').trim(), url: key === 'custom' ? (config.url === undefined ? prior.url : String(config.url || '').trim()) : Config.aiProviders[key].url, }; this.set('ouchn_ai_config_' + key, next); this.set('ouchn_ai_provider', key); return this.getAiConfig(key); }, getApiKey() { return this.getAiConfig().apiKey; }, setApiKey(v) { return this.setAiConfig({ ...this.getAiConfig(), apiKey: v }); }, getModel() { return this.getAiConfig().model; }, setModel(v) { const config = this.getAiConfig(); const aliases = { v4: 'deepseek-flash', v4pro: 'deepseek-v4-pro' }; return this.setAiConfig({ ...config, model: config.provider === 'deepseek' ? (aliases[v] || v) : 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) return; // 先展示最近一次成功的公告;网络异常不影响答题功能。 const cached = this._normalize(Store.get('ouchn_announce_cache', null)); if (cached) this.render(panel, cached); if (typeof GM_xmlhttpRequest !== 'function') return; const request = {}; panel._ocAnnouncementRequest = request; try { GM_xmlhttpRequest({ method: 'GET', url: Config.announceUrl, timeout: 8000, onload: (resp) => { if (panel._ocAnnouncementRequest !== request) return; try { if (resp.status && (resp.status < 200 || resp.status >= 300)) return; const res = JSON.parse(resp.responseText); if (Number(res.code) !== 1 || !res.data || typeof res.data !== 'object') return; if ([false, 0, '0', 'false'].includes(res.data.enabled)) { Store.del('ouchn_announce_cache'); this._hide(panel); return; } const data = this._normalize(res.data); if (!data) return; Store.set('ouchn_announce_cache', data); this.render(panel, data); } catch (_) { /* silent */ } }, onerror: () => {}, ontimeout: () => {}, onabort: () => {}, }); } catch (_) {} }, _safeUrl(value) { if (typeof value !== 'string') return ''; const url = value.trim(); if (!url || /[\u0000-\u001f\u007f]/.test(url)) return ''; try { const parsed = new URL(url, Config.announceUrl); return /^https?:$/.test(parsed.protocol) && !parsed.username && !parsed.password ? parsed.href : ''; } catch (_) { return ''; } }, _images(data) { const result = []; const add = (value, depth = 0) => { if (depth > 5 || result.length >= 12 || value == null) return; if (Array.isArray(value)) { value.slice(0, 24).forEach(item => add(item, depth + 1)); return; } if (typeof value === 'object') { add(value.url || value.src || value.image_url || value.image, depth + 1); return; } if (typeof value !== 'string') return; const raw = value.trim().slice(0, 12000); if (!raw) return; if (/^[\[{]/.test(raw)) { try { add(JSON.parse(raw), depth + 1); return; } catch (_) {} } // 兼容后台的换行/逗号列表;URL 本身允许逗号,不随意截断。 const entries = raw.split(/\r?\n|,\s*(?=(?:https?:)?\/\/)/).filter(Boolean); if (entries.length > 1) { entries.slice(0, 24).forEach(item => add(item, depth + 1)); return; } const url = this._safeUrl(raw); if (url && !result.includes(url)) result.push(url); }; add(data.images); add(data.image); add(data.image_url); return result; }, _normalize(data) { if (!data || typeof data !== 'object' || ![true, 1, '1', 'true'].includes(data.enabled)) return null; return { enabled: true, id: String(data.id || '').slice(0, 100), revision: String(data.revision || data.version || data.updated_at || data.update_time || '').slice(0, 100), title: String(data.title || '公告').slice(0, 180), content: String(data.content || '').slice(0, 80000), type: ['info', 'warning', 'error', 'success'].includes(data.type) ? data.type : 'info', images: this._images(data), }; }, _identity(data) { const raw = JSON.stringify([data.id, data.revision, data.title, data.content, data.type, data.images]); let hash = 2166136261; for (let i = 0; i < raw.length; i++) hash = Math.imul(hash ^ raw.charCodeAt(i), 16777619); return 'v1-' + (hash >>> 0).toString(16) + '-' + raw.length; }, _hide(panel) { const root = panel.querySelector('#oc-announce'); const reopen = panel.querySelector('#oc-announce-reopen'); if (root) { root.hidden = true; root.classList.remove('show'); } if (reopen) { reopen.hidden = true; reopen.setAttribute('aria-expanded', 'false'); } panel._ocAnnouncement = null; }, render(panel, data) { data = this._normalize(data); if (!panel || !data) return; 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'); const toggleEl = panel.querySelector('#oc-announce-toggle'); const reopenEl = panel.querySelector('#oc-announce-reopen'); if (!root || !titleEl || !contentEl) return; const identity = this._identity(data); let expanded = panel._ocAnnouncement?.identity === identity ? panel._ocAnnouncement.expanded : true; panel._ocAnnouncement = { identity, expanded }; const icon = { info: '📢', warning: '⚠️', error: '❌', success: '✅' }[data.type]; const iconEl = root.querySelector('.oc-announce-icon'); if (iconEl) iconEl.textContent = icon; titleEl.textContent = data.title; contentEl.innerHTML = this._sanitize(data.content, data); contentEl.querySelectorAll('.oc-announce-image img').forEach(img => { img.addEventListener('error', () => { img.hidden = true; const hint = img.parentElement?.querySelector('.oc-announce-image-hint'); if (hint) hint.textContent = '图片加载失败,打开原图'; }, { once: true }); }); root.classList.remove('info', 'warning', 'error', 'success'); root.classList.add(data.type); const show = (visible, focus = false) => { root.hidden = !visible; root.classList.toggle('show', visible); if (reopenEl) { reopenEl.hidden = visible; reopenEl.setAttribute('aria-expanded', String(visible)); } if (focus) (visible ? toggleEl : reopenEl)?.focus({ preventScroll: true }); }; const setExpanded = (value) => { expanded = value; panel._ocAnnouncement.expanded = value; contentEl.hidden = !value; if (toggleEl) { toggleEl.textContent = value ? '收起正文' : '展开正文'; toggleEl.setAttribute('aria-expanded', String(value)); } }; setExpanded(expanded); show(Store.get('ouchn_announce_dismissed', '') !== identity); if (toggleEl) toggleEl.onclick = () => setExpanded(!expanded); if (closeEl) closeEl.onclick = () => { Store.set('ouchn_announce_dismissed', identity); show(false, true); }; if (reopenEl) { reopenEl.title = '重新查看:' + data.title; reopenEl.onclick = () => { Store.del('ouchn_announce_dismissed'); setExpanded(true); show(true, true); }; } }, _sanitize(html, data = {}) { // template 为惰性容器:远程内容只有经白名单重建后才进入页面。 const source = document.createElement('template'); // Markdown 的 先转为文本,防止 HTML 解析器将其误当标签。 source.innerHTML = String(html || '').slice(0, 80000).replace(/(!?\[[^\]]*\]\(\s*)<([^<>\r\n]+)>/g, '$1<$2>'); const output = document.createElement('div'); const images = new Set(); const allowed = new Set(['br', 'p', 'div', 'span', 'b', 'strong', 'i', 'em', 'u', 's', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre']); const blocked = new Set(['script', 'style', 'iframe', 'frame', 'frameset', 'object', 'embed', 'link', 'meta', 'base', 'svg', 'math', 'template', 'form', 'input', 'button', 'textarea', 'select', 'video', 'audio']); const image = (parent, raw, alt) => { const url = this._safeUrl(raw); if (!url || images.has(url) || images.size >= 12) return; images.add(url); const link = document.createElement('a'); link.className = 'oc-announce-image'; link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.setAttribute('aria-label', '查看公告原图:' + (alt || '公告图片').slice(0, 180)); const img = document.createElement('img'); img.src = url; img.alt = String(alt || '公告图片').slice(0, 180); img.setAttribute('loading', 'lazy'); img.setAttribute('decoding', 'async'); img.setAttribute('referrerpolicy', 'no-referrer'); const hint = document.createElement('span'); hint.className = 'oc-announce-image-hint'; hint.textContent = '查看原图'; link.append(img, hint); parent.appendChild(link); }; const safeLink = (parent, text, raw) => { const url = this._safeUrl(raw); if (!url) { parent.appendChild(document.createTextNode(text)); return; } const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.textContent = text || '打开公告链接'; parent.appendChild(link); }; const text = (parent, value) => { const pattern = /(!?)\[([^\]]*)\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\s*\)/g; let start = 0; for (const match of value.matchAll(pattern)) { parent.appendChild(document.createTextNode(value.slice(start, match.index))); if (match[1]) image(parent, match[3] || match[4], match[2]); else safeLink(parent, match[2], match[3] || match[4]); start = match.index + match[0].length; } parent.appendChild(document.createTextNode(value.slice(start))); }; let nodes = 0; const walk = (node, parent, depth = 0) => { if (++nodes > 3000 || depth > 60) return; if (node.nodeType === 3) { text(parent, node.textContent || ''); return; } if (node.nodeType !== 1) return; const tag = node.tagName.toLowerCase(); if (blocked.has(tag)) return; if (tag === 'img') { image(parent, node.getAttribute('src'), node.getAttribute('alt')); return; } if (tag === 'a') { // 图片链接保留图片自身原图地址,不使用远程传入的事件或 target。 if (node.querySelector('img')) { Array.from(node.childNodes).forEach(child => walk(child, parent, depth + 1)); return; } safeLink(parent, node.textContent || '打开公告链接', node.getAttribute('href')); return; } const element = allowed.has(tag) ? document.createElement(tag) : parent; if (element !== parent) parent.appendChild(element); Array.from(node.childNodes).forEach(child => walk(child, element, depth + 1)); }; Array.from(source.content.childNodes).forEach(node => walk(node, output)); this._images(data).forEach(url => image(output, url, '公告图片')); return output.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:多来源 Chat Completions ============== 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 : ''}`; } }, validateConfig(config) { if (!config || !Object.prototype.hasOwnProperty.call(Config.aiProviders, config.provider)) return { ok: false, error: '请选择有效的 AI 来源', field: 'provider' }; const apiKey = String(config.apiKey || '').trim(); if (!apiKey) return { ok: false, error: '请填写当前来源的 API Key', field: 'apiKey' }; if (/\s|[\u0000-\u001f\u007f]/.test(apiKey)) return { ok: false, error: 'API Key 内不能包含空格或换行', field: 'apiKey' }; const model = String(config.model || '').trim(); if (!model) return { ok: false, error: config.provider === 'doubao' ? '请填写已开通的豆包 Model ID 或 ep- 接入点 ID' : '请填写模型 ID', field: 'model' }; if (model.length > 200 || /\s|[\u0000-\u001f\u007f]/.test(model)) return { ok: false, error: '模型 ID 格式无效,请勿输入空格或换行', field: 'model' }; try { this.resolveEndpoint(config); } catch (e) { return { ok: false, error: e.message, field: 'url' }; } return { ok: true, error: '', field: '' }; }, resolveEndpoint(config) { if (!config || !Object.prototype.hasOwnProperty.call(Config.aiProviders, config.provider)) throw new Error('请选择有效的 AI 来源'); const source = config.provider === 'custom' ? String(config.url || '').trim() : Config.aiProviders[config.provider].url; if (!source) throw new Error('请填写自定义接口地址'); if (/[\u0000-\u0020\u007f\\]/.test(source)) throw new Error('接口地址不能含空白或反斜杠'); let parsed; try { parsed = new URL(source); } catch (_) { throw new Error('接口地址无效,请填写完整的 http:// 或 https:// 地址'); } if (!['https:', 'http:'].includes(parsed.protocol) || !parsed.hostname) throw new Error('接口仅支持 HTTP 或 HTTPS 协议'); if (parsed.username || parsed.password) throw new Error('接口地址不能内嵌用户名或密码,请在 API Key 栏填写密钥'); if (parsed.search || parsed.hash || source.includes('?') || source.includes('#')) throw new Error('接口地址不能包含查询参数或 # 片段'); let path = parsed.pathname.replace(/\/+$/, ''); if (!/\/chat\/completions$/i.test(path)) path += '/chat/completions'; parsed.pathname = path; return parsed.toString(); }, _makeError(message, retryable = false, status = 0) { const error = new Error(message); error.retryable = retryable; error.status = status; return error; }, _snapshot(config) { const check = this.validateConfig(config); if (!check.ok) { const error = this._makeError(check.error); error.field = check.field; throw error; } return Object.freeze({ provider: config.provider, apiKey: String(config.apiKey).trim(), model: String(config.model).trim(), url: this.resolveEndpoint(config) }); }, _buildBody(config, prompt, test = false) { const body = { model: config.model, messages: [{ role: 'user', content: prompt }], stream: false, max_tokens: test ? 256 : 8192 }; if (config.provider === 'deepseek') { const thinking = !test && config.model === 'deepseek-v4-pro'; body.thinking = { type: thinking ? 'enabled' : 'disabled' }; if (thinking) body.reasoning_effort = 'low'; else body.max_tokens = test ? 64 : 2048; } else if (config.provider === 'zhipu' && config.model === 'glm-5.3') { // GLM-5.3 官方要求始终开启思考,低强度用于较短答题请求。 body.thinking = { type: 'enabled' }; body.reasoning_effort = 'low'; if (test) body.max_tokens = 512; } else if (config.provider === 'zhipu' && ['glm-4.7', 'glm-4.7-flash'].includes(config.model)) { body.thinking = { type: 'disabled' }; body.max_tokens = test ? 64 : 2048; } // 自定义和豆包模型能力因账号而异,只发送通用 Chat Completions 字段。 return body; }, _readContent(raw, test = false) { if (raw?.error) throw this._makeError('AI 服务返回错误,请检查模型权限、Key 和账户状态'); const choice = raw?.choices?.[0]; const finish = choice?.finish_reason; if (finish === 'length' || finish === 'max_tokens') throw this._makeError(test ? '接口已响应,但测试输出被截断,请检查模型的思考模式或输出限制' : 'AI 输出被截断,未使用不完整答案;请更换模型后重试'); if (finish && !['stop', 'end_turn'].includes(finish)) throw this._makeError('AI 未返回可用的完整文本答案(' + String(finish).replace(/[^a-zA-Z_]/g, '').slice(0, 32) + ')'); const content = choice?.message?.content; const text = typeof content === 'string' ? content.trim() : ''; // reasoning_content 是思考过程,不能作为作答结果或拼进正式答案。 if (!text) throw this._makeError(choice?.message?.reasoning_content ? 'AI 仅返回思考过程,未返回正式答案,请检查模型配置' : 'AI 未返回正式文本答案,请检查接口兼容性'); return text; }, async call(question, options, type, logger) { // 在等待与重试之前固定来源,切换设置不会把旧 Key 发给新来源。 const config = this._snapshot(Store.getAiConfig()); const body = this._buildBody(config, this.buildPrompt(question, options, type)); let lastErr; for (let attempt = 1; attempt <= Config.aiRetryMax + 1; attempt++) { try { const since = Date.now() - lastAiCallAt; if (since < Config.aiMinIntervalMs) await Utils.sleep(Config.aiMinIntervalMs - since); lastAiCallAt = Date.now(); const raw = await this._request(config, body); const content = this._readContent(raw); const answers = this._parseAnswer(content, type); if (!answers.length) throw this._makeError('AI 正式答案无法解析,请重试或调整模型'); answers._raw = content; return answers; } catch (e) { lastErr = e; logger?.(' ⚠️ AI 第 ' + attempt + ' 次失败:' + (e.message || '请求失败')); if (!e.retryable || attempt > Config.aiRetryMax) break; await Utils.sleep(1000 * attempt); } } throw lastErr || this._makeError('AI 调用失败'); }, async testConnection(config) { const snapshot = this._snapshot(config); const raw = await this._request(snapshot, this._buildBody(snapshot, '请只回复 OK。', true)); const choice = raw?.choices?.[0]; const message = choice?.message; if (!raw?.error && ['length', 'max_tokens'].includes(choice?.finish_reason) && message && (typeof message.content === 'string' || typeof message.reasoning_content === 'string')) { return '接口连接成功;本次小额测试已达到输出上限,尚未验证完整答案。正式答题会使用更大额度,截断内容不会用于填答。'; } this._readContent(raw, true); return '连接成功,当前来源和模型已返回正式文本。'; }, _request(config, body) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') return reject(this._makeError('GM_xmlhttpRequest 不可用,请用 Tampermonkey 安装本脚本')); let settled = false; const finish = (fn, value) => { if (!settled) { settled = true; fn(value); } }; const fail = (message, retryable = false, status = 0) => finish(reject, this._makeError(message, retryable, status)); try { GM_xmlhttpRequest({ method: 'POST', url: config.url, timeout: Config.aiTimeoutMs, anonymous: true, headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + config.apiKey }, data: JSON.stringify(body), onload: (resp) => { const status = Number(resp.status) || 0; if (status >= 200 && status < 300) { try { finish(resolve, JSON.parse(resp.responseText)); } catch (_) { fail('AI 响应不是有效 JSON,请检查接口地址和兼容性'); } return; } const hints = { 400: '请求参数或模型 ID 不被支持', 401: 'API Key 无效或已过期', 402: '账户余额不足', 403: '当前 Key 无权访问该模型', 404: '接口地址或模型不存在', 408: '服务请求超时', 429: '请求过于频繁或配额不足' }; const detail = hints[status] || (status >= 500 ? 'AI 服务暂时不可用' : 'AI 请求失败,请检查配置和网络'); // 不回显远端错误正文,避免中转服务把 Authorization / Key 带入日志。 fail('HTTP ' + status + ' · ' + detail, status === 0 || status === 408 || status === 429 || status >= 500, status); }, onerror: () => fail('网络请求失败,请检查网络和脚本跨域授权', true), ontimeout: () => fail('AI 请求超时,请稍后重试', true), onabort: () => fail('AI 请求已取消'), }); } catch (_) { fail('AI 请求无法发出,请检查脚本跨域授权和接口配置'); } }); }, _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:12px;bottom:72px;width:420px;max-width:calc(100vw - 24px);max-height:calc(100vh - 96px);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; } #ouchn-panel, #ouchn-panel *{box-sizing:border-box;} #ouchn-panel [hidden]{display:none!important;} #ouchn-panel :is(button,a,input,select,summary):focus-visible{outline:2px solid #93c5fd;outline-offset:2px;} #ouchn-panel .oc-ai-settings{min-width:0;} #ouchn-panel .oc-ai-settings summary{cursor:pointer;font-weight:600;line-height:1.6;color:#f1f5f9;} #ouchn-panel .oc-ai-settings summary::marker{color:#93c5fd;} #ouchn-panel .oc-ai-summary{display:block;color:#94a3b8;font-size:11px;font-weight:400;margin:3px 0 8px;overflow-wrap:anywhere;} #ouchn-panel .oc-ai-field{display:flex;flex-direction:column;gap:5px;min-width:0;margin-top:10px;} #ouchn-panel .oc-ai-field :is(input,select){width:100%;min-width:0;} #ouchn-panel #oc-key{padding-right:52px;} #ouchn-panel .oc-ai-field label{font-size:12px;color:#cbd5e1;} #ouchn-panel .oc-ai-help{font-size:11px;line-height:1.6;color:#94a3b8;margin:5px 0;overflow-wrap:anywhere;} #ouchn-panel .oc-ai-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px;} #ouchn-panel .oc-ai-actions .oc-btn{flex:1;min-width:100px;} #ouchn-panel #oc-ai-status{min-height:18px;white-space:pre-wrap;} #ouchn-panel #oc-ai-key-link{color:#93c5fd;font-size:11px;} #ouchn-panel [aria-invalid="true"]{border-color:#fca5a5;} #ouchn-panel button:disabled{cursor:wait;opacity:.6;} @media(prefers-reduced-motion:reduce){#ouchn-panel,#ouchn-ball,#ouchn-panel *{animation:none!important;transition:none!important;}} .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:9px 14px;font-size:12px;line-height:1.55;flex-shrink:0; 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;} #ouchn-panel .oc-announce[hidden],#ouchn-panel .oc-announce [hidden],#ouchn-panel .oc-announce-reopen[hidden]{display:none!important;} @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:14px;line-height:28px;} .oc-announce-body{flex:1;min-width:0;} .oc-announce-heading{display:flex;align-items:center;gap:6px;min-height:28px;} .oc-announce-title{flex:1;min-width:0;font-weight:600;font-size:12px;overflow-wrap:anywhere;} .oc-announce-content{max-height:min(220px,30vh);overflow-y:auto;overscroll-behavior:contain;padding-right:4px;font-size:12px;color:#e2e8f0;line-height:1.65;overflow-wrap:anywhere;white-space:pre-wrap;} .oc-announce-content p,.oc-announce-content ul,.oc-announce-content ol,.oc-announce-content blockquote{margin:6px 0;} .oc-announce-content ul,.oc-announce-content ol{padding-left:22px;} .oc-announce-content blockquote{padding-left:10px;border-left:2px solid #64748b;} .oc-announce-content pre{white-space:pre-wrap;overflow-wrap:anywhere;} .oc-announce-content b{color:#fde68a;font-weight:600;} .oc-announce-content a{color:#bfdbfe;text-decoration:underline;text-underline-offset:2px;} .oc-announce-image{display:block;margin:8px 0;width:fit-content;max-width:100%;} .oc-announce-image img{display:block;max-width:100%;height:auto;max-height:240px;object-fit:contain;border-radius:6px;} .oc-announce-image-hint{display:block;margin-top:3px;font-size:11px;} .oc-announce-toggle,.oc-announce-close,.oc-announce-reopen{font-family:inherit;color:#cbd5e1;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.16);border-radius:5px;cursor:pointer;font-size:11px;line-height:1.4;min-height:28px;} .oc-announce-toggle{flex-shrink:0;padding:3px 6px;} .oc-announce-close{flex-shrink:0;min-width:28px;padding:3px;font-size:16px;} .oc-announce-reopen{align-self:flex-start;margin:6px 14px;padding:4px 9px;flex-shrink:0;} .oc-announce-toggle:hover,.oc-announce-close:hover,.oc-announce-reopen:hover{color:#fff;background:rgba(255,255,255,.15);} .oc-announce button:focus-visible,.oc-announce a:focus-visible,.oc-announce-content:focus-visible,.oc-announce-reopen:focus-visible{outline:2px solid #93c5fd;outline-offset:2px;} @media(prefers-reduced-motion:reduce){.oc-announce.show{animation:none;}} .oc-body{padding:14px;overflow-y:auto;flex:1;min-height:0;display:flex;flex-direction:column;gap:12px;} #ouchn-panel .oc-body>*{flex-shrink:0;} #ouchn-panel>.oc-header,#ouchn-panel>.oc-footer,#ouchn-panel>.oc-wechat-tip{flex-shrink:0;} .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;min-width:0;} .oc-verify-input{ flex:1 1 0;min-width:0;width:0;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;} @media(max-width:480px){ #ouchn-panel .oc-verify-qr{width:84px;height:84px;} #ouchn-panel .oc-verify-qr-wrap{max-width:84px;} #ouchn-panel .oc-verify-qr-zoom{display:none;} #ouchn-panel .oc-verify-input{letter-spacing:2px;padding-inline:4px;} } .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 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 配置

各服务分别保存密钥和模型。修改后点击保存,答题使用已保存的配置。

打开服务商控制台

测试会发送一条简短请求,可能产生少量费用,不查询课程题目。

⚙️ 答题设置
答完自动翻页
题间间隔(毫秒)
📜 运行日志
`; 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('⚠️ 请在 AI 配置中选择服务并保存 API Key', 'warn'); if (Store.isVerifyValid()) { this.addLog(`🔓 验证有效期至 ${Store.getValidUntilStr()}`); } else { this.addLog('🔒 未验证,扫码获取 4 位验证码后激活', 'warn'); } }, bindAiSettings(panel) { const find = selector => panel.querySelector(selector); const section = find('#oc-ai-settings'); const providerEl = find('#oc-provider'); const modelEl = find('#oc-model'); const keyEl = find('#oc-key'); const urlEl = find('#oc-ai-url'); const saveEl = find('#oc-ai-save'); const testEl = find('#oc-ai-test'); const statusEl = find('#oc-ai-status'); const fields = { provider: providerEl, model: modelEl, apiKey: keyEl, url: urlEl }; const drafts = new Map(); let activeProvider = Store.getAiConfig().provider; let revision = 0; let testing = false; const clean = value => String(value || '').trim(); const read = () => ({ provider: providerEl.value, apiKey: clean(keyEl.value), model: clean(modelEl.value), url: providerEl.value === 'custom' ? clean(urlEl.value) : Config.aiProviders[providerEl.value]?.url || '' }); const equal = (a, b) => ['provider', 'apiKey', 'model', 'url'].every(name => clean(a[name]) === clean(b[name])); const safeMessage = (value, config = read()) => { let text = String(value || '').slice(0, 800); if (config.apiKey) text = text.split(config.apiKey).join('[已隐藏密钥]'); return text; }; const message = (text, error = false) => { statusEl.textContent = safeMessage(text); statusEl.style.color = error ? '#fca5a5' : '#cbd5e1'; }; const clearErrors = () => Object.values(fields).forEach(el => el.removeAttribute('aria-invalid')); const describeSaved = () => { const saved = Store.getAiConfig(); find('#oc-ai-summary').textContent = `${Config.aiProviders[saved.provider]?.name || 'AI'} · ${saved.apiKey ? saved.model || '待配置模型' : '待配置'}(已保存)`; }; const invalid = validation => { section.open = true; clearErrors(); const el = fields[validation.field] || keyEl; el.setAttribute('aria-invalid', 'true'); message(validation.error || '请检查 AI 配置。', true); el.focus(); return false; }; const render = config => { const info = Config.aiProviders[config.provider]; providerEl.value = config.provider; keyEl.value = config.apiKey || ''; modelEl.value = config.model || ''; urlEl.value = config.url || ''; keyEl.type = 'password'; find('#oc-key-toggle').textContent = '显示'; find('#oc-key-toggle').setAttribute('aria-label', '显示 API Key'); find('#oc-key-toggle').setAttribute('aria-pressed', 'false'); find('#oc-ai-url-row').hidden = config.provider !== 'custom'; urlEl.required = config.provider === 'custom'; find('#oc-model-hint').textContent = info.hint || ''; modelEl.placeholder = config.provider === 'doubao' ? '控制台中的模型 ID 或 ep- 接入点' : info.model || '填写服务商模型名称'; const options = find('#oc-model-options'); options.replaceChildren(); (info.models || []).forEach(model => { const option = document.createElement('option'); option.value = model.id; option.label = model.name || model.id; options.appendChild(option); }); const link = find('#oc-ai-key-link'); link.hidden = !info.keyUrl; if (info.keyUrl) { link.href = info.keyUrl; link.textContent = `打开${info.name}控制台`; } else link.removeAttribute('href'); clearErrors(); }; Object.entries(Config.aiProviders).forEach(([provider, info]) => { const option = document.createElement('option'); option.value = provider; option.textContent = info.name; providerEl.appendChild(option); }); render(Store.getAiConfig()); describeSaved(); section.open = !AI.validateConfig(Store.getAiConfig()).ok; message(section.open ? '请补齐配置并保存。' : '已载入保存的配置。'); providerEl.addEventListener('change', () => { drafts.set(activeProvider, { provider: activeProvider, apiKey: keyEl.value, model: modelEl.value, url: urlEl.value }); activeProvider = providerEl.value; revision++; render(drafts.get(activeProvider) || Store.getAiConfig(activeProvider)); message(equal(read(), Store.getAiConfig()) ? '已载入保存的配置。' : '已切换配置,点击保存后用于答题。'); }); [keyEl, modelEl, urlEl].forEach(el => { const edited = () => { revision++; el.removeAttribute('aria-invalid'); message('配置已修改,保存后用于答题。'); }; el.addEventListener('input', edited); el.addEventListener('change', edited); }); find('#oc-key-toggle').addEventListener('click', () => { const visible = keyEl.type === 'password'; keyEl.type = visible ? 'text' : 'password'; find('#oc-key-toggle').textContent = visible ? '隐藏' : '显示'; find('#oc-key-toggle').setAttribute('aria-label', visible ? '隐藏 API Key' : '显示 API Key'); find('#oc-key-toggle').setAttribute('aria-pressed', String(visible)); }); saveEl.addEventListener('click', () => { const config = read(); const validation = AI.validateConfig(config); if (!validation.ok) { invalid(validation); return; } try { Store.setAiConfig(config); revision++; drafts.set(config.provider, { ...config }); clearErrors(); describeSaved(); message('配置已保存。'); this.addLog(`✅ 已保存${Config.aiProviders[config.provider].name}配置`); } catch (_) { message('配置保存失败,请重试。', true); } }); testEl.addEventListener('click', async () => { if (testing) return; const config = read(); const validation = AI.validateConfig(config); if (!validation.ok) { invalid(validation); return; } const token = revision; testing = true; testEl.disabled = true; testEl.textContent = '测试中…'; testEl.setAttribute('aria-busy', 'true'); message('正在测试当前填写的配置,请稍候。'); try { const result = await AI.testConnection({ ...config }); if (section.isConnected && token === revision) message(safeMessage(typeof result === 'string' ? result : '连接成功。', config) + (equal(config, Store.getAiConfig()) ? '' : ' 当前配置尚未保存。')); } catch (error) { if (section.isConnected && token === revision) message('连接失败:' + safeMessage(error?.message || error, config), true); } finally { testing = false; testEl.disabled = false; testEl.textContent = '测试连接'; testEl.removeAttribute('aria-busy'); } }); return { ensureSaved: () => { const config = read(); const validation = AI.validateConfig(config); if (!validation.ok) return invalid(validation); if (!equal(config, Store.getAiConfig())) { section.open = true; message('当前配置尚未保存,请先点击保存配置。', true); saveEl.focus(); return false; } return true; } }; }, 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 aiSettings = this.bindAiSettings(panel); // 自动翻页 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 (!aiSettings.ensureSaved()) 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(); } })();