// ==UserScript== // @name ZZ超星助手 // @namespace https://local.chaoxing.autowatch/cat // @version 3.1.40 // @description 自动播课 + 测验自动答题(字体解密 / 本地题库 / AI)。适配脚本猫,无卡密。 // @author - // @match https://mooc1.chaoxing.com/* // @match https://mooc2-ans.chaoxing.com/* // @run-at document-idle // @noframes // @connect 127.0.0.1 // @grant none // ==/UserScript== /* 由 build-userscript.mjs 自动生成,别直接改。 改逻辑请改 cx-auto-course.js / cx-quiz-bank.js,然后重新运行:node build-userscript.mjs 顺序很重要:播放模块先跑(建面板),题库模块随后挂进它的「题库」标签页。 */ /* ========================================================= * cx-auto-course.js (v5) * 超星课程:扫描目录找未完成章节 → 从那里开始逐节自动播完 * 自带可视化面板(Shadow DOM 隔离):进度条 / 章节状态 / 实时日志 * * 用法: * 1) 打开超星章节页,Ctrl+Shift+J 打开控制台 * 2) 粘贴本文件全部内容,回车 → 右下角出现面板 * 3) 面板点【开始】;或命令行: * __next.scan() 只扫描看结果 * __next.watchFrom(12) 从第 12 节开始 * __next.probe() 诊断目录结构 * __next.ui.hide() 隐藏面板 * __next.stop() 停止 * * 关键设计: * - 脚本粘在内容 iframe 里也能用:自动往上找"哪一层有目录" * - 完成度按任务点判断:div.ans-attach-ct 带 ans-job-finished 就是看完了 * - 目录里的 firstLayer 是单元标题,点了不跳转,必须排除 * - 事件常绑在子元素 span.posCatalog_name 上,要点对节点 * - 找不到目录也不放弃,至少把当前页视频播完 * ========================================================= */ (() => { 'use strict'; /* 启动横幅:脚本一注入就能在控制台看到,方便区分「没注入」和「注入了但面板没出来」 */ const BUILD_TAG = '3.1.40'; // 构建时替换成真实版本号 /* 防重复注入:装了多个脚本管理器(脚本猫 + 脚本猫 Beta + 油猴)时会跑多份, 这里把上一份的定时器、面板都清掉,只留最新的这一份 */ try { if (window.__next && typeof window.__next.__destroy === 'function') { window.__next.__destroy(); console.log('[ZZ超星助手] 已停用上一份实例'); } const oldHost = document.getElementById('cx-auto-host'); if (oldHost && oldHost.parentNode) oldHost.parentNode.removeChild(oldHost); } catch (e) {} try { console.log('%cZZ超星助手 ' + BUILD_TAG + ' · 播放模块已注入', 'color:#0d9488;font-weight:700;font-size:13px'); } catch (e) {} window.__cxBoot = { tag: BUILD_TAG, course: false, bank: false, at: Date.now() }; const CFG = { autoStart: true, mute: true, restart: false, skipNearEnd: true, skipDone: true, // 任务点已完成(ans-job-finished)就跳过 autoQuiz: true, // 视频看完后自动切到「章节测验」并停下等你作答 stallMs: 180000, // 多久没进展就自查(毫秒);默认 3 分钟 deepMs: 1200000, // 深度检查间隔;默认 20 分钟(全局兜底) resumeOnPause: true, // 视频被暂停(切后台 / 平台拦截)时自动恢复播放 stopAtTaskDone: true, // 任务点标记完成(通常 90%)就停,不必播到 98% autoDoc: true, // 自动滚动「文档/阅读」类任务点 docScrollMs: 45000, // 单节滚动时间上限(毫秒) docPasses: 2, // 最多换几个容器/几轮 rate: 1, // 播放倍速,1 = 原速 requireLicense: false, // 视频功能永久免费,不校验卡密。收费只发生在题库模块的「答题」 printInterval: 3000, maxChapters: 100, videoTimeout: 15000, chapterTimeout: 30000, settle: 1000, maxLog: 300, }; /* 用户是否手动停止(手动停止后不再自动恢复) */ let userStopped = false; let autoResumeAt = 0; let autoResumeFails = 0; const S = { running: false, paused: false, busy: false, started: false, phase: '待机', round: 0, chapterIndex: -1, vids: [], videoIdx: -1, videosDone: 0, chapterVideosDone: 0, report: [], startIndex: -1, t0: 0, timer: null, rate: 1, }; /* ============ 运行位置:自动找有目录的那一层 ============ */ const ROOT = (function () { const cands = []; try { if (window.top && window.top !== window) cands.push(window.top); } catch (e) {} try { if (window.parent && window.parent !== window) cands.push(window.parent); } catch (e) {} for (const w of cands) { try { const d = w.document; if (d && d.querySelectorAll('.posCatalog_select').length >= 2) return w; } catch (e) {} } return window; })(); const RDOC = () => { try { return ROOT.document || document; } catch (e) { return document; } }; const RHREF = () => { try { return String((ROOT.location && ROOT.location.href) || ''); } catch (e) { return ''; } }; /* ============ 授权(卡密) ============ 只内置【公钥】:能验签,不能签发。 卡密 = CX1.. 载荷 = v1|到期日|序号 ------------------------------------- */ const LICENSE = { PUB: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE3SiTo6XjbxbTtPDRj8orkxaZHM+Al/7I+lK4uTZWIgswdgkMbw8ZUzWDslSr/Fh011qq5ybP+m5Nzv2tyOpVMg==', // ↑ 若自己重新生成过密钥,记得同步替换 API: '', // 填 Worker 地址,例如 'https://cx-license.你的账号.workers.dev' // 留空 = 只做本地验签(可被删代码绕过) STORE: 'cx_license_v1', state: { ok: false, exp: '', sn: '', daysLeft: 0, online: false }, b64uToBytes(s) { s = String(s).replace(/-/g, '+').replace(/_/g, '/'); while (s.length % 4) s += '='; const bin = atob(s); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; }, bytesToB64u(bytes) { const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); let bin = ''; for (let i = 0; i < arr.length; i++) bin += String.fromCharCode(arr[i]); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }, // 设备指纹:浏览器/系统不变就不变,清缓存也不影响 hwid() { let tz = ''; try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {} const s = [ navigator.userAgent || '', (window.screen ? window.screen.width + 'x' + window.screen.height + 'x' + window.screen.colorDepth : ''), tz, ].join('|'); let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h.toString(16).padStart(8, '0'); }, async _sigCheck(text, sigBytes) { const c = (typeof crypto !== 'undefined' && crypto.subtle) ? crypto : null; if (!c) return null; const key = await c.subtle.importKey('spki', this.b64uToBytes(this.PUB), { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']); return c.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, sigBytes, new TextEncoder().encode(text)); }, // 校验服务器下发的短期令牌(离线可用) async checkToken(token, hwid) { const parts = String(token || '').split('.'); if (parts.length !== 3 || parts[0] !== 'T1') return null; let text; try { text = new TextDecoder().decode(this.b64uToBytes(parts[1])); } catch (e) { return null; } let sig; try { sig = this.b64uToBytes(parts[2]); } catch (e) { return null; } const good = await this._sigCheck(text, sig); if (!good) return null; const seg = text.split('|'); if (seg[0] !== 't1') return null; const sn = seg[1], exp = seg[2], hw = seg[3], issued = Number(seg[4]), ttl = Number(seg[5]); if (hw !== hwid) return null; if (!isFinite(issued) || !isFinite(ttl)) return null; if (Date.now() > issued + ttl) return null; const end = new Date(exp + 'T23:59:59'); if (isNaN(end.getTime()) || end.getTime() < Date.now()) return null; return { ok: true, exp: exp, sn: sn, daysLeft: Math.ceil((end.getTime() - Date.now()) / 86400000) }; }, // 纯本地验签(离线兜底,也是联网前的前置检查) async verifyLocal(card) { const raw = String(card || '').trim().replace(/\s+/g, ''); const parts = raw.split('.'); if (parts.length !== 3 || parts[0] !== 'CX1') return { ok: false, reason: '卡密格式不对,应为 CX1.xxx.xxx' }; let payloadText, sig; try { payloadText = new TextDecoder().decode(this.b64uToBytes(parts[1])); sig = this.b64uToBytes(parts[2]); } catch (e) { return { ok: false, reason: '卡密内容损坏' }; } if (!(typeof crypto !== 'undefined' && crypto.subtle)) { return { ok: false, reason: '当前页面不支持校验(需要 https 环境)' }; } let good = false; try { good = await this._sigCheck(payloadText, sig); } catch (e) { return { ok: false, reason: '校验失败:' + e.message }; } if (!good) return { ok: false, reason: '卡密无效(签名对不上)' }; const seg = payloadText.split('|'); if (seg[0] !== 'v1') return { ok: false, reason: '卡密版本不支持' }; const exp = seg[1], sn = seg[2] || ''; const end = new Date(exp + 'T23:59:59'); if (isNaN(end.getTime())) return { ok: false, reason: '卡密里的日期损坏' }; const left = Math.ceil((end.getTime() - Date.now()) / 86400000); if (left <= 0) return { ok: false, reason: '卡密已于 ' + exp + ' 过期', exp: exp }; return { ok: true, exp: exp, sn: sn, daysLeft: left, card: raw }; }, async verify(card) { const raw = String(card || '').trim().replace(/\s+/g, ''); const hw = this.hwid(); // 1) 先本地验签:格式/签名不对,连服务器都不用问 const local = await this.verifyLocal(raw); if (!local.ok) return local; // 2) 没配 API → 只做本地校验 if (!this.API) { local.online = false; return local; } // 3) 联网校验(设备绑定 / 吊销 / 有效期都在服务端判定) try { const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null; const timer = ctl ? setTimeout(() => ctl.abort(), 8000) : null; const res = await fetch(String(this.API).replace(/\/+$/, '') + '/verify', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ card: raw, hwid: hw }), signal: ctl ? ctl.signal : undefined, }); if (timer) clearTimeout(timer); let data = null; try { data = await res.json(); } catch (e) { data = null; } if (!data) return { ok: false, reason: '服务器返回异常(HTTP ' + res.status + ')' }; if (!data.ok) return { ok: false, reason: data.reason || '服务端拒绝', hard: true }; return { ok: true, exp: data.exp, sn: data.sn, daysLeft: data.daysLeft, token: data.token, online: true, card: raw, }; } catch (e) { // 4) 连不上 → 用本地缓存的令牌(24 小时内有效) const saved = this.load(); if (saved && saved.token) { const t = await this.checkToken(saved.token, hw); if (t) { this.state.offline = true; return { ok: true, exp: t.exp, sn: t.sn, daysLeft: t.daysLeft, token: saved.token, online: false, offline: true }; } } return { ok: false, reason: '连不上授权服务器,且没有可用的离线凭证(' + (e && e.name === 'AbortError' ? '超时' : '网络错误') + ')' }; } }, load() { try { return JSON.parse(localStorage.getItem(this.STORE) || 'null'); } catch (e) { return null; } }, save(card, r) { try { localStorage.setItem(this.STORE, JSON.stringify({ card: card, exp: r.exp, sn: r.sn, token: r.token || '', hwid: this.hwid(), })); } catch (e) {} }, clear() { try { localStorage.removeItem(this.STORE); } catch (e) {} }, }; /* ============ 小工具 ============ */ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const clean = (s) => String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); const fmt = (s) => { if (!isFinite(s) || s < 0) return '--:--'; s = Math.floor(s); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = String(s % 60).padStart(2, '0'); return h ? h + ':' + String(m).padStart(2, '0') + ':' + sec : m + ':' + sec; }; const pct = (v) => (isFinite(v.duration) && v.duration > 0 ? Math.min(100, (v.currentTime / v.duration) * 100) : 0); const isFinished = (v) => !!v.ended || (isFinite(v.duration) && v.duration > 0 && v.currentTime >= v.duration * 0.98); /* ============ 日志(面板 + 控制台双写) ============ */ const LOG = { lines: [], // 保留一份,供导出 add(level, msg) { const item = { t: new Date(), level, msg: String(msg) }; this.lines.push(item); if (this.lines.length > 800) this.lines.splice(0, this.lines.length - 800); try { UI.log(item); } catch (e) {} const fn = level === 'err' ? console.error : level === 'warn' ? console.warn : console.log; try { fn.call(console, msg); } catch (e) {} }, info(m) { this.add('info', m); }, ok(m) { this.add('ok', m); }, warn(m) { this.add('warn', m); }, err(m) { this.add('err', m); }, dump() { const pad = (n) => String(n).padStart(2, '0'); return this.lines.map((x) => { const d = x.t; return '[' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '] ' + ({ err: 'ERR ', warn: 'WARN', ok: 'OK ' }[x.level] || 'INFO') + ' ' + x.msg; }).join('\n'); }, }; /* ============ 面板样式 ============ */ const CSS = ` *{box-sizing:border-box;margin:0;padding:0} .wrap{position:fixed;right:18px;bottom:18px;width:442px;z-index:2147483647; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif; color:#334155;font-size:13.5px;line-height:1.65;font-weight:500; font-variant-numeric:tabular-nums;-webkit-font-smoothing:antialiased; background:#ffffff;border:1px solid #e9eff6;border-radius:18px; box-shadow:0 1px 2px rgba(15,23,42,.04),0 18px 50px rgba(15,23,42,.16); overflow:hidden} .hd{display:flex;align-items:center;gap:10px;padding:14px 16px 12px;cursor:move;user-select:none; background:linear-gradient(180deg,#fcfdff 0%,#f6f9fc 100%);border-bottom:1px solid #eef2f7} .dot{width:9px;height:9px;border-radius:50%;background:#cbd5e1;flex:none;transition:all .2s} .dot.run{background:#0d9488;box-shadow:0 0 0 4px rgba(13,148,136,.14);animation:pulse 1.6s ease-in-out infinite} .dot.pause{background:#f59e0b;box-shadow:0 0 0 4px rgba(245,158,11,.14)} .dot.done{background:#10b981;box-shadow:0 0 0 4px rgba(16,185,129,.14)} @keyframes pulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.55;transform:scale(.86)}} .ttl{font-weight:800;font-size:15px;flex:1;color:#0f172a;letter-spacing:.3px; display:flex;align-items:center;gap:9px} .ver{font-size:10.5px;color:#94a3b8;font-weight:600;background:#f1f5f9; border-radius:99px;padding:1px 7px;margin:0 4px;vertical-align:middle;letter-spacing:.2px} .ttl small{font-weight:600;color:#0f766e;background:#f0fdfa;border:1px solid #ccfbf1; border-radius:99px;padding:1px 9px;font-size:11.5px;letter-spacing:0;white-space:nowrap} .ic{width:24px;height:24px;border-radius:8px;border:none;background:#eef3f9; color:#64748b;cursor:pointer;font-size:13px;display:grid;place-items:center;line-height:1; transition:background .15s,color .15s} .ic:hover{background:#e2eaf4;color:#334155} .tabs{display:flex;gap:4px;padding:10px 14px 0;border-bottom:1px solid #eef2f7} .tab{padding:7px 16px;border:none;background:transparent;color:#94a3b8;font-size:13px;font-weight:500; cursor:pointer;border-radius:9px 9px 0 0;font-family:inherit;transition:all .15s; border-bottom:2px solid transparent;margin-bottom:-1px} .tab:hover{color:#64748b;background:#f8fafc} .tab.on{color:#0f766e;border-bottom-color:#0d9488;font-weight:800;background:#f0fdfa} .bd{padding:14px 16px 16px} .wrap.min .bd,.wrap.min .ft{display:none} .wrap.min{width:210px} .wrap.min .hd{border-bottom:none} .stat{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:7px; color:#64748b;font-size:13px;font-weight:500} .stat b{color:#0f172a;font-weight:700;font-variant-numeric:tabular-nums} .bar{height:8px;border-radius:99px;background:#eef3f9;overflow:hidden;margin-bottom:6px; box-shadow:inset 0 1px 2px rgba(15,23,42,.04)} .bar>i{display:block;height:100%;width:0;border-radius:99px; background:linear-gradient(90deg,#0d9488,#10b981);transition:width .45s cubic-bezier(.4,0,.2,1)} .bar.sub>i{background:linear-gradient(90deg,#60a5fa,#0d9488)} .row{display:flex;justify-content:space-between;color:#94a3b8;font-size:12.5px;margin-bottom:11px;font-weight:500} .pills{display:flex;gap:6px;margin-bottom:12px} .pill{flex:1;padding:7px 0;font-size:12.5px;border-radius:9px;cursor:pointer;font-family:inherit;font-weight:500; background:#f8fafc;border:1px solid #e9eff6;color:#64748b;transition:all .15s; font-variant-numeric:tabular-nums} .pill:hover{background:#f1f8f7;border-color:#cfeee9;color:#0f766e} .pill.on{background:#0d9488;border-color:#0d9488;color:#fff;font-weight:800; box-shadow:0 2px 8px rgba(13,148,136,.28)} .sec{margin-top:12px;padding-top:11px;border-top:1px solid #f1f5f9} .sec h4{font-size:11.5px;font-weight:800;color:#94a3b8;letter-spacing:.8px; margin-bottom:8px;display:flex;justify-content:space-between;align-items:baseline} .sec h4 span{color:#b6c0cc;font-weight:500;letter-spacing:0;font-size:11.5px} .list{max-height:158px;overflow:auto;border-radius:11px;background:#fbfcfe; border:1px solid #eff3f8} .ci{display:flex;align-items:center;gap:9px;padding:7px 10px;border-bottom:1px solid #f4f7fa; transition:background .12s} .ci:last-child{border-bottom:none} .ci:hover{background:#f5f9fd} .ci .n{color:#b6c0cc;font-size:11.5px;width:22px;flex:none;text-align:right; font-variant-numeric:tabular-nums} .ci .t{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569;font-size:12.5px;font-weight:500} .ci.cur{background:#f0fdfa;box-shadow:inset 3px 0 0 #0d9488} .ci.cur .t{color:#0f766e;font-weight:700} .ci.done .t{color:#a8b3c1;text-decoration:line-through} .tag{font-size:11px;padding:2px 9px;border-radius:99px;flex:none;font-weight:700; white-space:nowrap} .tag.ok{background:#ecfdf5;color:#059669} .tag.no{background:#f1f5f9;color:#94a3b8} .tag.q{background:#fffbeb;color:#d97706} .log{max-height:168px;overflow:auto;font-family:ui-monospace,Consolas,"Cascadia Mono",monospace; font-size:12px;line-height:1.8;font-weight:500;background:#fbfcfe;border:1px solid #eff3f8;border-radius:11px; padding:9px 11px} .ln{display:flex;gap:8px;white-space:pre-wrap;word-break:break-all;border-radius:5px} .ln:hover{background:#f4f8fc} .ln time{color:#c3ccd8;flex:none;font-variant-numeric:tabular-nums} .ln.info span{color:#64748b} .ln.ok span{color:#059669} .ln.warn span{color:#d97706} .ln.err span{color:#dc2626} .list::-webkit-scrollbar,.log::-webkit-scrollbar{width:8px} .list::-webkit-scrollbar-thumb,.log::-webkit-scrollbar-thumb{background:#dbe3ec;border-radius:99px} .list::-webkit-scrollbar-thumb:hover,.log::-webkit-scrollbar-thumb:hover{background:#c8d3e0} .ft{display:flex;gap:8px;padding:0 16px 16px} .btn{flex:1;padding:10px 0;border-radius:11px;border:1px solid #e5ecf4; background:#f8fafc;color:#475569;font-size:13px;cursor:pointer;font-family:inherit; font-weight:700;transition:all .15s} .btn:hover:not(:disabled){background:#f1f8f7;color:#0f766e;border-color:#cfeee9} .btn:active:not(:disabled){transform:translateY(1px)} .btn:disabled{opacity:.42;cursor:not-allowed} .btn.pri{background:linear-gradient(180deg,#12a99b,#0d9488);border-color:#0d9488;color:#fff;font-weight:800; box-shadow:0 2px 10px rgba(13,148,136,.28)} .btn.pri:hover:not(:disabled){background:linear-gradient(180deg,#0fa093,#0b8579);color:#fff} .btn.dan:hover:not(:disabled){background:#fef2f2;color:#dc2626;border-color:#fecaca} .gate{position:fixed;inset:0;z-index:2147483646;display:none;place-items:center; background:rgba(15,23,42,.38);backdrop-filter:blur(4px)} .gate.show{display:grid} .gcard{width:390px;padding:24px;border-radius:20px;background:#fff; border:1px solid #e9eff6;box-shadow:0 26px 80px rgba(15,23,42,.3)} .gtitle{font-size:17px;font-weight:700;color:#0f172a;letter-spacing:.3px;margin-bottom:6px} .gsub{color:#64748b;font-size:12.5px;margin-bottom:16px;line-height:1.7} .gin{width:100%;min-height:80px;padding:11px 13px;border-radius:12px;resize:vertical; background:#fafcfe;border:1px solid #dbe4ee;color:#111827; font-family:ui-monospace,Consolas,monospace;font-size:12.5px;line-height:1.6;outline:none; transition:border .15s,box-shadow .15s} .gin:focus{border-color:#7dd3c8;box-shadow:0 0 0 4px rgba(13,148,136,.1)} .gmsg{min-height:18px;font-size:12.5px;margin:10px 0 14px;color:#d97706} .gmsg.ok{color:#059669} .gmsg.bad{color:#dc2626} .gbtn{width:100%;padding:11px 0;border-radius:12px;border:none; background:linear-gradient(180deg,#12a99b,#0d9488);color:#fff;font-weight:700; font-size:14px;cursor:pointer;font-family:inherit;box-shadow:0 2px 12px rgba(13,148,136,.3)} .gbtn:hover{background:linear-gradient(180deg,#0fa093,#0b8579)} .gfoot{margin-top:12px;font-size:11.5px;color:#94a3b8;text-align:center} /* ===================== 横板布局 ===================== */ .wrap{width:min(760px,calc(100vw - 40px))} .tabrow{display:flex;align-items:center;gap:14px;padding:10px 16px;border-bottom:1px solid #eef2f7} .tabrow .tabs{display:flex;gap:6px;padding:0;border:none;flex:none} .tabrow .tab{border-radius:9px;padding:7px 17px;border-bottom:none;margin-bottom:0;font-size:14.5px} .tabrow .tab.on{background:linear-gradient(180deg,#12a99b,#0d9488);color:#fff;box-shadow:0 2px 8px rgba(13,148,136,.26)} .cxai{flex:1;display:flex;gap:8px;min-width:0} .cxai input{flex:1;min-width:0;padding:9px 13px;border:1px solid #e3eaf3;border-radius:11px;font:14.5px/1.4 inherit;color:#0f172a;background:#fbfdff;outline:none} .cxai input:focus{border-color:#7dd3c8;background:#fff;box-shadow:0 0 0 4px rgba(13,148,136,.1)} .cxai button{padding:9px 16px;border:none;border-radius:11px;cursor:pointer;font-family:inherit;font-weight:700;font-size:14px;background:linear-gradient(180deg,#12a99b,#0d9488);color:#fff;box-shadow:0 2px 10px rgba(13,148,136,.26)} .cxaians{margin:12px 16px 0;padding:10px 13px;border:1px solid #bae6fd;background:#f0f9ff;border-radius:12px;font-size:13.5px;line-height:1.75;color:#0c4a6e;max-height:150px;overflow:auto;white-space:pre-wrap} .bd{display:grid;grid-template-columns:1fr 1fr;gap:16px;padding:15px 16px 16px;align-items:start} .bd .col{min-width:0} .bd .colR .sec{margin-top:0} .bd .list{max-height:168px} .bd .log{height:190px} .cxlog{padding:0 16px 12px} .cxlog h4{font-size:12px;font-weight:800;color:#8fa0b5;letter-spacing:.5px;margin-bottom:8px; display:flex;align-items:baseline;gap:8px} .cxlog h4 span{color:#b6c0cc;font-weight:600} .cxlog .clr{margin-left:auto;border:1px solid #e3eaf3;background:#f8fafc;color:#94a3b8; border-radius:8px;padding:2px 10px;font:11.5px/1.6 inherit;cursor:pointer;font-family:inherit} .cxlog .log{height:132px;border:1px solid #eef2f8;border-radius:12px;background:#fcfdff;padding:8px 11px} .tips{font-size:13px;line-height:1.75;color:#94a3b8} .ft{justify-content:flex-end;gap:10px;display:flex} .ft .btn{padding:10px 26px} .ft .btn.pri{padding:10px 40px;flex:none} /* ===================== 悬浮球 ===================== */ .wrap.hidden{display:none} .ball{position:fixed;right:20px;bottom:96px;width:56px;height:56px;border-radius:50%; display:none;place-items:center;cursor:pointer;z-index:2147483647; background:linear-gradient(135deg,#0ea5e9,#0d9488);color:#fff; font:800 15px/1 -apple-system,"Microsoft YaHei",sans-serif;letter-spacing:.5px; box-shadow:0 10px 26px rgba(13,148,136,.38),0 2px 6px rgba(15,23,42,.16); border:none;transition:transform .16s,box-shadow .16s;user-select:none} .ball:hover{transform:translateY(-2px) scale(1.05);box-shadow:0 14px 32px rgba(13,148,136,.46)} .ball.show{display:grid} .ball .ring{position:absolute;inset:-6px;border-radius:50%;border:2px solid rgba(13,148,136,.35); animation:ballpulse 2.2s ease-out infinite;pointer-events:none} @keyframes ballpulse{0%{transform:scale(.9);opacity:.9}70%{transform:scale(1.2);opacity:0}100%{opacity:0}} /* ===================== Apple 风格(覆盖前面所有外观) ===================== */ .wrap{width:min(720px,calc(100vw - 40px));font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","PingFang SC","Helvetica Neue",sans-serif; font-size:15px;line-height:1.55;color:#1c1c1e;background:rgba(255,255,255,.92); -webkit-backdrop-filter:saturate(180%) blur(20px);backdrop-filter:saturate(180%) blur(20px); border:.5px solid rgba(0,0,0,.08);border-radius:22px;overflow:hidden; box-shadow:0 12px 40px rgba(0,0,0,.16),0 1px 3px rgba(0,0,0,.06)} .hd{padding:14px 18px 12px;background:transparent;border-bottom:.5px solid rgba(60,60,67,.12)} .dot{width:9px;height:9px;background:#34c759;box-shadow:0 0 0 3px rgba(52,199,89,.16)} .ttl{font-size:16px;font-weight:600;letter-spacing:-.2px;color:#1c1c1e} .ver{font-size:11px;background:#f2f2f7;color:#8e8e93;font-weight:500;padding:2px 7px;border-radius:6px} .ttl small{font-size:12px;font-weight:600;color:#007aff;background:rgba(0,122,255,.1);border:none;padding:2px 9px;border-radius:99px} .ic{width:28px;height:28px;border-radius:50%;background:#f2f2f7;color:#8e8e93;font-size:14px} .ic:hover{background:#e5e5ea;color:#1c1c1e} /* 分段控件(iOS Segmented Control) */ .tabrow{padding:12px 16px 0;border-bottom:none;gap:12px} .tabrow .tabs{background:#f2f2f7;border-radius:10px;padding:2px;gap:0} .tabrow .tab{border-radius:8px;padding:6px 18px;font-size:13.5px;font-weight:600;color:#1c1c1e;background:transparent;box-shadow:none} .tabrow .tab.on{background:#fff;color:#1c1c1e;font-weight:700;box-shadow:0 1px 3px rgba(0,0,0,.12)} .cxai input{padding:8px 12px;border:.5px solid rgba(60,60,67,.16);border-radius:10px;background:rgba(118,118,128,.08);font-size:14px} .cxai input:focus{border-color:#007aff;background:#fff;box-shadow:0 0 0 3.5px rgba(0,122,255,.15)} .cxai button{padding:8px 16px;border-radius:10px;background:#007aff;font-size:14px;font-weight:600;box-shadow:none} .cxaians{margin:12px 16px 0;border:.5px solid rgba(0,122,255,.2);background:rgba(0,122,255,.07);border-radius:12px;color:#1c1c1e;font-size:13.5px} .bd{padding:14px 16px 4px;gap:14px} .stat{font-size:13.5px;color:#8e8e93;margin-bottom:7px} .stat b{font-size:15px;font-weight:600;color:#1c1c1e} .bar{height:8px;border-radius:99px;background:rgba(118,118,128,.16);margin-bottom:5px;box-shadow:none} .bar>i{background:#34c759} .bar.sub>i{background:#007aff} .row,.row2{font-size:16px;color:#8e8e93;margin-bottom:11px;font-weight:500} /* 倍速:分段控件 */ .pills{background:#f2f2f7;border-radius:10px;padding:2px;gap:0;margin-bottom:10px} .pill{border:none;background:transparent;border-radius:8px;padding:7px 0;font-size:13.5px;font-weight:600;color:#1c1c1e} .pill.on{background:#fff;color:#007aff;font-weight:700;box-shadow:0 1px 3px rgba(0,0,0,.12)} .sec{border:.5px solid rgba(60,60,67,.12);border-radius:14px;background:#fff;margin-top:12px;padding:12px 12px 10px} .sec h4{font-size:12px;font-weight:600;color:#8e8e93;letter-spacing:.2px;text-transform:none;margin-bottom:8px} .sec h4 span{color:#c7c7cc;font-weight:500} .list{border:.5px solid rgba(60,60,67,.1);border-radius:10px} .ci{padding:8px 10px;border-bottom:.5px solid rgba(60,60,67,.1)} .ci .n{color:#c7c7cc;font-size:12.5px} .ci .t{font-size:13.5px;font-weight:500;color:#1c1c1e} .ci.cur{background:rgba(0,122,255,.07);box-shadow:inset 3px 0 0 #007aff} .ci.cur .t{color:#007aff;font-weight:600} .ci.done .t{color:#c7c7cc} .tag{font-size:11px;font-weight:600;padding:2px 8px;border-radius:99px} .tag.ok{background:rgba(52,199,89,.14);color:#248a3d} .tag.no{background:rgba(255,149,0,.14);color:#c93400} .cxlog{padding:8px 16px 12px} .cxlog h4{font-size:12px;font-weight:600;color:#8e8e93;margin-bottom:7px} .cxlog .clr{border:none;background:#f2f2f7;color:#8e8e93;border-radius:7px;padding:2px 9px;font-size:11.5px} .cxlog .log{height:128px;border:.5px solid rgba(60,60,67,.12);border-radius:12px;background:#f9f9fb; padding:8px 11px;font-family:"SF Mono",ui-monospace,Menlo,Consolas,monospace;font-size:12.5px;line-height:1.65;color:#3a3a3c} .log .t{color:#c7c7cc} .tips{font-size:13px;line-height:1.65;color:#8e8e93} .ft{justify-content:flex-end;gap:10px;padding:12px 16px 16px;border-top:none} .btn{padding:10px 24px;border-radius:12px;border:none;background:#f2f2f7;color:#007aff; font-size:15px;font-weight:600;box-shadow:none} .btn:hover:not(:disabled){background:#e5e5ea} .btn.pri{padding:10px 34px;background:#007aff;color:#fff;font-weight:600;box-shadow:0 4px 14px rgba(0,122,255,.32)} .btn.pri:hover:not(:disabled){background:#0066d6} /* 悬浮球:iOS 质感 */ .ball{background:linear-gradient(145deg,#0a84ff,#0071e3);border:none; box-shadow:0 10px 28px rgba(0,122,255,.36),0 1px 4px rgba(0,0,0,.12); font-family:-apple-system,"SF Pro Text","PingFang SC",sans-serif;font-weight:600;font-size:13px} .ball .ring{border-color:rgba(0,122,255,.4)} `; const noopUI = { enabled: false, log() {}, setState() {}, setChapters() {}, setButtons() {}, show() {}, hide() {}, destroy() {}, setRateState() {}, showGate() {}, hideGate() {}, }; function createUI() { try { return buildUI(); } catch (e) { try { console.warn('[面板] 初始化失败,退回控制台模式:', e); } catch (_) {} return noopUI; } } function buildUI() { if (!document.body || typeof document.createElement !== 'function') return noopUI; const host = document.createElement('div'); host.id = 'cx-auto-host'; host.setAttribute('data-cx-version', BUILD_TAG); const root = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = CSS; root.appendChild(style); const wrap = document.createElement('div'); wrap.className = 'wrap'; wrap.innerHTML = [ '
', '
ZZ超星助手 待机
', '
', '
', '
', '
', '
', '', '
', '
', '
当前章节-
', '
', '
章节进度 0%0/0
', '
当前视频-
', '
', '
0:00 / 0:000%
', '
播放速度', '1x
', '
', '

章节状态 未扫描

', '
', '
', '

快速说明 日志在下面

', '
点「开始」后全自动。日志在面板底部,播放和题库的记录都在这里。
', '
', '
', '
', '

运行日志 0

', '
', '
', '', '', '
', ].join(''); root.appendChild(wrap); /* 激活遮罩 */ const gate = document.createElement('div'); gate.className = 'gate'; gate.innerHTML = [ '
', '
授权验证
', '
粘贴你拿到的卡密以激活。卡密只在本机保存,到期自动失效。
', '', '
', '', '
卡密形如 CX1.xxx.yyy,请完整粘贴
', '
', ].join(''); root.appendChild(gate); (document.body || document.documentElement).appendChild(host); /* 面板上显示版本号 */ try { const verEl = root.getElementById('ver'); if (verEl) verEl.textContent = BUILD_TAG; } catch (e) {} const $ = (id) => root.getElementById(id); /* ---- 统一日志入口(题库模块的日志也进这里) ---- */ try { window.__cxLogAdd = (lv, msg) => { try { UI.log(lv || 'info', '[题库] ' + msg); } catch (e) {} }; const clr = $('cxLogClear'); if (clr) clr.onclick = () => { try { $('log').innerHTML = ''; } catch (e) {} }; } catch (e) {} /* ---- AI 搜索(调用题库模块) ---- */ try { const askBox = $('cxaiq'); const askBtn = $('cxaiBtn'); const ansBox = $('cxaians'); const doSearch = async () => { const q = String((askBox && askBox.value) || '').trim(); if (!q) return; if (ansBox) { ansBox.style.display = ''; ansBox.textContent = '正在问 AI…'; } let r = null; try { r = await window.__bank.aiSearch(q); } catch (e) { r = { ok: false, reason: e.message }; } if (!ansBox) return; if (r && r.ok) { ansBox.textContent = String(r.answer).replace(/\n/g, '\n') + (typeof r.left === 'number' ? '\n\n—— AI 搜索今日还剩 ' + r.left + ' 次 ——' : ''); ansBox.style.color = ''; } else { ansBox.textContent = (r && r.reason) || 'AI 没返回'; ansBox.style.color = '#dc2626'; } }; if (askBtn) askBtn.onclick = doSearch; if (askBox) askBox.onkeydown = (e) => { if (e.key === 'Enter') doSearch(); }; } catch (e) {} const E = { dot: $('dot'), phase: $('phase'), chap: $('chap'), vid: $('vid'), bar1: $('bar1'), bar2: $('bar2'), p1t: $('p1t'), p1v: $('p1v'), p2t: $('p2t'), p2v: $('p2v'), list: $('list'), sum: $('sum'), log: $('log'), cnt: $('cnt'), rateBar: $('rateBar'), rateVal: $('rateVal'), bScan: $('bScan'), bStart: $('bStart'), bPause: $('bPause'), bStop: $('bStop'), }; /* 倍速按钮 */ const RATES = [1, 1.25, 1.5, 2, 3]; E.rateBar.innerHTML = RATES.map((r) => '').join(''); E.rateBar.addEventListener('click', (ev) => { const b = ev.target && ev.target.closest && ev.target.closest('.pill'); if (!b) return; if (api.onRate) api.onRate(Number(b.getAttribute('data-r'))); }); /* 拖动 */ let drag = null; wrap.addEventListener('mousedown', (e) => { const t = e.target; if (!t.closest || !t.closest('.hd') || t.closest('.ic')) return; const r = wrap.getBoundingClientRect(); drag = { dx: e.clientX - r.left, dy: e.clientY - r.top }; wrap.style.right = 'auto'; wrap.style.bottom = 'auto'; wrap.style.left = r.left + 'px'; wrap.style.top = r.top + 'px'; e.preventDefault(); }); try { window.addEventListener('mousemove', (e) => { if (!drag) return; const w = window.innerWidth || 1200, h = window.innerHeight || 800; wrap.style.left = Math.max(0, Math.min(w - wrap.offsetWidth, e.clientX - drag.dx)) + 'px'; wrap.style.top = Math.max(0, Math.min(h - 60, e.clientY - drag.dy)) + 'px'; }); window.addEventListener('mouseup', () => { drag = null; }); } catch (e) {} /* ============ 悬浮球:收起面板,只留一个小球 ============ */ const ball = document.createElement('button'); ball.className = 'ball'; ball.type = 'button'; ball.title = '点开面板'; ball.innerHTML = 'Z'; root.appendChild(ball); let collapsed = false; function collapse(on) { collapsed = !!on; if (collapsed) { wrap.classList.add('hidden'); ball.classList.add('show'); } else { wrap.classList.remove('hidden'); ball.classList.remove('show'); } try { localStorage.setItem('cx_panel_collapsed', collapsed ? '1' : '0'); } catch (e) {} } ball.addEventListener('click', () => collapse(false)); $('btnMin').addEventListener('click', () => collapse(true)); $('btnClose').addEventListener('click', () => collapse(true)); /* 记住上次是展开还是收起 */ try { if (localStorage.getItem('cx_panel_collapsed') === '1') collapse(true); } catch (e) {} /* 球上显示当前进度,收起也能看到在跑 */ function refreshBall() { try { const total = (S.report || []).length; const done = (S.report || []).filter((r) => r.state === true).length; if (!total) { ball.textContent = 'Z'; return; } ball.textContent = done + '/' + total; ball.style.fontSize = '13px'; } catch (e) {} } setInterval(refreshBall, 5000); refreshBall(); /* 标签页切换 */ function switchTab(paneId) { const bar = $('tabs'); for (let i = 0; i < bar.children.length; i++) { const b = bar.children[i]; b.className = 'tab' + (b.getAttribute('data-pane') === paneId ? ' on' : ''); } let panes = []; try { panes = [...wrap.querySelectorAll(':scope > .bd')]; } catch (e) { panes = []; } for (let i = 0; i < panes.length; i++) { panes[i].style.display = panes[i].id === paneId ? '' : 'none'; } } const mainTabBtn = $('tabs').children[0]; if (mainTabBtn) mainTabBtn.addEventListener('click', () => switchTab('pane-main')); /* 激活框 */ const gInput = $('gInput'), gMsg = $('gMsg'), gBtn = $('gBtn'); let gateCb = null; gBtn.addEventListener('click', async () => { const card = String(gInput.value || '').trim().replace(/\s+/g, ''); if (!card) { gMsg.className = 'gmsg bad'; gMsg.textContent = '请先粘贴卡密'; return; } gMsg.className = 'gmsg'; gMsg.textContent = '校验中…'; gBtn.disabled = true; let r = null; try { r = await (gateCb ? gateCb(card) : { ok: false, reason: '内部错误' }); } catch (e) { r = { ok: false, reason: String(e && e.message || e) }; } gBtn.disabled = false; if (r && r.ok) { gMsg.className = 'gmsg ok'; gMsg.textContent = '激活成功,有效期至 ' + r.exp; } else { gMsg.className = 'gmsg bad'; gMsg.textContent = (r && r.reason) || '激活失败'; } }); const api = { enabled: true, onStart: null, onPause: null, onStop: null, onScan: null, show() { host.style.display = ''; }, hide() { host.style.display = 'none'; }, destroy() { try { host.remove(); } catch (e) {} }, showGate(cb) { gateCb = cb; host.style.display = ''; gate.className = 'gate show'; gMsg.className = 'gmsg'; gMsg.textContent = ''; try { gInput.focus(); } catch (e) {} }, hideGate() { gate.className = 'gate'; }, log(item) { const line = document.createElement('div'); line.className = 'ln ' + item.level; const t = document.createElement('time'); t.textContent = item.t.toTimeString().slice(0, 8); const s = document.createElement('span'); s.textContent = item.msg; line.appendChild(t); line.appendChild(s); E.log.appendChild(line); while (E.log.children.length > 200) E.log.removeChild(E.log.firstChild); E.cnt.textContent = E.log.children.length; E.log.scrollTop = E.log.scrollHeight; }, setButtons() { const running = S.running && !S.paused; E.bStart.disabled = S.busy || running; E.bStart.textContent = S.paused ? '继续' : S.started ? '重新开始' : '开始'; E.bPause.disabled = !S.running; E.bPause.textContent = S.paused ? '继续' : '暂停'; E.bStop.disabled = !S.running && !S.busy; E.bScan.disabled = S.busy; }, setRateState() { const kids = E.rateBar.children; for (let k = 0; k < kids.length; k++) { const b = kids[k]; b.className = 'pill' + (Number(b.getAttribute('data-r')) === S.rate ? ' on' : ''); } E.rateVal.textContent = S.rate + 'x'; }, /* 给别的模块挂一个标签页,返回 { pane, show, hide } */ addTab(label, builder) { const paneId = 'pane-' + label.replace(/[^\w]/g, '') + '-' + Math.random().toString(36).slice(2, 6); const btn = document.createElement('button'); btn.className = 'tab'; btn.textContent = label; btn.setAttribute('data-pane', paneId); btn.addEventListener('click', () => switchTab(paneId)); const pane = document.createElement('div'); pane.className = 'bd'; pane.id = paneId; pane.style.display = 'none'; const ft = wrap.querySelector('.ft'); if (ft && ft.parentNode) ft.parentNode.insertBefore(pane, ft); else wrap.appendChild(pane); $('tabs').appendChild(btn); try { builder(pane); } catch (e) { try { console.warn('[tab] 构建失败', e); } catch (_) {} } return { pane: pane, show() { switchTab(paneId); }, hide() { switchTab('pane-main'); }, }; }, setState() { E.dot.className = S.paused ? 'dot pause' : S.running ? 'dot run' : S.phase === '完成' ? 'dot done' : 'dot'; E.phase.textContent = S.phase; const total = S.report.length || 0; const doneCh = S.report.filter((r) => r.state === true).length; E.chap.textContent = S.chapterIndex >= 0 ? '第 ' + (S.chapterIndex + 1) + '/' + total + ' 节' : '-'; const chPct = total ? Math.round(((S.chapterIndex >= 0 ? S.chapterIndex + 1 : 0) / total) * 100) : 0; E.bar1.style.width = Math.max(0, Math.min(100, chPct)) + '%'; E.p1t.textContent = '章节进度 ' + chPct + '%'; E.p1v.textContent = (S.chapterIndex + 1) + '/' + total; const v = S.videoIdx >= 0 && S.vids[S.videoIdx] ? S.vids[S.videoIdx].v : null; if (v) { const p = pct(v); E.vid.textContent = '第 ' + (S.videoIdx + 1) + '/' + S.vids.length + ' 个'; E.bar2.style.width = p.toFixed(1) + '%'; E.p2t.textContent = fmt(v.currentTime) + ' / ' + fmt(v.duration); E.p2v.textContent = p.toFixed(1) + '%' + (S.paused ? ' 已暂停' : ''); } else { E.vid.textContent = S.vids.length ? '共 ' + S.vids.length + ' 个' : '-'; E.bar2.style.width = '0%'; E.p2t.textContent = '0:00 / 0:00'; E.p2v.textContent = '0%'; } const left = total ? S.report.filter((r) => r.state !== true).length : 0; const spent = S.t0 ? Math.max(0, Math.round((Date.now() - S.t0) / 60000)) : 0; /* 预计完成时间:按已完成的平均速度外推 */ let eta = ''; try { if (S.running && doneCh > 0 && left > 0 && spent >= 1) { const perChapter = spent / doneCh; // 分钟/节 const remainMin = Math.round(perChapter * left); const at = new Date(Date.now() + remainMin * 60000); const hh = String(at.getHours()).padStart(2, '0'); const mm = String(at.getMinutes()).padStart(2, '0'); const h = Math.floor(remainMin / 60), m2 = remainMin % 60; eta = ' · 预计 ' + hh + ':' + mm + ' 刷完(还需 ' + (h ? h + ' 小时 ' : '') + m2 + ' 分)'; } else if (S.running && doneCh === 0 && spent >= 3) { eta = ' · 正在估算剩余时间…'; } } catch (e) {} E.sum.textContent = total ? '共 ' + total + ' 节 · 已完成 ' + doneCh + ' · 剩余 ' + left + ' · 看完 ' + S.videosDone + (spent ? ' · 已用 ' + spent + ' 分' : '') + eta : '未扫描'; }, setChapters() { E.list.innerHTML = ''; if (!S.report.length) { const e2 = document.createElement('div'); e2.className = 'ci'; e2.innerHTML = '尚未扫描'; E.list.appendChild(e2); return; } S.report.forEach((r, i) => { const d = document.createElement('div'); d.className = 'ci' + (i === S.chapterIndex ? ' cur' : '') + (r.state === true ? ' done' : ''); const tag = r.state === true ? '已完成' : r.state === false ? '未完成' : '未知'; d.innerHTML = '' + (i + 1) + '' + tag; d.querySelector('.t').textContent = r.name; E.list.appendChild(d); if (i === S.chapterIndex) { try { const lr = E.list.getBoundingClientRect(), rr = d.getBoundingClientRect(); if (rr.top < lr.top || rr.bottom > lr.bottom) { E.list.scrollTop += rr.top - lr.top - (lr.height - rr.height) / 2; } } catch (e) {} } }); }, }; E.bScan.addEventListener('click', () => api.onScan && api.onScan()); E.bStart.addEventListener('click', () => api.onStart && api.onStart()); E.bPause.addEventListener('click', () => api.onPause && api.onPause()); E.bStop.addEventListener('click', () => api.onStop && api.onStop()); /* 供看门狗兜底:面板被页面重渲染顶掉后,重新挂回去 */ api.mount = function () { try { const b = document.body || document.documentElement; if (!b) return false; if (!host.isConnected) b.appendChild(host); return host.isConnected; } catch (e) { return false; } }; api.mounted = function () { try { return !!host.isConnected; } catch (e) { return false; } }; return api; } const UI = createUI(); /* ============ 视频采集 + 任务点完成判断 ============ */ function collectVideos() { const out = []; (function walk(win, path, depth, owner) { if (depth > 8) return; let doc; try { doc = win.document; } catch (e) { return; } doc.querySelectorAll('video').forEach((v) => out.push({ v, path, owner })); let frames = []; try { frames = [...doc.querySelectorAll('iframe,frame')]; } catch (e) {} frames.forEach((el2, i) => { let c = null; try { c = el2.contentWindow; } catch (e) {} if (!c) return; try { if (!c.document) return; const cls = String(el2.className || ''); const owner2 = /ans-attach-online/.test(cls) ? el2 : owner; walk(c, path + ' > ' + (el2.id || 'iframe') + '[' + i + ']', depth + 1, owner2); } catch (e) {} }); })(ROOT, 'root', 0, null); return out; } // 超星给完成的任务点容器加 ans-job-finished;图标上还有 aria-label function jobDone(owner) { if (!owner) return false; try { let n = owner; for (let k = 0; k < 6 && n; k++) { if (/(^|\s)ans-job-finished(\s|$)/.test(String(n.className || ''))) return true; if (n.getAttribute && String(n.getAttribute('aria-label') || '').indexOf('任务点已完成') >= 0) return true; n = n.parentElement; } if (owner.closest && owner.closest('.ans-job-finished,[aria-label*="任务点已完成"]')) return true; } catch (e) {} return false; } // 扫描当前页所有任务点(视频/测验/文档),看哪些没完成 function listTasks() { const out = []; const seen = new Set(); const scan = (win, depth) => { if (depth > 4) return; let doc; try { doc = win.document; } catch (e) { return; } if (!doc || seen.has(doc)) return; seen.add(doc); let nodes = []; try { nodes = [...doc.querySelectorAll('.ans-attach-ct')]; } catch (e) {} if (!nodes.length) { try { nodes = [...doc.querySelectorAll('.ans-job-icon')]; } catch (e) {} } nodes.forEach((node) => { let icon = null; try { icon = (node.classList && node.classList.contains('ans-job-icon')) ? node : node.querySelector('.ans-job-icon'); } catch (e) {} const boxCls = String(node.className || ''); const iconCls = icon ? String(icon.className || '') : ''; // 图标上的类型最准确(ans-job-video / ans-job-quiz …),容器类只作兜底 const probe = iconCls || boxCls; let type = '其他'; if (/ans-job-quiz|ans-job-exam|ans-job-work|ans-job-homework|ans-job-test/i.test(probe)) type = '测验'; else if (/ans-job-video|ans-job-audio|videoContainer/i.test(probe)) type = '视频'; else if (/ans-job-doc|ans-job-pdf|ans-job-ppt|ans-job-file|ans-job-read|ans-job-text|ans-job-docx|ans-job-word/i.test(probe)) type = '文档'; // 结构兜底:分类没命中时,按内容判断 if (type === '其他') { try { if (node.querySelector && node.querySelector('video')) type = '视频'; } catch (e) {} // 节点里有题目(章节测验和视频同页)→ 这是测验,不是文档 if (type === '其他') { try { if (node.querySelector && node.querySelector('.TiMu, .newTiMu, .questionLi, .mark_item')) type = '测验'; } catch (e) {} } if (type === '其他') { try { const fr = node.querySelector && node.querySelector('.ans-attach-online,iframe'); const src = fr ? String(fr.getAttribute('src') || '') : ''; if (/doc|pdf|read|text|reader/i.test(src)) type = '文档'; } catch (e) {} } if (type === '其他') { try { const txt = clean(node.textContent || ''); if (txt.length > 300) type = '文档'; // 一大段文字 → 当阅读任务 } catch (e) {} } } const aria = String((icon && icon.getAttribute && icon.getAttribute('aria-label')) || ''); const done = /(^|\s)ans-job-finished(\s|$)/.test(String(node.className || '')) || aria.indexOf('任务点已完成') >= 0; let title = ''; try { const t = node.querySelector('.ans-attach-title,.attach-title,.title,h3,h4'); title = clean(t ? t.textContent : '').slice(0, 28); } catch (e) {} out.push({ type: type, done: done, title: title, el: node }); }); let frames = []; try { frames = [...doc.querySelectorAll('iframe,frame')]; } catch (e) {} frames.forEach((f) => { try { if (f.contentWindow && f.contentWindow.document) scan(f.contentWindow, depth + 1); } catch (e) {} }); }; scan(ROOT, 0); return out; } /* ============ 目录 ============ */ const CATALOG_SELECTORS = [ '.posCatalog_select', '#courseTree .posCatalog_select', '.oneone.posCatalog .posCatalog_select', '.catalog_points .posCatalog_select', 'li[onclick*="chapterId"]', 'div[onclick*="toOld"]', ]; function findCatalogRaw() { const docs = [{ win: ROOT, doc: RDOC() }]; const base = RDOC(); (function walk(doc, depth) { if (depth > 3) return; let frames = []; try { frames = [...doc.querySelectorAll('iframe,frame')]; } catch (e) {} frames.forEach((el) => { let c = null; try { c = el.contentWindow; } catch (e) {} if (!c) return; try { if (c.document) { docs.push({ win: c, doc: c.document }); walk(c.document, depth + 1); } } catch (e) {} }); })(base, 0); for (const { win, doc } of docs) { for (const sel of CATALOG_SELECTORS) { let raw = []; try { raw = [...doc.querySelectorAll(sel)]; } catch (e) { continue; } if (raw.length < 2) continue; const leaves = raw.filter((e) => !/(^|\s)firstLayer(\s|$)/.test(String(e.className || ''))); const els = leaves.length >= 2 ? leaves : raw; return { win, doc, sel, els, all: raw }; } } return null; } let tabTried = false; function openCatalogTab() { const d = RDOC(); for (const s of ['.tabCatalog', '#tabCatalog', 'a[href="#content1"]']) { let els = []; try { els = [...d.querySelectorAll(s)]; } catch (e) {} for (const e of els) { try { e.click(); return true; } catch (err) {} } } try { const hit = [...d.querySelectorAll('a,div,span,li')] .filter((e) => clean(e.textContent) === '目录' && e.children.length === 0)[0]; if (hit) { hit.click(); return true; } } catch (e) {} return false; } function findCatalog() { const got = findCatalogRaw(); if (got) return got; if (!tabTried) { tabTried = true; if (openCatalogTab()) LOG.info('目录没找到,已尝试点开「目录」标签页'); return findCatalogRaw(); } return null; } function catalogDiag() { const d = RDOC(); const n = (sel) => { try { return d.querySelectorAll(sel).length; } catch (e) { return -1; } }; return { url: RHREF().slice(0, 100), root: ROOT === window ? '当前窗口' : '上层窗口', posCatalog: n('[class*="posCatalog"]'), content1: n('#content1'), tabCatalog: n('.tabCatalog'), iframes: n('iframe'), }; } function entryKey(el) { let sub = ''; try { const s = el.querySelector && el.querySelector('[onclick],[href]'); if (s) sub = (s.getAttribute('onclick') || '') + ' ' + (s.getAttribute('href') || ''); } catch (e) {} const raw = [ el.getAttribute && el.getAttribute('onclick') || '', el.getAttribute && el.getAttribute('href') || '', el.id || '', sub, ].join(' '); const m = raw.match(/(?:chapterId|knowledgeid)[=:]"?(\d+)/i); if (m) return m[1]; const nums = raw.match(/\d{5,}/g); if (nums && nums.length) return nums[nums.length - 1]; const m2 = (el.id || '').match(/^cur(\d+)/); return m2 ? m2[1] : ''; } function isActive(el) { const cls = String(el.className || '') + ' ' + String((el.parentElement && el.parentElement.className) || ''); if (/(^|\s)(active|current|selected|cur_select|posCatalog_active|is-active)(\s|$)/i.test(cls)) return true; if (/^cur\d+/.test(el.id || '')) return true; return false; } function entryState(el) { try { const inp = el.querySelector && el.querySelector('input.jobUnfinishCount,.jobUnfinishCount'); if (inp) { const v = parseInt(inp.value != null && inp.value !== '' ? inp.value : inp.getAttribute('value'), 10); if (!isNaN(v)) return v > 0 ? false : true; } } catch (e) {} const html = String(el.outerHTML || ''); if (/已完成|已学完|已看完/.test(html)) return true; if (/未完成|未开始/.test(html)) return false; return null; } function entryName(el) { try { const n = el.querySelector && el.querySelector('.posCatalog_name,.posCatalog_title'); if (n) { const t = clean(n.getAttribute('title') || n.textContent); if (t) return t.slice(0, 42); } } catch (e) {} return clean(el.getAttribute && el.getAttribute('title')).slice(0, 42) || clean(el.textContent).slice(0, 42) || '(未命名)'; } // 事件可能绑在子元素上,点外层不会触发 function clickableOf(el) { try { if (el.getAttribute && (el.getAttribute('onclick') || el.getAttribute('href'))) return el; const c = el.querySelector && el.querySelector('[onclick],a[href]'); if (c) return c; const p = el.parentElement; if (p && p.getAttribute && p.getAttribute('onclick')) return p; } catch (e) {} return el; } /* ============ 扫描 ============ */ function scanByDom() { const cat = findCatalog(); if (!cat) { LOG.err('扫描失败:没找到目录列表'); LOG.warn('诊断:' + JSON.stringify(catalogDiag())); return { ok: false, rows: [], startIndex: -1, trusted: false }; } const rows = cat.els.map((el, i) => ({ i, name: entryName(el), key: entryKey(el), active: isActive(el), state: entryState(el), })); S.report = rows; UI.setChapters(); const known = rows.filter((r) => r.state !== null).length; const done = rows.filter((r) => r.state === true).length; const allDone = rows.length > 0 && done === rows.length; const trusted = known >= Math.ceil(rows.length * 0.5) && !allDone; LOG.info('扫描完成:共 ' + rows.length + ' 节,可判定 ' + known + ' 节,已完成 ' + done + ' 节'); if (!trusted && !allDone) LOG.warn('完成标记不可信,将保守地从当前章节开始'); UI.setState(); let startIndex = rows.findIndex((r) => r.state !== true); if (startIndex < 0) startIndex = -1; return { ok: true, rows, startIndex, trusted, allDone }; } /* ============ 文档 / 阅读类任务点:自动滚到底 ============ */ // 找页面上可滚动的容器(同源 iframe 也找),按优先级排 function findScrollTargets() { const out = []; const seenDoc = new Set(); const consider = (el, win, depth, inFrame) => { try { if (!el || !win) return; const sh = Number(el.scrollHeight || 0); const ch = Number(el.clientHeight || 0); if (!ch || sh <= ch + 120) return; // 没得滚 let pri = 50; const name = String(el.className || '') + ' ' + String(el.id || ''); if (/docContent|doc-content|reader|article|ans-attach-online|ans-job-doc/i.test(name)) pri = 10; if (depth > 0 && el === win.document.documentElement) pri = 20; out.push({ el, win, sh, ch, pri, depth, inFrame: !!inFrame }); } catch (e) {} }; const walk = (win, depth) => { if (depth > 4) return; let doc; try { doc = win.document; } catch (e) { return; } if (!doc || seenDoc.has(doc)) return; seenDoc.add(doc); // 已知阅读容器 try { const KNOWN = '#docContent,.docContent,.doc-content,.reader,.article,.ans-attach-online,.ans-job-doc'; (doc.querySelectorAll(KNOWN) || []).forEach((el) => consider(el, win, depth, depth > 0)); } catch (e) {} // 全部元素里找可滚的(限数量,防卡) let all = []; try { all = [...doc.querySelectorAll('div,section,article,main,td,body,html')]; } catch (e) {} const cap = Math.min(all.length, 1200); for (let i = 0; i < cap; i++) consider(all[i], win, depth, depth > 0); let frames = []; try { frames = [...doc.querySelectorAll('iframe,frame')]; } catch (e) {} frames.forEach((f) => { try { if (f.contentWindow && f.contentWindow.document) walk(f.contentWindow, depth + 1); } catch (e) {} }); }; walk(ROOT, 0); const uniq = []; const seenEl = new Set(); out.forEach((x) => { if (!seenEl.has(x.el)) { seenEl.add(x.el); uniq.push(x); } }); uniq.sort((a, b) => (a.pri - b.pri) || (b.sh - a.sh)); return uniq.slice(0, 8); } // 把一个容器从头滚到底(到底后停留几秒,懒加载变长就继续) async function scrollThrough(t, budgetMs) { const t0 = Date.now(); const el = t.el; const win = t.win; let pos = 0; try { el.scrollTop = 0; } catch (e) {} while (Date.now() - t0 < budgetMs) { if (!S.running) return false; let ch = 0, sh = 0; try { ch = el.clientHeight || 0; sh = el.scrollHeight || 0; } catch (e) { break; } if (!ch || !sh) break; const step = Math.max(120, Math.floor(ch * 0.8)); pos = Math.min(sh, pos + step); try { el.scrollTop = pos; } catch (e) {} try { win.scrollTo(0, pos); } catch (e) {} try { el.dispatchEvent(new (win.Event || Event)('scroll', { bubbles: true })); } catch (e) {} try { win.dispatchEvent(new (win.Event || Event)('scroll')); } catch (e) {} try { el.dispatchEvent(new (win.WheelEvent || WheelEvent)('wheel', { deltaY: 400, bubbles: true })); } catch (e) {} await sleep(150); // 到底 → 停留观察是否变长 if (pos + ch >= sh - 4) { const dwellEnd = Date.now() + 3000; let grew = false; while (Date.now() < dwellEnd) { if (!S.running) return false; await sleep(400); let nsh = 0; try { nsh = el.scrollHeight || 0; } catch (e) {} if (nsh > sh + 40) { sh = nsh; grew = true; break; } } if (!grew) break; // 真的到底了 } } return true; } // 等超星把"已完成"标记打上(有延迟) async function waitDocDone(ms) { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (!S.running) return false; const left = listTasks().filter((t) => !t.done); if (!left.some((t) => t.type === '文档' || t.type === '其他')) return true; await sleep(500); } return false; } /* 页面上有没有题目(含 iframe)——有题目就绝不滚动 */ function pageHasQuiz() { let hit = false; try { if (window.__bank && typeof window.__bank.hasQuestions === 'function') { hit = !!window.__bank.hasQuestions(); } } catch (e) {} if (hit) return true; eachDocLocal((doc) => { if (hit) return; try { if (doc.querySelector('.TiMu, .newTiMu, .questionLi')) hit = true; } catch (e) {} }); return hit; } async function completeDocTasks() { if (!CFG.autoDoc) return false; /* 这一页有题目 → 交给题库模块答题,不要滚动 */ if (pageHasQuiz()) { LOG.info('本页有测验题目,不做文档滚动'); return false; } const left0 = listTasks().filter((t) => !t.done); if (left0.some((t) => t.type === '视频')) return false; // 视频没做完不插手 if (left0.some((t) => t.type === '测验')) return false; // 有测验没做完不插手 const docs = left0.filter((t) => t.type === '文档' || t.type === '其他'); if (!docs.length) return false; const targets = findScrollTargets(); if (!targets.length) { LOG.warn('没找到可滚动的文档容器(可能文档在跨域框架里)'); return false; } for (let pass = 0; pass < CFG.docPasses && S.running; pass++) { for (const t of targets) { if (!S.running) return false; LOG.info('滚动文档:' + (String(t.el.className || t.el.tagName || '').slice(0, 24)) + '(内容高 ' + t.sh + ' / 视口 ' + t.ch + ')'); await scrollThrough(t, CFG.docScrollMs); if (await waitDocDone(4000)) return true; } } LOG.warn('文档任务点自动完成失败,需要手动滚动'); return false; } /* ============ 等待 ============ */ function snapshot() { const cat = findCatalog(); let activeKey = ''; if (cat) { const e = cat.els.find(isActive); activeKey = e ? entryKey(e) : ''; } const src = collectVideos().map((o) => o.v.currentSrc || o.v.src).join(','); return [activeKey, RHREF(), src.length, src.slice(0, 160)].join('|'); } async function waitForVideos(timeout) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { if (!S.running) return []; const v = collectVideos(); if (v.length) return v; await sleep(600); } return []; } // 当前页面所有视频地址的指纹:换章节时它一定会变 function videoSig() { return collectVideos().map((o) => o.v.currentSrc || o.v.src || '').join(','); } // 从 onclick 里抠出 getTeacherAjax 的参数 function parseNavArgs(el) { let raw = ''; try { raw = (el.getAttribute && el.getAttribute('onclick')) || ''; if (!raw) { const c = el.querySelector && el.querySelector('[onclick]'); if (c) raw = c.getAttribute('onclick') || ''; } } catch (e) {} const m = raw.match(/getTeacherAjax\s*\(([^)]*)\)/); if (!m) return null; const args = m[1].split(',').map((x) => x.trim().replace(/^['"]|['"]$/g, '')).filter((x) => x !== ''); return args.length ? args : null; } // 在所有同源窗口里找换章函数 function findNavFn() { const wins = [ROOT]; try { if (ROOT.parent && ROOT.parent !== ROOT) wins.push(ROOT.parent); } catch (e) {} try { if (ROOT.top && ROOT.top !== ROOT) wins.push(ROOT.top); } catch (e) {} try { ROOT.document.querySelectorAll('iframe,frame').forEach((f) => { try { if (f.contentWindow && f.contentWindow.document) wins.push(f.contentWindow); } catch (e) {} }); } catch (e) {} for (const w of wins) { try { if (typeof w.getTeacherAjax === 'function') return w.getTeacherAjax.bind(w); } catch (e) {} } return null; } // 等导航真的生效:视频换了 / 地址换了 / 高亮换了,任一即可 async function waitNavChange(beforeVid, beforeHref, beforeActive, timeout) { const t0 = Date.now(); while (Date.now() - t0 < timeout) { if (!S.running) return false; await sleep(500); const cat = findCatalog(); const act = cat ? cat.els.findIndex(isActive) : -1; if (videoSig() !== beforeVid || RHREF() !== beforeHref || (act !== -1 && act !== beforeActive)) { // 等它稳定,别在加载中途就数视频 let last = null; for (let k = 0; k < 12; k++) { await sleep(400); const cur = videoSig(); if (cur === last) break; last = cur; } return true; } } return false; } /* 遍历本页 + iframe 里的 document(播放模块自用) */ function eachDocLocal(fn) { const list = []; try { list.push(document); } catch (e) {} const walk = (d) => { let frames = []; try { frames = d.querySelectorAll('iframe'); } catch (e) {} for (const f of frames) { try { const cd = f.contentDocument; if (cd && list.indexOf(cd) < 0) { list.push(cd); walk(cd); } } catch (e) {} } }; try { walk(document); } catch (e) {} for (const d of list) { try { fn(d); } catch (e) {} } } /* 目录高亮有时候不更新,但页面其实已经切过去了。 用页面标题 / URL 里的 chapterId 来判断,避免误报"切换失败"。 */ function pageTitleText() { try { const cands = ['.chapter_title', '.prev_title', '.title', 'h1', '#chapterTitle']; for (const sel of cands) { let el = null; eachDocLocal((doc) => { if (!el) { try { el = doc.querySelector(sel); } catch (e) {} } }); if (el && el.textContent && el.textContent.trim().length > 1) return el.textContent.trim(); } return String(document.title || '').trim(); } catch (e) { return ''; } } function alreadyOnChapter(el, i) { try { const want = clean(el.textContent).replace(/^d+(.d+)*s*/, '').slice(0, 12); const got = clean(pageTitleText()); if (want && got && (got.indexOf(want) >= 0 || want.indexOf(got) >= 0)) return true; // URL 里的 chapterId 和目录项解析出的参数对一下 const args = parseNavArgs(el); if (args && args.length) { const href = RHREF(); const last = String(args[args.length - 1] || ''); if (last && /^\d{5,}$/.test(last) && href.indexOf(last) >= 0) return true; } } catch (e) {} return false; } async function jumpTo(i) { const cat = findCatalog(); if (!cat || !cat.els[i]) { LOG.err('跳转失败:目录里没有第 ' + (i + 1) + ' 项'); return false; } const el = cat.els[i]; // 只认"目录项自身带高亮",绝不用 URL 猜测,避免误判成"已在当前节" const activeIdx = cat.els.findIndex(isActive); if (activeIdx === i) return true; const beforeVid = videoSig(); const beforeHref = RHREF(); const args = parseNavArgs(el); const navFn = findNavFn(); let method = 'none'; try { el.scrollIntoView({ block: 'center' }); } catch (e) {} if (args && navFn) { try { navFn.apply(null, args); method = 'getTeacherAjax'; } catch (e) { LOG.warn('调用 getTeacherAjax 抛错:' + e.message); } } if (method === 'none') { const t = clickableOf(el); try { if (t.click) { t.click(); method = 'click'; } } catch (e) { LOG.warn('click 抛错:' + e.message); } } LOG.info('第 ' + (i + 1) + ' 节:跳转方式=' + method + ',参数=' + (args ? JSON.stringify(args) : '(无)')); let ok = await waitNavChange(beforeVid, beforeHref, activeIdx, 7000); if (!ok && S.running) { const t = clickableOf(el); for (const type of ['mousedown', 'mouseup', 'click']) { try { t.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window })); } catch (e) {} } LOG.warn('第 ' + (i + 1) + ' 节:改派发鼠标事件重试'); ok = await waitNavChange(beforeVid, beforeHref, activeIdx, 7000); } // 内容没变,但可能本来就已经在这一节了(目录高亮没刷新) if (!ok && S.running && alreadyOnChapter(el, i)) { LOG.ok('第 ' + (i + 1) + ' 节:页面已经在这一节(目录高亮没更新,按已切换处理)'); return true; } if (ok) LOG.ok('第 ' + (i + 1) + ' 节:已切换'); else LOG.warn('第 ' + (i + 1) + ' 节:内容未变化'); return ok; } /* ============ 播放 ============ */ /* 同一节里可能有多个视频(多个任务点)。 必须严格"一次只播一个",否则两个 video 会互相抢播放 → 来回切换 → 触发风控。 */ function pauseOthers(items, keepIdx) { for (let k = 0; k < items.length; k++) { if (k === keepIdx) continue; try { if (!items[k].v.paused) items[k].v.pause(); } catch (e) {} } } async function playAll(items) { S.vids = items; S.chapterVideosDone = 0; LOG.info('本节发现 ' + items.length + ' 个视频'); for (let i = 0; i < items.length && S.running; i++) { S.videoIdx = i; const v = items[i].v; v.playbackRate = S.rate; ensureMuted(v); if (CFG.skipDone && jobDone(items[i].owner)) { LOG.ok('第 ' + (i + 1) + ' 个任务点已完成,跳过'); S.chapterVideosDone++; continue; } if (CFG.skipNearEnd && isFinished(v)) { LOG.info('第 ' + (i + 1) + ' 个已在末尾(' + pct(v).toFixed(1) + '%),跳过'); S.chapterVideosDone++; continue; } if (CFG.restart) { try { v.currentTime = 0; } catch (e) {} } if (!isFinite(v.duration)) { try { v.load(); } catch (e) {} await sleep(800); } // 只留这个在播,其它先停 pauseOthers(items, i); S.curVideo = v; let played = false; for (let k = 0; k < 3 && S.running; k++) { try { await v.play(); played = true; break; } catch (e) { LOG.warn('第 ' + (i + 1) + ' 个 play() 失败(' + e.name + '),重试 ' + (k + 1) + '/3'); ensureMuted(v); await sleep(1000); } } if (!played) { LOG.err('第 ' + (i + 1) + ' 个播不了,跳过'); continue; } LOG.info('开始播放第 ' + (i + 1) + '/' + items.length + ' 个'); let stall = 0, lastT = -1; await new Promise((res) => { const finish = () => { clearInterval(tick); res(); }; const tick = setInterval(() => { if (!S.running) return finish(); if (S.paused) { if (!v.paused) v.pause(); return; } const t = v.currentTime; if (v.ended || (isFinite(v.duration) && v.duration > 0 && t >= v.duration - 0.3)) return finish(); // 平台把任务点标成"已完成"(一般看到 90% 就够)→ 提前收手 if (CFG.stopAtTaskDone && jobDone(items[i].owner)) { const p = isFinite(v.duration) && v.duration > 0 ? (t / v.duration * 100) : 0; LOG.ok('第 ' + (i + 1) + ' 个任务点已判定完成(播到 ' + p.toFixed(0) + '%),提前结束'); return finish(); } if (v.paused) v.play().catch(() => {}); if (v.playbackRate !== S.rate) v.playbackRate = S.rate; if (Math.abs(t - lastT) < 0.05) { if (++stall >= 120) { LOG.warn('第 ' + (i + 1) + ' 个卡住 2 分钟,跳过'); finish(); } } else stall = 0; lastT = t; }, 1000); v.addEventListener('ended', finish, { once: true }); }); if (S.curVideo === v) S.curVideo = null; if (S.running) { S.videosDone++; S.chapterVideosDone++; const doneByTask = CFG.stopAtTaskDone && jobDone(items[i].owner) && !v.ended; LOG.ok('第 ' + (i + 1) + '/' + items.length + ' 个' + (doneByTask ? '(任务点已完成,不等 98%)' : '看完')); } } S.videoIdx = -1; } /* ============ 主循环 ============ */ /* 本节视频完成后,尝试切到「章节测验」 */ async function tryQuiz(i) { if (!CFG.autoQuiz) return false; try { if (window.__bank && typeof window.__bank.gotoQuiz === 'function') { LOG.info('第 ' + (i + 1) + ' 节:尝试切到「章节测验」…'); return await window.__bank.gotoQuiz(); } } catch (e) {} return false; } async function watchLoop(startIdx, retryList) { const cat0 = findCatalog(); if (!cat0) { LOG.warn('没有目录:只播放当前页面,无法自动翻节'); S.chapterIndex = 0; S.phase = '等视频'; const vids = await waitForVideos(CFG.videoTimeout); if (!vids.length) { LOG.warn('当前页面也没有视频'); return; } if (CFG.skipDone && vids.every((o) => jobDone(o.owner))) { LOG.ok('当前页面任务点已全部完成,无需播放'); return; } S.phase = '播放中'; await playAll(vids); return; } const total = cat0.els.length; const isRetry = Array.isArray(retryList) && retryList.length > 0; const order = isRetry ? retryList.slice() : null; // 重试模式:只跑指定章节 const count = order ? order.length : total - startIdx; LOG.info(isRetry ? '重试 ' + count + ' 节(' + order.map((n) => n + 1).join(',') + ')' : '从第 ' + (startIdx + 1) + '/' + total + ' 节开始'); let stuck = 0; let nothingToPlay = 0; let quizMiss = 0; // 连续几节测验切不过去 const failed = []; // 本轮没做成功的章节,最后回头再试 for (let k = 0; k < count && S.running && S.round < CFG.maxChapters; k++) { const i = order ? order[k] : startIdx + k; S.round = k + 1; S.chapterIndex = i; UI.setChapters(); UI.setState(); const rep = S.report[i]; const name = (cat0.els[i] && (cat0.els[i].textContent || '').trim().slice(0, 22)) || ('第 ' + (i + 1) + ' 节'); LOG.info('【步骤 ' + (k + 1) + '/' + count + '】进入第 ' + (i + 1) + ' 节:' + name); if (rep && rep.state === true) { LOG.info(' 这节已完成,跳过'); continue; } S.phase = '跳转中'; LOG.info(' ① 切换章节…'); const sigBefore = videoSig(); // 切之前记下视频源指纹,用来判断新视频有没有来 const changed = await jumpTo(i); LOG.info(' ① ' + (changed ? '切换成功' : '切换失败(可能目录点不动)')); if (!S.running) break; if (changed) stuck = 0; else stuck++; if (stuck >= 3) { LOG.err('连续 3 节都跳不动,已停止(目录可能点不动)'); break; } S.phase = '等视频'; LOG.info(' ② 等待视频出现(最多 ' + Math.round(CFG.videoTimeout / 1000) + ' 秒)…'); const vids = await waitForVideos(CFG.videoTimeout); if (!S.running) break; if (!vids.length) { // 没有视频,但页面可能已经判定完成(视频在别的 frame / 已看完) let pageDone = false; try { let bodyText = ''; eachDocLocal((doc) => { if (bodyText) return; try { bodyText = clean(doc.body ? doc.body.textContent : ''); } catch (e) {} }); /* 只认明确的「任务点已完成」;以前这里还匹配任意「已完成」, 页面上别的章节/测验显示"已完成"就会把这一节误标成完成 */ pageDone = /任务点已完成/.test(bodyText) || chapterAllDone(); } catch (e) {} if (pageDone) { LOG.ok(' ② 这节没有视频,但页面已显示任务点完成,记为已完成'); if (S.report[i]) S.report[i].state = true; UI.setChapters(); UI.setState(); stuck = 0; } else { LOG.warn(' ② 这节没有视频也没完成,跳过这节继续后面的'); stuck = 0; } continue; } else { /* 关键:超星切节时,页面会先保留上一节的