// ==UserScript==
// @name 四川省执业药师继续教育(Beta 测试版)
// @namespace http://tampermonkey.net/
// @version 1.4.0-beta.1
// @description 【Beta 测试版】四川省执业药师继续教育助手:视频与文章倍速、后台任务、全能托管、AI 答题纠错及异常提醒。
// @author Coren
// @match https://www.sclpa.cn/*
// @match https://zyys.ihehang.com/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant unsafeWindow
// @grant GM_getValue
// @grant GM_notification
// @grant GM_setValue
// @connect api.deepseek.com
// @connect self
// @inject-into page
// @run-at document-start
// @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.info('[执业药师] Beta 1.4.0-beta.1 已加载');
(function() {
'use strict';
if (window.__sclpaScriptInstance) return;
window.__sclpaScriptInstance = { version: '1.4.0-beta.1' };
const debugEnabled = GM_getValue('sclpa_debug', false);
const debugLog = (...args) => { if (debugEnabled) console.debug(...args); };
// ===================================================================================
// --- 脚本配置 (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,
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 disposeVideoSpeedEngine = null;
const backgroundPlaybackGuards = new Map();
let emptyCourseObservation = 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 isAiStyleInstalled = false;
let isAiAnswerPending = false; // Flag to track if AI answer is currently being awaited
let aiBatchGeneration = 0;
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();
// 仅保留当前考试重试链;确认错答与未及格轮次分开记录。
const confirmedWrongAnswers = new Map();
const failedAttemptAnswers = 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 VIDEO_LOAD_TIMEOUT_MS = 45000;
const VIDEO_LOAD_MAX_RETRIES = 2;
const VIDEO_LOAD_FAILURES_KEY = 'sclpa_video_load_failures';
let videoLoadingState = null;
let videoLoadFailures = readVideoLoadFailures();
// 全能托管状态会持久化,页面跳转或刷新后仍能从上一次阶段继续。
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;
const backgroundAlarmMessages = {
'video-stalled': '视频持续加载无进展,脚本正在尝试恢复。请留意学习进度。',
'video-skipped': '视频重试后仍无法播放,已暂时跳过;该视频仍未完成,请稍后处理。',
'video-stopped': '剩余视频加载失败,学习服务已暂停。请返回学习页检查并重试。',
'ai-failed': 'AI 未返回可用答案,当前试卷保留,请返回学习页处理。',
'ai-timeout': '等待 AI 答案超时,当前试卷保留,请返回学习页重试。',
'exam-unknown': '无法确认考试结果,自动跳转已停止,请返回学习页确认。',
'exam-failed': '存在重试后仍未通过的考试,请返回学习页检查。',
'scheduler-error': '后台任务执行异常,请返回学习页检查运行状态。',
'scheduler-fallback': '后台调度不可用,已退回普通计时;后台进度可能变慢,请检查学习页。',
'page-frozen': '浏览器冻结了后台学习页,学习将暂停;请返回学习页恢复。',
'test': '这是一条测试通知。点击通知可返回学习页面。'
};
const backgroundAlarmTimes = new Map();
let pendingAutomationNotice = null;
let automationNoticeTimer = null;
let backgroundNotificationSequence = 0;
const backgroundNotificationsPending = new Set();
const runtimeKeepAwake = createRuntimeKeepAwake();
const backgroundScheduler = createBackgroundScheduler();
const scriptSetTimeout = backgroundScheduler.setTimeout;
const scriptSetInterval = backgroundScheduler.setInterval;
const scriptClearTimeout = backgroundScheduler.clear;
const scriptClearInterval = backgroundScheduler.clear;
function createRuntimeKeepAwake() {
const status = { running: false, webLock: 'inactive', screenLock: 'inactive' };
let active = false;
let epoch = 0;
let webAbort = null;
let webRelease = null;
let screenSentinel = null;
let screenPending = false;
let webRetryAt = 0;
let screenRetryAt = 0;
const lockName = 'sclpa-study-runtime-' + (crypto.randomUUID?.() || Math.random().toString(36).slice(2));
function render() {
const node = document.getElementById('runtime-keep-awake-status');
if (!node) return;
node.textContent = !active ? '保活已释放(服务暂停)' :
'尝试保活:后台锁' + (status.webLock === 'held' ? '已持有' : '未持有') +
' / 屏幕唤醒锁' + (status.screenLock === 'held' ? '已持有' : document.hidden ? '后台不可持有' : '未持有');
}
function releaseScreen() {
const sentinel = screenSentinel;
screenSentinel = null;
status.screenLock = 'inactive';
if (sentinel) Promise.resolve(sentinel.release()).catch(() => {});
}
function release() {
active = false;
status.running = false;
epoch++;
webAbort?.abort();
webAbort = null;
webRelease?.();
webRelease = null;
status.webLock = 'inactive';
releaseScreen();
render();
}
function requestWebLock() {
if (!active || webAbort || performance.now() < webRetryAt) return;
if (!navigator.locks?.request) { status.webLock = 'unsupported'; return; }
const generation = epoch;
const abort = new AbortController();
webAbort = abort;
status.webLock = 'pending';
navigator.locks.request(lockName, { mode: 'shared', signal: abort.signal }, async lock => {
if (!lock || !active || generation !== epoch) return;
status.webLock = 'held';
render();
await new Promise(resolve => { webRelease = resolve; });
}).catch(() => {
if (active && generation === epoch) status.webLock = 'unavailable';
}).finally(() => {
if (webAbort !== abort) return;
webAbort = null;
webRelease = null;
status.webLock = active ? 'unavailable' : 'inactive';
webRetryAt = performance.now() + 30000;
render();
});
}
async function requestScreenLock() {
if (!active || document.hidden || screenPending || screenSentinel || performance.now() < screenRetryAt) return;
if (!navigator.wakeLock?.request) { status.screenLock = 'unsupported'; return; }
const generation = epoch;
screenPending = true;
status.screenLock = 'pending';
try {
const sentinel = await navigator.wakeLock.request('screen');
if (!active || generation !== epoch || document.hidden) {
await sentinel.release();
return;
}
screenSentinel = sentinel;
status.screenLock = 'held';
sentinel.addEventListener('release', () => {
if (screenSentinel !== sentinel) return;
screenSentinel = null;
status.screenLock = active ? 'released' : 'inactive';
screenRetryAt = performance.now() + 30000;
render();
});
} catch (_) {
if (active && generation === epoch) status.screenLock = 'unavailable';
screenRetryAt = performance.now() + 30000;
} finally {
screenPending = false;
render();
}
}
function sync() {
if (!isServiceActive) { if (active) release(); else render(); return; }
if (!active) { active = true; status.running = true; epoch++; webRetryAt = 0; screenRetryAt = 0; }
requestWebLock();
requestScreenLock();
render();
}
document.addEventListener('visibilitychange', () => {
if (document.hidden) releaseScreen();
else { screenRetryAt = 0; sync(); }
});
document.addEventListener('resume', sync);
window.addEventListener('pageshow', sync);
window.addEventListener('pagehide', release);
window.__sclpaRuntimeKeepAwake = status;
return { sync, release };
}
function createBackgroundScheduler() {
const nativeTimeout = window.setTimeout.bind(window);
const nativeClearTimeout = window.clearTimeout.bind(window);
const jobs = new Map();
const status = { mode: 'native', frozen: false, callbacks: 0, pending: 0 };
let sequence = 0;
let worker = null;
let workerUrl = null;
let fallbackTimer = null;
let dispatching = false;
const clock = () => performance.now();
function arm() {
status.pending = jobs.size;
if (dispatching) return;
let next = Infinity;
if (!status.frozen) for (const job of jobs.values()) next = Math.min(next, job.due);
const delay = Number.isFinite(next) ? Math.max(10, next - clock()) : null;
if (worker) {
worker.postMessage({ delay });
} else {
if (fallbackTimer !== null) nativeClearTimeout(fallbackTimer);
fallbackTimer = delay === null ? null : nativeTimeout(dispatch, delay);
}
}
function dispatch() {
if (status.frozen) return;
const now = clock();
dispatching = true;
for (const [id, job] of Array.from(jobs)) {
if (!jobs.has(id) || job.due > now) continue;
if (job.interval) job.due = now + job.delay;
else jobs.delete(id);
try {
job.callback.apply(window, job.args);
status.callbacks++;
} catch (error) {
console.error('[Script] 后台任务执行失败:', error);
notifyBackgroundException('scheduler-error');
}
}
dispatching = false;
arm();
}
function releaseWorkerUrl() {
if (workerUrl) URL.revokeObjectURL(workerUrl);
workerUrl = null;
}
try {
workerUrl = URL.createObjectURL(new Blob([
"let timer=null;onmessage=e=>{if(timer!==null)clearTimeout(timer);timer=null;if(e.data.delay!==null)timer=setTimeout(()=>postMessage('tick'),e.data.delay)};postMessage('ready');"
], { type: 'text/javascript' }));
worker = new Worker(workerUrl);
status.mode = 'worker';
worker.onmessage = event => {
if (event.data === 'ready') releaseWorkerUrl();
else if (event.data === 'tick') dispatch();
};
worker.onerror = () => {
worker.terminate();
worker = null;
status.mode = 'native';
releaseWorkerUrl();
console.warn('[Script] 当前页面不允许后台调度,已退回普通计时。');
notifyBackgroundException('scheduler-fallback');
arm();
};
} catch (error) {
worker = null;
notifyBackgroundException('scheduler-fallback');
releaseWorkerUrl();
}
function schedule(callback, delay, interval, args) {
if (typeof callback !== 'function') throw new TypeError('后台任务必须是函数');
const id = --sequence;
const milliseconds = Math.max(interval ? 10 : 0, Number(delay) || 0);
jobs.set(id, { callback, args, delay: milliseconds, interval, due: clock() + milliseconds });
arm();
return id;
}
function clear(id) {
jobs.delete(id);
arm();
}
function resetArticleClocks() {
for (const record of window.__sclpaArticleTimingEngine?.records?.values() || []) {
record.lastTick = clock();
record.carry = 0;
}
}
document.addEventListener('freeze', () => {
notifyBackgroundException('page-frozen');
status.frozen = true;
resetArticleClocks();
arm();
});
document.addEventListener('resume', () => {
status.frozen = false;
resetArticleClocks();
const now = clock();
for (const job of jobs.values()) if (job.interval) job.due = now + job.delay;
arm();
});
const resumeScheduler = () => {
if (status.frozen) {
status.frozen = false;
resetArticleClocks();
const now = clock();
for (const job of jobs.values()) if (job.interval) job.due = now + job.delay;
}
arm();
};
document.addEventListener('visibilitychange', () => { if (!document.hidden) resumeScheduler(); });
window.addEventListener('pageshow', resumeScheduler);
window.addEventListener('pagehide', event => {
if (event.persisted) return;
jobs.clear();
status.pending = 0;
worker?.terminate();
if (fallbackTimer !== null) nativeClearTimeout(fallbackTimer);
releaseWorkerUrl();
});
window.__sclpaBackgroundScheduler = status;
return {
setTimeout: (callback, delay, ...args) => schedule(callback, delay, false, args),
setInterval: (callback, delay, ...args) => schedule(callback, delay, true, args),
clear
};
}
// ===================================================================================
// --- 辅助函数 (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') {
debugLog('[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);
debugLog(`[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 =
'你是执业药师考试答题助手。以下题目已由复核页确认回答错误,并附有历轮确认错答。' +
'单选题排除所有已确认错误的选项;多选题排除已确认错误的完整组合,但不能据此认定组合中每个选项都错误。' +
'未及格轮次作答仅供参考,不代表每题均错误。请重新判断并给出答案。' +
'只输出一行一道,格式为“序号.字母选项”(多选连续字母),不要输出解释。';
const EXAM_NO_REVIEW_CORRECTION_SYSTEM_PROMPT =
'你是执业药师考试答题助手。以下提供历次未及格考试的作答记录;缺少逐题复核,不能把每道题的历史选项都认定为错误。' +
'仅排除另行标明的已确认错答:单选排除对应选项,多选排除完整组合,不能排除组合中的所有单个选项。' +
'结合所有历轮记录重新判断答案。只输出一行一道,格式为“序号.字母选项”(多选连续字母),不要输出解释。';
function normalizeExamRetryLimit(value) {
const parsed = Number(value);
return value === '' || value === null || !Number.isFinite(parsed) ? 3 : Math.max(0, Math.min(3, Math.trunc(parsed)));
}
function questionHistoryKey(question) {
return JSON.stringify([question.title, question.options]);
}
function canonicalAnswer(value) {
return [...new Set(normalizeAnswerLetters(value))].sort().join('');
}
function recordAttemptAnswer(history, question, answer) {
const letters = canonicalAnswer(answer);
if (!letters) return;
const key = questionHistoryKey(question);
const entries = history.get(key) || [];
if (!entries.some(entry => entry.round === examAttemptCount && entry.answer === letters)) {
entries.push({ round: examAttemptCount, answer: letters });
history.set(key, entries);
}
}
function describeAttemptHistory(history, question) {
return (history.get(questionHistoryKey(question)) || [])
.map(entry => '第' + entry.round + '轮:' + entry.answer).join(';') || '无';
}
function isConfirmedWrongAnswer(question, answer) {
const letters = canonicalAnswer(answer);
return (confirmedWrongAnswers.get(questionHistoryKey(question)) || []).some(entry => entry.answer === letters);
}
function buildRetryHistoryContext(items) {
const lines = Array.from(items).map((item, index) => {
const question = {
title: getNormalizedQuestionTitle(item),
options: Array.from(item.querySelectorAll('.examination-check-item')).map(option => option.innerText.trim()).filter(Boolean)
};
const wrong = describeAttemptHistory(confirmedWrongAnswers, question);
const failed = describeAttemptHistory(failedAttemptAnswers, question);
return wrong === '无' && failed === '无' ? '' :
'第' + (index + 1) + '题:已确认错答:' + wrong + ';未及格轮次作答(未逐题确认):' + failed;
}).filter(Boolean);
return lines.length ? '\n\n重试历史:单选排除已确认错答;多选只排除已确认错误的完整组合。未逐题确认的作答仅供参考。\n' + lines.join('\n') : '';
}
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) {
aiBatchGeneration++;
if (reason) debugLog(`[Script] 重置考试自动重试状态:${reason}`);
examAttemptCount = 0;
examRetryChainActive = false;
isHandlingExamResult = false;
lastAttemptData.clear();
confirmedWrongAnswers.clear();
failedAttemptAnswers.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 = scriptSetInterval(() => {
const reviewVisible = document.querySelector('.examination-body-item .details-state') ||
document.querySelector('.examination-body-item .examination-details em');
if (reviewVisible || Date.now() - startedAt > timeoutMs) {
scriptClearInterval(timer);
resolve(Boolean(reviewVisible));
}
}, pollMs);
});
}
/**
* 考试未通过后的统一处理:未达重试上限则进入自动重试闭环;
* 已达上限则记录失败并放弃该场,继续处理其他考试(不等待人工复核)。
*/
function handleFailedExamResult() {
if (isHandlingExamResult) return;
isHandlingExamResult = true;
const maxRetries = normalizeExamRetryLimit(GM_getValue('sclpa_exam_max_retries', 3));
if (maxRetries <= 0 || examAttemptCount >= maxRetries) {
console.warn(`[Script] 考试未通过,已达自动重试上限(${examAttemptCount}/${maxRetries}),放弃该场继续下一场。`);
recordFailedExamAndContinue();
return;
}
isSubmittingExam = false;
isAiAnswerPending = false;
examRetryChainActive = true;
examAttemptCount++;
debugLog(`[Script] 检测到考试未通过,启动自动重试闭环(第 ${examAttemptCount}/${maxRetries} 次)。`);
// 若复核页已经可见(部分平台交卷后直接展示错题),则无需点击“纠错查看”。
const reviewAlreadyVisible = Array.from(document.querySelectorAll('.examination-body-item .details-state'))
.some(element => isElementVisible(element));
if (reviewAlreadyVisible) {
debugLog('[Script] 纠错复核页已可见,直接提取错题。');
collectReviewAnswersAndCorrect();
return;
}
const reviewButton = findButtonByExactText('纠错查看') ||
findButtonByExactText('查看答案') ||
findButtonByExactText('查看错题') ||
findButtonByExactText('错题查看');
if (!reviewButton) {
console.warn('[Script] 未找到“纠错查看 / 查看答案”按钮,改用上一轮答案组合请求 AI 修正。');
attemptRetryWithoutReview();
return;
}
debugLog('[Script] 点击“纠错查看 / 查看答案”,进入纠错复核页提取错题。');
clickElement(reviewButton);
const reviewGeneration = aiBatchGeneration;
waitForReviewPage().then(found => {
if (!isServiceActive || aiBatchGeneration !== reviewGeneration) return;
if (!found) {
console.warn('[Script] 未检测到纠错复核页,改用上一轮答案组合请求 AI 修正。');
attemptRetryWithoutReview();
return;
}
collectReviewAnswersAndCorrect();
});
}
/**
* 从纠错复核页提取错题与答对题目,把答对的答案保留进记忆,
* 将错题回传 AI 获取修正答案,然后点击“返回”回到考试列表继续下一场。
*/
function collectReviewAnswersAndCorrect() {
const correctionGeneration = aiBatchGeneration;
const correctionRoute = window.location.hash;
const correctionCurrent = () => isServiceActive && aiBatchGeneration === correctionGeneration && window.location.hash === correctionRoute;
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);
wrongQuestions.forEach(question => {
recordAttemptAnswer(confirmedWrongAnswers, question, question.previousAnswer);
examAnswerMemory.delete(question.title);
});
if (wrongQuestions.length === 0) {
debugLog('[Script] 纠错复核页未标记“回答错误”的题目,直接返回考试列表。');
persistExamAnswerMemory();
returnToExamListAfterReview();
return;
}
debugLog(`[Script] 发现 ${wrongQuestions.length} 道错题,请求 AI 修正答案...`);
askAiForAnswer(buildCorrectionPrompt(wrongQuestions), EXAM_CORRECTION_SYSTEM_PROMPT).then(correctionText => {
if (!correctionCurrent()) return;
const correctedMap = parseAnswersToMap(correctionText);
let applied = 0;
wrongQuestions.forEach(question => {
const letters = correctedMap.get(question.index);
if (letters && !isConfirmedWrongAnswer(question, letters)) {
examAnswerMemory.set(question.title, letters);
applied++;
} else {
console.warn(`[Script] AI 未返回第 ${question.index} 题的修正答案(原答案 ${question.previousAnswer || '无'} 不保留)。`);
}
});
persistExamAnswerMemory();
debugLog(`[Script] AI 修正完成,已更新 ${applied} 道错题答案,返回考试列表继续作答。`);
returnToExamListAfterReview();
}).catch(error => {
if (!correctionCurrent()) return;
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历轮已确认错答:${describeAttemptHistory(confirmedWrongAnswers, question)}\n未及格轮次作答(未逐题确认):${describeAttemptHistory(failedAttemptAnswers, question)}`;
});
return [
'以下题目由复核页确认答错;请结合所有历轮记录排除已确认错答,重新判断答案。',
'',
...lines,
'',
'请按上述题目序号输出答案,格式:序号.字母选项(多选连续,如 2.ABC)。'
].join('\n');
}
/**
* 无纠错复核页时的修正提示词:把上一轮作答的答案组合发给 AI,指出组合有问题。
*/
function buildNoReviewCorrectionPrompt() {
const entries = Array.from(lastAttemptData.values());
entries.forEach(data => {
recordAttemptAnswer(failedAttemptAnswers, data, data.answer);
examAnswerMemory.delete(data.title);
});
const lines = entries.map((data, index) => {
const optionLines = data.options.map(option => ` ${option}`).join('\n');
return `第${index + 1}题:${data.title}\n选项:\n${optionLines}\n历轮未及格考试作答(未逐题确认):${describeAttemptHistory(failedAttemptAnswers, data)}\n历轮已确认错答:${describeAttemptHistory(confirmedWrongAnswers, data)}`;
});
return [
'以下是历次未及格考试的作答记录。缺少逐题复核,不能把所有历史作答都认定为错误;仅排除已确认错答。',
'',
...lines,
'',
'请按上述题目序号输出答案,格式:序号.字母选项(多选连续,如 2.ABC)。'
].join('\n');
}
/**
* 没有纠错复核页时的重试路径:把上一轮答案组合回传 AI 修正后,
* 返回考试列表从“待考试”第一个重新作答。
*/
function attemptRetryWithoutReview() {
const correctionGeneration = aiBatchGeneration;
const correctionRoute = window.location.hash;
const correctionCurrent = () => isServiceActive && aiBatchGeneration === correctionGeneration && window.location.hash === correctionRoute;
if (lastAttemptData.size === 0) {
console.warn('[Script] 无纠错复核页且无上一轮作答记录,放弃该场继续下一场。');
recordFailedExamAndContinue();
return;
}
debugLog(`[Script] 未找到纠错复核页,改用上一轮答案组合请求 AI 修正(共 ${lastAttemptData.size} 题)。`);
askAiForAnswer(buildNoReviewCorrectionPrompt(), EXAM_NO_REVIEW_CORRECTION_SYSTEM_PROMPT).then(correctionText => {
if (!correctionCurrent()) return;
const correctedMap = parseAnswersToMap(correctionText);
let applied = 0;
Array.from(lastAttemptData.values()).forEach((data, index) => {
const letters = correctedMap.get(index + 1);
if (letters && !isConfirmedWrongAnswer(data, letters)) {
examAnswerMemory.set(data.title, letters);
applied++;
} else {
console.warn(`[Script] AI 未返回第 ${index + 1} 题的修正答案(不保留旧组合,重试时由 AI 重答)。`);
}
});
persistExamAnswerMemory();
debugLog(`[Script] 无复核页纠错完成,已更新 ${applied} 道题答案,返回考试列表继续作答。`);
returnToExamListAfterReview();
}).catch(error => {
if (!correctionCurrent()) return;
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);
notifyBackgroundException('exam-failed');
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}`);
showAutomationNotice(`所有考试处理完成。\n\n${failedExamSummary.length} 场考试多次重试后仍未通过,平台可能要求重新学习对应课程:\n${names}`);
failedExamSummary = [];
} else {
debugLog('[Script] 全部考试处理完成,本批次均已通过。');
}
}
/**
* 复核页没有“重新考试”按钮:点击页面上的“返回”回到考试列表,
* 由主循环从“待考试”第一个(即刚答错的那场)继续自动开始作答。
* 保持失败处理占位,直到离开复核视图后再放行。
*/
function returnToExamListAfterReview() {
// 保持 isHandlingExamResult 为 true,避免切换期间失败弹窗/复核页残留被重复处理。
isHandlingExamResult = true;
const backButton = findButtonByExactText('返回') ||
findButtonByExactText('返回上一页') ||
findButtonByExactText('返回列表') ||
findButtonByExactText('重新考试') || // 兼容个别平台存在“重新考试”按钮的情况
findButtonByExactText('重新作答') ||
findButtonByExactText('重考');
if (backButton) {
debugLog('[Script] 点击“返回”,回到考试列表继续下一场作答。');
clickElement(backButton);
} else {
console.warn('[Script] 未找到“返回”按钮,直接导航回考试列表。');
window.location.href = currentExamListRoute;
}
currentQuestionBatchText = ''; // 确保新一轮题目会被重新处理
// 兜底:点击“返回”后若仍停留在考试页(例如回到结果页),强制返回考试列表。
scriptSetTimeout(() => {
if (window.location.hash.toLowerCase().includes('/examination')) {
debugLog(`[Script] 点击“返回”后仍停留在考试页,直接导航回考试列表:${currentExamListRoute}`);
window.location.href = currentExamListRoute;
}
}, 2500);
const startedAt = Date.now();
const releaseTimer = scriptSetInterval(() => {
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) {
scriptClearInterval(releaseTimer);
isHandlingExamResult = false;
if (reviewVisible || failureDialogVisible) {
console.warn('[Script] 等待离开复核页超时,已放行失败处理(若失败弹窗仍存在将再次进入重试链)。');
}
}
}, 1000);
}
/**
* 考试通过后的收尾:确保考试导航上下文,
* 若平台未自动跳转则直接返回考试列表,让主循环继续开始下一场考试。
*/
function proceedAfterExamPassed() {
// 确保返回考试列表后仍按“考试”流程处理,而不是被当作课程流程跳回课程页。
GM_setValue('sclpa_nav_context', 'exam');
currentNavContext = 'exam';
scriptSetTimeout(() => {
if (window.location.hash.toLowerCase().includes('/examination')) {
debugLog(`[Script] 考试结果页未自动跳转,直接返回考试列表:${currentExamListRoute}`);
window.location.href = currentExamListRoute;
} else {
debugLog('[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;
scriptSetTimeout(() => {
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) {
debugLog(`[Script] 公需课-${publicTarget} 的所有分类均未找到未完成内容,停止切换。`);
return false;
}
debugLog(`[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;
}
async function notifyBackgroundException(kind, force = false) {
const message = backgroundAlarmMessages[kind];
if (!message || (!force && !document.hidden)) return false;
const now = Date.now();
let last = backgroundAlarmTimes.get(kind) || 0;
try { last = Math.max(last, Number(sessionStorage.getItem('sclpa-alarm:' + kind)) || 0); } catch (_) {}
if (!force && (now - last < 300000 || backgroundNotificationsPending.has(kind))) return false;
if (!force) backgroundNotificationsPending.add(kind);
showAutomationNotice(message);
const tag = 'sclpa-learning-' + kind + '-' + now + '-' + (++backgroundNotificationSequence);
const markAccepted = () => {
if (force) return;
const acceptedAt = Date.now();
backgroundAlarmTimes.set(kind, acceptedAt);
try { sessionStorage.setItem('sclpa-alarm:' + kind, String(acceptedAt)); } catch (_) {}
};
const clearFailedCooldown = () => {
if (force) return;
backgroundAlarmTimes.delete(kind);
try { sessionStorage.removeItem('sclpa-alarm:' + kind); } catch (_) {}
};
const focusPage = () => { window.focus(); };
const nativeNotification = () => {
try {
if (typeof Notification !== 'function' || Notification.permission !== 'granted') return false;
const notice = new Notification('执业药师:学习异常提醒', { body: message, tag, requireInteraction: true, silent: false });
notice.onclick = () => { focusPage(); notice.close(); };
notice.onerror = () => {
clearFailedCooldown();
showAutomationNotice(message + '\n桌面通知未能显示,请检查浏览器和系统通知设置。');
};
return true;
} catch (_) { return false; }
};
try {
if (typeof GM_notification === 'function') {
let waitTimer = null;
try {
// 等脚本初始化完成后使用内部调度,避免启动阶段回退通知触及未初始化的计时器。
await Promise.resolve();
const result = await Promise.race([
Promise.resolve(GM_notification({ title: '执业药师:学习异常提醒', text: message, tag,
silent: false, highlight: true, timeout: 0, onclick: focusPage })),
new Promise((_, reject) => {
waitTimer = scriptSetTimeout(() => reject(new Error('notification request timeout')), 5000);
})
]);
if (result !== false) { markAccepted(); return true; }
} catch (_) {
// 失败时尝试已授权的原生通知,不将失败写入去重记录。
} finally {
if (waitTimer !== null) scriptClearTimeout(waitTimer);
}
}
if (nativeNotification()) { markAccepted(); return true; }
showAutomationNotice(message + '\n桌面通知不可用,请点击设置中的“测试桌面通知”检查权限。');
return false;
} finally {
if (!force) backgroundNotificationsPending.delete(kind);
}
}
function dismissAutomationNotice(expected = pendingAutomationNotice) {
if (expected !== pendingAutomationNotice) return;
pendingAutomationNotice = null;
if (automationNoticeTimer !== null) scriptClearTimeout(automationNoticeTimer);
automationNoticeTimer = null;
document.getElementById('sclpa-automation-notice')?.remove();
}
function renderAutomationNotice() {
const state = pendingAutomationNotice;
if (!state) return;
if (state.hideAt && state.hideAt <= Date.now()) { dismissAutomationNotice(state); return; }
const host = document.querySelector('#tab-control') || document.body;
if (!host) return;
let notice = document.getElementById('sclpa-automation-notice');
if (!notice) {
notice = document.createElement('div');
notice.id = 'sclpa-automation-notice';
notice.setAttribute('role', 'status');
notice.style.cssText = 'margin:12px 0;padding:12px;background:#E8F3FF;border:1px solid #0078D4;border-radius:6px;white-space:pre-wrap;';
}
host.appendChild(notice);
notice.replaceChildren();
const text = document.createElement('div');
text.textContent = state.message;
notice.appendChild(text);
if (!state.hideAt) {
const close = document.createElement('button');
close.type = 'button';
close.textContent = '我知道了';
close.style.cssText = 'margin-top:10px;padding:6px 12px;cursor:pointer;';
close.onclick = () => dismissAutomationNotice(state);
notice.appendChild(close);
} else if (automationNoticeTimer === null) {
automationNoticeTimer = scriptSetTimeout(() => dismissAutomationNotice(state), Math.max(0, state.hideAt - Date.now()));
}
}
function showAutomationNotice(message, { autoHideMs = 0 } = {}) {
if (automationNoticeTimer !== null) scriptClearTimeout(automationNoticeTimer);
automationNoticeTimer = null;
pendingAutomationNotice = { message, hideAt: autoHideMs > 0 ? Date.now() + autoHideMs : 0 };
console.info('[Script] ' + message);
renderAutomationNotice();
}
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;
debugLog(`[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();
debugLog(`[Script] 全能托管进入阶段:${phase.label}。`);
// 公需课视频与文章共用同一 SPA 路由;同路由切换时不能等待 hashchange。
const targetHash = new URL(phase.url).hash.toLowerCase();
if (window.location.hash.toLowerCase() === targetHash) {
allInOneTransitionPending = false;
return true;
}
window.location.href = phase.url;
return true;
}
function setRuntimeServiceActive(active, message = '') {
isServiceActive = Boolean(active);
if (!isServiceActive) {
aiBatchGeneration++;
isAiAnswerPending = false;
isSubmittingExam = false;
isHandlingExamResult = false;
currentQuestionBatchText = '';
}
GM_setValue('sclpa_service_active', isServiceActive);
if (!isServiceActive) disposeVideoSpeedEngine?.();
runtimeKeepAwake.sync();
const button = document.getElementById('service-toggle-btn');
if (button) { button.textContent = isServiceActive ? '⏸️ 暂停服务' : '▶️ 启动服务'; button.className = 'panel-btn ' + (isServiceActive ? 'service-btn-active' : 'service-btn-paused'); }
const dot = document.getElementById('status-dot');
if (dot) dot.className = 'status-dot ' + (isServiceActive ? 'active' : 'paused');
const status = document.getElementById('status-text');
if (status) status.textContent = message || (isServiceActive ? '服务运行中' : '服务已暂停');
if (isServiceActive) initializeEnhancedVideoSpeedEngine();
}
function startAllInOneMode() {
setRuntimeServiceActive(true);
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('所有阶段已完成');
setRuntimeServiceActive(false, '全部任务已处理,请核对学习进度和考试结果');
const failures = failedExamSummary.length;
showAutomationNotice(failures ? '全能托管已处理完毕,但仍有 ' + failures + ' 场考试未通过,请核对平台记录并重新处理。' : '✅ 全能托管已处理完毕,请核对平台的学习进度和考试结果。');
if (failures) notifyBackgroundException('exam-failed');
failedExamSummary = [];
return true;
}
allInOnePhase = nextPhase.id;
GM_setValue('sclpa_all_in_one_phase', allInOnePhase);
publicCourseTraversalTarget = '';
exhaustedPublicCourseCategories.clear();
debugLog(`[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) {
debugLog('[Script] Mode switcher panel already created, skipping.');
return;
}
isModePanelCreated = true;
debugLog('[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);
if (pendingAutomationNotice) renderAutomationNotice();
debugLog('[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');
};
});
document.getElementById('test-background-notification-btn')?.addEventListener('click', async () => {
try {
if (typeof GM_notification !== 'function' && typeof Notification === 'function' && Notification.permission === 'default') {
await Notification.requestPermission();
}
if (await notifyBackgroundException('test', true)) {
showAutomationNotice('已请求发送测试通知。若未显示,请检查浏览器和系统通知设置。', { autoHideMs: 8000 });
}
} catch (_) { showAutomationNotice('通知权限未能启用,请检查浏览器和系统通知设置。'); }
});
// 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 = () => {
const active = !isServiceActive;
if (active) resetVideoLoadFailures();
setRuntimeServiceActive(active);
if (active) mainLoop();
};
}
// 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);
debugLog(`[Script] 学习倍速设置为: ${newRate}x,应用到视频和文章计时。`);
applyCurrentVideoSpeed();
if (!runtimeUiState.speedChangeAlertShown) {
scriptSetTimeout(() => {
showAutomationNotice(`学习倍速已更新为 ${newRate}x。`, { autoHideMs: 6000 });
runtimeUiState.speedChangeAlertShown = true;
GM_setValue('sclpa_speed_alert_shown', true);
}, 100);
}
});
}
// 重试次数 0–3:0 跳过错题重试,默认 3 次。
const examRetryInput = document.getElementById('exam-retry-input');
if (examRetryInput) {
examRetryInput.addEventListener('change', () => {
const value = normalizeExamRetryLimit(examRetryInput.value);
examRetryInput.value = value;
GM_setValue('sclpa_exam_max_retries', value);
debugLog(`[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;
aiApiKey = newKey;
const aiSubmit = document.getElementById('ai-helper-submit-btn');
if (aiSubmit) aiSubmit.disabled = false;
const aiWarning = document.getElementById('ai-key-warning');
if (aiWarning) aiWarning.style.display = 'none';
if (apiKeyStatus) {
apiKeyStatus.className = 'api-key-status configured';
apiKeyStatus.innerHTML = '✅ API Key 已保存!';
}
scriptSetTimeout(() => {
aiApiKey = apiKeyInput.value.trim();
CONFIG.AI_API_SETTINGS.API_KEY = aiApiKey;
showAutomationNotice('API Key 已保存并应用。', { autoHideMs: 6000 });
}, 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'));
}
debugLog('[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();
debugLog('[Script] Removed existing AI helper panel.');
}
debugLog('[Script] Attempting to create Modern AI Helper Panel...');
try {
if (!isAiStyleInstalled) {
GM_addStyle(`
/* AI Helper Panel - Fluent Design */
#ai-helper-panel {
position: fixed;
box-sizing: border-box;
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;
}
@media (max-width: 860px) {
#ai-helper-panel { right: 12px; max-width: calc(100vw - 24px); }
}
`);
isAiStyleInstalled = true;
}
const panel = document.createElement('div');
panel.id = 'ai-helper-panel';
panel.innerHTML = `
⚠️ 请先在控制面板的"设置"标签页中配置您的 DeepSeek API Key
💬 AI 回答:
请在上方输入您的问题...
`;
if (document.body) {
document.body.appendChild(panel);
debugLog('[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 (!CONFIG.AI_API_SETTINGS.API_KEY || CONFIG.AI_API_SETTINGS.API_KEY.includes('请在此处')) {
resultDiv.innerHTML = '❌ 错误:请先在控制面板中设置您的 DeepSeek API Key!';
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = 'AI思考中...';
resultDiv.innerHTML = '正在向AI发送请求...
';
try {
const answer = await askAiForAnswer(question);
resultDiv.textContent = String(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'));
}
debugLog('[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;
const expectedRoute = courseType === '公需课' ? '/publicdemand' : '/specialized';
if (!window.location.hash.toLowerCase().includes(expectedRoute)) return;
debugLog(`[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')) {
debugLog(`[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)) {
debugLog('[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 = () => {
debugLog('[Script] Course List: Waiting after clicking "未完成" tab, then re-evaluating...');
handleCourseListPage(courseType);
};
if (courseType === '公需课') {
schedulePublicCourseListAction(continueAfterFilter, 3000);
} else {
scriptSetTimeout(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 = () => {
if (!isServiceActive) return;
if (!window.location.hash.toLowerCase().includes(expectedRoute)) return;
if (Array.from(document.querySelectorAll('.el-loading-mask')).some(isElementVisible)) return;
let targetCourseElement = Array.from(document.querySelectorAll('.play-card')).find(card =>
!card.querySelector('.el-icon-success') && !card.innerText.includes('已完成') &&
!(courseType === '公需课' && isBlockedPublicVideoCard(card))
);
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) {
emptyCourseObservation = null;
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()}`);
}
debugLog(`[Script] ${courseType}: Found the first unfinished item, clicking to enter study...`);
const clickableElement = targetCourseElement.querySelector('.play-card-box-right-text') || targetCourseElement;
clickElement(clickableElement);
} else {
const category = getPublicCourseCategoryTabs().find(tab => tab.classList.contains('radio-tab-tag-ed'))?.innerText.trim() || '';
const emptyKey = window.location.hash + '|' + courseType + '|' + GM_getValue('sclpa_public_target', 'video') + '|' + category;
if (emptyCourseObservation?.key !== emptyKey) {
emptyCourseObservation = { key: emptyKey, since: performance.now() };
return;
}
if (performance.now() - emptyCourseObservation.since < 4000) return;
debugLog('[Script] 未完成列表持续为空,继续检查后续分类或阶段。');
if (courseType === '公需课') {
const moved = moveToNextPublicCourseCategory();
if (!moved) {
const publicTarget = GM_getValue('sclpa_public_target', 'video');
if (publicTarget === 'video' && Object.values(videoLoadFailures).some(record => !record.professional && record.blocked)) {
pauseForVideoLoadFailures();
return;
}
advanceAllInOnePhaseIfExpected(publicTarget === 'article' ? 'public-article' : 'public-video');
}
} else {
advanceAllInOnePhaseIfExpected('specialized-video');
}
}
};
if (courseType === '公需课') {
schedulePublicCourseListAction(findAndEnterCourse, 1500);
} else {
scriptSetTimeout(findAndEnterCourse, 1500);
}
}
}
/**
* Main handler for learning page
*/
function handleLearningPage() {
if (!isServiceActive) return;
debugLog('[Script] handleLearningPage called.');
if (/\/(?:openplayer|majorplayerpage)(?:[/?]|$)/i.test(window.location.hash) &&
handleVideoLoading(document.querySelector('video'))) return;
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 if (/\/(?:imageandtext|articleplayerpage)(?:[/?]|$)/i.test(window.location.hash)) {
handleArticleReadingPage();
} else {
// 视频页面异步挂载播放器;尚无 video 时应等待,不能按文章完成状态返回列表。
debugLog('[Script] Waiting for video player to mount.');
}
}
}
/**
* [FIXED] Handle multi-chapter courses (professional courses)
* @param {NodeListOf} directoryItems
*/
function handleMultiChapterCourse(directoryItems) {
if (isChangingChapter) return;
debugLog('[Script] handleMultiChapterCourse called.');
const video = document.querySelector('video');
// [FIX] Ensure video object exists before proceeding
if (!video) {
debugLog('[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') && !isBlockedProfessionalChapter(item)) {
nextChapter = item;
break;
}
}
if (nextChapter) {
const isAlreadySelected = nextChapter.classList.contains('catalogue-item-ed');
if (isAlreadySelected) { // If it's the correct chapter but paused
debugLog('[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
debugLog('[Script] Moving to next chapter:', nextChapter.innerText.trim());
clickElement(nextChapter);
isChangingChapter = true;
scriptSetTimeout(() => { isChangingChapter = false; }, 4000); // Give time for chapter to load
}
} else {
if (Array.from(directoryItems).some(item => !item.querySelector('.el-icon-success') && isBlockedProfessionalChapter(item))) {
pauseForVideoLoadFailures();
return;
}
// All chapters have the success icon. The main loop will now handle navigation via handleMajorPlayerPage.
debugLog('[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) {
if (!video.dataset.singleVidControlled) {
video.addEventListener('ended', () => {
if (isServiceActive && hasConfirmedPublicVideoCompletion()) {
safeNavigateAfterCourseCompletion();
}
});
video.dataset.singleVidControlled = 'true';
debugLog('[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.ended) {
// 平台在 ended 后异步保存学习记录;播放器结束不等于记录已保存。
if (hasConfirmedPublicVideoCompletion()) safeNavigateAfterCourseCompletion();
return;
}
if (video.paused) {
debugLog('[Script] Single media video paused, attempting to play.');
video.play().catch(e => { console.error('[Script Error] Failed to play single media video:', e); });
}
}
function hasConfirmedPublicVideoCompletion() {
const activeItem = document.querySelector('.open-player-right-content .video-list-item.active');
if (!activeItem) return false;
const status = activeItem.querySelector('.list-item-status')?.innerText.trim();
const completedIcon = activeItem.querySelector('use');
const iconName = completedIcon?.getAttribute('xlink:href') || completedIcon?.getAttribute('href');
return status === '待考试' || status === '完成' || status === '已完成' || iconName === '#icon-wancheng';
}
function readVideoLoadFailures() {
try {
const stored = JSON.parse(sessionStorage.getItem(VIDEO_LOAD_FAILURES_KEY) || '{}');
if (stored && typeof stored === 'object' && !Array.isArray(stored)) return stored;
} catch (error) { /* 浏览器禁用会话存储时,仅保留当前页面的重试记录。 */ }
return {};
}
function saveVideoLoadFailures() {
try {
sessionStorage.setItem(VIDEO_LOAD_FAILURES_KEY, JSON.stringify(videoLoadFailures));
return true;
} catch (error) { return false; }
}
function resetVideoLoadFailures() {
videoLoadFailures = {};
videoLoadingState = null;
saveVideoLoadFailures();
}
function publicVideoTitle(element) {
return (element?.querySelector('.title')?.textContent || element?.textContent || '').replace(/\s+/g, '').trim();
}
function isBlockedPublicVideoTitle(title) {
return Boolean(title && Object.values(videoLoadFailures).some(record => !record.professional && record.blocked && record.title === title));
}
function isBlockedPublicVideoCard(card) {
if (GM_getValue('sclpa_public_target', 'video') !== 'video') return false;
const text = card.textContent.replace(/\s+/g, '');
return Object.values(videoLoadFailures).some(record => !record.professional && record.blocked && record.title && text.includes(record.title));
}
function professionalChapterKey(item) {
// 仅在当前课程目录内关联章节,不保存学习者或接口参数。
const titles = Array.from(document.querySelectorAll('.catalogue-item')).map(publicVideoTitle);
return `professional:${JSON.stringify(titles)}:${publicVideoTitle(item)}`;
}
function isBlockedProfessionalChapter(item) {
return Boolean(videoLoadFailures[professionalChapterKey(item)]?.blocked);
}
function pauseForVideoLoadFailures() {
notifyBackgroundException('video-stopped');
setRuntimeServiceActive(false);
const status = document.getElementById('status-text');
if (status) status.textContent = '加载失败课程仍未完成;启动服务可重新尝试';
const button = document.getElementById('service-toggle-btn');
if (button) {
button.textContent = '▶️ 启动服务';
button.className = 'panel-btn service-btn-paused';
}
document.getElementById('status-dot')?.classList.remove('active');
}
function reconcileVideoLoadingIndicator(video, previousTime) {
if (!video || video.error || video.readyState < 2 || !video.videoWidth || !video.videoHeight) return;
const quality = video.getVideoPlaybackQuality?.();
const hasDecodedFrame = quality && quality.totalVideoFrames > quality.droppedVideoFrames;
if (!hasDecodedFrame && Math.abs(video.currentTime - previousTime) <= 0.05) return;
const component = video.closest('.video-box')?.__vue__;
if (component?.$options?.name !== 'videoPlayer' || !component.loadLoading ||
component.loadError || component.slideVerifyShow || component.toSeeEl) return;
if (!component.playerEl?.el()?.contains(video)) return;
// 网站仅在 loadedmetadata 回调中关闭遮罩;已解码/播放的视频不能继续被旧加载状态遮住。
component.loadLoading = false;
debugLog('[Script] 视频已经可播放,校正网站残留的加载遮罩。');
}
// 只恢复实际加载停滞;不修改学习进度、不把失败课程标记为完成。
function handleVideoLoading(video) {
const params = new URLSearchParams(window.location.hash.split('?')[1] || '');
const professional = /\/majorplayerpage(?:[/?]|$)/i.test(window.location.hash);
if (professional && isChangingChapter) return true;
const activeItem = document.querySelector(professional ? '.catalogue-item-ed' : '.open-player-right-content .video-list-item.active');
const title = activeItem ? publicVideoTitle(activeItem) : '';
const courseId = params.get('courseContId');
const key = professional ? (activeItem ? professionalChapterKey(activeItem) : 'professional:player-mount') : courseId ? `course:${courseId}` : title ? `title:${title}` : 'public:player-mount';
if (!key) return false;
const now = performance.now();
const time = video?.currentTime || 0;
if (!videoLoadingState || videoLoadingState.key !== key) {
videoLoadingState = { key, lastTime: time, lastProgressAt: now };
}
const state = videoLoadingState;
const pendingRequest = Array.from(document.querySelectorAll('.el-loading-mask')).some(isElementVisible);
if (!pendingRequest) reconcileVideoLoadingIndicator(video, state.lastTime);
if (!pendingRequest && video?.ended) return false;
if (!pendingRequest && video && !video.error &&
(Math.abs(time - state.lastTime) > 0.05 || (video.paused && video.readyState >= 3))) {
state.lastTime = time;
state.lastProgressAt = now;
return false;
}
state.lastTime = time;
if (now - state.lastProgressAt < VIDEO_LOAD_TIMEOUT_MS) return false;
const record = videoLoadFailures[key] || { retries: 0, title, professional, blocked: false };
if (title) record.title = title;
videoLoadFailures[key] = record;
state.lastProgressAt = now;
if (record.retries < VIDEO_LOAD_MAX_RETRIES) {
if (record.retries === 0) notifyBackgroundException('video-stalled');
record.retries++;
const persisted = saveVideoLoadFailures();
console.warn(`[Script] 视频加载超时,恢复尝试 ${record.retries}/${VIDEO_LOAD_MAX_RETRIES}。`);
if (persisted && (!video || pendingRequest || record.retries === VIDEO_LOAD_MAX_RETRIES)) {
window.location.reload();
} else if (video) {
video.load();
video.play().catch(() => {});
}
return true;
}
record.blocked = true;
saveVideoLoadFailures();
notifyBackgroundException('video-skipped');
console.warn('[Script] 此视频重试后仍加载失败,本轮暂时跳过,保留未完成状态。');
if (professional) {
const nextChapter = Array.from(document.querySelectorAll('.catalogue-item')).find(item =>
!item.classList.contains('catalogue-item-ed') && !item.querySelector('.el-icon-success') && !isBlockedProfessionalChapter(item));
videoLoadingState = null;
if (nextChapter) {
clickElement(nextChapter);
isChangingChapter = true;
scriptSetTimeout(() => { isChangingChapter = false; }, 4000);
} else pauseForVideoLoadFailures();
return true;
}
const next = Array.from(document.querySelectorAll('.open-player-right-content .video-list-item')).find(item => {
const status = item.querySelector('.list-item-status')?.textContent.trim() || '';
const icon = item.querySelector('use');
const completedIcon = (icon?.getAttribute('xlink:href') || icon?.getAttribute('href')) === '#icon-wancheng';
return !item.classList.contains('active') && !/待考试|完成|合格/.test(status) && !completedIcon &&
!isBlockedPublicVideoTitle(publicVideoTitle(item));
});
videoLoadingState = null;
if (next) clickElement(next);
else if (record.title) safeNavigateBackToList();
else pauseForVideoLoadFailures();
const status = document.getElementById('status-text');
if (isServiceActive && status) status.textContent = '运行中(有加载失败视频暂时跳过;重启服务可重试)';
return true;
}
/**
* Handle article reading page
*/
function handleArticleReadingPage() {
if (!/\/(?:imageandtext|articleplayerpage)(?:[/?]|$)/i.test(window.location.hash)) return;
debugLog('[Script] handleArticleReadingPage called.');
const progressLabel = document.querySelector('.action-btn .label');
const info = document.querySelector('.image-and-text')?.__vue__?.ImageAndTextInfo;
const confirmed = info ? Boolean(info.isWanCheng || info.isTongGuo)
: Boolean(progressLabel && /^(待考试|已完成|完成)$/.test(progressLabel.innerText.trim()));
if (confirmed) {
debugLog('[Script] Article study completed, preparing to return to list.');
safeNavigateAfterCourseCompletion();
} else {
debugLog('[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
debugLog('[Script] handleExamPage called.');
currentNavContext = GM_getValue('sclpa_nav_context', ''); // Ensure context is fresh
if (currentNavContext === 'course') {
debugLog('[Script] Current navigation context is "course". Ignoring exam automation and navigating back to course list.');
safeNavigateBackToList();
return;
}
if (isSubmittingExam) {
debugLog('[Script] Exam submission in progress, deferring AI processing.');
return;
}
if (!document.getElementById('ai-helper-panel')) {
createManualAiHelper();
scriptSetTimeout(() => {
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) {
debugLog('[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) {
debugLog('[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) {
debugLog('[Script] AI answer already pending for current question batch, skipping new query.');
} else if (fullQuestionBatchContent === currentQuestionBatchText) {
debugLog('[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)) {
debugLog('[Script] Using remembered answers to answer current page directly (auto retry round).');
currentQuestionBatchText = fullQuestionBatchContent;
if (!parseAndSelectAllAnswers('')) return;
const answerRoute = window.location.hash;
scriptSetTimeout(() => {
if (isCurrentAnswerBatch(answerRoute, fullQuestionBatchContent)) {
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) {
debugLog('[Script] AI helper elements missing. Cannot trigger AI.');
return;
}
currentQuestionBatchText = fullQuestionBatchContent; // Update current batch text
aiHelperTextarea.value = fullQuestionBatchContent + buildRetryHistoryContext(examinationItems); // Set textarea value with all questions
aiHelperResultDiv.innerText = '正在向AI发送请求...';
debugLog('[Script] New batch of exam questions copied to AI helper textarea, triggering AI query...');
isAiAnswerPending = true;
const answerRoute = window.location.hash;
const answerGeneration = ++aiBatchGeneration;
const answerDeadline = performance.now() + 150000;
const checkInterval = 500;
clickElement(aiHelperSubmitBtn);
const checkAiResult = scriptSetInterval(() => {
if (!isServiceActive || answerGeneration !== aiBatchGeneration || !isCurrentAnswerBatch(answerRoute, fullQuestionBatchContent)) {
scriptClearInterval(checkAiResult);
if (answerGeneration === aiBatchGeneration) isAiAnswerPending = false;
return;
}
if (aiHelperResultDiv.innerText.trim() && aiHelperResultDiv.innerText.trim() !== '正在向AI发送请求...' && aiHelperResultDiv.innerText.trim() !== '请先提问...') {
scriptClearInterval(checkAiResult);
isAiAnswerPending = false;
if (/错误|失败|超时|未配置|请先.*(?:设置|填写|配置)/.test(aiHelperResultDiv.innerText)) {
notifyBackgroundException('ai-failed');
console.warn('[Script] AI 没有返回可用答案,保留当前试卷等待处理。');
return;
}
debugLog('[Script] AI response received:', aiHelperResultDiv.innerText.trim());
if (!parseAndSelectAllAnswers(aiHelperResultDiv.innerText.trim())) return;
scriptSetTimeout(() => {
if (isCurrentAnswerBatch(answerRoute, fullQuestionBatchContent)) {
handleNextQuestionOrSubmitExam();
}
}, 1000);
} else if (performance.now() >= answerDeadline) {
scriptClearInterval(checkAiResult);
isAiAnswerPending = false;
debugLog('[Script] Timeout waiting for AI response for question batch.');
aiHelperResultDiv.innerText = 'AI请求超时,请手动重试。';
notifyBackgroundException('ai-timeout');
}
}, checkInterval);
}
function isCurrentAnswerBatch(route, batch) {
if (window.location.hash !== route || currentQuestionBatchText !== batch) return false;
const current = Array.from(document.querySelectorAll('.examination-body-item'))
.map(item => item.innerText.trim() + '\n\n').join('');
return current === batch;
}
/**
* 为单个题目按答案字母(如 "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) {
debugLog(`[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 answers = parseAnswersToMap(aiResponse);
const items = Array.from(document.querySelectorAll('.examination-body-item'));
const selections = [];
for (const item of items) {
const title = item.querySelector('.examination-body-title')?.innerText.trim() || '';
const number = title.match(/^(\d+)\s*[、..::))]/)?.[1];
const answer = canonicalAnswer(getRememberedAnswerForItem(item) || answers.get(Number(number)) || '');
const options = Array.from(item.querySelectorAll('.examination-check-item')).map(option => option.innerText.trim());
const question = { title: getNormalizedQuestionTitle(item), options };
if (!number || !answer || [...answer].some(letter => !options.some(text => text.startsWith(letter + '.'))) || isConfirmedWrongAnswer(question, answer)) {
showAutomationNotice('AI 答案缺失、选项无效或重复已确认错答,已停止自动交卷,请核对后重试。');
notifyBackgroundException('ai-failed');
return false;
}
selections.push([item, answer]);
}
if (!selections.length) return false;
selections.forEach(([item, answer]) => selectAnswersForItem(item, answer));
return true;
}
/**
* Handles navigation after answering a question: either to the next question or submits the exam.
*/
function handleNextQuestionOrSubmitExam() {
if (!window.location.hash.toLowerCase().includes('/examination')) return;
if (!isServiceActive || isSubmittingExam) {
debugLog('[Script] Service inactive or exam submission in progress, deferring next step.');
return;
}
debugLog('[Script] handleNextQuestionOrSubmitExam called.');
// First, try to find the "下一题" button
const nextQuestionButton = findElementByText('button span', '下一题');
if (nextQuestionButton) {
debugLog('[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) {
debugLog('[Script] "下一题" not found. Found "提交试卷" button, clicking it...');
isSubmittingExam = true;
clickElement(submitExamButton.closest('button'));
const submittedRoute = window.location.hash;
const submittedGeneration = aiBatchGeneration;
let resultChecks = 0;
const resultDeadline = performance.now() + 15000;
const resultCheckTimer = scriptSetInterval(() => {
if (!isServiceActive || window.location.hash !== submittedRoute || aiBatchGeneration !== submittedGeneration) {
scriptClearInterval(resultCheckTimer);
if (aiBatchGeneration === submittedGeneration) isSubmittingExam = false;
return;
}
if (hasFailedExamResult()) {
scriptClearInterval(resultCheckTimer);
handleFailedExamResult();
return;
}
// 若结果提示已出现但并非“未通过”,视为通过:确认完成、重置重试状态并自动返回列表继续下一场。
const resultTip = document.querySelector('.result-tip-content');
if (resultTip && isElementVisible(resultTip) && !resultTipTextIsFailure(resultTip.innerText) &&
/考试通过|考试合格|成绩合格|通过考试|及格/.test(resultTip.innerText)) {
scriptClearInterval(resultCheckTimer);
isSubmittingExam = false;
resetExamRetryState('检测到考试通过结果');
debugLog('[Script] 检测到考试通过结果,自动返回考试列表继续下一场考试。');
proceedAfterExamPassed();
return;
}
resultChecks++;
if (resultChecks >= 30 || performance.now() >= resultDeadline) {
scriptClearInterval(resultCheckTimer);
isSubmittingExam = false;
notifyBackgroundException('exam-unknown');
showAutomationNotice('未能确认考试结果,已停止自动跳转,请核对平台结果。');
resetExamRetryState('结果未知,等待人工确认');
}
}, 500);
} else {
debugLog('[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 hasConfirmedEmptyExamList() {
return Array.from(document.querySelectorAll('.el-table__empty-text, .el-empty__description, .no-data, .empty-data'))
.some(element => isElementVisible(element) && /暂无数据|暂无.*考试|没有.*考试/.test(element.innerText));
}
function handleExamListPage() {
if (!isServiceActive) return;
debugLog('[Script] handleExamListPage called.');
const currentHash = window.location.hash.toLowerCase();
if (!/\/(?:onlineexam|openonlineexam)(?:[/?]|$)/.test(currentHash)) return;
if (Array.from(document.querySelectorAll('.el-loading-mask')).some(isElementVisible)) return;
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') {
debugLog('[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)) {
debugLog('[Script] Found "待考试" tab, clicking it...');
clickElement(pendingExamTab);
// After clicking, wait for the content to load, then re-evaluate
scriptSetTimeout(() => {
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 && isElementVisible(emptyDataText) && emptyDataText.innerText.includes('暂无数据')) {
debugLog('[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
debugLog('[Script] "待考试" tab is active. Attempting to find "开始考试" button...');
attemptClickStartExamButton();
} else {
// 标签未挂载时不能认定列表为空。
if (!hasConfirmedEmptyExamList()) return;
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) {
debugLog('[Script] Found "开始考试" button, clicking it...');
lastStartedExamSignature = getExamRowSignature(targetButton);
examSummaryNotified = false; // 新一批开始,重置“全部完成”提示标记
clickElement(targetButton);
} else if (startExamButtons.length > 0) {
debugLog('[Script] 待考试列表中其余场次均已重试耗尽,停止自动考试并提示。');
if (handleAllInOneExamPhaseExhausted()) return;
notifyExamBatchFinished();
} else {
debugLog('[Script] "开始考试" button not found on the page.');
if (!hasConfirmedEmptyExamList()) return;
if (handleAllInOneExamPhaseExhausted()) return;
notifyExamBatchFinished();
}
}
/**
* Handle generic popups, including the "前往考试" popup after course completion.
*/
function handleGenericPopups() {
if (!isServiceActive || isPopupBeingHandled) return;
debugLog('[Script] handleGenericPopups called.');
if (hasFailedExamResult()) {
handleFailedExamResult();
return;
}
const currentHash = window.location.hash.toLowerCase(); // Get current hash here
const visibleMessageBox = Array.from(document.querySelectorAll('.el-message-box')).find(isElementVisible);
const examCompletionPopupMessage = visibleMessageBox?.querySelector('.el-message-box__message p');
const popupButtons = visibleMessageBox ? Array.from(visibleMessageBox.querySelectorAll('button')).filter(isElementVisible) : [];
const goToExamBtnInPopup = popupButtons.find(button => button.innerText.trim() === '前往考试');
const cancelBtnInPopup = popupButtons.find(button => button.innerText.trim() === '取消');
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') {
debugLog('[Script] Detected "恭喜您" completion popup on non-majorPlayerPage. Clicking "取消".');
isPopupBeingHandled = true;
clickElement(cancelBtnInPopup);
// 关闭仍可见的旧提示不代表新课程完成;视频必须确实结束才能返回列表。
const canLeaveCurrentCourse = /\/(?:imageandtext|articleplayerpage)(?:[/?]|$)/i.test(currentHash) ||
(currentHash.includes('/openplayer') && document.querySelector('video')?.ended);
if (canLeaveCurrentCourse) safeNavigateAfterCourseCompletion();
scriptSetTimeout(() => { isPopupBeingHandled = false; }, 1000); // Reset flag after delay
return;
}
}
const visibleDialogButtons = Array.from(document.querySelectorAll('.el-message-box, .el-dialog'))
.filter(isElementVisible)
.flatMap(dialog => Array.from(dialog.querySelectorAll('button')).filter(isElementVisible));
const genericBtn = visibleDialogButtons.find(button => button.innerText.trim() === '确定') ||
visibleDialogButtons.find(button => button.innerText.trim() === '进入下一节学习');
if (genericBtn) {
debugLog(`[Script] Detected generic popup button: ${genericBtn.innerText.trim()}. Clicking it.`);
isPopupBeingHandled = true;
clickElement(genericBtn);
scriptSetTimeout(() => { 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;
debugLog(`[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`;
}
debugLog(`[Script] 动态倍速应用完成: ${targetRate}x`);
}
/**
* 标准 HTML5 视频倍速引擎。
* 使用加载/播放事件、播放器实例级倍速保护和 MutationObserver 覆盖 SPA 重新挂载视频的场景;
* 不篡改浏览器原生属性描述符或页面计时器。
*/
function initializeEnhancedVideoSpeedEngine() {
if (!isServiceActive) return;
if (isVideoSpeedEngineInitialized && refreshVideoSpeedEngine) { refreshVideoSpeedEngine(); return; }
const videos = new Map();
const roots = new Map();
const frames = new Map();
let scanTimer = null;
let disposed = false;
const stats = { videos: 0, roots: 0, frames: 0, scans: 0 };
window.__sclpaVideoRuntime = stats;
function updateStats() { stats.videos = videos.size; stats.roots = roots.size; stats.frames = frames.size; }
function connectedDocument(owner) {
try {
while (owner !== document) {
const frame = owner.defaultView?.frameElement;
if (!frame?.isConnected) return false;
owner = frame.ownerDocument;
}
return true;
} catch (_) { return false; }
}
function attached(video) {
if (!video.isConnected) return false;
return connectedDocument(video.ownerDocument);
}
function apply(video) {
if (!isServiceActive || !attached(video)) return;
const rate = CONFIG.VIDEO_PLAYBACK_RATE;
try {
if (Math.abs(video.defaultPlaybackRate - rate) > 0.01) video.defaultPlaybackRate = rate;
if (Math.abs(video.playbackRate - rate) > 0.01) video.playbackRate = rate;
} catch (_) {}
}
function watch(video) {
if (videos.has(video) || !attached(video)) return;
let timer = null;
const original = Object.getOwnPropertyDescriptor(video, 'playbackRate');
const native = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'playbackRate');
const get = native?.get ? function() { return native.get.call(this); } : null;
if (get && native.set) {
try {
Object.defineProperty(video, 'playbackRate', { configurable: true, get,
set(value) {
const target = isServiceActive ? CONFIG.VIDEO_PLAYBACK_RATE : value;
if (Math.abs(native.get.call(this) - target) > 0.01) native.set.call(this, target);
}
});
} catch (_) {}
}
const reapply = () => {
if (!isServiceActive || timer !== null || disposed) return;
timer = scriptSetTimeout(() => { timer = null; apply(video); }, 100);
};
for (const event of ['loadedmetadata', 'canplay', 'playing', 'ratechange']) video.addEventListener(event, reapply);
installBackgroundPlaybackGuard(video, reapply);
videos.set(video, () => {
if (timer !== null) scriptClearTimeout(timer);
for (const event of ['loadedmetadata', 'canplay', 'playing', 'ratechange']) video.removeEventListener(event, reapply);
backgroundPlaybackGuards.get(video)?.();
backgroundPlaybackGuards.delete(video);
if (get && Object.getOwnPropertyDescriptor(video, 'playbackRate')?.get === get) {
try { if (original) Object.defineProperty(video, 'playbackRate', original); else delete video.playbackRate; } catch (_) {}
}
});
apply(video);
}
function queueScan() {
if (scanTimer !== null || disposed || !isServiceActive) return;
scanTimer = scriptSetTimeout(() => { scanTimer = null; scan(); }, 100);
}
function relevant(node) {
return node.nodeType === 1 && (node.matches('video,iframe') || node.shadowRoot || node.querySelector('video,iframe') || Array.from(node.querySelectorAll('*')).some(host => host.shadowRoot));
}
function observeRoot(root) {
if (roots.has(root)) return;
const observer = new MutationObserver(mutations => {
if (mutations.some(m => Array.from(m.removedNodes).some(relevant) || Array.from(m.addedNodes).some(relevant))) queueScan();
// 新增组件可能通过 Shadow DOM 挂载媒体,仅检查新子树。
for (const mutation of mutations) for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.shadowRoot) { observeRoot(node.shadowRoot); queueScan(); }
for (const host of node.querySelectorAll('*')) if (host.shadowRoot) { observeRoot(host.shadowRoot); queueScan(); }
}
});
observer.observe(root, { childList: true, subtree: true });
roots.set(root, observer);
}
function scan() {
if (disposed || !isServiceActive) return;
stats.scans++;
for (const [video, cleanup] of videos) if (!attached(video)) { cleanup(); videos.delete(video); }
for (const [frame, cleanup] of frames) if (!frame.isConnected) { cleanup(); frames.delete(frame); }
for (const [root, observer] of roots) {
const connected = root === document || (root.host ? root.host.isConnected && connectedDocument(root.host.ownerDocument) : connectedDocument(root));
if (!connected) { observer.disconnect(); roots.delete(root); }
}
for (const root of Array.from(roots.keys())) {
root.querySelectorAll('video').forEach(video => { watch(video); apply(video); });
for (const host of root.querySelectorAll('*')) if (host.shadowRoot) observeRoot(host.shadowRoot);
for (const frame of root.querySelectorAll('iframe')) {
if (!frames.has(frame)) {
const load = () => {
for (const [oldRoot, observer] of roots) if (oldRoot !== document && !oldRoot.host && oldRoot.defaultView?.frameElement === frame) {
observer.disconnect(); roots.delete(oldRoot);
}
queueScan();
};
frame.addEventListener('load', load);
frames.set(frame, () => frame.removeEventListener('load', load));
}
try { if (frame.contentDocument) observeRoot(frame.contentDocument); } catch (_) {}
}
}
// 新发现的同源 iframe / shadow root 在下一轮扫描应用,最多同时保留一个扫描任务。
const pendingMedia = Array.from(roots.keys()).some(root => Array.from(root.querySelectorAll('video')).some(video => !videos.has(video)));
if (pendingMedia) queueScan();
updateStats();
}
function dispose() {
disposed = true;
if (scanTimer !== null) scriptClearTimeout(scanTimer);
scanTimer = null;
for (const cleanup of videos.values()) cleanup();
for (const observer of roots.values()) observer.disconnect();
for (const cleanup of frames.values()) cleanup();
videos.clear(); roots.clear(); frames.clear(); updateStats();
isVideoSpeedEngineInitialized = false;
refreshVideoSpeedEngine = null;
disposeVideoSpeedEngine = null;
window.removeEventListener('pagehide', dispose);
}
observeRoot(document);
refreshVideoSpeedEngine = queueScan;
disposeVideoSpeedEngine = dispose;
isVideoSpeedEngineInitialized = true;
window.addEventListener('pagehide', dispose);
scan();
}
// ===================================================================================
/** 只识别平台文章的 100ms 阅读计时,复用平台回调和保存完成流程。 */
function initializeArticleTimingEngine() {
if (window.__sclpaArticleTimingEngine) return;
const siteWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const nativeSetInterval = siteWindow.setInterval;
const nativeClearInterval = siteWindow.clearInterval;
const records = new Map();
const engine = { records, acceleratedTicks: 0 };
window.__sclpaArticleTimingEngine = engine;
siteWindow.setInterval = function(callback, delay, ...args) {
let articleCallback = false;
if (typeof callback === 'function' && Number(delay) === 100 &&
/\/(?:imageandtext|articleplayerpage)(?:[/?]|$)/i.test(location.hash)) {
const source = Function.prototype.toString.call(callback);
articleCallback = source.includes('precossTime') && source.includes('ImageAndTextInfo') && source.includes('totalSeconds');
}
if (!articleCallback) return nativeSetInterval.call(this, callback, delay, ...args);
const record = { id: null, carry: 0, lastRate: 1, cancelled: false, lastTick: performance.now(), missingOwnerAt: null };
record.id = scriptSetInterval(function(...callbackArgs) {
const now = performance.now();
const elapsed = now - record.lastTick;
record.lastTick = now;
const component = document.querySelector('.image-and-text')?.__vue__;
const onArticle = /\/(?:imageandtext|articleplayerpage)(?:[/?]|$)/i.test(location.hash);
if (!component) record.missingOwnerAt ??= now; else record.missingOwnerAt = null;
if (!onArticle || (component?.countdown != null && component.countdown !== record.id) ||
(record.missingOwnerAt !== null && now - record.missingOwnerAt > 5000)) {
siteWindow.clearInterval(record.id);
return;
}
const active = GM_getValue('sclpa_service_active', true) && component?.countdown === record.id &&
/\/(?:imageandtext|articleplayerpage)(?:[/?]|$)/i.test(location.hash);
const requested = Number(GM_getValue('sclpa_playback_rate', 1));
const rate = active && Number.isFinite(requested) ? Math.min(16, Math.max(1, requested)) : 1;
if (record.lastRate !== rate) record.carry = 0;
record.lastRate = rate;
// 用真实执行间隔维持倍率;长时间冻结不补算为已学习,更不积压完成请求。
record.carry += rate * (elapsed > 2000 ? 1 : Math.max(0, elapsed / 100));
const ticks = Math.floor(record.carry);
record.carry -= ticks;
for (let index = 0; index < ticks && !record.cancelled; index++) {
callback.apply(this, callbackArgs);
if (rate > 1) engine.acceleratedTicks++;
}
}, delay, ...args);
records.set(record.id, record);
return record.id;
};
siteWindow.clearInterval = function(id) {
const record = records.get(id);
if (record) {
record.cancelled = true;
records.delete(id);
return scriptClearInterval(id);
}
return nativeClearInterval.call(this, id);
};
}
/**
* 后台播放守护。
* 不重写全局 Object.defineProperty,也不伪造 document.hidden;前者会阻断本脚本
* 为单个播放器安装的倍速 setter,后者会破坏站点自己的可见性逻辑。
*/
function initializeVideoPlaybackFixes() {
debugLog('[Script] 初始化后台播放守护:保留浏览器原生可见性与属性 API。');
}
/**
* 当站点在页面失焦或后台暂停视频时,尝试恢复播放与当前倍速。
* 浏览器仍可能对后台标签进行原生节流;本函数不会修改浏览器调度策略。
*/
function installBackgroundPlaybackGuard(video, reapplySpeed) {
if (!video || backgroundPlaybackGuards.has(video)) return;
video.dataset.sclpaBackgroundGuardInstalled = 'true';
let restoreTimer = null;
const restorePlayback = () => {
if (!isServiceActive || !video.isConnected || video.ended || restoreTimer) return;
restoreTimer = scriptSetTimeout(() => {
restoreTimer = null;
if (!isServiceActive || !video.isConnected || video.ended) return;
reapplySpeed();
if (video.paused) {
video.play().catch(error => {
console.debug('[Script] 后台恢复播放被浏览器拒绝:', error);
});
}
}, 150);
};
const handleVisibilityChange = () => {
if (video.ownerDocument.hidden) restorePlayback();
};
const handlePause = () => {
if (video.ownerDocument.hidden && !video.ended) restorePlayback();
};
const ownerDocument = video.ownerDocument;
const ownerWindow = ownerDocument.defaultView;
ownerDocument.addEventListener('visibilitychange', handleVisibilityChange, true);
window.addEventListener('blur', restorePlayback, true);
if (ownerWindow && ownerWindow !== window) ownerWindow.addEventListener('blur', restorePlayback, true);
video.addEventListener('pause', handlePause);
backgroundPlaybackGuards.set(video, () => {
scriptClearTimeout(restoreTimer);
ownerDocument.removeEventListener('visibilitychange', handleVisibilityChange, true);
window.removeEventListener('blur', restorePlayback, true);
if (ownerWindow && ownerWindow !== window) ownerWindow.removeEventListener('blur', restorePlayback, true);
video.removeEventListener('pause', handlePause);
delete video.dataset.sclpaBackgroundGuardInstalled;
});
}
function cleanupDetachedVideoGuards() {
for (const [video, cleanup] of backgroundPlaybackGuards) {
if (!video.isConnected) {
cleanup();
backgroundPlaybackGuards.delete(video);
}
}
}
/**
* 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';
debugLog(`[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
debugLog('[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) {
debugLog('[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 {
debugLog('[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')) {
debugLog('[Script] Professional Course completed. Awaiting main loop handler for navigation.');
} else {
// For public courses (or other non-majorPlayerPage players), use general navigation
debugLog('[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)
debugLog('[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 = Array.from(document.querySelectorAll('.el-message-box'))
.find(popup => isElementVisible(popup) && popup.innerText.includes('恭喜您已经完成所有课程学习'));
if (completionPopup && completionPopup.innerText.includes('恭喜您已经完成所有课程学习')) {
debugLog('[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();
debugLog('[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 {
debugLog('[Script] Router: No specific handler for current hash, idling.');
}
}
/**
* Main script loop, executed every 2 seconds
*/
function mainLoop() {
runtimeKeepAwake.sync();
debugLog('[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
debugLog(`[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;
aiBatchGeneration++;
isSubmittingExam = false;
debugLog('[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
debugLog('[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
*/
function startScript() {
debugLog(`[Script] Sichuan Licensed Pharmacist Continuing Education (v1.4.0-beta.1) started.`);
debugLog(`[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);
}
runtimeKeepAwake.sync();
// Start the main loop
scriptSetInterval(mainLoop, 2000);
debugLog('[Script] Main loop initiated.');
}
// 在站点创建文章计时器前安装;不启用旧的全局 Date/计时器加速。
initializeArticleTimingEngine();
// 脚本管理器可能在 load 已触发后注入;两种时机均走同一个启动入口。
if (document.readyState === 'complete') {
startScript();
} else {
window.addEventListener('load', startScript, { once: true });
}
})();