// ==UserScript== // @name U校园刷课助手 // @namespace http://tampermonkey.net/ // @version 1.2 // @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 合规声明 * * 【v1.2 更新】 * - 修复多选/多答案听力·阅读题答案被截断为单个的问题(isMulti 透传到答案解析) * - 新增匹配/连线/拖词题:AI 配对 + 模拟拖拽(HTML5 原生 + 指针拖拽双兼容) * - 新增排序题:AI 给出正确顺序 + 逐项拖拽重排 * * ========================================== */ (() => { '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.2', 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: 'nearest' }); } catch (_) {} } const r = el.getBoundingClientRect(); const cx = Math.round(r.left + r.width / 2); const cy = Math.round(r.top + r.height / 2); const base = { bubbles: true, cancelable: true, view: window, clientX: cx, clientY: cy }; // 现代 React SPA 通常需要 PointerEvent + 带坐标的 MouseEvent 才能触发 onClick ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click'].forEach(type => { try { const ev = type.startsWith('pointer') ? new PointerEvent(type, { ...base, pointerId: 1, isPrimary: true, pointerType: 'mouse' }) : new MouseEvent(type, base); el.dispatchEvent(ev); } 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; }, // 元素中心坐标 centerOf(el) { const r = el.getBoundingClientRect(); return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) }; }, // HTML5 原生拖拽(draggable="true" 场景,如 react-dnd HTML5 后端) // 共享一个 DataTransfer,按 dragstart→dragenter→dragover→drop→dragend 顺序派发 html5Drag(source, target) { if (!source || !target) return false; try { const dt = new DataTransfer(); const sc = this.centerOf(source); const tc = this.centerOf(target); const fire = (el, type, pt) => { let ev; const init = { bubbles: true, cancelable: true, composed: true, view: window, clientX: pt.x, clientY: pt.y, dataTransfer: dt }; try { ev = new DragEvent(type, init); } catch (_) { ev = new MouseEvent(type, init); try { Object.defineProperty(ev, 'dataTransfer', { value: dt }); } catch (_) {} } if (!ev.dataTransfer) { try { Object.defineProperty(ev, 'dataTransfer', { value: dt }); } catch (_) {} } el.dispatchEvent(ev); }; try { source.scrollIntoView({ block: 'center' }); } catch (_) {} fire(source, 'dragstart', sc); fire(target, 'dragenter', tc); fire(target, 'dragover', tc); fire(target, 'drop', tc); fire(source, 'dragend', tc); return true; } catch (_) { return false; } }, // 指针拖拽(自定义 JS 拖拽,靠 mousedown/mousemove/mouseup 实现的场景) async pointerDrag(source, target) { if (!source || !target) return false; try { const sc = this.centerOf(source); const tc = this.centerOf(target); const fire = (el, type, pt, EventCls = MouseEvent) => { const init = { bubbles: true, cancelable: true, view: window, clientX: pt.x, clientY: pt.y, button: 0 }; if (EventCls === PointerEvent) Object.assign(init, { pointerId: 1, isPrimary: true, pointerType: 'mouse' }); try { el.dispatchEvent(new EventCls(type, init)); } catch (_) {} }; try { source.scrollIntoView({ block: 'center' }); } catch (_) {} fire(source, 'pointerdown', sc, PointerEvent); fire(source, 'mousedown', sc); await this.sleep(60); // 分几步移动,触发依赖位移阈值的拖拽库 const steps = 5; for (let i = 1; i <= steps; i++) { const pt = { x: Math.round(sc.x + (tc.x - sc.x) * i / steps), y: Math.round(sc.y + (tc.y - sc.y) * i / steps) }; fire(target, 'pointermove', pt, PointerEvent); fire(target, 'mousemove', pt); await this.sleep(30); } fire(target, 'pointerup', tc, PointerEvent); fire(target, 'mouseup', tc); return true; } catch (_) { return false; } }, // 综合拖拽:source 若是原生 draggable 用 HTML5,否则用指针拖拽 async dragTo(source, target) { if (!source || !target) return false; const isNative = source.getAttribute && source.getAttribute('draggable') === 'true'; if (isNative) { const ok = this.html5Drag(source, target); await this.sleep(150); return ok; } return this.pointerDrag(source, target); }, }; // ---- 存储 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, // 跟读/口语(不支持,自动跳过) TYPE_MATCH: 17, // 匹配/连线/拖词(左右两组配对) TYPE_ORDER: 18, // 排序(句子/段落/词序重排) // 把内部扩展题型映射回标准题型(用于题库查询/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: '口语题', 17: '匹配/连线题', 18: '排序题', }; 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 (_) {} // 兜底:找含可见 input[type=text] 的 li/p 元素(exploration 主题听写题) // 必须在验证之前加入候选,否则验证阶段不会包含这些元素 try { const listItems = doc.querySelectorAll('li, p'); for (const item of listItems) { if (isInOwnPanel(item)) continue; const fills = [...item.querySelectorAll('input[type="text"], input[type="number"]')] .filter(e => e.offsetParent !== null); if (fills.length >= 1 && !candidates.has(item)) { // 只接受祖先里没有其他候选(避免重复添加子项) const alreadyCovered = [...candidates].some(c => c.contains(item) || item.contains(c)); if (!alreadyCovered) candidates.add(item); } } } 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"]')].filter(e => e.offsetParent !== null).length; const fillCount = [...el.querySelectorAll('input[type="text"], input[type="number"], textarea, [contenteditable="true"]')].filter(e => e.offsetParent !== null).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); } }, // 统计题目内"可见且可写"的填空输入框数量(用于按空格数精确生成答案) countBlanks(container) { if (!container) return 0; try { return [...container.querySelectorAll( 'input[type="text"], input[type="number"], input:not([type]), textarea, [contenteditable="true"]' )].filter(e => e.offsetParent !== null && !e.disabled && !e.readOnly).length; } catch (_) { return 0; } }, // 收集页面上可拖拽的元素(draggable=true 或常见拖拽库类名),用于匹配/排序题 findDraggables(root = document) { const inOwnPanel = (el) => !!el.closest?.('[data-unipus-helper], #unipus-panel, #unipus-settings-overlay'); const sel = '[draggable="true"], [class*="draggable"], [class*="Draggable"], ' + '[class*="drag-item"], [class*="dragItem"], [class*="DragItem"], ' + '[class*="sortable-item"], [class*="SortableItem"], [data-rbd-draggable-id]'; const seen = new Set(); const out = []; try { root.querySelectorAll(sel).forEach(el => { if (inOwnPanel(el) || el.offsetParent === null) return; const txt = Utils.safeText(el.innerText || el.textContent); if (!txt || txt.length > 200) return; // 若已收录了当前元素的后代 → 当前是祖先容器,跳过(保留更内层项) if ([...seen].some(s => el.contains(s) && el !== s)) return; // 若已收录了当前元素的祖先 → 用当前(更内层)替换祖先 for (const s of [...seen]) { if (s.contains(el) && s !== el) { seen.delete(s); const i = out.indexOf(s); if (i >= 0) out.splice(i, 1); } } seen.add(el); out.push(el); }); } catch (_) {} return out; }, // 收集拖拽的放置区(drop zone / 空位 / 配对槽) findDropZones(root = document) { const inOwnPanel = (el) => !!el.closest?.('[data-unipus-helper], #unipus-panel, #unipus-settings-overlay'); const sel = '[class*="drop-zone"], [class*="dropZone"], [class*="DropZone"], ' + '[class*="droppable"], [class*="Droppable"], [class*="drop-target"], ' + '[class*="blank-slot"], [class*="answer-slot"], [class*="match-target"], ' + '[data-rbd-droppable-id]'; const out = []; try { root.querySelectorAll(sel).forEach(el => { if (inOwnPanel(el) || el.offsetParent === null) return; // 嵌套放置区只保留最内层,避免左侧索引错位 if (out.some(s => el.contains(s) && el !== s)) return; for (let i = out.length - 1; i >= 0; i--) { if (out[i].contains(el) && out[i] !== el) out.splice(i, 1); } out.push(el); }); } catch (_) {} return out; }, // 探测匹配/排序题:返回 { isMatch, isOrder, leftItems, rightItems, orderItems, ... } detectDragQuestion(root = document) { const drags = this.findDraggables(root); const zones = this.findDropZones(root); const txt = (root.body?.innerText || root.innerText || '').slice(0, 1500); const result = { isMatch: false, isOrder: false, dragCount: drags.length, zoneCount: zones.length }; if (drags.length < 2) return result; // 文本线索 const matchHint = /match|matching|连线|配对|匹配|pair|对应|搭配|与.*相符|连接/i.test(txt); const orderHint = /order|sequence|reorder|排序|顺序|排列|put.*in.*order|rearrange|number.*following/i.test(txt); // 有明确放置区且左右数量相近 → 匹配题 if (zones.length >= 2 || matchHint) { result.isMatch = true; } else if (orderHint || (drags.length >= 2 && zones.length === 0)) { // 无放置区、纯一组可拖动项 → 排序题 result.isOrder = true; } return result; }, // 阅读理解题组:向上找共享文章正文(多道小题共用一篇文章) // U 校园阅读题常把文章放在题组容器顶部,小题在下方各自一个 container extractSharedPassage(container) { if (!container) return ''; try { let cur = container.parentElement; for (let d = 0; d < 8 && cur; d++) { // 在祖先里找"不含 input/选项"且文字很长的兄弟块(多半是文章正文) const blocks = [...cur.children].filter(c => !c.contains(container)); for (const b of blocks) { if (b.querySelector('input[type="radio"], input[type="checkbox"], textarea, [class*="option"], [class*="Option"]')) continue; const txt = Utils.safeText(b.innerText || b.textContent); // 文章正文一般 > 200 字符 if (txt && txt.length >= 200) { return txt.length > 3000 ? txt.slice(0, 3000) : txt; } } cur = cur.parentElement; } // 兜底:常见文章容器选择器 const passSel = '[class*="passage"], [class*="Passage"], [class*="article"], [class*="Article"], [class*="reading-text"], [class*="ReadingText"], [class*="material"]'; let scope = container.parentElement; for (let d = 0; d < 8 && scope; d++) { const p = scope.querySelector?.(passSel); if (p && !p.contains(container)) { const txt = Utils.safeText(p.innerText || p.textContent); if (txt && txt.length >= 100) return txt.length > 3000 ? txt.slice(0, 3000) : txt; } scope = scope.parentElement; } } catch (_) {} return ''; }, // 综合识别:返回 {question, options, type, container, optionNodes, blankCount, passage, audioUrl} 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); const blankCount = this.countBlanks(container); // 阅读题:附带共享文章正文;其它题型不取(省 token) const passage = (type === this.TYPE_READING) ? this.extractSharedPassage(container) : ''; const audioUrl = this.extractAudioUrl(container); return { question, options, type, container, optionNodes, blankCount, passage, audioUrl }; }, // 根据题型构造专门 prompt(U 校园英语类题型) // opts.passage: 阅读题共享文章正文;opts.isMulti: 选项里是否含 checkbox(多选) buildAIPrompt(question, options, type, blankCount = 1, opts = {}) { const typeName = this.typeName(type); const passage = opts.passage || ''; const isMulti = !!opts.isMulti; const optTxt = (options && options.length) ? '\n选项:\n' + options.map((o, i) => `${String.fromCharCode(65 + i)}. ${o}`).join('\n') : ''; // 空格数提示(≥2 时明确告诉 AI 需要几个答案) const blankHint = (blankCount && blankCount >= 2) ? `\n\n注意:本题共有 ${blankCount} 个空,请严格给出 ${blankCount} 个答案,用 "||" 顺序分隔,数量必须一致。` : ''; 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${blankHint}`; case this.TYPE_DICTATION: return `你是英语听写助手。请根据题目上下文推断听写答案。 题目(听写):${question} 要求: - 如果题目提供了听力原文,请按顺序给出每空的内容 - 多个空用 "||" 分隔 - 直接给出文本,不要解释${blankHint}`; 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: { const passageBlock = passage ? `文章:\n${passage}\n\n` : ''; const multiHint = isMulti ? '- 本题为多选,请回答所有正确选项的完整内容,用 "||" 分隔' : '- 本题为单选,只回答一个选项的完整内容(不要字母)'; return `你是英语阅读理解助手。请仔细阅读文章后回答问题。 ${passageBlock}题目(阅读理解):${question}${optTxt} 要求: - 答案必须基于上文文章内容,不要凭常识猜测 ${multiHint} - 只复制选项的完整文本,不要字母 A/B/C/D,不要解释`; } 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} 要求: - 给出每个空的内容 - 多个空用 "||" 分隔 - 不要解释、不要前缀`; case this.TYPE_MATCH: { // opts.leftItems / opts.rightItems:左右两组待配对项 const left = opts.leftItems || []; const right = opts.rightItems || []; const leftBlock = left.map((t, i) => `${i + 1}. ${t}`).join('\n'); const rightBlock = right.map((t, i) => `${String.fromCharCode(65 + i)}. ${t}`).join('\n'); return `你是英语匹配题助手。请把左侧每一项与右侧最匹配的一项配对(词义匹配 / 问答配对 / 搭配等)。 题目说明:${question} 左侧(按序号): ${leftBlock} 右侧(按字母): ${rightBlock} 要求: - 为左侧每一项找出右侧唯一对应项 - 严格按左侧顺序输出,每行一个配对,格式为「序号-字母」,如:1-C - 多个配对之间用 "||" 分隔,例如:1-C || 2-A || 3-B - 不要解释、不要多余文字`; } case this.TYPE_ORDER: { // opts.orderItems:待排序的乱序项 const items = opts.orderItems || []; const itemBlock = items.map((t, i) => `${i + 1}. ${t}`).join('\n'); return `你是英语排序题助手。下面是被打乱顺序的句子/段落,请给出正确的先后顺序。 题目说明:${question} 乱序项(当前编号): ${itemBlock} 要求: - 输出这些项排好后的正确顺序,用当前编号表示 - 只输出编号序列,用 "||" 分隔,例如:3 || 1 || 4 || 2 - 序列长度必须等于 ${items.length},每个编号只出现一次 - 不要解释、不要多余文字`; } default: return `你是答题助手。请直接给出答案,不要解释。 题目类型:${typeName} 题目:${question}${optTxt} 回答要求: - 单选题:只回答一个选项的完整内容(不要回答字母A/B/C/D) - 多选题:回答所有正确选项的完整内容,用"||"分隔 - 判断题:只回答"正确"或"错误" - 填空题:直接给出填空内容(多个空用 || 分隔)`; } }, // 从题目容器提取音频 URL(用于多模态 AI 听力题) // 增强:支持 blob:(交给下载器转 base64)、懒加载 data-* 属性、网络嗅探缓存、整页兜底 extractAudioUrl(container) { const isUsable = (u) => !!u && (/^https?:|^blob:|^data:/i.test(u)); // data: / blob: 也接受 —— _callQwenAudio/_fetchAudioAsBase64 会自行下载转码 const pickFrom = (root) => { if (!root || !root.querySelectorAll) return null; try { // 1)