// ==UserScript== // @name 杏林学堂刷课[V4] // @namespace http://tampermonkey.net/ // @version 4.0.0 // @description 测试版:可取消异步管道、可靠路由监听、分页任务、诊断导出与响应式界面 // @author You // @match https://mbeta.xlxt.net/* // @connect wwwbeta.xlxt.net // @grant GM_xmlhttpRequest // ==/UserScript== /* global GM_xmlhttpRequest */ (function () { 'use strict'; const CONFIG = { VERSION: '4.0.0', API: { TASK_LIST: 'https://wwwbeta.xlxt.net/Task/GetMemberTask', COURSE_LIST: 'https://wwwbeta.xlxt.net/Task/GetMemberCourseProgress', LOGIN_PAGE: 'https://mbeta.xlxt.net/#/login?from=home', TASK_PAGE_SIZE: 30, MAX_TASK_PAGES: 100, REQUEST_TIMEOUT: 15000, REQUEST_RETRIES: 2 }, DEFAULTS: { playButtonDelay: 2500, videoDetectionDelay: 2000, videoAdjustWait: 500, maxRetries: 2, maxPlayButtonRetries: 2, maxLoopRetries: 3, phone: '未设置', password: '未设置', deviceName: '未设置' }, LIMITS: { playButtonDelay: [0, 60000], videoDetectionDelay: [100, 60000], videoAdjustWait: [100, 60000], maxRetries: [1, 20], maxPlayButtonRetries: [1, 20], maxLoopRetries: [1, 20] }, LOG_BUFFER_MAX: 500, ERROR_BUFFER_MAX: 50, UI_MSG_MAX: 40 }; function createAbortError() { const error = new Error('ABORTED'); error.name = 'AbortError'; return error; } function isAbortError(error) { return !!error && (error.name === 'AbortError' || error.message === 'ABORTED'); } function throwIfAborted(signal) { if (signal && signal.aborted) throw createAbortError(); } function delay(ms, signal) { throwIfAborted(signal); return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { settled = true; cleanup(); resolve(); }, Math.max(0, Number(ms) || 0)); function cleanup() { if (signal) signal.removeEventListener('abort', onAbort); } function onAbort() { if (settled) return; settled = true; clearTimeout(timer); cleanup(); reject(createAbortError()); } if (signal) signal.addEventListener('abort', onAbort, { once: true }); }); } // 条件驱动等待:目标一出现立即继续;timeout 仅用于避免页面异常时永久卡住。 function waitForCondition(check, options = {}) { const signal = options.signal; const timeout = Math.max(100, Number(options.timeout) || 10000); const interval = Math.max(50, Number(options.interval) || 100); throwIfAborted(signal); return new Promise((resolve, reject) => { let settled = false; let timeoutTimer = null; let pollTimer = null; let observer = null; function cleanup() { if (timeoutTimer) clearTimeout(timeoutTimer); if (pollTimer) clearTimeout(pollTimer); if (observer) observer.disconnect(); if (signal) signal.removeEventListener('abort', onAbort); } function finish(callback, value) { if (settled) return; settled = true; cleanup(); callback(value); } function onAbort() { finish(reject, createAbortError()); } function evaluate() { if (settled) return; try { const value = check(); if (value) finish(resolve, value); } catch (error) { finish(reject, error); } } function schedulePoll() { if (settled) return; pollTimer = setTimeout(() => { evaluate(); schedulePoll(); }, interval); } if (signal) signal.addEventListener('abort', onAbort, { once: true }); evaluate(); if (settled) return; if (typeof MutationObserver !== 'undefined' && document.documentElement) { observer = new MutationObserver(evaluate); observer.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); } timeoutTimer = setTimeout(() => finish(resolve, null), timeout); schedulePoll(); }); } function clampInt(value, fallback, min, max) { const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed)) return fallback; return Math.min(max, Math.max(min, parsed)); } function normalizeSettings(value) { const raw = value && typeof value === 'object' ? value : {}; const normalized = { ...CONFIG.DEFAULTS }; Object.keys(CONFIG.LIMITS).forEach(key => { const [min, max] = CONFIG.LIMITS[key]; normalized[key] = clampInt(raw[key], CONFIG.DEFAULTS[key], min, max); }); ['phone', 'password', 'deviceName'].forEach(key => { const text = raw[key] == null ? '' : String(raw[key]).trim(); normalized[key] = text || CONFIG.DEFAULTS[key]; }); return normalized; } const Events = (() => { const listeners = new Map(); return { on(eventName, listener) { const group = listeners.get(eventName) || []; group.push(listener); listeners.set(eventName, group); return () => this.off(eventName, listener); }, off(eventName, listener) { const group = listeners.get(eventName); if (!group) return; listeners.set(eventName, group.filter(item => item !== listener)); }, emit(eventName, payload) { (listeners.get(eventName) || []).slice().forEach(listener => { try { listener(payload); } catch (error) { console.error('[XLXT] 事件监听器异常:', eventName, error); } }); } }; })(); const Settings = (() => { let cache = null; return { load() { if (cache) return { ...cache }; try { const raw = localStorage.getItem('autoCourseSettings'); cache = normalizeSettings(raw ? JSON.parse(raw) : CONFIG.DEFAULTS); } catch (error) { console.error('[XLXT] 加载设置失败:', error); cache = normalizeSettings(CONFIG.DEFAULTS); } return { ...cache }; }, save(value) { cache = normalizeSettings(value); localStorage.setItem('autoCourseSettings', JSON.stringify(cache)); Events.emit('settings:changed', { ...cache }); return { ...cache }; }, get(key) { if (!cache) this.load(); return cache[key]; } }; })(); const Logger = (() => { const buffer = []; function write(level, message) { const entry = { time: new Date().toISOString(), level, msg: String(message) }; buffer.push(entry); if (buffer.length > CONFIG.LOG_BUFFER_MAX) buffer.shift(); Events.emit('log:entry', entry); const prefix = `[XLXT ${new Date().toLocaleTimeString()}] `; if (level === 'error') console.error(prefix + entry.msg); else if (level === 'warning') console.warn(prefix + entry.msg); else console.log(prefix + entry.msg); } return { info(message) { write('info', message); }, success(message) { write('success', message); }, warn(message) { write('warning', message); }, error(message) { write('error', message); }, getAll() { return buffer.slice(); } }; })(); const ErrorCollector = (() => { const errors = []; function add(error) { errors.push(error); if (errors.length > CONFIG.ERROR_BUFFER_MAX) errors.shift(); } window.addEventListener('error', event => { const error = event.error || { message: event.message || '未知错误', stack: '' }; add({ time: new Date().toISOString(), message: error.message, stack: error.stack || '', source: event.filename || '', line: event.lineno || 0, col: event.colno || 0 }); }); window.addEventListener('unhandledrejection', event => { if (isAbortError(event.reason)) return; add({ time: new Date().toISOString(), message: 'UnhandledPromise: ' + (event.reason ? (event.reason.message || String(event.reason)) : ''), stack: event.reason && event.reason.stack ? event.reason.stack : '', source: '', line: 0, col: 0 }); }); return { getAll: () => errors.slice() }; })(); const Store = (() => { const data = { phase: 'idle', taskQueue: [], taskIndex: -1, courseQueue: [], courseIndex: -1, active: false, cookie: document.cookie }; function set(key, value) { const old = data[key]; data[key] = value; Events.emit('store:changed', { key, old, value }); Events.emit('store:' + key, value); } return { get phase() { return data.phase; }, set phase(value) { set('phase', value); }, get taskQueue() { return data.taskQueue; }, set taskQueue(value) { set('taskQueue', value); }, get taskIndex() { return data.taskIndex; }, set taskIndex(value) { set('taskIndex', value); }, get courseQueue() { return data.courseQueue; }, set courseQueue(value) { set('courseQueue', value); }, get courseIndex() { return data.courseIndex; }, set courseIndex(value) { set('courseIndex', value); }, get active() { return data.active; }, set active(value) { set('active', value); }, get cookie() { return data.cookie; }, set cookie(value) { set('cookie', value); }, snapshot() { return { phase: data.phase, taskQueue: data.taskQueue.slice(), taskIndex: data.taskIndex, courseQueue: data.courseQueue.slice(), courseIndex: data.courseIndex, active: data.active, cookie: data.cookie }; } }; })(); async function retry(operation, options = {}) { const maxRetries = Math.max(0, Number(options.maxRetries) || 0); const delayMs = Math.max(0, Number(options.delayMs) || 0); const signal = options.signal; let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { throwIfAborted(signal); try { if (attempt > 0 && options.onRetry) options.onRetry(attempt, maxRetries, lastError); return await operation(attempt); } catch (error) { if (isAbortError(error)) throw error; lastError = error; if (attempt < maxRetries) await delay(delayMs, signal); } } throw lastError || new Error('重试次数已耗尽'); } const API = { request(method, url, options = {}) { const signal = options.signal; throwIfAborted(signal); Store.cookie = document.cookie; return new Promise((resolve, reject) => { let requestHandle = null; let settled = false; function cleanup() { if (signal) signal.removeEventListener('abort', onAbort); } function finish(callback, value) { if (settled) return; settled = true; cleanup(); callback(value); } function onAbort() { try { if (requestHandle && typeof requestHandle.abort === 'function') requestHandle.abort(); } catch (error) { console.warn('[XLXT] 取消网络请求失败:', error); } finish(reject, createAbortError()); } if (signal) signal.addEventListener('abort', onAbort, { once: true }); try { requestHandle = GM_xmlhttpRequest({ method, url, timeout: options.timeout || CONFIG.API.REQUEST_TIMEOUT, headers: { 'Content-Type': 'application/json', 'Cookie': Store.cookie }, onload: response => { const status = Number(response.status) || 0; if (status === 401) { let payload = { Code: 401 }; try { payload = { ...JSON.parse(response.responseText), Code: 401 }; } catch (error) { // HTTP 状态已经足以确认登录失效。 } finish(resolve, payload); return; } if (status && (status < 200 || status >= 300)) { const error = new Error(`HTTP ${status}`); error.status = status; finish(reject, error); return; } try { finish(resolve, JSON.parse(response.responseText)); } catch (error) { finish(reject, new Error(`JSON解析失败${status ? `(HTTP ${status})` : ''}`)); } }, onerror: () => finish(reject, new Error('网络请求失败')), ontimeout: () => finish(reject, new Error('请求超时')), onabort: () => finish(reject, createAbortError()) }); } catch (error) { finish(reject, error); } }); }, requestWithRetry(method, url, signal) { return retry( () => this.request(method, url, { signal }), { maxRetries: CONFIG.API.REQUEST_RETRIES, delayMs: 1500, signal, onRetry: (attempt, maxRetries, error) => { Logger.warn(`请求失败,正在重试 ${attempt}/${maxRetries}:${error ? error.message : '未知错误'}`); } } ); }, async fetchTasks(signal) { const tasks = []; const seen = new Set(); let firstResponse = null; for (let page = 1; page <= CONFIG.API.MAX_TASK_PAGES; page++) { throwIfAborted(signal); const query = new URLSearchParams({ pageindex: String(page), pagesize: String(CONFIG.API.TASK_PAGE_SIZE), StudyStatus: '0' }); const response = await this.requestWithRetry('GET', `${CONFIG.API.TASK_LIST}?${query}`, signal); if (!firstResponse) firstResponse = response; if (Number(response && response.Code) === 401) return response; if (!response || !Array.isArray(response.Data)) throw new Error('任务接口返回格式不正确'); let added = 0; response.Data.forEach((task, index) => { const key = task && task.TaskID != null ? `id:${task.TaskID}` : `page:${page}:index:${index}:${JSON.stringify(task)}`; if (!seen.has(key)) { seen.add(key); tasks.push(task); added++; } }); if (response.Data.length < CONFIG.API.TASK_PAGE_SIZE || added === 0) break; Logger.info(`任务列表第 ${page} 页已加载,继续获取下一页...`); } return { ...(firstResponse || {}), Data: tasks }; }, async fetchCourses(taskId, signal) { const query = new URLSearchParams({ TaskID: String(taskId), pageindex: '1', pagesize: '1000' }); const response = await this.requestWithRetry('GET', `${CONFIG.API.COURSE_LIST}?${query}`, signal); if (Number(response && response.Code) === 401) return response; if (!response || !Array.isArray(response.Data)) throw new Error('课程接口返回格式不正确'); return response; } }; const Dom = { findPlayButton() { return document.querySelector('.vjs-big-play-button') || document.querySelector('.prism-big-play-btn') || document.querySelector('#J_prismPlayer_component_F46C34E7-1A34-4EFB-AA07-68B666B6A1DD') || document.querySelector('.play-button') || document.querySelector('button[aria-label*="播放"]') || document.querySelector('button[title="播放"]') || document.querySelector('.x-video-button'); }, isHidden(element) { if (!element) return true; const style = window.getComputedStyle ? window.getComputedStyle(element) : element.style; return style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0; }, findVideoElement() { let video = document.querySelector('#J_prismPlayer video'); if (video) return video; video = document.querySelector('video'); if (video) return video; const nodes = document.querySelectorAll('*'); for (let index = 0; index < nodes.length; index++) { if (nodes[index].shadowRoot) { const shadowVideo = nodes[index].shadowRoot.querySelector('video'); if (shadowVideo) return shadowVideo; } } const frames = document.querySelectorAll('iframe'); for (let index = 0; index < frames.length; index++) { try { const frameVideo = frames[index].contentDocument && frames[index].contentDocument.querySelector('video'); if (frameVideo) return frameVideo; } catch (error) { // 跨域 iframe 无法访问时忽略。 } } return null; }, getDurationFromUI() { const element = document.querySelector('.prism-time-display .duration') || document.querySelector('.duration'); if (!element || !element.textContent) return 0; const raw = element.textContent.trim().replace('/', '').trim(); const parts = raw.split(':').map(Number); if (parts.some(part => !Number.isFinite(part) || part < 0)) return 0; if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2]; if (parts.length === 2) return parts[0] * 60 + parts[1]; return 0; }, isCourseCompleted() { const candidates = document.querySelectorAll('.wrapper .info-text, .info-text'); return Array.from(candidates).some(element => element.textContent && element.textContent.includes('视频播放结束') ); }, captureCourseView() { const video = this.findVideoElement(); const state = document.querySelector('.course-state .info-text, .info-text'); return { video, videoSource: video ? String(video.currentSrc || video.src || video.getAttribute('src') || '') : '', state, stateText: state ? String(state.textContent || '') : '', completed: this.isCourseCompleted() }; }, hasCourseViewChanged(before) { if (!before) return true; const current = this.captureCourseView(); return current.video !== before.video || current.videoSource !== before.videoSource || current.state !== before.state || current.stateText !== before.stateText || current.completed !== before.completed; }, getRouteCourse() { const hash = window.location.hash || ''; const queryIndex = hash.indexOf('?'); if (queryIndex < 0) return { courseId: null, taskId: null }; const query = new URLSearchParams(hash.slice(queryIndex + 1)); return { courseId: query.get('courseID'), taskId: query.get('TaskID') }; } }; const VideoController = { async processCourseOnPage(options = {}) { const signal = options.signal; const settings = Settings.load(); const maxLoops = settings.maxLoopRetries; const entryTimeout = Math.max(3000, settings.playButtonDelay * settings.maxPlayButtonRetries); const readyTimeout = Math.max(3000, settings.videoDetectionDelay * settings.maxRetries); const completionTimeout = Math.max(1000, settings.videoAdjustWait * Math.max(6, settings.maxRetries * 2)); if (Dom.isCourseCompleted()) { Logger.success('当前页面已经显示视频播放结束'); return true; } for (let loop = 0; loop < maxLoops; loop++) { throwIfAborted(signal); Logger.info(`第 ${loop + 1}/${maxLoops} 次尝试:查找播放入口`); const entry = await this._waitForPlaybackEntry(entryTimeout, signal); if (!entry) { Logger.warn('等待播放入口超时'); continue; } if (entry.button) { if (Dom.isHidden(entry.button)) Logger.info('播放按钮已隐藏,继续检测视频'); else { try { entry.button.click(); Logger.success('已点击播放按钮'); } catch (error) { Logger.error('点击播放按钮失败: ' + error.message); continue; } } } else { Logger.info('未发现播放按钮,但视频元素已经存在'); } let video = await this._waitForReadyVideo(readyTimeout, signal); if (!video) continue; const duration = await this._getValidDuration(video, readyTimeout, signal); if (!duration) continue; try { this._seekToCompletion(video, duration); } catch (error) { Logger.error('调整视频进度失败: ' + error.message); continue; } Logger.success(`调整进度至 ${duration} 秒,等待页面反馈...`); const completed = await waitForCondition(() => Dom.isCourseCompleted(), { timeout: completionTimeout, interval: 80, signal }); if (completed) return true; Logger.warn('等待完成标记超时,重新尝试本课程'); } Logger.error(`超过最大循环重试次数 (${maxLoops}),本课失败`); return false; }, _seekToCompletion(video, duration) { if (!video) throw new Error('视频元素不存在'); if (!Number.isFinite(duration) || duration <= 0) throw new Error('视频时长无效'); video.muted = true; try { video.currentTime = duration; } catch (firstError) { const fallback = Math.max(0, duration - 0.25); video.currentTime = fallback; try { const playResult = video.play(); if (playResult && typeof playResult.catch === 'function') playResult.catch(() => {}); } catch (playError) { // 已经成功定位到后备时间点时,播放失败不阻止后续页面反馈检测。 } } }, async _waitForPlaybackEntry(timeout, signal) { return waitForCondition(() => { const button = Dom.findPlayButton(); const video = Dom.findVideoElement(); return button || video ? { button, video } : null; }, { timeout, interval: 100, signal }); }, async _waitForReadyVideo(timeout, signal) { const video = await waitForCondition(() => { const candidate = Dom.findVideoElement(); if (!candidate || candidate.readyState < 3) return null; return candidate; }, { timeout, interval: 100, signal }); if (!video) Logger.error('等待可用 Video 标签超时'); return video; }, async _getValidDuration(video, timeout, signal) { const duration = await waitForCondition(() => { let value = Number(video.duration); if (!Number.isFinite(value) || value <= 0) { const uiDuration = Dom.getDurationFromUI(); if (Number.isFinite(uiDuration) && uiDuration > 0) value = uiDuration; } return Number.isFinite(value) && value > 0 ? value : null; }, { timeout, interval: 100, signal }); if (!duration) { Logger.error('等待有效视频时长超时'); return 0; } return duration; } }; const LoginController = { _running: false, AGREEMENT_UNCHECKED_IMAGE: '163944958500030968.png', AGREEMENT_CHECKED_IMAGE: '163945296800046520.png', async execute() { if (this._running) return; this._running = true; try { const phone = Settings.get('phone'); const password = Settings.get('password'); if (!phone || !password || phone === '未设置' || password === '未设置') { Logger.error('请先在设置中填写手机号和密码'); return; } const agreement = await this._waitForElement([ '.login-ua img', `img[src*="${this.AGREEMENT_UNCHECKED_IMAGE}"]`, `img[src*="${this.AGREEMENT_CHECKED_IMAGE}"]` ]); if (!agreement) { Logger.warn('未找到用户协议控件,请手动点击'); return; } if (!await this._ensureAgreementChecked(agreement)) return; const phoneInput = await this._waitForElement([ '.input-item input.tel-input', 'input.tel-input', 'input[placeholder="请输入手机号/用户名"]' ]); if (!phoneInput) { Logger.warn('未找到手机号输入框'); return; } const passwordInput = await this._waitForElement([ 'input[type="password"][placeholder="请输入登录密码"]', 'input[placeholder="请输入登录密码"]' ]); if (!passwordInput) { Logger.warn('未找到密码输入框'); return; } if (!this._fillInput(phoneInput, phone, '手机号')) return; if (!this._fillInput(passwordInput, password, '密码')) return; const loginButton = await this._waitForLoginButton(); if (!loginButton) { Logger.warn('未找到登录按钮,请手动点击'); return; } const currentAgreement = document.querySelector('.login-ua img') || agreement; if (!this._isAgreementChecked(currentAgreement)) { Logger.warn('提交前协议状态未勾选,正在重新确认...'); if (!await this._ensureAgreementChecked(currentAgreement)) return; } if (loginButton.disabled) { Logger.warn('登录按钮当前不可用,请检查账号和密码'); return; } loginButton.click(); Logger.info('自动登录中...'); } catch (error) { Logger.error('自动登录失败: ' + error.message); } finally { this._running = false; } }, async _waitForElement(selectors, timeout = 3000) { return waitForCondition(() => { for (const selector of selectors) { const element = document.querySelector(selector); if (element) return element; } return null; }, { timeout, interval: 80 }); }, async _waitForLoginButton() { return waitForCondition(() => { const buttons = Array.from(document.querySelectorAll('button')); const button = document.querySelector('button.login-btn') || document.querySelector('form button[type="submit"]') || buttons.find(item => item.textContent && item.textContent.trim() === '登录'); return button && !Dom.isHidden(button) && !button.disabled ? button : null; }, { timeout: 3000, interval: 80 }); }, _getAgreementState(image) { if (!image) return 'missing'; const src = String(image.currentSrc || image.getAttribute('src') || ''); if (src.includes(this.AGREEMENT_CHECKED_IMAGE)) return 'checked'; if (src.includes(this.AGREEMENT_UNCHECKED_IMAGE)) return 'unchecked'; return 'unknown'; }, _isAgreementChecked(image) { return this._getAgreementState(image) === 'checked'; }, async _ensureAgreementChecked(image) { const initialState = this._getAgreementState(image); if (initialState === 'checked') { Logger.info('用户协议已经勾选'); return true; } if (initialState !== 'unchecked') { Logger.warn('无法识别用户协议状态,请手动确认'); return false; } image.click(); const checked = await waitForCondition(() => this._isAgreementChecked(image), { timeout: 1000, interval: 50 }); if (checked) { Logger.info('已勾选用户协议和隐私政策'); return true; } Logger.warn('用户协议勾选未生效,请手动确认'); return false; }, _fillInput(input, value, label) { if (!input) { Logger.warn(`未找到${label}输入框`); return false; } try { const descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value'); if (descriptor && descriptor.set) descriptor.set.call(input, value); else input.value = value; input.focus(); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); const filled = input.value === String(value); if (!filled) Logger.warn(`${label}填写后未通过页面校验`); return filled; } catch (error) { Logger.error(`填写${label}失败: ${error.message}`); return false; } } }; let Router; const Pipeline = { _busy: false, _controller: null, _pendingCourseView: null, get signal() { return this._controller ? this._controller.signal : null; }, abort(options = {}) { const wasActive = Store.active; if (this._controller && !this._controller.signal.aborted) this._controller.abort(); this._controller = null; this._pendingCourseView = null; Store.active = false; Store.phase = 'idle'; Store.taskQueue = []; Store.taskIndex = -1; Store.courseQueue = []; Store.courseIndex = -1; Events.emit('pipeline:stopped', { reason: options.reason || 'user' }); if (wasActive && !options.silent) Logger.warn('已停止刷课'); }, async start() { if (Store.active || this._busy) return; this._controller = new AbortController(); const signal = this.signal; this._busy = true; Store.active = true; Store.phase = 'fetching_tasks'; Events.emit('pipeline:start'); try { Logger.info('正在获取未完成任务列表...'); const response = await API.fetchTasks(signal); throwIfAborted(signal); if (Number(response.Code) === 401) { this._redirectToLogin(); return; } if (!Array.isArray(response.Data)) throw new Error('任务列表格式不正确'); if (response.Data.length === 0) { Logger.warn('没有获取到未完成任务'); this.abort({ silent: true, reason: 'empty' }); return; } Store.taskQueue = response.Data; Store.taskIndex = 0; Logger.success(`共获取到 ${response.Data.length} 个任务`); } catch (error) { if (!isAbortError(error)) { Logger.error('启动失败: ' + error.message); this.abort({ silent: true, reason: 'error' }); } return; } finally { this._busy = false; } if (!Store.active || signal.aborted) return; try { await this._loadAndNavigateTask(signal); } catch (error) { if (!isAbortError(error)) { Logger.error('加载任务失败: ' + error.message); this.abort({ silent: true, reason: 'error' }); } } }, _redirectToLogin() { Logger.error('登录状态失效,即将前往登录页面'); this.abort({ silent: true, reason: 'auth' }); Router.navigate(CONFIG.API.LOGIN_PAGE); }, async _loadAndNavigateTask(signal) { while (Store.active && Store.taskIndex < Store.taskQueue.length) { throwIfAborted(signal); const task = Store.taskQueue[Store.taskIndex]; if (!task || task.TaskID == null) { Logger.warn('发现缺少 TaskID 的任务,已跳过'); Store.taskIndex++; continue; } Store.phase = 'processing_tasks'; Logger.info(`处理任务:${task.Name || task.TaskID}`); let response; try { response = await API.fetchCourses(task.TaskID, signal); } catch (error) { if (isAbortError(error)) throw error; Logger.error(`获取任务课程列表失败:${error.message},跳过此任务`); Store.taskIndex++; continue; } if (Number(response.Code) === 401) { this._redirectToLogin(); return; } const courses = response.Data.filter(course => course && (course.Progress == null || Number(course.Progress.StudyStatus) === 0) ); Logger.info(`任务“${task.Name || task.TaskID}”需要处理 ${courses.length} 个课程`); const queue = courses .filter(course => course.CourseID != null) .map(course => ({ courseId: course.CourseID, taskId: task.TaskID, jumpUrl: this._buildCourseUrl(course.CourseID, task.TaskID) })); if (queue.length === 0) { Store.taskIndex++; continue; } Store.courseQueue = queue; Store.courseIndex = 0; Store.phase = 'navigating'; Logger.info('准备进入第一个课程页面...'); this._navigateToCourse(queue[0]); return; } if (Store.active) { Logger.success('所有任务和课程处理完毕'); this.abort({ silent: true, reason: 'complete' }); } }, async processOneCourseOnPage() { if (!Store.active || Store.courseQueue.length === 0 || this._busy) return; const signal = this.signal; throwIfAborted(signal); const course = Store.courseQueue[Store.courseIndex]; if (!course) { Logger.warn('课程索引已超出队列,继续下一个任务'); Store.courseQueue = []; Store.courseIndex = -1; Store.taskIndex++; await this._loadAndNavigateTask(signal); return; } const route = Dom.getRouteCourse(); if (route.courseId != null && String(route.courseId) !== String(course.courseId)) { Logger.warn(`当前页面课程 ${route.courseId} 与队列课程 ${course.courseId} 不一致,正在校正...`); this._navigateToCourse(course); return; } if (!await this._waitForCourseView(course, signal)) return; this._busy = true; try { Store.phase = 'processing_course'; Logger.info(`开始处理课程 ${course.courseId}`); const success = await VideoController.processCourseOnPage({ signal }); if (success) Logger.success(`课程 ${course.courseId} 处理成功`); else Logger.warn(`课程 ${course.courseId} 处理失败,继续下一课`); } catch (error) { if (!isAbortError(error)) Logger.error('课程处理异常: ' + error.message); } finally { this._busy = false; } if (!Store.active || !signal || signal.aborted) return; Store.courseIndex++; if (Store.courseIndex < Store.courseQueue.length) { Store.phase = 'navigating'; this._navigateToCourse(Store.courseQueue[Store.courseIndex]); return; } Store.courseQueue = []; Store.courseIndex = -1; Store.taskIndex++; await this._loadAndNavigateTask(signal); }, _navigateToCourse(course) { this._pendingCourseView = { courseId: String(course.courseId), before: Dom.captureCourseView() }; Router.navigate(course.jumpUrl); }, async _waitForCourseView(course, signal) { const pending = this._pendingCourseView; if (!pending || pending.courseId !== String(course.courseId)) return true; this._pendingCourseView = null; // 仅在离开“已完成”课程时设屏障,避免把上一页遗留标记误判给新课程。 if (!pending.before.completed) return true; Logger.info('等待课程页面更新,避免沿用上一课完成状态...'); const changed = await waitForCondition( () => !Dom.isCourseCompleted() || Dom.hasCourseViewChanged(pending.before), { timeout: 8000, interval: 80, signal } ); if (changed) return true; Logger.error('课程页面在 8 秒内未更新,已暂停本轮,避免误判完成'); this.abort({ silent: true, reason: 'course-view-stale' }); return false; }, _buildCourseUrl(courseId, taskId) { const query = new URLSearchParams({ courseID: String(courseId), TaskID: String(taskId) }); return `https://mbeta.xlxt.net/#/course-info?${query}`; } }; const UI = (() => { const refs = { panel: null, messageList: null, startButton: null, processButton: null, settingsButton: null, collapseButton: null, floatButton: null, phaseBadge: null, summary: null, settingsOverlay: null, settingsInputs: {} }; let manualBusy = false; let starting = false; // 所有可注入按钮只允许在这里声明,并统一交给 renderButtonSlot 创建。 const BUTTON_COMPONENTS = Object.freeze([ Object.freeze({ id: 'open-settings', slot: 'header-actions', label: '⚙', className: 'xlxt4-icon-button', kind: 'icon', title: '设置', action: openSettings, ref: 'settingsButton' }), Object.freeze({ id: 'toggle-collapse', slot: 'header-actions', label: '—', className: 'xlxt4-icon-button', kind: 'icon', title: '收起面板', action: collapsePanel, ref: 'collapseButton' }), Object.freeze({ id: 'restore-panel', domId: 'xlxt4-float', slot: 'floating-restore', label: '杏', className: 'xlxt4-float', kind: 'standalone', title: '展开杏林学堂助手', action: restorePanel, ref: 'floatButton' }), Object.freeze({ id: 'pipeline-toggle', slot: 'main-actions', label: '开始刷课', className: 'xlxt4-button-primary', action: togglePipeline, ref: 'startButton' }), Object.freeze({ id: 'process-current-course', slot: 'main-actions', label: '处理当前课程', className: 'xlxt4-button-secondary', action: processCurrent, ref: 'processButton' }), Object.freeze({ id: 'go-to-login', slot: 'utility-actions', label: '前往登录', className: 'xlxt4-button-utility', action: goToLogin }), Object.freeze({ id: 'export-diagnostics', slot: 'utility-actions', label: '导出诊断', className: 'xlxt4-button-utility', action: exportDiagnostics }), Object.freeze({ id: 'close-settings', slot: 'settings-header', label: '×', className: 'xlxt4-icon-button xlxt4-settings-close', kind: 'icon', title: '关闭设置', action: closeSettings }), Object.freeze({ id: 'cancel-settings', slot: 'settings-footer', label: '取消', action: closeSettings }), Object.freeze({ id: 'save-settings', slot: 'settings-footer', label: '保存设置', className: 'xlxt4-save', action: saveSettings }) ]); const STYLE_TEXT = ` #xlxt4-panel, #xlxt4-panel * { box-sizing: border-box; } #xlxt4-panel { --xlxt-accent: #2f7d6d; --xlxt-accent-strong: #1f6255; --xlxt-bg: rgba(252, 253, 251, .97); --xlxt-text: #1f2926; --xlxt-muted: #6b7772; --xlxt-line: #dfe7e3; position: fixed; right: 16px; bottom: 16px; width: min(372px, calc(100vw - 24px)); max-height: min(680px, calc(100vh - 32px)); display: flex; flex-direction: column; overflow: hidden; color: var(--xlxt-text); background: var(--xlxt-bg); border: 1px solid rgba(47, 125, 109, .18); border-radius: 18px; box-shadow: 0 20px 55px rgba(26, 56, 48, .20), 0 2px 8px rgba(26, 56, 48, .09); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); z-index: 2147483646; font: 13px/1.5 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; } .xlxt4-header { display: flex; align-items: center; gap: 11px; padding: 14px 14px 12px; color: #fff; background: linear-gradient(135deg, #245f55, #3d9581); } .xlxt4-logo { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid rgba(255,255,255,.32); border-radius: 12px; background: rgba(255,255,255,.14); font-size: 18px; font-weight: 800; } .xlxt4-title-wrap { min-width: 0; flex: 1; } .xlxt4-title { margin: 0; font-size: 15px; font-weight: 760; letter-spacing: .02em; } .xlxt4-version { margin-top: 1px; color: rgba(255,255,255,.72); font-size: 10px; } .xlxt4-header-actions { display: flex; gap: 7px; } .xlxt4-icon-button { width: 30px; height: 30px; display: grid; place-items: center; flex: 0 0 auto; padding: 0; border: 0; border-radius: 9px; color: #fff; background: rgba(255,255,255,.13); cursor: pointer; font: 700 16px/1 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; transition: background .16s ease, transform .16s ease; } .xlxt4-icon-button:hover { background: rgba(255,255,255,.24); transform: translateY(-1px); } .xlxt4-float { position: fixed; right: 16px; bottom: 16px; width: 48px; height: 48px; display: none; place-items: center; padding: 0; color: #fff; border: 1px solid rgba(255,255,255,.5); border-radius: 15px; background: linear-gradient(135deg, #245f55, #3d9581); box-shadow: 0 12px 28px rgba(26, 56, 48, .24); cursor: pointer; z-index: 2147483646; font: 800 19px/1 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; } .xlxt4-status { display: grid; grid-template-columns: auto 1fr; gap: 8px 10px; align-items: center; padding: 12px 14px; border-bottom: 1px solid var(--xlxt-line); background: rgba(242, 248, 245, .8); } .xlxt4-phase { min-width: 55px; padding: 4px 9px; border-radius: 999px; color: #53615c; background: #e5ebe8; text-align: center; font-size: 11px; font-weight: 700; } .xlxt4-phase[data-active="true"] { color: #fff; background: var(--xlxt-accent); } .xlxt4-summary { min-width: 0; color: var(--xlxt-muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .xlxt4-messages { min-height: 128px; max-height: 260px; overflow: auto; padding: 8px 10px; scrollbar-width: thin; scrollbar-color: #b8c8c1 transparent; } .xlxt4-message { position: relative; margin: 0; padding: 7px 8px 7px 24px; border-radius: 9px; color: #46514d; font-size: 12px; overflow-wrap: anywhere; } .xlxt4-message:hover { background: #f2f6f4; } .xlxt4-message::before { content: ""; position: absolute; left: 9px; top: 13px; width: 6px; height: 6px; border-radius: 50%; background: #6b91b5; } .xlxt4-message[data-level="success"]::before { background: #36a06e; } .xlxt4-message[data-level="warning"]::before { background: #e89b38; } .xlxt4-message[data-level="error"] { color: #9d3d3d; } .xlxt4-message[data-level="error"]::before { background: #d85656; } .xlxt4-time { margin-right: 6px; color: #98a39f; font-variant-numeric: tabular-nums; } .xlxt4-actions { display: grid; grid-template-columns: 1fr; gap: 10px; padding: 12px 12px 8px; border-top: 1px solid var(--xlxt-line); background: #f8faf9; } .xlxt4-utility-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; padding: 0 12px 13px; background: #f8faf9; } .xlxt4-button { min-height: 37px; padding: 8px 10px; border: 1px solid #d9e3df; border-radius: 10px; color: #40504a; background: #fff; cursor: pointer; font: inherit; font-weight: 650; transition: border-color .16s ease, box-shadow .16s ease, transform .16s ease, background .16s ease; } .xlxt4-button:hover:not(:disabled) { border-color: #9bbcb2; box-shadow: 0 5px 13px rgba(33, 91, 76, .10); transform: translateY(-1px); } .xlxt4-button:disabled { cursor: wait; opacity: .58; } .xlxt4-button-primary { min-height: 52px; border-radius: 12px; color: #fff; border-color: transparent; background: linear-gradient(135deg, var(--xlxt-accent-strong), var(--xlxt-accent)); box-shadow: 0 8px 18px rgba(35, 104, 87, .20); font-size: 16px; font-weight: 760; } .xlxt4-button-primary[data-active="true"] { background: linear-gradient(135deg, #a83f45, #d85b5f); } .xlxt4-button-secondary { min-height: 46px; border-radius: 12px; color: var(--xlxt-accent-strong); border-color: rgba(47, 125, 109, .28); background: #eef7f3; font-size: 14px; font-weight: 720; } .xlxt4-button-utility { min-height: 31px; padding: 5px 8px; color: #64716c; border-color: #dfe7e3; background: rgba(255,255,255,.72); font-size: 11px; font-weight: 620; } #xlxt4-settings-overlay, #xlxt4-settings-overlay * { box-sizing: border-box; } #xlxt4-settings-overlay { position: fixed; inset: 0; display: none; place-items: center; padding: 16px; background: rgba(18, 31, 27, .46); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); z-index: 2147483647; font: 13px/1.5 "Microsoft YaHei", "PingFang SC", system-ui, sans-serif; } .xlxt4-settings { width: min(520px, 100%); max-height: min(720px, calc(100vh - 32px)); overflow: auto; color: #26332f; background: #fbfcfb; border: 1px solid rgba(255,255,255,.7); border-radius: 18px; box-shadow: 0 24px 70px rgba(9, 31, 25, .28); } .xlxt4-settings-header { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; padding: 15px 18px; border-bottom: 1px solid #e0e7e4; background: rgba(251,252,251,.94); backdrop-filter: blur(12px); } .xlxt4-settings-title { margin: 0; font-size: 16px; } .xlxt4-settings-close { color: #61706b; background: #edf2ef; } .xlxt4-settings-body { padding: 4px 18px 16px; } .xlxt4-section-title { margin: 16px 0 9px; color: #2f7d6d; font-size: 12px; letter-spacing: .08em; } .xlxt4-field { display: grid; grid-template-columns: minmax(145px, .9fr) minmax(150px, 1.1fr); gap: 12px; align-items: center; padding: 9px 0; border-bottom: 1px dashed #e1e8e5; } .xlxt4-field-label { font-weight: 650; } .xlxt4-field-hint { display: block; margin-top: 2px; color: #8a9691; font-size: 10px; font-weight: 400; } .xlxt4-input { width: 100%; height: 38px; padding: 7px 10px; border: 1px solid #d2ddd8; border-radius: 9px; outline: none; color: #26332f; background: #fff; font: inherit; } .xlxt4-input:focus { border-color: #4b9b88; box-shadow: 0 0 0 3px rgba(75,155,136,.13); } .xlxt4-settings-footer { position: sticky; bottom: 0; display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid #e0e7e4; background: rgba(251,252,251,.96); } .xlxt4-save { color: #fff; border-color: transparent; background: #2f7d6d; } @media (max-width: 520px) { #xlxt4-panel { right: 8px; bottom: 8px; width: calc(100vw - 16px); max-height: calc(100vh - 16px); } .xlxt4-messages { max-height: 34vh; } .xlxt4-field { grid-template-columns: 1fr; gap: 6px; } } @media (prefers-reduced-motion: reduce) { #xlxt4-panel *, #xlxt4-settings-overlay * { transition: none !important; } } `; function element(tag, className, text, parent) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; if (parent) parent.appendChild(node); return node; } function createButtonComponent(component, parent) { const className = [component.kind === 'icon' ? '' : 'xlxt4-button', component.className] .filter(Boolean) .join(' '); const node = element('button', className, component.label, parent); node.type = 'button'; if (component.domId) node.id = component.domId; node.dataset.componentId = component.id; if (component.title) { node.title = component.title; node.setAttribute('aria-label', component.title); } node.addEventListener('click', component.action); if (component.ref) refs[component.ref] = node; return node; } function renderButtonSlot(slot, parent) { return BUTTON_COMPONENTS .filter(component => component.slot === slot) .map(component => createButtonComponent(component, parent)); } function injectStyles() { if (document.getElementById('xlxt4-style')) return; const style = document.createElement('style'); style.id = 'xlxt4-style'; style.textContent = STYLE_TEXT; (document.head || document.documentElement).appendChild(style); } function buildPanel() { refs.panel = element('section'); refs.panel.id = 'xlxt4-panel'; refs.panel.setAttribute('aria-label', '杏林学堂自动处理面板'); const header = element('header', 'xlxt4-header', null, refs.panel); element('div', 'xlxt4-logo', '杏', header); const titleWrap = element('div', 'xlxt4-title-wrap', null, header); element('h2', 'xlxt4-title', '杏林学堂助手', titleWrap); element('div', 'xlxt4-version', `V${CONFIG.VERSION} · 稳定管道`, titleWrap); const headerActions = element('div', 'xlxt4-header-actions', null, header); renderButtonSlot('header-actions', headerActions); const status = element('div', 'xlxt4-status', null, refs.panel); refs.phaseBadge = element('span', 'xlxt4-phase', '待机', status); refs.summary = element('span', 'xlxt4-summary', '任务 0/0 · 课程 0/0', status); refs.messageList = element('div', 'xlxt4-messages', null, refs.panel); refs.messageList.setAttribute('role', 'log'); refs.messageList.setAttribute('aria-live', 'polite'); const actions = element('div', 'xlxt4-actions', null, refs.panel); renderButtonSlot('main-actions', actions); const utilityActions = element('div', 'xlxt4-utility-actions', null, refs.panel); renderButtonSlot('utility-actions', utilityActions); document.body.appendChild(refs.panel); renderButtonSlot('floating-restore', document.body); } function addSettingField(parent, config) { const row = element('label', 'xlxt4-field', null, parent); const label = element('span', 'xlxt4-field-label', config.label, row); if (config.hint) element('small', 'xlxt4-field-hint', config.hint, label); const input = element('input', 'xlxt4-input', null, row); input.id = `xlxt4-${config.key}`; input.type = config.type || 'number'; if (config.min != null) input.min = String(config.min); if (config.max != null) input.max = String(config.max); refs.settingsInputs[config.key] = input; } function buildSettings() { if (refs.settingsOverlay) return; refs.settingsOverlay = element('div'); refs.settingsOverlay.id = 'xlxt4-settings-overlay'; refs.settingsOverlay.addEventListener('click', event => { if (event.target === refs.settingsOverlay) closeSettings(); }); const modal = element('section', 'xlxt4-settings', null, refs.settingsOverlay); modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); const header = element('header', 'xlxt4-settings-header', null, modal); element('h2', 'xlxt4-settings-title', '运行设置', header); renderButtonSlot('settings-header', header); const body = element('div', 'xlxt4-settings-body', null, modal); element('h3', 'xlxt4-section-title', '播放与重试', body); addSettingField(body, { key: 'playButtonDelay', label: '播放入口最大等待', hint: '检测到入口即继续;超时后才重试,单位毫秒', min: 0, max: 60000 }); addSettingField(body, { key: 'videoDetectionDelay', label: '视频就绪最大等待', hint: '视频可用即继续;超时后才重试,单位毫秒', min: 100, max: 60000 }); addSettingField(body, { key: 'videoAdjustWait', label: '完成标记最大等待', hint: '检测到完成即继续;超时后才重试,单位毫秒', min: 100, max: 60000 }); addSettingField(body, { key: 'maxRetries', label: '视频状态重试', hint: '建议 2–5 次', min: 1, max: 20 }); addSettingField(body, { key: 'maxPlayButtonRetries', label: '播放按钮重试', hint: '建议 2–5 次', min: 1, max: 20 }); addSettingField(body, { key: 'maxLoopRetries', label: '课程整体重试', hint: '单课失败前的完整尝试次数', min: 1, max: 20 }); element('h3', 'xlxt4-section-title', '登录与设备', body); addSettingField(body, { key: 'phone', label: '手机号 / 用户名', type: 'text' }); addSettingField(body, { key: 'password', label: '登录密码', type: 'text' }); addSettingField(body, { key: 'deviceName', label: '设备名称', hint: '用于诊断包文件名', type: 'text' }); const footer = element('footer', 'xlxt4-settings-footer', null, modal); renderButtonSlot('settings-footer', footer); document.body.appendChild(refs.settingsOverlay); document.addEventListener('keydown', event => { if (event.key === 'Escape' && refs.settingsOverlay.style.display === 'grid') closeSettings(); }); } function openSettings() { buildSettings(); const settings = Settings.load(); Object.keys(refs.settingsInputs).forEach(key => { refs.settingsInputs[key].value = settings[key]; }); refs.settingsOverlay.style.display = 'grid'; const firstInput = refs.settingsInputs.playButtonDelay; if (firstInput) setTimeout(() => firstInput.focus(), 0); } function closeSettings() { if (refs.settingsOverlay) refs.settingsOverlay.style.display = 'none'; } function saveSettings() { const value = {}; Object.keys(refs.settingsInputs).forEach(key => { value[key] = refs.settingsInputs[key].value; }); try { Settings.save(value); Logger.success('设置已保存'); closeSettings(); } catch (error) { Logger.error('保存设置失败: ' + error.message); } } function addMessage(entry) { if (!refs.messageList) return; const row = element('p', 'xlxt4-message', null, refs.messageList); row.dataset.level = entry.level; element('span', 'xlxt4-time', new Date(entry.time).toLocaleTimeString(), row); row.appendChild(document.createTextNode(entry.msg)); while (refs.messageList.children.length > CONFIG.UI_MSG_MAX) { refs.messageList.removeChild(refs.messageList.firstChild); } refs.messageList.scrollTop = refs.messageList.scrollHeight; } function updateState() { if (!refs.phaseBadge || !refs.startButton || !refs.summary) return; const labels = { idle: '待机', fetching_tasks: '取任务', processing_tasks: '取课程', navigating: '跳转中', processing_course: '处理中' }; refs.phaseBadge.textContent = labels[Store.phase] || Store.phase; refs.phaseBadge.dataset.active = String(Store.active); refs.startButton.textContent = Store.active ? '停止刷课' : '开始刷课'; refs.startButton.dataset.active = String(Store.active); const taskTotal = Store.taskQueue.length; const taskCurrent = Store.taskIndex >= 0 ? Math.min(Store.taskIndex + 1, taskTotal) : 0; const courseTotal = Store.courseQueue.length; const courseCurrent = Store.courseIndex >= 0 ? Math.min(Store.courseIndex + 1, courseTotal) : 0; refs.summary.textContent = `任务 ${taskCurrent}/${taskTotal} · 课程 ${courseCurrent}/${courseTotal}`; } async function togglePipeline() { if (Store.active) { Pipeline.abort({ reason: 'user' }); return; } if (starting) return; starting = true; try { await Pipeline.start(); } catch (error) { if (!isAbortError(error)) Logger.error('启动流程异常: ' + error.message); } finally { starting = false; } } async function processCurrent() { if (manualBusy) return; if (Store.active) { Logger.warn('自动刷课正在运行,请先停止后再手动处理当前课程'); return; } if (!window.location.href.includes('/course-info')) { Logger.error('当前不在课程页面'); return; } manualBusy = true; refs.processButton.disabled = true; refs.processButton.textContent = '处理中...'; Logger.info('正在手动处理当前课程...'); try { const success = await VideoController.processCourseOnPage(); if (success) Logger.success('当前课程处理成功'); else Logger.warn('当前课程处理失败,请查看日志'); } catch (error) { Logger.error('当前课程处理异常: ' + error.message); } finally { manualBusy = false; refs.processButton.disabled = false; refs.processButton.textContent = '处理当前课程'; } } function goToLogin() { Router.navigate(CONFIG.API.LOGIN_PAGE); } function exportDiagnostics() { Diagnostics.export(); } function collapsePanel() { refs.panel.style.display = 'none'; refs.floatButton.style.display = 'grid'; } function restorePanel() { refs.floatButton.style.display = 'none'; refs.panel.style.display = 'flex'; } function init() { if (document.getElementById('xlxt4-panel')) return; injectStyles(); buildPanel(); Events.on('log:entry', addMessage); Events.on('store:changed', updateState); Events.on('pipeline:stopped', updateState); updateState(); Logger.info(`杏林学堂助手 V${CONFIG.VERSION} 已就绪`); Logger.info('点击“开始刷课”执行全部未完成任务,也可手动处理当前课程'); } return { init }; })(); const Diagnostics = (() => { const CRC32_TABLE = (() => { const table = []; for (let index = 0; index < 256; index++) { let value = index; for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1; table[index] = value >>> 0; } return table; })(); function crc32(bytes) { let crc = 0xffffffff; for (let index = 0; index < bytes.length; index++) { crc = CRC32_TABLE[(crc ^ bytes[index]) & 0xff] ^ (crc >>> 8); } return (crc ^ 0xffffffff) >>> 0; } function toBytes(content, encoder) { if (content instanceof Uint8Array) return content; if (content instanceof ArrayBuffer) return new Uint8Array(content); if (ArrayBuffer.isView(content)) return new Uint8Array(content.buffer, content.byteOffset, content.byteLength); return encoder.encode(content == null ? '' : String(content)); } function createZipBlob(files) { const encoder = new TextEncoder(); const localParts = []; const centralParts = []; let offset = 0; const now = new Date(); const dosTime = (now.getHours() << 11) | (now.getMinutes() << 5) | Math.floor(now.getSeconds() / 2); const dosDate = ((now.getFullYear() - 1980) << 9) | ((now.getMonth() + 1) << 5) | now.getDate(); files.forEach(file => { const nameBytes = encoder.encode(file.name); const bodyBytes = toBytes(file.content, encoder); const checksum = crc32(bodyBytes); const localHeader = new ArrayBuffer(30); const localView = new DataView(localHeader); localView.setUint32(0, 0x04034b50, true); localView.setUint16(4, 20, true); localView.setUint16(6, 0x0800, true); localView.setUint16(8, 0, true); localView.setUint16(10, dosTime, true); localView.setUint16(12, dosDate, true); localView.setUint32(14, checksum, true); localView.setUint32(18, bodyBytes.length, true); localView.setUint32(22, bodyBytes.length, true); localView.setUint16(26, nameBytes.length, true); localView.setUint16(28, 0, true); localParts.push(localHeader, nameBytes, bodyBytes); const localOffset = offset; offset += localHeader.byteLength + nameBytes.length + bodyBytes.length; const centralHeader = new ArrayBuffer(46); const centralView = new DataView(centralHeader); centralView.setUint32(0, 0x02014b50, true); centralView.setUint16(4, 20, true); centralView.setUint16(6, 20, true); centralView.setUint16(8, 0x0800, true); centralView.setUint16(10, 0, true); centralView.setUint16(12, dosTime, true); centralView.setUint16(14, dosDate, true); centralView.setUint32(16, checksum, true); centralView.setUint32(20, bodyBytes.length, true); centralView.setUint32(24, bodyBytes.length, true); centralView.setUint16(28, nameBytes.length, true); centralView.setUint16(30, 0, true); centralView.setUint16(32, 0, true); centralView.setUint16(34, 0, true); centralView.setUint16(36, 0, true); centralView.setUint32(38, 0, true); centralView.setUint32(42, localOffset, true); centralParts.push(centralHeader, nameBytes); }); const centralSize = centralParts.reduce((total, part) => total + (part.byteLength || part.length), 0); const endHeader = new ArrayBuffer(22); const endView = new DataView(endHeader); endView.setUint32(0, 0x06054b50, true); endView.setUint16(4, 0, true); endView.setUint16(6, 0, true); endView.setUint16(8, files.length, true); endView.setUint16(10, files.length, true); endView.setUint32(12, centralSize, true); endView.setUint32(16, offset, true); endView.setUint16(20, 0, true); return new Blob(localParts.concat(centralParts, [endHeader]), { type: 'application/zip' }); } function sanitize(value) { return String(value || '').replace(/[\\/:*?"<>|]/g, '_').trim(); } function collect() { const timestamp = new Date().toISOString(); let dom = ''; try { dom = document.documentElement.outerHTML; } catch (error) { dom = '*** ERROR: ' + error.message + ' ***'; } const localStorageData = {}; try { const settings = localStorage.getItem('autoCourseSettings'); if (settings) localStorageData.autoCourseSettings = JSON.parse(settings); } catch (error) { localStorageData.error = error.message; } return { info: { timestamp, url: window.location.href, userAgent: navigator.userAgent, viewport: { width: window.innerWidth, height: window.innerHeight }, scriptVersion: CONFIG.VERSION, deviceName: Settings.get('deviceName') || '未设置' }, logs: Logger.getAll(), state: Store.snapshot(), dom, errors: ErrorCollector.getAll(), localStorage: localStorageData, cookie: document.cookie }; } function exportDiagnostics() { try { Logger.info('正在收集诊断数据并打包...'); const diagnostics = collect(); const files = [ { name: 'info.json', content: JSON.stringify(diagnostics.info, null, 2) }, { name: 'logs.json', content: JSON.stringify(diagnostics.logs, null, 2) }, { name: 'state.json', content: JSON.stringify(diagnostics.state, null, 2) }, { name: 'dom.html', content: diagnostics.dom }, { name: 'errors.json', content: JSON.stringify(diagnostics.errors, null, 2) }, { name: 'localstorage.json', content: JSON.stringify(diagnostics.localStorage, null, 2) }, { name: 'cookie.txt', content: diagnostics.cookie } ]; const blob = createZipBlob(files); const url = URL.createObjectURL(blob); const link = document.createElement('a'); const deviceName = sanitize(diagnostics.info.deviceName); const deviceSuffix = deviceName && deviceName !== '未设置' ? '-' + deviceName : ''; link.href = url; link.download = `XLXT-diagnostics${deviceSuffix}-${diagnostics.info.timestamp.replace(/:/g, '_')}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); setTimeout(() => URL.revokeObjectURL(url), 1000); Logger.success('诊断包已下载'); } catch (error) { Logger.error('诊断包生成失败: ' + error.message); } } return { export: exportDiagnostics }; })(); function onUrlChange() { const url = window.location.href; if (url.includes('/#/course-info')) { if (Store.active && Store.courseQueue.length > 0) { Pipeline.processOneCourseOnPage().catch(error => { if (!isAbortError(error)) Logger.error('课程路由处理失败: ' + error.message); }); } } else if (url.includes('/login')) { LoginController.execute(); } } Router = (() => { let lastUrl = ''; let started = false; function notify(force = false) { const currentUrl = window.location.href; if (!force && currentUrl === lastUrl) return; lastUrl = currentUrl; setTimeout(onUrlChange, 0); } function wrapHistoryMethod(name) { const original = history[name]; if (!original || original.__xlxt4Wrapped) return; const wrapped = function (...args) { const result = original.apply(this, args); setTimeout(() => notify(false), 0); return result; }; Object.defineProperty(wrapped, '__xlxt4Wrapped', { value: true }); try { history[name] = wrapped; } catch (error) { Logger.warn(`无法包装 history.${name},将继续使用 hashchange/popstate`); } } return { start() { if (started) return; started = true; window.addEventListener('hashchange', () => notify(false)); window.addEventListener('popstate', () => notify(false)); wrapHistoryMethod('pushState'); wrapHistoryMethod('replaceState'); notify(true); }, navigate(url) { const target = new URL(url, window.location.href).href; if (target === window.location.href) { notify(true); return; } window.location.href = target; setTimeout(() => notify(false), 80); } }; })(); function boot() { if (!document.body) { setTimeout(boot, 50); return; } UI.init(); Router.start(); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot, { once: true }); else boot(); })();