// ==UserScript==
// @name 超星学习通 · 自动学习
// @namespace local.chaoxing-auto-video
// @version 1.3.2
// @description 仅支持超星学习通(chaoxing.com):自动播放视频(倍速 / 静音 / 暂停续播 / 人脸识别等待 / 自动切章),并基于 DeepSeek 官方接口自动完成章节测试与作业答题(flash / pro 模型切换、思考模式与三档强度;检测到字体加密时自动本地解密,无需任何开关)。
// @author 1774213288
// @license MIT
// @match *://*.chaoxing.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_setClipboard
// @grant GM_notification
// @grant unsafeWindow
// 本脚本基于 OCS 网课助手(作者 enncy,MIT 协议)移植/修改:
// 字体解密(crackFont 系列)
// @original-author enncy
// @original-license MIT
// @original-script https://scriptcat.org/zh-CN/script-show-page/367
// @connect *
// @connect cdn.ocsjs.com
// @connect scriptcat.org
// @noframes
// @run-at document-start
// ==/UserScript==
(() => {
'use strict';
const VERSION = '1.3.2'; // 与 @version 保持一致,面板标题栏会显示
/** 悬浮球/面板吸靠屏幕边缘时保留的间距 */
const EDGE_GAP = 10;
const W = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const DOC = W.document;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const q = (sel, root = DOC) => root.querySelector(sel);
const qa = (sel, root = DOC) => Array.from(root.querySelectorAll(sel));
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/** 随机抖动:ms ± pct%(默认 ±40%),并夹到 [lo, hi]。用于所有人工操作节奏,
* 固定 sleep(400)/sleep(3000) 的时间戳周期是最典型的机器特征。 */
const randAround = (ms, pct = 0.4, lo = 200, hi = 8000) =>
clamp(Math.round(ms * (1 - pct + Math.random() * pct * 2)), lo, hi);
/** 轮询直到 pred 为真;timeout=0 表示一直等 */
function until(pred, gap = 300, timeout = 0) {
return new Promise((resolve, reject) => {
const t0 = Date.now();
const it = setInterval(() => {
let ok = false;
try { ok = pred(); } catch (e) { ok = false; }
if (ok) { clearInterval(it); resolve(ok); }
else if (timeout && Date.now() - t0 > timeout) {
clearInterval(it);
reject(new Error('等待超时'));
}
}, gap);
});
}
// ==================== 日志 ====================
/** 日志级别:info 中性 / ok 成功 / warn 警告 / err 错误 / head 小标题 / sep 分隔线
* 旧实现靠 LOG_LEVEL 正则猜级别,但正则全是**子串无锚定**匹配
* (`失败|出错|未找到|未检测到|无法|不支持|越界`),把「未检测到字体加密」
* 「跳过不支持的题型」这类**否定式描述的正常状态**判成错误色——
* 实测 14 条真实文案判错 8 条,红色噪声掩盖了真正要排查的错误。
* 现在改为:调用点显式传级为主,正则仅作未传级时的兜底,且**宁可漏判不着色**,
* 也不误判(错色会把排查方向带偏)。 */
const LOG_LEVELS = ['info', 'ok', 'warn', 'err', 'head', 'sep'];
const LOG_FALLBACK = [
[/^[═─]+/, 'sep'],
[/^\s*✓|^已提交|^已暂存|解密完成|连接正常|完成率|答题结束|全部.*完成|任务点已处理完毕/, 'ok'],
[/^⚠|^✗|建议|请手动|置信度偏低|已降级|偏短|兜底|失败|出错|越界|不支持/, 'err'],
];
const logLevelOf = (text) => {
for (const [re, cls] of LOG_FALLBACK) if (re.test(text)) return cls;
return '';
};
const stamp = () => {
const d = new Date();
const p = (n) => String(n).padStart(2, '0');
return `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
};
const logBuffer = [];
/** 本次运行的分级计数,供日志页签顶部的异常徽标展示 */
const logStat = { ok: 0, warn: 0, err: 0 };
/** 增减分级计数。只有 ok/warn/err 计入,info/head/sep 是过程信息不计 */
function countLevel(lv, delta) {
if (lv === 'err' || lv === 'warn' || lv === 'ok') logStat[lv] += delta;
}
/** 重置计数(清空日志时用) */
function resetLogStat() {
logStat.ok = logStat.warn = logStat.err = 0;
}
/** log(文本[, 级别]):省略级别时按文本兜底猜测;级别可选 info/ok/warn/err/head/sep */
function log(...args) {
const level = args.length > 1 && typeof args[args.length - 1] === 'string'
&& LOG_LEVELS.includes(args[args.length - 1])
? args.pop() : null;
const text = args.map((x) => (typeof x === 'string' ? x : (x && x.message) || String(x))).join(' ');
if (!level) console.log('%c[自动学习]', 'color:#111827;font-weight:600', text);
const lv = level || logLevelOf(text);
logBuffer.push({ text, level: lv, time: stamp() });
countLevel(lv, 1);
// 挤出最旧一行时把它的计数一并减掉,否则徽标会在长章节里持续虚高。
// 必须在 push 之后判断「超限」,且减的是被 shift 掉的那一条本身。
if (logBuffer.length > 200) countLevel(logBuffer.shift().level, -1);
renderLog();
}
// ==================== 配置 ====================
const CFG_KEY = 'cx_auto_video_cfg';
const DS = {
endpoint: 'https://api.deepseek.com/chat/completions',
// 超星加密字体的参考特征表(字形指纹库)
refTable: 'https://cdn.ocsjs.com/resources/font/font_ref_cx.bin',
models: { flash: 'deepseek-flash', pro: 'deepseek-v4-pro' },
// 思考强度:直接显示官方原生档位名,UI 上看到的就是实际发送给接口的值,
// 不再做「低/中/高」的中文改称(那样会让「中」其实等于 high,容易误解)。
// 官方原生只有 low / high / max 三档(medium 会被静默映射为 high,故不使用)。
efforts: { low: 'low', high: 'high', max: 'max' },
// 脚本猫脚本页(用户手动更新入口)
updatePage: 'https://scriptcat.org/zh-CN/script-show-page/8529',
// 脚本猫公开 API v2:返回脚本元数据 JSON(含最新 version)
// ⚠ 必须是 /api/v2/ —— v1 路径返回 404,直接 GET 脚本页返回的是 SPA 空壳 HTML,
// 两者都会让正则抓到页面里其他脚本的版本号(实测误抓过 v2024.11.0)。
updateApi: 'https://scriptcat.org/api/v2/scripts/8529',
};
const DEFAULTS = {
autoStart: false, // 运行状态快照:start() 置真、stop() 置假(内部状态,不是用户开关)
autoResume: false, // 用户开关:允许进入超星页面时自动续跑上次未跑完的任务(默认关)
stopReason: 'manual', // 暂停原因:manual 手动 / finished 全部完成 / idle 仍有未完成 / timer 定时停止 / section 学完指定小节
playbackRate: 1,
muted: true,
loopFirstChapter: false, // 播到最后一章后回到第一章
// 定时停止(分钟):0 = 关闭。到点自动暂停(不是关页面),中途可手动停止。
// ⚠ 只在真正「学习中」计时应答:学习页长期无人值守最怕的就是挂一整夜,
// 所以计时从 start() 起算,而不是从最后一次心跳起算。
stopMinutes: 0,
// 指定第 N 个小节后停止:0 = 关闭。N 按目录里 .posCatalog_select 的**当前顺序**
// 计数(含已完成的小节),跑完第 N 个小节内的全部任务点后自动暂停。
stopAtSection: 0,
afterAction: 'none', // 答完后:none / save(暂存) / submit / both
// 提交率门槛(默认门槛 80%):
// 作答完成率 >= 该值才真正提交,否则降级为仅暂存。
// ⚠ 旧版硬编码「必须 100%」,而任何一题被跳过(连线/阅读题、未开简答 AI、
// 无题干、已作答)都会让完成率永远 <100% —— 结果「仅提交」静默变成仅暂存,
// 用户以为交了其实没交。默认 80%,且允许 10%~90% 自选。
// 这里额外保留 100% 选项,想绝对保守时可以选它。
submitRate: 80,
essayAI: false, // 简答/论述题是否也让 AI 作答(默认关:主观题 AI 得分率低)
overwriteExisting: false, // 覆盖已作答的题目(默认关:多选题超星的勾选是「追加」而非替换)
// 重刷已完成的任务点(默认关闭):
// 关掉时已完成的视频/任务点会被直接跳过,不再从头重播
restudy: false,
ai: {
enabled: false,
apiKey: '',
model: 'flash', // flash | pro(视觉模式仅支持 flash)
thinking: false, // 思考模式开关
effort: 'high', // 思考强度:low | high | max(仅 thinking 开启时生效;关思考时保留档位记忆)
review: true, // 落笔前复核:让模型先自查再给答案(默认开:提升准确率)
customPrompt: '', // 自定义补充提示词:追加到答题提示词末尾(留空 = 不追加)
},
};
const cfg = Object.assign({}, DEFAULTS, GM_getValue(CFG_KEY, {}));
cfg.ai = Object.assign({}, DEFAULTS.ai, cfg.ai || {});
// ---- 旧配置迁移 ----
const old = cfg.ai;
// 1. 自定义 endpoint / model 一律丢弃,只保留 key,模型归一为 flash / pro
if (old.model) {
const m = String(old.model);
cfg.ai.model = /pro/i.test(m) ? 'pro' : 'flash';
}
if (!DS.models[cfg.ai.model]) cfg.ai.model = 'flash';
if (!DS.efforts[cfg.ai.effort]) cfg.ai.effort = 'high';
// 批量作答已彻底移除(配置项一并清理,避免旧缓存残留无用字段)
delete cfg.ai.batch;
if (cfg.autoSubmit === true) cfg.afterAction = 'submit';
delete cfg.autoSubmit;
if (cfg.backToFirstWhenFinish !== undefined) {
cfg.loopFirstChapter = cfg.backToFirstWhenFinish;
delete cfg.backToFirstWhenFinish;
}
if (cfg.enabled !== undefined) { cfg.autoStart = cfg.enabled; delete cfg.enabled; }
// 落笔复核:默认开启。v1.1.1 中途改过默认值(曾被设为 false),会把历史配置带过来,
// 故用 reviewTouched 区分「用户自己关过」与「历史遗留false」:
// 只有用户从未手动点过这个开关时,才纠正回开启。
if (!cfg.reviewTouched) cfg.ai.review = true;
// 记录已迁移到的版本,避免重复触发上面的兼容处理
if (cfg.lastVer !== VERSION) {
cfg.lastVer = VERSION;
GM_setValue(CFG_KEY, cfg);
}
const saveCfg = () => GM_setValue(CFG_KEY, cfg);
const state = {
running: false, media: null,
// 世代令牌:每次 start() 自增。stop() 后再 start() 时,上一轮 run() 可能
// 仍卡在最长 60s 的 AI 请求里;没有令牌它恢复后会读到被重新置真的 running,
// 于是两个循环并发跑,导致重复作答、重复控制播放器。
gen: 0,
// 任务序号:**跨章节累计**,切章不清零(只在 start() 时归零)。
// 旧版把计数放在 studyChapter() 的局部变量里、切章还显式归零,
// 于是每到新章节日志又从「任务 1」开始,看不出整门课一共做了多少。
jobSeq: 0,
doneJobs: new Set(), submittedAnswers: new Set(),
aiFatal: '',
// AI 调用统计:耗时与 token,用于判断该开哪个模型、思考强度是否划算
aiStats: { requests: 0, ms: 0, tokensIn: 0, tokensOut: 0, cacheHit: 0 },
};
// ==================== 人脸识别 ====================
const faceLocked = () =>
qa('#fcqrimg[src]', DOC).length > 0 ||
qa('.chapterVideoFaceMaskDiv', DOC).some((el) => el.style.display !== 'none');
/** 人脸识别等待中标志:视频进度停滞检测要据此让路。
* ⚠ 人脸识别可能耗时数分钟(要掏手机),而停滞检测的上限只有 2 分钟 ——
* 旧版两者互不知情,于是人脸识别超过 2 分钟就会把视频判成卡死跳过,
* 任务点被标记已处理、本会话不再重试,用户白等一场。 */
let faceWaiting = false;
function waitFaceFree() {
if (!faceLocked()) return Promise.resolve();
const msg = '检测到人脸识别,请手动识别,脚本会自动继续';
tip(msg, 0);
log(msg, 'warn');
faceWaiting = true;
// ⚠ 必须设上限:人脸遮罩 DOM 残留(弹窗被页面自身关闭但节点没摘)时
// until 永不 resolve,旧 gen 的循环会永久挂起在 onPause 里,媒体句柄不释放。
// 30 分钟远超正常人脸识别耗时,超时放行交给停滞检测兜底。
return until(() => !faceLocked(), 1000, 30 * 60 * 1000)
.catch(() => log('人脸识别等待超时(30 分钟),继续流程', 'warn'))
.finally(() => { faceWaiting = false; });
}
// ==================== 播放器解锁 ====================
/** 解除超星对进度条拖拽 / 快捷键的限制(取自原脚本 rateHack) */
function rateHack() {
let done = false;
const hack = () => {
const { videojs, Ext } = W;
if (done || !videojs || !Ext) return;
done = true;
const origin = videojs.getPlugin('seekBarControl');
let dragCount = 0;
videojs.registerPlugin('seekBarControl', videojs.extend(videojs.getPlugin('plugin'), {
constructor: function (ext, data) {
const sendLog = data.sendLog;
data.sendLog = (...args) => {
// 短时间频繁拖拽会被判定为异常,中断一次规避
if (args[1] === 'drag') {
if (++dragCount > 100) { dragCount = 0; q('video', W.document)?.pause(); }
} else {
sendLog.apply(data, args);
}
};
origin.apply(origin.prototype, [ext, data]);
},
}));
Ext.define('ans.VideoJs', {
override: 'ans.VideoJs',
constructor: function (data) {
this.addEvents(['seekstart']);
this.mixins.observable.constructor.call(this, data);
const vjs = videojs(data.videojs, this.params2VideoOpt(data.params), function () {});
const el = Ext.fly(data.videojs);
el.on('contextmenu', (e) => e.preventDefault());
el.on('keydown', (e) => {
if ([32, 37, 39, 107].includes(e.keyCode)) e.preventDefault();
});
if (vjs.videoJsResolutionSwitcher) {
vjs.on('resolutionchange', () => {
const cur = vjs.currentResolution();
Ext.setCookie('resolution', cur.sources ? cur.sources[0].res : false);
});
}
if (vjs.videoJsPlayLine) {
vjs.on('playlinechange', () => Ext.setCookie('net', vjs.currentPlayline().net));
}
},
});
};
try {
hack();
document.addEventListener('readystatechange', hack);
window.addEventListener('load', hack);
} catch (e) {
console.error(e);
}
}
/** 程序代码答案检测:
* 强特征(#include / int main( / def / printf( 等)+2 分,中等特征(while( / if( / -> 等)+1 分,
* 有 {} +1、有 ; +1,>=2 分判定为代码。
* 用途:① 切分答案时排除 ; | ; 分隔符(它们是代码语法);② 写入 UEditor 时整体包
代码块 */
function isProgramAnswer(answer) {
const code = String(answer ?? '').trim();
if (!code || !/[;|;]/.test(code)) return false;
let score = 0;
const strong = [
/#include\s*[<"]/, /#define\s+\w/, /#!\//,
/\b(?:int|void|float|double|char|long|short)\s+main\s*\(/,
/\bfunction\s+\w+\s*\(/, /\bdef\s+\w+\s*\(/,
/\bpublic\s+(?:class|static|void|int|float|double|String|final)\b/,
/\bprintf\s*\(/, /\bscanf\s*\(/, /\bcout\s*<, /\bcin\s*>>/,
/System\.out\./, /console\.log\s*\(/,
/\bpackage\s+[\w.]+\s*;/, /\bimport\s+[\w.*]+\s*;/,
/\bclass\s+\w+\s*\{/, /\bstruct\s+\w+\s*\{/,
];
for (const re of strong) if (re.test(code)) score += 2;
const medium = [
/=>/, /->/, /::/, /\+\+/, /--/, /[+\-*/%]=/,
/\bnew\s+\w+\s*\(/, /\bfor\s*\(/, /\bwhile\s*\(/, /\bif\s*\(/,
/\bswitch\s*\(/, /\btypedef\s+/,
/\b(?:int|float|double|char|void|bool|boolean|long|short|String|auto|const|let|var)\s+\w+/,
/\bsizeof\s*\(/, /\bmalloc\s*\(/, /\bfree\s*\(/,
/\bprocess\s+\w+\s*\{/, /\bsemaphore\s+\w+/, // 信号量伪代码(超星常见题型)
];
for (const re of medium) if (re.test(code)) score += 1;
if (/\{/.test(code) && /\}/.test(code)) score += 1;
if (/;/.test(code)) score += 1;
return score >= 2;
}
/** 内的 HTML 转义:只转 & < >,不处理缩进(pre 原生保留空白) */
const escPlain = (s) => String(s).replace(/&/g, '&').replace(//g, '>');
/** 解除超星页面对输入框的粘贴/复制/剪切/选中限制
* 解除思路:重写输入框的事件处理器并持续监听动态元素。
* 超星常用 onpaste="return false" / addEventListener('paste', e=>e.preventDefault())
* 以及 UEditor 的 beforepaste 监听来禁止粘贴。 */
function unblockPaste() {
const SEL = 'input, textarea, [contenteditable="true"], [contenteditable]';
/** 把目标元素的 onselectstart/oncopy/onpaste/onkeydown 改成放行 */
function hackSelect(target) {
if (!target || target.nodeType !== 1) return;
const orig = {
onselectstart: target.onselectstart,
oncopy: target.oncopy,
onpaste: target.onpaste,
onkeydown: target.onkeydown,
};
target.onselectstart = (e) => {
if (orig.onselectstart) orig.onselectstart.call(target, e);
e.stopPropagation();
e.returnValue = true;
return true;
};
target.oncopy = (e) => {
if (orig.oncopy) orig.oncopy.call(target, e);
e.stopPropagation();
e.returnValue = true;
return true;
};
target.onpaste = (e) => {
if (orig.onpaste) orig.onpaste.call(target, e);
e.stopPropagation();
e.returnValue = true;
return true;
};
target.onkeydown = (e) => {
if (orig.onkeydown) orig.onkeydown.call(target, e);
e.stopPropagation();
e.returnValue = true;
return true;
};
target.removeAttribute('readonly');
target.removeAttribute('disabled');
}
/** 清理当前页面及所有同源 iframe 里的输入框 */
function clearPage() {
hackSelect(document);
hackSelect(document.body);
document.querySelectorAll(SEL).forEach(hackSelect);
for (const frame of qa('iframe', document)) {
try {
const fd = frame.contentDocument;
if (!fd) continue;
hackSelect(fd);
hackSelect(fd.body);
fd.querySelectorAll(SEL).forEach(hackSelect);
} catch (e) { /* 跨域 iframe,忽略 */ }
}
}
clearPage();
/** 持续监听动态插入的元素(超星切章、切题目时会重新渲染 DOM) */
const mo = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches(SEL)) hackSelect(node);
node.querySelectorAll(SEL).forEach(hackSelect);
}
}
});
try {
mo.observe(document.documentElement, { childList: true, subtree: true });
} catch (e) { /* 权限不足则忽略 */ }
/** UEditor 编辑器:移除 beforepaste 限制(简答题/论述题常见) */
function hackEditorPaste() {
try {
const instants = (W.UE && W.UE.instants) || {};
for (const key in instants) {
const ue = instants[key];
if (!ue || !ue.textarea) continue;
if (W.editorPaste) {
try { ue.removeListener('beforepaste', W.editorPaste); } catch (e) { /* ignore */ }
}
if (W.myEditor_paste) {
try { ue.removeListener('beforepaste', W.myEditor_paste); } catch (e) { /* ignore */ }
}
}
} catch (e) { /* ignore */ }
}
let ueTries = 0;
const ueTimer = setInterval(() => {
hackEditorPaste();
ueTries++;
if (typeof W.UE !== 'undefined' || ueTries >= 20) clearInterval(ueTimer);
}, 500);
/** 额外保险:paste/copy/cut 的 preventDefault 对输入类元素失效 */
const origPD = Event.prototype.preventDefault;
Event.prototype.preventDefault = function () {
const t = this.target;
if ((this.type === 'paste' || this.type === 'copy' || this.type === 'cut')
&& t && t.nodeType === 1 && (t.matches?.(SEL) || t.closest?.(SEL))) {
return; // 不阻止默认行为
}
return origPD.call(this);
};
}
// ==================== 播放单个视频 ====================
async function safePlay(media) {
try {
await media.play();
return true;
} catch (e) {
if (/interact|NotAllowed/i.test(String(e))) {
tip('浏览器限制自动播放,点击视频区域即可继续');
// ⚠ 监听必须绑到**媒体元素所在的文档**:视频在同源 iframe 里,
// 旧版只绑顶层 document,用户点视频根本不触发,兜底形同虚设。
// 30 秒后仍未点击则自动摘除,避免监听永久残留引用旧媒体元素。
const docs = new Set([media.ownerDocument || document, document]);
let armed = true;
const once = () => {
if (!armed) return;
armed = false;
clearTimeout(giveUp);
docs.forEach((d) => d.removeEventListener('click', once));
media.play().catch(() => {});
};
const giveUp = setTimeout(() => once(), 30000);
docs.forEach((d) => d.addEventListener('click', once));
} else {
log('播放失败:', String(e), 'err');
}
return false;
}
}
const ERR_MARKS = ['视频文件损坏', '网络错误导致视频下载中途失败', '视频因格式不支持', '网络的问题无法加载'];
/** 视频中途弹出的「弹窗题」处理。
* 视频播到一半会弹出一道题,玩家被暂停等作答;不处理就会一直卡在弹窗上。
* ⚠ 旧版完全没有这段逻辑,弹窗出现后 onPause 每秒抢着 play() 也没用,
* 2 分钟后停滞检测把视频判成卡死跳过 —— 任务点白丢。
* 默认策略是随机选一个。 */
function handleVideoQuiz(doc) {
const submitBtn = () => q('#videoquiz-submit', doc);
const boxSel = '.ans-videoquiz, #video .ans-videoquiz';
if (!submitBtn() && !q(boxSel, doc)) return false;
const list = qa('.ans-videoquiz-opt label, .ans-videoquiz-opt', doc);
if (list.length) {
const pick = list[Math.floor(Math.random() * list.length)];
try { pick.click(); } catch (e) { /* ignore */ }
}
try { submitBtn()?.click(); } catch (e) { /* ignore */ }
// 收掉弹窗遮罩,让播放器恢复
for (const el of qa(boxSel, doc)) el.remove();
// ⚠ 只隐藏 quiz 弹窗自己的遮罩层:.x-component-default 是 ExtJS 通用容器类,
// 无条件隐藏会打掉播放器正常的清晰度/线路切换弹层。
for (const el of qa('.x-component-default', doc)) {
if (el.contains(q('.ans-videoquiz', doc)) || el.querySelector?.('.ans-videoquiz')
|| el.id?.includes('videoquiz')) {
el.style.display = 'none';
}
}
log('检测到视频弹窗题,已作答并关闭(随机选项)', 'warn');
return true;
}
/** 播放单个视频
* 返回 true = 正常播完(或已处理完毕);false = 加载失败/卡死,调用方应重试 */
async function playVideo(doc) {
const media = await until(() => q('video,audio', doc), 200, 60000).catch(() => null);
const player = q('#video,#audio', doc);
if (!player || !media) { log('视频检测不到,跳过', 'warn'); return true; }
state.media = media;
const bar = q('.vjs-control-bar', player);
if (bar) bar.style.opacity = '1'; // 强制常显进度条
/** 播放结果:'ended' 正常结束 / 'error' 加载失败 / 'stall' 卡死 / 'noauto' 无法自动播放 */
const result = await new Promise((resolve) => {
let settled = false;
let watcher = 0; // 错误弹窗轮询
let stallTimer = 0; // 进度停滞检测
/** 唯一出口:清掉所有定时器与监听,保证只 resolve 一次、不泄漏
* @param outcome 'ended' | 'error' | 'stall' | 'noauto' */
const finish = (outcome = 'ended') => {
if (settled) return;
settled = true;
clearInterval(watcher);
clearInterval(stallTimer);
media.removeEventListener('pause', onPause);
// ⚠ ended 监听虽然注册了 once,但错误/停滞路径退出时它还没触发,
// 会一直挂在旧 media 上(切章后该元素可能被复用),必须显式移除;
// 同时清空 state.media,避免 setRate 去改已结束的视频。
media.removeEventListener('ended', onEnded);
if (state.media === media) state.media = null;
resolve(outcome);
};
const onEnded = () => {
log('视频播放完毕', 'ok');
finish('ended');
};
// 视频加载失败 → 返回失败让调用方重试;顺便处理中途弹出的弹窗题
watcher = setInterval(() => {
const box = q('.vjs-modal-dialog-content', doc);
if (box && ERR_MARKS.some((s) => box.innerText.includes(s))) {
log('视频加载失败', 'err');
finish('error');
return;
}
// 弹窗题:作答并关掉遮罩,让播放继续(否则会一路卡到停滞检测)
handleVideoQuiz(doc);
}, 3000);
const onPause = async () => {
// ⚠ 必须先看是否还在运行:stop() 会调 media.pause() 触发这里,
// 旧版无条件 media.play(),导致点「停止」后视频 1 秒又自己恢复播放。
if (!state.running) return;
await waitFaceFree();
if (media.ended || !state.running) return;
await sleep(1000);
if (!state.running) return;
media.play().catch(() => {});
media.playbackRate = cfg.playbackRate;
};
media.addEventListener('pause', onPause);
media.addEventListener('ended', onEnded, { once: true });
media.volume = cfg.muted ? 0 : 1;
media.currentTime = 0;
// 进度停滞检测:既没 ended 也没错误弹窗,且 currentTime 长时间不动 → 判定卡死。
// 这是最后一道保险,防止任何未预料的路径让 await playVideo 永久挂起。
let lastTime = -1;
let stall = 0;
stallTimer = setInterval(() => {
if (media.ended) return finish('ended');
// 人脸识别 / 弹窗题等待期间进度本就不会走,此时不能计入停滞,
// 否则一场 3 分钟的人脸识别会把视频判成卡死跳过(任务点被永久放弃)
if (faceWaiting || media.paused) { stall = 0; lastTime = media.currentTime; return; }
const t = media.currentTime;
if (t > 0 && Math.abs(t - lastTime) < 0.1) {
if (++stall >= 40) { // 40 × 3s = 2 分钟毫无推进
log('视频进度长时间无变化', 'warn');
finish('stall');
}
} else {
stall = 0;
}
lastTime = t;
}, 3000);
setTimeout(async () => {
const ok = await safePlay(media);
if (ok) {
media.playbackRate = cfg.playbackRate;
return;
}
// ⚠ 自动播放被拦时旧版这里不 resolve,await playVideo 会永久挂起、整轮卡死。
// 给用户 20 秒点击补救的时间;仍无动静就放过本视频,保证主循环继续。
let started = false;
try { started = await until(() => !media.paused || media.currentTime > 0, 500, 20000); }
catch (e) { started = false; }
if (!started) {
log('视频始终未能自动播放(点一下页面可让后续视频正常播放)', 'warn');
finish('noauto');
}
}, 200);
});
// 失败重试:加载失败 / 卡死 → 返回 false 交给任务级重试机制处理(重试 3 次)
// noauto 不算失败(用户没交互导致,重试也没用)
if (result === 'error' || result === 'stall') return false;
return true;
}
/** 视频失败后的现场恢复:清除错误弹窗、重置播放器,让下次重试能从干净状态开始。
* 优先刷新承载播放器的 iframe(超星视频在 iframe 内),失败则只清 DOM 错误态。 */
function recoverMediaPlayer(doc) {
try {
// ① 移除播放器上的错误弹窗遮罩,避免重试时立刻再次命中同一个错误文案
for (const box of qa('.vjs-modal-dialog-content', doc)) {
const wrap = box.closest('.vjs-modal-dialog') || box.parentElement;
if (wrap) wrap.style.display = 'none'; else box.remove();
}
// ② 尝试重置播放器(触发媒体重新加载)
const media = q('video,audio', doc);
if (media) {
try {
media.pause();
if (media.load) media.load();
media.currentTime = 0;
} catch (e) { /* ignore */ }
}
// ③ 播放器在 iframe 里时刷新该 iframe:超星的错误态往往是播放器内部状态,
// 只清 DOM 不够,必须让 iframe 重新初始化。
for (const frame of qa('iframe', DOC)) {
try {
const fd = frame.contentDocument;
if (!fd) continue;
if (!fd.querySelector('video,audio')) continue;
log(' 正在刷新播放器以重试', 'info');
frame.contentWindow.location.reload();
return;
} catch (e) { /* 跨域跳过 */ }
}
log(' 已清除播放器错误状态,准备重试', 'info');
} catch (e) {
log(' 恢复播放器失败:' + String(e).slice(0, 60), 'warn');
}
}
// ==================== 任务点查找 ====================
/** 递归收集所有同源 iframe */
function sameOriginFrames() {
const queue = qa('iframe', DOC);
const out = [];
while (queue.length) {
const frame = queue.shift();
try {
if (frame?.contentWindow?.document) {
out.push(frame);
queue.push(...qa('iframe', frame.contentWindow.document));
}
} catch (e) { /* 跨域,跳过 */ }
}
return out;
}
/** 取 iframe 上的任务点 id(超星把任务信息放在 frame 的 data 属性里) */
function frameJobId(frame) {
try {
const raw = frame.getAttribute('data') || '';
if (!raw) return null;
const d = JSON.parse(raw);
return d.jobid || d._jobid || null;
} catch (e) { return null; }
}
function jobKey(frame, idx) {
const jid = frameJobId(frame);
if (jid) return 'job_' + jid;
const src = frame.getAttribute('src') || '';
// ⚠ 不能多个 frame 都回退成同一个 'src_':那样第一个处理完,
// 其余同 src(或 src 为空)的任务点会被 processed 一并跳过(旧版 bug)。
return 'src_' + (src || `#${idx}`);
}
/** 从页面权威任务表 attachments 取任务状态 */
function jobState(jobid) {
if (jobid == null) return null;
const list = W.attachments;
if (!Array.isArray(list)) return null;
const a = list.find((x) => {
const id = x && (x.jobid || (x.property && x.property._jobid));
return id != null && String(id) === String(jobid);
});
if (!a) return null;
return a.job ? 'job' : (a.isPassed ? 'finished' : 'not-job');
}
/** 这个任务点还需不需要处理。
* ⚠ 旧版只看 DOM 里有没有