// ==UserScript==
// @name 气象培训平台自动学习
// @namespace cma.meteorology.autolearn
// @version 1.11.27
// @description 自动遍历"我的自选课"未完成课程,跳过已学完课程,进入学习空间逐个学习课件;以课件页 s_point 对勾/completestate 判断完成状态并断点续学;登录超时弹窗绝不点击;会话失效时自动停止跳转;弹窗被拦截时暂停等待用户允许。已集成跨域课件域(kjcourse)辅助:自动关闭欢迎弹窗/遮罩、触发视频播放器并静音倍速播放。单脚本,适配 ScriptCat / Tampermonkey。
// @author you
// @match *://cmatcpx-cmatc.cma.cn/*
// @match *://pxkckj-cmatc.cma.cn/*
// @match *://kjcourse-cmatc.cma.cn/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @run-at document-idle
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 供 setupIframeLoadWatcher 使用的模块级状态(必须在注入分发之前声明,否则 TDZ 报错)
let __iframeLoadWatcherInstalled = false;
let __lastIframeLoadTs = 0;
// CMS 学习窗口复用:同一窗口反复跳转,避免每次 window.open 被浏览器拦截
let sharedLearnWin = null;
// CMS 主循环重入保护:防止"详情页自动启动"与"用户点开始"并发多个循环(导致反复刷新/重复开窗)
let cmsLoopRunning = false;
// 班级详情页"全部刷完"轮次计数(防死循环)
let classRetryCount = 0;
// 手势预开的第一个窗口(多窗口模式把它算作第一个标签,避免 window.open 被拦截)
let preOpenedFirst = null;
/* ===================== 配置区 ===================== */
const CONFIG = {
// 站点
CMS_HOME: 'https://cmatcpx-cmatc.cma.cn/cms/',
CMS_HOST: 'cmatcpx-cmatc.cma.cn',
LEARN_HOST: 'pxkckj-cmatc.cma.cn',
MY_COURSE_HREF: '/u/student/study/myCourseTheme.action',
// 等待超时(毫秒)
WAIT_TIMEOUT: 20000,
// 单个课件学习等待(秒):视频播放/图文停留
COURSEWARE_WATCH_SEC: 35,
// 课件点击后内容加载缓冲
SETTLE_SEC: 1,
// 轮询间隔
POLL_INTERVAL: 500,
// 最多翻页数(防异常)
MAX_PAGES: 200,
// 是否开启调试日志
DEBUG: true,
// 默认静音播放视频
DEFAULT_MUTE: true,
// 默认模式:'normal'=普通(只学未学完课程),'review'=复习(重新学习所有课程,含已学完)
DEFAULT_MODE: 'normal',
// 默认是否启动(false=需手动点开始)
DEFAULT_RUNNING: false,
// 静音观察器扫描间隔
MUTE_SCAN_INTERVAL: 1500,
// 视频完成检测:每多少毫秒轮询一次
VIDEO_POLL_INTERVAL: 1000,
// 视频单课件最长等待(秒),超时强制结束该课件。
// 必须 ≥ 最长的视频时长,否则长视频没播完就超时跳过、永远学不完(平台按累计播放时长判定)
VIDEO_MAX_WAIT_SEC: 7200,
// 每 N 分钟检查一次当前课件是否已被平台标记完成(已完成则提前结束、跳下一个)
COMPLETION_CHECK_MIN: 5,
// 课程未完成时最大重刷次数(0=不重刷,2=最多重刷2遍,共学3遍)
DEFAULT_RETRY: 2,
// CMS 等待学习空间单门课完成的超时(秒),超时自动跳下一门(防止卡死)。
// 必须 ≥ 单门课全部视频播完所需时间,否则一门没学完就去开下一门 → 标签越开越多
CMS_WAIT_LEARN_SEC: 7200,
// 学习空间致命错误后宽限期(秒),过后自动 markDone+关窗以解除 CMS 阻塞
LEARN_GRACE_SEC: 30,
// 课件内容加载后未发现视频的重试次数(每次间隔 2s)
CONTENT_RETRY_TIMES: 4,
// 时间加速:每个课件向服务器提交的额外学习时长(秒),0=不加速
TIME_ACCELERATE_SEC: 1800,
};
/* ===================== 日志 ===================== */
const log = {
info: (...a) => console.log('%c[气象学习]', 'color:#1976d2;font-weight:bold', ...a),
warn: (...a) => console.warn('[气象学习]', ...a),
err: (...a) => console.error('[气象学习]', ...a),
debug: (...a) => { if (CONFIG.DEBUG) console.log('%c[debug]', 'color:#9e9e9e', ...a); },
};
// 诊断缓冲:记录关键路径/状态,供调试日志查看
const Diag = {
buf: [],
add(...args) {
try {
const s = args.map(a => {
try { return typeof a === 'string' ? a : JSON.stringify(a); } catch (e) { return String(a); }
}).join(' ');
this.buf.push('[' + new Date().toLocaleTimeString() + '] ' + s);
if (this.buf.length > 300) this.buf.splice(0, 50);
} catch (e) {}
},
dump() { return this.buf.join('\n'); },
};
/* ===================== 通用工具 ===================== */
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
function waitUntil(fn, timeout = CONFIG.WAIT_TIMEOUT, interval = 300) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
Promise.resolve().then(() => {
try { return fn(); } catch (e) { return false; }
}).then(result => {
if (result) return resolve(true);
if (Date.now() - start >= timeout) return resolve(false);
setTimeout(check, interval);
}).catch(() => {
if (Date.now() - start >= timeout) return resolve(false);
setTimeout(check, interval);
});
})();
});
}
// 等待并返回元素本身(不是 boolean)。超时返回 null。
function waitForEl(selOrFn, timeout = CONFIG.WAIT_TIMEOUT) {
return new Promise((resolve) => {
const start = Date.now();
(function check() {
let el = null;
try {
el = typeof selOrFn === 'function' ? selOrFn() : document.querySelector(selOrFn);
} catch (e) { el = null; }
if (el) return resolve(el);
if (Date.now() - start >= timeout) return resolve(null);
setTimeout(check, 300);
})();
});
}
function waitEl(selOrFn, timeout = CONFIG.WAIT_TIMEOUT) {
return waitUntil(() => typeof selOrFn === 'function'
? selOrFn()
: document.querySelector(selOrFn), timeout).then(() =>
typeof selOrFn === 'function' ? selOrFn() : document.querySelector(selOrFn));
}
const $ = (sel, root = document) => root.querySelector(sel);
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
// 网络班级上下文(班级列表页 #classList / 班级详情页):多窗口学习在此不可用,须禁用
function isClassContext() {
return !!(document.getElementById('classList') || document.getElementById('classDetailCourseId')
|| /classDetailNew|classCourseDetail/.test(location.pathname));
}
// 手动干预检测:用户自己点击课程/班级/课件/翻页/标签时,立即暂停自动流程并清除断点,
// 尊重手动选择,避免脚本抢占跳转到它认为要学的课程(isTrusted 只认真实用户点击,脚本 click 不触发)
function installManualTakeoverWatch() {
document.addEventListener('click', (e) => {
try {
if (!e || e.isTrusted !== true) return;
const t = e.target;
if (!t || typeof t.closest !== 'function') return;
const sel = 'a.btn-solid-primary, a.btn-theme, a.btn-link.myCourse, a.btn-link.getMyTrainingClass, '
+ '.s_point, [completestate], li.learn-item, li.courseware-item, li.tree-node[data-itemid], '
+ 'a.pg-link, a.pg-next, a.pg-prev, .tabs-link';
if (!t.closest(sel)) return;
if (!StatusPanel.isRunning() || StatusPanel.isPaused()) return;
StatusPanel.setPaused(true);
STORE.clearProgress(); // 手动接管后,旧断点续学位置作废
StatusPanel.update({ status: '已暂停', detail: '手动选择,已暂停自动学习' });
StatusPanel.render();
log.info('检测到手动选择,已暂停自动学习(尊重手动操作;点「开始」恢复)');
notify('已暂停自动学习', '检测到手动操作,自动学习已暂停;点「开始」可恢复');
} catch (e) {}
}, true);
}
/* ===================== 跨标签页协调(GM 存储) =====================
* CMS 标签:点击"开始学习"打开学习空间新标签 → 轮询 GM:learningDone
* 学习空间标签:学完课件 → GM_setValue('learningDone', true) → 关闭窗口
*/
const STORE = {
// 学习完成标记:GM 存储 + localStorage 双通道。
// localStorage 变化会触发其它标签页的 storage 事件(后台标签也不受定时器节流影响),
// 让 CMS 主控页能第一时间收到"学习完成"通知并进入下一门课
markDone: () => {
GM_setValue('learningDone', true);
try { localStorage.setItem('qxj_done', String(Date.now())); } catch (e) {}
},
clearDone: () => {
GM_setValue('learningDone', false);
try { localStorage.removeItem('qxj_done'); } catch (e) {}
},
isDone: () => {
if (GM_getValue('learningDone', false) === true) return true;
try { return localStorage.getItem('qxj_done') !== null; } catch (e) { return false; }
},
setTask: (title) => GM_setValue('currentTask', title),
getTask: () => GM_getValue('currentTask', ''),
setAutoStart: (v) => GM_setValue('autoStart', v),
isAutoStart: () => GM_getValue('autoStart', false) === true,
// 会话失效标记:曾跳转到超时页/弹出登录超时弹窗 → 重新登录前不再自动跳转
setSessionDead: (v) => GM_setValue('sessionDead', v === true),
isSessionDead: () => GM_getValue('sessionDead', false) === true,
// 断点续学进度:保存/恢复当前遍历位置
saveProgress(page, learned, total, courseTitle, courseIdx) {
GM_setValue('qxj_progress', { page, learned, total, courseTitle: courseTitle || '', courseIdx: courseIdx || 0, ts: Date.now() });
},
loadProgress() {
const p = GM_getValue('qxj_progress', null);
if (p && p.page) return p;
return null;
},
clearProgress() {
GM_deleteValue('qxj_progress');
},
};
// (MiniWindowPool 已移除,改用 window.open 独立标签页模式)
/* ===================== 状态面板(启停/模式/静音/实时状态) ===================== */
const StatusPanel = {
// 持久化状态键
K_PAUSED: 'qxj_paused', // 暂停标记(跨页面)
K_MODE: 'qxj_mode', // 'normal' | 'review'
K_MUTE: 'qxj_mute', // 静音开关
K_RUNNING: 'qxj_running', // 是否在运行(跨页面)
K_RETRY: 'qxj_retry', // 课程未完成时最大重刷次数(0=不重刷)
// 实时状态(仅当前页面内存)
state: {
status: '待命', // 待命/运行中/已暂停/已完成/异常
course: '-', // 当前课程
progress: '', // 进度文本(如 "3/12")
detail: '', // 详情
page: '', // 当前页(如 "第1页/共3页")
totalPages: 0, // 总页数
totalCourses: 0, // 当前页课程总数
learnedCourses: 0, // 已学习课程数(跳过+完成)
},
el: null,
// —— 状态读写 ——
isPaused() { return GM_getValue(this.K_PAUSED, false) === true; },
setPaused(v) { GM_setValue(this.K_PAUSED, v === true); },
isRunning() { return GM_getValue(this.K_RUNNING, false) === true; },
setRunning(v) { GM_setValue(this.K_RUNNING, v === true); },
getMode() { return GM_getValue(this.K_MODE, CONFIG.DEFAULT_MODE); },
setMode(v) { GM_setValue(this.K_MODE, v === 'review' ? 'review' : 'normal'); },
isReview() { return this.getMode() === 'review'; },
isMute() { return GM_getValue(this.K_MUTE, CONFIG.DEFAULT_MUTE); },
setMute(v) { GM_setValue(this.K_MUTE, v === true); },
getRetry() { const v = GM_getValue(this.K_RETRY, CONFIG.DEFAULT_RETRY); return Math.max(0, Math.min(5, parseInt(v, 10) || 0)); },
setRetry(v) { v = Math.max(0, Math.min(5, parseInt(v, 10) || 0)); GM_setValue(this.K_RETRY, v); },
// —— 更新实时状态 ——
update(patch) {
Object.assign(this.state, patch);
this.render();
},
// —— 创建 UI ——
mount() {
if (this.el || document.getElementById('qxj-panel')) return;
const wrap = document.createElement('div');
wrap.id = 'qxj-panel';
wrap.innerHTML = `
气象自动学习
—
状态待命
模式普通
课程-
进度-
页码-
动作-
重刷2
窗口数:
`;
const style = document.createElement('style');
style.textContent = `
#qxj-panel{position:fixed;right:16px;bottom:16px;z-index:2147483647;width:248px;
background:rgba(33,41,56,.96);color:#e6eaf2;font-family:"Microsoft YaHei",Arial,sans-serif;
font-size:12px;border-radius:10px;box-shadow:0 8px 32px rgba(0,0,0,.35);
overflow:hidden;user-select:none;line-height:1.6}
#qxj-panel .qxj-head{display:flex;justify-content:space-between;align-items:center;
padding:8px 12px;background:linear-gradient(90deg,#1976d2,#42a5f5);cursor:move}
#qxj-panel .qxj-title{font-weight:700;letter-spacing:1px}
#qxj-panel .qxj-fold{cursor:pointer;width:18px;text-align:center}
#qxj-panel .qxj-body{padding:8px 12px;display:none}
#qxj-panel.qxj-open .qxj-body{display:block}
#qxj-panel .qxj-row{display:flex;margin:2px 0}
#qxj-panel .qxj-row .k{width:42px;color:#9fb3c8;flex-shrink:0}
#qxj-panel .qxj-row .v{color:#e6eaf2;word-break:break-all}
#qxj-panel .qxj-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
#qxj-panel .qxj-btns{display:flex;gap:6px;padding:8px 12px;border-top:1px solid rgba(255,255,255,.08)}
#qxj-panel .qxj-btn{flex:1;padding:6px 0;border:none;border-radius:6px;cursor:pointer;
font-size:12px;background:#2d3a52;color:#e6eaf2;transition:.15s}
#qxj-panel .qxj-btn:hover{background:#3b4a66}
#qxj-panel .qxj-btn.active{background:#1976d2;color:#fff}
#qxj-panel .qxj-btn[data-on="true"]{background:#43a047;color:#fff}
#qxj-panel .qxj-btn[data-on="false"]{background:#616161;color:#fff}
#qxj-panel .qxj-btn.qxj-retry{background:#6a1b9a;width:100%;margin-top:2px}
#qxj-panel .qxj-btn.qxj-retry:hover{background:#8e24aa}
#qxj-panel .qxj-btn.qxj-allfinish{background:#e65100;width:100%;margin-top:2px}
#qxj-panel .qxj-btn.qxj-allfinish:hover{background:#f57c00}
#qxj-panel .qxj-btn.qxj-allfinish[data-on="true"]{background:#43a047;color:#fff}
.qxj-open-link{display:block;padding:10px;margin:8px 0;background:#1976d2;color:#fff!important;text-align:center;border-radius:6px;font-weight:bold;font-size:14px;text-decoration:none!important;cursor:pointer;z-index:99999;position:relative;}
.qxj-open-link:hover{background:#1565c0;}
#qxj-panel.qxj-collapsed .qxj-body,#qxj-panel.qxj-collapsed .qxj-btns{display:none}
`;
document.head.appendChild(style);
document.body.appendChild(wrap);
this.el = wrap;
// 默认展开
wrap.classList.add('qxj-open');
// 按钮事件
wrap.querySelector('.qxj-fold').addEventListener('click', () => {
wrap.classList.toggle('qxj-collapsed');
wrap.classList.toggle('qxj-open');
});
wrap.querySelector('.qxj-toggle').addEventListener('click', () => this.onToggle());
wrap.querySelector('.qxj-mode').addEventListener('click', () => this.onMode());
wrap.querySelector('.qxj-retry').addEventListener('click', () => this.onRetry());
wrap.querySelector('.qxj-allfinish').addEventListener('click', () => this.onAllFinish());
wrap.querySelector('.qxj-accel').addEventListener('click', () => this.onAccel());
wrap.querySelector('.qxj-diag').addEventListener('click', () => this.onDiag());
// 拖动
this.enableDrag(wrap, wrap.querySelector('.qxj-head'));
// 监听跨页面状态变化(其它标签改变暂停状态时同步按钮)
window.addEventListener('storage', () => this.render());
this.render();
log.info('状态面板已挂载');
},
// —— 渲染 ——
render() {
if (!this.el) return;
const s = this.state;
this.el.querySelector('[data-k="status"]').textContent = s.status;
this.el.querySelector('[data-k="mode"]').textContent = this.isReview() ? '复习' : '普通';
this.el.querySelector('[data-k="course"]').textContent = s.course;
// 进度:显示 总X门 剩Y门
const progressEl = this.el.querySelector('[data-k="progress"]');
if (s.totalCourses > 0) {
const remain = Math.max(0, s.totalCourses - s.learnedCourses);
progressEl.textContent = `总${s.totalCourses}门 剩${remain}门`;
} else {
progressEl.textContent = s.progress || '-';
}
// 页码:显示 第X页/共Y页
const pageEl = this.el.querySelector('[data-k="page"]');
if (s.totalPages > 0) {
const curPage = s.page || '1';
// 如果 page 已经包含"第X页"格式,提取数字;否则直接用
const m = curPage.match(/(\d+)/);
const cur = m ? m[1] : curPage;
pageEl.textContent = `第${cur}页/共${s.totalPages}页`;
} else {
pageEl.textContent = s.page || '-';
}
this.el.querySelector('[data-k="detail"]').textContent = s.detail || '-';
// 重刷次数显示
const retry = this.getRetry();
this.el.querySelector('[data-k="retry"]').textContent = retry === 0 ? '不重刷' : (retry + ' 次');
const retryBtn = this.el.querySelector('.qxj-retry');
retryBtn.textContent = '重刷:' + (retry === 0 ? '关' : retry);
// 全部刷完显示
const allFinish = GM_getValue('qxj_all_finish', false);
const allFinishBtn = this.el.querySelector('.qxj-allfinish');
allFinishBtn.textContent = '全部刷完:' + (allFinish ? '开' : '关');
allFinishBtn.dataset.on = allFinish ? 'true' : 'false';
// 时间加速显示
const accelBtn = this.el.querySelector('.qxj-accel');
if (accelBtn) {
const accel = GM_getValue('qxj_accel', true) === true;
accelBtn.textContent = '加速:' + (accel ? '开' : '关');
accelBtn.dataset.on = accel ? 'true' : 'false';
}
// 启停按钮:未运行=开始(绿),运行中=暂停(灰),暂停中=开始(绿)
const toggle = this.el.querySelector('.qxj-toggle');
const running = this.isRunning();
const paused = this.isPaused();
toggle.textContent = !running ? '开始' : (paused ? '开始' : '暂停');
const isGreen = !running || paused;
toggle.dataset.on = isGreen ? 'true' : 'false';
toggle.classList.toggle('active', !running);
// 模式按钮
const mode = this.el.querySelector('.qxj-mode');
mode.textContent = this.isReview() ? '复习模式' : '普通模式';
mode.classList.toggle('active', this.isReview());
// 多窗口:网络班级页面不可用 → 置灰不可选,显示"禁止"
const winInput = this.el.querySelector('#qxj-win-count');
const winLabel = this.el.querySelector('#qxj-win-label');
if (winInput) {
if (isClassContext()) {
winInput.disabled = true;
winInput.value = '1';
winInput.style.opacity = '.45';
winInput.style.cursor = 'not-allowed';
winInput.title = '网络班级课程不支持多窗口学习';
if (winLabel) { winLabel.textContent = '多窗口:禁止'; winLabel.style.color = '#8a93a5'; }
} else {
winInput.disabled = false;
winInput.style.opacity = '';
winInput.style.cursor = '';
winInput.removeAttribute('title');
if (winLabel) { winLabel.textContent = '窗口数:'; winLabel.style.color = '#9fb3c8'; }
}
}
},
onToggle() {
if (!this.isRunning() || this.isPaused()) {
// 主循环已在运行(班级详情页自动启动等):只解除暂停,不重启、不弹新窗、不刷新
if (cmsLoopRunning) {
this.setRunning(true);
this.setPaused(false);
this.update({ status: '运行中', detail: '已恢复运行(无需重复启动)' });
this.render();
log.info('主循环已在运行,仅恢复,不重复启动');
return;
}
// 启动/恢复
this.setRunning(true);
this.setPaused(false);
STORE.setSessionDead(false);
// 注:不再在点击时预开窗口——预开改为 qxj:start 事件内、获取全局运行锁成功后执行,
// 且使用唯一窗口名,避免把其它标签页已打开的学习窗口误跳成 about:blank
// 读取窗口数输入框(网络班级页多窗口禁用,强制单窗口)
const inClassCtx = isClassContext();
const winInput = document.getElementById('qxj-win-count');
const winCount = inClassCtx ? 1 : (winInput ? Math.max(1, Math.min(20, parseInt(winInput.value, 10) || 1)) : 1);
if (!inClassCtx) GM_setValue('qxj_win_count', winCount);
else GM_setValue('qxj_multi_window', false); // 班级页不使用多窗口标记
this.update({ status: '运行中', detail: inClassCtx ? '网络班级:多窗口已禁用(单窗口学习)' : `窗口数:${winCount}` });
log.info('用户点击【开始】' + (inClassCtx ? '(网络班级,多窗口禁用,单窗口学习)' : ',窗口数:' + winCount));
window.__qxjToken = (window.__qxjToken || 0) + 1;
const host = location.hostname;
if (host === CONFIG.CMS_HOST) {
window.dispatchEvent(new CustomEvent('qxj:start'));
} else if (host === CONFIG.LEARN_HOST) {
window.dispatchEvent(new CustomEvent('qxj:restartLearn'));
}
} else {
// 暂停
this.setPaused(true);
this.update({ status: '已暂停' });
log.info('用户点击【暂停】');
}
this.render();
},
onMode() {
this.setMode(this.isReview() ? 'normal' : 'review');
this.update({ detail: '已切换:' + (this.isReview() ? '复习模式' : '普通模式') });
this.render();
log.info('模式切换:', this.getMode());
},
onRetry() {
const cur = this.getRetry();
const next = cur >= 5 ? 0 : cur + 1;
this.setRetry(next);
this.update({ detail: '重刷次数:' + (next === 0 ? '不重刷' : next + ' 次') });
this.render();
log.info('重刷次数切换:', next);
},
onAllFinish() {
const cur = GM_getValue('qxj_all_finish', false);
GM_setValue('qxj_all_finish', !cur);
this.update({ detail: '全部刷完:' + (!cur ? '开' : '关') });
this.render();
log.info('全部刷完切换:', !cur);
},
onAccel() {
const cur = GM_getValue('qxj_accel', true) === true;
GM_setValue('qxj_accel', !cur);
this.update({ detail: '时间加速:' + (!cur ? '开' : '关') + (cur ? '(课程秒完成)' : '(只能靠真实观看)') });
this.render();
log.info('时间加速切换:', !cur);
},
// 脚本重置刷新:清除所有 GM/localStorage 状态,停止运行,刷新页面
onDiag() {
try {
// 清除所有脚本相关的 GM 存储
const gmKeys = ['learningDone', 'currentTask', 'autoStart', 'sessionDead',
'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry', 'qxj_progress', 'qxj_all_finish', 'qxj_cms_loop', 'qxj_accel'];
gmKeys.forEach(k => { try { GM_deleteValue(k); } catch (e) {} });
// 清除 localStorage 中脚本相关的键
const lsKeys = ['qxj_done', 'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry'];
lsKeys.forEach(k => { try { localStorage.removeItem(k); } catch (e) {} });
// 重置内存状态
window.__qxjToken = (window.__qxjToken || 0) + 1;
window.__qxjLearning = false;
log.info('脚本状态已重置,刷新页面');
// 刷新页面
location.reload();
} catch (e) {
log.debug('重置异常:', e);
location.reload();
}
},
// —— 暂停闸门(主循环在每个关键步骤前 await) ——
async waitIfPaused() {
while (this.isPaused()) {
this.update({ status: '已暂停' });
await sleep(500);
}
this.update({ status: '运行中' });
},
// —— 拖动 ——
enableDrag(box, handle) {
handle.addEventListener('mousedown', (e) => {
if (e.target.classList.contains('qxj-fold')) return;
const rect = box.getBoundingClientRect();
const dx = e.clientX - rect.left;
const dy = e.clientY - rect.top;
const move = (ev) => {
box.style.left = (ev.clientX - dx) + 'px';
box.style.top = (ev.clientY - dy) + 'px';
box.style.right = 'auto';
box.style.bottom = 'auto';
};
const up = () => {
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
});
},
};
/* ===================== 静音观察器(自动静音所有 video,含 iframe) ===================== */
const MuteWatcher = {
timer: null,
start() {
if (this.timer) return;
// 每2秒扫描一次(强制静音需高频,对抗平台播放器重置声音)
this.timer = setInterval(() => this.scan(), 2000);
log.debug('静音观察器已启动(每2s,强制静音)');
},
scan() {
// 递归收集所有可访问文档(视频可能在 mainContent→mainFrame 深层嵌套 iframe 里)
const docs = [];
const seen = new Set();
const walk = (d) => {
if (!d || seen.has(d)) return;
try { seen.add(d); docs.push(d); } catch (e) { return; }
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
});
} catch (e) {}
};
walk(document);
const paused = StatusPanel.isPaused();
docs.forEach(d => {
if (!d) return;
d.querySelectorAll('video').forEach(v => {
if (v.ended) return;
// 强制静音(muted + volume 双保险),不受静音开关影响
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
// 暂停时不干预播放;否则恢复播放
if (v.paused && !paused) {
const p = v.play();
if (p && p.catch) p.catch(() => {
try { v.muted = true; v.volume = 0; v.play(); } catch (e2) {}
});
}
});
});
},
};
// ===== 轻量视频监控 + 页面健康检测:每5秒检查,暂停续播 + 错误页自动退出 =====
const VideoMonitor = {
timer: null,
lastTime: new WeakMap(),
start() {
if (this.timer) return;
this.timer = setInterval(() => this.check(), 10000);
log.debug('VideoMonitor 已启动(每5s)');
},
check() {
try {
// 检测错误/超时页面 → 自动退出回课程列表
if (this.isErrorPage()) {
log.warn('【 monitor 】检测到错误/超时页面,自动返回课程列表');
const cmsUrl = 'https://' + CONFIG.CMS_HOST + CONFIG.MY_COURSE_HREF;
try { location.href = cmsUrl; } catch (e) {}
return;
}
if (!StatusPanel.isRunning() || StatusPanel.isPaused()) return;
// 递归收集所有可访问文档(视频可能在 mainContent→mainFrame 深层嵌套 iframe 里)
const docs = [];
const seen = new Set();
const walk = (d) => {
if (!d || seen.has(d)) return;
try { seen.add(d); docs.push(d); } catch (e) { return; }
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
});
} catch (e) {}
};
walk(document);
let found = 0, playing = 0, paused = 0;
for (const doc of docs) {
if (!doc) continue;
for (const v of doc.querySelectorAll('video')) {
found++;
if (v.ended) continue;
// 强制静音(muted + volume 双保险)
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
// 视频快播完时(剩余<5秒)不干预,避免触发重新加载
if (v.duration && isFinite(v.duration) && v.currentTime >= v.duration - 5) continue;
const prev = this.lastTime.get(v) || 0;
const now = v.currentTime || 0;
this.lastTime.set(v, now);
if (!v.paused && now > prev + 0.2) {
playing++;
} else {
// 视频暂停 → 尝试续播
try {
if (!v.muted) { v.muted = true; if (v.volume > 0) v.volume = 0; }
} catch (e) {}
const p = v.play();
if (p && p.catch) p.catch(() => {
// 播放被阻止时保持静音重试
try { v.muted = true; v.volume = 0; v.play(); } catch (e2) {}
});
paused++;
}
}
}
if (found > 0 && StatusPanel.isRunning && StatusPanel.isRunning()) {
if (paused > 0 && playing === 0) {
StatusPanel.update({ detail: `视频暂停,已续播(${paused}个)` });
} else if (playing > 0) {
StatusPanel.update({ detail: `视频播放中(${playing}/${found})` });
}
}
} catch (e) {}
},
isErrorPage() {
const p = location.pathname || '';
if (/\/error\/|timeout|sessionTimeout|loginExpired|login\.(htm|html|action|jsp)/i.test(p)) return true;
try {
const dlg = document.querySelector('.layui-layer-content');
if (dlg && /登录超时|登录已超时|请重新登录|会话已过期/.test(dlg.textContent || '')) return true;
const bt = (document.body && document.body.textContent || '').slice(0, 500);
if (/登录超时|会话已过期|页面无法访问|ERR_|无法访问此网站/.test(bt)) return true;
} catch (e) {}
return false;
},
};
/* ===================== 课件学习核心模块 =====================
* 功能:
* 1. 递归深入嵌套 iframe,精准定位视频/图文/完成按钮
* 2. 视频倍速播放(默认4倍),自动续播,监听 ended/timeupdate 完成状态
* 3. 图文课件按设定时间停留
* 4. 多课件逐个学习,单个学完后自动切下一个
* 5. 完成状态检测:ended事件 / currentTime到末尾 / 完成按钮出现 / 完成标记
*/
const Courseware = {
// 递归收集顶层 + 所有同源嵌套 iframe 的 document
collectDocs(rootDoc) {
const docs = [];
const visited = new Set();
const walk = (d) => {
if (!d || visited.has(d)) return;
try { visited.add(d); docs.push(d); } catch (e) { return; }
try {
const ifs = d.querySelectorAll('iframe');
for (const f of ifs) {
try {
if (f.contentDocument) walk(f.contentDocument);
} catch (e) {}
}
} catch (e) {}
};
walk(rootDoc || document);
return docs;
},
queryAll(selector, docs) {
const out = [];
for (const d of docs) {
try { d.querySelectorAll(selector).forEach(el => out.push(el)); } catch (e) {}
}
return out;
},
// 找视频(递归 iframe;优先未结束的,绝不返回已结束的旧视频——
// 否则会把上个课件残留的 ended 视频当"本课件已学完")
findVideo(docs) {
const vs = this.queryAll('video', docs);
const live = vs.filter(v => !v.ended && v.readyState >= 1);
if (live.length) return live[0];
const pending = vs.filter(v => !v.ended);
if (pending.length) return pending[0];
return null; // 全部已结束/无有效视频 → 按图文处理
},
// 检测当前课件是否已完成
isFinished(docs, curVideo) {
// 视频课件:只看该视频是否真的播完(ended / currentTime 到末尾)。
// 不做宽泛的文本/class 判定——课程页常残留"完成"字样或旧元素,误判会导致视频几秒就被当"学完",
// 课件永远学不完、界面反复重学跳转。
if (curVideo) {
if (curVideo.ended) return true;
if (curVideo.duration && isFinite(curVideo.duration) && curVideo.currentTime >= curVideo.duration - 0.5) return true;
return false;
}
// 图文课件:查找明确的完成标记
const finishKeywords = ['已完成', '学习完成', '已学完', 'completed', 'finished', '本节已学完'];
const texts = this.queryAll('.complete, .finished, .done, .learned, .status-complete, [data-complete="true"]', docs);
for (const el of texts) {
const t = (el.textContent || '').trim();
if (t.length > 0 && t.length < 30 && finishKeywords.some(k => t.includes(k))) return true;
if (el.classList.contains('complete') || el.classList.contains('finished')) return true;
}
return false;
},
// 尝试关闭欢迎弹窗(部分课程会出现,遮挡课件内容)
tryCloseWelcomePopup(fullScan) {
try {
// 递归收集所有可访问文档(含嵌套 iframe)——欢迎弹窗可能出现在 mainContent 深层 iframe 里
const docs = this.collectDocs(document);
// 点击后延迟验证弹窗是否真的被关闭(容器被隐藏/移除),便于确认消除是否成功
const verifyClosed = (cont, via) => {
setTimeout(() => {
try {
const still = cont && cont.isConnected && cont.offsetParent !== null;
if (still) {
log.warn(` [弹窗] 已点击"开始学习"(${via}),但弹窗容器仍可见,点击可能无效`);
StatusPanel.update({ detail: '已点击开始学习,但弹窗未消失' });
} else {
log.info(` [弹窗][成功] 欢迎弹窗已关闭(${via})`);
StatusPanel.update({ detail: '已自动关闭欢迎弹窗' });
}
} catch (e) {}
}, 500);
};
// 方法1:精确选择器(廉价,快速模式下也执行)
for (const doc of docs) {
if (!doc) continue;
const btn = doc.querySelector('.continue .user_choise');
if (btn) {
log.info(' [弹窗] 关闭欢迎弹窗(选择器)');
try { btn.click(); } catch (e) {}
verifyClosed(btn.closest('.continue') || doc.querySelector('.continue'), '选择器');
return true;
}
}
if (!fullScan) return false; // 性能优化:快速模式不做全文档文本遍历
// CMS 页面没有欢迎弹窗,且课程卡片含"开始学习/继续学习"按钮——
// 若在这里做文本匹配会把真按钮当弹窗点击,导致平台自己弹出重复学习窗口!必须跳过
if (location.hostname === CONFIG.CMS_HOST) return false;
// 方法2:文本匹配("开始学习/视频续播/继续播放/继续学习";
// 要求附近有 欢迎/续播 字样——这两个词只出现在弹窗里,课程卡片不会有)
for (const doc of docs) {
if (!doc) continue;
const allEls = doc.querySelectorAll('div, a, button, span, p');
for (const el of allEls) {
const txt = (el.textContent || '').trim();
if (!/^(开始学习|视频续播|继续播放|继续学习)$/.test(txt)) continue;
if (el.offsetParent === null || el.children.length > 0) continue;
let p = el.parentElement, near = false;
for (let i = 0; p && i < 3; i++, p = p.parentElement) {
if (/欢迎|续播/.test(p.textContent || '')) { near = true; break; }
}
if (!near) continue;
log.info(' [弹窗] 关闭弹窗(文本:' + txt + ')');
try { el.click(); } catch (e) {}
verifyClosed(el.closest('.continue') || el.parentElement, '文本');
return true;
}
}
// 方法3:仅在页面存在欢迎弹窗痕迹(.continue 容器)时才发送键盘事件,避免频繁干扰页面
for (const doc of docs) {
if (!doc || !doc.querySelector('.continue')) continue;
try {
doc.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
doc.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', code: 'Space', keyCode: 32, bubbles: true }));
log.info(' [弹窗] 检测到欢迎弹窗痕迹,发送键盘事件尝试关闭');
return true;
} catch (e) {}
}
} catch (e) {}
return false;
},
// 加速学习时间:直接调用服务器接口累加学习时长(跳过视频播放等待)
async accelerateTime(seconds) {
try {
// 沙箱内看不到页面的 var 全局变量,按 页面window → unsafeWindow → URL参数 依次取 courseId
let courseId = '';
try { if (typeof window.courseId !== 'undefined') courseId = window.courseId; } catch (e) {}
if (!courseId) { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.courseId) courseId = unsafeWindow.courseId; } catch (e) {} }
if (!courseId) {
const m = (location.href || '').match(/[?&]params\.courseId=([^&]+)/);
if (m) courseId = m[1];
}
courseId = String(courseId || '');
let limitId = '';
try { if (typeof window.limitId !== 'undefined') limitId = window.limitId; } catch (e) {}
if (!limitId) { try { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.limitId) limitId = unsafeWindow.limitId; } catch (e) {} }
if (!courseId) { log.debug('[加速] 未找到 courseId'); return; }
// 平台根路径:页面函数不可用时用 /learnspace(学习空间站点固定前缀)
const rootPath = (() => {
try { if (typeof getPlatformRootPath === 'function') return getPlatformRootPath(); } catch (e) {}
try { if (typeof unsafeWindow !== 'undefined' && typeof unsafeWindow.getPlatformRootPath === 'function') return unsafeWindow.getPlatformRootPath(); } catch (e) {}
return '/learnspace';
})();
// 用 GM_xmlhttpRequest 发 POST(沙箱内页面 jQuery 不可用;GM 请求自带会话 cookie)
const gmPost = (url, data) => new Promise((resolve) => {
try {
GM_xmlhttpRequest({
method: 'POST',
url,
data: Object.keys(data).map(k => encodeURIComponent(k) + '=' + encodeURIComponent(data[k])).join('&'),
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
timeout: CONFIG.WAIT_TIMEOUT,
onload: (res) => {
try { resolve(JSON.parse(res.responseText || '{}')); } catch (e) { resolve(null); }
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
} catch (e) { resolve(null); }
});
// 查询当前已累计的学习时长
let current_time = 0;
try {
const qResp = await gmPost(rootPath + '/course/study/learningTime_queryLearningTime.action', { courseId: courseId });
if (qResp && qResp.success === 'true') {
current_time = parseInt(qResp.learnTime, 10) || 0;
}
} catch (e) {}
// 每次提交300秒,分批提交直到达到目标
const steps = Math.ceil(seconds / 300);
for (let i = 0; i < steps; i++) {
const chunk = Math.min(300, seconds - i * 300);
current_time += chunk;
const d = await gmPost(rootPath + '/course/study/learningTime_saveLearningTime.action', { courseId: courseId, studyTime: current_time, limitId: limitId });
log.debug(`[加速] 提交+${chunk}s,累计${current_time}s`);
await sleep(500); // 间隔500ms避免过快
}
log.info(`[加速] 完成,累计学习时长 ${current_time}s(${Math.round(current_time/60)}分钟)`);
StatusPanel.update({ detail: `时间加速完成(${Math.round(current_time/60)}分钟)` });
} catch (e) {
log.debug('[加速] 异常:', e);
}
},
// 学习当前课件内容(视频或图文)。itemId 用于周期检查平台完成状态
async learn(itemId) {
// 并发保护:同一时间只允许一个学习任务(主流程 vs iframe 刷新监听),
// 防止重复点击/并发播放导致平台提示"视频播放异常"
if (window.__qxjLearning) { log.debug(' [课件] 已有学习任务进行中,跳过本次'); return false; }
window.__qxjLearning = true;
window.__qxjLastLearnAt = Date.now();
try {
// 首次未发现视频时多次重试扫描(视频可能延迟加载,或被弹窗遮挡)
let video = null;
let docs = [];
for (let t = 0; t < CONFIG.CONTENT_RETRY_TIMES; t++) {
docs = this.collectDocs(getMainDoc() || document);
video = this.findVideo(docs);
if (video) break;
// 存在跨域课件 iframe 时,同域扫描永远找不到视频 → 直接进入跨域流程,不再重试
const hasCross = this.queryAll('iframe', docs).some(f => {
const s = f.src || '';
return s && !/^(about:|javascript:)/.test(s) && s.indexOf(location.hostname) < 0;
});
if (hasCross) {
log.debug(` [课件] 检测到跨域课件 iframe,跳过同域重试,直接走跨域流程`);
break;
}
// 未找到视频 → 可能是弹窗遮挡 → 尝试关闭弹窗(全量扫描)
this.tryCloseWelcomePopup(true);
log.debug(` [课件] 第${t + 1}/${CONFIG.CONTENT_RETRY_TIMES}次未发现视频,2s 后重试扫描`);
StatusPanel.update({ detail: `未发现视频,尝试关闭弹窗 ${t + 1}/${CONFIG.CONTENT_RETRY_TIMES}` });
await sleep(2000);
}
// 所有重试都失败 → 可能是弹窗挡住了,再试一次关闭弹窗(全量扫描)
if (!video) {
this.tryCloseWelcomePopup(true);
await sleep(1000);
docs = this.collectDocs(getMainDoc() || document);
video = this.findVideo(docs);
}
log.debug(` [课件] 扫描 ${docs.length} 个文档,video=${!!video}`);
// 检测跨域课件 iframe(如 kjcourse-cmatc.cma.cn):内部视频主脚本无法访问
let crossIframes = [];
try {
crossIframes = this.queryAll('iframe', docs).filter(f => {
const s = f.src || '';
return s && !/^(about:|javascript:)/.test(s) && s.indexOf(location.hostname) < 0;
});
if (crossIframes.length) {
log.warn(` [课件] ${crossIframes.length} 个跨域课件 iframe(${crossIframes[0].src.slice(0, 80)}),等待辅助脚本处理`);
StatusPanel.update({ detail: '跨域课件(web),等待辅助脚本' });
}
} catch (e) {}
// 跨域课件:等辅助脚本关闭弹窗并播放视频(辅助脚本通过 postMessage 上报)
if (!video && crossIframes.length) {
CwHelper.start();
const hv = await CwHelper.waitVideo(30000);
if (hv && hv.found) {
log.info(` [课件] 辅助脚本已找到并播放视频(${hv.duration ? Math.round(hv.duration) + 's' : '时长未知'})`);
Diag.add('[视频] 跨域视频由辅助脚本播放,duration=' + (hv.duration || '?'));
StatusPanel.update({ detail: `跨域视频播放中(${hv.duration ? Math.round(hv.duration) + 's' : '?'})` });
const maxMs = CONFIG.VIDEO_MAX_WAIT_SEC * 1000;
const start = Date.now();
let lastT = -1, lastProg = Date.now();
let missingSince = 0;
let maxT = 0; // 记录看过的最大播放位置(识别"播完一遍后自动重播")
while (Date.now() - start < maxMs) {
await StatusPanel.waitIfPaused();
const cur = CwHelper.video;
if (!cur || !cur.found) {
// 视频元素短暂消失(播放器重绘/重建的一瞬间)→ 容忍 10s,不轻易放弃,
// 否则视频播到一半会被误判为"消失"而切走(表现为"视频播放不完就刷新")
if (!missingSince) missingSince = Date.now();
if (Date.now() - missingSince > 10000) break;
await sleep(2000);
continue;
}
missingSince = 0;
if (cur.ended || (cur.duration && isFinite(cur.duration) && cur.currentTime >= cur.duration - 0.5)) {
log.info(` [课件] 跨域视频播放完成(t=${(cur.currentTime || 0).toFixed(1)}/${cur.duration ? cur.duration.toFixed(0) : '?'})`);
StatusPanel.update({ detail: '跨域视频播放完成' });
return true;
}
// 视频源无效(404/未加载):10s 内元数据都没出来,或明确无源 → 按图文处理
if (cur.readyState === 0 && (cur.networkState === 3 || Date.now() - start > 10000)) {
log.warn(' [课件] 跨域视频源无效(未加载出元数据),改按图文课件处理');
StatusPanel.update({ detail: '跨域视频源无效,按图文停留处理' });
break;
}
const t = cur.currentTime || 0;
if (t > maxT) maxT = t;
// 已播到较靠后,然后位置跳回开头附近 → 判定已完整播完一遍(平台自动重播)
if (maxT > 30 && t > 0 && t < 5 && t < maxT - 30
&& (cur.duration && isFinite(cur.duration) && maxT >= cur.duration - 30)) {
log.info(` [课件] 视频已播放到 ${Math.round(maxT)}s 后回到开头,判定已完整播放`);
StatusPanel.update({ detail: '跨域视频已完整播放' });
return true;
}
if (t > lastT) { lastT = t; lastProg = Date.now(); }
else if (Date.now() - lastProg > 15000) {
log.warn(' [课件] 跨域视频 15s 无推进,改按图文课件处理');
break;
}
await sleep(2000);
}
log.warn(' [课件] 跨域视频等待超时,按图文课件处理');
} else {
// 跨域欢迎弹窗仍在/视频始终未出现 → 不能按图文算完成(视频其实没播)
if (CwHelper.popupVisible) {
log.warn(' [课件] 跨域欢迎弹窗未关闭且未发现视频,判定未完成(交由重学轮处理)');
StatusPanel.update({ detail: '跨域课件未完成,交由重学' });
return false;
}
log.warn(' [课件] 辅助脚本未汇报到视频(可能纯图文),按图文课件处理');
StatusPanel.update({ detail: '跨域课件无视频,按图文停留处理' });
}
}
// 有视频:先正常播放(不预先加速——避免平台提前判定完成,导致学习窗口几分钟就切下一门)
if (video) {
const vok = await this.learnVideo(video, itemId);
if (vok) return true;
// 视频学习失败(损坏/网络中断)→ 时间加速(若开启)+ 图文兜底
log.warn(' [课件] 视频学习未完成,按图文课件兜底处理');
if (accelEnabled() && CONFIG.TIME_ACCELERATE_SEC > 0) {
await this.accelerateTime(CONFIG.TIME_ACCELERATE_SEC);
}
return await this.learnDocument(docs);
}
// 无视频(图文课件):先加速学习时长(若开启),再按图文停留
if (accelEnabled() && CONFIG.TIME_ACCELERATE_SEC > 0) {
await this.accelerateTime(CONFIG.TIME_ACCELERATE_SEC);
}
// 图文课件
return await this.learnDocument(docs);
} finally {
window.__qxjLearning = false;
}
},
// 视频学习:正常速度播放 → 持续续播/卡住重启 → 等待播放到结尾完成(不加速、不跳进度)
async learnVideo(video, itemId) {
try {
const docsFor = () => this.collectDocs(getMainDoc() || document);
// 启动:强制静音 + play() + 最高倍速 + 重试
const startPlay = () => {
try {
video.muted = true;
if (video.volume > 0) video.volume = 0;
if (video.paused) {
const p = video.play();
if (p && p.catch) p.catch(() => {
// 播放被阻止 → 保持静音重试(浏览器允许静音自动播放)
try { video.muted = true; video.volume = 0; video.play(); } catch (e2) {}
});
}
if (video.playbackRate < 2) {
video.playbackRate = 2;
}
} catch (e) {}
};
startPlay();
let dur = (video.duration && isFinite(video.duration)) ? video.duration : 0;
log.info(` 视频学习:时长 ${dur ? dur.toFixed(1) + 's' : '未知'},src=${(video.currentSrc || video.src || '').slice(0, 80)}`);
Diag.add('[视频] 开始 src=' + (video.currentSrc || video.src || '').slice(0, 100) + ' 时长=' + (dur ? dur.toFixed(1) + 's' : '未知') + ' t=' + (video.currentTime || 0).toFixed(1));
// 30 秒内加载不出元数据(时长未知)→ 视频源损坏/网络中断,判定为图文课件返回失败
if (!dur) {
const metaStart = Date.now();
while (Date.now() - metaStart < 30000) {
await sleep(1000);
try { if (video.ended) return true; } catch (e) {}
try { dur = (video.duration && isFinite(video.duration)) ? video.duration : 0; } catch (e) {}
if (dur) break;
}
if (!dur) {
log.warn(' 视频 30s 未加载出元数据(源损坏/网络中断),按图文课件兜底');
Diag.add('[视频] 30s 无元数据,按图文兜底');
return false;
}
}
const maxMs = CONFIG.VIDEO_MAX_WAIT_SEC * 1000;
const interval = CONFIG.VIDEO_POLL_INTERVAL;
const start = Date.now();
// 正常速度播放,等待播放到结尾(平台按连续播放时长判定完成)
StatusPanel.update({ detail: `视频播放中(${dur ? Math.round(dur) + 's' : '未知'})` });
let lastT = video.currentTime || 0;
let lastProgressAt = Date.now();
let lastTry = 0;
let lastHeartbeat = 0;
let lastCheckTs = Date.now(); // 初始化为当前时间:首次周期检查不立即触发(否则视频刚开播就被"服务器已标记完成"打断)
let maxT = 0; // 记录看过的最大播放位置(识别"播完一遍后自动重播")
let sawPlayback = false; // 本课件是否真实播放推进过(杜绝"没学完却显示完成")
let reloadCount = 0; // 视频 reload 次数(reload 会从头重载,必须限量,否则形成"一直刷新"死循环)
let lastRateTs = 0; // 倍速强制时间戳(避免每秒干预播放器触发刷新)
let recoveryTried = false; // 末尾收尾回退是否已尝试(只试一次,防止循环 seek)
while (Date.now() - start < maxMs) {
// 暂停闸门
await StatusPanel.waitIfPaused();
// 视频元素被播放器替换/移除 → 按 src 重新获取
if (!video.isConnected) {
const same = docsFor().find(v => (v.currentSrc || v.src) === (video.currentSrc || video.src));
if (same) {
video = same;
dur = (same.duration && isFinite(same.duration)) ? same.duration : 0;
log.info(' 视频元素被播放器替换,已重新获取');
}
}
// 强制静音(对抗平台播放器重置声音状态)
try { if (!video.muted) video.muted = true; if (video.volume > 0) video.volume = 0; } catch (e) {}
// —— 完成检测:只认真实播放完成,杜绝"没学完却显示完成/学完不退出" ——
// 1) 真正 ended;2) 已到末尾且本课件确实播放推进过(sawPlayback)
const cT = video.currentTime || 0;
const cDur = (video.duration && isFinite(video.duration)) ? video.duration : dur;
const atEnd = !!cDur && cT >= cDur - 0.5;
if (video.ended || (atEnd && sawPlayback)) {
log.info(` 视频播放完成(ended=${video.ended} t=${cT.toFixed(1)}/${cDur ? cDur.toFixed(0) : '?'})`);
Diag.add('[视频] 播放完成 ended=' + video.ended + ' t=' + cT.toFixed(1) + '/' + (cDur ? cDur.toFixed(0) : '?'));
// 按 itemId 确认平台登记(服务器可能延迟几秒~几十秒),最多4次×15s。
// 不再抓 CMS 课程列表:学习空间 host 上 fetchCourseList 拿不到课程列表,
// 且那里确认的是整门课而非本课件,只会白等 6 分钟
StatusPanel.update({ detail: '视频播完,确认平台登记...' });
let itemConfirmed = false;
for (let check = 0; check < 4; check++) {
let rc = null, cur = null;
try {
const rec = await LearnRecord.read();
rc = rec ? rec.find(x => x.itemId === itemId) : null;
} catch (e) {}
try { cur = itemId ? CoursewareList.read().find(x => x.itemId === itemId) : null; } catch (e) {}
if ((rc && rc.done) || (cur && cur.done)) {
log.info(` 平台已登记完成:${itemId}`);
itemConfirmed = true;
break;
}
log.info(` 平台尚未登记完成,15s后复查(第${check + 1}/4次)`);
StatusPanel.update({ detail: `等待平台登记完成(${4 - check}次复查)` });
try { video.muted = true; video.volume = 0; video.play(); } catch (e) {}
await sleep(15000);
}
if (itemConfirmed) {
StatusPanel.update({ detail: '平台确认完成,切下一课件' });
return true;
} else {
log.warn(` 平台1分钟内未登记完成,按已完成处理(itemId=${itemId})`);
StatusPanel.update({ detail: '平台未登记,按已完成处理' });
return true;
}
}
// 加载即位于末尾(平台断点恢复到结尾)且本课件尚未播放推进 → 回退几秒真实播完,
// 否则平台不会登记、下一轮又是"没学完"(只尝试一次,防止循环 seek)
if (atEnd && !sawPlayback && !recoveryTried && cDur > 3) {
recoveryTried = true;
log.warn(` 视频加载即位于末尾(t=${cT.toFixed(1)}/${cDur.toFixed(0)}),回退收尾重播`);
StatusPanel.update({ detail: '视频位于末尾,回退收尾重播' });
try { video.currentTime = Math.max(0, cDur - 2); video.muted = true; video.volume = 0; video.play(); } catch (e) {}
await sleep(5000);
sawPlayback = true;
lastProgressAt = Date.now();
continue;
}
// 每 COMPLETION_CHECK_MIN 分钟检查一次平台完成状态:已完成 → 提前结束跳下一个
if (itemId && Date.now() - lastCheckTs >= CONFIG.COMPLETION_CHECK_MIN * 60 * 1000) {
// 视频正在正常播放(近期有推进)→ 不提前结束,让它自然播完
// (否则服务器旧记录会让刚开播的视频几秒就被"标记完成"打断)
if (Date.now() - lastProgressAt < 45000) {
lastCheckTs = Date.now();
continue;
}
lastCheckTs = Date.now();
StatusPanel.update({ detail: `每${CONFIG.COMPLETION_CHECK_MIN}分钟检查完成状态` });
const rec = await LearnRecord.read();
const rc = rec ? rec.find(x => x.itemId === itemId) : null;
if (rc && rc.done) {
log.info(` 周期检查:课件 ${itemId} 平台已标记完成,提前结束`);
Diag.add('[视频] 周期检查:课件 ' + itemId + ' 已完成,提前结束');
await sleep(800);
return true;
}
const cur = CoursewareList.read().find(x => x.itemId === itemId);
if (cur && cur.done) {
log.info(` 周期检查:课件 ${itemId} 已有对勾,提前结束`);
Diag.add('[视频] 周期检查:课件 ' + itemId + ' 有对勾,提前结束');
await sleep(800);
return true;
}
}
// 确保倍速不被播放器重置(每30秒才强制一次,避免频繁干预播放器触发视频刷新)
if (Date.now() - lastRateTs > 30000 && video.playbackRate < 2) {
lastRateTs = Date.now();
try { video.playbackRate = 2; } catch (e) {}
}
// 进度跟踪
const t = cT;
if (t > maxT) maxT = t;
// 已播到接近结尾、然后位置跳回开头附近 → 判定已完整播完一遍(平台自动重播)。
// 必须真的到达过接近结尾(maxT ≥ 时长-30s),避免中途 reload 重置到0被误判"已播完"
if (maxT > 30 && t > 0 && t < 5 && t < maxT - 30 && cDur > 0 && maxT >= cDur - 30) {
log.info(` 视频已播放到 ${Math.round(maxT)}s 后回到开头,判定已完整播放`);
Diag.add('[视频] 播完一遍后重播,判定完成 maxT=' + Math.round(maxT));
StatusPanel.update({ detail: '视频已完整播放' });
return true;
}
if (!video.ended && t > lastT) {
lastT = t;
lastProgressAt = Date.now();
sawPlayback = true;
}
// 60 秒无进展:先尝试续播;reload 有副作用(视频从头重载),最多 2 次,
// 否则会形成"视频一直刷新无法播放/学完不退出"的死循环;仍无进展则按图文兜底
else if (Date.now() - lastProgressAt > 60000 && Date.now() - lastTry > 60000) {
lastTry = Date.now();
log.warn(` 视频未推进(t=${t.toFixed(1)},paused=${video.paused},已reload ${reloadCount}/2 次)`);
StatusPanel.update({ detail: '视频未推进,尝试续播' });
// 强制静音 + play
try { video.muted = true; if (video.volume > 0) video.volume = 0; } catch (e) {}
startPlay();
if (video.paused && reloadCount < 2) {
reloadCount++;
log.warn(` 视频仍暂停,reload 视频(第${reloadCount}/2 次)`);
StatusPanel.update({ detail: `视频无进展,reload(${reloadCount}/2)` });
try { video.load(); } catch (e) {}
await sleep(1500);
startPlay();
lastProgressAt = Date.now() - 30000;
} else {
// reload 已用完(或视频未暂停却无推进)→ 不再空等2小时,按图文兜底结束
log.warn(` 视频 ${reloadCount} 次 reload 后仍无进展,按图文课件兜底处理`);
StatusPanel.update({ detail: '视频无进展,按图文兜底' });
return false;
}
}
// 心跳:每 30 秒记录一次进度,便于排障
if (Date.now() - lastHeartbeat > 30000) {
lastHeartbeat = Date.now();
Diag.add('[视频] 心跳 t=' + (video.currentTime || 0).toFixed(1) + '/' + (video.duration ? video.duration.toFixed(0) : '?') + ' paused=' + video.paused + ' ended=' + video.ended + ' connected=' + video.isConnected);
}
await sleep(interval);
}
log.warn(` 视频等待超时(${CONFIG.VIDEO_MAX_WAIT_SEC}s),强制结束`);
Diag.add('[视频] 等待超时');
StatusPanel.update({ detail: '视频等待超时,跳过' });
return false;
} catch (e) {
log.err('视频学习异常:', e);
Diag.add('[视频] 异常: ' + (e && e.stack || e));
return false;
}
},
// 图文/网页课件学习:停留设定时间(让服务器计满浏览时长),期间检测完成
async learnDocument(docs) {
const waitSec = Math.min(CONFIG.COURSEWARE_WATCH_SEC, 40);
log.info(` 图文课件,停留 ${waitSec}s`);
StatusPanel.update({ detail: `图文课件阅读 ${waitSec}s` });
const total = waitSec * 1000;
const step = 1000;
let waited = 0;
while (waited < total) {
await StatusPanel.waitIfPaused();
const seg = Math.min(step, total - waited);
await sleep(seg);
waited += seg;
// 期间检测完成
const allDocs = this.collectDocs(getMainDoc() || document);
if (this.isFinished(allDocs)) {
log.info(' 检测到已完成标记,提前结束');
StatusPanel.update({ detail: '图文已完成,切下一课件' });
return true;
}
}
return true;
},
};
/* ===================== 跨域课件辅助(kjcourse-cmatc.cma.cn) =====================
* 课件内容可能托管在跨域 iframe(如 kjcourse),主脚本无法直接访问其 DOM。
* 配套脚本"气象培训课件域辅助"在该域名内运行:关闭欢迎弹窗、播放视频,
* 并通过 postMessage 上报到顶层窗口,主脚本在此接收状态。
*/
const CwHelper = {
last: null,
video: null,
popupVisible: false,
_prevPopup: false,
listening: false,
start() {
if (this.listening) return;
this.listening = true;
window.addEventListener('message', (e) => {
try {
const d = e.data;
if (!d || d.source !== 'qxj_cw_helper') return;
this.last = d;
this.video = d.video || null;
this.popupVisible = !!d.popupVisible;
if (this._prevPopup && !this.popupVisible) {
log.info('[跨域] 欢迎弹窗已被辅助脚本关闭');
StatusPanel.update({ detail: '跨域欢迎弹窗已关闭' });
}
this._prevPopup = this.popupVisible;
log.debug('[跨域] 辅助脚本上报:video=' + (this.video && this.video.found ? '有' : '无') + ' popup=' + this.popupVisible);
} catch (err) {}
});
log.info('跨域辅助监听已启动');
},
// 等待辅助脚本汇报视频(弹窗关闭后视频可能需要几秒才出现)
waitVideo(timeoutMs) {
return new Promise((resolve) => {
const start = Date.now();
const check = () => {
if (this.video && this.video.found) return resolve(this.video);
if (Date.now() - start >= timeoutMs) return resolve(null);
setTimeout(check, 500);
};
check();
});
},
};
/* ===== 跨域课件域(kjcourse)辅助:单脚本集成 =====
* 课件内容可能托管在跨域 iframe(kjcourse-cmatc.cma.cn),主脚本无法跨域访问其 DOM。
* 该域内运行下方 runKjCourseHelper():点击"开始学习"触发播放器初始化 → 清理弹窗/灰色遮罩
* → 静音倍速播放视频 → postMessage 上报顶层(CwHelper 接收)。
*/
if (location.hostname === 'kjcourse-cmatc.cma.cn') {
runKjCourseHelper();
return;
}
// 非顶层框架(mainContent/mainFrame 等 iframe)不重复运行主逻辑(原 @noframes 行为)
if (window.top !== window.self) return;
// kjcourse 辅助实现(仅在该域名内被调用)
function runKjCourseHelper() {
const kjLog = (...a) => log.info('[kjcourse]', ...a);
// 暂停状态跨域共享(同一脚本的 GM 存储):暂停时不点击/不续播,避免课程自动刷新
const kjPaused = () => { try { return StatusPanel.isPaused(); } catch (e) { return false; } };
const collectDocs = (root) => {
const docs = [];
const seen = new Set();
const walk = (d) => {
if (!d || seen.has(d)) return;
try { seen.add(d); docs.push(d); } catch (e) { return; }
try {
d.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
});
} catch (e) {}
};
walk(root || document);
return docs;
};
// 灰色遮罩:只删"全屏 + 深色半透明 + 几乎无文字"的固定层
const removeShade = () => {
let removed = false;
for (const doc of collectDocs(document)) {
if (!doc) continue;
let winW = 0, winH = 0;
try { winW = doc.defaultView ? doc.defaultView.innerWidth : 0; winH = doc.defaultView ? doc.defaultView.innerHeight : 0; } catch (e) {}
if (!winW) continue;
doc.querySelectorAll('div').forEach(el => {
if (!el.isConnected) return;
let st = null;
try { st = getComputedStyle(el); } catch (e) {}
if (!st || st.position !== 'fixed') return;
const r = el.getBoundingClientRect();
if (r.width < winW * 0.7 || r.height < winH * 0.7) return;
const bg = st.backgroundColor || '';
const alpha = (st.opacity || '1') * 1;
const darkShade = /rgba\(\s*0,\s*0,\s*0,\s*(0?\.\d+)/.test(bg) || (bg === 'rgb(0, 0, 0)' && alpha < 1);
if (!darkShade) return;
if ((el.textContent || '').trim().length > 30) return;
try { el.remove(); } catch (e) { try { el.style.display = 'none'; } catch (e2) {} }
kjLog('已移除灰色遮罩层(' + String(el.className || '').slice(0, 40) + ')');
removed = true;
});
}
return removed;
};
const removePopup = () => {
let removed = false;
for (const doc of collectDocs(document)) {
if (!doc) continue;
const cont = doc.querySelector('.continue');
if (cont && cont.isConnected) {
try { cont.remove(); } catch (e) { try { cont.style.display = 'none'; } catch (e2) {} }
kjLog('已移除欢迎弹窗容器');
removed = true;
}
}
return removeShade() || removed;
};
const findVideo = () => {
let first = null;
for (const doc of collectDocs(document)) {
if (!doc) continue;
const vs = doc.querySelectorAll('video');
for (const v of vs) {
if (!first) first = v;
if (v.currentSrc || v.src) return v; // 优先有有效源的视频
}
}
return first;
};
// 上报状态到顶层(主逻辑 CwHelper 在顶层监听);paused=true 时仅上报、不干预播放
// 温和续播:视频暂停超过 4 秒才强制播放——避免每 1 秒一次的 play() 与平台播放器的
// 缩放/全屏/暂停逻辑冲突(一缩放就把视频"抢"到播放,导致缩放后不播放)
let lastForcePlay = 0;
const report = (paused) => {
try {
const v = findVideo();
const payload = { source: 'qxj_cw_helper', url: location.href.slice(0, 150) };
if (v) {
payload.video = {
found: true, paused: v.paused, muted: v.muted,
currentTime: v.currentTime, duration: v.duration, ended: v.ended,
readyState: v.readyState, networkState: v.networkState,
};
if (!paused) {
// 强制静音(muted + volume 双保险)
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
const now = Date.now();
if (v.paused && !v.ended && now - lastForcePlay > 4000) {
lastForcePlay = now;
const p = v.play();
if (p && p.catch) p.catch(() => { try { v.muted = true; v.volume = 0; v.play(); } catch (e2) {} });
}
// 倍速只在正常播放中恢复(缩放/全屏过程不干预)
if (!v.paused && v.playbackRate < 2 && now - lastForcePlay > 2000) {
try { v.playbackRate = 2; } catch (e) {}
}
}
} else {
payload.video = { found: false };
}
payload.popupVisible = document.querySelector('.continue') !== null;
try { window.top.postMessage(payload, '*'); } catch (e) {}
} catch (e) {}
};
// ===== 点击"开始学习"触发播放器初始化 =====
// 时机很重要:播放器框架可能晚于页面就绪。策略:等 1.5s 后开始,
// 每 2.5s 点一次,最多点 6 次(约 16s 窗口);视频一旦有了源立刻停止;
// 结束后才清理弹窗/遮罩(避免提前移除导致后续无法再点)。
let clickCount = 0;
let decided = false;
const videoHasSrc = () => {
try {
const v = findVideo();
if (!v) return false;
const s = v.currentSrc || v.src || '';
return /^https?:\/\//.test(s); // 必须是真实 http(s) 源,空/占位 src 不算
} catch (e) { return false; }
};
const tryClickOnce = () => {
if (kjPaused()) return false; // 暂停时不点击"开始学习",避免课程自动刷新
const BTN_TEXT = /^(开始学习|视频续播|继续播放|继续学习)$/;
for (const doc of collectDocs(document)) {
if (!doc) continue;
const btn = doc.querySelector('.continue .user_choise');
if (btn) {
clickCount++;
try { btn.click(); } catch (e) {}
kjLog('已点击"开始学习"(第' + clickCount + '次,触发播放器初始化)');
return true;
}
// 文本匹配:"开始学习/视频续播/继续播放"等变体按钮
const els = doc.querySelectorAll('div, a, button, span, p');
for (const el of els) {
const t = (el.textContent || '').trim();
if (!BTN_TEXT.test(t)) continue;
if (el.children.length > 0 || el.offsetParent === null) continue;
clickCount++;
try { el.click(); } catch (e) {}
kjLog('已点击"' + t + '"(第' + clickCount + '次)');
return true;
}
}
return false;
};
const finish = (why) => {
if (decided) return;
decided = true;
removePopup();
report(kjPaused());
kjLog(why);
};
// 点击阶段:先点"开始学习"(触发播放器初始化让视频源加载),点完/超时才清理弹窗/遮罩。
// 注意:点击阶段结束前绝不移除弹窗容器——移除了按钮就点不到,视频就加载不出来。
setTimeout(() => {
if (decided) return;
if (videoHasSrc()) return finish('视频源已加载,清理弹窗/遮罩');
tryClickOnce();
const iv = setInterval(() => {
if (decided) { clearInterval(iv); return; }
if (videoHasSrc()) { clearInterval(iv); return finish('视频源已加载,清理弹窗/遮罩'); }
if (clickCount >= 6) { clearInterval(iv); return finish('点击6次仍未加载视频源,按图文处理'); }
tryClickOnce();
}, 2500);
setTimeout(() => { if (!decided) { clearInterval(iv); finish('点击阶段超时,清理弹窗/遮罩'); } }, 30000);
}, 800);
// 常规轮询:持续上报;点击阶段结束(decided)后才清理可能重现的弹窗/遮罩
let lastAct = 0;
const act = () => {
const now = Date.now();
if (now - lastAct < 1000) return;
lastAct = now;
const paused = kjPaused();
try {
// 暂停时不清理(防课程自动刷新),仅上报状态
if (!paused && decided) {
if (document.querySelector('.continue')) removePopup();
}
} catch (e) {}
report(paused);
};
const mo = new MutationObserver(act);
mo.observe(document.documentElement, { childList: true, subtree: true }); // 去掉 attributes 减少触发
setInterval(act, 3000);
// 强制静音:每 1 秒对所有 video 应用静音(muted + volume 双保险,对抗平台播放器重置)
setInterval(() => {
try {
for (const doc of collectDocs(document)) {
if (!doc) continue;
doc.querySelectorAll('video').forEach(v => {
try { if (!v.muted) v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
});
}
} catch (e) {}
}, 1000);
kjLog('跨域课件域辅助已启动');
}
/* ===================== 入口:按域名分流 ===================== */
const host = location.hostname;
log.info('脚本版本 1.11.27 已注入:', host, location.href);
Diag.add('[注入] 版本 1.11.27 host=' + host + ' url=' + location.href);
// 全局错误捕获:任何未处理异常都记入诊断(async 函数第一处 await 前抛错是静默 rejection)
window.addEventListener('error', (e) => {
try { Diag.add('[全局错误] ' + ((e && (e.message || (e.error && (e.error.stack || e.error.message)) || e)) || e)); } catch (err) {}
});
window.addEventListener('unhandledrejection', (e) => {
try { Diag.add('[未处理rejection] ' + ((e && e.reason && (e.reason.stack || e.reason.message || e.reason)) || e)); } catch (err) {}
});
// 手动干预检测:用户自己点击课程/课件时暂停自动流程(尊重手动选课)
installManualTakeoverWatch();
// CMS 主站首页/登录页不启动(避免干扰登录、误关登录超时弹窗)
// 仅在"我的自选课"等业务页面启动;学习空间站则直接启动
function shouldStartOnCms() {
const p = location.pathname;
return /myCourse|student\/study|courseList|cms\/u\/student|getMyTrainingClass|classList|classDetail|classCourseDetail/.test(p + location.hash)
|| document.querySelector('a.btn-link.myCourse, a.btn-link.getMyTrainingClass, #courseList, ul#courseList, #classList, #classDetailCourseId') !== null;
}
// 会话失效判定:当前页面是超时/错误/登录页(如 /error/timeout.htm)
// 此类页面绝不自动跳转,否则会形成 超时页↔课程页 无限循环
function isSessionErrorPage() {
const p = location.pathname || '';
return /\/error\/|timeout|sessionTimeout|loginExpired|signin|login\.(htm|html|action|jsp)/i.test(p);
}
// 会话失效综合判定:超时/错误/登录页,或页面出现"登录超时"弹窗/提示
function isSessionDead() {
if (isSessionErrorPage()) return true;
try {
const dlg = document.querySelector('.layui-layer-content');
if (dlg && /登录超时|登录已超时|请重新登录|会话已过期|登录已过期/.test(dlg.textContent || '')) return true;
const bt = (document.body && document.body.textContent || '').slice(0, 800);
if (/登录超时,为了您的信息安全,请重新登录/.test(bt)) return true;
} catch (e) {}
return false;
}
// 等待 body 就绪后挂载面板
(async () => {
await waitUntil(() => document.body, 10000);
if (host === CONFIG.CMS_HOST) {
// CMS 站:先判断是否在业务页面
if (!shouldStartOnCms()) {
log.info('CMS 首页/登录页,暂不启动面板与弹窗扫描');
// 监听 URL 变化,进入业务页面后再启动
let started = false;
const tryStart = () => {
if (started) return;
if (shouldStartOnCms()) {
started = true;
StatusPanel.mount();
MuteWatcher.start();
DialogDismisser.start();
VideoMonitor.start();
log.info('已进入 CMS 业务页面,面板与扫描已启动');
}
};
tryStart();
// URL 变化(SPA 路由)触发
let lastHref = location.href;
setInterval(() => {
if (location.href !== lastHref) {
lastHref = location.href;
tryStart();
}
}, 1000);
return;
}
}
// 学习空间站 / CMS 业务页面:直接启动
StatusPanel.mount();
MuteWatcher.start();
DialogDismisser.start();
VideoMonitor.start();
})();
// ===== 自动关闭弹窗(30分钟学习提醒等)=====
// 每 3 秒扫描一次(降低频率避免卡死主线程)
const DialogDismisser = {
timer: null,
// 只有弹窗内容匹配这些关键词才点击"确定"
MATCH_KEYWORDS: ['学了', '30分钟', '继续学习', '休息', '已经学', '学习时间', '分钟', '欢迎学习', '欢迎'],
// 弹窗内容含这些词则【绝不点击】(登录超时/会话失效等)
SKIP_KEYWORDS: [
'登录', '超时', '失效', '未登录', '重新登录', '重新登陆', '登录超时', '会话已过期', '登录已过期',
'已过期', '过期', '信息安全', '身份', '会话', 'session', 'timeout', 'login',
],
// 含这些词视为"登录/会话失效"类弹窗 → 暂停自动化,等待人工重新登录
LOGIN_KEYWORDS: ['登录超时', '登录已超时', '重新登录', '重新登陆', '信息安全', '会话', '超时', 'timeout', 'login'],
lastSkipLog: 0,
sessionTimeoutNotified: false,
observer: null,
observedDocs: new WeakSet(),
start() {
if (this.timer) return;
this.timer = setInterval(() => this.scan(), 5000);
// MutationObserver 专门监听欢迎弹窗(需要立即关闭,不能等5秒)
this.welcomeObserver = new MutationObserver(() => this.scanWelcome());
this.welcomeObserver.observe(document.documentElement, { childList: true, subtree: true });
// 递归监听所有可访问文档(含嵌套 iframe):欢迎弹窗可能出现在 mainContent 深层 iframe 里
this.installWelcomeObservers();
log.info('DialogDismisser 已启动(每5s扫描 + 欢迎弹窗实时监听)');
},
// 对每个可访问文档(含嵌套 iframe)安装欢迎弹窗监听;新 iframe 出现时补装
installWelcomeObservers() {
const seen = new WeakSet();
const install = (win) => {
try {
if (seen.has(win)) return;
seen.add(win);
const doc = win.document;
const mo = new MutationObserver(() => {
// 性能优化:先廉价检查本文档是否有弹窗痕迹,没有就不做全文档扫描
try {
if (StatusPanel.isPaused()) return; // 暂停时不点击(防课程自动刷新)
if (!doc.querySelector('.continue')) return;
} catch (e) { return; }
const now = Date.now();
if (now - (this._lastWelcomeScan || 0) > 500) {
this._lastWelcomeScan = now;
try { Courseware.tryCloseWelcomePopup(false); } catch (e) {}
}
});
mo.observe(doc.documentElement, { childList: true, subtree: true }); // 去掉 attributes:属性频繁变化会大量触发
doc.querySelectorAll('iframe').forEach(f => {
try { if (f.contentDocument) install(f.contentWindow); } catch (e) {}
});
} catch (e) {}
};
install(window);
if (this._welcomeObsTimer) clearInterval(this._welcomeObsTimer);
this._welcomeObsTimer = setInterval(() => install(window), 2000);
},
// 专门检测欢迎弹窗( MutationObserver 触发,立即响应)
scanWelcome() {
try {
if (StatusPanel.isPaused()) return; // 暂停时不关闭欢迎弹窗(防自动点击刷新课程)
if (!document.querySelector('.continue')) return; // 廉价过滤
if (Courseware.tryCloseWelcomePopup(false)) {
StatusPanel.update({ detail: '已关闭欢迎弹窗' });
}
} catch (e) {}
},
// 用 MutationObserver 监听弹窗插入:
// 后台标签页里 setInterval 会被浏览器节流到 ~1次/分钟,而 DOM 变化回调不被节流,
// 弹窗一出现(平台 JS 插入 DOM)就能立即扫描关闭
installObserver(rootDoc) {
try {
if (!rootDoc || this.observedDocs.has(rootDoc)) return;
this.observedDocs.add(rootDoc);
const mo = new MutationObserver(() => this.scan());
mo.observe(rootDoc.body || rootDoc.documentElement, { childList: true, subtree: true });
if (!this.observer) this.observer = mo;
} catch (e) {}
},
scan() {
try {
// 手动暂停时:仅继续识别登录超时弹窗(绝不点击),不再点击任何提醒/欢迎弹窗,
// 避免触发课程自动刷新/续播
const paused = StatusPanel.isPaused();
// 仅检查顶层 + mainContent iframe(不递归,避免卡死)
const docs = [document];
const mc = $('#mainContent');
if (mc) {
try { if (mc.contentDocument) docs.push(mc.contentDocument); } catch (e) {}
}
for (const doc of docs) {
if (!doc) continue;
// 找所有 layui-layer 弹窗容器
const layers = doc.querySelectorAll('.layui-layer');
for (const layer of layers) {
if (!layer.style || layer.style.display === 'none') continue;
// 读弹窗内容(标题 + 正文 + 整体文本)
const content = layer.querySelector('.layui-layer-content');
const titleEl = layer.querySelector('.layui-layer-title');
const txt = [
(content && content.textContent) || '',
(titleEl && titleEl.textContent) || '',
(layer.textContent) || '',
].join(' ');
// 【第一步】SKIP:含登录/超时/失效等关键词 → 绝不点击
const skipHit = this.SKIP_KEYWORDS.find(k => txt.indexOf(k) >= 0);
if (skipHit) {
// 节流日志(5 秒一次,避免刷屏)
const now = Date.now();
if (now - this.lastSkipLog > 5000) {
this.lastSkipLog = now;
log.info('[弹窗] 保留弹窗(命中"' + skipHit + '"),不点确定:' + txt.substring(0, 50));
}
// 登录/会话失效类弹窗 → 立即暂停自动化,避免继续点击/跳转导致弹窗反复弹出
if (this.LOGIN_KEYWORDS.some(k => txt.indexOf(k) >= 0)) {
this.pauseOnSessionTimeout();
}
continue;
}
// 【第二步】MATCH:必须是学习提醒类弹窗才点
if (paused) continue; // 暂停中不点击(防止课程自动刷新/续播)
if (!this.MATCH_KEYWORDS.some(k => txt.indexOf(k) >= 0)) {
continue;
}
// 找该弹窗内的"确定"按钮
const btn = layer.querySelector('.layui-layer-btn0, .layui-layer-btn a');
if (btn) {
const btnTxt = (btn.textContent || '').trim();
if (btnTxt === '确定' || btnTxt === 'OK' || btnTxt === '继续学习' || btnTxt === '继续') {
log.info('[弹窗] 自动点击学习提醒弹窗「' + btnTxt + '」:' + txt.substring(0, 40));
try { btn.click(); } catch (e) {}
StatusPanel.update({ detail: '已关闭学习提醒弹窗' });
// 弹窗关闭后自动恢复视频播放(弹窗可能导致视频暂停)
setTimeout(() => {
try {
const docs = [document];
const mc = $('#mainContent');
if (mc) { try { if (mc.contentDocument) docs.push(mc.contentDocument); } catch (e) {} }
docs.forEach(d => {
d.querySelectorAll('video').forEach(v => {
if (v.paused && !v.ended) {
try { v.muted = true; if (v.volume > 0) v.volume = 0; } catch (e) {}
try { v.play(); } catch (e) {}
log.info('[弹窗] 弹窗关闭后恢复视频播放');
}
});
});
} catch (e) {}
}, 1000);
}
}
}
}
// 欢迎学习弹窗(自定义HTML弹窗,非layui-layer):递归扫描所有可访问文档(5s一次全量,兜底文本变体)
if (!paused) {
try {
if (Courseware.tryCloseWelcomePopup(true)) {
StatusPanel.update({ detail: '已关闭欢迎弹窗' });
}
} catch (e) {}
}
} catch (e) {
// 扫描异常忽略,避免中断定时器
}
},
// 检测到登录超时/会话失效:暂停全部自动化,等待人工重新登录(绝不点击该弹窗)
pauseOnSessionTimeout() {
if (this.sessionTimeoutNotified) return;
this.sessionTimeoutNotified = true;
try {
StatusPanel.setRunning(false);
StatusPanel.setPaused(true);
STORE.setSessionDead(true);
StatusPanel.update({ status: '登录超时', detail: '会话已失效,请重新登录后点「开始」' });
StatusPanel.render();
} catch (e) {}
log.warn('检测到登录超时弹窗:已暂停自动化,等待人工重新登录(绝不点击该弹窗)');
try { notify('登录超时', '请重新登录后再点「开始」继续学习'); } catch (e) {}
// 3 分钟后允许再次提醒
setTimeout(() => { this.sessionTimeoutNotified = false; }, 3 * 60 * 1000);
},
};
// 递归收集顶层 + 所有同源 iframe 的 document(供 DialogDismisser 用)
function collectAllDocs(rootDoc) {
const docs = [rootDoc];
try {
const iframes = rootDoc.querySelectorAll('iframe');
for (const f of iframes) {
try {
if (f.contentDocument) docs.push(...collectAllDocs(f.contentDocument));
} catch (e) {} // 跨域 iframe 跳过
}
} catch (e) {}
return docs;
}
// 抑制浏览器"是否离开网站"确认弹窗:
// 1) 平台 beforeunload 通过全局 customNeedCloseConfirm 开关控制 → 置 false
// 2) 清掉 onbeforeunload 属性处理器与 jQuery 注册的 beforeunload(本平台 handler 多挂在 $(window).bind 上)
function suppressLeaveConfirm() {
try { window.customNeedCloseConfirm = false; } catch (e) {}
try { window.onbeforeunload = null; } catch (e) {}
try { document.onbeforeunload = null; } catch (e) {}
// 覆盖 addEventListener 注册的 beforeunload:返回空值可阻止弹窗
try { window.addEventListener('beforeunload', function(e) { delete e.returnValue; }); } catch (e) {}
try {
const jq = window.jQuery || (window.$ && window.$.fn && window.$);
if (jq && jq._data) {
const e1 = jq._data(window, 'events');
if (e1 && e1.beforeunload) delete e1.beforeunload;
const e2 = jq._data(document, 'events');
if (e2 && e2.beforeunload) delete e2.beforeunload;
}
} catch (e) {}
}
// 关闭学习空间标签页
function closeCleanly() {
suppressLeaveConfirm();
try { window.customNeedCloseConfirm = false; } catch (e) {}
try { window.onbeforeunload = null; } catch (e) {}
// 课程已学完:清除 GM 课程登记,允许 CMS 为"重刷"再次导航本窗口
try { GM_setValue('qxj_cur_course', ''); } catch (e) {}
// 多窗口模式:直接关闭标签页
try {
if (GM_getValue('qxj_multi_window', false)) {
window.close();
return;
}
} catch (e) {}
// 单窗口模式:不关闭标签页(供 CMS 复用)。用 document.open 替换文档(清掉所有
// beforeunload 处理器,不触发"离开此网站"确认框),显示"学习完成"页。
// 若 URL 已变化(CMS 已抢先导航到下一门课),则不动,让新课程正常加载。
try {
if (window.__qxjLearnUrl && location.href !== window.__qxjLearnUrl) return;
document.open();
document.write('');
document.close();
// 注意:不再 5s 后自动 window.close()——CMS 收 markDone 后还会复用本窗口导航到下一门课,
// 提前关闭会导致下一门 window.open 非用户手势被拦截。整批学完由 CMS 流程统一关闭。
} catch (e) {
try { window.stop(); } catch (e2) {}
try { window.close(); } catch (e3) {}
}
}
if (host === CONFIG.CMS_HOST) {
runCmsBranch();
} else if (host === CONFIG.LEARN_HOST) {
// 学习空间:轮询关闭欢迎弹窗(递归所有可访问文档,含嵌套 iframe)
(function watchForWelcomePopup() {
let attempts = 0;
const timer = setInterval(() => {
attempts++;
// 性能优化:顶层有弹窗痕迹才扫描;文本变体由 5s 全量扫描兜底
try {
// 暂停时不点击"开始学习"(避免课程被自动刷新/重播)
if (StatusPanel.isPaused()) return;
if (document.querySelector('.continue')) Courseware.tryCloseWelcomePopup(false);
else if (attempts % 5 === 0) Courseware.tryCloseWelcomePopup(true);
} catch (e) {}
if (attempts > 180) clearInterval(timer); // 最多持续3分钟
}, 1000);
})();
// 注入时自动启动一次
window.__qxjToken = (window.__qxjToken || 0) + 1;
runLearnBranch(window.__qxjToken);
// 监听"重启"事件(用户点「开始」按钮触发)
window.addEventListener('qxj:restartLearn', () => {
window.__qxjToken = (window.__qxjToken || 0) + 1;
const token = window.__qxjToken;
log.info('=== 学习空间重启(用户触发),token=' + token + ' ===');
StatusPanel.update({ status: '运行中', detail: '重新启动学习' });
runLearnBranch(token);
});
}
/* =====================================================================
* 分支一:CMS 站(cmatcpx-cmatc.cma.cn)
* ===================================================================== */
/* ===== CMS 学习窗口复用:避免每次 window.open 被浏览器拦截 ===== */
// 弹窗被拦截时显示可见链接;点击是真实手势,能正常打开窗口,并自动恢复运行
function showOpenLink(hostOrCourse, url, winName) {
try {
const hostEl = (hostOrCourse && hostOrCourse.li) ? hostOrCourse.li
: (hostOrCourse && hostOrCourse.appendChild) ? hostOrCourse
: document.body;
const link = document.createElement('a');
link.href = url;
link.target = winName || 'qxj_learn_win';
link.textContent = '▶ 点击学习此课程';
link.className = 'qxj-open-link';
hostEl.appendChild(link);
link.addEventListener('click', () => {
try { StatusPanel.setPaused(false); StatusPanel.render(); } catch (e) {}
});
StatusPanel.update({ detail: '弹窗被拦截,请点击蓝色链接' });
notify('弹窗被拦截', '请点击页面上的蓝色链接打开学习页面');
return link;
} catch (e) { return null; }
}
// 从 URL 提取 courseId(兼容 courseId= 与 params.courseId= 两种格式,去掉 ___ 后缀)
function extractCourseId(url) {
try {
const m = String(url || '').match(/[?&](?:params\.)?courseId=([^&]+)/);
return m ? decodeURIComponent(m[1]).replace(/___$/, '') : '';
} catch (e) { return ''; }
}
// 打开学习窗口:优先复用已打开的窗口(固定 name,不会被拦截),失败则给可见链接并暂停
function openLearnWindow(url, course, winName) {
winName = winName || 'qxj_learn_win';
try {
if (sharedLearnWin && !sharedLearnWin.closed) {
// 学习窗口已在目标课程(GM 精确登记)→ 只激活,不导航(防重复导航刷新)
const targetId = extractCourseId(url);
let gmCourse = '';
try { gmCourse = GM_getValue('qxj_cur_course', ''); } catch (e) {}
if (gmCourse && targetId && targetId === gmCourse) {
try { sharedLearnWin.focus(); } catch (e) {}
log.info('[窗口] 学习窗口已在该课程(GM确认),仅激活');
return true;
}
// 8秒内已导航过 → 跳过(防刷新循环最后一道闸)
try {
const lastNav = GM_getValue('qxj_last_nav_ts', 0);
if (Date.now() - lastNav < 8000) {
log.info('[窗口] 8秒内已导航过学习窗口,跳过本次(防刷新循环)');
return true;
}
GM_setValue('qxj_last_nav_ts', Date.now());
} catch (e) {}
log.info('[窗口] 导航学习窗口 → ' + String(url).slice(0, 130));
try { sharedLearnWin.location.href = url; sharedLearnWin.focus(); } catch (e) {}
return true;
}
} catch (e) { sharedLearnWin = null; }
let win = null;
try { win = window.open(url, winName); } catch (e) { win = null; }
if (win) {
sharedLearnWin = win;
try { GM_setValue('qxj_last_nav_ts', Date.now()); } catch (e) {}
try { win.focus(); } catch (e) {}
log.info('[窗口] 打开新学习窗口 → ' + String(url).slice(0, 130));
return true;
}
// 被拦截:暂停 + 可见链接
StatusPanel.setPaused(true);
StatusPanel.render();
showOpenLink(course, url, winName);
return false;
}
// ===== 全局主循环锁(GM 存储,跨标签页)=====
// 保证整个浏览器同时只有一个 processAllCourses 在运行,防止多个标签页并发
// (自选课页 + 班级详情页 等)各自开窗/抢窗口 → 重复窗口、反复刷新。
function acquireCmsLoop() {
try {
if (GM_getValue('qxj_cms_loop', false) === true) {
// 锁带时间戳:超过10分钟视为残留(浏览器崩溃/断电),允许接管,避免所有页面永远卡死
const ts = GM_getValue('qxj_cms_loop_ts', 0);
if (Date.now() - ts < 10 * 60 * 1000) return false;
log.warn('全局运行锁疑似残留(>10分钟),强制接管');
}
GM_setValue('qxj_cms_loop', true);
GM_setValue('qxj_cms_loop_ts', Date.now());
} catch (e) {}
cmsLoopRunning = true;
return true;
}
function releaseCmsLoop() {
try { GM_setValue('qxj_cms_loop', false); GM_setValue('qxj_cms_loop_ts', 0); } catch (e) {}
cmsLoopRunning = false;
}
// 时间加速开关(面板"加速"按钮控制):开=向服务器提交学习时长秒完成;关=只能靠真实观看
function accelEnabled() {
try { return GM_getValue('qxj_accel', true) === true; } catch (e) { return true; }
}
// 等待"班级详情页整班学完"标记(由详情页循环结束时设置,用于网络班级上级页面)
function waitClassDone(timeoutMs) {
return new Promise((resolve) => {
const started = Date.now();
(function check() {
try { if (GM_getValue('qxj_class_done', false) === true) return resolve(true); } catch (e) {}
if (Date.now() - started >= timeoutMs) return resolve(false);
setTimeout(check, 1000);
})();
});
}
async function runCmsBranch() {
// 标签页关闭/刷新时释放全局运行锁,避免锁卡死(其它页面永远等不到)
window.addEventListener('pagehide', () => releaseCmsLoop());
// 会话失效(/error/*、登录页、页面出现"登录超时"弹窗):记录标记并静默停止,绝不自动跳转
// (在超时页上不做任何可见动作:不挂面板、不弹通知、不点弹窗)
if (isSessionDead()) {
STORE.setSessionDead(true);
log.warn('检测到会话失效(' + location.pathname + '),静默停止自动跳转,等待人工重新登录');
return;
}
// 业务页(课程列表)正常渲染 → 会话有效,清除失效标记
if (shouldStartOnCms() && STORE.isSessionDead()) {
STORE.setSessionDead(false);
log.info('业务页正常渲染,会话有效,清除失效标记');
}
// 存在失效标记(如刚登出回到首页)→ 不自动跳转,等人工重新登录
if (STORE.isSessionDead()) {
log.info('存在会话失效标记,跳过自动跳转');
return;
}
// 每次进入课程列表页:重置所有脚本状态(干净启动)
try {
const resetKeys = ['learningDone', 'currentTask', 'autoStart', 'sessionDead',
'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry']; // 保留 qxj_progress:断点续学要在整页刷新后恢复
resetKeys.forEach(k => { try { GM_deleteValue(k); } catch (e) {} });
const lsKeys = ['qxj_done', 'qxj_paused', 'qxj_mode', 'qxj_mute', 'qxj_running', 'qxj_retry'];
lsKeys.forEach(k => { try { localStorage.removeItem(k); } catch (e) {} });
window.__qxjToken = (window.__qxjToken || 0) + 1;
window.__qxjLearning = false;
log.info('课程列表页加载,脚本状态已完全重置');
} catch (e) {}
// 1. 检测当前页面类型(DOM + URL 双重判断)
const isClassPage = !!$('#classList'); // 网络班级列表页
const isCoursePage = !!$('#courseList'); // 自选课页
const isClassDetail = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname); // 班级详情页
if (isClassPage) log.info('检测到网络班级页面');
if (isCoursePage) log.info('检测到自选课页面');
if (isClassDetail) log.info('检测到班级详情页');
// 2. 仅在课程列表页时启动主控(班级详情页直接进入,不跳转)
if (!isClassPage && !isCoursePage && !isClassDetail) {
// 不在任何课程页 → 尝试导航到课程页
await goMyCourses();
// 导航后再次检测
const isClassPage2 = !!$('#classList');
const isCoursePage2 = !!$('#courseList');
const isClassDetail2 = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname);
if (!isClassPage2 && !isCoursePage2 && !isClassDetail2) {
log.info('当前不在课程列表页,脚本待命。');
return;
}
}
// 监听"开始"事件(用户点面板按钮触发;全局已有主循环运行时忽略,避免重复开窗/刷新)
window.addEventListener('qxj:start', () => {
if (!acquireCmsLoop()) {
log.info('=== 已有学习流程在运行(本页或其它标签页),忽略本次「开始」 ===');
StatusPanel.update({ detail: '已有学习流程在运行,无需重复启动' });
return;
}
// 用户手势内(事件同步派发)预开学习窗口:直接打开第一个待学课程,
// 避免先弹出 about:blank 空白页,也避免后续 window.open 被浏览器拦截。
// 所有模式都预开(多窗口模式把它算作第一个标签,不会多开窗口)。
// 用唯一窗口名:绝不复用/误跳其它标签页已打开的学习窗口(防止互相导航 → 刷新循环)
try {
if (!sharedLearnWin || sharedLearnWin.closed) {
let firstUrl = '';
try {
if (document.getElementById('classList')) {
// 网络班级页:预开第一个班级详情
const firstCard = $$('#classList > li.cell')[0];
if (firstCard) {
const btn = $('a.btn-solid-primary, a.btn-theme', firstCard);
const onclick = btn ? (btn.getAttribute('onclick') || '') : '';
const m = onclick.match(/openClassByIp\(['"]([^'"]+)['"]\)/);
if (m) firstUrl = 'https://' + CONFIG.CMS_HOST + '/cms/classDetailNew.htm?classId=' + m[1] + '&prePage=getMyTrainingClass';
}
} else {
// 自选课页:找第一个未完成课程
const firstLi = $$('#courseList > li.cell').find(li => {
const proEl = li.querySelector('span[id^="pro_"]');
let progress = 0;
if (proEl) {
const t = (proEl.textContent || '').replace(/[%%]/g, '').trim();
progress = /^\d+$/.test(t) ? parseInt(t, 10) : 0;
}
return progress < 100;
});
if (firstLi) {
const btn = $$('a.btn-solid-primary', firstLi).find(a => /开始学习|继续学习/.test(a.textContent)) || $('a.btn-solid-primary', firstLi);
if (btn) firstUrl = parseEnterCourseUrl(btn);
}
}
} catch (e) {}
const win = window.open(firstUrl || 'about:blank', 'qxj_pre_' + Date.now());
if (win) {
sharedLearnWin = win;
preOpenedFirst = { win, courseId: extractCourseId(firstUrl) };
}
}
} catch (e) {}
const token = window.__qxjToken;
log.info('=== CMS 主控启动(用户触发),token=' + token + ' ===');
notify('气象自动学习已启动', '将自动遍历课程并学习未完成项');
try { StatusPanel.setPaused(false); StatusPanel.render(); } catch (e) {} // 清除残留暂停
processAllCourses(token).then(() => {
releaseCmsLoop();
// 仅当本次 token 仍是最新时才更新最终状态
if (window.__qxjToken !== token) {
log.info('旧流程(token=' + token + ')已结束,不更新状态');
return;
}
log.info('=== 全部课程处理完毕 ===');
StatusPanel.update({ status: '已完成', detail: '所有课程处理完毕' });
StatusPanel.setRunning(false);
StatusPanel.render();
notify('学习完成', '所有未完成课程已处理');
// 全部学完:关闭学习窗口
try { if (sharedLearnWin && !sharedLearnWin.closed) sharedLearnWin.close(); } catch (e) {}
}).catch((e) => {
releaseCmsLoop();
log.err('主控流程异常:', e);
});
});
// 班级详情页:自动开始学习(重入保护:用户再点「开始」不会重复启动)
const isDetailPage = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname);
if (isDetailPage) {
log.info('班级详情页,等待课程列表加载...');
StatusPanel.update({ status: '运行中', detail: '等待课程列表加载...' });
StatusPanel.setRunning(true);
StatusPanel.render();
// 等待课程列表加载
const loaded = await waitUntil(() => $$('.class2Li').length > 0, 15000);
if (!loaded) {
log.warn('课程列表未加载');
StatusPanel.update({ status: '异常', detail: '课程列表未加载' });
return;
}
const courseCount = $$('.class2Li').length;
log.info(`班级详情页课程列表已加载,${courseCount}门课程`);
if (courseCount === 0) {
StatusPanel.update({ status: '异常', detail: '课程列表为空' });
return;
}
if (cmsLoopRunning || !acquireCmsLoop()) {
// 本页已自动启动过,或其它标签页正在运行:等待锁释放后重试(最多60s)
log.info('班级详情页:等待全局运行锁...');
StatusPanel.update({ detail: '等待其它页面的学习流程结束...' });
let got = false;
for (let w = 0; w < 60 && !got; w++) {
await sleep(1000);
if (!cmsLoopRunning && acquireCmsLoop()) got = true;
}
if (!got) {
log.warn('获取运行锁超时,班级详情页暂不启动');
StatusPanel.update({ status: '待命', detail: '其它页面正在运行,请稍后点「开始」' });
return;
}
}
// 清除可能残留的暂停(旧自动暂停/弹窗拦截留下的 qxj_paused),避免主循环卡在"等待开始"
try { StatusPanel.setPaused(false); StatusPanel.setRunning(true); StatusPanel.render(); } catch (e) {}
window.__qxjToken = (window.__qxjToken || 0) + 1;
const token = window.__qxjToken;
STORE.clearDone();
StatusPanel.update({ status: '运行中', detail: '自动识别班级详情页,开始学习' });
await processAllCourses(token);
try { GM_setValue('qxj_class_done', true); } catch (e) {} // 整班学完标记(网络班级上级页等待)
releaseCmsLoop();
if (window.__qxjToken !== token) return;
// 全部学完:关闭学习窗口(避免"学习完成"页一直挂着)
try { if (sharedLearnWin && !sharedLearnWin.closed) sharedLearnWin.close(); } catch (e) {}
StatusPanel.update({ status: '已完成', detail: '所有课程处理完毕' });
StatusPanel.setRunning(false);
StatusPanel.render();
notify('学习完成', '所有课程已处理完毕');
return;
}
// 自选课页面:若之前已在运行则自动恢复
if (StatusPanel.isRunning() && !StatusPanel.isPaused()) {
if (!acquireCmsLoop()) { log.info('全局运行锁被占用,跳过自动恢复'); return; }
window.__qxjToken = (window.__qxjToken || 0) + 1;
const token = window.__qxjToken;
STORE.clearDone();
log.info('=== CMS 主控自动恢复,token=' + token + ' ===');
StatusPanel.update({ status: '运行中', detail: '自动恢复中' });
try {
await processAllCourses(token);
} finally {
releaseCmsLoop();
}
if (window.__qxjToken !== token) return;
StatusPanel.update({ status: '已完成', detail: '所有课程处理完毕' });
StatusPanel.setRunning(false);
StatusPanel.render();
notify('学习完成', '所有未完成课程已处理');
// 全部学完:关闭学习窗口
try { if (sharedLearnWin && !sharedLearnWin.closed) sharedLearnWin.close(); } catch (e) {}
} else {
StatusPanel.update({ status: '待命', detail: '点击「开始」启动学习' });
log.info('脚本已就绪,等待点击「开始」');
}
}
// 检查当前 token 是否仍为最新(旧流程自动退出)
function isCurrentToken(token) {
return window.__qxjToken === token;
}
async function goMyCourses() {
// 已在自选课页或网络班级页
if ($('#courseList') || $('#classList')) return;
if (STORE.isSessionDead() || isSessionErrorPage()) {
log.warn('会话失效,不跳转');
return;
}
suppressLeaveConfirm();
// 找左侧菜单:优先"我的自选课",其次"我的网络班级"
let link = $('a.btn-link.myCourse.active') || $('a.btn-link.myCourse')
|| $('a.btn-link.getMyTrainingClass.active') || $('a.btn-link.getMyTrainingClass');
if (link) {
log.info('点击左侧:' + link.textContent.trim());
link.click();
await waitUntil(() => $('#courseList') || $('#classList'), CONFIG.WAIT_TIMEOUT);
return;
}
// 兜底:跳转自选课
log.info('未找到按钮,直接访问自选课地址');
location.href = 'https://' + CONFIG.CMS_HOST + CONFIG.MY_COURSE_HREF;
}
// 点击"未完成"标签页,让服务器只返回未完成的课程(避免遍历大量已完成课程浪费时间)
async function switchToUnfinishedTab() {
const notFinishTab = $('#getNotFinish');
if (!notFinishTab) {
log.debug('未找到"未完成"标签,跳过切换');
return;
}
// 检查当前是否已经在"未完成"标签(active 状态)
const activeLink = notFinishTab.querySelector('.tabs-link.active');
if (activeLink) {
log.info('已在"未完成"标签页,无需切换');
return;
}
log.info('普通模式:切换到"未完成"标签,过滤已完成课程');
Diag.add('[流程] 切换到"未完成"标签');
StatusPanel.update({ detail: '切换到"未完成"标签' });
try {
// 多种方式尝试触发 getCourseNoFinish()
// 1. 直接调用平台 JS 函数(最可靠)
if (typeof getCourseNoFinish === 'function') {
log.info('直接调用 getCourseNoFinish()');
getCourseNoFinish();
} else {
// 2. 点击 的 子元素(a.tabs-link)
const link = notFinishTab.querySelector('a.tabs-link') || notFinishTab.querySelector('a');
if (link) {
log.info('点击 标签触发');
link.click();
} else {
// 3. 兜底:点击
notFinishTab.click();
}
}
// 等待 AJAX 刷新课程列表
await sleep(1000);
// 验证标签是否切换成功(active 应在"未完成"上)
const checkActive = notFinishTab.querySelector('.tabs-link.active');
if (checkActive) {
log.info('标签切换成功:"未完成"已激活');
} else {
log.warn('标签切换可能未成功,尝试兜底调用');
// 兜底:再次尝试直接调用
if (typeof getCourseNoFinish === 'function') {
getCourseNoFinish();
await sleep(1000);
}
}
await waitUntil(() => $('#courseList > li.cell'), CONFIG.WAIT_TIMEOUT);
await sleep(500);
log.info('已切换到"未完成"标签页,课程列表已刷新');
Diag.add('[流程] "未完成"标签切换完成');
} catch (e) {
log.warn('切换"未完成"标签失败:', e);
Diag.add('[流程] 标签切换异常:' + e);
}
}
async function processAllCourses(token) {
let page = 1;
let totalLearned = 0;
let totalAllCourses = 0;
Diag.add('[流程] CMS 遍历开始(token=' + token + ')');
// 恢复断点续学进度
const saved = STORE.loadProgress();
let resumeCourse = ''; // 需要恢复的课程标题
let resumeIdx = 0; // 该课程在当前页的索引
if (saved && saved.page >= 1) {
page = saved.page;
totalLearned = saved.learned || 0;
totalAllCourses = saved.total || 0;
resumeCourse = saved.courseTitle || '';
resumeIdx = saved.courseIdx || 0;
log.info(`【断点续学】恢复到第${page}页,已学${totalLearned}门,目标课程:${resumeCourse || '(从头)'}`);
StatusPanel.update({ detail: `断点续学:第${page}页${resumeCourse ? ' → ' + resumeCourse.slice(0, 15) : ''}` });
}
// === 网络班级页面:点击"未通过"→ 选择"培训中" → 进入班级 → 读取课程 ===
if ($('#classList')) {
log.info('【网络班级】进入网络班级流程');
StatusPanel.update({ detail: '网络班级:准备切换标签' });
// 1. 点击"未通过"标签
const noPassTab = $('#getNoPassClass');
if (noPassTab) {
log.info('点击"未通过"标签');
noPassTab.click();
await sleep(1500);
}
// 2. 选择"培训中"状态(code="1")
const trainingStatus = $('span.code-1, span[code="1"]');
if (trainingStatus) {
log.info('选择"培训中"状态');
trainingStatus.click();
await sleep(1500);
}
// 3. 读取班级列表
await waitUntil(() => $('#classList > li.cell'), CONFIG.WAIT_TIMEOUT);
const classCards = $$('#classList > li.cell');
log.info(`【网络班级】找到 ${classCards.length} 个班级`);
// 4. 逐个进入班级学习
for (const card of classCards) {
if (!isCurrentToken(token)) return;
await StatusPanel.waitIfPaused();
if (!isCurrentToken(token)) return;
const titleEl = $('.title', card);
const className = titleEl ? titleEl.textContent.trim() : '未知班级';
const enterBtn = $('a.btn-solid-primary, a.btn-theme', card);
StatusPanel.update({ course: className, detail: '进入班级' });
log.info(`【网络班级】进入:${className}`);
// 点击"进入班级"
if (enterBtn) {
const onclick = enterBtn.getAttribute('onclick') || '';
const classIdMatch = onclick.match(/openClassByIp\(['"]([^'"]+)['"]\)/);
if (classIdMatch) {
// 用 window.open 打开班级详情页
const classUrl = '/cms/classDetailNew.htm?classId=' + classIdMatch[1] + '&prePage=getMyTrainingClass';
const fullUrl = 'https://' + CONFIG.CMS_HOST + classUrl;
STORE.clearDone();
STORE.setTask(className);
try { GM_setValue('qxj_class_done', false); } catch (e) {} // 重置整班完成标记
if (!openLearnWindow(fullUrl, card, 'qxj_class_win')) {
await StatusPanel.waitIfPaused();
if (!openLearnWindow(fullUrl, card, 'qxj_class_win')) { log.warn('仍无法打开学习窗口,跳过:' + className); continue; }
}
log.info(`[网络班级] 已打开班级:${className}`);
// 等待班级详情页整班学完(详情页自动启动自己的循环并设置 qxj_class_done)
await sleep(5000); // 等页面加载
releaseCmsLoop(); // 让班级详情页能获取运行锁、自动启动
const ok = await waitClassDone(CONFIG.CMS_WAIT_LEARN_SEC * 1000);
if (!acquireCmsLoop()) { log.warn('运行锁被其它页面占用,网络班级流程停止'); return; }
if (ok) log.info(`[网络班级] 班级完成:${className}`);
else log.warn(`[网络班级] 班级超时:${className}`);
STORE.clearDone();
} else {
// 兜底:直接点击
enterBtn.click();
await sleep(5000);
}
}
}
log.info('【网络班级】所有班级处理完毕');
return;
}
// === 班级详情页:读取课程列表 ===
const isClassDetail = !!$('#classDetailCourseId') || /classDetailNew|classCourseDetail/.test(location.pathname);
if (isClassDetail || /classDetailNew/.test(location.pathname)) {
log.info('【班级详情】读取课程列表');
// 等待课程列表加载(可能需要几秒)
await waitUntil(() => $$('.class2Li').length > 0, 15000);
// 普通模式:切到"未完成"过滤(只显示未完成的课程,聚焦剩余课程、减少翻页)
if (!StatusPanel.isReview()) {
try {
const notFinishTab = $('#courseStatusUnfinished') || document.querySelector('[onclick*="courseStatusUnfinished"]');
if (notFinishTab) {
notFinishTab.click();
await sleep(1500);
await waitUntil(() => $$('.class2Li').length > 0, CONFIG.WAIT_TIMEOUT);
log.info('【班级详情】已切换到"未完成"过滤');
}
} catch (e) {}
}
// 断页续学:若上次翻页触发整页刷新(脚本重跑回到第1页),跳回目标页
let classPage = 1;
try {
const wantPage = parseInt(GM_getValue('qxj_class_page', ''), 10) || 0;
if (wantPage > 1) {
const curPg = ((document.querySelector('a.pagin-link.pagin-cur, .pagin-cur') || {}).textContent || '').trim();
const pageLink = Array.from(document.querySelectorAll('a.pagin-link, a[onclick*="changePage"], a[href*="changePage"]')).find(a => parseInt(a.textContent.trim(), 10) === wantPage);
if (pageLink && curPg !== String(wantPage)) {
log.info(`【班级详情】检测到上次翻页到第${wantPage}页,先跳回`);
pageLink.click();
await sleep(1500);
}
classPage = wantPage;
}
} catch (e) {}
// 支持分页:逐页读取 class2Li 课程并学习(平台课程列表可能有多页)
let emptyRetry = 0;
while (classPage <= 50) {
const finalCourses = $$('.class2Li');
if (finalCourses.length === 0) {
emptyRetry++;
log.warn(`【班级详情】第${classPage}页未找到课程(重试${emptyRetry}/3)`);
if (emptyRetry >= 3) break;
await sleep(3000);
continue;
}
emptyRetry = 0;
log.info(`【班级详情】第${classPage}页找到 ${finalCourses.length} 门课程`);
totalAllCourses += finalCourses.length;
for (const li of finalCourses) {
if (!isCurrentToken(token)) return;
await StatusPanel.waitIfPaused();
if (!isCurrentToken(token)) return;
const titleEl = $('h2 a, .v2-title', li);
const title = titleEl ? titleEl.textContent.trim() : '未知课程';
const proEl = li.querySelector('em[id^="pro_"]') || li.querySelector('span[id^="pro_"]');
let progress = 0;
if (proEl) {
const t = (proEl.textContent || '').replace(/[%%]/g, '').trim();
progress = /^\d+$/.test(t) ? parseInt(t, 10) : 0;
}
const btn = $('a.btn-theme, a.btn-solid-primary', li);
const btnText = btn ? (btn.textContent || '').trim() : '';
if (!proEl && !progress) {
if (btnText === '继续学习') progress = 50;
else if (!btnText || btnText === '已学完') progress = 100;
}
// 普通模式跳过已完成
if (progress >= 100 && !StatusPanel.isReview()) {
totalLearned++;
StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses });
continue;
}
// 保存断点 → 打开学习
STORE.saveProgress(0, totalLearned, totalAllCourses, title, 0);
StatusPanel.update({ course: title, detail: '准备学习' });
// 解析学习URL
const onclick = btn ? (btn.getAttribute('onclick') || '') : '';
const urlMatch = onclick.match(/enterCourse\(\s*['"]([^'"]+)['"]/);
if (urlMatch) {
const url = urlMatch[1].replace(/&/g, '&');
// 防重复开窗:本页没有可用学习窗口、且该课程已在其它窗口学习中 → 跳过
try {
const gmCourse = GM_getValue('qxj_cur_course', '');
const targetId = extractCourseId(url);
if ((!sharedLearnWin || sharedLearnWin.closed) && gmCourse && targetId && gmCourse === targetId) {
log.info(`[班级课程] ${title} 已在其它窗口学习中,跳过避免重复开窗`);
totalLearned++;
continue;
}
} catch (e) {}
STORE.clearDone();
STORE.setTask(title);
if (!openLearnWindow(url, li)) {
await StatusPanel.waitIfPaused();
if (!openLearnWindow(url, li)) { totalLearned++; continue; }
}
log.info(`[班级课程] 打开:${title}`);
const ok = await waitLearnDone(CONFIG.CMS_WAIT_LEARN_SEC * 1000);
if (ok) log.info(`[班级课程] 完成:${title}`);
else log.warn(`[班级课程] 超时:${title}`);
STORE.clearDone();
// 跳转缓冲:12 秒倒计时(给下一门视频加载留时间),面板显示倒计时
for (let s = 12; s > 0; s--) {
StatusPanel.update({ detail: `准备下一门课程… ${s}s` });
await sleep(1000);
}
totalLearned++;
StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses });
} else {
log.warn(`[班级课程] 未解析到URL:${title}`);
totalLearned++;
}
}
// 翻到下一页(平台分页组件:a.pagin-link / changePage 链接)
const pageAnchors = Array.from(document.querySelectorAll('a.pagin-link, a[onclick*="changePage"], a[href*="changePage"]'));
let nextLink = pageAnchors.find(a => /下一页|下页/.test(a.textContent || '')) || null;
if (!nextLink) {
const cur = document.querySelector('a.pagin-link.pagin-cur, .pagin-cur');
const curNum = cur ? parseInt(cur.textContent.trim(), 10) : 0;
nextLink = pageAnchors.find(a => parseInt(a.textContent.trim(), 10) === curNum + 1) || null;
}
if (!nextLink || /disabled/i.test(nextLink.className || '')) {
log.info(`【班级详情】没有下一页,共 ${classPage} 页`);
try { GM_setValue('qxj_class_page', ''); } catch (e) {}
break;
}
// 记录当前页第一条课程特征,用于确认翻页成功(AJAX 内容替换)
const sigBefore = ($$('.class2Li')[0] || {}).outerHTML || '';
try { GM_setValue('qxj_class_page', String(classPage + 1)); } catch (e) {} // 整页刷新时跳回用
nextLink.click();
log.info(`【班级详情】点击翻页 → 第 ${classPage + 1} 页`);
const pageOk = await waitUntil(() => {
const first = $$('.class2Li')[0];
return first && first.outerHTML !== sigBefore;
}, CONFIG.WAIT_TIMEOUT);
if (!pageOk) {
log.warn(`【班级详情】翻页后课程列表未变化,可能整页刷新,停止翻页`);
try { GM_setValue('qxj_class_page', ''); } catch (e) {}
break;
}
await sleep(500);
classPage++;
}
// 全部刷完/重刷闭环:复查还有未完成的课程则再来一轮(带轮次上限,防死循环)
try {
const allFinish = GM_getValue('qxj_all_finish', false);
const maxRounds = allFinish ? 5 : Math.min(3, StatusPanel.getRetry() + 1);
if (classRetryCount < maxRounds) {
// 重新切回"未完成"过滤,直接数页面上未完成的课程(默认列表第一页数不到第二页的未完成课)
let left = 0;
try {
const tab = $('#courseStatusUnfinished') || document.querySelector('[onclick*="courseStatusUnfinished"]');
if (tab) { tab.click(); await sleep(1500); }
left = $$('.class2Li').filter(li => {
const proEl = li.querySelector('em[id^="pro_"]') || li.querySelector('span[id^="pro_"]');
if (!proEl) return false;
const t = (proEl.textContent || '').replace(/[%%]/g, '').trim();
return /^\d+$/.test(t) && parseInt(t, 10) < 100;
}).length;
} catch (e) {}
if (left > 0 && isCurrentToken(token)) {
classRetryCount++;
log.warn(`【班级详情】还有 ${left} 门未完成(第${classRetryCount}轮),继续学习`);
StatusPanel.update({ detail: `还有${left}门未完成,第${classRetryCount}轮` });
await sleep(2000);
return await processAllCourses(token);
}
}
} catch (e) {}
classRetryCount = 0;
log.info('【班级详情】课程处理完毕');
return;
}
// === 自选课页面 ===
// 普通模式:先点击"未完成"标签
if (!StatusPanel.isReview() && isCurrentToken(token)) {
await switchToUnfinishedTab();
if (!isCurrentToken(token)) return;
}
// 诊断:打印当前标签状态和课程列表信息
try {
const activeTab = $('.tabs-link.active');
const activeTabText = activeTab ? activeTab.textContent.trim() : '(无)';
const courseCount = $$('#courseList > li.cell').length;
const totalPages = getTotalPages();
log.info(`[诊断] 当前标签:${activeTabText},课程数:${courseCount},总页数:${totalPages}`);
Diag.add('[诊断] 标签=' + activeTabText + ' 课程数=' + courseCount + ' 总页=' + totalPages);
// 初始化面板总页数
StatusPanel.update({ totalPages: totalPages, learnedCourses: totalLearned, totalCourses: totalAllCourses });
} catch (e) {}
// 断点续学:跳到保存的页码
if (page > 1) {
log.info(`【断点续学】跳转到第${page}页`);
for (let p = 1; p < page; p++) {
if (!isCurrentToken(token)) return;
if (!await gotoNextPage()) { log.warn('断点续学翻页失败,从第1页开始'); page = 1; break; }
}
}
while (page <= CONFIG.MAX_PAGES) {
// token 失效则退出(被新启动取代)
if (!isCurrentToken(token)) { log.info('旧流程退出(token=' + token + ')'); return; }
// 暂停闸门
await StatusPanel.waitIfPaused();
if (!isCurrentToken(token)) return;
// 获取总页数(每页可能更新)
const curTotalPages = getTotalPages();
StatusPanel.update({ page: `第${page}页`, totalPages: curTotalPages, detail: '读取课程列表' });
log.info(`==== 读取第 ${page} 页 ====`);
const cards = readCardsOnPage();
totalAllCourses += cards.length;
// 收集待学课程
let idx = 0;
let resumed = !resumeCourse;
const pending = [];
for (const c of cards) {
idx++;
if (!resumed) {
if (resumeCourse && (c.title === resumeCourse || c.title.indexOf(resumeCourse) >= 0 || resumeCourse.indexOf(c.title) >= 0)) {
resumed = true;
log.info(`【断点续学】找到目标:${c.title}`);
} else {
totalLearned++;
continue;
}
}
if (c.progress >= 100 && !StatusPanel.isReview()) {
totalLearned++;
continue;
}
pending.push(c);
}
if (pending.length === 0) continue;
// 读取窗口数
const winCount = GM_getValue('qxj_win_count', 1);
if (winCount > 1) {
// === 多窗口模式 ===
GM_setValue('qxj_multi_window', true); // 标记多窗口模式(学习脚本据此决定关闭标签页而非跳转)
log.info(`【多窗口】${winCount}个窗口,${pending.length}门待学`);
let courseIdx = 0;
const tabs = []; // {win, course, courseId}
// 优先使用手势预开的第一个窗口(在用户点击内同步打开,不会被拦截),算作第一个标签
if (preOpenedFirst && preOpenedFirst.win && !preOpenedFirst.win.closed && courseIdx < pending.length) {
const c = pending[courseIdx++];
const cid = preOpenedFirst.courseId || extractCourseId(parseEnterCourseUrl(c.btn));
tabs.push({ win: preOpenedFirst.win, course: c, courseId: cid });
StatusPanel.update({ detail: `打开1/${winCount}:${c.title}` });
log.info(`[多窗口] 使用预开窗口:${c.title}`);
preOpenedFirst = null;
}
// 逐个打开剩余标签页,每个间隔10秒
for (let i = tabs.length; i < winCount && courseIdx < pending.length; i++) {
if (!isCurrentToken(token)) return;
const c = pending[courseIdx++];
const url = parseEnterCourseUrl(c.btn);
if (!url) { totalLearned++; i--; continue; }
STORE.clearDone();
STORE.setTask(c.title);
let win = window.open(url, '_blank');
if (!win) {
StatusPanel.setPaused(true);
StatusPanel.render();
notify('弹窗被拦截', '请点击地址栏弹窗图标选择「始终允许」,然后点继续');
await StatusPanel.waitIfPaused();
win = window.open(url, '_blank');
if (!win) { totalLearned++; i--; continue; }
}
try { win.focus(); } catch (e) {}
tabs.push({ win, course: c, courseId: extractCourseId(url) });
StatusPanel.update({ detail: `打开${i + 1}/${winCount}:${c.title}` });
log.info(`[多窗口] 打开并激活:${c.title}`);
if (i < winCount - 1) await sleep(10000);
}
// 监控:轮流激活 + 检测完成
log.info(`[多窗口] ${tabs.length}个标签页已打开,开始监控`);
while (tabs.length > 0) {
if (!isCurrentToken(token)) return;
await StatusPanel.waitIfPaused();
if (!isCurrentToken(token)) return;
// 轮流激活每个标签页
for (let i = 0; i < tabs.length; i++) {
try { tabs[i].win.focus(); } catch (e) {}
await sleep(2000); // 激活2秒让视频播放
}
// 检查完成(按课程ID匹配真正完成的那扇窗口,避免把全局完成标记归给错误的窗口
// ——否则一门课学完会把其它还在学的窗口误关/误跳)
if (STORE.isDone()) {
let doneCourse = '';
try { doneCourse = GM_getValue('qxj_done_course', ''); } catch (e) {}
let doneIdx = -1;
for (let i = 0; i < tabs.length; i++) {
if (tabs[i].courseId && doneCourse && tabs[i].courseId === doneCourse) { doneIdx = i; break; }
}
if (doneIdx === -1) {
// 完成标记可能来自已自行关闭的窗口 → 仅清除标记
STORE.clearDone();
try { GM_setValue('qxj_done_course', ''); } catch (e) {}
continue;
}
const doneTab = tabs[doneIdx];
log.info(`[多窗口] 完成:${doneTab.course.title}`);
// 多等1分钟让服务器确认(99%问题)
for (let w = 60; w > 0; w -= 10) {
StatusPanel.update({ detail: `等待服务器确认(${w}秒):${doneTab.course.title}` });
await sleep(10000);
}
totalLearned++;
STORE.clearDone();
try { GM_setValue('qxj_done_course', ''); } catch (e) {}
STORE.saveProgress(page, totalLearned, totalAllCourses, '', 0);
StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses });
if (courseIdx < pending.length) {
// 还有课 → 跳转到下一门(窗口已自行关闭则开新窗口补课)
const nc = pending[courseIdx++];
const nurl = parseEnterCourseUrl(nc.btn);
if (nurl) {
STORE.clearDone();
STORE.setTask(nc.title);
if (doneTab.win.closed) {
const nwin = window.open(nurl, '_blank');
if (nwin) { doneTab.win = nwin; try { nwin.focus(); } catch (e) {} }
} else {
try { doneTab.win.location.href = nurl; } catch (e) {}
}
doneTab.course = nc;
doneTab.courseId = extractCourseId(nurl);
log.info(`[多窗口] 补新课程:${nc.title}`);
}
} else {
// 没有新课 → 关闭标签页
try { doneTab.win.close(); } catch (e) {}
tabs.splice(doneIdx, 1);
}
}
StatusPanel.update({ detail: `监控中(${tabs.length}个窗口)已完成${totalLearned}门` });
await sleep(10000);
}
GM_setValue('qxj_multi_window', false); // 清除多窗口标记
preOpenedFirst = null;
log.info(`【多窗口】全部完成`);
} else {
// === 单窗口模式:逐个学习 ===
for (const c of pending) {
if (!isCurrentToken(token)) return;
await StatusPanel.waitIfPaused();
if (!isCurrentToken(token)) return;
STORE.saveProgress(page, totalLearned, totalAllCourses, c.title, 0);
StatusPanel.update({ page: `第${page}页`, totalPages: curTotalPages, course: c.title, detail: '准备学习' });
await studyOneCourseWithRetry(c, token);
if (!isCurrentToken(token)) return;
totalLearned++;
await sleep(200);
StatusPanel.update({ learnedCourses: totalLearned, totalCourses: totalAllCourses });
STORE.saveProgress(page, totalLearned, totalAllCourses, '', 0);
}
}
// 翻页
const total = curTotalPages;
if (page >= total) { log.info(`已是最后一页,共 ${total} 页。`); break; }
if (!await gotoNextPage()) break;
page++;
}
if (isCurrentToken(token)) {
STORE.clearProgress();
const allFinish = GM_getValue('qxj_all_finish', false);
const maxRetry = StatusPanel.getRetry();
// 检查未完成课程数
const finalCheck = await fetchCourseList();
const unfinished = finalCheck ? finalCheck.filter(x => !x.done).length : -1;
if (unfinished > 0 && (allFinish || maxRetry > 0)) {
// "全部刷完"开启 或 重刷次数>0 → 继续处理未完成课程
const maxRounds = allFinish ? 999 : maxRetry; // 全部刷完=无限循环
for (let r = 1; r <= maxRounds; r++) {
if (!isCurrentToken(token)) return;
log.info(`【继续学习】第${r}轮,剩余${unfinished}门未完成`);
StatusPanel.update({ detail: `继续学习第${r}轮,剩余${unfinished}门` });
notify('继续学习', `还有${unfinished}门未完成,开始第${r}轮`);
await sleep(2000);
if (!isCurrentToken(token)) return;
// 重新遍历
await processAllCourses(token);
if (!isCurrentToken(token)) return;
// 再次检查
const recheck = await fetchCourseList();
const remain = recheck ? recheck.filter(x => !x.done).length : -1;
if (remain === 0) {
log.info(`【继续学习】第${r}轮后全部完成`);
break;
}
if (remain >= unfinished) {
log.warn(`【继续学习】第${r}轮后数量未变(${remain}),停止`);
break;
}
unfinished = remain;
}
}
// 最终状态
const finalRecheck = await fetchCourseList();
const finalUnfinished = finalRecheck ? finalRecheck.filter(x => !x.done).length : -1;
if (finalUnfinished === 0) {
StatusPanel.update({ status: '已完成', detail: '所有课程已学完' });
notify('学习完成', '所有课程已处理完毕');
} else if (finalUnfinished > 0) {
StatusPanel.update({ detail: `处理完毕,仍剩${finalUnfinished}门未完成` });
log.info(`处理完毕,仍剩${finalUnfinished}门未完成`);
} else {
StatusPanel.update({ detail: `已处理${totalLearned}门课程` });
}
StatusPanel.setRunning(false);
StatusPanel.render();
// 诚实汇报:还有哪些课未完成
try {
const cards = await fetchCourseList();
const left = cards ? cards.filter(x => !x.done) : [];
if (left.length) {
log.warn('剩余未完成课程 ' + left.length + ' 门:' + left.map(x => x.title + '(' + (x.progress || '?') + '%)').join('、'));
Diag.add('[课程] 剩余未完成:' + left.map(x => x.title).join('、'));
}
} catch (e) {}
}
}
function readCardsOnPage() {
const lis = $$('#courseList > li.cell');
return lis.map((li, i) => {
const titleEl = $('.title', li);
const title = (titleEl && (titleEl.getAttribute('title') || titleEl.textContent.trim())) || '未知课程';
const proEl = $('span[id^="pro_"]', li);
let progress = 0;
if (proEl) {
const t = (proEl.textContent || '').replace(/[%%]/g, '').trim();
progress = /^\d+$/.test(t) ? parseInt(t, 10) : 0;
}
const btn = $$('a.btn-solid-primary', li).find(a => /开始学习|继续学习/.test(a.textContent)) || $('a.btn-solid-primary', li);
const btnText = btn ? (btn.textContent || '').trim() : '';
// 进度数值缺失时用按钮文本兜底:"开始学习"=0%、"继续学习"=进行中(<100)、无按钮=已完成
if (!proEl && !progress) {
if (btnText === '继续学习') progress = 50;
else if (!btnText) progress = 100;
}
return { index: i, title, progress, li, btn, btnText };
});
}
// 查找分页容器(兼容 #commonPage 和 .pg-btns 两种结构)
function findPager() {
// 空指针防护:.pg-btns 不存在时 $() 返回 null,直接 .closest 会抛异常导致主流程崩溃
const common = $('#commonPage');
if (common) return common;
const pg = $('.pg-btns');
if (pg) return pg.closest('div') || pg;
return null;
}
function getTotalPages() {
// 优先用 pg-total .num(如"共 2 页")
const totalNum = $('.pg-total .num');
if (totalNum && /^\d+$/.test(totalNum.textContent.trim())) {
return parseInt(totalNum.textContent.trim(), 10);
}
// 兜底:数页码链接
const pager = findPager();
if (!pager) return 1;
const nums = $$('a.pg-link', pager).map(a => a.textContent.trim()).filter(t => /^\d+$/.test(t));
return nums.length || 1;
}
async function gotoNextPage() {
const next = $('a.pg-next');
if (!next) return false;
if ((next.className || '').includes('disabled')) { log.info('下一页 disabled。'); return false; }
const before = $('a.pg-link.active');
const beforeTxt = before ? before.textContent.trim() : '';
next.click();
log.info('点击『下一页』');
await waitUntil(() => {
const cur = $('a.pg-link.active');
return (cur && cur.textContent.trim() !== beforeTxt) || !$('#courseList > li.cell');
}, CONFIG.WAIT_TIMEOUT);
await waitUntil(() => $('#courseList > li.cell'), CONFIG.WAIT_TIMEOUT);
await sleep(800);
return true;
}
async function studyOneCourse(course) {
log.info(`>>>>> 开始学习:${course.title}(进度 ${course.progress}%)`);
if (!course.btn) { log.warn('未找到开始学习按钮,跳过'); return; }
// 暂停闸门
await StatusPanel.waitIfPaused();
StatusPanel.update({ detail: '正在打开学习空间', progress: course.progress + '%' });
// 优先解析 onclick 中的学习空间 URL,强制新标签打开,保留 CMS 主控页
const url = parseEnterCourseUrl(course.btn);
// 防重复开窗:本页没有可用学习窗口、且该课程已在其它窗口学习中 → 跳过
try {
const gmCourse = GM_getValue('qxj_cur_course', '');
const targetId = extractCourseId(url);
if ((!sharedLearnWin || sharedLearnWin.closed) && gmCourse && targetId && gmCourse === targetId) {
log.info(`[课程] ${course.title} 已在其它窗口学习中,跳过避免重复开窗`);
return;
}
} catch (e) {}
STORE.clearDone();
STORE.setTask(course.title);
Diag.add('[课程] 打开:' + course.title + '(进度' + course.progress + '%)url=' + (url ? '解析成功' : '未解析到'));
if (url) {
log.debug('打开学习空间:', url);
if (!openLearnWindow(url, course)) {
await StatusPanel.waitIfPaused();
if (!openLearnWindow(url, course)) {
log.warn(`仍无法打开学习窗口,跳过:${course.title}`);
return;
}
}
} else {
// 解析不到 URL:直接点击会导航走 CMS 主控页,学完无法继续下一门 → 安全跳过并提示
log.warn(`未解析到学习空间 URL,跳过:${course.title}`);
StatusPanel.update({ detail: '未解析到学习空间 URL,跳过:' + course.title });
Diag.add('[课程] 未解析到 URL,跳过:' + course.title);
notify('无法打开学习空间', '未解析到学习 URL,请手动学习:' + course.title);
return;
}
// 轮询等待学习空间标签完成(期间持续响应暂停),超时自动跳下一门
log.info('等待学习空间完成……(超时 ' + CONFIG.CMS_WAIT_LEARN_SEC + 's 自动跳过)');
StatusPanel.update({ detail: '学习空间学习中……(超时自动跳过)' });
const ok = await waitLearnDone(CONFIG.CMS_WAIT_LEARN_SEC * 1000);
if (ok) {
log.info(`学习空间已完成:${course.title}`);
} else {
log.warn(`等待超时(${CONFIG.CMS_WAIT_LEARN_SEC}s),自动跳到下一门:${course.title}`);
StatusPanel.update({ detail: '等待超时,跳下一门', progress: course.progress + '%' });
}
STORE.clearDone();
// 跳转缓冲:12 秒倒计时(给下一门视频加载留时间),面板显示倒计时
for (let s = 12; s > 0; s--) {
StatusPanel.update({ detail: `准备下一门课程… ${s}s` });
await sleep(1000);
}
}
// 等待学习空间完成:轮询(1s) + storage 事件(后台标签也能即时收到)+ 回到前台立即复查
function waitLearnDone(timeoutMs) {
return new Promise((resolve) => {
const started = Date.now();
let settled = false;
const cleanup = () => {
window.removeEventListener('storage', onStorage);
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
const finish = (ok) => {
if (settled) return;
settled = true;
cleanup();
resolve(ok);
};
const check = async () => {
await StatusPanel.waitIfPaused();
if (STORE.isDone()) return finish(true);
if (Date.now() - started >= timeoutMs) return finish(false);
setTimeout(check, 600);
};
const onStorage = (e) => {
if (e.key === 'qxj_done' && e.newValue) finish(true);
};
const onVisible = () => { if (!document.hidden) check(); };
window.addEventListener('storage', onStorage);
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
check();
});
}
// 用 GM_xmlhttpRequest 抓取课程列表最新 HTML,解析各卡片完成状态
// (按钮文本"开始学习/继续学习"=未完成;无学习按钮或"已学完/已完成"=已完成)
function fetchCourseList() {
return new Promise((resolve) => {
const url = 'https://' + CONFIG.CMS_HOST + CONFIG.MY_COURSE_HREF;
try {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: CONFIG.WAIT_TIMEOUT,
onload: (res) => {
try {
const doc = new DOMParser().parseFromString(res.responseText || '', 'text/html');
const cards = [];
const lis = Array.from(doc.querySelectorAll('#courseList > li.cell'));
for (const li of lis) {
const titleEl = li.querySelector('.title');
const title = (titleEl && (titleEl.getAttribute('title') || titleEl.textContent.trim())) || '';
const proEl = li.querySelector('span[id^="pro_"]');
let progress = 0;
if (proEl) {
const t = (proEl.textContent || '').replace(/[%%]/g, '').trim();
if (/^\d+$/.test(t)) progress = parseInt(t, 10);
}
const btn = Array.from(li.querySelectorAll('a.btn-solid-primary')).find(a => /开始学习|继续学习/.test(a.textContent)) || li.querySelector('a.btn-solid-primary');
const btnText = btn ? (btn.textContent || '').trim() : '';
const done = progress >= 100 || (!btnText) || /已学完|已完成/.test(btnText);
cards.push({ title, progress, btnText, done });
}
resolve(cards);
} catch (e) { resolve(null); }
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
} catch (e) { resolve(null); }
});
}
// 学一门课 + 抓取课程列表复查:仍未完成(按钮仍"开始/继续学习")→ 按重刷次数重学
async function studyOneCourseWithRetry(course, token) {
const maxRe = StatusPanel.getRetry();
for (let r = 0; r <= maxRe; r++) {
await studyOneCourse(course);
if (token !== undefined && !isCurrentToken(token)) return;
const cards = await fetchCourseList();
if (!cards || !cards.length) return; // 抓不到列表就不复查
const mine = cards.find(c => c.title && course.title && (c.title === course.title || c.title.indexOf(course.title) >= 0 || course.title.indexOf(c.title) >= 0));
if (mine && mine.done) {
log.info(` 【确认】${course.title} 已真正完成(${mine.progress || 100}%)`);
Diag.add('[课程] 确认完成:' + course.title);
return;
}
if (r < maxRe) {
log.warn(` ${course.title} 学完复查仍未完成(第${r + 1}/${maxRe}遍),重学`);
Diag.add('[课程] 复查未完成,重学:' + course.title);
} else {
log.warn(` ${course.title} 复查仍未完成(已达上限 ${maxRe}),留待人工处理`);
Diag.add('[课程] 复查未完成(上限' + maxRe + '):' + course.title);
}
}
}
function parseEnterCourseUrl(btn) {
// onclick 形如:enterCourse('https://pxkckj.../signLearn.action?...','courseId','1','','xxx','')
// 逐级向上查找(onclick 可能挂在父级 a/li 上,直接点击会导航走主控页)
let node = btn;
for (let depth = 0; node && depth < 5; depth++, node = node.parentElement) {
const oc = node.getAttribute && node.getAttribute('onclick');
if (oc) {
const m = oc.match(/enterCourse\(\s*['"]([^'"]+)['"]/);
if (m) return m[1].replace(/&/g, '&');
const w = oc.match(/(?:window\.open|openNewWindow|openWindow)\(\s*['"]([^'"]+)['"]/);
if (w) return w[1].replace(/&/g, '&');
}
}
// 兜底:按钮本身就是学习空间链接
const href = btn.getAttribute('href') || '';
if (/^https?:\/\//.test(href) && href.indexOf('learnspace') >= 0) return href;
// 整卡兜底:从卡片 HTML 里提取学习空间 URL
const li = btn.closest('li');
if (li) {
const html = li.outerHTML || '';
const u = html.match(/https?:\/\/pxkckj-cmatc\.cma\.cn\/learnspace\/[^"'\s]+/);
if (u) return u[0].replace(/&/g, '&');
}
return '';
}
/* =====================================================================
* 分支二:学习空间站(pxkckj-cmatc.cma.cn)
* ===================================================================== */
// 监听 mainContent iframe 加载:仅记录日志(不再自动学习,见下方注释)
function setupIframeLoadWatcher() {
if (__iframeLoadWatcherInstalled) return;
__iframeLoadWatcherInstalled = true;
const check = () => {
const f = $('#mainContent');
if (!f) return;
if (f.dataset.qxjWatched === '1') return;
f.dataset.qxjWatched = '1';
f.addEventListener('load', () => {
// 仅记录日志,不做任何自动学习/状态更新:
// 主流程已负责学习,这里重复启动会导致"检测到内容刷新→视频反复刷新"死循环
log.debug('mainContent iframe 重新加载');
});
};
setInterval(check, 2000);
check();
}
async function runLearnBranch(token) {
// 记录本次学习的入口 URL:closeCleanly 据此判断是否已被 CMS 导航到下一门课
window.__qxjLearnUrl = location.href;
// 尽早抑制平台 beforeunload(避免学完跳转时弹"是否离开网站")
suppressLeaveConfirm();
// 跨域课件辅助脚本(kjcourse)状态监听
CwHelper.start();
// —— 平台"单课程模式"防退出 + 刷新循环防护 ——
try {
const mCid = (location.href || '').match(/[?&](?:params\.)?courseId=([^&]+)/);
const rawCid = mCid ? decodeURIComponent(mCid[1]) : '';
const cid = rawCid.replace(/___$/, '');
if (rawCid) {
// 1) GM 登记当前课程(CMS 据此判断学习窗口是否已在目标课程,避免重复导航)
try { GM_setValue('qxj_cur_course', cid); GM_setValue('qxj_cur_course_ts', Date.now()); } catch (e) {}
// 2) 设置 learning-course cookie,防止平台 courseLimit 判定失败反复 quitLearning
try {
const mc = $('#mainContent');
const ssoEl = mc && mc.contentDocument ? mc.contentDocument.querySelector('#ssoUserId') : null;
const ssoUserId = ssoEl ? (ssoEl.value || '') : '';
if (ssoUserId) document.cookie = 'learning-course=' + ssoUserId + '_' + rawCid + '; path=/';
} catch (e) {}
// 3) 刷新循环检测:同一课程 30s 内加载 ≥4 次 → 仅告警(刷新已由导航保护根治,
// 不再自动暂停——全局暂停会让 CMS 主循环卡住,反而断掉"刷下一门"的闭环)
try {
const prev = GM_getValue('qxj_reload', { c: '', n: 0, t: 0 });
const now = Date.now();
if (prev.c === cid && now - prev.t < 30000) {
prev.n++;
if (prev.n >= 4) {
log.warn('检测到页面反复刷新(30s内' + prev.n + '次),请确认没有其它页面在重复打开本课程');
}
} else {
prev.c = cid; prev.n = 1;
}
prev.t = now;
GM_setValue('qxj_reload', prev);
} catch (e) {}
}
} catch (e) {}
Diag.add('[流程] runLearnBranch 开始 token=' + token + ' top==self=' + (window.top === window.self));
try {
if (window.top !== window.self) { Diag.add('[流程] 非顶层框架,退出'); return; }
// 暂停闸门:学习页加载时若已暂停,先等待用户恢复,避免自动点击/刷新课程
await StatusPanel.waitIfPaused();
if (token !== undefined && !isCurrentToken(token)) { log.info('学习空间旧流程退出(token=' + token + ')'); Diag.add('[流程] token 失效,旧流程退出'); return; }
try {
StatusPanel.update({ status: '运行中', detail: '等待学习空间加载' });
Diag.add('[流程] updateOK');
} catch (e) {
Diag.add('[流程] update异常: ' + (e && e.stack || e));
}
try {
setupIframeLoadWatcher();
Diag.add('[流程] watcherOK');
} catch (e) {
Diag.add('[流程] watcher异常: ' + (e && e.stack || e));
}
Diag.add('[流程] 等待 #learn-menu ...');
const menuOk = await waitUntil(() => $('#learn-menu'), CONFIG.WAIT_TIMEOUT);
Diag.add('[流程] 菜单就绪=' + menuOk + ' token有效=' + isCurrentToken(token) + ' __qxjToken=' + window.__qxjToken);
if (!isCurrentToken(token)) { log.info('学习空间旧流程退出(token=' + token + ')'); Diag.add('[流程] token 失效,旧流程退出'); return; }
if (!menuOk) {
log.err('学习空间页未加载出左侧菜单');
Diag.add('[流程] 菜单未加载,走宽限期');
StatusPanel.update({ status: '异常', detail: '菜单未加载,' + CONFIG.LEARN_GRACE_SEC + 's后自动跳过' });
await graceThenDone('学习空间菜单未加载', token);
return;
}
log.info('=== 学习空间页加载,开始课件学习 ===');
Diag.add('[流程] 开始课件学习(token=' + token + ')');
} catch (e) {
Diag.add('[流程] 整体异常: ' + (e && e.stack || e));
log.err('runLearnBranch 前置阶段异常:', e);
}
STORE.clearDone();
StatusPanel.update({
status: '运行中',
course: STORE.getTask() || '-',
detail: '学习空间已加载,准备点课件菜单',
});
let learnedAll = false;
try {
learnedAll = await learnAllCoursewares(token);
if (learnedAll) {
log.info('该课程课件学习完成');
StatusPanel.update({ detail: '课件学习完成,即将关闭' });
} else {
log.warn('课件未全部学完,宽限期后自动跳过');
StatusPanel.update({ status: '未完成', detail: '课件未全部学完,' + CONFIG.LEARN_GRACE_SEC + 's后自动跳过' });
}
} catch (e) {
log.err('学习空间流程异常:', e);
StatusPanel.update({ status: '异常', detail: '异常:' + String(e && e.message || e) + ',' + CONFIG.LEARN_GRACE_SEC + 's后跳过' });
}
if (!isCurrentToken(token)) { log.info('学习空间旧流程退出(token=' + token + ')'); return; }
// 学完:markDone+关窗;未完成/异常:宽限期后 markDone+关窗,防止 CMS 卡死
if (learnedAll) {
try { StatusPanel.setPaused(false); } catch (e) {} // 学完解除任何残留暂停,确保 CMS 继续下一门
// 登记本窗口完成的课程ID(多窗口模式按此匹配,避免误关其它窗口)
try { GM_setValue('qxj_done_course', extractCourseId(location.href)); } catch (e) {}
STORE.markDone();
notify('课件学习完成', STORE.getTask() || '');
await sleep(300); // 尽快写完"学习完成"页,缩小与 CMS 导航的竞态窗口
closeCleanly();
} else {
await graceThenDone(learnedAll === false ? '课件未全部学完' : '学习流程异常', token);
}
}
// 致命错误宽限期:等待 LEARN_GRACE_SEC 秒供人工查看,随后 markDone+关窗解除 CMS 阻塞
async function graceThenDone(reason, token) {
log.warn(`【${reason}】${CONFIG.LEARN_GRACE_SEC}s 宽限期后自动 markDone+关窗`);
for (let i = CONFIG.LEARN_GRACE_SEC; i > 0; i--) {
if (token !== undefined && !isCurrentToken(token)) { log.info('宽限期旧流程退出'); return; }
StatusPanel.update({ detail: `${reason},${i}s 后自动跳过并关闭` });
await StatusPanel.waitIfPaused();
if (token !== undefined && !isCurrentToken(token)) { log.info('宽限期旧流程退出'); return; }
await sleep(1000);
}
if (token !== undefined && !isCurrentToken(token)) return;
// 登记本窗口完成的课程ID(多窗口模式按此匹配)
try { GM_setValue('qxj_done_course', extractCourseId(location.href)); } catch (e) {}
STORE.markDone();
log.info('已通知 CMS 跳过本门');
closeCleanly();
}
async function clickCoursewareMenu() {
// 点击左侧『课件』菜单,绝不点击『学习进度』(#course_score)
StatusPanel.update({ detail: '正在查找课件菜单' });
const menu = await waitForEl('#courseware_main_menu', CONFIG.WAIT_TIMEOUT);
if (!menu || typeof menu.scrollIntoView !== 'function') {
throw new Error('未找到课件菜单 #courseware_main_menu');
}
menu.scrollIntoView({ block: 'center' });
StatusPanel.update({ detail: '正在点击课件菜单' });
await sleep(300);
const link = $('a.learn-menu-cell-link', menu) || menu;
link.click();
log.info('已点击『课件』菜单(未触碰学习进度)');
StatusPanel.update({ detail: '已点课件菜单,等待加载' });
await sleep(1200);
}
async function enterMainIframe() {
StatusPanel.update({ detail: '等待课件内容加载' });
const ok = await waitUntil(() => {
const f = $('#mainContent');
return f && (f.src || '').includes('courseware');
}, CONFIG.WAIT_TIMEOUT);
if (!ok) { throw new Error('课件 iframe 未加载(mainContent src 不含 courseware)'); }
log.info('mainContent iframe 已加载课件页');
StatusPanel.update({ detail: '课件内容已加载' });
}
function getMainDoc() {
const f = $('#mainContent');
if (!f) return null;
try {
return f.contentDocument || (f.contentWindow && f.contentWindow.document);
} catch (e) {
log.warn('访问 iframe 文档失败(可能跨域):', e);
return null;
}
}
function readCoursewares() {
// 递归在顶层 + 所有同源嵌套 iframe 中查找课件列表
const docs = Courseware.collectDocs(getMainDoc() || document);
const selectors = [
'div.s_point[id^="s_point_"]',
'li.learn-item',
'li.courseware-item',
'div.learn-list li',
'ul.course-list li',
'li.tree-node[data-itemid]',
'a[data-itemid]',
'div.catalog li',
'li.chapter-item',
"li a[href*='learnItemId']",
"li a[onclick*='goCoursewareIndex']",
'li .title-text',
'.courseware-list li',
'.lesson-list li',
'.section-list li',
'li.node',
'li[data-nodeid]',
];
for (const sel of selectors) {
for (const d of docs) {
try {
const items = Array.from(d.querySelectorAll(sel)).filter(el => (el.textContent || '').trim());
if (items.length) {
log.debug(`定位课件 ${items.length} 个(${sel},doc=${d.location ? d.location.pathname : '?'})`);
return items;
}
} catch (e) {}
}
}
// 兜底
for (const d of docs) {
try {
const allLi = Array.from(d.querySelectorAll('li')).filter(el => {
const t = (el.textContent || '').trim();
return t && t.length < 80;
});
if (allLi.length) {
log.debug(`兜底定位课件 ${allLi.length} 个`);
return allLi;
}
} catch (e) {}
}
return [];
}
function isItemFinished(item) {
if (!item) return false;
// 优先检测对勾标记(真正的完成标志)
if (isCoursewareFinished(item)) return true;
// 蓝色进度条满(width:100% 或 类名含 100)
if (isProgressBarFull(item)) return true;
// class 名
const cls = item.className || '';
if (/finished|completed|learned|done/i.test(cls)) return true;
const txt = (item.textContent || '');
if (/已完成|已学完|已观看/.test(txt)) return true;
// 进度数值 100%
const m = txt.match(/(\d+(?:\.\d+)?)\s*%/);
if (m && parseFloat(m[1]) >= 100) return true;
return false;
}
// 检测蓝色进度条是否已满(width:100% 或 内联样式 100%)
// 平台常见:.progress-bar / .progress-inner / .layui-progress-bar / .el-progress-bar__inner
function isProgressBarFull(item) {
if (!item) return false;
const barSels = [
'.progress-bar', '.progress-inner', '.progress-bar-inner',
'.layui-progress-bar', '.layui-progress-big',
'.el-progress-bar__inner', '.ant-progress-bg',
'.course-progress', '.learn-progress', '.study-progress',
'.bar-inner', '.progress > .inner',
'[class*="progress-bar"]', '[class*="progress-inner"]',
];
for (const sel of barSels) {
try {
const bars = item.querySelectorAll(sel);
for (const bar of bars) {
// 内联 width:100%
const w = bar.style && (bar.style.width || '');
if (/100%/.test(w)) {
log.debug(` [进度条满] 命中 ${sel} width=100%`);
return true;
}
// 属性 aria-valuenow=100
const v = bar.getAttribute('aria-valuenow');
if (v && parseFloat(v) >= 100) {
log.debug(` [进度条满] 命中 ${sel} aria-valuenow=${v}`);
return true;
}
// class 含 100
if (/\b100\b/.test(bar.className || '')) {
log.debug(` [进度条满] 命中 ${sel} class含100`);
return true;
}
}
} catch (e) {}
}
return false;
}
// 读取课件项的蓝色进度条百分比(0~100),无进度条返回 -1
function readItemProgress(item) {
if (!item) return -1;
const barSels = [
'.progress-bar', '.progress-inner', '.progress-bar-inner',
'.layui-progress-bar', '.el-progress-bar__inner', '.ant-progress-bg',
'.course-progress', '.learn-progress', '.study-progress',
'[class*="progress-bar"]', '[class*="progress-inner"]',
];
for (const sel of barSels) {
try {
const bar = item.querySelector(sel);
if (!bar) continue;
// 内联 width:XX%
const w = (bar.style && bar.style.width) || '';
const m = w.match(/(\d+(?:\.\d+)?)\s*%/);
if (m) return parseFloat(m[1]);
// aria-valuenow
const v = bar.getAttribute('aria-valuenow');
if (v) return parseFloat(v);
} catch (e) {}
}
// 文本百分比
const m = (item.textContent || '').match(/(\d+(?:\.\d+)?)\s*%/);
if (m) return parseFloat(m[1]);
return -1;
}
// 专门检测课件列表项的"对勾"完成标记
// 平台图标体系:i.coursespace icon-xxx;完成对勾常见于 icon-check/icon-finish/icon-complete
// 同时支持对勾字符 ✓ √ ✔ 和各种通用 class
function isCoursewareFinished(item) {
if (!item) return false;
// 0) 平台 s_point 完成标志:completestate="1" / done_icon_show 对勾
if (item.getAttribute && item.getAttribute('completestate') === '1') return true;
try { if (item.querySelector('.done_icon_show')) return true; } catch (e) {}
// 1) 对勾图标(i.coursespace icon-check 等)
const checkSels = [
"i.coursespace[class*='check']", "i.coursespace[class*='finish']", "i.coursespace[class*='complete']",
"i.coursespace[class*='Check']", "i.coursespace[class*='Finish']", "i.coursespace[class*='Complete']",
"i.icon-check", "i.fa-check", "i.el-icon-check", "i.anticon-check",
"i[class*='icon-check']", "i[class*='icon-finish']", "i[class*='icon-complete']",
"i[class*='iconCheck']", "i[class*='iconFinish']", "i[class*='iconComplete']",
".check-icon", ".finish-icon", ".complete-icon", ".status-finish", ".status-complete",
".learned-icon", ".finished-icon", ".done-icon",
".node-status.finish", ".node-status.complete", ".node-status.done",
];
for (const sel of checkSels) {
try {
if (item.querySelector(sel)) {
log.debug(` [对勾] 命中 ${sel}`);
return true;
}
} catch (e) {}
}
// 2) 对勾字符 ✓ √ ✔ / 明确完成词(后不跟百分比/数字,避免"已完成60%""已观看3分钟"被误判完成)
const txt = (item.textContent || '');
if (/[✓√✔]|已(?:完成|学完|观看)(?![%%\d])/.test(txt)) return true;
// 3) 项整体被标记完成(class 含 finish/complete/learned/done)
const cls = item.className || '';
if (/\b(finished|completed|learned|done|is-finish|is-complete|is-learned)\b/i.test(cls)) return true;
// 4) 子元素有完成态 class
try {
if (item.querySelector('.finished, .completed, .learned, .done, .is-finish, .is-complete')) return true;
} catch (e) {}
return false;
}
async function learnAllCoursewares(token, pass) {
pass = pass || 1;
surveyLearnDom(); // 打印课件列表位置与可读性(排查用)
// 主路径1:课件页 s_point 列表驱动(completestate / done_icon_show 对勾,最可靠的完成状态)
// 仅首次(pass===1)点击课件菜单并进入 iframe,重试时菜单已打开无需重复点击
if (pass === 1) {
try { await clickCoursewareMenu(); await enterMainIframe(); } catch (e) {}
}
await waitForEl(() => CoursewareList.collect().length > 0, CONFIG.WAIT_TIMEOUT) || await sleep(1000);
await sleep(800); // 等列表完成状态渲染稳定
const items = CoursewareList.read();
if (items && items.length) {
log.info(`[学习空间] 使用 s_point 课件列表路径:${items.length} 个课件`);
Diag.add('[路径] s_point 课件列表:' + items.length + ' 个 | ' + items.map(x => x.itemId + (x.done ? '✓' : '✗')).join(' '));
items.forEach((x, i) => log.debug(` s_point[${i + 1}]: id=${x.itemId} title=${x.title} done=${x.done}`));
return await learnByList(token, items, pass);
}
log.warn('课件页未读到 s_point 课件列表');
Diag.add('[路径] s_point 未读到(0 个)');
// 主路径2:学习进度页驱动(GM_xmlhttpRequest 抓 HTML,跨域也能读)
const items2 = await LearnRecord.read();
if (items2 && items2.length && isCurrentToken(token)) {
log.info(`[学习空间] 使用学习进度页路径:${items2.length} 个课件`);
return await learnByRecord(token, items2, pass);
}
log.warn('学习进度页未读到课件列表');
// 回退:课件列表驱动(旧逻辑)
log.warn('课件列表与学习进度页均不可用,回退旧逻辑');
return await learnAllCoursewaresLegacy(token, pass);
}
// s_point 课件列表驱动的学习流程:跳过已完成(有对勾)→ 逐个学习未完成 → 全部学完后复查并重刷
async function learnByList(token, items, pass) {
const total = items.length;
const doneCount = items.filter(i => i.done).length;
const overall = total ? Math.round(doneCount / total * 100) : 0;
const review = StatusPanel.isReview();
log.info(`课件列表:${doneCount}/${total} 已完成(整体 ${overall}%),共 ${total} 个课件(第${pass}遍${review ? ',复习模式全部重学' : ''})`);
StatusPanel.update({ progress: `${doneCount}/${total}`, detail: `课件列表:${doneCount}/${total} 已完成,断点续学` });
if (doneCount === total && !review) {
log.info('所有课件均已学完(全部有对勾),无需学习');
StatusPanel.update({ detail: `全部课件已完成(${total}/${total})`, progress: `${total}/${total}` });
return true;
}
let learned = 0;
for (let i = 0; i < total; i++) {
if (token !== undefined && !isCurrentToken(token)) return false;
// 暂停闸门
await StatusPanel.waitIfPaused();
if (token !== undefined && !isCurrentToken(token)) return false;
const it = items[i];
// 学习前重新读取该课件的最新完成状态(防止初始读取时对勾尚未渲染)
const cur = CoursewareList.read().find(x => x.itemId === it.itemId) || it;
if (!review && cur.done) {
log.info(` [${i + 1}/${total}] 已学完(有对勾),跳过:${cur.title || cur.itemId}`);
Diag.add('[跳过] ' + it.itemId + ' 已有对勾,跳过');
continue;
}
const ok = await learnOneListItem(it, i, total);
if (ok) learned++;
await waitMainIframeStable();
}
// 全部学完后确认真实完成状态:以服务器学习进度为准(页面 s_point 对勾可能不自动刷新,
// 只看 DOM 会误判"整体 0%" → 反复重学)
StatusPanel.update({ detail: '重新读取确认完成状态' });
await sleep(1500);
let f2 = 0, t2 = 0;
const items2 = CoursewareList.read();
if (items2 && items2.length) {
t2 = items2.length;
f2 = items2.filter(i => i.done).length;
}
try {
const rec = await LearnRecord.read();
if (rec && rec.length) {
const doneCount = rec.filter(r => r.done).length;
if (doneCount >= f2) { f2 = doneCount; t2 = rec.length; } // 服务器更准确则采用
}
} catch (e) {}
const overall2 = t2 ? Math.round(f2 / t2 * 100) : 0;
log.info(`课件列表确认:整体 ${overall2}%(${f2}/${t2})`);
StatusPanel.update({ progress: `${f2}/${t2}`, detail: `课件列表确认:整体 ${overall2}%(${f2}/${t2})` });
const maxRetry = StatusPanel.getRetry();
if (overall2 < 100 && pass <= maxRetry) {
log.warn(`整体完成度 ${overall2}% < 100%,重新学习未完成的课件(第${pass + 1}遍,上限${maxRetry})`);
StatusPanel.update({ detail: `课程未完成(${overall2}%),重学第${pass + 1}遍` });
await sleep(2000);
return await learnAllCoursewares(token, pass + 1);
}
if (overall2 < 100) {
log.warn(`重学${pass}遍后整体完成度仍 ${overall2}% < 100%(上限${maxRetry}),请人工检查`);
StatusPanel.update({ detail: `重学${maxRetry}次后仍未完成(${overall2}%),请人工检查` });
}
return overall2 >= 100; // 诚实汇报:还有未完成课件时不让上层显示"课件学习完成"
}
// 学习单个课件:点击 s_point 打开课件 → 视频/图文学习 → 重新加载列表确认对勾
async function learnOneListItem(it, idx, count) {
const txt = (it.title || '').slice(0, 30) || it.itemId;
StatusPanel.update({ detail: `[${idx + 1}/${count}] 打开课件:${txt}` });
const ok = await CoursewareList.openItem(it);
if (!ok) {
// 兜底:改用学习进度页方式加载
log.warn(` [${idx + 1}/${count}] s_point 点击失败,改用原生加载:${it.itemId}`);
await LearnRecord.goItem(it.itemId);
}
await waitUntil(() => {
const f = $('#mainContent');
return f && /courseware_index/.test(f.src || '');
}, CONFIG.WAIT_TIMEOUT);
await sleep(800);
log.info(` [${idx + 1}/${count}] 开始学习:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 学习中:${txt}` });
const learnOk = await Courseware.learn(it.itemId);
const confirmed = await confirmListItemFinished(it, idx, count, txt, learnOk);
await sleep(CONFIG.SETTLE_SEC * 1000);
return confirmed;
}
// 学完后检查课件列表中该课件是否出现对勾;优先读 DOM,未找到再点菜单刷新。
// learnOk=false(视频无法播放/学习未成功)时不再重学重载,避免视频反复刷新
async function confirmListItemFinished(it, idx, count, txt, learnOk) {
// 学完后先耐心等平台确认(服务器更新可能延迟数十秒)。
// 直接读服务器学习进度(LearnRecord)确认真实状态,不看页面缓存——
// 避免"视频播完了但对勾还没出来 → 立即重学 → 页面刷新视频重播"的循环。
const CONFIRM_WAIT = 45000;
const waitDone = (timeout) => new Promise((resolve) => {
const started = Date.now();
(async function check() {
let done = false;
try {
const cur = CoursewareList.read().find(x => x.itemId === it.itemId);
done = !!(cur && cur.done);
if (!done) {
const rec = await LearnRecord.read();
const rc = rec ? rec.find(x => x.itemId === it.itemId) : null;
done = !!(rc && rc.done);
}
} catch (e) {}
if (done) return resolve(true);
if (Date.now() - started >= timeout) return resolve(false);
setTimeout(check, 4000);
})();
});
let ok = await waitDone(CONFIRM_WAIT);
if (ok) {
log.info(` [${idx + 1}/${count}] 平台已确认完成:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成:${txt}` });
return true;
}
// 学习未成功(视频无法播放等)时不重学,避免反复 reload 视频造成"一直刷新"
if (!learnOk) {
log.warn(` [${idx + 1}/${count}] 课件未学习成功(视频可能无法播放),跳过重学:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 未学成,跳过重学` });
return true;
}
// 45 秒仍未确认 → 刷新课件菜单再查一次,仍无才重学(最多2次)
const RECONFIRM_RETRY = 2;
for (let r = 1; r <= RECONFIRM_RETRY; r++) {
log.warn(` [${idx + 1}/${count}] 学完45s仍未确认,第${r}/${RECONFIRM_RETRY}次重学:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 未确认完成,重学:${txt}` });
try { await clickCoursewareMenu(); } catch (e) {}
await sleep(2000);
const cur2 = CoursewareList.read().find(x => x.itemId === it.itemId);
if (cur2 && cur2.done) {
log.info(` [${idx + 1}/${count}] 刷新后对勾出现,确认完成:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成(刷新确认):${txt}` });
return true;
}
await CoursewareList.openItem(cur2 || it);
await waitUntil(() => {
const f = $('#mainContent');
return f && /courseware_index/.test(f.src || '');
}, CONFIG.WAIT_TIMEOUT);
await sleep(1500);
await Courseware.learn();
ok = await waitDone(CONFIRM_WAIT);
if (ok) {
log.info(` [${idx + 1}/${count}] 重学后平台确认完成:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成:${txt}` });
return true;
}
}
log.warn(` [${idx + 1}/${count}] 重学${RECONFIRM_RETRY}次后仍未确认,按已完成处理:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 按已完成处理:${txt}` });
return true;
}
// 学习进度页驱动的学习流程:跳过已完成 → 逐个学习未完成 → 全部学完后复查并重刷
async function learnByRecord(token, items, pass) {
const total = items.length;
const doneCount = items.filter(i => i.done).length;
const overall = total ? Math.round(doneCount / total * 100) : 0;
const review = StatusPanel.isReview();
log.info(`学习进度页:${doneCount}/${total} 已完成(整体 ${overall}%),共 ${total} 个课件(第${pass}遍${review ? ',复习模式全部重学' : ''})`);
StatusPanel.update({ progress: `${doneCount}/${total}`, detail: `学习进度:${doneCount}/${total} 已完成,断点续学` });
if (doneCount === total && !review) {
log.info('所有课件均已学完,无需学习');
StatusPanel.update({ detail: `全部课件已完成(${total}/${total})`, progress: `${total}/${total}` });
return true;
}
let learned = 0;
for (let i = 0; i < total; i++) {
if (token !== undefined && !isCurrentToken(token)) return false;
// 暂停闸门
await StatusPanel.waitIfPaused();
if (token !== undefined && !isCurrentToken(token)) return false;
const it = items[i];
if (!review && it.done) {
log.info(` [${i + 1}/${total}] 已学完,跳过:${it.title || it.itemId}`);
continue;
}
// 学习前再核对课件页对勾(若 s_point 可读)
const sp = CoursewareList.read().find(x => x.itemId === it.itemId);
if (!review && sp && sp.done) {
log.info(` [${i + 1}/${total}] s_point 有对勾,跳过:${it.title || it.itemId}`);
continue;
}
const ok = await learnOneByItem(it, i, total);
if (ok) learned++;
await waitMainIframeStable();
}
// 全部学完后,重新读取学习进度确认真实完成状态
StatusPanel.update({ detail: '重新读取学习进度确认' });
await sleep(1500);
const items2 = await LearnRecord.read();
const f2 = items2 ? items2.filter(i => i.done).length : learned;
const t2 = items2 ? items2.length : total;
const overall2 = t2 ? Math.round(f2 / t2 * 100) : 0;
log.info(`学习进度确认:整体 ${overall2}%(${f2}/${t2})`);
StatusPanel.update({ progress: `${f2}/${t2}`, detail: `学习进度确认:整体 ${overall2}%(${f2}/${t2})` });
const maxRetry = StatusPanel.getRetry();
if (overall2 < 100 && pass <= maxRetry) {
log.warn(`整体完成度 ${overall2}% < 100%,重新学习未完成的课件(第${pass + 1}遍,上限${maxRetry})`);
StatusPanel.update({ detail: `课程未完成(${overall2}%),重学第${pass + 1}遍` });
await sleep(2000);
return await learnAllCoursewares(token, pass + 1);
}
if (overall2 < 100) {
log.warn(`重学${pass}遍后整体完成度仍 ${overall2}% < 100%(上限${maxRetry}),请人工检查`);
StatusPanel.update({ detail: `重学${maxRetry}次后仍未完成(${overall2}%),请人工检查` });
}
return overall2 >= 100; // 诚实汇报:还有未完成课件时不让上层显示"课件学习完成"
}
// 学习单个课件:跳转课件页 → 视频/图文学习 → 回读学习进度确认完成
async function learnOneByItem(it, idx, count) {
const txt = (it.title || '').slice(0, 30) || it.itemId;
StatusPanel.update({ detail: `[${idx + 1}/${count}] 加载课件:${txt}` });
const ok = await LearnRecord.goItem(it.itemId);
if (!ok) { log.warn(` [${idx + 1}/${count}] 跳转课件失败:${it.itemId}`); return false; }
await waitUntil(() => {
const f = $('#mainContent');
return f && /courseware_index/.test(f.src || '');
}, CONFIG.WAIT_TIMEOUT);
await sleep(800);
log.info(` [${idx + 1}/${count}] 开始学习:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 学习中:${txt}` });
const learnOk = await Courseware.learn(it.itemId);
// 回读学习进度页确认该课件已完成
const confirmed = await confirmRecordFinished(it, idx, count, txt, learnOk);
await sleep(CONFIG.SETTLE_SEC * 1000);
return confirmed;
}
// 学完后回读"学习进度"页确认该课件变为已完成;服务器延迟时先复查,仍未完成才重学。
// learnOk=false(学习未成功)时不再重学重载,避免视频反复刷新
async function confirmRecordFinished(it, idx, count, txt, learnOk) {
const RECONFIRM_RETRY = 2;
for (let r = 0; r <= RECONFIRM_RETRY; r++) {
if (r > 0) await sleep(4000);
const items = await LearnRecord.read();
const cur = items ? items.find(x => x.itemId === it.itemId) : null;
if (cur && cur.done) {
log.info(` [${idx + 1}/${count}] 学习进度确认完成:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 已完成(进度页确认):${txt}` });
return true;
}
if (r === 0) continue; // 首次未更新:等 4s 复查,不急着重学
// 学习未成功(视频无法播放等)时不重学,避免反复 reload 视频造成"一直刷新"
if (!learnOk) {
log.warn(` [${idx + 1}/${count}] 课件未学习成功(视频可能无法播放),跳过重学:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 未学成,跳过重学` });
return true;
}
if (r < RECONFIRM_RETRY) {
log.warn(` [${idx + 1}/${count}] 学习进度仍显示未完成,第${r}/${RECONFIRM_RETRY}次重学:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 未确认完成,重学:${txt}` });
await LearnRecord.goItem(it.itemId);
await waitUntil(() => {
const f = $('#mainContent');
return f && /courseware_index/.test(f.src || '');
}, CONFIG.WAIT_TIMEOUT);
await sleep(1500);
await Courseware.learn();
}
}
log.warn(` [${idx + 1}/${count}] 重学后仍未完成,按已完成处理:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 按已完成处理:${txt}` });
return true;
}
// 旧逻辑:课件列表驱动(学习进度页不可用时的回退)
async function learnAllCoursewaresLegacy(token, pass) {
pass = pass || 1; // 第几遍学习(进度条满但课程未完成时重刷)
let count = readCoursewares().length;
log.info(`右侧共 ${count} 个课件,开始逐个学习(第${pass}遍)`);
StatusPanel.update({ detail: `读取到课件 ${count} 个(第${pass}遍)`, progress: `0/${count}` });
if (!count) {
log.warn('未定位到课件,可能选择器需调整(见 readCoursewares)');
StatusPanel.update({ detail: '未定位到课件,请人工检查选择器' });
return false;
}
// 跳转到第一个未学完的课件(蓝色进度条到达的位置)
// 复习模式 / 重刷(pass>1)不跳过,从头开始学
let startIdx = 0;
if (!StatusPanel.isReview() && pass === 1) {
const items0 = readCoursewares();
let lastFinished = -1; // 最后一个已完成的位置
for (let i = 0; i < items0.length; i++) {
if (isItemFinished(items0[i])) {
lastFinished = i;
} else {
// 找到第一个未完成的,蓝色进度条就到这里
startIdx = i;
break;
}
}
// 状态栏报告蓝色进度条位置
const progAt = lastFinished >= 0 ? readItemProgress(items0[lastFinished]) : -1;
if (startIdx > 0) {
log.info(`跳转到第一个未学完的课件:第 ${startIdx + 1}/${count}(蓝色进度条到达位置,上个已完成项进度=${progAt}%)`);
StatusPanel.update({
detail: `跳转到第 ${startIdx + 1}/${count}(蓝色进度条到达处)`,
progress: `${startIdx}/${count}`,
});
// 滚动到该课件
try { items0[startIdx].scrollIntoView({ block: 'center' }); } catch (e) {}
await sleep(500);
} else if (lastFinished === items0.length - 1) {
// 全部已完成,普通模式无需再学
log.info(`所有 ${count} 个课件都已完成,无需跳转`);
StatusPanel.update({ detail: `全部课件已完成(${count}/${count})`, progress: `${count}/${count}` });
}
}
let done = 0;
for (let i = startIdx; i < count; i++) {
if (token !== undefined && !isCurrentToken(token)) return false;
// 暂停闸门
await StatusPanel.waitIfPaused();
if (token !== undefined && !isCurrentToken(token)) return false;
// 每轮重新读取,避免元素失效
const items = readCoursewares();
if (i >= items.length) { log.info('课件列表已变化,结束'); break; }
StatusPanel.update({ progress: `${i + 1}/${count}`, detail: `开始第${i + 1}/${count}个(第${pass}遍)` });
const ok = await learnOne(items[i], i, count);
if (ok) done++;
// 学完切回 mainContent iframe 上下文
await waitMainIframeStable();
}
if (token !== undefined && !isCurrentToken(token)) return false;
StatusPanel.update({ progress: `${done}/${count}`, detail: `课件处理完毕(${done}/${count},第${pass}遍)` });
// 学完所有课件后,读取"学习进度"页面确认真实完成状态
StatusPanel.update({ detail: '读取学习进度页面确认真实完成状态' });
const progress = await readLearnProgress();
if (progress) {
log.info(`学习进度确认:整体 ${progress.overall}%(${progress.finished}/${progress.total})`);
StatusPanel.update({
progress: `${progress.finished}/${progress.total}`,
detail: `学习进度确认:整体 ${progress.overall}%(${progress.finished}/${progress.total})`,
});
// 蓝色进度条已全满但课程整体未完成 → 重新刷一遍所有课件(最多重刷 getRetry() 次)
const maxRetry = StatusPanel.getRetry();
if (progress.overall < 100 && pass <= maxRetry) {
log.warn(`整体完成度 ${progress.overall}% < 100%,蓝色进度条已满但课程未完成,重新刷一遍(第${pass + 1}遍,上限${maxRetry})`);
StatusPanel.update({ detail: `课程未完成(${progress.overall}%),重新刷一遍(${pass}/${maxRetry})` });
await sleep(2000);
// 重试时菜单已打开,无需重复点击,直接重新学习
return await learnAllCoursewaresLegacy(token, pass + 1);
}
if (progress.overall < 100) {
log.warn(`重刷${pass - 1}遍后整体完成度仍 ${progress.overall}% < 100%(上限${maxRetry}),可能需要人工检查`);
StatusPanel.update({ detail: `重刷${maxRetry}次后仍未完成(${progress.overall}%),请人工检查` });
}
}
return progress ? progress.overall >= 100 : true; // 诚实汇报:未学完时不让上层显示"完成"
}
async function learnOne(item, idx, count) {
try { item.scrollIntoView({ block: 'center' }); } catch (e) {}
await sleep(400);
const txt = (item.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 30);
// 课件级别判断完成:
// - 普通模式:跳过已完成的单个课件(只学未完成的)
// - 复习模式:不跳过,全部重新学习
if (isItemFinished(item) && !StatusPanel.isReview()) {
log.info(` [${idx + 1}/${count}] 已学完,跳过:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 已学完跳过:${txt}` });
return true;
}
log.info(` [${idx + 1}/${count}] 开始学习:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 点击课件:${txt}` });
// 优先用平台原生 goCoursewareIndex 方式加载课件(设置 mainContent.src 带 learnItemId)
// 比单纯 DOM click 更可靠,fallback 到 click
const loaded = await loadCoursewareNative(item);
if (!loaded) {
// fallback:DOM click
let clicked = false;
for (const sel of ['a', 'span.title-text', '.title', 'div']) {
const sub = item.querySelector(sel);
if (sub) { try { sub.click(); clicked = true; break; } catch (e) {} }
}
if (!clicked) { try { item.click(); } catch (e) {} }
}
// 等待 mainContent iframe 内容加载(用 waitForEl 等待 video/正文/页面主体)
StatusPanel.update({ detail: `[${idx + 1}/${count}] 等待内容加载:${txt}` });
await waitForEl(() => {
const doc = getMainDoc();
if (!doc) return null;
if (doc.querySelector('video')) return doc.querySelector('video');
if (doc.body && doc.body.children.length > 0) return doc.body;
return null;
}, CONFIG.WAIT_TIMEOUT) || await sleep(CONFIG.SETTLE_SEC * 1000);
await sleep(1000);
// 学习当前课件(视频倍速播放等待完成,或图文停留)
StatusPanel.update({ detail: `[${idx + 1}/${count}] 学习中:${txt}` });
const learnOk = await Courseware.learn();
// 学完后回读左侧课件列表项的"对勾"确认真正完成(不只看进度条)
const confirmed = await confirmCoursewareFinished(item, idx, count, txt, learnOk);
await sleep(CONFIG.SETTLE_SEC * 1000);
return confirmed;
}
// 学完视频/图文后,回读课件列表项的对勾/完成标记确认真正完成
// 若未出现对勾,最多重试 RECONFIRM_RETRY 次重新学习;learnOk=false 时跳过重学,避免视频反复刷新
async function confirmCoursewareFinished(item, idx, count, txt, learnOk) {
const RECONFIRM_RETRY = 2;
for (let r = 0; r <= RECONFIRM_RETRY; r++) {
// 等待对勾出现(列表项状态可能延迟更新)
const waitOk = await waitUntil(() => {
// 每轮重新读取课件项(DOM 可能重建)
const cur = readCoursewares()[idx] || item;
return isCoursewareFinished(cur);
}, CONFIG.SETTLE_SEC * 1000 * 3, 1000);
if (waitOk) {
log.info(` [${idx + 1}/${count}] 对勾已出现,确认完成:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 已学完(对勾确认):${txt}` });
return true;
}
// 学习未成功(视频无法播放等)时不重学,避免反复 reload 视频造成"一直刷新"
if (!learnOk) {
log.warn(` [${idx + 1}/${count}] 课件未学习成功(视频可能无法播放),跳过重学:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 未学成,跳过重学` });
return true;
}
if (r < RECONFIRM_RETRY) {
log.warn(` [${idx + 1}/${count}] 学完但未出现对勾,第${r + 1}/${RECONFIRM_RETRY}次重学:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 未确认完成,重学:${txt}` });
// 重新点击并学习(优先原生加载)
try {
const cur = readCoursewares()[idx] || item;
const loaded = await loadCoursewareNative(cur);
if (!loaded) {
const sub = cur.querySelector('a') || cur.querySelector('.title-text') || cur;
sub.click();
}
} catch (e) {}
await sleep(1500);
await Courseware.learn();
}
}
log.warn(` [${idx + 1}/${count}] 重学后仍未出现对勾,按已完成处理:${txt}`);
StatusPanel.update({ detail: `[${idx + 1}/${count}] 按已完成处理:${txt}` });
return true;
}
// ===== 用平台原生 goCoursewareIndex 方式加载课件(第二页源码逻辑)=====
// 设置 #mainContent.src = courseware_index.action?courseId=xxx&learnItemId=xxx&itemOperation=xxx
// 比单纯 DOM click 更可靠地触发课件加载
function getCourseId() {
// 从课件菜单链接 href 提取 courseId
const link = $('#courseware_main_menu a.learn-menu-cell-link') || $('#courseware_main_menu a');
if (link) {
const m = (link.getAttribute('href') || '').match(/courseId=([^&]+)/);
if (m) return m[1];
}
// 从 mainContent 当前 src 提取
const f = $('#mainContent');
if (f) {
const m = (f.src || '').match(/courseId=([^&]+)/);
if (m) return m[1];
}
return '';
}
function extractLearnItemId(item) {
// 从课件项的 data-itemid / data-learnitemid / onclick / href 提取 learnItemId
const candidates = [
item.getAttribute('data-itemid'),
item.getAttribute('data-learnitemid'),
item.getAttribute('itemid'),
item.getAttribute('nodeid'),
];
for (const c of candidates) { if (c) return c; }
// id 形如 s_point_xxx(平台课件列表元素)
const mId = (item.id || '').match(/^s_point_(.+)$/);
if (mId) return mId[1];
const a = item.querySelector('a[href*="learnItemId"], a[onclick*="learnItemId"]');
if (a) {
const m = (a.getAttribute('href') || a.getAttribute('onclick') || '').match(/learnItemId=([^&"']+)/);
if (m) return m[1];
}
// onclick 形如 goCoursewareIndex('xxx','1')
const oc = item.getAttribute('onclick') || (item.querySelector('[onclick]') || {}).getAttribute && (item.querySelector('[onclick]').getAttribute('onclick'));
if (oc) {
const m = oc.match(/goCoursewareIndex\s*\(\s*['"]([^'"]+)['"]/);
if (m) return m[1];
const m2 = oc.match(/openLearnResItem\s*\(\s*['"]([^'"]+)['"]/);
if (m2) return m2[1];
}
return '';
}
async function loadCoursewareNative(item) {
try {
const itemId = extractLearnItemId(item);
const courseId = getCourseId();
const f = $('#mainContent');
if (!f || !itemId || !courseId) {
log.debug(' [原生加载] 缺少 itemId/courseId,回退 click', { itemId, courseId });
return false;
}
// 模板路径从当前 mainContent src 或课件菜单推断
let base = '/learnspace/learn/learn/templateeight/courseware_index.action';
const curSrc = f.src || '';
const m = curSrc.match(/(\/learnspace\/learn\/learn\/template\w+\/courseware_index\.action)/);
if (m) base = m[1];
const newSrc = `${base}?params.courseId=${courseId}¶ms.learnItemId=${itemId}¶ms.itemOperation=1`;
log.info(` [原生加载] 设置 mainContent.src(learnItemId=${itemId})`);
StatusPanel.update({ detail: `原生加载课件(learnItemId=${itemId})` });
f.src = newSrc;
return true;
} catch (e) {
log.debug(' [原生加载] 异常,回退 click:', e);
return false;
}
}
// ===== 学习进度页模块:真实的课件完成状态 + 断点续学 =====
// 平台"学习进度"页(learnRecord_stuLearnRecord.action)展示每个课件的 未完成/已完成 状态
// (见第二页过程.txt:tr[id^="item_"] + btn_unfinished/已完成 按钮 + toItem(itemId,...)),
// 是判断"学到哪里"的唯一可靠来源(课件列表的蓝色进度条在不同模板下不可靠)。
// 用法:open() 打开进度页 → read() 解析 → goItem() 跳到指定课件学习。
const LearnRecord = {
// 学习进度页 URL(从左侧菜单链接提取:& 解码 + 绝对化)
getUrl() {
const link = $('#course_score a.learn-menu-cell-link') || $('#course_score a');
if (link && link.getAttribute && link.getAttribute('href')) {
const href = link.getAttribute('href').replace(/&/g, '&');
return /^https?:\/\//i.test(href) ? href : (location.origin + href);
}
return '';
},
// 用 GM_xmlhttpRequest 抓取学习进度页 HTML(不受 iframe 跨域限制,带会话 cookie)
fetchHtml(url) {
return new Promise((resolve) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url,
timeout: CONFIG.WAIT_TIMEOUT,
onload: (res) => resolve(res.responseText || ''),
onerror: () => resolve(''),
ontimeout: () => resolve(''),
});
} catch (e) {
resolve('');
}
});
},
// 解析学习进度页 HTML → [{ itemId, title, done }],无课件行返回 null
parseHtml(html) {
const items = [];
const doc = new DOMParser().parseFromString(html, 'text/html');
const rows = Array.from(doc.querySelectorAll('tr[id^="item_"]'));
for (const tr of rows) {
const itemId = (tr.id || '').replace(/^item_/, '');
if (!itemId) continue;
const titleTd = tr.querySelector('td.mn3, td:first-child');
const title = titleTd ? (titleTd.textContent || '').trim().replace(/\s+/g, ' ') : '';
const btn = tr.querySelector('input[type="button"], button, a');
let done = false;
if (btn) {
const v = (btn.getAttribute('value') || btn.textContent || '').trim();
const cls = btn.className || '';
done = /已完成|已学完|已学习|finished|completed/.test(v + ' ' + cls);
} else {
done = /已完成|已学完|已学习/.test(tr.textContent || '');
}
// 补充完成标志:对勾图标 / completestate / 勾选字符
if (!done) {
done = tr.querySelector('.done_icon_show, [completestate="1"], i.icon-check, .item_done_icon.done_icon_show') !== null
|| /[✓√✔]/.test(tr.textContent || '');
}
items.push({ itemId, title, done });
}
return items.length ? items : null;
},
// 读取学习进度:优先 GM_xmlhttpRequest 抓 HTML 解析(跨域可用),失败再读 iframe DOM
async read() {
const url = this.getUrl();
if (url) {
const html = await this.fetchHtml(url);
if (html && html.length > 500) {
const items = this.parseHtml(html);
if (items && items.length) {
log.debug(`[学习进度] HTML 解析成功:${items.length} 个课件`);
return items;
}
log.warn('[学习进度] HTML 已抓取但未解析到课件行');
} else {
log.warn('[学习进度] 抓取学习进度页失败或内容为空');
}
}
// 兜底:iframe DOM
const doc = getMainDoc();
if (doc) {
try {
const items = this.parseHtml(doc.documentElement.outerHTML);
if (items && items.length) return items;
} catch (e) {}
}
return null;
},
// 打开学习进度页(人工查看用;主流程改用抓 HTML,不依赖 iframe 可读性)
async open() {
const url = this.getUrl();
const f = $('#mainContent');
if (f && url) {
f.src = url;
return true;
}
const menu = await waitForEl('#course_score', CONFIG.WAIT_TIMEOUT);
if (!menu) return false;
(menu.querySelector('a.learn-menu-cell-link') || menu).click();
return true;
},
// 跳转到指定课件(等价于平台 goCoursewareIndex)
async goItem(itemId) {
if (!itemId) return false;
await clickCoursewareMenu();
const f = $('#mainContent');
const courseId = getCourseId();
if (!f || !courseId) return false;
let base = '/learnspace/learn/learn/templateeight/courseware_index.action';
const m = (f.src || '').match(/(\/learnspace\/learn\/learn\/template\w+\/courseware_index\.action)/);
if (m) base = m[1];
f.src = base + '?params.courseId=' + courseId + '¶ms.learnItemId=' + itemId + '¶ms.itemOperation=1';
log.info('[学习进度] 跳转课件:' + itemId);
return true;
},
};
// ===== 课件列表模块:读课件页的 s_point 列表 =====
// 平台课件页每个课件是一个 div.s_point:id="s_point_",completestate="1" 表示已完成,
// 完成时显示对勾 .done_icon_show,点击触发 openLearnResItem(itemId,...) 打开课件。
// 这是判断"学到哪里/是否完成"最可靠的结构(学习进度页对已完成课件可能只显示对勾/时长)。
const CoursewareList = {
// 收集 s_point 元素:扫描顶层页面 + mainContent 及其所有同源嵌套 iframe
// (不依赖 getMainDoc,即使 mainContent 主文档访问受限也能从其它 iframe 找到列表)
collect() {
const out = [];
const seenDocs = new Set();
const roots = [document];
const mc = $('#mainContent');
if (mc) roots.push(mc);
const walk = (d) => {
if (!d || seenDocs.has(d)) return;
seenDocs.add(d);
try {
d.querySelectorAll('div.s_point[id^="s_point_"], .s_point, [completestate]').forEach(el => out.push(el));
} catch (e) {}
try {
const ifs = d.querySelectorAll('iframe');
for (const f of ifs) {
try { if (f.contentDocument) walk(f.contentDocument); } catch (e) {}
}
} catch (e) {}
};
for (const r of roots) walk(r);
return out;
},
// 解析为 [{ itemId, title, done, el }](按 itemId 去重)
read() {
const seen = new Set();
const items = [];
for (const el of this.collect()) {
let itemId = '';
const mId = (el.id || '').match(/^s_point_(.+)$/);
if (mId) itemId = mId[1];
if (!itemId) {
const oc = el.getAttribute('onclick') || '';
const m = oc.match(/openLearnResItem\s*\(\s*['"]([^'"]+)['"]/);
if (m) itemId = m[1];
}
if (!itemId || seen.has(itemId)) continue;
seen.add(itemId);
const titleEl = el.querySelector('.s_pointti');
const title = (el.getAttribute('title') || (titleEl && titleEl.textContent) || '').trim().replace(/\s+/g, ' ');
const done = el.getAttribute('completestate') === '1'
|| !!el.querySelector('.done_icon_show, .item_done_icon.done_icon_show, i[class*="check"], i[class*="Check"]')
|| /done_icon_show/.test(el.className || '')
|| /[✓√✔]|已(?:完成|学完)(?![%%\d])/.test(el.textContent || '');
items.push({ itemId, title, done, el });
}
return items;
},
// 点击 s_point 打开课件(平台原生 openLearnResItem)
async openItem(item) {
const el = item.el || document.querySelector('#s_point_' + item.itemId);
if (!el) return false;
try { el.click(); return true; } catch (e) { return false; }
},
};
// ===== 学习空间 DOM 诊断:打印课件列表位置与可读性(排查"对勾读不到")=====
function surveyLearnDom() {
try {
const f = $('#mainContent');
let mainOk = false, mainSrc = '';
if (f) {
mainSrc = f.src || '';
try { mainOk = !!f.contentDocument; } catch (e) {}
}
const spoints = CoursewareList.collect().length;
log.info(`[诊断] mainContent存在=${!!f} src=${mainSrc} 可访问=${mainOk} s_point数=${spoints}`);
Diag.add('[诊断] mainContent存在=' + !!f + ' src=' + mainSrc + ' 可访问=' + mainOk + ' s_point数=' + spoints);
if (spoints === 0 && f) {
try {
const d = f.contentDocument;
if (d) {
const ifs = d.querySelectorAll('iframe');
log.info(`[诊断] mainContent 内 iframe 数=${ifs.length},body子元素=${d.body ? d.body.children.length : 0}`);
Diag.add('[诊断] mainContent 内 iframe 数=' + ifs.length + ',body子元素=' + (d.body ? d.body.children.length : 0));
const cands = d.querySelectorAll('[class*="point"],[class*="catalog"],[class*="directory"],[class*="tree"] li,[class*="list"] li');
log.info(`[诊断] mainContent 内目录候选元素=${cands.length}`);
Diag.add('[诊断] mainContent 内目录候选元素=' + cands.length);
for (let i = 0; i < Math.min(cands.length, 10); i++) {
const c = cands[i];
try {
log.debug(` 候选[${i}]: <${c.tagName} class="${String(c.className || '').slice(0, 50)}" id="${c.id}"> ${String(c.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 40)}`);
} catch (e2) {}
}
} else {
log.warn('[诊断] mainContent.contentDocument 不可访问(可能跨域)');
Diag.add('[诊断] mainContent.contentDocument 不可访问(可能跨域)');
}
} catch (e) {
log.warn('[诊断] mainContent 访问异常:', e);
Diag.add('[诊断] mainContent 访问异常:' + e);
}
}
} catch (e) {
log.debug('[诊断] 异常:', e);
}
}
// ===== 读取"学习进度"页面确认真实完成状态(第二页源码 #course_score)=====
// 点击左侧『学习进度』菜单,等 mainContent 加载 learnRecord_stuLearnRecord 页面,
// 读取每个课件的真实完成状态,返回已完成课件数/总数
async function readLearnProgress() {
try {
StatusPanel.update({ detail: '读取学习进度页面确认完成状态' });
log.info('=== 读取学习进度页面 ===');
// 点击『学习进度』菜单(#course_score)
const menu = await waitForEl('#course_score', CONFIG.WAIT_TIMEOUT);
if (!menu) {
log.warn('未找到学习进度菜单 #course_score');
return null;
}
const link = $('a.learn-menu-cell-link', menu) || menu;
link.click();
// 等 mainContent 加载学习进度页面
const ok = await waitUntil(() => {
const f = $('#mainContent');
return f && /learnRecord|stuLearnRecord|study/.test(f.src || '');
}, CONFIG.WAIT_TIMEOUT);
if (!ok) {
log.warn('学习进度页面未加载');
return null;
}
await sleep(2000); // 等页面渲染
const doc = getMainDoc();
if (!doc) return null;
// 读取完成状态:找进度条/百分比/对勾/完成文本
let total = 0, finished = 0;
// 策略1:百分比文本
const items = Array.from(doc.querySelectorAll('tr, li, .learnRecord-item, .record-item, .study-item, .item'));
for (const it of items) {
const t = (it.textContent || '').trim();
if (!t || t.length > 200) continue;
total++;
const m = t.match(/(\d+(?:\.\d+)?)\s*%/);
if (m && parseFloat(m[1]) >= 100) { finished++; continue; }
if (/已完成|已学完|已完成学习/.test(t)) { finished++; continue; }
}
// 策略2:整页进度文本
if (!total) {
const allText = (doc.body && doc.body.textContent) || '';
const mAll = allText.match(/(\d+(?:\.\d+)?)\s*%/g);
if (mAll) {
total = mAll.length;
finished = mAll.filter(s => parseFloat(s) >= 100).length;
}
// 整体完成度
const mTotal = allText.match(/完成度[::]\s*(\d+(?:\.\d+)?)\s*%/);
if (mTotal) {
const overall = parseFloat(mTotal[1]);
log.info(`学习进度页面整体完成度:${overall}%`);
StatusPanel.update({ detail: `学习进度:整体完成度 ${overall}%` });
return { overall, total, finished };
}
}
log.info(`学习进度页面:${finished}/${total} 已完成`);
StatusPanel.update({ detail: `学习进度确认:${finished}/${total} 已完成` });
return { overall: total ? (finished / total * 100) : 0, total, finished };
} catch (e) {
log.err('读取学习进度页面异常:', e);
return null;
}
}
async function waitMainIframeStable() {
// 课件点击后可能改变 mainContent src,等其重新加载稳定
await waitUntil(() => {
const f = $('#mainContent');
return f && (f.src || '').includes('courseware');
}, CONFIG.WAIT_TIMEOUT);
await sleep(800);
}
/* ===================== 通知 ===================== */
function notify(title, text) {
try {
if (typeof GM_notification === 'function') {
GM_notification({ title, text, timeout: 4000 });
}
} catch (e) {}
}
})();