// ==UserScript== // @name 智慧树刷课助手 1.5.5 // @namespace https://github.com/CXRunfree/Autovisor // @version 1.5.5 // @description 智慧树普通与 AI 课程自动播放、切换资源,支持开始/暂停/继续答题、普通作业自动下一题与提交、模型搜索、公式图片题和具体失败原因提示 // @author 叶屿 // @license GPL3 // @antifeature payment 题库答题功能需要激活码(付费),视频播放等基础功能完全免费 // @match *://passport.zhihuishu.com/* // @match *://studyh5.zhihuishu.com/* // @match *://studyvideoh5.zhihuishu.com/* // @match *://fusioncourseh5.zhihuishu.com/* // @match *://hike.zhihuishu.com/* // @match *://www.zhihuishu.com/* // @match *://studyservice-api.zhihuishu.com/* // @match *://examh5.zhihuishu.com/* // @match *://onlineexamh5new.zhihuishu.com/* // @match *://studentexambaseh5.zhihuishu.com/* // @match *://studentexamcomh5.zhihuishu.com/studentReviewTestOrExam/* // @match *://ai-smart-course-student-pro.zhihuishu.com/point/* // @match *://ai-smart-course-student-pro.zhihuishu.com/examPreview/* // @match *://ai-smart-course-student-pro.zhihuishu.com/singleCourse/knowledgeStudy/* // @match *://ai.zhihuishu.com/AIstudent/* // @match *://ai-smart-course-student-pro.zhihuishu.com/learnPage/* // @match *://hike-teaching-center.polymas.com/stu-hike/agent-course-hike/ai-course-center* // @match *://onlineweb.zhihuishu.com/* // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @connect qsy.iano.cn // @connect lyck6.cn // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // v1.5.5 (2026-10-18):恢复旧版卡片式搜题结果布局,封闭题干不再自动展开手动输入区。 // v1.5.4 (2026-10-18):恢复旧版一键搜题/一键填入流程;封闭题干优先尝试剪贴板并保留手动填入兜底。 // v1.5.3 (2026-10-18):恢复封闭题干场景的手动搜题、读取剪贴板和填入当前题;自动流程仍在无法读取正文时安全暂停。 // v1.5.2 (2026-10-18):适配智慧树普通作业封闭题干组件;无法读取题干时明确诊断并停止查询,防止把题号误当题干。 // v1.5.1 (2026-10-18):空占位不再阻断题目识别,新增有界题干/题型结构回退,隐藏旧选项按点击边界过滤; // 支持无自身尺寸的题根与选项,作答区域就绪后才查询,首次与翻题加载最长等待30秒,等待期间显示平台总题数。 // v1.5.0 (2026-10-18):修复普通作业选中状态位于外层标签/选项行时误判未填入,异步回写等待确认后自动下一题; // 排除整卷 questionContent 包装器和嵌套正文碎片,避免整页公式图被当成一道题;补齐普通题号和分页控件识别。 // v1.4.12 (2026-10-18):修复题根直接正文、内层同名容器和短题干导致的题干/公式选项丢失,排除隐藏旧题; // 图片能力、下载、接口配置及网络错误展示具体原因,只有真正无答案才计入未找到;失败卡片改为红色。 // v1.4.11 (2026-10-18):答题改为明确的开始/暂停/继续按钮,保留已填答案、计数与查询结果; // 翻题和提交等待中暂停后继续仅等待原操作,不重复点击;跳过答题改为略过目录中的作业/测试章节; // 修复普通作业题干误取选项、公式配图丢失和题目总数识别,随堂弹题保持独立设置。 // v1.4.10 (2026-10-18):学习与自动答题合并为单面板,支持标签切换、悬浮球收起与位置记忆; // 新增跳过答题,立即停止旧请求的后续作答,只使用平台明确允许的跳过入口; // 取消跳过需重新开启答题,模型设置按标签同步且保留未保存草稿,优化窄屏与键盘操作。 // v1.4.9 (2026-10-18):独立题页改为勾选自动答题与答完自动提交,移除手动搜题/填入按钮; // 自动查询、验证填入、切换下一题并确认交卷,支持同页/分页及 hash 分页; // 修正多选错选、已选答案和多空完整性,停止/换题/换来源后忽略旧响应,避免重复查询或提交。 // v1.4.8 (2026-10-18):保留平台容器与主题按钮,题干按纯文本展示,公告隔离平台样式; // 正常课时链接保留原生路由兜底,避免切课后停在空壳; // 安全提示仅检查可见内容,不以隐藏模板或正文引用误停任务。 // v1.4.7 (2026-10-18):普通/AI 目录统一检查祖先首页及退出链接,识别非根路径 hash 首页; // 新版共享课按具体课时切换,取消额外鼠标事件和默认表单提交,保留正常 Vue 路由; // 保留时长/完成提醒,首页进入待命,切换未确认时暂停重试并记录目标课时。 // v1.4.6 (2026-10-18):学习/考试面板新增模型列表获取、名称与 ID 搜索、完整名称展示; // 支持题干及选项配图和图片能力检测,按来源、Key、接口与模型隔离能力状态; // 修复自动切集误触目录中的首页链接,优化模型选项对比度和窄屏布局。 // v1.4.5 (2026-10-17):公告请求按面板合并,替换显示并保留关闭状态,忽略已销毁面板的旧响应。 // v1.4.4 (2026-10-17):适配无标准弹窗类名的 AI 随堂练习及自定义选项; // 题后仅使用明确的继续学习操作,防止通用关闭图标/表单默认提交导致退出; // 当前视频未结束不按历史 100% 切课,安全提示出现后停止任务,停用合成鼠标事件。 // v1.4.3 (2026-10-15):助教随堂题可独立选择 AI、免费题库或付费题库; // 学习面板补齐验证码、付费激活及余额配置,不自动切换来源; // 请求期间持续校验免费有效期,同题重绘复用查询结果,换题/切换来源/停止取消旧流程。 // v1.4.2 (2026-10-15):保留平台根节点和异步弹层,避免白屏及误触退出; // AI 课程按实际播放结束切课,防旧视频状态串课;助教题接入 AI 并确认提交。 // 新增学习页 AI 配置入口,修复异步换题、SPA 切页和停止/重启生命周期。 /* * ========================================== * 免责声明 / Disclaimer * ========================================== * * 本脚本仅供个人学习和技术研究使用,请勿用于任何违反 * 学校规章制度、考试纪律或法律法规的行为。 * * 使用本脚本即表示您同意以下条款: * 1. 本脚本不鼓励、不支持任何形式的考试作弊行为 * 2. 使用者应自行承担使用本脚本产生的一切后果 * 3. 作者不对因使用本脚本而导致的任何直接或间接 * 损失(包括但不限于学业处分、账号封禁等)承担责任 * 4. 本脚本不保证题库答案的准确性和完整性 * 5. 请遵守《中华人民共和国教育法》及相关法律法规 * 6. 请遵守所在学校的学术诚信规范 * * 如您不同意以上条款,请立即停止使用并删除本脚本。 * * This script is for personal learning and technical * research purposes only. The author assumes no * responsibility for any consequences. * ========================================== */ // ===================== 配置区 ===================== const DEFAULT_CONFIG = { playbackSpeed: 1.5, // 播放倍速 (0.5 ~ 1.8) soundOff: true, // 是否静音 autoNext: true, // 自动切换下一集 autoAnswer: true, // 自动处理随堂弹题(AI 助教使用用户配置) autoPlay: true, // 自动播放 & 暂停检测 autoExam: false, // 点击开始/继续后自动查询、填入、下一题 examAutoSubmit: true, // 全部题目确认完成后自动提交 skipAnswer: false, // 自动切课时跳过作业/测试等答题资源 showPanel: true, // 显示悬浮面板 }; // 从存储读取或使用默认配置 function loadConfig() { const saved = GM_getValue('autovisor_config', null); if (saved) { const loaded = Object.assign({}, DEFAULT_CONFIG, saved); if (loaded.skipAnswer) loaded.autoExam = false; return loaded; } return Object.assign({}, DEFAULT_CONFIG); } function saveConfig(cfg) { GM_setValue('autovisor_config', cfg); } let config = loadConfig(); // ===================== 日志系统 ===================== const LOG_PREFIX = '[智慧树助手]'; const log = { info: (msg) => console.log(`%c${LOG_PREFIX} ${msg}`, 'color:#409EFF;font-weight:bold;'), warn: (msg) => console.warn(`%c${LOG_PREFIX} ${msg}`, 'color:#E6A23C;font-weight:bold;'), error: (msg) => console.error(`%c${LOG_PREFIX} ${msg}`, 'color:#F56C6C;font-weight:bold;'), success: (msg) => console.log(`%c${LOG_PREFIX} ${msg}`, 'color:#67C23A;font-weight:bold;'), }; // ===================== 工具函数 ===================== function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } function $(selector) { return document.querySelector(selector); } function $$(selector) { return document.querySelectorAll(selector); } function safeClick(selector) { const el = typeof selector === 'string' ? $(selector) : selector; if (el) { el.click(); return true; } return false; } // hash 路由可以挂在 /stuStudy 等非根路径,不能只检查 pathname。 function isRootNavigationHref(href) { if (!href) return false; try { const target = new URL(href, location.href); if (target.origin !== location.origin) return false; const path = (target.pathname || '/').replace(/\/+$/, '') || '/'; const hashPath = target.hash.replace(/^#/, '').split('?')[0].replace(/\/+$/, '') || '/'; if (target.hash) return /^(?:\/|\/?home|\/?index|\/?index\.html)$/i.test(hashPath); return /^(?:\/|\/index(?:\.html)?|\/home)$/i.test(path); } catch (e) { return false; } } function hasUnsafeStudyLink(control) { // 检查实际事件传播路径上的链接,不能漏掉目录项外层的 ; // 卡片内部的无关链接不在传播路径上,不影响点击卡片本身。 for (let node = control; node; node = node.parentElement) { if (!node.matches?.('a[href]')) continue; const href = String(node.getAttribute('href') || '').trim(); if (!href || href === '#' || /^javascript:\s*(?:void\s*\(\s*0\s*\)\s*;?|;?)$/i.test(href)) continue; try { const target = new URL(href, location.href); const route = `${target.pathname}/${target.hash.replace(/^#\/?/, '')}`; if (!/^https?:$/.test(target.protocol) || target.origin !== location.origin || isRootNavigationHref(target.href) || /\/(?:login|logout|passport)(?:\/|[?#]|$)/i.test(route)) return true; } catch (_) { return true; } } return false; } function dispatchStudyClick(control) { if (!control?.isConnected || control.closest?.('[disabled], [aria-disabled="true"]') || hasUnsafeStudyLink(control)) return false; // 只发一次 click。额外的按下/抬起/悬浮事件会触发另一组平台回调; // 有效课时链接交给 Vue/React 路由监听器或浏览器原生跳转; // 过早 preventDefault 会被 RouterLink 当成已处理而拒绝切课。 const click = new MouseEvent('click', { bubbles: true, cancelable: true, view: window }); const anchor = control.closest('a[href]'); const href = String(anchor?.getAttribute('href') || '').trim(); const placeholder = !!anchor && (!href || href === '#' || /^javascript:/i.test(href)); // 有效课时链接保留原生跳转作为兜底;RouterLink 处理时会自行取消。 // 无条件阻断会让依赖 href/hash 的页面停在已卸载内容的空壳上。 const guard = event => { if (event === click && (!anchor || placeholder)) event.preventDefault(); }; // 占位链接没有有效课时路由,需提前取消,覆盖平台 stopPropagation。 if (placeholder) control.addEventListener('click', guard, true); window.addEventListener('click', guard); const form = control.closest('button, input')?.form || control.closest('form'); const blockSubmit = event => { event.preventDefault(); event.stopImmediatePropagation(); }; // 即使 click 被平台中途停止冒泡,默认提交也不能运行表单退出回调。 form?.addEventListener('submit', blockSubmit, true); try { control.dispatchEvent(click); return true; } catch (_) { return false; } finally { control.removeEventListener('click', guard, true); window.removeEventListener('click', guard); form?.removeEventListener('submit', blockSubmit, true); } } function triggerLessonClick(item) { if (!item || !item.isConnected) return false; // 保持课时节点为 event.target,不提升到外层的路由链接/提交按钮。 if (hasUnsafeStudyLink(item)) { log.warn('切换已取消:目录入口关联首页、退出或外站链接'); return false; } try { item.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'auto' }); } catch (_) {} return dispatchStudyClick(item); } // 判断页面类型 function isFusionClass() { return location.href.includes('fusioncourseh5.zhihuishu.com') || location.href.includes('fusioncourseh5') || !!document.querySelector('.chapter-item'); } function isNewVersion() { return isFusionClass(); } function isHikeClass() { return location.href.includes('hike.zhihuishu.com'); } // 智慧树 AI 课程使用独立的 learnPage/singleCourse 路由,目录和资源卡 // 也不再复用普通共享课的 .child-info/.clearfix.video 结构。这里只做 // 页面和 DOM 适配,不直接请求课程接口,避免影响平台自身的数据流。 function isAICourseClass() { const host = location.hostname || ''; const path = location.pathname || ''; return host === 'ai-smart-course-student-pro.zhihuishu.com' && (/\/learnPage\//.test(path) || /\/singleCourse\/knowledgeStudy\//.test(path)) || host === 'ai.zhihuishu.com' && /^\/AIstudent(?:\/|$)/i.test(path) || host === 'hike-teaching-center.polymas.com' && path.includes('/stu-hike/agent-course-hike/ai-course-center') || host === 'onlineweb.zhihuishu.com' && /(learn|study|course)/i.test(path); } function getAICourseResourceItems() { const selectors = [ '.resources-list .basic-info-video-card-container', '.resources-list [class*="basic-info"][class*="card-container"]', '.basic-info-video-card-container', '[class*="basic-info"][class*="card-container"]', '.resources-list [data-resources-uid]', '.resources-list [data-resource-uid]', '.resource-list .resource-card', '.resource-card[data-uid]', ]; const seen = new Set(); const items = []; selectors.forEach(selector => { document.querySelectorAll(selector).forEach(item => { if (!seen.has(item)) { seen.add(item); items.push(item); } }); }); // 同一张卡片的外层和 data-resource-* 子节点可能同时命中。 // 保持页面顺序并只保留外层卡片,避免一次切换被当成两课。 return items.filter(item => !items.some(other => other !== item && other.contains(item))) .sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING ? 1 : -1); } function getAICoursePointItems() { const selectors = [ '.section-item-collapse-info', '.collapse-item-sub', '[data-knowledge-id]', '[data-point-id]', ]; const seen = new Set(); const items = []; selectors.forEach(selector => { document.querySelectorAll(selector).forEach(item => { if (!seen.has(item)) { seen.add(item); items.push(item); } }); }); return items.filter(item => !items.some(other => other !== item && item.contains(other))) .sort((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_PRECEDING ? 1 : -1); } function getAICourseItems() { const resources = getAICourseResourceItems(); if (resources.length) return resources; return getAICoursePointItems(); } function getAICourseItemProgress(item) { if (!item) return 0; const studyStatus = item.getAttribute('data-study-status'); const status = studyStatus || item.getAttribute('data-status') || item.getAttribute('data-learn-status'); if (studyStatus === '1' || /^(done|finished|completed|已学|已完成)$/i.test(String(status || '').trim())) return 100; // schedule 可能是已观看秒数,通用 data-status=1 也可能仅代表可用状态。 const attrNames = ['data-progress', 'data-percent', 'aria-valuenow']; for (const name of attrNames) { const raw = item.getAttribute(name); if (raw !== null && /^\s*\d+(?:\.\d+)?\s*%?\s*$/.test(raw)) return parseProgress(raw); } const progress = item.querySelector('[aria-valuenow], [data-progress], [class*="progress"]'); if (progress) { const raw = progress.getAttribute('aria-valuenow') || progress.getAttribute('data-progress') || progress.textContent; if (/^\s*\d+(?:\.\d+)?\s*%?\s*$/.test(raw)) return parseProgress(raw); } const statusNodes = [item, ...item.querySelectorAll('[class*="status"], [class*="progress"], .tag, .el-tag')]; if (statusNodes.some(node => /^(?:已学|已完成|学习完成|完成学习|100%)$/.test((node.textContent || '').trim()))) return 100; return 0; } function isAICourseItemComplete(item) { return getAICourseItemProgress(item) >= 100; } function getActiveAICourseItem() { const items = getAICourseItems(); if (!items.length) return null; const activeSelectors = [ '.active', '.current', '.selected', '.is-active', '.current-resource', '[aria-current="true"]', '[aria-selected="true"]', '[data-active="true"]', ]; const marked = items.filter(item => activeSelectors.some(selector => item.matches(selector)) || activeSelectors.slice(4).some(selector => item.querySelector(selector))); if (marked.length === 1) return marked[0]; // 多个目录都留有 active 时不能默认采用第一张旧卡片。 if (marked.length > 1) { const visible = marked.filter(isElementVisible); return visible.length === 1 ? visible[0] : null; } const video = Array.from($$('video')).find(isElementVisible); if (video) { let current = video.closest('.basic-info-video-card-container, [class*="resource"], .resources-list'); if (current && !items.includes(current)) { current = items.find(item => item.contains(video) || item.contains(current)); } if (current) return current; } return null; } function isStudyPage() { if (isRootNavigationHref(location.href)) return false; return /^(?:studyh5|studyvideoh5|fusioncourseh5|hike)\.zhihuishu\.com$/i.test(location.hostname) || isAICourseClass(); } function isExamPage() { const url = location.href; const aiExamHost = /ai-smart-course-student-pro\.zhihuishu\.com|studentexamcomh5\.zhihuishu\.com|ai\.zhihuishu\.com/i.test(location.hostname); const aiExamRoute = /\/studentReviewTestOrExam\/|\/examPreview\/|\/point\//i.test(location.pathname); // 只匹配实际做题页面,不匹配作业/考试列表页 return url.includes('examh5.zhihuishu.com') || url.includes('onlineexamh5new.zhihuishu.com') || url.includes('studentexambaseh5.zhihuishu.com') || url.includes('/stuExamWeb.html') || url.includes('/webExamList/dohomework/') || url.includes('/webExamList/doexamination/') || (aiExamHost && aiExamRoute) || // 通用: 页面内存在题目容器(延迟检测) (url.includes('zhihuishu.com') && hasStandaloneExamQuestion('.examPaper_subject, .questionBox, .ques-detail')) || (aiExamHost && hasStandaloneExamQuestion('.questionContent .el-radio-group, .questionContent .el-checkbox-group, .questionContent ul.radio-view, .questionContent ul.checkbox-view')); } function hasStandaloneExamQuestion(selector) { return Array.from(document.querySelectorAll(selector)).some(node => isElementVisible(node) && !node.closest('.el-dialog, .el-message-box, [role="dialog"], .dialog-test, .ai-class-exercise-dialog') && !getStudyExerciseDialog()?.contains(node)); } function isAiExamHost() { const hostMatched = /ai-smart-course-student-pro\.zhihuishu\.com|studentexamcomh5\.zhihuishu\.com|ai\.zhihuishu\.com/i.test(location.hostname); if (!hostMatched) return false; if (/\/studentReviewTestOrExam\/|\/examPreview\/|\/point\//i.test(location.pathname)) return true; // AI 课程把练习嵌在 singleCourse/knowledgeStudy 路由中,题目通常异步挂载。 // 只有检测到题目容器时才切到 AI 答题模式,避免普通学习页误显示答题面板。 return /\/singleCourse\/knowledgeStudy\//i.test(location.pathname) && hasStandaloneExamQuestion('.questionContent, .questionTitle, .exam-preview, [class*="question-content"]'); } // ===================== 平台提示保护 ===================== // 平台根节点和弹层都由框架管理,不能根据后代文字删除/隐藏它们。 // 错误提示的确认/关闭回调也可能导航到首页,因此只暂停自动操作。 const PLATFORM_SAFETY_PATTERN = /异常脚本|关闭插件|异常行为|安全验证|身份验证/; const PLATFORM_SAFETY_MESSAGE_PATTERN = /^(?:[⚠️!!\s]*|(?:温馨)?提示[::]?)(?:检测到(?:异常脚本|异常行为)|(?:请|先|需要|手动).{0,12}关闭插件|(?:请|需要|进行|完成).{0,12}(?:安全验证|身份验证))/; function isPlatformNoticeHidden(node) { if (node.hidden || node.getAttribute('aria-hidden') === 'true') return true; const style = getComputedStyle(node); return style.display === 'none' || /^(?:hidden|collapse)$/.test(style.visibility) || Number.parseFloat(style.opacity || '1') <= 0.05; } function isPlatformNoticeVisible(node) { if (!isElementVisible(node)) return false; for (let parent = node; parent; parent = parent.parentElement) { if (isPlatformNoticeHidden(parent)) return false; } return true; } // 只汇总真正显示的提示文字,隐藏模板不能触发安全停止锁。 function getPlatformDialogText(dialog, excludeControls = false) { const parts = []; const visit = node => { if (node.nodeType === Node.TEXT_NODE) { parts.push(node.nodeValue || ''); return; } if (node.nodeType !== Node.ELEMENT_NODE || node.matches('script, style, template') || isPlatformNoticeHidden(node)) return; if (excludeControls && node !== dialog && node.matches('button, .el-dialog__header, .el-message-box__header, .el-dialog__footer, .el-message-box__btns, .el-dialog__headerbtn, [aria-label="关闭"], [aria-label="Close"]')) return; node.childNodes.forEach(visit); }; visit(dialog); return parts.join('').replace(/\s+/g, '').trim(); } function getBlockingPlatformDialog() { const dialogs = Array.from(document.querySelectorAll('.el-dialog, .el-message-box, [role="dialog"], [role="alertdialog"], [role="alert"], .ant-modal')); let exercise; // 自定义提示只检查短文本叶节点;先排除祖先容器,避免读取整页 // 聚合文本把隐藏警告或正文引用误当成可见安全提示。 document.querySelectorAll('p, div, span').forEach(node => { if (node.children.length || node.closest('#autovisor-panel, #av-exam-panel, .questionContent, .examPaper_subject, .questionBox, .ques-detail, .topic-title, .question-title')) return; const text = (node.textContent || '').replace(/\s+/g, ''); if (text.length >= 200 || !PLATFORM_SAFETY_MESSAGE_PATTERN.test(text) || !isPlatformNoticeVisible(node)) return; if (exercise === undefined) exercise = getStudyExerciseDialog(); if (!exercise?.contains(node)) dialogs.push(node); }); const blocking = dialogs .filter(dialog => isPlatformNoticeVisible(dialog) && !isStudyExerciseDialog(dialog)) .map(dialog => ({ dialog, text: getPlatformDialogText(dialog), content: getPlatformDialogText(dialog, true) })) .filter(({ text, content }) => { return !content || /异常脚本|关闭插件|异常行为|登录|重新登录|网络(?:错误|异常)|加载失败|播放失败|操作失败|访问异常|安全验证|身份验证/.test(text) || /学习时长|学习时间.*(?:提醒|提示)|休息提醒/.test(text); }); // 多层弹窗并存时,安全提示优先于尚在加载的空白提示。 return (blocking.find(({ text }) => PLATFORM_SAFETY_PATTERN.test(text)) || blocking[0])?.dialog || null; } function hasBlockingPlatformDialog() { return !!getBlockingPlatformDialog(); } let lastPlatformDialog = null; let platformSafetyStopped = false; function checkPlatformDialogs() { const dialog = getBlockingPlatformDialog(); if (platformSafetyStopped) return true; if (!dialog) { lastPlatformDialog = null; return false; } completionSince = 0; if (PLATFORM_SAFETY_PATTERN.test(getPlatformDialogText(dialog))) { platformSafetyStopped = true; stopAllTasks(); pauseForVerification(); log.warn('平台安全提示已出现,自动任务已停止;请按平台提示处理后手动重启'); updatePanelStatus('⏹ 平台安全提示,已停止;处理后手动重启'); return true; } if (dialog !== lastPlatformDialog) { lastPlatformDialog = dialog; log.warn('平台提示尚未处理,已暂停自动操作并保留原页面'); updatePanelStatus('⏸ 请先处理页面提示'); } return true; } // ===================== 核心功能模块 ===================== // --- 获取视频元素 --- function getVideo() { return $('video'); } const STALL_TIMEOUT_MS = 120000; const SWITCH_COOLDOWN_MS = 8000; let switchInProgress = false; let nextSwitchAllowedAt = 0; let unconfirmedLessonSwitch = ''; let verifyBlocking = false; let progressHealth = { lessonKey: '', percent: -1, currentTime: -1, lastChangedAt: Date.now(), recoveryCount: 0, }; let completionSince = 0; let completionLessonKey = ''; let studyPlaybackBinding = null; const appliedPlaybackSpeeds = new WeakMap(); function isElementVisible(element) { if (!element) return false; const style = getComputedStyle(element); const rect = element.getBoundingClientRect(); return style.display !== 'none' && style.visibility !== 'hidden' && Number.parseFloat(style.opacity || '1') > 0.05 && rect.width > 0 && rect.height > 0; } function isVerificationVisible() { return isElementVisible($('.yidun_modal__title')); } function parseProgress(raw) { const text = String(raw ?? '').replace(/\s+/g, ''); const match = text.match(/(\d+(?:\.\d+)?)%?/); if (!match) return 0; const value = Number(match[1]); if (!Number.isFinite(value)) return 0; return Math.max(0, Math.min(100, value)); } function formatProgress(raw) { const value = parseProgress(raw); return `${Number.isInteger(value) ? value : value.toFixed(1)}%`; } function getLessonItems() { if (isAICourseClass()) return getAICourseItems(); if (isFusionClass()) { // chapter-item 可能是一整章,按具体课时绑定进度和点击目标。 const lessons = Array.from($$('.chapter-item .chapter-content-second')); return lessons.length ? lessons : Array.from($$('.chapter-item')); } if (isHikeClass()) return Array.from($$('.file-item')); const wisdomItems = Array.from($$('.child-info.hasvideo')); return wisdomItems.length ? wisdomItems : Array.from($$('.clearfix.video')); } function isLessonQuestionResource(item) { if (!item) return false; const typeNodes = [item, ...item.querySelectorAll('[data-resource-type], [data-resources-data-type], [data-resource-kind], [data-type]')]; const types = typeNodes.flatMap(node => ['data-resource-type', 'data-resources-data-type', 'data-resource-kind', 'data-type'] .map(name => String(node.getAttribute(name) || '').trim().toLowerCase()).filter(Boolean)); const questionType = /^(?:exam|examination|homework|assignment|quiz|test|question|questions|exercise|practice|作业|考试|测试|测验|练习|习题|答题|章节测试|单元测试|课后作业)$/; if (types.some(type => questionType.test(type))) return true; // 已知的视频/图文类型不依靠标题猜测,数字类型只采用已有视频/图文约定。 if (types.some(type => /^(?:video|11|22|document|article|text|link|12|21)$/.test(type))) return false; if (item.matches('[class*="basic-info-exam-card"], [class*="basic-info-homework-card"], [class*="basic-info-question-card"], [class*="basic-info-questions-card"], [class*="basic-info-quiz-card"], [class*="basic-info-test-card"], [class*="basic-info-exercise-card"], [class*="basic-info-practice-card"], .exam-resource, .homework-resource, .quiz-resource, .exercise-resource')) return true; const badges = item.querySelectorAll('.resource-type, .resource-type-name, .type-name, .resource-kind, [data-resource-type-label]'); if (Array.from(badges).some(node => questionType.test((node.textContent || '').replace(/\s+/g, '').toLowerCase()))) return true; const links = item.matches('a[href]') ? [item] : Array.from(item.querySelectorAll('a[href]')); return links.some(link => { try { const target = new URL(link.getAttribute('href'), location.href); if (!/^(?:https?):$/.test(target.protocol)) return false; return /\/(?:examPreview|studentReviewTestOrExam|point|dohomework|doexamination|homework|assignment|quiz|examination|exercise)(?:\/|[?#]|$)/i.test(target.pathname + '/' + target.hash.replace(/^#\/?/, '')); } catch (_) { return false; } }); } function getExplicitActiveResource(items) { const marked = items.filter(item => item.matches('.active, .current, .current_play, .selected, .is-active, .current-resource, [aria-current="true"], [aria-selected="true"], [data-active="true"]') || !!item.querySelector('.current-resource, [aria-current="true"], [data-active="true"]')); if (marked.length === 1) return marked[0]; const visible = marked.filter(isElementVisible); return visible.length === 1 ? visible[0] : null; } async function skipCurrentQuestionResource() { if (!config.skipAnswer || platformSafetyStopped || verifyBlocking || isVerificationVisible() || checkPlatformDialogs() || switchInProgress || Date.now() < nextSwitchAllowedAt) return; const aiResources = getAICourseResourceItems(); const items = aiResources.length ? aiResources : getLessonItems(); const active = getExplicitActiveResource(items); if (!active || !isLessonQuestionResource(active)) { if (isExamPage()) updatePanelStatus('已跳过答题;未定位到课程目录,请手动返回课程继续学习'); return; } const oldKey = getLessonKey(active); if (unconfirmedLessonSwitch === oldKey) return; const following = items.slice(items.indexOf(active) + 1); const next = following.find(item => item.isConnected && !isLessonQuestionResource(item) && !isLessonComplete(item)); if (!next) { updatePanelStatus('后续没有可继续的学习资源;剩余答题章节已按设置跳过'); return; } if (getStudyExerciseDialog()) return; switchInProgress = true; nextSwitchAllowedAt = Date.now() + SWITCH_COOLDOWN_MS; const generation = taskGeneration, beforeUrl = location.href; try { updatePanelStatus('正在跳过答题章节,继续后面的学习资源…'); if (!triggerLessonClick(next)) throw new Error('目录入口不可用'); const deadline = Date.now() + 8000; while (Date.now() < deadline) { if (!config.skipAnswer || generation !== taskGeneration || platformSafetyStopped || checkPlatformDialogs()) return; if (beforeUrl !== location.href && isRootNavigationHref(location.href)) { stopAllTasks(); updatePanelStatus('已离开课程,请手动返回学习页面'); return; } const currentResources = getAICourseResourceItems(); const current = getExplicitActiveResource(currentResources.length ? currentResources : getLessonItems()); if (current && current !== active && getLessonKey(current) === getLessonKey(next)) { if (!isExamPage()) afterLessonSwitch(); return; } await sleep(250); } unconfirmedLessonSwitch = oldKey; updatePanelStatus('跳过后的资源切换尚未确认,请手动检查;不会重复跳转'); } catch (_) { unconfirmedLessonSwitch = oldKey; updatePanelStatus('后续学习资源入口不可用,请手动切换'); } finally { switchInProgress = false; } } function getActiveLesson() { if (isAICourseClass()) return getActiveAICourseItem(); let marked; if (isFusionClass()) { const lessons = getLessonItems(); marked = lessons.filter(item => item.matches('.current, [aria-current="true"]') || !item.matches('.chapter-content-second') && !!item.querySelector('.chapter-content-second.current')); } else if (isHikeClass()) marked = Array.from($$('.file-item.active')); else if ($('.child-info.hasvideo')) marked = Array.from($$('.child-info.hasvideo.current')); else marked = Array.from($$('.clearfix.video.current_play')); if (marked.length === 1) return marked[0]; const visible = marked.filter(isElementVisible); return visible.length === 1 ? visible[0] : null; } function getLessonKey(item = getActiveLesson()) { if (!item) return ''; const explicit = item.getAttribute('data-id') || item.getAttribute('data-lesson-id') || item.getAttribute('data-resources-uid') || item.getAttribute('data-resource-uid') || item.getAttribute('data-knowledge-id') || item.getAttribute('data-point-id') || (isAICourseClass() ? item.getAttribute('data-uid') : ''); if (explicit) return explicit; if (isAICourseClass()) { const nestedId = item.querySelector('[data-resources-uid], [data-resource-uid], [data-knowledge-id], [data-point-id], [data-uid]'); if (nestedId) { const key = ['data-resources-uid', 'data-resource-uid', 'data-knowledge-id', 'data-point-id', 'data-uid'] .map(name => nestedId.getAttribute(name)).find(Boolean); if (key) return key; } // 百分比、已学时长会动态改变,不能并入课时身份导致播放器失去绑定。 const heading = item.querySelector('.title-text, [title], [class*="title"], [class*="name"]'); const title = heading?.getAttribute('title') || heading?.textContent; if (title?.trim()) return title.replace(/\s+/g, ' ').trim().slice(0, 120); } const title = item.querySelector('[title]')?.getAttribute('title'); return (title || item.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120); } function isLessonComplete(item) { if (!item) return false; if (isAICourseClass()) return isAICourseItemComplete(item); if (item.querySelector('.finish-icon, .icon-finish, .time_icofinish, .child-check')) { return true; } const progress = item.querySelector( '.el-progress[aria-valuenow], .progress-num, .rate, [role="progressbar"][aria-valuenow]' ); if (!progress) return false; const value = progress.getAttribute('aria-valuenow') ?? progress.textContent; return parseProgress(value) >= 100; } function resetProgressHealth() { progressHealth = { lessonKey: getLessonKey(), percent: parseProgress(getCourseProgress()), currentTime: Number(getVideo()?.currentTime) || 0, lastChangedAt: Date.now(), recoveryCount: 0, }; } function monitorProgressHealth(progress) { if (verifyBlocking || isVerificationVisible()) return; const video = getVideo(); if (!video || video.paused || video.ended || video.readyState < 2) { progressHealth.lastChangedAt = Date.now(); return; } const lessonKey = getLessonKey(); const currentTime = Number(video.currentTime); const moved = Number.isFinite(currentTime) && Number.isFinite(progressHealth.currentTime) && currentTime > progressHealth.currentTime + 0.25; if (lessonKey !== progressHealth.lessonKey || progress !== progressHealth.percent || moved) { progressHealth.lessonKey = lessonKey; progressHealth.percent = progress; progressHealth.currentTime = Number.isFinite(currentTime) ? currentTime : progressHealth.currentTime; progressHealth.lastChangedAt = Date.now(); progressHealth.recoveryCount = 0; return; } if (Date.now() - progressHealth.lastChangedAt < STALL_TIMEOUT_MS) return; if (progressHealth.recoveryCount === 0) { progressHealth.recoveryCount = 1; progressHealth.lastChangedAt = Date.now(); log.warn('视频和平台进度连续 120 秒未推进,正在尝试恢复播放'); updatePanelStatus('⚠️ 进度停滞,正在恢复...'); video.play().catch(() => {}); return; } log.error('视频和平台进度连续 120 秒未推进,已停止自动任务'); updatePanelStatus('⏹ 进度停滞,请手动检查页面'); stopAllTasks(); } // --- 页面提示检查:保留平台节点和主题状态 --- async function optimizePage(canContinue = () => true) { await sleep(2000); if (!canContinue()) return; // 与定时清理共用规则,不能把随堂弹题的关闭图标当成普通提示。 closePopups(); // 平台容器类名可能复用在应用外壳上,不能按类名删除节点; // 主题按钮也可能提交表单或触发平台回调,交给用户自行操作。 log.info('页面提示检查完成,已保留平台页面结构'); } // --- 保存原始 pause,交给播放保活任务决定是否恢复 --- function overridePause() { const video = getVideo(); if (video && !video._pauseOverridden) { video._autovisorOriginalPause = video.pause.bind(video); video._pauseOverridden = true; log.info('已保存视频 pause 方法,由播放保活任务负责恢复'); } } function pauseForVerification() { const video = getVideo(); if (video && !video.paused) { video.pause(); } } // --- 静音控制 --- function setMute() { if (!config.soundOff || platformSafetyStopped) return; const video = getVideo(); if (video && video.volume !== 0) { video.volume = 0; const volumeBox = $('.volumeBox'); if (volumeBox) volumeBox.classList.add('volumeNone'); log.info('已静音'); } } // --- 倍速控制 --- function setSpeed() { const video = getVideo(); if (!video || platformSafetyStopped || hasBlockingStudyDialog()) return; const speed = Math.max(0.5, Math.min(config.playbackSpeed, 1.8)); const source = video.currentSrc || video.src || ''; const applied = appliedPlaybackSpeeds.get(video); // 同一视频/同一配置仅尝试一次。平台若主动恢复倍速,尊重播放器状态, // 不再每五秒与平台反复争抢 playbackRate。 if (applied?.speed === speed && applied.source === source) return; appliedPlaybackSpeeds.set(video, { speed, source }); if (video.playbackRate !== speed) { video.playbackRate = speed; const speedLabel = $('.speedBox span'); if (speedLabel) speedLabel.innerText = `X ${speed}`; log.info(`已设置倍速: ${speed}x`); } } // --- 检测暂停并续播 --- async function checkAndPlay() { if (!config.autoPlay || platformSafetyStopped) return; if (isVerificationVisible()) { verifyBlocking = true; pauseForVerification(); return; } verifyBlocking = false; // 弹题仍待处理时平台会主动暂停;不要和弹题处理流程争抢播放。 if (exerciseInProgress || hasBlockingStudyDialog()) return; const video = getVideo(); // 对 ended 的视频调用 play() 会从头重播,让下一集检测错过结束状态。 if (video && video.paused && !video.ended) { try { await video.play(); log.info('检测到视频暂停,已恢复播放'); } catch (e) { // 自动播放可能被浏览器策略阻止 log.warn('自动播放被阻止,请手动点击播放'); } } } // --- 随堂弹题 --- let exerciseInProgress = false; let exerciseGeneration = 0; const aiExerciseAttempts = new WeakMap(); const aiExerciseTransitions = new WeakMap(); const studyExerciseAnswerCache = new WeakMap(); function getStudyAnswerMode() { const mode = GM_getValue('av_study_answer_mode', 'ai'); return ['ai', 'free', 'paid'].includes(mode) ? mode : 'ai'; } function invalidateStudyExerciseAnswer() { exerciseGeneration++; const dialog = getStudyExerciseDialog(); if (dialog) { aiExerciseAttempts.delete(dialog); studyExerciseAnswerCache.delete(dialog); } } function setSkipAnswer(enabled) { const skip = Boolean(enabled); config.skipAnswer = skip; if (skip) { config.autoExam = false; stopExamAutomation('已开启跳过答题章节,将继续后面的学习资源。'); } saveConfig(config); if (typeof refreshSkipAnswerUI === 'function') refreshSkipAnswerUI(); updatePanelStatus(skip ? '自动切课将跳过作业/测试章节,随堂弹题仍按原设置处理' : '已关闭跳过答题章节'); if (skip) skipCurrentQuestionResource(); return skip; } function setStudyAnswerMode(mode) { if (!['ai', 'free', 'paid'].includes(mode)) return false; if (mode !== getStudyAnswerMode()) { GM_setValue('av_study_answer_mode', mode); invalidateStudyExerciseAnswer(); } return true; } function isAiStudyExerciseDialog(dialog) { if (!dialog) return false; const heading = dialog.querySelector('.el-dialog__title, .dialog-title, .modal-title, h1, h2, h3'); const title = `${heading?.textContent || ''} ${(dialog.textContent || '').slice(0, 180)}`.replace(/\s+/g, ''); return dialog.matches('.ai-class-exercise-dialog') || /AI助教.{0,30}(?:出题|随堂|练习)|AI随堂(?:练习|测试)/i.test(title) || (isAICourseClass() && !!dialog.querySelector('.questionContent .el-radio-group, .questionContent .el-checkbox-group')); } function isExerciseMetaText(text) { const value = formatString(text || '').replace(/\s+/g, '').replace(/^\d+[.、.]?/, ''); return !value || /AI助教|AI随堂(?:练习|测试)|以下所有内容均由AI生成|请注意甄别|学习过程中有问题|随时提问/.test(value) || /^(?:[((【\[]?(?:单选题?|多选题?|判断题?|不定项选择题?)[))】\]]?)$/.test(value); } function getStudyExerciseCandidates() { const selectors = [ '.dialog-test', '.ai-class-exercise-dialog', '.el-dialog', '.el-message-box', '[role="dialog"]', '.ant-modal', '[class*="exercise"], [class*="practice"], [class*="question-modal"], [class*="dialog"], [class*="modal"], [class*="popup"]', ]; const seen = new Set(); const result = []; selectors.forEach(selector => document.querySelectorAll(selector).forEach(node => { if (!seen.has(node)) { seen.add(node); result.push(node); } })); // 某些版本的弹层没有 role 或固定类名。从准确的标题往上寻找 // 同时容纳选项及操作按钮的最小容器,不能直接返回应用根节点。 document.querySelectorAll('h1, h2, h3, h4, p, div, span').forEach(title => { if (title.children.length || !/^(?:AI随堂(?:练习|测试)|AI助教.{0,12}(?:出题|随堂练习))$/i.test((title.textContent || '').replace(/\s+/g, '')) || !isElementVisible(title)) return; let parent = title.parentElement; for (let depth = 0; parent && parent !== document.body && depth < 6; depth++, parent = parent.parentElement) { if (getAiExerciseChoices(parent).length < 2 || !findExerciseButton(parent, /^(?:提交(?:答案|作答)?|继续学习|继续播放|关闭练习)$/)) continue; if (!seen.has(parent)) { seen.add(parent); result.unshift(parent); } break; } }); return result; } function getAiExerciseChoices(dialog) { const selectors = [ '.topic-item', 'label.el-radio, label.el-checkbox, label.ant-radio-wrapper, label.ant-checkbox-wrapper', '[role="radio"], [role="checkbox"]', '.questionContent ul.radio-view > li, .questionContent ul.checkbox-view > li', '.option-item, .options-item, .answer-item, .choice-item, .radio-item, .checkbox-item', '.topic-list > li, .option-list > li, .options-list > li', '.option', 'label:has(input[type="radio"]), label:has(input[type="checkbox"])', ]; for (const selector of selectors) { const choices = Array.from(dialog.querySelectorAll(selector)).filter(isElementVisible) .filter(node => !node.closest('.answer-analysis, .analysis, .answer-result')); if (choices.length >= 2) return choices.filter(node => !choices.some(other => other !== node && other.contains(node))); } // 新版随堂练习使用普通 div,A/B 是选项前缀,原生 input 可能被隐藏。 const inputs = Array.from(dialog.querySelectorAll('input[type="radio"], input[type="checkbox"]')); const labelled = inputs.map(input => input.labels?.[0] || input.closest('label') || input.parentElement) .filter(node => node && isElementVisible(node) && node.textContent.trim()); if (labelled.length >= 2 && new Set(labelled).size === labelled.length) return labelled; const fallback = Array.from(dialog.querySelectorAll('li, label, div, [role="button"]')).filter(node => isElementVisible(node) && !node.closest('.answer-analysis, .analysis, .answer-result') && /^[A-H](?:[..、::))\s]+|(?=正确|错误|是|否|对|错))\S/.test((node.textContent || '').trim())); const choices = fallback.filter(node => !fallback.some(other => other !== node && node.contains(other))); const group = choices[0]?.parentElement; return choices.filter(node => node.parentElement === group); } function readAiExerciseQuestion(dialog) { const choices = getAiExerciseChoices(dialog); if (choices.length < 2) return null; const cleanOption = value => formatString(value).replace(/^\s*[A-H](?:[..、::))\s]+|(?=正确|错误|是|否|对|错))\s*/i, '').trim(); const options = choices.map(choice => cleanOption( replaceQuestionImagesWithPlaceholders(choice) || choice.getAttribute('aria-label') || (choice.labels && Array.from(choice.labels).map(label => label.textContent).join(' ')) || choice.value || '' ) || (choice.querySelector?.('img') ? '[选项配图]' : '')); if (options.some(option => !option)) return null; const candidates = Array.from(dialog.querySelectorAll('.topic-title, .question-title, .questionTitle .centent-pre, .questionTitle, .question-stem, .stem, .subject-title, [class*="question"], [class*="subject"]')); const usableText = node => { if (!isElementVisible(node) || choices.some(choice => choice === node || choice.contains(node) || node.contains(choice))) return ''; if (node.closest('.el-dialog__header, .el-dialog__footer, .answer, .analysis, .answer-analysis, .answer-result, button, [role="button"]')) return ''; const text = formatString(node.textContent || '').trim(); if (text.length < 4 || isExerciseMetaText(text)) return ''; return text; }; let question = candidates.map(node => usableText(node)).filter(Boolean).sort((a, b) => b.length - a.length)[0] || ''; if (!question) { const fallback = Array.from(dialog.querySelectorAll('p, div, span')); question = fallback.map(usableText).filter(Boolean).sort((a, b) => b.length - a.length)[0] || ''; } const text = (dialog.textContent || '').replace(/\s+/g, ''); const type = /多选|多项选择|不定项/.test(text) || choices.some(choice => choice.matches('.el-checkbox, [role="checkbox"], input[type="checkbox"]') || choice.querySelector('input[type="checkbox"]')) ? 1 : /判断题|是非题/.test(text) ? 3 : 0; const current = dialog.querySelector('.number.active, .number.current, .number.is-active, .number[aria-selected="true"]'); const images = extractQuestionImages(dialog, choices); // 图片题即使没有文字题干也必须保留一个可追踪占位,后续 AI 会结合 images 作答。 if (!question && images.length) question = '[题目配图] 请根据题图及选项作答。'; if (!question) return null; const contentKey = JSON.stringify([question, options, type, images]); const key = JSON.stringify([current?.textContent || '', contentKey]); return { question, options, choices, type, images, key, contentKey }; } function matchAiExerciseAnswers(question, answers) { const normalize = text => formatString(String(text || '')).replace(/\s+/g, '').toLowerCase(); const truth = text => /^(正确|对|是|√|true|t)$/i.test(normalize(text)) ? true : /^(错误|错|否|×|false|f)$/i.test(normalize(text)) ? false : null; const indices = new Set(); const normalizedAnswers = (Array.isArray(answers) ? answers : answers == null ? [] : [answers]).flatMap(answer => { const value = String(answer ?? '').trim(); if (question.options.some(option => normalize(option) === normalize(value))) return [value]; if (question.type === 1 && /^(?:[A-H]{2,8}|[A-H](?:\s*[,,、;;|/\s]\s*[A-H])+)$/.test(value.toUpperCase())) { return value.toUpperCase().match(/[A-H]/g) || []; } if (question.type === 1 && value.includes('||')) return value.split('||').map(part => part.trim()); return [value]; }); for (const answer of normalizedAnswers) { const value = String(answer || '').trim(); let index = -1; if (/^[A-H]$/i.test(value)) index = value.toUpperCase().charCodeAt(0) - 65; else { const matching = question.options.map((option, i) => normalize(option) === normalize(value) || (question.type === 3 && truth(value) !== null && truth(option) === truth(value)) ? i : -1).filter(i => i >= 0); if (matching.length === 1) index = matching[0]; } if (index < 0 || index >= question.choices.length) return null; indices.add(index); } if (!indices.size || (question.type !== 1 && indices.size !== 1)) return null; return indices; } async function answerAiStudyExercise(dialog, canContinue) { if (hasExerciseAnswer(dialog)) return true; const question = readAiExerciseQuestion(dialog); if (!question) { updatePanelStatus('⏳ 等待 AI 助教题目和选项加载'); return false; } const mode = getStudyAnswerMode(); const sourceName = { ai: 'AI', free: '免费题库', paid: '付费题库' }[mode]; const canAnswer = () => canContinue() && getStudyAnswerMode() === mode && (mode !== 'free' || isVerifyValid()); if (mode === 'free' && !isVerifyValid()) { updatePanelStatus('⏸ 请在助教答题设置中验证免费题库验证码'); return false; } if (!canAnswer()) return false; const previous = aiExerciseAttempts.get(dialog); if (previous?.key === question.key && previous.mode === mode && Date.now() < previous.retryAt) return false; const sameQuestionContent = () => canAnswer() && dialog.isConnected && isElementVisible(dialog) && readAiExerciseQuestion(dialog)?.key === question.key; const sameQuestion = () => sameQuestionContent() && question.choices.every(choice => choice.isConnected && dialog.contains(choice)); const confirmedOriginal = () => { if (!canAnswer() || !dialog.isConnected || !isElementVisible(dialog) || !hasExerciseAnswer(dialog)) return false; const current = readAiExerciseQuestion(dialog); return !current || current.key === question.key; }; aiExerciseAttempts.set(dialog, { key: question.key, mode, retryAt: Date.now() + 20000 }); updatePanelStatus(`正在使用${sourceName}回答助教随堂题`); const cached = studyExerciseAnswerCache.get(dialog); const reusable = cached && cached.key === question.key && cached.mode === mode && cached.route === location.href; const result = reusable ? cached.result : await searchAnswer(question.question, question.options, question.type, mode, question.images); // 提交未确认时复用本题答案;付费查询即便响应异常也不自动重复扣费。 // 同题重绘只取消旧节点点击,仍保留结果;换题、停止或切换来源不缓存旧请求。 if (!reusable && sameQuestionContent() && (mode === 'paid' || result?.answers?.length)) { studyExerciseAnswerCache.set(dialog, { key: question.key, mode, route: location.href, result }); } if (!sameQuestion()) return confirmedOriginal(); // 用户在查询期间自行提交时,以平台结果为准,不再覆盖答案。 if (hasExerciseAnswer(dialog)) return true; const targets = matchAiExerciseAnswers(question, result?.answers); if (!targets) { log.warn(`${sourceName}未取得可匹配的助教题答案,保留题窗`); updatePanelStatus('⚠️ ' + (result?.message || `${sourceName}返回的答案无法与本题选项匹配,请检查题目或手动作答`)); return false; } // 多选先清除错误勾选,单选由平台原生互斥;每一步都核对仍是原题。 const indices = question.type === 1 ? question.choices.map((_choice, i) => i) : [...targets]; for (const index of indices) { if (confirmedOriginal()) return true; if (!sameQuestion()) return false; if (hasExerciseAnswer(dialog)) return true; const choice = question.choices[index]; if (isExerciseChoiceSelected(choice) === targets.has(index)) continue; if (choice.disabled || choice.matches('.is-disabled, [aria-disabled="true"]') || choice.querySelector('input:disabled')) return false; activateExerciseChoice(choice, dialog); await sleep(150); } if (!sameQuestion()) return confirmedOriginal(); if (hasExerciseAnswer(dialog)) return true; const selected = question.choices.map(isExerciseChoiceSelected); if (!selected.every((checked, i) => checked === targets.has(i))) { updatePanelStatus('⚠️ AI 助教选项未选中,请检查题目'); return false; } const submit = findExerciseButton(dialog, /^(提交(?:答案|作答)?|确定|确认(?:答案|作答)?|完成作答)$/); if (submit) clickExerciseControl(submit, dialog); if (!await waitForExerciseAnswer(dialog, canAnswer, question.key)) { updatePanelStatus('⚠️ AI 助教题尚未确认提交,已保留题窗'); return false; } return true; } function isStudyExerciseDialog(dialog) { if (!dialog) return false; return dialog.matches('.dialog-test, .ai-class-exercise-dialog') || isAiStudyExerciseDialog(dialog) || !!dialog.querySelector('.topic-item') || (!!dialog.querySelector('.number') && /弹题|出题|随堂|练习/.test(dialog.textContent || '')); } function getStudyExerciseDialog() { return getStudyExerciseCandidates().find(dialog => { if (!isElementVisible(dialog) || !isStudyExerciseDialog(dialog)) return false; const text = (dialog.textContent || '').replace(/\s+/g, ''); const choices = getAiExerciseChoices(dialog); return choices.length >= 2 || dialog.matches('.dialog-test, .ai-class-exercise-dialog') || /AI助教.{0,30}(?:出题|随堂|练习)|AI随堂(?:练习|测试)/i.test(text) && !!findExerciseButton(dialog, /^(?:提交(?:答案|作答)?|继续学习|继续播放|关闭练习)$/); }) || null; } function clickExerciseControl(control, dialog) { if (!control || !dialog || !dialog.contains(control) || !control.isConnected || !isElementVisible(control) || platformSafetyStopped || hasBlockingPlatformDialog()) return false; return dispatchStudyClick(control); } function findExerciseCompletionControl(dialog) { return findExerciseButton(dialog, /^(?:关闭练习|继续学习|继续播放|返回视频)$/); } function activateExerciseChoice(choice, dialog) { // button/a 形式的自定义选项也不能提交外层表单或跳转; // 原生 input/label 保留默认动作,才能正常更新 checked 状态。 if (choice.closest('button, a[href]')) return clickExerciseControl(choice, dialog); choice.click(); return true; } function isUnansweredExerciseWarning(dialog) { return /未[作做]答.*(?:不能|无法)关闭/.test((dialog?.textContent || '').replace(/\s+/g, '')); } function hasBlockingStudyDialog() { return hasBlockingPlatformDialog() || !!getStudyExerciseDialog() || Array.from($$('.el-message-box, .el-dialog')) .some(dialog => isElementVisible(dialog) && !isStudyExerciseDialog(dialog) && isUnansweredExerciseWarning(dialog)); } function dismissExerciseWarning() { if (platformSafetyStopped || hasBlockingPlatformDialog()) return; // 只关闭明确的“未作答不能关闭”提示,保留下面的题目继续处理。 Array.from($$('.el-message-box, .el-dialog')).forEach(dialog => { if (!isElementVisible(dialog) || isStudyExerciseDialog(dialog) || !isUnansweredExerciseWarning(dialog)) return; const close = dialog.querySelector('.el-message-box__headerbtn, .el-dialog__headerbtn, .iconfont.iconguanbi'); if (close && isElementVisible(close)) clickExerciseControl(close, dialog); }); } function isExerciseChoiceSelected(choice) { return choice.matches('input:checked, .active, .selected, .is-selected, .checked, .is-checked, .ant-radio-wrapper-checked, .ant-checkbox-wrapper-checked, [aria-checked="true"]') || !!choice.querySelector('input:checked, .is-checked, .ant-radio-checked, .ant-checkbox-checked, [aria-checked="true"]'); } function getExerciseQuestionKey(dialog) { if (isAiStudyExerciseDialog(dialog)) return readAiExerciseQuestion(dialog)?.contentKey || ''; return Array.from(dialog.querySelectorAll('.topic-title, .topic-item')).filter(isElementVisible) .map(node => (node.textContent || node.getAttribute('aria-label') || node.value || '').trim()).join('\n'); } async function showExerciseQuestion(dialog, number, canContinue) { // 题号可能先变蓝、题干稍后才返回。不能沿用上一题尚未消失的 .answer。 const aiQuestion = isAiStudyExerciseDialog(dialog); const previousTransition = aiQuestion ? aiExerciseTransitions.get(dialog) : null; const active = number.matches('.active, .current, .is-active, [aria-selected="true"], [aria-current="true"]'); if (active && (!previousTransition || previousTransition.number !== number)) return true; const previousKey = getExerciseQuestionKey(dialog); let transition = previousTransition?.number === number ? previousTransition : null; if (aiQuestion && !transition) { const before = readAiExerciseQuestion(dialog); transition = { number, contentKey: before?.contentKey || previousKey, choices: before?.choices || [], results: Array.from(dialog.querySelectorAll('.answer, .answer-result, .question-result, .result-content, .answer-analysis, .analysis, .correct-answer, [role="status"]')) .filter(isElementVisible).map(node => ({ node, text: node.textContent || '' })) }; aiExerciseTransitions.set(dialog, transition); } if (!active) clickExerciseControl(number, dialog); for (let attempt = 0; attempt < 30; attempt++) { if (!canContinue() || !dialog.isConnected || !isElementVisible(dialog)) return false; if (aiQuestion) { const current = readAiExerciseQuestion(dialog); const bodyChanged = current && (current.contentKey !== transition.contentKey || (transition.choices.length > 0 && transition.choices.every(choice => !dialog.contains(choice)))); const staleResult = transition.results.some(({ node, text }) => dialog.contains(node) && isElementVisible(node) && (node.textContent || '') === text); if (bodyChanged && !staleResult) { aiExerciseTransitions.delete(dialog); return true; } } else { const key = getExerciseQuestionKey(dialog); if (key && key !== previousKey) return true; } await sleep(100); } return false; } function hasExerciseAnswer(dialog) { // .answer 是平台作答后的答案/解析区。仅看当前弹题内可见的结果, // 不能用页面其他题目的 .answer 或单纯勾选状态当作提交成功。 const aiDialog = isAiStudyExerciseDialog(dialog); const legacyResult = !aiDialog && Array.from(dialog.querySelectorAll('.answer')).some(answer => isElementVisible(answer) && !answer.querySelector('.topic-item') && !!((answer.textContent || '').trim() || answer.querySelector('img'))); if (legacyResult) return true; if (!aiDialog) return false; return Array.from(dialog.querySelectorAll('.answer, .answer-result, .question-result, .result-content, .answer-analysis, .analysis, .correct-answer, [role="status"]')) .filter(result => !getAiExerciseChoices(result).length) .some(result => isElementVisible(result) && /正确答案|参考答案|回答正确|回答错误|回答[对错]了|作答成功|提交成功|已作答|答案解析/.test(result.textContent || '')); } function findExerciseButton(dialog, textPattern) { return Array.from(dialog.querySelectorAll('button, .btn, [role="button"], .el-button')) .find(button => isElementVisible(button) && !button.disabled && !button.classList.contains('is-disabled') && button.getAttribute('aria-disabled') !== 'true' && textPattern.test((button.textContent || '').replace(/\s+/g, ''))); } async function waitForExerciseAnswer(dialog, canContinue, questionKey = '') { for (let attempt = 0; attempt < (questionKey ? 40 : 20); attempt++) { if (!canContinue()) return false; if (!isElementVisible(dialog) || !dialog.isConnected) return true; const current = questionKey ? readAiExerciseQuestion(dialog) : null; if (questionKey && current && current.key !== questionKey) return false; if (hasExerciseAnswer(dialog)) return true; await sleep(100); } return false; } async function handleFusionExercise(canContinue = () => true) { if (!isFusionClass()) return false; const dialog = $('.ai-class-exercise-dialog'); if (!isElementVisible(dialog) || !canContinue()) return false; try { const option = dialog.querySelector('.option'); if (option && !isExerciseChoiceSelected(option)) activateExerciseChoice(option, dialog); await sleep(600); if (!canContinue() || !isElementVisible(dialog)) return false; const submit = dialog.querySelector('.el-dialog__footer button.el-button'); if (submit && !submit.disabled && !submit.classList.contains('is-disabled')) clickExerciseControl(submit, dialog); await sleep(1000); if (!canContinue()) return false; log.info('融合课 AI 随堂练习已尝试提交'); return Boolean(option || submit); } catch (e) { log.warn('融合课 AI 随堂练习处理失败: ' + e.message); return false; } } async function skipQuestions() { if (!config.autoAnswer || exerciseInProgress || platformSafetyStopped) return; if (isHikeClass()) return; // 翻转课不支持自动答题 if (isVerificationVisible()) { verifyBlocking = true; pauseForVerification(); return; } const dialog = getStudyExerciseDialog(); if (!dialog) return; const generation = exerciseGeneration; const route = location.href; const canContinue = () => generation === exerciseGeneration && route === location.href && config.autoAnswer && !platformSafetyStopped && !isVerificationVisible() && !hasBlockingPlatformDialog(); exerciseInProgress = true; try { dismissExerciseWarning(); if (isFusionClass() && !isAiStudyExerciseDialog(dialog)) { await handleFusionExercise(canContinue); return; } // 目录和题目都使用 el-scrollbar__view,必须从当前可见题窗内找题。 const numbers = Array.from(dialog.querySelectorAll('.number')).filter(isElementVisible); const total = Math.max(1, numbers.length); log.info(`检测到 ${total} 道弹题`); for (let index = 0; index < total; index++) { if (!canContinue() || !dialog.isConnected || !isElementVisible(dialog)) return; const currentNumbers = Array.from(dialog.querySelectorAll('.number')).filter(isElementVisible); if (numbers.length > 1) { if (!currentNumbers[index]) return; if (!await showExerciseQuestion(dialog, currentNumbers[index], canContinue)) { if (canContinue()) updatePanelStatus('⏳ 弹题切换未确认,请检查题目'); return; } } if (!canContinue() || !dialog.isConnected || !isElementVisible(dialog)) return; if (hasExerciseAnswer(dialog)) continue; if (isAiStudyExerciseDialog(dialog)) { if (!await answerAiStudyExercise(dialog, canContinue)) return; continue; } const choices = Array.from(dialog.querySelectorAll('.topic-item')).filter(isElementVisible); if (!choices.length) { updatePanelStatus('⏳ 等待弹题选项加载'); return; } // 随堂练习沿用选项触发作答的流程;单选只点一次,多选保留已有勾选。 // 点击后若平台已显示答案,不能继续点击下一选项把答案反选。 if (!choices.some(isExerciseChoiceSelected)) { const multiple = /多选|多项选择/.test(dialog.textContent || '') || !!dialog.querySelector('input[type="checkbox"], .el-checkbox'); for (const choice of choices.slice(0, multiple ? 2 : 1)) { if (!canContinue() || hasExerciseAnswer(dialog)) break; if (choice.disabled || choice.getAttribute('aria-disabled') === 'true') continue; activateExerciseChoice(choice, dialog); await sleep(200); } } if (!canContinue()) return; // 部分版本选择后直接显示答案,其他版本必须点击提交/确定。 const submit = findExerciseButton(dialog, /^(提交(?:答案|作答)?|确定|确认(?:答案|作答)?|完成作答)$/); if (!hasExerciseAnswer(dialog) && submit) { clickExerciseControl(submit, dialog); } if (!await waitForExerciseAnswer(dialog, canContinue)) { if (canContinue()) { log.warn(`第 ${index + 1} 道弹题尚未确认作答,保留题窗等待处理`); updatePanelStatus('⚠️ 弹题未完成,请检查题目'); } return; } } if (!canContinue() || !isElementVisible(dialog) || !dialog.isConnected) return; const close = findExerciseCompletionControl(dialog); if (close) { clickExerciseControl(close, dialog); await sleep(350); if (!dialog.isConnected || !isElementVisible(dialog)) log.success('弹题已完成并关闭'); else updatePanelStatus('⏳ 等待弹题关闭'); } else { updatePanelStatus('✅ 弹题已提交,请手动关闭题窗后继续'); } } catch (e) { log.warn('处理弹题时出错: ' + e.message); } finally { exerciseInProgress = false; if (canContinue() && !hasBlockingStudyDialog()) await checkAndPlay(); } } function dialogTextWithoutControls(dialog) { if (!dialog) return ''; const clone = dialog.cloneNode(true); clone.querySelectorAll('button, .el-dialog__header, .el-message-box__header, .el-dialog__footer, .el-message-box__btns, .el-dialog__headerbtn, [aria-label="关闭"], [aria-label="Close"]').forEach(node => node.remove()); return (clone.textContent || '').replace(/\s+/g, ' ').trim(); } // 空白弹层可能仍在加载课程或题目。保留节点和遮罩供框架继续渲染, // 不点击确认、不发送 ESC,也不删除框架持有的 DOM。 function clearBlankDialogs() { return checkPlatformDialogs(); } // --- 关闭其他弹窗 --- function closePopups() { if (platformSafetyStopped || isVerificationVisible()) return; clearBlankDialogs(); if (hasBlockingPlatformDialog()) return; dismissExerciseWarning(); // 学习时长/完成提醒的关闭回调也可能退出课程,保留给用户处理。 } // --- 检测人机验证 --- function checkVerify() { const visible = isVerificationVisible(); if (visible) { verifyBlocking = true; pauseForVerification(); log.warn('⚠️ 检测到安全验证,请手动完成验证!'); updatePanelStatus('⚠️ 需要手动验证!'); return true; } if (verifyBlocking) { verifyBlocking = false; resetProgressHealth(); updatePanelStatus('▶️ 验证完成,恢复运行'); } return false; } // AI 目录先切活动项、播放器后换源时,旧视频不能归属到新课时。 // 保留源和时间绑定,只有换源、换播放器或时间回到起点后才接管新课。 let aiPlaybackBinding = null; function bindLessonPlayback(previous, video, lessonKey) { const source = video.currentSrc || video.src || ''; const time = Number(video.currentTime) || 0; if (!previous || previous.video !== video || previous.source !== source) { // 视频可能先换源,目录下一帧才更新。为刚换的新源保留一次 // 短暂的重新归属机会,避免把它永久绑定到上一课。 const pendingLesson = previous?.lessonKey === lessonKey && !video.ended ? lessonKey : ''; return { video, source, lessonKey, time, ready: true, pendingLesson, changedAt: Date.now() }; } if (previous.lessonKey !== lessonKey) { const sourceArrivedFirst = previous.pendingLesson === previous.lessonKey && Date.now() - previous.changedAt < SWITCH_COOLDOWN_MS && !video.ended; previous.lessonKey = lessonKey; previous.ready = sourceArrivedFirst; previous.pendingLesson = ''; } if (!previous.ready && !video.ended && time + 0.25 < previous.time) previous.ready = true; previous.time = time; return previous; } function getAICoursePlayback(active) { const video = Array.from($$('video')).find(isElementVisible); const lessonKey = getLessonKey(active); if (!active || !lessonKey || !video) return null; aiPlaybackBinding = bindLessonPlayback(aiPlaybackBinding, video, lessonKey); return aiPlaybackBinding.ready ? video : null; } function isAICourseVideoItem(item) { return !!item && (item.matches('.basic-info-video-card-container') || /^(?:video|11|22)$/i.test(item.getAttribute('data-resource-type') || item.getAttribute('data-resources-data-type') || '') || !!item.querySelector('video')); } // --- 获取当前学习进度 --- function getCourseProgress() { const active = getActiveLesson(); if (!active) return '0%'; if (isAICourseClass()) { const video = getAICoursePlayback(active); if (video && Number.isFinite(video.duration) && video.duration > 0 && video.currentTime >= 0) { if (video.ended) return '100%'; const percent = Math.min(99.9, (video.currentTime / video.duration) * 100); return `${percent.toFixed(1)}%`; } if (isAICourseVideoItem(active) || Array.from($$('video')).some(isElementVisible)) return '0%'; return `${getAICourseItemProgress(active)}%`; } if (isLessonComplete(active)) return '100%'; const progress = active.querySelector( '.el-progress[aria-valuenow], .progress-num, .rate, [role="progressbar"][aria-valuenow]' ); if (!progress) return '0%'; return formatProgress(progress.getAttribute('aria-valuenow') ?? progress.textContent); } // --- 获取未完成的课程列表 --- function getUnfinishedLessons() { return getLessonItems().filter(item => !isLessonComplete(item)); } function isCurrentLessonFinished() { if (isAICourseClass()) { const active = getActiveLesson(); if (!active) return false; const video = getAICoursePlayback(active); if (video) return video.ended === true; // 视频尚未加载/仍是上一课的源时,不采用历史目录进度跳课。 if (isAICourseVideoItem(active) || Array.from($$('video')).some(isElementVisible)) return false; return isAICourseItemComplete(active); } // 目录的 100% 可能来自上次观看。必须先确认当前播放器真正结束, // 并持续绑定课时和视频源,避免题后切课或旧视频 ended 串到下一课。 const video = getVideo(); const lessonKey = getLessonKey(); if (!video || !lessonKey) return false; studyPlaybackBinding = bindLessonPlayback(studyPlaybackBinding, video, lessonKey); return studyPlaybackBinding.ready && video.ended === true; } async function checkLessonProgress() { if (platformSafetyStopped || checkPlatformDialogs()) return; if (config.skipAnswer && isLessonQuestionResource(getActiveLesson())) { await skipCurrentQuestionResource(); return; } const progress = getCourseProgress(); updatePanelProgress(progress); if (isVerificationVisible()) { verifyBlocking = true; pauseForVerification(); completionSince = 0; return; } verifyBlocking = false; if (exerciseInProgress || hasBlockingStudyDialog()) { completionSince = 0; return; } monitorProgressHealth(parseProgress(progress)); const currentKey = getLessonKey(); if (currentKey !== completionLessonKey) { completionLessonKey = currentKey; completionSince = 0; } if (isCurrentLessonFinished()) { if (!completionSince) completionSince = Date.now(); if (Date.now() - completionSince >= 2000) await switchToNext(); } else { completionSince = 0; } } // --- 切换到下一集 --- async function switchToNext() { if (platformSafetyStopped || checkPlatformDialogs()) return; if (!config.autoNext || verifyBlocking || isVerificationVisible()) return; if (exerciseInProgress || hasBlockingStudyDialog()) return; if (switchInProgress || Date.now() < nextSwitchAllowedAt) return; if (!isCurrentLessonFinished()) return; const allItems = getLessonItems(); if (allItems.length === 0) { log.warn('未识别到课程目录,暂不判断为全部完成'); updatePanelStatus('⚠️ 未识别到课程目录'); return; } const active = getActiveLesson(); if (!active) { log.warn('未识别到当前活动课时,暂不切换'); updatePanelStatus('⚠️ 未识别到当前课时'); return; } const currentIndex = allItems.indexOf(active); if (currentIndex < 0) return; const oldKey = getLessonKey(active); // 目录未变化时不反复点击同一目标,避免慢加载/失败回调重复执行。 if (unconfirmedLessonSwitch && unconfirmedLessonSwitch === oldKey) return; unconfirmedLessonSwitch = ''; const ordered = [ ...allItems.slice(currentIndex + 1), ...allItems.slice(0, currentIndex + 1), ]; // 折叠章节中的目录项虽然暂不可见,仍是未完成课时。目录组件的点击 // 监听器可以接收事件,不能因此把课程误判为全部完成并关闭定时任务。 const next = ordered.find(item => item !== active && item.isConnected && !isLessonComplete(item) && (!config.skipAnswer || !isLessonQuestionResource(item))); if (!next) { if (config.skipAnswer && allItems.some(item => !isLessonComplete(item) && isLessonQuestionResource(item))) { updatePanelStatus('后续仅剩答题章节,已按设置跳过;题目未标记完成'); return; } log.success('🎉 所有课程已学习完毕!'); updatePanelStatus('🎉 全部完成!'); stopAllTasks(); return; } switchInProgress = true; const generation = exerciseGeneration; nextSwitchAllowedAt = Date.now() + SWITCH_COOLDOWN_MS; try { log.success('当前课程已完成,正在切换下一集...'); log.info(`切换课时:${oldKey} → ${getLessonKey(next)}`); updatePanelStatus('🔄 正在切换下一集...'); const beforeUrl = location.href; if (!triggerLessonClick(next)) { throw new Error('目录项不可点击'); } log.info('已发送下一集切换请求'); const deadline = Date.now() + 8000; let changed = false; while (Date.now() < deadline) { if (generation !== exerciseGeneration) return; if (platformSafetyStopped || checkPlatformDialogs()) return; // 路由已离开课程时停止任务;history.back 会再次触发旧定时器, // 造成页面反复进出,不能用历史后退修补平台自身的跳转。 if (beforeUrl !== location.href && isRootNavigationHref(location.href)) { log.warn('检测到切换后已离开学习页,自动任务已停止'); stopAllTasks(); updatePanelStatus('⏹ 已离开课程,请手动返回学习页面'); return; } const current = getActiveLesson(); if (current && (getLessonKey(current) !== oldKey || (!oldKey && current !== active))) { changed = true; break; } await sleep(250); } if (generation !== exerciseGeneration || platformSafetyStopped || checkPlatformDialogs()) return; if (changed && !isRootNavigationHref(location.href)) { afterLessonSwitch(); } else { unconfirmedLessonSwitch = oldKey; log.warn('切换下一集后未确认课时变化,已暂停本课的自动重试;请手动切课或重启脚本'); updatePanelStatus('⚠️ 切换未确认,请手动切课'); } } catch (e) { unconfirmedLessonSwitch = oldKey; log.warn('切换下一集失败: ' + e.message); updatePanelStatus('⚠️ 课时切换失败'); } finally { switchInProgress = false; } } // 切换课程后重新初始化 function afterLessonSwitch() { if (platformSafetyStopped || checkPlatformDialogs()) return; overridePause(); setMute(); setSpeed(); resetProgressHealth(); completionSince = 0; completionLessonKey = getLessonKey(); const title = getLessonTitle(); log.info(`正在学习: ${title}`); updatePanelStatus(`▶️ ${title}`); } // 获取当前课程名称 function getLessonTitle() { if (isAICourseClass()) { const active = getActiveLesson(); if (active) { const title = active.querySelector('.title-text, [title], [class*="title"], [class*="name"]'); const text = title?.getAttribute('title') || title?.textContent || active.textContent; if (text && text.trim()) return text.replace(/\s+/g, ' ').trim().slice(0, 120); } const heading = document.querySelector('.study-main h1, .knowledge-title, h1, h2'); return heading?.textContent?.trim() || '未知课程'; } if (isFusionClass()) { const item = getActiveLesson(); const name = item?.querySelector('.item-name'); if (name) return name.getAttribute('title') || name.textContent.trim() || '未知课程'; } if (isHikeClass()) { const span = $('.file-item.active span[title]'); return span ? span.getAttribute('title') : '未知课程'; } else { const el = $('#lessonOrder'); return el ? el.getAttribute('title') : '未知课程'; } } // ===================== 定时任务管理 ===================== let timers = []; let taskGeneration = 0; function getRuntimePageMode() { return isExamPage() ? 'exam' : isStudyPage() ? 'study' : 'idle'; } function watchPageContext(generation) { let route = location.href; let mode = getRuntimePageMode(); // 独立于学习任务检测 SPA 地址和延迟习题组件;不改写平台 history。 // 仍纳入 timers,手动停止后不会因路由变化而自行复活。 const id = setInterval(() => { if (generation !== taskGeneration) return; const nextRoute = location.href; const nextMode = getRuntimePageMode(); if (route === nextRoute && mode === nextMode) return; // 答题流程自己等待下一题挂载;同份试卷的分页地址变化不重建任务。 if (examRun && (examRun.submitting || examRun.context === getExamContextKey())) { route = nextRoute; return; } route = nextRoute; mode = nextMode; main().catch(error => log.warn('页面切换初始化失败: ' + error.message)); }, 1000); timers.push(id); } function syncRuntimePanels(mode) { const panel = $('#autovisor-panel'); if (panel && panel.dataset.runtimeMode !== mode) { panel.dataset.runtimeMode = mode; selectPanelTab(mode === 'exam' ? 'exam' : 'study'); const context = $('#av-context-label'); if (context) context.textContent = mode === 'exam' ? '当前:题目页面' : mode === 'study' ? '当前:视频学习' : '等待进入课程'; } const trial = $('#av-trial-modal'); if (trial) trial.hidden = true; refreshExamAnswerAction(); } function addTimer(fn, interval) { const generation = taskGeneration; let running = false; const tick = async () => { if (running || platformSafetyStopped || generation !== taskGeneration || !isStudyPage() || isExamPage()) return; if (hasBlockingPlatformDialog()) { checkPlatformDialogs(); return; } running = true; try { await fn(); } catch (e) { log.warn('定时任务执行失败: ' + e.message); } finally { running = false; } }; const id = setInterval(tick, interval); timers.push(id); return id; } function stopAllTasks() { taskGeneration++; stopExamAutomation('自动任务已停止'); invalidateStudyExerciseAnswer(); completionSince = 0; timers.forEach(id => clearInterval(id)); timers = []; log.info('所有任务已停止'); } // ===================== 悬浮控制面板 ===================== let panelStatusEl = null; let panelProgressEl = null; function createPanel() { if (!config.showPanel) return; if ($('#autovisor-panel')) return; const panel = document.createElement('div'); panel.id = 'autovisor-panel'; panel.innerHTML = `
智慧树助手 v1.5.5
等待进入课程
自动切课时略过作业/测试章节;随堂弹题按原设置处理。
播放控制
⏩ 倍速
${config.playbackSpeed}x
🔇 静音
自动化
▶️ 自动播放
⏭️ 自动下一集
💬 弹题作答
等待课程就绪 --
`; document.body.appendChild(panel); createStudyAiSettings(panel); panelStatusEl = $('#av-status'); panelProgressEl = $('#av-progress'); initializeUnifiedPanel(panel); // 倍速控制 $('#av-speed-down').addEventListener('click', () => { config.playbackSpeed = Math.max(0.5, +(config.playbackSpeed - 0.25).toFixed(2)); saveConfig(config); setSpeed(); $('#av-speed-val').textContent = config.playbackSpeed + 'x'; }); $('#av-speed-up').addEventListener('click', () => { config.playbackSpeed = Math.min(1.8, +(config.playbackSpeed + 0.25).toFixed(2)); saveConfig(config); setSpeed(); $('#av-speed-val').textContent = config.playbackSpeed + 'x'; }); // 静音切换 $('#av-mute-btn').addEventListener('click', () => { config.soundOff = !config.soundOff; saveConfig(config); const video = getVideo(); if (video) { video.volume = config.soundOff ? 0 : 0.5; const volumeBox = $('.volumeBox'); if (volumeBox) { if (config.soundOff) volumeBox.classList.add('volumeNone'); else volumeBox.classList.remove('volumeNone'); } } const muteBtn = $('#av-mute-btn'); muteBtn.textContent = config.soundOff ? '已静音' : '有声音'; muteBtn.className = 'av-btn ' + (config.soundOff ? 'av-on' : 'av-off'); }); // 开关切换 function toggleBtn(key, btnId) { $(btnId).addEventListener('click', () => { config[key] = !config[key]; saveConfig(config); const btn = $(btnId); btn.textContent = config[key] ? '已开启' : '已关闭'; btn.className = 'av-btn ' + (config[key] ? 'av-on' : 'av-off'); }); } toggleBtn('autoPlay', '#av-autoplay-btn'); toggleBtn('autoNext', '#av-autonext-btn'); toggleBtn('autoAnswer', '#av-autoanswer-btn'); createExamPanel(); refreshSkipAnswerUI(); syncRuntimePanels(getRuntimePageMode()); } function createStudyAiSettings(panel) { if (!panel || panel.querySelector('#av-ai-settings')) return; const body = panel.querySelector('#av-panel-body'); if (!body) return; const settings = document.createElement('details'); settings.id = 'av-ai-settings'; settings.className = 'av-section av-ai-settings'; settings.innerHTML = ` 助教答题设置

用于助教随堂题,与自动答题标签分别选择题库来源;AI 模型配置共用。更换来源或保存后可重试当前题目。

模型配置与本脚本考试页共用,保存后用于助教题重试。

当前模型:未选择

`; body.appendChild(settings); const find = selector => settings.querySelector(selector); const modeInput = find('#av-study-answer-mode'); const verifyInput = find('#av-study-verify-code'); const verifyButton = find('#av-study-verify-btn'); const verifyMessage = find('#av-study-verify-msg'); const paidInput = find('#av-study-paid-code'); const activateButton = find('#av-study-activate-btn'); const creditsButton = find('#av-study-credits-btn'); const paidMessage = find('#av-study-paid-msg'); let uiGeneration = 0; let verifyPending = false; let paidPending = false; let verifyExpiryTimer = null; const isCurrentView = (generation, mode) => settings.isConnected && generation === uiGeneration && modeInput.value === mode; const showMessage = (element, text, error = false) => { element.textContent = text; element.style.color = error ? '#fca5a5' : '#86efac'; }; const refreshVerification = () => { clearTimeout(verifyExpiryTimer); if (!settings.isConnected || modeInput.value !== 'free') return; const validUntil = Number(getVerifyState()?.validUntil || 0); const valid = Boolean(isVerifyValid()); const status = find('#av-study-verify-status'); status.textContent = valid ? `已验证,有效期至 ${new Date(validUntil * 1000).toLocaleString()}` : validUntil > 0 ? `验证码已于 ${new Date(validUntil * 1000).toLocaleString()} 到期,请重新验证。` : '尚未验证,请输入验证码。'; status.style.color = valid ? '#86efac' : '#cbd5e1'; verifyInput.disabled = verifyPending; verifyButton.disabled = verifyPending; if (valid) verifyExpiryTimer = setTimeout(refreshVerification, Math.min(Math.max(validUntil * 1000 - Date.now() + 50, 100), 2147483647)); }; const syncMode = () => { ['ai', 'free', 'paid'].forEach(mode => { find(`#av-study-area-${mode}`).hidden = modeInput.value !== mode; }); if (modeInput.value === 'free') { const qr = find('#av-study-verify-qr'); if (!qr.getAttribute('src')) qr.src = 'https://qsy.iano.cn/yzm.png'; refreshVerification(); } else clearTimeout(verifyExpiryTimer); if (modeInput.value === 'paid') find('#av-study-device-id').value = getDeviceId(); keepPanelInViewport(panel); }; modeInput.value = getStudyAnswerMode(); modeInput.addEventListener('change', () => { uiGeneration++; setStudyAnswerMode(modeInput.value); verifyMessage.textContent = ''; paidMessage.textContent = ''; syncMode(); }); settings.addEventListener('toggle', () => { if (settings.open) syncMode(); }); find('#av-study-verify-qr').addEventListener('error', event => { event.target.hidden = true; }); mountAiModelControls(settings, 'av', { canSave: () => modeInput.value === 'ai', onSave: () => invalidateStudyExerciseAnswer(), onLayout: () => keepPanelInViewport(panel) }); syncMode(); verifyInput.addEventListener('input', () => verifyInput.removeAttribute('aria-invalid')); paidInput.addEventListener('input', () => paidInput.removeAttribute('aria-invalid')); verifyInput.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); verifyButton.click(); } }); paidInput.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); activateButton.click(); } }); verifyButton.addEventListener('click', async () => { if (verifyPending || modeInput.value !== 'free') return; const code = verifyInput.value.trim(); if (code.length !== 4) { verifyInput.setAttribute('aria-invalid', 'true'); showMessage(verifyMessage, '请输入4位验证码。', true); verifyInput.focus(); return; } const generation = uiGeneration; verifyPending = true; verifyInput.disabled = verifyButton.disabled = true; verifyButton.textContent = '验证中…'; find('#av-study-area-free').setAttribute('aria-busy', 'true'); showMessage(verifyMessage, '正在验证,请稍候。'); try { const result = await verifyCode(code); if (!isCurrentView(generation, 'free')) return; showMessage(verifyMessage, result?.msg || '验证失败,请重试。', !result?.ok); if (result?.ok) { verifyInput.value = ''; invalidateStudyExerciseAnswer(); } else verifyInput.setAttribute('aria-invalid', 'true'); } catch (_) { if (isCurrentView(generation, 'free')) showMessage(verifyMessage, '验证请求失败,请重试。', true); } finally { verifyPending = false; verifyInput.disabled = verifyButton.disabled = false; verifyButton.textContent = '验证'; find('#av-study-area-free').removeAttribute('aria-busy'); refreshVerification(); } }); const runPaidAction = async activate => { if (paidPending || modeInput.value !== 'paid') return; const code = paidInput.value.trim(); if (activate && !code) { paidInput.setAttribute('aria-invalid', 'true'); showMessage(paidMessage, '请输入激活码。', true); paidInput.focus(); return; } const generation = uiGeneration; paidPending = true; paidInput.disabled = activateButton.disabled = creditsButton.disabled = true; find('#av-study-area-paid').setAttribute('aria-busy', 'true'); showMessage(paidMessage, activate ? '正在激活,请稍候。' : '正在刷新余额,请稍候。'); try { if (activate) { const result = await activateCode(code); if (!isCurrentView(generation, 'paid')) return; showMessage(paidMessage, result?.msg || (result?.ok ? '激活成功,正在刷新余额。' : '激活失败,请重试。'), !result?.ok); if (!result?.ok) { paidInput.setAttribute('aria-invalid', 'true'); return; } paidInput.value = ''; invalidateStudyExerciseAnswer(); } const credits = await queryCredits(); if (!isCurrentView(generation, 'paid')) return; find('#av-study-credits').textContent = Number.isFinite(Number(credits)) && Number(credits) >= 0 ? String(credits) : '--'; showMessage(paidMessage, activate ? '激活成功,余额已刷新。' : '余额已刷新。'); } catch (_) { if (isCurrentView(generation, 'paid')) showMessage(paidMessage, '请求失败,请重试。', true); } finally { paidPending = false; paidInput.disabled = activateButton.disabled = creditsButton.disabled = false; find('#av-study-area-paid').removeAttribute('aria-busy'); } }; activateButton.addEventListener('click', () => runPaidAction(true)); creditsButton.addEventListener('click', () => runPaidAction(false)); } // 面板与悬浮球共用一份开闭/位置状态,切换标签和收起不影响任务。 const savedPanelUi = GM_getValue('av_panel_ui', {}); const panelUi = savedPanelUi && typeof savedPanelUi === 'object' ? savedPanelUi : {}; function savePanelUi() { GM_setValue('av_panel_ui', panelUi); } function restorePanelPosition(element, position) { if (!position || !Number.isFinite(position.left) || !Number.isFinite(position.top)) return; element.style.left = position.left + 'px'; element.style.top = position.top + 'px'; element.style.right = 'auto'; element.style.bottom = 'auto'; } function rememberPanelPosition(element, key) { const rect = element.getBoundingClientRect(); panelUi[key] = { left: rect.left, top: rect.top }; savePanelUi(); } function setPanelMinimized(minimized, moveFocus = true) { const panel = $('#autovisor-panel'), ball = $('#av-floating-ball'); if (!panel || !ball) return; panelUi.minimized = Boolean(minimized); panel.hidden = panelUi.minimized; ball.hidden = !panelUi.minimized; ball.setAttribute('aria-expanded', String(!panelUi.minimized)); const visible = panelUi.minimized ? ball : panel; keepPanelInViewport(visible); if (moveFocus) (panelUi.minimized ? ball : panel.querySelector('[role="tab"][aria-selected="true"]')).focus({ preventScroll: true }); savePanelUi(); } function selectPanelTab(tab, moveFocus = false) { const study = tab !== 'exam'; const panel = $('#autovisor-panel'); if (!panel) return; panel.dataset.activeTab = study ? 'study' : 'exam'; for (const name of ['study', 'exam']) { const button = $('#av-tab-' + name), selected = name === (study ? 'study' : 'exam'); const pane = $(name === 'study' ? '#av-study-pane' : '#av-exam-panel'); if (button) { button.setAttribute('aria-selected', String(selected)); button.tabIndex = selected ? 0 : -1; if (selected && moveFocus) button.focus(); } if (pane) pane.hidden = !selected; } const modelRoot = study ? $('#av-ai-settings') : $('#av-exam-panel'); modelRoot?._avAiControls?.refresh(); if (!panel.hidden) keepPanelInViewport(panel); } function refreshSkipAnswerUI() { const skip = $('#av-skip-answer'), submit = $('#ae-auto-submit'); if (skip) skip.checked = Boolean(config.skipAnswer); const help = $('#av-skip-help'); if (help) help.textContent = config.skipAnswer ? '已开启:自动切课略过作业/测试,继续后面的学习章节。' : '自动切课时略过作业/测试章节;随堂弹题按原设置处理。'; if (submit) submit.disabled = Boolean(config.skipAnswer); const button = $('#av-autoanswer-btn'); if (button) { button.disabled = false; button.textContent = config.autoAnswer ? '已开启' : '已关闭'; button.className = 'av-btn ' + (config.autoAnswer ? 'av-on' : 'av-off'); } if (config.skipAnswer) updateExamAutomationStatus('已开启跳过答题章节。目录可定位时自动进入后续学习资源;无课程目录时保留本页,请手动返回课程。'); refreshExamAnswerAction(); } function initializeUnifiedPanel(panel) { panel.setAttribute('role', 'region'); panel.setAttribute('aria-label', '智慧树助手'); const controller = new AbortController(); let disposed = false; panel._avController = controller; const style = document.createElement('style'); style.textContent = ` #autovisor-panel { width:400px; background:#111c2e; border:1px solid #344259; border-radius:14px; backdrop-filter:none; -webkit-backdrop-filter:none; box-shadow:0 12px 36px #02061740; animation:none; color-scheme:dark; } #autovisor-panel[hidden], #autovisor-panel [hidden], #av-floating-ball[hidden] { display:none !important; } #autovisor-panel .av-header { padding:14px 16px; border:0; background:none; touch-action:none; } #autovisor-panel .av-brand-mark { display:grid; place-items:center; width:34px; height:34px; flex-shrink:0; background:#2563eb; color:#fff; border-radius:10px; font-size:16px; font-weight:700; } #autovisor-panel .av-title { background:none; -webkit-text-fill-color:currentColor; color:#f1f5f9; font-size:14px; line-height:21px; } #autovisor-panel .av-ver { color:#9aacc5; background:none; padding:0 0 0 4px; font-size:10px; font-weight:400; } #autovisor-panel .av-context { color:#a5b4cb; font-size:11px; line-height:16px; } #autovisor-panel .av-toggle-btn { width:34px; height:34px; font-size:22px; color:#cbd5e1; } #autovisor-panel .av-tabs { display:flex; margin:0 16px; border-bottom:1px solid #344259; flex-shrink:0; } #autovisor-panel .av-tabs button { flex:1; min-width:0; min-height:42px; border:0; border-bottom:2px solid transparent; background:none; color:#a5b4cb; font:inherit; font-weight:600; cursor:pointer; } #autovisor-panel .av-tabs button[aria-selected="true"] { color:#93c5fd; border-bottom-color:#60a5fa; } #autovisor-panel .av-tabs button:hover { background:#1c2b42; } #autovisor-panel .av-skip-control { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid #29374d; flex-shrink:0; } #autovisor-panel .av-skip-control label { display:flex; gap:8px; align-items:center; min-height:28px; flex-shrink:0; cursor:pointer; font-size:12px; font-weight:600; color:#e2e8f0; } #autovisor-panel .av-skip-control input { width:16px; height:16px; margin:0; accent-color:#60a5fa; } #autovisor-panel #av-skip-help { color:#a5b4cb; font-size:11px; line-height:1.6; } #autovisor-panel #av-workspace { display:flex; flex-direction:column; min-height:0; overflow:hidden; flex:1 1 auto; } #autovisor-panel #av-study-pane { display:flex; flex-direction:column; min-height:0; overflow:hidden; } #autovisor-panel .av-body { padding:8px 0 12px; } #autovisor-panel .av-section { padding:8px 16px; } #autovisor-panel .av-section-title { font-size:12px; color:#cbd5e1; letter-spacing:0; margin-bottom:8px; } #autovisor-panel .av-automation-grid { grid-template-columns:minmax(0,1fr); gap:3px; } #autovisor-panel .av-row { min-height:34px; } #autovisor-panel .av-btn { min-height:30px; transition:background-color .15s; } #autovisor-panel .av-btn:hover { transform:none; } #autovisor-panel button:disabled { cursor:default; opacity:.55; transform:none; } #autovisor-panel .av-label, #autovisor-panel .av-ai-help { color:#a5b4cb; } #autovisor-panel .av-status-bar { min-height:42px; padding:9px 16px; background:#142136; border-color:#29374d; } #autovisor-panel .av-status-dot { box-shadow:none; animation:none; } #autovisor-panel .av-status-text { color:#cbd5e1; font-size:11px; } #autovisor-panel #av-exam-panel { position:static; inset:auto; z-index:auto; width:100%; max-width:none; max-height:none; min-height:0; padding:12px 16px; flex:1 1 auto; overflow-y:auto; overflow-x:hidden; overscroll-behavior:contain; border:0; border-radius:0; background:none; box-shadow:none; backdrop-filter:none; -webkit-backdrop-filter:none; animation:none; } #autovisor-panel #av-exam-panel .ae-section { padding:10px 0; margin:0; background:none; border-radius:0; border-bottom:1px solid #29374d; } #autovisor-panel #av-exam-panel .ae-row { gap:8px; min-height:32px; } #autovisor-panel #av-exam-panel .ae-label { color:#a5b4cb; } #autovisor-panel #av-exam-panel .ae-input { min-height:32px; border-radius:6px; color:#e2e8f0; border-color:#475569; background:#172235; font:inherit; font-size:12px; } #autovisor-panel #av-exam-panel .ae-btn { min-height:32px; border-radius:6px; } #autovisor-panel #av-exam-panel .ae-auto-controls { gap:6px 16px; } #autovisor-panel #av-exam-panel .ae-auto-option { min-height:34px; font-size:12px; } #autovisor-panel #av-exam-panel .ae-auto-status { margin:6px 0 0; color:#a5b4cb; } #autovisor-panel #av-exam-panel .ae-answer-action { width:100%; min-height:42px; margin:0 0 8px; padding:9px 12px; border:1px solid #60a5fa; border-radius:8px; background:#2563eb; color:#fff; font-family:inherit; font-size:13px; font-weight:600; line-height:1.5; cursor:pointer; } #autovisor-panel #av-exam-panel .ae-answer-action:hover { background:#1d4ed8; } #autovisor-panel #av-exam-panel .ae-answer-action[data-state="running"] { background:#25364e; border-color:#64748b; color:#e2e8f0; } #autovisor-panel #av-exam-panel .ae-answer-action[data-state="completed"] { background:#153d38; border-color:#3d786a; color:#a7f3d0; } #autovisor-panel #av-exam-panel .ae-answer-action:disabled { opacity:.55; cursor:default; } #autovisor-panel #av-exam-panel .ae-answer-action[data-state="completed"]:disabled { opacity:1; } #autovisor-panel #av-exam-panel .ae-config-settings { padding:8px 0; border-bottom:1px solid #29374d; } #autovisor-panel #av-exam-panel .ae-config-settings summary { padding:8px 0; color:#cbd5e1; font-size:12px; font-weight:600; cursor:pointer; } #autovisor-panel #av-exam-panel .ae-mode-tab { position:relative; } #autovisor-panel #av-exam-panel .ae-mode-tab:has(input:focus-visible) { outline:2px solid #93c5fd; outline-offset:-2px; } #autovisor-panel #av-exam-panel #ae-question-list { margin-top:12px; } #av-floating-ball { position:fixed; right:18px; bottom:100px; z-index:999999; display:grid; place-items:center; width:52px; height:52px; padding:0; border:1px solid #93c5fd; border-radius:50%; background:#2563eb; color:#fff; box-shadow:0 4px 16px #02061738; font:700 20px 'Microsoft YaHei',sans-serif; cursor:pointer; touch-action:none; user-select:none; } #av-floating-ball::after { content:''; position:absolute; right:1px; bottom:2px; width:9px; height:9px; border:2px solid #111c2e; border-radius:50%; background:#4ade80; } #av-floating-ball:focus-visible, #autovisor-panel :is(button,input,[role="tabpanel"]):focus-visible { outline:2px solid #93c5fd; outline-offset:2px; } @media (max-width:399px) { #autovisor-panel .av-header { padding:12px; } #autovisor-panel .av-skip-control { padding:10px 12px; gap:10px; } #autovisor-panel .av-tabs { margin:0 12px; } #autovisor-panel .av-section { padding:8px 12px; } #autovisor-panel #av-exam-panel { padding:10px 12px; } } @media (prefers-reduced-motion:reduce) { #autovisor-panel *, #autovisor-panel *::before, #autovisor-panel *::after { animation:none !important; transition:none !important; } } `; panel.appendChild(style); // 重建时先清理旧入口,独立弹窗与监听随 shell 一起释放。 $('#av-floating-ball')?.remove(); $('#av-trial-modal')?.remove(); const ball = document.createElement('button'); ball.id = 'av-floating-ball'; ball.type = 'button'; ball.textContent = '智'; ball.setAttribute('aria-label', '展开智慧树助手'); ball.setAttribute('aria-controls', 'autovisor-panel'); ball.title = '智慧树助手 · 点击展开,拖动调整位置'; document.body.appendChild(ball); restorePanelPosition(panel, panelUi.panelPosition); restorePanelPosition(ball, panelUi.ballPosition); let draggedBall = false; ball.addEventListener('pointerdown', () => { draggedBall = false; }); ball.addEventListener('click', event => { if (draggedBall && event.detail !== 0) { draggedBall = false; return; } draggedBall = false; setPanelMinimized(false); }); $('#av-collapse').addEventListener('click', () => setPanelMinimized(true)); $('#av-skip-answer').addEventListener('change', event => setSkipAnswer(event.target.checked)); for (const name of ['study', 'exam']) { const tab = $('#av-tab-' + name); tab.addEventListener('click', () => selectPanelTab(name)); tab.addEventListener('keydown', event => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); selectPanelTab(event.key === 'Home' ? 'study' : event.key === 'End' ? 'exam' : name === 'study' ? 'exam' : 'study', true); }); } panel.addEventListener('keydown', event => { if (event.key === 'Escape' && !$('#av-trial-modal')?.hidden) return; if (event.key === 'Escape') { event.preventDefault(); setPanelMinimized(true); } }); makeDraggable(panel, $('#av-drag-handle'), { constrainToViewport: true, onDragEnd: () => rememberPanelPosition(panel, 'panelPosition') }); makeDraggable(ball, ball, { constrainToViewport: true, allowInteractiveHandle: true, onDragEnd: () => { draggedBall = true; rememberPanelPosition(ball, 'ballPosition'); } }); const onResize = () => { if (!panel.isConnected) { panel._avDispose(); return; } keepPanelInViewport(panel.hidden ? ball : panel); }; const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(onResize) : null; observer?.observe(panel); window.addEventListener('resize', onResize, { signal: controller.signal }); panel._avDispose = () => { if (disposed) return; disposed = true; controller.abort(); observer?.disconnect(); ball.remove(); $('#av-trial-modal')?.remove(); $('#av-ai-settings')?._avAiControls?.dispose(); $('#av-exam-panel')?._avAiControls?.dispose(); }; setPanelMinimized(Boolean(panelUi.minimized), false); } function keepPanelInViewport(el) { const rect = el.getBoundingClientRect(); const width = document.documentElement.clientWidth || window.innerWidth; const height = document.documentElement.clientHeight || window.innerHeight; const left = Math.max(12, Math.min(rect.left, width - rect.width - 12)); const top = Math.max(12, Math.min(rect.top, height - rect.height - 12)); if (Math.abs(left - rect.left) > 0.5 || Math.abs(top - rect.top) > 0.5) { el.style.left = left + 'px'; el.style.top = top + 'px'; el.style.right = 'auto'; } } function makeDraggable(el, handle, { constrainToViewport = false, allowInteractiveHandle = false, onDragEnd } = {}) { let drag = null; handle.addEventListener('pointerdown', event => { if (event.button !== 0 || !allowInteractiveHandle && event.target.closest('button, a, input, select, textarea')) return; const rect = el.getBoundingClientRect(); drag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false }; handle.setPointerCapture?.(event.pointerId); }); handle.addEventListener('pointermove', event => { if (!drag || event.pointerId !== drag.id) return; const dx = event.clientX - drag.x, dy = event.clientY - drag.y; if (!drag.moved && Math.hypot(dx, dy) < 5) return; drag.moved = true; event.preventDefault(); el.style.left = drag.left + dx + 'px'; el.style.top = drag.top + dy + 'px'; el.style.right = 'auto'; el.style.bottom = 'auto'; if (constrainToViewport) keepPanelInViewport(el); }); const finish = event => { if (!drag || event.pointerId !== drag.id) return; const moved = drag.moved; drag = null; if (handle.hasPointerCapture?.(event.pointerId)) handle.releasePointerCapture(event.pointerId); if (moved && event.type === 'pointerup') onDragEnd?.(); }; handle.addEventListener('pointerup', finish); handle.addEventListener('pointercancel', finish); handle.addEventListener('lostpointercapture', finish); } function updatePanelStatus(text) { if (panelStatusEl) { panelStatusEl.textContent = text; panelStatusEl.title = text; } const ball = $('#av-floating-ball'); if (ball) { ball.title = '智慧树助手 · ' + text; ball.setAttribute('aria-label', '展开智慧树助手,' + text); } } function updatePanelProgress(text) { if (panelProgressEl) panelProgressEl.textContent = text; } // ===================== 考试答题模块 ===================== // 按用户勾选启动自动作答;AI 使用 OpenAI 兼容接口,题库来源分别配置。 const AI_PROVIDERS = { zhipu: { name: '智谱 GLM-4-Flash', model: 'glm-4-flash', url: 'https://open.bigmodel.cn/api/paas/v4/chat/completions', desc: '智谱 AI,中文题目适配较好' }, doubao: { name: '豆包 Doubao-lite', model: 'doubao-lite-32k', url: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions', desc: '火山引擎豆包模型(模型名可按控制台配置修改)' }, deepseek: { name: 'DeepSeek Flash(V4.1)', model: 'deepseek-flash', url: 'https://api.deepseek.com/chat/completions', thinking: 'disabled', desc: 'DeepSeek 官方 Flash 模型' }, deepseek_v4: { name: 'DeepSeek V4 Pro', model: 'deepseek-v4-pro', url: 'https://api.deepseek.com/chat/completions', thinking: 'disabled', desc: 'DeepSeek 官方 Pro 模型' } }; // 模型发现与能力状态:目录、草稿、异步回包均按来源 + Key + 接口地址隔离。 function aiConfigIdentity(config) { const provider = String(config?.provider || '').trim(); const url = String(provider === 'custom' ? config?.customUrl || config?.url || '' : AI_PROVIDERS[provider]?.url || '').trim(); const key = String(config?.apiKey || '').trim(); return JSON.stringify([provider, url, key]); } function normalizeModelSearch(value) { return String(value || '').normalize('NFKC').toLocaleLowerCase().trim(); } function modelVisionMetadata(entry) { const flags = [entry?.supports_vision, entry?.supports_image_input, entry?.vision, entry?.capabilities?.vision, entry?.capabilities?.image_input]; if (flags.some(value => value === true || value === 'true')) return 'supported'; if (flags.some(value => value === false || value === 'false')) return 'unsupported'; const modalities = [entry?.input_modalities, entry?.supported_input_modalities, entry?.modalities?.input, entry?.architecture?.input_modalities, entry?.capabilities?.input_modalities] .flatMap(value => Array.isArray(value) ? value : value ? [value] : []); if (modalities.some(value => /^(?:image|images|vision)$/i.test(String(value).trim()))) return 'supported'; if (modalities.length && modalities.every(value => /^(?:text|audio)$/i.test(String(value).trim()))) return 'unsupported'; return 'unknown'; } function modelDiscoveryRequest(url, apiKey) { return new Promise((resolve, reject) => { if (typeof GM_xmlhttpRequest !== 'function') return reject(new Error('无法获取模型,请用 Tampermonkey 运行脚本;也可手动填写模型 ID')); let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error('获取模型超时;可手动填写模型 ID')); } }, 16000); const finish = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); fn(value); }; try { GM_xmlhttpRequest({ method: 'GET', url, timeout: 15000, anonymous: true, headers: { Authorization: `Bearer ${apiKey}` }, onload: response => { const status = Number(response.status) || 0; if (status < 200 || status >= 300) { const hints = { 401: 'API Key 无效或已过期', 403: '当前 Key 没有获取列表的权限', 404: '该服务未提供标准模型列表接口', 405: '该服务不支持获取模型列表', 429: '请求过于频繁或配额不足' }; return finish(reject, new Error(`HTTP ${status} · ${hints[status] || '获取模型列表失败'};可手动填写模型 ID`)); } if (typeof response.responseText !== 'string' || response.responseText.length > 2 * 1024 * 1024) return finish(reject, new Error('模型列表响应过大或无效,请手动填写模型 ID')); try { finish(resolve, JSON.parse(response.responseText)); } catch (_) { finish(reject, new Error('模型列表不是有效 JSON,请手动填写模型 ID')); } }, onerror: () => finish(reject, new Error('获取模型列表失败,请检查网络和脚本跨域授权;可手动填写模型 ID')), ontimeout: () => finish(reject, new Error('获取模型超时;可手动填写模型 ID')), onabort: () => finish(reject, new Error('获取模型已取消;可手动填写模型 ID')), }); } catch (_) { finish(reject, new Error('无法发出模型列表请求,请检查接口和脚本跨域授权')); } }); } async function listAvailableModels(config) { const endpoint = getAiEndpoint(config); if (!endpoint || !String(config?.apiKey || '').trim()) throw new Error('请先填写 API Key 和接口配置'); let url; try { url = new URL(endpoint); url.pathname = url.pathname.replace(/\/chat\/completions\/?$/i, '/models'); url.search = ''; } catch (_) { throw new Error('接口地址无效,请检查配置'); } const raw = await modelDiscoveryRequest(url.href, String(config.apiKey).trim()); const entries = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.models) ? raw.models : Array.isArray(raw) ? raw : null; if (!entries) throw new Error('未识别到标准模型列表,请手动填写模型 ID'); const models = [], seen = new Set(); for (const entry of entries) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; const id = String(entry.id || entry.model || '').trim(); if (!id || id.length > 200 || /[\s\u0000-\u001f\u007f<>"']/i.test(id) || seen.has(id)) continue; seen.add(id); const name = String(entry.name || entry.display_name || id).replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 160) || id; models.push({ id, name, vision: modelVisionMetadata(entry) }); if (models.length >= 300) break; } return { models, message: models.length ? '列表不代表当前 Key 一定有调用权限;图片能力需单独检测。' : '接口未返回可选模型,仍可手动填写模型 ID。' }; } function getStoredAiProfile(config, own = {}) { const profiles = own && own.aiProfiles && typeof own.aiProfiles === 'object' ? own.aiProfiles : {}; const profile = profiles[aiConfigIdentity(config)]; return profile && typeof profile === 'object' ? profile : {}; } function saveModelCatalog(config, models) { let own = {}; try { own = GM_getValue('av_ai_config', {}) || {}; } catch (_) {} if (typeof own === 'string') { try { own = JSON.parse(own); } catch (_) { own = {}; } } const identity = aiConfigIdentity(config); own.modelCatalogs = { ...(own.modelCatalogs || {}), [identity]: { models, updatedAt: Date.now() } }; try { GM_setValue('av_ai_config', own); } catch (_) {} } function getStoredAiCatalog(config) { let own = {}; try { own = GM_getValue('av_ai_config', {}) || {}; } catch (_) {} if (typeof own === 'string') { try { own = JSON.parse(own); } catch (_) { own = {}; } } const item = own?.modelCatalogs?.[aiConfigIdentity(config)]; return Array.isArray(item?.models) ? item.models : []; } async function checkAiVision(config) { const endpoint = getAiEndpoint(config); const model = getAiModel(config); if (!endpoint || !config?.apiKey || !model) throw new Error('请先填写 API Key 和模型 ID'); const challenge = String(1000 + Math.floor(Math.random() * 9000)); let image; try { const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 100; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 320, 100); ctx.fillStyle = '#111'; ctx.font = 'bold 56px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(challenge, 160, 50); image = canvas.toDataURL('image/png'); } catch (_) { throw new Error('浏览器无法生成检测图片'); } const payload = { model, temperature: 0, max_tokens: 32, stream: false, messages: [{ role: 'user', content: [{ type: 'text', text: '请读取图片中央的四位数字,只输出数字。' }, { type: 'image_url', image_url: { url: image } }] }] }; const res = await gmRequest({ method: 'POST', url: endpoint, anonymous: true, headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${config.apiKey}` }, data: JSON.stringify(payload), timeout: 30000 }); if (!res || res.status < 200 || res.status >= 300) throw new Error('检测请求失败,图片能力尚未确认'); let json; try { json = JSON.parse(res.responseText || '{}'); } catch (_) { throw new Error('检测响应不是有效 JSON'); } const text = parseAiResponseText(json).replace(/[^0-9]/g, ''); const status = text === challenge ? 'supported' : 'unknown'; const message = status === 'supported' ? '检测通过:当前模型可以读取图片。' : '模型未正确读出检测图片,图片题将暂停。'; return { status, message }; } // 学习页与考试页共用模型控件;所有草稿与检测状态只属于对应配置。 function mountAiModelControls(root, prefix, options = {}) { const get = suffix => root.querySelector(`#${prefix}-ai-${suffix}`); const provider = get('provider'), apiKey = get('key'), url = get('url'), model = get('model'); const search = get('model-search'), list = get('model-list'), fetchButton = get('fetch-models'); const status = get('model-status'), name = get('model-name'), modelId = get('model-id'); const capability = get('model-capability'), visionButton = get('check-vision'), visionStatus = get('vision-status'); const hint = get('hint'), message = get('msg'), saveButton = get('save'); const urlRow = get('url-row') || get('custom-row'); if (!provider.options.length) { Object.entries(AI_PROVIDERS).forEach(([key, info]) => { const option = document.createElement('option'); option.value = key; option.textContent = info.name; provider.appendChild(option); }); const custom = document.createElement('option'); custom.value = 'custom'; custom.textContent = '自定义接口'; provider.appendChild(custom); } const initial = loadExamAiConfig(); const providerDrafts = new Map(), contextDrafts = new Map(), catalogs = new Map(), checks = new Map(); const pendingLists = new Map(), pendingVision = new Map(); let activeProvider = initial.provider, revision = 0, disposed = false; const isLive = () => !disposed && root.isConnected; const configKey = config => JSON.stringify([aiConfigIdentity(config), String(config.model || config.customModel || '').trim()]); const providerDefault = selected => String(AI_PROVIDERS[selected]?.model || ''); const cloneDraft = value => ({ provider: value.provider, apiKey: String(value.apiKey || '').trim(), customUrl: value.provider === 'custom' ? String(value.customUrl || '').trim() : '', customModel: String(value.model || value.customModel || providerDefault(value.provider)).trim(), model: String(value.model || value.customModel || providerDefault(value.provider)).trim(), vision: ['supported', 'unsupported'].includes(value.vision) ? value.vision : 'unknown' }); const read = () => ({ provider: provider.value, apiKey: apiKey.value.trim(), customUrl: provider.value === 'custom' ? url.value.trim() : '', customModel: model.value.trim(), model: model.value.trim(), vision: 'unknown' }); const storedProfile = config => { let own = {}; try { own = GM_getValue('av_ai_config', {}) || {}; if (typeof own === 'string') own = JSON.parse(own); } catch (_) {} const saved = own.aiProfiles?.[aiConfigIdentity(config)]; return saved && typeof saved === 'object' ? cloneDraft(saved) : null; }; const remember = config => { const draft = cloneDraft(config); contextDrafts.set(aiConfigIdentity(draft), draft); providerDrafts.set(draft.provider, draft); }; const noteCapability = config => { if (['supported', 'unsupported'].includes(config.vision)) checks.set(configKey(config), { status: config.vision, message: config.vision === 'supported' ? '已确认当前配置支持图片输入。' : '当前配置明确不支持图片输入。' }); }; const currentCatalog = () => { const current = read(), identity = aiConfigIdentity(current); if (!catalogs.has(identity)) catalogs.set(identity, getStoredAiCatalog(current)); return catalogs.get(identity) || []; }; const currentCapability = () => { const current = read(); const checked = checks.get(configKey(current)); if (checked) return checked; const metadata = currentCatalog().find(item => item.id === current.model)?.vision; return { status: metadata === 'unsupported' ? 'unsupported' : 'unknown', message: metadata === 'supported' ? '接口标注支持图片;请检测当前模型后用于图片题。' : metadata === 'unsupported' ? '接口标注不支持图片输入,可更换模型或检测确认。' : '当前模型图片能力尚未确认。' }; }; const liveDraft = () => ({ ...read(), vision: currentCapability().status }); const notify = (text, error = false) => { message.textContent = text; message.style.color = error ? '#fca5a5' : '#86efac'; }; const validate = (config, requireModel = true) => { if (!config.apiKey) return { element: apiKey, text: '请填写当前来源的 API Key。' }; if (/\s|[\u0000-\u001f\u007f]/.test(config.apiKey)) return { element: apiKey, text: 'API Key 不能包含空格或换行。' }; if (config.provider === 'custom') { try { const target = new URL(config.customUrl); if (!/^https?:$/.test(target.protocol) || target.username || target.password) throw new Error(); } catch (_) { return { element: url, text: '请填写有效的 HTTP/HTTPS 接口地址,地址不能包含账号密码。' }; } } if (requireModel && (!config.model || config.model.length > 200 || /\s|[\u0000-\u001f\u007f]/.test(config.model))) return { element: model, text: '请填写有效的完整模型 ID,不能包含空格或换行。' }; return null; }; const showValidation = invalid => { if (!invalid) return false; invalid.element.setAttribute('aria-invalid', 'true'); if (invalid.element === model) { const manual = get('model-manual'); if (manual) manual.open = true; } notify(invalid.text, true); invalid.element.focus(); return true; }; const updateDetails = () => { const current = read(), selected = currentCatalog().find(item => item.id === current.model); name.textContent = selected?.name || current.model || '未选择'; modelId.textContent = current.model ? `模型 ID:${current.model}` : '模型 ID:未填写'; const result = currentCapability(), key = configKey(current); capability.textContent = result.status === 'supported' ? '图片能力:已检测支持' : result.status === 'unsupported' ? '图片能力:不支持' : '图片能力:尚未确认'; capability.style.color = result.status === 'supported' ? '#86efac' : result.status === 'unsupported' ? '#fca5a5' : '#94a3b8'; visionButton.disabled = pendingVision.has(key); visionButton.textContent = visionButton.disabled ? '检测中…' : '检测图片能力'; visionStatus.textContent = visionButton.disabled ? '正在验证当前模型能否读取图片…' : result.message; visionStatus.style.color = result.status === 'supported' ? '#86efac' : result.status === 'unsupported' ? '#fca5a5' : '#94a3b8'; }; const render = () => { const current = read(), catalog = currentCatalog(), tokens = normalizeModelSearch(search.value).split(/\s+/).filter(Boolean); const filtered = catalog.filter(item => { const value = normalizeModelSearch(`${item.name} ${item.id}`); return tokens.every(token => value.includes(token)); }); list.replaceChildren(); filtered.forEach(item => { const option = document.createElement('option'); option.value = item.id; option.textContent = item.name === item.id ? item.id : `${item.name}(${item.id})`; list.appendChild(option); }); if (current.model && !filtered.some(item => item.id === current.model)) { const option = document.createElement('option'); option.value = current.model; option.textContent = `${current.model}(当前选择${tokens.length ? ',不在筛选结果中' : ''})`; list.appendChild(option); } if (!list.options.length) { const option = document.createElement('option'); option.value = ''; option.textContent = '请获取模型或手动填写 ID'; list.appendChild(option); } list.value = current.model; const identity = aiConfigIdentity(current); fetchButton.disabled = pendingLists.has(identity); fetchButton.textContent = fetchButton.disabled ? '获取中…' : '获取模型'; status.textContent = fetchButton.disabled ? '正在获取当前 Key 的模型列表…' : catalog.length ? `匹配 ${filtered.length} / 共 ${catalog.length} 个模型;未找到时可手动填写 ID。` : '尚未获取模型列表,可手动填写模型 ID。'; status.style.color = '#94a3b8'; updateDetails(); options.onLayout?.(); }; const apply = config => { const draft = cloneDraft(config); activeProvider = draft.provider; provider.value = draft.provider; apiKey.value = draft.apiKey; url.value = draft.customUrl; model.value = draft.model; search.value = ''; if (urlRow) { urlRow.hidden = draft.provider !== 'custom'; urlRow.style.display = draft.provider === 'custom' ? '' : 'none'; } hint.textContent = (AI_PROVIDERS[draft.provider]?.desc || 'OpenAI 兼容接口:从模型列表选择或手动填写模型 ID。') + ' 模型列表不保证调用权限;检测图片能力会发送小测试图,可能产生少量费用。'; noteCapability(draft); remember(draft); render(); }; const draftKey = value => JSON.stringify([configKey(value), ['supported', 'unsupported'].includes(value.vision) ? value.vision : 'unknown']); let lastDraft = cloneDraft(initial), committedKey = draftKey(initial); apply(initial); provider.addEventListener('change', () => { remember(lastDraft); revision++; const target = provider.value; const next = providerDrafts.get(target) || loadExamAiConfig(target); apply(next); lastDraft = liveDraft(); notify('已切换来源,确认配置后保存。'); }); [apiKey, url].forEach(input => input.addEventListener('input', () => { remember(lastDraft); revision++; const credentials = read(), identity = aiConfigIdentity(credentials); const prior = contextDrafts.get(identity) || storedProfile(credentials); model.value = prior?.model || providerDefault(credentials.provider); if (prior) noteCapability(prior); search.value = ''; lastDraft = liveDraft(); remember(lastDraft); input.removeAttribute('aria-invalid'); render(); notify('配置已修改,保存后用于答题。'); })); model.addEventListener('input', () => { revision++; model.removeAttribute('aria-invalid'); lastDraft = liveDraft(); remember(lastDraft); render(); notify('模型已修改,保存后用于答题。'); }); list.addEventListener('change', () => { model.value = list.value; revision++; lastDraft = liveDraft(); remember(lastDraft); render(); notify('模型已选择,保存后用于答题。'); }); search.addEventListener('input', render); fetchButton.addEventListener('click', async () => { const config = read(), identity = aiConfigIdentity(config); if (showValidation(validate(config, false)) || pendingLists.has(identity)) return; const generation = revision; const pending = listAvailableModels(config); pendingLists.set(identity, pending); render(); try { const result = await pending; pendingLists.delete(identity); catalogs.set(identity, result.models); saveModelCatalog(config, result.models); if (!isLive() || generation !== revision || identity !== aiConfigIdentity(read())) return; render(); status.textContent += ` ${result.message}`; } catch (error) { if (isLive() && generation === revision && identity === aiConfigIdentity(read())) { status.textContent = error.message || '获取模型失败,可手动填写模型 ID。'; status.style.color = '#fca5a5'; } } finally { pendingLists.delete(identity); if (isLive() && identity === aiConfigIdentity(read())) { fetchButton.disabled = false; fetchButton.textContent = '获取模型'; } } }); visionButton.addEventListener('click', async () => { const config = read(), identity = configKey(config); if (showValidation(validate(config)) || pendingVision.has(identity)) return; const generation = revision, pending = checkAiVision(config); pendingVision.set(identity, pending); updateDetails(); try { const result = await pending; pendingVision.delete(identity); checks.set(identity, result); if (!isLive() || generation !== revision || identity !== configKey(read())) return; updateDetails(); lastDraft = liveDraft(); remember(lastDraft); notify('图片能力检测已完成;保存后用于答题。'); } catch (error) { if (isLive() && generation === revision && identity === configKey(read())) { visionStatus.textContent = error.message || '检测未完成,请检查配置后重试。'; visionStatus.style.color = '#fca5a5'; } } finally { pendingVision.delete(identity); if (isLive() && identity === configKey(read())) { visionButton.disabled = false; visionButton.textContent = '检测图片能力'; } } }); saveButton.addEventListener('click', () => { if (options.canSave && !options.canSave()) return; const config = liveDraft(); if (showValidation(validate(config))) return; const saved = saveExamAiConfig(config); lastDraft = cloneDraft(saved); committedKey = draftKey(saved); remember(saved); options.onSave?.(saved); notify('AI 配置已保存,答题将使用当前来源和模型。'); render(); }); notify(initial.apiKey ? '已载入本脚本保存的 AI 配置。' : '请填写当前来源的 API Key,获取模型后保存。'); const controls = { read: liveDraft, refresh: () => { if (!isLive()) return; const saved = loadExamAiConfig(); if (draftKey(saved) === committedKey) return; if (draftKey(liveDraft()) !== committedKey) { notify('另一标签已保存 AI 配置;此处保留未保存的修改,保存后将覆盖共用配置。'); return; } revision++; catalogs.set(aiConfigIdentity(saved), getStoredAiCatalog(saved)); apply(saved); lastDraft = cloneDraft(saved); committedKey = draftKey(saved); notify('已同步另一标签保存的 AI 配置。'); }, dispose: () => { disposed = true; revision++; }, }; root._avAiControls = controls; return controls; } function readSharedAiConfig() { let raw = null; try { raw = GM_getValue('cx_answer_config', null); } catch (_) {} if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch (_) { raw = null; } } const shared = raw && typeof raw === 'object' ? raw : {}; // 雨课堂使用 localStorage 保存同一组 AI 配置;跨脚本复用时只读取 // 接口、模型和 Key,不读取任何题库/身份数据。 try { const provider = localStorage.getItem('ykt_ai_provider'); const apiKey = localStorage.getItem('ykt_ai_api_key'); const customUrl = localStorage.getItem('ykt_ai_custom_url'); const customModel = localStorage.getItem('ykt_ai_custom_model'); if (provider || apiKey || customUrl || customModel) { return Object.assign(shared, { aiProvider: provider || shared.aiProvider || '', aiApiKey: apiKey || shared.aiApiKey || '', aiCustomUrl: customUrl || shared.aiCustomUrl || '', aiCustomModel: customModel || shared.aiCustomModel || '' }); } } catch (_) {} return shared; } function loadExamAiConfig(requestedProvider) { const shared = readSharedAiConfig(); let own = {}; try { own = GM_getValue('av_ai_config', {}) || {}; } catch (_) {} if (typeof own === 'string') { try { own = JSON.parse(own); } catch (_) { own = {}; } } const rawProvider = String(own.provider || shared.aiProvider || (isAiExamHost() ? 'deepseek' : 'zhipu')); const selectedProvider = (AI_PROVIDERS[rawProvider] || rawProvider === 'custom') ? rawProvider : 'deepseek'; const provider = requestedProvider && (AI_PROVIDERS[requestedProvider] || requestedProvider === 'custom') ? requestedProvider : selectedProvider; const legacyApplies = provider === selectedProvider; const saved = own.providerConfigs?.[provider]; const sharedProfile = shared.aiProviderConfigs?.[provider]; const active = saved && typeof saved === 'object' ? saved : legacyApplies ? own : {}; const sharedApplies = provider === shared.aiProvider; const base = { provider, apiKey: String(active.apiKey ?? sharedProfile?.apiKey ?? (sharedApplies ? shared.aiApiKey : '') ?? '').trim(), customUrl: String(active.customUrl ?? sharedProfile?.url ?? (provider === 'custom' && sharedApplies ? shared.aiCustomUrl : '') ?? '').trim(), customModel: String(active.customModel ?? sharedProfile?.model ?? (provider === 'custom' && sharedApplies ? shared.aiCustomModel : '') ?? '').trim(), model: String(active.model ?? sharedProfile?.model ?? (provider === 'custom' ? active.customModel || (sharedApplies ? shared.aiCustomModel : '') : AI_PROVIDERS[provider]?.model) ?? '').trim(), vision: active.vision || 'unknown' }; const profile = getStoredAiProfile(base, own); return { ...base, ...profile, provider }; } function saveExamAiConfig(config) { const clean = { provider: String(config.provider || 'deepseek'), apiKey: String(config.apiKey || '').trim(), customUrl: String(config.customUrl || '').trim(), customModel: String(config.customModel || '').trim(), model: String(config.model || '').trim(), vision: ['supported', 'unsupported', 'unknown'].includes(config.vision) ? config.vision : 'unknown' }; let own = {}; try { own = GM_getValue('av_ai_config', {}) || {}; } catch (_) {} if (typeof own === 'string') { try { own = JSON.parse(own); } catch (_) { own = {}; } } const identity = aiConfigIdentity(clean); const previous = loadExamAiConfig(); const previousIdentity = aiConfigIdentity(previous); // 首次从旧版全局配置升级时,先记住原来源,避免保存另一来源后丢失旧 Key。 own = { ...own, ...clean, aiProfiles: { [previousIdentity]: previous, ...(own.aiProfiles || {}), [identity]: clean }, providerConfigs: { [previous.provider]: previous, ...(own.providerConfigs || {}), [clean.provider]: clean } }; try { GM_setValue('av_ai_config', own); } catch (_) {} // 与学习通/雨课堂共用 API Key 和自定义接口配置,用户只需填写一次。 const shared = readSharedAiConfig(); shared.aiProvider = clean.provider; shared.aiApiKey = clean.apiKey; shared.aiCustomUrl = clean.customUrl; shared.aiCustomModel = clean.customModel; try { GM_setValue('cx_answer_config', JSON.stringify(shared)); } catch (_) {} try { localStorage.setItem('ykt_ai_provider', clean.provider); localStorage.setItem('ykt_ai_api_key', clean.apiKey); localStorage.setItem('ykt_ai_custom_url', clean.customUrl); localStorage.setItem('ykt_ai_custom_model', clean.customModel); } catch (_) {} return clean; } function getAiEndpoint(ai) { if (ai?.provider !== 'custom') return AI_PROVIDERS[ai?.provider]?.url || ''; const raw = String(ai.customUrl || '').trim(); if (!raw) return ''; try { const endpoint = new URL(raw); if (!/^https?:$/.test(endpoint.protocol) || endpoint.username || endpoint.password) return ''; let path = endpoint.pathname.replace(/\/+$/, ''); if (!/\/chat\/completions$/i.test(path)) path += /\/(?:v\d+|api\/paas\/v\d+)$/i.test(path) ? '/chat/completions' : '/v1/chat/completions'; endpoint.pathname = path; endpoint.hash = ''; return endpoint.href; } catch (_) { return ''; } } function getAiModel(ai) { if (ai.provider === 'custom') return String(ai.customModel || '').trim(); return String(ai.model || AI_PROVIDERS[ai.provider]?.model || '').trim(); } function parseAiResponseText(json) { const choice = json && Array.isArray(json.choices) ? json.choices[0] : null; const finishReason = String(choice?.finish_reason || '').toLowerCase(); if (['length', 'content_filter', 'tool_calls', 'aborted'].includes(finishReason)) { return ''; } let content = choice && choice.message ? choice.message.content : (choice && choice.text); if (Array.isArray(content)) content = content.map(x => typeof x === 'string' ? x : (x && x.text || '')).join(''); return String(content || '').trim(); } function parseAiAnswers(content, q) { let text = String(content || '').trim(); if (!text) return []; const fenced = text.match(/```(?:json|text)?\s*([\s\S]*?)```/i); if (fenced) text = fenced[1].trim(); try { const obj = JSON.parse(text); if (obj && typeof obj === 'object') text = String(obj.answer ?? obj.answers ?? obj.result ?? obj.output ?? '').trim(); } catch (_) {} text = text.replace(/^\s*(?:正确答案|最终答案|答案|答复|答)\s*[::]\s*/i, '').trim(); if (!text) return []; // 选择题优先解析 A、B、C 等标签,兼容 “A,C”/“A||C”/“选 A 和 C”。 if (q && (q.type === 0 || q.type === 1 || q.type === 3)) { const labels = text.match(/\b[A-H](?:\s*(?:[,,、|/&和与及]\s*)[A-H]){0,7}\b/i); if (labels && labels[0].replace(/[^A-H]/ig, '').length >= 1 && (/[答案选项选择]/i.test(content) || /^[A-H](?:\s*[,,、|/&和与及]\s*[A-H])*\s*$/i.test(text))) { const letters = labels[0].match(/[A-H]/ig) || []; const unique = [...new Set(letters.map(x => x.toUpperCase()))]; return q.type === 0 || q.type === 3 ? unique.slice(0, 1) : unique; } } // 主观题/填空答案可能自然包含逗号,只有显式 || 才表示多个空。 if (q && q.type >= 2) { return text.split(/\s*\|\|\s*/).map(x => x.trim()).filter(Boolean); } const pieces = text.split(/\s*(?:\|\||[,,、;;]|\s+和\s+|\s+与\s+)\s*/).map(x => x.trim()).filter(Boolean); return pieces.length ? pieces : [text]; } // 题目图片采集与安全转换:只处理已定位的题目容器,不能扫描整个页面。 // 图片能力必须由模型目录/配置明确标记为 supported;未知时停止本题,避免把图片题当文字题猜答。 function isQuestionImage(image) { if (!image || image.tagName !== 'IMG') return false; if (image.closest('button, [role="button"], script, style, .avatar, .user-avatar, .headImg, .head-img, .logo, .icon, .toolbar, .toolbar-container, .answer-analysis, .analysis, .answer-result')) return false; for (let node = image; node && node.nodeType === 1; node = node.parentElement) { if (isPlatformNoticeHidden(node)) return false; } const width = Number(image.getAttribute('width')); const height = Number(image.getAttribute('height')); return !(width > 0 && height > 0 && width <= 3 && height <= 3); } function getQuestionImageURL(image) { if (!image) return ''; const attrs = ['data-src', 'data-original', 'data-lazy-src', 'data-actualsrc', 'data-url', 'original']; const candidates = attrs.map(name => image.getAttribute(name)); candidates.push(image.currentSrc, image.getAttribute('src')); const srcset = image.getAttribute('data-srcset') || image.getAttribute('srcset'); if (srcset && !/^\s*data:/i.test(srcset)) candidates.push(srcset.split(',')[0].trim().split(/\s+/)[0]); for (const candidate of candidates) { const raw = String(candidate || '').trim(); if (!raw || /^(?:javascript:|about:|#)/i.test(raw) || /^data:image\/gif;base64,R0lGODlhAQABA/i.test(raw)) continue; try { const resolved = new URL(raw, image.ownerDocument?.baseURI || location.href); if (/^(?:https?:|blob:)$/.test(resolved.protocol) || /^data:image\//i.test(raw)) return resolved.href; } catch (_) {} } return ''; } function extractQuestionImages(container, optionEls = []) { if (!container || !container.querySelectorAll) return []; const result = []; const seen = new Set(); for (const image of container.querySelectorAll('img')) { if (!isQuestionImage(image)) continue; const optionIndex = optionEls.findIndex(option => option?.contains?.(image)); const role = optionIndex >= 0 ? 'option' : 'question'; const url = getQuestionImageURL(image); const key = `${role}:${optionIndex}:${url || 'unresolved-' + result.length}`; if (seen.has(key)) continue; seen.add(key); const alt = String(image.getAttribute('alt') || image.getAttribute('title') || '') .replace(/\s+/g, ' ').trim().slice(0, 160); const descriptor = { url, alt, role, sourceIndex: result.length }; if (optionIndex >= 0) descriptor.optionIndex = optionIndex; result.push(descriptor); } return result; } function replaceQuestionImagesWithPlaceholders(node) { if (!node) return ''; if (!node.querySelector?.('img')) return node.textContent || ''; const copy = node.cloneNode(true); copy.querySelectorAll('img').forEach(image => { const alt = String(image.getAttribute('alt') || image.getAttribute('title') || '') .replace(/\s+/g, ' ').trim().slice(0, 120); image.replaceWith(isQuestionImage(image) ? ` [配图${alt ? ':' + alt : ''}] ` : ''); }); return copy.textContent || ''; } function aiVisionCapability(ai) { const provider = AI_PROVIDERS[ai?.provider]; const value = ai?.vision ?? ai?.visionStatus ?? provider?.vision; if (value === true || value === 'supported') return 'supported'; if (value === false || value === 'unsupported') return 'unsupported'; return 'unknown'; } function imageBytesToDataURL(bytes) { if (!bytes?.length || bytes.length > 5 * 1024 * 1024) throw new Error('题图为空或超过 5 MB'); const prefix = String.fromCharCode(...bytes.subarray(0, 12)); let mime = ''; if (bytes.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((v, i) => bytes[i] === v)) mime = 'image/png'; else if (bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) mime = 'image/jpeg'; else if (/^GIF8[79]a/.test(prefix)) mime = 'image/gif'; else if (prefix.startsWith('RIFF') && prefix.slice(8, 12) === 'WEBP') mime = 'image/webp'; if (!mime) throw new Error('题图格式不支持或下载到了登录页面'); let binary = ''; for (let offset = 0; offset < bytes.length; offset += 32768) binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768)); return `data:${mime};base64,${btoa(binary)}`; } async function loadQuestionImage(source) { if (/^data:image\/(?:png|jpe?g|gif|webp);base64,/i.test(source)) { const match = source.match(/^data:image\/(?:png|jpe?g|gif|webp);base64,([a-zA-Z0-9+/=\r\n]+)$/i); if (!match) throw new Error('题图内嵌编码无效'); let binary; try { binary = atob(match[1].replace(/\s/g, '')); } catch (_) { throw new Error('题图内嵌编码无效'); } return imageBytesToDataURL(Uint8Array.from(binary, ch => ch.charCodeAt(0))); } let url; try { url = new URL(source, location.href); } catch (_) { throw new Error('题图地址无效'); } if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('题图地址不受支持'); const res = await gmRequest({ method: 'GET', url: url.href, responseType: 'arraybuffer', timeout: 15000 }); if (!res || res.status < 200 || res.status >= 300 || !res.response) throw new Error('题图读取失败'); return imageBytesToDataURL(new Uint8Array(res.response)); } async function prepareQuestionImages(images) { if (!Array.isArray(images) || !images.length) return []; const unique = new Map(); for (const image of images) { const source = typeof image === 'string' ? image : image?.url; if (!source || typeof source !== 'string') throw new Error('题图地址缺失'); if (!unique.has(source)) unique.set(source, []); unique.get(source).push(image); } if (unique.size > 6) throw new Error('题目图片超过 6 张'); const parts = []; let total = 0; let index = 0; for (const [source, descriptors] of unique) { const dataURL = await loadQuestionImage(source); total += Math.ceil(dataURL.length * 3 / 4); if (total > 12 * 1024 * 1024) throw new Error('题目图片合计超过 12 MB'); const labels = [...new Set(descriptors.map(descriptor => { const role = descriptor?.role === 'option' && Number.isInteger(descriptor.optionIndex) ? `选项 ${String.fromCharCode(65 + descriptor.optionIndex)}` : '题干'; const alt = String(descriptor?.alt || '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 120); return role + (alt ? ':' + alt : ''); }))]; parts.push({ type: 'text', text: `图片 ${++index}(${labels.join(';')})` }); parts.push({ type: 'image_url', image_url: { url: dataURL } }); } return parts; } function answerFailure(errorCode, message) { // 只返回预设的可操作提示,接口原文可能含密钥、题库 token 或服务端路径。 log.warn(`[考试] ${message}`); return { answers: [], errorCode, message }; } function answerHttpFailure(source, res) { if (!res || !res.status) return answerFailure('NETWORK_ERROR', `${source}请求超时或网络连接失败,请检查网络后继续答题。`); const status = Number(res.status); if (source !== 'AI') { const advice = status === 401 || status === 403 ? '题库服务授权异常,请联系管理员。' : status === 404 ? '题库服务地址不可用,请联系管理员。' : status === 429 ? '题库服务请求过于频繁,请稍后重试。' : status >= 500 ? '题库服务暂时不可用,请稍后重试或联系管理员。' : '请检查题库服务状态或联系管理员。'; return answerFailure('HTTP_ERROR', `${source}请求失败(HTTP ${status}),${advice}`); } const advice = status === 401 ? '请检查 API Key 或接口授权。' : status === 403 ? '当前配置无访问权限,请检查授权。' : status === 404 ? '请检查接口地址和模型名称。' : status === 429 ? '请求过于频繁或额度不足,请检查额度后稍后重试。' : status >= 500 ? '服务暂时不可用,请稍后重试。' : '请检查接口配置与当前模型支持的请求格式。'; return answerFailure('HTTP_ERROR', `${source}请求失败(HTTP ${status}),${advice}`); } function answerServiceFailure(source, value) { const detail = String(value?.msg || value?.message || value?.error?.message || value?.error || ''); if (/系统.*(?:积分|配置)|积分池|管理员|upstream|上游/i.test(detail)) return answerFailure('SERVICE_UNAVAILABLE', `${source}服务配置或上游额度异常,请联系管理员。`); if (/积分不足|余额不足|need.?recharge|insufficient.*(?:credit|balance)/i.test(detail) || value?.data?.need_recharge) return answerFailure('CREDITS_REQUIRED', `${source}积分不足,请先检查设备余额或激活码。`); if (/验证码|verify|验证.*(?:失效|过期|无效)/i.test(detail)) return answerFailure('VERIFY_REQUIRED', `${source}验证已失效,请重新验证后继续答题。`); if (/激活|设备.*(?:不存在|无效|未绑定|标识)/i.test(detail)) return answerFailure('ACTIVATION_REQUIRED', `${source}设备或激活状态无效,请检查激活码与设备绑定。`); if (/未找到|无答案|暂无答案|未匹配|no\s+answer|not\s+found/i.test(detail)) return answerFailure('ANSWER_NOT_FOUND', `${source}未找到本题答案,已暂停。`); return answerFailure('QUERY_REJECTED', `${source}拒绝了本次查询,请检查服务状态或联系管理员。`); } async function askAiAPI(question, options, type, images = []) { const ai = loadExamAiConfig(); const endpoint = getAiEndpoint(ai); const model = getAiModel(ai); if (!endpoint || !ai.apiKey || !model) { return answerFailure('AI_CONFIG_MISSING', 'AI 配置不完整,请在答案来源与模型设置中填写接口、API Key 和模型名称。'); } const typeName = TYPE_NAMES[type] || '未知题型'; const optionLines = (options || []).map((o, i) => `${String.fromCharCode(65 + i)}. ${o}`).join('\n'); const hasImages = Array.isArray(images) && images.length > 0; if (hasImages) { const capability = aiVisionCapability(ai); if (capability !== 'supported') { return answerFailure(capability === 'unsupported' ? 'VISION_UNSUPPORTED' : 'VISION_UNKNOWN', capability === 'unsupported' ? '当前模型不支持图片输入,请选择支持图片的模型并保存配置后继续答题。' : '当前模型的图片能力尚未确认,请在模型设置中点击“检测图片能力”,通过后保存配置并继续答题。'); } } const prompt = [ '你是学习辅助答题助手。根据题干和选项给出最可能正确的答案。', '只输出 JSON,不要解释:{"answer":"A"};多选用逗号分隔(如 A,C);填空/简答直接写答案文本。', `题型:${typeName}`, `题干:${formatString(question)}`, optionLines ? `选项:\n${optionLines}` : '该题没有显式选项。', hasImages ? '题目包含附图,请结合附图作答;无法看清或信息不足时不要猜测。' : '' ].join('\n'); let imageParts = []; if (hasImages) { try { imageParts = await prepareQuestionImages(images); } catch (error) { const detail = String(error?.message || ''); const reason = /超过|为空/.test(detail) ? '题图数量或大小超出限制' : /格式|编码/.test(detail) ? '题图格式或编码不受支持' : /地址/.test(detail) ? '题图地址缺失或不受支持' : '题图读取失败,请检查图片是否加载或登录是否有效'; return answerFailure('IMAGE_LOAD_FAILED', `${reason},已暂停本题,未向模型发送不完整的图片题。`); } } const userContent = hasImages ? [{ type: 'text', text: prompt }, ...imageParts] : prompt; const payload = { model, temperature: 0.1, max_tokens: type >= 4 ? 800 : 256, stream: false, messages: [{ role: 'system', content: '只返回可解析的 JSON,不要输出 Markdown 或解释。' }, { role: 'user', content: userContent }] }; if (AI_PROVIDERS[ai.provider]?.thinking) payload.thinking = { type: AI_PROVIDERS[ai.provider].thinking }; const res = await gmRequest({ method: 'POST', url: endpoint, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${ai.apiKey}` }, data: JSON.stringify(payload), timeout: 60000 }); if (!res || res.status < 200 || res.status >= 300) { return answerHttpFailure('AI', res); } try { const json = JSON.parse(res.responseText || '{}'); if (json.error) return answerFailure('AI_API_ERROR', 'AI 接口返回错误,请检查 API Key、模型权限和接口额度后继续答题。'); const finishReason = String(json?.choices?.[0]?.finish_reason || '').toLowerCase(); if (finishReason === 'length') return answerFailure('AI_RESPONSE_TRUNCATED', 'AI 输出被长度限制截断,未获得完整答案,请检查模型输出设置或更换模型。'); if (finishReason === 'content_filter') return answerFailure('AI_RESPONSE_BLOCKED', 'AI 接口过滤了本题输出,未返回可用答案。'); if (['tool_calls', 'aborted'].includes(finishReason)) return answerFailure('INVALID_RESPONSE', 'AI 未返回完整的文字答案,请检查模型与接口兼容性。'); const content = parseAiResponseText(json); if (!content) return answerFailure('INVALID_RESPONSE', 'AI 接口未返回可读取的答案内容,请检查模型与接口兼容性。'); const answers = parseAiAnswers(content, { type }); return answers.length ? { answers, remaining: -1, source: `AI·${AI_PROVIDERS[ai.provider]?.name || '自定义接口'}` } : answerFailure('ANSWER_NOT_FOUND', 'AI 未提供有效答案,已暂停本题。'); } catch (e) { return answerFailure('INVALID_RESPONSE', 'AI 接口响应不是有效的答案格式,请检查接口是否兼容 OpenAI 聊天格式。'); } } // --- 设备ID --- function getDeviceId() { let id = GM_getValue('av_device_id', ''); if (!id) { id = 'av_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); GM_setValue('av_device_id', id); } return id; } // --- 文本格式化 (借鉴万能脚本 formatString) --- function formatString(src) { src = String(src); // HTML 实体解码 if (!src.includes('img') && !src.includes('iframe')) { const tmp = document.createElement('div'); tmp.innerHTML = src; src = tmp.textContent || tmp.innerText || ''; } // 全角转半角 src = src.replace(/[\uff01-\uff5e]/g, s => String.fromCharCode(s.charCodeAt(0) - 65248)); return src.replace(/\s+/g, ' ').replace(/[""]/g, '"').replace(/['']/g, "'") .replace(/。/g, '.').replace(/[,.?:!;]$/, '').trim(); } // 判断题匹配 (借鉴万能脚本) function isTrue(str) { return Boolean(String(str).match(/(^|,)(正确|是|对|√|T|ri|true|A)(,|$)/)); } function isFalse(str) { return Boolean(String(str).match(/(^|,)(错误|否|错|×|F|不是|wr|false|B)(,|$)/)); } function isPlainAnswer(answer) { if (!answer || answer.length > 8 || !/[A-Z]/.test(answer)) return false; let min = 0; for (let i = 0; i < answer.length; i++) { if (answer.charCodeAt(i) < min) return false; min = answer.charCodeAt(i); } return true; } // 答案文本相似度 (简化版) function answerSimilar(answer, options) { return options.map(opt => { const o = opt.replace(/\s/g, ''); const a = answer.replace(/\s/g, ''); if (o === a) return 100; if (o.includes(a) || a.includes(o)) return 85; let match = 0; const shorter = a.length < o.length ? a : o; const longer = a.length < o.length ? o : a; for (let i = 0; i < shorter.length; i++) { if (longer.includes(shorter[i])) match++; } return Math.round(match / longer.length * 100); }); } // --- 题型检测 (万能脚本 getQuestionType) --- function getQuestionType(typeText) { const t = String(typeText).trim(); if (/单选|单项选择|radio/i.test(t)) return 0; if (/多选|多项选择|checkbox|不定项/i.test(t)) return 1; if (/填空/i.test(t)) return 2; if (/判断|是非|对错/i.test(t)) return 3; if (/简答|问答|主观/i.test(t)) return 4; if (/名词解释/i.test(t)) return 5; if (/论述/i.test(t)) return 6; if (/计算/i.test(t)) return 7; if (/阅读理解|完型填空|听力/i.test(t)) return 8; return 4; // 默认 } const TYPE_NAMES = { 0: '单选', 1: '多选', 2: '填空', 3: '判断', 4: '简答', 5: '名词解释', 6: '论述', 7: '计算', 8: '其他' }; // 题根/选项标签可使用 display:contents 或由后代撑开,不能只看自己的矩形。 // 仅用于题目内容,平台提示/导航控件仍沿用原来的可见性保护。 function isExamContentVisible(element) { if (!element?.isConnected) return false; for (let node = element; node; node = node.parentElement) { if (isPlatformNoticeHidden(node)) return false; } const visit = node => { if (node.matches('script, style, template') || isPlatformNoticeHidden(node)) return false; return isElementVisible(node) || Array.from(node.children).some(visit); }; return visit(element); } // --- 题目提取 (借鉴万能脚本的智慧树选择器) --- function extractExamQuestions(visibleOnly = false, requireReady = false) { // 按优先级尝试不同页面版本的选择器 const layouts = [ { // 智慧树 AI 课程习题/考试(questionContent) root: '.questionContent', question: '.questionTitle .centent-pre, .questionTitle, .centent-pre', options: '.el-radio-group > label.el-radio .preStyle, .el-checkbox-group > label.el-checkbox .preStyle, ul.radio-view > li .preStyle, ul.checkbox-view > li .preStyle, .option-list li .preStyle', $options: '.el-radio-group > label.el-radio, .el-checkbox-group > label.el-checkbox, ul.radio-view > li, ul.checkbox-view > li, .option-list li', type: '.questionTitle, .letterSortNum', checked: '.is-checked, .is-checked input:checked, input:checked', }, { // 智慧树作业/考试 (examPaper) root: '.examPaper_subject', // 正文可能直接位于 describe 中,不能先取其任意子 div(可能是选项)。 question: '.subject_describe, .smallStem_describe', options: '.subject_node .nodeLab .node_detail', $options: '.subject_node .nodeLab', type: '.subject_type span:first-child', checked: '.onChecked', }, { // 智慧树学分课作业 root: '.questionBox', question: '.questionContent', options: '.optionUl label .el-radio__label, .optionUl label .el-checkbox__label', $options: '.optionUl label', type: '.questionTit', checked: '.is-checked', }, { // 智慧树学分课考试 root: '.ques-detail', question: '.questionName .centent-pre, .questionName', options: '.radio-view li .preStyle, .checkbox-views label .preStyle', $options: '.radio-view li, .checkbox-views label', type: '.letterSortNum', checked: '.is-checked', }, { // 新版题根:从明确的题干/题型锚点寻找最近的完整题目,不猜整页 class。 root: '结构识别', question: '.subject_describe, .smallStem_describe, .questionName, .questionTitle, .question-title, .ques-title, .topic-title, .stem', options: '.node_detail, .preStyle, .el-radio__label, .el-checkbox__label', $options: '.nodeLab, .subject_node, .option-item, .topic-item, .optionUl label, .el-radio, .el-checkbox, .radio-view li, .checkbox-view li, .checkbox-views label, .option-list li, label', type: '.subject_type, .questionTit, .letterSortNum', checked: '.onChecked, .is-checked', } ]; const ignoredContent = '#autovisor-panel, #av-exam-panel, #av-trial-modal, .answer-analysis, .analysis, .answer-result, .subject_node, .nodeLab, .optionUl, .el-radio-group, .el-checkbox-group, .radio-view, .checkbox-view, .checkbox-views, .option-list, .option-item, .topic-item, nav, [role="navigation"], button, [role="button"], script, style, template'; const editorSelector = 'textarea, input[type="text"], input:not([type]), [contenteditable="true"], .edui-editor, .edui-editor-iframeholder, .edui-body-container'; const usableContent = node => { for (let parent = node; parent; parent = parent.parentElement) { if (isPlatformNoticeHidden(parent)) return false; } return !visibleOnly || isExamContentVisible(node); }; const getStructureRoots = layout => { const roots = new Set(); const stemAnchors = Array.from($$(layout.question)).filter(node => !node.closest(ignoredContent) && usableContent(node)); const typeAnchors = Array.from($$(layout.type)).filter(node => !node.closest(ignoredContent) && usableContent(node)); const independent = nodes => nodes.filter(node => !nodes.some(parent => parent !== node && parent.contains(node))); for (const anchor of [...stemAnchors, ...typeAnchors]) { let container = anchor; for (let depth = 0; container && depth < 6; depth++, container = container.parentElement) { if (container.matches('html, body, #app, #root, .exam-page, .question-paper') || container.closest(ignoredContent)) break; const stems = independent(stemAnchors.filter(node => container.contains(node))); const types = independent(typeAnchors.filter(node => container.contains(node))); // 不越过另一道题的题干/题号;不能将整卷的选项或图片聚在一起。 if (stems.length > 1 || types.length > 1) break; const hasControls = Array.from(container.querySelectorAll(layout.$options + ', ' + editorSelector)).some(usableContent); if (hasControls) { roots.add(container); break; } } } return Array.from(roots).filter(container => !Array.from(roots).some(child => child !== container && container.contains(child))); }; // 一个布局只有成功提取出题目才采用;空占位、隐藏旧题不能阻断后续布局。 for (const activeLayout of layouts) { const questions = []; let containers = (activeLayout.root === '结构识别' ? getStructureRoots(activeLayout) : Array.from($$(activeLayout.root))).filter(container => { if (container.closest(ignoredContent)) return false; if (!usableContent(container)) return false; const dialog = container.closest('.dialog-test, .ai-class-exercise-dialog, .el-dialog, [role="dialog"]'); if (visibleOnly && dialog && isStudyExerciseDialog(dialog)) return false; // 普通课/学分课的正文也可叫 questionContent,应交给外层完整题目提取。 if (activeLayout.root === '.questionContent' && container.closest('.questionBox, .examPaper_subject, .ques-detail')) return false; // 同名容器也可能包住整份试卷;只将实际题根交给本布局,避免采集整页图片。 if (activeLayout.root === '.questionContent' && (container.querySelector('.questionBox, .examPaper_subject, .ques-detail') || Array.from(container.querySelectorAll('.questionContent')).some(child => child.querySelector(activeLayout.$options + ', textarea, input[type="text"]')))) return false; return container.matches(activeLayout.question) || !!container.querySelector(activeLayout.question) || !!container.querySelector(activeLayout.$options) || !!container.querySelector('textarea, input[type="text"], img'); }); if (activeLayout.root === '.questionContent') { containers = containers.filter(container => !containers.some(parent => parent !== container && parent.contains(container))); } // 只剥离题号/题型/分值前缀,同一行后面的短题干也必须保留。 const cleanStemText = value => formatString(value) .replace(/^(?:第\s*\d+\s*题\s*[:.、]?|\d+\s*(?:\.(?!\d)[)、]*|[、)]+))\s*/, '') .replace(/^(?:[【[(]\s*(?:单项选择题|多项选择题|单选题?|多选题?|判断题?|填空题?|简答题?|问答题?|论述题?|计算题?|名词解释)\s*[】\])]|(?:单项选择题|多项选择题|单选题|多选题|判断题|填空题|简答题|问答题|论述题|计算题|名词解释)(?=\s|\(|$))\s*/, '') .replace(/^(?:单选|多选|判断|填空|简答|论述|计算)(?=\s*(?:\(\s*\d+(?:\.\d+)?\s*分\s*\))?\s*$)/, '') .replace(/^\(\s*\d+(?:\.\d+)?\s*分\s*\)\s*/, '').trim(); containers.forEach((container, index) => { const excludedContent = '.subject_node, .optionUl, .el-radio-group, .el-checkbox-group, ul.radio-view, ul.checkbox-view, .option-list, .radio-view, .checkbox-views, .option-item, .topic-item, .answer-analysis, .analysis, .answer-result, .subject_type, .subject_num, [id^="anchor_"], .questionTit, .letterSortNum, script, style, button, [role="button"], ' + editorSelector; // 只读取题干范围;正文与选项共用一层 describe 时也不能把选项带进题干。 const readStemText = node => { if (node.closest(excludedContent) || Array.from(container.querySelectorAll(activeLayout.$options)).some(option => option === node || option.contains(node))) return ''; for (let parent = node; parent; parent = parent.parentElement) { if (isPlatformNoticeHidden(parent)) return ''; if (parent === container) break; } // 在原 DOM 上判断可见性,脱离文档的副本会丢失样式类和祖先的隐藏状态。 const read = current => { if (current.nodeType === Node.TEXT_NODE) return current.nodeValue || ''; if (current.nodeType !== Node.ELEMENT_NODE || current.matches(excludedContent + ', ' + activeLayout.$options) || isPlatformNoticeHidden(current)) return ''; const text = (current.textContent || '').trim(); if (current.matches('.questionTitle') && !cleanStemText(text) && !current.querySelector('img')) return ''; if (current.tagName === 'IMG') { if (!isQuestionImage(current)) return ''; const alt = String(current.getAttribute('alt') || current.getAttribute('title') || '').replace(/\s+/g, ' ').trim().slice(0, 120); return ` [配图${alt ? ':' + alt : ''}] `; } if (current.tagName === 'BR') return ' '; const content = Array.from(current.childNodes, read).join(''); return /^(?:P|DIV|SECTION|H[1-6]|LI|PRE)$/.test(current.tagName) ? ` ${content} ` : content; }; return read(node).trim(); }; // 题目文本 — 跳过题型/分值标题,找到真正的问题 let questionText = ''; const qEls = container.querySelectorAll(activeLayout.question); for (const qEl of qEls) { const t = cleanStemText(readStemText(qEl)); // 跳过:太短、题型标记如"1.【单选题】(2分)"、纯选项字母 if (t.length < 2) continue; if (/^[A-G][\..\s、]/.test(t)) continue; questionText = formatString(t); break; } // 正文可直接放在完整题根的文本节点中,不能只查它的后代元素。 if (!questionText) { const text = cleanStemText(readStemText(container)); if (text.length >= 2 && !/^[A-G][\..\s、]/.test(text)) questionText = text; } // 备用:直接在container里找长文本段落 if (!questionText) { const ps = container.querySelectorAll('p, span, div'); for (const p of ps) { const t = cleanStemText(readStemText(p)); if (t.length <= 8) continue; if (/^[A-G][\..\s、]/.test(t)) continue; questionText = formatString(t); break; } } // 题型 const typeEl = Array.from(container.querySelectorAll(activeLayout.type)).find(usableContent); let typeCode = typeEl ? getQuestionType(typeEl.textContent) : (activeLayout.root === '.questionContent' ? (container.querySelector('.el-checkbox-group, ul.checkbox-view') ? 1 : 0) : 4); // 选项文本 & 可点击元素 const optTexts = []; const $optEls = []; const readOptionText = node => { if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || ''; if (node.nodeType !== Node.ELEMENT_NODE || isPlatformNoticeHidden(node) || node.matches('script, style, template')) return ''; if (node.tagName === 'IMG') { if (!isQuestionImage(node)) return ''; const alt = String(node.getAttribute('alt') || node.getAttribute('title') || '').replace(/\s+/g, ' ').trim().slice(0, 120); return ` [配图${alt ? ':' + alt : ''}] `; } return Array.from(node.childNodes, readOptionText).join(node.tagName === 'BR' ? ' ' : ''); }; const optTextEls = Array.from(container.querySelectorAll(activeLayout.options)).filter(usableContent); const optClickEls = Array.from(container.querySelectorAll(activeLayout.$options)).filter(option => { if (!usableContent(option)) return false; if (activeLayout.root !== '结构识别') return true; // 表单的“答案”label 包含输入框,不是一个选择项。 if (option.querySelector(editorSelector)) return false; return !option.matches('label') || option.matches('.nodeLab, .el-radio, .el-checkbox') || !!option.closest('.subject_node, .option-item, .topic-item, .optionUl, .radio-view, .checkbox-view, .checkbox-views, .option-list') || !!option.querySelector('input[type="radio"], input[type="checkbox"]'); }); const candidates = optClickEls.length ? optClickEls : optTextEls; // label 与外层选项行只保留一个点击边界,文本必须在该边界内配对,避免隐藏旧选项导致错位。 const useClickEls = candidates.filter(option => !candidates.some(child => child !== option && option.contains(child))); if ((activeLayout.root === '.questionContent' && typeCode >= 4 || activeLayout.root === '结构识别' && !typeEl) && useClickEls.length) { typeCode = container.querySelector('.el-checkbox-group, ul.checkbox-view, input[type="checkbox"]') ? 1 : 0; } for (let i = 0; i < useClickEls.length; i++) { const option = useClickEls[i]; const textSrc = optTextEls.find(text => option === text || option.contains(text)) || option; let raw = formatString(readOptionText(textSrc)).replace(/^[A-Za-z][.\s、.]+/, '').trim(); // 可见图片已由 readOptionText 保留占位,隐藏图片不能伪装为已加载选项。 optTexts.push(raw); $optEls.push(option); } const images = extractQuestionImages(container, $optEls); if (!$optEls.length && !container.querySelector(editorSelector) && !questionText.replace(/\[配图[^\]]*\]/g, '').trim()) return; if (!questionText && images.some(image => image.role === 'question')) questionText = '[题目配图] 请根据题图及选项作答。'; if (!questionText || (questionText.length < 2 && !images.length)) return; // 已选检测 const isChecked = (el) => { if (!activeLayout.checked) return false; return el.matches('[aria-checked="true"], input:checked') || !!el.querySelector('input:checked') || activeLayout.checked.split(',').some(sel => el.matches(sel.trim()) || el.querySelector(sel.trim())); }; questions.push({ index, el: container, text: questionText, type: typeCode, options: optTexts, images, optionEls: $optEls, isChecked, }); }); if (questions.length) { // 非空骨架也可能仍未挂载答框;自动作答时让后续完整布局继续参与识别。 // 同一布局已有可作答题时保留其余待加载题,由快照整体等待,不能丢题后误交卷。 if (requireReady && !questions.some(isExamQuestionReady)) continue; log.info(`[考试] 使用选择器: ${activeLayout.root}, 检测到 ${questions.length} 题`); return questions; } } return []; } // --- API调用 --- function gmRequest(opts) { return new Promise(resolve => { const timer = setTimeout(() => resolve(null), opts.timeout || 12000); try { GM_xmlhttpRequest({ ...opts, onload: (res) => { clearTimeout(timer); resolve(res); }, onerror: () => { clearTimeout(timer); resolve(null); }, ontimeout: () => { clearTimeout(timer); resolve(null); }, }); } catch { clearTimeout(timer); resolve(null); } }); } // 通用答案数组解析 function parseAnswersFromResult(resultObj, options) { if (!resultObj) return []; if (resultObj.success === false) return []; const raw = resultObj.answers || resultObj.answer || []; let answers = []; if (typeof raw === 'string' && raw.trim()) { // 单个字符串答案 answers = [raw.trim()]; } else if (Array.isArray(raw) && raw.length > 0) { if (Array.isArray(raw[0])) { // 嵌套数组 [[答案1, 答案2]] answers = raw[0].map(a => String(a)); } else if (typeof raw[0] === 'number') { // 索引数组 [0, 2] => 对应选项 answers = raw.map(idx => options[idx] || String.fromCharCode(65 + idx)); } else { answers = raw.map(a => String(a)); } } return answers.filter(a => a && a.trim()); } // 你的中转API (qsy.iano.cn) async function askPaidAPI(question, options, type) { const deviceId = getDeviceId(); log.info(`[考试] 发送题目: "${question.slice(0, 40)}..." 选项:${options.length}个`); const res = await gmRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/answer', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ device_id: deviceId, question: question, options: options, type: type, location: '智慧树' }), timeout: 12000, }); if (!res || res.status !== 200) return answerHttpFailure('付费题库', res); try { const json = JSON.parse(res.responseText); if (!json || typeof json !== 'object') return answerFailure('INVALID_RESPONSE', '付费题库响应格式异常,请联系管理员。'); const successfulCode = value => value == null || ['0', '1', '200', 'success', 'ok'].includes(String(value).trim().toLowerCase()); if (json.error || json.success === false || !successfulCode(json.code) || String(json.code).trim() === '0') return answerServiceFailure('付费题库', json); if (['1', '200', 'success', 'ok'].includes(String(json.code).trim().toLowerCase()) && json.data) { const result = json.data.result || json.data; if (result.error || result.success === false || !successfulCode(result.code)) return answerServiceFailure('付费题库', result); const remaining = json.data.remaining_credits || 0; const answers = parseAnswersFromResult(result, options); if (answers.length > 0) { return { answers, remaining, source: '付费题库' }; } if (!Object.prototype.hasOwnProperty.call(result, 'answers') && !Object.prototype.hasOwnProperty.call(result, 'answer')) return answerFailure('INVALID_RESPONSE', '付费题库响应缺少答案字段,请联系管理员。'); return answerFailure('ANSWER_NOT_FOUND', '付费题库未找到本题答案,已暂停。'); } return answerFailure('INVALID_RESPONSE', '付费题库响应格式异常,请联系管理员。'); } catch (e) { return answerFailure('INVALID_RESPONSE', '付费题库响应无法解析,请检查服务状态或联系管理员。'); } } // 免费题库 (lyck6.cn, 与万能脚本同源) async function askFreeAPI(question, options, type) { const res = await gmRequest({ method: 'POST', url: 'http://lyck6.cn/scriptService/api/autoFreeAnswer', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ question: question, options: options, type: type, location: location.href }), timeout: 15000, }); if (!res || res.status !== 200) return answerHttpFailure('免费题库', res); try { const json = JSON.parse(res.responseText); if (!json || typeof json !== 'object') return answerFailure('INVALID_RESPONSE', '免费题库响应格式异常,请稍后重试。'); const result = json.result || json.data || json; // 上游已有 code=0/1/200/success/ok 的成功格式,明确拒绝优先于旧答案字段。 const successfulCode = value => value == null || ['0', '1', '200', 'success', 'ok'].includes(String(value).trim().toLowerCase()); if (json.error || json.success === false || !successfulCode(json.code)) return answerServiceFailure('免费题库', json); if (result.error || result.success === false || !successfulCode(result.code)) return answerServiceFailure('免费题库', result); const answers = parseAnswersFromResult(result, options); if (answers.length > 0) { return { answers, remaining: -1, source: '免费题库' }; } if (!Object.prototype.hasOwnProperty.call(result, 'answers') && !Object.prototype.hasOwnProperty.call(result, 'answer')) return answerFailure('INVALID_RESPONSE', '免费题库响应缺少答案字段,请稍后重试。'); return answerFailure('ANSWER_NOT_FOUND', '免费题库未找到本题答案,已暂停。'); } catch (e) { return answerFailure('INVALID_RESPONSE', '免费题库响应无法解析,请检查服务状态后重试。'); } } // --- 当前题库模式: 'free' 或 'paid' --- let examMode = isAiExamHost() ? 'ai' : GM_getValue('av_exam_mode', 'free'); if (!['free', 'paid', 'ai'].includes(examMode)) examMode = isAiExamHost() ? 'ai' : 'free'; function setExamMode(mode) { if (examMode !== mode && examRun) { stopExamAutomation('答题来源已切换,请点击开始答题'); config.autoExam = false; saveConfig(config); } examMode = mode; GM_setValue('av_exam_mode', mode); } // --- 免费验证码相关 --- function getVerifyState() { return GM_getValue('av_verify_state', { validUntil: 0 }); } function setVerifyState(state) { GM_setValue('av_verify_state', state); } function isVerifyValid() { const state = getVerifyState(); return state.validUntil && state.validUntil > Date.now() / 1000; } async function verifyCode(code) { const res = await gmRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/code/verify', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: `code=${encodeURIComponent(code)}`, timeout: 10000, }); if (!res || res.status !== 200) return { ok: false, msg: '网络错误' }; try { const json = JSON.parse(res.responseText); if (json.code === 1 && json.data) { const validUntil = json.data.valid_until || (Date.now() / 1000 + 86400); const validStr = json.data.valid_until_str || new Date(validUntil * 1000).toLocaleString(); setVerifyState({ validUntil }); return { ok: true, msg: `验证成功,有效期至 ${validStr}` }; } return { ok: false, msg: json.msg || '验证码无效或已过期' }; } catch { return { ok: false, msg: '解析错误' }; } } // 综合搜索:考试沿用考试模式,助教显式传入自己的来源,避免串用/意外扣费。 async function searchAnswer(question, options, type, mode = examMode, images = []) { if (!['ai', 'free', 'paid'].includes(mode)) return answerFailure('INVALID_SOURCE', '请选择有效的答案来源后开始答题。'); const cleanQ = formatString(question); if (!cleanQ || cleanQ.length < 4) return answerFailure('QUESTION_INCOMPLETE', '未读取到完整题干,请等待题目加载或检查页面后继续答题。'); const cleanOpts = (options || []).map(o => formatString(o)); if (Array.isArray(images) && images.length && mode !== 'ai') { return answerFailure('IMAGE_SOURCE_UNSUPPORTED', '本题包含图片或公式图片,当前免费/付费题库不支持图片识别。请手动选择支持图片的 AI 模型并保存配置后继续答题。'); } if (mode === 'ai') { return askAiAPI(cleanQ, cleanOpts, type, images); } if (mode === 'paid') { // 付费模式 log.info('[考试] 正在查询付费题库...'); const paidResult = await askPaidAPI(cleanQ, cleanOpts, type); if (paidResult && paidResult.answers.length > 0) { log.success(`[考试] 付费题库找到答案 (余${paidResult.remaining}积分)`); updateCreditsDisplay(paidResult.remaining); return paidResult; } return paidResult; } else { // 免费模式 if (!isVerifyValid()) { return answerFailure('VERIFY_REQUIRED', '免费验证码未验证或已过期,请在来源设置中完成验证后继续答题。'); } log.info('[考试] 正在查询免费题库...'); const freeResult = await askFreeAPI(cleanQ, cleanOpts, type); if (freeResult && freeResult.answers.length > 0) { log.success('[考试] 免费题库找到答案'); return freeResult; } return freeResult; } } // --- 智能答案匹配 & 填入 (借鉴万能脚本) --- function normalizeExamAnswer(value) { return String(value ?? '').normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '') .replace(/\s+/g, '').toLowerCase(); } function getExamAnswerValues(result) { if (!result || result.success === false || !Array.isArray(result.answers)) return null; const values = result.answers.flat(Infinity); if (!values.length || values.some(value => value == null || typeof value === 'object')) return null; const answers = values.map(value => String(value).trim()); return answers.every(Boolean) ? answers : null; } function getExamAnswerTargets(q, result) { const answers = getExamAnswerValues(result); const options = Array.from(q?.options || []); if (!answers || !options.length || options.length !== q.optionEls?.length) return null; const normalized = options.map(normalizeExamAnswer); const truth = value => { const text = normalizeExamAnswer(String(value ?? '').replace(/^\s*[A-H](?:[..、::))]\s*|\s+)(?=\S)/i, '')); if (/^(?:正确|对|是|√|✓|true|t)$/.test(text)) return true; if (/^(?:错误|错|否|×|✗|false|f)$/.test(text)) return false; return null; }; const targets = new Set(); for (const answer of answers) { // 判断题先按真/假含义找选项,不能假定 A=正确;单独 A/B 仍指选项位置。 const answerTruth = Number(q.type) === 3 ? truth(answer) : null; if (answerTruth !== null) { const hits = options.map((option, index) => truth(option) === answerTruth ? index : -1).filter(index => index >= 0); if (hits.length !== 1) return null; targets.add(hits[0]); continue; } // 完整选项文本优先,例如选项内容本身就是 ABC 时不能拆成三个字母。 const exact = normalized.map((option, index) => option === normalizeExamAnswer(answer) ? index : -1).filter(index => index >= 0); if (exact.length > 1) return null; if (exact.length === 1) { targets.add(exact[0]); continue; } const labels = answer.normalize('NFKC').toUpperCase().trim(); if (/^[A-Z]+$/.test(labels) || /^[A-Z](?:\s*[,,、;;|/\s]\s*[A-Z])+$/.test(labels)) { const letters = labels.match(/[A-Z]/g) || []; for (const letter of letters) { const index = letter.charCodeAt(0) - 65; if (index < 0 || index >= options.length) return null; targets.add(index); } continue; } const pieces = answer.split(/\|\||[;;]/).map(value => value.trim()).filter(Boolean); if (pieces.length < 2) return null; for (const piece of pieces) { const hits = normalized.map((option, index) => option === normalizeExamAnswer(piece) ? index : -1).filter(index => index >= 0); if (hits.length !== 1) return null; targets.add(hits[0]); } } if (!targets.size || (Number(q.type) !== 1 && targets.size !== 1)) return null; return Array.from(targets).sort((a, b) => a - b); } function isExamAnswerElementReady(element, root, allowEditorBacking = false) { if (!element?.isConnected || !root?.isConnected || (element !== root && !root.contains(element))) return false; if (element.disabled || element.readOnly || element.closest('[disabled], [aria-disabled="true"], .is-disabled')) return false; if (element !== root && element.querySelector?.('input:disabled, textarea:disabled, button:disabled, [aria-disabled="true"]')) return false; if (!allowEditorBacking && !isExamContentVisible(element)) return false; for (let node = allowEditorBacking ? element.parentElement : element; node; node = node.parentElement) { if (node.hidden || node.getAttribute('aria-hidden') === 'true') return false; const style = getComputedStyle(node); if (style.display === 'none' || /^(?:hidden|collapse)$/.test(style.visibility) || Number.parseFloat(style.opacity || '1') <= 0.05) return false; } return true; } function isExamOptionChecked(q, element) { try { if (q.isChecked?.(element)) return true; } catch (_) {} if (isExerciseChoiceSelected(element)) return true; // 普通课的选中类/原生 radio 可能位于正文外的标签或行上,只允许读取单个选项的边界。 const boundaries = [element.closest('.nodeLab'), element.closest('.subject_node')]; return boundaries.some(boundary => boundary && boundary !== q.el && q.el.contains(boundary) && !Array.from(q.optionEls || []).some(other => other !== element && boundary.contains(other)) && (boundary.matches('.onChecked') || !!boundary.querySelector('.onChecked') || isExerciseChoiceSelected(boundary))); } function getExamAnswerFields(q) { if (!isExamAnswerElementReady(q?.el, q?.el)) return []; const fields = []; const plainText = html => { const holder = q.el.ownerDocument.createElement('div'); holder.innerHTML = String(html ?? ''); return holder.textContent || ''; }; const nodes = Array.from(q.el.querySelectorAll('input:not([type]), input[type="text"], textarea, [contenteditable="true"]')); for (const node of nodes) { if (node.closest('#autovisor-panel, #av-exam-panel')) continue; if ((node.disabled || node.readOnly || node.closest('[aria-disabled="true"], .is-disabled')) && isElementVisible(node)) return []; // UEditor 的 textarea 常被隐藏,只接受有读取接口且可见的编辑器实例。 let editor = null; if (node.tagName === 'TEXTAREA' && node.getAttribute('name') && typeof UE !== 'undefined') { try { const key = node.getAttribute('name'); editor = UE.instants ? (UE.instants[key] || UE.instants[node.id]) : UE.getEditor?.(key); } catch (_) {} } if (editor && typeof editor.setContent === 'function' && (typeof editor.getContentTxt === 'function' || typeof editor.getContent === 'function') && editor.isReady !== false) { if (!isExamAnswerElementReady(editor.container || node, q.el, !editor.container)) continue; fields.push({ node, read: () => typeof editor.getContentTxt === 'function' ? String(editor.getContentTxt() || '') : plainText(editor.getContent()), write: value => { const holder = q.el.ownerDocument.createElement('div'); holder.textContent = value; editor.setContent(holder.innerHTML); } }); continue; } if (!isExamAnswerElementReady(node, q.el) || node.closest('.edui-editor')) continue; if (node.matches('[contenteditable="true"]') && nodes.some(other => other !== node && other.contains(node) && other.matches('[contenteditable="true"]'))) continue; fields.push({ node, read: () => node.matches('[contenteditable="true"]') ? node.textContent || '' : node.value, write: value => { if (node.matches('[contenteditable="true"]')) node.textContent = value; else { const win = node.ownerDocument.defaultView; const proto = node.tagName === 'TEXTAREA' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (!setter) throw new Error('未找到输入框原生写入接口'); setter.call(node, value); } node.dispatchEvent(new Event('input', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); } }); } return fields; } function normalizeExamFieldValue(value) { // 代码、公式和简答中的内部空格有含义,字段读回不能沿用选项的去空白匹配。 return String(value ?? '').replace(/\r\n?/g, '\n').trim(); } function isExamQuestionAnswered(q) { if (!isExamAnswerElementReady(q?.el, q?.el)) return false; if (Array.from(q.optionEls || []).length) { return Array.from(q.optionEls).every(option => isExamAnswerElementReady(option, q.el)) && Array.from(q.optionEls).some(option => isExamOptionChecked(q, option)); } const fields = getExamAnswerFields(q); return fields.length > 0 && fields.every(field => normalizeExamFieldValue(field.read()).length > 0); } function matchAndFill(q, result) { if (!isExamAnswerElementReady(q?.el, q?.el)) return false; const answers = getExamAnswerValues(result); if (!answers) return false; if ([2, 4, 5, 6, 7].includes(Number(q.type)) && !q.optionEls?.length) { const fields = getExamAnswerFields(q); // 每个空必须有独立答案,不能重复首个答案来伪装为完整填空。 if (!fields.length || answers.length !== fields.length) return false; try { for (let index = 0; index < fields.length; index++) { if (!q.el.isConnected || !fields[index].node.isConnected) return false; if (normalizeExamFieldValue(fields[index].read()) !== normalizeExamFieldValue(answers[index])) fields[index].write(answers[index]); } return fields.every((field, index) => field.node.isConnected && normalizeExamFieldValue(field.read()) === normalizeExamFieldValue(answers[index])); } catch (_) { return false; } } const targets = getExamAnswerTargets(q, result); const options = Array.from(q.optionEls || []); if (!targets || !options.every(option => isExamAnswerElementReady(option, q.el))) return false; // 普通课仍从正文触发原生选项事件,外层标签仅用作状态和禁用检查的边界。 const activateOption = index => { const option = options[index]; const target = option.matches('.nodeLab') ? option.querySelector('.node_detail') || option : option; return activateExerciseChoice(target, q.el); }; const selected = new Set(targets); const matches = () => q.el.isConnected && options.every((option, index) => isExamAnswerElementReady(option, q.el) && isExamOptionChecked(q, option) === selected.has(index)); if (matches()) return true; try { // 多选先取消错误勾选,再补正确项;单选由平台自己的互斥规则取消旧项。 if (Number(q.type) === 1) { for (let index = 0; index < options.length; index++) { if (!selected.has(index) && isExamOptionChecked(q, options[index])) { if (!isExamAnswerElementReady(options[index], q.el) || !activateOption(index)) return false; if (isExamOptionChecked(q, options[index])) return false; } } } for (const index of targets) { if (!isExamOptionChecked(q, options[index])) { if (!isExamAnswerElementReady(options[index], q.el) || !activateOption(index)) return false; } } return matches(); } catch (_) { return false; } } // --- 积分管理 --- function updateCreditsDisplay(credits) { document.querySelectorAll('#ae-credits, #av-study-credits').forEach(el => { el.textContent = credits >= 0 ? credits : '--'; }); } async function queryCredits() { const res = await gmRequest({ method: 'GET', url: `https://qsy.iano.cn/index.php?s=/api/question_bank/credits&device_id=${encodeURIComponent(getDeviceId())}`, timeout: 8000, }); if (!res || res.status !== 200) return 0; try { const json = JSON.parse(res.responseText); if (json.code === 1 && json.data) return json.data.remaining_credits || 0; } catch { } return 0; } async function activateCode(code) { const res = await gmRequest({ method: 'POST', url: 'https://qsy.iano.cn/index.php?s=/api/question_bank/activate', headers: { 'Content-Type': 'application/json' }, data: JSON.stringify({ code, device_id: getDeviceId() }), timeout: 10000, }); if (!res || res.status !== 200) return { ok: false, msg: '网络错误' }; try { const json = JSON.parse(res.responseText); return { ok: json.code === 1, msg: json.msg || '', data: json.data }; } catch { return { ok: false, msg: '解析错误' }; } } // --- 考试面板 UI --- function createExamPanel() { if ($('#av-exam-panel')) return; if (!config.showPanel) return; if (!$('#autovisor-panel')) { createPanel(); return; } const panel = document.createElement('div'); panel.id = 'av-exam-panel'; panel.setAttribute('role', 'tabpanel'); panel.setAttribute('aria-labelledby', 'av-tab-exam'); panel.tabIndex = 0; panel.hidden = $('#autovisor-panel').dataset.activeTab !== 'exam'; panel.innerHTML = `
点击开始答题后自动查询、填入并切换下一题,可随时暂停和继续。
答题来源与模型设置
扫码获取验证码
📱 扫码观看广告获取验证码
验证后免费使用24小时
0 已找到 0 未找到 0 总题数
⚠️ 仅供学习交流,请勿用于违规行为
使用者自行承担一切后果
`; $('#av-workspace').appendChild(panel); // 领取付费题库测试弹窗(独立于面板,避免被面板滚动区域遮挡) const trialModal = document.createElement('div'); trialModal.id = 'av-trial-modal'; trialModal.hidden = true; trialModal.innerHTML = ` `; document.body.appendChild(trialModal); let trialPreviousFocus = null; const closeTrialModal = () => { trialModal.hidden = true; if (trialPreviousFocus && typeof trialPreviousFocus.focus === 'function') trialPreviousFocus.focus(); }; const openTrialModal = () => { trialPreviousFocus = document.activeElement; trialModal.hidden = false; const closeButton = trialModal.querySelector('.ae-trial-close'); if (closeButton) closeButton.focus(); }; $('#ae-trial-btn').addEventListener('click', openTrialModal); trialModal.querySelector('.ae-trial-close').addEventListener('click', closeTrialModal); trialModal.querySelector('.ae-trial-done').addEventListener('click', closeTrialModal); trialModal.addEventListener('click', (event) => { if (event.target === trialModal) closeTrialModal(); }); document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !trialModal.hidden) { event.preventDefault(); closeTrialModal(); } }, { signal: $('#autovisor-panel')._avController.signal }); // --- 模式切换 --- function switchModeUI(mode) { panel.querySelectorAll('input[name="ae-mode"]').forEach(input => { input.checked = input.value === mode; }); const freeTab = $('#ae-mode-free'); const paidTab = $('#ae-mode-paid'); const aiTab = $('#ae-mode-ai'); const freeArea = $('#ae-area-free'); const paidArea = $('#ae-area-paid'); const aiArea = $('#ae-area-ai'); [freeTab, paidTab, aiTab].forEach(tab => { if (tab) { tab.style.background = 'transparent'; tab.style.color = '#94a3b8'; } }); [freeArea, paidArea, aiArea].forEach(area => { if (area) area.style.display = 'none'; }); if (mode === 'ai') { aiTab.style.background = 'rgba(167,139,250,0.16)'; aiTab.style.color = '#c4b5fd'; aiArea.style.display = ''; setExamMode('ai'); } else if (mode === 'paid') { freeTab.style.background = 'transparent'; freeTab.style.color = '#94a3b8'; paidTab.style.background = 'rgba(96,165,250,0.15)'; paidTab.style.color = '#60a5fa'; freeArea.style.display = 'none'; paidArea.style.display = ''; setExamMode('paid'); } else { paidTab.style.background = 'transparent'; paidTab.style.color = '#94a3b8'; freeTab.style.background = 'rgba(52,211,153,0.15)'; freeTab.style.color = '#34d399'; paidArea.style.display = 'none'; freeArea.style.display = ''; setExamMode('free'); } } panel.querySelectorAll('input[name="ae-mode"]').forEach(input => { input.addEventListener('change', () => { if (input.checked) switchModeUI(input.value); }); }); panel._avSetMode = switchModeUI; // 恢复上次模式 switchModeUI(examMode); // 学习页与考试页共用同一套来源隔离的模型配置交互。 mountAiModelControls(panel, 'ae', { onSave: () => { stopExamAutomation('AI 配置已保存'); if (config.autoExam) startExamAutomation(); } }); // --- 免费验证码 --- // 恢复验证状态 if (isVerifyValid()) { const state = getVerifyState(); const expStr = new Date(state.validUntil * 1000).toLocaleString(); $('#ae-verify-msg').textContent = `✅ 已验证,有效期至 ${expStr}`; $('#ae-verify-msg').style.color = '#34d399'; $('#ae-verify-code').disabled = true; $('#ae-verify-btn').disabled = true; } $('#ae-verify-btn').addEventListener('click', async () => { const code = ($('#ae-verify-code').value || '').trim(); if (!code || code.length !== 4) { $('#ae-verify-msg').textContent = '请输入4位验证码'; $('#ae-verify-msg').style.color = '#f87171'; return; } $('#ae-verify-btn').disabled = true; $('#ae-verify-btn').textContent = '验证中...'; const result = await verifyCode(code); $('#ae-verify-msg').textContent = result.ok ? `✅ ${result.msg}` : `❌ ${result.msg}`; $('#ae-verify-msg').style.color = result.ok ? '#34d399' : '#f87171'; if (result.ok) { $('#ae-verify-code').disabled = true; } else { $('#ae-verify-btn').disabled = false; } $('#ae-verify-btn').textContent = '验证'; if (result.ok && config.autoExam) startExamAutomation(); }); // 回车验证 $('#ae-verify-code').addEventListener('keypress', (e) => { if (e.key === 'Enter') $('#ae-verify-btn').click(); }); // --- 付费激活码 --- $('#ae-activate-btn').addEventListener('click', async () => { const code = ($('#ae-code-input').value || '').trim(); if (!code) return; $('#ae-activate-msg').textContent = '激活中...'; const result = await activateCode(code); $('#ae-activate-msg').textContent = result.msg; $('#ae-activate-msg').style.color = result.ok ? '#34d399' : '#f87171'; if (result.ok) { const c = await queryCredits(); updateCreditsDisplay(c); if (config.autoExam) startExamAutomation(); } }); // 主按钮统一开始、暂停和继续;暂停会使旧查询和后续点击失效。 $('#ae-answer-action').addEventListener('click', toggleExamAutomation); $('#ae-search-all').addEventListener('click', searchAllExamQuestions); $('#ae-fill-all').addEventListener('click', fillAllExamAnswers); $('#ae-manual-search-btn').addEventListener('click', runManualExamSearch); $('#ae-manual-fill-btn').addEventListener('click', fillManualExamAnswer); $('#ae-manual-clipboard').addEventListener('click', readManualExamClipboard); $('#ae-manual-search').addEventListener('toggle', event => { if (event.target.open) prepareManualExamForm(); }); $('#ae-auto-submit').addEventListener('change', event => { config.examAutoSubmit = event.target.checked; saveConfig(config); }); // 付费模式查询积分 if (examMode === 'paid' && isExamPage()) { queryCredits().then(c => updateCreditsDisplay(c)); } if (isExamPage()) $('#ae-total').textContent = String(getExamKnownTotal() || extractExamQuestions(true).length); refreshExamAnswerAction(); } // --- 独立题页自动答题:查询 → 验证填入 → 下一题 → 整卷提交 --- let examRun = null; let examGeneration = 0; const examQueryCache = new Map(); let examProgress = null; let examAutomationState = 'idle'; function getExamAutomationState() { const progress = examRun || examProgress; const state = examRun ? 'running' : examAutomationState; const label = { idle: '开始答题', running: '暂停答题', paused: '继续答题', completed: '答题已完成' }[state]; return { state, label, completed: progress?.completed.size || 0, found: progress?.found || 0, missing: progress?.missing || 0 }; } function refreshExamAnswerAction() { const button = $('#ae-answer-action'); if (!button) return; const state = getExamAutomationState(); button.textContent = state.label; button.dataset.state = state.state; button.disabled = Boolean(config.skipAnswer || state.state === 'completed' || !isExamPage()); button.title = config.skipAnswer ? '请先取消跳过答题' : !isExamPage() ? '进入题目页面后可开始' : state.state === 'running' ? '暂停后保留已填答案和进度' : state.state === 'paused' ? '继续处理未完成的题目' : ''; } function pauseExamAutomation() { const progress = examRun || examProgress; config.autoExam = false; saveConfig(config); stopExamAutomation('已暂停答题,答案和进度已保留。点击“继续答题”恢复。', { preserveProgress: Boolean(progress) }); if (!progress) { examAutomationState = 'idle'; refreshExamAnswerAction(); } } function toggleExamAutomation() { if (examRun) { pauseExamAutomation(); return; } if (config.skipAnswer || !isExamPage() || examAutomationState === 'completed') { refreshExamAnswerAction(); return; } config.autoExam = true; saveConfig(config); return startExamAutomation(); } function updateExamAutomationStatus(message) { const status = $('#ae-auto-status'); if (status) status.textContent = message; refreshExamAnswerAction(); if (getRuntimePageMode() === 'exam') updatePanelStatus(message); log.info('[自动答题] ' + message); } function stopExamAutomation(reason = '自动答题已停止', { preserveProgress = false } = {}) { examProgress = preserveProgress ? examRun || examProgress : null; examGeneration++; examRun = null; examAutomationState = examProgress ? 'paused' : 'idle'; pauseExamAnswerItems(); if ($('#ae-auto-status')) updateExamAutomationStatus(reason); refreshExamAnswerAction(); } function pauseExamAnswerItems() { document.querySelectorAll('#ae-question-list .ae-q-item.searching').forEach(item => { item.className = 'ae-q-item paused'; item.querySelector('.ae-q-answer').textContent = '已暂停,当前答案已保留'; }); } function getExamContextKey() { const url = new URL(location.href); const removePageParameters = params => { for (const key of [...params.keys()]) { if (/^(?:questionid|question_id|qid|questionindex|currentquestion|page|pageno|pageindex|index)$/i.test(key)) params.delete(key); } params.sort(); }; removePageParameters(url.searchParams); // Vue 页也可能把 questionId/page 放在 hash 查询参数中。 if (url.hash.includes('?')) { const [hashPath, ...query] = url.hash.slice(1).split('?'); const params = new URLSearchParams(query.join('?')); removePageParameters(params); url.hash = hashPath + (params.size ? '?' + params.toString() : ''); } url.searchParams.sort(); return url.href; } function getExamSourceIdentity() { const ai = loadExamAiConfig(); return examMode === 'ai' ? JSON.stringify([examMode, aiConfigIdentity(ai), getAiModel(ai), ai.vision]) : examMode; } function isExamAnswerMatched(question, result) { if (!question || !isExamQuestionAnswered(question)) return false; if (question.optionEls.length) { const targets = getExamAnswerTargets(question, result); return !!targets && question.optionEls.every((option, index) => isExamOptionChecked(question, option) === targets.includes(index)); } const fields = getExamAnswerFields(question), answers = getExamAnswerValues(result); return !!answers && fields.length === answers.length && fields.every((field, index) => normalizeExamFieldValue(field.read()) === normalizeExamFieldValue(answers[index])); } function getExamQuestionKey(question) { const id = ['data-question-id', 'data-questionid', 'data-subject-id', 'data-id', 'id'] .map(name => question.el.getAttribute(name)).find(Boolean) || ''; const number = question.el.querySelector('.letterSortNum, .question-number, .subject_num, .subject_type span:first-child, .questionTit')?.textContent || ''; return JSON.stringify([id, number.trim(), question.type, question.text, question.options, (question.images || []).map(image => [image.src || image.url, image.role, image.optionIndex])]); } function isExamQuestionReady(question) { if (!isExamAnswerElementReady(question.el, question.el)) return false; const options = Array.from(question.optionEls || []); if (options.length) { return options.length >= 2 && options.length === question.options.length && question.options.every(text => String(text || '').trim()) && options.every(option => isExamAnswerElementReady(option, question.el)); } // 题干先挂载时不能以空选项发起计费查询;主观题也要等待输入控件就绪。 if ([0, 1, 3].includes(Number(question.type))) return false; return getExamAnswerFields(question).length > 0; } // 智慧树新版普通作业会把题干交给 QuestionStem 的 closed Shadow DOM。 // 浏览器标准 DOM、Selection 和宿主 textContent 都无法读取这部分内容; // 不能用题号或选项拼成题干,更不能因此发起一次错误/计费查询。 const sealedExamStemSeenAt = new WeakMap(); function getSealedExamStemIssue() { const now = Date.now(); const roots = Array.from(document.querySelectorAll('.examPaper_subject')).filter(root => isExamContentVisible(root) && !root.closest('#autovisor-panel, #av-exam-panel, #av-trial-modal')); for (const root of roots) { const describe = root.querySelector('.subject_describe'); const anchor = root.querySelector('.subject_num span[id^="anchor_"]'); // 只有带有当前可见题号的题根才属于已挂载的作业题。 // 其他组件也可能暂时拥有空的 QuestionStem 宿主,不能据此误报封闭题干。 if (!anchor || !isExamContentVisible(anchor) || !String(anchor.textContent || '').trim()) continue; const options = Array.from(root.querySelectorAll('.subject_node .nodeLab')).filter(option => isExamContentVisible(option) && String(option.querySelector('.node_detail')?.textContent || '').trim()); if (!describe || options.length < 2) continue; const visibleText = String(describe.textContent || '').replace(/\s+/g, '').trim(); if (visibleText || describe.querySelector('img')) { sealedExamStemSeenAt.delete(root); continue; } const sealedHost = Array.from(describe.querySelectorAll('*')).find(node => { if (!isExamContentVisible(node) || node.textContent?.trim() || node.shadowRoot !== null) return false; const descriptor = Object.getOwnPropertyDescriptor(node, 'attachShadow'); return typeof descriptor?.value === 'function'; }); if (sealedHost) { const firstSeen = sealedExamStemSeenAt.get(root) || now; sealedExamStemSeenAt.set(root, firstSeen); // 空的 QuestionStem 宿主也会短暂存在于加载阶段;只在签名稳定一小段时间后诊断。 if (now - firstSeen < 1200) continue; return { code: 'STEM_UNREADABLE', number: String(anchor.textContent || '').trim(), message: '当前页面使用封闭题干组件,脚本无法读取正文。已停止查询与自动操作,请手动作答本题。', }; } } return null; } function getExamQuestionReadIssue() { return getSealedExamStemIssue(); } // 封闭 Shadow DOM 的题干无法从标准 DOM 提取,但题号、选项和作答控件仍在外层。 // 手动搜题沿用当前题库/AI 来源:用户只需粘贴题干,选项默认从当前题目读取。 let manualExamResult = null; let sealedExamFallbackCard = null; function getManualExamQuestionContext() { const readable = extractExamQuestions(true)[0]; if (readable) return readable; const roots = Array.from(document.querySelectorAll('.examPaper_subject')).filter(root => isExamContentVisible(root) && !root.closest('#autovisor-panel, #av-exam-panel, #av-trial-modal')); for (const root of roots) { const anchor = root.querySelector('.subject_num span[id^="anchor_"]'); if (!anchor || !isExamContentVisible(anchor) || !String(anchor.textContent || '').trim()) continue; const optionEls = Array.from(root.querySelectorAll('.subject_node .nodeLab')).filter(option => isExamContentVisible(option) && String(option.querySelector('.node_detail')?.textContent || '').trim()); const options = optionEls.map(option => String(option.querySelector('.node_detail')?.textContent || '') .replace(/\s+/g, ' ').trim().replace(/^[A-H][..、::))]\s*/i, '')).filter(Boolean); const typeText = String(root.querySelector('.subject_type, .subject_type_describe')?.textContent || ''); const fields = Array.from(root.querySelectorAll('input:not([type]), input[type="text"], textarea, [contenteditable="true"]')) .filter(field => isExamContentVisible(field)); if (!optionEls.length && !fields.length) continue; return { index: 0, el: root, text: '', type: getQuestionType(typeText), options, images: extractQuestionImages(root, optionEls), optionEls, isChecked: element => isExerciseChoiceSelected(element) || element.classList.contains('onChecked') || !!element.querySelector('.onChecked'), }; } return null; } function parseManualExamOptions(value, fallback = []) { const raw = String(value || '').trim(); if (!raw) return Array.from(fallback || []); let lines = raw.split(/\r?\n/).map(line => line.trim()).filter(Boolean); if (lines.length === 1) lines = lines[0].split(/\s+(?=[A-H][..、::))]\s*)/i).map(line => line.trim()).filter(Boolean); return lines.map(line => line.replace(/^[A-H][..、::))]\s*/i, '').trim()).filter(Boolean); } function splitManualExamClipboard(value) { const lines = String(value || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean); const optionIndex = lines.findIndex(line => /^[A-H][..、::))]\s+/i.test(line)); if (optionIndex > 0) { return { question: lines.slice(0, optionIndex).join(' '), options: parseManualExamOptions(lines.slice(optionIndex).join('\n')), }; } return { question: lines.join(' '), options: [] }; } function prepareManualExamForm() { const questionInput = $('#ae-manual-question'); const optionsInput = $('#ae-manual-options'); const hint = $('#ae-manual-hint'); if (!questionInput || !optionsInput) return; const context = getManualExamQuestionContext(); if (context?.text && !questionInput.value.trim()) questionInput.value = context.text; if (context?.options?.length && !optionsInput.value.trim()) optionsInput.value = context.options.join('\n'); if (hint) hint.textContent = context?.options?.length ? '当前题目的选项已自动读取;题干若为空,请复制题干后粘贴,或点击“读取剪贴板”。' : '当前题干或选项仍未挂载,请先等待页面完成加载;封闭题干需要复制后粘贴才能搜题。'; } async function readManualExamClipboard() { const button = $('#ae-manual-clipboard'); const resultEl = $('#ae-manual-result'); try { let text = String(window.getSelection?.()?.toString?.() || '').trim(); if (!text && navigator.clipboard?.readText) text = String(await navigator.clipboard.readText() || '').trim(); if (!text) throw new Error('剪贴板为空,请先复制题干或直接粘贴到输入框'); const parsed = splitManualExamClipboard(text); if ($('#ae-manual-question')) $('#ae-manual-question').value = parsed.question; if (parsed.options.length && $('#ae-manual-options')) $('#ae-manual-options').value = parsed.options.join('\n'); if (resultEl) resultEl.textContent = parsed.options.length ? '已读取题干和选项,请点击“搜题”。' : '已读取剪贴板题干,请点击“搜题”。'; } catch (error) { if (resultEl) resultEl.textContent = '⚠️ ' + String(error.message || error); } finally { if (button) button.blur(); } } async function runManualExamSearch() { const searchButton = $('#ae-manual-search-btn'); const fillButton = $('#ae-manual-fill-btn'); const resultEl = $('#ae-manual-result'); const context = getManualExamQuestionContext(); const question = String($('#ae-manual-question')?.value || context?.text || '').replace(/\s+/g, ' ').trim(); const options = parseManualExamOptions($('#ae-manual-options')?.value, context?.options || []); if (!context) { if (resultEl) resultEl.textContent = '⚠️ 未找到当前题目的作答区域,请等待题目加载完成后重试。'; return; } if (question.length < 4) { if (resultEl) resultEl.textContent = '⚠️ 请先粘贴完整题干(至少 4 个字符)。'; return; } if (examRun) pauseExamAutomation(); if (searchButton) searchButton.disabled = true; if (fillButton) fillButton.disabled = true; if (resultEl) resultEl.textContent = '正在查询答案…'; try { const query = { ...context, text: question, options }; const result = await searchAnswer(question, options, context.type, examMode, context.images || []); if (!result?.answers?.length) { manualExamResult = null; if (sealedExamFallbackCard) setExamSearchItem(sealedExamFallbackCard, query, result); if (resultEl) resultEl.textContent = '❌ ' + (result?.message || '未找到答案,请检查题干或切换答题来源。'); return; } manualExamResult = { question: query, result }; if (sealedExamFallbackCard) setExamSearchItem(sealedExamFallbackCard, query, result); if (fillButton) fillButton.disabled = false; if (resultEl) resultEl.textContent = `✅ ${result.answers.join(' | ')}(来源:${result.source || examMode})\n可点击“填入当前题”写回页面。`; } catch (error) { manualExamResult = null; if (resultEl) resultEl.textContent = '❌ ' + String(error.message || error); } finally { if (searchButton) searchButton.disabled = false; } } function fillManualExamAnswer() { const resultEl = $('#ae-manual-result'); if (!manualExamResult?.result) { if (resultEl) resultEl.textContent = '⚠️ 请先搜题,再填入答案。'; return; } const current = getManualExamQuestionContext(); if (!current) { if (resultEl) resultEl.textContent = '⚠️ 当前题目已离开页面,请返回题目后重试。'; return; } if (manualExamResult.question.options.length !== current.options.length) { if (resultEl) resultEl.textContent = '⚠️ 当前页面选项数量已变化,未填入答案,请重新搜题。'; return; } const question = { ...current, text: manualExamResult.question.text }; const ok = matchAndFill(question, manualExamResult.result); if (resultEl) resultEl.textContent = ok ? '✅ 已填入当前题,请检查页面后点击平台“下一题”。' : '❌ 平台未确认答案已填入,请检查题型或手动选择。'; if (ok) updateExamAutomationStatus('手动搜题答案已填入当前题,请点击平台“下一题”。'); if (ok && sealedExamFallbackCard) sealedExamFallbackCard.querySelector('.ae-q-answer').textContent += '(已填入)'; } function renderSealedExamFallbackCard(issue) { const list = $('#ae-question-list'); if (!list) return; list.replaceChildren(); const item = document.createElement('div'); item.className = 'ae-q-item query-error'; item.innerHTML = '
⚠️ 题干由平台封闭组件渲染,暂时无法自动读取
请复制题干后使用一键搜题
'; item.querySelector('.ae-q-num').textContent = issue?.number ? `第${issue.number.replace(/[.。]$/, '')}题` : '当前题目'; item.querySelector('.ae-q-text').textContent = '[题目配图] 请根据题图及选项作答。'; list.appendChild(item); sealedExamFallbackCard = item; item.querySelector('[data-sealed-manual]')?.addEventListener('click', () => { const manual = $('#ae-manual-search'); if (manual) { manual.open = true; prepareManualExamForm(); $('#ae-manual-question')?.focus(); } }); item.querySelector('[data-sealed-clipboard]')?.addEventListener('click', async () => { const manual = $('#ae-manual-search'); if (manual) manual.open = true; await readManualExamClipboard(); if ($('#ae-manual-question')?.value.trim()) await runManualExamSearch(); }); } // 兼容旧版的“ 一键搜题 → 一键填入 ”流程。自动答题和这个手动触发流程共用同一套题库、模型和填入校验。 let examSearchResults = new Map(); let examSearchRunning = false; function setExamSearchItem(item, question, result) { const answer = item?.querySelector('.ae-q-answer'); const source = item?.querySelector('.ae-q-source'); if (!answer || !source) return; if (result?.answers?.length) { item.className = 'ae-q-item found'; answer.className = 'ae-q-answer'; answer.textContent = '✅ ' + result.answers.join(' | '); source.textContent = '来源:' + (result.source || examMode); } else { item.className = result?.errorCode && result.errorCode !== 'ANSWER_NOT_FOUND' ? 'ae-q-item query-error' : 'ae-q-item not-found'; answer.className = 'ae-q-answer empty'; answer.textContent = result?.message || '❌ 未找到答案'; source.textContent = '来源:' + ({ ai: 'AI', free: '免费题库', paid: '付费题库' }[examMode] || examMode); } } async function searchAllExamQuestions() { if (examSearchRunning) return; if (!isExamPage()) { updateExamAutomationStatus('请先进入题目页面,再使用一键搜题。'); return; } if (examRun) pauseExamAutomation(); let questions = extractExamQuestions(true); if (!questions.length) { const issue = getExamQuestionReadIssue(); if (issue) { renderSealedExamFallbackCard(issue); updateExamAutomationStatus('检测到封闭题干,已保留卡片;点击卡片“读取题干”或展开辅助后再搜题。'); } else { updateExamAutomationStatus('题目或选项尚未加载完整,请稍后再使用一键搜题。'); } return; } examSearchRunning = true; examSearchResults = new Map(); sealedExamFallbackCard = null; const list = $('#ae-question-list'); if (list) list.replaceChildren(); let found = 0, missing = 0; $('#ae-total') && ($('#ae-total').textContent = String(questions.length)); $('#ae-found') && ($('#ae-found').textContent = '0'); $('#ae-notfound') && ($('#ae-notfound').textContent = '0'); updateExamAutomationStatus(`一键搜题开始,共 ${questions.length} 题…`); try { for (let index = 0; index < questions.length; index++) { if (!examSearchRunning) break; const question = questions[index]; const item = renderExamAnswerItem(question, index + 1); const actions = document.createElement('div'); actions.className = 'ae-q-actions'; actions.innerHTML = ``; item.appendChild(actions); const result = await searchAnswer(question.text, question.options, question.type, examMode, question.images || []); examSearchResults.set(index, { question, result }); setExamSearchItem(item, question, result); if (result?.answers?.length) found++; else if (!result?.errorCode || result.errorCode === 'ANSWER_NOT_FOUND') missing++; $('#ae-found') && ($('#ae-found').textContent = String(found)); $('#ae-notfound') && ($('#ae-notfound').textContent = String(missing)); await sleep(250); } updateExamAutomationStatus(`一键搜题完成:找到 ${found} 题,未找到 ${missing} 题。点击“一键填入”写回答案。`); } finally { examSearchRunning = false; } list?.querySelectorAll('[data-exam-search-fill]').forEach(button => button.addEventListener('click', () => { const data = examSearchResults.get(Number(button.dataset.examSearchFill)); const ok = data?.result?.answers?.length ? matchAndFill(data.question, data.result) : false; button.textContent = ok ? '已填入' : '填入失败'; })); list?.querySelectorAll('[data-exam-search-retry]').forEach(button => button.addEventListener('click', async () => { const index = Number(button.dataset.examSearchRetry), data = examSearchResults.get(index); if (!data || examSearchRunning) return; button.disabled = true; const item = button.closest('.ae-q-item'); if (item) { item.className = 'ae-q-item searching'; item.querySelector('.ae-q-answer').textContent = '正在重试…'; } const result = await searchAnswer(data.question.text, data.question.options, data.question.type, examMode, data.question.images || []); examSearchResults.set(index, { ...data, result }); setExamSearchItem(item, data.question, result); button.disabled = false; })); } function fillAllExamAnswers() { if (!examSearchResults.size) { updateExamAutomationStatus('请先点击“一键搜题”,再使用“一键填入”。'); return; } let filled = 0, failed = 0; for (const data of examSearchResults.values()) { if (data?.result?.answers?.length && matchAndFill(data.question, data.result)) filled++; else failed++; } updateExamAutomationStatus(`一键填入完成:成功 ${filled} 题,失败 ${failed} 题。请检查页面后继续。`); } function getExamPageSnapshot(requireReady = false) { const stemIssue = requireReady ? getSealedExamStemIssue() : null; if (stemIssue) { return { questions: [], key: '', issue: stemIssue, context: getExamContextKey(), route: location.href }; } const questions = extractExamQuestions(true, requireReady); if (requireReady && questions.some(question => !isExamQuestionReady(question))) { return { questions: [], key: '', context: getExamContextKey(), route: location.href }; } return { questions, key: questions.length ? JSON.stringify(questions.map(getExamQuestionKey)) : '', context: getExamContextKey(), route: location.href }; } function refreshExamKnownTotal(run, fallback = 0) { const total = getExamKnownTotal(); if (run.total && total && run.total !== total) throw new Error('试卷总题数发生变化,已停止自动提交'); run.total = total || run.total; if ($('#ae-total')) $('#ae-total').textContent = String(run.total || fallback); } function assertExamRun(run) { if (examRun !== run || run.generation !== examGeneration || !config.autoExam || config.skipAnswer) throw new Error('自动答题已取消'); if (run.context !== getExamContextKey() || !isExamPage() && !run.awaitingPage) throw new Error('已离开当前试卷,自动答题已停止'); if (run.source !== getExamSourceIdentity()) throw new Error('来源或模型已变化,请点击开始答题'); if (platformSafetyStopped || isVerificationVisible() || hasBlockingPlatformDialog()) { checkPlatformDialogs(); throw new Error('请先处理平台提示,再点击继续答题'); } if (examMode === 'free' && !isVerifyValid()) throw new Error('请先验证免费验证码'); } async function waitExamCondition(run, check, timeout = 8000) { const deadline = Date.now() + timeout; do { assertExamRun(run); const result = check(); if (result) return result; await sleep(200); } while (Date.now() < deadline); assertExamRun(run); return null; } function getExamControls() { return Array.from(document.querySelectorAll('button, a, input[type="button"], input[type="submit"], [role="button"], .next-topic, .reviewDone, .submit-btn, .el-button, .pre-next > div, .pre-next > span')) .filter(node => !node.closest('#autovisor-panel, #av-exam-panel, #av-trial-modal, .subject_describe, .smallStem_describe, .questionName, .questionTitle, .optionUl, .subject_node, .nodeLab, .el-radio-group, .el-checkbox-group, .radio-view, .checkbox-view, .checkbox-views, .option-list, .option-item, .topic-item, .analysis, .answer-analysis, .answer-result, [contenteditable="true"]') && isPlatformNoticeVisible(node)); } function examControlLabel(node) { return String(node.getAttribute('aria-label') || node.textContent || node.value || node.title || '').replace(/\s+/g, ''); } function isExamControlEnabled(node) { return !node.disabled && !node.closest('[disabled], [aria-disabled="true"], .is-disabled, .disabled, .noNext') && (!node.matches('.next-topic') || node.matches('.next-t')) && !hasUnsafeStudyLink(node); } function findExamAction(kind, includeDisabled = false) { const patterns = { next: /^(?:下一题|下一道|下一页|下页)(?:[>›»→])?$/, local: /^(?:提交答案|确认答案|完成作答)$/, final: /^(?:提交试卷|提交作业|我要交卷|交卷|提交并结束|提交)$/ }; const candidates = getExamControls().filter(node => { if (node.closest('.el-dialog, .el-message-box, [role="dialog"], [role="alertdialog"], .ant-modal')) return false; const label = examControlLabel(node); const localSubmit = label === '提交' && !!node.closest('.questionContent, .examPaper_subject, .questionBox, .ques-detail'); const matches = (patterns[kind].test(label) && !(kind === 'final' && localSubmit)) || kind === 'local' && localSubmit || kind === 'next' && node.matches('.pre-next .next-topic') && !label; return matches && (includeDisabled || isExamControlEnabled(node)); }); // 嵌套 role/button 只保留最内层;多个不同入口不猜测。 const leaves = candidates.filter(node => !candidates.some(other => node !== other && node.contains(other))); return leaves.length === 1 ? leaves[0] : null; } function getExamKnownTotal() { const cards = Array.from(document.querySelectorAll('.sheetR .font-sec-style-node, .ETC-right .font-sec-style-node, .el-tree .font-sec-style-node, .answer-card [data-question-id], .answer-card .question-number')) .filter(isPlatformNoticeVisible); const excluded = '#autovisor-panel, #av-exam-panel, #av-trial-modal, .examPaper_subject, .questionContent, .questionBox, .ques-detail, .subject_describe, .smallStem_describe, .answer-analysis, .analysis, .answer-result'; // 普通作业的“题目数”常由相邻 label/value 组成,未必有专用计数 class。 // 仅检查短小、可见的页面说明块,不扫描题干或助手自身的统计数字。 const summaryCounters = Array.from(document.querySelectorAll('div, p, span, li, td, th, dt, dd, h1, h2, h3, h4, label')) .filter(node => !node.closest(excluded) && !node.querySelector(excluded) && isPlatformNoticeVisible(node)) .map(node => (node.textContent || '').replace(/\s+/g, '')) .filter(text => text.length <= 240 && /题目数|试题总数|试题数|总题数/.test(text)); const paperTotals = summaryCounters.map(text => text.match(/(?:题目数|试题总数|试题数)[::]?(\d+)(?:题|道)?/)) .filter(Boolean).map(match => Number(match[1])).filter(value => value > 0); if (paperTotals.length) return Math.max(...paperTotals); const counters = document.querySelectorAll('#exam-progress, .question-progress, .question-count, .exam-progress, .pre-next, .paper-title, .exam-title, .questionTitle .letterSortNum'); for (const counter of counters) { if (counter.closest('#autovisor-panel, #av-exam-panel, #av-trial-modal') || !isPlatformNoticeVisible(counter)) continue; const text = (counter.textContent || '').replace(/\s+/g, ''); const match = text.match(/(?:共|总题数[::]?)(\d+)题?/) || text.match(/\d+\/(\d+)/); if (match && Number(match[1]) > 0) return Number(match[1]); } const sectionTotals = summaryCounters.map(text => text.match(/总题数[::]?(\d+)(?:题|道)?/)) .filter(Boolean).map(match => Number(match[1])).filter(value => value > 0); if (sectionTotals.length) return Math.max(...sectionTotals); if (cards.length) { const ids = cards.map(node => { const id = node.getAttribute('data-question-id') || node.getAttribute('data-questionid') || node.getAttribute('data-id'); if (id) return id; const text = (node.textContent || '').replace(/\s+/g, ''); return /^[1-9]\d*$/.test(text) ? String(Number(text)) : ''; }); // 显式总题数优先,避免虚拟题卡只挂载部分节点时提前交卷。 // 桌面/移动题卡可能保留两套 DOM,相同题号只计一次。 return new Set(ids.filter(Boolean)).size; } return 0; } function renderExamAnswerItem(question, index) { const item = document.createElement('div'); item.className = 'ae-q-item searching'; for (const [className, text] of [['ae-q-num', `第${index}题 [${TYPE_NAMES[question.type] || '未知'}]`], ['ae-q-text', question.text], ['ae-q-answer', '正在查询答案…'], ['ae-q-source', '']]) { const element = document.createElement('div'); element.className = className; element.textContent = text; if (className === 'ae-q-text') element.title = question.text; item.appendChild(element); } $('#ae-question-list')?.appendChild(item); return item; } function examHasNewSubmissionResult(baseline, expectedTotal) { const selectors = '.exam-result, .examResult, .result-page, .exam-result-page, .submit-success, .el-message, .ant-message-notice-content, .line1-count-total, .answer-card .item.green, .answer-card .item.red'; return Array.from(document.querySelectorAll(selectors)).some(node => { if (!isPlatformNoticeVisible(node) || node.closest('#autovisor-panel, #av-exam-panel')) return false; const text = (node.textContent || '').replace(/\s+/g, ''); if (baseline.get(node) === text) return false; const results = node.closest('.answer-card'); const completeResultCard = results && expectedTotal > 0 && !getExamPageSnapshot().questions.length && results.querySelectorAll('.item.green, .item.red').length >= expectedTotal; return /提交成功|交卷成功|试卷已提交|作业已提交|答题完成|已完成作答|考试结束|作答结果/.test(text) || node.matches('.answer-card .item.green, .answer-card .item.red') && completeResultCard || node.matches('.line1-count-total') && /已答对/.test(text); }); } async function submitExamAutomatically(run, page) { if (config.skipAnswer) throw new Error('已开启跳过答题,不会提交本页'); if (!config.examAutoSubmit) { updateExamAutomationStatus('本页已自动作答,未勾选自动提交,已保留答案。'); return; } const submit = findExamAction('final'); if (!run.finalSubmission && !submit) throw new Error('题目已作答,未识别到唯一的试卷提交按钮,已保留答案'); assertExamRun(run); if (getExamPageSnapshot().key !== page.key || page.questions.some(question => !isExamAnswerMatched(question, run.answers.get(getExamQuestionKey(question))))) throw new Error('提交前题目或答案发生变化,已停止提交'); const baseline = run.finalSubmission?.baseline || new Map(Array.from(document.querySelectorAll('.exam-result, .examResult, .result-page, .exam-result-page, .submit-success, .el-message, .ant-message-notice-content, .line1-count-total, .answer-card .item.green, .answer-card .item.red')) .map(node => [node, (node.textContent || '').replace(/\s+/g, '')])); updateExamAutomationStatus('全部题目已作答,正在提交…'); run.submitting = true; if (!run.finalSubmission) { run.finalSubmission = { baseline, confirmed: false }; if (!dispatchStudyClick(submit)) throw new Error('提交入口不可用,已保留答案'); } let confirmed = run.finalSubmission.confirmed; const submissionDeadline = Date.now() + 8000; do { if (examRun !== run || run.generation !== examGeneration || !config.autoExam || config.skipAnswer || run.source !== getExamSourceIdentity()) throw new Error('自动答题已取消'); if (examHasNewSubmissionResult(baseline, run.completed.size)) { updateExamAutomationStatus('自动答题完成,平台已确认提交成功。'); return; } // 交卷后路由可能先进入结果页,成绩组件稍后才挂载。 // 这段等待只读取结果,不在新页面查找或点击确认按钮。 if (run.context !== getExamContextKey() || !isExamPage()) { await sleep(200); continue; } assertExamRun(run); const dialogs = Array.from(document.querySelectorAll('.el-dialog, .el-message-box, [role="dialog"], [role="alertdialog"], .ant-modal')) .filter(node => !node.closest('#av-exam-panel, #av-trial-modal') && isPlatformNoticeVisible(node)); for (const dialog of dialogs) { const text = getPlatformDialogText(dialog); if (!/提交|交卷/.test(text)) continue; if (/返回|退出|离开|首页|注销|未提交|未交卷/.test(text) || !/(?:确认|确定|是否|即将|准备|现在).{0,16}(?:提交|交卷)|(?:提交|交卷)后/.test(text)) { throw new Error('平台显示的不是明确交卷确认,已保留窗口供手动处理'); } const warning = text.replace(/未(?:作)?答[::]?0(?:题)?/g, ''); if (/未答|未作答|尚未|遗漏|未完成|剩余|异常|验证|登录/.test(warning)) throw new Error('平台提示仍有未完成内容,已保留提示并停止提交'); const buttons = Array.from(dialog.querySelectorAll('button, [role="button"], .el-button, .comfirm')) .filter(node => /^(?:确定提交|确认提交|确定交卷|确认交卷|确定|确认|提交)$/.test(examControlLabel(node)) && isPlatformNoticeVisible(node) && isExamControlEnabled(node)); if (buttons.length === 1 && !confirmed) { assertExamRun(run); if (!config.examAutoSubmit) throw new Error('自动提交已关闭,已保留确认窗口'); confirmed = true; run.finalSubmission.confirmed = true; dispatchStudyClick(buttons[0]); } } if (examHasNewSubmissionResult(baseline, run.completed.size)) { updateExamAutomationStatus('自动答题完成,平台已确认提交成功。'); return; } await sleep(200); } while (Date.now() < submissionDeadline); throw new Error('已尝试提交,尚未收到平台成功结果;请检查页面,脚本不会重复提交'); } async function waitForExamNextPage(run, oldKey) { let stableKey = '', since = 0; return waitExamCondition(run, () => { const snapshot = getExamPageSnapshot(true); if (snapshot.issue) throw new Error(snapshot.issue.message); if (!snapshot.key || snapshot.key === oldKey) { stableKey = ''; return null; } if (snapshot.key !== stableKey) { stableKey = snapshot.key; since = Date.now(); } return Date.now() - since >= 400 ? snapshot : null; }, 30000); } async function startExamAutomation() { if (config.skipAnswer) { updateExamAutomationStatus('已跳过答题,不会查询、填入或提交本页。'); return; } if (examRun) return examRun.promise; if (!config.autoExam || !isExamPage()) return; const context = getExamContextKey(), source = getExamSourceIdentity(); const prior = examProgress?.context === context && examProgress?.source === source ? examProgress : null; const run = { ...prior, generation: ++examGeneration, context, source, completed: new Set(prior?.completed), answers: new Map(prior?.answers), visited: new Set(prior?.visited), items: new Map(prior?.items), missingKeys: new Set(prior?.missingKeys), localSubmitted: new Set(prior?.localSubmitted), awaitingPage: true, total: prior?.total || 0, found: prior?.found || 0, missing: prior?.missing || 0 }; if (prior?.pageKey) run.visited.delete(prior.pageKey); examRun = run; examProgress = run; examAutomationState = 'running'; if (!prior) $('#ae-question-list')?.replaceChildren(); for (const [id, count] of [['ae-found', run.found], ['ae-notfound', run.missing], ['ae-total', run.total]]) { if ($('#' + id)) $('#' + id).textContent = String(count); } refreshExamAnswerAction(); run.promise = (async () => { try { if (run.finalSubmission) { await submitExamAutomatically(run, getExamPageSnapshot()); examAutomationState = 'completed'; config.autoExam = false; saveConfig(config); return; } for (let pageIndex = 0; pageIndex < 500; pageIndex++) { updateExamAutomationStatus(run.nextPending ? '正在等待下一题及选项加载…' : '正在识别题目,等待题干和作答区域加载…'); let page = await waitExamCondition(run, () => { refreshExamKnownTotal(run); const snapshot = getExamPageSnapshot(true); if (snapshot.issue) throw new Error(snapshot.issue.message); return snapshot.questions.length ? snapshot : null; }, 30000); if (!page) throw new Error('题目或作答区域在30秒内未加载完整,请检查页面后继续答题'); if (run.nextPending) { page = await waitForExamNextPage(run, run.nextPending); if (!page) throw new Error('下一题未加载成功,请手动检查;不会重复点击'); run.nextPending = ''; } run.awaitingPage = false; if (run.visited.has(page.key)) throw new Error('下一题返回了已处理题目,已停止重复作答'); run.visited.add(page.key); run.pageKey = page.key; if (run.localPending && run.localPending.key !== page.key) run.localPending = null; refreshExamKnownTotal(run, page.questions.length); for (const original of page.questions) { assertExamRun(run); const key = getExamQuestionKey(original); let item = run.items.get(key); if (!item?.isConnected) { item = renderExamAnswerItem(original, run.items.size + 1); run.items.set(key, item); } const previousAnswer = run.answers.get(key); if (run.completed.has(key) && isExamAnswerMatched(original, previousAnswer)) continue; item.className = 'ae-q-item searching'; item.querySelector('.ae-q-answer').textContent = '正在查询答案…'; updateExamAutomationStatus(`正在自动作答第 ${run.completed.size + 1}${run.total ? '/' + run.total : ''} 题…`); const queryKey = JSON.stringify([run.context, run.source, key]); let pending = examQueryCache.get(queryKey); if (!pending) { pending = Promise.resolve(searchAnswer(original.text, original.options, original.type, examMode, original.images)); examQueryCache.set(queryKey, pending); if (examQueryCache.size > 300) examQueryCache.delete(examQueryCache.keys().next().value); } const result = await pending; assertExamRun(run); const current = getExamPageSnapshot(); if (current.key !== page.key) throw new Error('查询期间题目已变化,已忽略旧答案'); const question = current.questions.find(candidate => getExamQuestionKey(candidate) === key); if (!question) throw new Error('原题已离开页面,已忽略旧答案'); if (!result?.answers?.length) { examQueryCache.delete(queryKey); const missing = !result?.errorCode || result.errorCode === 'ANSWER_NOT_FOUND'; if (missing) run.missingKeys.add(key); else run.missingKeys.delete(key); run.missing = run.missingKeys.size; const message = result?.message || '未找到有效答案,请检查当前来源或模型后继续答题'; item.className = missing ? 'ae-q-item not-found' : 'ae-q-item query-error'; item.querySelector('.ae-q-answer').textContent = message; item.querySelector('.ae-q-source').textContent = '来源:' + ({ ai: 'AI', free: '免费题库', paid: '付费题库' }[examMode] || examMode); if ($('#ae-notfound')) $('#ae-notfound').textContent = String(run.missing); throw new Error(message); } matchAndFill(question, result); await sleep(250); assertExamRun(run); // 平台可能异步回写选中状态;只读等待确认,不再次点击选项。 const verified = await waitExamCondition(run, () => { const snapshot = getExamPageSnapshot(); if (snapshot.key !== page.key) throw new Error('填入期间题目已变化,已停止翻题和提交'); const fresh = snapshot.questions.find(candidate => getExamQuestionKey(candidate) === key); return isExamAnswerMatched(fresh, result) ? snapshot : null; }, 3000); if (!verified) { item.className = 'ae-q-item query-error'; item.querySelector('.ae-q-answer').textContent = '平台未确认完整选中或填入答案,已保留当前内容并暂停'; throw new Error('平台未确认完整选中或填入答案,已停止翻题和提交'); } run.completed.add(key); run.answers.set(key, result); run.found = run.completed.size; run.missingKeys.delete(key); run.missing = run.missingKeys.size; if ($('#ae-notfound')) $('#ae-notfound').textContent = String(run.missing); item.className = 'ae-q-item found'; item.querySelector('.ae-q-answer').textContent = '✅ ' + result.answers.join(' | ') + '(已自动填入)'; item.querySelector('.ae-q-source').textContent = '来源:' + (result.source || examMode); if ($('#ae-found')) $('#ae-found').textContent = String(run.found); } assertExamRun(run); let current = getExamPageSnapshot(); if (current.key !== page.key || current.questions.some(question => !isExamAnswerMatched(question, run.answers.get(getExamQuestionKey(question))))) throw new Error('翻题前答案发生变化,已暂停'); const local = findExamAction('local'); if (run.localPending?.key === page.key || local && !run.localSubmitted.has(page.key)) { run.localSubmitted.add(page.key); const resultBaseline = run.localPending?.key === page.key ? run.localPending.baseline : new Map(Array.from(document.querySelectorAll('.answer-result, .answer-analysis, .question-result, .analysis, .answer-card .item.green, .answer-card .item.red')) .map(node => [node, (node.textContent || '').trim()])); run.awaitingPage = true; if (!run.localPending) { run.localPending = { key: page.key, baseline: resultBaseline }; if (!dispatchStudyClick(local)) throw new Error('当前题目提交按钮不可用'); } const answered = await waitExamCondition(run, () => { const snapshot = getExamPageSnapshot(); if (snapshot.key && snapshot.key !== page.key) return snapshot; return Array.from(document.querySelectorAll('.answer-result, .answer-analysis, .question-result, .analysis, .answer-card .item.green, .answer-card .item.red')) .some(node => !node.closest('#av-exam-panel') && isPlatformNoticeVisible(node) && resultBaseline.get(node) !== (node.textContent || '').trim() && /正确答案|参考答案|回答正确|回答错误|提交成功|答案解析|得分/.test(node.textContent || '')); }); if (!answered) throw new Error('本题提交尚未确认,已停止重复提交'); run.localPending = null; run.awaitingPage = false; if (answered.key) continue; } const next = findExamAction('next'); if (next) { updateExamAutomationStatus('本页已作答,正在切换下一题…'); assertExamRun(run); run.awaitingPage = true; run.nextPending = page.key; if (!dispatchStudyClick(next)) throw new Error('下一题入口不可用'); const changed = await waitForExamNextPage(run, page.key); if (!changed) throw new Error('下一题未加载成功,已保留答案;不会重复点击'); run.nextPending = ''; run.awaitingPage = false; continue; } current = getExamPageSnapshot(); const hasPagination = !!findExamAction('next', true) || !!document.querySelector('.pre-next .next-topic, .sheetR .font-sec-style-node, .ETC-right .font-sec-style-node'); const complete = run.total ? run.completed.size === run.total : !hasPagination && run.visited.size === 1; if (!complete) throw new Error('尚不能确认整份试卷已答完,已保留答案并暂停提交'); await submitExamAutomatically(run, current); examAutomationState = 'completed'; config.autoExam = false; saveConfig(config); return; } throw new Error('已达到本次处理上限,自动答题已暂停'); } catch (error) { if (examRun === run) { examAutomationState = 'paused'; config.autoExam = false; saveConfig(config); pauseExamAnswerItems(); updateExamAutomationStatus('已暂停:' + String(error.message || error).replace(/[。.!!??\s]+$/, '') + '。点击“继续答题”可恢复。'); } } finally { if (examRun === run) { examRun = null; examProgress = run; refreshExamAnswerAction(); } } })(); return run.promise; } // --- 考试页入口 --- async function examMain(canContinue = () => true) { log.info('考试助手启动!'); await sleep(3000); if (!canContinue()) return; // 练习组件可能在首次路由进入后才挂载;到面板创建前再判断一次, // 让 AI 课程的 singleCourse/knowledgeStudy 习题自动进入 AI 模式。 if (isAiExamHost()) { examMode = 'ai'; GM_setValue('av_exam_mode', 'ai'); } createExamPanel(); $('#av-exam-panel')?._avSetMode?.(examMode); syncRuntimePanels('exam'); fetchAnnouncement(); if (config.skipAnswer) { await skipCurrentQuestionResource(); if (!canContinue()) return; const generation = taskGeneration; let pending = false; const id = setInterval(async () => { if (pending || generation !== taskGeneration || !config.skipAnswer || !isExamPage()) return; pending = true; try { await skipCurrentQuestionResource(); } catch (error) { log.warn('跳过答题章节失败: ' + error.message); } finally { pending = false; } }, 1500); timers.push(id); } if (config.autoExam && !config.skipAnswer) { await sleep(2000); if (!canContinue()) return; startExamAutomation(); } else if (!config.skipAnswer) { updatePanelStatus('就绪,点击“开始答题”'); } } // 获取并展示公告 const announcementStates = new WeakMap(); function fetchAnnouncement() { const area = $('#av-notice-area'); if (!area) return; let state = announcementStates.get(area); if (!state) { state = { pending: false, requestId: 0, dismissedKey: '' }; announcementStates.set(area, state); } if (state.pending) return; state.pending = true; const requestId = ++state.requestId; const finish = () => { if (requestId === state.requestId) state.pending = false; }; try { GM_xmlhttpRequest({ method: 'GET', url: 'https://qsy.iano.cn/index.php?s=/admin/unified_manage/scriptannouncementapi', timeout: 5000, onload: function(resp) { try { // SPA 重建/手动重启后,旧请求只能写回发起请求的原容器。 if (!state.pending || requestId !== state.requestId || !area.isConnected || $('#av-notice-area') !== area) return; var res = JSON.parse(resp.responseText); if (res.code === 1 && (!res.data || !res.data.enabled)) { area.replaceChildren(); return; } if (res.code === 1 && res.data && res.data.enabled) { const noticeKey = JSON.stringify([res.data.type, res.data.title, res.data.content, res.data.images, res.data.image_url]); if (state.dismissedKey === noticeKey) { area.replaceChildren(); return; } var colors = { info: '#60a5fa', warning: '#fbbf24', error: '#f87171', success: '#4ade80' }; var c = colors[res.data.type] || '#60a5fa'; var el = document.createElement('div'); el.className = 'av-notice'; var borders = { info:'rgba(96,165,250,0.3)', warning:'rgba(251,191,36,0.3)', error:'rgba(248,113,113,0.3)', success:'rgba(74,222,128,0.3)' }; el.style.borderColor = borders[res.data.type] || borders.info; var header = document.createElement('div'); header.className = 'av-notice-title'; var title = document.createElement('span'); title.style.color = c; title.innerHTML = '📢 '; title.appendChild(document.createTextNode(String(res.data.title || '公告'))); var close = document.createElement('span'); close.className = 'av-notice-close'; close.innerHTML = '×'; header.appendChild(title); header.appendChild(close); el.appendChild(header); // 公告图片可能由 images/image_url 字段返回,也可能直接写在正文(Markdown 或 img 标签)中。 // 旧实现只把 content 塞进 innerHTML,后台使用独立图片字段时图片会被完全丢弃。 var rawContent = String(res.data.content || ''); var noticeImages = []; var addImage = function(url) { url = String(url || '').trim(); if (!url) return; if (/^data:image\//i.test(url) || /^https?:\/\//i.test(url)) { noticeImages.push(url); return; } if (/^\/\//.test(url)) { noticeImages.push('https:' + url); return; } try { var parsedUrl = new URL(url, 'https://qsy.iano.cn/'); if (/^https?:$/.test(parsedUrl.protocol)) noticeImages.push(parsedUrl.href); } catch (_) {} }; var images = res.data.images; if (Array.isArray(images)) { images.forEach(addImage); } else if (typeof images === 'string' && images.trim()) { try { var parsedImages = JSON.parse(images); (Array.isArray(parsedImages) ? parsedImages : [images]).forEach(addImage); } catch (_) { images.split(/[\n,]+/).forEach(addImage); } } if (res.data.image_url) addImage(res.data.image_url); rawContent = rawContent.replace(/!\[[^\]]*\]\(\s*]+)>?(?:\s+["'][^)]*)?\)/g, function(_, url) { addImage(url); return ''; }); rawContent = rawContent.replace(/]*\bsrc\s*=\s*(?:["']([^"']+)["']|([^\s>]+))[^>]*>/gi, function(_, quotedUrl, bareUrl) { addImage(quotedUrl || bareUrl); return ''; }).trim(); noticeImages = noticeImages.filter(function(url, idx, arr) { return url && arr.indexOf(url) === idx; }).slice(0, 6); var body = document.createElement('div'); body.className = 'av-notice-body'; // 只生成允许的排版节点,不带入平台 class/id 或可执行内容, // 避免公告命中宿主页的遮罩、隐藏等全局样式。 var contentBox = document.createElement('div'); var contentSource = document.createElement('template'); contentSource.innerHTML = rawContent; var appendNoticeContent = function(source, target) { source.childNodes.forEach(function(node) { if (node.nodeType === Node.TEXT_NODE) { target.appendChild(document.createTextNode(node.nodeValue || '')); return; } if (node.nodeType !== Node.ELEMENT_NODE || /^(?:script|style|iframe|object|embed|link|meta|svg|math|template|noscript)$/i.test(node.localName)) return; var allowed = /^(?:p|br|strong|b|em|i|u|s|ul|ol|li|blockquote|code|pre|span|div|hr|small)$/i.test(node.localName); var element = allowed ? document.createElement(node.localName) : target; if (allowed) target.appendChild(element); appendNoticeContent(node, element); }); }; appendNoticeContent(contentSource.content, contentBox); if (contentBox.textContent.trim() || contentBox.querySelector('*')) body.appendChild(contentBox); if (noticeImages.length) { var imageBox = document.createElement('div'); imageBox.className = 'av-notice-images'; noticeImages.forEach(function(url) { var link = document.createElement('a'); link.className = 'av-notice-image-link'; link.href = url; link.target = '_blank'; link.rel = 'noopener noreferrer'; link.title = '点击查看原图'; link.setAttribute('aria-label', '查看公告原图'); var img = document.createElement('img'); img.src = url; img.alt = '公告图片'; img.loading = 'lazy'; img.decoding = 'async'; img.referrerPolicy = 'no-referrer'; img.addEventListener('error', function() { link.style.display = 'none'; var tip = document.createElement('div'); tip.className = 'av-notice-image-fallback'; tip.textContent = '公告图片加载失败'; imageBox.appendChild(tip); }, { once: true }); link.appendChild(img); imageBox.appendChild(link); }); body.appendChild(imageBox); var imageHint = document.createElement('div'); imageHint.className = 'av-notice-image-hint'; imageHint.textContent = '点击图片查看原图'; body.appendChild(imageHint); } el.appendChild(body); area.replaceChildren(el); close.onclick = function(){ state.dismissedKey = noticeKey; el.remove(); }; } } catch(e) {} finally { finish(); } }, onerror: finish, ontimeout: finish, onabort: finish, }); } catch(e) { finish(); } } // ===================== 主流程 ===================== async function main() { // 重启或停止后,旧的异步初始化不能再次创建面板/注册定时器。 stopAllTasks(); if (platformSafetyStopped) return; unconfirmedLessonSwitch = ''; const generation = taskGeneration; const startUrl = location.href; const canContinue = () => generation === taskGeneration && location.href === startUrl; watchPageContext(generation); syncRuntimePanels(getRuntimePageMode()); // AI 课程的习题组件由 SPA 异步挂载。路由刚进入时可能只有空壳, // 不能仅等待一次就落入学习面板,否则题目稍后出现时不会再创建答题面板。 if (isAICourseClass() && /\/(?:singleCourse\/knowledgeStudy|learnPage)\//i.test(location.pathname) && !document.querySelector('.questionContent, .questionTitle, .exam-preview, [class*="question-content"]')) { const questionDeadline = Date.now() + 8000; while (Date.now() < questionDeadline && !isExamPage() && !getVideo()) { if (!canContinue()) return; await sleep(400); } } if (!canContinue()) return; // 考试页走考试流程。AI 课程习题组件可能晚于首屏挂载,再补一次检测, // 避免把没有 video 的习题页误当成普通学习页。 let examPage = isExamPage(); if (examPage) { await examMain(canContinue); return; } if (!isStudyPage()) { log.info('当前页面非学习页,脚本待命中'); createPanel(); fetchAnnouncement(); updatePanelStatus('⏳ 等待进入学习/考试页面...'); return; } log.info('智慧树刷课助手启动!'); log.info(`页面类型: ${isAICourseClass() ? '智慧树 AI 课程' : isHikeClass() ? '翻转课' : isNewVersion() ? '智慧共享课(新版)' : '共享课'}`); // 等待页面加载 await sleep(3000); if (!canContinue()) return; // 创建面板 createPanel(); // 获取公告 fetchAnnouncement(); checkPlatformDialogs(); if (platformSafetyStopped) return; // 页面优化 await optimizePage(canContinue); if (!canContinue()) return; // 等待视频元素 await waitForVideo(canContinue); if (!canContinue()) return; // 异步加载期间出现的独立习题页才进入习题流程,助教弹题保留学习流程。 if (isExamPage()) { await examMain(canContinue); return; } resetProgressHealth(); completionSince = 0; // 初始化视频设置 if (!hasBlockingPlatformDialog()) { overridePause(); setMute(); setSpeed(); } const title = getLessonTitle(); log.info(`正在学习: ${title}`); updatePanelStatus(`▶️ ${title}`); // 启动定时任务 // 1. 检测暂停并续播 (每3秒) addTimer(checkAndPlay, 3000); // 2. 视频设置维持(倍速、静音) (每5秒) addTimer(() => { overridePause(); setMute(); setSpeed(); }, 5000); // 3. 弹题检测 (每3秒) addTimer(skipQuestions, 3000); // 保留加载中/错误弹层,暂停自动操作,避免触发平台的退出回调。 addTimer(clearBlankDialogs, 1000); // 4. 弹窗关闭 (每4秒) addTimer(closePopups, 4000); // 5. 人机验证检测 (每5秒) addTimer(checkVerify, 5000); // 6. 进度监控 & 自动切换 (每3秒) addTimer(checkLessonProgress, 3000); log.success('所有任务模块已启动'); } async function waitForVideo(canContinue = () => true) { let retries = 0; while (!getVideo() && retries < 30) { if (!canContinue() || isExamPage()) return; await sleep(1000); retries++; } if (!canContinue()) return; if (getVideo()) { log.info('视频元素已就绪'); } else { log.warn('未找到视频元素,部分功能可能不可用'); } } // ===================== 油猴菜单命令 ===================== GM_registerMenuCommand('⚡ 重新启动脚本', () => { // 安全提示仍在时保持停止;只有用户处理提示后明确重启才解除锁存。 if (getBlockingPlatformDialog()) { checkPlatformDialogs(); updatePanelStatus('⏹ 请先按平台提示处理,再手动重启'); return; } platformSafetyStopped = false; stopAllTasks(); const panel = $('#autovisor-panel'); if (panel) { panel._avDispose?.(); panel.remove(); } main(); }); GM_registerMenuCommand('⏹ 停止脚本', () => { stopAllTasks(); updatePanelStatus('⏹ 脚本已停止'); log.info('脚本已手动停止'); }); GM_registerMenuCommand('🔄 重置配置', () => { config = Object.assign({}, DEFAULT_CONFIG); saveConfig(config); log.info('配置已重置为默认值'); location.reload(); }); // ===================== 启动 ===================== main(); })();