// ==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 = `
快速导航
快速开始
- 安装脚本后,屏幕右下角会出现控制面板
- 点击相应按钮可快速跳转到不同学习模块
- 开启服务后,脚本将自动完成刷课任务
- 视频默认16倍速静音播放
AI 助手
- 在使用AI答题功能前,需先设置 DeepSeek API Key
- 在"设置"标签页中输入您的 API Key 并保存
- 获取 API Key:点击此处
- AI会自动处理考试题目并选择答案
功能说明
- 专业课程:自动播放视频课程,支持多章节切换
- 公需课-视频:自动播放视频,支持静音倍速
- 公需课-文章:自动计时,标记已读状态
- 考试:AI自动答题(需配置API Key)
视频倍速技术
- 增强倍速引擎:采用多重防护机制,确保倍速稳定生效
- 自动检测:支持主文档、iframe和Shadow DOM中的视频
- 实时监控:每秒检查并修正倍速设置
- 防护机制:阻止网页重置playbackRate属性
- 智能重试:自动适应视频加载和切换场景
注意事项:
- 请保持刷课页面始终处于前台
- 不要折叠控制面板
- AI答题不能保证100%正确率
- 考试未通过时脚本会自动提取错题回传AI修正并重新作答(可在“设置”调整重试次数)
获取帮助
- GitHub:访问项目主页
- 问题反馈:在 脚本猫 或 GitHub 提交 Issue
`;
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 = `
⚠️ 请先在控制面板的"设置"标签页中配置您的 DeepSeek API Key
💬 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.');
});
})();