// ==UserScript== // @name 国开答题助手 // @namespace https://github.com/leye/ouchn-helper // @version 1.1.4 // @description 国家开放大学(lms.ouchn.cn)AI 答题助手 · 多来源独立配置,支持选择、判断、填空、简答及视觉模型图片题 // @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.4 (2026-10-17):分离翻页与提交、完成状态检查、模型名称展示与搜索筛选。 // v1.1.1 (2026-10-17):修复下拉框对比度、行内填空/富文本题型、判断符号与重复点击。 // 图片题自动检测模型能力;题图读取失败、超限或不支持时停止本题填写。 // v1.1.0 (2026-10-15):AI 服务支持 DeepSeek、豆包、智谱及自定义兼容接口, // 分别保存 Key/模型并迁移旧配置;新增显式保存、配置校验和手动连接测试。 // 公告支持安全图文、正文折叠、关闭记忆/重开及缓存;优化窄屏布局。 // ============== 配置 ============== const Config = { version: '1.1.4', // 官方模型与端点核对日期: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 调用最小间隔 navigationTimeoutMs: 8000, // 翻页/提交状态等待上限 // 微信 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 { const view = el.ownerDocument?.defaultView || window; el.dispatchEvent(new view.Event('input', { bubbles: true })); el.dispatchEvent(new view.Event('change', { bubbles: true })); el.dispatchEvent(new view.Event('blur', { bubbles: true })); } catch (_) {} }, // 框架反查 setter(React 等会拦截 .value 赋值) nativeSet(el, value) { const view = el.ownerDocument?.defaultView || window; const proto = el.tagName === 'TEXTAREA' ? view.HTMLTextAreaElement.prototype : view.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 (el.disabled || el.matches?.(':disabled, [aria-disabled="true"]')) return false; if (typeof el.click === 'function') el.click(); else { const view = el.ownerDocument?.defaultView || window; el.dispatchEvent(new view.MouseEvent('click', { bubbles: true, cancelable: 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.disabled || input.readOnly || input.matches?.(':disabled, [aria-disabled="true"]') || input.closest?.('[aria-disabled="true"]')) 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 view = input.ownerDocument?.defaultView || window; const setter = Object.getOwnPropertyDescriptor(view.HTMLInputElement.prototype, 'checked')?.set; if (setter) { setter.call(input, want); input.dispatchEvent(new view.Event('input', { bubbles: true })); input.dispatchEvent(new view.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(), vision: saved.vision === true, // 官方来源固定使用官方域名,自定义来源才允许配置中转地址。 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(), vision: config.vision === undefined ? prior.vision : config.vision === true, 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); }, // 能力检测只缓存在内存;换 Key、地址或模型后必须使用各自的检测结果。 _visionResults: new Map(), _visionIdentity(config) { return JSON.stringify([config.provider, AI.resolveEndpoint(config), String(config.model || '').trim(), String(config.apiKey || '').trim()]); }, getVisionStatus(config) { let key; try { key = this._visionIdentity(config); } catch (_) { return null; } const result = this._visionResults.get(key); if (!result) return null; const ttl = result.status === 'supported' ? 86400000 : result.status === 'unsupported' ? 3600000 : 300000; const age = Date.now() - result.checkedAt; if (age < 0 || age > ttl) { this._visionResults.delete(key); return null; } return { ...result }; }, setVisionStatus(config, result) { const key = this._visionIdentity(config); if (!['supported', 'unsupported', 'unknown'].includes(result?.status)) return; if (this._visionResults.size >= 32 && !this._visionResults.has(key)) this._visionResults.delete(this._visionResults.keys().next().value); this._visionResults.set(key, { status: result.status, message: String(result.message || '').slice(0, 500), checkedAt: Date.now() }); try { window.dispatchEvent(new Event('ouchn-vision-status')); } catch (_) {} }, 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); }, getAutoSubmit() { return this.get('ouchn_autoSubmit', true); }, setAutoSubmit(v) { this.set('ouchn_autoSubmit', !!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 `你是答题助手。下面是一道单选题,请完整阅读题干、附图和全部选项,再选择唯一正确的选项。 【题目】${question} 【选项】 ${optStr} 要求: - 只输出一个 JSON 对象,格式为 {"choices":["B"]},B 是上方选项的字母编号 - choices 必须只含一个实际存在的选项编号;编号与选项原文中的字母、数字是不同的 - 同一题干包含多个空或多个问题时,必须同时求解全部问题,选中完整符合的组合选项 - 例如选项原文为“5,3”,不要仅回答局部数字“5”,应返回该完整选项的编号 - 有附图时结合图中的连接关系和标注;关键信息不清楚、不能确定答案时输出 {"choices":[]} - 不输出选项原文、解释、Markdown 代码块或其他内容`; case this.TYPE_MULTI: return `你是答题助手。下面是一道多选题,请完整阅读题干、附图和全部选项,选出所有正确选项。 【题目】${question} 【选项】 ${optStr} 要求: - 只输出一个 JSON 对象,例如 {"choices":["A","C"]},数组里是上方正确选项的字母编号 - 必须列出全部正确选项,每个编号只出现一次;编号与选项原文中的字母、数字是不同的 - 同一题干包含多个空或多个问题时,必须综合全部问题,不能仅回答某个局部数字或词语 - 有附图时结合图中的连接关系和标注;关键信息不清楚、不能确定全部答案时输出 {"choices":[]} - 不输出选项原文、解释、Markdown 代码块或其他内容`; case this.TYPE_JUDGE: return `你是答题助手。下面是一道判断题,请只回答"正确"或"错误"。 【题目】${question} 【选项参考】 ${optStr || '正确 / 错误'} 要求:只输出 "正确" 或 "错误" 两字,不要其他内容`; case this.TYPE_FILL: return `你是答题助手。下面是一道填空题,请按题目中的空缺顺序逐空作答。 【题目】${question} 要求: - 只输出一个 JSON 字符串数组,例如 ["第一个空的答案","第二个空的答案"] - 每一个空对应数组中的一个字符串,单空题也必须输出数组 - [空1]、[空2] 等是空缺位置标记,答案中不要包含这些标记 - 同一个空的答案即使包含逗号、分号或公式,也放在同一个字符串中 - 不要省略空缺,不要解释、序号或 Markdown 代码块`; case this.TYPE_ESSAY: return `你是答题助手。下面是一道简答、计算或论述题,请根据题意完整作答。 【题目】${question} 要求: - 简答/论述题分点或分段陈述关键内容,篇幅由题目要求决定 - 计算题写出公式、已知量、推导和最终结果,保留单位、方向和正负号 - 题目指定解题方法时必须使用该方法;例如支路电流法要列出支路电流的 KCL、KVL 方程并求解,不能只用其他方法替代 - 题目有附图时结合图中的连接关系和标注;看不清关键条件时明确说明,不要编造 - 电路题先明确图示电流箭头和电源正负参考方向,再将电源带符号数值代入相应电位差;不能忽略极性标注,也不能把负号重复处理。结果代回原 KCL、KVL 方程核验,并区分按箭头定义的电流值与实际方向 - 答案会直接填入不支持公式渲染的编辑框,必须输出纯文本并保留换行。变量写作 I1、Us2、Un,分式写作 (a-b)/(c+d),乘法用 *,幂用 x^2,单位直接写 A、V、Ω - 禁止 LaTeX、美元符号数学定界符、反斜杠公式命令和 Markdown 代码块;不要输出需要渲染的分式、上下标、方框公式 - 不要寒暄、不要题目重述`; 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), vision: config.vision === true }); }, _modelVisionMetadata(model) { // 模型名称不能证明图片能力;只有接口明确给出的输入模态/能力字段才作提示。 const flags = [model.supports_vision, model.supports_image_input, model.vision, model.capabilities?.vision, model.capabilities?.image_input] .filter(value => typeof value === 'boolean'); const inputs = [model.input_modalities, model.supported_input_modalities, model.modalities?.input, model.architecture?.input_modalities, model.capabilities?.input_modalities]; for (const input of inputs) { if (!Array.isArray(input) || !input.length || !input.every(value => typeof value === 'string')) continue; flags.push(input.some(value => /^(?:image|images|vision)$/i.test(value.trim()))); } if (!flags.length || flags.some(value => value !== flags[0])) return 'unknown'; return flags[0] ? 'supported' : 'unsupported'; }, async listModels(config) { // 获取列表不要求预先填写模型 ID,但必须校验并固定本次来源、地址和 Key。 const snapshot = this._snapshot({ ...config, model: 'models-discovery' }); const endpoint = new URL(snapshot.url); endpoint.pathname = endpoint.pathname.replace(/\/chat\/completions\/?$/i, '/models'); const raw = await new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') return reject(this._makeError('无法获取模型,请用 Tampermonkey 运行脚本;也可手动填写模型 ID')); let settled = false; const finish = (fn, value) => { if (!settled) { settled = true; fn(value); } }; const fail = message => finish(reject, this._makeError(message)); try { GM_xmlhttpRequest({ method: 'GET', url: endpoint.href, timeout: 20000, anonymous: true, headers: { 'Accept': 'application/json', 'Authorization': 'Bearer ' + snapshot.apiKey }, onload: response => { const status = Number(response.status) || 0; if (status >= 200 && status < 300) { if (typeof response.responseText !== 'string' || response.responseText.length > 2 * 1024 * 1024) return fail('模型列表响应过大或无效,请手动填写模型 ID'); try { finish(resolve, JSON.parse(response.responseText)); } catch (_) { fail('模型列表不是有效 JSON,该接口可能不提供模型发现;请手动填写模型 ID'); } return; } const hints = { 401: 'API Key 无效或已过期', 402: '账户余额不足', 403: '当前 Key 没有获取列表的权限', 404: '该服务未提供标准模型列表接口', 405: '该服务不支持获取模型列表', 408: '获取模型列表超时', 429: '请求过于频繁或配额不足' }; // 远端错误正文可能包含凭据,仅显示本地固定的状态说明。 fail('HTTP ' + status + ' · ' + (hints[status] || (status >= 500 ? '模型列表服务暂时不可用' : '获取模型列表失败')) + ';可手动填写模型 ID'); }, onerror: () => fail('获取模型列表失败,请检查网络和脚本跨域授权;可手动填写模型 ID'), ontimeout: () => fail('获取模型列表超时;可稍后重试或手动填写模型 ID'), onabort: () => fail('获取模型列表已取消;可手动填写模型 ID'), }); } catch (_) { fail('无法发出模型列表请求,请检查接口和脚本跨域授权;可手动填写模型 ID'); } }); if (raw?.error) throw this._makeError('模型列表服务返回错误,请检查 Key 和接口权限;可手动填写模型 ID'); const entries = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.models) ? raw.models : Array.isArray(raw) ? raw : null; if (!entries) throw this._makeError('未识别到标准模型列表,请手动填写模型 ID'); const models = []; const seen = new Set(); for (const entry of entries) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const id = typeof entry.id === 'string' ? entry.id.trim() : typeof entry.model === 'string' ? entry.model.trim() : ''; if (!id || id.length > 200 || /[\s\u0000-\u001f\u007f<>"']/.test(id) || /^(?:(?:https?|ftp|file|data|javascript|blob):|\/\/)/i.test(id) || seen.has(id)) continue; seen.add(id); const name = typeof entry.name === 'string' ? entry.name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 120) : ''; models.push({ id, name: name || id, vision: this._modelVisionMetadata(entry) }); if (models.length >= 300) break; } return { models, message: models.length ? '列表不代表当前 Key 一定有调用权限;请测试连接,图片能力需单独检测。' : '接口未返回可选模型;仍可手动填写已开通的模型 ID。' }; }, _isVisionUnsupportedResponse(raw) { // 只提取错误字段作本地分类,绝不把服务端原文写进异常、日志或页面。 let value = raw; if (typeof value === 'string') { if (value.length > 32768) return false; try { value = JSON.parse(value); } catch (_) { return false; } } const error = value?.error; const fields = typeof error === 'string' ? [error] : [error?.message, error?.code, error?.type]; const message = fields.filter(part => typeof part === 'string').join(' ').slice(0, 4000).toLowerCase(); if (!message) return false; if (/(?:unsupported|not_supported)[_-](?:vision|multimodal|image_input|image_url)|(?:vision|image_input|image_url)[_-](?:unsupported|not_supported)/.test(message)) return true; if (/(?:does not|doesn't|do not|cannot|can't)\s+(?:currently\s+)?support\s+(?:any\s+)?(?:image|images|vision|multimodal|multi-modal)|(?:image(?:_url)?|vision|multimodal|multi-modal)\s+(?:input\s+|content\s+)?(?:is\s+|are\s+)?(?:not supported|unsupported|not allowed|not permitted|only supported by)/.test(message)) return true; if (/(?:model|endpoint)[\s\S]{0,60}(?:text-only|text only)|不支持[\s\S]{0,20}(?:图片输入|图像输入|图片内容|图像内容|视觉|多模态)|(?:图片输入|图像输入|图片内容|图像内容|视觉|多模态)[\s\S]{0,20}不支持/.test(message)) return true; return false; }, async probeVision(config) { const snapshot = this._snapshot(config); let challenge; let image; try { const random = new Uint32Array(1); if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') crypto.getRandomValues(random); else random[0] = Math.floor(Math.random() * 0x100000000); challenge = String(1000 + random[0] % 9000); const canvas = document.createElement('canvas'); canvas.width = 360; canvas.height = 120; const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('canvas'); ctx.fillStyle = '#ffffff'; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = '#111111'; ctx.font = 'bold 68px monospace'; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; ctx.fillText(challenge, canvas.width / 2, canvas.height / 2); image = canvas.toDataURL('image/png'); if (!/^data:image\/png;base64,[a-zA-Z0-9+/=]+$/.test(image)) throw new Error('canvas'); } catch (_) { return { status: 'unknown', message: '浏览器无法生成图片检测样本,尚未确认图片能力;图片题将暂停。' }; } try { // 挑战数字只出现在本地生成的图片中,文字提示不泄露答案。 const content = [{ type: 'text', text: '请读取图片正中央的四位阿拉伯数字。仅输出这四位数字,不加空格、标点或说明;看不到图片时请回答无法读取。' }, { type: 'image_url', image_url: { url: image } }]; const raw = await this._request(snapshot, this._buildBody(snapshot, content, true)); if (raw?.error && this._isVisionUnsupportedResponse(raw)) return { status: 'unsupported', message: '当前模型或接口明确不支持图片输入;请选择支持图片的模型后重新检测。' }; const answer = this._readContent(raw, true); if (answer !== challenge) return { status: 'unknown', message: '模型未正确读出检测图中的数字,尚未确认图片能力;图片题将暂停,可更换模型或重新检测。' }; return { status: 'supported', message: '当前模型已正确读取检测图片,已确认支持本接口的图片输入。检测通过不代表图片题答案一定正确。' }; } catch (error) { if (error?.visionUnsupported === true) return { status: 'unsupported', message: '当前模型或接口明确不支持图片输入;请选择支持图片的模型后重新检测。' }; const hints = { 400: '请求参数或模型配置需要检查', 401: 'API Key 无效或已过期', 402: '账户余额不足', 403: '当前 Key 没有模型调用权限', 404: '模型或接口不存在', 408: '检测超时', 429: '请求过于频繁或配额不足' }; const status = Number(error?.status) || 0; const reason = hints[status] || (status >= 500 ? '服务暂时不可用' : /超时/.test(error?.message || '') ? '检测超时' : '接口未返回有效检测结果,请检查网络、Key 和模型配置'); return { status: 'unknown', message: reason + ',尚未确认图片能力;图片题将暂停,可调整配置后重新检测。' }; } }, _visionChecks: new Map(), async checkVision(config, force = false) { const snapshot = this._snapshot(config); const cached = !force && Store.getVisionStatus?.(snapshot); if (cached && ['supported', 'unsupported', 'unknown'].includes(cached.status)) return { status: cached.status, message: String(cached.message || '') }; // 仅内存合并同配置的并发检测;不把 Key 派生缓存键写入日志或持久化。 const key = JSON.stringify([snapshot.provider, snapshot.url, snapshot.model, snapshot.apiKey]); if (this._visionChecks.has(key)) return this._visionChecks.get(key); const pending = this.probeVision(snapshot).then(result => { Store.setVisionStatus?.(snapshot, result); return result; }); this._visionChecks.set(key, pending); try { return await pending; } finally { this._visionChecks.delete(key); } }, _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, images = []) { // 在等待与重试之前固定来源,切换设置不会把旧 Key 发给新来源。 const config = this._snapshot(Store.getAiConfig()); let content = this.buildPrompt(question, options, type); if (!Array.isArray(images)) throw this._makeError('题图信息格式无效,请重新扫描题目'); if (images.length) { // 先完整校验并读取题图;缺图、登录页、无效格式或超限不应触发收费能力检测。 const imageParts = await this._prepareImages(images); logger?.(' 🧪 正在确认当前模型的图片能力…'); const capability = await this.checkVision(config); if (capability.status !== 'supported') throw this._makeError(capability.message || '尚未确认当前模型支持图片输入,已停止本题作答;请更换模型或重新检测图片能力'); logger?.(' ✅ 已确认当前模型支持图片输入'); content = [{ type: 'text', text: content + '\n\n【附图】后续图片按题面出现顺序排列,请结合题干及选项中的图片标记阅读。' }, ...imageParts]; } let body = this._buildBody(config, content); let essayFormatRetried = false; let lastErr; // 网络重试与简答格式纠正分别计数;格式至多纠正一次,始终复用原配置和题图。 for (let attempt = 1; attempt <= Config.aiRetryMax + 1;) { 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); if (type === this.TYPE_ESSAY && this._essayFormatIssue(content)) { if (essayFormatRetried) throw this._makeError('AI 重试后仍返回无法直接填写的公式格式,已停止本题填写;请检查答案或更换模型'); essayFormatRetried = true; logger?.(' ⚠️ 简答答案包含 LaTeX 或代码块,按纯文本公式重试一次'); const correction = '\n\n【纯文本格式校正】上一回答包含编辑框无法渲染的公式格式。请基于原题、所有条件及附图重新完整作答,核验符号与计算;只输出可直接阅读的纯文本。用 I1、Us2、Un 表示变量,用 (a-b)/(c+d) 表示分式,保留单位、正负号和换行。禁止 LaTeX、数学美元定界符、反斜杠公式命令及 Markdown 代码块。'; const original = body.messages[0].content; const corrected = Array.isArray(original) ? original.map(part => part.type === 'text' ? { ...part, text: part.text + correction } : part) : original + correction; body = this._buildBody(config, corrected); continue; } 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); attempt++; } } throw lastErr || this._makeError('AI 调用失败'); }, async _prepareImages(images) { const unique = new Map(); for (const image of images) { const source = typeof image === 'string' ? image : image?.url; if (typeof source !== 'string' || !source.trim()) throw this._makeError('有题图缺少有效地址,已停止作答,请等待图片加载后重试'); const url = source.trim(); if (!unique.has(url)) unique.set(url, { url, labels: [] }); const label = typeof image?.alt === 'string' ? image.alt .replace(/(?:https?:\/\/|data:)[^\s]+/gi, '[图片地址]') .replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 200) : ''; if (label && !unique.get(url).labels.includes(label)) unique.get(url).labels.push(label); } const records = Array.from(unique.values()); if (records.length > 6) throw this._makeError('本题图片超过 6 张,已停止自动作答,请手动检查'); const parts = []; let total = 0; for (let index = 0; index < records.length; index++) { const result = await this._loadImage(records[index].url); total += result.size; if (total > 12 * 1024 * 1024) throw this._makeError('本题图片合计超过 12 MB,已停止自动作答'); parts.push({ type: 'text', text: '图片 ' + (index + 1) + (records[index].labels.length ? '(' + records[index].labels.join(';') + ')' : '') }); parts.push({ type: 'image_url', image_url: { url: result.url } }); } return parts; }, _imageData(bytes) { if (!bytes?.length) throw this._makeError('题图内容为空,已停止作答,请刷新题图后重试'); if (bytes.length > 5 * 1024 * 1024) throw this._makeError('单张题图超过 5 MB,已停止自动作答'); const prefix = String.fromCharCode(...bytes.subarray(0, 12)); let mime = ''; if (bytes.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((value, index) => bytes[index] === value)) mime = 'image/png'; else if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) mime = 'image/jpeg'; else if (/^GIF8[79]a/.test(prefix)) mime = 'image/gif'; else if (prefix.startsWith('RIFF') && prefix.slice(8, 12) === 'WEBP') mime = 'image/webp'; if (!mime) throw this._makeError('题图不是支持的 PNG/JPEG/GIF/WebP 图片,或下载到登录页面,已停止作答'); let binary = ''; for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); return { url: 'data:' + mime + ';base64,' + btoa(binary), size: bytes.length }; }, async _loadImage(source) { const maxBytes = 5 * 1024 * 1024; if (/^data:/i.test(source)) { const match = source.match(/^data:image\/(?:png|jpe?g|gif|webp);base64,([a-zA-Z0-9+/=\r\n]+)$/i); if (!match) throw this._makeError('题图的内嵌图片格式不支持,已停止作答'); if (match[1].length > Math.ceil(maxBytes / 3) * 4 + 1024) throw this._makeError('单张题图超过 5 MB,已停止自动作答'); let binary; try { binary = atob(match[1].replace(/\s/g, '')); } catch (_) { throw this._makeError('题图编码无效,已停止作答'); } return this._imageData(Uint8Array.from(binary, character => character.charCodeAt(0))); } let url; try { url = new URL(source, typeof location !== 'undefined' ? location.href : undefined); } catch (_) { throw this._makeError('题图地址无效,已停止作答'); } if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password) throw this._makeError('题图地址仅支持 HTTP/HTTPS 或内嵌图片,已停止作答'); return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') return reject(this._makeError('无法读取题图,请通过 Tampermonkey 运行脚本')); let settled = false; let request; let timer; const finish = (fn, value) => { if (!settled) { settled = true; clearTimeout(timer); fn(value); } }; const fail = message => finish(reject, this._makeError(message)); const abort = message => { fail(message); try { request?.abort(); } catch (_) {} }; timer = setTimeout(() => abort('读取题图超时,已停止作答,请稍后重试'), 16000); try { request = GM_xmlhttpRequest({ // 使用题图所在站点的正常登录态;此请求不会携带 AI 密钥,也不把原始图片地址发给 AI。 method: 'GET', url: url.href, responseType: 'arraybuffer', timeout: 15000, onprogress: event => { if (Number(event.loaded) > maxBytes || Number(event.total) > maxBytes) abort('单张题图超过 5 MB,已停止自动作答'); }, onload: response => { if (settled) return; if (Number(response.status) < 200 || Number(response.status) >= 300) return fail('题图下载失败,已停止作答,请确认图片可正常打开'); try { if (!response.response || Object.prototype.toString.call(response.response) !== '[object ArrayBuffer]') return fail('题图下载未返回有效图片数据,已停止作答'); finish(resolve, this._imageData(new Uint8Array(response.response))); } catch (_) { fail('题图内容无效、过大或格式不支持,已停止作答,请检查图片'); } }, onerror: () => fail('题图读取失败,已停止作答,请检查网络及脚本的跨域访问授权'), ontimeout: () => fail('读取题图超时,已停止作答,请稍后重试'), onabort: () => fail('题图读取已取消,已停止作答'), }); } catch (_) { fail('无法发出题图请求,已停止作答,请检查脚本的跨域访问授权'); } }); }, 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 带入日志。 const error = this._makeError('HTTP ' + status + ' · ' + detail, status === 0 || status === 408 || status === 429 || status >= 500, status); if ((status === 400 || status === 422) && this._isVisionUnsupportedResponse(resp.responseText)) error.visionUnsupported = true; finish(reject, error); }, onerror: () => fail('网络请求失败,请检查网络和脚本跨域授权', true), ontimeout: () => fail('AI 请求超时,请稍后重试', true), onabort: () => fail('AI 请求已取消'), }); } catch (_) { fail('AI 请求无法发出,请检查脚本跨域授权和接口配置'); } }); }, _essayFormatIssue(content) { const text = String(content || ''); // 不翻译复杂 LaTeX,避免在转换分式、上下标或负号时改变数学含义。 if (/^\s*(?:`{3,}|~{3,})/m.test(text)) return '答案包含 Markdown 代码块'; if (/\\[\[\]()]/.test(text) || /\$\$/.test(text) || /\\(?:dfrac|tfrac|frac|sqrt|boxed|begin|end|text|textrm|mathrm|mathbf|mathit|operatorname|overset|underset|substack)\s*\{/.test(text) || /\\(?:left|right|cdot|times|div|approx|leq?|geq?|neq?|Omega|alpha|beta|gamma|theta|pi|mu|omega|qquad|quad|displaystyle)\b/.test(text)) return '答案包含 LaTeX 公式'; for (const match of text.matchAll(/(^|[^\\])\$([^$\n]+)\$/g)) { const inner = match[2].trim(); // “$100 + $50”中的第二个美元符号属于下一笔金额,不是数学闭合符。 if (/^[+-]?\d/.test(inner) && /^[+-]?\d/.test(text.slice(match.index + match[0].length))) continue; if (/^[A-Za-z][A-Za-z0-9_{}^+*/=().,\s-]*$/.test(inner) || /[\\_^=+*/<>]/.test(inner) || /^[+-]?\d+(?:\.\d+)?$/.test(inner)) return '答案包含数学美元定界符'; } return ''; }, _parseAnswer(content, type) { let raw = String(content || '').trim(); // 简答保留完整纯文本;不把未渲染公式原样写入编辑框。 if (type === this.TYPE_ESSAY) return raw && !this._essayFormatIssue(raw) ? [raw] : []; if (type === this.TYPE_FILL) { const text = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').replace(/^(?:正确答案|答案|答)\s*[::]\s*/, '').trim(); try { const parsed = JSON.parse(text); const values = Array.isArray(parsed) ? parsed : parsed?.answers; if (!Array.isArray(values) || !values.length) return []; if (!values.every(value => (typeof value === 'string' && value.trim()) || (typeof value === 'number' && Number.isFinite(value)))) return []; return values.map(value => String(value).trim()); } catch (_) { // 兼容旧模型的逐空分隔协议;逗号、分号、公式中的符号不能用于拆空。 if (/^[\[{]/.test(text) || !text.includes('||')) return []; const values = text.split('||').map(value => value.trim()); return values.every(Boolean) ? values : []; } } if (type === this.TYPE_SINGLE || type === this.TYPE_MULTI) { const structured = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim(); if (/^[\[{]/.test(structured)) { try { const parsed = JSON.parse(structured); if (!parsed || Array.isArray(parsed) || !Array.isArray(parsed.choices) || !parsed.choices.length) return []; if (!parsed.choices.every(value => typeof value === 'string' && /^[A-Z]$/i.test(value))) return []; const letters = parsed.choices.map(value => value.toUpperCase()); if (new Set(letters).size !== letters.length || (type === this.TYPE_SINGLE && letters.length !== 1)) return []; // 只有显式 choices 协议携带编号,避免把数学选项原文“A”误当第一个选项。 letters._answerIndices = letters.map(value => value.charCodeAt(0) - 65); return letters; } catch (_) { return []; } } // 兼容旧版选项原文协议;不从解释段落、局部数字中回捞答案。 const answerLine = raw.match(/(?:^|\n)\s*(?:正确答案|答案|答)\s*[::]\s*([^\n]+)\s*$/i); let text = (answerLine ? answerLine[1] : raw).trim(); text = text.replace(/^["'「『]+/, '').replace(/["'」』]+$/, '').trim(); if (!text) return []; // 单选选项自身可能是“5,3”或公式,逗号、分号、顿号都不能拆答案。 if (type === this.TYPE_SINGLE) return text.includes('||') ? [] : [text]; const parts = text.split('||').map(value => value.trim()); return parts.every(Boolean) ? parts : []; } // 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) { const value = txt.normalize('NFKC') .replace(/^[A-Ha-h]\s*[..、::))]\s*/, '') .replace(/^(?:本题|该说法|该判断|这个说法|这个判断|这)(?:是|为)?\s*/, '') .replace(/[。.!!*\s]+$/g, '').trim().toLowerCase(); if (/^(?:错误|不正确|不对|错|否|false|f|wrong|no|×|✗|✘|❌|x)$/.test(value)) return ['错误']; if (/^(?:正确|对|是|true|t|right|yes|√|✓|✔|✅)$/.test(value)) return ['正确']; // 不用“包含正确/是”等宽松判断,避免把“不正确”或解释段落映射成肯定答案。 return []; } return txt ? [txt] : []; }, }; // ============== Detector:找题目 / 选项 / 题型 ============== const Detector = { _choiceSelector: 'input[type="radio"], input[type="checkbox"]', _textSelector: 'input[type="text"], input:not([type]), textarea', _editableSelector: '[contenteditable="true"], [contenteditable=""], [contenteditable="plaintext-only"]', _editorChromeSelector: '.tox, .tox-tinymce, .mce-tinymce, .cke, .ck-editor, .w-e-toolbar, .w-e-text-container, .ql-toolbar, .ql-container, .editor-toolbar, [role="toolbar"]', _visible(el) { if (!el || !el.isConnected || Utils.isOwnPanel(el)) return false; const view = el.ownerDocument?.defaultView; for (let p = el; p && p.nodeType === 1; p = p.parentElement) { const style = view?.getComputedStyle(p); if (p.hidden || style?.display === 'none' || style?.visibility === 'hidden' || style?.visibility === 'collapse') return false; } return !!el.getClientRects().length; }, _writable(el) { return !!el && !Utils.isOwnPanel(el) && !el.disabled && !el.readOnly && !el.matches?.(':disabled') && !el.closest?.('[aria-disabled="true"], [aria-readonly="true"], [contenteditable="false"]'); }, _choiceInputs(container) { return Array.from(container.querySelectorAll(this._choiceSelector)).filter(input => { if (!this._writable(input)) return false; // 自定义单选组件常把原生 input 隐藏,选项包装可见时仍能作答。 return this._visible(input) || this._visible(input.closest('label, li, [class*="option"], [class*="Option"], .answer')); }); }, _frameEditor(frame) { try { const doc = frame.contentDocument; const body = doc?.body; if (!body) return null; if (body.isContentEditable || body.matches(this._editableSelector) || doc.designMode?.toLowerCase() === 'on') return body; const editable = doc.querySelector(this._editableSelector); if (!editable || doc.querySelector('.question, .que, .question-item, .exam-question, [data-question-id], ' + this._choiceSelector + ', ' + this._textSelector)) return null; const surrounding = body.cloneNode(true); surrounding.querySelectorAll(this._editableSelector + ', ' + this._editorChromeSelector + ', script, style').forEach(node => node.remove()); // 只有编辑区的 iframe 才视为编辑器;完整试卷内的一处 editable 不能吞掉整张试卷。 return Utils.safeText(surrounding) ? null : editable; } catch (_) { return null; } }, _answerFields(container) { const out = []; container.querySelectorAll(this._textSelector).forEach(input => { if (!this._writable(input) || !this._visible(input)) return; if (input.closest(this._editorChromeSelector) && input.tagName !== 'TEXTAREA') return; out.push({ text: '__FILL__', node: input, input, isFill: true }); }); container.querySelectorAll(this._editableSelector).forEach(input => { if (!this._writable(input) || !this._visible(input)) return; if (input.parentElement?.closest(this._editableSelector)) return; out.push({ text: '__ESSAY__', node: input, input, isFill: true, isEditor: true }); }); container.querySelectorAll('iframe').forEach(frame => { if (!this._visible(frame) || !this._writable(frame)) return; const input = this._frameEditor(frame); if (input && this._writable(input)) out.push({ text: '__ESSAY__', node: frame, input, isFill: true, isEditor: true }); }); return out; }, // 一个容器是否"看起来像一道完整题目" _looksLikeQuestion(el) { if (!el || Utils.isOwnPanel(el)) return false; const choices = this._choiceInputs(el); if (choices.length >= 2) return true; if (choices.length || !this._answerFields(el).length) return false; // fill-blank 一类名字常用于单个空的 span;必须连同题干才是完整题目。 return this._stemText(el).replace(/\[空\d+\]/g, '').trim().length >= 5 || !!el.querySelector('img:not([class*="icon"])'); }, // 同源 iframe 内的 document 集合(按顶层 + 嵌套 iframe 收集) _allDocs() { const docs = [document]; const scan = (doc) => { try { doc.querySelectorAll('iframe').forEach(f => { try { const d = f.contentDocument; // 编辑器 iframe 的 body 是答案区,不是一张新的试卷。 if (d && !this._frameEditor(f) && !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"]', '.question-item', '.questionLi', '.question-box', '.question-wrap', '.subject-item', ]; docs.forEach(doc => { selectors.forEach(sel => { try { doc.querySelectorAll(sel).forEach(el => { if (!this._looksLikeQuestion(el)) return; candidates.add(el); }); } catch (_) { /* 非法 CSS */ } }); }); // ---- 核心兜底:从每个 radio/checkbox 向上爬 DOM,找包含 ≥2 个同类输入的最小父级 ---- // 这种方法不依赖 input.name 属性,适用于 React/Vue LMS(很多框架 radio 无 name) const allInputs = []; docs.forEach(doc => { try { this._choiceInputs(doc).forEach(i => allInputs.push(i)); } catch (_) {} }); const seenInputs = new WeakSet(); for (const inp of allInputs) { if (seenInputs.has(inp)) continue; if (Array.from(candidates).some(container => container.contains(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 = this._choiceInputs(p).length; if (cnt >= 2) { groupP = p; break; } p = p.parentElement; } if (!groupP) continue; const groupInputs = this._choiceInputs(groupP); // 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 = this._choiceInputs(next).length; if (upRadios > groupInputs.length) break; if (this._answerFields(next).length) break; const stemLen = Utils.safeText(next).length; container = next; // 上层文本明显多于选项总和 → 已经包含题干,可以用这一层 if (stemLen - optLen > 8) break; } candidates.add(container); groupInputs.forEach(i => seenInputs.add(i)); } // 行内填空、textarea、contenteditable、同源编辑器 iframe 共用一套发现逻辑。 docs.forEach(doc => { try { this._answerFields(doc).forEach(field => { const inp = field.node; if (Array.from(candidates).some(container => container.contains(inp))) return; const bodyOfInp = inp.ownerDocument?.body; let wrap = inp.parentElement; let found = null; for (let depth = 0; depth < 9 && wrap && wrap !== bodyOfInp; depth++) { if (Utils.isOwnPanel(wrap)) break; if (this._choiceInputs(wrap).length) break; if (Array.from(candidates).some(container => wrap.contains(container))) break; if (wrap.matches(this._editorChromeSelector)) { wrap = wrap.parentElement; continue; } // 不把富文本答案内容当题干;同一行中的多个空保留为一道题。 const stem = this._stemText(wrap); if (stem.replace(/\[空\d+\]/g, '').trim().length >= 8) { found = wrap; break; } wrap = wrap.parentElement; } if (found) candidates.add(found); }); } catch (_) {} }); // 有两个互不包含的完整子题时,父级是题目列表,不能当作一道题。 const all = Array.from(candidates); const arr = all.filter(parent => { const children = all.filter(child => child !== parent && parent.contains(child)); return !children.some((a, i) => children.slice(i + 1).some(b => !a.contains(b) && !b.contains(a))); }); // 单道题内部 .que / .formulation 等嵌套仍保留包含题干的外层。 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), }; }, _stemText(el) { const clone = el.cloneNode(true); // 空格位置是题意的一部分,不能像普通 input 一样直接删除。 let blank = 0; const originalInputs = el.querySelectorAll('input[type="text"], input:not([type])'); clone.querySelectorAll('input[type="text"], input:not([type])').forEach((input, index) => { const original = originalInputs[index]; if (!this._writable(original) || !this._visible(original) || input.closest(this._editorChromeSelector)) { input.remove(); return; } input.replaceWith(clone.ownerDocument.createTextNode(` [空${++blank}] `)); }); clone.querySelectorAll(this._choiceSelector).forEach(input => { const wrap = input.closest('label, li, [class*="option"], [class*="Option"], .answer'); if (wrap && wrap !== clone) wrap.remove(); else input.remove(); }); clone.querySelectorAll( 'textarea, iframe, select, button, script, style, ' + this._editableSelector + ', ' + this._editorChromeSelector + ', ' + '.answers, .answer-input, .question-type, .question-score, .qtype, [class*="questionType"]' ).forEach(node => node.remove()); clone.querySelectorAll('img').forEach(img => { // 图片本体另外传给视觉模型;这里只保留非空的说明。 img.replaceWith(clone.ownerDocument.createTextNode(img.alt ? ` [配图:${img.alt}] ` : ' [配图] ')); }); return Utils.safeText(clone) .replace(/(?:单选题|多选题|判断题|填空题|简答题|论述题|计算题|解答题)\s*[((]\s*\d+(?:\.\d+)?\s*分\s*[))]/g, ' ') .replace(/请按题目中的空缺顺序依次填写答案/g, ' ') .replace(/^\s*第?\s*\d+\s*[.、.::题]\s*/, '').replace(/\s+/g, ' ').trim(); }, // 提取题干文本 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.closest(this._editorChromeSelector + ', ' + this._editableSelector) && !el.querySelector(this._choiceSelector)) { const t = this._stemText(el); const fieldCount = this._answerFields(container).filter(field => !field.isEditor && field.input.tagName !== 'TEXTAREA').length; // 标题不含行内空时,使用完整题干,避免丢失空的所在位置。 if (t.length > 5 && (!fieldCount || (t.match(/\[空\d+\]/g) || []).length === fieldCount)) return t.slice(0, 4000); } } catch (_) {} } return this._stemText(container).slice(0, 4000) || '(题干为空,请人工核对)'; }, // 提取选项 [{ text, node, input }] extractOptions(container) { const out = []; // 优先按 radio/checkbox 反查 const inputs = this._choiceInputs(container); inputs.forEach((inp, index) => { // 找最近的选项包装:label / li / [class*="option"] const wrap = inp.closest('label, li, [class*="option"], [class*="Option"], .answer') || inp.parentElement; if (!wrap) return; const explicitLabel = Array.from(inp.labels || []).find(label => container.contains(label)); let text = Utils.safeText(explicitLabel || wrap); // 剥掉前缀字母 text = text.replace(/^\s*[A-Ha-h]\s*[.、.::))]\s*/, '').trim(); if (!text && wrap.querySelector('img')) text = `(图片选项 ${String.fromCharCode(65 + index)})`; if (text) out.push({ text, node: explicitLabel || wrap, input: inp }); }); if (out.length) return out; return this._answerFields(container); }, // 只收本题题干和选项的配图,不收答题编辑器、工具栏、附件按钮图标。 extractImages(container) { const out = []; const options = this.extractOptions(container).filter(option => !option.isFill); for (const img of container.querySelectorAll('img')) { // 第 7 张交给 AI 层明确报数量超限,不能截成 6 张后不完整作答。 if (out.length >= 7) break; if (!this._visible(img) || Utils.isOwnPanel(img) || img.closest(this._editorChromeSelector + ', ' + this._editableSelector + ', .attachment, .attachments, [class*="upload"], [class*="avatar"], [class*="icon"]')) continue; const raw = img.getAttribute('data-src') || img.getAttribute('data-original') || img.currentSrc || img.getAttribute('src'); let url = raw || ''; try { if (raw) url = new URL(raw, img.baseURI).href; } catch (_) { /* 由 AI 层明确报图片地址异常 */ } const optionIndex = options.findIndex(option => option.node.contains(img)); const context = optionIndex >= 0 ? `选项 ${String.fromCharCode(65 + optionIndex)}` : `题干配图 ${out.length + 1}`; const alt = `${context}${img.alt ? ':' + img.alt.trim().slice(0, 180) : ''}`; const prior = out.find(item => item.url === url); if (prior) prior.alt += ';' + alt; else out.push({ url, alt }); } return out; }, _judgeValue(value) { const text = String(value || '').trim().replace(/^\s*[A-H]\s*[.、.::))]\s*/i, '').replace(/[\s(()).。!!]/g, '').toLowerCase(); if (/^(?:正确|对|是|true|t|√|✓|✔|right|yes|正确√|√正确)$/.test(text)) return true; if (/^(?:错误|错|否|false|f|×|✕|✖|✗|✘|x|wrong|no|错误×|×错误)$/.test(text)) return false; return null; }, // 推断题型 detectType(container, opts) { const txt = Utils.safeText(container); // 判断题(包含"对/错"或"正确/错误"且只有 2 个选项) if (opts.length === 2) { const values = opts.map(option => this._judgeValue(option.text)); if (values.includes(true) && values.includes(false)) { 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.isEditor || o.input?.tagName === 'TEXTAREA'); if (/填空题|填空|fill.?in.?the.?blank/i.test(txt)) return AI.TYPE_FILL; 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: 已填值 const fields = opts.filter(option => option.isFill); if (fields.length && fields.every(option => String(option.isEditor ? option.input?.textContent || '' : option.input?.value || '').trim())) return true; return false; }, _navigationLabel(element) { return String(Utils.safeText(element) || element.value || element.getAttribute('aria-label') || element.title || '') .replace(/[\u200b-\u200d\ufeff]/g, '').replace(/\s+/g, ' ').trim(); }, _navigationControls(root) { return Array.from(root.querySelectorAll('button, a, [role="button"], input[type="submit"], input[type="button"]')) .filter(element => this._visible(element) && this._writable(element)); }, _submissionDialogSelector: '[role="dialog"], dialog, .el-message-box, .el-dialog, .ant-modal, .ant-modal-confirm, .modal, .layui-layer-dialog, .swal2-popup, .sweet-alert, .ui-dialog', // 下一页与最终交卷分别处理;固定定位按钮及同源试卷 iframe 都可识别。 findNavigationAction(preferredDocs = []) { const docs = Array.from(new Set([...preferredDocs, ...this._allDocs()])); const nextRe = /^(?:下一题|下一页|下一步|继续答题|Next)\s*[>›→»]?$/i; const submitRe = /^(?:提交|提交答案|提交试卷|提交答卷|确认提交|交卷|交卷并结束|结束答题|结束作答|结束考试|完成答题|完成作答|全部提交|提交全部并结束|提交所有答案并结束|Submit|Submit all and finish|Finish attempt)\s*[>›→»]?$/i; let submit = null; for (const doc of docs) { for (const element of this._navigationControls(doc)) { // 弹窗内按钮只能走交卷确认流程,不能作为“下一页”直接点击。 if (element.closest(this._submissionDialogSelector)) continue; const label = this._navigationLabel(element); if (nextRe.test(label)) return { kind: 'next', element, label }; if (!submit && submitRe.test(label)) submit = { kind: 'submit', element, label }; } } return submit; }, findSubmissionState() { const confirms = []; let complete = false; const completeRe = /提交成功|交卷成功|(?:试卷|答卷|答案)已提交|已成功提交|已交卷|考试已结束/; const contextRe = /提交|交卷|结束(?:答题|作答|考试)|完成(?:答题|作答)|submit|finish attempt/i; const unansweredRe = /(?:\d+\s*(?:道|个)?\s*(?:题|小题)[^。.!!\n]{0,16}(?:未答|没答|未作答|未完成|空白))|(?:未答|未作答|未完成|空白)(?:的)?(?:题目|试题|小题|题)|(?:题目|试题)[^。.!!\n]{0,8}(?:未答|未作答)/; const confirmRe = /^(?:确定|确认|是|继续提交|确认提交|确定提交|确认交卷|确定交卷|立即交卷|提交|交卷|提交试卷|提交答案|Submit|Confirm|Yes)$/i; for (const doc of this._allDocs()) { const dialogs = Array.from(doc.querySelectorAll(this._submissionDialogSelector)).filter(element => this._visible(element)); for (const dialog of dialogs) { const text = Utils.safeText(dialog); const warnings = text .replace(/(?:还有|尚有|仍有|剩余)?\s*0\s*(?:道|个)\s*(?:题|小题)(?:尚)?(?:未答|未作答|未完成|空白)/g, '') .replace(/(?:未答|未作答|未完成|空白)(?:的)?(?:题目|试题|小题|题)(?:数量|数)?\s*[::]?\s*0\s*(?:道|个)?/g, ''); if (unansweredRe.test(warnings)) return { kind: 'warning', message: '平台提示仍有未作答题目,已停止自动交卷' }; if (completeRe.test(text)) complete = true; if (!contextRe.test(text)) continue; const buttons = this._navigationControls(dialog).filter(element => confirmRe.test(this._navigationLabel(element))); // 重叠的弹窗包装可能重复包含同一按钮,只保留一次。 for (const element of buttons) if (!confirms.includes(element)) confirms.push(element); } const messages = doc.querySelectorAll('[role="status"], [role="alert"], .alert, .el-message, .ant-message-notice-content, .toast, .submission-status, .submission-result, .exam-result, .quiz-result, .quiz-results'); for (const element of messages) { if (this._visible(element) && completeRe.test(Utils.safeText(element))) complete = true; } } if (complete) return { kind: 'complete' }; if (confirms.length === 1) return { kind: 'confirm', element: confirms[0], label: this._navigationLabel(confirms[0]) }; return { kind: confirms.length > 1 ? 'ambiguous' : 'none' }; }, // 保留旧调用兼容;自动答题使用带动作类型的接口。 findNextButton() { return this.findNavigationAction()?.element || null; }, }; // ============== Solver:填答案 ============== const Solver = { // 把 AI 答案匹配到选项索引集合(仅对 single/multi/judge) _matchAnswersToIndices(opts, answers, type) { if (!Array.isArray(opts) || !Array.isArray(answers) || !answers.length) return []; if (answers._answerIndices !== undefined) { const indices = answers._answerIndices; if (!Array.isArray(indices) || indices.length !== answers.length || !indices.length) return []; if (indices.some(index => !Number.isInteger(index) || index < 0 || index >= opts.length)) return []; if (new Set(indices).size !== indices.length || (type !== AI.TYPE_MULTI && indices.length !== 1)) return []; return indices.slice(); } if (type === AI.TYPE_JUDGE) { if (answers.length !== 1) return []; const ans = String(answers[0] || '').trim(); const truth = Detector._judgeValue(ans); if (truth !== null) { const matches = opts.map((option, index) => Detector._judgeValue(option.text) === truth ? index : -1).filter(index => index >= 0); return matches.length === 1 ? matches : []; } // 无法理解的返回值不能默认当成“错”。只允许明确的选项字母。 const letter = ans.match(/^([A-H])[.、.::))]?$/i); const index = letter ? letter[1].toUpperCase().charCodeAt(0) - 65 : -1; return index >= 0 && index < opts.length ? [index] : []; } // SINGLE / MULTI:只有完整、唯一的原文匹配或明确编号才允许修改选项。 if (type !== AI.TYPE_SINGLE && type !== AI.TYPE_MULTI) return []; if (type === AI.TYPE_SINGLE && answers.length !== 1) return []; const normalize = value => String(value ?? '').normalize('NFKC') .replace(/^\s*[A-Z]\s*[.、:))]\s*/i, '').replace(/\s+/g, '').trim(); const optionTexts = opts.map(option => normalize(option.text)); const targets = []; const used = new Set(); for (const ans of answers) { const text = typeof ans === 'string' ? ans.trim() : ''; const ansN = normalize(text); if (!ansN) return []; const labeled = text.match(/^([A-Z])\s*[..、::))]\s*(.+)$/i); let index = -1; if (labeled) { index = labeled[1].toUpperCase().charCodeAt(0) - 65; if (index >= opts.length || optionTexts[index] !== normalize(labeled[2])) return []; } else { const matches = optionTexts.map((optionText, optionIndex) => optionText === ansN ? optionIndex : -1).filter(optionIndex => optionIndex >= 0); if (matches.length > 1) return []; if (matches.length === 1) index = matches[0]; else { // 先匹配原文“A”,再兼容旧模型仅返回字母编号的情况。 const letter = text.match(/^(?:(?:选项|选择|应选|选)\s*)?([A-Z])[.、.::))]?$/i); if (letter) index = letter[1].toUpperCase().charCodeAt(0) - 65; } } // 多选只匹配到部分答案或出现重复/越界编号时,整道题不自动选择。 if (index < 0 || index >= opts.length || used.has(index)) return []; targets.push(index); used.add(index); } return targets; }, _fieldText(option) { return String(option.isEditor ? option.input?.innerText ?? option.input?.textContent ?? '' : option.input?.value ?? '').replace(/\r\n?/g, '\n').trim(); }, async _setField(option, answer) { const input = option.input; if (!Detector._writable(input) || !Detector._visible(option.node)) return false; const text = String(answer).replace(/\r\n?/g, '\n'); if (!option.isEditor) { Utils.nativeSet(input, text); } else { const doc = input.ownerDocument; const hostView = option.node.ownerDocument.defaultView; const html = text.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); let usedApi = false; // TinyMCE / CKEditor 优先走实例 API,同时同步隐藏的原始 textarea。 const tiny = hostView?.tinymce || hostView?.tinyMCE; const tinyEditor = tiny?.get?.(option.node.id?.replace(/_ifr$/, '')); if (tinyEditor?.mode?.isReadOnly?.()) return false; if (tinyEditor) { tinyEditor.setContent(html); tinyEditor.save?.(); tinyEditor.fire?.('change'); usedApi = true; } if (!usedApi && hostView?.CKEDITOR?.instances) { const editor = Object.values(hostView.CKEDITOR.instances).find(instance => instance.container?.$?.contains(option.node) || instance.editable?.()?.$ === input); if (editor) { if (editor.readOnly) return false; await new Promise((resolve, reject) => { let done = false; const timer = setTimeout(() => { if (!done) reject(new Error('编辑器同步超时')); }, 3000); editor.setData(html, { callback() { done = true; clearTimeout(timer); editor.updateElement?.(); resolve(); } }); }); usedApi = true; } } if (!usedApi) { input.focus(); const selection = doc.getSelection(); const range = doc.createRange(); range.selectNodeContents(input); selection?.removeAllRanges(); selection?.addRange(range); // insertText 经过编辑器的输入链路;浏览器不支持时再设置纯文本。 let inserted = false; try { inserted = !!doc.execCommand?.('insertText', false, text); } catch (_) {} if (!inserted) input.textContent = text; selection?.removeAllRanges(); } const EventType = doc.defaultView.Event; input.dispatchEvent(new EventType('input', { bubbles: true })); input.dispatchEvent(new EventType('change', { bubbles: true })); input.blur(); } await Utils.sleep(100); const actual = this._fieldText(option); // 富文本段落在 innerText 中可能多出换行或不换行空格,按可读文本验证。 return option.isEditor ? actual.replace(/\s+/g, ' ') === text.trim().replace(/\s+/g, ' ') : actual === text.trim(); }, // 根据 AI 答案应用到 DOM(异步:状态同步式,逐项 verify) async applyAnswer(opts, answers, type) { if (!answers?.length) return { success: false, msg: '无答案' }; // 填空 / 简答 if (type === AI.TYPE_FILL || type === AI.TYPE_ESSAY) { if (type === AI.TYPE_ESSAY && answers.some(answer => AI._essayFormatIssue(answer))) return { success: false, msg: '简答答案仍含无法直接填写的公式格式,未修改原答案' }; const fills = opts.filter(o => o.isFill); if (!fills.length) return { success: false, msg: '无填空框' }; if (answers.length !== fills.length) return { success: false, msg: `答案数量 ${answers.length} 与答题框 ${fills.length} 不符,请人工核对` }; if (answers.some(answer => typeof answer !== 'string' || !answer.trim())) return { success: false, msg: '答案包含空值,未填写' }; if (fills.some(option => !Detector._writable(option.input) || !Detector._visible(option.node))) return { success: false, msg: '答题框不可编辑,未填写' }; let count = 0, failed = 0; for (let i = 0; i < fills.length; i++) { try { if (await this._setField(fills[i], answers[i])) count++; else failed++; } catch (_) { failed++; } } return { success: count === fills.length && !failed, msg: `已确认填写 ${count}/${fills.length} 个答题框${failed ? ',部分未保存,请人工核对' : ''}` }; } // radio/checkbox 类:先匹配出目标索引集合 const targets = this._matchAnswersToIndices(opts, answers, type); if (!targets.length) return { success: false, msg: 'AI 答案未匹配任何选项' }; const wantSet = new Set(targets); if (opts.some((option, index) => option.input && !option.isFill && !Detector._writable(option.input) && (!!option.input.checked !== wantSet.has(index)))) return { success: false, msg: '选项已禁用或只读,未修改' }; // 状态同步:逐个 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 = targets.filter(index => opts[index]?.input?.checked).length; const matches = opts.every((option, index) => !option.input || option.isFill || !!option.input.checked === wantSet.has(index)); 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 === total && failed === 0 && matches, 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 images = Detector.extractImages(container); if (images.length) logger(` 🖼️ 检测到 ${images.length} 张题目/选项图片`); let answers = await AI.call(stem, opts.map(o => o.text), type, logger, images); const isChoice = type === AI.TYPE_SINGLE || type === AI.TYPE_MULTI; if (isChoice && !Solver._matchAnswersToIndices(opts, answers, type).length) { logger(` ⚠️ AI 回答“${answers.join(' / ').slice(0, 80)}”未对应完整选项,按选项编号重试一次`); const correctedStem = stem + '\n\n【作答格式校正】上一回答未对应任何完整选项。请重新阅读原题、全部选项及附图,求解所有空或所有问题;只返回 {"choices":["A"]} 这样的选项编号 JSON,多选列出全部编号。不能确定时返回 {"choices":[]}。'; answers = await AI.call(correctedStem, opts.map(o => o.text), type, logger, images); if (!Solver._matchAnswersToIndices(opts, answers, type).length) { throw new Error('AI 重试后仍未给出完整、唯一的选项,已停止本题填写;请检查题图和模型回答'); } } 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) }; } }, _questionFingerprint(questions) { // 只比较题目身份和题面;已填答案、运行日志、计时器变化不能冒充翻页成功。 const parts = questions.map(question => ({ id: question.getAttribute('data-question-id') || question.getAttribute('data-questionid') || question.getAttribute('data-quiz-id') || question.id || '', stem: Detector.extractStem(question), options: Detector.extractOptions(question).map(option => option.text), images: Detector.extractImages(question).map(image => image.url), })); const text = JSON.stringify(parts); let hash = 2166136261; for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 16777619); return text.length + ':' + (hash >>> 0).toString(16); }, async _navigationGuard(logger) { await waitWhilePaused(logger); return !stopRequested; }, _navigationTimeout() { return Math.max(200, Math.min(30000, Number(Config.navigationTimeoutMs) || 8000)); }, async _waitForPageChange(previousFingerprint, logger) { let deadline = Date.now() + this._navigationTimeout(); while (Date.now() <= deadline) { const pausedAt = Date.now(); if (!await this._navigationGuard(logger)) return false; deadline += Date.now() - pausedAt; const questions = Detector.findQuestions(); if (questions.length && this._questionFingerprint(questions) !== previousFingerprint) return true; await Utils.sleep(Math.min(200, Math.max(1, deadline - Date.now()))); } return false; }, async _submitOnce(action, logger, expectedFingerprint) { if (!await this._navigationGuard(logger) || !Store.getAutoSubmit()) return; logger('⏳ 提交前等待答案状态稳定,可暂停或停止'); await Utils.sleep(Math.max(1000, Number(Config.afterClickMs) || 600)); if (!await this._navigationGuard(logger)) return; if (!Store.getAutoSubmit()) { logger('⏸ 自动提交已关闭,停留在交卷前'); return; } const current = Detector.findQuestions(); if (!current.length || this._questionFingerprint(current) !== expectedFingerprint || current.some(question => !Detector.isAnswered(question, Detector.extractOptions(question)))) { logger('⚠️ 提交前题目或答案状态发生变化,已停止自动交卷,请核对当前页面'); return; } const readyAction = Detector.findNavigationAction(current.map(question => question.ownerDocument)); if (!readyAction || readyAction.kind !== 'submit') { logger('⚠️ 提交前按钮状态发生变化,已停止自动交卷'); return; } action = readyAction; const initial = Detector.findSubmissionState(); if (initial.kind === 'warning') { logger(`⚠️ ${initial.message}`); return; } if (initial.kind === 'complete') { logger('✅ 页面已显示提交完成状态,停止继续操作'); return; } if (initial.kind === 'confirm' || initial.kind === 'ambiguous') { logger('⚠️ 页面已有待处理弹窗,已停止自动交卷,请检查弹窗内容'); return; } if (!Utils.safeClick(action.element)) { logger('⚠️ 提交按钮无法点击,已停止自动交卷'); return; } logger(`📤 已点击“${action.label}”,等待平台反馈`); let confirmed = false; let deadline = Date.now() + this._navigationTimeout(); while (Date.now() <= deadline) { const pausedAt = Date.now(); if (!await this._navigationGuard(logger)) { logger('⏹ 已停止,未继续确认提交'); return; } deadline += Date.now() - pausedAt; const state = Detector.findSubmissionState(); if (state.kind === 'warning') { logger(`⚠️ ${state.message}`); return; } if (state.kind === 'complete') { logger('✅ 平台显示提交完成'); return; } if (state.kind === 'ambiguous') { logger('⚠️ 检测到多个提交确认按钮,已停止,请手动核对弹窗'); return; } if (state.kind === 'confirm' && !confirmed) { if (!Store.getAutoSubmit()) { logger('⏸ 自动提交已关闭,停留在确认弹窗'); return; } if (!await this._navigationGuard(logger)) return; // 仅确认包含交卷上下文的弹窗,不劫持浏览器原生 confirm。 if (!Utils.safeClick(state.element)) { logger('⚠️ 无法点击交卷确认按钮,请手动核对'); return; } confirmed = true; logger('📤 已确认交卷,等待平台反馈'); } await Utils.sleep(Math.min(200, Math.max(1, deadline - Date.now()))); } logger('ℹ️ 已触发提交,待核对平台结果;未重复点击提交'); }, 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; let allSucceeded = true; const seenPages = new Set(); while (!stopRequested) { if (!await this._navigationGuard(logger)) break; const questions = Detector.findQuestions(); if (!questions.length) { logger(`⚠️ 第 ${page} 页未找到可作答题目,已停止;未点击翻页或提交`); break; } const fingerprint = this._questionFingerprint(questions); if (seenPages.has(fingerprint)) { logger('⚠️ 检测到重复题页,已停止,避免重复调用 AI'); break; } seenPages.add(fingerprint); logger(`📚 第 ${page} 页:${questions.length} 道题`); stats.total += questions.length; let pageSucceeded = true; for (let i = 0; i < questions.length; i++) { if (stopRequested) break; const result = await this.runOne(questions[i], i, questions.length, logger); if (!result?.ok) pageSucceeded = false; await Utils.sleep(Store.getInterMs()); } if (!await this._navigationGuard(logger)) break; const unanswered = questions.some(question => !Detector.isAnswered(question, Detector.extractOptions(question))); allSucceeded = allSucceeded && pageSucceeded && !unanswered; const action = Detector.findNavigationAction(questions.map(question => question.ownerDocument)); if (!action) { logger('✅ 当前页处理结束,未找到可用的下一页或提交按钮'); break; } if (action.kind === 'next') { if (!Store.getAutoNext()) { logger('⏸ 已关闭自动翻页,停留在当前页'); break; } if (!await this._navigationGuard(logger)) break; if (!Utils.safeClick(action.element)) { logger('⚠️ 下一页按钮无法点击,已停止'); break; } logger(`➡️ 已点击“${action.label}”,等待题目更新`); if (!await this._waitForPageChange(fingerprint, logger)) { if (!stopRequested) logger('⚠️ 等待后题目仍未更新,已停止,避免重复答题和重复调用 AI'); break; } page++; continue; } if (!Store.getAutoSubmit()) { logger('⏸ 自动提交已关闭,答案已填写,停留在交卷前'); break; } if (!allSucceeded || stats.failed || stats.skipped || stats.success !== stats.total || !stats.total) { logger('⚠️ 本轮存在答题失败、跳过或未填写题目,已停止自动提交,请人工核对'); break; } await this._submitOnce(action, logger, fingerprint); break; } } 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-ai-picker{display:flex;gap:8px;align-items:center;min-width:0;} #ouchn-panel .oc-ai-picker select{flex:1;min-width:0;} #ouchn-panel .oc-ai-picker button{flex:none;padding:8px 10px;font-size:12px;} #ouchn-panel .oc-ai-label-row{display:flex;align-items:center;justify-content:space-between;gap:8px;} #ouchn-panel .oc-ai-label-row button{font-size:12px;padding:7px 10px;flex-shrink:0;} #ouchn-panel .oc-model-current{display:flex;flex-direction:column;gap:3px;font-size:12px;line-height:1.6;color:#cbd5e1;overflow-wrap:anywhere;} #ouchn-panel .oc-model-current strong{font-weight:600;color:#f1f5f9;} #ouchn-panel .oc-model-current code{font-size:11px;white-space:pre-wrap;overflow-wrap:anywhere;color:#bfdbfe;} #ouchn-panel .oc-model-current[hidden]{display:none;} #ouchn-panel .oc-model-manual summary{font-size:12px;font-weight:400;color:#bfdbfe;} #ouchn-panel .oc-model-manual[open] summary{margin-bottom:7px;} #ouchn-panel #oc-model-search-clear:disabled{cursor:default;} #ouchn-panel .oc-switch input:focus-visible + .oc-switch-slider{outline:2px solid #93c5fd;outline-offset:3px;} #ouchn-panel button:focus-visible{outline:2px solid #93c5fd;outline-offset:2px;} #ouchn-panel #oc-ai-vision-status{font-size:12px;line-height:1.6;color:#cbd5e1;} #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;} #ouchn-panel .oc-input,#ouchn-panel .oc-select{ width:100%;padding:7px 10px;background:#1e293b!important;color:#f1f5f9!important;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;color-scheme:dark;-webkit-text-fill-color:#f1f5f9!important; } #ouchn-panel .oc-input:focus,#ouchn-panel .oc-select:focus{outline:2px solid #93c5fd;outline-offset:2px;border-color:#3b82f6;box-shadow:0 0 0 3px rgba(59,130,246,.2);} #ouchn-panel .oc-select{cursor:pointer;} #ouchn-panel .oc-select option,#ouchn-panel #oc-model-options option{background:#1e293b!important;color:#f1f5f9!important;-webkit-text-fill-color:#f1f5f9!important;} .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 autoSubmit = Store.getAutoSubmit(); const interMs = Store.getInterMs(); panel.innerHTML = `
🎓 国开答题助手 v${Config.version}
⚠️ BUG 反馈 / 购码请加微信 ${Config.wechat} 📋
${Store.isVerifyValid() ? '✅ 已验证' : '🔐 验证码激活'}
扫码获取验证码 扫码大图
悬停放大 / 点击新窗口
扫码看广告 → 获取 4 位验证码 → 输入下方激活,验证后免费使用 24 小时
${Store.isVerifyValid() ? `✅ 有效期至 ${Store.getValidUntilStr()}` : '⏳ 未验证,启动答题前需先激活'}
AI 配置

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

打开服务商控制台

填写当前服务的 Key 后获取模型。列表以接口返回为准;是否有调用权限仍需测试连接。

手动填写模型 / 接入点 ID

遇到图片题会自动检测当前模型;也可提前检测。检测会向所选 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 modelListEl = find('#oc-model-list'); const modelSearchEl = find('#oc-model-search'); const modelClearEl = find('#oc-model-search-clear'); const modelManualEl = find('#oc-model-manual'); const modelsEl = find('#oc-ai-models'); const visionTestEl = find('#oc-ai-vision-test'); const visionStatusEl = find('#oc-ai-vision-status'); 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(); const modelLists = new Map(); let activeProvider = Store.getAiConfig().provider; let revision = 0; let testing = false; let fetchingModels = false; let detectingVision = 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 listIdentity = config => { let url = config.url; try { url = AI.resolveEndpoint(config); } catch (_) {} return JSON.stringify([config.provider, clean(config.apiKey), clean(url)]); }; const renderModelList = (config = read()) => { const result = modelLists.get(listIdentity(config)); const all = result?.models || []; const words = clean(modelSearchEl.value).normalize('NFKC').toLocaleLowerCase().split(/\s+/).filter(Boolean); const visible = all.filter(model => { const haystack = `${model.name || ''} ${model.id}`.normalize('NFKC').toLocaleLowerCase(); return words.every(word => haystack.includes(word)); }); const current = all.find(model => model.id === config.model); const nameOf = model => model.name && model.name !== model.id ? `${model.name} · ${model.id}` : model.id; modelListEl.replaceChildren(); const placeholder = document.createElement('option'); placeholder.value = ''; placeholder.disabled = true; placeholder.textContent = result ? all.length ? visible.length ? `请选择模型(${visible.length} 个)` : '没有匹配的模型' : '接口未返回模型,可手动填写' : '填写 Key 后点击获取模型'; modelListEl.appendChild(placeholder); for (const model of visible) { const option = document.createElement('option'); option.value = model.id; option.textContent = nameOf(model); modelListEl.appendChild(option); } const filteredCurrent = current && !visible.includes(current); if (filteredCurrent) { const option = document.createElement('option'); option.value = current.id; option.textContent = `当前:${nameOf(current)}(不在筛选结果中)`; modelListEl.appendChild(option); } const manual = document.createElement('option'); manual.value = '__ouchn_manual__'; manual.dataset.manual = 'true'; manual.textContent = '手动填写模型 / 接入点 ID'; modelListEl.appendChild(manual); modelListEl.value = current ? config.model : config.model ? '__ouchn_manual__' : ''; modelListEl.title = current ? nameOf(current) : config.model || '选择模型'; modelClearEl.disabled = !modelSearchEl.value; find('#oc-model-filter-status').textContent = result ? `匹配 ${visible.length} / 共 ${all.length} 个模型${filteredCurrent ? ';已保留当前选择' : ''}。${all.length && !visible.length ? '请换一个关键词或清空搜索。' : ''}` : '获取模型后,可按名称或 ID 搜索;搜索仅在当前列表中进行。'; find('#oc-model-current').hidden = !config.model; find('#oc-model-current-name').textContent = current?.name || config.model || ''; find('#oc-model-current-id').textContent = config.model ? `模型 ID:${config.model}` : ''; find('#oc-model-current-capability').textContent = current ? current.vision === 'supported' ? '接口标注支持图片,实际能力需检测。' : current.vision === 'unsupported' ? '接口标注仅文字。' : '接口未标注图片能力。' : config.model ? '手动指定的模型,调用权限需测试连接。' : ''; find('#oc-model-list-help').textContent = result ? `已从当前接口获取 ${result.models.length} 个模型。${result.message || '列表不代表当前 Key 一定有调用权限,请测试连接。'}` : '填写当前服务的 Key 后获取模型。列表以接口返回为准;服务不提供列表时,可手动填写并测试连接。'; const suggestions = find('#oc-model-options'); suggestions.replaceChildren(); for (const model of result?.models || []) { const option = document.createElement('option'); option.value = model.id; option.label = model.name || model.id; suggestions.appendChild(option); } }; const refreshVision = () => { const status = Store.getVisionStatus(read()); const labels = { supported: '图片能力:已确认支持', unsupported: '图片能力:不支持', unknown: '图片能力:尚未确认' }; visionStatusEl.textContent = status ? `${labels[status.status]}。${safeMessage(status.message)}` : '图片能力:未检测,遇到图片题时自动检测。'; visionStatusEl.style.color = status?.status === 'supported' ? '#86efac' : status?.status === 'unsupported' ? '#fca5a5' : '#cbd5e1'; }; const describeSaved = () => { const saved = Store.getAiConfig(); const status = Store.getVisionStatus(saved); find('#oc-ai-summary').textContent = `${Config.aiProviders[saved.provider]?.name || 'AI'} · ${saved.apiKey ? saved.model || '待配置模型' : '待配置'}${status?.status === 'supported' ? ' · 图片已确认' : ''}(已保存)`; }; const invalid = validation => { section.open = true; clearErrors(); const el = fields[validation.field] || keyEl; if (el === modelEl) modelManualEl.open = true; 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 || '填写服务商模型名称'; modelSearchEl.value = ''; modelManualEl.open = !modelLists.get(listIdentity(config))?.models.some(model => model.id === config.model); renderModelList(config); refreshVision(); 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'); if (el !== modelEl) modelSearchEl.value = ''; renderModelList(); refreshVision(); message('配置已修改,保存后用于答题。'); }; el.addEventListener('input', edited); el.addEventListener('change', edited); }); // 筛选只影响候选列表,不修改配置、检测状态或正在获取列表的请求身份。 modelSearchEl.addEventListener('input', () => renderModelList()); modelClearEl.addEventListener('click', () => { modelSearchEl.value = ''; renderModelList(); modelSearchEl.focus(); }); modelListEl.addEventListener('change', () => { if (modelListEl.selectedOptions[0]?.dataset.manual === 'true') { modelManualEl.open = true; modelEl.focus(); return; } modelEl.value = modelListEl.value; modelManualEl.open = false; modelEl.removeAttribute('aria-invalid'); revision++; renderModelList(); refreshVision(); message('已选择模型,保存后用于答题。'); }); modelsEl.addEventListener('click', async () => { if (fetchingModels) return; const config = read(); // 获取目录不要求先填写模型,其他校验与正式请求相同。 const validation = AI.validateConfig({ ...config, model: 'models-discovery' }); if (!validation.ok) { invalid(validation); return; } const token = revision; fetchingModels = true; modelsEl.disabled = true; modelsEl.textContent = '获取中…'; modelsEl.setAttribute('aria-busy', 'true'); message('正在使用当前 Key 获取模型列表…'); try { const result = await AI.listModels({ ...config }); modelLists.set(listIdentity(config), result); if (section.isConnected && token === revision) { modelManualEl.open = !result.models.some(model => model.id === read().model); renderModelList(); message(result.models.length ? `已获取 ${result.models.length} 个模型,请选择并保存。${result.message || ''}` : result.message || '接口未返回可选模型,请手动填写模型 ID 并测试连接。'); } } catch (error) { if (section.isConnected && token === revision) message('获取模型失败:' + safeMessage(error?.message || error, config), true); } finally { fetchingModels = false; modelsEl.disabled = false; modelsEl.textContent = '获取模型'; modelsEl.removeAttribute('aria-busy'); } }); visionTestEl.addEventListener('click', async () => { if (detectingVision) return; const config = read(); const validation = AI.validateConfig(config); if (!validation.ok) { invalid(validation); return; } const token = revision; detectingVision = true; visionTestEl.disabled = true; visionTestEl.textContent = '检测中…'; visionTestEl.setAttribute('aria-busy', 'true'); visionStatusEl.textContent = '图片能力:正在检测当前模型…'; message('正在发送本地生成的小图,检查当前模型能否识别。'); try { const result = await AI.checkVision({ ...config }, true); if (section.isConnected && token === revision) { refreshVision(); describeSaved(); message(safeMessage(result.message || '图片能力检测已完成。', config) + (equal(config, Store.getAiConfig()) ? '' : ' 当前配置尚未保存。'), result.status !== 'supported'); } } catch (error) { if (section.isConnected && token === revision) { refreshVision(); message('检测未完成:' + safeMessage(error?.message || error, config), true); } } finally { detectingVision = false; visionTestEl.disabled = false; visionTestEl.textContent = '检测图片能力'; visionTestEl.removeAttribute('aria-busy'); } }); window.addEventListener('ouchn-vision-status', () => { if (section.isConnected) { refreshVision(); describeSaved(); } }); 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 submitEl = panel.querySelector('#oc-autoSubmit'); submitEl.onchange = () => { Store.setAutoSubmit(submitEl.checked); this.addLog(`✅ 全部完成后自动提交:${submitEl.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(); } })();