// ==UserScript== // @name U校园刷课助手 // @namespace http://tampermonkey.net/ // @version 1.1 // @description U校园(Unipus)刷课工具,自动遍历目录 + AI 自动作答(DeepSeek 文本 + Qwen3-Omni-Flash 多模态听力)+ 时长分配 + 视频自动播放 // @author 叶屿 // @license GPL3 // @antifeature payment AI 答题与解锁加速功能需要验证码(免费看广告获取),视频自动播放等基础功能完全免费;用户需自备 DeepSeek / 通义千问 等 API Key // @match https://ucontent.unipus.cn/* // @match https://ipub.unipus.cn/* // @match https://uai.unipus.cn/* // @match https://u.unipus.cn/* // @match https://*.u.unipus.cn/* // @icon https://ucontent.unipus.cn/favicon.ico // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect qsy.iano.cn // @connect open.bigmodel.cn // @connect api.deepseek.com // @connect dashscope.aliyuncs.com // @connect unipus.cn // @connect ucontent.unipus.cn // @connect *.unipus.cn // @connect * // @run-at document-end // @noframes false // ==/UserScript== /* * ========================================== * U校园刷课助手 v1.1 * ========================================== * * 【功能说明】 * - 自动遍历 Unit/Section/Micro 三级目录 * - AI 自动作答:DeepSeek(文本)+ 通义千问Qwen3-Omni-Flash / 智谱GLM-4-Voice(听力多模态) * - 学习时长智能分配(总时长均分到每项) * - 视频/音频自动播放 * - 自动关闭"我知道了"等弹窗 * - 实时进度日志 + 倒计时显示 * - 悬浮球UI入口 * * 【参考项目】 * - UnipusAIAutoPlayer (https://github.com/uxudjs/UnipusAIAutoPlayer) * - 借鉴菜单识别 + iframe 通信架构 * * 【免责声明】 * 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为 * * 【版权信息】 * 作者:叶屿 | 版本:v1.1 | 更新:2026-05-24 * * 【v1.1 更新】 * - 听力题模型推荐标签(千问满分率高,智谱限流不稳) * - 远程公告系统(对接 qsy.iano.cn 后台) * - 增加 @antifeature payment 合规声明 * * ========================================== */ (() => { 'use strict'; // ---- 全局环境标识 ---- const IS_IPUB = location.hostname.includes('ipub.unipus.cn') || location.hostname.includes('uai.unipus.cn'); const IS_UCONTENT = location.hostname.includes('ucontent.unipus.cn'); // 普通版(旧版)U 校园:u.unipus.cn / sso.u.unipus.cn 等 const IS_LEGACY = location.hostname === 'u.unipus.cn' || location.hostname.endsWith('.u.unipus.cn'); // ucontent 可能是全页面(用户从目录点进来)或 iframe 内 const IS_IFRAME = (() => { try { return window.self !== window.top; } catch (_) { return true; } })(); // 主页面:顶层 ipub/legacy,或者 ucontent 作为全页面打开时 const IS_MAIN = !IS_IFRAME && (IS_IPUB || IS_LEGACY || IS_UCONTENT); // ---- 全局状态 ---- let panel = null; let isRunning = false; let isPaused = false; let stopRequested = false; let AI_PROVIDERS = {}; // 各 AI 提供商最近一次请求时间(节流避免限流) const aiLastRequestAt = {}; // ---- 配置 ---- const Config = { version: '1.1', playbackRate: 1, // 视频倍速(默认 1x) stepWaitMs: 3000, // 章节进入后等待(给 SPA 足够加载时间) minStepSec: 8, // 每项最少等待秒数 pollIntervalMs: 800, // DOM 轮询间隔 chapterEndWaitMs: 2500, // 章节末尾 → 下一章 之前的缓冲时间 afterSubmitWaitMs: 1500, // 答题提交后等待(关弹窗 / 等待跳转) tabSwitchWaitMs: 2500, // 标签页切换后的等待 // 题间延迟(毫秒) questionDelayLocked: 35000, // 广告模式:35 秒 questionDelayUnlocked: 1500, // 解锁后:1.5 秒 questionDelayJitter: 500, // 抖动随机量 // 广告 / 解锁码 API qrImageUrl: 'https://qsy.iano.cn/yzm.png', verifyUrl: 'https://qsy.iano.cn/index.php?s=/api/code/verify', announceUrl: 'https://qsy.iano.cn/index.php?s=/admin/unified_manage/scriptannouncementapi', storageKeys: { deviceId: 'unipus_device_id', aiApiKey: 'unipus_ai_api_key', aiProvider: 'unipus_ai_provider', audioApiKey: 'unipus_audio_api_key', audioProvider: 'unipus_audio_provider', verifyValidUntil: 'unipus_verify_valid_until', answerMode: 'unipus_answer_mode', unmatchedFallback: 'unipus_unmatched_fallback', playbackRate: 'unipus_playback_rate', featureConf: 'unipus_feature_conf', lastTimeValue: 'unipus_last_time_value', panelOpen: 'unipus_panel_open', } }; // ---- 工具函数 ---- const Utils = { sleep: (ms = 1000) => new Promise(r => setTimeout(r, ms)), safeText: (v) => (typeof v === 'string' ? v.replace(/\s+/g, ' ').trim() : ''), safeJSON(raw, fallback = null) { if (!raw) return fallback; try { return JSON.parse(raw); } catch (_) { return fallback; } }, genDeviceId() { let id = localStorage.getItem(Config.storageKeys.deviceId); if (!id) { id = 'unipus_' + Date.now() + '_' + Math.random().toString(36).slice(2, 11); localStorage.setItem(Config.storageKeys.deviceId, id); } return id; }, // 等待条件成立 async poll(predicate, { interval = 800, timeout = 30000 } = {}) { const start = Date.now(); while (Date.now() - start < timeout) { if (stopRequested) return null; const v = await predicate(); if (v) return v; await Utils.sleep(interval); } return null; }, // 等待暂停结束 async waitIfPaused() { while (isPaused && !stopRequested) { await Utils.sleep(500); } }, formatTime(seconds) { seconds = Math.max(0, Math.round(seconds)); const m = Math.floor(seconds / 60); const s = seconds % 60; return `${m}:${String(s).padStart(2, '0')}`; }, // 模拟点击(鼠标事件 + click) safeClick(el) { if (!el) return false; try { if (el.scrollIntoView) { try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) {} } const opts = { bubbles: true, cancelable: true, view: window }; ['mouseover', 'mousedown', 'mouseup', 'click'].forEach(t => { try { el.dispatchEvent(new MouseEvent(t, opts)); } catch (_) {} }); if (typeof el.click === 'function') { try { el.click(); } catch (_) {} } return true; } catch (_) { return false; } }, // 取第一个可见元素 firstVisible(nodes) { for (const n of (nodes || [])) { if (!n) continue; try { const rect = n.getBoundingClientRect?.(); if (rect && rect.width > 0 && rect.height > 0) return n; } catch (_) {} } return null; }, }; // ---- 存储 Store ---- const Store = { getAIApiKey() { return localStorage.getItem(Config.storageKeys.aiApiKey) || ''; }, setAIApiKey(v) { localStorage.setItem(Config.storageKeys.aiApiKey, v || ''); }, getAIProvider() { const p = localStorage.getItem(Config.storageKeys.aiProvider) || 'deepseek'; // 旧值兼容:之前可能存的是 zhipu/qwen/gpt 等已删除的,统一回退到 deepseek return (typeof AI_PROVIDERS !== 'undefined' && AI_PROVIDERS[p] && AI_PROVIDERS[p].type === 'text') ? p : 'deepseek'; }, setAIProvider(v) { localStorage.setItem(Config.storageKeys.aiProvider, v || 'deepseek'); }, getAudioApiKey() { return localStorage.getItem(Config.storageKeys.audioApiKey) || ''; }, setAudioApiKey(v) { localStorage.setItem(Config.storageKeys.audioApiKey, v || ''); }, getAudioProvider() { const p = localStorage.getItem(Config.storageKeys.audioProvider) || 'qwen_audio'; return (typeof AI_PROVIDERS !== 'undefined' && AI_PROVIDERS[p] && AI_PROVIDERS[p].type === 'audio') ? p : 'qwen_audio'; }, setAudioProvider(v) { localStorage.setItem(Config.storageKeys.audioProvider, v || 'qwen_audio'); }, getVerifyValidUntil() { return parseInt(localStorage.getItem(Config.storageKeys.verifyValidUntil) || '0', 10); }, setVerifyValidUntil(ts) { localStorage.setItem(Config.storageKeys.verifyValidUntil, String(ts)); }, isVerifyValid() { return this.getVerifyValidUntil() > Math.floor(Date.now() / 1000); }, // 广告模式别名 isUnlocked() { return this.isVerifyValid(); }, getRemainingMs() { const until = this.getVerifyValidUntil(); if (!until) return 0; return Math.max(0, until * 1000 - Date.now()); }, getValidUntilStr() { const until = this.getVerifyValidUntil(); if (!until) return ''; try { return new Date(until * 1000).toLocaleString('zh-CN'); } catch (_) { return ''; } }, getAnswerMode() { return localStorage.getItem(Config.storageKeys.answerMode) || 'free'; }, // free/ai setAnswerMode(v) { localStorage.setItem(Config.storageKeys.answerMode, v || 'free'); }, getPlaybackRate() { const v = parseFloat(localStorage.getItem(Config.storageKeys.playbackRate)); return Number.isFinite(v) && v > 0 ? v : 1; }, setPlaybackRate(v) { localStorage.setItem(Config.storageKeys.playbackRate, String(v)); }, getFeatureConf() { return Utils.safeJSON(localStorage.getItem(Config.storageKeys.featureConf), { autoAnswer: true, autoPopup: true }); }, setFeatureConf(conf) { localStorage.setItem(Config.storageKeys.featureConf, JSON.stringify(conf || {})); }, getLastTimeValue() { const v = parseInt(localStorage.getItem(Config.storageKeys.lastTimeValue) || '60', 10); return Number.isFinite(v) && v > 0 ? v : 60; }, setLastTimeValue(v) { localStorage.setItem(Config.storageKeys.lastTimeValue, String(v)); }, getPanelOpen() { return localStorage.getItem(Config.storageKeys.panelOpen) === '1'; }, setPanelOpen(v) { localStorage.setItem(Config.storageKeys.panelOpen, v ? '1' : '0'); }, }; // 各 provider 最小请求间隔(毫秒)—— 避免触发免费层级限流 // 智谱 GLM-4-Voice 限流严格(QPM ~6),需要至少 10s 间隔 const AI_MIN_INTERVAL_MS = { deepseek: 500, qwen_audio: 1500, zhipu_audio: 12000, }; // ---- AI 提供商配置 ---- // type: 'text' = 普通文本题;'audio' = 多模态(含音频)听力题 AI_PROVIDERS = { deepseek: { name: 'DeepSeek-V3', model: 'deepseek-chat', url: 'https://api.deepseek.com/chat/completions', free: false, type: 'text', desc: 'DeepSeek高性价比文本模型,用于普通题目兜底', link: 'https://platform.deepseek.com/', linkText: '前往 DeepSeek 平台 →', keyHint: '左侧 API Keys → 创建', }, qwen_audio: { name: '通义千问 Qwen3-Omni-Flash', model: 'qwen3-omni-flash', url: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions', free: false, type: 'audio', desc: '阿里通义千问全模态模型 Flash 版,听力题首选', link: 'https://bailian.console.aliyun.com/', linkText: '前往阿里云百炼 →', keyHint: '右上角 API-KEY → 创建(新用户有免费额度)', }, zhipu_audio: { name: '智谱 GLM-4-Voice', model: 'glm-4-voice', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', free: true, type: 'audio', desc: '智谱端到端语音模型,邀请注册得 2000万 Tokens', link: 'https://www.bigmodel.cn/invite?icode=upWK8Rq09RynLhZ8CwWAqf2gad6AKpjZefIo3dVEQyA%3D', linkText: '前往智谱开放平台 (得2000万Tokens) →', keyHint: '控制台 → API Keys → 创建', }, }; // ---- Solver: 题目识别 + AI 答题 ---- const Solver = { // 标准题型(与雨课堂/题库 API 一致) TYPE_SINGLE: 0, TYPE_MULTI: 1, TYPE_FILL: 2, TYPE_JUDGE: 3, TYPE_OTHER: 4, // U 校园扩展题型(内部使用,发送到题库前会映射回标准题型) TYPE_LISTENING: 10, // 听力(音频+选择) TYPE_READING: 11, // 阅读理解(文章+多题) TYPE_TRANSLATION: 12, // 翻译题(输入框) TYPE_WRITING: 13, // 写作(长文 textarea) TYPE_CLOZE: 14, // 完形填空(多个空格) TYPE_DICTATION: 15, // 听写 TYPE_SPEAKING: 16, // 跟读/口语(不支持,自动跳过) // 把内部扩展题型映射回标准题型(用于题库查询/AI prompt) standardType(t) { const map = { 10: 0, // 听力 → 单选/多选(看实际选项) 11: 0, // 阅读 → 单选 12: 2, // 翻译 → 填空 13: 2, // 写作 → 填空 14: 2, // 完形 → 填空 15: 2, // 听写 → 填空 }; return map[t] !== undefined ? map[t] : t; }, typeName(t) { const names = { 0: '单选题', 1: '多选题', 2: '填空题', 3: '判断题', 4: '其他', 10: '听力题', 11: '阅读理解', 12: '翻译题', 13: '写作题', 14: '完形填空', 15: '听写题', 16: '口语题', }; return names[t] || '未知'; }, // 在文档中查找所有题目容器(兼容新版 + 普通版 U 校园) findQuestions(doc = document) { // 排除脚本自身面板(避免把"AI 兜底/跳过"radio 当成题目) const isInOwnPanel = (el) => { if (!el || !el.closest) return false; return !!el.closest('#unipus-panel, #unipus-settings-overlay, [data-unipus-helper]'); }; const selectors = [ // 新版(ipub/uai/ucontent)—— 精确选择器 '.QuestionBox', '.question-item', '.test-question', '.exercise-question', '[class*="QuestionItem"]', '[class*="question-wrap"]', '.iSlide-item .question', 'div[data-question-id]', // ucontent 常见题目容器 '.topic-item', '.subject-item', '.ques-item', '.practice-item', '.practice-question', // 普通版 u.unipus.cn '.exercise-item', '.exercise-container', '.activity-question', '[data-type="question"]', '[id^="question-"]', '[id^="q_"]', '.test-item', '.task-item', '.quiz-item', '.exam-item', ]; const candidates = new Set(); for (const sel of selectors) { try { doc.querySelectorAll(sel).forEach(el => { if (!isInOwnPanel(el)) candidates.add(el); }); } catch (_) {} } // 始终用「按 radio/checkbox 分组」补充候选(不只在选择器空时) try { const radios = doc.querySelectorAll('input[type="radio"], input[type="checkbox"]'); // 按 name 分组(同 name 一定是同一题) const byName = new Map(); radios.forEach(r => { if (isInOwnPanel(r)) return; // 排除自身面板的 radio const name = r.name || r.getAttribute('data-name') || ''; if (!name) return; if (!byName.has(name)) byName.set(name, []); byName.get(name).push(r); }); for (const group of byName.values()) { if (group.length < 2 || group.length > 10) continue; // 找整组的最近公共祖先 let ancestor = group[0].parentElement; for (let d = 0; d < 12 && ancestor; d++) { if (group.every(r => ancestor.contains(r))) break; ancestor = ancestor.parentElement; } if (ancestor && !isInOwnPanel(ancestor)) candidates.add(ancestor); } } catch (_) {} // 验证:每个候选必须有真实答题元素(≥2 个 radio/checkbox 或 ≥1 个填空类输入) const validated = []; for (const el of candidates) { if (isInOwnPanel(el)) continue; try { const radioCount = el.querySelectorAll('input[type="radio"], input[type="checkbox"]').length; const fillCount = el.querySelectorAll('input[type="text"], input[type="number"], textarea, [contenteditable="true"]').length; if (radioCount >= 2 || (radioCount === 0 && fillCount >= 1)) { validated.push(el); } } catch (_) {} } // 去重:嵌套时只保留最内层(深度更深的) const filtered = validated.filter(c => !validated.some(other => other !== c && c.contains(other)) ); // 排序:按 DOM 顺序(document order) filtered.sort((a, b) => { try { const pos = a.compareDocumentPosition(b); if (pos & Node.DOCUMENT_POSITION_FOLLOWING) return -1; if (pos & Node.DOCUMENT_POSITION_PRECEDING) return 1; } catch (_) {} return 0; }); return filtered; }, // 从题目容器提取题干 extractQuestion(container) { if (!container) return ''; const stems = [ // 新版 '.QuestionStem', '.question-stem', '.q-content', '.stem', '[class*="QuestionStem"]', '[class*="question-content"]', '[class*="question-title"]', '[class*="QuestionTitle"]', // 普通版 '.exercise-stem', '.exercise-title', '.q-stem', '.q-title', '.task-content', '.question-title', '.test-title', '.exam-title', 'h3', 'h4', // 题干常用标题 ]; for (const sel of stems) { const el = container.querySelector(sel); if (el) { const txt = Utils.safeText(el.innerText || el.textContent); if (txt && txt.length >= 3) return this._cleanQuestionText(txt); } } // 兜底:克隆容器,移除所有选项 / 输入框,取剩下的文字(避免把选项混进题干) try { const clone = container.cloneNode(true); clone.querySelectorAll( 'input, textarea, ' + 'label[class*="radio"], label[class*="checkbox"], ' + '[role="radio"], [role="checkbox"], ' + '[class*="option"], [class*="Option"], ' + '[class*="choice"], [class*="Choice"], ' + '.QuestionOption, .question-option, .answer-option, .test-option, ' + 'audio, video, button, .ant-radio-wrapper, .ant-checkbox-wrapper' ).forEach(n => n.remove()); const txt = Utils.safeText(clone.innerText || clone.textContent); if (txt && txt.length >= 3) { return this._cleanQuestionText(txt.length > 500 ? txt.slice(0, 500) : txt); } } catch (_) {} // 题干在容器内为空(典型听力题:只有 ABCD 选项),向上找父节点的指引文字 try { const sharedPrompt = this._findSharedPrompt(container); if (sharedPrompt) return this._cleanQuestionText(sharedPrompt); } catch (_) {} // 最终兜底:返回提示语(避免把选项当题干,导致 AI 混乱) return '(听力题,请根据音频和选项作答)'; }, // 向上找祖先节点,寻找共享的题目指引文字(如"Listen and choose...") _findSharedPrompt(container) { let cur = container.parentElement; for (let d = 0; d < 6 && cur; d++) { // 找父节点直接子元素中不含选项 / input 的文本节点 const directChildren = Array.from(cur.children).filter(c => !c.contains(container)); for (const ch of directChildren) { // 跳过包含 input / option 的子树 if (ch.querySelector('input[type="radio"], input[type="checkbox"], [class*="option"], [class*="Option"]')) continue; const txt = Utils.safeText(ch.innerText || ch.textContent); // 长度合理 (10-300) 且包含问号或祈使语气 if (txt && txt.length >= 10 && txt.length <= 300 && (/[??]/.test(txt) || /listen|choose|select|watch|read|please|根据|选择|听|看|读/i.test(txt))) { return txt; } } cur = cur.parentElement; } return ''; }, // 清洗题干:去掉常见前缀干扰 _cleanQuestionText(t) { if (!t) return ''; return t .replace(/^\s*\d+\s*[.、.::))]\s*/, '') // 去开头的 "1." "2、" 等题号 .replace(/^\s*\(\s*\d+\s*\)\s*/, '') // 去开头 "(1)" .replace(/^\s*【\s*\d+\s*】\s*/, '') // 去开头 "【1】" .replace(/^\s*Q\s*\d+\s*[.::]?\s*/i, '') // 去开头 "Q1." "Q1:" .trim(); }, // 提取选项列表(返回 [{text, node}, ...]) extractOptions(container) { if (!container) return []; const seen = new Set(); const options = []; const stripPrefix = (s) => (s || '').replace(/^[A-Ha-h]\s*[.、.::))]\s*/, '').trim(); // 优先策略:直接找 radio/checkbox,每个对应一个选项 // 这是最可靠的(题目最终要点这些 input) try { const inputs = container.querySelectorAll('input[type="radio"], input[type="checkbox"]'); for (const inp of inputs) { // 找最近的可点击包装(label > .option > li > div) const wrap = inp.closest('label, [class*="option"], [class*="Option"], [class*="choice"], [class*="Choice"], li') || inp.parentElement; if (!wrap || seen.has(wrap)) continue; seen.add(wrap); seen.add(inp); let text = Utils.safeText(wrap.innerText || wrap.textContent); text = stripPrefix(text); if (text && text.length <= 500) options.push({ text, node: wrap }); } } catch (_) {} // 备用:role=radio/checkbox(无原生 input 时) if (options.length < 2) { try { container.querySelectorAll('[role="radio"], [role="checkbox"]').forEach(n => { if (seen.has(n)) return; seen.add(n); let text = stripPrefix(Utils.safeText(n.innerText || n.textContent)); if (text && text.length <= 500) options.push({ text, node: n }); }); } catch (_) {} } // 备用 2:用 CSS class 选择器(仅当上面都没找到) if (options.length < 2) { const optSelectors = [ '.QuestionOption', '.question-option', '.option-item', '.opt-item', '.opt', '.choice', '.choice-item', '[class*="Option"]', '[class*="option"]', '.answer-option', '.answer-item', '.test-option', '.quiz-option', '.choice-list > li', '.option-list > li', 'ul.options > li', 'ol.options > li', 'label.choice', 'label.option', ]; for (const sel of optSelectors) { let nodes; try { nodes = container.querySelectorAll(sel); } catch (_) { continue; } if (!nodes || !nodes.length) continue; for (const n of nodes) { if (seen.has(n)) continue; seen.add(n); let text = stripPrefix(Utils.safeText(n.innerText || n.textContent)); if (text && text.length <= 500) options.push({ text, node: n }); } } } return options; }, // 探测题型(U 校园扩展:先识别特殊题型,再回退到通用题型) detectType(container, optionCount) { if (!container) return this.TYPE_OTHER; const txt = (container.innerText || container.textContent || '').slice(0, 300); const hint = txt.toLowerCase(); const html = (container.innerHTML || '').toLowerCase(); // ----- U 校园特殊题型识别 ----- // 口语/跟读:检测录音按钮(无法自动) if (/跟读|口语|record|speaking/i.test(txt) || container.querySelector('[class*="record"], [class*="Record"], [class*="speak"]')) { return this.TYPE_SPEAKING; } // 听写题:音频 + 多个填空框(不是选项) // 音频可能在容器内 / 父级 / 兄弟节点(多个子题共享一个 tab 级音频) const hasAudioInside = !!container.querySelector('audio, [class*="audio"], [class*="Audio"]'); // 整页查找(兜底):U 校园通常每个 tab 共享一个顶层音频,可能在容器外更高位置 const hasAudioOnPage = !!document.querySelector('audio'); const hasAudio = hasAudioInside || hasAudioOnPage; const inputCount = container.querySelectorAll('input[type="text"], textarea, [contenteditable="true"]').length; if (/听写|dictation/i.test(txt) || (hasAudio && inputCount >= 2 && !optionCount)) { return this.TYPE_DICTATION; } // 听力选择:音频 + 选项 if (hasAudio && optionCount > 0) { return this.TYPE_LISTENING; } // 写作题:长文 textarea(一个 textarea + 题干含"写作/作文/composition/essay") if (/写作|作文|composition|essay|write\s+(an?\s+)?(essay|article|paragraph)/i.test(txt)) { if (container.querySelector('textarea')) return this.TYPE_WRITING; } // 单一 textarea 且字数限制提示 → 写作 if (container.querySelectorAll('textarea').length === 1 && /字数|words|word\s*count|至少.*\d+\s*字/i.test(txt)) { return this.TYPE_WRITING; } // 翻译题:题干含"翻译/translate/中译英/英译中" + 有输入框 if (/翻译|translate|translation|译成|中译英|英译中|翻成/i.test(txt) && inputCount > 0) { return this.TYPE_TRANSLATION; } // 完形填空:多个空(≥3)且常带"完形/cloze" if (/完形填空|cloze/i.test(txt) || (inputCount >= 3 && !optionCount)) { return this.TYPE_CLOZE; } // 阅读理解:题干很长(>500 字符)且有选项 → 多半是阅读 if (txt.length > 500 && optionCount >= 2) { return this.TYPE_READING; } // ----- 通用题型识别 ----- if (/判断|对错|正确.*错误|true.*false|✓.*✗/i.test(txt)) return this.TYPE_JUDGE; if (/多选|多项选择|选出所有|选择所有|all\s+that\s+apply/i.test(txt)) return this.TYPE_MULTI; if (/单选|单项选择|选择正确/i.test(txt)) return this.TYPE_SINGLE; if (/填空|fill\s+in|gap[\s-]?fill/i.test(txt)) return this.TYPE_FILL; // 通过 DOM 推断 if (container.querySelector('input[type="checkbox"], label.ant-checkbox-wrapper, [role="checkbox"]')) return this.TYPE_MULTI; if (container.querySelector('input[type="radio"], label.ant-radio-wrapper, [role="radio"]')) return this.TYPE_SINGLE; if (inputCount > 0) return this.TYPE_FILL; if (optionCount === 2) return this.TYPE_JUDGE; return this.TYPE_OTHER; }, // 探测题目内的音频元素(用于听力/听写题先播完音频) findAudioInQuestion(container) { if (!container) return null; const audio = container.querySelector('audio'); if (audio) return audio; // 自定义音频播放器 const customPlayer = container.querySelector('[class*="audio-player"], [class*="AudioPlayer"], [class*="audioBox"], .play-btn, [class*="PlayBtn"]'); if (customPlayer) { // 找音频元素 const a = customPlayer.querySelector('audio'); if (a) return a; // 如果只有播放按钮,返回按钮(调用方可以点击) return customPlayer; } return null; }, // 播放音频并等待完成(用于听力题) async playAudioAndWait(audioOrBtn, maxWaitMs = 5 * 60 * 1000) { if (!audioOrBtn) return; if (audioOrBtn.tagName === 'AUDIO') { try { audioOrBtn.muted = false; // 听力题需要听完,浏览器允许 muted 自动播放 audioOrBtn.volume = 0.001; // 实际接近静音 audioOrBtn.playbackRate = Store.getPlaybackRate(); const p = audioOrBtn.play(); if (p && p.catch) p.catch(() => {}); } catch (_) {} // 等待播放完成 const start = Date.now(); await Utils.poll(() => { if (Date.now() - start > maxWaitMs) return true; return Player.isEnded(audioOrBtn); }, { interval: 1000, timeout: maxWaitMs }); } else { // 点击播放按钮 Utils.safeClick(audioOrBtn); // 等几秒让音频播放 await Utils.sleep(3000); // 再尝试找 audio 元素 const a = audioOrBtn.querySelector?.('audio') || document.querySelector('audio'); if (a) await this.playAudioAndWait(a, maxWaitMs); } }, // 综合识别:返回 {question, options, type, container, optionNodes} recognize(container) { const question = this.extractQuestion(container); const optionPairs = this.extractOptions(container); const options = optionPairs.map(o => o.text); const optionNodes = optionPairs.map(o => o.node); const type = this.detectType(container, options.length); return { question, options, type, container, optionNodes }; }, // 根据题型构造专门 prompt(U 校园英语类题型) buildAIPrompt(question, options, type, blankCount = 1) { const typeName = this.typeName(type); const optTxt = (options && options.length) ? '\n选项:\n' + options.map((o, i) => `${String.fromCharCode(65 + i)}. ${o}`).join('\n') : ''; switch (type) { case this.TYPE_TRANSLATION: return `你是英语翻译助手。请直接翻译,不要解释。 题目(翻译):${question} 要求: - 中→英 或 英→中(根据题目方向) - 译文要自然、地道,符合语境 - 多个待翻译的句子或段落用 "||" 分隔 - 直接给出译文,不要"译:" 前缀`; case this.TYPE_WRITING: return `你是英语写作助手。请直接写出一篇符合要求的英语作文。 题目(写作):${question} 要求: - 写作题:直接给出完整作文(150-300 词,看题目要求) - 结构清晰:开头-主体-结尾 - 语法正确、用词地道 - 不要解释,不要标题,不要"开始作文"等前缀 - 直接输出正文`; case this.TYPE_CLOZE: return `你是完形填空助手。请根据上下文给出每个空的最佳答案。 题目(完形填空):${question} 要求: - 直接给出每个空的答案,按顺序用 "||" 分隔 - 答案应该是单个英文单词或短语(除非题目要求短句) - 不要解释,不要带空号 - 例如:apple || run || beautiful`; case this.TYPE_DICTATION: return `你是英语听写助手。请根据题目上下文推断听写答案。 题目(听写):${question} 要求: - 如果题目提供了听力原文,请按顺序给出每空的内容 - 多个空用 "||" 分隔 - 直接给出文本,不要解释`; case this.TYPE_LISTENING: { const hasRealQuestion = question && !/听力题.*音频.*选项|根据.*音频/.test(question); const head = hasRealQuestion ? `请仔细听音频,从下面 ${options.length} 个选项里选出能回答下方问题的答案。\n\n问题:${question}` : `请仔细听音频,从下面 ${options.length} 个选项里选出**最符合音频内容**的答案(题干未提供文字,请根据选项推断要问什么)。`; return `你是英语听力题助手。${head}${optTxt} 要求: - 必须根据音频内容判断,不要凭选项字面意思猜测 - 只输出一个选项的完整文本(与给定选项一字不差地复制其中一个) - 不要输出字母 A/B/C/D,不要 "||" 分隔,不要解释,不要多余文字 - 不要输出其他题目的答案或音频中无关内容`; } case this.TYPE_READING: return `你是英语阅读理解助手。请仔细阅读文章后回答问题。 题目(阅读理解):${question}${optTxt} 要求: - 单选:只给一个选项内容(不要字母) - 多选:用 "||" 分隔 - 直接答案,不要解释`; case this.TYPE_MULTI: return `你是答题助手。请直接给出答案,不要解释。 题目类型:多选题 题目:${question}${optTxt} 要求: - 回答所有正确选项的完整内容,用 "||" 分隔 - 不要回答字母 A/B/C/D - 例如:apple || banana`; case this.TYPE_JUDGE: return `你是答题助手。请直接给出答案,不要解释。 题目类型:判断题 题目:${question} 要求:只回答 "正确" 或 "错误"(也可用 True/False)`; case this.TYPE_FILL: return `你是答题助手。请直接给出填空答案。 题目(填空题):${question} 要求: - 给出每个空的内容 - 多个空用 "||" 分隔 - 不要解释、不要前缀`; default: return `你是答题助手。请直接给出答案,不要解释。 题目类型:${typeName} 题目:${question}${optTxt} 回答要求: - 单选题:只回答一个选项的完整内容(不要回答字母A/B/C/D) - 多选题:回答所有正确选项的完整内容,用"||"分隔 - 判断题:只回答"正确"或"错误" - 填空题:直接给出填空内容(多个空用 || 分隔)`; } }, // 从题目容器提取音频 URL(用于多模态 AI 听力题) extractAudioUrl(container) { if (!container) return null; try { // 1)