`;
} catch (error) {
resultDiv.innerHTML = `❌ 请求失败:${error}`;
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = '🚀向 AI 提问';
}
};
}
if (panel && document.getElementById('ai-helper-header')) {
makeDraggable(panel, document.getElementById('ai-helper-header'));
}
console.log('[Script] Modern AI Helper Panel creation attempted and event listeners attached.');
} catch (e) {
console.error('[Script Error] Error creating Modern AI Helper Panel:', e);
}
}
/**
* Make UI panel draggable
* @param {HTMLElement} panel - The panel element to be dragged.
* @param {HTMLElement} header - The header element that acts as the drag handle.
*/
function makeDraggable(panel, header) {
let isDragging = false, offsetX, offsetY;
header.addEventListener('mousedown', (e) => {
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT') return;
isDragging = true;
if (panel.style.bottom || panel.style.right) {
const rect = panel.getBoundingClientRect();
panel.style.top = `${rect.top}px`;
panel.style.left = `${rect.left}px`;
panel.style.bottom = '';
panel.style.right = '';
}
offsetX = e.clientX - parseFloat(panel.style.left);
offsetY = e.clientY - parseFloat(panel.style.top);
header.style.cursor = 'grabbing';
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const newX = e.clientX - offsetX;
const newY = e.clientY - offsetY;
panel.style.left = `${newX}px`;
panel.style.top = `${newY}px`;
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
header.style.cursor = 'move';
document.body.style.userSelect = '';
}
});
}
// ===================================================================================
// --- AI 调用 (AI Invocation) ---
// ===================================================================================
/**
* Send request to DeepSeek AI and get answer
* @param {string} question - User's question
* @returns {Promise}
*/
function askAiForAnswer(question) {
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 payload = {
model: "deepseek-chat",
messages: [{
"role": "system",
"content": "你是一个乐于助人的问题回答助手。聚焦于执业药师相关的内容,请根据用户提出的问题,提供准确、清晰的解答。注意回答时仅仅包括答案,不允许其他额外任何解释,输出为一行一道题目的答案,答案只能是题目序号:字母选项,不能包含文字内容。单选输出示例:1.A。多选输出示例:1.ABC。"
}, {
"role": "user",
"content": question
}],
temperature: 0.2
};
GM_xmlhttpRequest({
method: 'POST',
url: CONFIG.AI_API_SETTINGS.DEEPSEEK_API_URL,
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${CONFIG.AI_API_SETTINGS.API_KEY}` },
data: JSON.stringify(payload),
timeout: 20000,
onload: (response) => { try { const result = JSON.parse(response.responseText); if (result.choices && result.choices.length > 0) { resolve(result.choices[0].message.content.trim()); } else { reject('AI响应格式不正确。'); } } catch (e) { reject(`解析AI响应失败: ${e.message}`); } },
onerror: (err) => reject(`请求AI API网络错误: ${err.statusText || '未知错误'}`),
ontimeout: () => reject('请求AI API超时')
});
});
}
// ===================================================================================
// --- 页面逻辑处理 (Page-Specific Logic) ---
// ===================================================================================
/**
* Handle course list page, compatible with video and article
* @param {string} courseType - '专业课' or '公需课'.
*/
function handleCourseListPage(courseType) {
if (!isServiceActive) return;
console.log(`[Script] handleCourseListPage called for ${courseType}.`);
// Handle public course tab switching first
if (courseType === '公需课') {
const publicTarget = GM_getValue('sclpa_public_target', 'video');
const targetTabText = publicTarget === 'article' ? '文章资讯' : '视频课程';
const targetTab = findElementByText('.radioTab > .radio-tab-tag', targetTabText);
if (targetTab && !targetTab.classList.contains('radio-tab-tag-ed')) {
console.log(`[Script] Public Course: Target is ${targetTabText}, switching tab...`);
clickElement(targetTab);
// After clicking the tab, wait for content to load, then re-run this function
setTimeout(() => handleCourseListPage(courseType), 1000); // Re-evaluate after tab switch
return;
}
}
const unfinishedTab = findElementByText('div.radio-tab-tag', '未完成');
// Step 1: Click "未完成" tab if not already active
// Removed `!unfinishedTabClicked` to ensure it keeps trying to click until active
if (unfinishedTab && !isUnfinishedTabActive(unfinishedTab)) {
console.log('[Script] Course List: Found "未完成" tab and it is not active, clicking it...');
clickElement(unfinishedTab);
// Set unfinishedTabClicked to true only after a successful click attempt
// This flag is reset by mainLoop when hash changes to a list page.
unfinishedTabClicked = true;
// After clicking, wait for the page to filter/load the unfinished list
setTimeout(() => {
console.log('[Script] Course List: Waiting after clicking "未完成" tab, then re-evaluating...');
// After delay, re-call handleCourseListPage to re-check the active state and proceed.
handleCourseListPage(courseType);
}, 3000); // Increased delay to 3 seconds for tab content to load
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)) {
setTimeout(() => {
let targetCourseElement = document.querySelector('.play-card:not(:has(.el-icon-success))');
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) {
console.log(`[Script] ${courseType}: Found the first unfinished item, clicking to enter study...`);
const clickableElement = targetCourseElement.querySelector('.play-card-box-right-text') || targetCourseElement;
clickElement(clickableElement);
} else {
console.log(`[Script] ${courseType}: No unfinished items found on "未完成" page. All courses might be completed or elements not yet loaded.`);
}
}, 1500); // Delay before finding the course element
}
}
/**
* Main handler for learning page
*/
function handleLearningPage() {
if (!isServiceActive) return;
console.log('[Script] handleLearningPage called.');
if (!isTimeAccelerated) {
accelerateTime();
initializeEnhancedVideoSpeedEngine();
isTimeAccelerated = true;
}
const directoryItems = document.querySelectorAll('.catalogue-item');
if (directoryItems.length > 0) {
handleMultiChapterCourse(directoryItems);
} else {
const video = document.querySelector('video');
if (video) {
handleSingleMediaCourse(video);
} else {
handleArticleReadingPage();
}
}
}
/**
* [FIXED] Handle multi-chapter courses (professional courses)
* @param {NodeListOf} directoryItems
*/
function handleMultiChapterCourse(directoryItems) {
if (isChangingChapter) return;
console.log('[Script] handleMultiChapterCourse called.');
const video = document.querySelector('video');
// [FIX] Ensure video object exists before proceeding
if (!video) {
console.log('[Script] Video element not found, waiting...');
return;
}
// [FIX] Always set playbackRate and muted properties if video exists.
// This ensures the speed is applied even if the video is currently paused.
video.playbackRate = CONFIG.VIDEO_PLAYBACK_RATE;
video.muted = true;
// If video is playing, we've done our job for this cycle.
if (!video.paused) {
return;
}
// Logic to find the next unfinished chapter
let nextChapter = null;
for (const item of directoryItems) {
if (!item.querySelector('.el-icon-success')) {
nextChapter = item;
break;
}
}
if (nextChapter) {
const isAlreadySelected = nextChapter.classList.contains('catalogue-item-ed');
if (isAlreadySelected) { // If it's the correct chapter but paused
console.log('[Script] Current chapter is correct but video is paused, attempting to play.');
video.play().catch(e => { console.error('[Script Error] Failed to play video:', e); });
} else { // If we need to switch to the next chapter
console.log('[Script] Moving to next chapter:', nextChapter.innerText.trim());
clickElement(nextChapter);
isChangingChapter = true;
setTimeout(() => { isChangingChapter = false; }, 4000); // Give time for chapter to load
}
} else {
// All chapters have the success icon. The main loop will now handle navigation via handleMajorPlayerPage.
console.log('[Script] All chapters appear to be complete. The main loop will verify and navigate.');
}
}
/**
* [FIXED] Handle single media courses (public courses)
* @param {HTMLVideoElement} video
*/
function handleSingleMediaCourse(video) {
console.log('[Script] handleSingleMediaCourse called.');
if (!video.dataset.singleVidControlled) {
video.addEventListener('ended', safeNavigateAfterCourseCompletion);
video.dataset.singleVidControlled = 'true';
console.log('[Script] Added "ended" event listener for single media course.');
}
// [FIX] Always set playbackRate and muted properties.
video.playbackRate = CONFIG.VIDEO_PLAYBACK_RATE;
video.muted = true;
if (video.paused) {
console.log('[Script] Single media video paused, attempting to play.');
video.play().catch(e => { console.error('[Script Error] Failed to play single media video:', e); });
}
}
/**
* Handle article reading page
*/
function handleArticleReadingPage() {
console.log('[Script] handleArticleReadingPage called.');
const progressLabel = document.querySelector('.action-btn .label');
if (progressLabel && (progressLabel.innerText.includes('100') || progressLabel.innerText.includes('待考试'))) {
console.log('[Script] Article study completed, preparing to return to list.');
safeNavigateAfterCourseCompletion();
} else {
console.log('[Script] Article progress not yet 100% or "待考试".');
}
}
/**
* Handle exam page (where the actual questions are displayed)
* Automatically copies question to AI helper and processes the AI answer.
*/
function handleExamPage() {
if (!isServiceActive) return; // Only run if service is active
console.log('[Script] handleExamPage called.');
currentNavContext = GM_getValue('sclpa_nav_context', ''); // Ensure context is fresh
if (currentNavContext === 'course') {
console.log('[Script] Current navigation context is "course". Ignoring exam automation and navigating back to course list.');
safeNavigateBackToList();
return;
}
if (isSubmittingExam) {
console.log('[Script] Exam submission in progress, deferring AI processing.');
return;
}
if (!document.getElementById('ai-helper-panel')) {
createManualAiHelper();
setTimeout(() => {
triggerAiQuestionAndProcessAnswer();
}, 500);
} else {
triggerAiQuestionAndProcessAnswer();
}
}
/**
* Gathers all questions and options from the current exam page,
* sends them to AI, and waits for the response to select answers.
*/
async function triggerAiQuestionAndProcessAnswer() {
const examinationItems = document.querySelectorAll('.examination-body-item');
const aiHelperTextarea = document.getElementById('ai-helper-textarea');
const aiHelperSubmitBtn = document.getElementById('ai-helper-submit-btn');
const aiHelperResultDiv = document.getElementById('ai-helper-result');
if (examinationItems.length === 0 || !aiHelperTextarea || !aiHelperSubmitBtn || !aiHelperResultDiv) {
console.log('[Script] No examination items found or AI helper elements missing. Cannot trigger AI.');
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) {
currentQuestionBatchText = fullQuestionBatchContent; // Update current batch text
aiHelperTextarea.value = fullQuestionBatchContent; // Set textarea value with all questions
aiHelperResultDiv.innerText = '正在向AI发送请求...';
console.log('[Script] New batch of exam questions copied to AI helper textarea, triggering AI query...');
isAiAnswerPending = true;
clickElement(aiHelperSubmitBtn);
let attempts = 0;
const maxAttempts = 300; // Max 300 attempts * 500ms = 60 seconds
const checkInterval = 500;
const checkAiResult = setInterval(() => {
if (aiHelperResultDiv.innerText.trim() && aiHelperResultDiv.innerText.trim() !== '正在向AI发送请求...' && aiHelperResultDiv.innerText.trim() !== '请先提问...') {
clearInterval(checkAiResult);
isAiAnswerPending = false;
console.log('[Script] AI response received:', aiHelperResultDiv.innerText.trim());
parseAndSelectAllAnswers(aiHelperResultDiv.innerText.trim()); // Call new function to handle all answers
setTimeout(() => {
handleNextQuestionOrSubmitExam(); // After all answers are selected, move to next step
}, 1000);
} else if (attempts >= maxAttempts) {
clearInterval(checkAiResult);
isAiAnswerPending = false;
console.log('[Script] Timeout waiting for AI response for question batch.');
aiHelperResultDiv.innerText = 'AI请求超时,请手动重试。';
setTimeout(() => {
handleNextQuestionOrSubmitExam();
}, 1000);
}
attempts++;
}, checkInterval);
} else if (isAiAnswerPending) {
console.log('[Script] AI answer already pending for current question batch, skipping new query.');
} else if (fullQuestionBatchContent === currentQuestionBatchText) {
console.log('[Script] Question batch content has not changed, skipping AI query.');
}
}
/**
* Parses the AI response and automatically selects the corresponding options for all questions on the exam page.
* @param {string} aiResponse - The raw response string from the AI (e.g., "1.A\n2.BC\n3.D").
*/
function parseAndSelectAllAnswers(aiResponse) {
const aiAnswerLines = aiResponse.split('\n').map(line => line.trim()).filter(line => line.length > 0);
const examinationItems = document.querySelectorAll('.examination-body-item');
const aiAnswersMap = new Map(); // Map to store {questionNumber: answerLetters}
aiAnswerLines.forEach(line => {
const parts = line.split('.');
if (parts.length >= 2) {
const qNum = parseInt(parts[0]);
const ansLetters = parts[1].toUpperCase();
if (!isNaN(qNum) && ansLetters) {
aiAnswersMap.set(qNum, ansLetters);
} else {
console.warn(`[Script] Invalid AI response line format or content: ${line}`);
}
} else {
console.warn(`[Script] Invalid AI response line format: ${line}`);
}
});
examinationItems.forEach(item => {
const questionTitleElement = item.querySelector('.examination-body-title');
if (questionTitleElement) {
const match = questionTitleElement.innerText.trim().match(/^(\d+)、/);
const questionNumber = match ? parseInt(match[1]) : null;
if (questionNumber !== null && aiAnswersMap.has(questionNumber)) {
const answerLetters = aiAnswersMap.get(questionNumber);
console.log(`[Script] Processing Q${questionNumber}: Selecting options ${answerLetters}`);
for (const letter of answerLetters) {
const optionText = `${letter}.`;
// Find options specific to this question item
const optionElement = Array.from(item.querySelectorAll('.examination-check-item')).find(el =>
el.innerText.trim().startsWith(optionText)
);
if (optionElement) {
console.log(`[Script] Selecting option: ${letter} for Q${questionNumber}`);
clickElement(optionElement);
} else {
console.warn(`[Script] Option '${letter}' not found for Q${questionNumber} using text '${optionText}'.`);
}
}
} else if (questionNumber === null) {
console.warn('[Script] Could not extract question number from item:', item.innerText.trim().substring(0, 50) + '...');
} else {
console.log(`[Script] No AI answer found for Q${questionNumber} in AI response. Skipping.`);
}
}
});
console.log('[Script] Finished parsing and selecting all answers on current page.');
}
/**
* Handles navigation after answering a question: either to the next question or submits the exam.
*/
function handleNextQuestionOrSubmitExam() {
if (!isServiceActive || isSubmittingExam) {
console.log('[Script] Service inactive or exam submission in progress, deferring next step.');
return;
}
console.log('[Script] handleNextQuestionOrSubmitExam called.');
// First, try to find the "下一题" button
const nextQuestionButton = findElementByText('button span', '下一题');
if (nextQuestionButton) {
console.log('[Script] Found "下一题" button, clicking it...');
clickElement(nextQuestionButton.closest('button'));
// After clicking "下一题", the page should load the next question batch.
// mainLoop will detect hash change and re-trigger handleExamPage,
// or if on the same hash but content changed, triggerAiQuestionAndProcessAnswer will detect new questions.
// Reset question batch text to ensure new questions are processed
currentQuestionBatchText = '';
} else {
// If "下一题" not found, try to find "提交试卷"
const submitExamButton = findElementByText('button.submit-btn span', '提交试卷');
if (submitExamButton) {
console.log('[Script] "下一题" not found. Found "提交试卷" button, clicking it...');
isSubmittingExam = true;
clickElement(submitExamButton.closest('button'));
setTimeout(() => {
console.log('[Script] Exam submitted. Navigating back to exam list page...');
const hash = window.location.hash.toLowerCase();
const returnUrl = hash.includes('openonlineexam')
? 'https://zyys.ihehang.com/#/openOnlineExam'
: 'https://zyys.ihehang.com/#/onlineExam';
window.location.href = returnUrl;
isSubmittingExam = false;
currentQuestionBatchText = ''; // Clear for next exam cycle
}, 3000);
} else {
console.log('[Script] Neither "下一题" nor "提交试卷" button found. Check page state or selectors.');
}
}
}
/**
* Handle exam list page (e.g., #/onlineExam or #/openOnlineExam)
* This function will find and click the "待考试" tab if it's not already active,
* then find and click the "开始考试" button for the first pending exam.
*/
function handleExamListPage() {
if (!isServiceActive) return;
console.log('[Script] handleExamListPage called.');
const currentHash = window.location.hash.toLowerCase();
currentNavContext = GM_getValue('sclpa_nav_context', '');
// If the context is 'course', we should not be automating exams. Navigate back.
if (currentNavContext === 'course') {
console.log('[Script] Current navigation context is "course". Ignoring exam automation and navigating back to course list.');
safeNavigateBackToList();
return;
}
const pendingExamTab = findElementByText('div.radio-tab-tag', '待考试');
if (pendingExamTab && !isUnfinishedTabActive(pendingExamTab)) {
console.log('[Script] Found "待考试" tab, clicking it...');
clickElement(pendingExamTab);
// After clicking, wait for the content to load, then re-evaluate
setTimeout(() => {
handleExamListPage();
}, 2500);
return;
} else if (pendingExamTab && isUnfinishedTabActive(pendingExamTab)) {
// Check for "暂无数据" if on professional exam page
if (currentHash.includes('/onlineexam')) {
const emptyDataText = document.querySelector('.el-table__empty-text');
if (emptyDataText && emptyDataText.innerText.includes('暂无数据')) {
console.log('[Script] Professional Exam List: Detected "暂无数据". Switching to Public Exam List.');
window.location.href = 'https://zyys.ihehang.com/#/openOnlineExam';
return; // Exit after navigation
}
}
// If not "暂无数据" or on public exam page, attempt to start exam
console.log('[Script] "待考试" tab is active. Attempting to find "开始考试" button...');
attemptClickStartExamButton();
} else {
console.log('[Script] No "待考试" tab or pending exam found. All exams might be completed.');
// If all exams are completed, or no pending tab, the script will idle here.
}
}
/**
* Attempts to find and click the "开始考试" button for the first available exam.
*/
function attemptClickStartExamButton() {
const startExamButton = findElementByText('button.el-button--danger span', '开始考试');
if (startExamButton) {
console.log('[Script] Found "开始考试" button, clicking it...');
clickElement(startExamButton.closest('button'));
} else {
console.log('[Script] "开始考试" button not found on the page.');
}
}
/**
* Handle generic popups, including the "前往考试" popup after course completion.
*/
function handleGenericPopups() {
if (!isServiceActive || isPopupBeingHandled) return;
console.log('[Script] handleGenericPopups called.');
const currentHash = window.location.hash.toLowerCase(); // Get current hash here
const examCompletionPopupMessage = document.querySelector('.el-message-box__message p');
const goToExamBtnInPopup = findElementByText('button.el-button--primary span', '前往考试');
const cancelBtnInPopup = findElementByText('button.el-button--default span', '取消');
if (examCompletionPopupMessage && examCompletionPopupMessage.innerText.includes('恭喜您已经完成所有课程学习') && goToExamBtnInPopup && cancelBtnInPopup) {
// If on major player page, the new dedicated handler will manage this popup.
if (currentHash.includes('/majorplayerpage')) {
return;
}
currentNavContext = GM_getValue('sclpa_nav_context', '');
// Only handle this popup for course completion context on non-majorPlayerPage
if (currentNavContext === 'course') {
console.log('[Script] Detected "恭喜您" completion popup on non-majorPlayerPage. Clicking "取消".');
isPopupBeingHandled = true;
clickElement(cancelBtnInPopup.closest('button'));
setTimeout(() => { isPopupBeingHandled = false; }, 1000); // Reset flag after delay
return;
}
}
const genericBtn = findElementByText('button span', '确定') || findElementByText('button span', '进入下一节学习');
if (genericBtn) {
console.log(`[Script] Detected generic popup button: ${genericBtn.innerText.trim()}. Clicking it.`);
isPopupBeingHandled = true;
clickElement(genericBtn.closest('button'));
setTimeout(() => { isPopupBeingHandled = false; }, 2500);
}
}
// ===================================================================================
// --- 核心自动化 (Core Automation) ---
// ===================================================================================
/**
* [动态倍速应用器] 立即将当前配置的倍速应用到所有视频
* 允许在不重新加载页面的情况下动态调整倍速
*/
function applyCurrentVideoSpeed() {
const targetRate = GM_getValue('sclpa_playback_rate', 1.0);
CONFIG.VIDEO_PLAYBACK_RATE = targetRate;
currentPlaybackRate = targetRate;
function applyToVideo(video) {
if (!video || video.nodeType !== Node.ELEMENT_NODE) return;
const currentRate = video.playbackRate;
if (Math.abs(currentRate - targetRate) > 0.01) {
try {
video.playbackRate = targetRate;
console.log(`[Script] 动态应用倍速: ${targetRate}x (从 ${currentRate}x 调整)`);
} catch (e) {
console.warn('[Script] 应用倍速失败:', e);
}
}
}
document.querySelectorAll('video').forEach(video => applyToVideo(video));
try {
document.querySelectorAll('iframe').forEach(iframe => {
iframe.contentDocument?.querySelectorAll('video').forEach(video => applyToVideo(video));
});
} catch (e) {
}
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
el.shadowRoot.querySelectorAll('video').forEach(video => applyToVideo(video));
}
});
if (isTimeAccelerated) {
console.log(`[Script] 重新初始化增强版倍速引擎,倍速: ${targetRate}x`);
initializeEnhancedVideoSpeedEngine();
}
const speedDisplay = document.getElementById('speed-display');
if (speedDisplay) {
speedDisplay.textContent = `${targetRate}x`;
}
console.log(`[Script] 动态倍速应用完成: ${targetRate}x`);
}
/**
* [增强版视频倍速引擎 v2] 专门针对HTML5视频播放器的高强度倍速控制
* 参考time.user.js和time-hooker的VideoSpeedModule实现
* 增强功能:防止视频暂停、自动恢复播放、多重防护
*/
function initializeEnhancedVideoSpeedEngine() {
console.log(`[Script] Enhanced HTML5 Video Speed Engine v2 started, rate: ${CONFIG.VIDEO_PLAYBACK_RATE}x`);
const targetRate = CONFIG.VIDEO_PLAYBACK_RATE;
const monitoredVideos = new WeakSet();
function applyVideoSpeed(video) {
if (!video || video.nodeType !== Node.ELEMENT_NODE) return;
const currentRate = video.playbackRate;
if (Math.abs(currentRate - targetRate) > 0.01) {
try {
video.playbackRate = targetRate;
console.log(`[Script] 增强倍速已应用: ${targetRate}x (原倍速: ${currentRate}x)`);
} catch (e) {
console.warn('[Script] 应用倍速失败:', e);
}
}
}
function applySpeedToAllVideos() {
document.querySelectorAll('video').forEach(video => {
applyVideoSpeed(video);
if (!monitoredVideos.has(video)) {
monitoredVideos.add(video);
enhanceVideoMonitoring(video);
}
});
try {
document.querySelectorAll('iframe').forEach(iframe => {
iframe.contentDocument?.querySelectorAll('video').forEach(video => {
applyVideoSpeed(video);
if (!monitoredVideos.has(video)) {
monitoredVideos.add(video);
enhanceVideoMonitoring(video);
}
});
});
} catch (e) {
}
document.querySelectorAll('*').forEach(el => {
if (el.shadowRoot) {
el.shadowRoot.querySelectorAll('video').forEach(video => {
applyVideoSpeed(video);
if (!monitoredVideos.has(video)) {
monitoredVideos.add(video);
enhanceVideoMonitoring(video);
}
});
}
});
}
function enhanceVideoMonitoring(video) {
if (!video) return;
const descriptor = Object.getOwnPropertyDescriptor(HTMLVideoElement.prototype, 'playbackRate');
if (descriptor && descriptor.set) {
const originalSetter = descriptor.set;
Object.defineProperty(video, 'playbackRate', {
get: function() {
return originalSetter.call(this);
},
set: function(value) {
if (Math.abs(value - targetRate) > 0.01) {
console.log(`[Script] 拦截playbackRate设置: ${value} → ${targetRate}`);
return originalSetter.call(this, targetRate);
}
return originalSetter.call(this, value);
},
configurable: true,
enumerable: true
});
}
hook(video, 'play', (original) => async function(...args) {
const result = original.apply(this, args);
setTimeout(() => {
applyVideoSpeed(this);
if (this.paused && !document.hidden) {
this.play().catch(() => {});
}
}, 50);
return result;
});
hook(video, 'pause', (original) => function(...args) {
if (!document.hidden) {
console.log('[Script] 拦截视频暂停,保持播放状态');
return;
}
return original.apply(this, args);
});
video.addEventListener('ratechange', () => {
if (Math.abs(video.playbackRate - targetRate) > 0.01) {
console.log('[Script] 检测到倍速变化,正在恢复...');
setTimeout(() => applyVideoSpeed(video), 10);
}
});
video.addEventListener('loadedmetadata', () => {
setTimeout(() => applyVideoSpeed(video), 100);
});
}
applySpeedToAllVideos();
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
mutation.addedNodes.forEach(node => {
if (node.nodeName === 'VIDEO' || (node.querySelectorAll && node.querySelectorAll('video').length > 0)) {
shouldScan = true;
}
});
}
});
if (shouldScan) {
setTimeout(applySpeedToAllVideos, 100);
}
});
observer.observe(document.body || document.documentElement, {
childList: true,
subtree: true
});
hook(Object, 'defineProperty', (original) => function(target, property, descriptor) {
if (target instanceof HTMLMediaElement && property === 'playbackRate') {
console.log('[Script] 拦截defineProperty锁定playbackRate');
descriptor.value = targetRate;
descriptor.writable = true;
}
return original.apply(this, arguments);
});
hook(HTMLMediaElement.prototype, 'setAttribute', (original) => function(name, value) {
if (this instanceof HTMLVideoElement && name.toLowerCase() === 'playbackrate') {
console.log('[Script] 拦截setAttribute设置playbackRate');
return;
}
return original.apply(this, arguments);
});
setInterval(applySpeedToAllVideos, 1000);
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(applySpeedToAllVideos, 500);
});
}
console.log('[Script] 增强版视频倍速引擎 v2 已启动,多重防护机制已激活');
}
/**
* [Time Engine] Global time acceleration, including setTimeout, setInterval, and requestAnimationFrame
*/
function accelerateTime() {
console.log(`[Script] Time acceleration engine started, rate: ${CONFIG.TIME_ACCELERATION_RATE}x`);
const rate = CONFIG.TIME_ACCELERATION_RATE;
const percentage = 1 / rate;
let scriptStartTime = Date.now();
let lastDateTime = scriptStartTime;
let lastModifiedTime = scriptStartTime;
const DateOrigin = window.Date;
let DateModified = window.Date;
const trackedIntervals = new Map();
const trackedTimeouts = new Map();
let timerIdCounter = 0;
try {
const setTimeoutOrigin = window.setTimeout;
const setIntervalOrigin = window.setInterval;
const clearTimeoutOrigin = window.clearTimeout;
const clearIntervalOrigin = window.clearInterval;
window.setTimeout = function(callback, delay, ...args) {
if (typeof delay !== 'number' || delay <= 0) {
return setTimeoutOrigin.call(window, callback, delay, ...args);
}
const originalDelay = delay;
const hookedDelay = Math.floor(originalDelay * percentage);
const timerId = setTimeoutOrigin.call(window, function() {
trackedTimeouts.delete(timerId);
if (typeof callback === 'function') {
callback.apply(this, arguments);
} else if (typeof callback === 'string') {
eval(callback);
}
}, hookedDelay, ...args);
trackedTimeouts.set(timerId, {
args: [callback, originalDelay, ...args],
originDelay: originalDelay,
hookedDelay: hookedDelay
});
return timerId;
};
window.setInterval = function(callback, delay, ...args) {
if (typeof delay !== 'number' || delay <= 0) {
return setIntervalOrigin.call(window, callback, delay, ...args);
}
const originalDelay = delay;
const hookedDelay = Math.floor(originalDelay * percentage);
const intervalId = setIntervalOrigin.call(window, callback, hookedDelay, ...args);
trackedIntervals.set(intervalId, {
args: [callback, originalDelay, ...args],
originDelay: originalDelay,
hookedDelay: hookedDelay
});
return intervalId;
};
window.clearTimeout = function(timerId) {
trackedTimeouts.delete(timerId);
return clearTimeoutOrigin.call(window, timerId);
};
window.clearInterval = function(intervalId) {
trackedIntervals.delete(intervalId);
return clearIntervalOrigin.call(window, intervalId);
};
window.Date = function(...args) {
if (args.length === 0) {
const now = DateOrigin.now();
const delta = now - lastDateTime;
const adjustedDelta = delta * rate;
const newTime = lastModifiedTime + adjustedDelta;
lastModifiedTime = newTime;
lastDateTime = now;
return new Date(newTime);
} else if (args.length === 1 && typeof args[0] === 'number') {
return new DateOrigin(args[0]);
} else {
return new (Function.prototype.bind.apply(DateOrigin, [null].concat(args)))();
}
};
window.Date.prototype = DateOrigin.prototype;
window.Date.now = function() {
const now = DateOrigin.now();
const delta = now - lastDateTime;
const adjustedDelta = delta * rate;
const newTime = lastModifiedTime + adjustedDelta;
return Math.floor(newTime);
};
window.Date.prototype.now = window.Date.now;
const originalDateToString = DateOrigin.prototype.toString;
window.Date.prototype.toString = function() {
const now = DateOrigin.now();
const delta = now - lastDateTime;
const adjustedDelta = delta * rate;
const newTime = lastModifiedTime + adjustedDelta;
const fakeDate = new DateOrigin(newTime);
return originalDateToString.call(fakeDate);
};
window.Date.prototype.getTime = function() {
const now = DateOrigin.now();
const delta = now - lastDateTime;
const adjustedDelta = delta * rate;
return lastModifiedTime + adjustedDelta;
};
hook(window, 'requestAnimationFrame', (original) => {
let firstTimestamp = -1;
return (callback) => {
return original.call(window, (timestamp) => {
if (firstTimestamp < 0) firstTimestamp = timestamp;
const acceleratedTimestamp = firstTimestamp + (timestamp - firstTimestamp) * rate;
callback(acceleratedTimestamp);
});
};
});
console.log(`[Script] Time acceleration hooks applied successfully (rate: ${rate}x)`);
} catch (e) {
console.error('[Script Error] Failed to apply time acceleration hooks:', e);
}
}
/**
* Initializes video playback fixes including rate anti-rollback and background playback prevention.
*/
function initializeVideoPlaybackFixes() {
console.log('[Script] Initializing video playback fixes (rate anti-rollback and background playback).');
try {
// 1. Prevent webpage from resetting video playback rate
hook(Object, 'defineProperty', (original) => function(target, property, descriptor) {
if (target instanceof HTMLMediaElement && property === 'playbackRate') {
console.log('[Script] Detected website attempting to lock video playback rate, intercepted.');
return; // Prevent original defineProperty call for playbackRate
}
return original.apply(this, arguments);
});
// 2. Prevent video pausing when tab is in background by faking visibility state
Object.defineProperty(document, "hidden", {
get: function() {
return false;
},
configurable: true
});
Object.defineProperty(document, "visibilityState", {
get: function() {
return "visible";
},
configurable: true
});
console.log('[Script] Document visibility state faked successfully.');
} catch (e) {
console.error('[Script Error] Failed to initialize video playback fixes:', e);
}
}
/**
* Safely navigate back to the corresponding course list
* This function is now mostly a fallback, as direct button clicks are preferred.
*/
function safeNavigateBackToList() {
const hash = window.location.hash.toLowerCase();
const returnUrl = hash.includes('public') || hash.includes('openplayer') || hash.includes('imageandtext') || hash.includes('openonlineexam')
? 'https://zyys.ihehang.com/#/publicDemand'
: 'https://zyys.ihehang.com/#/specialized';
console.log(`[Script] Fallback: Navigating back to list: ${returnUrl}`);
window.location.href = returnUrl;
}
/**
* Decide next action after a course (including all its chapters) is completed.
* This function is crucial for determining whether to proceed to exam or continue course swiping.
*/
function safeNavigateAfterCourseCompletion() {
const hash = window.location.hash.toLowerCase();
currentNavContext = GM_getValue('sclpa_nav_context', ''); // Ensure context is fresh
console.log('[Script] safeNavigateAfterCourseCompletion called. Current hash:', hash, 'Context:', currentNavContext);
// Check if the current page is a player page (video or article player)
if (hash.includes('/majorplayerpage') || hash.includes('/articleplayerpage') || hash.includes('/openplayer') || hash.includes('/imageandtext')) {
// If the navigation context is explicitly set to 'exam' (e.g., user clicked '专业课-考试' from panel)
if (currentNavContext === 'exam') {
const goToExamButton = findElementByText('button span', '前往考试');
if (goToExamButton) {
console.log('[Script] Course completed. Context is "exam". Found "前往考试" button, clicking it.');
clickElement(goToExamButton.closest('button'));
return; // Exit after clicking exam button
} else {
console.log('[Script] Course completed. Context is "exam" but "前往考试" button not found, navigating back to exam list.');
// Navigate to appropriate exam list if '前往考试' isn't found
const examReturnUrl = hash.includes('openplayer') || hash.includes('imageandtext') ? 'https://zyys.ihehang.com/#/openOnlineExam' : 'https://zyys.ihehang.com/#/onlineExam';
window.location.href = examReturnUrl;
return;
}
} else {
// For majorPlayerPage, navigation is now handled by the dedicated handler.
if (hash.includes('/majorplayerpage')) {
console.log('[Script] Professional Course completed. Awaiting main loop handler for navigation.');
} else {
// For public courses (or other non-majorPlayerPage players), use general navigation
console.log('[Script] Public Course completed. Navigating back to general course list.');
safeNavigateBackToList();
}
return; // Exit after attempting navigation
}
}
// Fallback for other cases (e.g., if this function is called from a non-player page unexpectedly)
console.log('[Script] safeNavigateAfterCourseCompletion called from non-player page or unhandled scenario. Navigating back to general course list.');
safeNavigateBackToList();
}
// ===================================================================================
// --- 主循环与启动器 (Main Loop & Initiator) ---
// ===================================================================================
/**
* [FIXED] Dedicated handler for the professional course player page (/majorPlayerPage).
* This function's only job is to detect the final completion popup and navigate.
* @returns {boolean} - Returns true if navigation was initiated, otherwise false.
*/
function handleMajorPlayerPage() {
// Priority 1: Check for the "Congratulations" popup. Its presence means the course is finished.
const completionPopup = document.querySelector('.el-message-box');
if (completionPopup && completionPopup.innerText.includes('恭喜您已经完成所有课程学习')) {
console.log('[Script] Completion popup detected. This signifies the course is finished. Navigating to professional courses list.');
const navButton = document.getElementById('nav-specialized-btn');
if (navButton) {
clickElement(navButton);
} else {
console.warn('[Script] Could not find "专业课程" button (nav-specialized-btn) for navigation. Falling back to URL change.');
window.location.href = 'https://zyys.ihehang.com/#/specialized';
}
// Return true as we've initiated the final navigation action.
return true;
}
// If no popup is found, it means the course is still in progress. Return false.
return false;
}
/**
* Page router, determines which handler function to execute based on URL hash
*/
function router() {
const hash = window.location.hash.toLowerCase();
console.log('[Script] Router: Current hash is', hash);
if (hash.includes('/specialized')) {
handleCourseListPage('专业课');
} else if (hash.includes('/publicdemand')) {
handleCourseListPage('公需课');
} else if (hash.includes('/examination')) {
handleExamPage();
} else if (hash.includes('/majorplayerpage') || hash.includes('/articleplayerpage') || hash.includes('/openplayer') || hash.includes('/imageandtext')) {
handleLearningPage();
} else if (hash.includes('/onlineexam') || hash.includes('/openonlineexam')) {
handleExamListPage();
} else {
console.log('[Script] Router: No specific handler for current hash, idling.');
}
}
/**
* Main script loop, executed every 2 seconds
*/
function mainLoop() {
console.log('[Script] Main loop running...');
const currentHash = window.location.hash; // Get current hash at the start of the loop
// Detect hash change to reset states
if (currentHash !== currentPageHash) {
const oldHash = currentPageHash;
currentPageHash = currentHash; // Update currentPageHash
console.log(`[Script] Hash changed from ${oldHash} to ${currentHash}.`);
// If exiting an examination page, clean up AI panel and related flags
if (oldHash.includes('/examination') && !currentHash.includes('/examination')) {
const aiPanel = document.getElementById('ai-helper-panel');
if (aiPanel) aiPanel.remove();
currentQuestionBatchText = ''; // Reset batch text on exam page exit
isAiAnswerPending = false;
isSubmittingExam = false;
console.log('[Script] Exited examination page, reset AI related flags.');
}
}
// Always reset unfinishedTabClicked if we are on a course list page or exam list page.
// This ensures that even if the hash doesn't change (e.g., page reload to same hash),
// the "未完成" tab logic is re-evaluated.
if (currentHash.includes('/specialized') || currentHash.includes('/publicdemand') ||
currentHash.includes('/onlineexam') || currentHash.includes('/openonlineexam')) {
if (unfinishedTabClicked) { // Only log if it's actually being reset
console.log('[Script] Resetting unfinishedTabClicked flag for current list page.');
}
unfinishedTabClicked = false;
}
if (isServiceActive) {
// High-priority handler for the professional course player page.
if (currentHash.toLowerCase().includes('/majorplayerpage')) {
// If the handler initiates navigation, it returns true.
// We should then skip the rest of the main loop for this cycle.
if (handleMajorPlayerPage()) {
return;
}
}
// Handle other generic popups
handleGenericPopups();
}
// Route to the appropriate page handler
router();
}
/**
* Start the script
*/
window.addEventListener('load', () => {
console.log(`[Script] Sichuan Licensed Pharmacist Continuing Education (v1.3.1) started.`);
console.log(`[Script] Service status: ${isServiceActive ? 'Running' : 'Paused'} | Current speed: ${currentPlaybackRate}x`);
currentPageHash = window.location.hash;
currentNavContext = GM_getValue('sclpa_nav_context', ''); // Load initial navigation context
try {
initializeVideoPlaybackFixes();
} catch (e) {
console.error('[Script Error] Failed to initialize video playback fixes during load:', e);
}
try {
createModeSwitcherPanel(); // This creates the UI panel
} catch (e) {
console.error('[Script Error] Failed to create Mode Switcher Panel during load:', e);
}
// Start the main loop
setInterval(mainLoop, 2000);
console.log('[Script] Main loop initiated.');
});
})();