// ==UserScript==
// @name 木金网课助手强辅版|自动切课|自动切考
// @namespace https://wk.bobo91.com/mujin-strong-helper
// @version 3.3.31
// @description 木金网课助手强辅版:学完一科自动切换下一科真正做到解放双手!配合木金网课助手使用。
// @author 木金
// @match *://chaoxing.com/*
// @match *://*.chaoxing.com/*
// @match *://i.chaoxing.com/*
// @match *://i.mooc.chaoxing.com/*
// @match *://passport2.chaoxing.com/*
// @match *://passport2-api.chaoxing.com/*
// @match *://mooc1.chaoxing.com/*
// @match *://mooc2-ans.chaoxing.com/*
// @match *://fanya.chaoxing.com/*
// @match *://*.fanya.chaoxing.com/*
// @match *://*.jxjy.chaoxing.com/*
// @match *://chaoxing.com.cn/*
// @match *://*.chaoxing.com.cn/*
// @match *://xueyinonline.com/*
// @match *://*.xueyinonline.com/*
// @match *://*.zhihuishu.com/*
// @match *://*.icve.com.cn/*
// @match *://*.icourse163.org/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @grant GM_notification
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @grant unsafeWindow
// @connect wk.bobo91.com
// @connect chaoxing.com
// @connect *.chaoxing.com
// @connect passport2.chaoxing.com
// @connect passport2-api.chaoxing.com
// @connect mooc1.chaoxing.com
// @connect mooc2-ans.chaoxing.com
// @connect fanya.chaoxing.com
// @connect *.fanya.chaoxing.com
// @connect *.jxjy.chaoxing.com
// @connect chaoxing.com.cn
// @connect *.chaoxing.com.cn
// @connect xueyinonline.com
// @connect *.xueyinonline.com
// @run-at document-end
// @homepageURL https://wk.bobo91.com/
// @supportURL https://wk.bobo91.com/
// @icon https://wk.bobo91.com/logo.png
// @license All Rights Reserved
// ==/UserScript==
// Copyright (c) 2026 木金. All rights reserved.
// 未经作者许可,禁止复制、二次发布、倒卖或改名分发。
// 本发布版为干净可审核的可读源码版,未做混淆、加密、自解密或动态执行包裹。
(function () {
'use strict';
const SCRIPT_VERSION = '3.3.30';
if (window.__mjn3_loaded__ === SCRIPT_VERSION) {
return;
}
window.__mjn3_loaded__ = SCRIPT_VERSION;
const CFG = {
PREFIX: 'mjn3_',
MONITOR_MS: 15000,
TASK_DELAY_MS: 300,
PAGE_LOAD_MS: 1200,
POPUP_MS: 500,
API_TIMEOUT: 10000,
EXAM_SCAN_CONCURRENCY: 1,
EXAM_SCAN_DELAY_MIN_MS: 1000,
EXAM_SCAN_DELAY_MAX_MS: 3000,
EXAM_SCAN_CACHE_MS: 43200000,
SWITCH_MS: 3000,
API_CHECK_INTERVAL_MS: 15000,
TASK_RECHECK_MS: 30000,
PROGRESS_DETAIL_CACHE_MS: 600000,
PROGRESS_SCAN_CONCURRENCY: 3,
PROGRESS_BACKGROUND_REFRESH_MS: 120000,
AUTO_FETCH_DELAY_MS: 1200,
PROGRESS_LOG_MS: 1800000,
HEARTBEAT_MS: 1800000,
MIN_TASK_NAME_LEN: 2,
MAX_TASK_NAME_LEN: 200,
RELOGIN_MAX: 5,
CAPTCHA_WAIT_S: 180,
PROGRESS_REPORT_STEP: 5,
CHAPTER_EXPAND_MS: 300,
BODY_TEXT_MAX: 2000,
TRANSITION_MIN_CD: 2,
TRANSITION_CD_MS: 1000,
TASK_TOAST_MS: 2000,
TOAST_DURATION_MS: 2000,
UPDATE_URL: 'https://wk.bobo91.com/mujin-course-queue.user.js',
UPDATE_CHECK_MS: 86400000,
UPDATE_SNOOZE_MS: 86400000,
UPDATE_TIMEOUT: 8000,
COURSE_BTN_DELAY_1: 2000,
COURSE_BTN_DELAY_2: 5000,
DRAG_HOLD_MS: 260,
MIN_HTML_LEN: 200,
MIN_ID_LEN: 3,
NAV_CLICK_SLEEP: 800,
ENTER_TASK_SLEEP: 80,
FADE_OUT_MS: 300,
NOTIFY_TIMEOUT: 5000,
MIN_ENC_LEN: 32,
QUEUE_PREVIEW_COUNT: 2,
QUEUE_PREVIEW_LEN: 8,
NAME_TRUNC_LEN: 14,
};
const $ = (s, p) => (p || document).querySelector(s);
const $$ = (s, p) => [...(p || document).querySelectorAll(s)];
const sleep = ms => new Promise(r => setTimeout(r, ms));
const log = (msg, lv) => {
const level = lv ?? 'log';
const method = console[level] ?? console.log;
method('[木金队列v3]', msg);
};
function getUrlParam(name) {
try { return new URL(location.href).searchParams.get(name); } catch (_) { return null; }
}
function getUrlParamFrom(url, name) {
try {
const params = new URL(url, location.href).searchParams;
const names = Array.isArray(name) ? name : [name];
for (const n of names) {
const value = params.get(n);
if (value) return value;
}
return '';
} catch (_) { return ''; }
}
function isVisible(el) {
if (!el) return false;
const r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return false;
const s = getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden';
}
function detectPlatform() {
const h = location.hostname;
if (h.includes('chaoxing.com')) return 'chaoxing';
if (h.includes('zhihuishu.com')) return 'zhihuishu';
if (h.includes('icve.com.cn')) return 'icve';
if (h.includes('icourse163.org')) return 'mooc';
return 'unknown';
}
const PLATFORM = detectPlatform();
const isTopFrame = window === window.top;
function storeKey(k) { return CFG.PREFIX + k; }
function loadQueue() {
try {
const r = GM_getValue(storeKey('queue_' + PLATFORM), '[]');
const a = JSON.parse(r);
return Array.isArray(a) ? a : [];
} catch (_) {
log('loadQueue 存储异常,返回空队列', 'warn');
return [];
}
}
function saveQueue(q) {
try { GM_setValue(storeKey('queue_' + PLATFORM), JSON.stringify(q)); }
catch (_) { log('saveQueue 存储异常', 'warn'); }
}
function loadState() {
try {
const r = GM_getValue(storeKey('state'), 'null');
return r ? JSON.parse(r) : null;
} catch (_) {
log('loadState 存储异常', 'warn');
return null;
}
}
function saveState(s) {
try { GM_setValue(storeKey('state'), JSON.stringify(s)); }
catch (_) { log('saveState 存储异常', 'warn'); }
}
function clearState() {
try { GM_deleteValue(storeKey('state')); }
catch (_) { log('clearState 存储异常', 'warn'); }
}
function autoFetchKey() {
return storeKey('auto_fetch_' + PLATFORM);
}
function requestAutoFetchCourses() {
try { GM_setValue(autoFetchKey(), String(Date.now())); }
catch (_) { /* 自动获取标记失败 */ }
}
function consumeAutoFetchCourses() {
try {
const v = GM_getValue(autoFetchKey(), '');
if (!v) return false;
GM_deleteValue(autoFetchKey());
return true;
} catch (_) {
return false;
}
}
function loadCourseHomeUrl() {
try { return GM_getValue(storeKey('course_home_' + PLATFORM), '') || ''; }
catch (_) { return ''; }
}
function saveCourseHomeUrl(url) {
if (!url) return;
try { GM_setValue(storeKey('course_home_' + PLATFORM), url); }
catch (_) { /* 课程首页缓存失败 */ }
}
function loadExamScanState() {
try {
const r = GM_getValue(storeKey('exam_scan_state_' + PLATFORM), 'null');
const s = r ? JSON.parse(r) : null;
return s && typeof s === 'object' ? s : null;
} catch (_) {
log('loadExamScanState 存储异常,返回空状态', 'warn');
return null;
}
}
function saveExamScanState(s) {
try { GM_setValue(storeKey('exam_scan_state_' + PLATFORM), JSON.stringify(s || {})); }
catch (_) { log('saveExamScanState 存储异常', 'warn'); }
}
function clearExamScanState() {
try { GM_deleteValue(storeKey('exam_scan_state_' + PLATFORM)); }
catch (_) { log('clearExamScanState 存储异常', 'warn'); }
}
function resetExamScanProgress() {
const state = loadExamScanState() || {};
saveExamScanState({
cache: state.cache && typeof state.cache === 'object' ? state.cache : {},
nextIndex: 0,
scannedKeys: [],
resetAt: Date.now(),
});
}
function getDefaultCourseHomeUrl() {
if (PLATFORM === 'chaoxing') return 'https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction';
return '';
}
function getDefaultAccountHomeUrl() {
if (PLATFORM === 'chaoxing') return 'https://i.mooc.chaoxing.com/space/index';
return getDefaultCourseHomeUrl();
}
function isBadCourseHomeUrl(url) {
try {
const u = new URL(url, location.href);
return /jxjy\.chaoxing\.com$/i.test(u.hostname) && /\/course-v2\/studyApp\/studying/i.test(u.pathname);
} catch (_) {
return false;
}
}
const TASK_TYPE_MAP = {
'1': '视频', '2': '文档', '3': '测验', '4': '作业',
'5': '讨论', '6': '直播', '7': '考试', '8': '阅读',
'video': '视频', 'doc': '文档', 'document': '文档',
'test': '测验', 'exam': '考试', 'quiz': '测验',
'work': '作业', 'homework': '作业', 'discuss': '讨论',
'live': '直播', 'read': '阅读',
};
function formatMinutes(minutes) {
if (minutes < 60) return `${Math.floor(minutes)}分钟`;
const h = Math.floor(minutes / 60);
const m = Math.floor(minutes % 60);
return m > 0 ? `${h}小时${m}分钟` : `${h}小时`;
}
function calcProgressPercent(done, total) {
const d = Number(done) || 0;
const t = Number(total) || 0;
if (t <= 0) return 0;
return Number(((d / t) * 100).toFixed(2));
}
function formatProgressPercent(percent) {
const pct = Number(percent);
if (!Number.isFinite(pct)) return '0';
return pct.toFixed(2).replace(/\.?0+$/, '');
}
function clampPercent(percent) {
const pct = Number(percent);
if (!Number.isFinite(pct)) return 0;
return Math.max(0, Math.min(100, pct));
}
function toInt(value, fallback = 0) {
const n = parseInt(String(value ?? '').replace(/[^\d-]/g, '') || String(fallback), 10);
return Number.isFinite(n) ? n : fallback;
}
function buildApiProgress(done, total, options = {}) {
const safeTotal = Math.max(0, toInt(total, 0));
if (safeTotal <= 0) return null;
const safeDone = Math.max(0, Math.min(toInt(done, 0), safeTotal));
const rawUnfinish = options.unfinish !== undefined ? toInt(options.unfinish, safeTotal - safeDone) : safeTotal - safeDone;
return {
done: safeDone,
total: safeTotal,
percent: calcProgressPercent(safeDone, safeTotal),
unfinish: Math.max(0, Math.min(rawUnfinish, safeTotal)),
source: 'api',
sourceDetail: options.sourceDetail || 'api',
};
}
function isApiProgress(prog) {
return !!prog && prog.source === 'api' && Number(prog.total) > 0;
}
function progressSourceLabel(prog) {
return prog?.sourceDetail || prog?.source || 'unknown';
}
function isChaoxingLoginPageContent(text, url = '') {
const body = String(text || '');
const href = String(url || '');
const hay = body + '\n' + href;
return href.includes('//passport2.chaoxing.com/login')
|| href.includes('/passport2.chaoxing.com/login')
|| (/
]*>\s*用户登录\s*<\/title>/i.test(body) && /passport2-static|fanya\/login|twoFactorLogin/i.test(body))
|| (/用户登录/i.test(body) && /passport2\.chaoxing\.com|passport2-static/i.test(hay));
}
function isChaoxingAntiSpiderContent(text, url = '') {
const body = String(text || '');
const href = String(url || '');
if (isChaoxingLoginPageContent(body, href)) return false;
return /antispiderShowVerify|processVerifyPng|操作异常|【?9010】?|\b9010\b|请完成验证|安全验证|滑块验证/i.test(body + '\n' + href);
}
class ChaoxingAntiSpiderError extends Error {
constructor() {
super('检测到学习通风控,已暂停跨课程扫描');
this.name = 'ChaoxingAntiSpiderError';
}
}
function safeFetch(url, options = {}) {
const ctrl = new AbortController();
const timeout = options.timeout ?? CFG.API_TIMEOUT;
const tid = setTimeout(() => ctrl.abort(), timeout);
const merged = { ...options, signal: ctrl.signal };
delete merged.timeout;
delete merged.detectAntiSpider;
return fetch(url, merged).finally(() => clearTimeout(tid));
}
function canFetchSameOrigin(url) {
try { return new URL(url, location.href).origin === location.origin; }
catch (_) { return false; }
}
function getChaoxingCourseFrame() {
if (PLATFORM !== 'chaoxing') return null;
const frames = [
document.getElementById('frame_content'),
...$$('iframe[src*="mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction"], iframe[src*="/mooc2-ans/visit/interaction"]'),
].filter(Boolean);
for (const frame of frames) {
try {
const href = frame.contentWindow?.location?.href || frame.src || '';
if (href.includes('mooc2-ans.chaoxing.com') && frame.contentWindow?.fetch) return frame;
} catch (_) { /* 跨域iframe不可读 */ }
}
return null;
}
function canFetchViaChaoxingFrame(url) {
if (PLATFORM !== 'chaoxing') return false;
try {
const u = new URL(url, location.href);
return u.origin === 'https://mooc2-ans.chaoxing.com' && !!getChaoxingCourseFrame();
} catch (_) {
return false;
}
}
async function fetchViaChaoxingFrame(url, options = {}) {
const frame = getChaoxingCourseFrame();
if (!frame?.contentWindow?.fetch) throw new Error('课程iframe不可用');
const resp = await frame.contentWindow.fetch(url, {
method: options.method || 'GET',
headers: options.headers || {},
body: options.body || options.data,
credentials: 'include',
redirect: 'follow',
});
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const text = await resp.text();
return { text, finalUrl: resp.url || url };
}
async function fetchAnyOrigin(url, options = {}) {
if (canFetchSameOrigin(url)) {
const resp = await safeFetch(url, { credentials: 'include', ...options });
if (!resp.ok) throw new Error('HTTP ' + resp.status);
const text = await resp.text();
const finalUrl = resp.url || url;
if (options.detectAntiSpider && isChaoxingAntiSpiderContent(text, finalUrl)) throw new ChaoxingAntiSpiderError();
return { text, finalUrl };
}
if (canFetchViaChaoxingFrame(url)) {
const fetched = await fetchViaChaoxingFrame(url, options);
if (options.detectAntiSpider && isChaoxingAntiSpiderContent(fetched.text, fetched.finalUrl)) throw new ChaoxingAntiSpiderError();
return fetched;
}
if (typeof GM_xmlhttpRequest === 'function') {
const request = (currentUrl, redirects = 0) => new Promise((resolve, reject) => {
try {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: currentUrl,
headers: options.headers || {},
data: options.body || options.data,
anonymous: false,
timeout: options.timeout || CFG.API_TIMEOUT,
onload: resp => {
const finalUrl = resp.finalUrl || resp.responseURL || currentUrl;
if (resp.status >= 300 && resp.status < 400 && redirects < 3) {
const loc = String(resp.responseHeaders || '').match(/(?:^|\n)\s*location\s*:\s*([^\n\r]+)/i);
if (loc && loc[1]) {
try {
resolve(request(new URL(loc[1].trim(), currentUrl).href, redirects + 1));
return;
} catch (_) { /* ignore */ }
}
}
if (resp.status >= 200 && resp.status < 400) {
const text = resp.responseText || '';
if (options.detectAntiSpider && isChaoxingAntiSpiderContent(text, finalUrl)) reject(new ChaoxingAntiSpiderError());
else resolve({ text, finalUrl });
}
else reject(new Error('HTTP ' + resp.status));
},
onerror: () => reject(new Error('请求失败')),
ontimeout: () => reject(new Error('请求超时')),
});
} catch (e) {
reject(e);
}
});
return request(url);
}
throw new Error('跨域读取不可用');
}
async function fetchTextAnyOrigin(url, options = {}) {
return (await fetchAnyOrigin(url, options)).text;
}
function compareVersions(a, b) {
const pa = String(a || '').split(/[.-]/).map(part => parseInt(part.replace(/[^\d]/g, '') || '0', 10));
const pb = String(b || '').split(/[.-]/).map(part => parseInt(part.replace(/[^\d]/g, '') || '0', 10));
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const da = Number.isFinite(pa[i]) ? pa[i] : 0;
const db = Number.isFinite(pb[i]) ? pb[i] : 0;
if (da > db) return 1;
if (da < db) return -1;
}
return 0;
}
function parseRemoteVersion(source) {
const match = String(source || '').match(/^\s*\/\/\s*@version\s+([^\s]+)/m);
return match ? match[1].trim() : '';
}
function shouldCheckForUpdate() {
const now = Date.now();
const snoozeUntil = Number(GM_getValue(storeKey('update_snooze_until'), 0)) || 0;
if (snoozeUntil > now) return false;
const last = Number(GM_getValue(storeKey('update_last_check'), 0)) || 0;
return now - last >= CFG.UPDATE_CHECK_MS;
}
function markUpdateChecked() {
try { GM_setValue(storeKey('update_last_check'), String(Date.now())); }
catch (_) { /* 更新检查时间保存失败 */ }
}
function snoozeUpdatePrompt() {
try { GM_setValue(storeKey('update_snooze_until'), String(Date.now() + CFG.UPDATE_SNOOZE_MS)); }
catch (_) { /* 更新提醒延后失败 */ }
}
async function checkForScriptUpdate(force = false) {
if (!force && !shouldCheckForUpdate()) return null;
markUpdateChecked();
try {
const source = await fetchTextAnyOrigin(CFG.UPDATE_URL, { timeout: CFG.UPDATE_TIMEOUT });
const latestVersion = parseRemoteVersion(source);
if (!latestVersion) throw new Error('未读取到远程版本号');
if (compareVersions(latestVersion, SCRIPT_VERSION) <= 0) return null;
const info = {
currentVersion: SCRIPT_VERSION,
latestVersion,
url: CFG.UPDATE_URL,
};
UI.showUpdateNotice(info);
return info;
} catch (e) {
log('检查更新失败: ' + (e?.message || e), 'warn');
return null;
}
}
function parseStudystateData(el) {
if (!el) return null;
const raw = (el.value || '').replace(/"/g, '"');
let data = null;
try { data = JSON.parse(raw); }
catch (_) {
try { data = JSON.parse(raw.replace(/([{,]\s*)([a-zA-Z_$][a-zA-Z0-9_$]*)\s*:/g, '$1"$2":')); }
catch (__) { /* JSON解析失败 */ }
}
return data;
}
function getAllDocs() {
const docs = [document];
for (const iframe of $$('iframe')) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (doc?.body) docs.push(doc);
} catch (_) { /* 跨域iframe,忽略 */ }
}
return docs;
}
function getChaoxingPageEnc() {
let enc = getUrlParam('enc') || '';
if (enc) return enc;
try {
for (const doc of getAllDocs()) {
for (const s of doc.querySelectorAll('script')) {
const match = (s.textContent || '').match(/(?:var|let|const)\s+enc\s*=\s*["']([a-f0-9]{32})["']/);
if (match) return match[1];
}
}
} catch (_) { /* ignore */ }
try {
if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.toOld === 'function') {
const match = unsafeWindow.toOld.toString().match(/enc\s*=\s*"([a-f0-9]{32})"/);
if (match) return match[1];
}
} catch (_) { /* ignore */ }
return '';
}
function normText(text) {
return (text || '').trim().replace(/\s+/g, ' ');
}
function escapeRegExp(text) {
return String(text || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function cleanExtractedUrl(url) {
return String(url || '')
.replace(/&/g, '&')
.trim()
.replace(/(?:%27|%22|['")\s])+$/g, '');
}
function getAllUiDocs() {
const docs = [];
const seen = new Set();
const collect = doc => {
if (!doc || seen.has(doc)) return;
seen.add(doc);
docs.push(doc);
for (const iframe of doc.querySelectorAll('iframe')) {
try {
collect(iframe.contentDocument || iframe.contentWindow?.document);
} catch (_) { /* cross-origin iframe */ }
}
};
collect(document);
return docs;
}
function injectCss(css) {
try {
if (typeof GM_addStyle === 'function') {
GM_addStyle(css);
return;
}
} catch (e) {
log('GM_addStyle 注入失败,使用style兜底: ' + e.message, 'warn');
}
const style = document.createElement('style');
style.dataset.mjn3Ui = 'style';
style.textContent = css;
(document.head || document.documentElement || document.body)?.appendChild(style);
}
function classText(el) {
if (!el) return '';
const cls = typeof el.className === 'string' ? el.className : (el.className?.baseVal || '');
return String(cls).toLowerCase();
}
function getTaskCompletionStatus(item, iconEl) {
const text = normText(item?.textContent || '');
const unfinishEl = item?.querySelector?.('.jobUnfinishCount, [class*="Unfinish"], [class*="unfinish"], [class*="unfinished"]');
if (unfinishEl) {
const raw = normText(unfinishEl.textContent || unfinishEl.value || '');
const count = parseInt(raw.replace(/[^\d]/g, '') || '0', 10);
return count > 0 ? 'todo' : 'done';
}
if (/未完成|未学习|待完成|待学习|未通过|进行中/.test(text)) return 'todo';
if (/已完成|已通过|已学习|已观看|完成度\s*100%|100%\s*完成/.test(text)) return 'done';
const cls = `${classText(item)} ${classText(iconEl)}`;
if (/(unfinish|unfinished|notfinish|not-finish|todo|doing|progress)/.test(cls)) return 'todo';
if (/(finish|finished|done|passed|complete|completed|correct|success|studied|checked)/.test(cls)) return 'done';
return 'unknown';
}
function parseCourseListProgressFromApiDoc(doc) {
if (!doc) return null;
const ssEl = doc.querySelector('#_studystate, [name="studystate"]');
const data = parseStudystateData(ssEl);
if (data) {
const total = toInt(data.totalNum, 0);
const done = toInt(data.finishNum, 0);
if (total > 0) {
return buildApiProgress(done, total, {
unfinish: data.unfinishCount,
sourceDetail: 'studystate',
});
}
}
return null;
}
function parseCourseListTaskStatsFromApiDoc(doc) {
if (!doc) return null;
const ssEl = doc.querySelector('#_studystate, [name="studystate"]');
const data = parseStudystateData(ssEl);
const chapters = [];
for (const item of doc.querySelectorAll('[id^="cur"]')) {
if (item.querySelector('ul, .posCatalog_level')) continue;
const id = (item.id || '').replace(/^cur/, '');
if (!id || id.length <= CFG.MIN_ID_LEN) continue;
const nameEl = item.querySelector('.posCatalog_name, .catalog_name, .clicktitle, a');
const iconEl = item.querySelector('.posCatalog_icon, [class*="icon"], .icon_Completed');
const name = normText(nameEl ? (nameEl.getAttribute('title') || nameEl.textContent) : item.textContent || '');
const unfinishEl = item.querySelector('.jobUnfinishCount, [class*="Unfinish"], [class*="unfinish"], [class*="unfinished"]');
const unfinish = toInt(unfinishEl?.value || unfinishEl?.textContent, 0);
chapters.push({
id,
name,
status: getTaskCompletionStatus(item, iconEl),
unfinish,
});
}
if (chapters.length === 0 && !data) return null;
return {
chapters,
nextChapterId: data?.nextChapterId ? String(data.nextChapterId) : '',
unfinish: data?.unfinishCount !== undefined ? toInt(data.unfinishCount, 0) : chapters.reduce((sum, ch) => sum + (ch.unfinish || 0), 0),
};
}
function parseKnowledgeCardTaskStats(html) {
const text = String(html || '');
const stats = { total: 0, done: 0 };
const re = /mArg\s*=\s*(\{[\s\S]*?\})\s*;/g;
let match;
while ((match = re.exec(text))) {
let data = null;
try { data = JSON.parse(match[1]); }
catch (_) { data = null; }
const attachments = Array.isArray(data?.attachments) ? data.attachments : [];
for (const att of attachments) {
if (!att) continue;
const jobid = att.jobid || att._jobid || '';
if (!jobid) continue;
stats.total += 1;
if (att.isPassed === true || String(att.isPassed) === 'true') stats.done += 1;
}
}
return stats;
}
async function mapLimit(items, limit, worker) {
const list = Array.isArray(items) ? items : [];
const size = Math.max(1, Number(limit) || 1);
const results = new Array(list.length);
let next = 0;
const runners = Array.from({ length: Math.min(size, list.length) }, async () => {
while (next < list.length) {
const idx = next++;
results[idx] = await worker(list[idx], idx);
}
});
await Promise.all(runners);
return results;
}
function buildTaskCandidate(name, taskExtra = {}) {
return {
name,
knowledgeId: taskExtra.knowledgeId || '',
chapterName: taskExtra.chapterName || '',
chapterId: taskExtra.chapterId || '',
isDone: false,
type: taskExtra.type || '',
element: taskExtra.element,
clickTarget: taskExtra.clickTarget,
courseId: taskExtra.courseId || '',
clazzid: taskExtra.clazzid || '',
cpi: taskExtra.cpi || '',
enc: taskExtra.enc || '',
};
}
function pickNextTask(tasks) {
const unfinished = (tasks || []).filter(t => !t.isDone);
if (unfinished.length === 0) return null;
return unfinished.find(t => t.knowledgeId && String(t.knowledgeId).length > CFG.MIN_ID_LEN) || unfinished[0];
}
function getCourseParams(task) {
return {
cid: task?.courseId || getUrlParam('courseId') || getUrlParam('courseid') || getUrlParam('cid') || '',
clid: task?.clazzid || getUrlParam('clazzid') || getUrlParam('clazzId') || '',
cpi: task?.cpi || getUrlParam('cpi') || '',
enc: task?.enc || getUrlParam('enc') || '',
};
}
const PageDetector = {
chaoxing() {
const u = location.href;
if (u.includes('/visit/interaction') && !u.includes('courseid=')) return 'courseList';
if (u.includes('/visit/stucoursemiddle') && !u.includes('courseid=')) return 'courseList';
if (/mooc2-ans\.chaoxing\.com\/mooc2-ans\/?$/.test(u)) return 'courseList';
if (u.includes('knowledgeid=') || u.includes('knowledgeId=')) return 'taskPage';
if (u.includes('/mycourse/studentstudy')) return 'taskPage';
if (u.includes('/visit/interaction') && u.includes('courseid=')) return 'courseDetail';
if (u.includes('/ananas/modules/') || u.includes('/work/')) return 'taskPage';
if ((u.includes('courseId=') || u.includes('courseid=') || u.includes('clazzid=')) && u.includes('/stu')) return 'courseDetail';
if (u.includes('/mycourse/studentcourse')) return 'courseDetail';
if (u.includes('/visit/stucoursemiddle') && u.includes('courseid=')) return 'courseDetail';
return 'unknown';
},
zhihuishu() {
const u = location.href;
if (u.includes('onlinestuh5') && !u.includes('/stu/study')) return 'courseList';
if (u.includes('onlineweb') && !u.includes('/study')) return 'courseList';
if (u.includes('/student/courseList')) return 'courseList';
if (u.includes('/stu/study') || u.includes('/study/video')) return 'taskPage';
if (u.includes('courseId') || u.includes('recruitId') || u.includes('shareId')) return 'courseDetail';
return 'unknown';
},
icve() {
const u = location.href;
if (u.includes('/student/course') || u.includes('/center/')) return 'courseList';
if (u.includes('/study/') || u.includes('/learn/') || u.includes('/video/')) return 'taskPage';
if (u.includes('courseId=') || u.includes('courseid=') || u.includes('/course/')) return 'courseDetail';
return 'unknown';
},
mooc() {
const u = location.href;
if (/icourse163\.org\/?$/.test(u) || u.includes('/home/') || u.includes('/course/')) return 'courseList';
if (u.includes('/learn/') && u.includes('#/learn/content')) return 'taskPage';
if (u.includes('/learn/')) return 'courseDetail';
return 'unknown';
},
detect() {
return (this[PLATFORM] || (() => 'unknown')).call(this);
},
};
function isChaoxingErrorPage() {
const title = String(document.title || '').toLowerCase();
const text = String(document.body?.innerText || '').slice(0, CFG.BODY_TEXT_MAX);
return title.includes('404')
|| text.includes('(404)')
|| text.includes('页面不存在')
|| text.includes('您所浏览的页面不存在');
}
function clickChapterTab() {
const tabs = document.querySelectorAll('a, span, li, div, button, [role="tab"]');
for (const tab of tabs) {
const t = (tab.textContent || '').trim();
if (t === '章节' || t === '课程章节' || t === '课程内容' || t === '目录' || t === '课程目录') {
try { tab.click(); log('点击章节标签: ' + t); return t; } catch (_) { /* 点击失败 */ }
}
}
const sidebarLinks = document.querySelectorAll('a[href*="chapter"], a[href*="catalog"], a[href*="knowledge"]');
for (const link of sidebarLinks) {
const t = (link.textContent || '').trim();
if (t.includes('章节') || t.includes('目录') || t.includes('课程内容')) {
try { link.click(); return 'link:' + t; } catch (_) { /* 点击失败 */ }
}
}
return null;
}
function fixCourseUrl(href) {
if (!href) return href;
if (href.includes('/mycourse/studentstudy')) return href;
if (href.includes('/visit/stucoursemiddle')) return href;
href = href.replace('mooc1-1.chaoxing.com', 'mooc2-ans.chaoxing.com');
href = href.replace('mooc1.chaoxing.com', 'mooc2-ans.chaoxing.com');
href = href.replace('i.chaoxing.com', 'mooc2-ans.chaoxing.com');
href = href.replace('/mooc-ans/', '/mooc2-ans/');
if (href.includes('mooc2-ans.chaoxing.com') && !href.includes('mooc2-ans.chaoxing.com/mooc2-ans/')) {
href = href.replace('mooc2-ans.chaoxing.com/', 'mooc2-ans.chaoxing.com/mooc2-ans/');
}
return href;
}
function buildCourseUrl(url) {
if (!url) return url;
const cid = url.match(/courseid=(\d+)/i) || url.match(/courseId=(\d+)/i);
const clid = url.match(/clazzid=(\d+)/i) || url.match(/clazzId=(\d+)/i);
if (cid && clid) {
const cpi = url.match(/[&?]cpi=(\d+)/i);
const enc = url.match(/[&?]enc=([a-f0-9]+)/i);
let newUrl = `https://mooc1.chaoxing.com/visit/stucoursemiddle?courseid=${cid[1]}&clazzid=${clid[1]}`;
if (cpi) newUrl += `&cpi=${cpi[1]}`;
if (enc) newUrl += `&enc=${enc[1]}`;
newUrl += '&ismooc2=1&v=2';
return newUrl;
}
return url;
}
function buildCourseStuUrl(url) {
if (!url) return '';
const cid = url.match(/courseid=(\d+)/i) || url.match(/courseId=(\d+)/i);
const clid = url.match(/clazzid=(\d+)/i) || url.match(/clazzId=(\d+)/i) || url.match(/classId=(\d+)/i);
if (!cid || !clid) return '';
const cpi = url.match(/[&?]cpi=(\d+)/i);
const enc = url.match(/[&?]enc=([a-z0-9]+)/i);
let newUrl = `https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/stu?courseid=${cid[1]}&clazzid=${clid[1]}`;
if (cpi) newUrl += `&cpi=${cpi[1]}`;
if (enc) newUrl += `&enc=${enc[1]}`;
newUrl += '&mooc2=1&v=2&hideHead=0';
return newUrl;
}
function getCourseIdentityFromUrl(url) {
try {
const u = new URL(url, location.href);
const p = u.searchParams;
const cid = p.get('courseId') || p.get('courseid') || p.get('cid') || '';
const clid = p.get('clazzid') || p.get('clazzId') || p.get('classId') || '';
return cid && clid ? `${cid}:${clid}` : '';
} catch (_) {
return '';
}
}
function isWrongCoursePage(currentUrl, targetUrl) {
const currentKey = getCourseIdentityFromUrl(currentUrl);
const targetKey = getCourseIdentityFromUrl(targetUrl);
return !!currentKey && !!targetKey && currentKey !== targetKey;
}
function isProbablyACourse(text, href) {
text = (text || '').trim();
href = href || '';
const excludeTexts = [
'进入空间', '账号管理', '已删除课程', '已结束课程', '已退课课程',
'课程已结束',
'更新公告', '我的课', '我教的课', '添加课程', '新建文件夹',
'学习通', '客服', '帮助', '设置', '退出', '登录', '注册',
'空间', '消息', '通知', '作业', '考试', '讨论',
];
for (const ex of excludeTexts) {
if (text.includes(ex)) return false;
}
const hasCoursePath = href.includes('studentcourse') || href.includes('stucoursemiddle');
if (!hasCoursePath) return false;
const hasCourseId = href.includes('courseId') || href.includes('courseid') || href.includes('clazzid') || href.includes('clazzId');
return hasCourseId;
}
function buildCourseEntry(el, baseUrl, seen) {
const rawHref = el.getAttribute('href') || '';
let href = '';
if (rawHref) {
try { href = new URL(rawHref, baseUrl).href; } catch (_) { return null; }
}
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
const fixed = fixCourseUrl(href);
if (!href || !text || seen.has(fixed) || text.length < CFG.MIN_TASK_NAME_LEN || text.length > CFG.MAX_TASK_NAME_LEN) return null;
if (!isProbablyACourse(text, fixed)) return null;
seen.add(fixed);
return { name: text, url: fixed, platform: 'chaoxing' };
}
function normalizeCourseKey(course) {
const fixed = fixCourseUrl(course?.url || '');
if (fixed) {
return fixed.replace(/([?&])(?:t|_)=\d+/g, '$1').replace(/[?&]$/, '');
}
return normText(course?.name || '');
}
function mergeCourseLists(...lists) {
const merged = [];
const seen = new Set();
for (const list of lists) {
for (const course of list || []) {
if (!course || !course.name || !course.url) continue;
const item = {
...course,
url: fixCourseUrl(course.url),
platform: course.platform || PLATFORM,
};
const key = normalizeCourseKey(item);
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(item);
}
}
return merged;
}
function getUrlFromOnclick(onclick, baseUrl) {
if (!onclick) return '';
const direct = onclick.match(/https?:\/\/[^'")\s]+/);
if (direct) {
try { return new URL(cleanExtractedUrl(direct[0]), baseUrl).href; } catch (_) { return cleanExtractedUrl(direct[0]); }
}
const quotedArgs = [...onclick.matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
const urlArg = quotedArgs.find(arg => /^(\/|https?:\/\/)/.test(arg) || /\/ks\/|exam|paper|test/i.test(arg));
if (urlArg) {
try { return new URL(cleanExtractedUrl(urlArg), baseUrl).href; } catch (_) { return ''; }
}
const quoted = onclick.match(/(?:open|href|setUrl|toExam|goExam|startExam)[^(]*\((?:[^'"]*['"]){1}([^'"]+)/i);
if (quoted && quoted[1]) {
try { return new URL(cleanExtractedUrl(quoted[1]), baseUrl).href; } catch (_) { return ''; }
}
return '';
}
function splitJsArgs(src) {
const args = [];
let cur = '';
let quote = '';
let escaped = false;
for (const ch of String(src || '')) {
if (escaped) {
cur += ch;
escaped = false;
continue;
}
if (ch === '\\') {
cur += ch;
escaped = true;
continue;
}
if (quote) {
cur += ch;
if (ch === quote) quote = '';
continue;
}
if (ch === '\'' || ch === '"') {
quote = ch;
cur += ch;
continue;
}
if (ch === ',') {
args.push(cur.trim());
cur = '';
continue;
}
cur += ch;
}
if (cur.trim() || src) args.push(cur.trim());
return args.map(v => {
const s = String(v || '').trim();
if ((s.startsWith("'") && s.endsWith("'")) || (s.startsWith('"') && s.endsWith('"'))) {
return s.slice(1, -1).replace(/\\(['"\\])/g, '$1');
}
return s;
});
}
function getDocValue(doc, names) {
for (const name of names) {
const byId = doc?.getElementById?.(name);
if (byId?.value) return String(byId.value).trim();
const byName = doc?.querySelector?.(`input[name="${name}"], textarea[name="${name}"], select[name="${name}"]`);
if (byName?.value) return String(byName.value).trim();
const dataName = String(name || '').replace(/[A-Z]/g, m => '-' + m.toLowerCase()).toLowerCase();
const byData = doc?.querySelector?.(`[data-${dataName}], [data-${String(name || '').toLowerCase()}]`);
const dataValue = byData?.getAttribute?.(`data-${dataName}`) || byData?.getAttribute?.(`data-${String(name || '').toLowerCase()}`);
if (dataValue) return String(dataValue).trim();
}
const scriptText = [...(doc?.querySelectorAll?.('script') || [])]
.map(s => s.textContent || '')
.filter(Boolean)
.join('\n')
.slice(0, 300000);
for (const name of names) {
const re = new RegExp(`(?:^|[\\s{,;])["']?${escapeRegExp(name)}["']?\\s*[:=]\\s*["']?([^"',;\\s}&<]+)`, 'i');
const match = scriptText.match(re);
if (match && match[1]) return String(match[1]).trim();
}
return '';
}
function getUrlParamFrom(baseUrl, names) {
try {
const u = new URL(baseUrl, location.href);
for (const name of names) {
const v = u.searchParams.get(name);
if (v) return v;
}
} catch (_) { /* ignore */ }
return '';
}
function getUrlFromGoTest(onclick, baseUrl, doc) {
if (!/goTest\s*\(/i.test(onclick || '')) return '';
const match = String(onclick || '').match(/goTest\s*\(([\s\S]*?)\)\s*;?/i);
if (!match) return '';
const args = splitJsArgs(match[1]);
const courseId = args[0] || getDocValue(doc, ['courseId', 'courseid']) || getUrlParamFrom(baseUrl, ['courseId', 'courseid']);
const examId = args[1] || '';
const isRetest = /^(true|1)$/i.test(String(args[5] || ''));
const classId = getDocValue(doc, ['classId', 'clazzId', 'clazzid'])
|| getUrlParamFrom(baseUrl, ['classId', 'clazzId', 'clazzid']);
const cpi = getDocValue(doc, ['cpi']) || getUrlParamFrom(baseUrl, ['cpi']) || '0';
if (!courseId || !classId || !examId) return '';
try {
const u = new URL('/exam-ans/exam/test/examcode/examnotes', baseUrl);
u.searchParams.set('courseId', courseId);
u.searchParams.set('classId', classId);
u.searchParams.set('examId', examId);
u.searchParams.set('cpi', cpi);
if (isRetest) u.searchParams.set('reset', 'true');
return u.href;
} catch (_) {
return '';
}
}
function getUrlFromExamData(el, baseUrl, doc) {
const raw = el?.getAttribute?.('data-url') || el?.getAttribute?.('data-href')
|| el?.getAttribute?.('data-link') || el?.getAttribute?.('url') || '';
if (raw) {
try { return new URL(cleanExtractedUrl(raw), baseUrl).href; } catch (_) { /* ignore */ }
}
const examId = el?.getAttribute?.('data-examid') || el?.getAttribute?.('examid')
|| el?.dataset?.examid || el?.dataset?.examId || '';
if (!examId) return '';
const courseId = el?.getAttribute?.('data-courseid') || el?.dataset?.courseid || el?.dataset?.courseId
|| getDocValue(doc, ['courseId', 'courseid']) || getUrlParamFrom(baseUrl, ['courseId', 'courseid']);
const classId = el?.getAttribute?.('data-classid') || el?.getAttribute?.('data-clazzid')
|| el?.dataset?.classid || el?.dataset?.classId || el?.dataset?.clazzid || el?.dataset?.clazzId
|| getDocValue(doc, ['classId', 'clazzId', 'clazzid'])
|| getUrlParamFrom(baseUrl, ['classId', 'clazzId', 'clazzid']);
const cpi = el?.getAttribute?.('data-cpi') || el?.dataset?.cpi
|| getDocValue(doc, ['cpi']) || getUrlParamFrom(baseUrl, ['cpi']) || '0';
if (!courseId || !classId) return '';
try {
const u = new URL('/exam-ans/exam/test/examcode/examnotes', baseUrl);
u.searchParams.set('courseId', courseId);
u.searchParams.set('classId', classId);
u.searchParams.set('examId', examId);
u.searchParams.set('cpi', cpi);
return u.href;
} catch (_) {
return '';
}
}
function isExamListPageUrl(href) {
try {
const u = new URL(href, location.href);
return /examSysList/i.test(u.pathname) || /\/exam-ans\/mooc2\/exam\/exam-list/i.test(u.pathname);
} catch (_) {
return /examSysList|\/exam-ans\/mooc2\/exam\/exam-list/i.test(href || '');
}
}
function isExamContextPage(doc = document, href = location.href) {
if (isExamListPageUrl(href)) return true;
try {
const u = new URL(href, location.href);
if (/\/ks\/xs\/|\/exam-ans\/exam\/|\/exam-v2\//i.test(u.pathname)) return true;
} catch (_) { /* ignore */ }
const title = normText(doc?.title || '');
if (/考试列表|我的考试|在线考试/.test(title)) return true;
return !!doc?.querySelector?.('.bottomList li .icon-exam, .gtestlistul li.gclassitems2:not(.gclasslisttitle)');
}
function isExamJunkText(rowText, actionText) {
const row = normText(rowText);
const action = normText(actionText);
const single = /^(提示|确定|取消|知道了|退出|退出考试|申诉|重新识别|继续作答|重刷|删除|确定重考)$/;
if (single.test(action)) return true;
if (/^(提示\s*)?(确定|取消|知道了|退出|退出考试|申诉|重新识别|继续作答|重刷|删除|确定重考)(\s+(确定|取消|申诉|退出考试|继续作答|重刷))*$/.test(row)) return true;
return /退出考试将强制收卷|设备已锁定|识别结果|删除后将无法恢复|系统检测到|错题练习|直播二维码|延时提醒|锁定原因/.test(row);
}
function isExamJunkElement(el, row) {
const target = el?.closest?.('.maskDiv, .popDiv, .popBottom, .popHead, .deviceLockPop, [id*="Pop"], [id*="Win"], [id*="Tip"]');
if (!target) return false;
const cls = `${classText(target)} ${classText(row)}`;
const id = `${target.id || ''} ${row?.id || ''}`;
return /maskDiv|popDiv|popBottom|popHead|deviceLock|confirm|appeal|forceSubmit|submit|Lock|Pop|Win|Tip/i.test(`${cls} ${id}`);
}
function isProbablyExam(text, href, actionText) {
const hay = `${text || ''} ${href || ''} ${actionText || ''}`.toLowerCase();
text = text || '';
actionText = actionText || '';
if (/进入空间|账号管理|退出登录|输入邀请码|课程队列|考试队列|获取考试|添加选中|开始学习|考试成绩管理|成绩管理/.test(text)) return false;
const hrefPath = (() => {
try { return new URL(href, location.href).pathname; } catch (_) { return href || ''; }
})();
if (/examSysList|\/exam-ans\/mooc2\/exam\/exam-list|\/keeper\/appeal|teacherscore|teacherScoreManagePage/i.test(hrefPath)) return false;
const hasExamWord = /考试|期末|测验/.test(text) || /进入考试|开始考试|立即考试|继续考试/.test(actionText)
|| /\/ks\/|exam|paper|test|examcode|mycourse\/transfer|gotest|icon-exam/.test(hay);
if (!hasExamWord) return false;
if (/总评|成绩查询|收件箱|课程队列|获取课程|添加选中|开始学习/.test(text)) return false;
if (/查看成绩|查看试卷|已交卷|已完成|已结束|缺考/.test(text) && !/未开始|待考|进入考试|开始考试/.test(text)) return false;
return true;
}
function buildExamEntry(el, baseUrl, seen) {
const actionText = normText(el.textContent || el.value || el.title || '');
const onclick = el.getAttribute?.('onclick') || '';
const rawHref = el.getAttribute?.('href') || '';
let href = '';
if (rawHref && rawHref !== '#' && !/^javascript:/i.test(rawHref)) {
try { href = new URL(rawHref, baseUrl).href; } catch (_) { href = ''; }
}
if (!href && /^javascript:/i.test(rawHref)) href = getUrlFromOnclick(rawHref, baseUrl);
if (!href) href = getUrlFromOnclick(onclick, baseUrl);
if (!href) href = getUrlFromGoTest(onclick, baseUrl, el.ownerDocument || document);
if (!href) href = getUrlFromExamData(el, baseUrl, el.ownerDocument || document);
if (!href) return null;
const row = el.closest?.('.bottomList li, tr, li, .item, .list-item, .exam-item, .examList, .ks-item, .clearfix, .table-row, .content, .box')
|| el.parentElement || el;
const rowText = normText(row.textContent || actionText);
if (isExamListPageUrl(href) || isExamJunkElement(el, row) || isExamJunkText(rowText, actionText)) return null;
if (!isProbablyExam(rowText, href, actionText)) return null;
const cardName = normText(row.querySelector?.('.overHidden2, .right-content p:first-child')?.textContent || '');
let name = (cardName || rowText)
.replace(/^(进入考试|开始考试|立即考试|查看|操作)\s*/g, '')
.replace(/\s*(进入考试|开始考试|立即考试|查看|操作)\s*$/g, '')
.trim();
const parts = rowText.split(/\s+/).filter(Boolean);
const dateIdx = parts.findIndex(p => /^\d{4}-\d{2}-\d{2}$/.test(p));
if (dateIdx > 1) {
const nameParts = parts.slice(1, dateIdx);
if (nameParts.length >= 2 && nameParts[0] === nameParts[1]) name = nameParts[0];
else if (nameParts.length > 0) name = nameParts.join(' ');
}
if (!name || name.length < CFG.MIN_TASK_NAME_LEN) name = actionText || '未命名考试';
if (name.length > 120) name = name.slice(0, 120);
const key = href.replace(/([?&])t=\d+/g, '$1').replace(/([?&])_=\d+/g, '$1');
if (seen.has(key)) return null;
seen.add(key);
return {
name,
url: href,
platform: PLATFORM,
addedAt: Date.now(),
};
}
function parseExamRowsAsListEntries(doc, baseUrl, seen) {
const entries = [];
const rows = doc.querySelectorAll('.bottomList li, .gtestlistul li.gclassitems2:not(.gclasslisttitle), li, tr');
let index = 0;
for (const row of rows) {
if (row.closest?.('#mjn3-panel, .mjn3-panel, .mjn3-bar, .mjn3-exam-bar')) continue;
const rowText = normText(row.textContent || '');
if (!rowText || /暂无考试|没有考试|暂无数据|筛选\s*全部\s*已完成\s*未完成/.test(rowText)) continue;
const hasExamSignal = row.querySelector?.('.icon-exam, [class*="icon-exam"], [onclick*="viewExam"], [onclick*="startTest"], [onclick*="goTest"]')
|| /考试|测验|期末|期中|重考|智能分析|剩余\d+小时|完成\d+%任务点可参加考试/.test(rowText);
if (!hasExamSignal) continue;
let name = normText(row.querySelector?.('.overHidden2, .right-content p:first-child, .exam-name, .name, td:first-child')?.textContent || '');
if (!name) {
const parts = rowText.split(/\s+/).filter(Boolean);
name = parts.find(p => !/^(已完成|已过期|未完成|待考|进行中|智能分析|重考|\d+\s*分|剩余\d+)/.test(p)) || rowText;
}
name = name
.replace(/\s*(已完成|已过期|未完成|待考|进行中|智能分析|重考).*$/g, '')
.trim();
if (!name || name.length < CFG.MIN_TASK_NAME_LEN) name = '未命名考试';
if (name.length > 120) name = name.slice(0, 120);
let href = '';
const clickable = row.querySelector?.('[onclick*="viewExam"], [onclick*="startTest"], [onclick*="goTest"], [onclick*="toExam"], a[href*="exam"], a[href*="test"]');
if (clickable) {
const clickJs = clickable.getAttribute?.('onclick') || '';
href = getUrlFromOnclick(clickJs, baseUrl)
|| getUrlFromGoTest(clickJs, baseUrl, doc)
|| getUrlFromExamData(clickable, baseUrl, doc);
}
if (!href) {
try {
const u = new URL(baseUrl, location.href);
u.hash = `mjn3-exam-${index}`;
href = u.href;
} catch (_) {
href = baseUrl;
}
}
const key = href.replace(/([?&])t=\d+/g, '$1').replace(/([?&])_=\d+/g, '$1');
if (seen.has(key)) {
index++;
continue;
}
seen.add(key);
entries.push({ name, url: href, platform: PLATFORM, addedAt: Date.now() });
index++;
}
return entries;
}
const ChaoxingAdapter = {
_taskProgressCache: new Map(),
getCourseHomeCandidates() {
const urls = [];
const push = raw => {
if (!raw) return;
try {
const href = new URL(cleanExtractedUrl(raw), location.href).href;
if (/^javascript:/i.test(href)) return;
if (/[?&](?:courseId|courseid|clazzId|clazzid)=/i.test(href)) return;
if (/\/visit\/interaction|\/visit\/stucoursemiddle|\/mooc2-ans\/visit\//i.test(href)) urls.push(href);
} catch (_) { /* ignore */ }
};
push(location.href);
push(document.referrer);
for (const iframe of $$('iframe')) {
push(iframe.src || iframe.getAttribute('src') || '');
}
for (const el of $$('a[href], [onclick], [data-url]')) {
const rawHref = el.getAttribute('href') || '';
const dataUrl = el.getAttribute('data-url') || '';
const onclick = el.getAttribute('onclick') || rawHref;
push(rawHref);
push(dataUrl);
push(getUrlFromOnclick(onclick, location.href));
}
push(getDefaultCourseHomeUrl());
return [...new Set(urls)];
},
parseCoursesFromDoc(doc, baseUrl, seen = new Set()) {
const courses = [];
const selectors = [
'a[href*="studentcourse"]',
'a[href*="stucoursemiddle"]',
'a[href*="courseId"]',
'a[href*="courseid"]',
'a[href*="clazzid"]',
'a[href*="clazzId"]',
];
for (const sel of selectors) {
for (const el of doc.querySelectorAll(sel)) {
const entry = buildCourseEntry(el, baseUrl, seen);
if (entry) courses.push(entry);
}
}
return courses;
},
async fetchCoursesFromCourseHomePages() {
const courses = [];
const seen = new Set();
for (const fetchUrl of this.getCourseHomeCandidates()) {
try {
const fetched = await fetchAnyOrigin(fetchUrl, { timeout: CFG.API_TIMEOUT });
const doc = new DOMParser().parseFromString(fetched.text, 'text/html');
courses.push(...this.parseCoursesFromDoc(doc, fetched.finalUrl || fetchUrl, seen));
} catch (e) {
log(`课程首页扫描失败: ${fetchUrl} - ${e.message}`, 'warn');
}
}
return mergeCourseLists(this.parseCourseList(), courses);
},
async fetchCoursesViaAPI() {
const courses = [];
const seen = new Set();
const urls = [...new Set([
...this.getCourseHomeCandidates(),
'https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction',
'https://mooc2-ans.chaoxing.com/mooc2-ans/visit/stucoursemiddle',
])];
for (const fetchUrl of urls) {
try {
const fetched = await fetchAnyOrigin(fetchUrl, { timeout: CFG.API_TIMEOUT });
const doc = new DOMParser().parseFromString(fetched.text, 'text/html');
courses.push(...this.parseCoursesFromDoc(doc, fetched.finalUrl || fetchUrl, seen));
} catch (_) { /* 网络请求失败 */ }
}
return mergeCourseLists(this.parseCourseList(), courses);
},
switchToNewVersion() {
const u = location.href;
if (u.includes('mooc2-ans')) return true;
if (u.includes('/mycourse/studentstudy')) return true;
if (u.includes('/visit/stucoursemiddle') && u.includes('ismooc2=1')) return true;
if (u.includes('mooc1') || u.includes('mooc-ans') || u.includes('i.chaoxing.com')) {
const newUrl = fixCourseUrl(u);
if (newUrl !== u) {
log('切换新版: ' + newUrl);
location.href = newUrl;
return true;
}
}
for (const iframe of $$('iframe')) {
try {
const src = iframe.src || '';
if (src.includes('/mycourse/studentstudy')) continue;
if (src.includes('mooc1') || src.includes('mooc-ans') || src.includes('visit/interaction')) {
const newUrl = fixCourseUrl(src);
if (newUrl !== src) {
log('从iframe切换新版: ' + newUrl);
location.href = newUrl;
return true;
}
}
} catch (_) { /* 跨域iframe */ }
}
return false;
},
_progressCacheKey(params) {
const cid = params?.cid || '';
const clid = params?.clid || '';
const cpi = params?.cpi || '';
return `${cid}:${clid}:${cpi}`;
},
_buildStudentStudyAjaxUrl(params, chapterId) {
const cid = params?.cid || '';
const clid = params?.clid || '';
const cpi = params?.cpi || '';
const u = new URL('https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudyAjax');
u.searchParams.set('courseId', cid);
if (clid) u.searchParams.set('clazzid', clid);
u.searchParams.set('chapterId', chapterId);
if (cpi) u.searchParams.set('cpi', cpi);
u.searchParams.set('verificationcode', '');
u.searchParams.set('mooc2', '1');
u.searchParams.set('toComputer', 'false');
u.searchParams.set('microTopicId', '0');
u.searchParams.set('editorPreview', '0');
u.searchParams.set('isPreviewVideo', 'false');
u.searchParams.set('videoWidth', '0');
u.searchParams.set('videoHeight', '0');
u.searchParams.set('targetVideoJobId', '');
u.searchParams.set('cardIndex', '0');
return u.href;
},
_buildKnowledgeCardsUrl(params, chapterId, num) {
const cid = params?.cid || '';
const clid = params?.clid || '';
const cpi = params?.cpi || '';
const u = new URL('https://mooc1.chaoxing.com/mooc-ans/knowledge/cards');
if (clid) u.searchParams.set('clazzid', clid);
u.searchParams.set('courseid', cid);
u.searchParams.set('knowledgeid', chapterId);
u.searchParams.set('num', String(num || 0));
u.searchParams.set('ut', 's');
if (cpi) u.searchParams.set('cpi', cpi);
u.searchParams.set('mooc2', '1');
u.searchParams.set('isMicroCourse', 'false');
u.searchParams.set('editorPreview', '0');
return u.href;
},
async _scanChapterTaskStats(params, chapter) {
try {
const ajaxHtml = await fetchTextAnyOrigin(this._buildStudentStudyAjaxUrl(params, chapter.id), { timeout: CFG.API_TIMEOUT });
const doc = new DOMParser().parseFromString(ajaxHtml, 'text/html');
const cardCount = Math.max(1, toInt(doc.querySelector('#cardcount')?.value, 1));
let total = 0;
let done = 0;
for (let i = 0; i < cardCount; i++) {
const cardHtml = await fetchTextAnyOrigin(this._buildKnowledgeCardsUrl(params, chapter.id, i), { timeout: CFG.API_TIMEOUT });
const stats = parseKnowledgeCardTaskStats(cardHtml);
total += stats.total;
done += stats.done;
}
return { total, done };
} catch (e) {
log(`任务点精扫失败: ${chapter.name || chapter.id} - ${e.message}`, 'warn');
return { total: 0, done: 0, failed: true };
}
},
async _scanCourseTaskProgress(params, listStats) {
const chapters = (listStats?.chapters || []).filter(ch => ch.id);
if (chapters.length === 0) return null;
const stats = await mapLimit(chapters, CFG.PROGRESS_SCAN_CONCURRENCY, ch => this._scanChapterTaskStats(params, ch));
const scannedTotal = stats.reduce((sum, item) => sum + (item?.total || 0), 0);
const scannedDone = stats.reduce((sum, item) => sum + (item?.done || 0), 0);
const failedCount = stats.filter(item => item?.failed).length;
const unfinish = Math.max(0, toInt(listStats?.unfinish, 0));
const total = Math.max(scannedTotal, scannedDone + unfinish);
const done = unfinish === 0 ? total : Math.max(0, Math.min(scannedDone, total));
if (total <= 0) return null;
const cache = {
total,
scannedTotal,
scannedDone,
failedCount,
unfinishOffset: Math.max(0, total - done - unfinish),
refreshing: false,
updatedAt: Date.now(),
};
this._taskProgressCache.set(this._progressCacheKey(params), cache);
return buildApiProgress(done, total, {
unfinish: Math.max(unfinish, total - done),
sourceDetail: failedCount > 0 ? 'task_cards_partial_scan' : 'task_cards_scan',
});
},
async _buildTaskProgressFromCourseListDoc(params, doc) {
const listStats = parseCourseListTaskStatsFromApiDoc(doc);
if (!listStats) return null;
const unfinish = Math.max(0, toInt(listStats.unfinish, 0));
const cacheKey = this._progressCacheKey(params);
const cache = this._taskProgressCache.get(cacheKey);
if (cache?.total > 0 && Date.now() - cache.updatedAt < CFG.PROGRESS_DETAIL_CACHE_MS) {
const done = Math.max(0, Math.min(cache.total, cache.total - unfinish - (cache.unfinishOffset || 0)));
if (!cache.refreshing && Date.now() - cache.updatedAt >= CFG.PROGRESS_BACKGROUND_REFRESH_MS) {
cache.refreshing = true;
this._scanCourseTaskProgress(params, listStats)
.catch(e => log('任务点进度后台刷新失败: ' + e.message, 'warn'))
.finally(() => {
const latest = this._taskProgressCache.get(cacheKey);
if (latest) latest.refreshing = false;
});
}
return buildApiProgress(done, cache.total, {
unfinish: Math.max(unfinish, cache.total - done),
sourceDetail: 'task_cards_cache',
});
}
const scanned = await this._scanCourseTaskProgress(params, listStats);
if (scanned) return scanned;
if (unfinish === 0 && listStats.chapters.length > 0) {
return buildApiProgress(listStats.chapters.length, listStats.chapters.length, {
unfinish: 0,
sourceDetail: 'course_list_complete_only',
});
}
return null;
},
parseCourseList() {
const courses = [], seen = new Set();
const baseUrl = location.href;
const selectors = ['a[href*="studentcourse"]', 'a[href*="stucoursemiddle"]',
'a[href*="courseId"]', 'a[href*="courseid"]', 'a[href*="clazzid"]', 'a[href*="clazzId"]'];
for (const sel of selectors) {
for (const el of $$(sel)) {
const entry = buildCourseEntry(el, baseUrl, seen);
if (entry) courses.push(entry);
}
}
if (courses.length === 0) {
for (const el of $$('a')) {
const entry = buildCourseEntry(el, baseUrl, seen);
if (entry) courses.push(entry);
}
}
if (courses.length === 0) {
for (const iframe of $$('iframe')) {
try {
const doc = iframe.contentDocument || iframe.contentWindow?.document;
if (!doc) continue;
const iframeSrc = iframe.src || baseUrl;
for (const el of doc.querySelectorAll('a')) {
const entry = buildCourseEntry(el, iframeSrc, seen);
if (entry) courses.push(entry);
}
} catch (_) { /* 跨域iframe */ }
}
}
return courses;
},
getCourseHomeUrl() {
const defaultHome = getDefaultCourseHomeUrl();
const candidates = [location.href, document.referrer].filter(Boolean);
for (const iframe of $$('iframe')) {
const src = iframe.src || iframe.getAttribute('src') || '';
if (src) candidates.push(src);
}
for (const el of $$('a[href], [onclick]')) {
const rawHref = el.getAttribute('href') || '';
const onclick = el.getAttribute('onclick') || '';
if (rawHref) candidates.push(rawHref);
const fromClick = getUrlFromOnclick(onclick, location.href);
if (fromClick) candidates.push(fromClick);
}
try {
const qback = new URL(location.href).searchParams.get('qbankbackurl');
if (qback) candidates.push(decodeURIComponent(qback));
} catch (_) { /* ignore */ }
const normalizeHome = raw => {
if (!raw) return '';
let u;
try { u = new URL(cleanExtractedUrl(raw), location.href); } catch (_) { return ''; }
if (isBadCourseHomeUrl(u.href)) return '';
if (/\/(?:course-v2\/)?studyApp\/studying/i.test(u.pathname)) {
if (!/\/course-v2\/studyApp\/studying/i.test(u.pathname)) u.pathname = '/course-v2/studyApp/studying';
return u.href;
}
const s = u.searchParams.get('s');
if (s && /jxjy\.chaoxing\.com$/i.test(u.hostname)) {
return '';
}
return '';
};
for (const raw of candidates) {
const home = normalizeHome(raw);
if (home) {
saveCourseHomeUrl(home);
return home;
}
}
const saved = loadCourseHomeUrl();
if (saved && !isBadCourseHomeUrl(saved)) return saved;
return defaultHome || 'https://i.mooc.chaoxing.com/space/index';
},
goCourseHome() {
const url = getDefaultAccountHomeUrl() || this.getCourseHomeUrl();
if (url) location.href = url;
},
getExamListUrl() {
const frameSelectors = [
'iframe[src*="examSysList"]',
'iframe[src*="/ks/xs/"]',
'iframe[src*="exam-v2"]',
'iframe[src*="/exam-ans/mooc2/exam/exam-list"]',
'iframe[src*="exam-list"]',
];
for (const sel of frameSelectors) {
for (const iframe of $$(sel)) {
const src = iframe.src || iframe.getAttribute('src') || '';
if (!src) continue;
try { return new URL(cleanExtractedUrl(src), location.href).href; } catch (_) { /* ignore */ }
}
}
const selectors = [
'[onclick*="examSysList"]',
'a[href*="examSysList"]',
'[onclick*="/ks/xs/"]',
'a[href*="/ks/xs/"]',
'[data-url*="exam-list"]',
'a[href*="exam-list"]',
'[onclick*="exam-list"]',
];
for (const sel of selectors) {
for (const el of $$(sel)) {
const text = normText(el.textContent || el.title || '');
const onclick = el.getAttribute('onclick') || '';
const rawHref = el.getAttribute('href') || '';
const rawDataUrl = el.getAttribute('data-url') || '';
let href = '';
if (rawHref && /^javascript:/i.test(rawHref)) href = getUrlFromOnclick(rawHref, location.href);
if (!href && rawHref) href = rawHref;
if (!href && rawDataUrl) href = rawDataUrl;
if (!href) href = getUrlFromOnclick(onclick, location.href);
if (!href) continue;
href = cleanExtractedUrl(href);
if (text.includes('我的考试') || text.includes('在线考试') || href.includes('examSysList') || href.includes('/ks/xs/') || href.includes('exam-list')) {
try { return new URL(href, location.href).href; } catch (_) { /* ignore */ }
}
}
}
return '';
},
parseExamList(doc = document, baseUrl = location.href) {
const exams = [];
const seen = new Set();
const addEntry = el => {
const entry = buildExamEntry(el, baseUrl, seen);
if (entry) exams.push(entry);
};
const rowSelectors = [
'.bottomList li',
'.gtestlistul li.gclassitems2:not(.gclasslisttitle)',
'li.gclassitems2:not(.gclasslisttitle)',
'tr',
];
for (const row of doc.querySelectorAll(rowSelectors.join(','))) {
const rowText = normText(row.textContent || '');
if (!rowText) continue;
if (/已结束|已提交|已交卷|查看成绩|查看试卷|缺考/.test(rowText) && !/开始考试|进入考试|继续考试/.test(rowText)) continue;
const actions = row.querySelectorAll('[onclick*="toExam"], [onclick*="goExam"], [onclick*="startExam"], [onclick*="goTest"], [onclick*="exam"], a[href*="exam"], a[href*="test"], button, a');
for (const el of actions) addEntry(el);
}
if (exams.length > 0) return exams;
const rowEntries = parseExamRowsAsListEntries(doc, baseUrl, seen);
if (rowEntries.length > 0) return rowEntries;
const selectors = [
'[onclick*="toExam"]', '[onclick*="goExam"]', '[onclick*="startExam"]', '[onclick*="goTest"]',
'a[href*="/ks/"]', 'a[href*="exam"]', 'a[href*="paper"]',
'[onclick*="/ks/"]', '[onclick*="exam"]', '[onclick*="paper"]',
'button', 'a[href]',
];
for (const sel of selectors) {
for (const el of doc.querySelectorAll(sel)) {
addEntry(el);
}
}
return exams;
},
async fetchCoursesFromCourseListData() {
const read = (id, fallback = '') => document.getElementById(id)?.value || fallback;
const courseType = read('courseType', '1');
const pageHeader = courseType === '1' ? read('stuPageHeader', '-1') : read('tchPageHeader', '-1');
const params = new URLSearchParams({
courseType,
courseFolderId: read('courseFolderId', '0'),
query: read('searchInput', ''),
pageHeader,
single: read('single', '0'),
superstarClass: read('superstarClass', '0'),
isFirefly: read('isFirefly', '0') === '1' ? '1' : '0',
});
const html = await fetchTextAnyOrigin('https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
timeout: CFG.API_TIMEOUT,
});
const doc = new DOMParser().parseFromString(html, 'text/html');
const courses = [];
const seen = new Set();
const selectors = [
'a[href*="stucoursemiddle"]',
'a[href*="studentcourse"]',
'a[href*="courseid"][href*="clazzid"]',
'a[href*="courseId"][href*="clazzId"]',
];
for (const sel of selectors) {
for (const el of doc.querySelectorAll(sel)) {
const entry = buildCourseEntry(el, 'https://mooc2-ans.chaoxing.com/mooc2-ans/visit/courselistdata', seen);
if (entry) courses.push(entry);
}
}
return courses;
},
extractCourseStuUrl(html, baseUrl) {
const text = String(html || '').replace(/&/g, '&').replace(/\\u0026/g, '&');
const patterns = [
/https?:\/\/[^"'<>]+\/mooc2-ans\/mycourse\/stu\?[^"'<>]+/i,
/\/mooc2-ans\/mycourse\/stu\?[^"'<>]+/i,
];
for (const re of patterns) {
const m = text.match(re);
if (!m) continue;
try { return new URL(cleanExtractedUrl(m[0]), baseUrl).href; } catch (_) { /* ignore */ }
}
return '';
},
buildExamListUrlFromCourseDoc(doc, baseUrl) {
const iframe = doc.querySelector('iframe[src*="/exam-ans/mooc2/exam/exam-list"], iframe[src*="exam-list"]');
if (iframe) {
const src = iframe.src || iframe.getAttribute('src') || '';
if (src) {
try { return new URL(cleanExtractedUrl(src), baseUrl).href; } catch (_) { /* ignore */ }
}
}
const link = doc.querySelector('[data-url*="/exam-ans/mooc2/exam/exam-list"], [data-url*="exam-list"], a[href*="exam-list"]');
const raw = link?.getAttribute?.('data-url') || link?.getAttribute?.('href') || 'https://mooc1.chaoxing.com/exam-ans/mooc2/exam/exam-list';
const courseId = getDocValue(doc, ['courseid', 'courseId']) || getUrlParamFrom(baseUrl, ['courseid', 'courseId']);
const clazzid = getDocValue(doc, ['clazzid', 'clazzId', 'classId']) || getUrlParamFrom(baseUrl, ['clazzid', 'clazzId', 'classId']);
const cpi = getDocValue(doc, ['cpi']) || getUrlParamFrom(baseUrl, ['cpi']) || '';
if (!courseId || !clazzid) return '';
try {
const u = new URL(cleanExtractedUrl(raw), baseUrl);
u.searchParams.set('courseid', courseId);
u.searchParams.set('clazzid', clazzid);
if (cpi) u.searchParams.set('cpi', cpi);
u.searchParams.set('ut', getDocValue(doc, ['heardUt', 'ut']) || getUrlParamFrom(baseUrl, ['ut']) || 's');
u.searchParams.set('t', getDocValue(doc, ['t']) || getUrlParamFrom(baseUrl, ['t']) || String(Date.now()));
const stuenc = getDocValue(doc, ['enc']) || getUrlParamFrom(baseUrl, ['enc', 'stuenc']);
const examEnc = getDocValue(doc, ['examEnc']) || getUrlParamFrom(baseUrl, ['examEnc']);
const openc = getDocValue(doc, ['openc']) || getUrlParamFrom(baseUrl, ['openc']);
if (stuenc) u.searchParams.set('stuenc', stuenc);
if (examEnc) u.searchParams.set('enc', examEnc);
if (openc) u.searchParams.set('openc', openc);
return u.href;
} catch (_) {
return '';
}
},
async fetchCourseExams(course, seen) {
const urls = [...new Set([buildCourseStuUrl(course.url), course.url, buildCourseUrl(course.url)].filter(Boolean))];
for (const detailUrl of urls) {
try {
let fetched = await fetchAnyOrigin(detailUrl, { timeout: CFG.API_TIMEOUT, detectAntiSpider: true });
let html = fetched.text;
let currentUrl = fetched.finalUrl || detailUrl;
let doc = new DOMParser().parseFromString(html, 'text/html');
let examListUrl = this.buildExamListUrlFromCourseDoc(doc, currentUrl);
if (!examListUrl) {
const stuUrl = this.extractCourseStuUrl(html, currentUrl);
if (stuUrl) {
fetched = await fetchAnyOrigin(stuUrl, { timeout: CFG.API_TIMEOUT, detectAntiSpider: true });
html = fetched.text;
currentUrl = fetched.finalUrl || stuUrl;
doc = new DOMParser().parseFromString(html, 'text/html');
examListUrl = this.buildExamListUrlFromCourseDoc(doc, currentUrl);
}
}
if (!examListUrl) continue;
const listHtml = await fetchTextAnyOrigin(examListUrl, { timeout: CFG.API_TIMEOUT, detectAntiSpider: true });
const listDoc = new DOMParser().parseFromString(listHtml, 'text/html');
const exams = this.parseExamList(listDoc, examListUrl);
const picked = [];
for (const exam of exams) {
const key = exam.url.replace(/([?&])t=\d+/g, '$1').replace(/([?&])_=\d+/g, '$1');
if (seen.has(key)) continue;
seen.add(key);
const name = course.name && !exam.name.includes(course.name) ? `${course.name} - ${exam.name}` : exam.name;
picked.push({ ...exam, name, courseName: course.name, courseUrl: course.url });
}
if (picked.length > 0) return picked;
} catch (e) {
if (e instanceof ChaoxingAntiSpiderError) throw e;
log(`课程考试扫描失败: ${course.name || detailUrl} - ${e.message}`, 'warn');
}
}
return [];
},
_examCourseKey(course) {
const identity = getCourseIdentityFromUrl(course?.url || '');
if (identity) return identity;
return String(course?.url || course?.name || '').replace(/([?&])t=\d+/g, '$1').replace(/([?&])_=\d+/g, '$1');
},
_loadFreshExamScanCache() {
const now = Date.now();
const state = loadExamScanState() || {};
const cache = state.cache && typeof state.cache === 'object' ? state.cache : {};
for (const key of Object.keys(cache)) {
if (now - Number(cache[key]?.scannedAt || 0) > CFG.EXAM_SCAN_CACHE_MS) delete cache[key];
}
if (Object.keys(cache).length === 0) {
state.nextIndex = 0;
state.scannedKeys = [];
}
state.cache = cache;
return state;
},
async fetchExamsFromCourseList(options = {}) {
let listDataCourses = [];
let homeCourses = [];
let pageCourses = [];
try { listDataCourses = await this.fetchCoursesFromCourseListData(); } catch (e) { log('课程列表考试扫描失败: ' + e.message, 'warn'); }
try { homeCourses = await this.fetchCoursesFromCourseHomePages(); } catch (e) { log('课程首页考试扫描失败: ' + e.message, 'warn'); }
try { pageCourses = this.parseCourseList ? this.parseCourseList() : []; } catch (_) { pageCourses = []; }
const courses = mergeCourseLists(pageCourses, homeCourses, listDataCourses);
const seen = new Set();
const exams = [];
const controller = options.controller || { paused: false };
const onProgress = typeof options.onProgress === 'function' ? options.onProgress : null;
const state = this._loadFreshExamScanCache();
const cache = state.cache || {};
const scannedKeys = new Set(Array.isArray(state.scannedKeys) ? state.scannedKeys : []);
const startIndex = Math.max(0, Math.min(Number(state.nextIndex || 0) || 0, courses.length));
this._lastExamScan = { total: courses.length, nextIndex: startIndex, completed: courses.length === 0, incomplete: courses.length > 0, paused: false };
for (const key of scannedKeys) {
const cached = cache[key];
if (Array.isArray(cached?.exams)) {
for (const exam of cached.exams) {
const examKey = exam.url.replace(/([?&])t=\d+/g, '$1').replace(/([?&])_=\d+/g, '$1');
if (seen.has(examKey)) continue;
seen.add(examKey);
exams.push(exam);
}
}
}
for (let i = startIndex; i < courses.length; i++) {
if (controller.paused) {
saveExamScanState({ ...state, nextIndex: i, scannedKeys: [...scannedKeys], cache, pausedAt: Date.now() });
this._lastExamScan = { total: courses.length, nextIndex: i, completed: false, incomplete: true, paused: true };
if (onProgress) onProgress({ index: i, total: courses.length, paused: true, exams: exams.length });
return exams;
}
const course = courses[i];
const courseKey = this._examCourseKey(course) || `course-${i}`;
const cached = cache[courseKey];
if (cached && Date.now() - Number(cached.scannedAt || 0) <= CFG.EXAM_SCAN_CACHE_MS) {
scannedKeys.add(courseKey);
if (Array.isArray(cached.exams)) {
for (const exam of cached.exams) {
const examKey = exam.url.replace(/([?&])t=\d+/g, '$1').replace(/([?&])_=\d+/g, '$1');
if (seen.has(examKey)) continue;
seen.add(examKey);
exams.push(exam);
}
}
saveExamScanState({ ...state, nextIndex: i + 1, scannedKeys: [...scannedKeys], cache, updatedAt: Date.now() });
this._lastExamScan = { total: courses.length, nextIndex: i + 1, completed: i + 1 >= courses.length, incomplete: i + 1 < courses.length, paused: false };
continue;
}
if (onProgress) onProgress({ index: i + 1, total: courses.length, course, exams: exams.length });
const found = await this.fetchCourseExams(course, seen);
cache[courseKey] = { scannedAt: Date.now(), exams: found };
scannedKeys.add(courseKey);
for (const exam of found) exams.push(exam);
saveExamScanState({ ...state, nextIndex: i + 1, scannedKeys: [...scannedKeys], cache, updatedAt: Date.now() });
this._lastExamScan = { total: courses.length, nextIndex: i + 1, completed: i + 1 >= courses.length, incomplete: i + 1 < courses.length, paused: false };
if (i < courses.length - 1) {
const waitMs = CFG.EXAM_SCAN_DELAY_MIN_MS + Math.floor(Math.random() * (CFG.EXAM_SCAN_DELAY_MAX_MS - CFG.EXAM_SCAN_DELAY_MIN_MS + 1));
if (onProgress) onProgress({ index: i + 1, total: courses.length, course, exams: exams.length, waitMs });
await sleep(waitMs);
}
}
saveExamScanState({ ...state, nextIndex: courses.length, scannedKeys: [...scannedKeys], cache, completedAt: Date.now(), updatedAt: Date.now() });
this._lastExamScan = { total: courses.length, nextIndex: courses.length, completed: true, incomplete: false, paused: false };
return exams;
},
async fetchExamsViaAPI(options = {}) {
this._lastExamScan = null;
let exams = isExamContextPage(document, location.href) ? this.parseExamList(document, location.href) : [];
if (exams.length > 0 && !exams.every(e => e.url.includes('examSysList'))) return exams;
const listUrl = this.getExamListUrl();
if (!listUrl) {
const courseListExams = await this.fetchExamsFromCourseList(options);
if (courseListExams.length > 0) return courseListExams;
return exams.filter(e => !e.url.includes('examSysList') && !isExamListPageUrl(e.url));
}
try {
const html = await fetchTextAnyOrigin(listUrl, { timeout: CFG.API_TIMEOUT, detectAntiSpider: true });
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const fetched = this.parseExamList(doc, listUrl);
if (fetched.length > 0) return fetched;
} catch (e) {
if (e instanceof ChaoxingAntiSpiderError) throw e;
log('获取考试列表失败: ' + e.message, 'warn');
}
const courseListExams = await this.fetchExamsFromCourseList(options);
if (courseListExams.length > 0) return courseListExams;
return exams.filter(e => !e.url.includes('examSysList') && !isExamListPageUrl(e.url));
},
async getTaskPointsViaAPI() {
const { cid, clid, cpi, enc } = getCourseParams();
if (!cid) return null;
const apiUrls = [
`https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentstudycourselist?courseId=${cid}&clazzid=${clid}&cpi=${cpi}&mooc2=1&searchChapterListByName=`,
`https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudycourselist?courseId=${cid}&clazzid=${clid}&cpi=${cpi}&mooc2=1`,
];
for (const url of apiUrls) {
try {
if (!canFetchSameOrigin(url)) continue;
const resp = await safeFetch(url, { credentials: 'include' });
if (!resp.ok) continue;
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const ssEl = doc.querySelector('#_studystate, [name="studystate"]');
let studystateData = parseStudystateData(ssEl);
if (studystateData?.chapters) {
const tasks = [];
for (const ch of studystateData.chapters) {
if (!ch) continue;
const children = ch.children || ch.sections || ch.nodes || [];
for (const node of children) {
if (node.isTaskPoint && !node.isPassed) {
tasks.push({
name: node.name || node.title || '未命名任务',
knowledgeId: String(node.knowledgeid || node.knowledgeId || node.id || ''),
chapterName: ch.name || ch.title || ch.chapterName || '',
chapterId: String(ch.id || ch.chapterId || ''),
isDone: false,
type: String(node.property || node.type || 0),
courseId: cid, clazzid: clid, cpi, enc,
});
}
const subChildren = node.children || node.sections || node.nodes || [];
for (const sub of subChildren) {
if (sub.isTaskPoint && !sub.isPassed) {
tasks.push({
name: sub.name || sub.title || '未命名任务',
knowledgeId: String(sub.knowledgeid || sub.knowledgeId || sub.id || ''),
chapterName: node.name || node.title || '',
chapterId: String(node.id || node.chapterId || ''),
isDone: false,
type: String(sub.property || sub.type || 0),
courseId: cid, clazzid: clid, cpi, enc,
});
}
}
}
if (ch.isTaskPoint && !ch.isPassed && !(ch.children && ch.children.length > 0)) {
const chkid = String(ch.knowledgeid || ch.id || '');
if (String(ch.property || ch.type || '') || chkid.length > 5) {
tasks.push({
name: ch.name || ch.title || '未命名任务',
knowledgeId: chkid,
chapterName: ch.chapterName || '',
chapterId: String(ch.chapterId || ch.id || ''),
isDone: false,
type: String(ch.property || ch.type || ''),
courseId: cid, clazzid: clid, cpi, enc,
});
}
}
}
if (tasks.length > 0) return tasks;
}
const curItems = doc.querySelectorAll('[id^="cur"]');
const tasks = [];
for (const item of curItems) {
const subUl = item.querySelector('ul, .posCatalog_level');
if (subUl) continue;
const nameEl = item.querySelector('.posCatalog_name, .catalog_name, .clicktitle');
const iconEl = item.querySelector('.posCatalog_icon, [class*="icon"]');
const name = (nameEl ? (nameEl.getAttribute('title') || nameEl.textContent) : item.textContent || '').trim().replace(/\s+/g, ' ');
if (!name || name.length < CFG.MIN_TASK_NAME_LEN || name.length > CFG.MAX_TASK_NAME_LEN) continue;
const idMatch = (item.id || '').match(/cur(\d+)/);
const kid = idMatch ? idMatch[1] : '';
if (!kid) continue;
const status = getTaskCompletionStatus(item, iconEl);
if (status === 'todo') {
const parentChapter = item.closest('.posCatalog_level')?.previousElementSibling;
const chapterEl = parentChapter || item.closest('li')?.querySelector('.firstLayer .posCatalog_title');
const chapterName = chapterEl ? (chapterEl.getAttribute('title') || chapterEl.textContent || '').trim().replace(/\s+/g, ' ') : '';
tasks.push(buildTaskCandidate(name, {
knowledgeId: kid, chapterName, chapterId: kid,
courseId: cid, clazzid: clid, cpi, enc,
}));
}
}
if (tasks.length > 0) return tasks;
if (studystateData?.unfinishCount !== undefined && studystateData.unfinishCount > 0) {
const nextId = String(studystateData.nextChapterId || '');
if (nextId && nextId.length > CFG.MIN_ID_LEN) {
return [{
name: '自动检测到未完成任务',
knowledgeId: nextId, chapterName: '', chapterId: nextId,
isDone: false, type: '', courseId: cid, clazzid: clid, cpi, enc,
}];
}
return [{ name: '未完成任务(' + studystateData.unfinishCount + '个)', knowledgeId: '', chapterName: '', chapterId: '', isDone: false, type: '', courseId: cid, clazzid: clid, cpi, enc }];
}
} catch (_) { /* API请求失败 */ }
}
return null;
},
async getTaskPointsViaDOM() {
const tasks = [];
const unknownTasks = [];
const { cid, clid, cpi } = getCourseParams();
let enc = getUrlParam('enc') || '';
if (!enc) {
try {
const scripts = document.querySelectorAll('script');
for (const s of scripts) {
const match = (s.textContent || '').match(/(?:var|let|const)\s+enc\s*=\s*["']([a-f0-9]{32})["']/);
if (match) { enc = match[1]; log('从页面脚本提取enc: ' + enc); break; }
}
} catch (_) { /* 脚本提取失败 */ }
}
if (!enc) {
try {
if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.toOld === 'function') {
const src = unsafeWindow.toOld.toString();
const match = src.match(/enc\s*=\s*"([a-f0-9]{32})"/);
if (match) enc = match[1];
}
} catch (_) { /* unsafeWindow访问失败 */ }
}
const docs = getAllDocs();
for (const doc of docs) {
for (const head of doc.querySelectorAll('.chapter_head, .posCatalog_select.chapter, [class*="chapter_item"]')) {
if (isVisible(head)) { try { head.click(); } catch (_) { /* 点击失败 */ } }
}
}
await sleep(CFG.CHAPTER_EXPAND_MS);
for (const doc of docs) {
const items = [...doc.querySelectorAll('[id^="cur"]')];
log(`在文档中找到 ${items.length} 个任务点元素`);
for (const item of items) {
const subUl = item.querySelector('ul, .posCatalog_level');
if (subUl) continue;
const nameEl = item.querySelector('.posCatalog_name, .catalog_name, .clicktitle, a');
const iconEl = item.querySelector('.posCatalog_icon, [class*="icon"]');
const name = (nameEl ? nameEl.textContent : item.textContent || '').trim().replace(/\s+/g, ' ');
if (!name || name.length < CFG.MIN_TASK_NAME_LEN || name.length > CFG.MAX_TASK_NAME_LEN) continue;
const onclick = item.getAttribute('onclick') || '';
const onclickMatch = onclick.match(/toOld\s*\(\s*'(\d+)'\s*,\s*'(\d+)'/);
const kid = onclickMatch ? onclickMatch[2] : (item.id ? item.id.replace('cur', '') : '');
const status = getTaskCompletionStatus(item, iconEl);
if (status !== 'done') {
const task = buildTaskCandidate(name, {
knowledgeId: kid,
element: item,
clickTarget: nameEl || item,
courseId: cid,
clazzid: clid,
cpi,
enc,
});
log((status === 'todo' ? '找到未完成任务: ' : '找到状态未知候选任务: ') + name + ' (knowledgeId: ' + kid + ')');
if (status === 'todo') tasks.push(task);
else unknownTasks.push(task);
}
}
}
const result = tasks.length > 0 ? tasks : unknownTasks;
log(`getTaskPointsViaDOM 总共找到 ${result.length} 个候选任务点`);
return result;
},
getFastTaskPointViaDOM() {
const { cid, clid, cpi } = getCourseParams();
let enc = getChaoxingPageEnc();
const preferred = [];
const fallback = [];
for (const doc of getAllDocs()) {
for (const item of doc.querySelectorAll('[id^="cur"]')) {
const subUl = item.querySelector('ul, .posCatalog_level');
if (subUl) continue;
const nameEl = item.querySelector('.posCatalog_name, .catalog_name, .clicktitle, a');
const iconEl = item.querySelector('.posCatalog_icon, [class*="icon"]');
const name = (nameEl ? nameEl.textContent : item.textContent || '').trim().replace(/\s+/g, ' ');
if (!name || name.length < CFG.MIN_TASK_NAME_LEN || name.length > CFG.MAX_TASK_NAME_LEN) continue;
const onclick = item.getAttribute('onclick') || '';
const onclickMatch = onclick.match(/toOld\s*\(\s*'(\d+)'\s*,\s*'(\d+)'/);
let kid = onclickMatch ? onclickMatch[2] : (item.id ? item.id.replace('cur', '') : '');
if (!kid) {
const href = nameEl?.href || item.querySelector('a[href]')?.href || '';
kid = getUrlParamFrom(href, 'chapterId') || getUrlParamFrom(href, 'knowledgeId') || '';
if (!enc) enc = getUrlParamFrom(href, 'enc') || '';
}
if (!kid || kid.length <= CFG.MIN_ID_LEN) continue;
const task = buildTaskCandidate(name, {
knowledgeId: kid,
element: item,
clickTarget: nameEl || item,
courseId: cid,
clazzid: clid,
cpi,
enc,
});
const status = getTaskCompletionStatus(item, iconEl);
if (status === 'done') fallback.push(task);
else preferred.push(task);
}
}
return preferred[0] || fallback[0] || null;
},
async getTaskPoints() {
let tasks = await this.getTaskPointsViaDOM();
if (!tasks || tasks.length === 0) {
tasks = await this.getTaskPointsViaAPI();
}
if ((!tasks || tasks.length === 0) && PLATFORM === 'chaoxing') {
tasks = this.getTaskPointsFromPageStudystate();
}
return tasks;
},
getTaskPointsFromPageStudystate() {
const { cid, clid, cpi, enc } = getCourseParams();
const docs = getAllDocs();
for (const doc of docs) {
const ssEl = doc.querySelector('#_studystate, [name="studystate"]');
if (!ssEl) continue;
const data = parseStudystateData(ssEl);
if (data?.nextChapterId) {
const nextId = String(data.nextChapterId);
if (nextId.length > CFG.MIN_ID_LEN) {
log('从页面_studystate获取nextChapterId: ' + nextId);
return [{
name: '下一个未完成任务',
knowledgeId: nextId, chapterName: '', chapterId: '',
isDone: false, type: '', courseId: cid, clazzid: clid, cpi, enc,
}];
}
}
}
return null;
},
pickRandom(tasks) {
return pickNextTask(tasks);
},
async enterTask(task) {
log('开始进入任务: ' + task.name);
if (task.knowledgeId && task.knowledgeId.length > CFG.MIN_ID_LEN) {
const { cid, clid, cpi, enc } = getCourseParams(task);
const url = `https://mooc1.chaoxing.com/mycourse/studentstudy`
+ `?chapterId=${task.knowledgeId}`
+ `&courseId=${cid}&clazzid=${clid}&cpi=${cpi}`
+ `&enc=${enc}&mooc2=1`;
log('跳转任务: ' + task.name + ' → ' + url);
location.href = url;
return;
}
if (task.clickTarget && task.element) {
log('尝试直接点击任务元素');
try {
const clickEl = task.element.querySelector('.clicktitle, .catalog_name a, a')
|| task.element.querySelector('[role="link"]') || task.element;
clickEl.scrollIntoView({ block: 'center', behavior: 'smooth' });
await sleep(CFG.ENTER_TASK_SLEEP);
task.element.click();
log('直接点击成功!');
return;
} catch (e) {
log('直接点击失败: ' + e.message);
}
}
if (task.chapterId && task.chapterId.length > CFG.MIN_ID_LEN && task.chapterId !== task.knowledgeId) {
const { cid, clid, cpi, enc } = getCourseParams(task);
const url = `https://mooc1.chaoxing.com/mycourse/studentstudy`
+ `?chapterId=${task.chapterId}`
+ `&courseId=${cid}&clazzid=${clid}&cpi=${cpi}`
+ `&enc=${enc}&mooc2=1`;
log('跳转任务(chapterId): ' + task.name + ' → ' + url);
location.href = url;
return;
}
log('任务没有knowledgeId也没有chapterId!尝试从studentstudycourselist API获取...');
try {
const { cid, clid, cpi, enc } = getCourseParams(task);
const apiUrls = [
`https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentstudycourselist?courseId=${cid}&clazzid=${clid}&cpi=${cpi}&mooc2=1`,
`https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudycourselist?courseId=${cid}&clazzid=${clid}&cpi=${cpi}&mooc2=1`,
];
for (const apiUrl of apiUrls) {
if (!canFetchSameOrigin(apiUrl)) continue;
const resp = await safeFetch(apiUrl, { credentials: 'include' });
if (!resp.ok) continue;
const html = await resp.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const ssEl = doc.querySelector('#_studystate, [name="studystate"]');
const data = parseStudystateData(ssEl);
if (data?.nextChapterId) {
const url = `https://mooc1.chaoxing.com/mycourse/studentstudy`
+ `?chapterId=${data.nextChapterId}`
+ `&courseId=${cid}&clazzid=${clid}&cpi=${cpi}`
+ `&enc=${enc}&mooc2=1`;
log('从API获取nextChapterId跳转: ' + url);
location.href = url;
return;
}
}
} catch (e) {
log('API获取nextChapterId失败: ' + e.message);
}
},
async checkCompletion() {
const cid = getUrlParam('courseId') || getUrlParam('courseid') || getUrlParam('cid');
if (!cid) return null;
const clid = getUrlParam('clazzid') || getUrlParam('clazzId') || '';
const cpi = getUrlParam('cpi') || '';
const chapterId = getUrlParam('chapterId') || getUrlParam('knowledgeId') || '';
const params = { cid, clid, cpi };
const addQuery = (base, extra = {}) => {
const u = new URL(base, location.href);
u.searchParams.set('courseId', cid);
if (chapterId) u.searchParams.set('chapterId', chapterId);
if (clid) u.searchParams.set('clazzid', clid);
if (cpi) u.searchParams.set('cpi', cpi);
u.searchParams.set('mooc2', '1');
for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v);
return u.href;
};
const urls = [...new Set([
addQuery(`${location.origin}/mooc-ans/mycourse/studentstudycourselist`, { searchChapterListByName: '' }),
addQuery(`${location.origin}/mooc-ans/mycourse/studentstudycourselist`, { isMicroCourse: 'false' }),
addQuery(`${location.origin}/mycourse/studentstudycourselist`),
addQuery('https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudycourselist', { searchChapterListByName: '' }),
addQuery('https://mooc1.chaoxing.com/mycourse/studentstudycourselist'),
addQuery('https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentstudycourselist', { searchChapterListByName: '' }),
])];
for (const url of urls) {
try {
const html = await fetchTextAnyOrigin(url, { timeout: CFG.API_TIMEOUT });
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const prog = parseCourseListProgressFromApiDoc(doc);
if (prog) return prog;
const taskProg = await this._buildTaskProgressFromCourseListDoc(params, doc);
if (taskProg) return taskProg;
} catch (_) { /* API请求失败 */ }
}
return null;
},
async navigateFromCourseDetailToStudy() {
const u = location.href;
const isCoursePage = u.includes('studentcourse') || u.includes('/mycourse/stu') || u.includes('/visit/interaction');
if (!isCoursePage || u.includes('studentstudy')) return false;
const currentTasks = [...document.querySelectorAll('[id^="cur"]')];
if (currentTasks.length > 0) {
log('当前页面已有任务点,无需跳转');
return false;
}
for (const iframe of $$('iframe')) {
const src = iframe.src || '';
if (src.includes('studentcourse') && !src.includes('studentstudy')) {
log('检测到课程章节iframe,导航进入: ' + src);
location.href = src;
return true;
}
if (src.includes('studentstudy') || src.includes('knowledge/cards')) {
log('从iframe导航到学习页: ' + src);
location.href = src;
return true;
}
}
const { cid, clid, cpi, enc } = getCourseParams();
try {
const apiUrls = [
`https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentstudycourselist?courseId=${cid}&clazzid=${clid}&cpi=${cpi}&mooc2=1`,
`https://mooc1.chaoxing.com/mooc-ans/mycourse/studentstudycourselist?courseId=${cid}&clazzid=${clid}&cpi=${cpi}&mooc2=1`,
];
for (const apiUrl of apiUrls) {
try {
if (!canFetchSameOrigin(apiUrl)) continue;
const resp = await safeFetch(apiUrl, { credentials: 'include' });
if (!resp.ok) continue;
const html = await resp.text();
if (html.length < CFG.MIN_HTML_LEN) continue;
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const ssEl = doc.querySelector('#_studystate, [name="studystate"]');
const data = parseStudystateData(ssEl);
if (data?.nextChapterId) {
const url = `https://mooc1.chaoxing.com/mycourse/studentstudy`
+ `?chapterId=${data.nextChapterId}`
+ `&courseId=${cid}&clazzid=${clid}&cpi=${cpi}`
+ `&enc=${enc}&mooc2=1`;
log('从API导航到学习页: ' + url);
location.href = url;
return true;
}
} catch (_) { /* API请求失败 */ }
}
} catch (_) { /* API请求失败 */ }
const docs = getAllDocs();
for (const doc of docs) {
const chapterHeads = doc.querySelectorAll('.chapter_head, [class*="chapter_head"]');
if (chapterHeads.length > 0) {
const firstHead = chapterHeads[0];
const toggleEl = firstHead.querySelector('.posCatalog_name, .chapter_name, [title]');
if (toggleEl) {
try { toggleEl.click(); } catch (_) { /* 点击失败 */ }
} else {
try { firstHead.click(); } catch (_) { /* 点击失败 */ }
}
}
}
await sleep(CFG.POPUP_MS);
for (const doc of docs) {
const allItems = [...doc.querySelectorAll('[id^="cur"]')];
for (const item of allItems) {
const nameEl = item.querySelector('.posCatalog_name, .catalog_name, .clicktitle, a');
if (!nameEl) continue;
const idMatch = (item.id || '').match(/cur(\d+)/);
const kid = idMatch ? idMatch[1] : '';
const hasChildren = item.querySelector('ul') !== null;
if (!hasChildren && kid) {
try { nameEl.click(); } catch (_) { /* 点击失败 */ }
await sleep(CFG.NAV_CLICK_SLEEP);
const newUrl = location.href;
if (newUrl.includes('studentstudy') || newUrl.includes('knowledgeid=')) return true;
for (const iframe2 of $$('iframe')) {
const src2 = iframe2.src || '';
if (src2.includes('studentstudy') || src2.includes('knowledge/cards')) {
location.href = src2;
return true;
}
}
break;
}
}
}
return false;
},
getCourseListUrl() {
return 'https://mooc2-ans.chaoxing.com/mooc2-ans/visit/interaction';
},
};
const ZhihuishuAdapter = {
parseCourseList() {
const courses = [], seen = new Set();
for (const sel of ['a[href*="courseId"]', 'a[href*="recruitId"]', 'a[href*="shareId"]',
'a[href*="/stu/study"]', '.course-item a', '[class*="courseCard"] a', '.lesson-list a']) {
for (const el of $$(sel)) {
const href = el.href || '';
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
if (href && text && !seen.has(href) && text.length > CFG.MIN_TASK_NAME_LEN && text.length < CFG.MAX_TASK_NAME_LEN) {
seen.add(href);
courses.push({ name: text, url: href, platform: 'zhihuishu' });
}
}
}
if (courses.length === 0) {
for (const el of $$('a')) {
const href = el.href || '';
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
if (href && text && !seen.has(href) && text.length > CFG.MIN_TASK_NAME_LEN && text.length < CFG.MAX_TASK_NAME_LEN
&& (href.includes('study') || href.includes('course'))) {
seen.add(href);
courses.push({ name: text, url: href, platform: 'zhihuishu' });
}
}
}
return courses;
},
async fetchCoursesViaAPI() { return this.parseCourseList(); },
async getTaskPoints() {
const tasks = [];
for (const el of $$('.chapter-title, .chapterTitle, [class*="chapter"] [class*="title"], .catalogue-item')) {
if (isVisible(el)) { try { el.click(); } catch (_) { /* 点击失败 */ } }
}
await sleep(CFG.CHAPTER_EXPAND_MS);
for (const sel of [
'.video-item', '.lesson-item', '.catalogue-item', '[class*="lessonItem"]',
'li[class*="video"]', 'li[class*="lesson"]', '.study-item',
]) {
for (const el of $$(sel)) {
const nameEl = el.querySelector('.title, .name, span, a') || el;
const name = (nameEl.textContent || '').trim().replace(/\s+/g, ' ');
if (!name || name.length < CFG.MIN_TASK_NAME_LEN || name.length > CFG.MAX_TASK_NAME_LEN) continue;
let isDone = false;
const cls = el.className || '';
if (cls.includes('done') || cls.includes('finish') || cls.includes('completed') || cls.includes('studied')) isDone = true;
const icon = el.querySelector('.icon-finish, .icon-done, [class*="finish"], [class*="success"], .check-icon');
if (icon && isVisible(icon)) isDone = true;
if (!isDone) {
tasks.push({
name, knowledgeId: '', chapterName: '', isDone: false,
element: el, clickTarget: el.querySelector('a') || el.querySelector('.title, .name') || el,
});
}
}
}
return tasks;
},
pickRandom(tasks) {
return pickNextTask(tasks);
},
async enterTask(task) {
log('进入智慧树任务: ' + task.name);
if (task.clickTarget) {
task.clickTarget.scrollIntoView({ block: 'center' });
await sleep(CFG.ENTER_TASK_SLEEP);
try { task.clickTarget.click(); } catch (_) { /* 点击失败 */ }
}
},
async checkCompletion() {
const bodyText = document.body?.innerText || '';
const patterns = [
/进度\s*[::]\s*(\d+)\s*\/\s*(\d+)/,
/完成\s*[::]\s*(\d+)\s*\/\s*(\d+)/,
/已学\s*[::]\s*(\d+)\s*\/\s*(\d+)/,
/(\d+)%\s*(完成|已学)/,
/学习进度\s*(\d+)%/,
/完成度\s*(\d+)%/,
];
for (const re of patterns) {
const m = bodyText.match(re);
if (m) {
if (m[1] && m[2]) {
const done = parseInt(m[1], 10) || 0;
const total = parseInt(m[2], 10) || 0;
if (total > 0) return { done, total, percent: calcProgressPercent(done, total), source: 'dom' };
} else if (m[1]) {
const pct = parseInt(m[1], 10) || 0;
return { done: pct, total: 100, percent: pct, source: 'dom' };
}
}
}
if (bodyText.includes('100%') && (bodyText.includes('完成') || bodyText.includes('已学'))) {
const unfinishedIcons = $$('.icon-unlock, [class*="unlock"], [class*="lock"], [class*="unfinished"]');
if (unfinishedIcons.length === 0) return { done: 100, total: 100, percent: 100, source: 'completion_text' };
}
return null;
},
getCourseListUrl() { return location.origin; },
};
const MoocAdapter = {
parseCourseList() {
const courses = [], seen = new Set();
for (const sel of ['a[href*="/learn/"]', '.course-card a', '[class*="course"] a[href*="/learn/"]',
'.u-course-card a', '.course-list a', 'a[href*="tid="]']) {
for (const el of $$(sel)) {
const href = el.href || '';
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
if (href && text && !seen.has(href) && text.length > CFG.MIN_TASK_NAME_LEN && text.length < CFG.MAX_TASK_NAME_LEN) {
seen.add(href);
courses.push({ name: text, url: href, platform: 'mooc' });
}
}
}
return courses;
},
async fetchCoursesViaAPI() { return this.parseCourseList(); },
async getTaskPoints() {
const tasks = [];
for (const el of $$('.chapter, .chaptersection, [class*="chapter"] [class*="header"], .f-icon-arrow-right')) {
if (isVisible(el)) { try { el.click(); } catch (_) { /* 点击失败 */ } }
}
await sleep(CFG.CHAPTER_EXPAND_MS);
for (const sel of [
'.j-chapter-section', '.section', '.chapter-item', '[class*="lesson"]',
'.u-lesson', '.lesson-item', '[class*="unit"]',
]) {
for (const el of $$(sel)) {
const linkEl = el.querySelector('a[href*="content"]') || el.querySelector('a') || el;
const name = (linkEl.textContent || el.textContent || '').trim().replace(/\s+/g, ' ');
if (!name || name.length < CFG.MIN_TASK_NAME_LEN || name.length > CFG.MAX_TASK_NAME_LEN) continue;
let isDone = false;
const cls = el.className || '';
if (cls.includes('done') || cls.includes('finish') || cls.includes('completed') || cls.includes('studied')) isDone = true;
const checkIcon = el.querySelector('.f-icon-correct, .icon-correct, [class*="check"], [class*="finish"]');
if (checkIcon) isDone = true;
if (!isDone) {
tasks.push({
name, knowledgeId: '', chapterName: '', isDone: false,
element: el, clickTarget: linkEl.tagName === 'A' ? linkEl : (el.querySelector('a') || el),
});
}
}
}
return tasks;
},
pickRandom(tasks) {
return pickNextTask(tasks);
},
async enterTask(task) {
log('进入MOOC任务: ' + task.name);
if (task.clickTarget) {
task.clickTarget.scrollIntoView({ block: 'center' });
await sleep(CFG.ENTER_TASK_SLEEP);
try { task.clickTarget.click(); } catch (_) { /* 点击失败 */ }
}
},
async checkCompletion() {
const bodyText = document.body?.innerText || '';
const patterns = [
/已完成\s*(\d+)\s*\/\s*(\d+)/,
/学习进度\s*(\d+)%/,
/完成度\s*(\d+)%/,
/进度\s*(\d+)\s*\/\s*(\d+)/,
];
for (const re of patterns) {
const m = bodyText.match(re);
if (m) {
if (m[2]) {
const done = parseInt(m[1], 10) || 0;
const total = parseInt(m[2], 10) || 0;
if (total > 0) return { done, total, percent: calcProgressPercent(done, total), source: 'dom' };
} else {
const pct = parseInt(m[1], 10) || 0;
return { done: pct, total: 100, percent: pct, source: 'dom' };
}
}
}
return null;
},
getCourseListUrl() { return 'https://www.icourse163.org/'; },
};
const IcveAdapter = {
parseCourseList() {
const courses = [], seen = new Set();
for (const sel of ['a[href*="courseId"]', 'a[href*="courseid"]', 'a[href*="/course/"]',
'.course-item a', '[class*="course"] a', '.course-card a']) {
for (const el of $$(sel)) {
const href = el.href || '';
const text = (el.textContent || '').trim().replace(/\s+/g, ' ');
if (href && text && !seen.has(href) && text.length > CFG.MIN_TASK_NAME_LEN && text.length < CFG.MAX_TASK_NAME_LEN) {
seen.add(href);
courses.push({ name: text, url: href, platform: 'icve' });
}
}
}
return courses;
},
async fetchCoursesViaAPI() { return this.parseCourseList(); },
async getTaskPoints() {
const tasks = [];
for (const el of $$('.chapter-title, .unit-title, [class*="expand"], [class*="fold"], .tree-node [class*="toggle"]')) {
if (isVisible(el)) { try { el.click(); } catch (_) { /* 点击失败 */ } }
}
await sleep(CFG.CHAPTER_EXPAND_MS);
for (const sel of [
'.lesson-item', '.task-item', '[class*="lesson"]', '[class*="taskItem"]',
'.study-item', '.learn-item', '[class*="video"]',
]) {
for (const el of $$(sel)) {
const nameEl = el.querySelector('.title, .name, a, span') || el;
const name = (nameEl.textContent || '').trim().replace(/\s+/g, ' ');
if (!name || name.length < CFG.MIN_TASK_NAME_LEN || name.length > CFG.MAX_TASK_NAME_LEN) continue;
let isDone = false;
const cls = el.className || '';
if (cls.includes('done') || cls.includes('finish') || cls.includes('completed') || cls.includes('pass')) isDone = true;
const icon = el.querySelector('[class*="finish"], [class*="done"], [class*="success"], [class*="check"]');
if (icon) isDone = true;
if (!isDone) {
tasks.push({
name, knowledgeId: '', chapterName: '', isDone: false,
element: el, clickTarget: el.querySelector('a') || nameEl,
});
}
}
}
return tasks;
},
pickRandom(tasks) {
return pickNextTask(tasks);
},
async enterTask(task) {
log('进入智慧职教任务: ' + task.name);
if (task.clickTarget) {
task.clickTarget.scrollIntoView({ block: 'center' });
await sleep(CFG.ENTER_TASK_SLEEP);
try { task.clickTarget.click(); } catch (_) { /* 点击失败 */ }
}
},
async checkCompletion() {
const bodyText = document.body?.innerText || '';
const patterns = [
/进度\s*[::]\s*(\d+)\s*\/\s*(\d+)/,
/完成\s*[::]\s*(\d+)\s*\/\s*(\d+)/,
/已学\s*[::]\s*(\d+)\s*\/\s*(\d+)/,
/学习进度\s*(\d+)%/,
/完成度\s*(\d+)%/,
];
for (const re of patterns) {
const m = bodyText.match(re);
if (m) {
if (m[2]) {
const done = parseInt(m[1], 10) || 0;
const total = parseInt(m[2], 10) || 0;
if (total > 0) return { done, total, percent: calcProgressPercent(done, total), source: 'dom' };
} else {
const pct = parseInt(m[1], 10) || 0;
return { done: pct, total: 100, percent: pct, source: 'dom' };
}
}
}
return null;
},
getCourseListUrl() { return location.origin + '/student/course'; },
};
const Adapters = { chaoxing: ChaoxingAdapter, zhihuishu: ZhihuishuAdapter, mooc: MoocAdapter, icve: IcveAdapter };
const Adapter = Adapters[PLATFORM] || null;
const Queue = {
get() { return loadQueue(); },
save(q) { saveQueue(q); },
add(course) {
const q = this.get();
const existing = q.find(c => c.url === course.url);
if (existing) {
if (existing.completed) {
existing.completed = false;
delete existing.completedAt;
delete existing.skipped;
delete existing.skippedAt;
delete existing.skipReason;
delete existing.skipProgress;
existing.addedAt = Date.now();
existing.name = course.name || existing.name;
this.save(q);
return true;
}
return false;
}
if (!existing) {
q.push({ ...course, addedAt: Date.now() });
this.save(q);
return true;
}
return false;
},
remove(idx) {
const q = this.get();
if (idx >= 0 && idx < q.length) { q.splice(idx, 1); this.save(q); return true; }
return false;
},
move(from, to) {
const q = this.get();
if (from < 0 || from >= q.length) return;
const targetIdx = Math.max(0, Math.min(to, q.length - 1));
const item = q.splice(from, 1)[0];
q.splice(targetIdx, 0, item);
this.save(q);
},
clear() { this.save([]); },
getNextPending() {
return this.get().find(c => !c.completed) || null;
},
markCompleted(url) {
const q = this.get();
const c = q.find(x => x.url === url);
if (c) {
c.completed = true;
c.completedAt = Date.now();
delete c.skipped;
delete c.skippedAt;
delete c.skipReason;
delete c.skipProgress;
this.save(q);
}
},
markSkipped(url, reason, prog) {
const q = this.get();
const c = q.find(x => x.url === url);
if (c) {
c.completed = true;
c.skipped = true;
c.completedAt = Date.now();
c.skippedAt = Date.now();
c.skipReason = reason || '异常跳过';
if (prog) {
c.skipProgress = {
done: prog.done,
total: prog.total,
percent: prog.percent,
unfinish: prog.unfinish,
source: prog.source,
};
}
this.save(q);
}
},
progress() {
const q = this.get();
const done = q.filter(c => c.completed).length;
return { done, total: q.length, pct: q.length > 0 ? Math.round((done / q.length) * 100) : 0 };
},
};
function loadExamState() {
try {
const r = GM_getValue(storeKey('exam_state'), 'null');
return r ? JSON.parse(r) : null;
} catch (_) {
return null;
}
}
function saveExamState(s) {
try { GM_setValue(storeKey('exam_state'), JSON.stringify(s)); }
catch (_) { log('saveExamState 存储异常', 'warn'); }
}
function clearExamState() {
try { GM_deleteValue(storeKey('exam_state')); }
catch (_) { /* ignore */ }
}
const ExamQueue = {
get() {
try {
const r = GM_getValue(storeKey('exam_queue_' + PLATFORM), '[]');
const a = JSON.parse(r);
return Array.isArray(a) ? a : [];
} catch (_) {
return [];
}
},
save(q) {
try { GM_setValue(storeKey('exam_queue_' + PLATFORM), JSON.stringify(q)); }
catch (_) { log('saveExamQueue 存储异常', 'warn'); }
},
add(exam) {
const q = this.get();
const existing = q.find(e => e.url === exam.url);
if (existing) {
if (existing.completed) {
existing.completed = false;
delete existing.completedAt;
existing.name = exam.name || existing.name;
existing.addedAt = Date.now();
this.save(q);
return true;
}
return false;
}
q.push({ ...exam, addedAt: Date.now() });
this.save(q);
return true;
},
remove(idx) {
const q = this.get();
if (idx >= 0 && idx < q.length) { q.splice(idx, 1); this.save(q); return true; }
return false;
},
move(from, to) {
const q = this.get();
if (from < 0 || from >= q.length) return;
const targetIdx = Math.max(0, Math.min(to, q.length - 1));
const item = q.splice(from, 1)[0];
q.splice(targetIdx, 0, item);
this.save(q);
},
clear() { this.save([]); clearExamState(); },
getNextPending() { return this.get().find(e => !e.completed) || null; },
markCompleted(url) {
const q = this.get();
const e = q.find(x => x.url === url);
if (e) { e.completed = true; e.completedAt = Date.now(); this.save(q); }
},
progress() {
const q = this.get();
const done = q.filter(e => e.completed).length;
return { done, total: q.length, pct: q.length > 0 ? Math.round((done / q.length) * 100) : 0 };
},
};
const ExamRunner = {
openNext() {
const next = ExamQueue.getNextPending();
if (!next) {
clearExamState();
UI.toast('考试队列已全部处理');
return false;
}
saveExamState({
running: true,
platform: PLATFORM,
exam: next,
startedAt: Date.now(),
});
if (typeof UI !== 'undefined' && UI.createExamBar) UI.createExamBar(next);
UI.toast('正在打开考试:' + next.name);
location.href = next.url;
return true;
},
markCurrentCompleted(openNext = true) {
const st = loadExamState();
if (!st?.exam) {
UI.toast('当前没有正在处理的考试');
return false;
}
ExamQueue.markCompleted(st.exam.url);
clearExamState();
if (typeof UI !== 'undefined' && UI.removeExamBar) UI.removeExamBar();
UI.toast('已标记考试完成:' + st.exam.name);
if (openNext) return this.openNext();
return true;
},
stop() {
clearExamState();
if (typeof UI !== 'undefined' && UI.removeExamBar) UI.removeExamBar();
UI.toast('已停止考试队列');
},
};
const Scheduler = {
_monitorTimer: null,
_checkRunning: false,
_lastProgress: null,
_lastApiCheck: 0,
_lastTaskRecheck: 0,
_lastProgressLog: 0,
_lastHeartbeat: 0,
_lastReportedDone: 0,
_monitorStart: 0,
start() {
const next = Queue.getNextPending();
if (!next) { UI.toast('队列为空!请先添加课程。'); return false; }
const targetUrl = buildCourseUrl(next.url);
saveState({
running: true,
platform: PLATFORM,
course: next,
courseIdx: Queue.get().filter(c => !c.completed).indexOf(next),
phase: 'navigate',
entered: false,
targetUrl,
startedAt: Date.now(),
});
log('开始学习: ' + next.name + ' → ' + targetUrl);
location.href = targetUrl;
return true;
},
skip() {
const st = loadState();
if (st?.course) Queue.markCompleted(st.course.url);
this._stopMonitoring();
this._navigateNext();
},
stop() {
this._stopMonitoring();
clearState();
UI.removeStatusBar();
},
_stopMonitoring() {
this._checkRunning = false;
if (this._monitorTimer) { clearInterval(this._monitorTimer); this._monitorTimer = null; }
},
_resetMonitorState() {
this._lastProgress = null;
this._lastApiCheck = 0;
this._lastTaskRecheck = 0;
this._lastProgressLog = Date.now();
this._lastHeartbeat = Date.now();
this._lastReportedDone = 0;
this._monitorStart = Date.now();
},
_isCourseDoneProgress(prog) {
return !!prog && (prog.percent >= 100 || (prog.total > 0 && prog.done >= prog.total));
},
_isApiProgress(prog) {
return isApiProgress(prog);
},
_finishCurrentCourse(st, reason) {
if (!st?.course) return false;
log(`${reason}: ${st.course.name}`);
Queue.markCompleted(st.course.url);
this._stopMonitoring();
this._navigateNext();
return true;
},
_hasKnownUnfinishedProgress(prog) {
if (!prog) return false;
if (prog.unfinish !== undefined) return prog.unfinish > 0;
if (prog.total > 0) return prog.done < prog.total && prog.percent < 100;
return false;
},
_skipAbnormalCourse(st, reason, prog) {
if (!st?.course) return false;
const detail = prog
? ` (${prog.done || 0}/${prog.total || 0}, ${prog.percent || 0}%, 剩余:${prog.unfinish ?? '未知'})`
: '';
const finalReason = reason || '异常跳过';
log(`异常跳过课程: ${st.course.name} - ${finalReason}${detail}`, 'warn');
Queue.markSkipped(st.course.url, finalReason, prog);
UI.toast('已异常跳过:' + st.course.name);
this._stopMonitoring();
this._navigateNext();
return true;
},
async _finishOrSkipWhenNoTask(reason) {
const st = loadState();
let prog = null;
if (Adapter?.checkCompletion) {
try {
prog = await Adapter.checkCompletion();
if (this._isApiProgress(prog) && st?.course) UI.updateStatusBar(st.course, prog);
} catch (_) { /* completion check failed */ }
}
if (this._isApiProgress(prog) && this._isCourseDoneProgress(prog)) {
return this._finishCurrentCourse(st, '课程完成');
}
if (this._isApiProgress(prog) && this._hasKnownUnfinishedProgress(prog)) {
return this._skipAbnormalCourse(st, reason || '找不到可进入的未完成任务点', prog);
}
const skipReason = (reason || '找不到可进入的未完成任务点') + ',且未读取到API完成进度';
return this._skipAbnormalCourse(st, skipReason, prog);
},
async _enterRandomTask() {
if (!Adapter?.getTaskPoints) return;
log('正在查找任务点...');
if (PLATFORM === 'chaoxing' && Adapter.switchToNewVersion) {
const beforeSwitchUrl = location.href;
Adapter.switchToNewVersion();
if (location.href !== beforeSwitchUrl) return;
}
if (location.href.includes('studentstudy') && !location.href.includes('studentcourse')) {
const { cid, clid, cpi } = getCourseParams();
const backUrl = `https://mooc2-ans.chaoxing.com/mooc2-ans/mycourse/studentcourse?courseid=${cid}&clazzid=${clid}&cpi=${cpi}&ut=s&t=${Date.now()}`;
log('在任务学习页无法获取任务点,返回课程章节页');
const st = loadState();
if (st) { st.phase = 'navigate'; saveState(st); }
location.href = backUrl;
return;
}
if (PLATFORM === 'chaoxing' && Adapter.getFastTaskPointViaDOM) {
const fastTask = Adapter.getFastTaskPointViaDOM();
if (fastTask?.knowledgeId && fastTask.courseId && fastTask.clazzid) {
log('Fast enter task: ' + fastTask.name);
UI.showTaskToast(fastTask.name);
const st = loadState();
if (st) { st.entered = true; st.phase = 'click_task'; saveState(st); }
await sleep(CFG.ENTER_TASK_SLEEP);
await Adapter.enterTask(fastTask);
return;
}
}
await sleep(CFG.POPUP_MS);
clickChapterTab();
await sleep(CFG.CHAPTER_EXPAND_MS);
const tasks = await Adapter.getTaskPoints();
if (!tasks || tasks.length === 0) {
if (PLATFORM === 'chaoxing' && isChaoxingErrorPage()) {
const { cid, clid, cpi, enc } = getCourseParams();
if (cid && clid && location.href.includes('mooc2-ans') && location.href.includes('/visit/stucoursemiddle')) {
let fallbackUrl = `https://mooc1.chaoxing.com/visit/stucoursemiddle?courseid=${cid}&clazzid=${clid}`;
if (cpi) fallbackUrl += `&cpi=${cpi}`;
if (enc) fallbackUrl += `&enc=${enc}`;
fallbackUrl += '&ismooc2=1&v=2';
log('检测到 mooc2 课程入口 404,切回有效入口: ' + fallbackUrl);
location.href = fallbackUrl;
return;
}
log('当前页面是错误页,暂停任务查找,避免误判课程完成');
UI.toast('当前课程入口页面异常,未标记完成');
return;
}
log('未找到任务点,可能所有任务已完成');
await this._finishOrSkipWhenNoTask('找不到可进入的未完成任务点');
return;
}
log(`找到 ${tasks.length} 个未完成任务点`);
const picker = Adapter.pickNextTask || Adapter.pickRandom || pickNextTask;
const picked = picker.call(Adapter, tasks);
if (!picked) {
log('所有任务已标记完成');
await this._finishOrSkipWhenNoTask('任务列表没有可选择的未完成任务');
return;
}
const chapterPath = picked.chapterName || '';
const taskType = TASK_TYPE_MAP[String(picked.type || '')] || '';
const displayParts = [];
if (taskType) displayParts.push(`[${taskType}]`);
if (chapterPath) displayParts.push(chapterPath);
displayParts.push(picked.name);
const display = displayParts.join(' > ');
log(`选中任务: ${display}`);
UI.showTaskToast(display);
const st = loadState();
if (st) { st.entered = true; st.phase = 'click_task'; saveState(st); }
await sleep(CFG.TASK_DELAY_MS);
await Adapter.enterTask(picked);
},
_startMonitoring() {
this._stopMonitoring();
this._resetMonitorState();
const check = async () => {
if (this._checkRunning) return;
this._checkRunning = true;
try {
const st = loadState();
if (!st?.running) { this._stopMonitoring(); return; }
if (isWrongCoursePage(location.href, st.targetUrl || st.course?.url)) {
log('当前页面不属于队列课程,停止本页监控并跳转目标课程');
this._stopMonitoring();
location.href = st.targetUrl || buildCourseUrl(st.course.url);
return;
}
const now = Date.now();
if (Adapter?.checkCompletion) {
try {
if (now - this._lastApiCheck >= CFG.API_CHECK_INTERVAL_MS) {
this._lastApiCheck = now;
const prog = await Adapter.checkCompletion();
this._lastTaskRecheck = now;
if (this._isApiProgress(prog)) {
UI.updateStatusBar(st.course, prog);
this._lastProgress = prog;
if (this._isCourseDoneProgress(prog)) {
this._finishCurrentCourse(st, '课程完成');
return;
}
if (prog.total > 0 && prog.done - this._lastReportedDone >= CFG.PROGRESS_REPORT_STEP) {
log(`进度: ${prog.done}/${prog.total} (${formatProgressPercent(prog.percent)}%)`);
this._lastReportedDone = prog.done;
}
}
}
if (now - this._lastTaskRecheck >= CFG.TASK_RECHECK_MS) {
this._lastTaskRecheck = now;
const prog2 = await Adapter.checkCompletion();
if (this._isApiProgress(prog2) && this._isCourseDoneProgress(prog2)) {
this._finishCurrentCourse(st, '复查确认课程完成');
return;
}
}
} catch (_) { /* 检测异常,下次重试 */ }
}
if (now - this._lastProgressLog >= CFG.PROGRESS_LOG_MS) {
this._lastProgressLog = now;
const prog = this._lastProgress;
if (prog && prog.total > 0) {
log(`📊 [${st.course.name}] 进度: ${prog.done}/${prog.total} (${formatProgressPercent(prog.percent)}%) [来源:${progressSourceLabel(prog)}]`);
} else {
log(`📊 [${st.course.name}] 等待API进度...`);
}
}
if (now - this._lastHeartbeat >= CFG.HEARTBEAT_MS) {
this._lastHeartbeat = now;
const elapsed = (now - this._monitorStart) / 60000;
log(`💓 学习心跳: ${st.course.name},已运行: ${formatMinutes(elapsed)}`);
}
} finally {
this._checkRunning = false;
}
};
setTimeout(() => { check(); }, CFG.PAGE_LOAD_MS);
this._monitorTimer = setInterval(check, CFG.MONITOR_MS);
},
_navigateNext() {
const next = Queue.getNextPending();
if (!next) {
const st = loadState();
if (st) {
const totalMs = Date.now() - (st.startedAt || Date.now());
const mins = totalMs / 60000;
clearState();
UI.showAllDone(formatMinutes(mins));
try { GM_notification({ title: '木金课程队列', text: '全部课程已完成!', timeout: CFG.NOTIFY_TIMEOUT }); }
catch (_) { /* 通知失败 */ }
}
return;
}
const st = loadState();
const courseIdx = (st ? st.courseIdx || 0 : 0) + 1;
const targetUrl = buildCourseUrl(next.url);
saveState({
running: true,
platform: PLATFORM,
course: next,
courseIdx: courseIdx,
phase: 'switching',
entered: false,
targetUrl,
startedAt: st ? st.startedAt : Date.now(),
});
log(`切换到: ${next.name} (第${courseIdx + 1}门)`);
UI.showTransition(next, () => {
location.href = targetUrl;
}, () => {
log('已暂停自动切课');
});
},
async onPageLoad() {
const st = loadState();
if (!st?.running) return;
if (isWrongCoursePage(location.href, st.targetUrl || st.course?.url)) {
log('页面课程与队列当前课程不一致,跳转到队列课程: ' + st.course.name);
location.href = st.targetUrl || buildCourseUrl(st.course.url);
return;
}
const pageType = PageDetector.detect();
log(`页面类型: ${pageType}, 阶段: ${st.phase}`);
if (pageType === 'courseDetail') {
UI.createStatusBar(st.course, null);
if (PLATFORM === 'chaoxing' && Adapter?.switchToNewVersion) {
Adapter.switchToNewVersion();
await sleep(CFG.POPUP_MS);
}
await sleep(CFG.PAGE_LOAD_MS);
clickChapterTab();
await sleep(CFG.CHAPTER_EXPAND_MS);
if (PLATFORM === 'chaoxing' && Adapter?.navigateFromCourseDetailToStudy) {
const navigated = await Adapter.navigateFromCourseDetailToStudy();
if (navigated) return;
}
if (st.phase === 'navigate' || !st.entered) {
await this._enterRandomTask();
}
} else if (pageType === 'taskPage') {
UI.createStatusBar(st.course, null);
if (st.phase === 'click_task' || st.entered) {
await sleep(CFG.PAGE_LOAD_MS);
st.phase = 'monitor';
saveState(st);
this._startMonitoring();
} else if (st.phase === 'navigate' || !st.entered) {
await sleep(CFG.PAGE_LOAD_MS);
st.phase = 'monitor';
st.entered = true;
saveState(st);
this._startMonitoring();
}
} else if (pageType === 'courseList') {
if (st.course) {
log('回到课程列表,继续进入当前队列课程');
await sleep(CFG.SWITCH_MS);
location.href = buildCourseUrl(st.course.url);
}
} else {
UI.createStatusBar(st.course, null);
if (st.phase === 'navigate' || !st.entered) {
log('页面类型未知但正在运行,等待后重试...');
await sleep(CFG.PAGE_LOAD_MS);
await this._enterRandomTask();
}
}
},
};
const UI = {
_panelEl: null,
_statusBarEl: null,
_examBarEl: null,
_transitionEl: null,
_courseListEl: null,
_fetchedCourses: [],
_fetchedExams: [],
_courseCheckboxes: [],
_dragCleanup: null,
_examScanStatus: null,
_examScanController: null,
_cleanupAllPanels() {
try {
if (this._dragCleanup) { this._dragCleanup(); this._dragCleanup = null; }
for (const doc of getAllUiDocs()) {
doc.querySelectorAll('.mjn3-panel, #mjn3-panel, [data-mjn3-ui="panel"]').forEach(el => el.remove());
doc.querySelectorAll('.mjn3-bar, #mjn3-bar, #mjn3-status-bar, [data-mjn3-ui="status"]').forEach(el => el.remove());
doc.querySelectorAll('.mjn3-overlay, #mjn3-transition-overlay, [data-mjn3-ui="overlay"]').forEach(el => el.remove());
doc.querySelectorAll('.mjn3-toast, .mjn3-task-toast, [data-mjn3-ui="toast"]').forEach(el => el.remove());
doc.querySelectorAll('style[data-mjn3-ui="style"]').forEach(el => el.remove());
}
} catch (_) { /* 清理异常 */ }
},
injectStyles() {
injectCss(`
.mjn3-panel {
position:fixed;top:72px;right:14px;z-index:999999;width:330px;max-width:calc(100vw - 18px);
max-height:72vh;background:#fff;border-radius:10px;box-shadow:0 8px 26px rgba(0,0,0,.14);
overflow-y:auto;font-family:"Microsoft YaHei","PingFang SC",sans-serif;font-size:13px;
pointer-events:auto !important;user-select:auto !important;
}
.mjn3-panel * { box-sizing:border-box;pointer-events:auto !important;user-select:auto !important; }
.mjn3-drag-capture {
position:fixed;inset:0;z-index:999998;background:transparent;cursor:grabbing;
pointer-events:auto !important;user-select:none !important;
}
.mjn3-header {
background:linear-gradient(135deg,#6366F1,#8B5CF6);color:#fff;
padding:8px 10px;display:flex;align-items:center;justify-content:space-between;gap:6px;
cursor:move;user-select:none;
}
.mjn3-header h3 { margin:0;font-size:13px;font-weight:700;pointer-events:none;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; }
.mjn3-header-actions { display:flex;align-items:center;gap:5px;flex-shrink:0; }
.mjn3-home-btn {
height:24px;border:none;border-radius:5px;background:rgba(255,255,255,.22);
color:#fff;font-size:11px;font-weight:600;cursor:pointer;padding:0 6px;
white-space:nowrap;transition:background .15s;
}
.mjn3-home-btn:hover { background:rgba(255,255,255,.36); }
.mjn3-collapse-btn {
width:24px;height:24px;border:none;border-radius:5px;background:rgba(255,255,255,.2);
color:#fff;font-size:12px;cursor:pointer;display:flex;align-items:center;justify-content:center;
transition:background .15s;flex-shrink:0;margin-left:4px;
}
.mjn3-collapse-btn:hover { background:rgba(255,255,255,.35); }
.mjn3-panel.collapsed .mjn3-section,
.mjn3-panel.collapsed .mjn3-footer { display:none; }
.mjn3-panel.collapsed { width:auto;min-width:168px; }
.mjn3-btn-stop {
flex:1;padding:7px 10px;border:none;border-radius:7px;
background:linear-gradient(135deg,#ef4444,#dc2626);color:#fff;
font-size:13px;font-weight:600;cursor:pointer;transition:opacity .2s;
}
.mjn3-btn-stop:hover { opacity:.9; }
.mjn3-badge { background:rgba(255,255,255,.2);border-radius:20px;padding:1px 8px;font-size:11px; }
.mjn3-section { border-bottom:1px solid #f0f0f0; }
.mjn3-section-header {
padding:7px 10px;display:flex;align-items:center;justify-content:space-between;
background:#fafbff;font-weight:600;font-size:12px;color:#555;
}
.mjn3-section-body { max-height:145px;overflow-y:auto;padding:3px 6px; }
.mjn3-section-body.empty { padding:12px;text-align:center;color:#999;font-size:12px; }
.mjn3-course-item {
display:flex;align-items:center;padding:4px 6px;border-radius:5px;
margin-bottom:1px;transition:background .15s;cursor:pointer;
}
.mjn3-course-item:hover { background:#f0f2ff; }
.mjn3-course-item input[type="checkbox"] {
appearance:none !important;-webkit-appearance:none !important;box-sizing:border-box !important;
margin:0 6px 0 0;border:2px solid #a5b4fc;border-radius:4px;background:#fff;
width:14px !important;height:14px !important;flex-shrink:0;
display:inline-block !important;opacity:1 !important;visibility:visible !important;
position:relative !important;pointer-events:auto !important;cursor:pointer !important;
}
.mjn3-course-item input[type="checkbox"]:checked {
background:#6366F1;border-color:#6366F1;
}
.mjn3-course-item input[type="checkbox"]:checked::after {
content:'';position:absolute;left:3px;top:0;width:4px;height:8px;
border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg);
}
.mjn3-course-item label {
flex:1;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
cursor:pointer;
}
.mjn3-course-tag {
margin-left:5px;font-size:10px;color:#22c55e;white-space:nowrap;flex-shrink:0;
}
.mjn3-btn-row { display:flex;gap:5px;padding:5px 8px;flex-wrap:wrap; }
.mjn3-sbtn {
padding:4px 8px;border:1px solid #ddd;border-radius:5px;
background:#fff;color:#555;font-size:11px;cursor:pointer;transition:.15s;
}
.mjn3-sbtn:hover { border-color:#6366F1;color:#6366F1; }
.mjn3-sbtn:disabled { opacity:.5;cursor:not-allowed; }
.mjn3-sbtn.primary { background:#6366F1;color:#fff;border-color:#6366F1; }
.mjn3-sbtn.primary:hover { background:#5558e6; }
.mjn3-sbtn.danger { background:#fee2e2;color:#dc2626;border-color:#fecaca; }
.mjn3-sbtn.danger:hover { background:#fecaca; }
.mjn3-item {
display:flex;align-items:center;padding:5px 8px;border-radius:6px;
margin-bottom:2px;background:#f8f9ff;transition:background .2s;
}
.mjn3-item:hover { background:#eef0ff; }
.mjn3-item.done { background:#f0fdf4;opacity:.8; }
.mjn3-item.skipped { background:#fff7ed;opacity:.85; }
.mjn3-idx {
width:20px;height:20px;border-radius:50%;background:#6366F1;color:#fff;
font-size:11px;display:flex;align-items:center;justify-content:center;
margin-right:6px;flex-shrink:0;
}
.mjn3-item.done .mjn3-idx { background:#22c55e; }
.mjn3-item.skipped .mjn3-idx { background:#f59e0b; }
.mjn3-info { flex:1;min-width:0; }
.mjn3-name { font-size:11px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis; }
.mjn3-meta { font-size:10px;color:#888;margin-top:1px; }
.mjn3-btn {
width:21px;height:21px;border:none;border-radius:5px;cursor:pointer;
font-size:11px;display:flex;align-items:center;justify-content:center;transition:.15s;
}
.mjn3-btn-rm { background:#fee2e2;color:#dc2626; }
.mjn3-btn-rm:hover { background:#fecaca; }
.mjn3-btn-up,.mjn3-btn-down { background:#e0e7ff;color:#6366F1; }
.mjn3-btn-up:hover,.mjn3-btn-down:hover { background:#c7d2fe; }
.mjn3-footer { padding:7px 8px;border-top:1px solid #f0f0f0;display:flex;gap:6px; }
.mjn3-btn-start {
flex:1;padding:7px 10px;border:none;border-radius:7px;
background:linear-gradient(135deg,#6366F1,#8B5CF6);color:#fff;
font-size:13px;font-weight:600;cursor:pointer;transition:opacity .2s;
}
.mjn3-btn-start:hover { opacity:.9; }
.mjn3-btn-start:disabled { opacity:.5;cursor:not-allowed; }
.mjn3-btn-sec {
padding:7px 10px;border:1px solid #ddd;border-radius:7px;
background:#fff;color:#666;font-size:12px;cursor:pointer;transition:.2s;
}
.mjn3-btn-sec:hover { border-color:#999;color:#333; }
.mjn3-bar {
position:fixed;bottom:0;left:0;right:0;z-index:999999;
background:linear-gradient(135deg,#6366F1,#8B5CF6);color:#fff;
padding:10px 20px;display:flex;align-items:center;justify-content:space-between;
font-family:"Microsoft YaHei","PingFang SC",sans-serif;font-size:13px;
}
.mjn3-bar .mjn3-pbar {
width:150px;height:6px;background:rgba(255,255,255,.25);border-radius:3px;overflow:hidden;
}
.mjn3-bar .mjn3-pfill { height:100%;background:#fff;border-radius:3px;transition:width .3s; }
.mjn3-bar .mjn3-skip {
background:rgba(255,255,255,.2);color:#fff;border:1px solid rgba(255,255,255,.3);
padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;
}
.mjn3-bar .mjn3-skip:hover { background:rgba(255,255,255,.35); }
.mjn3-overlay {
position:fixed;inset:0;z-index:9999999;background:rgba(0,0,0,.5);
display:flex;align-items:center;justify-content:center;
font-family:"Microsoft YaHei","PingFang SC",sans-serif;
}
.mjn3-dialog {
background:#fff;border-radius:16px;padding:32px;width:420px;text-align:center;
box-shadow:0 16px 48px rgba(0,0,0,.2);
}
.mjn3-dialog .mjn3-icon {
width:64px;height:64px;border-radius:50%;
background:linear-gradient(135deg,#22c55e,#16a34a);
display:flex;align-items:center;justify-content:center;
margin:0 auto 20px;font-size:32px;color:#fff;
}
.mjn3-dialog .mjn3-icon.warn { background:linear-gradient(135deg,#f59e0b,#d97706); }
.mjn3-dialog h2 { margin:0 0 8px;font-size:20px;color:#333; }
.mjn3-dialog .mjn3-course { color:#6366F1;font-weight:600; }
.mjn3-dialog .mjn3-sub { font-size:14px;color:#888;margin-bottom:16px; }
.mjn3-dialog .mjn3-cd { font-size:13px;color:#999;margin-bottom:20px; }
.mjn3-dialog .mjn3-cd span { font-weight:700;color:#6366F1; }
.mjn3-dialog .mjn3-btns { display:flex;gap:12px;justify-content:center; }
.mjn3-dialog .mjn3-btns .mjn3-btn-start { flex:0 1 auto;min-width:110px; }
.mjn3-toast {
position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:99999999;
background:#333;color:#fff;padding:10px 24px;border-radius:8px;
font-size:14px;font-family:"Microsoft YaHei",sans-serif;
box-shadow:0 4px 16px rgba(0,0,0,.2);
}
.mjn3-task-toast {
position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:99999999;
background:rgba(0,0,0,.8);color:#fff;padding:16px 32px;border-radius:12px;
font-size:16px;font-family:"Microsoft YaHei",sans-serif;text-align:center;
box-shadow:0 8px 32px rgba(0,0,0,.3);transition:opacity .3s;
}
.mjn3-update-notice {
position:fixed;top:82px;right:360px;z-index:99999998;width:300px;max-width:calc(100vw - 24px);
background:#fff;border:1px solid #c7d2fe;border-radius:12px;box-shadow:0 12px 32px rgba(15,23,42,.18);
font-family:"Microsoft YaHei","PingFang SC",sans-serif;color:#1f2937;overflow:hidden;
}
.mjn3-update-head {
background:linear-gradient(135deg,#0ea5e9,#14b8a6);color:#fff;padding:9px 12px;
display:flex;align-items:center;justify-content:space-between;gap:8px;font-weight:700;font-size:13px;
}
.mjn3-update-close {
width:22px;height:22px;border:none;border-radius:5px;background:rgba(255,255,255,.2);color:#fff;
cursor:pointer;font-size:14px;line-height:1;
}
.mjn3-update-body { padding:12px;font-size:12px;line-height:1.6; }
.mjn3-update-version { color:#0f766e;font-weight:700; }
.mjn3-update-actions { display:flex;gap:8px;padding:0 12px 12px; }
.mjn3-update-primary,.mjn3-update-secondary {
flex:1;border:none;border-radius:7px;padding:7px 8px;font-size:12px;font-weight:700;cursor:pointer;
}
.mjn3-update-primary { background:#0ea5e9;color:#fff; }
.mjn3-update-primary:hover { background:#0284c7; }
.mjn3-update-secondary { background:#f1f5f9;color:#475569; }
.mjn3-update-secondary:hover { background:#e2e8f0; }
@media (max-width:760px) {
.mjn3-update-notice { right:12px;left:12px;top:74px;width:auto; }
}
.mjn3-loading {
display:inline-block;width:14px;height:14px;border:2px solid rgba(99,102,241,.3);
border-top-color:#6366F1;border-radius:50%;animation:mjn3-spin .6s linear infinite;
margin-right:6px;vertical-align:middle;
}
@keyframes mjn3-spin { to { transform:rotate(360deg); } }
`);
},
escapeHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
},
createPanel() {
this._cleanupAllPanels();
this._panelEl = null;
const q = Queue.get();
const prog = Queue.progress();
const pendingCount = q.filter(c => !c.completed).length;
const st = loadState();
const isRunning = st?.running;
const examQ = ExamQueue.get();
const examProg = ExamQueue.progress();
const examPendingCount = examQ.filter(e => !e.completed).length;
const examSt = loadExamState();
const isExamRunning = examSt?.running;
const panel = document.createElement('div');
panel.className = 'mjn3-panel';
panel.id = 'mjn3-panel';
let courseListHtml = '';
if (this._fetchedCourses.length === 0) {
courseListHtml = `点击"获取课程"加载课程列表
`;
} else {
courseListHtml = ``;
this._fetchedCourses.forEach((c, i) => {
const inQ = q.find(qc => qc.url === c.url);
const tag = inQ ? `
${inQ.completed ? (inQ.skipped ? '异常跳过' : '已完成过') : '队列中'}` : '';
courseListHtml += `
${tag}
`;
});
courseListHtml += `
`;
}
let examListHtml = '';
if (this._examScanStatus?.running) {
const progress = this._examScanStatus;
const name = progress.course?.name || '当前课程';
const wait = progress.waitMs ? `,下一门约 ${Math.ceil(progress.waitMs / 1000)} 秒后继续` : '';
examListHtml = `
⌛
正在扫描第${progress.index}/${progress.total}门课:${this.escapeHtml(name)}
请耐心等待,脚本正在低速跨课程扫描;扫描完成后再显示考试列表${wait}
`;
} else if (this._examScanStatus?.incomplete) {
const progress = this._examScanStatus;
const errorText = progress.error ? this.escapeHtml(String(progress.error)) : '';
const titleText = errorText
? `扫描异常:${errorText}`
: `已扫描 ${progress.nextIndex || 0}/${progress.total || 0} 门课`;
const tipText = errorText
? '本次扫描已停止,脚本已保留当前进度;请稍后再点获取考试继续低速扫描'
: '跨课程扫描已暂停,再次点击获取考试会继续扫描剩余课程;扫描完成后再显示考试列表';
examListHtml = `
${errorText ? '⚠' : '⏸'}
${titleText}
${tipText}
`;
} else if (this._fetchedExams.length === 0) {
examListHtml = `点击"获取考试"加载考试列表
`;
} else {
examListHtml = ``;
this._fetchedExams.forEach((e, i) => {
const inQ = examQ.find(qe => qe.url === e.url);
const tag = inQ ? `
${inQ.completed ? '已处理' : '考试队列中'}` : '';
examListHtml += `
${tag}
`;
});
examListHtml += `
`;
}
let examQueueHtml = '';
if (examQ.length === 0) {
examQueueHtml = ``;
} else {
examQueueHtml = ``;
examQ.forEach((e, i) => {
const doneCls = e.completed ? ' done' : '';
const idxLabel = e.completed ? '✓' : String(i + 1);
const upBtn = i > 0 ? `
` : '';
const dnBtn = i < examQ.length - 1 ? `
` : '';
const meta = e.completed
? `已处理 · ${new Date(e.completedAt).toLocaleString('zh-CN')}`
: `添加于 · ${new Date(e.addedAt).toLocaleString('zh-CN')}`;
examQueueHtml += `
${idxLabel}
${this.escapeHtml(e.name)}
${meta}
${upBtn}${dnBtn}
`;
});
examQueueHtml += `
`;
}
let queueHtml = '';
if (q.length === 0) {
queueHtml = ``;
} else {
queueHtml = ``;
q.forEach((c, i) => {
const doneCls = c.completed ? (c.skipped ? ' skipped' : ' done') : '';
const idxLabel = c.completed ? (c.skipped ? '!' : '✓') : String(i + 1);
const upBtn = i > 0 ? `
` : '';
const dnBtn = i < q.length - 1 ? `
` : '';
const meta = c.completed
? (c.skipped
? `异常跳过 · ${new Date(c.skippedAt || c.completedAt).toLocaleString('zh-CN')}${c.skipReason ? ' · ' + this.escapeHtml(c.skipReason) : ''}`
: `已完成 · ${new Date(c.completedAt).toLocaleString('zh-CN')}`)
: `添加于 · ${new Date(c.addedAt).toLocaleString('zh-CN')}`;
queueHtml += `
${idxLabel}
${this.escapeHtml(c.name)}
${meta}
${upBtn}${dnBtn}
`;
});
queueHtml += `
`;
}
panel.innerHTML = `
${courseListHtml}
${queueHtml}
${examListHtml}
${this._examScanStatus?.running ? '' : ''}
${examQueueHtml}
`;
panel.querySelectorAll('[data-a]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const a = b.dataset.a;
const i = parseInt(b.dataset.i, 10);
if (isNaN(i)) return;
if (a === 'rm') Queue.remove(i);
else if (a === 'up') Queue.move(i, i - 1);
else if (a === 'down') Queue.move(i, i + 1);
this.createPanel();
});
});
panel.querySelectorAll('[data-ea]').forEach(b => {
b.addEventListener('click', e => {
e.stopPropagation();
const a = b.dataset.ea;
const i = parseInt(b.dataset.i, 10);
const exams = ExamQueue.get();
if (isNaN(i) || i < 0 || i >= exams.length) return;
if (a === 'rm') {
ExamQueue.remove(i);
this.createPanel();
} else if (a === 'up') {
ExamQueue.move(i, i - 1);
this.createPanel();
} else if (a === 'down') {
ExamQueue.move(i, i + 1);
this.createPanel();
} else if (a === 'open') {
const exam = exams[i];
saveExamState({ running: true, platform: PLATFORM, exam, startedAt: Date.now() });
location.href = exam.url;
}
});
});
const fetchBtn = panel.querySelector('#mjn3-fetch');
if (fetchBtn) fetchBtn.addEventListener('click', () => this._fetchCourses());
const selectAllBtn = panel.querySelector('#mjn3-select-all');
if (selectAllBtn) selectAllBtn.addEventListener('click', () => this._toggleSelectAll());
const addSelectedBtn = panel.querySelector('#mjn3-add-selected');
if (addSelectedBtn) addSelectedBtn.addEventListener('click', () => this._addSelectedToQueue());
const courseHomeBtn = panel.querySelector('#mjn3-course-home');
if (courseHomeBtn) courseHomeBtn.addEventListener('click', e => {
e.stopPropagation();
this.goCourseHome();
});
const helpBtn = panel.querySelector('#mjn3-help');
if (helpBtn) helpBtn.addEventListener('click', e => {
e.preventDefault();
e.stopPropagation();
const opened = window.open('https://wk.bobo91.com/', '_blank', 'noopener,noreferrer');
if (opened) {
try { opened.opener = null; } catch (_) { /* ignore */ }
}
});
const fetchExamsBtn = panel.querySelector('#mjn3-fetch-exams');
if (fetchExamsBtn) fetchExamsBtn.addEventListener('click', () => this._fetchExams());
const pauseExamScanBtn = panel.querySelector('#mjn3-pause-exam-scan');
if (pauseExamScanBtn) pauseExamScanBtn.addEventListener('click', () => {
if (this._examScanController) this._examScanController.paused = true;
this.toast('已请求暂停跨课程考试扫描,当前课程处理完后停止');
});
const examSelectAllBtn = panel.querySelector('#mjn3-exam-select-all');
if (examSelectAllBtn) examSelectAllBtn.addEventListener('click', () => this._toggleSelectAllExams());
const addSelectedExamsBtn = panel.querySelector('#mjn3-add-selected-exams');
if (addSelectedExamsBtn) addSelectedExamsBtn.addEventListener('click', () => this._addSelectedToExamQueue());
const examOpenNextBtn = panel.querySelector('#mjn3-exam-open-next');
if (examOpenNextBtn) examOpenNextBtn.addEventListener('click', () => ExamRunner.openNext());
const examMarkNextBtn = panel.querySelector('#mjn3-exam-mark-next');
if (examMarkNextBtn) examMarkNextBtn.addEventListener('click', () => {
if (confirm('确认当前考试已由你手动完成,并打开下一场?')) ExamRunner.markCurrentCompleted(true);
});
const examStopBtn = panel.querySelector('#mjn3-exam-stop');
if (examStopBtn) examStopBtn.addEventListener('click', () => { ExamRunner.stop(); this.createPanel(); });
const examClearBtn = panel.querySelector('#mjn3-exam-clear');
if (examClearBtn) examClearBtn.addEventListener('click', () => {
if (confirm('确定清空考试队列?已处理记录也会清除。')) { ExamQueue.clear(); this._fetchedExams = []; resetExamScanProgress(); this._examScanStatus = null; this.createPanel(); }
});
const startStopBtn = panel.querySelector('#mjn3-start-stop');
if (startStopBtn) {
const curSt = loadState();
if (curSt?.running) {
startStopBtn.addEventListener('click', () => {
if (confirm('确定停止当前学习?')) { Scheduler.stop(); this.createPanel(); }
});
} else {
startStopBtn.addEventListener('click', () => { Scheduler.start(); this.createPanel(); });
}
}
const collapseBtn = panel.querySelector('#mjn3-collapse');
if (collapseBtn) {
collapseBtn.addEventListener('click', (e) => {
e.stopPropagation();
panel.classList.toggle('collapsed');
collapseBtn.textContent = panel.classList.contains('collapsed') ? '▶' : '▼';
});
}
const clearBtn = panel.querySelector('#mjn3-clear');
if (clearBtn) {
clearBtn.addEventListener('click', () => {
if (confirm('确定清空课程队列?已完成课程记录也会清除。')) { Scheduler.stop(); Queue.clear(); this._fetchedCourses = []; this.createPanel(); }
});
}
document.body?.appendChild(panel);
this._panelEl = panel;
if (this._dragCleanup) { this._dragCleanup(); this._dragCleanup = null; }
const header = panel.querySelector('.mjn3-header');
function getClientPos(e) {
if (e.touches?.length > 0) return { x: e.touches[0].clientX, y: e.touches[0].clientY };
if (e.changedTouches?.length > 0) return { x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY };
return { x: e.clientX, y: e.clientY };
}
let mouseMoveHandler = null;
let mouseUpHandler = null;
let touchMoveHandler = null;
let touchEndHandler = null;
let touchCancelHandler = null;
let blurHandler = null;
let dragHoldTimer = null;
let dragHoldEndHandler = null;
let dragCaptureEl = null;
let pendingDrag = null;
function removeDragCapture() {
if (dragCaptureEl) {
dragCaptureEl.remove();
dragCaptureEl = null;
}
}
function clearDragHoldEndListeners() {
if (!dragHoldEndHandler) return;
document.removeEventListener('mouseup', dragHoldEndHandler);
document.removeEventListener('touchend', dragHoldEndHandler);
document.removeEventListener('touchcancel', dragHoldEndHandler);
dragHoldEndHandler = null;
}
function cancelDragHold() {
if (dragHoldTimer) {
clearTimeout(dragHoldTimer);
dragHoldTimer = null;
}
clearDragHoldEndListeners();
pendingDrag = null;
if (!mouseUpHandler) removeDragCapture();
}
function stopPanelDrag() {
panel.style.transition = '';
panel.style.cursor = '';
if (header) header.style.cursor = '';
document.removeEventListener('mousemove', mouseMoveHandler);
document.removeEventListener('mouseup', mouseUpHandler);
document.removeEventListener('touchmove', touchMoveHandler);
document.removeEventListener('touchend', touchEndHandler);
document.removeEventListener('touchcancel', touchCancelHandler);
window.removeEventListener('blur', blurHandler);
mouseMoveHandler = null;
mouseUpHandler = null;
touchMoveHandler = null;
touchEndHandler = null;
touchCancelHandler = null;
blurHandler = null;
removeDragCapture();
}
function startPanelDrag() {
const drag = pendingDrag;
cancelDragHold();
if (!drag) return;
dragCaptureEl = document.createElement('div');
dragCaptureEl.className = 'mjn3-drag-capture';
dragCaptureEl.dataset.mjn3Ui = 'drag-capture';
document.body?.appendChild(dragCaptureEl);
panel.style.transition = 'none';
panel.style.cursor = 'grabbing';
if (header) header.style.cursor = 'grabbing';
mouseMoveHandler = function (ev) {
ev.preventDefault();
const p = getClientPos(ev);
let newLeft = p.x - drag.offsetX;
let newTop = p.y - drag.offsetY;
const maxLeft = window.innerWidth - panel.offsetWidth;
const maxTop = window.innerHeight - panel.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxLeft));
newTop = Math.max(0, Math.min(newTop, maxTop));
panel.style.left = newLeft + 'px';
panel.style.top = newTop + 'px';
panel.style.right = 'auto';
};
mouseUpHandler = function (ev) {
ev?.preventDefault?.();
stopPanelDrag();
};
touchMoveHandler = function (ev) {
if (mouseMoveHandler) mouseMoveHandler(ev);
};
touchEndHandler = function (ev) {
if (mouseUpHandler) mouseUpHandler(ev);
};
touchCancelHandler = function (ev) {
if (mouseUpHandler) mouseUpHandler(ev);
};
blurHandler = function () {
stopPanelDrag();
};
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
document.addEventListener('touchmove', touchMoveHandler, { passive: false });
document.addEventListener('touchend', touchEndHandler);
document.addEventListener('touchcancel', touchCancelHandler);
window.addEventListener('blur', blurHandler);
mouseMoveHandler(drag.startEvent);
}
function onDragHoldStart(e) {
if (e.target.closest('button')) return;
if (e.type === 'mousedown' && e.button !== 0) return;
cancelDragHold();
const pos = getClientPos(e);
const rect = panel.getBoundingClientRect();
pendingDrag = {
offsetX: pos.x - rect.left,
offsetY: pos.y - rect.top,
startEvent: e,
};
dragCaptureEl = document.createElement('div');
dragCaptureEl.className = 'mjn3-drag-capture';
dragCaptureEl.dataset.mjn3Ui = 'drag-capture';
document.body?.appendChild(dragCaptureEl);
dragHoldEndHandler = function () {
cancelDragHold();
};
document.addEventListener('mouseup', dragHoldEndHandler);
document.addEventListener('touchend', dragHoldEndHandler);
document.addEventListener('touchcancel', dragHoldEndHandler);
dragHoldTimer = setTimeout(startPanelDrag, CFG.DRAG_HOLD_MS);
e.preventDefault();
}
if (header) {
header.addEventListener('mousedown', onDragHoldStart);
header.addEventListener('touchstart', onDragHoldStart, { passive: false });
}
this._dragCleanup = () => {
cancelDragHold();
if (mouseUpHandler) mouseUpHandler();
if (mouseMoveHandler) document.removeEventListener('mousemove', mouseMoveHandler);
if (mouseUpHandler) document.removeEventListener('mouseup', mouseUpHandler);
if (touchMoveHandler) document.removeEventListener('touchmove', touchMoveHandler);
if (touchEndHandler) document.removeEventListener('touchend', touchEndHandler);
if (touchCancelHandler) document.removeEventListener('touchcancel', touchCancelHandler);
if (blurHandler) window.removeEventListener('blur', blurHandler);
removeDragCapture();
if (header) {
header.removeEventListener('mousedown', onDragHoldStart);
header.removeEventListener('touchstart', onDragHoldStart);
}
};
panel.querySelectorAll('.mjn3-course-item input[type="checkbox"]').forEach(cb => {
cb.addEventListener('click', e => e.stopPropagation());
cb.addEventListener('mousedown', e => e.stopPropagation());
});
panel.querySelectorAll('.mjn3-course-item').forEach(item => {
item.addEventListener('click', e => {
e.stopPropagation();
if (e.target?.matches?.('input[type="checkbox"], label')) return;
const cb = item.querySelector('input[type="checkbox"]');
if (cb) cb.checked = !cb.checked;
});
});
},
goCourseHome() {
if (Adapter?.goCourseHome) {
Adapter.goCourseHome();
return;
}
const saved = loadCourseHomeUrl();
if (saved && !isBadCourseHomeUrl(saved)) {
location.href = saved;
return;
}
if (PLATFORM === 'chaoxing') {
location.href = getDefaultAccountHomeUrl() || 'https://i.mooc.chaoxing.com/space/index';
return;
}
history.back();
},
async _fetchCourses() {
if (!Adapter) { this.toast('当前平台无适配器'); return; }
const fetchBtn = this._panelEl?.querySelector('#mjn3-fetch') ?? null;
if (fetchBtn) {
fetchBtn.disabled = true;
fetchBtn.innerHTML = '获取中...';
}
try {
let courses = [];
if (Adapter.fetchCoursesViaAPI) {
courses = await Adapter.fetchCoursesViaAPI();
}
if (!courses || courses.length === 0) {
courses = Adapter.parseCourseList ? Adapter.parseCourseList() : [];
}
if (!courses || courses.length === 0) {
if (PLATFORM === 'chaoxing') {
this.toast('正在跳转到课程列表页获取...');
requestAutoFetchCourses();
location.href = Adapter.getCourseListUrl();
return;
}
}
this._fetchedCourses = courses || [];
this.toast(`获取到 ${this._fetchedCourses.length} 门课程`);
this.createPanel();
} catch (e) {
this.toast('获取课程失败: ' + e.message);
if (fetchBtn) { fetchBtn.disabled = false; fetchBtn.textContent = '获取课程'; }
}
},
_toggleSelectAll() {
const checkboxes = this._panelEl?.querySelectorAll('.mjn3-course-item:not(.mjn3-exam-item) input[type="checkbox"]') ?? [];
if (checkboxes.length === 0) return;
const allChecked = [...checkboxes].every(cb => cb.checked);
checkboxes.forEach(cb => { cb.checked = !allChecked; });
},
_addSelectedToQueue() {
const checkboxes = this._panelEl?.querySelectorAll('.mjn3-course-item:not(.mjn3-exam-item) input[type="checkbox"]') ?? [];
let addedCount = 0;
checkboxes.forEach(cb => {
if (cb.checked) {
const i = parseInt(cb.dataset.i, 10);
if (isNaN(i) || i < 0 || i >= this._fetchedCourses.length) return;
const course = this._fetchedCourses[i];
if (Queue.add(course)) addedCount++;
}
});
if (addedCount > 0) {
this.toast(`已添加 ${addedCount} 门课程到队列`);
} else {
this.toast('没有新课程可添加(可能已在队列中)');
}
this.createPanel();
},
async _fetchExams() {
if (!Adapter) { this.toast('当前平台暂无适配器'); return; }
if (loadState()?.running) { this.toast('正在学习中,暂不执行跨课程考试扫描'); return; }
const btn = this._panelEl?.querySelector('#mjn3-fetch-exams') ?? null;
if (btn) {
btn.disabled = true;
btn.innerHTML = '获取中...';
}
const controller = { paused: false };
this._examScanController = controller;
this._examScanStatus = { running: true, index: 0, total: 0, course: null };
this._fetchedExams = [];
this.createPanel();
try {
let exams = [];
if (Adapter.fetchExamsViaAPI) {
exams = await Adapter.fetchExamsViaAPI({
controller,
onProgress: progress => {
this._examScanStatus = { running: !progress.paused, ...progress };
this.createPanel();
},
});
}
if ((!exams || exams.length === 0) && Adapter.parseExamList) {
exams = Adapter.parseExamList(document, location.href);
}
const scan = Adapter._lastExamScan;
if (scan?.completed || !scan?.incomplete) {
this._examScanStatus = null;
this._fetchedExams = exams || [];
this.toast(`全部课程扫描完毕,获取到 ${this._fetchedExams.length} 场考试`);
} else {
this._examScanStatus = {
running: false,
incomplete: true,
paused: !!scan.paused || controller.paused,
nextIndex: scan.nextIndex || 0,
total: scan.total || 0,
};
this._fetchedExams = [];
this.toast(`已扫描 ${this._examScanStatus.nextIndex}/${this._examScanStatus.total} 门课,请继续扫描剩余课程`);
}
this.createPanel();
} catch (e) {
const scan = Adapter?._lastExamScan || {};
const persisted = loadExamScanState() || {};
const nextIndex = Number(scan.nextIndex ?? persisted.nextIndex ?? 0) || 0;
const total = Number(scan.total ?? persisted.total ?? 0) || 0;
const message = e?.message || String(e);
log(`获取考试失败: ${message}`, 'warn');
if (e?.stack) log(e.stack, 'warn');
this._examScanStatus = {
running: false,
incomplete: true,
error: message,
paused: e instanceof ChaoxingAntiSpiderError,
nextIndex,
total,
};
this.createPanel();
if (e instanceof ChaoxingAntiSpiderError) this.toast('检测到学习通风控,已暂停跨课程扫描');
else this.toast('获取考试失败: ' + message);
if (btn) { btn.disabled = false; btn.textContent = '获取考试'; }
} finally {
if (this._examScanController === controller) this._examScanController = null;
}
},
_toggleSelectAllExams() {
const checkboxes = this._panelEl?.querySelectorAll('.mjn3-exam-item input[type="checkbox"]') ?? [];
if (checkboxes.length === 0) return;
const allChecked = [...checkboxes].every(cb => cb.checked);
checkboxes.forEach(cb => { cb.checked = !allChecked; });
},
_addSelectedToExamQueue() {
const checkboxes = this._panelEl?.querySelectorAll('.mjn3-exam-item input[type="checkbox"]') ?? [];
let addedCount = 0;
checkboxes.forEach(cb => {
if (cb.checked) {
const i = parseInt(cb.dataset.ei, 10);
if (isNaN(i) || i < 0 || i >= this._fetchedExams.length) return;
const exam = this._fetchedExams[i];
if (ExamQueue.add(exam)) addedCount++;
}
});
if (addedCount > 0) {
this.toast(`已添加 ${addedCount} 场考试到队列`);
} else {
this.toast('没有新考试可添加(可能已在考试队列中)');
}
this.createPanel();
},
initCourseButtons() {
if (!Adapter?.parseCourseList) return;
const courses = Adapter.parseCourseList();
const q = Queue.get();
for (const course of courses) {
let target = null;
for (const el of $$('a')) {
if (!el.href) continue;
try {
const elUrl = new URL(el.href);
const cUrl = new URL(course.url);
if (elUrl.pathname === cUrl.pathname && elUrl.search === cUrl.search) { target = el; break; }
} catch (_) { /* URL解析失败 */ }
}
if (!target) continue;
if (target.parentElement?.querySelector('.mjn3-add-btn')) continue;
const inQ = q.some(c => c.url === course.url);
const btn = document.createElement('span');
btn.className = 'mjn3-add-btn';
btn.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;border-radius:50%;border:2px solid #6366F1;background:#fff;color:#6366F1;font-size:16px;font-weight:bold;cursor:pointer;transition:.2s;margin-left:6px;flex-shrink:0;line-height:1;';
btn.textContent = inQ ? '✓' : '+';
btn.title = inQ ? '已在队列中' : '添加到课程队列';
if (inQ) { btn.style.background = '#22c55e'; btn.style.borderColor = '#22c55e'; btn.style.color = '#fff'; }
btn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
const added = Queue.add(course);
if (added) {
btn.textContent = '✓'; btn.style.background = '#22c55e'; btn.style.borderColor = '#22c55e'; btn.style.color = '#fff';
btn.title = '已添加到队列';
this.createPanel();
this.toast('已添加: ' + course.name);
}
});
const wrapper = document.createElement('span');
wrapper.style.display = 'inline-flex';
wrapper.style.alignItems = 'center';
target.parentNode.insertBefore(wrapper, target);
wrapper.appendChild(target);
wrapper.appendChild(btn);
}
},
_removeStatusBars() {
for (const doc of getAllUiDocs()) {
doc.querySelectorAll('.mjn3-bar, #mjn3-bar, #mjn3-status-bar, [data-mjn3-ui="status"]').forEach(el => el.remove());
}
this._statusBarEl = null;
},
_removeExamBars() {
for (const doc of getAllUiDocs()) {
doc.querySelectorAll('.mjn3-exam-bar, #mjn3-exam-bar, [data-mjn3-ui="exam-bar"]').forEach(el => el.remove());
}
this._examBarEl = null;
},
removeExamBar() {
this._removeExamBars();
},
createExamBar(exam) {
this._removeExamBars();
if (!exam || !document.body) return;
const prog = ExamQueue.progress();
const title = exam.name || '当前考试';
const bar = document.createElement('div');
bar.className = 'mjn3-exam-bar';
bar.id = 'mjn3-exam-bar';
bar.dataset.mjn3Ui = 'exam-bar';
bar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:999999;background:linear-gradient(135deg,#4f46e5,#7c3aed);color:#fff;padding:10px 18px;display:flex;align-items:center;justify-content:space-between;gap:14px;font-family:"Microsoft YaHei","PingFang SC",sans-serif;font-size:13px;box-sizing:border-box;box-shadow:0 -8px 24px rgba(0,0,0,.16);';
bar.innerHTML = `
考试入口队列
${this.escapeHtml(title)}
${prog.done}/${prog.total}
`;
const doneBtn = bar.querySelector('#mjn3-exam-done-next');
if (doneBtn) doneBtn.addEventListener('click', e => {
e.stopPropagation();
if (confirm('确认当前考试已经由你手动完成?脚本只会打开下一场考试入口。')) ExamRunner.markCurrentCompleted(true);
});
const panelBtn = bar.querySelector('#mjn3-exam-panel');
if (panelBtn) panelBtn.addEventListener('click', e => {
e.stopPropagation();
this.createPanel();
const st = loadExamState();
if (st?.running && st.exam) this.createExamBar(st.exam);
});
const stopBtn = bar.querySelector('#mjn3-exam-stop-bar');
if (stopBtn) stopBtn.addEventListener('click', e => {
e.stopPropagation();
if (confirm('确定停止考试入口队列?')) {
ExamRunner.stop();
this.createPanel();
}
});
bar.addEventListener('mousedown', e => e.stopPropagation());
bar.addEventListener('click', e => e.stopPropagation());
document.body.appendChild(bar);
this._examBarEl = bar;
},
createStatusBar(course, prog) {
this._removeStatusBars();
if (!course) return;
const hasApiProgress = isApiProgress(prog);
const done = hasApiProgress ? prog.done : 0;
const total = hasApiProgress ? prog.total : 0;
const pct = hasApiProgress ? prog.percent : 0;
const primaryText = hasApiProgress ? `${done}/${total}` : '等待API进度';
const percentText = hasApiProgress ? `${formatProgressPercent(pct)}%` : '--';
const fillPct = hasApiProgress ? clampPercent(pct) : 0;
const qProg = Queue.progress();
const queuePreview = Queue.get().filter(c => !c.completed && c.url !== course.url).slice(0, CFG.QUEUE_PREVIEW_COUNT)
.map(c => c.name.substring(0, CFG.QUEUE_PREVIEW_LEN)).join(' → ');
const bar = document.createElement('div');
bar.className = 'mjn3-bar';
bar.id = 'mjn3-bar';
bar.dataset.mjn3Ui = 'status';
bar.style.cssText = 'position:fixed;bottom:0;left:0;right:0;z-index:999999;background:linear-gradient(135deg,#6366F1,#8B5CF6);color:#fff;padding:10px 20px;display:flex;align-items:center;justify-content:space-between;font-family:"Microsoft YaHei","PingFang SC",sans-serif;font-size:13px;box-sizing:border-box;';
bar.innerHTML = `
📊 ${primaryText}
${percentText}
队列: ${course.name.substring(0, CFG.NAME_TRUNC_LEN)}${course.name.length > CFG.NAME_TRUNC_LEN ? '...' : ''}
${queuePreview ? ' → ' + queuePreview : ''}
(${qProg.done}/${qProg.total})
`;
const skipBtn = bar.querySelector('#mjn3-skip');
if (skipBtn) skipBtn.style.cssText = 'background:rgba(255,255,255,.2);color:#fff;border:1px solid rgba(255,255,255,.3);padding:4px 12px;border-radius:6px;font-size:12px;cursor:pointer;';
if (skipBtn) skipBtn.addEventListener('click', () => {
if (confirm('确定跳过当前课程?')) Scheduler.skip();
});
document.body?.appendChild(bar);
this._statusBarEl = bar;
bar.addEventListener('mousedown', e => e.stopPropagation());
bar.addEventListener('click', e => e.stopPropagation());
},
removeStatusBar() {
this._removeStatusBars();
},
updateStatusBar(course, prog) {
this.createStatusBar(course, prog);
},
showTransition(nextCourse, onGo, onPause) {
if (this._transitionEl) this._transitionEl.remove();
let cd = Math.floor(CFG.SWITCH_MS / 1000);
if (cd < CFG.TRANSITION_MIN_CD) cd = CFG.TRANSITION_MIN_CD;
let paused = false;
const overlay = document.createElement('div');
overlay.className = 'mjn3-overlay';
overlay.innerHTML = `
🎉
课程已完成!
下一门:${this.escapeHtml(nextCourse.name)}
${cd} 秒后自动跳转
`;
document.body?.appendChild(overlay);
this._transitionEl = overlay;
overlay.addEventListener('mousedown', e => e.stopPropagation());
overlay.addEventListener('click', e => e.stopPropagation());
const cdEl = overlay.querySelector('#mjn3-cd');
const timer = setInterval(() => {
if (paused) return;
cd--;
if (cdEl) cdEl.textContent = cd;
if (cd <= 0) { clearInterval(timer); this.closeTransition(); if (onGo) onGo(); }
}, CFG.TRANSITION_CD_MS);
const goBtn = overlay.querySelector('#mjn3-go');
if (goBtn) {
goBtn.addEventListener('click', () => {
clearInterval(timer); this.closeTransition(); if (onGo) onGo();
});
}
const pauseBtn = overlay.querySelector('#mjn3-pause');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
paused = !paused;
pauseBtn.textContent = paused ? '▶ 继续' : '⏸ 暂停';
if (onPause && paused) onPause();
});
}
},
showAllDone(timeStr) {
if (this._transitionEl) this._transitionEl.remove();
const overlay = document.createElement('div');
overlay.className = 'mjn3-overlay';
overlay.id = 'mjn3-overlay';
overlay.innerHTML = `
🏆
全部课程已完成!
总用时:${timeStr || '未知'}
`;
document.body?.appendChild(overlay);
this._transitionEl = overlay;
overlay.addEventListener('mousedown', e => e.stopPropagation());
overlay.addEventListener('click', e => e.stopPropagation());
const doneBtn = overlay.querySelector('#mjn3-done-close');
if (doneBtn) doneBtn.addEventListener('click', () => this.closeTransition());
},
closeTransition() {
if (this._transitionEl) { this._transitionEl.remove(); this._transitionEl = null; }
},
showUpdateNotice(info) {
if (!info || !document.body) return;
document.querySelectorAll('.mjn3-update-notice').forEach(el => el.remove());
const notice = document.createElement('div');
notice.className = 'mjn3-update-notice';
notice.dataset.mjn3Ui = 'update';
notice.innerHTML = `
发现新版本
当前版本 ${this.escapeHtml(info.currentVersion)},
最新版本 ${this.escapeHtml(info.latestVersion)}。建议更新后再继续使用。
`;
const close = () => notice.remove();
const openUpdate = () => {
const opened = window.open(info.url || CFG.UPDATE_URL, '_blank', 'noopener,noreferrer');
if (opened) {
try { opened.opener = null; } catch (_) { /* ignore */ }
} else {
location.href = info.url || CFG.UPDATE_URL;
}
};
notice.querySelector('.mjn3-update-close')?.addEventListener('click', e => {
e.stopPropagation();
close();
});
notice.querySelector('.mjn3-update-primary')?.addEventListener('click', e => {
e.stopPropagation();
openUpdate();
});
notice.querySelector('.mjn3-update-secondary')?.addEventListener('click', e => {
e.stopPropagation();
snoozeUpdatePrompt();
close();
});
notice.addEventListener('mousedown', e => e.stopPropagation());
notice.addEventListener('click', e => e.stopPropagation());
document.body.appendChild(notice);
try { GM_notification({ title: '木金网课助手有新版本', text: `${info.currentVersion} → ${info.latestVersion}`, timeout: CFG.NOTIFY_TIMEOUT }); }
catch (_) { /* 通知失败 */ }
},
showTaskToast(taskName) {
const toast = document.createElement('div');
toast.className = 'mjn3-task-toast';
toast.innerHTML = `🎯 进入任务
${this.escapeHtml(taskName)}`;
document.body?.appendChild(toast);
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), CFG.FADE_OUT_MS); }, CFG.TASK_TOAST_MS);
},
toast(msg) {
const t = document.createElement('div');
t.className = 'mjn3-toast';
t.textContent = msg;
document.body?.appendChild(t);
setTimeout(() => { t.style.opacity = '0'; setTimeout(() => t.remove(), CFG.FADE_OUT_MS); }, CFG.TOAST_DURATION_MS);
},
};
async function main() {
if (PLATFORM === 'unknown') { log('未知平台,脚本不运行', 'warn'); return; }
if (!Adapter) { log(`平台 ${PLATFORM} 暂无适配器`, 'warn'); return; }
if (!isTopFrame) {
log('运行在iframe中,跳过UI创建');
return;
}
UI._cleanupAllPanels();
UI.injectStyles();
setTimeout(() => { checkForScriptUpdate(); }, 1200);
const pageType = PageDetector.detect();
const st = loadState();
const examSt = loadExamState();
if (examSt?.running && examSt.exam) {
UI.createExamBar(examSt.exam);
}
log(`平台: ${PLATFORM}, 页面: ${pageType}, 运行: ${!!(st?.running)}`);
if (pageType === 'courseList') {
UI.createPanel();
setTimeout(() => { UI.initCourseButtons(); }, CFG.COURSE_BTN_DELAY_1);
setTimeout(() => { UI.initCourseButtons(); }, CFG.COURSE_BTN_DELAY_2);
if (consumeAutoFetchCourses()) {
setTimeout(() => { UI._fetchCourses(); }, CFG.AUTO_FETCH_DELAY_MS);
}
if (st?.running) {
log('运行中,恢复当前队列课程...');
await Scheduler.onPageLoad();
}
} else if (pageType === 'courseDetail') {
UI.createPanel();
if (st?.running) {
await Scheduler.onPageLoad();
}
} else if (pageType === 'taskPage') {
if (st?.running) {
UI.createStatusBar(st.course, null);
await Scheduler.onPageLoad();
}
} else {
UI.createPanel();
if (st?.running && (st.phase === 'navigate' || !st.entered)) {
log('页面类型未知但正在运行,等待后重试...');
await sleep(CFG.PAGE_LOAD_MS);
await Scheduler._enterRandomTask();
}
}
}
function runMain() {
main().catch(e => log('启动失败: ' + (e?.message || e), 'error'));
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', runMain);
} else {
runMain();
}
})();