// ==UserScript== // @name 山东省教师教育网 · 不用守着,自动一节接一节播完 // @namespace https://scriptcat.org/zh-CN/users/219470 // @version 1.5.4 // @description 不用守着电脑:打开课程页自动播放,切窗口、锁屏被暂停会自动恢复,一节播完自动跳到下一节还没学的,十几二十节不用一直盯着。右下角小窗显示当前章节、本节进度、今日自动接了几节和运行日志。不改学时、不发请求、不需要卡密。 // @author banger // @match *://*.qlteacher.com/* // @match *://qlteacher.com/* // @grant none // @run-at document-idle // @icon https://scriptcat.org/api/v2/resource/image/NH1cC8OAoyGu6Z6w // @license MIT // @tag 山东省教师教育网 // @tag 山东教师教育网 // @tag 教师研修 // @tag 继续教育 // @tag 远程研修 // @tag 自动播放 // @tag 自动连播 // @tag qlteacher // ==/UserScript== /* * 免费版做三件事,且只做这三件: * 1. 自动播放(被浏览器或平台暂停后自动恢复,必要时静音绕过自动播放限制) * 2. 一节播完 / 平台标记「已完成」后,自动点开章节列表切到下一节未完成的 * 3. 右下角一个小面板,显示当前章节、播放进度、自动切换次数 * * 刻意不做的事: * · 自动答随堂题 / 章节测试(会误导学习者,也容易被判无效) * · 跨课程连续学习、无人值守(同上) * · 跳过或伪造播放时长 * 原因很简单:「自动答题」和「跳过时长」都直接违反平台的学习规则, * 放进来会让脚本被举报下架,用户账号也可能被判学时无效。 * 本脚本不改动视频进度、不伪造上报、不改倍速,只是替你点「播放」。 */ (function () { 'use strict'; const VER = '1.5.4'; /* 同一 frame 只跑一份;但**版本不同必须接管**—— 改过 @name 之后,脚本管理器会把新旧各装一份,旧实例先占位时 若直接 return,新装的这一份就永远画不出面板(用户看到「升级后没反应」)。 */ if (window.__sdfFree === VER) return; window.__sdfFree = VER; const IS_TOP = window.top === window.self; const LS_KEY = 'sdf_free_v1'; const TICK = 2000; /* ================= 配置存取 ================= */ const DEFAULT_CFG = { on: true, folded: false, x: null, y: null, switched: 0 }; let cfg = Object.assign({}, DEFAULT_CFG); function loadCfg() { try { const o = JSON.parse(localStorage.getItem(LS_KEY) || '{}'); cfg = Object.assign({}, DEFAULT_CFG, o); } catch (e) { cfg = Object.assign({}, DEFAULT_CFG); } } function saveCfg() { try { localStorage.setItem(LS_KEY, JSON.stringify(cfg)); } catch (e) { /* 隐私模式下写不进去,忽略 */ } } loadCfg(); const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /* ================= 平台接口失败记录(只看不动) ================= 平台把所有失败请求都弹成「出错了 + 状态文本 + 点击刷新」: status 0 → "Unknown Error"(请求被取消,比如切页把它掐了) status 5xx → "Server Error" / "Internal Server Error"(它后端自己挂了) 脚本不发任何请求(@grant none),所以这类 5xx 与脚本无关; 但光靠嘴说没用,这里把挂掉的那条接口记下来,面板日志里点名, 用户截图一看就知道是平台的哪个接口在报错。 */ const netFails = []; function recordFail(method, url, status, statusText, body) { try { const u = String(url || '').replace(location.origin, '').slice(0, 90); const item = { m: String(method || 'GET').toUpperCase(), u: u, s: status, t: statusText || '', b: String(body || '').replace(/\s+/g, ' ').slice(0, 80), at: Date.now() }; netFails.push(item); while (netFails.length > 5) netFails.shift(); /* 4xx 太吵(平台自己也会轮询 401),只在 5xx 时写进日志 */ if (status >= 500) logLine('平台接口失败 ' + status + ' ' + item.t + ':' + item.m + ' ' + u); } catch (e) { /* ignore */ } } (function hookNet() { try { const XO = window.XMLHttpRequest; if (XO && XO.prototype) { const op = XO.prototype.open; const sd = XO.prototype.send; XO.prototype.open = function (m, u) { this.__sdfM = m; this.__sdfU = u; return op.apply(this, arguments); }; XO.prototype.send = function () { try { this.addEventListener('loadend', () => { try { if (this.status >= 400) { recordFail(this.__sdfM, this.__sdfU, this.status, this.statusText, this.responseText); } } catch (e) { /* ignore */ } }); } catch (e) { /* ignore */ } return sd.apply(this, arguments); }; } } catch (e) { /* 页面把 XHR 冻住了就跳过,不影响主流程 */ } try { const F = window.fetch; if (F) { window.fetch = function (input, init) { const u = typeof input === 'string' ? input : input && input.url; const m = (init && init.method) || (input && input.method) || 'GET'; return F.apply(this, arguments).then((res) => { try { if (res && res.status >= 400) { res.clone().text().then((t) => recordFail(m, u, res.status, res.statusText, t)).catch(() => {}); } } catch (e) { /* ignore */ } return res; }); }; } } catch (e) { /* ignore */ } })(); /* ================= 基础判定 ================= */ /* 章节页:不再猜死路径,凡是学习/播放类路径或页面上有章节列表都算 */ const isChapterPage = () => { const u = location.pathname + location.hash + location.search; if (/\/learning\//i.test(u)) return true; if (/(player|\/play|learn|course|lesson|chapter|study|video|kecheng)/i.test(u)) return true; return !!document.querySelector('.chapter-drawer, nz-sider, [class*=chapter], [class*=section-list]'); }; const getVideo = () => document.querySelector('video'); /* 平台的错误弹层:切章节那一刻,它自己还没发完的请求会被取消, Angular 把被取消的请求抛成 status=0 的 HttpErrorResponse (statusText 默认就是 "Unknown Error"),平台的全局错误处理 原样显示出来,就成了「出错了 / Unknown Error / 点击刷新」。 这跟脚本无关,但用户会以为是脚本坏了——认出来,在面板里说清楚。 */ function platformErrorText() { const nodes = document.querySelectorAll( '[role=dialog], .ant-modal, .ant-modal-content, .el-message-box, .ant-message-notice' ); for (const n of nodes) { if (!n.offsetParent) continue; const t = (n.innerText || '').replace(/\s+/g, ' ').trim(); if (!t) continue; if (t.indexOf('Unknown Error') >= 0) return t.slice(0, 46); if (t.indexOf('出错了') >= 0 && t.indexOf('刷新') >= 0) return t.slice(0, 46); } return ''; } /* 平台右上角的「剩余时长 / 已完成」角标 */ function badgeText() { const el = document.querySelector('.count-down'); return el ? (el.innerText || '').replace(/\s+/g, ' ').trim() : ''; } /* 随堂题:弹出带输入框 / 选项的弹层。检测到就停下,别跟它抢播放 */ function hasQuiz() { const boxes = document.querySelectorAll('.ant-modal textarea, .ant-modal .ant-checkbox-input, .ant-modal .ant-radio-input, textarea'); for (const el of boxes) { if (el.offsetParent !== null) return true; } return false; } /* 平台「同一活动多窗口」提示,出现时别继续点播放,否则越点越乱 */ function hasConflict() { const txt = (document.body && document.body.innerText) || ''; return /同一活动.{0,16}多个窗口|多个窗口进行学习|只对最后一次打开的窗口/.test(txt); } /* ================= 视频守护 ================= */ let lastTry = 0; /* ---------- 声音状态 ---------- 浏览器的自动播放策略会拦「有声 play()」,脚本的保命手段是「先静音再 play」。 静音只是手段、不是目的,所以必须记住这两件事: 1) 这次静音是脚本干的(autoMuted),不是用户的意愿; 2) 用户自己点过静音(userMuted),那才是真的不要声音,别去覆盖。 否则切到后台被平台暂停、脚本静音救回来之后,声音就再也不会自己回来, 回到前台整节课都哑着——用户只会认为「这功能被故意限制了」。 */ let autoMuted = false; let userMuted = false; /* 恢复声音也会失败:有的浏览器一 unmute 就把视频掐停。 这时立刻退回静音续播,并且退避几秒再试,别每 2 秒来回抖一次。 */ let restoreFails = 0; let restoreCoolUntil = 0; try { userMuted = localStorage.getItem('sdf_free_sound') === 'off'; } catch (e) { /* ignore */ } function setUserMuted(off) { userMuted = !!off; try { localStorage.setItem('sdf_free_sound', off ? 'off' : 'on'); } catch (e) { /* ignore */ } } /* 把脚本临时关掉的声音还回去。unmute 不需要用户手势,所以随时可以调用; 个别浏览器 unmute 会把视频掐停(自动播放策略),900ms 后检查一次, 真被掐了就退回静音续播,宁可有声变无声,也不能让进度停住。 */ function restoreSound(why, force) { const v = getVideo(); if (!v) return; if (!autoMuted) return; if (userMuted) { autoMuted = false; return; } if (!force && Date.now() < restoreCoolUntil) return; /* 刚试过被掐,歇一会儿 */ const playing = !v.paused && !v.ended; autoMuted = false; v.muted = false; logLine('声音已恢复' + (why ? '(' + why + ')' : '')); if (!playing) return; setTimeout(() => { const vv = getVideo(); if (!vv || vv.ended) return; if (vv.paused) { vv.muted = true; autoMuted = true; try { const p = vv.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* ignore */ } restoreFails += 1; restoreCoolUntil = Date.now() + Math.min(60000, 6000 * restoreFails); logLine('这个浏览器不让直接出声,先静音保证不中断(' + Math.round((restoreCoolUntil - Date.now()) / 1000) + ' 秒后再试)'); renderProgress(vv); /* 喇叭图标立刻跟着变成 🔇,别等下一拍 */ } else { restoreFails = 0; restoreCoolUntil = 0; } }, 900); } async function tryPlay(v) { if (!v) return; if (Date.now() - lastTry < 2500) return; lastTry = Date.now(); /* 已经回到前台了:先把上次为止的临时静音还回去,再尝试播放。 否则「静音 play 一定成功」,播放是恢复了,用户听到的还是哑的。 */ if (!document.hidden && autoMuted && !userMuted) { autoMuted = false; v.muted = false; } try { const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* 自动播放被拦,下面静音重试 */ } await sleep(700); if (v.paused) { try { /* 打标记:这声静音是脚本为了把暂停救回来干的,不是用户设的 */ if (!v.muted) autoMuted = true; v.muted = true; const p2 = v.play(); if (p2 && p2.catch) p2.catch(() => {}); } catch (e) { /* ignore */ } } } /* ================= 章节列表 ================= */ /* 「章节」入口:先按平台原来的结构找,找不到再按文字兜底(平台改版时不至于整脚本失效) */ function findChapterMenu() { const direct = [...document.querySelectorAll('nz-sider .menu-item')].find( (x) => (x.innerText || '').trim() === '章节' ); if (direct) return direct; const cands = [ ...document.querySelectorAll('.menu-item, .ant-menu-item, [class*="menu"] div, [class*="menu"] span'), ]; return ( cands.find((x) => (x.innerText || '').trim() === '章节' && x.offsetParent !== null) || null ); } async function openDrawer() { let d = document.querySelector('.chapter-drawer'); if (d && d.classList.contains('show')) return true; const btn = findChapterMenu(); if (!btn) return false; btn.click(); for (let i = 0; i < 12; i++) { await sleep(400); d = document.querySelector('.chapter-drawer'); if (d && d.classList.contains('show')) return true; } return false; } function parseChapters() { const nodes = document.querySelectorAll('.chapter-drawer.show li.ant-list-item'); return [...nodes] .map((el, i) => { const t = el.querySelector('ellipsis'); const raw = (el.innerText || '').replace(/\s+/g, ' ').trim(); return { el, i, raw, title: (t && t.getAttribute('title')) || raw.replace(/^必学\s*/, ''), done: raw.indexOf('已完成') >= 0, recent: raw.indexOf('最近学习') >= 0, }; }) .filter((c) => c.title); } let switching = false; let cooldownUntil = 0; /* 切换失败后的冷却,避免死循环狂点 */ const triedFail = new Set(); /* 点过但没跳转的标题,避免反复点同一节 */ const doneTitles = new Set(); /* 本次会话里已判定学完的标题,防止平台不标记时来回跳 */ /* 标题归一化:去掉「必学」前缀和标点,用来比较「这一节是不是当前这一节」 */ function normTitle(s) { return (s || '') .replace(/^(必学|必修|选修|必|选)\s*/, '') .replace(/[\s·、,,。.::\-—_+()()]/g, '') .toLowerCase(); } const sameTitle = (a, b) => !!normTitle(a) && normTitle(a) === normTitle(b); /* 「简介 / 导学 / 前言」这类章节一般不计学时,平台也不给它们标「已完成」。 把它们当候选的后果就是:整门课学完之后,一次一次跳回最前面那一节。 标题写法很杂——「简介」「专题简介」「课程简介(必看)」「简介:本专题说明」, 所以不看开头结尾,只看词 + 长度,短标题里出现这些词就当日志类章节。 */ const INTRO_WORDS = /(简介|介绍|导学|导言|前言|序言|课程说明|学习说明|研修说明|使用说明)/; const isIntroTitle = (t) => { const s = normTitle(t); return !!s && s.length <= 10 && INTRO_WORDS.test(s); }; /* ===== 「已播过」记录:按课程存本地,刷新页面、重进课程都不丢 ===== */ function courseId() { const m = location.pathname.match(/\/learning\/([^/?#]+)/i); return m ? m[1] : location.pathname.replace(/[^\w-]/g, '_').slice(-48); } let visitedKey = ''; let visited = new Set(); function initVisited() { visitedKey = 'sdf_seen_' + courseId(); try { const a = JSON.parse(localStorage.getItem(visitedKey) || '[]'); visited = new Set(Array.isArray(a) ? a : []); } catch (e) { visited = new Set(); } } function markVisited(title) { const k = normTitle(title); if (!k || visited.has(k)) return; visited.add(k); try { localStorage.setItem(visitedKey, JSON.stringify([...visited].slice(-200))); } catch (e) { /* 隐私模式写不进去,忽略 */ } } initVisited(); /* 页面上正在播的这一节叫什么 */ function currentTitleFromPage() { const h = document.querySelector('.ellipsis-title, nz-header h1, h1'); return h ? (h.innerText || '').replace(/\s+/g, ' ').trim() : ''; } function markDoneHere() { const t = currentTitleFromPage(); if (t) doneTitles.add(normTitle(t)); const list = parseChapters(); const r = list.find((c) => c.recent); if (r) doneTitles.add(normTitle(r.title)); } async function goNextChapter(reason) { if (switching) return false; if (Date.now() < cooldownUntil) return false; switching = true; let closeAfter = false; let switchedOk = false; const fromTitle = currentTitleFromPage(); try { setStatus(reason || '准备切换下一节…'); const wasOpen = !!( document.querySelector('.chapter-drawer') && document.querySelector('.chapter-drawer').classList.contains('show') ); const ok = await openDrawer(); if (!ok) { cooldownUntil = Date.now() + 20000; setStatus('没找到章节列表:手动点左侧「章节」再试'); return false; } if (!wasOpen) closeAfter = true; const list = parseChapters(); if (!list.length) { cooldownUntil = Date.now() + 20000; setStatus('章节列表是空的,请手动切下一节'); return false; } /* 当前节定位:先用页面标题精确匹配,其次「最近学习」标记, 最后才按「第一个未完成项的前一项」猜。 只认「最近学习」有个坑:平台有时把标记停在最早打开的那一节上, 当前节一旦认错,后面选「下一节」就全乱。 */ const pageTitle = currentTitleFromPage(); let cur = -1; if (pageTitle) cur = list.findIndex((c) => sameTitle(c.title, pageTitle)); if (cur < 0 && pageTitle) { const p = normTitle(pageTitle); cur = list.findIndex((c) => { const k = normTitle(c.title); return k.length >= 4 && (p.includes(k) || k.includes(p)); }); } if (cur < 0) cur = list.findIndex((c) => c.recent); if (cur < 0) { const firstUndone = list.findIndex((c) => !c.done); cur = firstUndone <= 0 ? -1 : firstUndone - 1; } /* 能走到这里说明刚学完一节,把它记下来——平台上如果只有角标、 不在列表里标「已完成」,不记就会来回跳同一节 */ if (cur >= 0 && list[cur]) doneTitles.add(normTitle(list[cur].title)); /* 候选分两段: after = 当前位置之后所有未完成的(正常的前进方向) before = 当前位置之前漏掉的,只在 after 全都不合适时才轮到它 旧版这里是「after.concat(整个列表)」,等于学完最后一节就回头, 而「简介」一般不会被标「已完成」,于是每次都跳回最前面那一节。 现在回头必须同时满足:不是简介类、没播过、没点失败过。 */ const after = list.slice(cur + 1).filter((c) => !c.done); const before = list .slice(0, Math.max(cur, 0)) .filter((c) => !c.done && !isIntroTitle(c.title) && !visited.has(normTitle(c.title))); const raw = after.concat(before); const seen = new Set(); const cands = []; /* 「简介 / 导学」这类章节不参与自动切换:进课程时平台本来就会先播它, 学完别的再跳回去只会打断后面。例外:整门课只有这一节。 */ const skipIntro = list.length > 1; for (const c of raw) { const k = normTitle(c.title); if (!k) continue; /* 按位置去重,不按标题——有些专题每节都叫「视频」, 按标题去重会把后面几节全当成重复项丢掉。 */ if (seen.has(c.i)) continue; seen.add(c.i); if (triedFail.has(k)) continue; if (doneTitles.has(k)) continue; if (skipIntro && isIntroTitle(c.title)) continue; if (pageTitle && sameTitle(c.title, pageTitle)) continue; cands.push(c); } if (!cands.length) { cooldownUntil = Date.now() + 3600000; /* 都学完了,别再问 */ courseFinished = true; setStatus('本课程章节已全部完成', 'ok'); setHint('完整版会自动进入下一门课,并提交五星评价,一门接一门 ›'); logLine('本课程已完成(完整版会自动切下一门)'); return false; } /* 最多试 3 个候选,第一个点不动就换下一个,不要卡死在同一节 */ for (const next of cands.slice(0, 3)) { const before = location.href; next.el.click(); let moved = false; for (let i = 0; i < 16; i++) { await sleep(500); if (location.href !== before) { moved = true; break; } } if (moved) { switchedOk = true; cfg.switched = (cfg.switched || 0) + 1; saveCfg(); triedFail.clear(); if (fromTitle) { doneTitles.add(normTitle(fromTitle)); markVisited(fromTitle); } markVisited(next.title); setStatus('已切到:' + next.title, 'ok'); logLine('切到下一节:' + next.title); lastSwitchAt = Date.now(); updateLic(); return true; } triedFail.add(normTitle(next.title)); } cooldownUntil = Date.now() + 20000; setStatus('自动切换没生效,请手动点左侧章节里的下一节'); return false; } catch (e) { cooldownUntil = Date.now() + 20000; setStatus('切换失败:' + (e && e.message ? e.message : e)); return false; } finally { /* 只有「没切走」时才去关抽屉:已经切到新页面的话, 这一下点到的可能是新页的「章节」按钮,白开一次列表请求—— 请求刚发出去就被关掉,正好又是那种 status=0 的取消。 */ if (closeAfter && !switchedOk) { const btn = findChapterMenu(); if (btn) btn.click(); } switching = false; /* 切换后重算一节的判定窗口 */ pageEnterAt = Date.now(); sawCountdown = false; doneStreak = 0; endHandled = false; endedAt = 0; } } /* ================= 面板 ================= */ const CONTACT = { qq: '1131654415', qqLink: 'https://qm.qq.com/q/NVEaYbxOo2', email: 'voodoo4@163.com', }; let host = null; let root = null; let ui = {}; let lastError = ''; let lastHint = ''; /* 兜底面板:主面板万一构建失败,也要让用户看到「脚本在跑」, 而不是一片空白以为装错了 */ function fallbackPanel(err) { try { let h = document.getElementById('sdf-free-host2'); if (!h) { h = document.createElement('div'); h.id = 'sdf-free-host2'; h.style.cssText = 'position:fixed;z-index:2147483647;right:16px;bottom:16px;max-width:320px;' + 'background:#fff;border:1px solid #fecaca;border-radius:10px;padding:10px 12px;' + 'font:12px/1.6 -apple-system,"Microsoft YaHei",sans-serif;color:#991b1b;' + 'box-shadow:0 8px 24px rgba(16,24,40,.18);line-height:1.6'; document.documentElement.appendChild(h); } h.textContent = '自动学习助手已运行,但面板没能画出来。错误:' + String((err && (err.message || err)) || '未知'); return h; } catch (e) { return null; } } /* 任何异常都不允许把面板整个吞掉 */ function buildPanel(force) { try { const r = buildPanelInner(force); const fp = document.getElementById('sdf-free-host2'); if (fp && fp.parentNode) fp.parentNode.removeChild(fp); return r; } catch (err) { lastError = String((err && (err.stack || err.message)) || err); try { console.error('[自动学习助手] 面板构建失败:', err); } catch (e) { /* ignore */ } return fallbackPanel(err); } } function buildPanelInner(force) { if (!IS_TOP) return; const old = document.getElementById('sdf-free-host'); if (old && old.shadowRoot && !old.__sdfOwn) { /* 容器是别的版本实例画的:直接接管它的 shadow 重画, 避免两个面板叠在一起,也避免它因为 host 消失而重建 */ host = old; host.__sdfOwn = true; host.style.display = ''; root = old.shadowRoot; root.innerHTML = ''; } else { if (old) { if (!force) return; if (old.parentNode) old.parentNode.removeChild(old); } host = document.createElement('div'); host.id = 'sdf-free-host'; host.__sdfOwn = true; host.style.cssText = 'position:fixed;z-index:2147483647;right:16px;bottom:16px;'; root = host.attachShadow({ mode: 'open' }); } const css = ` *{box-sizing:border-box;margin:0;padding:0;font-family:-apple-system,"Microsoft YaHei",sans-serif} .wrap{width:302px;background:#fff;border:1px solid #e3e8ef;border-radius:12px; box-shadow:0 8px 30px rgba(16,24,40,.16);overflow:hidden;font-size:12px;color:#1f2937; max-height:calc(100vh - 24px);display:flex;flex-direction:column} .hd{display:flex;align-items:center;gap:6px;padding:9px 12px; background:linear-gradient(135deg,#1d4ed8,#3b82f6);color:#fff;cursor:move;user-select:none} .hd b{font-size:13px;font-weight:600;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .dot{width:7px;height:7px;border-radius:50%;background:#9ca3af;flex:0 0 auto} .dot.on{background:#22c55e;box-shadow:0 0 0 3px rgba(34,197,94,.28)} .dot.warn{background:#fbbf24;box-shadow:0 0 0 3px rgba(251,191,36,.26)} .ver{font-size:10px;padding:1px 5px;border-radius:4px;background:rgba(255,255,255,.2); flex:0 0 auto;cursor:pointer} .ver:hover{background:rgba(255,255,255,.32)} /* 折叠成一条时,标题栏里挤一个进度胶囊:35% / 已切 3 节, 扫一眼就知道脚本还在干活,不用展开面板 */ .fp{display:none;flex:0 0 auto;font-size:10px;line-height:1;padding:3px 6px;border-radius:4px; background:rgba(255,255,255,.26);font-variant-numeric:tabular-nums;letter-spacing:.2px; white-space:nowrap;cursor:default} .fold .fp.on{display:inline-block} .fold .ver .vl{display:none} .mini{cursor:pointer;opacity:.9;padding:0 4px;font-size:14px;line-height:1} .bd{padding:10px 12px 12px;overflow-y:auto;flex:1;min-height:0} .uinfo{display:flex;align-items:center;gap:8px;padding:7px 9px;background:#f0f7ff; border:1px solid #d6e6ff;border-radius:8px;margin-bottom:8px} .avt{width:26px;height:26px;border-radius:50%;background:#2563eb;color:#fff;font-size:12px; display:flex;align-items:center;justify-content:center;flex:none} .utx{flex:1;line-height:1.35;overflow:hidden} .utx b{font-size:12.5px;color:#0f172a;display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .utx span{display:block;color:#6b7280;font-size:10.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .link{color:#2563eb;cursor:pointer;font-size:11px;flex:none} .link.lock{color:#b45309} .status{margin-bottom:7px;padding:7px 9px;background:#f8fafc;border:1px solid #eef2f7;border-radius:8px; line-height:1.6;color:#374151;max-height:52px;overflow:auto} .status.ok{background:#f0fdf4;border-color:#bbf7d0;color:#15803d} .status.warn{background:#fff7ed;border-color:#fed7aa;color:#9a3412} .chap{margin-bottom:8px;padding:7px 9px;background:#fbfcfe;border:1px solid #eef2f7;border-radius:8px} .ct{font-size:11.5px;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:5px} .bar{height:5px;background:#eef2f7;border-radius:3px;overflow:hidden} .bar>i{display:block;height:100%;width:0;background:linear-gradient(90deg,#3b82f6,#22c55e);transition:width .6s} .cp2{display:flex;justify-content:space-between;font-size:10.5px;color:#94a3b8;margin-top:4px} .cp2 #pc{font-variant-numeric:tabular-nums} .cp2 .vol{flex:none;margin-left:8px;cursor:pointer;font-size:11px;line-height:1; opacity:.85;transition:opacity .15s} .cp2 .vol:hover{opacity:1} .cp2 .vol.mute{color:#ea580c;opacity:1} .btns{display:flex;gap:8px} .btns button{flex:1;height:30px;border-radius:7px;border:1px solid #d8dee9;background:#fff;cursor:pointer; font-size:12px;color:#374151;font-family:inherit} .btns button.primary{background:#2563eb;border-color:#2563eb;color:#fff;font-weight:600} .btns button.primary.stop{background:#ef4444;border-color:#ef4444} .btns button:hover{filter:brightness(.97)} .adv{margin-top:8px;color:#2563eb;cursor:pointer;font-size:11px} .advbox{display:none;margin-top:6px;padding:8px 9px;background:#fbfcfe;border:1px solid #eef2f7;border-radius:8px} .advbox.show{display:block} .row{display:flex;align-items:center;gap:6px;margin-bottom:6px} .row:last-child{margin-bottom:0} .row label{width:56px;flex:none;color:#9aa3af;font-size:11px} .row input{flex:1;height:26px;border:1px solid #e8edf4;border-radius:6px;padding:0 7px;font-size:11px; background:#f4f6f9;color:#b6bec9;outline:none} .row input[type=checkbox]{flex:none;width:14px;height:14px;accent-color:#cbd5e1;padding:0} .lk{font-size:10px;color:#b45309;flex:none} .log{margin-top:8px;max-height:76px;overflow:auto;background:#0f172a;border-radius:8px;padding:7px 8px; color:#cbd5e1;line-height:1.55;font-family:Consolas,Menlo,monospace;font-size:10.5px} .log div{white-space:pre-wrap;word-break:break-all} .lic{margin-top:8px;padding-top:8px;border-top:1px dashed #e5e7eb;display:flex;align-items:center;gap:6px; color:#6b7280;font-size:10.5px;line-height:1.5} .lic span{flex:1} .link.buy{color:#ea580c;font-weight:600} .buybox{display:none;margin-top:7px;padding:8px 9px;background:#fffbf5;border:1px solid #fde3c8; border-radius:8px;font-size:10.5px;color:#7c2d12;line-height:1.6} .buybox.show{display:block} .bq{display:flex;align-items:center;gap:6px;margin-bottom:5px} .bq .lab{color:#9a6b45;flex:none} .bq b{font-family:Consolas,Menlo,monospace;font-size:12.5px;color:#b45309;letter-spacing:.4px; user-select:all;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .feat{margin:4px 0 5px;padding-top:5px;border-top:1px dashed #f3d3b4} .feat div:first-child{color:#9a3412;font-weight:600;margin-bottom:2px} .feat div{line-height:1.7} .bhint{color:#9ca3af;font-size:10px;margin-top:4px;line-height:1.5} .tip{margin-top:7px;color:#a3adba;font-size:10px;line-height:1.5} .hint{display:none;margin-bottom:7px;padding:6px 9px 6px 8px;background:#fffdf8; border:1px solid #fde8c8;border-left:3px solid #f59e0b;border-radius:6px; font-size:10.5px;color:#b45309;line-height:1.55;cursor:pointer} .hint.show{display:block} /* 课件 / 文档类章节:没有视频,平台不会自己往下走,给一个手动跳过的按钮 */ .skipbar{display:none;align-items:center;gap:8px;margin-bottom:7px;padding:7px 9px; background:#fff7ed;border:1px solid #fed7aa;border-radius:8px} .skipbar.show{display:flex} .skipbar button{flex:none;height:26px;padding:0 11px;border-radius:6px;border:1px solid #f59e0b; background:#f59e0b;color:#fff;font-size:11px;font-weight:600;cursor:pointer;font-family:inherit} .skipbar button:hover{filter:brightness(.96)} .skipbar button:disabled{opacity:.6;cursor:default} .skipbar span{font-size:10px;color:#9a3412;line-height:1.4} .hint:hover{background:#fff8ec} .hint b{color:#9a3412} .advmore{margin-top:6px;padding-top:6px;border-top:1px dashed #eef2f7;color:#ea580c; cursor:pointer;font-size:10.5px;text-align:center} .advmore:hover{text-decoration:underline} .feat h4{font-size:10.5px;color:#9a3412;margin:6px 0 2px;font-weight:600} .feat h4:first-child{margin-top:0} .feat div.it{line-height:1.7;color:#8a5a3d} .feat div.it::before{content:"· ";color:#c98a5e} .expanded .log{display:none} .capsule{width:auto} .capsule .bd{display:none} .fold{width:auto} .fold .bd{display:none} `; const style = document.createElement('style'); style.textContent = css; const card = document.createElement('div'); card.className = 'wrap'; card.innerHTML = `