// ==UserScript== // @name 四川省执业药师继续教育 // @namespace http://tampermonkey.net/ // @version 1.3.7-beta.1 // @description 【1.3.7-beta.1 | H5 倍速修复】四川职业药师继续教育;稳定应用标准 HTML5 视频倍速;新增错题自动纠正(通过率提高) // @author Coren // @match https://www.sclpa.cn/* // @match https://zyys.ihehang.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @connect api.deepseek.com // @connect self // @inject-into page // @license CC BY-NC-SA 4.0 // license: https://creativecommons.org/licenses/by-nc-sa/4.0/deed.zh-hans // ==/UserScript== // Script execution starts here. This log should appear first in console if script loads. console.log(`[Script Init] Attempting to load Sichuan Licensed Pharmacist Continuing Education script.`); (function() { 'use strict'; // =================================================================================== // --- 脚本配置 (Script Configuration) --- // =================================================================================== // Get user-defined playback speed from storage, default to 16x if not set let currentPlaybackRate = GM_getValue('sclpa_playback_rate', 1.0); // Get user-defined AI API Key from storage let aiApiKey = GM_getValue('sclpa_deepseek_api_key', '请在此处填入您自己的 DeepSeek API Key'); const CONFIG = { // Use user-defined playback speed VIDEO_PLAYBACK_RATE: currentPlaybackRate, TIME_ACCELERATION_RATE: currentPlaybackRate, AI_API_SETTINGS: { // IMPORTANT: Get API Key from storage API_KEY: aiApiKey, DEEPSEEK_API_URL: 'https://api.deepseek.com/chat/completions', }, }; // --- 脚本全局状态 (Global States) --- let isServiceActive = GM_getValue('sclpa_service_active', true); let scriptMode = GM_getValue('sclpa_script_mode', 'video'); let isVideoSpeedEngineInitialized = false; let refreshVideoSpeedEngine = null; let unfinishedTabClicked = false; // Flag to track if "未完成" tab has been clicked in the current page session let isPopupBeingHandled = false; let isModePanelCreated = false; let currentPageHash = ''; let isChangingChapter = false; let isAiAnswerPending = false; // Flag to track if AI answer is currently being awaited let currentQuestionBatchText = ''; // Renamed from currentQuestionText to reflect batch processing let isSubmittingExam = false; // Flag to indicate if exam submission process is ongoing // --- 考试自动重试闭环状态 (Exam auto-retry closed loop state) --- let examAnswerMemory = loadExamAnswerMemory(); // Map<题目文本, 答案字母>,跨重试轮次保留已确认答案 let examAttemptCount = 0; // 当前考试链内已自动重试的次数 let examRetryChainActive = false; // 自首次失败至通过/放弃该场期间为 true let isHandlingExamResult = false; // 防止失败弹窗被多处重复处理 // 上一轮作答的题目与答案组合(Map<题目文本, {answer, options, title}>), // 用于没有纠错复核页时把“刚刚的答案组合”回传 AI 修正。 let lastAttemptData = new Map(); // 考试批次结束提示相关状态 let lastStartedExamSignature = ''; // 最近一次“开始考试”所在行的文本,用于记录失败场次与跳过已重试耗尽的场次 let failedExamSummary = []; // 重试耗尽仍未通过的场次汇总 let exhaustedExamSignatures = new Set(); // 已重试耗尽的场次签名(跳过,不再自动重进) let examSummaryNotified = false; // 防止“全部处理完成”提示重复弹出 let currentNavContext = GM_getValue('sclpa_nav_context', ''); // 当前考试所属的考试列表路由,考后用于自动返回并继续下一场考试(专业课优先,公需课其次)。 let currentExamListRoute = 'https://zyys.ihehang.com/#/onlineExam'; const runtimeUiState = { speedChangeAlertShown: GM_getValue('sclpa_speed_alert_shown', false) }; let publicCourseTraversalTarget = ''; let publicCourseListActionPending = false; const exhaustedPublicCourseCategories = new Set(); // 全能托管状态会持久化,页面跳转或刷新后仍能从上一次阶段继续。 const ALL_IN_ONE_PHASES = [ { id: 'specialized-video', label: '专业课视频', url: 'https://zyys.ihehang.com/#/specialized', context: 'course' }, { id: 'public-video', label: '公需课视频', url: 'https://zyys.ihehang.com/#/publicDemand', context: 'course', publicTarget: 'video' }, { id: 'public-article', label: '公需课文章', url: 'https://zyys.ihehang.com/#/publicDemand', context: 'course', publicTarget: 'article' }, { id: 'specialized-exam', label: '专业课考试', url: 'https://zyys.ihehang.com/#/onlineExam', context: 'exam' }, { id: 'public-exam', label: '公需课考试', url: 'https://zyys.ihehang.com/#/openOnlineExam', context: 'exam' } ]; let isAllInOneMode = GM_getValue('sclpa_all_in_one_enabled', false); let allInOnePhase = GM_getValue('sclpa_all_in_one_phase', ''); let allInOneTransitionPending = false; // =================================================================================== // --- 辅助函数 (Helper Functions) --- // =================================================================================== /** * Find element by selector and text content * @param {string} selector - CSS selector. * @param {string} text - The text to match. * @returns {HTMLElement|null} */ function findElementByText(selector, text) { try { return Array.from(document.querySelectorAll(selector)).find(el => el.innerText.trim() === text.trim()); } catch (e) { console.error(`[Script Error] findElementByText failed for selector "${selector}" with text "${text}":`, e); return null; } } /** * Safely click an element * @param {HTMLElement} element - The element to click. */ function clickElement(element) { if (element && typeof element.click === 'function') { console.log('[Script] Clicking element:', element); element.click(); } else { console.warn('[Script] Attempted to click a non-existent or unclickable element:', element); } } /** * Hook a method on a given object. * @param {Object} object The object to hook the method on. * @param {string} methodName The name of the method to hook (e.g., 'setTimeout'). * @param {(original: Function) => Function} hooker A function that receives the original function and returns a new function. */ function hook(object, methodName, hooker) { const original = object[methodName]; if (typeof original === 'function') { object[methodName] = hooker(original); console.log(`[Script] Successfully hooked ${methodName}`); } else { console.warn(`[Script] Failed to hook ${methodName}: original is not a function.`); } } /** * Detects the visible result dialog supplied by the examination page. * A failed result triggers the auto-retry closed loop (or manual review * once the retry limit is reached). */ function isElementVisible(element) { return Boolean(element && element.getClientRects().length > 0); } function resultTipTextIsFailure(text) { return /考试未通过|未通过|考试不合格|不合格|没有通过/.test(String(text || '')); } function hasFailedExamResult() { const resultTip = document.querySelector('.result-tip-content'); return Boolean(resultTip && isElementVisible(resultTip) && resultTipTextIsFailure(resultTip.innerText)); } // =================================================================================== // --- 考试自动重试闭环 (Exam Auto-Retry Closed Loop) --- // =================================================================================== const EXAM_CORRECTION_SYSTEM_PROMPT = '你是执业药师考试答题助手。以下是本次考试中回答错误的题目。' + '请先判断每道题是单选题还是多选题:' + '若为单选题,上次所选选项必定是错误的,绝不能再次选择该选项;' + '若为多选题,上次答案组合有问题(可能缺少正确选项,也可能包含错误选项),请重新判断整个答案组合。' + '只输出答案:一行一道,格式为“序号.字母选项”(多选连续写字母,如 2.ABC)。' + '不要输出任何解释、标点或其他文字。'; // 无纠错复核页时的修正提示词:直接把上一轮作答的答案组合回传 AI。 const EXAM_NO_REVIEW_CORRECTION_SYSTEM_PROMPT = '你是执业药师考试答题助手。以下是刚刚提交但被判定为不及格的考试中,各题的上次答案。' + '请先判断每道题是单选题还是多选题:' + '若为单选题,上次所选选项必定是错误的,绝不能再次选择该选项;' + '若为多选题,上次答案组合有问题(可能缺少正确选项,也可能包含错误选项),请重新判断整个答案组合。' + '只输出答案:一行一道,格式为“序号.字母选项”(多选连续写字母,如 2.ABC)。' + '不要输出任何解释、标点或其他文字。'; function loadExamAnswerMemory() { try { const raw = GM_getValue('sclpa_exam_answer_memory', ''); const object = raw ? JSON.parse(raw) : {}; return new Map(Object.entries(object)); } catch (e) { return new Map(); } } function persistExamAnswerMemory() { try { GM_setValue('sclpa_exam_answer_memory', JSON.stringify(Object.fromEntries(examAnswerMemory))); } catch (e) { console.warn('[Script] 保存考试答案记忆失败:', e); } } function resetExamRetryState(reason) { if (reason) console.log(`[Script] 重置考试自动重试状态:${reason}`); examAttemptCount = 0; examRetryChainActive = false; isHandlingExamResult = false; lastAttemptData.clear(); if (examAnswerMemory.size > 0) { examAnswerMemory.clear(); persistExamAnswerMemory(); } } /** * 从题目元素中提取去序号的题目文本,作为跨重试轮次记忆答案的稳定键。 */ function getNormalizedQuestionTitle(item) { const titleElement = item.querySelector('.examination-body-title'); if (!titleElement) return ''; return titleElement.innerText.trim() .replace(/^\s*\d+\s*[、..::))]\s*/, '') .trim(); } function getRememberedAnswerForItem(item) { const title = getNormalizedQuestionTitle(item); return title ? (examAnswerMemory.get(title) || '') : ''; } function normalizeAnswerLetters(value) { if (!value) return ''; const letters = String(value).match(/[A-Za-z]/g); return letters ? letters.join('').toUpperCase() : ''; } /** * 解析“1.A / 2.ABC / 3:B”这类答案为 {序号: 字母} 映射。 */ function parseAnswersToMap(answerText) { const map = new Map(); String(answerText || '').split('\n').forEach(line => { const match = line.trim().match(/^(\d+)\s*[..::、]\s*([A-Za-z]+)/); if (match) map.set(parseInt(match[1], 10), match[2].toUpperCase()); }); return map; } /** * 按完整文本查找按钮,兼容 等多种结构。 */ function findButtonByExactText(text) { const candidates = [ findElementByText('button span', text), findElementByText('button', text), findElementByText('span', text) ]; for (const element of candidates) { if (!element) continue; const button = element.tagName === 'BUTTON' ? element : element.closest('button'); if (button) return button; } return null; } /** * 等待“纠错查看”复核页渲染完成(出现错题标记或上次答案元素)。 */ function waitForReviewPage(timeoutMs = 12000, pollMs = 500) { return new Promise((resolve) => { const startedAt = Date.now(); const timer = setInterval(() => { const reviewVisible = document.querySelector('.examination-body-item .details-state') || document.querySelector('.examination-body-item .examination-details em'); if (reviewVisible || Date.now() - startedAt > timeoutMs) { clearInterval(timer); resolve(Boolean(reviewVisible)); } }, pollMs); }); } /** * 考试未通过后的统一处理:未达重试上限则进入自动重试闭环; * 已达上限则记录失败并放弃该场,继续处理其他考试(不等待人工复核)。 */ function handleFailedExamResult() { if (isHandlingExamResult) return; isHandlingExamResult = true; const maxRetries = Math.max(0, parseInt(GM_getValue('sclpa_exam_max_retries', 3), 10) || 0); if (maxRetries <= 0 || examAttemptCount >= maxRetries) { console.warn(`[Script] 考试未通过,已达自动重试上限(${examAttemptCount}/${maxRetries}),放弃该场继续下一场。`); recordFailedExamAndContinue(); return; } isSubmittingExam = false; isAiAnswerPending = false; examRetryChainActive = true; examAttemptCount++; console.log(`[Script] 检测到考试未通过,启动自动重试闭环(第 ${examAttemptCount}/${maxRetries} 次)。`); // 若复核页已经可见(部分平台交卷后直接展示错题),则无需点击“纠错查看”。 const reviewAlreadyVisible = Array.from(document.querySelectorAll('.examination-body-item .details-state')) .some(element => isElementVisible(element)); if (reviewAlreadyVisible) { console.log('[Script] 纠错复核页已可见,直接提取错题。'); collectReviewAnswersAndCorrect(); return; } const reviewButton = findButtonByExactText('纠错查看') || findButtonByExactText('查看答案') || findButtonByExactText('查看错题') || findButtonByExactText('错题查看'); if (!reviewButton) { console.warn('[Script] 未找到“纠错查看 / 查看答案”按钮,改用上一轮答案组合请求 AI 修正。'); attemptRetryWithoutReview(); return; } console.log('[Script] 点击“纠错查看 / 查看答案”,进入纠错复核页提取错题。'); clickElement(reviewButton); waitForReviewPage().then(found => { if (!found) { console.warn('[Script] 未检测到纠错复核页,改用上一轮答案组合请求 AI 修正。'); attemptRetryWithoutReview(); return; } collectReviewAnswersAndCorrect(); }); } /** * 从纠错复核页提取错题与答对题目,把答对的答案保留进记忆, * 将错题回传 AI 获取修正答案,然后点击“返回”回到考试列表继续下一场。 */ function collectReviewAnswersAndCorrect() { const items = Array.from(document.querySelectorAll('.examination-body-item')); if (items.length === 0) { console.warn('[Script] 纠错复核页没有题目元素,改用上一轮答案组合请求 AI 修正。'); attemptRetryWithoutReview(); return; } const reviewQuestions = items.map((item, index) => { const dangerState = item.querySelector('.details-state.danger'); const isWrong = Boolean(dangerState && dangerState.innerText.includes('回答错误')); const title = getNormalizedQuestionTitle(item) || item.querySelector('.examination-body-title')?.innerText.trim() || `题目${index + 1}`; const options = Array.from(item.querySelectorAll('.examination-check-item')) .map(option => option.innerText.trim()) .filter(Boolean); const previousAnswer = item.querySelector('.examination-details em')?.innerText.trim() || ''; return { index: index + 1, title, options, previousAnswer, isWrong }; }); // 答对的题目直接保留其上次答案,避免重试时被 AI 改错。 reviewQuestions.forEach(question => { if (!question.isWrong && question.previousAnswer) { examAnswerMemory.set(question.title, normalizeAnswerLetters(question.previousAnswer)); } }); const wrongQuestions = reviewQuestions.filter(question => question.isWrong); if (wrongQuestions.length === 0) { console.log('[Script] 纠错复核页未标记“回答错误”的题目,直接返回考试列表。'); persistExamAnswerMemory(); returnToExamListAfterReview(); return; } console.log(`[Script] 发现 ${wrongQuestions.length} 道错题,请求 AI 修正答案...`); askAiForAnswer(buildCorrectionPrompt(wrongQuestions), EXAM_CORRECTION_SYSTEM_PROMPT).then(correctionText => { const correctedMap = parseAnswersToMap(correctionText); let applied = 0; wrongQuestions.forEach(question => { const letters = correctedMap.get(question.index); if (letters) { examAnswerMemory.set(question.title, letters); applied++; } else { console.warn(`[Script] AI 未返回第 ${question.index} 题的修正答案(原答案 ${question.previousAnswer || '无'} 不保留)。`); } }); persistExamAnswerMemory(); console.log(`[Script] AI 修正完成,已更新 ${applied} 道错题答案,返回考试列表继续作答。`); returnToExamListAfterReview(); }).catch(error => { console.warn('[Script] AI 纠错请求失败,放弃该场继续下一场:', error); recordFailedExamAndContinue(); }); } function buildCorrectionPrompt(wrongQuestions) { const lines = wrongQuestions.map(question => { const optionLines = question.options.map(option => ` ${option}`).join('\n'); return `第${question.index}题:${question.title}\n选项:\n${optionLines}\n上次答案:${question.previousAnswer || '未提供'}(单选:此选项必错;多选:组合有误)`; }); return [ '以下是本次考试中回答错误的题目。每题的上次答案均有问题:单选题勿再选上次选项,多选题请重新判断整个答案组合。', '', ...lines, '', '请按上述题目序号输出答案,格式:序号.字母选项(多选连续,如 2.ABC)。' ].join('\n'); } /** * 无纠错复核页时的修正提示词:把上一轮作答的答案组合发给 AI,指出组合有问题。 */ function buildNoReviewCorrectionPrompt() { const entries = Array.from(lastAttemptData.values()); const lines = entries.map((data, index) => { const optionLines = data.options.map(option => ` ${option}`).join('\n'); return `第${index + 1}题:${data.title}\n选项:\n${optionLines}\n上次答案:${data.answer}`; }); return [ '以下是刚刚提交但未及格的答案组合,请按规则修正(单选勿再选上次选项,多选重新判断整个组合)。', '', ...lines, '', '请按上述题目序号输出答案,格式:序号.字母选项(多选连续,如 2.ABC)。' ].join('\n'); } /** * 没有纠错复核页时的重试路径:把上一轮答案组合回传 AI 修正后, * 返回考试列表从“待考试”第一个重新作答。 */ function attemptRetryWithoutReview() { if (lastAttemptData.size === 0) { console.warn('[Script] 无纠错复核页且无上一轮作答记录,放弃该场继续下一场。'); recordFailedExamAndContinue(); return; } console.log(`[Script] 未找到纠错复核页,改用上一轮答案组合请求 AI 修正(共 ${lastAttemptData.size} 题)。`); askAiForAnswer(buildNoReviewCorrectionPrompt(), EXAM_NO_REVIEW_CORRECTION_SYSTEM_PROMPT).then(correctionText => { const correctedMap = parseAnswersToMap(correctionText); let applied = 0; Array.from(lastAttemptData.values()).forEach((data, index) => { const letters = correctedMap.get(index + 1); if (letters) { examAnswerMemory.set(data.title, letters); applied++; } else { console.warn(`[Script] AI 未返回第 ${index + 1} 题的修正答案(不保留旧组合,重试时由 AI 重答)。`); } }); persistExamAnswerMemory(); console.log(`[Script] 无复核页纠错完成,已更新 ${applied} 道题答案,返回考试列表继续作答。`); returnToExamListAfterReview(); }).catch(error => { console.warn('[Script] AI 纠错请求失败,放弃该场继续下一场:', error); recordFailedExamAndContinue(); }); } /** * 记录重试耗尽仍未通过的场次,跳过该场,返回考试列表继续处理其他考试。 */ function recordFailedExamAndContinue() { isSubmittingExam = false; isAiAnswerPending = false; const signature = lastStartedExamSignature || ''; const displayName = signature ? signature.split('\n')[0].trim().slice(0, 50) : '未知考试'; if (signature) exhaustedExamSignatures.add(signature); failedExamSummary.push({ name: displayName, attempts: examAttemptCount }); console.warn(`[Script] 已记录失败场次:${displayName}(重试 ${examAttemptCount} 次),继续处理其他考试。`); resetExamRetryState('考试重试耗尽,放弃该场'); returnToExamListAfterReview(); } /** * 全部考试处理完成后的提示(只提示一次,仅在存在失败场次时弹窗)。 */ function notifyExamBatchFinished() { if (examSummaryNotified) return; examSummaryNotified = true; if (failedExamSummary.length > 0) { const names = failedExamSummary.map(item => item.name || '未知考试').join('、'); console.warn(`[Script] 全部考试处理完成,${failedExamSummary.length} 场重试后仍未通过(平台可能要求重新学习):${names}`); alert(`所有考试处理完成。\n\n${failedExamSummary.length} 场考试多次重试后仍未通过,平台可能要求重新学习对应课程:\n${names}`); failedExamSummary = []; } else { console.log('[Script] 全部考试处理完成,本批次均已通过。'); } } /** * 复核页没有“重新考试”按钮:点击页面上的“返回”回到考试列表, * 由主循环从“待考试”第一个(即刚答错的那场)继续自动开始作答。 * 保持失败处理占位,直到离开复核视图后再放行。 */ function returnToExamListAfterReview() { // 保持 isHandlingExamResult 为 true,避免切换期间失败弹窗/复核页残留被重复处理。 isHandlingExamResult = true; const backButton = findButtonByExactText('返回') || findButtonByExactText('返回上一页') || findButtonByExactText('返回列表') || findButtonByExactText('重新考试') || // 兼容个别平台存在“重新考试”按钮的情况 findButtonByExactText('重新作答') || findButtonByExactText('重考'); if (backButton) { console.log('[Script] 点击“返回”,回到考试列表继续下一场作答。'); clickElement(backButton); } else { console.warn('[Script] 未找到“返回”按钮,直接导航回考试列表。'); window.location.href = currentExamListRoute; } currentQuestionBatchText = ''; // 确保新一轮题目会被重新处理 // 兜底:点击“返回”后若仍停留在考试页(例如回到结果页),强制返回考试列表。 setTimeout(() => { if (window.location.hash.toLowerCase().includes('/examination')) { console.log(`[Script] 点击“返回”后仍停留在考试页,直接导航回考试列表:${currentExamListRoute}`); window.location.href = currentExamListRoute; } }, 2500); const startedAt = Date.now(); const releaseTimer = setInterval(() => { const reviewVisible = Array.from(document.querySelectorAll('.examination-body-item .details-state')) .find(element => element.offsetParent !== null); const failureDialogVisible = hasFailedExamResult(); if ((!reviewVisible && !failureDialogVisible) || Date.now() - startedAt > 15000) { clearInterval(releaseTimer); isHandlingExamResult = false; if (reviewVisible || failureDialogVisible) { console.warn('[Script] 等待离开复核页超时,已放行失败处理(若失败弹窗仍存在将再次进入重试链)。'); } } }, 1000); } /** * 考试通过后的收尾:确保考试导航上下文, * 若平台未自动跳转则直接返回考试列表,让主循环继续开始下一场考试。 */ function proceedAfterExamPassed() { // 确保返回考试列表后仍按“考试”流程处理,而不是被当作课程流程跳回课程页。 GM_setValue('sclpa_nav_context', 'exam'); currentNavContext = 'exam'; setTimeout(() => { if (window.location.hash.toLowerCase().includes('/examination')) { console.log(`[Script] 考试结果页未自动跳转,直接返回考试列表:${currentExamListRoute}`); window.location.href = currentExamListRoute; } else { console.log('[Script] 考试完成后平台已自动跳转,主循环继续。'); } }, 2500); } /** * Intelligently determine if "unfinished" tab is active (compatible with professional and public courses) * @param {HTMLElement} tabElement - The tab element to check. * @returns {boolean} */ function isUnfinishedTabActive(tabElement) { if (!tabElement) return false; return tabElement.classList.contains('active-radio-tag') || tabElement.classList.contains('radio-tab-tag-ed'); } function getPublicCourseCategoryTabs() { return Array.from(document.querySelectorAll('.tabsList .radioBodx .radio-tab-tag')); } function getPublicCourseUnfinishedTab() { return Array.from(document.querySelectorAll('.tabsList .radio-box .radio-tab-tag')) .find(tab => tab.innerText.trim() === '未完成') || findElementByText('div.radio-tab-tag', '未完成'); } function schedulePublicCourseListAction(callback, delay) { if (publicCourseListActionPending) return; publicCourseListActionPending = true; setTimeout(() => { publicCourseListActionPending = false; callback(); }, delay); } function moveToNextPublicCourseCategory() { const publicTarget = GM_getValue('sclpa_public_target', 'video'); if (publicCourseTraversalTarget !== publicTarget) { publicCourseTraversalTarget = publicTarget; exhaustedPublicCourseCategories.clear(); } const categories = getPublicCourseCategoryTabs(); const currentIndex = categories.findIndex(tab => tab.classList.contains('radio-tab-tag-ed')); if (categories.length === 0 || currentIndex < 0) { console.warn('[Script] 未找到公需课分类标签,无法切换到下一分类。'); return false; } const currentCategory = categories[currentIndex].innerText.trim(); exhaustedPublicCourseCategories.add(`${publicTarget}:${currentCategory}`); const followingCategories = [ ...categories.slice(currentIndex + 1), ...categories.slice(0, currentIndex) ]; const nextCategory = followingCategories.find(tab => !exhaustedPublicCourseCategories.has(`${publicTarget}:${tab.innerText.trim()}`) ); if (!nextCategory) { console.log(`[Script] 公需课-${publicTarget} 的所有分类均未找到未完成内容,停止切换。`); return false; } console.log(`[Script] 当前分类“${currentCategory}”没有未完成内容,切换到“${nextCategory.innerText.trim()}”。`); clickElement(nextCategory); schedulePublicCourseListAction(() => handleCourseListPage('公需课'), 1500); return true; } // =================================================================================== // --- 全能托管 (All-in-one workflow) --- // =================================================================================== function getAllInOnePhase() { return ALL_IN_ONE_PHASES.find(phase => phase.id === allInOnePhase) || null; } function updateAllInOneButton() { const button = document.getElementById('nav-all-in-one-btn'); if (!button) return; const phase = getAllInOnePhase(); const text = isAllInOneMode && phase ? `全能托管中:${phase.label}` : '全能托管(按顺序完成全部)'; const textNode = button.querySelector('.nav-btn-text'); if (textNode) textNode.textContent = text; } function stopAllInOneMode(reason = '已停止') { if (!isAllInOneMode && !allInOnePhase) return; console.log(`[Script] 全能托管${reason}。`); isAllInOneMode = false; allInOnePhase = ''; allInOneTransitionPending = false; GM_setValue('sclpa_all_in_one_enabled', false); GM_setValue('sclpa_all_in_one_phase', ''); updateAllInOneButton(); } function navigateToAllInOnePhase() { const phase = getAllInOnePhase(); if (!isAllInOneMode || !phase) return false; allInOneTransitionPending = true; currentNavContext = phase.context; GM_setValue('sclpa_nav_context', phase.context); if (phase.publicTarget) GM_setValue('sclpa_public_target', phase.publicTarget); updateAllInOneButton(); console.log(`[Script] 全能托管进入阶段:${phase.label}。`); // 公需课视频与文章共用同一 SPA 路由;同路由切换时不能等待 hashchange。 const targetHash = phase.url.split('#')[1].toLowerCase(); if (window.location.hash.toLowerCase() === targetHash) { allInOneTransitionPending = false; return true; } window.location.href = phase.url; return true; } function startAllInOneMode() { isAllInOneMode = true; allInOnePhase = ALL_IN_ONE_PHASES[0].id; allInOneTransitionPending = false; publicCourseTraversalTarget = ''; exhaustedPublicCourseCategories.clear(); examSummaryNotified = false; GM_setValue('sclpa_all_in_one_enabled', true); GM_setValue('sclpa_all_in_one_phase', allInOnePhase); navigateToAllInOnePhase(); } /** * 仅当当前阶段正是 expectedPhase 时前进,避免列表尚在加载时误切换。 */ function advanceAllInOnePhaseIfExpected(expectedPhase) { if (!isAllInOneMode || allInOnePhase !== expectedPhase || allInOneTransitionPending) return false; const currentIndex = ALL_IN_ONE_PHASES.findIndex(phase => phase.id === expectedPhase); const nextPhase = ALL_IN_ONE_PHASES[currentIndex + 1]; if (!nextPhase) { stopAllInOneMode('所有阶段已完成'); alert('✅ 全能托管已完成:专业课视频、公需课视频、公需课文章及两类考试均已处理完毕。'); return true; } allInOnePhase = nextPhase.id; GM_setValue('sclpa_all_in_one_phase', allInOnePhase); publicCourseTraversalTarget = ''; exhaustedPublicCourseCategories.clear(); console.log(`[Script] 全能托管:${expectedPhase} 无待处理内容,切换到 ${nextPhase.label}。`); return navigateToAllInOnePhase(); } function handleAllInOneExamPhaseExhausted() { if (allInOnePhase === 'specialized-exam') { return advanceAllInOnePhaseIfExpected('specialized-exam'); } if (allInOnePhase === 'public-exam') { return advanceAllInOnePhaseIfExpected('public-exam'); } return false; } // =================================================================================== // --- UI面板管理 (UI Panel Management) --- // =================================================================================== /** * Create the modern script control panel with tabs */ function createModeSwitcherPanel() { if (isModePanelCreated) { console.log('[Script] Mode switcher panel already created, skipping.'); return; } isModePanelCreated = true; console.log('[Script] Attempting to create Modern Mode Switcher Panel...'); try { GM_addStyle(` /* Microsoft Fluent Design System - 微软流畅设计系统 */ #mode-switcher-panel { position: fixed; bottom: 20px; right: 20px; width: 400px; background: #FFFFFF; border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08); z-index: 10000; overflow: hidden; font-family: 'Segoe UI Variable', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; transition: all 0.2s ease; border: 1px solid rgba(0, 0, 0, 0.06); } #mode-switcher-panel:hover { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.1); } #mode-switcher-panel.collapsed { width: 240px; } /* Header - 标题栏 */ #mode-switcher-header { padding: 16px 20px; background: #F3F2F1; color: #323130; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(0, 0, 0, 0.06); } #mode-switcher-header h3 { margin: 0; font-size: 15px; font-weight: 600; display: flex; align-items: center; gap: 8px; letter-spacing: -0.01em; } #mode-switcher-toggle-collapse { background: transparent; border: none; color: #605E5C; font-size: 18px; cursor: pointer; padding: 4px 12px; border-radius: 4px; transition: all 0.15s ease; line-height: 1; } #mode-switcher-toggle-collapse:hover { background: rgba(0, 0, 0, 0.05); color: #323130; } /* Tabs - 标签页 */ #mode-switcher-tabs { display: flex; background: #FAFAFA; padding: 8px; gap: 4px; border-bottom: 1px solid rgba(0, 0, 0, 0.06); } .tab-btn { flex: 1; padding: 8px 12px; background: transparent; border: none; color: #605E5C; font-size: 13px; cursor: pointer; border-radius: 4px; transition: all 0.15s ease; font-weight: 500; font-family: inherit; } .tab-btn:hover { background: rgba(0, 0, 0, 0.04); color: #323130; } .tab-btn.active { background: #FFFFFF; color: #0078D4; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); font-weight: 600; } /* Content - 内容区域 */ #mode-switcher-content { padding: 20px; background: #FFFFFF; max-height: 480px; overflow-y: auto; max-height: 480px; } #mode-switcher-content::-webkit-scrollbar { width: 8px; } #mode-switcher-content::-webkit-scrollbar-track { background: #F3F2F1; } #mode-switcher-content::-webkit-scrollbar-thumb { background: #C8C8C8; border-radius: 4px; } #mode-switcher-content::-webkit-scrollbar-thumb:hover { background: #A8A8A8; } /* Tab Content Animation */ .tab-content { display: none; animation: fluentFadeIn 0.2s ease; } .tab-content.active { display: block; } @keyframes fluentFadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } /* Section Title */ .panel-section { margin-bottom: 24px; } .panel-section:last-child { margin-bottom: 0; } .section-title { font-size: 12px; color: #605E5C; margin-bottom: 12px; display: flex; align-items: center; gap: 8px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.02em; } /* Status Indicator */ .status-indicator { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 16px; background: #F3F2F1; border-radius: 6px; margin-bottom: 16px; border: 1px solid rgba(0, 0, 0, 0.04); } .status-dot { width: 10px; height: 10px; border-radius: 50%; animation: fluentPulse 2s infinite; } .status-dot.active { background: #107C10; box-shadow: 0 0 8px rgba(16, 124, 16, 0.4); } .status-dot.paused { background: #D13438; box-shadow: 0 0 8px rgba(209, 52, 56, 0.4); animation: none; } @keyframes fluentPulse { 0%, 100% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.15); opacity: 0.75; } } #status-text { font-size: 14px; font-weight: 500; color: #323130; } /* Primary Button */ .panel-btn { padding: 10px 20px; font-size: 14px; color: #FFFFFF; border: none; border-radius: 4px; cursor: pointer; transition: all 0.15s ease; width: 100%; box-sizing: border-box; font-weight: 600; font-family: inherit; letter-spacing: 0.01em; } .panel-btn:hover { transform: translateY(-1px); } .panel-btn:active { transform: translateY(0); } .service-btn-active { background: #107C10; } .service-btn-active:hover { background: #0B5C0B; } .service-btn-paused { background: #D13438; } .service-btn-paused:hover { background: #A80000; } #api-key-save-btn.panel-btn:hover { background: #106EBE !important; } /* Navigation Button */ .nav-btn { padding: 12px 16px; font-size: 13px; color: #323130; background: #FFFFFF; border: 1px solid #E1DFDD; border-radius: 4px; cursor: pointer; transition: all 0.15s ease; width: 100%; margin-bottom: 8px; font-weight: 500; display: flex; align-items: center; gap: 12px; font-family: inherit; } .nav-btn:last-child { margin-bottom: 0; } .nav-btn:hover { background: #F3F2F1; border-color: #0078D4; transform: translateX(4px); } .nav-btn-icon { font-size: 16px; width: 24px; text-align: center; } .nav-btn-text { flex: 1; text-align: left; } .nav-btn-arrow { opacity: 0; transition: all 0.15s ease; color: #0078D4; font-weight: 600; } .nav-btn:hover .nav-btn-arrow { opacity: 1; } /* Navigation Grid */ .nav-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } .nav-grid .nav-btn { margin-bottom: 0; } /* Setting Row */ .setting-row { margin-bottom: 20px; } .setting-row:last-child { margin-bottom: 0; } .setting-row label { display: block; margin-bottom: 8px; font-size: 13px; color: #323130; font-weight: 600; } /* Speed Slider */ .speed-slider-container { display: flex; align-items: center; gap: 16px; background: #F3F2F1; padding: 12px 16px; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.04); } .speed-slider-container input[type="range"] { flex: 1; height: 4px; border-radius: 2px; background: #E1DFDD; outline: none; -webkit-appearance: none; } .speed-slider-container input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #0078D4; cursor: pointer; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); transition: all 0.15s ease; } .speed-slider-container input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } #speed-display { font-weight: 700; font-size: 15px; color: #0078D4; min-width: 48px; text-align: center; letter-spacing: -0.01em; } /* API Key Input */ .api-key-input { width: 100%; padding: 10px 12px; border: 1px solid #E1DFDD; border-radius: 4px; box-sizing: border-box; font-size: 13px; transition: all 0.15s ease; font-family: inherit; color: #323130; } .api-key-input:focus { outline: none; border-color: #0078D4; box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2); } .api-key-status { margin-top: 8px; font-size: 12px; padding: 8px 12px; border-radius: 4px; display: flex; align-items: center; gap: 6px; font-weight: 500; } .api-key-status.configured { background: #DFF6DD; color: #0B5C0B; border: 1px solid #A7F0A3; } .api-key-status.not-configured { background: #FFF4CE; color: #8A6914; border: 1px solid #FCEFC4; } /* Divider */ .panel-divider { width: 100%; height: 1px; background: #E1DFDD; margin: 24px 0; } /* Tutorial Content */ .tutorial-content { background: #FAFAFA; padding: 16px; border-radius: 4px; border: 1px solid rgba(0, 0, 0, 0.04); } .tutorial-section { margin-bottom: 20px; } .tutorial-section:last-child { margin-bottom: 0; } .tutorial-section h4 { font-size: 13px; color: #0078D4; margin: 0 0 10px 0; display: flex; align-items: center; gap: 8px; font-weight: 600; } .tutorial-section ul { margin: 0; padding-left: 20px; color: #323130; font-size: 13px; line-height: 1.7; } .tutorial-section li { margin-bottom: 6px; } .tutorial-section li::marker { color: #0078D4; } .tutorial-warning { background: #FFF4CE; border-left: 3px solid #FFB900; padding: 12px; border-radius: 4px; margin-top: 16px; } .tutorial-warning strong { color: #8A6914; } .tutorial-link { color: #0078D4; text-decoration: none; font-weight: 500; } .tutorial-link:hover { text-decoration: underline; } /* Collapsed State */ #mode-switcher-panel.collapsed #mode-switcher-tabs, #mode-switcher-panel.collapsed #mode-switcher-content { display: none; } `); const panel = document.createElement('div'); panel.id = 'mode-switcher-panel'; panel.innerHTML = `

控制面板

服务运行中
快速导航
播放设置
${currentPlaybackRate}x
考试设置
考试未通过时自动提取错题回传 AI 修正并重新作答;设为 0 则失败后转人工复核。
AI 设置
⚠️ 未配置 API Key

快速开始

  • 安装脚本后,屏幕右下角会出现控制面板
  • 点击相应按钮可快速跳转到不同学习模块
  • 开启服务后,脚本将自动完成刷课任务
  • 视频默认16倍速静音播放

AI 助手

  • 在使用AI答题功能前,需先设置 DeepSeek API Key
  • 在"设置"标签页中输入您的 API Key 并保存
  • 获取 API Key:点击此处
  • AI会自动处理考试题目并选择答案

功能说明

  • 专业课程:自动播放视频课程,支持多章节切换
  • 公需课-视频:自动播放视频,支持静音倍速
  • 公需课-文章:自动计时,标记已读状态
  • 考试:AI自动答题(需配置API Key)

视频倍速技术

  • 增强倍速引擎:采用多重防护机制,确保倍速稳定生效
  • 自动检测:支持主文档、iframe和Shadow DOM中的视频
  • 实时监控:每秒检查并修正倍速设置
  • 防护机制:阻止网页重置playbackRate属性
  • 智能重试:自动适应视频加载和切换场景
注意事项:
  • 请保持刷课页面始终处于前台
  • 不要折叠控制面板
  • AI答题不能保证100%正确率
  • 考试未通过时脚本会自动提取错题回传AI修正并重新作答(可在“设置”调整重试次数)

获取帮助

`; if (document.body) { document.body.appendChild(panel); console.log('[Script] Modern Mode Switcher Panel appended to body.'); } else { console.error('[Script Error] document.body is not available when trying to append Mode Switcher Panel.'); isModePanelCreated = false; return; } // Tab switching functionality const tabBtns = document.querySelectorAll('.tab-btn'); const tabContents = document.querySelectorAll('.tab-content'); tabBtns.forEach(btn => { btn.onclick = () => { const targetTab = btn.dataset.tab; tabBtns.forEach(b => b.classList.remove('active')); tabContents.forEach(c => c.classList.remove('active')); btn.classList.add('active'); document.getElementById(`tab-${targetTab}`).classList.add('active'); }; }); // Service toggle const serviceBtn = document.getElementById('service-toggle-btn'); const statusDot = document.getElementById('status-dot'); const statusText = document.getElementById('status-text'); const updateServiceButton = (isActive) => { if (serviceBtn) { serviceBtn.innerText = isActive ? '⏸️ 暂停服务' : '▶️ 启动服务'; serviceBtn.className = 'panel-btn ' + (isActive ? 'service-btn-active' : 'service-btn-paused'); } if (statusDot) { statusDot.className = 'status-dot ' + (isActive ? 'active' : 'paused'); } if (statusText) { statusText.innerText = isActive ? '服务运行中' : '服务已暂停'; } }; updateServiceButton(isServiceActive); if (serviceBtn) { serviceBtn.onclick = () => { isServiceActive = !isServiceActive; GM_setValue('sclpa_service_active', isServiceActive); window.location.reload(); }; } // Speed slider const speedSlider = document.getElementById('speed-slider'); const speedDisplay = document.getElementById('speed-display'); if (speedSlider) { speedSlider.addEventListener('input', () => { if (speedDisplay) speedDisplay.textContent = `${speedSlider.value}x`; }); speedSlider.addEventListener('change', () => { const newRate = parseFloat(speedSlider.value); GM_setValue('sclpa_playback_rate', newRate); console.log(`[Script] 播放倍速设置为: ${newRate}x,立即应用到所有视频...`); applyCurrentVideoSpeed(); if (!runtimeUiState.speedChangeAlertShown) { setTimeout(() => { alert(`✅ 播放倍速已更新为 ${newRate}x,并立即应用到当前页面!\n\n💡 如需在其他页面生效,刷新页面即可。`); runtimeUiState.speedChangeAlertShown = true; GM_setValue('sclpa_speed_alert_shown', true); }, 100); } }); } // Exam auto-retry setting (0 = disabled, fall back to manual review) const examRetryInput = document.getElementById('exam-retry-input'); if (examRetryInput) { examRetryInput.addEventListener('change', () => { const value = Math.max(0, Math.min(5, parseInt(examRetryInput.value, 10) || 0)); examRetryInput.value = value; GM_setValue('sclpa_exam_max_retries', value); console.log(`[Script] 考试失败自动重试次数设置为: ${value}`); }); } // API Key const apiKeyInput = document.getElementById('api-key-input'); const apiKeyStatus = document.getElementById('api-key-status'); const apiKeySaveBtn = document.getElementById('api-key-save-btn'); // Load current API key const currentKey = GM_getValue('sclpa_deepseek_api_key', ''); if (apiKeyInput) { apiKeyInput.value = currentKey; } if (apiKeyStatus && currentKey) { apiKeyStatus.className = 'api-key-status configured'; apiKeyStatus.innerHTML = '✅ API Key 已配置'; } if (apiKeySaveBtn && apiKeyInput) { apiKeySaveBtn.onclick = () => { const newKey = apiKeyInput.value.trim(); if (newKey) { GM_setValue('sclpa_deepseek_api_key', newKey); CONFIG.AI_API_SETTINGS.API_KEY = newKey; if (apiKeyStatus) { apiKeyStatus.className = 'api-key-status configured'; apiKeyStatus.innerHTML = '✅ API Key 已保存!'; } setTimeout(() => { alert('API Key 已保存!下次页面加载时生效。'); }, 100); } else { if (apiKeyStatus) { apiKeyStatus.className = 'api-key-status not-configured'; apiKeyStatus.innerHTML = '⚠️ 请输入有效的 API Key'; } } }; } // Navigation buttons const navAllInOneBtn = document.getElementById('nav-all-in-one-btn'); const navSpecializedBtn = document.getElementById('nav-specialized-btn'); const navPublicVideoBtn = document.getElementById('nav-public-video-btn'); const navPublicArticleBtn = document.getElementById('nav-public-article-btn'); const navSpecializedExamBtn = document.getElementById('nav-specialized-exam-btn'); const navPublicExamBtn = document.getElementById('nav-public-exam-btn'); const collapseBtn = document.getElementById('mode-switcher-toggle-collapse'); if (collapseBtn) { collapseBtn.onclick = () => { if (panel) panel.classList.toggle('collapsed'); if (collapseBtn && panel) collapseBtn.innerText = panel.classList.contains('collapsed') ? '+' : '-'; }; } if (navAllInOneBtn) { updateAllInOneButton(); navAllInOneBtn.onclick = () => startAllInOneMode(); } if (navSpecializedBtn) { navSpecializedBtn.onclick = () => { stopAllInOneMode('已因手动切换到专业课程而停止'); GM_setValue('sclpa_nav_context', 'course'); window.location.href = 'https://zyys.ihehang.com/#/specialized'; }; } if (navPublicVideoBtn) { navPublicVideoBtn.onclick = () => { stopAllInOneMode('已因手动切换到公需课视频而停止'); GM_setValue('sclpa_public_target', 'video'); GM_setValue('sclpa_nav_context', 'course'); window.location.href = 'https://zyys.ihehang.com/#/publicDemand'; }; } if (navPublicArticleBtn) { navPublicArticleBtn.onclick = () => { stopAllInOneMode('已因手动切换到公需课文章而停止'); GM_setValue('sclpa_public_target', 'article'); GM_setValue('sclpa_nav_context', 'course'); window.location.href = 'https://zyys.ihehang.com/#/publicDemand'; }; } if (navSpecializedExamBtn) { navSpecializedExamBtn.onclick = () => { stopAllInOneMode('已因手动切换到专业课考试而停止'); GM_setValue('sclpa_nav_context', 'exam'); window.location.href = 'https://zyys.ihehang.com/#/onlineExam'; }; } if (navPublicExamBtn) { navPublicExamBtn.onclick = () => { stopAllInOneMode('已因手动切换到公需课考试而停止'); GM_setValue('sclpa_nav_context', 'exam'); window.location.href = 'https://zyys.ihehang.com/#/openOnlineExam'; }; } if (panel && document.getElementById('mode-switcher-header')) { makeDraggable(panel, document.getElementById('mode-switcher-header')); } console.log('[Script] Modern Mode Switcher Panel creation attempted and event listeners attached.'); } catch (e) { console.error('[Script Error] Error creating Modern Mode Switcher Panel:', e); isModePanelCreated = false; } } /** * Create AI helper panel, ensuring it's always new */ /** * Create modern AI helper panel */ function createManualAiHelper() { const existingPanel = document.getElementById('ai-helper-panel'); if (existingPanel) { existingPanel.remove(); console.log('[Script] Removed existing AI helper panel.'); } console.log('[Script] Attempting to create Modern AI Helper Panel...'); try { GM_addStyle(` /* AI Helper Panel - Fluent Design */ #ai-helper-panel { position: fixed; bottom: 20px; right: 420px; width: 400px; max-width: 90vw; background: #FFFFFF; border-radius: 8px; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(0, 0, 0, 0.08); z-index: 99999; font-family: 'Segoe UI Variable', 'Segoe UI', -apple-system, BlinkMacSystemFont, sans-serif; display: flex; flex-direction: column; overflow: hidden; transition: all 0.2s ease; border: 1px solid rgba(0, 0, 0, 0.06); } #ai-helper-panel:hover { box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16), 0 4px 12px rgba(0, 0, 0, 0.1); } #ai-helper-header { padding: 14px 20px; background: #F3F2F1; color: #323130; font-weight: 600; cursor: move; user-select: none; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid rgba(0, 0, 0, 0.06); } #ai-helper-header h3 { margin: 0; font-size: 14px; display: flex; align-items: center; gap: 10px; letter-spacing: -0.01em; } #ai-helper-close-btn { background: transparent; border: none; color: #605E5C; font-size: 18px; cursor: pointer; padding: 4px 12px; border-radius: 4px; transition: all 0.15s ease; line-height: 1; } #ai-helper-close-btn:hover { background: rgba(0, 0, 0, 0.05); color: #323130; } #ai-helper-content { padding: 20px; background: #FFFFFF; display: flex; flex-direction: column; gap: 16px; } #ai-helper-textarea { width: 100%; box-sizing: border-box; height: 120px; padding: 12px; border: 1px solid #E1DFDD; border-radius: 4px; resize: vertical; font-size: 14px; transition: all 0.15s ease; font-family: inherit; color: #323130; line-height: 1.5; } #ai-helper-textarea:focus { outline: none; border-color: #0078D4; box-shadow: 0 0 0 2px rgba(0, 120, 212, 0.2); } #ai-helper-textarea::placeholder { color: #A19F9D; } #ai-helper-submit-btn { padding: 12px 24px; background: #0078D4; color: #FFFFFF; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600; transition: all 0.15s ease; display: flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit; letter-spacing: 0.01em; } #ai-helper-submit-btn:hover { background: #106EBE; transform: translateY(-1px); } #ai-helper-submit-btn:active { transform: translateY(0); } #ai-helper-submit-btn:disabled { background: #E1DFDD; color: #A19F9D; cursor: not-allowed; transform: none; } #ai-helper-result { padding: 14px; background: #F3F2F1; border-radius: 4px; min-height: 80px; max-height: 250px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; border: 1px solid rgba(0, 0, 0, 0.04); } #ai-helper-result::-webkit-scrollbar { width: 8px; } #ai-helper-result::-webkit-scrollbar-track { background: #F3F2F1; } #ai-helper-result::-webkit-scrollbar-thumb { background: #C8C8C8; border-radius: 4px; } #ai-helper-result::-webkit-scrollbar-thumb:hover { background: #A8A8A8; } #ai-key-warning { color: #8A6914; font-size: 13px; padding: 12px; background: #FFF4CE; border-radius: 4px; display: flex; align-items: flex-start; gap: 8px; border: 1px solid #FCEFC4; line-height: 1.5; } .ai-thinking { display: flex; align-items: center; gap: 12px; color: #0078D4; } .ai-thinking-dot { display: flex; gap: 4px; } .ai-thinking-dot span { width: 8px; height: 8px; background: #0078D4; border-radius: 50%; animation: fluentBounce 1.4s infinite ease-in-out both; } .ai-thinking-dot span:nth-child(1) { animation-delay: -0.32s; } .ai-thinking-dot span:nth-child(2) { animation-delay: -0.16s; } @keyframes fluentBounce { 0%, 80%, 100% { transform: scale(0); } 40% { transform: scale(1); } } .ai-result-label { font-size: 12px; color: #605E5C; margin-bottom: 8px; font-weight: 600; display: flex; align-items: center; gap: 6px; text-transform: uppercase; letter-spacing: 0.02em; } `); const panel = document.createElement('div'); panel.id = 'ai-helper-panel'; panel.innerHTML = `

🤖 AI 问答助手

💬 AI 回答:
请在上方输入您的问题...
`; if (document.body) { document.body.appendChild(panel); console.log('[Script] Modern AI Helper Panel appended to body.'); } else { console.error('[Script Error] document.body is not available when trying to append AI Helper Panel.'); return; } // Get elements const submitBtn = document.getElementById('ai-helper-submit-btn'); const closeBtn = document.getElementById('ai-helper-close-btn'); const textarea = document.getElementById('ai-helper-textarea'); const resultDiv = document.getElementById('ai-helper-result'); const keyWarning = document.getElementById('ai-key-warning'); // Check API Key status const isApiKeyConfigured = CONFIG.AI_API_SETTINGS.API_KEY && CONFIG.AI_API_SETTINGS.API_KEY !== '请在此处填入您自己的 DeepSeek API Key'; if (keyWarning && submitBtn) { if (!isApiKeyConfigured) { keyWarning.style.display = 'block'; submitBtn.disabled = true; } } if (closeBtn) { closeBtn.onclick = () => { if (panel) panel.remove(); }; } if (submitBtn && textarea && resultDiv) { submitBtn.onclick = async () => { const question = textarea.value.trim(); if (!question) { resultDiv.innerHTML = '❌ 错误:问题不能为空!'; return; } if (!isApiKeyConfigured) { resultDiv.innerHTML = '❌ 错误:请先在控制面板中设置您的 DeepSeek API Key!'; return; } submitBtn.disabled = true; submitBtn.innerHTML = 'AI思考中...'; resultDiv.innerHTML = '
正在向AI发送请求...
'; try { const answer = await askAiForAnswer(question); resultDiv.innerHTML = `
✅ 已获取答案
${answer}
`; } catch (error) { resultDiv.innerHTML = `❌ 请求失败:${error}`; } finally { submitBtn.disabled = false; submitBtn.innerHTML = '🚀向 AI 提问'; } }; } if (panel && document.getElementById('ai-helper-header')) { makeDraggable(panel, document.getElementById('ai-helper-header')); } console.log('[Script] Modern AI Helper Panel creation attempted and event listeners attached.'); } catch (e) { console.error('[Script Error] Error creating Modern AI Helper Panel:', e); } } /** * Make UI panel draggable * @param {HTMLElement} panel - The panel element to be dragged. * @param {HTMLElement} header - The header element that acts as the drag handle. */ function makeDraggable(panel, header) { let isDragging = false, offsetX, offsetY; header.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return; isDragging = true; if (panel.style.bottom || panel.style.right) { const rect = panel.getBoundingClientRect(); panel.style.top = `${rect.top}px`; panel.style.left = `${rect.left}px`; panel.style.bottom = ''; panel.style.right = ''; } offsetX = e.clientX - parseFloat(panel.style.left); offsetY = e.clientY - parseFloat(panel.style.top); header.style.cursor = 'grabbing'; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; const newX = e.clientX - offsetX; const newY = e.clientY - offsetY; panel.style.left = `${newX}px`; panel.style.top = `${newY}px`; }); document.addEventListener('mouseup', () => { if (isDragging) { isDragging = false; header.style.cursor = 'move'; document.body.style.userSelect = ''; } }); } // =================================================================================== // --- AI 调用 (AI Invocation) --- // =================================================================================== /** * Send request to DeepSeek AI and get answer * @param {string} question - User's question * @returns {Promise} */ function askAiForAnswer(question, systemPromptOverride) { return new Promise((resolve, reject) => { if (!CONFIG.AI_API_SETTINGS.API_KEY || CONFIG.AI_API_SETTINGS.API_KEY === '请在此处填入您自己的 DeepSeek API Key') { reject('API Key 未设置或不正确,请在控制面板中设置!'); return; } const systemPrompt = systemPromptOverride || '你是一个乐于助人的问题回答助手。聚焦于执业药师相关的内容,请根据用户提出的问题,提供准确、清晰的解答。注意回答时仅仅包括答案,不允许其他额外任何解释,输出为一行一道题目的答案,答案只能是题目序号:字母选项,不能包含文字内容。单选输出示例:1.A。多选输出示例:1.ABC。'; const payload = { model: "deepseek-v4-flash", messages: [{ "role": "system", "content": systemPrompt }, { "role": "user", "content": question }], temperature: 0.2, thinking: {"type": "disabled"} }; GM_xmlhttpRequest({ method: 'POST', url: CONFIG.AI_API_SETTINGS.DEEPSEEK_API_URL, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CONFIG.AI_API_SETTINGS.API_KEY}` }, data: JSON.stringify(payload), timeout: 20000, onload: (response) => { try { const result = JSON.parse(response.responseText); if (result.choices && result.choices.length > 0) { resolve(result.choices[0].message.content.trim()); } else { reject('AI响应格式不正确。'); } } catch (e) { reject(`解析AI响应失败: ${e.message}`); } }, onerror: (err) => reject(`请求AI API网络错误: ${err.statusText || '未知错误'}`), ontimeout: () => reject('请求AI API超时') }); }); } // =================================================================================== // --- 页面逻辑处理 (Page-Specific Logic) --- // =================================================================================== /** * Handle course list page, compatible with video and article * @param {string} courseType - '专业课' or '公需课'. */ function handleCourseListPage(courseType) { if (!isServiceActive) return; console.log(`[Script] handleCourseListPage called for ${courseType}.`); // Handle public course tab switching first if (courseType === '公需课') { const publicTarget = GM_getValue('sclpa_public_target', 'video'); const targetTabText = publicTarget === 'article' ? '文章资讯' : '视频课程'; const targetTab = findElementByText('.radioTab > .radio-tab-tag', targetTabText); if (targetTab && !targetTab.classList.contains('radio-tab-tag-ed')) { console.log(`[Script] Public Course: Target is ${targetTabText}, switching tab...`); clickElement(targetTab); schedulePublicCourseListAction(() => handleCourseListPage(courseType), 1000); return; } } const unfinishedTab = courseType === '公需课' ? getPublicCourseUnfinishedTab() : findElementByText('div.radio-tab-tag', '未完成'); // Step 1: Click "未完成" tab if not already active // Removed `!unfinishedTabClicked` to ensure it keeps trying to click until active if (unfinishedTab && !isUnfinishedTabActive(unfinishedTab)) { console.log('[Script] Course List: Found "未完成" tab and it is not active, clicking it...'); clickElement(unfinishedTab); // Set unfinishedTabClicked to true only after a successful click attempt // This flag is reset by mainLoop when hash changes to a list page. unfinishedTabClicked = true; // After clicking, wait for the page to filter/load the unfinished list const continueAfterFilter = () => { console.log('[Script] Course List: Waiting after clicking "未完成" tab, then re-evaluating...'); handleCourseListPage(courseType); }; if (courseType === '公需课') { schedulePublicCourseListAction(continueAfterFilter, 3000); } else { setTimeout(continueAfterFilter, 3000); } return; // Crucial to prevent immediate fall-through to course finding } // Step 2: If "未完成" tab is active, proceed to find and click the first unfinished course. // This block will only execute if the tab is truly active. if (unfinishedTab && isUnfinishedTabActive(unfinishedTab)) { const findAndEnterCourse = () => { let targetCourseElement = Array.from(document.querySelectorAll('.play-card')).find(card => !card.querySelector('.el-icon-success') && !card.innerText.includes('已完成') ); if (!targetCourseElement) { // Fallback for article cards if play-card not found (for public courses) const allArticles = document.querySelectorAll('.information-card'); for (const article of allArticles) { const statusTag = article.querySelector('.status'); if (statusTag && statusTag.innerText.trim() === '未完成') { targetCourseElement = article; break; } } } if (targetCourseElement) { if (courseType === '公需课') { const publicTarget = GM_getValue('sclpa_public_target', 'video'); const activeCategory = getPublicCourseCategoryTabs().find(tab => tab.classList.contains('radio-tab-tag-ed')); if (activeCategory) exhaustedPublicCourseCategories.delete(`${publicTarget}:${activeCategory.innerText.trim()}`); } console.log(`[Script] ${courseType}: Found the first unfinished item, clicking to enter study...`); const clickableElement = targetCourseElement.querySelector('.play-card-box-right-text') || targetCourseElement; clickElement(clickableElement); } else { console.log(`[Script] ${courseType}: No unfinished items found on "未完成" page. All courses might be completed or elements not yet loaded.`); if (courseType === '公需课') { const moved = moveToNextPublicCourseCategory(); if (!moved) { const publicTarget = GM_getValue('sclpa_public_target', 'video'); advanceAllInOnePhaseIfExpected(publicTarget === 'article' ? 'public-article' : 'public-video'); } } else { advanceAllInOnePhaseIfExpected('specialized-video'); } } }; if (courseType === '公需课') { schedulePublicCourseListAction(findAndEnterCourse, 1500); } else { setTimeout(findAndEnterCourse, 1500); } } } /** * Main handler for learning page */ function handleLearningPage() { if (!isServiceActive) return; console.log('[Script] handleLearningPage called.'); if (!isVideoSpeedEngineInitialized) { initializeEnhancedVideoSpeedEngine(); } const directoryItems = document.querySelectorAll('.catalogue-item'); if (directoryItems.length > 0) { handleMultiChapterCourse(directoryItems); } else { const video = document.querySelector('video'); if (video) { handleSingleMediaCourse(video); } else { handleArticleReadingPage(); } } } /** * [FIXED] Handle multi-chapter courses (professional courses) * @param {NodeListOf} directoryItems */ function handleMultiChapterCourse(directoryItems) { if (isChangingChapter) return; console.log('[Script] handleMultiChapterCourse called.'); const video = document.querySelector('video'); // [FIX] Ensure video object exists before proceeding if (!video) { console.log('[Script] Video element not found, waiting...'); return; } // [FIX] Always set playbackRate and muted properties if video exists. // This ensures the speed is applied even if the video is currently paused. video.playbackRate = CONFIG.VIDEO_PLAYBACK_RATE; video.muted = true; // If video is playing, we've done our job for this cycle. if (!video.paused) { return; } // Logic to find the next unfinished chapter let nextChapter = null; for (const item of directoryItems) { if (!item.querySelector('.el-icon-success')) { nextChapter = item; break; } } if (nextChapter) { const isAlreadySelected = nextChapter.classList.contains('catalogue-item-ed'); if (isAlreadySelected) { // If it's the correct chapter but paused console.log('[Script] Current chapter is correct but video is paused, attempting to play.'); video.play().catch(e => { console.error('[Script Error] Failed to play video:', e); }); } else { // If we need to switch to the next chapter console.log('[Script] Moving to next chapter:', nextChapter.innerText.trim()); clickElement(nextChapter); isChangingChapter = true; setTimeout(() => { isChangingChapter = false; }, 4000); // Give time for chapter to load } } else { // All chapters have the success icon. The main loop will now handle navigation via handleMajorPlayerPage. console.log('[Script] All chapters appear to be complete. The main loop will verify and navigate.'); } } /** * [FIXED] Handle single media courses (public courses) * @param {HTMLVideoElement} video */ function handleSingleMediaCourse(video) { console.log('[Script] handleSingleMediaCourse called.'); if (!video.dataset.singleVidControlled) { video.addEventListener('ended', safeNavigateAfterCourseCompletion); video.dataset.singleVidControlled = 'true'; console.log('[Script] Added "ended" event listener for single media course.'); } // [FIX] Always set playbackRate and muted properties. video.playbackRate = CONFIG.VIDEO_PLAYBACK_RATE; video.muted = true; if (video.paused) { console.log('[Script] Single media video paused, attempting to play.'); video.play().catch(e => { console.error('[Script Error] Failed to play single media video:', e); }); } } /** * Handle article reading page */ function handleArticleReadingPage() { console.log('[Script] handleArticleReadingPage called.'); const progressLabel = document.querySelector('.action-btn .label'); if (progressLabel && (progressLabel.innerText.includes('100') || progressLabel.innerText.includes('待考试'))) { console.log('[Script] Article study completed, preparing to return to list.'); safeNavigateAfterCourseCompletion(); } else { console.log('[Script] Article progress not yet 100% or "待考试".'); } } /** * Handle exam page (where the actual questions are displayed) * Automatically copies question to AI helper and processes the AI answer. */ function handleExamPage() { if (!isServiceActive) return; // Only run if service is active console.log('[Script] handleExamPage called.'); currentNavContext = GM_getValue('sclpa_nav_context', ''); // Ensure context is fresh if (currentNavContext === 'course') { console.log('[Script] Current navigation context is "course". Ignoring exam automation and navigating back to course list.'); safeNavigateBackToList(); return; } if (isSubmittingExam) { console.log('[Script] Exam submission in progress, deferring AI processing.'); return; } if (!document.getElementById('ai-helper-panel')) { createManualAiHelper(); setTimeout(() => { triggerAiQuestionAndProcessAnswer(); }, 500); } else { triggerAiQuestionAndProcessAnswer(); } } /** * Gathers all questions and options from the current exam page, * sends them to AI, and waits for the response to select answers. */ async function triggerAiQuestionAndProcessAnswer() { const examinationItems = document.querySelectorAll('.examination-body-item'); if (examinationItems.length === 0) { console.log('[Script] No examination items found. Cannot trigger AI.'); return; } // 纠错复核视图(含错题标记)不是作答页面,不自动作答。 const visibleReviewState = Array.from(document.querySelectorAll('.examination-body-item .details-state')) .find(element => element.offsetParent !== null); if (visibleReviewState) { console.log('[Script] Current view is the correction review page, skipping auto answering.'); return; } let fullQuestionBatchContent = ''; examinationItems.forEach(item => { fullQuestionBatchContent += item.innerText.trim() + '\n\n'; // Concatenate all questions }); // Only process if the batch of questions has changed and AI answer is not pending if (!fullQuestionBatchContent || fullQuestionBatchContent === currentQuestionBatchText || isAiAnswerPending) { if (isAiAnswerPending) { console.log('[Script] AI answer already pending for current question batch, skipping new query.'); } else if (fullQuestionBatchContent === currentQuestionBatchText) { console.log('[Script] Question batch content has not changed, skipping AI query.'); } return; } // 自动重试轮次:若本页所有题目都有记忆答案,直接作答,不再请求 AI。 if (examAnswerMemory.size > 0) { const items = Array.from(examinationItems); const rememberedAnswers = items.map(item => getRememberedAnswerForItem(item)); if (rememberedAnswers.length > 0 && rememberedAnswers.every(answer => answer)) { console.log('[Script] Using remembered answers to answer current page directly (auto retry round).'); currentQuestionBatchText = fullQuestionBatchContent; items.forEach(item => selectAnswersForItem(item, getRememberedAnswerForItem(item))); setTimeout(() => { handleNextQuestionOrSubmitExam(); }, 1000); return; } } const aiHelperTextarea = document.getElementById('ai-helper-textarea'); const aiHelperSubmitBtn = document.getElementById('ai-helper-submit-btn'); const aiHelperResultDiv = document.getElementById('ai-helper-result'); if (!aiHelperTextarea || !aiHelperSubmitBtn || !aiHelperResultDiv) { console.log('[Script] AI helper elements missing. Cannot trigger AI.'); return; } currentQuestionBatchText = fullQuestionBatchContent; // Update current batch text aiHelperTextarea.value = fullQuestionBatchContent; // Set textarea value with all questions aiHelperResultDiv.innerText = '正在向AI发送请求...'; console.log('[Script] New batch of exam questions copied to AI helper textarea, triggering AI query...'); isAiAnswerPending = true; clickElement(aiHelperSubmitBtn); let attempts = 0; const maxAttempts = 300; // Max 300 attempts * 500ms = 60 seconds const checkInterval = 500; const checkAiResult = setInterval(() => { if (aiHelperResultDiv.innerText.trim() && aiHelperResultDiv.innerText.trim() !== '正在向AI发送请求...' && aiHelperResultDiv.innerText.trim() !== '请先提问...') { clearInterval(checkAiResult); isAiAnswerPending = false; console.log('[Script] AI response received:', aiHelperResultDiv.innerText.trim()); parseAndSelectAllAnswers(aiHelperResultDiv.innerText.trim()); // Call new function to handle all answers setTimeout(() => { handleNextQuestionOrSubmitExam(); // After all answers are selected, move to next step }, 1000); } else if (attempts >= maxAttempts) { clearInterval(checkAiResult); isAiAnswerPending = false; console.log('[Script] Timeout waiting for AI response for question batch.'); aiHelperResultDiv.innerText = 'AI请求超时,请手动重试。'; setTimeout(() => { handleNextQuestionOrSubmitExam(); }, 1000); } attempts++; }, checkInterval); } /** * 为单个题目按答案字母(如 "ABC")点击对应选项。 * 同时记录本轮作答的答案组合,用于没有纠错复核页时回传 AI 修正。 */ function selectAnswersForItem(item, answerLetters) { for (const letter of String(answerLetters || '')) { const optionText = `${letter}.`; // Find options specific to this question item const optionElement = Array.from(item.querySelectorAll('.examination-check-item')).find(el => el.innerText.trim().startsWith(optionText) ); if (optionElement) { console.log(`[Script] Selecting option: ${letter}`); clickElement(optionElement); } else { console.warn(`[Script] Option '${letter}' not found using text '${optionText}'.`); } } const answerText = String(answerLetters || '').toUpperCase(); if (!answerText) return; const title = getNormalizedQuestionTitle(item) || item.querySelector('.examination-body-title')?.innerText.trim() || ''; if (!title) return; const options = Array.from(item.querySelectorAll('.examination-check-item')) .map(option => option.innerText.trim()) .filter(Boolean); lastAttemptData.set(title, { answer: answerText, options, title }); } /** * Parses the AI response and automatically selects the corresponding options for all questions on the exam page. * 重试轮次中,记忆答案优先于 AI 新答案,避免已确认答对的题被 AI 改错。 * @param {string} aiResponse - The raw response string from the AI (e.g., "1.A\n2.BC\n3.D"). */ function parseAndSelectAllAnswers(aiResponse) { const aiAnswerLines = aiResponse.split('\n').map(line => line.trim()).filter(line => line.length > 0); const examinationItems = document.querySelectorAll('.examination-body-item'); const aiAnswersMap = new Map(); // Map to store {questionNumber: answerLetters} aiAnswerLines.forEach(line => { const parts = line.split('.'); if (parts.length >= 2) { const qNum = parseInt(parts[0]); const ansLetters = parts[1].toUpperCase(); if (!isNaN(qNum) && ansLetters) { aiAnswersMap.set(qNum, ansLetters); } else { console.warn(`[Script] Invalid AI response line format or content: ${line}`); } } else { console.warn(`[Script] Invalid AI response line format: ${line}`); } }); examinationItems.forEach(item => { const questionTitleElement = item.querySelector('.examination-body-title'); if (questionTitleElement) { const rememberedAnswer = getRememberedAnswerForItem(item); const match = questionTitleElement.innerText.trim().match(/^(\d+)、/); const questionNumber = match ? parseInt(match[1]) : null; if (rememberedAnswer) { console.log(`[Script] Using remembered answer for Q${questionNumber}: ${rememberedAnswer}`); selectAnswersForItem(item, rememberedAnswer); } else if (questionNumber !== null && aiAnswersMap.has(questionNumber)) { const answerLetters = aiAnswersMap.get(questionNumber); console.log(`[Script] Processing Q${questionNumber}: Selecting options ${answerLetters}`); selectAnswersForItem(item, answerLetters); } else if (questionNumber === null) { console.warn('[Script] Could not extract question number from item:', item.innerText.trim().substring(0, 50) + '...'); } else { console.log(`[Script] No AI answer found for Q${questionNumber} in AI response. Skipping.`); } } }); console.log('[Script] Finished parsing and selecting all answers on current page.'); } /** * Handles navigation after answering a question: either to the next question or submits the exam. */ function handleNextQuestionOrSubmitExam() { if (!isServiceActive || isSubmittingExam) { console.log('[Script] Service inactive or exam submission in progress, deferring next step.'); return; } console.log('[Script] handleNextQuestionOrSubmitExam called.'); // First, try to find the "下一题" button const nextQuestionButton = findElementByText('button span', '下一题'); if (nextQuestionButton) { console.log('[Script] Found "下一题" button, clicking it...'); clickElement(nextQuestionButton.closest('button')); // After clicking "下一题", the page should load the next question batch. // mainLoop will detect hash change and re-trigger handleExamPage, // or if on the same hash but content changed, triggerAiQuestionAndProcessAnswer will detect new questions. // Reset question batch text to ensure new questions are processed currentQuestionBatchText = ''; } else { // If "下一题" not found, try to find "提交试卷" const submitExamButton = findElementByText('button.submit-btn span', '提交试卷'); if (submitExamButton) { console.log('[Script] "下一题" not found. Found "提交试卷" button, clicking it...'); isSubmittingExam = true; clickElement(submitExamButton.closest('button')); let resultChecks = 0; const resultCheckTimer = setInterval(() => { if (hasFailedExamResult()) { clearInterval(resultCheckTimer); handleFailedExamResult(); return; } // 若结果提示已出现但并非“未通过”,视为通过:确认完成、重置重试状态并自动返回列表继续下一场。 const resultTip = document.querySelector('.result-tip-content'); if (resultTip && isElementVisible(resultTip) && !resultTipTextIsFailure(resultTip.innerText) && /考试通过|考试合格|成绩合格|通过考试|及格|考试完成/.test(resultTip.innerText)) { clearInterval(resultCheckTimer); isSubmittingExam = false; resetExamRetryState('检测到考试通过结果'); console.log('[Script] 检测到考试通过结果,自动返回考试列表继续下一场考试。'); proceedAfterExamPassed(); return; } resultChecks++; if (resultChecks >= 30) { clearInterval(resultCheckTimer); isSubmittingExam = false; // 结果页已出现但 15s 内未匹配到明确的通过文案:只要非“未通过”即视为通过并继续下一场。 const lateResultTip = document.querySelector('.result-tip-content'); if (lateResultTip && isElementVisible(lateResultTip) && !resultTipTextIsFailure(lateResultTip.innerText)) { console.log('[Script] 未能匹配到明确通过文案,但结果页非“未通过”,视为通过并继续下一场。'); resetExamRetryState('检测到考试通过结果'); proceedAfterExamPassed(); } else { console.warn('[Script] 未能确认考试结果,已停止自动跳转,等待人工确认。'); resetExamRetryState('结果未知,等待人工确认'); } } }, 500); } else { console.log('[Script] Neither "下一题" nor "提交试卷" button found. Check page state or selectors.'); } } } /** * Handle exam list page (e.g., #/onlineExam or #/openOnlineExam) * This function will find and click the "待考试" tab if it's not already active, * then find and click the "开始考试" button for the first pending exam. */ function handleExamListPage() { if (!isServiceActive) return; console.log('[Script] handleExamListPage called.'); const currentHash = window.location.hash.toLowerCase(); currentNavContext = GM_getValue('sclpa_nav_context', ''); // 记录当前所在考试列表,考后自动返回用(专业课优先,公需课其次)。 currentExamListRoute = currentHash.includes('/openonlineexam') ? 'https://zyys.ihehang.com/#/openOnlineExam' : 'https://zyys.ihehang.com/#/onlineExam'; // If the context is 'course', we should not be automating exams. Navigate back. if (currentNavContext === 'course') { console.log('[Script] Current navigation context is "course". Ignoring exam automation and navigating back to course list.'); safeNavigateBackToList(); return; } const pendingExamTab = findElementByText('div.radio-tab-tag', '待考试'); if (pendingExamTab && !isUnfinishedTabActive(pendingExamTab)) { console.log('[Script] Found "待考试" tab, clicking it...'); clickElement(pendingExamTab); // After clicking, wait for the content to load, then re-evaluate setTimeout(() => { handleExamListPage(); }, 2500); return; } else if (pendingExamTab && isUnfinishedTabActive(pendingExamTab)) { // Check for "暂无数据" if on professional exam page if (currentHash.includes('/onlineexam')) { const emptyDataText = document.querySelector('.el-table__empty-text'); if (emptyDataText && emptyDataText.innerText.includes('暂无数据')) { console.log('[Script] Professional Exam List: Detected "暂无数据". Switching to Public Exam List.'); if (advanceAllInOnePhaseIfExpected('specialized-exam')) return; window.location.href = 'https://zyys.ihehang.com/#/openOnlineExam'; return; // Exit after navigation } } // If not "暂无数据" or on public exam page, attempt to start exam console.log('[Script] "待考试" tab is active. Attempting to find "开始考试" button...'); attemptClickStartExamButton(); } else { console.log('[Script] No "待考试" tab or pending exam found. All exams might be completed.'); if (handleAllInOneExamPhaseExhausted()) return; notifyExamBatchFinished(); } } /** * 获取“开始考试”按钮所在行/卡片的文本,作为识别具体场次的签名。 */ function getExamRowSignature(button) { if (!button) return ''; const row = button.closest('tr') || button.closest('.el-card') || button.closest('li') || button.parentElement; return row ? row.innerText.trim() : ''; } /** * Attempts to find and click the "开始考试" button for the first available exam. * 跳过已重试耗尽的场次;找不到可开始的考试时提示批次完成。 */ function attemptClickStartExamButton() { // 非重试链中点击“开始考试”属于全新考试,清理可能残留的重试记忆。 if (!examRetryChainActive) { resetExamRetryState('开始新的考试'); } const startExamButtons = Array.from(document.querySelectorAll('button span')) .filter(span => span.innerText.trim() === '开始考试') .map(span => span.closest('button')) .filter(button => button); // 已重试耗尽的场次若已不在当前列表(平台要求重新学习等),移除标记,之后重现时仍可作答。 const currentSignatures = new Set(startExamButtons.map(button => getExamRowSignature(button)).filter(Boolean)); for (const signature of Array.from(exhaustedExamSignatures)) { if (!currentSignatures.has(signature)) exhaustedExamSignatures.delete(signature); } let targetButton = null; for (const button of startExamButtons) { const signature = getExamRowSignature(button); if (signature && exhaustedExamSignatures.has(signature)) continue; targetButton = button; break; } if (targetButton) { console.log('[Script] Found "开始考试" button, clicking it...'); lastStartedExamSignature = getExamRowSignature(targetButton); examSummaryNotified = false; // 新一批开始,重置“全部完成”提示标记 clickElement(targetButton); } else if (startExamButtons.length > 0) { console.log('[Script] 待考试列表中其余场次均已重试耗尽,停止自动考试并提示。'); if (handleAllInOneExamPhaseExhausted()) return; notifyExamBatchFinished(); } else { console.log('[Script] "开始考试" button not found on the page.'); if (handleAllInOneExamPhaseExhausted()) return; notifyExamBatchFinished(); } } /** * Handle generic popups, including the "前往考试" popup after course completion. */ function handleGenericPopups() { if (!isServiceActive || isPopupBeingHandled) return; console.log('[Script] handleGenericPopups called.'); if (hasFailedExamResult()) { handleFailedExamResult(); return; } const currentHash = window.location.hash.toLowerCase(); // Get current hash here const examCompletionPopupMessage = document.querySelector('.el-message-box__message p'); const goToExamBtnInPopup = findElementByText('button.el-button--primary span', '前往考试'); const cancelBtnInPopup = findElementByText('button.el-button--default span', '取消'); if (examCompletionPopupMessage && examCompletionPopupMessage.innerText.includes('恭喜您已经完成所有课程学习') && goToExamBtnInPopup && cancelBtnInPopup) { // If on major player page, the new dedicated handler will manage this popup. if (currentHash.includes('/majorplayerpage')) { return; } currentNavContext = GM_getValue('sclpa_nav_context', ''); // Only handle this popup for course completion context on non-majorPlayerPage if (currentNavContext === 'course') { console.log('[Script] Detected "恭喜您" completion popup on non-majorPlayerPage. Clicking "取消".'); isPopupBeingHandled = true; clickElement(cancelBtnInPopup.closest('button')); setTimeout(() => { isPopupBeingHandled = false; }, 1000); // Reset flag after delay return; } } const genericBtn = findElementByText('button span', '确定') || findElementByText('button span', '进入下一节学习'); if (genericBtn) { console.log(`[Script] Detected generic popup button: ${genericBtn.innerText.trim()}. Clicking it.`); isPopupBeingHandled = true; clickElement(genericBtn.closest('button')); setTimeout(() => { isPopupBeingHandled = false; }, 2500); } } // =================================================================================== // --- 核心自动化 (Core Automation) --- // =================================================================================== /** * [动态倍速应用器] 立即将当前配置的倍速应用到所有视频 * 允许在不重新加载页面的情况下动态调整倍速 */ function applyCurrentVideoSpeed() { const targetRate = GM_getValue('sclpa_playback_rate', 1.0); CONFIG.VIDEO_PLAYBACK_RATE = targetRate; currentPlaybackRate = targetRate; function applyToVideo(video) { if (!video || video.nodeType !== Node.ELEMENT_NODE) return; const currentRate = video.playbackRate; if (Math.abs(currentRate - targetRate) > 0.01) { try { video.playbackRate = targetRate; console.log(`[Script] 动态应用倍速: ${targetRate}x (从 ${currentRate}x 调整)`); } catch (e) { console.warn('[Script] 应用倍速失败:', e); } } } document.querySelectorAll('video').forEach(video => applyToVideo(video)); try { document.querySelectorAll('iframe').forEach(iframe => { iframe.contentDocument?.querySelectorAll('video').forEach(video => applyToVideo(video)); }); } catch (e) { } document.querySelectorAll('*').forEach(el => { if (el.shadowRoot) { el.shadowRoot.querySelectorAll('video').forEach(video => applyToVideo(video)); } }); if (refreshVideoSpeedEngine) { refreshVideoSpeedEngine(); } const speedDisplay = document.getElementById('speed-display'); if (speedDisplay) { speedDisplay.textContent = `${targetRate}x`; } console.log(`[Script] 动态倍速应用完成: ${targetRate}x`); } /** * 标准 HTML5 视频倍速引擎。 * 使用加载/播放事件、播放器实例级倍速保护和 MutationObserver 覆盖 SPA 重新挂载视频的场景; * 不篡改浏览器原生属性描述符或页面计时器。 */ function initializeEnhancedVideoSpeedEngine() { if (isVideoSpeedEngineInitialized && refreshVideoSpeedEngine) { refreshVideoSpeedEngine(); return; } console.log(`[Script] 标准 HTML5 视频倍速引擎启动,目标倍速: ${CONFIG.VIDEO_PLAYBACK_RATE}x`); const monitoredVideos = new WeakSet(); function applyVideoSpeed(video) { if (!video || video.nodeType !== Node.ELEMENT_NODE) return; const targetRate = CONFIG.VIDEO_PLAYBACK_RATE; const currentRate = video.playbackRate; if (Math.abs(currentRate - targetRate) > 0.01) { try { video.defaultPlaybackRate = targetRate; video.playbackRate = targetRate; console.log(`[Script] H5 倍速已应用: ${targetRate}x (原倍速: ${currentRate}x)`); } catch (e) { console.warn('[Script] 应用倍速失败:', e); } } } function applySpeedToAllVideos() { document.querySelectorAll('video').forEach(video => { applyVideoSpeed(video); if (!monitoredVideos.has(video)) { monitoredVideos.add(video); enhanceVideoMonitoring(video); } }); try { document.querySelectorAll('iframe').forEach(iframe => { iframe.contentDocument?.querySelectorAll('video').forEach(video => { applyVideoSpeed(video); if (!monitoredVideos.has(video)) { monitoredVideos.add(video); enhanceVideoMonitoring(video); } }); }); } catch (e) { } document.querySelectorAll('*').forEach(el => { if (el.shadowRoot) { el.shadowRoot.querySelectorAll('video').forEach(video => { applyVideoSpeed(video); if (!monitoredVideos.has(video)) { monitoredVideos.add(video); enhanceVideoMonitoring(video); } }); } }); } function enhanceVideoMonitoring(video) { if (!video) return; const rateDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate'); if (rateDescriptor?.get && rateDescriptor?.set) { try { Object.defineProperty(video, 'playbackRate', { configurable: true, get() { return rateDescriptor.get.call(this); }, set() { // 课程页会反复写入 1x;始终以当前面板设置为准。 return rateDescriptor.set.call(this, CONFIG.VIDEO_PLAYBACK_RATE); } }); } catch (e) { console.warn('[Script] 无法安装视频倍速保护,降级为事件重试:', e); } } else { console.warn('[Script] 未找到 HTMLMediaElement.playbackRate 描述符,无法安装倍速保护。'); } const reapply = () => setTimeout(() => applyVideoSpeed(video), 100); video.addEventListener('loadedmetadata', reapply); video.addEventListener('canplay', reapply); video.addEventListener('playing', reapply); installBackgroundPlaybackGuard(video, reapply); reapply(); } applySpeedToAllVideos(); const observer = new MutationObserver((mutations) => { let shouldScan = false; mutations.forEach(mutation => { if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { mutation.addedNodes.forEach(node => { if (node.nodeName === 'VIDEO' || (node.querySelectorAll && node.querySelectorAll('video').length > 0)) { shouldScan = true; } }); } }); if (shouldScan) { setTimeout(applySpeedToAllVideos, 100); } }); observer.observe(document.body || document.documentElement, { childList: true, subtree: true }); refreshVideoSpeedEngine = applySpeedToAllVideos; isVideoSpeedEngineInitialized = true; applySpeedToAllVideos(); if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', applySpeedToAllVideos, { once: true }); } console.log('[Script] 标准 HTML5 视频倍速引擎已启动。'); } // =================================================================================== /** * [Time Engine] Global time acceleration, including setTimeout, setInterval, and requestAnimationFrame */ function accelerateTime() { console.log(`[Script] Time acceleration engine started, rate: ${CONFIG.TIME_ACCELERATION_RATE}x`); const rate = CONFIG.TIME_ACCELERATION_RATE; const percentage = 1 / rate; let scriptStartTime = Date.now(); let lastDateTime = scriptStartTime; let lastModifiedTime = scriptStartTime; const DateOrigin = window.Date; let DateModified = window.Date; const trackedIntervals = new Map(); const trackedTimeouts = new Map(); let timerIdCounter = 0; try { const setTimeoutOrigin = window.setTimeout; const setIntervalOrigin = window.setInterval; const clearTimeoutOrigin = window.clearTimeout; const clearIntervalOrigin = window.clearInterval; window.setTimeout = function(callback, delay, ...args) { if (typeof delay !== 'number' || delay <= 0) { return setTimeoutOrigin.call(window, callback, delay, ...args); } const originalDelay = delay; const hookedDelay = Math.floor(originalDelay * percentage); const timerId = setTimeoutOrigin.call(window, function() { trackedTimeouts.delete(timerId); if (typeof callback === 'function') { callback.apply(this, arguments); } else if (typeof callback === 'string') { eval(callback); } }, hookedDelay, ...args); trackedTimeouts.set(timerId, { args: [callback, originalDelay, ...args], originDelay: originalDelay, hookedDelay: hookedDelay }); return timerId; }; window.setInterval = function(callback, delay, ...args) { if (typeof delay !== 'number' || delay <= 0) { return setIntervalOrigin.call(window, callback, delay, ...args); } const originalDelay = delay; const hookedDelay = Math.floor(originalDelay * percentage); const intervalId = setIntervalOrigin.call(window, callback, hookedDelay, ...args); trackedIntervals.set(intervalId, { args: [callback, originalDelay, ...args], originDelay: originalDelay, hookedDelay: hookedDelay }); return intervalId; }; window.clearTimeout = function(timerId) { trackedTimeouts.delete(timerId); return clearTimeoutOrigin.call(window, timerId); }; window.clearInterval = function(timerId) { trackedIntervals.delete(timerId); return clearIntervalOrigin.call(window, timerId); }; window.Date = function(...args) { if (args.length === 0) { const now = DateOrigin.now(); const delta = now - lastDateTime; const adjustedDelta = delta * rate; const newTime = lastModifiedTime + adjustedDelta; lastModifiedTime = newTime; lastDateTime = now; return new Date(newTime); } else if (args.length === 1 && typeof args[0] === 'number') { return new DateOrigin(args[0]); } else { return new (Function.prototype.bind.apply(DateOrigin, [null].concat(args)))(); } }; window.Date.prototype = DateOrigin.prototype; window.Date.now = function() { const now = DateOrigin.now(); const delta = now - lastDateTime; const adjustedDelta = delta * rate; const newTime = lastModifiedTime + adjustedDelta; return Math.floor(newTime); }; window.Date.prototype.now = window.Date.now; const originalDateToString = DateOrigin.prototype.toString; window.Date.prototype.toString = function() { const now = DateOrigin.now(); const delta = now - lastDateTime; const adjustedDelta = delta * rate; const newTime = lastModifiedTime + adjustedDelta; const fakeDate = new DateOrigin(newTime); return originalDateToString.call(fakeDate); }; window.Date.prototype.getTime = function() { const now = DateOrigin.now(); const delta = now - lastDateTime; const adjustedDelta = delta * rate; return lastModifiedTime + adjustedDelta; }; hook(window, 'requestAnimationFrame', (original) => { let firstTimestamp = -1; return (callback) => { return original.call(window, (timestamp) => { if (firstTimestamp < 0) firstTimestamp = timestamp; const acceleratedTimestamp = firstTimestamp + (timestamp - firstTimestamp) * rate; callback(acceleratedTimestamp); }); }; }); console.log(`[Script] Time acceleration hooks applied successfully (rate: ${rate}x)`); } catch (e) { console.error('[Script Error] Failed to apply time acceleration hooks:', e); } } /** * 后台播放守护。 * 不重写全局 Object.defineProperty,也不伪造 document.hidden;前者会阻断本脚本 * 为单个播放器安装的倍速 setter,后者会破坏站点自己的可见性逻辑。 */ function initializeVideoPlaybackFixes() { console.log('[Script] 初始化后台播放守护:保留浏览器原生可见性与属性 API。'); } /** * 当站点在页面失焦或后台暂停视频时,尝试恢复播放与当前倍速。 * 浏览器仍可能对后台标签进行原生节流;本函数不会修改浏览器调度策略。 */ function installBackgroundPlaybackGuard(video, reapplySpeed) { if (!video || video.dataset.sclpaBackgroundGuardInstalled) return; video.dataset.sclpaBackgroundGuardInstalled = 'true'; let restoreTimer = null; const restorePlayback = () => { if (video.ended || restoreTimer) return; restoreTimer = setTimeout(() => { restoreTimer = null; if (video.ended) return; reapplySpeed(); if (video.paused) { video.play().catch(error => { console.debug('[Script] 后台恢复播放被浏览器拒绝:', error); }); } }, 150); }; document.addEventListener('visibilitychange', () => { if (document.hidden) restorePlayback(); }, true); window.addEventListener('blur', restorePlayback, true); video.addEventListener('pause', () => { if (document.hidden && !video.ended) restorePlayback(); }); } /** * Safely navigate back to the corresponding course list * This function is now mostly a fallback, as direct button clicks are preferred. */ function safeNavigateBackToList() { const hash = window.location.hash.toLowerCase(); const returnUrl = hash.includes('public') || hash.includes('openplayer') || hash.includes('imageandtext') || hash.includes('openonlineexam') ? 'https://zyys.ihehang.com/#/publicDemand' : 'https://zyys.ihehang.com/#/specialized'; console.log(`[Script] Fallback: Navigating back to list: ${returnUrl}`); window.location.href = returnUrl; } /** * Decide next action after a course (including all its chapters) is completed. * This function is crucial for determining whether to proceed to exam or continue course swiping. */ function safeNavigateAfterCourseCompletion() { const hash = window.location.hash.toLowerCase(); currentNavContext = GM_getValue('sclpa_nav_context', ''); // Ensure context is fresh console.log('[Script] safeNavigateAfterCourseCompletion called. Current hash:', hash, 'Context:', currentNavContext); // Check if the current page is a player page (video or article player) if (hash.includes('/majorplayerpage') || hash.includes('/articleplayerpage') || hash.includes('/openplayer') || hash.includes('/imageandtext')) { // If the navigation context is explicitly set to 'exam' (e.g., user clicked '专业课-考试' from panel) if (currentNavContext === 'exam') { const goToExamButton = findElementByText('button span', '前往考试'); if (goToExamButton) { console.log('[Script] Course completed. Context is "exam". Found "前往考试" button, clicking it.'); currentExamListRoute = hash.includes('openplayer') || hash.includes('imageandtext') ? 'https://zyys.ihehang.com/#/openOnlineExam' : 'https://zyys.ihehang.com/#/onlineExam'; clickElement(goToExamButton.closest('button')); return; // Exit after clicking exam button } else { console.log('[Script] Course completed. Context is "exam" but "前往考试" button not found, navigating back to exam list.'); // Navigate to appropriate exam list if '前往考试' isn't found const examReturnUrl = hash.includes('openplayer') || hash.includes('imageandtext') ? 'https://zyys.ihehang.com/#/openOnlineExam' : 'https://zyys.ihehang.com/#/onlineExam'; currentExamListRoute = examReturnUrl; window.location.href = examReturnUrl; return; } } else { // For majorPlayerPage, navigation is now handled by the dedicated handler. if (hash.includes('/majorplayerpage')) { console.log('[Script] Professional Course completed. Awaiting main loop handler for navigation.'); } else { // For public courses (or other non-majorPlayerPage players), use general navigation console.log('[Script] Public Course completed. Navigating back to general course list.'); safeNavigateBackToList(); } return; // Exit after attempting navigation } } // Fallback for other cases (e.g., if this function is called from a non-player page unexpectedly) console.log('[Script] safeNavigateAfterCourseCompletion called from non-player page or unhandled scenario. Navigating back to general course list.'); safeNavigateBackToList(); } // =================================================================================== // --- 主循环与启动器 (Main Loop & Initiator) --- // =================================================================================== /** * [FIXED] Dedicated handler for the professional course player page (/majorPlayerPage). * This function's only job is to detect the final completion popup and navigate. * @returns {boolean} - Returns true if navigation was initiated, otherwise false. */ function handleMajorPlayerPage() { // Priority 1: Check for the "Congratulations" popup. Its presence means the course is finished. const completionPopup = document.querySelector('.el-message-box'); if (completionPopup && completionPopup.innerText.includes('恭喜您已经完成所有课程学习')) { console.log('[Script] Completion popup detected. This signifies the course is finished. Navigating to professional courses list.'); const navButton = document.getElementById('nav-specialized-btn'); if (navButton) { clickElement(navButton); } else { console.warn('[Script] Could not find "专业课程" button (nav-specialized-btn) for navigation. Falling back to URL change.'); window.location.href = 'https://zyys.ihehang.com/#/specialized'; } // Return true as we've initiated the final navigation action. return true; } // If no popup is found, it means the course is still in progress. Return false. return false; } /** * Page router, determines which handler function to execute based on URL hash */ function router() { const hash = window.location.hash.toLowerCase(); console.log('[Script] Router: Current hash is', hash); if (hash.includes('/specialized')) { handleCourseListPage('专业课'); } else if (hash.includes('/publicdemand')) { handleCourseListPage('公需课'); } else if (hash.includes('/examination')) { handleExamPage(); } else if (hash.includes('/majorplayerpage') || hash.includes('/articleplayerpage') || hash.includes('/openplayer') || hash.includes('/imageandtext')) { handleLearningPage(); } else if (hash.includes('/onlineexam') || hash.includes('/openonlineexam')) { handleExamListPage(); } else { console.log('[Script] Router: No specific handler for current hash, idling.'); } } /** * Main script loop, executed every 2 seconds */ function mainLoop() { console.log('[Script] Main loop running...'); const currentHash = window.location.hash; // Get current hash at the start of the loop // Detect hash change to reset states if (currentHash !== currentPageHash) { const oldHash = currentPageHash; currentPageHash = currentHash; // Update currentPageHash console.log(`[Script] Hash changed from ${oldHash} to ${currentHash}.`); // SPA 路由通常不会重载脚本;确认路由变化后允许下一阶段继续前进。 allInOneTransitionPending = false; // If exiting an examination page, clean up AI panel and related flags if (oldHash.includes('/examination') && !currentHash.includes('/examination')) { const aiPanel = document.getElementById('ai-helper-panel'); if (aiPanel) aiPanel.remove(); currentQuestionBatchText = ''; // Reset batch text on exam page exit isAiAnswerPending = false; isSubmittingExam = false; console.log('[Script] Exited examination page, reset AI related flags.'); } } // Always reset unfinishedTabClicked if we are on a course list page or exam list page. // This ensures that even if the hash doesn't change (e.g., page reload to same hash), // the "未完成" tab logic is re-evaluated. if (currentHash.includes('/specialized') || currentHash.includes('/publicdemand') || currentHash.includes('/onlineexam') || currentHash.includes('/openonlineexam')) { if (unfinishedTabClicked) { // Only log if it's actually being reset console.log('[Script] Resetting unfinishedTabClicked flag for current list page.'); } unfinishedTabClicked = false; } if (isServiceActive) { // High-priority handler for the professional course player page. if (currentHash.toLowerCase().includes('/majorplayerpage')) { // If the handler initiates navigation, it returns true. // We should then skip the rest of the main loop for this cycle. if (handleMajorPlayerPage()) { return; } } // Handle other generic popups handleGenericPopups(); } // Route to the appropriate page handler router(); } /** * Start the script */ window.addEventListener('load', () => { console.log(`[Script] Sichuan Licensed Pharmacist Continuing Education (v1.3.1) started.`); console.log(`[Script] Service status: ${isServiceActive ? 'Running' : 'Paused'} | Current speed: ${currentPlaybackRate}x`); currentPageHash = window.location.hash; currentNavContext = GM_getValue('sclpa_nav_context', ''); // Load initial navigation context try { initializeVideoPlaybackFixes(); } catch (e) { console.error('[Script Error] Failed to initialize video playback fixes during load:', e); } try { createModeSwitcherPanel(); // This creates the UI panel } catch (e) { console.error('[Script Error] Failed to create Mode Switcher Panel during load:', e); } // Start the main loop setInterval(mainLoop, 2000); console.log('[Script] Main loop initiated.'); }); })();