// ==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*<>/,
      /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 里有没有