// ==UserScript== // @name U校园刷课助手 v1.3.7 // @namespace http://tampermonkey.net/ // @version 1.3.7 // @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.3.7 * ========================================== * * 【功能说明】 * - 自动遍历 Unit/Section/Micro 目录、标签、子任务与任务内分页 * - AI 自动作答:DeepSeek(文本)+ 通义千问Qwen3-Omni-Flash / 智谱GLM-4-Voice(听力多模态) * - 学习时长智能分配(总时长均分到每项) * - 视频/音频自动播放 * - 自动关闭"我知道了"等弹窗 * - 实时进度日志 + 倒计时显示 * - 悬浮球UI入口 * * 【参考项目】 * - UnipusAIAutoPlayer (https://github.com/uxudjs/UnipusAIAutoPlayer) * - 借鉴菜单识别 + iframe 通信架构 * * 【免责声明】 * 本脚本仅供学习交流使用,请勿用于违反学校规定或作弊行为 * * 【版权信息】 * 作者:叶屿 | 版本:v1.3.7 | 更新:2026-10-22 * * 【v1.3.7 更新】 * - 排除评论区分页条数、跳页输入框与导航控件,修复误识别为阅读/填空/下拉 * - 社区识别兼容直接挂在题目容器的编辑器及链接式发布,保留人工处理 * - 直接作答入口同样保护社区内容,真题旁分页不影响题干、选项和空数 * * 【v1.3.6 更新】 * - 适配真实词汇下拉,保留逐空语境与已选值,修复浮层重复开关并核对全部选值 * - 修复听力/听写音频归属和播放状态,缺材料或音频 Key 时明确停留 * - 修复填空数量、数字答案、受控输入及下拉搜索框误识别 * - 加强拖动匹配/排序的完整性、重绘定位、事件兼容与最终落位校验 * - 同页普通题、下拉与拖动依次处理,混合录音任务优先列为人工待办 * * 【v1.3.5 更新】 * - 按登录课程核对两层标签与子任务,逐页处理同任务题目后才离开章节 * - 确认实际题面稳定再执行,排除媒体进度、隐藏旧页及输入答案对换题判断的干扰 * - 补齐听力纯题号、完整词库/表格多空题干与主音频归属 * - 朗读、录音、自评、讨论及作品上传列出完整人工待办,不误报全部完成 * - 提交失败保留当前位置,保存结果未确认时记录待核对项 * * 【v1.3.4 更新】 * - 按官方游客课程 DOM 补齐目录、标签、任务、纯 div 选择题与“提 交”识别 * - 修复下拉/讨论 AI 返回值解析,作答与拖拽核实实际页面结果 * - 防止换题、iframe 重载与停止重启时沿用旧任务或旧答案 * - 暂停状态同步课程子页面,媒体停止、换源与播放超时正确收尾 * - 修复跨域凭证同步及清除设置后旧配置回流 * * 【v1.3.3 更新】 * - 修复选择其他单元后误跳第一单元,目录刷新保留完整起点 * - 统一按目录推进,定位失败或作答未完成时保留当前章节 * - 修复独立课程页配置被空配置覆盖及重复启动 * - 提交与下一步按内容/章节范围识别,避免重复导航 * * 【v1.1 更新】 * - 听力题模型推荐标签(千问满分率高,智谱限流不稳) * - 远程公告系统(对接 qsy.iano.cn 后台) * - 增加 @antifeature payment 合规声明 * * 【v1.2 更新】 * - 修复多选/多答案听力·阅读题答案被截断为单个的问题(isMulti 透传到答案解析) * - 新增匹配/连线/拖词题:AI 配对 + 模拟拖拽(HTML5 原生 + 指针拖拽双兼容) * - 新增排序题:AI 给出正确顺序 + 逐项拖拽重排 * * 【v1.3 更新】 * - 增强填空/填词/小作文/听写题输入框识别与受控组件写入 * - 增强听力题音频识别、音频抓取兜底与无音频 Key 时文本兜底 * - 增强拖动题、选词填空、下拉式选词填空和公告图片点击 * - 新增启动前复习用途确认弹窗,自动启动也必须先确认 * * 【v1.3.1 更新】 * - 修复验证码/解锁码会话:带 scope/device_id 验证,保存 session_token 与本地 proof * - 统一用途确认取消文案 * * 【v1.3.2 更新】 * - 修复重复点击/重复推进,以及章节级与内容级导航互相串扰 * - 增加 PPT/PDF/文档页识别、翻页与无视频内容的继续推进 * - 限制失效媒体源重试,处理媒体复用、弹窗答题和停止竞态 * - 修复 iframe 配置同步、SPA 重绘后题目节点失效和动态目录节点失效 * * ========================================== */ (() => { '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.3.7', 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', verifyProof: 'unipus_verify_proof', verifySessionToken: 'unipus_verify_session_token', 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', studyPurposeConfirmed: 'unipus_study_purpose_confirmed', } }; // ---- 工具函数 ---- 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; } }, hashString(value) { const str = String(value || ''); let h1 = 0x811c9dc5; let h2 = 0x01000193; for (let i = 0; i < str.length; i++) { const c = str.charCodeAt(i); h1 ^= c; h1 = Math.imul(h1, 0x01000193); h2 ^= c + i; h2 = Math.imul(h2, 0x85ebca6b); } return ((h1 >>> 0).toString(36) + (h2 >>> 0).toString(36)); }, 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'].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 (_) {} } else { try { el.dispatchEvent(new MouseEvent('click', base)); } 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 || stopRequested || source.isConnected === false || target.isConnected === false) return false; try { const dt = new DataTransfer(); try { source.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {} 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 { dt.setData('text/plain', Utils.safeText(source.innerText || source.textContent)); } catch (_) {} fire(source, 'dragstart', sc); if (stopRequested || source.isConnected === false || target.isConnected === false) return false; fire(target, 'dragenter', tc); if (stopRequested || source.isConnected === false || target.isConnected === false) return false; fire(target, 'dragover', tc); if (stopRequested || source.isConnected === false || target.isConnected === false) return false; fire(target, 'drop', tc); fire(source, 'dragend', tc); return true; } catch (_) { return false; } }, // 指针拖拽(自定义 JS 拖拽,靠 mousedown/mousemove/mouseup 实现的场景) async pointerDrag(source, target) { await this.waitIfPaused(); if (!source || !target || stopRequested || source.isConnected === false || target.isConnected === false) return false; try { try { source.scrollIntoView({ block: 'center', inline: 'nearest' }); } catch (_) {} const sc = this.centerOf(source); const tc = this.centerOf(target); const PointerCls = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent; const fire = (el, type, pt, EventCls = MouseEvent) => { const init = { bubbles: true, cancelable: true, view: window, clientX: pt.x, clientY: pt.y, button: 0, buttons: /(?:down|move)$/.test(type) ? 1 : 0 }; if (/^pointer/.test(type)) Object.assign(init, { pointerId: 1, isPrimary: true, pointerType: 'mouse' }); try { el.dispatchEvent(new EventCls(type, init)); } catch (_) {} }; fire(source, 'pointerdown', sc, PointerCls); fire(source, 'mousedown', sc); await this.sleep(60); await this.waitIfPaused(); if (stopRequested || source.isConnected === false || target.isConnected === false) return false; // 分几步移动,触发依赖位移阈值的拖拽库 const steps = 8; for (let i = 1; i <= steps; i++) { await this.waitIfPaused(); if (stopRequested || source.isConnected === false || target.isConnected === false) return false; const pt = { x: Math.round(sc.x + (tc.x - sc.x) * i / steps), y: Math.round(sc.y + (tc.y - sc.y) * i / steps) }; const hit = document.elementFromPoint?.(pt.x, pt.y); const surface = hit?.isConnected ? hit : i === steps ? target : source; // 事件本身会冒泡到 document,不再额外派发三份移动事件。 fire(surface, 'pointermove', pt, PointerCls); fire(surface, 'mousemove', pt); await this.sleep(30); } await this.waitIfPaused(); if (stopRequested || source.isConnected === false || target.isConnected === false) return false; fire(target, 'pointerup', tc, PointerCls); fire(target, 'mouseup', tc); return true; } catch (_) { return false; } }, // 综合拖拽:source 若是原生 draggable 用 HTML5,否则用指针拖拽 async dragTo(source, target, verify = null) { await this.waitIfPaused(); if (!source || !target || stopRequested || source.isConnected === false || target.isConnected === false) return false; const parent = source.parentElement; const next = source.nextElementSibling; const beforeTarget = target.innerHTML; const isNative = source.draggable === true || source.getAttribute?.('draggable') === 'true'; const sent = isNative ? this.html5Drag(source, target) : await this.pointerDrag(source, target); if (!sent) return false; for (let attempt = 0; attempt < 6 && !stopRequested; attempt++) { await this.sleep(100); await this.waitIfPaused(); if (stopRequested) return false; try { const changed = verify ? verify() : (source.isConnected !== false && target.isConnected !== false && ( (target.contains(source) && source.parentElement !== parent) || source.parentElement !== parent || source.nextElementSibling !== next || target.innerHTML !== beforeTarget )); if (changed) return true; } catch (_) {} if (source.isConnected === false || target.isConnected === false) return false; } return false; }, }; // ---- 存储 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'); }, getDeviceId() { return Utils.genDeviceId(); }, getVerifyValidUntil() { return parseInt(localStorage.getItem(Config.storageKeys.verifyValidUntil) || '0', 10); }, makeAccessProof(scope, code, validUntil) { const issuedAt = Date.now(); const nonce = Math.random().toString(36).slice(2) + issuedAt.toString(36); const payload = { scope, family: 'unipus', deviceId: this.getDeviceId(), validUntil: Number(validUntil) || 0, issuedAt, nonce, ua: Utils.hashString(navigator.userAgent || ''), codeHash: Utils.hashString(String(code || '').trim()), }; const source = [ payload.scope, payload.family, payload.deviceId, payload.validUntil, payload.issuedAt, payload.nonce, payload.ua, Config.version, payload.codeHash, ].join('|'); payload.sig = Utils.hashString(source); try { return btoa(JSON.stringify(payload)); } catch (_) { return ''; } }, readAccessProof(key) { const raw = localStorage.getItem(key) || ''; if (!raw) return null; try { return JSON.parse(atob(raw)); } catch (_) { return null; } }, isAccessProofValid(scope, key, validUntil) { const until = Number(validUntil) || 0; if (until <= Date.now() / 1000) return false; const payload = this.readAccessProof(key); if (!payload || payload.scope !== scope) return false; if (Number(payload.validUntil) !== until) return false; if (payload.deviceId !== this.getDeviceId()) return false; if (payload.family !== 'unipus') return false; if (payload.ua !== Utils.hashString(navigator.userAgent || '')) return false; if (!payload.sig || !payload.nonce || !payload.issuedAt || !payload.codeHash) return false; if (Date.now() - Number(payload.issuedAt) > 30 * 60 * 60 * 1000) return false; // 修复升级仍认可上一版签发、尚在有效期内且属于本机的凭证。 return [Config.version, '1.3.6', '1.3.5', '1.3.4', '1.3.3', '1.3.2'].some(version => payload.sig === Utils.hashString([ payload.scope, payload.family, payload.deviceId, payload.validUntil, payload.issuedAt, payload.nonce, payload.ua, version, payload.codeHash, ].join('|'))); }, clearVerifySession() { // 保留空值墓碑,确保父页面可通过受信 RPC 将“注销”同步到跨 origin 子页。 localStorage.setItem(Config.storageKeys.verifyValidUntil, ''); localStorage.setItem(Config.storageKeys.verifyProof, ''); localStorage.setItem(Config.storageKeys.verifySessionToken, ''); }, setVerifyValidUntil(ts, code = '', sessionToken = '', scope = 'ai-unlock') { localStorage.setItem(Config.storageKeys.verifyValidUntil, String(ts)); if (code) localStorage.setItem(Config.storageKeys.verifyProof, this.makeAccessProof(scope, code, ts)); if (sessionToken) localStorage.setItem(Config.storageKeys.verifySessionToken, sessionToken); }, getVerifySessionToken() { return localStorage.getItem(Config.storageKeys.verifySessionToken) || ''; }, isVerifyValid() { const validUntil = this.getVerifyValidUntil(); const ok = this.isAccessProofValid('ai-unlock', Config.storageKeys.verifyProof, validUntil); if (!ok && validUntil) this.clearVerifySession(); return ok; }, // 广告模式别名 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 || {})); }, // 父页面与 ucontent iframe 使用不同 origin;通过受信任的 RPC 显式同步运行配置。 exportRuntimeConfig() { const keys = Config.storageKeys; const conf = {}; const copyRaw = (field, key) => { const value = localStorage.getItem(key); if (value !== null) conf[field] = value; }; copyRaw('deviceId', keys.deviceId); copyRaw('aiApiKey', keys.aiApiKey); copyRaw('aiProvider', keys.aiProvider); copyRaw('audioApiKey', keys.audioApiKey); copyRaw('audioProvider', keys.audioProvider); copyRaw('verifyValidUntil', keys.verifyValidUntil); copyRaw('verifyProof', keys.verifyProof); copyRaw('verifySessionToken', keys.verifySessionToken); copyRaw('answerMode', keys.answerMode); const playbackRate = localStorage.getItem(keys.playbackRate); if (playbackRate !== null) { const parsed = Number(playbackRate); conf.playbackRate = Number.isFinite(parsed) && parsed > 0 ? parsed : playbackRate; } const featureConf = localStorage.getItem(keys.featureConf); if (featureConf !== null) conf.featureConf = Utils.safeJSON(featureConf, featureConf); return conf; }, importRuntimeConfig(conf) { if (!conf || typeof conf !== 'object') return; const keys = Config.storageKeys; const put = (field, key) => { if (!Object.prototype.hasOwnProperty.call(conf, field)) return; const value = conf[field]; if (value === undefined || value === null) return; try { localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value)); } catch (_) {} }; // proof 的签名绑定 deviceId;必须先同步设备标识,再同步凭证字段。 put('deviceId', keys.deviceId); put('aiApiKey', keys.aiApiKey); put('aiProvider', keys.aiProvider); put('audioApiKey', keys.audioApiKey); put('audioProvider', keys.audioProvider); put('verifyValidUntil', keys.verifyValidUntil); put('verifyProof', keys.verifyProof); put('verifySessionToken', keys.verifySessionToken); put('answerMode', keys.answerMode); put('playbackRate', keys.playbackRate); put('featureConf', keys.featureConf); }, markRuntimeConfigCleared() { const keys = Config.storageKeys; [ keys.deviceId, keys.aiApiKey, keys.aiProvider, keys.audioApiKey, keys.audioProvider, keys.verifyValidUntil, keys.verifyProof, keys.verifySessionToken, keys.answerMode, keys.playbackRate, keys.featureConf, ].forEach(key => { try { localStorage.setItem(key, ''); } catch (_) {} }); }, 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'); }, isStudyPurposeConfirmed() { return localStorage.getItem(Config.storageKeys.studyPurposeConfirmed) === '1'; }, setStudyPurposeConfirmed() { localStorage.setItem(Config.storageKeys.studyPurposeConfirmed, '1'); }, }; // 各 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] || '未知'; }, writableInputSelector: [ 'input[type="text"]', 'input[type="number"]', 'input[type="search"]', 'input:not([type])', 'textarea', '[contenteditable="true"]', '[role="textbox"]', '.ant-input', '.el-input__inner', '[class*="blank-input"]', '[class*="BlankInput"]', '[class*="answer-input"]', '[class*="AnswerInput"]', ].join(', '), _isOwnPanel(el) { return !!el?.closest?.('#unipus-panel, #unipus-settings-overlay, [data-unipus-helper]'); }, // 分页与目录中的“每页条数/跳至页”属于界面控件,不能作为答案、选项或题干。 nonAnswerUiSelector: '.ant-pagination, .el-pagination, .ivu-page, .pagination, ' + '[aria-label="pagination" i], [aria-label="分页"], nav, [role="navigation"], aside, ' + '.pc-slider-content-menu, .pc-header-tabs-container, .pc-header-tasks-row, .question-common-course-page', _isNonAnswerUi(el) { return !!el?.closest?.(this.nonAnswerUiSelector); }, _answerText(el) { if (!el || this._isNonAnswerUi(el)) return ''; const clone = el.cloneNode(true); clone.querySelectorAll(this.nonAnswerUiSelector).forEach(node => node.remove()); return Utils.safeText(clone.innerText || clone.textContent); }, _isVisible(el) { if (!el) return false; try { const r = el.getBoundingClientRect?.(); return !!(r && r.width > 0 && r.height > 0 && el.offsetParent !== null); } catch (_) { return false; } }, _isWritableInput(el) { if (!el || this._isOwnPanel(el) || this._isNonAnswerUi(el) || el.closest?.('#mark-edit-in, .mark-edit-in')) return false; if (!this._isVisible(el)) return false; if (el.disabled || el.readOnly || el.getAttribute?.('aria-disabled') === 'true' || el.getAttribute?.('aria-readonly') === 'true') return false; if (el.closest?.('.ant-select, .ant-select-dropdown, .scoop-select-wrapper, .scoop-select-dropdown, .ant-dropdown-trigger, [role="combobox"]') || el.matches?.('.ant-select-search__field, .ant-select-selection-search-input')) return false; const tag = (el.tagName || '').toLowerCase(); const type = (el.getAttribute?.('type') || '').toLowerCase(); if (tag === 'input') return ['', 'text', 'number', 'search', 'email', 'url', 'tel'].includes(type); if (tag === 'textarea') return true; return el.contentEditable === 'true' || el.getAttribute?.('contenteditable') === 'true'; }, getWritableInputs(root = document) { const out = []; const seen = new Set(); const add = (el) => { if (!el || seen.has(el) || !this._isWritableInput(el)) return; seen.add(el); out.push(el); }; try { if (root.matches?.(this.writableInputSelector)) add(root); } catch (_) {} try { root.querySelectorAll?.(this.writableInputSelector).forEach(add); } catch (_) {} return out; }, hasVisibleRecorderControl(root = document) { try { return Array.from(root.querySelectorAll( '[class*="record-btn"], [class*="recordBtn"], [class*="RecordBtn"], ' + '[class*="recorder"], [class*="Recorder"], ' + '[class*="microphone"]:not([class*="off"]), [class*="Microphone"]:not([class*="off"]), ' + '[class*="mic-btn"], [class*="micBtn"], [class*="MicBtn"], ' + '[class*="voice-btn"], [class*="VoiceBtn"], ' + 'button[aria-label*="record" i], button[aria-label*="录音" i], ' + 'button[title*="录音" i], button[title*="record" i], ' + // 真实朗读起始页点击 start 之前尚未挂载 recorder,只按口语布局内的控件识别。 '.layout-container-oral-aloud .p-oral-aloud .p-oral-aloud-button .button-start' )).some(el => { if (this._isOwnPanel(el) || !this._isVisible(el)) return false; for (let current = el; current; current = current.parentElement) { const style = el.ownerDocument.defaultView.getComputedStyle(current); if (current.hidden || current.getAttribute('aria-hidden') === 'true' || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false; } return true; }); } catch (_) { return false; } }, // 在文档中查找所有题目容器(兼容新版 + 普通版 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', '.question-common-abs-choice', '[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) && !this._isNonAnswerUi(el)) candidates.add(el); }); } catch (_) {} } // 用局部题目容器补充候选。不能只按 name 在整页分组:很多 SPA 会给 // 不同题目复用同一个 name,整页分组会把多道题合成一个题目。 try { const radios = Array.from(doc.querySelectorAll('input[type="radio"], input[type="checkbox"]')) .filter(r => !isInOwnPanel(r) && !this._isNonAnswerUi(r)); const controlsIn = (root) => { try { return Array.from(root.querySelectorAll('input[type="radio"], input[type="checkbox"]')).filter(el => !this._isNonAnswerUi(el)); } catch (_) { return []; } }; const localScope = (radio) => { // 选择器阶段已经找到的题目节点优先,保证同名选项不会跨题合并。 try { let best = null; for (const candidate of candidates) { if (!candidate.contains?.(radio)) continue; if (!best || best.contains?.(candidate)) best = candidate; } if (best) return best; } catch (_) {} // 优先使用带题目语义的容器,避免把整个 form/页面当成一道题。 try { const explicit = radio.closest( '[data-question-id], [id^="question-"], [id^="q_"], ' + '[class*="QuestionBox"], [class*="QuestionItem"], [class*="question-item"], ' + '[class*="question-wrap"], [class*="exercise-question"], [class*="practice-question"], ' + 'fieldset' ); if (explicit && !isInOwnPanel(explicit) && controlsIn(explicit).length >= 2) return explicit; } catch (_) {} let cur = radio.parentElement; for (let depth = 0; depth < 10 && cur; depth++, cur = cur.parentElement) { if (isInOwnPanel(cur)) break; const count = controlsIn(cur).length; // 选项包装通常只有一个 input;继续向上直到找到完整选项组。 if (count >= 2 && count <= 10) return cur; } return null; }; const addCompactGroup = (group, fallback) => { if (!group || group.length < 2 || group.length > 10) return; let ancestor = group[0].parentElement; for (let depth = 0; depth < 12 && ancestor; depth++, ancestor = ancestor.parentElement) { if (isInOwnPanel(ancestor) || !group.every(r => ancestor.contains(r))) continue; const count = controlsIn(ancestor).filter(r => group.includes(r)).length; const total = controlsIn(ancestor).length; if (count === group.length && total <= group.length + 1) break; } if (!ancestor && fallback && controlsIn(fallback).length <= group.length + 1) ancestor = fallback; if (ancestor && !isInOwnPanel(ancestor)) candidates.add(ancestor); }; const byScope = new Map(); radios.forEach(r => { const scope = localScope(r); if (!scope) return; if (!byScope.has(scope)) byScope.set(scope, []); byScope.get(scope).push(r); }); for (const [scope, group] of byScope) { // 一个宽容器可能仍包含多道题,按 name 再拆一次;没有 name 时保留 // 已识别的局部 scope,但不再做跨页面公共祖先搜索。 const byName = new Map(); group.forEach(r => { const name = r.name || r.getAttribute('data-name') || `__anonymous_${group.indexOf(r)}`; if (!byName.has(name)) byName.set(name, []); byName.get(name).push(r); }); if (byName.size <= 1) { if (group.length >= 2 && group.length <= 10) candidates.add(scope); continue; } for (const namedGroup of byName.values()) addCompactGroup(namedGroup, scope); } // 只有在页面没有任何局部 scope 时,才用 name 分组兜底;限制公共祖先 // 的输入数量,避免把后续题目一起吸入第一题。 if (!byScope.size) { const byName = new Map(); radios.forEach(r => { 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()) addCompactGroup(group, null); } } catch (_) {} // 兜底:找含可写输入框的题目块(exploration 主题听写/填词/小作文题) // 必须在验证之前加入候选,否则验证阶段不会包含这些元素 try { const listItems = new Set(); this.getWritableInputs(doc).forEach(inp => { const item = inp.closest( '.QuestionBox, .question-item, .exercise-question, .practice-question, ' + '.topic-item, .subject-item, .ques-item, .exercise-item, .activity-question, ' + 'li, p, article, section' ) || inp.parentElement; if (item) listItems.add(item); }); for (const item of listItems) { if (isInOwnPanel(item)) continue; const fills = this.getWritableInputs(item); 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) || this._isNonAnswerUi(el)) continue; try { const radioCount = [...el.querySelectorAll('input[type="radio"], input[type="checkbox"]')].filter(e => e.offsetParent !== null && !this._isNonAnswerUi(e)).length; const fillCount = this.getWritableInputs(el).length; const divChoiceCount = el.matches('.question-common-abs-choice') ? Array.from(el.querySelectorAll('.option-wrap > .option')).filter(e => this._isVisible(e)).length : 0; if (radioCount >= 2 || divChoiceCount >= 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 ''; // 表格填空的 h3 是分组标题,题干应包含整份表格及各空的位置。 // 替换整个 fe-scoop,避免已输入答案进入题干或换题指纹。 const scoopReply = container.matches?.('.comp-scoop-reply, .fill-blank-reply') ? container : container.querySelector('.comp-scoop-reply, .fill-blank-reply'); if (scoopReply && (this.getWritableInputs(scoopReply).length || scoopReply.querySelector('.fe-scoop'))) { try { const clone = scoopReply.cloneNode(true); clone.querySelectorAll(this.nonAnswerUiSelector).forEach(node => node.remove()); clone.querySelectorAll('.fe-scoop').forEach((blank, i) => { const rawIndex = blank.getAttribute('data-scoop-index'); const index = rawIndex === null || rawIndex === '' ? NaN : Number(rawIndex); blank.replaceWith(clone.ownerDocument.createTextNode(` [空${Number.isFinite(index) ? index + 1 : i + 1}] `)); }); clone.querySelectorAll('input, textarea, [contenteditable="true"], [role="textbox"], audio, video, button, .ant-dropdown, .ant-select-dropdown, .scoop-select-dropdown, [role="listbox"], [hidden], [aria-hidden="true"]').forEach(n => n.remove()); clone.querySelectorAll('td, th').forEach(n => n.appendChild(clone.ownerDocument.createTextNode(' | '))); clone.querySelectorAll('tr, p, h2, h3, h4').forEach(n => n.appendChild(clone.ownerDocument.createTextNode(' '))); const text = Utils.safeText(clone.innerText || clone.textContent); if (text) return [this._findSharedPrompt(container), text].filter(Boolean).join('\n'); } catch (_) {} } 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', '.ques-title', '.task-content', '.question-title', '.test-title', '.exam-title', 'h3', 'h4', // 题干常用标题 ]; for (const sel of stems) { const el = container.querySelector(sel); if (el && !this._isNonAnswerUi(el)) { const txt = this._answerText(el); // 听力选择有时只显示题号,实际问题在共享音频里。保留题号, // 不能把四个小题都替换成相同 Directions,也不能因长度不足跳过。 const numberOnly = txt.match(/^\s*(?:Q\s*)?[((]?\s*(\d+)\s*[)).、.::]?\s*$/i); if (numberOnly) { const sharedPrompt = this._findSharedPrompt(container); return `${sharedPrompt || '请根据当前题组的材料作答。'}\n第${numberOnly[1]}题(题干见音频或共享材料)`; } if (txt && txt.length >= 3) return this._cleanQuestionText(txt); } } // 兜底:克隆容器,移除所有选项 / 输入框,取剩下的文字(避免把选项混进题干) try { const clone = container.cloneNode(true); clone.querySelectorAll(this.nonAnswerUiSelector).forEach(node => node.remove()); clone.querySelectorAll( 'input, textarea, [contenteditable="true"], [role="textbox"], ' + '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 (_) {} // 填空/写作类题目经常把题干放在输入框的祖先或相邻节点里 try { const inputPrompt = this._findInputPrompt(container); if (inputPrompt) return this._cleanQuestionText(inputPrompt); } catch (_) {} // 题干在容器内为空(典型听力题:只有 ABCD 选项),向上找父节点的指引文字 try { const sharedPrompt = this._findSharedPrompt(container); if (sharedPrompt) return this._cleanQuestionText(sharedPrompt); } catch (_) {} // 最终兜底:返回提示语(避免把选项当题干,导致 AI 混乱) return '(听力题,请根据音频和选项作答)'; }, // 向上找祖先节点,寻找共享的题目指引文字(如"Listen and choose...") _findSharedPrompt(container) { try { const scope = container.closest?.('.layout-container, .question-abs-question, .question-wrap'); const directions = scope?.querySelector('.layout-direction-container .abs-direction .content, .layout-direction-container .abs-direction'); const text = this._answerText(directions); if (text) return text; } catch (_) {} 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) { if (this._isNonAnswerUi(ch)) continue; // 跳过包含 input / option 的子树 if (ch.querySelector('input[type="radio"], input[type="checkbox"], [class*="option"], [class*="Option"]')) continue; const txt = this._answerText(ch); // 长度合理 (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 ''; }, _findInputPrompt(container) { const inputs = this.getWritableInputs(container); if (!inputs.length) return ''; const strip = (raw) => Utils.safeText(String(raw || '') .replace(/请输入|请\s*输\s*入|点击输入|Enter your answer|Your answer/gi, ' ') .replace(/请选择|点击选择|select\s+one/gi, '___')); const reject = (txt) => !txt || txt.length < 6 || /^(请输入|请选择|your answer|answer)$/i.test(txt); for (const input of inputs) { let cur = input.parentElement; for (let d = 0; d < 8 && cur; d++, cur = cur.parentElement) { if (this._isOwnPanel(cur)) break; const clone = cur.cloneNode(true); clone.querySelectorAll(this.nonAnswerUiSelector).forEach(node => node.remove()); try { clone.querySelectorAll('input, textarea, [contenteditable="true"], [role="textbox"], button, select, option').forEach(n => n.remove()); } catch (_) {} const txt = strip(clone.innerText || clone.textContent); if (!reject(txt) && txt.length <= 1500) return txt; } } 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(); // U 校园 AI 的词汇 Quiz 使用纯 div,caption 与正文分别存放。 // 精确选中每个 option,避免把整个 option-wrap 作为第五个选项。 if (container.matches?.('.question-common-abs-choice')) { const divOptions = Array.from(container.querySelectorAll('.option-wrap > .option')) .filter(node => this._isVisible(node)) .map(node => ({ node, text: Utils.safeText((node.querySelector('.content') || node).innerText || (node.querySelector('.content') || node).textContent) })) .filter(option => option.text && option.text.length <= 500); if (divOptions.length >= 2) return divOptions; } // 优先策略:直接找 radio/checkbox,每个对应一个选项 // 这是最可靠的(题目最终要点这些 input) try { const inputs = container.querySelectorAll('input[type="radio"], input[type="checkbox"]'); for (const inp of inputs) { if (this._isNonAnswerUi(inp)) continue; // 找最近的可点击包装(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) || this._isNonAnswerUi(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) || this._isNonAnswerUi(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 fullTxt = this._answerText(container).slice(0, 2000); const txt = fullTxt.slice(0, 600); const inputs = this.getWritableInputs(container); const inputCount = inputs.length; const textareaCount = inputs.filter(el => (el.tagName || '').toLowerCase() === 'textarea').length; const hasLongTextInput = inputs.some(el => { const tag = (el.tagName || '').toLowerCase(); const rows = parseInt(el.getAttribute?.('rows') || '0', 10); const max = parseInt(el.getAttribute?.('maxlength') || '0', 10); const placeholder = el.getAttribute?.('placeholder') || ''; return tag === 'textarea' || rows >= 3 || max >= 80 || /作文|写作|essay|composition|paragraph|comment|reply|answer/i.test(placeholder); }); // ----- U 校园特殊题型识别 ----- // 口语/跟读:必须有可见录音控件才跳过,避免“视听说/Speaking”标签误伤填空/听力题 const hasRecorder = this.hasVisibleRecorderControl(container); if (hasRecorder && (/跟读|口语|record|speaking|read\s+aloud/i.test(txt) || (!inputCount && !optionCount))) { return this.TYPE_SPEAKING; } // 听写题:音频 + 多个填空框(不是选项) // 音频可能在容器内 / 父级 / 兄弟节点(多个子题共享一个 tab 级音频) const hasAudio = !!this.findAudioInQuestion(container) || !!this.extractAudioUrl(container) || this._findQuestionMedia(container, 'video').some(el => this._declaredAudioUrl(el)); if (/听写|dictation/i.test(txt) || (hasAudio && inputCount >= 1 && !optionCount)) { return this.TYPE_DICTATION; } // 听力选择:音频 + 选项 if (hasAudio && optionCount > 0) { return this.TYPE_LISTENING; } // 写作题:长文输入框 / textarea / 词数提示 if (/写作|作文|composition|essay|write\s+(an?\s+)?(essay|article|paragraph)|paragraph|comment|reply/i.test(txt)) { if (hasLongTextInput) return this.TYPE_WRITING; } // 单一 textarea 且字数限制提示 → 写作 if (textareaCount === 1 && /字数|words|word\s*count|至少.*\d+\s*字/i.test(txt)) { return this.TYPE_WRITING; } // 只有一个长文本框且没有选择项,U校园里通常是小作文/开放写作 if (!optionCount && inputCount === 1 && hasLongTextInput && txt.length >= 20) { 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 (fullTxt.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; if (optionCount > 2 && container.matches?.('.question-common-abs-choice')) return this.TYPE_SINGLE; return this.TYPE_OTHER; }, _questionMediaScopes(container) { if (!container) return []; const scopes = [container]; const group = container.closest?.('.layout-container, .question-abs-question, .question-wrap'); if (group && group !== container) scopes.push(group); // 已知题组内部没有音频时,不借用页面其他题组或旧缓存的音频。 if (!group) scopes.push(container.ownerDocument || document); return scopes; }, _isQuestionMediaCandidate(el) { if (!el || this._isOwnPanel(el)) return false; if (el.hidden || el.getAttribute?.('aria-hidden') === 'true') return false; if (el.closest?.('.qc-abs-word-tips, .word-tips-wrap, [class*="word-tips"], [class*="recorder"], [class*="Recorder"], [class*="record-btn"], [class*="recordBtn"]')) return false; // 原生 audio 经常由可视播放器控制,自身无尺寸;检查其祖先是否隐藏。 let cur = /^(AUDIO|SOURCE)$/.test(el.tagName || '') ? el.parentElement : el; for (; cur; cur = cur.parentElement) { if (cur.hidden || cur.getAttribute?.('aria-hidden') === 'true') return false; try { const style = cur.ownerDocument?.defaultView?.getComputedStyle(cur); if (style?.display === 'none' || style?.visibility === 'hidden') return false; } catch (_) {} } return true; }, _findQuestionMedia(container, selector) { for (const scope of this._questionMediaScopes(container)) { try { const nodes = Array.from(scope.querySelectorAll(selector)).filter(n => this._isQuestionMediaCandidate(n)); // 旧版整页兜底仅接受唯一来源,避免借用别题的音频。 if (scope.nodeType === 9 && nodes.length !== 1) continue; nodes.sort((a, b) => Number(!!b.closest('.audio-material-wrapper, .question-audio')) - Number(!!a.closest('.audio-material-wrapper, .question-audio'))); if (nodes.length) return nodes; } catch (_) {} } return []; }, // 探测题目内的音频元素(用于听力/听写题先播完音频) findAudioInQuestion(container) { if (!container) return null; const nativeAudio = this._findQuestionMedia(container, 'audio'); const audio = nativeAudio.find(el => this._declaredAudioUrl(el)) || nativeAudio[0]; if (audio) return audio; // 自定义音频播放器 const customPlayer = this._findQuestionMedia(container, '[class*="audio-player"], [class*="AudioPlayer"], [class*="audioBox"], [class*="AudioBox"], ' + '.question-audio, .play-btn, [class*="PlayBtn"], button[aria-label*="play" i], button[title*="播放" i]' )[0]; 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 false; await Utils.waitIfPaused(); if (stopRequested) return false; if (audioOrBtn.tagName === 'AUDIO') { const source = this._declaredAudioUrl(audioOrBtn); if (!source) return false; const sameSource = () => audioOrBtn.isConnected !== false && this._declaredAudioUrl(audioOrBtn) === source; const sourceLoaded = () => !audioOrBtn.currentSrc || audioOrBtn.currentSrc === source; if (sourceLoaded() && Player.isEnded(audioOrBtn)) return true; let pausedByUser = false; let playFailed = false; let remainingMs = maxWaitMs; let lastTick = Date.now(); try { audioOrBtn.muted = false; // 听力题需要听完,浏览器允许 muted 自动播放 audioOrBtn.volume = 0.001; // 实际接近静音 audioOrBtn.playbackRate = Store.getPlaybackRate(); const p = audioOrBtn.play(); if (p && p.catch) p.catch(() => { playFailed = true; }); } catch (_) { playFailed = true; } try { // 暂停时间不计入听力等待上限;节点复用换源时不再操作新内容。 await Utils.poll(() => { const now = Date.now(); if (!isPaused && !pausedByUser) remainingMs -= Math.max(0, now - lastTick); lastTick = now; if (stopRequested || !sameSource() || playFailed || audioOrBtn.error) return true; if (isPaused) { if (!pausedByUser) { pausedByUser = true; try { audioOrBtn.pause(); } catch (_) {} } return false; } if (pausedByUser) { pausedByUser = false; if (!Player.isEnded(audioOrBtn)) { try { const resumed = audioOrBtn.play(); if (resumed && resumed.catch) resumed.catch(() => { playFailed = true; }); } catch (_) { playFailed = true; } } } return remainingMs <= 0 || (sourceLoaded() && Player.isEnded(audioOrBtn)); }, { interval: 1000, timeout: Number.MAX_SAFE_INTEGER }); return !stopRequested && sameSource() && sourceLoaded() && !playFailed && !audioOrBtn.error && Player.isEnded(audioOrBtn); } finally { if (sameSource()) { try { audioOrBtn.pause(); } catch (_) {} } } } else { // 点击播放按钮 if (!Utils.safeClick(audioOrBtn)) return false; // 等几秒让音频播放 await Utils.sleep(3000); await Utils.waitIfPaused(); if (stopRequested || audioOrBtn.isConnected === false) return false; // 再尝试找 audio 元素 const a = this._findQuestionMedia(audioOrBtn, 'audio').find(el => this._declaredAudioUrl(el)); return a ? this.playAudioAndWait(a, maxWaitMs) : false; } }, // 统计题目内"可见且可写"的填空输入框数量(用于按空格数精确生成答案) countBlanks(container) { if (!container) return 0; try { return this.getWritableInputs(container).length; } catch (_) { return 0; } }, _isDragVisible(el) { if (!el || this._isNonAnswerUi(el) || el.isConnected === false || el.offsetParent === null || el.closest?.( '[data-unipus-helper], #unipus-panel, #unipus-settings-overlay, select, option, ' + '[role="combobox"], [role="listbox"], [role="option"], .ant-select, .ant-select-dropdown, .el-select, .el-select-dropdown' )) return false; for (let current = el; current?.nodeType === 1; current = current.parentElement) { if (current.hidden || current.getAttribute('aria-hidden') === 'true') return false; try { const style = getComputedStyle(current); if (style.display === 'none' || style.visibility === 'hidden') return false; } catch (_) {} } return true; }, // 收集页面上可拖拽的元素(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*="drag-word"], [class*="DragWord"], ' + '[class*="sortable-item"], [class*="SortableItem"], [class*="sortable"], [class*="Sortable"], ' + '[class*="word-item"], [class*="WordItem"], [class*="word-card"], [class*="WordCard"], ' + '[class*="token-item"], [class*="TokenItem"], [data-rbd-draggable-id], [data-rbd-draggable-context-id], ' + '[data-drag-id], [data-draggable-id], [aria-grabbed="true"]'; const seen = new Set(); const out = []; try { root.querySelectorAll(sel).forEach(candidate => { if (inOwnPanel(candidate) || !this._isDragVisible(candidate)) return; // native/data-id 标识指向实际拖动节点,不能被内部 word-item 文本包装替换。 const identified = candidate.closest('[draggable="true"], [data-drag-id], [data-draggable-id], [data-rbd-draggable-id]'); const el = identified && (root === document || root.contains(identified)) ? identified : candidate; if (el.getAttribute('draggable') === 'false' || el.getAttribute('aria-disabled') === 'true' || seen.has(el)) 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*="BlankSlot"], [class*="answer-slot"], [class*="AnswerSlot"], ' + '[class*="match-target"], [class*="MatchTarget"], [class*="word-slot"], [class*="WordSlot"], ' + '[class*="target-slot"], [class*="TargetSlot"], [data-rbd-droppable-id], [data-rbd-droppable-context-id], ' + '[data-drop-id], [data-droppable-id]'; const out = []; try { root.querySelectorAll(sel).forEach(el => { if (inOwnPanel(el) || !this._isDragVisible(el)) 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 base = root.body || root; const clone = base.cloneNode(true); const originals = Array.from(base.querySelectorAll('*')); const copies = Array.from(clone.querySelectorAll('*')); const movable = new Set([...drags, ...zones]); originals.forEach((el, index) => { if (!this._isDragVisible(el) || movable.has(el)) copies[index]?.remove(); }); clone.querySelectorAll('nav, aside, header, [class*="menu"], [class*="sidebar"], #unipus-panel, #unipus-settings-overlay, [data-unipus-helper]').forEach(el => el.remove()); const txt = (clone.innerText || clone.textContent || '').slice(0, 6000); const result = { isMatch: false, isOrder: false, dragCount: drags.length, zoneCount: zones.length }; if (!drags.length) return result; // 文本线索 const matchHint = /\bmatch(?:ing)?\b|连线|配对|匹配|\bpairs?\b|对应|搭配|与.*相符|连接/i.test(txt); const orderHint = /\b(?:order|sequence|reorder|rearrange)\b|排序|顺序|排列|put.*in.*order|number.*following/i.test(txt); if (drags.length < 2 && !(zones.length >= 1 && matchHint)) return result; // 有明确放置区且左右数量相近 → 匹配题 if (orderHint && !matchHint) { result.isOrder = true; } else if (zones.length >= 2 || (matchHint && zones.length >= 1)) { result.isMatch = true; } else if (orderHint || (!matchHint && zones.length === 0 && drags.every(el => el.matches('[draggable="true"], [data-rbd-draggable-id], [data-drag-id], [data-draggable-id], [class*="sortable"], [class*="Sortable"], [class*="draggable"], [class*="Draggable"], [class*="drag-item"], [class*="dragItem"]')))) { // 无放置区、纯一组可拖动项 → 排序题 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 (this._isNonAnswerUi(b)) continue; if (b.querySelector('input[type="radio"], input[type="checkbox"], textarea, [class*="option"], [class*="Option"]')) continue; const txt = this._answerText(b); // 文章正文一般 > 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) && !this._isNonAnswerUi(p)) { const txt = this._answerText(p); 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} 个选项里选出**最符合音频内容**的答案(题干未提供文字,请根据选项推断要问什么)。`; const answerRule = isMulti ? '- 本题为多选,请输出所有正确选项的完整文本,用 "||" 分隔' : '- 只输出一个选项的完整文本(与给定选项一字不差地复制其中一个)'; const formatRule = isMulti ? '- 多个答案必须用 "||" 分隔' : '- 不要 "||" 分隔'; return `你是英语听力题助手。${head}${optTxt} 要求: - 必须根据音频内容判断,不要凭选项字面意思猜测 - ${answerRule.replace(/^- /, '')} - 不要输出字母 A/B/C/D ${formatRule} - 不要解释,不要多余文字 - 不要输出其他题目的答案或音频中无关内容`; } 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:/data:、懒加载地址与旧版唯一共享音频,限定当前题组以免串音频。 extractAudioUrl(container) { const isUsable = (u) => !!u && (/^https?:|^blob:|^data:/i.test(u)); for (const audio of this._findQuestionMedia(container, 'audio')) { const u = this._declaredAudioUrl(audio); if (isUsable(u)) return u; } for (const el of this._findQuestionMedia(container, '[data-audio], [data-src], [data-url], [data-audio-url], [data-mp3]')) { for (const attr of ['data-audio', 'data-audio-url', 'data-mp3', 'data-url', 'data-src']) { const raw = el.getAttribute(attr)?.trim(); let u = null; try { if (raw) u = new URL(raw, el.ownerDocument?.baseURI || location.href).href; } catch (_) {} if (isUsable(u) && (/\.(mp3|wav|m4a|ogg|aac|flac)/i.test(u) || /audio/i.test(u))) return u; } } return null; }, _declaredAudioUrl(audio) { if (!audio) return null; // SPA 切换 src 后 currentSrc 可能仍指向上一段,优先使用本题声明的来源。 const raw = audio.getAttribute('src')?.trim() || audio.querySelector('source[src]')?.getAttribute('src')?.trim() || audio.currentSrc; if (!raw) return null; try { const url = new URL(raw, audio.ownerDocument?.baseURI || location.href); return /^(https?:|blob:|data:)$/i.test(url.protocol) ? url.href : null; } catch (_) { return null; } }, // 解析 AI 文本输出 → 答案数组 // isMulti: 该题为多选(听力/阅读多选时不截断为单个答案) _parseAIText(content, type, isMulti = false) { content = (content || '').trim() .replace(/^答案[::]\s*/i, '') .replace(/^答[::]\s*/i, '') .replace(/^译文[::]\s*/i, '') .replace(/^译[::]\s*/i, '') .trim(); if (type === Solver.TYPE_WRITING) return [content]; let parts = content.includes('||') ? content.split('||').map(s => s.trim()) : [content]; // 单选题/判断题 + 非多选的听力/阅读题 默认单选 —— AI 偶尔会返回多答案污染,强制只取第一个 // 但当题目实际是多选(含 checkbox)时,保留全部答案 const baseSingle = new Set([Solver.TYPE_SINGLE, Solver.TYPE_JUDGE]); const choiceSingle = new Set([Solver.TYPE_LISTENING, Solver.TYPE_READING]); const forceSingle = baseSingle.has(type) || (choiceSingle.has(type) && !isMulti); if (forceSingle) parts = parts.slice(0, 1); return parts; }, expandFillAnswers(answers, targetCount = 1) { if (!Array.isArray(answers)) return []; let arr = answers.map(a => String(a ?? '').trim()); if (targetCount <= 1) return arr.length === 1 && arr[0].includes('||') ? arr[0].split('||').map(value => value.trim()) : arr; if (arr.length !== 1) return arr; const raw = arr[0]; const stripped = raw .replace(/^\s*(?:答案|答)[::]\s*/i, '') .replace(/\r/g, '\n') .trim(); const candidates = stripped .split(/\s*\|\|\s*|\n+|[;;]/) .map(s => s.replace(/^\s*(?:第\s*\d+\s*[题空]\s*[.、.::))-]?|[((]\s*\d+\s*[))]|(?:\d+|[A-Ha-h])\s*[.、.::))-](?!\d))\s*/, '').trim()); if (candidates.length > 1) return candidates; // 兜底:AI 常输出 "1. word 2. phrase 3. ..." const numbered = [...stripped.matchAll(/(?:^|\s)(?:第?\s*)?(\d+)\s*[题空]?\s*[.、.::))-]\s*([^0-9]+?)(?=\s+(?:第?\s*)?\d+\s*[题空]?\s*[.、.::))-]|$)/g)] .map(m => (m[2] || '').trim()) .filter(Boolean); if (numbered.length > 1) return numbered; return arr; }, // 全局节流:确保两次同 provider 请求间隔不小于 AI_MIN_INTERVAL_MS[provider] async _throttle(provider, logger = null, label = '') { const minMs = AI_MIN_INTERVAL_MS[provider] || 0; if (!minMs) return; const last = aiLastRequestAt[provider] || 0; const elapsed = Date.now() - last; if (elapsed < minMs) { const wait = minMs - elapsed; // 等待 > 2s 时给个提示 if (wait > 2000 && typeof logger === 'function') { logger(`⏳ ${label} ${AI_PROVIDERS[provider]?.name || provider} 限流节流,等待 ${(wait / 1000).toFixed(1)}s...`); } await Utils.sleep(wait); } aiLastRequestAt[provider] = Date.now(); }, // 调用 AI 模型作答(自动根据是否有音频选择文本/多模态模型) // 内置节流 + 429 重试(指数退避) // opts.passage: 阅读题文章正文;opts.isMulti: 是否多选;opts.blankCount: 填空数量 async askAI(question, options = [], type = 4, audioUrl = null, opts = {}) { await Utils.waitIfPaused(); if (stopRequested) throw '用户已停止'; const logger = opts.logger || null; const label = opts.label || ''; // 决定走音频模型还是文本模型 const useAudio = !!audioUrl; const provider = useAudio ? Store.getAudioProvider() : Store.getAIProvider(); const apiKey = useAudio ? Store.getAudioApiKey() : Store.getAIApiKey(); const conf = AI_PROVIDERS[provider]; if (!conf) throw `AI 提供商 ${provider} 配置缺失`; if (!apiKey) throw useAudio ? '音频 AI 未配置 API Key' : 'AI 未配置 API Key'; const blankCount = opts.blankCount || 1; const prompt = this.buildAIPrompt(question, options, type, blankCount, { passage: opts.passage || '', isMulti: !!opts.isMulti, leftItems: opts.leftItems || [], rightItems: opts.rightItems || [], orderItems: opts.orderItems || [], }); // 写作 600,含长文章的阅读题放宽到 400,其余 200 const maxTokens = (type === this.TYPE_WRITING) ? 600 : (opts.passage ? 400 : 200); const isMulti = !!opts.isMulti; const callOnce = () => { if (provider === 'qwen_audio') return this._callQwenAudio(conf, apiKey, prompt, type, audioUrl, maxTokens, isMulti); if (provider === 'zhipu_audio') return this._callZhipuAudio(conf, apiKey, prompt, type, audioUrl, maxTokens, isMulti); return this._callTextAI(conf, apiKey, prompt, type, maxTokens, isMulti); }; // 节流 + 重试(智谱限流极严,不重试直接放弃;其他 provider 最多重试 3 次) const maxAttempts = (provider === 'zhipu_audio') ? 1 : 3; let lastErr = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { await Utils.waitIfPaused(); if (stopRequested) throw '用户已停止'; await this._throttle(provider, logger, label); await Utils.waitIfPaused(); if (stopRequested) throw '用户已停止'; try { const result = await callOnce(); await Utils.waitIfPaused(); if (stopRequested) throw '用户已停止'; return result; } catch (err) { lastErr = err; const msg = String(err || ''); const isRateLimit = /429|请求过于频繁|too many|rate limit|频率/i.test(msg); if (!isRateLimit || attempt === maxAttempts) throw err; // 指数退避:5s → 15s → 30s const backoff = 5000 * Math.pow(2, attempt - 1); if (typeof logger === 'function') { logger(`⚠️ ${label} 限流(${msg}),第 ${attempt}/${maxAttempts} 次重试,等 ${backoff / 1000}s...`); } await Utils.sleep(backoff); } } throw lastErr || 'AI 请求失败'; }, // OpenAI 兼容文本对话 _callTextAI(conf, apiKey, prompt, type, maxTokens, isMulti = false) { const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; const body = { model: conf.model, messages: [{ role: 'user', content: prompt }], temperature: 0.1, max_tokens: maxTokens }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: conf.url, headers, data: JSON.stringify(body), timeout: 60000, onload: res => { if (res.status !== 200) { if (res.status === 401) return reject('AI Key 无效'); if (res.status === 429) return reject('AI 请求过于频繁'); return reject(`AI HTTP ${res.status}`); } try { const json = JSON.parse(res.responseText); const content = json.choices?.[0]?.message?.content || ''; resolve({ answers: Solver._parseAIText(content, type, isMulti), remaining: -1 }); } catch (_) { reject('AI 响应解析失败'); } }, onerror: () => reject('AI 网络错误'), ontimeout: () => reject('AI 请求超时'), }); }); }, // 通义千问 Qwen3-Omni-Flash(OpenAI 兼容模式,必须 stream:true) async _callQwenAudio(conf, apiKey, prompt, type, audioUrl, maxTokens, isMulti = false) { // 推测音频格式 let audioFormat = 'mp3'; const fmt = (audioUrl.match(/\.(wav|mp3|m4a|aac|ogg|flac|amr|3gp)(?:\?|$)/i) || [])[1]; if (fmt) audioFormat = fmt.toLowerCase(); // 处理 audio data:HTTP(S) URL 直接用;blob: 转 base64 let audioData = audioUrl; if (audioUrl.startsWith('blob:') || audioUrl.startsWith('data:')) { const r = await this._fetchAudioAsBase64(audioUrl); audioData = r.base64; audioFormat = r.format || audioFormat; } const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; const body = { model: conf.model, messages: [{ role: 'user', content: [ { type: 'input_audio', input_audio: { data: audioData, format: audioFormat } }, { type: 'text', text: prompt }, ], }], modalities: ['text'], // 只要文字输出 stream: true, // 必须流式 stream_options: { include_usage: false }, max_tokens: maxTokens, temperature: 0.1, }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: conf.url, headers, data: JSON.stringify(body), timeout: 120000, onload: res => { if (res.status !== 200) { if (res.status === 401) return reject('Qwen Key 无效'); if (res.status === 429) return reject('Qwen 请求过于频繁'); return reject(`Qwen HTTP ${res.status}: ${res.responseText?.slice(0, 200)}`); } try { // 解析 SSE 流:每行 data: {...} let content = ''; const lines = (res.responseText || '').split('\n'); for (const raw of lines) { const line = raw.trim(); if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (!data || data === '[DONE]') continue; try { const json = JSON.parse(data); const delta = json.choices?.[0]?.delta; if (delta && typeof delta.content === 'string') content += delta.content; } catch (_) {} } if (!content) return reject('Qwen 响应为空'); resolve({ answers: Solver._parseAIText(content, type, isMulti), remaining: -1 }); } catch (e) { reject('Qwen 响应解析失败: ' + (e.message || e)); } }, onerror: () => reject('Qwen 网络错误'), ontimeout: () => reject('Qwen 请求超时'), }); }); }, // 智谱 GLM-4-Voice(要求 base64 数据 + format 字段,不接受 URL) async _callZhipuAudio(conf, apiKey, prompt, type, audioUrl, maxTokens, isMulti = false) { // 先下载音频转 base64 const { base64, format } = await this._fetchAudioAsBase64(audioUrl); const headers = { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }; const body = { model: conf.model, messages: [{ role: 'user', content: [ { type: 'input_audio', input_audio: { data: base64, format } }, { type: 'text', text: prompt }, ], }], temperature: 0.1, max_tokens: maxTokens, }; return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url: conf.url, headers, data: JSON.stringify(body), timeout: 120000, onload: res => { if (res.status !== 200) { if (res.status === 401) return reject('智谱 Key 无效'); if (res.status === 429) return reject('智谱 请求过于频繁'); return reject(`智谱 HTTP ${res.status}: ${res.responseText?.slice(0, 200)}`); } try { const json = JSON.parse(res.responseText); const content = json.choices?.[0]?.message?.content || ''; resolve({ answers: Solver._parseAIText(content, type, isMulti), remaining: -1 }); } catch (_) { reject('智谱 响应解析失败'); } }, onerror: () => reject('智谱 网络错误'), ontimeout: () => reject('智谱 请求超时'), }); }); }, // 下载音频文件转 base64(用于不支持 URL 的多模态 API) _fetchAudioAsBase64(url) { return new Promise((resolve, reject) => { // 从 URL 推断格式 const m = (url || '').toLowerCase().match(/\.(mp3|wav|m4a|ogg|aac|flac)(?:\?|$)/); const format = m ? m[1] : 'mp3'; GM_xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', timeout: 60000, onload: res => { if (res.status !== 200 && res.status !== 206) { return reject(`下载音频失败 HTTP ${res.status}`); } try { const buf = res.response; if (!buf || !buf.byteLength) return reject('音频内容为空'); // ArrayBuffer → base64 const bytes = new Uint8Array(buf); let binary = ''; const chunk = 0x8000; for (let i = 0; i < bytes.length; i += chunk) { binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk)); } const base64 = btoa(binary); resolve({ base64, format }); } catch (e) { reject('音频转 base64 失败: ' + e.message); } }, onerror: () => reject('下载音频网络错误'), ontimeout: () => reject('下载音频超时'), }); }); }, // 不支持自动作答的题型(口语等) isUnsupportedType(type) { return type === this.TYPE_SPEAKING; }, // 不需要选项的题型(填空、判断、翻译、写作、完形、听写) isFillLikeType(type) { return type === this.TYPE_FILL || type === this.TYPE_JUDGE || type === this.TYPE_TRANSLATION || type === this.TYPE_WRITING || type === this.TYPE_CLOZE || type === this.TYPE_DICTATION; }, // 调用 AI 答题(文本题用 DeepSeek / 听力题自动切到音频 AI) async solveWithAI(result, logger = console.log, label = '本题') { const opts = (result?.options || []).filter(Boolean); const t = result.type !== undefined ? result.type : Solver.TYPE_OTHER; if (this.isUnsupportedType(t)) { logger(`⏭️ ${label} 检测到口语/跟读题,不支持自动作答,跳过`); return null; } if (!opts.length && !this.isFillLikeType(t)) { logger(`🤖 ${label} 选项为空,跳过`); return null; } // 听力只能使用当前题组材料;没有可靠完整原文时不能交给文本模型猜答。 const audioUrl = this.extractAudioUrl(result.container); if (!audioUrl && this._findQuestionMedia(result.container, 'video').some(el => this._declaredAudioUrl(el))) { logger(`🖐 ${label} 为视频听力材料,当前音频模型不支持直接读取视频音轨,请观看视频后人工完成;未将视频或单词音频发送到模型`); return null; } if (!audioUrl && (t === this.TYPE_LISTENING || t === this.TYPE_DICTATION)) { logger(`🖐 ${label} 未取得当前题组的有效听力音频,且未确认完整听力原文,请检查材料加载或人工完成;未调用文本 AI 猜答`); return null; } const useAudio = !!audioUrl; // 检查对应的 API Key const needKey = useAudio ? Store.getAudioApiKey() : Store.getAIApiKey(); if (!needKey) { const tip = useAudio ? `🖐 ${label} 未配置音频 AI Key,且未确认完整听力原文,请在面板配置 Qwen-Omni / 智谱 Voice 的 Key 后继续;未调用文本 AI 猜答` : `🤖 ${label} 未配置文本 AI Key,跳过(请在面板配置 DeepSeek Key)`; logger(tip); return null; } // 是否多选:题型为多选,或选项节点里含 checkbox(U 校园多选用 checkbox) const hasCheckbox = !!result.container?.querySelector?.( 'input[type="checkbox"], label.ant-checkbox-wrapper, [role="checkbox"]' ); const isMulti = (t === Solver.TYPE_MULTI) || hasCheckbox; try { const provName = useAudio ? AI_PROVIDERS[Store.getAudioProvider()]?.name || '音频AI' : AI_PROVIDERS[Store.getAIProvider()]?.name || '文本AI'; const extra = []; if (useAudio) extra.push('含音频'); if (isMulti && opts.length) extra.push('多选'); if (result.passage) extra.push('附文章'); if (result.blankCount >= 2) extra.push(`${result.blankCount}空`); logger(`🤖 ${label} 调用 ${provName}(${this.typeName(t)}${extra.length ? ' · ' + extra.join('·') : ''},选项:${opts.length})...`); const ai = await this.askAI(result.question, result.options || [], t, audioUrl, { logger, label, passage: result.passage || '', isMulti, blankCount: result.blankCount || 1, }); const answers = Array.isArray(ai?.answers) ? ai.answers.map(value => String(value ?? '').trim()) : []; if (answers.some(value => !value)) { logger(`🤖 ${label} AI 答案中存在空项,未进行部分填写`); return null; } if (answers.length) { const preview = answers.map(a => a.length > 60 ? a.slice(0, 60) + '...' : a).join(' / '); logger(`🤖 AI 返回 ${answers.length} 个答案:${preview}`); return answers; } logger(`🤖 ${label} AI 未返回有效答案,跳过`); return null; } catch (err) { logger(`🤖 ${label} AI 调用失败:${err}`); return null; } }, // 智能匹配答案到选项节点(返回需要勾选的节点列表) matchAnswersToOptions(answers, optionPairs, type) { if (!answers || !answers.length || !optionPairs.length) return []; // 剥掉 AI 答案常见的 "A. xxx" / "B、xxx" / "C) xxx" 前缀(防止 "D. 8." 不匹配 "8.") const stripLetterPrefix = (s) => String(s || '').replace(/^\s*[A-Ha-h]\s*[.、.::))]\s*/, '').trim(); const normalize = (s) => stripLetterPrefix(s).toLowerCase().replace(/[\s.、,。,;;::!!??'"'""\(\)()\-_]/g, ''); const matched = []; const used = new Set(); // 判断题特殊处理 if (type === Solver.TYPE_JUDGE) { const ans = stripLetterPrefix(answers[0] || ''); const isTrue = /^(正确|对|true|t|✓|right|yes|是)$/i.test(ans); const isFalse = /^(错误|错|false|f|✗|wrong|no|否)$/i.test(ans); for (const opt of optionPairs) { const optTxt = Utils.safeText(opt.text); if (isTrue && /^(正确|对|true|t|✓|right|yes|是|T)$/i.test(optTxt)) { matched.push(opt); break; } if (isFalse && /^(错误|错|false|f|✗|wrong|no|否|F)$/i.test(optTxt)) { matched.push(opt); break; } } if (matched.length) return matched; } for (const ans of answers) { const ansNorm = normalize(ans); if (!ansNorm) continue; let bestIdx = -1, bestScore = 0; for (let i = 0; i < optionPairs.length; i++) { if (used.has(i)) continue; const optNorm = normalize(optionPairs[i].text); if (!optNorm) continue; let score = 0; if (optNorm === ansNorm) score = 100; else if (optNorm.includes(ansNorm) || ansNorm.includes(optNorm)) { const minLen = Math.min(optNorm.length, ansNorm.length); const maxLen = Math.max(optNorm.length, ansNorm.length); score = (minLen / maxLen) * 80; } else { // 字母匹配(A/B/C/D) — 若答案以字母开头且对应索引匹配,给中等分 const m = String(ans).match(/^\s*([A-Ha-h])\b/); if (m && i === (m[1].toUpperCase().charCodeAt(0) - 65)) score = 60; } if (score > bestScore) { bestScore = score; bestIdx = i; } } if (bestIdx >= 0 && bestScore >= 50) { matched.push(optionPairs[bestIdx]); used.add(bestIdx); } } // 兜底:单条答案没匹上但内部含 "A. xxx B. xxx C. xxx" 多段 → 拆段重试 if (matched.length === 0 && answers.length === 1) { const raw = String(answers[0] || ''); const segs = raw.match(/[A-Ha-h]\s*[.、.::))]\s*[^A-Ha-h]+?(?=\s+[A-Ha-h]\s*[.、.::))]|$)/g); if (segs && segs.length >= 2) { const isSingleChoice = type === Solver.TYPE_SINGLE || type === Solver.TYPE_JUDGE || type === Solver.TYPE_LISTENING || type === Solver.TYPE_READING; const newAnswers = isSingleChoice ? [segs[0].trim()] : segs.map(s => s.trim()); // 防止递归(已经拆过一次) if (newAnswers[0] !== raw) { return this.matchAnswersToOptions(newAnswers, optionPairs, type); } } } return matched; }, // 读取浏览器/组件实际选中状态,不能把事件已派发当作选中成功。 optionState(node) { if (!node || node.isConnected === false) return null; const input = node.matches?.('input[type="radio"], input[type="checkbox"]') ? node : node.querySelector?.('input[type="radio"], input[type="checkbox"]'); if (input) return !!input.checked; const control = node.matches?.('[aria-checked], [aria-selected]') ? node : node.querySelector?.('[aria-checked], [aria-selected]'); if (control) return control.getAttribute('aria-checked') === 'true' || control.getAttribute('aria-selected') === 'true'; const selected = (el) => /(?:^|\s)(?:[\w-]+-)?(?:checked|selected|active)(?:\s|$)/i.test(String(el?.className || '')); return selected(node) || Array.from(node.querySelectorAll?.('[class]') || []).some(selected); }, // 在选项节点上模拟点击(适配 ant-radio / ant-checkbox / 普通 label) clickOption(node, checked = true) { if (!node || node.isConnected === false || stopRequested) return false; if (node.matches?.(':disabled, [aria-disabled="true"]') || node.closest?.('[aria-disabled="true"], .ant-radio-wrapper-disabled, .ant-checkbox-wrapper-disabled')) return false; // 原生 input 只点击一次;点击 input 后再点包装节点会把复选框切回原状态。 const input = node.matches?.('input[type="radio"], input[type="checkbox"]') ? node : node.querySelector?.('input[type="radio"], input[type="checkbox"]'); if (input) { if (input.disabled) return false; if (input.checked === checked) return true; if (input.type === 'radio' && !checked) return false; return Utils.safeClick(input); } if (this.optionState(node) === checked) return true; return Utils.safeClick(node); }, // 填空题:将答案填入输入框(兼容 React/Vue 受控组件) async fillBlanks(answers, container, isCurrent = null) { const valid = () => !stopRequested && container?.isConnected !== false && (!isCurrent || isCurrent()); await Utils.waitIfPaused(); if (!valid()) return false; // 题目输入消失或被禁用时必须停在本题,不能向上扩到兄弟题。 const inputs = this.getWritableInputs(container); if (!inputs.length) return false; const fillAnswers = this.expandFillAnswers(answers, inputs.length); // 答案不足/过多时先拒绝,避免只填第一个空就把整题记为完成。 if (fillAnswers.length !== inputs.length || fillAnswers.some(a => !String(a ?? '').trim())) return false; for (let i = 0; i < inputs.length; i++) { const el = inputs[i]; const val = String(fillAnswers[i] ?? '').trim(); await Utils.waitIfPaused(); if (!valid() || el.isConnected === false || !this._isWritableInput(el)) return false; try { // 聚焦 + 触发输入事件序列(模拟真实键入) try { el.focus(); } catch (_) {} const currentInput = () => valid() && el.isConnected !== false && this._isWritableInput(el); if (!currentInput()) return false; if (el.contentEditable === 'true' || el.getAttribute?.('contenteditable') === 'true') { try { el.innerText = val; } catch (_) { el.textContent = val; } el.dispatchEvent(new InputEvent('input', { bubbles: true, data: val, inputType: 'insertText' })); } else { // React 受控组件:必须用原生 setter 才能绕过 React 的 value 检测 const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) setter.call(el, val); else el.value = val; el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: val[0] || 'a' })); if (!currentInput()) return false; el.dispatchEvent(new InputEvent('input', { bubbles: true, data: val, inputType: 'insertText' })); if (!currentInput()) return false; el.dispatchEvent(new Event('change', { bubbles: true })); if (!currentInput()) return false; el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: val.slice(-1) || 'a' })); } if (!currentInput()) return false; const wasFocused = el.ownerDocument?.activeElement === el; try { el.blur(); } catch (_) {} if (!wasFocused && currentInput()) el.dispatchEvent(new Event('blur', { bubbles: true })); } catch (_) { return false; } } // 给受控组件处理 input/change/blur 后复读,检查值是否被框架撤销。 await Utils.sleep(100); await Utils.waitIfPaused(); return valid() && inputs.every((el, i) => el.isConnected !== false && String( el.contentEditable === 'true' || el.getAttribute?.('contenteditable') === 'true' ? (el.innerText || el.textContent || '') : el.value ?? '' ).trim() === String(fillAnswers[i]).trim()); }, // 应用答案到题目(点击选项 / 填空 / 写作) async applyAnswers(answers, recognized, isCurrent = null) { if (!answers || !answers.length) return false; const { container, optionNodes, type } = recognized; const valid = () => !stopRequested && container?.isConnected !== false && (!isCurrent || isCurrent()); await Utils.waitIfPaused(); if (!valid()) return false; // 口语/跟读:不支持 if (this.isUnsupportedType(type)) return false; // 填空类(fill/translation/writing/cloze/dictation):写入输入框 if (this.isFillLikeType(type) && type !== Solver.TYPE_JUDGE) { return this.fillBlanks(answers, container, valid); } // 判断题:可能是选项或文本输入 if (type === Solver.TYPE_JUDGE && !optionNodes.length) { // 没选项 → 尝试填空 return this.fillBlanks(answers, container, valid); } // 选择题(单选/多选/听力/阅读):匹配并点击 const optionPairs = optionNodes.map((node, i) => ({ text: recognized.options[i] || '', node, })); const matched = this.matchAnswersToOptions(answers, optionPairs, type); if (!matched.length) return false; const isMulti = type === Solver.TYPE_MULTI || !!container?.querySelector?.('input[type="checkbox"], [role="checkbox"]'); if (isMulti && matched.length < new Set(answers.map(a => String(a).trim())).size) return false; const targets = new Set((isMulti ? matched : matched.slice(0, 1)).map(m => m.node)); // 多选必须先清除旧的错误勾选;重复运行保持同一组最终选项。 if (isMulti) { for (const { node } of optionPairs) { await Utils.waitIfPaused(); if (!valid()) return false; if (!targets.has(node) && this.optionState(node) === true) { if (!this.clickOption(node, false)) return false; await Utils.sleep(100); } } } for (const node of targets) { await Utils.waitIfPaused(); if (!valid() || !this.clickOption(node, true)) return false; await Utils.sleep(300); } await Utils.waitIfPaused(); return valid() && optionPairs.every(({ node }) => this.optionState(node) === targets.has(node)); }, // ---- 广告模式 / 解锁码 ---- // 题间延迟(毫秒):解锁后 1.5s ± 抖动,未解锁 35s ± 抖动 getQuestionDelay() { const jitter = Math.random() * Config.questionDelayJitter; if (Store.isUnlocked()) { return Config.questionDelayUnlocked + jitter; } return Config.questionDelayLocked + jitter; }, // 验证解锁码(POST 到 qsy.iano.cn) verifyUnlockCode(code, scope = 'ai-unlock') { return new Promise((resolve, reject) => { if (!code || typeof code !== 'string') return reject('请输入解锁码'); const c = code.trim(); if (!c) return reject('请输入解锁码'); GM_xmlhttpRequest({ method: 'POST', url: Config.verifyUrl, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: [ 'code=' + encodeURIComponent(c), 'scope=' + encodeURIComponent(scope), 'device_id=' + encodeURIComponent(Store.getDeviceId()), ].join('&'), timeout: 15000, onload: (resp) => { if (resp.status && resp.status !== 200) { reject(`请求失败: HTTP ${resp.status}`); return; } try { const data = JSON.parse(resp.responseText); if (data.code === 1 && data.data && data.data.valid) { const validUntil = parseInt(data.data.valid_until, 10); const validUntilStr = data.data.valid_until_str || new Date(validUntil * 1000).toLocaleString('zh-CN'); resolve({ validUntil, validUntilStr, sessionToken: data.data.session_token || '', scope, }); } else { reject(data.msg || '验证码无效或已过期'); } } catch (_) { reject('验证响应解析失败'); } }, onerror: () => reject('网络错误'), ontimeout: () => reject('请求超时'), }); }); }, }; // ---- 弹窗自动关闭(U校园常见"我知道了"等) ---- function clickIKnow(doc = document) { try { const featureConf = typeof Store !== 'undefined' ? Store.getFeatureConf() : null; if (featureConf && featureConf.autoPopup === false) return; } catch (_) {} const dialogSelector = '[role="dialog"], .ant-modal, .ant-modal-root, .modal, .modal-dialog, ' + '.popup, .popup-dialog, .el-dialog, .el-message-box, .layui-layer, ' + '.know-box, [class*="confirm" i], [class*="notice" i], [class*="alert" i]'; const isInformationalDialog = (dialog) => { if (!dialog) return false; const text = Utils.safeText(dialog.innerText || dialog.textContent || ''); if (/题目|答题|作答|练习|测验|试题|提交答案|question|quiz|exercise|answer/i.test(text)) return false; return /知道|提示|公告|通知|提醒|成功|完成|播放|网络|错误|解锁|欢迎|notice|alert|success|complete|got it/i.test(text); }; const selectors = [ '.know-box .iKnow', '.ant-modal-confirm-btns .ant-btn-primary', '.ant-modal-confirm-btns .ant-btn.ant-btn-primary', '.ipublish-modal-footer-ok', 'button.ant-btn.ant-btn-default.ipublish-modal-footer-ok', '.modal-footer .confirm', '.popup-confirm', '.el-message-box__btns .el-button--primary', '.layui-layer-btn0', ]; for (const sel of selectors) { try { doc.querySelectorAll(sel).forEach(btn => { if (btn && typeof btn.click === 'function') { const dialog = btn.closest?.(dialogSelector); const isKnownSafe = /know-box|ipublish-modal-footer-ok/i.test(sel); if (!isKnownSafe && !isInformationalDialog(dialog)) return; try { btn.click(); } catch (_) {} } }); } catch (_) {} } // 通用文本兜底只处理真实弹层,避免把课程页上的“关闭/确定”业务按钮误点。 try { const confirmTexts = /^(确定|确认|知道了|我知道了|我已知晓|知道啦|明白|关闭|好的|OK|Got it|Close)$/i; doc.querySelectorAll('button, a.btn, [role="button"], .btn').forEach(btn => { // 跳过脚本自身面板内的按钮 if (btn.closest('#unipus-panel, #unipus-settings-overlay')) return; const dialog = btn.closest(dialogSelector); if (!dialog || !isInformationalDialog(dialog)) return; const txt = (btn.innerText || btn.textContent || '').trim(); if (confirmTexts.test(txt) && btn.offsetParent !== null) { try { btn.click(); } catch (_) {} } }); } catch (_) {} } // ---- 媒体(视频/音频)控制 ---- const Player = { _snifferInstalled: false, _sniffedAudio: '', _rememberAudioUrl(url, contentType = '') { try { const raw = String(url || ''); if (!raw) return; const abs = new URL(raw, location.href).href; if (/\.(mp3|wav|m4a|ogg|aac|flac|amr|3gp)(?:[?#]|$)/i.test(abs) || /audio\//i.test(contentType || '')) { this._sniffedAudio = abs; } } catch (_) {} }, setupAudioSniffer() { if (this._snifferInstalled) return; this._snifferInstalled = true; try { const origFetch = window.fetch; if (typeof origFetch === 'function') { window.fetch = (...args) => { const reqUrl = typeof args[0] === 'string' ? args[0] : (args[0]?.url || ''); return origFetch.apply(window, args).then(res => { try { this._rememberAudioUrl(res.url || reqUrl, res.headers?.get?.('content-type') || ''); } catch (_) {} return res; }); }; } } catch (_) {} try { const origOpen = XMLHttpRequest.prototype.open; const origSend = XMLHttpRequest.prototype.send; const self = this; XMLHttpRequest.prototype.open = function(method, url, ...rest) { try { this.__unipusAudioUrl = url; } catch (_) {} return origOpen.call(this, method, url, ...rest); }; XMLHttpRequest.prototype.send = function(...args) { try { this.addEventListener('loadend', function() { try { const ct = this.getResponseHeader?.('content-type') || ''; self._rememberAudioUrl(this.responseURL || this.__unipusAudioUrl, ct); } catch (_) {} }); } catch (_) {} return origSend.apply(this, args); }; } catch (_) {} }, getMediaSourceKey(media) { if (!media) return ''; try { return String(media.currentSrc || media.src || media.getAttribute?.('src') || media.querySelector?.('source[src]')?.src || '').trim(); } catch (_) { return ''; } }, getMediaState(media) { const source = this.getMediaSourceKey(media); const errorCode = Number(media?.error?.code || 0); const readyState = typeof media?.readyState === 'number' ? media.readyState : 0; const networkState = typeof media?.networkState === 'number' ? media.networkState : 0; return { source, errorCode, readyState, networkState, unsupported: errorCode === 4 || (networkState === 3 && !!source && readyState === 0 && !!media?.error), }; }, applyMediaDefault(media) { if (!media) return { ok: false, reason: 'missing' }; try { const state = this.getMediaState(media); if (!state.source) return { ok: false, reason: 'no-source' }; if (state.unsupported) return { ok: false, reason: 'unsupported' }; media.volume = 0; media.muted = true; media.playbackRate = Store.getPlaybackRate(); if (state.readyState === 0 && typeof media.load === 'function') { try { media.load(); } catch (_) {} } return { ok: true }; } catch (_) { return { ok: false, reason: 'setup-failed' }; } }, isEnded(media) { if (!media) return false; if (media.ended) return true; if (media.duration > 0 && media.currentTime >= media.duration - 0.5) return true; return false; }, // 在文档中找所有有效的课程 video/audio(排除录音系统、隐藏、无源元素) findAllMedia(doc = document) { const medias = []; try { doc.querySelectorAll('video, audio').forEach(m => { // 排除:属于录音/评测系统的元素(优先检查) if (m.closest('[class*="record"], [class*="clio"], [class*="webrtc"], [class*="Record"]')) return; // 排除:blob URL(通常是录音流) if ((m.src || '').startsWith('blob:')) return; // 允许:有 src / currentSrc / source子元素 / 有 duration(已加载) const hasSrc = m.src || m.currentSrc || m.querySelector('source[src]'); const hasDuration = m.duration && isFinite(m.duration) && m.duration > 0; if (!hasSrc && !hasDuration) return; // SPA 可能复用同一节点;只有同一来源已经完成/失败时才跳过。 const sourceKey = this.getMediaSourceKey(m); if (m.dataset.unipusPlayed === '1' && m.dataset.unipusPlayedSrc && m.dataset.unipusPlayedSrc === sourceKey) return; if (m.dataset.unipusFailedSrc && m.dataset.unipusFailedSrc === sourceKey) return; // 可见性检查:audio 和父元素都在隐藏子树 → 跳过 if (m.offsetParent === null) { const p = m.parentElement; if (!p || p.offsetParent === null) return; } medias.push(m); }); } catch (_) {} return medias; }, }; try { Player.setupAudioSniffer(); } catch (_) {} // ---- iframe 通信:父子页面菜单同步 ---- // ucontent.unipus.cn 是 iframe,被 ipub/uai 嵌入 // 菜单在 ucontent 中,控制面板在 ipub 中 // 收集菜单节点(在 ucontent 内或普通版主页内调用) function getMenuList(doc = document) { const containerSelectors = [ // 新版 ucontent '.pc-slider-menu-container.show .pc-slider-content-menu', '.pc-slider-menu-container .pc-slider-content-menu', '#part-menu-view .pc-slider-content-menu', '#part-menu-view .ant-tree', '#part-menu-view', '.pc-slider-content-menu', // 普通版 u.unipus.cn 常见结构 '.chapter-list', '.chapters-list', '.book-menu', '.book-chapters', '.course-menu', '.course-catalog', '.menu-tree', '.menu-list', '.lesson-list', '.unit-list', '.sidebar-menu', '.left-menu', 'aside .menu', 'aside ul', // uai.unipus.cn(ucm-前缀 Ant Design) '.ucm-ant-menu', '.ucm-ant-tree', '[class*="menu-side"]', '.ucm-ant-layout-sider-children', // ucontent 新版 exploration 主题(_explorationpc_default/pc.html) '[class*="catalog"]', '[class*="directory"]', '[class*="toc"]', '[class*="course-menu"]', '[class*="lesson-menu"]', '[class*="chapter-menu"]', '[class*="side-menu"]', '[class*="sider-menu"]', '[class*="exploration"] [class*="menu"]', '[class*="exploration"] [class*="list"]', 'aside [class*="menu"]', 'aside [class*="list"]', 'aside', // 通用兜底 '.ant-tree', '[role="tree"]', '.ant-menu', '[role="menu"]', 'nav ul.menu', ]; // 检查候选容器是否合法(排除播放器倍速菜单等) const isInvalidContainer = (el) => { if (!el) return true; // SPA 会保留旧目录的隐藏副本;只检查整容器及祖先,不过滤折叠单元内的课时。 try { for (let current = el; current; current = current.parentElement) { const style = doc.defaultView.getComputedStyle(current); if (current.hidden || current.getAttribute('aria-hidden') === 'true' || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return true; } } catch (_) {} // 排除行内元素(span/a/i/img/button 等不可能是课程列表容器) const tag = el.tagName?.toLowerCase(); if (['span', 'a', 'i', 'img', 'button', 'input', 'label', 'em', 'strong', 'small'].includes(tag)) return true; // 空容器:没有任何子元素或没有可见文本 const childCount = el.children?.length || 0; const txt = (el.innerText || el.textContent || '').trim(); if (childCount === 0 && !txt) return true; if (childCount === 0 && txt.length < 3) return true; // 在视频/播放器附近的菜单 → 多半是倍速菜单/字幕菜单 const inPlayer = el.closest('video, audio, [class*="video"], [class*="player"], [class*="Player"], [class*="media-control"]'); if (inPlayer) return true; // 自身就含 video/audio/play 控件 if (el.querySelector('video, audio, [class*="play-btn"], [class*="speed"], [class*="rate"]')) { // 但要确认不是普通课程页(课程页里可能有视频但不在菜单容器里) const ownItems = el.querySelectorAll('li, [role="treeitem"], [role="menuitem"]'); if (ownItems.length > 0 && ownItems.length <= 6) return true; // 小型菜单 + 含播放控件 → 高度可疑 } return false; }; let container = null; for (const sel of containerSelectors) { try { // 用 querySelectorAll 遍历所有匹配,跳过被 isInvalidContainer 拒绝的(如行内 span) const list = doc.querySelectorAll(sel); for (const c of list) { if (!isInvalidContainer(c)) { container = c; break; } } if (container) break; } catch (_) {} } if (!container) { // ---- 兜底策略:扫描页面上所有 链接中匹配课程编号模式的 ---- // u.unipus.cn 课程目录页的链接文本形如 "1-1 iPrepare", "1-2 iExplore 1: ..." const fallbackNodes = []; try { const courseItemRe = /^\s*(\d+)\s*[-–]\s*(\d+)\s+(.+)/; const unitRe = /^Unit\s+(\d+)/i; let curUnit = ''; const seenKeys = new Set(); // 按编号去重 // 扫描所有可能含课程项的元素(优先 链接) const allEls = doc.querySelectorAll('a, span[class*="item"], span[class*="node"], li > span, [data-key], [data-id]'); allEls.forEach(el => { const txt = (el.innerText || el.textContent || '').trim(); // 只匹配短文本(真正的菜单项,排除包含多行文本的父元素) if (!txt || txt.length > 80 || txt.includes('\n')) return; // 检测 Unit 标题 const um = txt.match(unitRe); if (um) { curUnit = txt; return; } // 检测课程项 const cm = txt.match(courseItemRe); if (cm) { const key = `${cm[1]}-${cm[2]}`; // e.g. "1-1", "1-2" if (seenKeys.has(key)) return; // 去重 seenKeys.add(key); fallbackNodes.push({ unit: curUnit, section: '', micro: txt, element: el, }); } }); } catch (_) {} if (fallbackNodes.length) { console.log(`[U校园助手] 兜底策略命中 ${fallbackNodes.length} 个课程项`); return fallbackNodes; } // ---- 启发式兜底:找右侧/侧边带 ≥4 个相似短文本子项的容器 ---- try { const vw = doc.defaultView?.innerWidth || 1280; const candidates = []; // 课程关键字:包含越多越像课程菜单(扩充常见英语课程目录项) const courseKwRe = /\b(Unit|Section|Chapter|Lesson|Quotation|Preview|Reading|Listening|Speaking|Writing|iExplore|iPrepare|iProduce|iReflect|Vocabulary|Grammar|Pronunciation|Discussion|Exercise|Quiz|Skills|Comprehension|Analysis|Structure|Module|Part|Learning|Objective|Board|Experience|Culture|Language|Focus|Activity|Task|Test|Review|Project|Warm|Up|Lead|In|World|Life|Society|Science|Technology|Health|Education|Career|Environment|Media|Art)\b/i; // 导航关键字:含这些短文本的容器多半是站点导航,不是课程菜单 const navKwRe = /^(首页|我的课程|我的作业|我的消息|消息|课程|作业|讨论|学习记录|教程目录|登录|退出|个人中心|设置|帮助|Home|Logout|Profile|Settings)$/i; doc.querySelectorAll('ul, ol, nav, aside, [class*="menu"], [class*="list"], [class*="catalog"], [class*="tree"], [class*="sider"], [class*="content"], [class*="panel"], [class*="tab-pane"]').forEach(el => { try { const rect = el.getBoundingClientRect(); if (!rect || rect.width < 100 || rect.height < 100) return; const items = el.querySelectorAll('li, [role="treeitem"], [role="menuitem"], [class*="item"], [class*="node"], [class*="row"], [class*="cell"]'); const shortItems = [...items].filter(it => { const t = (it.innerText || it.textContent || '').trim(); return t && t.length >= 2 && t.length <= 100 && !t.includes('\n'); }); if (shortItems.length < 3 || shortItems.length > 500) return; // 计算"课程感"分数:含课程关键字的子项数 let courseHits = 0, navHits = 0; for (const it of shortItems) { const t = (it.innerText || it.textContent || '').trim(); if (courseKwRe.test(t)) courseHits++; if (navKwRe.test(t)) navHits++; } // 若主要是导航项(>=半数命中导航关键字),直接弃 if (navHits > 0 && navHits >= shortItems.length / 2) return; // 排除 /// 等行内元素作为主容器(它们不会是课程列表容器) const tag = el.tagName?.toLowerCase(); if (['span', 'a', 'i', 'img', 'button', 'input', 'label'].includes(tag)) return; // 宽容器:必须有足够多的课程关键字才接受(避免误选大面积内容区) if (rect.width > vw * 0.7 && courseHits < 2) return; // 即使是侧边栏,若 0 课程命中 + 仅 3-5 项,也要警惕(可能是导航) if (rect.width <= vw * 0.4 && courseHits === 0 && shortItems.length <= 6) return; candidates.push({ el, count: shortItems.length, rect, courseHits, navHits }); } catch (_) {} }); // 综合评分:课程命中权重最大;导航命中重罚;子项数适中加分 candidates.sort((a, b) => { const sA = a.courseHits * 30 + Math.min(a.count, 100) - a.navHits * 50; const sB = b.courseHits * 30 + Math.min(b.count, 100) - b.navHits * 50; return sB - sA; }); if (candidates.length) { const best = candidates[0]; if (!isInvalidContainer(best.el)) { const tag = best.el.tagName?.toLowerCase(); const cls = (best.el.className || '').slice(0, 80); console.log(`[U校园助手] 启发式命中:<${tag} class="${cls}"> 共${best.count}项 / 课程关键字${best.courseHits} / 导航${best.navHits}`, best.el); const pnl0 = window.__unipusPanel; if (pnl0?.addLog && !window.__unipusHeurLogged) { window.__unipusHeurLogged = true; pnl0.addLog(`🔍 启发式: <${tag} class="${cls.slice(0, 50)}"> ${best.count}项 / 课程命中${best.courseHits}`); } container = best.el; } } } catch (_) {} // 若启发式找到容器,跳到后续策略解析 if (container) { // continue to strategies below } else { // 诊断:输出页面中前 80 个 class + 候选元素摘要 try { const allCls = new Set(); doc.querySelectorAll('[class]').forEach(el => { (el.className || '').split(/\s+/).forEach(c => { if (c && c.length > 2 && c.length < 80) allCls.add(c); }); }); const clsList = [...allCls].slice(0, 80).join(' | '); // 列出含 menu/catalog/list/tree 的 class 单独高亮 const menuCls = [...allCls].filter(c => /menu|catalog|directory|toc|chapter|lesson|tree|sider|side|nav/i.test(c)).slice(0, 30); console.warn('[U校园助手] 菜单容器未命中。'); console.warn(' 全部 class(前80):', clsList); console.warn(' 候选 menu-相关 class:', menuCls.join(' | ')); // 列出页面里所有 aside/nav 元素的 class const sides = [...doc.querySelectorAll('aside, nav, [class*="sider"], [class*="sidebar"]')] .slice(0, 6) .map(el => `<${el.tagName.toLowerCase()} class="${(el.className || '').slice(0, 80)}">`) .join('\n'); console.warn(' 侧边/导航元素:', sides); const pnl = window.__unipusPanel; if (pnl?.addLog) { pnl.addLog(`⚠️ 菜单未识别\n候选class: ${menuCls.join(' | ').slice(0, 300)}\n侧边元素: ${sides.slice(0, 300)}`); } } catch (_) {} return []; } } const nodes = []; const pushNode = (unit, section, micro, element) => { const m = Utils.safeText(micro); if (!m || !element) return; nodes.push({ unit: Utils.safeText(unit), section: Utils.safeText(section), micro: m, element }); }; const pickClickable = (root) => { if (!root) return null; if (root.classList?.contains('pc-slider-menu-node')) { const s = root.querySelector('span'); if (s) return s; } const checks = [ '.pc-menu-node-name', '.ant-tree-node-content-wrapper', '.ucm-ant-tree-node-content-wrapper', '.ant-menu-title-content', '.ucm-ant-menu-title-content', '[class*="ant-tree-node-content-wrapper"]', '[class*="ant-menu-title-content"]', ]; for (const sel of checks) { const el = root.querySelector?.(sel); if (el) return el; } return root.querySelector?.('a, span') || root; }; const pickName = (el) => { if (!el) return ''; return Utils.safeText(el.title || el.getAttribute?.('title') || el.innerText || el.textContent); }; // pc 目录同时存在嵌套结构和同级标题结构。按 DOM 顺序更新归属, // 不扫描标题的父节点,否则同级 unit/section 会重复收集其他单元的内容。 try { let curUnit = '', curSec = ''; const seen = new Set(); container.querySelectorAll('.pc-slider-menu-unit, .pc-slider-menu-section, .pc-slider-menu-micro, .pc-slider-menu-node').forEach(el => { if (!el.classList) return; if (el.classList.contains('pc-slider-menu-unit')) { const label = el.querySelector('.unit-label-item') || el.querySelector(':scope > span'); // 实际 AI 版 title 仅含单元名称,显示文本还含 Unit 编号,身份必须保留编号。 curUnit = Utils.safeText(label?.innerText || label?.textContent) || pickName(label); curSec = ''; } else if (el.classList.contains('pc-slider-menu-section')) { curSec = pickName(el.querySelector('span')); } else { // micro 包装内的 node 才是实际课时,不能把同一课时及其包装重复输出。 if (el.querySelector('.pc-slider-menu-micro, .pc-slider-menu-node')) return; const clickable = pickClickable(el); const microName = pickName(el.querySelector('.pc-menu-node-name')) || pickName(clickable) || pickName(el); if (microName && clickable && !seen.has(clickable)) { seen.add(clickable); pushNode(curUnit, curSec, microName, clickable); } } }); } catch (_) {} if (nodes.length) return validateMenuList(nodes); // 策略 3: ant-tree 通用结构 try { const treeSelector = '.ant-tree,.ucm-ant-tree,[role="tree"]'; // 容器自身通常已是树根;[class*="ant-tree"] 会误中首个 treenode 并丢掉后续单元。 const treeRoot = container.matches(treeSelector) ? container : container.querySelector(treeSelector) || container; let candidates = Array.from(treeRoot.querySelectorAll('[role="treeitem"]')); if (!candidates.length) candidates = Array.from(treeRoot.querySelectorAll('.ant-tree-treenode, .ucm-ant-tree-treenode, [class*="ant-tree-treenode"]')); if (!candidates.length) candidates = Array.from(treeRoot.querySelectorAll('.ant-menu-item, .ant-menu-submenu-title, .ucm-ant-menu-item, .ucm-ant-menu-submenu-title, [class*="ant-menu-item"], [class*="ant-menu-submenu-title"]')); const baseLeft = treeRoot.getBoundingClientRect?.().left || 0; const rows = []; candidates.forEach(node => { const clickable = pickClickable(node); if (!clickable?.getBoundingClientRect) return; const name = pickName(clickable); if (!name) return; const rect = clickable.getBoundingClientRect(); if (!rect || rect.width === 0) return; const indent = Math.max(0, Math.round(rect.left - baseLeft)); const ariaLevel = parseInt(node.getAttribute?.('aria-level') || '', 10); rows.push({ node, clickable, name, indent, ariaLevel }); }); if (rows.length) { const indents = [...new Set(rows.map(r => r.indent))].sort((a, b) => a - b); const levelByIndent = (ind) => { if (!indents.length) return 3; let bestIdx = 0, bestDiff = Math.abs(ind - indents[0]); for (let i = 1; i < indents.length; i++) { const d = Math.abs(ind - indents[i]); if (d < bestDiff) { bestDiff = d; bestIdx = i; } } return Math.min(6, bestIdx + 1); }; const stack = []; const seen = new Set(); const leafs = [], all = []; rows.forEach(r => { let lvl = Number.isFinite(r.ariaLevel) ? r.ariaLevel : levelByIndent(r.indent); if (lvl < 1) lvl = 1; stack[lvl - 1] = r.name; stack.length = lvl; const unitName = stack[0] || ''; const secName = stack[1] || ''; const microName = stack.slice(2).join(' / ') || r.name; const key = `${unitName}|${secName}|${microName}`; if (seen.has(key)) return; seen.add(key); const item = { unit: Utils.safeText(unitName), section: Utils.safeText(secName), micro: Utils.safeText(microName), element: r.clickable }; all.push(item); const cls = r.node.className || ''; const isLeaf = r.node.classList?.contains('ant-tree-treenode-leaf') || r.node.classList?.contains('ant-tree-treenode-leaf-last') || cls.includes('ant-tree-treenode-leaf') || r.node.classList?.contains('ucm-ant-menu-item'); const isParent = r.node.getAttribute?.('aria-expanded') === 'true' || r.node.getAttribute?.('aria-expanded') === 'false'; if (!isParent || isLeaf) leafs.push(item); }); return validateMenuList(leafs.length > 0 ? leafs : all); } } catch (_) {} // 策略 4: 普通版 ul/li 嵌套菜单(按 DOM 层级推断 unit/section/micro) try { const items = container.querySelectorAll('li'); if (items.length) { const seen = new Set(); const all = [], leafs = []; items.forEach(li => { const link = li.querySelector('a, span.clickable, [data-href], [data-url]') || li; const name = pickName(link); if (!name || name.length > 200) return; // 用嵌套 ul 的 li 距离推断层级 let depth = 0; let p = li.parentElement; while (p && p !== container && depth < 6) { if (p.tagName === 'LI') depth++; p = p.parentElement; } const lvl = Math.min(3, depth + 1); const isLeaf = !li.querySelector('ul, ol'); // 用包含关系组装 unit/section/micro let unit = '', sec = '', micro = name; const ancestors = []; let cur = li.parentElement?.closest('li'); while (cur && ancestors.length < 4) { const n = pickName(cur.querySelector('a, span') || cur); if (n) ancestors.unshift(n); cur = cur.parentElement?.closest('li'); } if (ancestors.length >= 1) unit = ancestors[0]; if (ancestors.length >= 2) sec = ancestors[1]; if (ancestors.length >= 3) micro = ancestors.slice(2).join(' / ') + ' / ' + name; const key = `${unit}|${sec}|${micro}`; if (seen.has(key)) return; seen.add(key); const item = { unit: Utils.safeText(unit), section: Utils.safeText(sec), micro: Utils.safeText(micro), element: link }; all.push(item); if (isLeaf) leafs.push(item); }); if (leafs.length || all.length) return validateMenuList(leafs.length ? leafs : all); } } catch (_) {} // 策略 5: 文本叶节点抽取(不依赖 class,靠视觉缩进推断层级) // 用于 exploration 新主题 / CSS-modules 等 class 不可预测的场景 try { const leafEls = []; const walker = doc.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, { acceptNode(el) { // 跳过 script/style/svg/img const tag = el.tagName?.toLowerCase(); if (['script', 'style', 'svg', 'img', 'video', 'audio', 'iframe', 'canvas', 'button', 'input'].includes(tag)) { return NodeFilter.FILTER_REJECT; } // 必须是叶子(没有元素子节点,或子节点只是 / 等内联包装) const childEls = el.children; let hasBlockChild = false; for (const c of childEls) { const ct = c.tagName?.toLowerCase(); if (!['span', 'i', 'em', 'strong', 'b', 'u'].includes(ct)) { hasBlockChild = true; break; } } if (hasBlockChild) return NodeFilter.FILTER_SKIP; const txt = (el.innerText || el.textContent || '').trim(); if (!txt || txt.length < 2 || txt.length > 100 || txt.includes('\n')) return NodeFilter.FILTER_SKIP; return NodeFilter.FILTER_ACCEPT; } }); let node; while ((node = walker.nextNode())) leafEls.push(node); if (leafEls.length >= 3) { // 按 left 像素分桶推断层级 const rows = leafEls.map(el => { const r = el.getBoundingClientRect(); return { el, txt: (el.innerText || el.textContent || '').trim(), left: Math.round(r.left), top: r.top, fontSize: parseFloat(getComputedStyle(el).fontSize) || 14 }; }).filter(r => r.txt.length >= 2); // 按 DOM 顺序处理;左缩进/字号决定层级 const lefts = [...new Set(rows.map(r => r.left))].sort((a, b) => a - b); // 把相邻 <8px 的合并为一桶 const bins = []; for (const l of lefts) { if (!bins.length || l - bins[bins.length - 1] >= 8) bins.push(l); } const levelOf = (left) => { let bi = 0, bd = Math.abs(left - bins[0]); for (let i = 1; i < bins.length; i++) { const d = Math.abs(left - bins[i]); if (d < bd) { bd = d; bi = i; } } return Math.min(3, bi + 1); }; // 取常见字号作为"基准",比它大的视为标题级 const sizes = rows.map(r => r.fontSize).sort((a, b) => a - b); const medianSize = sizes[Math.floor(sizes.length / 2)] || 14; const stack = []; const seen = new Set(); const items = []; rows.forEach(r => { let lvl = levelOf(r.left); // 字号大于中位数 +1px 视为标题 if (r.fontSize > medianSize + 1) lvl = Math.min(lvl, 2); if (lvl < 1) lvl = 1; stack[lvl - 1] = r.txt; stack.length = lvl; const unit = stack[0] || ''; const sec = stack[1] || ''; const micro = stack.slice(2).join(' / ') || r.txt; // 只把最深层视为叶子(micro) if (lvl < 3 && bins.length >= 3) return; // 还有更深层,先跳过非叶 const key = `${unit}|${sec}|${micro}`; if (seen.has(key)) return; seen.add(key); items.push({ unit: Utils.safeText(unit), section: Utils.safeText(sec), micro: Utils.safeText(micro), element: r.el }); }); // 若按"只取最深"得到的太少(如只有 2 级菜单),退回所有项 if (items.length < 3) { items.length = 0; seen.clear(); stack.length = 0; rows.forEach(r => { let lvl = levelOf(r.left); if (r.fontSize > medianSize + 1) lvl = Math.min(lvl, 2); if (lvl < 1) lvl = 1; stack[lvl - 1] = r.txt; stack.length = lvl; const unit = stack[0] || ''; const sec = stack[1] || ''; const micro = stack.slice(2).join(' / ') || r.txt; const key = `${unit}|${sec}|${micro}`; if (seen.has(key)) return; seen.add(key); items.push({ unit: Utils.safeText(unit), section: Utils.safeText(sec), micro: Utils.safeText(micro), element: r.el }); }); } if (items.length >= 3) { console.log(`[U校园助手] 策略 5(文本叶节点)命中 ${items.length} 项`); return validateMenuList(items); } } } catch (_) {} // ---- 最终诊断:所有策略都失败,dump 容器结构 ---- try { const containerHtml = (container.outerHTML || '').slice(0, 800); const containerCls = container.className || '(no class)'; const containerTag = container.tagName?.toLowerCase() || '?'; console.warn(`[U校园助手] 容器命中但所有解析策略失败。容器: <${containerTag} class="${containerCls}">`); console.warn(' outerHTML 前 800 字符:', containerHtml); const pnl = window.__unipusPanel; if (pnl?.addLog && !window.__unipusDiagSent) { window.__unipusDiagSent = true; // 只发一次,避免刷屏 pnl.addLog(`⚠️ 容器找到但解析失败\n容器: <${containerTag} class="${String(containerCls).slice(0, 100)}">\nHTML: ${containerHtml.slice(0, 400)}`); } } catch (_) {} return validateMenuList(nodes); } // 验证菜单列表:过滤掉播放器倍速菜单、字幕菜单等垃圾项 function validateMenuList(list) { if (!Array.isArray(list) || !list.length) return []; // 检测:所有 micro 名都是 "1.2x" / "1x" / "0.8x, selected" 这种倍速选项 const isPlaybackRate = (s) => /^\s*\d+(?:\.\d+)?\s*x\s*(?:,\s*selected)?\s*$/i.test(s || ''); const isJunkMicro = (s) => { if (!s || s.length > 200) return false; if (isPlaybackRate(s)) return true; // 纯数字 / 单字母 if (/^\s*\d+\s*[.,]?\s*$/.test(s)) return true; if (/^[A-D]\s*[.,)]?\s*$/.test(s)) return true; return false; }; const allJunk = list.every(item => isJunkMicro(item.micro)); if (allJunk) { console.warn('[U校园助手] 菜单提取结果全是垃圾(疑似倍速/字幕菜单),已丢弃:', list); return []; } // 检测:所有项都是站点导航(首页/我的课程/我的作业/消息 等)→ 弃 const navOnlyRe = /^(首页|我的课程|我的作业|我的消息|消息|课程|作业|讨论|学习记录|教程目录|登录|退出|个人中心|设置|帮助|Home|Logout|Profile|Settings)$/i; const allNav = list.every(item => navOnlyRe.test((item.micro || '').trim())); if (allNav) { console.warn('[U校园助手] 菜单提取结果全是站点导航,已丢弃:', list); return []; } // 过滤单个垃圾项 const filtered = list.filter(item => !isJunkMicro(item.micro)); if (filtered.length === 0) return []; return filtered; } // ============== Worker:执行业务(iframe 子页面 & 普通版主页共用) ============== const Worker = { _nextLockUntil: 0, _lastNextElement: null, _lastNextSnapshot: '', _lastNextScope: '', _documentStateKey: '', _documentNoProgressCount: 0, safeClickEl(el) { if (!el) return false; try { const menuEntry = el.closest?.('.pc-slider-menu-micro') || el.closest?.('.pc-slider-menu-node'); // 游客版锁定课时会接收点击但不切页,不能把这类点击报告为导航成功。 if (el.closest?.('.pc-menu-micro-lock,.pc-menu-lock-icon') || menuEntry?.querySelector?.('.pc-menu-lock-icon') || el.querySelector?.('.pc-menu-lock-icon')) return false; clickIKnow(); if (el.scrollIntoView) { try { el.scrollIntoView({ block: 'center', inline: 'center' }); } catch (_) {} } // 只选一个真正的交互节点,事件冒泡会通知菜单祖先,避免目标/祖先/span 连点。 const target = el.matches?.('a,button,input,[role="button"],[role="menuitem"]') ? el : (el.querySelector?.('a,button,input,[role="button"],[role="menuitem"]') || el); if (!Utils.safeClick(target)) return false; setTimeout(clickIKnow, 500); return true; } catch (_) { return false; } }, serializeMenu(nodes) { if (!this._menuPathIdentities) this._menuPathIdentities = new Map(); if (!this._menuPathSequence) this._menuPathSequence = 0; return nodes.map((n, i) => { let path = ''; if (n.element) { if (n.element.id) path = n.element.id; else { const uid = 'unipus-node-' + Date.now() + '-' + (++this._menuPathSequence); try { n.element.id = uid; path = uid; } catch (_) {} } } if (path) { const identity = { unit: n.unit, section: n.section, micro: n.micro }; const prior = this._menuPathIdentities.get(path); // 原生 id 也可能被 SPA 复用。仅路径调用遇到身份改变时拒绝; // 带完整目录身份的调用仍可重新定位目标。 if (!this._menuPathIdentities.has(path)) this._menuPathIdentities.set(path, identity); else if (prior && JSON.stringify(prior) !== JSON.stringify(identity)) this._menuPathIdentities.set(path, null); } const activeNodes = n.element ? [n.element] : []; const wrapper = n.element?.closest?.('.pc-slider-menu-node,.pc-slider-menu-micro,.ant-tree-treenode,.ucm-ant-tree-treenode,[role="treeitem"],[role="menuitem"],li'); if (wrapper && wrapper !== n.element && !nodes.some(other => other !== n && other.element && wrapper.contains(other.element))) activeNodes.push(wrapper); const active = activeNodes.some(element => { const current = String(element.getAttribute?.('aria-current') || '').toLowerCase(); const selected = String(element.getAttribute?.('aria-selected') || '').toLowerCase(); return ['true', 'page', 'step'].includes(current) || selected === 'true' || /(?:^|\s)(?:active|current|is-active|selected|pc-menu-activity|ant-tree-node-selected|ucm-ant-tree-node-selected|ant-menu-item-selected|ucm-ant-menu-item-selected)(?:\s|$)/i.test(String(element.className || '')); }); return { unit: n.unit, section: n.section, micro: n.micro, path, active }; }); }, scanMenu() { const list = getMenuList(document); return this.serializeMenu(list); }, clickByPath(path, text = '', unit = '', section = '') { try { const explicitIdentity = !!(text || unit || section); const registered = this._menuPathIdentities?.get(path); if (!explicitIdentity && this._menuPathIdentities?.has(path) && !registered) return false; const wantedIdentity = explicitIdentity ? { micro: text, unit, section } : registered; let direct = path && document.getElementById(path); if (!direct && path) { try { direct = document.querySelector(path); } catch (_) {} } if (!wantedIdentity) { // 旧调用可使用原生路径;过期的助手临时 id 缺少身份时不能盲目点击。 return direct && !/^#?unipus-node-/.test(path) ? this.safeClickEl(direct) : false; } const norm = value => Utils.safeText(value).toLowerCase(); const wanted = norm(wantedIdentity.micro); const wantedUnit = norm(wantedIdentity.unit); const wantedSection = norm(wantedIdentity.section); const matches = item => (!wanted || norm(item.micro) === wanted) && (!wantedUnit || norm(item.unit) === wantedUnit) && (!wantedSection || norm(item.section) === wantedSection); const list = getMenuList(document); if (direct) { const resolved = list.filter(item => item.element === direct || item.element?.contains?.(direct) || direct.contains?.(item.element)); if (resolved.length === 1 && matches(resolved[0])) return this.safeClickEl(resolved[0].element); } // 重绘后只能按完整目录身份唯一定位,不能退回第一个同名 micro(常位于第一单元)。 const candidates = list.filter(matches); if (candidates.length === 1 && candidates[0].element) return this.safeClickEl(candidates[0].element); } catch (_) {} return false; }, _controlText(el) { try { return Utils.safeText(el?.getAttribute?.('aria-label') || el?.getAttribute?.('title') || el?.innerText || el?.textContent || el?.value || ''); } catch (_) { return ''; } }, _isUsableControl(el) { if (!el) return false; try { if (el.closest?.('#unipus-panel, #unipus-settings-overlay, [data-unipus-helper]')) return false; if (el.offsetParent === null || el.disabled || el.getAttribute?.('aria-disabled') === 'true') return false; return true; } catch (_) { return false; } }, _isDocumentControl(el) { const text = this._controlText(el); const cls = String(el?.className || ''); const aria = String(el?.getAttribute?.('aria-label') || ''); return /(下一页|下一张|下一屏|下页|next\s*(?:page|slide)|page[-_ ]?next|slide[-_ ]?next|swiper-button-next|turnPageRight|right[-_ ]?arrow)/i.test(`${text} ${cls} ${aria}`); }, _isChapterControl(el) { const text = this._controlText(el); const cls = String(el?.className || ''); return /(下一节|下一章节|下一章|下一课|下一单元|继续学习|进入下一|next\s*(?:lesson|section|chapter|unit)|lesson[-_ ]?next|chapter[-_ ]?next|section[-_ ]?next)/i.test(`${text} ${cls}`); }, _isContentControl(el) { const text = this._controlText(el); const cls = String(el?.className || ''); return /(下一题|下一道|下一步|继续作答|next\s*(?:question|step)|next[-_ ]?question|next[-_ ]?step|next-btn|btn-next|pagination-next|(^|\s)next($|\s))/i.test(`${text} ${cls}`); }, _navigationScope(el) { if (!el || this._isDocumentControl(el)) return ''; const text = this._controlText(el); const meta = `${text} ${String(el.className || '')} ${el.id || ''}`; if (/(上一节|上一页|上一题|返回|关闭|取消|prev(?:ious)?|back|left[-_ ]?arrow)/i.test(meta)) return ''; const chapter = this._isChapterControl(el) || /(?:lesson|chapter|section|unit)[-_ ]?next|next[-_ ]?(?:lesson|chapter|section|unit)/i.test(`${el.className || ''} ${el.id || ''}`); const content = /(下一题|下一道|下一步|继续作答|next\s*(?:question|step)|next[-_ ]?(?:question|step)|(?:question|step)[-_ ]?next)/i.test(meta); if (chapter && content) return ''; if (chapter) return 'chapter'; if (content) return 'content'; if (!this._isContentControl(el) && !/^(Next|下一个|继续)$/i.test(text)) return ''; // 单独的 Next/next-btn 无法证明是下一题还是下一节,只在有范围标识时点击。 const chapterArea = el.closest?.( '[data-navigation-scope="chapter"],[data-navigation-scope="lesson"],[data-navigation="chapter"],[data-navigation="lesson"],' + '[class*="chapter-nav"],[class*="lesson-nav"],[class*="section-nav"],[class*="unit-nav"],[class*="course-nav"],' + '[class*="chapter-next"],[class*="lesson-next"],[class*="section-next"],[class*="unit-next"],' + '[class*="next-chapter"],[class*="next-lesson"],[class*="next-section"],[class*="next-unit"]' ); const contentArea = el.closest?.( '.question,.question-item,.question-container,.exercise,.exercise-container,.pc-question,.pc-task-content,.question-common-course-page,' + '[data-question-id],[data-exercise-id],[data-navigation-scope="content"],[data-navigation-scope="question"],' + '[class*="question-nav"],[class*="exercise-nav"],[class*="task-nav"],' + '[class*="next-question"],[class*="question-next"],[class*="next-step"],[class*="step-next"]' ); if (chapterArea && contentArea) { return contentArea !== chapterArea && contentArea.contains?.(chapterArea) ? 'chapter' : 'content'; } return chapterArea ? 'chapter' : contentArea ? 'content' : ''; }, _navigationSnapshot() { try { const active = document.querySelector('.active, .current, [aria-selected="true"], [aria-current="page"]'); const body = (document.body?.innerText || '').slice(0, 3000); return Utils.hashString(`${location.href}|${body}|${this._controlText(active)}`); } catch (_) { return String(Date.now()); } }, _documentInfo() { const text = (document.body?.innerText || '').slice(0, 4000); const selectors = [ 'embed[type*="pdf" i]', 'object[type*="pdf" i]', 'iframe[src*=".pdf" i]', 'iframe[src*=".ppt" i]', 'iframe[src*=".pptx" i]', 'iframe[src*="document" i]', 'iframe[src*="viewer" i]', '[data-file-type="pdf" i]', '[data-content-type="document" i]', '#viewerContainer', '.pdfViewer', '.pdf-viewer', '.pdf-page', '.document-viewer', '.document-reader', '.courseware-viewer', '.ppt-viewer', '.ppt-container', '.slide-container', '[class*="swiper-slide"]', '[class*="slide-page"]', ]; let viewer = null; for (const sel of selectors) { try { viewer = document.querySelector(sel); if (viewer) break; } catch (_) {} } const viewerSelector = '#viewerContainer, .pdfViewer, .pdf-viewer, .pdf-page, .document-viewer, .document-reader, .courseware-viewer, .ppt-viewer, .ppt-container, .slide-container, [class*="slide"], [class*="page"]'; const explicitControl = Array.from(document.querySelectorAll( 'button, a, [role="button"], [aria-label], [title], [class*="next"], [class*="Next"]' )).some(el => this._isUsableControl(el) && this._isDocumentControl(el) && !!el.closest?.(viewerSelector)); const keyword = /(\bPPTX?\b|\bPDF\b|PowerPoint|课件|文档阅读|阅读材料|document\s+viewer|courseware|slide\s*\d)/i.test(text); const slides = Array.from(document.querySelectorAll( '.swiper-slide, .slide, .ppt-page, .pdf-page, [class*="slide-item"], [class*="page-item"]' )).filter(el => this._isUsableControl(el) || el.classList?.contains('active') || el.classList?.contains('current')); const has = !!viewer || explicitControl || (keyword && slides.length > 0); let kind = '文档'; if (/\bPDF\b|pdfViewer|pdf-viewer/i.test(`${text} ${viewer?.className || ''}`)) kind = 'PDF'; else if (/PPT|PowerPoint|slide|swiper/i.test(`${text} ${viewer?.className || ''}`)) kind = 'PPT'; return { hasDocument: has, kind, viewer, slides }; }, _documentProgress() { const text = (document.body?.innerText || '').slice(0, 4000); const m = text.match(/(?:第\s*)?(\d{1,4})\s*(?:\/|/|页\s*\/\s*|of)\s*(\d{1,4})\s*(?:页)?/i) || text.match(/(?:page|页)\s*(\d{1,4})\s*(?:of|\/|/|共)\s*(\d{1,4})/i) || text.match(/第\s*(\d{1,4})\s*页\s*(?:共|\/|/)\s*(\d{1,4})\s*页?/i); return m ? { current: parseInt(m[1], 10), total: parseInt(m[2], 10) } : { current: 0, total: 0 }; }, _documentSnapshot(info = this._documentInfo()) { try { const active = document.querySelector('.swiper-slide-active, .slide.active, .slide.current, .page-item.active, [aria-current="page"]'); const p = this._documentProgress(); const scroll = info.viewer ? `${info.viewer.scrollTop || 0}/${info.viewer.scrollHeight || 0}` : ''; return Utils.hashString(`${p.current}/${p.total}|${this._controlText(active)}|${active?.getAttribute?.('data-index') || ''}|${scroll}|${(document.body?.innerText || '').slice(0, 800)}`); } catch (_) { return ''; } }, _findDocumentNext(info = this._documentInfo()) { const candidates = Array.from(document.querySelectorAll( 'button, a, [role="button"], [aria-label], [title], [class*="next"], [class*="Next"]' )); return candidates.find(el => { if (!this._isUsableControl(el) || !this._isDocumentControl(el)) return false; const insideViewer = !!el.closest?.('#viewerContainer, .pdfViewer, .pdf-viewer, .document-viewer, .document-reader, .courseware-viewer, .ppt-viewer, .ppt-container, .slide-container, [class*="slide"], [class*="page"]'); return insideViewer || /下一页|下一张|下一屏|next\s*(?:page|slide)/i.test(this._controlText(el)); }) || null; }, async runDocument(logger) { const info = this._documentInfo(); if (!info.hasDocument) return { ok: false, handled: false, complete: false }; let noProgress = 0; let last = this._documentSnapshot(info); logger(`📄 检测到${info.kind}文档,开始浏览`); for (let step = 0; step < 100 && !stopRequested; step++) { await Utils.waitIfPaused(); if (stopRequested) break; const currentInfo = this._documentInfo(); const progress = this._documentProgress(); if (progress.total > 0 && progress.current >= progress.total) { logger(`✅ ${currentInfo.kind}已浏览到第 ${progress.total} 页`); return { ok: true, handled: true, complete: true }; } const next = this._findDocumentNext(currentInfo); if (next) { const disabled = next.disabled || next.getAttribute?.('aria-disabled') === 'true' || next.classList?.contains('disabled'); if (disabled) return { ok: true, handled: true, complete: true }; if (!Utils.safeClick(next)) return { ok: false, handled: true, complete: false }; await Utils.sleep(900); const after = this._documentSnapshot(this._documentInfo()); if (after && after !== last) { noProgress = 0; last = after; } else noProgress++; if (noProgress >= 3) { logger('⚠️ 文档翻页无进度,已保留当前页面'); return { ok: false, handled: true, complete: false }; } continue; } const scrollTarget = currentInfo.viewer; if (scrollTarget && Number(scrollTarget.scrollHeight || 0) > Number(scrollTarget.clientHeight || 0) + 20) { const maxTop = Math.max(0, scrollTarget.scrollHeight - scrollTarget.clientHeight); if (scrollTarget.scrollTop < maxTop - 4) { scrollTarget.scrollTop = Math.min(maxTop, scrollTarget.scrollTop + Math.max(240, scrollTarget.clientHeight * 0.85)); scrollTarget.dispatchEvent?.(new Event('scroll', { bubbles: true })); await Utils.sleep(700); continue; } } // 单页 PPT/PDF 或平台没有暴露翻页控件时,短暂等待渲染后交给章节导航。 await Utils.sleep(900); logger(`✅ ${currentInfo.kind}页面已处理,进入下一节`); return { ok: true, handled: true, complete: true }; } return { ok: false, handled: true, complete: false, stopped: stopRequested }; }, _getDiscussionViews() { const visible = el => this._dropdownVisible(el, true); const isPublish = el => visible(el) && /^(发布|publish)$/i.test(Utils.safeText(el.textContent).replace(/\s+/g, '')); const candidates = new Set(document.querySelectorAll('.content-view')); // 真实社区组件也可能直接挂在 question-wrap 内,发布动作可以是 a 或 role=button。 for (const editor of document.querySelectorAll('#mark-edit-in[contenteditable]:not([contenteditable="false"])')) { if (visible(editor)) candidates.add(editor.closest('.content-view, .question-wrap, .layout-container') || editor.parentElement); } for (const button of document.querySelectorAll('button, a, [role="button"]')) { if (!isPublish(button)) continue; const view = button.closest('.content-view, .question-wrap, .question-abs-question, .layout-container'); if (view) candidates.add(view); } return Array.from(candidates).filter(view => { if (!visible(view)) return false; const editor = view.querySelector('#mark-edit-in[contenteditable]:not([contenteditable="false"])'); return visible(editor) || Array.from(view.querySelectorAll('button, a, [role="button"]')).some(isPublish); }); }, probe() { const visibleCourseElement = el => { if (!el || el.closest?.('#unipus-panel, #unipus-settings-overlay, [data-unipus-helper]') || !Solver._isVisible(el)) return false; for (let current = el; current; current = current.parentElement) { const style = document.defaultView.getComputedStyle(current); if (current.hidden || current.getAttribute('aria-hidden') === 'true' || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') return false; } return true; }; // 登录课程的自评使用 Got it / Review 图标表格,代表本人掌握情况,不能自动代选。 const reflectionTables = Array.from(document.querySelectorAll('.ticket-view .table-view .ant-table-wrapper')).filter(table => { if (!visibleCourseElement(table)) return false; const headers = Array.from(table.querySelectorAll('thead th')).map(th => Utils.safeText(th.textContent).replace(/\s+/g, '').toLowerCase()); return headers.includes('gotit') && headers.includes('review'); }); const reflectionCount = reflectionTables.reduce((count, table) => count + table.querySelectorAll('tbody tr.ant-table-row').length, 0); // 社区编辑器虽然可写,但属于公开讨论,优先于广义 contenteditable 填空识别。 const discussionViews = this._getDiscussionViews(); // 上传作品页的 textarea 是备注,文件与本人作品需人工准备,不能用文字填空替代。 const uploadViews = Array.from(document.querySelectorAll('.question-multi-file-upload')).filter(view => visibleCourseElement(view) && !!view.querySelector('input[type="file"], .ant-upload[role="button"], .ant-upload [role="button"]')); if (reflectionTables.length || discussionViews.length || uploadViews.length) { const isReflection = reflectionTables.length > 0; const isUpload = !isReflection && uploadViews.length > 0; return { manualRequired: true, manualReason: isReflection ? '自评清单需要本人确认' : isUpload ? '作品上传任务需要本人准备文件并提交' : '社区讨论需要本人填写和发布', hasQuestion: false, questionCount: 0, hasMedia: false, mediaCount: 0, hasSpeaking: false, hasDropdown: false, dropdownCount: 0, hasChecklist: isReflection, checklistCount: reflectionCount, hasDiscussion: !isReflection && !isUpload, discussionCount: isUpload ? 0 : discussionViews.length, hasUpload: isUpload, uploadCount: uploadViews.length, hasMatch: false, hasOrder: false, dragCount: 0, hasDocument: false, documentKind: '文档', }; } const questions = Solver.findQuestions(document); const medias = Player.findAllMedia(document); const inOwnPanel = (el) => !!el.closest?.('[data-unipus-helper]'); const txt = (document.body?.innerText || '').slice(0, 2000); // 检测口语/录音题:必须有"可见"的麦克风/录音按钮元素(不仅是含 "speak" 文字的标签) // 避免在"视听说"等课程里因页面到处含 "Speaking" 字样而误判 const hasSpeaking = Solver.hasVisibleRecorderControl(document); // 与执行端使用同一组去重控件;混合普通题不能遮掉同页下拉。 const dropdowns = this._getDropdownControls(); const hasDropdown = dropdowns.length >= 1; // Self-reflection 清单:含 Checklist/Got it/Reflection 表头 + 多个 checkbox const checklistCbs = Array.from(document.querySelectorAll('input[type="checkbox"]')) .filter(c => !inOwnPanel(c) && c.offsetParent !== null); const hasChecklist = !questions.length && !hasDropdown && /Got\s*it|Checklist|Self[\s-]?reflection|reflection/i.test(txt) && checklistCbs.length >= 2; // 讨论 / 写作(开放性 textarea,需 AI 生成) const discussionTas = Array.from(document.querySelectorAll('textarea')).filter(t => !inOwnPanel(t) && t.offsetParent !== null && !t.value); const hasDiscussion = !questions.length && !hasChecklist && discussionTas.length >= 1 && /discussion|share\s*your|讨论|share.*answer|talk.*about|invite|present|reply|comment|think.*about.*following/i.test(txt); // 拖动题可以与普通题或下拉同页;检测独立进行,控件内部选项由拖动识别器排除。 const drag = Solver.detectDragQuestion(document); const hasMatch = !!drag.isMatch; const hasOrder = !!drag.isOrder; const documentInfo = (!questions.length && !medias.length && !hasDropdown && !hasSpeaking && !hasChecklist && !hasDiscussion && !hasMatch && !hasOrder) ? this._documentInfo() : { hasDocument: false, kind: '文档', slides: [] }; return { hasQuestion: questions.length > 0, questionCount: questions.length, hasMedia: medias.length > 0, mediaCount: medias.length, hasSpeaking, hasDropdown, dropdownCount: dropdowns.length, hasChecklist, checklistCount: checklistCbs.length, hasDiscussion, discussionCount: discussionTas.length, hasMatch, hasOrder, dragCount: drag.dragCount || 0, hasDocument: !!documentInfo.hasDocument, documentKind: documentInfo.kind, }; }, clickNext(opts = {}) { const scope = opts.scope || 'any'; const skipSubmit = !!opts.skipSubmit; const now = Date.now(); const snapshot = this._navigationSnapshot(); // 同一作用域在短时间内只允许一次推进;即使 SPA 已立即重绘, // 也不能因为快照变化再次点击。章节级和内容级推进彼此独立。 if (now < this._nextLockUntil && scope === this._lastNextScope) return false; const controls = [...new Set(Array.from(document.querySelectorAll( 'button, a, [role="button"], input[type="submit"], input[type="button"], [class*="next"], [class*="Next"]' )).map(el => { const interactive = 'button,a,[role="button"],input[type="submit"],input[type="button"]'; return el.matches?.(interactive) ? el : (el.closest?.(interactive) || el.querySelector?.(interactive) || el); }))].filter(el => this._isUsableControl(el) && !el.closest?.('[disabled],.disabled,.is-disabled,[aria-disabled="true"]') && (!opts.taskPageOnly || !!el.closest?.('.question-common-course-page,[data-navigation-scope="content"],[data-navigation-scope="question"]'))); const usable = (el) => { const kind = this._navigationScope(el); return !!kind && (scope === 'any' || kind === scope); }; const clickOnce = target => { if (!target || !Utils.safeClick(target)) return false; this._lastNextElement = target; this._lastNextSnapshot = snapshot; this._lastNextScope = scope; this._nextLockUntil = now + 1600; return true; }; // 回到当前任务首题只允许题内 footer 的明确“上一题”,绝不借用章节返回按钮。 if (opts.previousOnly) { if (!opts.taskPageOnly || scope !== 'content') return false; const previous = controls.filter(el => el.closest?.('.question-common-course-page,[data-navigation-scope="content"],[data-navigation-scope="question"]') && /^(上一题|上一道|Previous(?:question)?|Prev(?:question)?)$/i.test(this._controlText(el).replace(/\s+/g, ''))); return previous.length === 1 && clickOnce(previous[0]); } // 提交与导航使用不同规则;提交后给平台保存/重绘时间,不在同一次调用连点 Next。 if (!skipSubmit && scope !== 'chapter') { const submitTexts = /^(提交|提交答案|Submit|确认提交)$/i; const submits = controls.filter(btn => submitTexts.test(this._controlText(btn).replace(/\s+/g, '')) && !this._isChapterControl(btn)); if (submits.length > 1) return false; if (submits.length === 1) return clickOnce(submits[0]); } if (opts.submitOnly) return false; const next = controls.find(usable); return clickOnce(next); }, closePopup() { clickIKnow(document); return true; }, // 检测顶部标签页(如 Listening skills / Conversation / Passage / Viewing and understanding 等) getTabs() { // 过滤掉纯数字/题号/选项字母(如 "1." "2." "A."),避免误识别为标签 const isJunkText = (t) => { if (!t || t.length < 2) return true; if (/^\s*\d+\s*[.、.::))]?\s*$/.test(t)) return true; // 纯数字+标点 if (/^[\d\s.、,。::))()]+$/.test(t)) return true; // 全是数字/标点/空白 if (/^[A-D]\s*[.、.::))]?\s*$/i.test(t)) return true; // 选项字母 A./B. return false; }; // 检查 tabs 是否合理:真正的标签页文本应互不相同;同时排除位于侧边/面包屑/菜单内的元素 const _isInsideSidebar = (el) => { if (!el) return true; return !!el.closest( '#unipus-panel, #unipus-settings-overlay, ' + '.ant-tree, [class*="menu"], [class*="Menu"], ' + '[class*="sidebar"], [class*="Sidebar"], [class*="sider"], [class*="Sider"], ' + '[class*="bread"], [class*="Bread"], [class*="crumb"], [class*="Crumb"], ' + '[class*="catalog"], [class*="Catalog"], [class*="toc"], ' + 'aside, nav[class*="bread"]' ); }; const _pickTabText = (el) => { // 优先 title 属性(catch 截断 "..." 的完整文本) const tt = el.getAttribute?.('title') || el.title; const raw = (tt && tt.trim()) ? tt : (el.innerText || el.textContent || ''); return String(raw || '') .replace(/\b(selected|active|current)\b/ig, ' ') .replace(/已选中|当前|选中/g, ' ') .replace(/\s+/g, ' ') .trim(); }; const _mkTabs = (nodes) => { const arr = Array.from(nodes) .map((t, idx) => ({ text: _pickTabText(t), idx, active: t.classList?.contains('active') || t.classList?.contains('current') || t.classList?.contains('pc-header-tab-activity') || t.parentElement?.classList?.contains('active') || t.parentElement?.classList?.contains('current') || t.parentElement?.classList?.contains('pc-header-tab-activity') || t.getAttribute?.('aria-selected') === 'true' || t.parentElement?.getAttribute?.('aria-selected') === 'true' || !!t.querySelector?.('.active, .current'), _el: t, })) .filter(t => this._isUsableControl(t._el) && t.text.length >= 2 && t.text.length <= 60 && !isJunkText(t.text)); // 去重:同一文本只保留第一个(这是唯一必要的过滤:真正的标签页文本各不相同) const seen = new Set(); const dedup = []; for (const t of arr) { if (seen.has(t.text)) continue; seen.add(t.text); dedup.push(t); } return dedup; }; // 策略 0(最高优先):新版 ucontent/ipub 精确选择器 try { const cols = document.querySelectorAll('.pc-header-tabs-container .ant-col'); const tabEls = []; cols.forEach(col => { if (!col.classList.contains('tab')) return; // 实际可点击元素是内部的 .pc-tab-view-container const inner = col.querySelector('.pc-tab-view-container') || col; tabEls.push(inner); }); if (tabEls.length >= 2) { const result = _mkTabs(tabEls); if (result.length >= 2) return result; } } catch (_) {} // 策略 1:常见 CSS 选择器 const tabSelectors = [ '#header ul.TabsBox a.topTab', '#header .TabsBox a.topTab', '.nav-tabs > li > a', '.nav-tabs > li', '.tab-nav > a', '.tab-nav > li > a', '[class*="tab-bar"] a', '[class*="tab-bar"] > div', '[class*="TabBar"] a', '[class*="TabBar"] > div', '[role="tablist"] > [role="tab"]', '.ant-tabs-tab', '.el-tabs__item', // ucontent 标签栏(各种变体) '.pc-header-tab a', '.header-tab a', '[class*="header-tab"] a', '[class*="content-tab"] a', '[class*="courseware-tab"] a', '[class*="courseware-header"] a', '[class*="ppt-tab"] a', '[class*="ppt-header"] a', '.steps-bar a', '.steps-bar > div', ]; for (const sel of tabSelectors) { try { const tabs = document.querySelectorAll(sel); if (tabs.length >= 2) { const result = _mkTabs(tabs); if (result.length >= 2) return result; } } catch (_) {} } // 策略 2:找页面顶部区域(前 250px)的水平排列可点击元素组 try { // 扫描所有可能是标签的元素(包括 Vue/React 渲染的 div/span) const allEls = document.querySelectorAll('a, button, div, span, li, [role="tab"]'); const topElements = []; allEls.forEach(el => { // 排除脚本面板自身 if (el.closest('#unipus-panel, #unipus-settings-overlay')) return; // 排除包含子链接的容器(只要叶子级别) if (el.querySelector('a, button')) return; const rect = el.getBoundingClientRect(); if (rect.top < 250 && rect.top > 0 && rect.height > 20 && rect.height < 60 && rect.width > 80) { const txt = (el.innerText || el.textContent || '').trim(); if (txt.length >= 3 && txt.length <= 40 && !txt.includes('\n') && !isJunkText(txt)) { topElements.push({ el, rect, txt }); } } }); // 按 Y 坐标分组(同一行的元素) if (topElements.length >= 2) { const groups = {}; topElements.forEach(item => { const yKey = Math.round(item.rect.top / 10) * 10; // 10px 容差分组 if (!groups[yKey]) groups[yKey] = []; groups[yKey].push(item); }); // 找满足条件的组:至少 2 个,且总宽度占页面宽度 > 50% const vw = window.innerWidth || document.documentElement.clientWidth; const candidates = Object.values(groups) .filter(g => g.length >= 2 && g.length <= 10) .sort((a, b) => b.length - a.length); for (const group of candidates) { group.sort((a, b) => a.rect.left - b.rect.left); const totalWidth = group.reduce((s, it) => s + it.rect.width, 0); // 标签栏通常占页面宽度的 40% 以上 if (totalWidth > vw * 0.4) { const result = _mkTabs(group.map(it => it.el)); if (result.length >= 2) return result; } } } } catch (_) {} // 策略 3:遍历所有容器,找含 2-10 个同级短文本子元素的(顶部 300px) try { const containers = document.querySelectorAll('div, ul, nav, header, section'); for (const bar of containers) { if (_isInsideSidebar(bar)) continue; const rect = bar.getBoundingClientRect(); if (rect.top > 300 || rect.top < 0 || rect.height > 80 || rect.height < 15) continue; // 容器宽度需占页面 40% 以上 const vw2 = window.innerWidth || document.documentElement.clientWidth; if (rect.width < vw2 * 0.4) continue; const children = bar.querySelectorAll(':scope > a, :scope > li, :scope > div, :scope > button, :scope > span'); if (children.length >= 2 && children.length <= 10) { const allValid = Array.from(children).every(c => { const t = (c.innerText || '').trim(); return t.length >= 3 && t.length <= 40 && !t.includes('\n') && !isJunkText(t); }); if (allValid) { const result = _mkTabs(children); if (result.length >= 2) return result; } } } } catch (_) {} return []; }, clickTab(idx, text = '') { const tabs = this.getTabs(); const matches = text ? tabs.filter(t => t.text === text) : []; const target = text ? (matches.length === 1 ? matches[0] : null) : tabs[idx]; return !!target && this._isUsableControl(target._el) && (target.active || Utils.safeClick(target._el)); }, // 检测 tab 内的子任务(新版 ucontent/ipub: .pc-header-tasks-row .pc-task) getTasks() { const tasks = []; try { const taskEls = document.querySelectorAll('.pc-header-tasks-row .pc-task'); taskEls.forEach((el, idx) => { if (!this._isUsableControl(el)) return; const text = (el.getAttribute('title') || el.title || el.innerText || el.textContent || '').trim(); if (!text) return; const active = el.classList.contains('active') || el.classList.contains('current') || el.classList.contains('pc-header-task-activity') || el.getAttribute('aria-selected') === 'true'; tasks.push({ text, idx, active, _el: el }); }); } catch (_) {} return tasks; }, clickTask(idx, text = '') { const original = document.querySelectorAll('.pc-header-tasks-row .pc-task')[idx]; if (original && !this._isUsableControl(original)) return false; const tasks = this.getTasks(); const matches = text ? tasks.filter(t => t.text === text) : []; const indexed = tasks.find(t => t.idx === idx); const target = text ? (indexed?.text === text ? indexed : matches.length === 1 ? matches[0] : null) : indexed; return !!target && this._isUsableControl(target._el) && (target.active || Utils.safeClick(target._el)); }, // 活动标签先更新、题面后更新是正常 SPA 行为;分别暴露内容指纹和加载态供调度确认。 getNavigationState() { const hiddenCache = new WeakMap(); const hidden = el => { if (!el || el.nodeType !== 1) return false; if (hiddenCache.has(el)) return hiddenCache.get(el); let value = !!(el.hidden || el.getAttribute('aria-hidden') === 'true' || el.matches('#unipus-panel, #unipus-settings-overlay, [data-unipus-helper], .word-tips-wrap-hidden, .qc-abs-word-tips')); try { const style = getComputedStyle(el); value = value || style.display === 'none' || style.visibility === 'hidden'; } catch (_) {} value = value || hidden(el.parentElement); hiddenCache.set(el, value); return value; }; const visible = el => { if (!el || hidden(el)) return false; try { const style = getComputedStyle(el); return el.offsetParent !== null && style.display !== 'none' && style.visibility !== 'hidden'; } catch (_) { return false; } }; const mediaUi = '.audio-control, .audio-control-bar, .timeBar, .startTime, .endTime, .playSpeed, ' + '.video-control, .video-control-bar, .media-controls, .player-controls, .vjs-control-bar, .record-count-down'; const pruneContent = root => { const clean = root.cloneNode(true); const originals = Array.from(root.querySelectorAll('*')); const copies = Array.from(clean.querySelectorAll('*')); originals.forEach((el, index) => { if (hidden(el) || el.matches('script, style, .question-common-course-page, .ant-message, .ant-message-notice, ' + mediaUi) || (el.matches('a, button, input[type="button"], input[type="submit"]') && (this._navigationScope(el) || /^(提交|提交答案|Submit|确认提交)$/i.test(this._controlText(el).replace(/\s+/g, ''))))) { copies[index].remove(); } }); clean.querySelectorAll('textarea, [contenteditable="true"], [role="textbox"]').forEach(el => el.textContent = ''); return clean; }; const contentNodes = Array.from(document.querySelectorAll( '.content-view, .pc-task-content, .pc-content-container, .question-common-abs-reply, ' + '.question-container, .exercise-container, [data-question-id], .document-viewer, .ppt-viewer, ' + 'main, #content, #main, .main-content, .course-content, .exercise, .question, .p-oral-personal-state' )).filter(visible); // 只取最外层题面容器,排除导航与浮动面板,避免倒计时日志造成“内容变化”。 const roots = contentNodes.filter(node => !contentNodes.some(other => other !== node && other.contains(node))); let content = roots.map(root => { const clean = pruneContent(root); const text = Utils.safeText(clean.textContent || ''); const controls = Array.from(clean.querySelectorAll('input, textarea, select, img, iframe, [contenteditable], [data-question-id]')) .map(el => [el.tagName, el.getAttribute('type') || '', el.getAttribute('src') || '', el.getAttribute('placeholder') || '', el.getAttribute('data-question-id') || ''].join(':')); // 音频 UI 的进度/倍速不属于题面,主资源地址仍用于区分真实换题。 // 原生 audio 自身 display:none 时可以由可视控件播放,只排除隐藏祖先。 const resources = Array.from(root.querySelectorAll('audio, video')).filter(el => !(el.tagName === 'AUDIO' ? hidden(el.parentElement) || el.hidden || el.getAttribute('aria-hidden') === 'true' : hidden(el)) && !el.closest('.qc-abs-word-tips, .word-tips-wrap, [class*="recorder"], [class*="Recorder"]') ).map(el => [el.tagName, el.getAttribute('src') || el.querySelector('source[src]')?.getAttribute('src') || el.currentSrc || ''].join(':')); if (!text && !controls.length && !resources.some(resource => !resource.endsWith(':'))) return ''; return `${root.getAttribute('data-question-id') || ''}|${text}|${controls.join('|')}|${resources.join('|')}`; }).filter(Boolean).join('\n'); if (!roots.length && document.body) { const fallback = pruneContent(document.body); fallback.querySelectorAll('#unipus-panel, #unipus-settings-overlay, [data-unipus-helper], script, style, header, nav, aside, [class*="menu"], [class*="sidebar"], .pc-header-tabs-container, .pc-header-tasks-row, .ant-message').forEach(el => el.remove()); content = Utils.safeText(fallback.textContent || ''); } const loadingNodes = Array.from(document.querySelectorAll( '.ant-spin-spinning, .el-loading-mask, .ivu-spin-show, [aria-busy="true"], ' + '.ant-message-notice, .ant-message-custom-content, [role="status"], .loading-text' )).filter(visible); const loading = loadingNodes.some(el => el.matches('.ant-spin-spinning, .el-loading-mask, .ivu-spin-show, [aria-busy="true"]') || /正在加载|加载中|loading(?:\s+data)?/i.test(Utils.safeText(el.innerText || el.textContent || ''))); const pageControls = Array.from(document.querySelectorAll('.question-common-course-page a, .question-common-course-page button, [data-navigation-scope="content"] a, [data-navigation-scope="content"] button')) .filter(visible); const usablePage = el => this._isUsableControl(el) && !el.closest?.('[disabled],.disabled,.is-disabled,[aria-disabled="true"]'); const submitText = el => /^(提交|提交答案|Submit|确认提交)$/i.test(this._controlText(el).replace(/\s+/g, '')); const submits = Array.from(document.querySelectorAll('a,button,input[type="submit"],input[type="button"]')).filter(el => visible(el) && submitText(el)); const completed = roots.some(root => /提交成功|已提交|已交卷|答案已保存|submitted successfully|submission (?:saved|complete)/i.test(pruneContent(root).textContent || '') || [root, ...root.querySelectorAll('.isReview,.question-review,.review-mode,[data-submitted="true"]')].some(el => el.matches('.isReview,.question-review,.review-mode,[data-submitted="true"]') && visible(el))); return { menu: this.scanMenu().filter(item => item.active).map(({ unit, section, micro }) => ({ unit, section, micro })), tabs: this.getTabs().map(({ text, idx, active }) => ({ text, idx, active })), tasks: this.getTasks().map(({ text, idx, active }) => ({ text, idx, active })), route: String(location.href), contentKey: content ? Utils.hashString(content) : '', hasContent: !!content, loading, contentPage: { hasPager: pageControls.length > 0, canNext: pageControls.some(el => usablePage(el) && this._navigationScope(el) === 'content'), canPrevious: pageControls.some(el => usablePage(el) && /^(上一题|上一道|Previous(?:question)?|Prev(?:question)?)$/i.test(this._controlText(el).replace(/\s+/g, ''))), canSubmit: submits.some(usablePage), submitVisible: submits.length > 0, submitInPager: submits.some(el => !!el.closest('.question-common-course-page,[data-navigation-scope="content"]')), completed, }, }; }, // SPA 重绘会替换题目节点;只保存 Element 引用会让后续答案落到 detached 节点。 _questionDescriptor(question) { const result = { questionId: '', elementId: '', text: '' }; if (!question) return result; try { result.questionId = Utils.safeText( question.getAttribute?.('data-question-id') || question.dataset?.questionId || '' ); } catch (_) {} try { result.elementId = Utils.safeText(question.getAttribute?.('id') || question.id || ''); } catch (_) {} try { result.text = Utils.safeText(Solver.extractQuestion(question)).slice(0, 500); } catch (_) {} return result; }, _findQuestionNode(current, descriptor, index, allowIndexFallback = true) { if (!Array.isArray(current) || !current.length) return null; const desc = descriptor || {}; const read = (node) => this._questionDescriptor(node); const uniqueMatch = (field, value) => { if (!value) return null; const matches = current.filter(node => read(node)[field] === value); return matches.length === 1 ? matches[0] : null; }; // 先用稳定的题目 ID;元素 id 仅在唯一时采用,避免多个题共用通用 id。 const byQuestionId = uniqueMatch('questionId', desc.questionId); if (byQuestionId) return byQuestionId; const byElementId = uniqueMatch('elementId', desc.elementId); if (byElementId) return byElementId; if (desc.text) { const textMatches = current.filter(node => read(node).text === desc.text); if (textMatches.length) { // 同题干时按当前顺序取对应项,仍能覆盖节点整体重建的情况。 return textMatches[Math.min(Math.max(index, 0), textMatches.length - 1)]; } } return allowIndexFallback ? (current[index] || null) : null; }, async _refreshQuestionNode(descriptor, index, fallback = null, allowIndexFallback = true) { // 页面切换时题目区可能短暂为空,给 React/Vue 一小段重绘时间。 for (let attempt = 0; attempt < 4 && !stopRequested; attempt++) { let current = []; try { current = Solver.findQuestions(document); } catch (_) {} const found = this._findQuestionNode(current, descriptor, index, allowIndexFallback); if (found) return found; if (attempt < 3) await Utils.sleep(120); } return fallback && fallback.isConnected !== false ? fallback : null; }, // 在当前文档中运行答题 async runAnswer(logger) { if (this._getDiscussionViews().length) { logger('🖐 社区讨论需要本人填写和发布,未调用 AI 作答'); return { ok: false, manualRequired: true, success: 0, failed: 0, skipped: 0, total: 0 }; } const questions = Solver.findQuestions(document); if (!questions.length) return { ok: false, msg: '未发现题目' }; const descriptors = questions.map(q => this._questionDescriptor(q)); logger(`📝 发现 ${questions.length} 道题,开始作答`); let success = 0, failed = 0, skipped = 0; for (let i = 0; i < questions.length; i++) { if (stopRequested) break; const label = `第 ${i+1} 题`; // 每题开始前重新扫描并按稳定标识定位,避免上一题触发 SPA 重绘后引用失效。 const descriptor = descriptors[i]; const matchesIdentity = (node) => { if (!node || node.isConnected === false) return false; const current = this._questionDescriptor(node); if (descriptor.questionId && current.questionId !== descriptor.questionId) return false; if (!descriptor.questionId && descriptor.elementId && current.elementId !== descriptor.elementId) return false; return !descriptor.text || current.text === descriptor.text; }; const q = await this._refreshQuestionNode(descriptor, i, null, false); if (!matchesIdentity(q)) { logger(`${label} 题目节点暂不可用,跳过`); failed++; continue; } let recognized = Solver.recognize(q); const fingerprint = (r) => JSON.stringify([r.question, r.options, r.passage || '', r.audioUrl || '']); const expectedQuestion = fingerprint(recognized); const recognizeCurrent = (node) => { if (!matchesIdentity(node)) return null; try { const result = Solver.recognize(node); return fingerprint(result) === expectedQuestion ? result : null; } catch (_) { return null; } }; if (!recognized.question || recognized.question.length < 3) { logger(`${label} 识别失败,跳过`); failed++; continue; } // 显示题型 + 题干 + 选项预览(便于排查识别错乱) const qPreview = recognized.question.length > 60 ? recognized.question.slice(0, 60) + '...' : recognized.question; const optsPreview = recognized.options.slice(0, 4).map((o, i) => `${String.fromCharCode(65 + i)}.${o.length > 20 ? o.slice(0, 20) + '…' : o}`).join(' / '); logger(`${label} 题型:${Solver.typeName(recognized.type)}(选项 ${recognized.options.length} 个)`); logger(` 题干:${qPreview}`); if (recognized.options.length) logger(` 选项:${optsPreview}`); // 口语/跟读题:直接跳过 if (Solver.isUnsupportedType(recognized.type)) { logger(`⏭️ ${label} 口语题不支持自动作答,跳过`); skipped++; continue; } // 听力/听写:先播完音频 if (recognized.type === Solver.TYPE_LISTENING || recognized.type === Solver.TYPE_DICTATION) { const audio = Solver.findAudioInQuestion(recognized.container); if (audio) { logger(`🎵 ${label} 检测到音频,先播放完毕...`); let played = false; try { played = await Solver.playAudioAndWait(audio); } catch (_) {} if (stopRequested) break; if (!played) { logger(`⚠️ ${label} 音频未播放完成或来源已变化,未继续作答`); failed++; continue; } logger(`🎵 ${label} 音频播放完毕`); } } // 音频等待也可能触发 SPA 重绘;调用 AI 前再确认一次当前题目节点。 const beforeAI = await this._refreshQuestionNode(descriptor, i, null, false); const currentBeforeAI = recognizeCurrent(beforeAI); if (currentBeforeAI) { recognized = currentBeforeAI; } else { logger(`⚠️ ${label} 题目已变化或无法定位,跳过`); failed++; continue; } // 直接调用 AI 答题(已移除付费题库) let answers = null; const ai = await Solver.solveWithAI(recognized, logger, label); await Utils.waitIfPaused(); if (stopRequested) break; if (ai?.length) answers = ai; if (answers && answers.length) { if (stopRequested) break; // AI 请求期间页面可能再次重绘;重新识别当前节点,确保点击/填空作用于新 DOM。 const fresh = await this._refreshQuestionNode(descriptor, i, null, false); const currentAfterAI = recognizeCurrent(fresh); if (currentAfterAI) { recognized = currentAfterAI; } else { logger(`⚠️ ${label} 题目已变化或无法定位,跳过应用旧答案`); failed++; continue; } const isCurrent = () => { const active = this._findQuestionNode(Solver.findQuestions(document), descriptor, i, false); return active === recognized.container && !!recognizeCurrent(active); }; const applied = await Solver.applyAnswers(answers, recognized, isCurrent); if (stopRequested) break; if (applied) { success++; logger(`✅ ${label} 已作答`); } else { // 诊断信息:统计输入框/选项数,定位问题 const c = recognized.container; const visibleFills = c ? Solver.getWritableInputs(c).length : 0; const optCount = recognized.optionNodes?.length || 0; logger(`⚠️ ${label} 答案应用失败(题型=${Solver.typeName(recognized.type)}, 选项=${optCount}, 可见填空=${visibleFills}, 容器=<${c?.tagName?.toLowerCase()||'?'} class="${(c?.className||'').slice(0,40)}">)`); failed++; } } else { logger(`❌ ${label} 无答案,跳过`); failed++; } // 题间延迟:解锁后 1.5s ± 抖动,未解锁 35s ± 抖动(广告模式) if (i < questions.length - 1 && !stopRequested) { const delayMs = Solver.getQuestionDelay(); const sec = Math.round(delayMs / 1000); if (!Store.isUnlocked() && delayMs >= 5000) { logger(`⏳ 广告模式冷却 ${sec}s(输入解锁码可加速至 ~1.5s)`); } await Utils.sleep(delayMs); } } return { ok: true, success, failed, skipped, total: questions.length }; }, // 播放所有媒体(视频/音频)并等待播放完成 async playMedia(logger) { const medias = Player.findAllMedia(document); if (!medias.length) return { ok: false, msg: '无媒体' }; logger(`🎬 发现 ${medias.length} 个媒体元素,开始播放`); let success = 0, failed = 0; // 同一内容页只串行处理可见媒体,避免多个音视频同时解码造成卡顿和缓存峰值。 for (const m of medias) { if (stopRequested) break; const result = await new Promise(resolve => { const source = Player.getMediaSourceKey(m); const setup = Player.applyMediaDefault(m); let done = false; let lastTime = Number(m.currentTime || 0); let stallCount = 0; let playAttempts = 0; let playPending = false; let pausedByUser = false; let checker = null; let timeout = null; const cleanup = () => { if (checker) clearInterval(checker); if (timeout) clearTimeout(timeout); try { m.removeEventListener?.('ended', onEnded); } catch (_) {} try { m.removeEventListener?.('error', onError); } catch (_) {} }; const finish = (ok, reason = '') => { if (done) return; done = true; cleanup(); if (ok) { m.dataset.unipusPlayed = '1'; if (source) m.dataset.unipusPlayedSrc = source; } else { // 停止、换源或离开内容不是坏源,不能因此永久跳过新任务中的媒体。 if (source && !/已停止|内容已切换/.test(reason)) m.dataset.unipusFailedSrc = source; if (Player.getMediaSourceKey(m) === source) { try { m.pause(); } catch (_) {} } if (reason) logger(`⏩ 媒体跳过:${reason}`); } resolve({ ok, reason }); }; const onEnded = () => Player.getMediaSourceKey(m) === source ? finish(true) : finish(false, '内容已切换'); const onError = () => finish(false, '视频源不可用'); if (!setup.ok) { finish(false, setup.reason === 'unsupported' ? '浏览器不支持该媒体格式' : '媒体没有可用源'); return; } try { m.addEventListener?.('ended', onEnded, { once: true }); } catch (_) {} try { m.addEventListener?.('error', onError, { once: true }); } catch (_) {} // 必须先安装停止/换源/超时监测:浏览器的 play() Promise 可能一直 pending。 checker = setInterval(() => { if (done) return; if (stopRequested) return finish(false, '已停止'); if (m.isConnected === false || Player.getMediaSourceKey(m) !== source) return finish(false, '内容已切换'); if (Player.isEnded(m)) return finish(true); if (isPaused) { try { m.pause(); } catch (_) {} pausedByUser = true; stallCount = 0; return; } if (pausedByUser) { pausedByUser = false; if (!playPending) start(true); return; } const cur = Number(m.currentTime || 0); if (Math.abs(cur - lastTime) < 0.1) stallCount++; else { stallCount = 0; lastTime = cur; } if (stallCount >= 10) return finish(false, '媒体长时间无进度'); if (m.paused && !playPending && playAttempts < 2) start(); }, 1500); timeout = setTimeout(() => finish(false, '媒体播放超时'), 30 * 60 * 1000); const start = async (resume = false) => { if (done || stopRequested) return finish(false, '已停止'); if (Player.isEnded(m)) return finish(true); if (isPaused) { pausedByUser = true; return; } if (!resume) playAttempts++; playPending = true; try { const p = m.play(); if (p && typeof p.then === 'function') await p; if (done) return; if (stopRequested) return finish(false, '已停止'); if (m.isConnected === false || Player.getMediaSourceKey(m) !== source) return finish(false, '内容已切换'); if (Player.isEnded(m)) return finish(true); } catch (err) { if (done) return; // pause() 会中止尚未完成的 play();用户暂停不代表媒体源损坏。 if ((isPaused || pausedByUser) && /AbortError/i.test(String(err?.name || ''))) return; const msg = String(err?.message || err || '播放失败'); if (/NotAllowedError/i.test(String(err?.name || '')) && playAttempts < 2) { m.muted = true; playPending = false; return start(); } finish(false, /no supported sources|NotSupportedError|MEDIA_ERR_SRC_NOT_SUPPORTED/i.test(msg) ? '视频源不可用' : msg); } finally { playPending = false; } }; start(); }); if (result.ok) success++; else failed++; if (stopRequested) break; } logger(`${failed || stopRequested ? '⚠️' : '✅'} 媒体处理:完成 ${success}/${medias.length},失败 ${failed}${stopRequested ? ',已停止' : ''}`); return { ok: !failed && !stopRequested, success, failed, total: medias.length, stopped: stopRequested }; }, // ---- Checklist:本人自评先交由人工确认,其余普通复选框按原流程处理 ---- async runChecklist(logger) { const info = this.probe(); if (info.manualRequired && info.hasChecklist) { logger(`☑️ ${info.manualReason}`); return { ok: false, manualRequired: true, manualReason: info.manualReason, complete: false, count: 0, total: info.checklistCount }; } const cbs = Array.from(document.querySelectorAll('input[type="checkbox"]')).filter(cb => !cb.closest('[data-unipus-helper]') && !cb.checked && cb.offsetParent !== null); if (!cbs.length) { logger('☑️ 未发现需要勾选的复选框'); return { ok: true, count: 0, total: 0 }; } logger(`☑️ 发现 ${cbs.length} 个复选框,开始勾选`); let count = 0; for (const cb of cbs) { await Utils.waitIfPaused(); if (stopRequested) break; Utils.safeClick(cb); const wrapper = cb.closest('label, [class*="checkbox-wrapper"], [role="checkbox"]'); if (wrapper && !cb.checked) Utils.safeClick(wrapper); if (cb.checked) count++; await Utils.sleep(180); } return { ok: true, count, total: cbs.length }; }, // 提取下拉框周围的句子(替换下拉为 ___ 让 AI 看到上下文) _dropdownGenericSelector: '[role="combobox"], [aria-haspopup="listbox"], [class*="select-box"], [class*="SelectBox"], [class*="select-input"], [class*="SelectInput"], [class*="dropdown-trigger"], [class*="DropdownTrigger"]', _dropdownPopupSelector: '.ant-select-dropdown, .scoop-select-dropdown, [role="listbox"], [class*="select-dropdown"], [class*="SelectDropdown"], [class*="dropdown-menu"], [class*="DropdownMenu"], [class*="option-list"], [class*="OptionList"]', _dropdownVisible(el, allowDisabled = false) { if (!el || el.isConnected === false || Solver._isOwnPanel(el) || !Solver._isVisible(el)) return false; for (let current = el; current; current = current.parentElement) { const style = el.ownerDocument.defaultView.getComputedStyle(current); if (current.hidden || current.getAttribute('aria-hidden') === 'true' || style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse' || current.classList.contains('ant-dropdown-hidden') || current.classList.contains('ant-select-dropdown-hidden')) return false; if (!allowDisabled && (current.disabled || current.getAttribute('aria-disabled') === 'true' || current.matches('.disabled,.is-disabled,.ant-select-disabled,.ant-select-item-option-disabled,.ant-dropdown-menu-item-disabled'))) return false; } return true; }, _getDropdownControls(root = document) { const selector = 'select, .ant-select, .fe-scoop[data-scoop-index] .ant-dropdown-trigger, ' + this._dropdownGenericSelector; const nodes = Array.from(root.querySelectorAll(selector)); if (root.matches?.(selector)) nodes.unshift(root); const contentRoots = root === document ? Array.from(document.querySelectorAll('main#main-content, #main-content.pc-content-container, .pc-content-container')) .filter(el => this._dropdownVisible(el, true)) : []; const controls = []; for (const node of nodes) { if (!this._dropdownVisible(node) || Solver._isNonAnswerUi(node) || node.closest(this._dropdownPopupSelector) || (contentRoots.length && !contentRoots.some(content => content.contains(node))) || node.closest('aside,nav,.pc-slider-content-menu,.pc-header-tabs-container,.pc-header-tasks-row')) continue; const ant = node.closest('.ant-select'); const scoop = node.closest('.fe-scoop[data-scoop-index]'); const control = ant || (scoop ? scoop.querySelector('.ant-dropdown-trigger') : node); if (!control || !this._dropdownVisible(control) || controls.includes(control) || controls.some(other => other.contains(control))) continue; controls.push(control); } return controls; }, _dropdownKind(node) { return node.tagName === 'SELECT' ? 'native' : node.matches('.ant-select') ? 'ant' : node.closest('.fe-scoop[data-scoop-index]') ? 'scoop' : 'generic'; }, _getDropdownContext(node) { const scoop = node.closest('.fe-scoop[data-scoop-index]'); const sentence = (scoop && (scoop.closest('p') || scoop.closest('.item'))) || node.closest('[data-question-id], .question-item, .question-common-abs-choice, .QuestionBox, p, li, section') || node.parentElement; const layout = node.closest('.layout-container, .question-abs-question, .question-wrap'); const selector = 'select, .ant-select, .fe-scoop[data-scoop-index], ' + this._dropdownGenericSelector; const textOf = root => { const read = el => { if (el.nodeType === 3) return el.nodeValue; if (el.nodeType !== 1 || Solver._isNonAnswerUi(el) || !this._dropdownVisible(el, true) || el.matches('script,style,svg,video,audio,button,[class*="player"],[class*="Player"],.ucomp-audio') || el.matches(this._dropdownPopupSelector)) return ''; if (el.matches(selector)) { const current = el === node || el.contains(node) || node.contains(el); const index = el.getAttribute('data-scoop-index'); return current ? ' [当前空' + (index !== null ? ' ' + (Number(index) + 1) : '') + ':___] ' : ' [其他空] '; } return Array.from(el.childNodes, read).join(el.matches('p,div,li,tr,td,th,br') ? ' ' : ''); }; return Utils.safeText(root ? read(root) : ''); }; const directions = textOf(layout?.querySelector('.layout-direction-container')); const materials = layout ? Array.from(layout.querySelectorAll('.layout-material-container, .layoutBody-material-container')) .filter(el => !el.contains(node) && this._dropdownVisible(el, true)).map(textOf).filter(Boolean) : []; const targetWords = sentence ? Array.from(sentence.querySelectorAll('.words-color, u, [style*="text-decoration"]')) .filter(el => this._dropdownVisible(el, true) && (el.tagName === 'U' || /underline/i.test(el.getAttribute('style') || ''))) .map(el => Utils.safeText(el.textContent)).filter(Boolean) : []; return [directions && 'Directions: ' + directions, materials.length && '共享材料:' + materials.join('\n'), '句子:' + textOf(sentence), scoop && '当前空编号:' + (Number(scoop.getAttribute('data-scoop-index')) + 1), targetWords.length && '划线目标词:' + Array.from(new Set(targetWords)).join('、')].filter(Boolean).join('\n'); }, _dropdownPopup(control, kind, allowGlobal = true) { const visible = el => this._dropdownVisible(el, true); const canonical = el => el?.closest?.(kind === 'scoop' ? '.scoop-select-dropdown' : '.ant-select-dropdown, [role="listbox"]') || el; const ids = [control, ...control.querySelectorAll('[aria-controls],[aria-owns]')] .flatMap(el => (el.getAttribute('aria-controls') || el.getAttribute('aria-owns') || '').split(/\s+/)).filter(Boolean); const linked = Array.from(new Set(ids.map(id => canonical(document.getElementById(id))).filter(visible))); if (linked.length === 1) return linked[0]; const localRoot = kind === 'scoop' ? control.closest('.scoop-select-wrapper, .fe-scoop') : control; const popupSelector = kind === 'scoop' ? '.scoop-select-dropdown' : kind === 'ant' ? '.ant-select-dropdown' : this._dropdownPopupSelector; const local = Array.from(localRoot.querySelectorAll(popupSelector)).filter(visible); const leafs = list => list.filter(el => !list.some(other => other !== el && el.contains(other))); const owned = leafs(local); if (owned.length === 1) return owned[0]; if (!allowGlobal) return null; const all = leafs(Array.from(document.querySelectorAll(popupSelector)).filter(visible)); return all.length === 1 ? all[0] : null; }, _dropdownOptions(control, kind, popup = null) { const placeholder = text => /^(?:请选择|点击选择|请\s*选\s*择|(?:please\s+)?select(?:\s+(?:one|an?\s+option))?[.…]*|choose(?:\s+one)?|[-—]{2,}.*)$/i.test(text); if (kind === 'native') return Array.from(control.options).filter(option => !option.disabled && !option.hidden && option.getAttribute('aria-hidden') !== 'true' && !option.closest('optgroup[disabled],optgroup[hidden]')) .map(option => ({ el: option, text: Utils.safeText(option.textContent), value: option.value })).filter(option => option.text && !placeholder(option.text)); if (!popup) return []; const selector = kind === 'scoop' ? 'li.select-option[role="menuitem"], li.ant-dropdown-menu-item[role="menuitem"]' : kind === 'ant' ? '.ant-select-item-option,[role="option"]' : '[role="option"],[role="menuitem"],[class*="option"],[class*="Option"],li,button'; const candidates = Array.from(popup.querySelectorAll(selector)).filter(el => this._dropdownVisible(el)); return candidates.filter(el => !candidates.some(other => other !== el && el.contains(other))) .map(el => ({ el, text: Utils.safeText(el.innerText || el.textContent) })).filter(option => option.text && !placeholder(option.text)); }, _dropdownMatch(answer, options) { const norm = value => Utils.safeText(value).toLocaleLowerCase(); const raw = Utils.safeText(answer); if (!raw) return null; const text = norm(raw.replace(/^[A-Z]\s*[.、.::))]\s*/i, '')); const exact = options.filter(option => norm(option.text) === text); if (exact.length) return exact.length === 1 ? exact[0] : null; const letter = raw.match(/^([A-Z])\s*[.、.::))]?$/i); const option = letter ? options[letter[1].toUpperCase().charCodeAt(0) - 65] : null; return option && options.filter(other => norm(other.text) === norm(option.text)).length === 1 ? option : null; }, _dropdownValue(control, kind) { if (kind === 'native') { const selected = control.selectedOptions?.[0]; return selected && !selected.disabled && !selected.hidden && selected.getAttribute('aria-hidden') !== 'true' && !selected.closest('optgroup[disabled],optgroup[hidden]') ? Utils.safeText(selected.textContent) : ''; } if (control.classList.contains('empty')) return ''; const display = kind === 'scoop' ? control.querySelector('.user-answer-text') : kind === 'ant' ? control.querySelector('.ant-select-selection-item,[class*="selection-item"]') : control.querySelector('[aria-valuetext], [class*="selection-item"], input'); if (kind === 'ant' && !display) return ''; const text = Utils.safeText((display || control).value || (display || control).getAttribute('aria-valuetext') || (display || control).textContent); return /^(?:请选择|点击选择|select\s+one|choose(?:\s+one)?)$/i.test(text) ? '' : text; }, // 下拉控件共用识别、精确匹配及写入核验;已选有效答案保留为成功。 async runDropdowns(logger) { let success = 0, failed = 0, preserved = 0; const norm = value => Utils.safeText(value).toLocaleLowerCase(); const mediaFor = node => { let url = null; try { url = Solver.extractAudioUrl(node); } catch (_) {} const videoUrl = url && /\.(?:mp4|webm|mov|m4v)(?:[?#]|$)/i.test(url); if (url && !videoUrl) return { audio: url, missing: '' }; const video = videoUrl || Solver._findQuestionMedia(node, 'video').some(el => Solver._declaredAudioUrl(el)); const layout = node.closest('.layout-container, .question-abs-question, .question-wrap'); const directions = layout ? Array.from(layout.querySelectorAll('.layout-direction-container, .layoutBody-direction-container, .abs-direction')) .filter(el => this._dropdownVisible(el, true)).map(el => Utils.safeText(el.textContent)).join(' ') : ''; const listening = /\blisten(?:ing)?\b|\baudio\b|\brecording\b|听录音|听音频/.test(directions.toLowerCase()) || Solver._findQuestionMedia(node, 'audio').length > 0; return { audio: null, missing: video ? 'video' : listening ? 'audio' : '' }; }; const descriptors = this._getDropdownControls().map(node => ({ node, kind: this._dropdownKind(node), href: location.href, context: this._getDropdownContext(node), ...mediaFor(node), questionId: node.closest('[data-question-id]')?.getAttribute('data-question-id') || '', scoopIndex: node.closest('.fe-scoop[data-scoop-index]')?.getAttribute('data-scoop-index') ?? null, attrs: ['id','name','data-id','aria-controls','aria-owns'].map(name => [name,node.getAttribute(name)]).filter(([,value]) => value), })); if (!descriptors.length) { logger(' ❌ 当前页面未找到可用下拉题,请检查题目是否已切换'); return { ok: false, success: 0, failed: 0, skipped: 0, preserved: 0, total: 0, stopped: stopRequested }; } const resolve = desc => { if (stopRequested || location.href !== desc.href) return null; const matches = this._getDropdownControls().filter(node => { if (this._dropdownKind(node) !== desc.kind || this._getDropdownContext(node) !== desc.context || (desc.questionId && node.closest('[data-question-id]')?.getAttribute('data-question-id') !== desc.questionId) || (desc.scoopIndex !== null && node.closest('.fe-scoop[data-scoop-index]')?.getAttribute('data-scoop-index') !== desc.scoopIndex) || !desc.attrs.every(([name,value]) => node.getAttribute(name) === value)) return false; const media = mediaFor(node); return media.audio === desc.audio && media.missing === desc.missing; }); return matches.includes(desc.node) ? desc.node : matches.length === 1 ? matches[0] : null; }; let activePopup = null; const clearPopupOwner = event => { if (!activePopup) return; const target = event.target?.nodeType === 1 ? event.target : event.target?.parentElement; if (target && !activePopup.popup.contains(target) && target.closest('select,.ant-select,.fe-scoop[data-scoop-index],'+this._dropdownGenericSelector)) activePopup = null; }; const popupEvents = ['pointerdown', 'mousedown', 'click']; popupEvents.forEach(name => document.addEventListener(name, clearPopupOwner, true)); const open = async desc => { let control = resolve(desc); if (!control) return null; let popup = this._dropdownPopup(control, desc.kind, false); if (!popup && activePopup?.desc === desc && activePopup.control === control && this._dropdownVisible(activePopup.popup, true) && this._dropdownPopup(control, desc.kind) === activePopup.popup) popup = activePopup.popup; if (!popup) { activePopup = null; await Utils.waitIfPaused(); control = resolve(desc); if (!control || !Utils.safeClick(desc.kind === 'ant' ? control.querySelector('.ant-select-selector') || control : control)) return null; await Utils.sleep(250); control = resolve(desc); if (!control) return null; popup = this._dropdownPopup(control, desc.kind); } activePopup = popup ? { desc, control, popup } : null; return popup ? { control, popup, options: this._dropdownOptions(control, desc.kind, popup) } : null; }; const signature = options => JSON.stringify(options.map(option => [norm(option.text), option.value ?? null])); const close = async () => { activePopup = null; await Utils.waitIfPaused(); if (!stopRequested) Utils.safeClick(document.body); }; const completed = []; try { for (const desc of descriptors) { await Utils.waitIfPaused(); if (stopRequested) break; try { let control = resolve(desc); if (!control) throw new Error('下拉题目已变化或无法唯一定位'); let opened = desc.kind === 'native' ? null : await open(desc); if (desc.kind !== 'native' && !opened) throw new Error('未找到当前下拉浮层'); control = opened?.control || control; const options = opened?.options || this._dropdownOptions(control, desc.kind); if (!options.length) throw new Error('当前下拉没有可用选项'); const current = this._dropdownValue(control, desc.kind); if (current && options.filter(option => norm(option.text) === norm(current)).length === 1 && (desc.kind !== 'native' || options.some(option => option.el === control.selectedOptions?.[0]))) { success++; preserved++; completed.push({ desc, text: current, signature: signature(options), preserved: true }); logger(` ✅ 已保留下拉选值:${current}`); if (opened) await close(); continue; } const audioUrl = desc.audio; if (desc.missing === 'video') throw new Error('当前下拉使用视频材料,暂不能传给音频模型;请观看后人工完成'); if (desc.missing === 'audio') throw new Error('未取得有效听力音频,已保留当前下拉;请确认材料后人工完成'); if (audioUrl && !Store.getAudioApiKey()) throw new Error('检测到听力材料但未配置音频 AI Key,已保留当前下拉;请先配置音频模型'); const ai = await Solver.askAI(desc.context, options.map(option => option.text), audioUrl ? Solver.TYPE_LISTENING : Solver.TYPE_SINGLE, audioUrl, { logger, label: `下拉[${desc.kind}]` }); await Utils.waitIfPaused(); if (stopRequested) break; const answer = Array.isArray(ai?.answers) && ai.answers.length === 1 ? ai.answers[0] : null; const expected = this._dropdownMatch(answer, options); if (!expected) throw new Error('AI 答案未能唯一精确匹配当前选项'); control = resolve(desc); if (!control) throw new Error('下拉题目已变化,未应用旧答案'); if (desc.kind === 'native') { if (signature(this._dropdownOptions(control, desc.kind)) !== signature(options)) throw new Error('下拉选项已变化'); const setter = Object.getOwnPropertyDescriptor(control.constructor.prototype, 'value')?.set; if (setter) setter.call(control, expected.value); else control.value = expected.value; control.dispatchEvent(new Event('input', { bubbles: true })); if (stopRequested) break; control = resolve(desc); if (!control || signature(this._dropdownOptions(control, desc.kind)) !== signature(options)) throw new Error('下拉题目或选项已变化'); control.dispatchEvent(new Event('change', { bubbles: true })); } else { opened = await open(desc); if (!opened || signature(opened.options) !== signature(options)) throw new Error('下拉题目或选项已变化'); await Utils.waitIfPaused(); control = resolve(desc); const popup = control && this._dropdownPopup(control, desc.kind); const freshOptions = popup ? this._dropdownOptions(control, desc.kind, popup) : []; const match = signature(freshOptions) === signature(options) ? this._dropdownMatch(answer, freshOptions) : null; if (stopRequested) break; if (!match || !Utils.safeClick(match.el)) throw new Error('下拉选项未能应用'); } let applied = false; for (let attempt = 0; attempt < 5; attempt++) { await Utils.sleep(100); await Utils.waitIfPaused(); control = resolve(desc); if (!control || stopRequested) break; if (norm(this._dropdownValue(control, desc.kind)) === norm(expected.text)) { applied = true; break; } } if (stopRequested) break; if (!applied) throw new Error('下拉选值未生效或题目已变化'); success++; completed.push({ desc, text: expected.text, signature: signature(options), preserved: false }); logger(` ✅ 下拉填入:${expected.text}`); if (desc.kind !== 'native') await close(); } catch (error) { if (stopRequested) break; failed++; logger(` ❌ 下拉未完成:${error.message || error}`); if (desc.kind !== 'native') await close(); } } if (!stopRequested) { for (const entry of completed) { await Utils.waitIfPaused(); if (stopRequested) break; const control = resolve(entry.desc); if (control && norm(this._dropdownValue(control, entry.desc.kind)) === norm(entry.text) && (entry.desc.kind !== 'native' || signature(this._dropdownOptions(control, entry.desc.kind)) === entry.signature)) continue; success--; failed++; if (entry.preserved) preserved--; logger(' ❌ 下拉最终复查未通过:先前选值已回退或题目已变化'); } } } finally { popupEvents.forEach(name => document.removeEventListener(name, clearPopupOwner, true)); } return { ok: !failed && !stopRequested, success, failed, skipped: 0, preserved, total: descriptors.length, stopped: stopRequested }; }, // ---- 讨论 / 开放写作:用 AI 生成回答填入 textarea(不自动提交) ---- async runDiscussion(logger) { if (this._getDiscussionViews().length) { logger('🖐 社区讨论需要本人填写和发布,未自动填写'); return { ok: false, manualRequired: true, success: 0, failed: 0, total: 0 }; } const tas = Array.from(document.querySelectorAll('textarea')).filter(t => !t.closest('[data-unipus-helper]') && !Solver._isNonAnswerUi(t) && t.offsetParent !== null && !t.value); if (!tas.length) return { ok: true, success: 0, total: 0 }; let success = 0, failed = 0; for (const ta of tas) { if (stopRequested) break; // 提取 prompt:从 textarea 向上找包含问题文字的容器 let prompt = ''; let parent = ta.parentElement; for (let depth = 0; depth < 6 && parent; depth++) { const t = (parent.textContent || '').trim(); if (t.length > 30 && t.length < 1500) { prompt = t; break; } parent = parent.parentElement; } // 清理 prompt 里的占位符 / textarea 自带文字 prompt = prompt.replace(/请输入|请\s*输\s*入/g, '').replace(/\s+/g, ' ').trim().slice(0, 800); if (!prompt) prompt = '请就上述话题用英语写一段简短回复(80-120 词)'; try { const ai = await Solver.askAI( `请就下列话题写一段 80-120 词的英语回复,自然、简洁、切题:\n${prompt}`, [], Solver.TYPE_WRITING, null, { logger, label: `讨论填答` }); if (stopRequested) break; const answer = Array.isArray(ai?.answers) ? String(ai.answers[0] ?? '').trim() : ''; if (answer) { if (await Solver.fillBlanks([answer], ta)) { success++; logger(` ✏️ 已填入 ${answer.length} 字(不会自动提交,请人工检查)`); } else { failed++; logger(' ❌ 找到作文框但写入失败'); } } else { failed++; } } catch (e) { failed++; logger(` ❌ AI 调用失败:${e}`); } await Utils.sleep(500); } return { ok: true, success, failed, total: tas.length }; }, _dragNodeDescriptor(node) { const attrs = {}; for (const name of ['data-drag-id', 'data-draggable-id', 'data-rbd-draggable-id', 'data-drop-id', 'data-droppable-id', 'data-rbd-droppable-id', 'data-id', 'id']) { const value = node?.getAttribute?.(name); if (value) attrs[name] = value; } return { attrs, text: Utils.safeText(node?.innerText || node?.textContent) }; }, _findDragNode(nodes, descriptor, zone = false, answerText = '') { const live = nodes.filter(node => node.isConnected !== false); const textMatches = (node) => { const text = Utils.safeText(node.innerText || node.textContent); if (!zone) return text === descriptor.text; if (text === descriptor.text) return true; // 放置后文本可能多出答案,仍只接受原标签与本次答案组成的区域。 return !!answerText && [descriptor.text + ' ' + answerText, descriptor.text + answerText, answerText + ' ' + descriptor.text, answerText + descriptor.text] .map(Utils.safeText).includes(text); }; for (const [name, value] of Object.entries(descriptor.attrs)) { const found = live.filter(node => node.getAttribute(name) === value && textMatches(node)); if (found.length === 1) return found[0]; if (found.length > 1) return null; } if (Object.keys(descriptor.attrs).length) return null; const found = live.filter(textMatches); return found.length === 1 ? found[0] : null; }, _dragContextSignature() { // 忽略可移动项/放置区,保留题面;拖拽本身改变 DOM 顺序时身份仍应相同。 const body = document.body; const clone = body.cloneNode(true); const originalNodes = Array.from(body.querySelectorAll('*')); const cloneNodes = Array.from(clone.querySelectorAll('*')); const movable = new Set([...Solver.findDraggables(document), ...Solver.findDropZones(document)]); originalNodes.forEach((node, index) => { if (movable.has(node) || !Solver._isDragVisible(node)) cloneNodes[index]?.remove(); }); clone.querySelectorAll('script, style, [data-unipus-helper], #unipus-panel, #unipus-settings-overlay, nav, aside, header, ' + '.pc-header-tabs-container, .pc-header-tasks-row, .pc-slider-content-menu, .audio-control, .audio-control-bar, .timeBar, .startTime, .endTime, .playSpeed, ' + '.video-control, .video-control-bar, .media-controls, .player-controls, .vjs-control-bar, .record-count-down').forEach(node => node.remove()); return location.href + '\n' + Utils.safeText(clone.innerText || clone.textContent); }, _dragZoneLabel(zone, zones, drags, index) { const own = Utils.safeText(zone.getAttribute('aria-label') || zone.getAttribute('title') || zone.innerText || zone.textContent); if (own && !/^(?:drop(?: here)?|drag(?: here)?|请拖入|拖(?:动|拽)?(?:到)?(?:此处|这里)|[((]?空位[))]?)$/i.test(own)) return own; let parent = zone.parentElement; for (let depth = 0; depth < 3 && parent && parent !== document.body; depth++, parent = parent.parentElement) { if (zones.some(other => other !== zone && parent.contains(other)) || drags.some(drag => !zone.contains(drag) && parent.contains(drag))) break; const text = Utils.safeText(parent.innerText || parent.textContent); if (text && text !== own && text.length <= 600) return text; } return own || `(空位 ${index + 1})`; }, _dragAudioMaterial(nodes) { const boundary = '.layout-container,.question-abs-question,.question-wrap,.question-common-abs-reply,[data-question-id]'; const scopes = []; for (let scope = nodes[0]?.closest(boundary); scope; scope = scope.parentElement?.closest(boundary)) { if (nodes.every(node => scope.contains(node))) scopes.push(scope); } // 不使用 extractAudioUrl 的 document 兜底;共享音频也必须在明确共同题组中。 let materialScope = scopes[scopes.length - 1] || null; let audios = [], videos = []; for (const scope of scopes) { const owned = el => { if (!Solver._isQuestionMediaCandidate(el)) return false; const owner = el.closest(boundary); return !owner || owner === scope || nodes.some(node => owner.contains(node)); }; const currentAudios = Array.from(scope.querySelectorAll('audio')).filter(owned); const currentVideos = Array.from(scope.querySelectorAll('video')).filter(owned); if (currentAudios.length || currentVideos.length) { materialScope = scope; audios = currentAudios; videos = currentVideos; break; } } let hintRoot = materialScope; if (!hintRoot) { hintRoot = nodes[0]?.parentElement || document.body; while (hintRoot && !nodes.every(node => hintRoot.contains(node))) hintRoot = hintRoot.parentElement; if (hintRoot && hintRoot !== document.body) hintRoot = hintRoot.parentElement || hintRoot; } const clean = (hintRoot || document.body).cloneNode(true); const original = Array.from((hintRoot || document.body).querySelectorAll('*')); const copies = Array.from(clean.querySelectorAll('*')); original.forEach((el, index) => { if (!Solver._isQuestionMediaCandidate(el) || nodes.includes(el) || (el.matches(boundary) && !nodes.some(node => el.contains(node)))) copies[index]?.remove(); }); clean.querySelectorAll('nav,aside,header,script,style,#unipus-panel,#unipus-settings-overlay,[data-unipus-helper],.pc-header-tabs-container,.pc-header-tasks-row,.pc-slider-content-menu').forEach(el => el.remove()); const listening = /\blisten(?:ing)?\b|\bhear\b|听力|听录音|听音频|根据(?:所听|录音|音频)|\baudio\s+(?:clip|recording|material)\b/i.test(clean.textContent || ''); const urls = [...new Set(audios.map(audio => Solver._declaredAudioUrl(audio)).filter(url => url && !/\.(?:mp4|webm|mov|m4v)(?:[?#]|$)/i.test(url)))]; const videoUrls = videos.map(video => Solver._declaredAudioUrl(video) || '').filter(Boolean); const audioUrl = urls.length === 1 ? urls[0] : null; let error = ''; if (urls.length > 1) error = '当前拖动题组有多个音频材料,无法确认归属,请人工处理'; else if (!audioUrl && (videos.length || videoUrls.length)) error = '当前拖动题只有视频材料,缺少独立音频,请人工处理'; else if (!audioUrl && (listening || audios.length)) error = '当前听力拖动题未找到可确认归属的音频,请人工处理'; else if (audioUrl && !Store.getAudioApiKey()) error = '当前听力拖动题未配置音频 AI Key,请先配置音频模型'; return { ok: !error, audioUrl, error, signature: JSON.stringify([audioUrl, urls, videoUrls, listening]) }; }, // ---- 匹配 / 连线 / 拖词题:AI 给出配对,模拟拖拽到放置区 ---- async runMatching(logger) { await Utils.waitIfPaused(); if (stopRequested) return { ok: true, success: 0, failed: 0, total: 0, stopped: true }; const det = Solver.detectDragQuestion(document); if (!det.isMatch) return { ok: false, success: 0, failed: 0, total: 0, msg: '当前已不是可处理的匹配题' }; const drags = Solver.findDraggables(document); const zones = Solver.findDropZones(document); if (drags.length < 1 || zones.length < 1) { logger(' ⚠️ 匹配题未找到足够的拖拽项/放置区,跳过'); return { ok: false, success: 0, failed: 0, total: 0 }; } // 左侧 = 放置区所在的题面项(固定不动);右侧 = 可拖动的待选项 const leftItems = zones.map((zone, index) => this._dragZoneLabel(zone, zones, drags, index)); const rightItems = drags.map(d => Utils.safeText(d.innerText || d.textContent)); const dragDescriptors = drags.map(node => this._dragNodeDescriptor(node)); const zoneDescriptors = zones.map(node => this._dragNodeDescriptor(node)); const contextSignature = this._dragContextSignature(); const question = contextSignature.slice(String(location.href).length + 1, String(location.href).length + 6001); const audioMaterial = this._dragAudioMaterial([...drags, ...zones]); if (!audioMaterial.ok) { logger(` ⚠️ ${audioMaterial.error}`); return { ok: false, success: 0, failed: zones.length, total: zones.length }; } const sameAudio = () => this._dragAudioMaterial([...Solver.findDraggables(document), ...Solver.findDropZones(document)]).signature === audioMaterial.signature; let pairs; try { const ai = await Solver.askAI(question, [], Solver.TYPE_MATCH, audioMaterial.audioUrl, { logger, label: '匹配题', leftItems, rightItems }); if (stopRequested) return { ok: true, success: 0, failed: 0, total: zones.length, stopped: true }; pairs = ai?.answers || []; } catch (e) { logger(` ❌ AI 调用失败:${e}`); return { ok: true, success: 0, failed: 1, total: zones.length }; } if (!pairs.length) { logger(' ❌ AI 未返回配对'); return { ok: true, success: 0, failed: 1, total: zones.length }; } if (!Array.isArray(pairs)) pairs = []; const parsed = pairs.map(p => String(p).trim().match(/^(\d+)\s*[-—–>→::]\s*([A-Za-z])$/)); const leftIndices = parsed.map(m => m ? Number(m[1]) - 1 : -1); const rightIndices = parsed.map(m => m ? m[2].toUpperCase().charCodeAt(0) - 65 : -1); if (parsed.length !== zones.length || parsed.some(m => !m) || new Set(leftIndices).size !== zones.length || new Set(rightIndices).size !== zones.length || leftIndices.some(i => i < 0 || i >= zones.length) || rightIndices.some(i => i < 0 || i >= drags.length)) { logger(' ❌ AI 配对不是全部放置区的完整一对一结果,未执行拖拽'); return { ok: true, success: 0, failed: 1, total: zones.length }; } if (this._dragContextSignature() !== contextSignature || !sameAudio() || zoneDescriptors.some(desc => !this._findDragNode(Solver.findDropZones(document), desc, true)) || dragDescriptors.some(desc => !this._findDragNode(Solver.findDraggables(document), desc))) { logger(' ⚠️ 匹配题或项目身份已变化,未应用旧配对'); return { ok: true, success: 0, failed: zones.length, total: zones.length }; } logger(` 🔗 AI 配对:${pairs.join(' , ')}`); let success = 0, failed = 0; // 解析「序号-字母」:左序号(1-based) → 右字母(A-based) for (const p of pairs) { await Utils.waitIfPaused(); if (stopRequested) break; const m = String(p).match(/(\d+)\s*[-—–>→::]\s*([A-Za-z])/); if (!m) { failed++; continue; } const li = parseInt(m[1], 10) - 1; const ri = m[2].toUpperCase().charCodeAt(0) - 65; if (!zoneDescriptors[li] || !dragDescriptors[ri]) { failed++; logger(` ❌ 配对越界:${p}`); continue; } if (this._dragContextSignature() !== contextSignature || !sameAudio()) { failed++; logger(' ⚠️ 匹配题题面或音频已变化,停止应用旧答案'); break; } const zone = this._findDragNode(Solver.findDropZones(document), zoneDescriptors[li], true); const drag = this._findDragNode(Solver.findDraggables(document), dragDescriptors[ri]); if (!zone || !drag) { failed++; logger(` ❌ 配对节点无法唯一定位:${p}`); break; } const beforeText = Utils.safeText(zone.innerText || zone.textContent); const verify = () => { if (this._dragContextSignature() !== contextSignature || !sameAudio()) return false; const currentZone = this._findDragNode(Solver.findDropZones(document), zoneDescriptors[li], true, rightItems[ri]); if (!currentZone) return false; const currentDrag = this._findDragNode(Solver.findDraggables(document), dragDescriptors[ri]); if (currentDrag && currentZone.contains(currentDrag) && currentDrag !== currentZone) return true; const afterText = Utils.safeText(currentZone.innerText || currentZone.textContent); return afterText !== beforeText && afterText.includes(rightItems[ri]); }; const ok = await Utils.dragTo(drag, zone, verify); if (ok) { success++; logger(` ✅ 拖入:${rightItems[ri]} → ${leftItems[li]}`); } else { failed++; logger(` ❌ 拖拽失败:${p}`); break; } await Utils.sleep(500); } // 后一次拖动可能撤销前一次落位;最终必须逐个确认,不能只累计事件成功数。 if (!stopRequested && this._dragContextSignature() === contextSignature && sameAudio()) { success = leftIndices.reduce((count, li, index) => { const ri = rightIndices[index]; const zone = this._findDragNode(Solver.findDropZones(document), zoneDescriptors[li], true, rightItems[ri]); const drag = this._findDragNode(Solver.findDraggables(document), dragDescriptors[ri]); const text = Utils.safeText(zone?.innerText || zone?.textContent || ''); const exact = [zoneDescriptors[li].text + ' ' + rightItems[ri], zoneDescriptors[li].text + rightItems[ri], rightItems[ri] + ' ' + zoneDescriptors[li].text, rightItems[ri] + zoneDescriptors[li].text].map(Utils.safeText); return count + Number(!!zone && ((!!drag && zone !== drag && zone.contains(drag)) || exact.includes(text))); }, 0); } else success = 0; failed = Math.max(failed, zones.length - success); return { ok: true, success, failed, total: zones.length, stopped: stopRequested }; }, // ---- 排序题:AI 给出正确顺序,把乱序项依次拖到目标位置 ---- async runOrdering(logger) { await Utils.waitIfPaused(); if (stopRequested) return { ok: true, success: 0, failed: 0, total: 0, stopped: true }; const det = Solver.detectDragQuestion(document); if (!det.isOrder) return { ok: false, success: 0, failed: 0, total: 0, msg: '当前已不是可处理的排序题' }; const items = Solver.findDraggables(document); if (items.length < 2) { logger(' ⚠️ 排序题未找到足够的可拖动项,跳过'); return { ok: false, success: 0, failed: 0, total: 0 }; } const orderItems = items.map(d => Utils.safeText(d.innerText || d.textContent)); const descriptors = items.map(node => this._dragNodeDescriptor(node)); const contextSignature = this._dragContextSignature(); const question = contextSignature.slice(String(location.href).length + 1, String(location.href).length + 6001); const audioMaterial = this._dragAudioMaterial(items); if (!audioMaterial.ok) { logger(` ⚠️ ${audioMaterial.error}`); return { ok: false, success: 0, failed: items.length, total: items.length }; } const sameAudio = () => this._dragAudioMaterial(Solver.findDraggables(document)).signature === audioMaterial.signature; let seq; try { const ai = await Solver.askAI(question, [], Solver.TYPE_ORDER, audioMaterial.audioUrl, { logger, label: '排序题', orderItems }); if (stopRequested) return { ok: true, success: 0, failed: 0, total: items.length, stopped: true }; // AI 返回单条 "3 || 1 || 4 || 2" 或多条,合并后抽数字 const raw = (ai?.answers || []).join(' '); seq = /^\s*\d+(?:(?:\s*(?:\|\||[,,;;→>])\s*|\s+)\d+)*\s*$/.test(raw) ? (raw.match(/\d+/g) || []).map(n => parseInt(n, 10) - 1) : []; } catch (e) { logger(` ❌ AI 调用失败:${e}`); return { ok: true, success: 0, failed: 1, total: items.length }; } // 去重并校验完整性 if (seq.length !== items.length || new Set(seq).size !== items.length || seq.some(n => n < 0 || n >= items.length)) { logger(' ❌ AI 顺序不是全部项目的完整排列,未执行拖拽'); return { ok: true, success: 0, failed: 1, total: items.length }; } logger(` 🔢 AI 目标顺序(原编号):${seq.map(i => i + 1).join(' → ')}`); // 选择排序:第 pos 位应放 seq[pos]。每步重新查询当前 DOM 顺序后拖动。 let failed = 0; const currentOrder = () => { if (this._dragContextSignature() !== contextSignature || !sameAudio()) return null; const current = Solver.findDraggables(document); if (current.length !== items.length) return null; const resolved = descriptors.map(desc => this._findDragNode(current, desc)); return resolved.every(Boolean) && new Set(resolved).size === items.length ? { current, resolved } : null; }; for (let pos = 0; pos < seq.length; pos++) { await Utils.waitIfPaused(); if (stopRequested) break; const state = currentOrder(); if (!state) { failed++; logger(' ⚠️ 排序题变化或项目身份不明确,停止后续拖拽'); break; } const cur = state.current; // 用文本定位目标项(拖拽后 DOM 节点会重排/重建,索引不可靠) const wantText = orderItems[seq[pos]]; const realFrom = cur.indexOf(state.resolved[seq[pos]]); if (realFrom === pos) continue; const verify = () => { const fresh = currentOrder(); return !!fresh && seq.slice(0, pos + 1).every((original, index) => fresh.current[index] === fresh.resolved[original]); }; const ok = await Utils.dragTo(cur[realFrom], cur[pos], verify); if (ok) { logger(` ✅ 第 ${pos + 1} 位 → ${wantText.slice(0, 30)}`); } else { failed++; break; } await Utils.sleep(600); } const final = !stopRequested && currentOrder(); const complete = !!final && seq.every((original, index) => final.current[index] === final.resolved[original]); return { ok: true, success: complete ? items.length : 0, failed: complete ? 0 : Math.max(failed, 1), total: items.length, stopped: stopRequested }; }, }; const LONG_RUNNING_RPC_COMMANDS = new Set([ 'ANSWER', 'PLAY_MEDIA', 'CHECKLIST', 'DROPDOWNS', 'DISCUSSION', 'MATCHING', 'ORDERING', 'DOCUMENT', ]); // ============== iframe 子页面 setup(薄包装:把 postMessage 转发给 Worker) ============== function setupIframeChild() { let activeWork = 0; const parentOrigin = (() => { try { if (document.referrer) return new URL(document.referrer).origin; const ancestor = window.location.ancestorOrigins?.[0]; if (ancestor) return ancestor; // 同源 about:blank / 隐藏 referrer 的场景仍可读取父窗口 origin。 try { const direct = window.parent?.location?.origin; if (direct && direct !== 'null') return direct; } catch (_) {} // 最后仅使用当前 origin,绝不退回 wildcard targetOrigin。 return window.location.origin && window.location.origin !== 'null' ? window.location.origin : ''; } catch (_) { return ''; } })(); const isTrustedParentMessage = (e) => { if (!e || e.source !== window.parent) return false; if (e.origin === 'null') return false; if (parentOrigin && e.origin !== parentOrigin) return false; return /(^|\.)unipus\.cn$/i.test(String(e.origin).replace(/^https?:\/\//, '').split(':')[0]); }; function postToParent(payload) { if (!parentOrigin) return; try { window.parent.postMessage(payload, parentOrigin); } catch (_) {} } function scanAndSendMenu() { const list = Worker.scanMenu(); if (!list.length) return false; postToParent({ type: 'UNIPUS_MENU_LIST', payload: list }); return true; } // 处理父页面消息 window.addEventListener('message', async (e) => { if (!e.data || typeof e.data !== 'object') return; const { type, cmd, path, text, unit, section, requestId } = e.data; if (type !== 'UNIPUS_CMD') return; if (!isTrustedParentMessage(e)) return; try { Store.importRuntimeConfig(e.data.runtimeConfig); } catch (_) {} const reply = (data) => postToParent({ type: 'UNIPUS_RESULT', requestId, ...data }); const logger = (msg) => postToParent({ type: 'UNIPUS_LOG', msg }); if (cmd === 'STOP') { stopRequested = true; isPaused = false; reply({ ok: true }); return; } if (cmd === 'SET_PAUSED') { isPaused = !!e.data.paused; reply({ ok: true, paused: isPaused }); return; } if (cmd === 'RESET') { if (activeWork > 0) { reply({ ok: false, busy: true, err: '旧任务仍在停止中' }); return; } stopRequested = false; isPaused = false; reply({ ok: true }); return; } const isLongRunning = LONG_RUNNING_RPC_COMMANDS.has(cmd); if (isLongRunning && activeWork > 0) { reply({ ok: false, busy: true, err: '已有任务正在执行' }); return; } if (isLongRunning) activeWork++; try { if (cmd === 'SCAN') { const found = scanAndSendMenu(); reply({ ok: found }); } else if (cmd === 'CLICK') { reply({ ok: Worker.clickByPath(path, text, unit, section) }); } else if (cmd === 'PROBE') { reply({ ok: true, info: Worker.probe() }); } else if (cmd === 'ANSWER') { const r = await Worker.runAnswer(logger); reply({ ok: true, result: r }); } else if (cmd === 'PLAY_MEDIA') { const r = await Worker.playMedia(logger); reply({ ok: true, result: r }); } else if (cmd === 'CHECKLIST') { const r = await Worker.runChecklist(logger); reply({ ok: true, result: r }); } else if (cmd === 'DROPDOWNS') { const r = await Worker.runDropdowns(logger); reply({ ok: true, result: r }); } else if (cmd === 'DISCUSSION') { const r = await Worker.runDiscussion(logger); reply({ ok: true, result: r }); } else if (cmd === 'MATCHING') { const r = await Worker.runMatching(logger); reply({ ok: true, result: r }); } else if (cmd === 'ORDERING') { const r = await Worker.runOrdering(logger); reply({ ok: true, result: r }); } else if (cmd === 'DOCUMENT') { const r = await Worker.runDocument(logger); reply({ ok: true, result: r }); } else if (cmd === 'CLICK_NEXT') { reply({ ok: Worker.clickNext(e.data.opts || {}) }); } else if (cmd === 'GET_TABS') { const tabs = Worker.getTabs().map(t => ({ text: t.text, idx: t.idx, active: t.active })); reply({ ok: true, tabs }); } else if (cmd === 'CLICK_TAB') { reply({ ok: Worker.clickTab(e.data.tabIdx || 0, e.data.tabText || '') }); } else if (cmd === 'GET_TASKS') { const tasks = Worker.getTasks().map(t => ({ text: t.text, idx: t.idx, active: t.active })); reply({ ok: true, tasks }); } else if (cmd === 'GET_NAV_STATE') { reply({ ok: true, state: Worker.getNavigationState() }); } else if (cmd === 'CLICK_TASK') { reply({ ok: Worker.clickTask(e.data.taskIdx || 0, e.data.taskText || '') }); } else if (cmd === 'CLOSE_POPUP') { Worker.closePopup(); reply({ ok: true }); } else { reply({ ok: false, err: 'unknown cmd' }); } } catch (err) { reply({ ok: false, err: String(err) }); } finally { if (isLongRunning) activeWork--; } }); // 定时自动关闭弹窗 setInterval(clickIKnow, 1500); // 初次扫描(带重试) function initScan() { if (scanAndSendMenu()) return; let count = 0; const tm = setInterval(() => { count++; if (count > 30 || scanAndSendMenu()) clearInterval(tm); }, 1500); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => setTimeout(initScan, 800)); } else { setTimeout(initScan, 800); } } // ============== 主页面(ipub/uai)逻辑 ============== // ---- 菜单缓存 ---- let _menuListCache = []; let _menuDetectionDone = false; // ---- IframeRPC:父页面与 iframe 通信 ---- const IframeRPC = { _pendingRequests: new Map(), _reqId: 0, _logHandlers: [], _channelElement: null, _channelWindow: null, _channelOrigin: '', _channelEpoch: 0, _channelWatch: null, _localActiveWork: 0, _getIframeElement() { try { // 先用精确选择器 let iframe = document.getElementById('ipublish-pc-book-easy-iframe') || document.querySelector('iframe.ipublish-pc-iframe-container') || document.querySelector('iframe[src*="ucontent.unipus.cn"]') || document.querySelector('iframe[src*="pc.html"]'); // 宽松兜底:遍历所有 iframe,找到载入了 ucontent 的 if (!iframe) { for (const f of document.querySelectorAll('iframe')) { try { const h = f.contentWindow?.location?.hostname; if (h && h.includes('ucontent.unipus.cn')) { iframe = f; break; } } catch (_) {} // src 属性兜底 if ((f.src || '').includes('ucontent.unipus.cn')) { iframe = f; break; } } } return iframe || null; } catch (_) { return null; } }, getIframeWin() { try { return this._getIframeElement()?.contentWindow || null; } catch (_) { return null; } }, _getIframeOrigin(iframe = this._getIframeElement()) { try { const isAllowed = (origin) => { try { const host = new URL(origin).hostname; return !!origin && origin !== 'null' && /(^|\.)unipus\.cn$/i.test(host); } catch (_) { return false; } }; const raw = iframe?.src || iframe?.getAttribute?.('src') || ''; if (raw && raw !== 'about:blank') { const origin = new URL(raw, location.href).origin; if (isAllowed(origin)) return origin; } try { const direct = iframe?.contentWindow?.location?.origin; if (isAllowed(direct)) return direct; } catch (_) {} return ''; } catch (_) { return ''; } }, _clearRemoteMenu() { if (!_menuListCache.length && !_menuDetectionDone) return; _menuListCache = []; _menuDetectionDone = false; window.dispatchEvent(new CustomEvent('UNIPUS_MENU_READY', { detail: [] })); }, _settlePending(reason = 'iframe channel changed') { for (const pending of [...this._pendingRequests.values()]) { if (pending.longRunning) { try { pending.source.postMessage({ type: 'UNIPUS_CMD', cmd: 'STOP', requestId: ++this._reqId, }, pending.origin); } catch (_) {} } pending.settle({ ok: false, channelChanged: true, err: reason }); } }, _stopChannelWatchIfIdle() { if (!this._pendingRequests.size && this._channelWatch) { clearInterval(this._channelWatch); this._channelWatch = null; } }, _startChannelWatch() { if (this._channelWatch) return; this._channelWatch = setInterval(() => { this._syncChannel('iframe channel changed'); this._stopChannelWatchIfIdle(); }, 500); }, _syncChannel(reason = 'iframe channel changed') { const iframe = this._getIframeElement(); let iw = null; try { iw = iframe?.contentWindow || null; } catch (_) {} const origin = iframe ? this._getIframeOrigin(iframe) : ''; const changed = iframe !== this._channelElement || iw !== this._channelWindow || origin !== this._channelOrigin; if (!changed) return { iframe, iw, origin, epoch: this._channelEpoch }; if (this._channelElement || this._channelWindow) { this._settlePending(reason); this._clearRemoteMenu(); } if (this._channelElement && this._channelLoadHandler) { try { this._channelElement.removeEventListener('load', this._channelLoadHandler); } catch (_) {} } this._channelElement = iframe; this._channelWindow = iw; this._channelOrigin = origin; this._channelEpoch++; if (iframe) { this._channelLoadHandler = () => { if (iframe !== this._channelElement) return; this._settlePending('iframe reloaded'); this._clearRemoteMenu(); try { this._channelWindow = iframe.contentWindow || null; } catch (_) { this._channelWindow = null; } this._channelOrigin = this._getIframeOrigin(iframe); this._channelEpoch++; this._stopChannelWatchIfIdle(); }; try { iframe.addEventListener('load', this._channelLoadHandler); } catch (_) {} } else { this._channelLoadHandler = null; } this._stopChannelWatchIfIdle(); return { iframe, iw, origin, epoch: this._channelEpoch }; }, _isTrustedMessage(e) { const channel = this._syncChannel(); if (!channel.iw || e?.source !== channel.iw || !e?.origin || e.origin === 'null') return false; if (channel.origin && e.origin !== channel.origin) return false; return /(^|\.)unipus\.cn$/i.test(String(e.origin).replace(/^https?:\/\//, '').split(':')[0]); }, init() { this._syncChannel(); window.addEventListener('message', (e) => { if (!e.data || typeof e.data !== 'object') return; if (!this._isTrustedMessage(e)) return; const { type, requestId } = e.data; if (type === 'UNIPUS_MENU_LIST') { const payload = e.data.payload; if (Array.isArray(payload) && payload.length) { _menuListCache = payload; _menuDetectionDone = true; window.dispatchEvent(new CustomEvent('UNIPUS_MENU_READY', { detail: payload })); } } else if (type === 'UNIPUS_RESULT') { const pending = this._pendingRequests.get(requestId); if (pending && pending.source === e.source && pending.origin === e.origin && pending.epoch === this._channelEpoch) { pending.settle(e.data); } } else if (type === 'UNIPUS_LOG') { this._logHandlers.forEach(h => { try { h(e.data.msg); } catch (_) {} }); } }); }, onLog(handler) { this._logHandlers.push(handler); }, async sendCmd(cmd, params = {}, { timeout = 60000 } = {}) { const channel = this._syncChannel(); const iw = channel.iw; // 普通版和独立 ucontent 没有课程 iframe 时,才在当前页面本地执行。 if (!iw) { if (!IS_IFRAME && (IS_LEGACY || IS_UCONTENT)) return this._runLocal(cmd, params); return { ok: false, err: 'course iframe unavailable' }; } const targetOrigin = channel.origin; if (!targetOrigin) { return { ok: false, err: 'iframe origin unavailable' }; } const requestId = ++this._reqId; const result = await new Promise((resolve) => { let settled = false; let timer = null; const settle = (data) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); this._pendingRequests.delete(requestId); this._stopChannelWatchIfIdle(); resolve(data); }; timer = setTimeout(() => { if (LONG_RUNNING_RPC_COMMANDS.has(cmd)) { try { iw.postMessage({ type: 'UNIPUS_CMD', cmd: 'STOP', requestId: ++this._reqId }, targetOrigin); } catch (_) {} } settle({ ok: false, err: '超时' }); }, timeout); this._pendingRequests.set(requestId, { settle, source: iw, origin: targetOrigin, epoch: channel.epoch, longRunning: LONG_RUNNING_RPC_COMMANDS.has(cmd), }); this._startChannelWatch(); try { iw.postMessage({ type: 'UNIPUS_CMD', cmd, requestId, runtimeConfig: Store.exportRuntimeConfig(), ...params, }, targetOrigin); } catch (e) { settle({ ok: false, err: e.message || 'postMessage 失败' }); } }); return result; }, // 本地执行(普通版无 iframe 时直接调用 Worker) async _runLocal(cmd, params = {}) { if (params && Object.prototype.hasOwnProperty.call(params, 'runtimeConfig') && params.runtimeConfig) { try { Store.importRuntimeConfig(params.runtimeConfig); } catch (_) {} } const logger = (msg) => this._logHandlers.forEach(h => { try { h(msg); } catch (_) {} }); if (cmd === 'STOP') { stopRequested = true; isPaused = false; return { ok: true }; } if (cmd === 'SET_PAUSED') { isPaused = !!params.paused; return { ok: true, paused: isPaused }; } if (cmd === 'RESET') { if (this._localActiveWork > 0) return { ok: false, busy: true, err: '旧任务仍在停止中' }; stopRequested = false; isPaused = false; return { ok: true }; } const isLongRunning = LONG_RUNNING_RPC_COMMANDS.has(cmd); if (isLongRunning && this._localActiveWork > 0) return { ok: false, busy: true, err: '已有任务正在执行' }; if (isLongRunning) this._localActiveWork++; try { switch (cmd) { case 'SCAN': { const list = Worker.scanMenu(); if (list.length) { _menuListCache = list; window.dispatchEvent(new CustomEvent('UNIPUS_MENU_READY', { detail: list })); } return { ok: true }; } case 'CLICK': return { ok: Worker.clickByPath(params.path, params.text, params.unit, params.section) }; case 'PROBE': return { ok: true, info: Worker.probe() }; case 'ANSWER': { const r = await Worker.runAnswer(logger); return { ok: true, result: r }; } case 'PLAY_MEDIA': { const r = await Worker.playMedia(logger); return { ok: true, result: r }; } case 'CHECKLIST': { const r = await Worker.runChecklist(logger); return { ok: true, result: r }; } case 'DROPDOWNS': { const r = await Worker.runDropdowns(logger); return { ok: true, result: r }; } case 'DISCUSSION': { const r = await Worker.runDiscussion(logger); return { ok: true, result: r }; } case 'MATCHING': { const r = await Worker.runMatching(logger); return { ok: true, result: r }; } case 'ORDERING': { const r = await Worker.runOrdering(logger); return { ok: true, result: r }; } case 'DOCUMENT': { const r = await Worker.runDocument(logger); return { ok: true, result: r }; } case 'CLICK_NEXT': return { ok: Worker.clickNext(params.opts || {}) }; case 'GET_TABS': { const tabs = Worker.getTabs().map(t => ({ text: t.text, idx: t.idx, active: t.active })); return { ok: true, tabs }; } case 'CLICK_TAB': return { ok: Worker.clickTab(params.tabIdx || 0, params.tabText || '') }; case 'GET_TASKS': { const tasks = Worker.getTasks().map(t => ({ text: t.text, idx: t.idx, active: t.active })); return { ok: true, tasks }; } case 'GET_NAV_STATE': return { ok: true, state: Worker.getNavigationState() }; case 'CLICK_TASK': return { ok: Worker.clickTask(params.taskIdx || 0, params.taskText || '') }; case 'CLOSE_POPUP': Worker.closePopup(); return { ok: true }; default: return { ok: false, err: 'unknown cmd' }; } } catch (e) { return { ok: false, err: String(e) }; } finally { if (isLongRunning) this._localActiveWork--; } }, requestScan() { return this.sendCmd('SCAN', {}, { timeout: 4000 }); }, clickItem(path, text = '', unit = '', section = '') { return this.sendCmd('CLICK', { path, text, unit, section }, { timeout: 5000 }); }, probe() { return this.sendCmd('PROBE', {}, { timeout: 5000 }); }, answer() { return this.sendCmd('ANSWER', {}, { timeout: 5 * 60 * 1000 }); }, playMedia() { return this.sendCmd('PLAY_MEDIA', {}, { timeout: 30 * 60 * 1000 }); }, runChecklist() { return this.sendCmd('CHECKLIST', {}, { timeout: 60 * 1000 }); }, runDropdowns() { return this.sendCmd('DROPDOWNS', {}, { timeout: 5 * 60 * 1000 }); }, runDiscussion() { return this.sendCmd('DISCUSSION', {}, { timeout: 5 * 60 * 1000 }); }, runMatching() { return this.sendCmd('MATCHING', {}, { timeout: 5 * 60 * 1000 }); }, runOrdering() { return this.sendCmd('ORDERING', {}, { timeout: 5 * 60 * 1000 }); }, runDocument() { return this.sendCmd('DOCUMENT', {}, { timeout: 5 * 60 * 1000 }); }, clickNext(opts = {}) { return this.sendCmd('CLICK_NEXT', { opts }, { timeout: 3000 }); }, getTabs() { return this.sendCmd('GET_TABS', {}, { timeout: 3000 }); }, clickTab(tabIdx, tabText = '') { return this.sendCmd('CLICK_TAB', { tabIdx, tabText }, { timeout: 3000 }); }, getTasks() { return this.sendCmd('GET_TASKS', {}, { timeout: 3000 }); }, getNavigationState() { return this.sendCmd('GET_NAV_STATE', {}, { timeout: 3000 }); }, clickTask(taskIdx, taskText = '') { return this.sendCmd('CLICK_TASK', { taskIdx, taskText }, { timeout: 3000 }); }, closePopup() { return this.sendCmd('CLOSE_POPUP', {}, { timeout: 3000 }); }, setPaused(paused) { return this.sendCmd('SET_PAUSED', { paused }, { timeout: 1000 }); }, stop() { return this.sendCmd('STOP', {}, { timeout: 1000 }); }, reset() { return this.sendCmd('RESET', {}, { timeout: 1000 }); }, }; // 目录重绘会更换临时 path,选中项必须按完整层级恢复。 const menuItemIdentity = (item) => item ? JSON.stringify( [item.unit, item.section, item.micro].map(value => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase()) ) : ''; // ---- MenuRunner: 自动遍历目录 + 时长分配 + 视频/答题 ---- const MenuRunner = { perStepSec: 60, startIdx: 0, totalSeconds: 0, currentIdx: 0, pendingItems: [], _currentContext: null, _recordPending(reason, category = 'manual') { const entry = { ...(this._currentContext || {}), reason, category }; const key = JSON.stringify([entry.unit, entry.section, entry.micro, entry.tab, entry.task, reason, category]); if (!this.pendingItems.some(item => item.key === key)) this.pendingItems.push({ ...entry, key }); }, _navigationTargetActive(state, kind, target) { if (!state) return false; if (kind === 'menu') return (state.menu || []).some(item => menuItemIdentity(item) === menuItemIdentity(target)); const list = kind === 'tab' ? state.tabs : state.tasks; return (list || []).some(item => item.active && item.text === target.text && (kind === 'tab' || item.idx === target.idx)); }, async _switchContent(kind, target) { const response = await IframeRPC.getNavigationState(); if (!response?.ok || !response.state) throw new Error('无法确认课程页面的导航状态,已停止跳转'); if (stopRequested) return false; const before = response.state; const alreadyActive = this._navigationTargetActive(before, kind, target); if (!alreadyActive) { const clicked = kind === 'menu' ? await IframeRPC.clickItem(target.path, target.micro, target.unit, target.section) : kind === 'tab' ? await IframeRPC.clickTab(target.idx, target.text) : await IframeRPC.clickTask(target.idx, target.text); if (!clicked?.ok) { if (kind === 'menu') return false; throw new Error(`无法切换${kind === 'tab' ? '标签' : '任务'}“${target.text}”,已保留当前章节`); } } let started = Date.now(); let stableKey = '', stableSince = 0; // 不能只相信点击返回值或 hash:平台可能先切高亮,再异步替换旧题面。 for (let attempt = 0; attempt < 101 && Date.now() - started <= 20000; attempt++) { const pausedAt = Date.now(); await Utils.waitIfPaused(); const pausedMs = Date.now() - pausedAt; started += pausedMs; if (stableKey) stableSince += pausedMs; if (stopRequested) return false; const current = await IframeRPC.getNavigationState(); if (!current?.ok || !current.state) throw new Error('课程页面未响应,已停止跳转'); const state = current.state; const contentChanged = !!state.hasContent && (!before.hasContent || state.contentKey !== before.contentKey); const ready = this._navigationTargetActive(state, kind, target) && !state.loading && state.hasContent && (alreadyActive || contentChanged); const key = `${state.route || ''}|${state.contentKey || ''}`; if (ready) { if (key !== stableKey) { stableKey = key; stableSince = Date.now(); } else if (Date.now() - stableSince >= 600) return true; } else { stableKey = ''; stableSince = 0; } await Utils.sleep(200); } if (stopRequested) return false; throw new Error(`“${target.text || target.micro}”尚未完成内容切换,已停止以避免处理上一页题目。请等待加载完成后重试`); }, async run(menuList, totalMin, startIdx, logger) { if (isRunning) { logger('⚠️ 已经在运行中...'); return false; } if (!Array.isArray(menuList) || !menuList.length) { logger('⚠️ 目录列表为空,请先在 iframe 内展开目录'); return false; } if (!Number.isInteger(startIdx) || startIdx < 0 || startIdx >= menuList.length) { logger('⚠️ 起始章节已失效,请重新选择目录项'); return false; } this.startIdx = startIdx; const jobs = menuList.slice(startIdx); this.totalSeconds = Math.max(60, totalMin * 60); this.perStepSec = Math.max(Config.minStepSec, Math.floor(this.totalSeconds / jobs.length)); logger(`🚀 开始刷课:共 ${jobs.length} 项,总时长 ${totalMin} 分钟,平均每项 ${Math.round(this.perStepSec)} 秒`); isRunning = true; stopRequested = false; isPaused = false; this.pendingItems = []; this._currentContext = null; try { const reset = await IframeRPC.reset(); if (!reset?.ok) throw new Error('课程页面未响应,请刷新目录后重试'); for (let i = 0; i < jobs.length; i++) { if (stopRequested) { logger('⏹ 已停止'); break; } await Utils.waitIfPaused(); if (stopRequested) break; this.currentIdx = startIdx + i; const job = jobs[i]; this._currentContext = { unit: job.unit, section: job.section, micro: job.micro, tab: '', task: '' }; Panel.selectMenuItem(job); logger(`\n📖 [${i + 1}/${jobs.length}] ${job.unit} / ${job.section} / ${job.micro}`); // 点击菜单项 if (!await this._switchContent('menu', job)) { if (!stopRequested) logger('⚠️ 无法唯一定位该章节,已停止。请展开对应单元并刷新目录后重试'); stopRequested = true; break; } if (stopRequested) break; await IframeRPC.closePopup(); // 检测标签页(如 Listening skills / Conversation / Passage 等) const tabResult = await IframeRPC.getTabs(); if (tabResult?.ok === false) throw new Error('无法读取课程标签,已停止跳转'); const tabs = tabResult?.tabs || []; if (tabs.length >= 2) { logger(`📑 检测到 ${tabs.length} 个标签页:${tabs.map(t => t.text).join(', ')}`); // 每个 tab 分配的等待时间 = 总时长 / tab 数(最少 4 秒) const tabWaitSec = Math.max(4, Math.floor(this.perStepSec / tabs.length)); for (let t = 0; t < tabs.length; t++) { if (stopRequested) break; await Utils.waitIfPaused(); if (stopRequested) break; // 点击标签 this._currentContext.tab = tabs[t].text; this._currentContext.task = ''; await this._switchContent('tab', { ...tabs[t], idx: tabs[t].idx ?? t }); if (stopRequested) break; await IframeRPC.closePopup(); logger(` 📌 标签 [${t + 1}/${tabs.length}] ${tabs[t].text}`); // 检测 tab 内的子任务(.pc-header-tasks-row .pc-task) const taskResult = await IframeRPC.getTasks(); if (taskResult?.ok === false) throw new Error('无法读取课程任务,已停止跳转'); const tasks = taskResult?.tasks || []; if (tasks.length >= 1) { const taskWaitSec = Math.max(3, Math.floor(tabWaitSec / tasks.length)); for (let k = 0; k < tasks.length; k++) { if (stopRequested) break; await Utils.waitIfPaused(); if (stopRequested) break; this._currentContext.task = tasks[k].text; await this._switchContent('task', { ...tasks[k], idx: tasks[k].idx ?? k }); if (stopRequested) break; await IframeRPC.closePopup(); logger(` ✏️ 任务 [${k + 1}/${tasks.length}] ${tasks[k].text}`); await this._processTabContent(logger, job.micro, taskWaitSec, { advance: true, navigate: false }); } } else { // 无子任务,整个 tab 当一个单元处理 await this._processTabContent(logger, job.micro, tabWaitSec, { advance: true, navigate: false }); } } } else { logger(`🔍 未检测到标签页,处理当前内容`); // 无标签页时,仍尝试看是否有 task 行 const taskResult0 = await IframeRPC.getTasks(); if (taskResult0?.ok === false) throw new Error('无法读取课程任务,已停止跳转'); const tasks0 = taskResult0?.tasks || []; if (tasks0.length >= 1) { logger(`✏️ 检测到 ${tasks0.length} 个任务:${tasks0.map(t => t.text).join(', ')}`); const taskWaitSec0 = Math.max(3, Math.floor(this.perStepSec / tasks0.length)); for (let k = 0; k < tasks0.length; k++) { if (stopRequested) break; await Utils.waitIfPaused(); if (stopRequested) break; this._currentContext.task = tasks0[k].text; await this._switchContent('task', { ...tasks0[k], idx: tasks0[k].idx ?? k }); if (stopRequested) break; await IframeRPC.closePopup(); logger(` ✏️ 任务 [${k + 1}/${tasks0.length}] ${tasks0[k].text}`); await this._processTabContent(logger, job.micro, taskWaitSec0, { advance: true, navigate: false }); } } else { await this._processTabContent(logger, job.micro, undefined, { advance: true }); } } // 提交后等待保存;下一项只通过完整目录定位,避免“下一节”再跳一次。 if (!stopRequested) { await Utils.sleep(Config.afterSubmitWaitMs); await IframeRPC.closePopup(); } if (i < jobs.length - 1 && !stopRequested) { logger(`⏭️ 进入下一章节...`); await Utils.sleep(Config.chapterEndWaitMs); } } return !stopRequested && this.pendingItems.length === 0; } catch (error) { stopRequested = true; try { await IframeRPC.stop(); } catch (_) {} throw error; } finally { const stopped = stopRequested; isRunning = false; if (!stopped) { stopRequested = false; const manual = this.pendingItems.filter(item => item.category === 'manual'); const unAnswered = this.pendingItems.filter(item => item.category === 'autoDisabled'); logger(`📚 目录学习遍历结束${manual.length ? `,${manual.length} 项需人工完成` : ''}${unAnswered.length ? `,${unAnswered.length} 项未自动作答` : ''}`); for (const item of this.pendingItems) { logger(` ${item.category === 'manual' ? '🖐' : '📝'} ${[item.unit, item.section, item.micro, item.tab, item.task].filter(Boolean).join(' / ')}:${item.reason}`); } } else { logger('⏹ 已停止,未继续执行旧任务'); } } }, _contentScopeIdentity(state) { return JSON.stringify([ (state.menu || []).map(menuItemIdentity), (state.tabs || []).filter(item => item.active).map(item => [item.idx, item.text]), (state.tasks || []).filter(item => item.active).map(item => [item.idx, item.text]), ]); }, async _waitForContentPage(before) { const expected = this._contentScopeIdentity(before); let started = Date.now(), stableKey = '', stableSince = 0; for (let attempt = 0; attempt < 101 && Date.now() - started <= 20000; attempt++) { const pausedAt = Date.now(); await Utils.waitIfPaused(); const pausedMs = Date.now() - pausedAt; started += pausedMs; if (stableKey) stableSince += pausedMs; if (stopRequested) return false; const response = await IframeRPC.getNavigationState(); if (!response?.ok || !response.state) throw new Error('无法确认下一题是否加载,已保留当前位置'); const state = response.state; if (this._contentScopeIdentity(state) !== expected) throw new Error('下一题跳出了当前小节,已停止以免漏掉内容'); const ready = !state.loading && state.hasContent && state.contentKey !== before.contentKey; if (ready) { if (stableKey !== state.contentKey) { stableKey = state.contentKey; stableSince = Date.now(); } else if (Date.now() - stableSince >= 600) return true; } else { stableKey = ''; stableSince = 0; } await Utils.sleep(200); } if (stopRequested) return false; throw new Error('下一题内容未完成加载,已停止以免重复处理上一题'); }, async _confirmSubmission(before, logger) { let started = Date.now(), stableSince = null; for (let attempt = 0; attempt < 31 && Date.now() - started <= 6000; attempt++) { const pausedAt = Date.now(); await Utils.waitIfPaused(); const pausedMs = Date.now() - pausedAt; started += pausedMs; if (stableSince !== null) stableSince += pausedMs; if (stopRequested) return false; const response = await IframeRPC.getNavigationState(); if (!response?.ok || !response.state) break; const state = response.state; if (this._contentScopeIdentity(state) !== this._contentScopeIdentity(before)) break; // “保存中”的 disabled 按钮不能证明成功;只接受明确回执或提交控件消失。 const newReceipt = !before.contentPage?.completed && !!state.contentPage?.completed; const submitRemoved = before.contentPage?.submitVisible && !state.contentPage?.submitVisible && state.hasContent && state.contentKey === before.contentKey; const saved = !state.loading && state.hasContent && (newReceipt || submitRemoved); if (saved) { if (stableSince === null) stableSince = Date.now(); else if (Date.now() - stableSince >= 600) { logger('💾 检测到提交后的页面确认'); return true; } } else stableSince = null; await Utils.sleep(200); } this._recordPending('已点击提交,保存结果待人工核对'); logger('🖐 已点击提交,保存结果待人工核对,已列入待处理项'); return false; }, async _returnToFirstContentPage(logger) { const response = await IframeRPC.getNavigationState(); if (!response?.ok || !response.state) throw new Error('无法确认当前题页,已保留当前位置'); let state = response.state; if (!state.contentPage?.canPrevious) return true; const probe = await IframeRPC.probe(); if (!probe?.ok) throw new Error('无法确认当前题型,已保留当前位置'); if (stopRequested) return false; const info = probe.info || {}; const auto = Store.getFeatureConf().autoAnswer; const manual = !!(info.manualRequired || info.hasSpeaking || info.hasDiscussion); const answerable = !!(info.hasQuestion || info.hasDropdown || info.hasMatch || info.hasOrder); if (manual || !auto || !answerable) { const reason = '当前位于任务中途,前面题页尚未确认,请手动返回首题后继续'; this._recordPending(reason, !auto && !manual ? 'autoDisabled' : 'manual'); logger(`🖐 ${reason}`); return false; } const seen = new Set([state.contentKey]); for (let page = 0; page < 50; page++) { if (!state.contentPage?.canPrevious) { logger('↩️ 已回到本小节首题,将按页顺序处理'); return true; } // 与 Worker 的防连点间隔一致,给平台保存当前视图和重绘时间。 await Utils.sleep(1700); await Utils.waitIfPaused(); if (stopRequested) return false; const previous = await IframeRPC.clickNext({ scope: 'content', previousOnly: true, taskPageOnly: true, skipSubmit: true }); if (!previous?.ok) throw new Error('无法返回本小节上一题,已保留当前位置'); if (!await this._waitForContentPage(state)) return false; const current = await IframeRPC.getNavigationState(); if (!current?.ok || !current.state) throw new Error('无法确认上一题加载结果,已保留当前位置'); state = current.state; if (!state.contentKey || seen.has(state.contentKey)) throw new Error('返回首题时遇到重复题面,已停止以避免循环跳转'); seen.add(state.contentKey); } throw new Error('本小节回退超过 50 页,已停止,请人工返回首题后重试'); }, async _processTabContent(logger, label, maxWaitSec, { advance = false, navigate = true } = {}) { if (advance && !await this._returnToFirstContentPage(logger)) return false; const seen = new Set(); let anyHandled = false; for (let page = 0; page < 50; page++) { if (advance) { const beforePage = await IframeRPC.getNavigationState(); if (!beforePage?.ok || !beforePage.state) throw new Error('无法读取本小节的分页状态,已保留当前位置'); if (beforePage.state.contentKey) { if (seen.has(beforePage.state.contentKey)) throw new Error('本小节重复出现相同题面,已停止以避免循环作答'); seen.add(beforePage.state.contentKey); } } const outcome = await this._processSinglePage(logger, label, maxWaitSec, { advance }); if (!outcome || stopRequested) return anyHandled; anyHandled = anyHandled || outcome.didSomething; if (!advance || outcome.manualRequired) return anyHandled; const response = await IframeRPC.getNavigationState(); if (!response?.ok || !response.state) throw new Error('无法读取本小节的分页状态,已保留当前位置'); const state = response.state; await Utils.sleep(outcome.didSomething ? Config.afterSubmitWaitMs : 300); await Utils.waitIfPaused(); if (stopRequested) return anyHandled; const contentPage = state.contentPage || {}; if (contentPage.canNext && contentPage.canSubmit) throw new Error('本页同时存在下一题与提交按钮,无法确定保存顺序,请人工确认'); if (contentPage.canNext && !outcome.autoDisabled) { if (page === 49) throw new Error('本小节分页超过 50 页,已停止,请人工检查目录'); const next = await IframeRPC.clickNext({ scope: 'content', skipSubmit: true, taskPageOnly: true }); if (!next?.ok) throw new Error('本小节下一题未成功切换,已保留当前题面'); if (!await this._waitForContentPage(state)) return anyHandled; logger(` 📄 本小节第 ${page + 2} 页`); continue; } if (outcome.answered && contentPage.submitVisible && !contentPage.canSubmit) { this._recordPending('提交按钮当前不可用,保存状态需人工核对'); logger('🖐 提交按钮当前不可用,已保留保存状态待核对项'); return anyHandled; } const clicked = await IframeRPC.clickNext({ scope: 'content', skipSubmit: !outcome.answered, submitOnly: !navigate || !outcome.answered, taskPageOnly: contentPage.submitInPager || false }); if (outcome.answered && contentPage.canSubmit) { if (!clicked?.ok) throw new Error('本小节提交按钮未成功点击,已保留当前题面'); await this._confirmSubmission(state, logger); } await IframeRPC.closePopup(); return anyHandled; } return anyHandled; }, async _processSinglePage(logger, label, maxWaitSec, { advance = false } = {}) { await Utils.waitIfPaused(); if (stopRequested) return false; const probe = await IframeRPC.probe(); if (stopRequested) return false; if (probe?.ok === false) throw new Error('无法检测当前课程内容,已保留当前位置'); const info = probe?.info || {}; const auto = Store.getFeatureConf().autoAnswer; let didSomething = false; let answered = false; const manualRequired = !!(info.manualRequired || info.hasSpeaking); const autoDisabled = !auto && !!(info.hasQuestion || info.hasDropdown || info.hasMatch || info.hasOrder || info.hasDiscussion); if (manualRequired) { const reason = info.manualReason || '录音/口语任务需本人录制并确认提交'; this._recordPending(reason); logger(`🖐 ${reason},已列入待处理项`); } else if (autoDisabled) { this._recordPending('自动作答已关闭,本页题目需手动作答', 'autoDisabled'); } const requireComplete = (response) => { const result = response?.result; if (response?.ok === false || !result || result.ok === false || result.stopped || Number(result.failed) > 0 || Number(result.skipped) > 0 || (Number(result.total) > 0 && Number(result.success || 0) < Number(result.total))) { throw new Error('当前内容未全部完成,已停止跳转。请检查本节作答结果后继续'); } }; if (manualRequired) { // 口语、社区讨论和主观自评保留给本人操作;不会代录音或提交空白结果。 } else if (info.hasQuestion && auto) { logger(`📝 发现 ${info.questionCount} 道题,启动自动作答`); const r = await IframeRPC.answer(); if (r?.result) { const { success = 0, failed = 0, skipped = 0, total = 0 } = r.result; logger(`✅ 作答完成:成功 ${success}/${total},失败 ${failed}${skipped ? `,跳过 ${skipped}` : ''}`); } requireComplete(r); didSomething = true; answered = true; } // 同页可以同时有选择/填空、下拉和拖动题,各类型都处理后才能提交或翻页。 if (stopRequested) return false; if (!manualRequired && info.hasDropdown && auto) { logger(`🔽 检测到 ${info.dropdownCount} 个下拉框,启动 AI 填空`); const r = await IframeRPC.runDropdowns(); if (r?.result) { const { success = 0, failed = 0, skipped = 0, total = 0 } = r.result; logger(`🔽 下拉完成:成功 ${success}/${total},失败 ${failed}${skipped ? `,跳过 ${skipped}` : ''}`); } requireComplete(r); didSomething = true; answered = true; } if (stopRequested) return false; if (!manualRequired && info.hasMatch && auto) { logger(`🔗 检测到匹配/连线题(${info.dragCount} 个拖拽项),启动 AI 配对`); const r = await IframeRPC.runMatching(); if (r?.result) { const { success = 0, failed = 0, total = 0 } = r.result; logger(`🔗 匹配完成:成功 ${success}/${total},失败 ${failed}`); } requireComplete(r); didSomething = true; answered = true; } if (stopRequested) return false; if (!manualRequired && info.hasOrder && auto) { logger(`🔢 检测到排序题(${info.dragCount} 个可拖动项),启动 AI 排序`); const r = await IframeRPC.runOrdering(); if (r?.result) { const { success = 0, failed = 0, total = 0 } = r.result; logger(`🔢 排序完成:成功 ${success}/${total},失败 ${failed}`); } requireComplete(r); didSomething = true; answered = true; } if (stopRequested) return false; if (!manualRequired && info.hasChecklist) { logger(`☑️ 检测到 Checklist (${info.checklistCount} 项),自动勾选`); const r = await IframeRPC.runChecklist(); if (r?.result) { const { count = 0, total = 0 } = r.result; logger(`☑️ 勾选完成:${count}/${total}`); } if (r?.ok === false || !r?.result || r.result.ok === false || Number(r.result.count) < Number(r.result.total)) { throw new Error('当前清单未全部勾选,已停止跳转'); } didSomething = true; answered = true; } if (stopRequested) return false; if (!manualRequired && info.hasDiscussion && auto) { logger(`💬 检测到讨论/写作题(${info.discussionCount} 个文本框),启动 AI 生成`); const r = await IframeRPC.runDiscussion(); if (r?.result) { const { success = 0, failed = 0, total = 0 } = r.result; logger(`💬 已填入 ${success}/${total}(不会自动提交,请人工检查后提交)`); } requireComplete(r); if (advance && Number(r.result.total) > 0) { throw new Error('讨论/写作内容已填入,请人工检查并提交后继续'); } await Utils.sleep(1200); didSomething = true; } if (stopRequested) return false; if (!manualRequired && !didSomething && info.hasDocument) { const r = await IframeRPC.runDocument(); if (stopRequested) return false; if (r?.ok === false || r?.result?.ok === false || r?.result?.complete === false) { throw new Error('当前文档未阅读完成,已停止跳转'); } if (r?.result?.handled) didSomething = true; } else if (!manualRequired && !didSomething && info.hasMedia) { logger(`🎬 发现 ${info.mediaCount} 个媒体,启动播放`); const r = await IframeRPC.playMedia(); if (stopRequested) return false; requireComplete(r); didSomething = true; } if (!didSomething) { // 普通页面:按分配时长等待(tab 模式传入 tabWaitSec,否则用 perStepSec) await this._countdownWait(maxWaitSec ?? this.perStepSec, logger, label); } return { didSomething, answered, manualRequired, autoDisabled }; }, async _countdownWait(seconds, logger, label) { let remainingMs = Math.max(0, Number(seconds) || 0) * 1000; let lastSec = -1; while (remainingMs > 0) { if (stopRequested) break; await Utils.waitIfPaused(); if (stopRequested) break; const remaining = Math.ceil(remainingMs / 1000); if (remaining !== lastSec) { lastSec = remaining; logger(`⏳ ${label} 等待中 ${Utils.formatTime(remaining)}`, true); } const started = Date.now(); await Utils.sleep(Math.min(1000, remainingMs)); // 暂停等待不消耗本节分配的学习时间。 if (!isPaused) remainingMs -= Math.max(0, Date.now() - started); } }, }; // ---- UI Panel: 悬浮球 + 控制面板 ---- const Panel = { panelEl: null, ballEl: null, logEl: null, menuSelectEl: null, timeInputEl: null, startBtn: null, pauseBtn: null, stopBtn: null, settingsPanel: null, _menuItems: [], _selectedMenuKey: '', _selectionExplicit: false, _starting: false, _startCancelled: false, getSelectedMenuItem() { const value = this.menuSelectEl?.value; if (!/^\d+$/.test(value || '')) return null; return this._menuItems[Number(value)] || null; }, rememberMenuSelection() { const item = this.getSelectedMenuItem(); if (item) { this._selectedMenuKey = menuItemIdentity(item); this._selectionExplicit = true; } return item; }, selectMenuItem(item) { this._selectedMenuKey = menuItemIdentity(item); this._selectionExplicit = true; if (!this.menuSelectEl) return; const matches = this._menuItems.map((entry, index) => menuItemIdentity(entry) === this._selectedMenuKey ? index : -1).filter(index => index >= 0); this.menuSelectEl.value = matches.length === 1 ? String(matches[0]) : ''; }, addLog(msg, isCountdown = false) { if (!this.logEl) return; if (isCountdown) { const last = this.logEl.lastElementChild; if (last && last.classList.contains('countdown-line')) { last.textContent = msg; } else { const div = document.createElement('div'); div.className = 'countdown-line'; div.textContent = msg; this.logEl.appendChild(div); } } else { const div = document.createElement('div'); // 按 emoji 添加颜色 hint if (/^[\s]*✅|^[\s]*🎉|^[\s]*🚀/.test(msg)) div.className = 'log-success'; else if (/^[\s]*⚠️|^[\s]*⏸|^[\s]*⏭/.test(msg)) div.className = 'log-warn'; else if (/^[\s]*❌|^[\s]*⛔/.test(msg)) div.className = 'log-error'; div.textContent = msg; this.logEl.appendChild(div); } this.logEl.scrollTop = this.logEl.scrollHeight; while (this.logEl.children.length > 200) this.logEl.removeChild(this.logEl.firstChild); }, populateMenu(list) { if (!this.menuSelectEl) return; const previousKey = this._selectedMenuKey || menuItemIdentity(this.getSelectedMenuItem()); this._menuItems = Array.isArray(list) ? list.slice() : []; this.menuSelectEl.innerHTML = ''; if (!this._menuItems.length) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = this._selectionExplicit ? '所选章节暂不可见,请展开对应单元并刷新目录' : '请先在课程页面展开目录'; this.menuSelectEl.appendChild(opt); return; } this._menuItems.forEach((n, i) => { const opt = document.createElement('option'); opt.value = String(i); opt.textContent = `${i + 1}. [${n.unit || '-'}] ${n.section || ''} > ${n.micro}`; this.menuSelectEl.appendChild(opt); }); const active = this._menuItems.filter(item => item.active); const preferredKey = !this._selectionExplicit && active.length === 1 ? menuItemIdentity(active[0]) : previousKey; const matches = this._menuItems.map((item, index) => menuItemIdentity(item) === preferredKey ? index : -1).filter(index => index >= 0); if (matches.length === 1) { this.menuSelectEl.value = String(matches[0]); this._selectedMenuKey = menuItemIdentity(this._menuItems[matches[0]]); } else if (this._selectionExplicit || preferredKey) { const opt = document.createElement('option'); opt.value = ''; opt.textContent = '所选章节已变化,请展开对应单元后重新选择'; this.menuSelectEl.prepend(opt); this.menuSelectEl.value = ''; } else { this.menuSelectEl.value = '0'; this._selectedMenuKey = menuItemIdentity(this._menuItems[0]); } this.addLog(`✅ 已加载 ${this._menuItems.length} 个目录项`); }, // 注入全局样式(只注入一次) injectStyles() { if (document.getElementById('unipus-styles')) return; const style = document.createElement('style'); style.id = 'unipus-styles'; style.textContent = ` #unipus-ball,#unipus-panel,#unipus-settings-overlay,#unipus-purpose-overlay{ font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; } #unipus-ball{ position:fixed;bottom:24px;right:24px;width:54px;height:54px; border-radius:50%;background:linear-gradient(135deg,#06b6d4 0%,#3b82f6 50%,#8b5cf6 100%); z-index:99999;box-shadow:0 8px 24px rgba(59,130,246,.35),0 2px 6px rgba(0,0,0,.12); display:flex;align-items:center;justify-content:center;cursor:pointer;color:#fff;font-size:24px; user-select:none;transition:transform .25s cubic-bezier(.34,1.56,.64,1),box-shadow .25s ease; } #unipus-ball:hover{transform:scale(1.12) rotate(-6deg);box-shadow:0 12px 32px rgba(59,130,246,.55),0 4px 8px rgba(0,0,0,.15);} #unipus-ball.running::after{ content:'';position:absolute;top:-2px;right:-2px;width:14px;height:14px;border-radius:50%; background:#10b981;border:2px solid #fff;animation:up-pulse 1.5s ease-in-out infinite; } @keyframes up-pulse{0%,100%{transform:scale(1);opacity:1}50%{transform:scale(1.2);opacity:.7}} @keyframes up-slidein{from{transform:translateY(20px) scale(.95);opacity:0}to{transform:translateY(0) scale(1);opacity:1}} @keyframes up-spin{to{transform:rotate(360deg)}} #unipus-panel{ position:fixed;right:24px;bottom:90px;width:380px;max-height:80vh;display:flex;flex-direction:column; background:#0f172a;color:#e2e8f0;border-radius:16px;z-index:99999; box-shadow:0 24px 60px rgba(0,0,0,.4),0 0 0 1px rgba(255,255,255,.06); animation:up-slidein .25s cubic-bezier(.34,1.56,.64,1);overflow:hidden; } .up-header{ padding:14px 16px;display:flex;align-items:center;justify-content:space-between; background:linear-gradient(135deg,rgba(6,182,212,.15),rgba(139,92,246,.15)); border-bottom:1px solid rgba(255,255,255,.06);cursor:move;user-select:none; } .up-title{font-size:14px;font-weight:600;display:flex;align-items:center;gap:8px;color:#f1f5f9;} .up-dot{width:8px;height:8px;border-radius:50%;background:#64748b;} .up-dot.ok{background:#10b981;box-shadow:0 0 8px #10b981;} .up-dot.running{background:#06b6d4;animation:up-pulse 1.2s ease-in-out infinite;} .up-dot.paused{background:#f59e0b;} .up-dot.error{background:#ef4444;} .up-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,transform .15s; } .up-iconbtn:hover{background:rgba(255,255,255,.16);transform:translateY(-1px);} .up-body{padding:14px;overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:12px;} .up-body::-webkit-scrollbar{width:6px} .up-body::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px} .up-section{ background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.06); border-radius:10px;padding:12px; } .up-section-title{font-size:11px;color:#94a3b8;font-weight:600;text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px;display:flex;align-items:center;gap:6px;} .up-row{display:flex;align-items:center;gap:8px;margin-bottom:8px;} .up-row:last-child{margin-bottom:0;} .up-label{font-size:12px;color:#cbd5e1;flex:1;font-weight:500;} .up-input,.up-select{ width:100%;padding:7px 10px;background:#1e293b;color:#f1f5f9;border:1px solid rgba(255,255,255,.1); border-radius:7px;font-size:12px;box-sizing:border-box;transition:border-color .15s,box-shadow .15s; font-family:inherit; } .up-input:focus,.up-select:focus{outline:none;border-color:#06b6d4;box-shadow:0 0 0 3px rgba(6,182,212,.2);} .up-select{cursor:pointer;} .up-btn{ padding:9px 14px;background:linear-gradient(135deg,#06b6d4,#3b82f6);color:#fff;border:none; border-radius:7px;font-size:13px;font-weight:600;cursor:pointer;transition:transform .12s,box-shadow .15s; font-family:inherit;display:inline-flex;align-items:center;justify-content:center;gap:6px; } .up-btn:hover{transform:translateY(-1px);box-shadow:0 6px 16px rgba(6,182,212,.35);} .up-btn:active{transform:translateY(0);} .up-btn-ghost{background:rgba(255,255,255,.08);color:#cbd5e1;} .up-btn-ghost:hover{background:rgba(255,255,255,.16);box-shadow:none;} .up-btn-warn{background:linear-gradient(135deg,#f59e0b,#f97316);} .up-btn-danger{background:linear-gradient(135deg,#ef4444,#dc2626);} .up-btn-success{background:linear-gradient(135deg,#10b981,#059669);} .up-btn-sm{padding:5px 9px;font-size:11px;} .up-btn:disabled{opacity:.5;cursor:not-allowed;transform:none;box-shadow:none;} .up-switch{position:relative;display:inline-block;width:36px;height:20px;flex-shrink:0;} .up-switch input{opacity:0;width:0;height:0;} .up-switch-slider{ position:absolute;cursor:pointer;inset:0;background:#334155;border-radius:20px;transition:.2s; } .up-switch-slider:before{ content:'';position:absolute;height:14px;width:14px;left:3px;bottom:3px;background:#fff; border-radius:50%;transition:.2s; } .up-switch input:checked + .up-switch-slider{background:linear-gradient(135deg,#06b6d4,#3b82f6);} .up-switch input:checked + .up-switch-slider:before{transform:translateX(16px);} .up-radiogroup{display:flex;gap:6px;} .up-radiogroup label{ flex:1;padding:6px 10px;background:#1e293b;border:1px solid rgba(255,255,255,.08); border-radius:7px;cursor:pointer;text-align:center;font-size:12px;color:#cbd5e1; transition:all .15s; } .up-radiogroup input{display:none;} .up-radiogroup input:checked + span{color:#06b6d4;} .up-radiogroup label:has(input:checked){ background:rgba(6,182,212,.15);border-color:#06b6d4;color:#06b6d4; } #unipus-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; } #unipus-log::-webkit-scrollbar{width:5px} #unipus-log::-webkit-scrollbar-thumb{background:rgba(255,255,255,.15);border-radius:3px} #unipus-log > div{padding:1px 0;word-break:break-all;} #unipus-log .countdown-line{color:#06b6d4;} #unipus-log .log-success{color:#10b981;} #unipus-log .log-warn{color:#f59e0b;} #unipus-log .log-error{color:#ef4444;} .up-key-row{position:relative;} .up-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; } .up-key-toggle:hover{color:#06b6d4;} /* 广告模式 / 解锁 */ .up-qr-box{ width:96px;height:96px;background:#fff;border-radius:8px;padding:6px;flex-shrink:0; display:flex;align-items:center;justify-content:center;position:relative; border:2px dashed rgba(6,182,212,.3); } .up-qr-box img{width:100%;height:100%;object-fit:contain;border-radius:4px;} .up-qr-fallback{ display:none;flex-direction:column;align-items:center;justify-content:center; gap:4px;position:absolute;inset:6px; } .up-unlock-status{ font-size:10px;font-weight:600;padding:2px 6px;border-radius:10px;letter-spacing:.3px; } .up-unlock-status.locked{background:rgba(245,158,11,.15);color:#f59e0b;} .up-unlock-status.unlocked{background:rgba(16,185,129,.15);color:#10b981;} #unipus-settings-overlay{ position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(4px); z-index:100000;display:flex;align-items:center;justify-content:center; animation:up-fadein .2s ease; } @keyframes up-fadein{from{opacity:0}to{opacity:1}} .up-dialog{ background:#0f172a;color:#e2e8f0;border-radius:14px;width:440px;max-width:90vw;max-height:80vh; overflow-y:auto;padding:18px;box-shadow:0 24px 60px rgba(0,0,0,.5); animation:up-slidein .25s cubic-bezier(.34,1.56,.64,1); } .up-dialog h3{margin:0 0 12px 0;font-size:16px;font-weight:600;color:#f1f5f9;} .up-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); } .up-footer a{color:#06b6d4;text-decoration:none;} /* 微信反馈条 */ .up-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; } .up-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; } .up-wechat-copy:hover{ background:rgba(245,158,11,.18);border-color:#f59e0b;color:#fff; transform:translateY(-1px); } .up-wechat-copy.copied{ background:rgba(16,185,129,.18);border-color:#10b981;color:#34d399; } /* 公告条(远程拉取,默认隐藏) */ .up-announce{ display:none;padding:8px 14px;font-size:11px;line-height:1.55; border-bottom:1px solid rgba(59,130,246,.22); align-items:flex-start;gap:6px;position:relative; } .up-announce.show{display:flex;animation:upAnnounceIn .35s ease;} @keyframes upAnnounceIn{from{opacity:0;transform:translateY(-4px);}to{opacity:1;transform:translateY(0);}} .up-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);} .up-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);} .up-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);} .up-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);} .up-announce-icon{flex-shrink:0;font-size:13px;line-height:1.3;} .up-announce-body{flex:1;min-width:0;} .up-announce-title{font-weight:600;margin-bottom:2px;font-size:11.5px;} .up-announce-content{font-size:11px;color:rgba(255,255,255,.85);line-height:1.55;word-break:break-word;} .up-announce-content b{color:#fde68a;font-weight:600;} .up-announce-images{display:grid;gap:6px;margin-top:8px;} .up-announce-images img{ width:100%;max-height:180px;object-fit:contain;border-radius:6px; border:1px solid rgba(255,255,255,.14);background:rgba(255,255,255,.06);cursor:pointer; } .up-announce-images img:hover{border-color:rgba(255,255,255,.32);} .up-announce-close{ position:absolute;top:4px;right:8px;cursor:pointer;color:rgba(255,255,255,.5); font-size:14px;line-height:1;padding:2px;user-select:none; } .up-announce-close:hover{color:#fff;} #unipus-purpose-overlay{ position:fixed;inset:0;z-index:100002;display:none;align-items:center;justify-content:center; padding:20px;background:rgba(15,23,42,.56);backdrop-filter:blur(5px);box-sizing:border-box; } .up-purpose-card{ width:min(440px,100%);max-height:92vh;display:flex;flex-direction:column;overflow:hidden; color:#111827;background:#fff;border:1px solid #dbe3ee;border-radius:12px; box-shadow:0 22px 56px rgba(15,23,42,.28);animation:upPurposeIn .22s ease; } @keyframes upPurposeIn{from{opacity:0;transform:translateY(12px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}} .up-purpose-head{padding:17px 18px 13px;border-bottom:1px solid #edf2f7;} .up-purpose-head h3{margin:0 0 6px;font-size:16px;line-height:1.35;color:#111827;} .up-purpose-head p{margin:0;font-size:12px;line-height:1.6;color:#64748b;} .up-purpose-scroll{ max-height:170px;overflow:auto;padding:14px 18px;font-size:13px;line-height:1.7;color:#334155; background:#f8fafc;border-bottom:1px solid #edf2f7; } .up-purpose-scroll p{margin:0 0 10px;} .up-purpose-scroll p:last-child{margin-bottom:0;} .up-purpose-hint{margin:0;padding:10px 18px 0;font-size:12px;font-weight:700;color:#d97706;} .up-purpose-check{display:flex;align-items:flex-start;gap:9px;padding:10px 18px 2px;font-size:13px;line-height:1.55;color:#334155;cursor:pointer;} .up-purpose-check input{width:16px;height:16px;margin-top:2px;accent-color:#2563eb;} .up-purpose-actions{display:grid;grid-template-columns:1fr 1fr;gap:10px;padding:16px 18px 18px;} .up-purpose-actions button{ border-radius:8px;padding:10px 12px;font-size:13px;font-weight:700;cursor:pointer; border:1px solid #cbd5e1;transition:opacity .15s,transform .15s; } .up-purpose-actions button:hover{opacity:.88;} .up-purpose-actions button:active{transform:scale(.98);} .up-purpose-actions button:disabled{opacity:.45;cursor:not-allowed;transform:none;} #up-purpose-cancel{background:#f8fafc;color:#334155;} #up-purpose-confirm{background:#2563eb;color:#fff;border-color:#2563eb;} `; document.head.appendChild(style); }, setStatus(status) { // status: idle | running | paused | error | ok const dot = this.panelEl?.querySelector('.up-dot'); const ball = this.ballEl; if (dot) { dot.className = 'up-dot ' + status; } if (ball) { ball.classList.toggle('running', status === 'running' || status === 'paused'); } }, // 刷新广告模式 UI(locked / unlocked + 倒计时) refreshUnlockUI() { if (!this.panelEl) return; const statusEl = this.panelEl.querySelector('#up-unlock-status'); const lockedBox = this.panelEl.querySelector('#up-unlock-locked'); const activeBox = this.panelEl.querySelector('#up-unlock-active'); const remainEl = this.panelEl.querySelector('#up-unlock-remaining'); const untilEl = this.panelEl.querySelector('#up-unlock-until'); if (!statusEl || !lockedBox || !activeBox) return; if (Store.isUnlocked()) { statusEl.textContent = '已解锁'; statusEl.className = 'up-unlock-status unlocked'; lockedBox.style.display = 'none'; activeBox.style.display = 'block'; const remainMs = Store.getRemainingMs(); const h = Math.floor(remainMs / 3600000); const m = Math.floor((remainMs % 3600000) / 60000); const s = Math.floor((remainMs % 60000) / 1000); if (remainEl) { remainEl.textContent = h > 0 ? `剩余 ${h} 时 ${m} 分 ${s} 秒` : `剩余 ${m} 分 ${s} 秒`; } if (untilEl) untilEl.textContent = '到期:' + Store.getValidUntilStr(); } else { statusEl.textContent = '广告模式'; statusEl.className = 'up-unlock-status locked'; lockedBox.style.display = 'flex'; activeBox.style.display = 'none'; } }, createBall() { this.injectStyles(); const ball = document.createElement('div'); ball.id = 'unipus-ball'; ball.innerText = '📚'; ball.title = 'U校园刷课助手'; ball.onclick = () => { if (this.panelEl) { const isHidden = this.panelEl.style.display === 'none'; this.panelEl.style.display = isHidden ? 'flex' : 'none'; Store.setPanelOpen(isHidden); } else { this.createPanel(); } }; document.body.appendChild(ball); this.ballEl = ball; }, createPanel() { this.injectStyles(); const cfg = Store.getFeatureConf(); const envLabel = IS_LEGACY ? '普通版' : (IS_IPUB ? '新版' : '内容页'); const panel = document.createElement('div'); panel.id = 'unipus-panel'; panel.setAttribute('data-unipus-helper', '1'); panel.innerHTML = `
📚 U校园助手 v${Config.version} · ${envLabel}
⚠️ BUG 反馈 请加微信 C919irt 📋
📢
公告
×
▶ 任务设置
📖 起始目录
⏱ 总时长(分钟)
⚡ 常用设置
自动作答
📝 文本 AI(普通题目)
🎧 音频 AI(听力题) ⭐ 推荐千问 · 基本满分 智谱限流严,备选
音频 AI Key
🚀 加速模式(广告 / 解锁码)
扫码看广告获取解锁码
📱
扫码看广告
获取解锁码
当前题间 35 秒
解锁后 1.5 秒
📜 运行日志
`; document.body.appendChild(panel); this.panelEl = panel; this.menuSelectEl = panel.querySelector('#up-menu-select'); this.timeInputEl = panel.querySelector('#up-time-input'); this.startBtn = panel.querySelector('#up-start-btn'); this.pauseBtn = panel.querySelector('#up-pause-btn'); this.stopBtn = panel.querySelector('#up-stop-btn'); this.logEl = panel.querySelector('#unipus-log'); // 恢复输入 this.timeInputEl.value = Store.getLastTimeValue(); // 事件 panel.querySelector('#up-close-btn').onclick = () => { panel.style.display = 'none'; Store.setPanelOpen(false); }; // 最小化:收回悬浮球 panel.querySelector('#up-min-btn').onclick = () => { panel.style.display = 'none'; }; // 拖动面板 (() => { const header = panel.querySelector('.up-header'); let dragging = false, ox = 0, oy = 0; header.addEventListener('mousedown', (e) => { if (e.target.closest('.up-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 = e.clientX - ox; let y = e.clientY - oy; // 限制不超出视口 x = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, x)); y = Math.max(0, Math.min(window.innerHeight - 40, y)); panel.style.left = x + 'px'; panel.style.top = y + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', () => { dragging = false; }); })(); panel.querySelector('#up-settings-btn').onclick = () => this.openSettings(); panel.querySelector('#up-refresh-btn').onclick = () => { this.addLog('🔄 重新扫描目录...'); _menuListCache = []; window.__unipusDiagSent = false; window.__unipusHeurLogged = false; IframeRPC.requestScan(); }; panel.querySelector('#up-clear-log').onclick = () => { if (this.logEl) this.logEl.innerHTML = ''; }; // 微信号复制 const wechatEl = panel.querySelector('#up-wechat-copy'); if (wechatEl) { wechatEl.onclick = async () => { const wechat = 'C919irt'; let ok = false; try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(wechat); ok = true; } } catch (_) {} if (!ok) { // 回退到 execCommand try { const ta = document.createElement('textarea'); ta.value = wechat; ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none;'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); document.body.removeChild(ta); } catch (_) {} } wechatEl.classList.add('copied'); wechatEl.textContent = ok ? '✅ 已复制 C919irt' : '❌ 复制失败,请手动'; setTimeout(() => { wechatEl.classList.remove('copied'); wechatEl.textContent = 'C919irt 📋'; }, 1800); }; } // 常用设置自动保存 const autoAnswerEl = panel.querySelector('#up-autoAnswer'); autoAnswerEl.onchange = () => { const c = Store.getFeatureConf(); Store.setFeatureConf({ ...c, autoAnswer: autoAnswerEl.checked }); this.addLog(`✅ 自动作答:${autoAnswerEl.checked ? '开' : '关'}`); }; const aiProviderEl = panel.querySelector('#up-aiProvider'); const aiLinkEl = panel.querySelector('#up-ai-link'); const updateAILink = () => { const conf = AI_PROVIDERS[aiProviderEl.value]; if (!conf) return; aiLinkEl.href = conf.link; aiLinkEl.textContent = `${conf.linkText} · ${conf.keyHint}`; }; updateAILink(); aiProviderEl.onchange = () => { Store.setAIProvider(aiProviderEl.value); updateAILink(); this.addLog(`✅ AI 模型:${AI_PROVIDERS[aiProviderEl.value]?.name || aiProviderEl.value}`); }; const aiKeyEl = panel.querySelector('#up-aiKey'); aiKeyEl.onblur = () => { const v = aiKeyEl.value.trim(); if (v !== Store.getAIApiKey()) { Store.setAIApiKey(v); this.addLog('✅ 文本 AI Key 已保存'); } }; panel.querySelector('#up-key-toggle').onclick = () => { aiKeyEl.type = aiKeyEl.type === 'password' ? 'text' : 'password'; }; // ---- 音频 AI 控件 ---- const audioProviderEl = panel.querySelector('#up-audioProvider'); const audioLinkEl = panel.querySelector('#up-audio-link'); const updateAudioLink = () => { const conf = AI_PROVIDERS[audioProviderEl.value]; if (!conf) return; audioLinkEl.href = conf.link; audioLinkEl.textContent = `${conf.linkText} · ${conf.keyHint}`; }; updateAudioLink(); audioProviderEl.onchange = () => { Store.setAudioProvider(audioProviderEl.value); updateAudioLink(); this.addLog(`✅ 音频 AI:${AI_PROVIDERS[audioProviderEl.value]?.name || audioProviderEl.value}`); }; const audioKeyEl = panel.querySelector('#up-audioKey'); audioKeyEl.onblur = () => { const v = audioKeyEl.value.trim(); if (v !== Store.getAudioApiKey()) { Store.setAudioApiKey(v); this.addLog('✅ 音频 AI Key 已保存'); } }; panel.querySelector('#up-audio-key-toggle').onclick = () => { audioKeyEl.type = audioKeyEl.type === 'password' ? 'text' : 'password'; }; // ---- 广告模式 / 解锁码 ---- const unlockInput = panel.querySelector('#up-unlock-input'); const unlockBtn = panel.querySelector('#up-unlock-btn'); unlockBtn.onclick = async () => { const code = unlockInput.value.trim(); if (!/^\d{4}$/.test(code)) { this.addLog('⚠️ 请输入4位数字解锁码'); unlockInput.focus(); return; } unlockBtn.disabled = true; unlockBtn.textContent = '验证中...'; try { const r = await Solver.verifyUnlockCode(code); Store.setVerifyValidUntil(r.validUntil, code, r.sessionToken || '', r.scope || 'ai-unlock'); unlockInput.value = ''; this.addLog(`✅ 解锁成功!有效期至 ${r.validUntilStr}`); this.refreshUnlockUI(); } catch (err) { this.addLog(`❌ 解锁失败:${err}`); } finally { unlockBtn.disabled = false; unlockBtn.textContent = '🔓 解锁'; } }; unlockInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') unlockBtn.click(); }); panel.querySelector('#up-unlock-clear').onclick = () => { if (confirm('确定注销当前解锁?')) { Store.clearVerifySession(); this.addLog('✅ 已注销解锁,恢复广告模式'); this.refreshUnlockUI(); } }; this.refreshUnlockUI(); // 每秒刷新一次倒计时 if (this._unlockTimer) clearInterval(this._unlockTimer); this._unlockTimer = setInterval(() => this.refreshUnlockUI(), 1000); this.startBtn.onclick = () => this.onStart(); this.menuSelectEl.onchange = () => this.rememberMenuSelection(); this.pauseBtn.onclick = () => this.onPause(); this.stopBtn.onclick = () => this.onStop(); // 监听菜单就绪 window.addEventListener('UNIPUS_MENU_READY', (e) => { this.populateMenu(e.detail); }); if (_menuListCache.length) this.populateMenu(_menuListCache); else this.populateMenu([]); // 监听日志(去重防止 panel 重建时重复绑定) if (!Panel._logBound) { Panel._logBound = true; IframeRPC.onLog((msg) => this.addLog(msg)); } // 远程公告 this._fetchAnnounce(panel); }, _fetchAnnounce(panel) { if (typeof GM_xmlhttpRequest !== 'function') return; try { GM_xmlhttpRequest({ method: 'GET', url: Config.announceUrl, timeout: 8000, onload: (resp) => { try { const res = JSON.parse(resp.responseText); if (res.code !== 1 || !res.data || !res.data.enabled) return; this._renderAnnounce(panel, res.data); } catch (_) {} }, onerror: () => {}, ontimeout: () => {}, }); } catch (_) {} }, _renderAnnounce(panel, data) { const root = panel.querySelector('#up-announce'); const titleEl = panel.querySelector('#up-announce-title'); const contentEl = panel.querySelector('#up-announce-content'); const closeEl = panel.querySelector('#up-announce-close'); const iconEl = root && root.querySelector('.up-announce-icon'); if (!root || !titleEl || !contentEl) return; const type = ['info','warning','error','success'].includes(data.type) ? data.type : 'info'; const icon = { info: '📢', warning: '⚠️', error: '❌', success: '✅' }[type]; if (iconEl) iconEl.textContent = icon; titleEl.textContent = data.title || '公告'; let rawContent = String(data.content || ''); let noticeImages = []; if (Array.isArray(data.images)) { noticeImages = data.images.slice(); } else if (typeof data.images === 'string' && data.images.trim()) { try { const parsed = JSON.parse(data.images); noticeImages = Array.isArray(parsed) ? parsed : [data.images]; } catch (_) { noticeImages = data.images.split(/[\n,]+/); } } if (data.image_url) noticeImages.push(data.image_url); rawContent = rawContent.replace(/!\[[^\]]*\]\(([^)]+)\)/g, (_, url) => { noticeImages.push(url); return ''; }).trim(); const normalizeImageUrl = (url) => { url = String(url || '').trim(); if (!url) return ''; if (/^https?:\/\//i.test(url) || /^data:image\//i.test(url)) return url; if (/^\/\//.test(url)) return 'https:' + url; try { return new URL(url, 'https://qsy.iano.cn/').href; } catch (_) {} return ''; }; noticeImages = noticeImages.map(normalizeImageUrl).filter((url, idx, arr) => url && arr.indexOf(url) === idx).slice(0, 6); // 简易白名单 sanitizer const tmp = document.createElement('div'); tmp.innerHTML = rawContent; tmp.querySelectorAll('*').forEach(el => { const tag = el.tagName.toLowerCase(); if (!['br','b','i','u','strong','em','span'].includes(tag)) { el.replaceWith(document.createTextNode(el.textContent || '')); return; } [...el.attributes].forEach(attr => { if (/^on/i.test(attr.name) || attr.name === 'style') el.removeAttribute(attr.name); }); }); contentEl.innerHTML = tmp.innerHTML; if (noticeImages.length) { const imageBox = document.createElement('div'); imageBox.className = 'up-announce-images'; noticeImages.forEach(url => { const img = document.createElement('img'); img.src = url; img.alt = '公告图片'; img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; img.addEventListener('click', () => { try { window.open(url, '_blank', 'noopener,noreferrer'); } catch (_) {} }); imageBox.appendChild(img); }); contentEl.appendChild(imageBox); } root.classList.remove('info','warning','error','success'); root.classList.add(type, 'show'); if (closeEl) closeEl.onclick = () => { root.classList.remove('show'); }; }, _purposeResolver: null, _getPurposeOverlay() { this.injectStyles(); let overlay = document.getElementById('unipus-purpose-overlay'); if (overlay) return overlay; overlay = document.createElement('div'); overlay.id = 'unipus-purpose-overlay'; overlay.setAttribute('data-unipus-helper', '1'); overlay.innerHTML = ` `; document.body.appendChild(overlay); const scroll = overlay.querySelector('#up-purpose-scroll'); const check = overlay.querySelector('#up-purpose-check'); const confirm = overlay.querySelector('#up-purpose-confirm'); const hint = overlay.querySelector('#up-purpose-hint'); const update = () => { if (confirm) confirm.disabled = !check || check.disabled || !check.checked; }; const markRead = () => { if (!scroll || !check) return; const bottomReached = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 4; if (bottomReached) { check.disabled = false; if (hint) hint.textContent = '已阅读到末尾,可以勾选确认。'; } update(); }; const close = (confirmed) => { overlay.style.display = 'none'; if (this._purposeResolver) { const resolve = this._purposeResolver; this._purposeResolver = null; resolve(confirmed); } }; scroll?.addEventListener('scroll', markRead); check?.addEventListener('change', update); overlay.querySelector('#up-purpose-cancel')?.addEventListener('click', () => close(false)); confirm?.addEventListener('click', () => { if (!confirm.disabled) close(true); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && overlay.style.display === 'flex') close(false); }); overlay._unipusPurposeReset = () => { if (check) { check.checked = false; check.disabled = true; } if (confirm) confirm.disabled = true; if (hint) hint.textContent = '请先阅读到末尾,再勾选确认。'; if (scroll) { scroll.scrollTop = 0; setTimeout(() => { markRead(); try { scroll.focus(); } catch (_) {} }, 0); } }; return overlay; }, async ensureStudyPurposeConfirmed() { if (Store.isStudyPurposeConfirmed()) return true; const overlay = this._getPurposeOverlay(); if (this._purposeResolver) { return new Promise(resolve => { const previous = this._purposeResolver; this._purposeResolver = confirmed => { previous(confirmed); resolve(confirmed); }; }); } return new Promise(resolve => { this._purposeResolver = (confirmed) => { if (confirmed) Store.setStudyPurposeConfirmed(); resolve(confirmed); }; if (typeof overlay._unipusPurposeReset === 'function') overlay._unipusPurposeReset(); overlay.style.display = 'flex'; }); }, async onStart() { if (isRunning || this._starting) { this.addLog('⚠️ 已经在启动或运行中...'); return; } this._starting = true; this._startCancelled = false; this._startCount = (this._startCount || 0) + 1; try { await this._startSelectedMenu(); } finally { this._starting = false; } }, async _startSelectedMenu() { if (!this.panelEl) this.createPanel(); // 在用途确认/扫描 await 之前记录起点,重绘不能改变本次任务的选择。 const requestedKey = menuItemIdentity(this.rememberMenuSelection()) || this._selectedMenuKey; const confirmed = await this.ensureStudyPurposeConfirmed(); if (!confirmed || this._startCancelled) { this.addLog('已取消启动:本功能只能用于课程复习和学习回看'); return; } // 如果在 u.unipus.cn 目录页,自动点击"继续学习"进入内容页 if (IS_LEGACY && location.pathname.includes('courseCatalog')) { const continueBtn = document.querySelector('a[class*="continue"], button[class*="continue"], a.start-btn, .course-info a, a[href*="ucontent"]'); // 也尝试按文本找 if (!continueBtn) { let found = null; document.querySelectorAll('a, button').forEach(el => { const t = (el.innerText || '').trim(); if (/^(继续学习|开始学习|进入课程|Start)$/i.test(t)) found = el; }); if (found) { this.addLog('🔗 检测到目录页,正在跳转到课程内容...'); found.click(); return; } } else { this.addLog('🔗 检测到目录页,正在跳转到课程内容...'); continueBtn.click(); return; } this.addLog('⚠️ 当前在目录页,请先点击"继续学习"进入课程内容页面'); return; } if (!_menuListCache.length) { this.addLog('⚠️ 目录未加载,正在重新扫描...'); await IframeRPC.requestScan(); await Utils.sleep(1500); if (!_menuListCache.length) { this.addLog('⚠️ 目录扫描失败,请确认已进入课程内容页面'); return; } } if (this._startCancelled) return; const startKey = requestedKey || menuItemIdentity(this.getSelectedMenuItem()); const matches = _menuListCache.map((item, index) => menuItemIdentity(item) === startKey ? index : -1).filter(index => index >= 0); if (!startKey || matches.length !== 1) { this.addLog('⚠️ 起始章节无法唯一定位,请展开对应单元并重新选择目录项'); return; } const startIdx = matches[0]; const parsedMin = parseInt(this.timeInputEl.value || '60', 10); const totalMin = Number.isFinite(parsedMin) ? Math.max(1, parsedMin) : 60; Store.setLastTimeValue(totalMin); this.startBtn.style.display = 'none'; this.pauseBtn.style.display = 'inline-flex'; this.pauseBtn.textContent = '⏸ 暂停'; this.pauseBtn.className = 'up-btn up-btn-warn'; this.stopBtn.style.display = 'inline-flex'; this.setStatus('running'); try { const completed = await MenuRunner.run(_menuListCache.slice(), totalMin, startIdx, (msg, isCD) => this.addLog(msg, isCD)); this.setStatus(completed ? 'ok' : 'idle'); } catch (e) { this.addLog(`❌ 运行异常:${e.message || e}`); this.setStatus('error'); } finally { this.startBtn.style.display = 'inline-flex'; this.pauseBtn.style.display = 'none'; this.stopBtn.style.display = 'none'; isPaused = false; } }, async onPause() { if (this._pausePending || stopRequested) return; this._pausePending = true; this.pauseBtn.disabled = true; const paused = !isPaused; isPaused = paused; try { const result = await IframeRPC.setPaused(paused); if (stopRequested) return; if (!result?.ok || result.paused !== paused) throw new Error(result?.err || '课程页面未确认暂停状态'); if (paused) { this.pauseBtn.textContent = '▶ 继续'; this.pauseBtn.className = 'up-btn up-btn-success'; this.setStatus('paused'); this.addLog('⏸ 已暂停(再次点击继续)'); } else { this.pauseBtn.textContent = '⏸ 暂停'; this.pauseBtn.className = 'up-btn up-btn-warn'; this.setStatus('running'); this.addLog('▶ 继续运行'); } } catch (error) { this.addLog(`⚠️ 暂停状态同步失败:${error.message || error},正在停止任务`); await this.onStop(); } finally { this._pausePending = false; this.pauseBtn.disabled = false; } }, async onStop() { this.addLog('⏹ 正在停止...'); this._startCancelled = true; stopRequested = true; isPaused = false; this.setStatus('idle'); try { await IframeRPC.stop(); } catch (_) {} }, openSettings() { if (this.settingsPanel) { this.settingsPanel.style.display = 'flex'; return; } const cfg = Store.getFeatureConf(); const overlay = document.createElement('div'); overlay.id = 'unipus-settings-overlay'; overlay.setAttribute('data-unipus-helper', '1'); const rate = Store.getPlaybackRate(); overlay.innerHTML = `

⚙️ 高级设置

媒体播放
视频/音频倍速
自动化行为
自动关闭"我知道了"等弹窗
数据管理
`; document.body.appendChild(overlay); this.settingsPanel = overlay; const close = () => { overlay.remove(); this.settingsPanel = null; }; overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay.querySelector('#cfg-close').onclick = close; overlay.querySelector('#cfg-cancel').onclick = close; overlay.querySelector('#cfg-reset-key').onclick = () => { if (confirm('确定清空所有 API Key(文本 + 音频)?')) { Store.setAIApiKey(''); Store.setAudioApiKey(''); this.addLog('✅ API Key 已清空'); // 同步主面板 const k = this.panelEl?.querySelector('#up-aiKey'); if (k) k.value = ''; const k2 = this.panelEl?.querySelector('#up-audioKey'); if (k2) k2.value = ''; } }; overlay.querySelector('#cfg-reset-all').onclick = () => { if (confirm('确定重置所有设置?(不可恢复)')) { Object.values(Config.storageKeys).forEach(k => { try { localStorage.removeItem(k); } catch (_) {} try { GM_setValue(k, ''); } catch (_) {} }); Store.markRuntimeConfigCleared(); this.addLog('✅ 已重置,请刷新页面'); close(); } }; overlay.querySelector('#cfg-save').onclick = () => { const newConf = { autoAnswer: Store.getFeatureConf().autoAnswer, autoPopup: overlay.querySelector('#cfg-autoPopup').checked, }; Store.setFeatureConf(newConf); Store.setPlaybackRate(parseFloat(overlay.querySelector('#cfg-playbackRate').value)); this.addLog('✅ 高级设置已保存'); close(); }; }, init() { window.__unipusPanel = this; this.createBall(); if (Store.getPanelOpen()) { this.createPanel(); } this.addLog?.(`✨ U校园刷课助手 v${Config.version} 已就绪`); }, }; // ---- 早期返回:iframe 子页面 ---- if (IS_IFRAME && IS_UCONTENT) { setupIframeChild(); console.log('[U校园助手] iframe 子页面已激活'); return; } // ---- 主页面(ipub/uai/普通版):初始化 ---- if (IS_MAIN || (!IS_IFRAME && IS_UCONTENT)) { const envName = IS_LEGACY ? '普通版' : (IS_IPUB ? '新版(ipub/uai)' : 'ucontent'); // 等待 DOM 准备 const initMainPage = () => { try { IframeRPC.init(); Panel.init(); // 定时自动关闭弹窗 setInterval(clickIKnow, 1500); const _log = (msg) => { console.log('[U校园助手]', msg); Panel.addLog?.(msg); }; _log(`🌐 环境: ${envName} | host: ${location.hostname}`); // 诊断:检查 iframe 状况 const iframes = document.querySelectorAll('iframe'); _log(`📦 iframe 数量: ${iframes.length}${iframes.length ? (' | src: ' + [...iframes].map(f => (f.src || '').slice(0, 60)).join(', ')) : ''}`); const iw = IframeRPC.getIframeWin(); if (iw) { _log('✅ 已找到 ucontent iframe,通过 postMessage 通信'); } else { _log('⚠️ 未找到 iframe,将本地扫描当前页面目录'); } // 首次延迟扫描 const _scanDelay = IS_LEGACY ? 2500 : 3000; let _autoStarted = false; const _tryAutoStart = () => { if (_autoStarted || !_menuListCache.length || isRunning || Panel._starting) return; // 在 ucontent 内容页自动开始刷课(不在目录页) if (IS_UCONTENT || (IS_LEGACY && !location.pathname.includes('courseCatalog'))) { _autoStarted = true; _log(`⏳ 已准备自动开始,等待用途确认...`); if (!Panel.panelEl) Panel.createPanel(); const startCount = Panel._startCount || 0; setTimeout(async () => { if (isRunning || Panel._starting || (Panel._startCount || 0) !== startCount) return; _log(`🚀 自动开始刷课...`); // 最小化面板到悬浮球 if (Panel.panelEl) Panel.panelEl.style.display = 'none'; Store.setPanelOpen(false); await Panel.onStart(); }, 500); } }; setTimeout(() => { IframeRPC.requestScan().then(r => { if (_menuListCache.length) { _log(`✅ 目录扫描成功: ${_menuListCache.length} 项`); _tryAutoStart(); } else { _log('⚠️ 首次扫描未找到目录项,等待重试...'); } }).catch(() => {}); }, _scanDelay); // 每 3 秒再试一次,最多重试 8 次(共 ~27s) let _scanRetry = 0; const _scanTimer = setInterval(() => { if (_menuListCache.length || ++_scanRetry > 8) { clearInterval(_scanTimer); if (_menuListCache.length) { _tryAutoStart(); } else { _log('❌ 目录扫描失败(8次重试后仍未找到)。请确认已进入课程学习页面(点击"开始学习")。'); } return; } IframeRPC.requestScan().catch(() => {}); }, 3000); console.log(`[U校园助手] 主页面已初始化(${envName})`); } catch (e) { console.error('[U校园助手] 初始化失败:', e); } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initMainPage); } else { initMainPage(); } } else { console.log('[U校园助手] 当前页面不属于支持范围'); } })();