// ==UserScript== // @name 山东省教师教育网 · 不用守着,自动一节接一节播完 // @namespace https://scriptcat.org/zh-CN/users/219470 // @version 1.4.6 // @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'; /* 同一 frame 只跑一份;页面 SPA 跳转不会重新执行,靠定时器兼容 */ if (window.__sdfFree) return; window.__sdfFree = true; 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)); /* ================= 基础判定 ================= */ /* 章节页:不再猜死路径,凡是学习/播放类路径或页面上有章节列表都算 */ 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'); /* 平台右上角的「剩余时长 / 已完成」角标 */ 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; async function tryPlay(v) { if (!v) return; if (Date.now() - lastTry < 2500) return; lastTry = Date.now(); try { const p = v.play(); if (p && p.catch) p.catch(() => {}); } catch (e) { /* 自动播放被拦,下面静音重试 */ } await sleep(700); if (v.paused) { try { 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; 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) { 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 { if (closeAfter) { const btn = findChapterMenu(); if (btn) btn.click(); } switching = false; /* 切换后重算一节的判定窗口 */ pageEnterAt = Date.now(); sawCountdown = false; doneStreak = 0; endHandled = false; } } /* ================= 面板 ================= */ const CONTACT = { qq: '1131654415', qqLink: 'https://qm.qq.com/q/NVEaYbxOo2', email: 'voodoo4@163.com', }; let host = null; let root = null; let ui = {}; function buildPanel(force) { if (!IS_TOP) return; const old = document.getElementById('sdf-free-host'); if (old) { if (!force) return; old.remove(); } host = document.createElement('div'); host.id = 'sdf-free-host'; host.style.cssText = 'position:fixed;z-index:2147483000;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} .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)} .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{cursor:pointer} .cp2 #pc:hover{color:#2563eb} .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} `; const style = document.createElement('style'); style.textContent = css; const card = document.createElement('div'); card.className = 'wrap'; card.innerHTML = `
自动学习助手 精简版 —
师
--平台未登录
换号
待机中
—
—0%
完整版遇到课件会自动跳过
不用你点
▸ 高级设置(项目地址 / 评价内容)
🔒
🔒
🔒
🔒
查看完整版全部 12 项能力 ›
自动连播已启用 完整版 ›
交流群${CONTACT.qq}复制

学完一门就没人管了?

本课程学完自动进下一门,直到全部学完
登录跟着平台走,换号不串号、不丢进度
学完自动提交五星评价(评价语可自己改)
课程打不开、跳 404,自动跳过并继续下一门
多开了窗口会自动让位,避免被判学时作废

题还得自己一道道答?

视频中途弹出的随堂题,自动作答并提交,答完接着播
章节测试自动交卷;答错会读平台答案改对再交
本地题库 + 从平台解析学答案,越用越准

有些章节只能干等?

点开就显示「已完成」的章节,8 秒确认后直接跳过
文档 / PDF 课件,进去即完成、直接返回
发现被改成非 1 倍速,立即纠正回 1 倍速
精简版负责「不用守着」;上面这些是完整版多出来的部分。
十几门课、上百个章节,手动点一遍是几百次;完整版只需点一次「开始学习」。
加群后看群公告;也可直接点 加群链接
全程 1 倍速,不改学时;运行期间请勿关闭本标签页。
`; root.append(style, card); document.documentElement.appendChild(host); ui = { card: card, dot: root.getElementById('dot'), ch: root.getElementById('ch'), pg: root.getElementById('pg'), pb: root.getElementById('pb'), pc: root.getElementById('pc'), st: root.getElementById('st'), more: root.getElementById('buybox'), hint: root.getElementById('hint'), log: root.getElementById('log'), avt: root.getElementById('avt'), uname: root.getElementById('uname'), uacc: root.getElementById('uacc'), skipBar: root.getElementById('skipbar'), skipBtn: root.getElementById('skipBtn'), }; lastHint = ''; /* 进度条右侧的「(静音)」可以点:手势内 play() 不会被自动播放策略拦, 声音能立刻出来,不用去页面上找播放器的喇叭 */ ui.pc.addEventListener('click', () => { const v = getVideo(); if (!v) return; const wantSound = v.muted; v.muted = !wantSound; if (wantSound) { try { const pr = v.play(); if (pr && pr.catch) pr.catch(() => {}); } catch (e) { /* ignore */ } } renderProgress(v); logLine(wantSound ? '已开启声音' : '已静音'); }); /* 课件 / 文档类章节:没有视频可播,只能手动跳。点一次就好。 */ ui.skipBtn.addEventListener('click', async () => { if (ui.skipBtn.disabled) return; ui.skipBtn.disabled = true; ui.skipBar.classList.remove('show'); setStatus('正在跳到下一节…'); await sleep(2200); /* 给平台记「已完成」的时间,别跳太快 */ const ok = await goNextChapter('手动跳过本节'); ui.skipBtn.disabled = false; if (!ok) { setStatus('没跳成功:可能是这一节平台还没记完成,稍等一下再点', 'warn'); ui.skipBar.classList.add('show'); } }); if (cfg.folded) card.classList.add('fold'); /* 平台用户信息(只读,不登录、不发请求) */ fillUser(); /* 开始 / 暂停 */ root.getElementById('sw').addEventListener('click', () => { cfg.on = !cfg.on; saveCfg(); renderSwitch(); if (cfg.on) { setStatus('已开启,正在接管播放…', 'ok'); logLine('开始接管'); } else { setStatus('已暂停接管,视频需手动播放', 'warn'); logLine('暂停接管'); } }); /* 折叠 */ root.getElementById('fo').addEventListener('click', () => { cfg.folded = !cfg.folded; saveCfg(); card.classList.toggle('fold', cfg.folded); root.getElementById('fo').textContent = cfg.folded ? '+' : '—'; }); /* 高级设置展开 */ root.getElementById('advt').addEventListener('click', () => { const box = root.getElementById('adv'); const on = box.classList.toggle('show'); root.getElementById('advt').textContent = (on ? '▾ ' : '▸ ') + '高级设置(项目地址 / 评价内容)'; }); /* 锁定项:点了就展示完整版能力 */ const openBuy = () => { root.getElementById('buybox').classList.add('show'); card.classList.add('expanded'); setHint(''); }; root.getElementById('swAcc').addEventListener('click', openBuy); root.getElementById('advmore').addEventListener('click', openBuy); root.getElementById('verTip').addEventListener('click', openBuy); root.getElementById('hint').addEventListener('click', openBuy); root.getElementById('buyBtn').addEventListener('click', () => { const on = root.getElementById('buybox').classList.toggle('show'); card.classList.toggle('expanded', on); if (on) setHint(''); }); root.querySelectorAll('.advbox input').forEach((el) => { el.parentElement.addEventListener('click', openBuy); }); /* 复制群号 / 打开加群链接 */ root.getElementById('qqCopy').addEventListener('click', async (e) => { e.stopPropagation(); try { await navigator.clipboard.writeText(CONTACT.qq); e.target.textContent = '已复制'; setTimeout(() => (e.target.textContent = '复制'), 1200); } catch (err) { window.prompt('手动复制', CONTACT.qq); } }); root.getElementById('qqLink').addEventListener('click', () => { window.open(CONTACT.qqLink, '_blank'); }); /* 清空日志 */ root.getElementById('clr').addEventListener('click', () => { const el = root.getElementById('log'); if (el) el.innerHTML = ''; logLine('日志已清空'); }); /* 拖动 */ let dragging = false; let offX = 0; let offY = 0; const hd = root.querySelector('.hd'); hd.addEventListener('mousedown', (e) => { if (e.target.tagName === 'BUTTON' || e.target.classList.contains('mini')) return; dragging = true; const r = host.getBoundingClientRect(); offX = e.clientX - r.left; offY = e.clientY - r.top; e.preventDefault(); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; const w = host.offsetWidth || 302; const h = host.offsetHeight || 200; let x = e.clientX - offX; let y = e.clientY - offY; x = Math.max(4, Math.min(x, innerWidth - w - 4)); y = Math.max(4, Math.min(y, innerHeight - h - 4)); host.style.left = x + 'px'; host.style.top = y + 'px'; host.style.right = 'auto'; host.style.bottom = 'auto'; cfg.x = x; cfg.y = y; }); window.addEventListener('mouseup', () => { if (dragging) { dragging = false; saveCfg(); } }); if (typeof cfg.x === 'number' && typeof cfg.y === 'number') { host.style.left = cfg.x + 'px'; host.style.top = cfg.y + 'px'; host.style.right = 'auto'; host.style.bottom = 'auto'; } renderSwitch(); if (!cfg.on) setStatus('已暂停接管,视频需手动播放', 'warn'); } /* 平台当前登录用户(只读 localStorage,不发请求) */ function fillUser() { if (!ui.uname) return; let u = null; try { u = JSON.parse(localStorage.getItem('user') || 'null'); } catch (e) { u = null; } if (u && u.name) { ui.uname.textContent = u.name; ui.avt.textContent = u.name.slice(0, 1); const acc = u.loginName || u.identityCardNum || ''; ui.uacc.textContent = acc ? '平台已登录 · ' + acc : '平台已登录'; } else { ui.uname.textContent = '未识别到账号'; ui.avt.textContent = '?'; ui.uacc.textContent = '请在平台登录后刷新'; } } /* 场景化提示条:只在「用户正好卡住」的时候冒出来一句,平时不打扰 */ let lastHint = ''; function setHint(html) { if (!ui.hint) return; if (lastHint === (html || '')) return; lastHint = html || ''; if (!html) { ui.hint.classList.remove('show'); ui.hint.innerHTML = ''; return; } ui.hint.innerHTML = html; ui.hint.classList.add('show'); } /* 底部授权栏:用已切换节数制造「进度感」 */ function updateLic() { if (!root) return; const el = root.getElementById('licTxt'); if (!el) return; el.textContent = cfg.switched ? '精简版 · 已自动切换 ' + cfg.switched + ' 节' : '精简版 · 自动连播已启用'; } let logCount = 0; function logLine(text) { if (!ui.log) return; const d = document.createElement('div'); const t = new Date(); const hh = String(t.getHours()).padStart(2, '0'); const mm = String(t.getMinutes()).padStart(2, '0'); const ss = String(t.getSeconds()).padStart(2, '0'); d.textContent = '[' + hh + ':' + mm + ':' + ss + '] ' + text; ui.log.appendChild(d); logCount++; while (ui.log.children.length > 60) ui.log.removeChild(ui.log.firstChild); ui.log.scrollTop = ui.log.scrollHeight; } function renderSwitch() { if (!ui.dot) return; ui.dot.className = 'dot' + (cfg.on ? ' on' : ''); const b = root.getElementById('sw'); if (b) { b.textContent = cfg.on ? '暂停学习' : '开始学习'; b.className = 'primary' + (cfg.on ? ' stop' : ''); } } let statusTimer = null; function setStatus(text, kind) { if (!ui.st) return; ui.st.textContent = text; ui.st.className = 'status' + (kind ? ' ' + kind : ''); if (statusTimer) clearTimeout(statusTimer); if (kind === 'ok') { statusTimer = setTimeout(() => { if (ui.st) ui.st.className = 'status'; }, 4000); } } function showSkip(on) { if (!ui.skipBar) return; const want = !!on; if (ui.skipBar.classList.contains('show') !== want) { ui.skipBar.classList.toggle('show', want); } } function renderProgress(v) { if (!ui.ch) return; const title = currentChapterTitle(); if (title) ui.ch.textContent = title; if (!v) { ui.pg.textContent = '—'; ui.pb.style.width = '0%'; ui.pc.textContent = '0%'; return; } const cur = Math.floor(v.currentTime || 0); const dur = isFinite(v.duration) ? Math.floor(v.duration) : 0; ui.pg.textContent = dur ? fmt(cur) + ' / ' + fmt(dur) : fmt(cur) + ' / —'; const p = dur ? Math.min(100, Math.round((cur / dur) * 100)) : 0; ui.pb.style.width = p + '%'; ui.pc.textContent = p + '%' + (v.paused ? '(已暂停)' : '') + (v.muted ? '(静音)' : ''); } function fmt(s) { s = Math.max(0, Math.round(s)); const m = Math.floor(s / 60); const r = s % 60; return m + ':' + String(r).padStart(2, '0'); } /* 当前章节标题:从页面标题或抽屉里「最近学习」那一项取 */ function currentChapterTitle() { const h = document.querySelector('.ellipsis-title, nz-header h1, h1'); if (h) { const t = (h.innerText || '').replace(/\s+/g, ' ').trim(); if (t) return t.length > 18 ? t.slice(0, 18) + '…' : t; } const list = parseChapters(); const r = list.find((c) => c.recent); if (r) return r.title.length > 18 ? r.title.slice(0, 18) + '…' : r.title; return '—'; } /* ================= 主循环 ================= */ let pageEnterAt = Date.now(); let lastLoggedChapter = ''; let lastSwitchAt = 0; let courseFinished = false; /* 本课程已学完,状态不再被后续 tick 覆盖 */ let toldNoVideo = false; let noVideoAt = 0; /* 本节第一次确认「没有 video」的时间,用来区分「还在加载」和「确实是课件」 */ let sawCountdown = false; let doneStreak = 0; let endHandled = false; async function tick() { if (!IS_TOP) { /* iframe 里的播放器:只管播放,播完通知顶层 */ if (!cfg.on) return; const v = getVideo(); if (v) { if (v.paused && !v.ended) await tryPlay(v); if (v.ended) { try { parent.postMessage({ __sdfFreeEnded: true }, '*'); } catch (e) { /* ignore */ } } } return; } /* host 丢了或被页面重渲染冲掉,就重建一次 */ if (!host || !host.isConnected) buildPanel(true); /* 关键改动:不再用 display:none 隐藏面板。 以前只要路径判定不上、视频又在 iframe 里,面板就整块消失, 用户看到的就是「脚本没运行」。现在改成收成小胶囊,永远看得见。 */ const here = isChapterPage() || !!getVideo(); if (ui.card) ui.card.classList.toggle('capsule', !here); if (!here) { pageEnterAt = Date.now(); doneStreak = 0; sawCountdown = false; endHandled = false; showSkip(false); if (!toldNoVideo) { toldNoVideo = true; setStatus('已运行,本页没有视频;打开课程播放页后自动接管'); setHint('完整版可从课程列表自动进入下一门,不用手动点 ›'); } return; } toldNoVideo = false; /* 已经学完就别再刷状态,否则「学完」提示会被下一次 tick 盖掉 */ if (courseFinished) { showSkip(false); renderProgress(getVideo()); return; } if (!cfg.on) { showSkip(false); renderProgress(getVideo()); setHint('暂停期间视频不会动;完整版可无人值守,一门接一门往下学 ›'); return; } if (hasConflict()) { showSkip(false); if (ui.dot) ui.dot.className = 'dot warn'; setStatus('平台提示「同一活动多窗口」,请只留一个窗口', 'warn'); setHint('完整版会自动让其他窗口让位,不会判学时作废 ›'); return; } /* 随堂题:让用户自己答,答完自动继续 */ if (hasQuiz()) { showSkip(false); if (ui.dot) ui.dot.className = 'dot warn'; setStatus('检测到题目,请手动作答;答完会自动继续播放', 'warn'); setHint('完整版可自动作答并提交,答错还会改对重交 ›'); if (!window.__sdfQuizLogged) { window.__sdfQuizLogged = true; logLine('检测到题目,等待作答'); } renderProgress(getVideo()); return; } if (ui.dot) ui.dot.className = 'dot'; const v = getVideo(); if (!v) { if (!noVideoAt) noVideoAt = Date.now(); const waited = Date.now() - noVideoAt; const fr = document.querySelectorAll('iframe').length; /* 刚进来那几秒视频可能还在加载,先不下结论 */ if (waited < 6000) { if (ui.dot) ui.dot.className = 'dot'; showSkip(false); setHint(''); setStatus('正在识别本节内容…'); renderProgress(null); return; } if (ui.dot) ui.dot.className = 'dot warn'; /* 等了 6 秒还没有 video:这一节是课件 / 文档,平台不会自己往下走。 精简版不替用户跳(那是完整版的能力),但必须给个按钮, 否则看起来就是「卡住了、脚本坏了」。 */ showSkip(true); setStatus('本节是课件(没有视频),需要你点一下才往下走', 'warn'); /* 按钮右边已经写了「完整版会自动跳过」,这里只在有 iframe 时补充说明 */ setHint(fr ? '页面上有 ' + fr + ' 个子框架,取不到播放器' : ''); renderProgress(null); return; } noVideoAt = 0; showSkip(false); if (v.paused && !v.ended) await tryPlay(v); renderProgress(v); /* 记住这一节播过:以后选「下一节」时,播过的一律不再回头 */ { const t0 = currentTitleFromPage(); if (t0) markVisited(t0); } /* 角标判定:只有先看到过「剩余时长」,才认后面的「已完成」, 否则刚进页面残留的上一节角标会让人误以为这节已经学完。 */ const txt = badgeText(); if (txt.indexOf('剩余时长') >= 0) sawCountdown = true; const doneNow = txt.indexOf('已完成') >= 0; if (doneNow && sawCountdown) { if (!endHandled) { endHandled = true; markDoneHere(); setHint(''); setStatus('本节已标记完成,切换下一节…', 'ok'); await sleep(1500); await goNextChapter('本节已完成,切换下一节…'); } return; } if (v.ended) { if (doneStreak < 2) { doneStreak++; setStatus('本节播完,确认平台是否已记录…'); await sleep(2500); return; } if (!endHandled) { endHandled = true; markDoneHere(); setStatus('本节播完,切换下一节…', 'ok'); await goNextChapter('本节播完,切换下一节…'); } return; } doneStreak = 0; /* 刚进页面 8 秒内不判完成,给平台角标刷新的时间 */ const waited = Date.now() - pageEnterAt; if (waited < 8000) { setStatus('已接管播放,正在等平台记录状态…'); if (lastLoggedChapter !== (currentChapterTitle() || '')) { lastLoggedChapter = currentChapterTitle() || ''; window.__sdfQuizLogged = false; logLine('开始播放:' + (lastLoggedChapter || '本节')); } return; } /* 兜底:角标一直只显示「已完成」、从未出现过「剩余时长」, 且视频根本没动(currentTime < 2 秒)——说明这一节平台早就记过了, 不切的话会一直卡在这里干等。给 18 秒观察期再判。 刚切完 25 秒内不判:新页面角标是上一节残留的,容易连跳好几节。 */ if ( !sawCountdown && doneNow && (v.currentTime || 0) < 2 && waited > 18000 && Date.now() - lastSwitchAt > 25000 ) { if (!endHandled) { endHandled = true; markDoneHere(); setStatus('这一节平台已记为完成,切换下一节…', 'ok'); await goNextChapter('本节早已完成,切换下一节…'); } return; } setStatus(cfg.switched ? '正在播放 · 已自动切换 ' + cfg.switched + ' 节' : '正在播放'); /* 刚切完 20 秒内提一次完整版能力,之后自动收起,避免每页都弹 */ if (Date.now() - lastSwitchAt < 20000) { setHint('本节学完会自动切下一节;完整版可跨课程连播,一门接一门 ›'); } else { setHint(''); } } /* ================= 启动 ================= */ try { console.log('[研修助手] 已注入 ' + location.href + ' | 顶层=' + IS_TOP); } catch (e) { /* ignore */ } buildPanel(); updateLic(); logLine('脚本已加载 · ' + location.hostname); /* 新用户不一定会注意到「完整版 ›」,第一次进播放页给一次入口提示 */ if (!localStorage.getItem('sdf_free_seen')) { setTimeout(() => { if (!cfg.on || !getVideo()) return; if (ui.card && ui.card.classList.contains('capsule')) return; setHint('本节学完会自动切下一节 · 点右上「精简版」或底部「完整版」看还能做什么 ›'); localStorage.setItem('sdf_free_seen', '1'); setTimeout(() => setHint(''), 12000); }, 12000); } setInterval(() => { tick().catch(() => {}); }, TICK); tick().catch(() => {}); /* SPA 路由变化时重置判定窗口;换到另一门课则清掉本课程的已完成记录 */ let lastHref = location.href; let lastCourse = courseId(); setInterval(() => { if (location.href !== lastHref) { lastHref = location.href; pageEnterAt = Date.now(); sawCountdown = false; doneStreak = 0; endHandled = false; lastTry = 0; courseFinished = false; noVideoAt = 0; const cur = courseId(); if (cur !== lastCourse) { lastCourse = cur; doneTitles.clear(); triedFail.clear(); cooldownUntil = 0; cfg.switched = 0; lastLoggedChapter = ''; saveCfg(); initVisited(); } } }, 800); /* iframe 播放器播完 → 顶层切下一节 */ window.addEventListener('message', (e) => { if (!e.data || !e.data.__sdfFreeEnded) return; if (!IS_TOP || !cfg.on) return; goNextChapter('iframe 播放结束,切换下一节…'); }); })();