// ==UserScript== // @name 教师研修网(仅自动刷课) // @namespace https://ipx.yanxiu.com/ // @version 3.15.0 // @description 登录之后,它替你打开课程、自动播放、播完回列表挑下一门。必修、选修全部达标以后,还能继续把没看完的课一门门连播下去。入口有两个:浮窗上的「设置」,或脚本管理器菜单里的「自动看课 · 打开设置」。改完点「保存并应用」立即生效,不需要刷新页面。脚本仅用于减少重复点击,观看行为仍由你本人账号产生。请遵守所在单位与平台的培训规则;因使用产生的考核、学分、账号等后果由使用者自行承担。 // @author ieeceee // @match https://ipx.yanxiu.com/train2/* // @match https://ipx.yanxiu.com/grain/* // @match https://ipx.yanxiu.com/cms/project/* // @icon https://srt-read-online.3ren.cn/basebusiness/headimg/20200711/1594463211851X00rZyPRV8-23.png // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant unsafeWindow // @noframes // @run-at document-idle // @license GPL-3.0 // @tag 在线教育 // @tag 自动挂课 // @tag 教师研修网 // ==/UserScript== /* ==UserConfig== 设置: 静音: title: 自动静音 description: 建议保持开启。浏览器不允许带声音的页面自动播放,关掉可能播不起来。 type: checkbox default: true 倍速: title: 2 倍速播放 description: 这个网站不允许倍速播放(播放器里压根没有倍速选项),建议保持关闭。 type: checkbox default: false 连播: title: 必修、选修都达标后继续连播 description: 开启后「自动学习」:即使必修和选修都已经达标,也会把没看完的课一门门接着播完。关掉的话,全达标就停下。 type: checkbox default: true 自动评价: title: 自动提交评价 / 评分(给满分) description: 看课中途弹出课程评价、问卷、评分或「验证」浮层时,自动给满分、填好评语并提交,让视频接着播。 type: checkbox default: true 过滑块: title: 自动拖动滑块 description: 弹出滑块验证时,按人手轨迹把滑块拖到底(先快后慢、带抖动)。只处理「拖到最右」这种线性滑块;拼图缺口型不硬拖,会提示你手动对位。 type: checkbox default: true 填验证码: title: 自动填写验证码 description: 验证码是算式(如 3+5=?)时直接算出来填上并提交。若是图片验证码,需要配好下面的「打码接口」才能认,否则会提示你手输。 type: checkbox default: true 打码接口: title: 打码接口(可选,留空则不联网) description: 填一个 HTTP 地址,脚本会 POST {"image":"data:image/png;base64,..."},接口返回 {"code":"1234"} 或纯文本即可。用于识别图片验证码;不填则永远不联网,图片验证码转人工。 type: text default: "" 浮窗: title: 显示状态浮窗 description: 右下角的面板,显示实时进度和「最近做了什么」。点最上面那行可以收起或展开;不想要整个面板就取消勾选这里。 type: checkbox default: true ==/UserConfig== */ /* 教师研修网 自动看课 —— 打开「工作台」就自己开始;用法见「使用说明.html」,原因见「开发笔记.md」 */ (function () { "use strict"; if (window !== top) return; var LOG = function () { console.log.apply(console, ["[自动看课]"].concat([].slice.call(arguments))); }; /* ================= 一、设置 ================= */ function cfgRaw(key, def) { var v = GM_getValue("设置." + key); return v === undefined || v === null ? def : v; } function cfg(key, def) { return !!cfgRaw(key, def); } function isMuted() { return cfg("静音", true); } /* 站点 playbackRates 是空数组,明确不允许倍速;默认关闭,关着时连 playbackRate 都不碰 */ function speedEnabled() { return cfg("倍速", false); } function hudEnabled() { return cfg("浮窗", true); } /* ---- 观看时长:full = 播到站点提示「您已完成」;fixed = 每门最多 N 分钟 ---- */ var DUR_MIN_DEFAULT = 30; var DUR_MIN_LOW = 1; var DUR_MIN_HIGH = 600; function watchMode() { return cfgRaw("时长模式", "full") === "fixed" ? "fixed" : "full"; } /* 存进去的可能是字符串(用户手改过存储),统一在这儿收口成合法整数 */ function watchLimitMin() { var n = Number(cfgRaw("时长分钟", DUR_MIN_DEFAULT)); if (!isFinite(n) || n < DUR_MIN_LOW) n = DUR_MIN_DEFAULT; return Math.min(DUR_MIN_HIGH, Math.max(DUR_MIN_LOW, Math.round(n))); } /* 0 表示「不限」,调用方拿 0 就当没有截止时间 */ function watchLimitMs() { return watchMode() === "fixed" ? watchLimitMin() * 60000 : 0; } /* 一门课达标后要不要自己去开下一门 */ function autoNextAfterPass() { return cfg("达标续播", true); } /* ---- 必修、选修全达标后继续连播(「自动学习」总开关) ---- 全达标之后,站点上还没看完(进度不到 100%)的课继续一门门自动播完。 「全达标」这个事实只有工作台页读得出来(考核卡片在那儿),所以由它写进 ak_pt, 课程列表页 / 看课页读这个标记,三处对同一件事说同一句话。 ============================================================ */ var PT_KEY = "ak_pt"; function autoPlayAfterPass() { return cfg("连播", true); } function setAllPassed(on) { try { GM_setValue(PT_KEY, !!on); } catch (e) {} } function isAllPassed() { return !!GM_getValue(PT_KEY); } /* 「连播模式」= 考核已全达标 + 用户开着连播 */ function playThrough() { return autoPlayAfterPass() && isAllPassed(); } /* ================= 二、右下角小浮窗 ================= */ var VERSION = "3.15.0"; /* 必须与头部 @version 一致,「复制状态」报的就是它 */ var HUD = { root: null, dot: null, line: null, head: null, body: null, arrow: null, live: null, log: null, detail: null, toast: null, runBtn: null, events: [], toastTimer: null, }; var COLORS = { running: "#378ADD", skip: "#1D9E75", done: "#1D9E75", error: "#E24B4A", idle: "#8A94A6", }; function nowText() { var d = new Date(); var p = function (n) { return (n < 10 ? "0" : "") + n; }; return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()); } function isPanelOpen() { var v = GM_getValue("ak_hud_open"); return v === undefined || v === null ? true : !!v; } function setPanelOpen(isOpen) { GM_setValue("ak_hud_open", !!isOpen); if (HUD.body) HUD.body.style.display = isOpen ? "block" : "none"; if (HUD.arrow) HUD.arrow.textContent = isOpen ? "▴ 收起" : "▾ 展开"; } function buildHud() { if (!hudEnabled()) return; if (HUD.root && document.body && document.body.contains(HUD.root)) return; if (!document.body) return; var root = document.createElement("div"); root.style.cssText = "position:fixed;right:14px;bottom:14px;z-index:2147483647;" + 'font:13px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;color:#042C53'; var toast = document.createElement("div"); toast.style.cssText = "position:absolute;right:0;bottom:100%;margin-bottom:8px;max-width:280px;" + "background:#042C53;color:#fff;border-radius:9px;padding:7px 11px;font-size:12px;" + "line-height:1.55;box-shadow:0 4px 14px rgba(0,0,0,.22);opacity:0;transform:translateY(6px);" + "transition:opacity .25s,transform .25s;pointer-events:none;word-break:break-all"; var card = document.createElement("div"); card.style.cssText = "background:#fff;border:1px solid #B5D4F4;border-radius:12px;" + "box-shadow:0 6px 20px rgba(0,0,0,.14);overflow:hidden;max-width:300px"; var head = document.createElement("div"); head.style.cssText = "display:flex;align-items:center;gap:8px;padding:10px 13px;" + "cursor:pointer;user-select:none"; var dot = document.createElement("span"); dot.style.cssText = "width:8px;height:8px;border-radius:50%;background:#378ADD;flex:0 0 auto"; var line = document.createElement("span"); line.style.cssText = "font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"; var arrow = document.createElement("span"); arrow.style.cssText = "margin-left:auto;color:#6E9BD1;font-size:11px;flex:0 0 auto;white-space:nowrap"; head.appendChild(dot); head.appendChild(line); head.appendChild(arrow); var body = document.createElement("div"); body.style.cssText = "padding:0 13px 12px;border-top:1px solid #E6F1FB"; var live = document.createElement("div"); live.style.cssText = "padding:9px 0 2px;color:#0C447C;font-size:12.5px;" + "white-space:pre-line;word-break:break-all"; var log = document.createElement("div"); log.style.cssText = "display:none;margin-top:8px;padding-top:8px;border-top:1px dashed #E6F1FB;" + "color:#7A8798;font-size:11.5px;line-height:1.6;white-space:pre-line;word-break:break-all"; var detail = document.createElement("div"); detail.style.cssText = "display:none;margin-top:8px;padding:8px 10px;border-radius:8px;" + "font-size:12px;white-space:pre-wrap;word-break:break-all"; var btns = document.createElement("div"); btns.style.cssText = "display:flex;gap:8px;margin-top:10px"; var b0 = document.createElement("button"); b0.textContent = "回到工作台"; b0.style.cssText = btnStyle("#1D9E75", "#ffffff"); b0.onclick = function () { var back = backUrl(); if (back) location.href = back; }; var b1 = document.createElement("button"); b1.textContent = "重新开始"; b1.style.cssText = btnStyle("#E6F1FB", "#0C447C"); b1.onclick = function () { location.reload(); }; var b2 = document.createElement("button"); b2.textContent = "复制状态"; b2.style.cssText = btnStyle("#F1EFE8", "#5F5E5A"); b2.onclick = function () { copyText(diagnosticsText()); }; var b3 = document.createElement("button"); b3.textContent = "刷新学分"; b3.style.cssText = btnStyle("#E6F1FB", "#0C447C"); b3.onclick = function () { logEvent("正在刷新学分…"); fetchCredits(true).then(function (d) { logEvent( d ? "学分已刷新:" + creditScoreText() : "学分没拉回来:" + (creditError || "未知原因"), ); }); }; /* 第一行只放「设置」和「启动 / 停止」:这两个是最高频的操作,别跟其他按钮挤一起 */ var btns0 = document.createElement("div"); btns0.style.cssText = "display:flex;gap:8px;margin-top:10px"; var bSet = document.createElement("button"); bSet.textContent = "⚙ 设置"; bSet.style.cssText = btnStyle("#E6F1FB", "#0C447C"); bSet.onclick = function () { openSettings(); }; var bRun = document.createElement("button"); bRun.textContent = isRunning() ? "⏸ 停止" : "▶ 启动"; bRun.style.cssText = btnStyle( isRunning() ? "#FCEBEB" : "#E6F6EE", isRunning() ? "#A32D2D" : "#0F6E4A", ); bRun.onclick = function () { if (isRunning()) stopWork("手动停止"); else startWork(); refreshRunUi(); }; btns0.appendChild(bSet); btns0.appendChild(bRun); /* 「回到工作台」只在被站点甩到「项目首页」时才出现,平时不占地方 */ if (isProjectHome() && backUrl()) btns.appendChild(b0); btns.appendChild(b1); btns.appendChild(b2); btns.appendChild(b3); body.appendChild(live); body.appendChild(log); body.appendChild(detail); body.appendChild(btns0); body.appendChild(btns); /* 打赏入口单独一行细字:按钮行三个 flex:1 已占满 300px,再挤会看不清 */ var donate = document.createElement("div"); donate.textContent = "☕ 请作者喝杯蜜雪冰城"; donate.style.cssText = "margin-top:9px;text-align:center;font-size:11.5px;color:#7A8798;" + "cursor:pointer;user-select:none"; donate.onclick = function () { openDonate(); }; donate.onmouseenter = function () { donate.style.color = "#BA7517"; }; donate.onmouseleave = function () { donate.style.color = "#7A8798"; }; body.appendChild(donate); card.appendChild(head); card.appendChild(body); root.appendChild(toast); root.appendChild(card); document.body.appendChild(root); head.onclick = function () { setPanelOpen(!isPanelOpen()); }; head.onmouseenter = function () { head.style.background = "#F2F8FF"; }; head.onmouseleave = function () { head.style.background = "transparent"; }; HUD.root = root; HUD.dot = dot; HUD.line = line; HUD.head = head; HUD.body = body; HUD.arrow = arrow; HUD.live = live; HUD.log = log; HUD.detail = detail; HUD.toast = toast; HUD.runBtn = bRun; setPanelOpen(isPanelOpen()); } function btnStyle(bg, fg) { return ( "flex:1;padding:6px 10px;border:0;border-radius:8px;background:" + bg + ";color:" + fg + ';font:12px -apple-system,"PingFang SC",sans-serif;cursor:pointer' ); } function setStatus(text, tone) { buildHud(); if (tone === "error") setPanelOpen(true); if (!HUD.line) return; HUD.line.textContent = text; HUD.dot.style.background = COLORS[tone] || COLORS.running; } function setLive(text) { buildHud(); if (HUD.live) HUD.live.textContent = text || ""; } function logEvent(text) { buildHud(); HUD.events.push(nowText() + " " + text); while (HUD.events.length > 3) HUD.events.shift(); if (HUD.log) { HUD.log.textContent = HUD.events.join("\n"); HUD.log.style.display = "block"; } showToast(text); LOG(text); } function setDetail(text, isError) { buildHud(); if (!HUD.detail) return; if (!text) { HUD.detail.style.display = "none"; return; } if (isError) setPanelOpen(true); HUD.detail.textContent = text; HUD.detail.style.background = isError ? "#FCEBEB" : "#E6F1FB"; HUD.detail.style.color = isError ? "#A32D2D" : "#0C447C"; HUD.detail.style.display = "block"; } function setError(text) { setDetail(text, true); } function showToast(text) { if (!HUD.toast) return; HUD.toast.textContent = text; HUD.toast.style.opacity = "1"; HUD.toast.style.transform = "translateY(0)"; if (HUD.toastTimer) clearTimeout(HUD.toastTimer); HUD.toastTimer = setTimeout(function () { HUD.toast.style.opacity = "0"; HUD.toast.style.transform = "translateY(6px)"; }, 6000); } function diagnosticsText() { var d = new Date(); var p = function (n) { return (n < 10 ? "0" : "") + n; }; var out = [ "教师研修网 自动看课 v" + VERSION, "时间:" + d.getFullYear() + "-" + p(d.getMonth() + 1) + "-" + p(d.getDate()) + " " + nowText(), "页面:" + location.href, "状态:" + (HUD.line ? HUD.line.textContent : ""), ]; if (HUD.live && HUD.live.textContent) out.push("实时:\n" + HUD.live.textContent); if ( HUD.detail && HUD.detail.style.display !== "none" && HUD.detail.textContent ) { out.push("说明:\n" + HUD.detail.textContent); } if (HUD.events.length) out.push("最近动态:\n" + HUD.events.join("\n")); return out.join("\n"); } function copyText(text) { function fallbackCopy() { try { var ta = document.createElement("textarea"); ta.value = text; ta.style.cssText = "position:fixed;left:-9999px;top:0"; document.body.appendChild(ta); ta.select(); var ok = document.execCommand("copy"); document.body.removeChild(ta); return !!ok; } catch (e) { return false; } } function finish(ok) { showToast( ok ? "状态已复制,粘贴给开发者就能定位问题" : "复制失败,请直接截图这个面板", ); } try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then( function () { finish(true); }, function () { finish(fallbackCopy()); }, ); return; } } catch (e) {} finish(fallbackCopy()); } /* ================= 三·1、打赏 ================= */ /* 赞赏码(Atroswnet):base64 在文件末尾「九、启动」那节。 这张码写死在脚本里,界面上不提供任何更换 / 上传 / 清除入口 —— 要换只能改代码。 */ var DONATE_QR = ""; var DONATE_QR_LABEL = "Atroswnet 的赞赏码"; /* 内嵌图被 CSP 拦过一次就记住,之后直接显示提示;blockedURI 是 "data"(无冒号),用 /^data:?/ 兼容 */ var donateCspBlocked = false; try { document.addEventListener("securitypolicyviolation", function (e) { if (!e || !e.blockedURI) return; if (!/^data:?/.test(String(e.blockedURI))) return; if (/img-src/.test(e.violatedDirective || "")) donateCspBlocked = true; }); } catch (e) {} var donateMask = null; var donateWrap = null; var donateEsc = null; function donateBtn(text, bg, fg, size) { var b = document.createElement("button"); b.textContent = text; b.style.cssText = "padding:4px 9px;border:0;border-radius:7px;background:" + bg + ";color:" + fg + ";font:" + (size || "11.5px") + ' -apple-system,"PingFang SC",sans-serif;cursor:pointer;white-space:nowrap'; return b; } /* 只画「一张图 + 名字」,没有按钮、不收拖拽: 赞赏码是作者本人的,不给使用者留任何替换的口子 */ function donateBox() { var box = document.createElement("div"); box.style.cssText = "width:100%;border:1px solid #B5D4F4;border-radius:11px;" + "padding:11px;text-align:center;background:#fff"; var img = document.createElement("img"); img.alt = DONATE_QR_LABEL; img.style.cssText = "width:100%;max-width:240px;border-radius:8px;display:block;" + "margin:0 auto;background:#F7FAFF"; var note = document.createElement("div"); note.textContent = "赞赏码显示不出来\n(这个页面限制了图片加载)"; note.style.cssText = "display:none;padding:34px 8px;border:1px dashed #B5D4F4;border-radius:8px;" + "background:#F7FAFF;color:#7A8798;font-size:11.5px;line-height:1.7;" + "white-space:pre-line"; var fail = function () { img.style.display = "none"; note.style.display = "block"; }; if (!DONATE_QR || donateCspBlocked) { /* 没内嵌图,或这个页面拦过 data: 图:直说显示不出来,不做任何兜底替换 */ fail(); } else { img.onerror = fail; /* 万一某个浏览器拦掉图时不派发 error,只把 naturalWidth 留成 0 */ img.onload = function () { if (!img.naturalWidth) fail(); }; img.src = DONATE_QR; /* 再兜一道:2 秒后还是 0 像素就认为没出来 */ setTimeout(function () { if (img.complete && img.naturalWidth === 0) fail(); }, 2000); } box.appendChild(img); box.appendChild(note); var name = document.createElement("div"); name.textContent = DONATE_QR_LABEL; name.style.cssText = "margin-top:8px;font-weight:500"; box.appendChild(name); return box; } function renderDonate() { if (!donateWrap) return; donateWrap.textContent = ""; donateWrap.appendChild(donateBox()); } function closeDonate() { if (donateEsc) { document.removeEventListener("keydown", donateEsc); donateEsc = null; } if (donateMask && donateMask.parentNode) { donateMask.parentNode.removeChild(donateMask); } donateMask = null; donateWrap = null; } function openDonate() { closeDonate(); if (!document.body) return; /* 与浮窗同为最大 z-index,靠「后插进 DOM」压在浮窗上面 */ var mask = document.createElement("div"); mask.style.cssText = "position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;" + "background:rgba(4,44,83,.42);display:flex;align-items:center;" + "justify-content:center;padding:14px"; mask.onclick = function (e) { if (e.target === mask) closeDonate(); }; var card = document.createElement("div"); card.style.cssText = "width:340px;max-width:100%;max-height:86vh;overflow:auto;background:#fff;" + "border-radius:14px;box-shadow:0 18px 50px rgba(4,44,83,.34);" + 'font:13px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;color:#042C53'; var head = document.createElement("div"); head.style.cssText = "padding:15px 17px;background:#0C447C;color:#fff"; var h = document.createElement("div"); h.textContent = "请作者喝杯蜜雪冰城"; h.style.cssText = "font-size:15px;font-weight:500"; var sub = document.createElement("div"); sub.textContent = "脚本免费,打赏只是让它能继续被维护"; sub.style.cssText = "margin-top:4px;font-size:11.5px;opacity:.88"; head.appendChild(h); head.appendChild(sub); var body = document.createElement("div"); body.style.cssText = "padding:15px 17px 17px"; var tip = document.createElement("div"); tip.textContent = "这不是付费墙 —— 所有功能对所有人开放,没有任何「打赏才能用」的东西。\n" + "不打算打赏也完全没关系,关掉这个框就行。"; tip.style.cssText = "padding:10px 12px;border-radius:9px;background:#E6F1FB;color:#0C447C;" + "font-size:12px;line-height:1.7;white-space:pre-line;margin-bottom:13px"; body.appendChild(tip); var wrap = document.createElement("div"); wrap.style.cssText = "display:flex;justify-content:center"; body.appendChild(wrap); var foot = document.createElement("div"); foot.style.cssText = "display:flex;justify-content:flex-end;margin-top:14px"; var close = donateBtn("关闭", "#0C447C", "#ffffff", "12px"); close.style.padding = "6px 14px"; close.onclick = function () { closeDonate(); }; foot.appendChild(close); /* 按钮行必须挂 body:只有 body 带 17px 内边距,挂 card 上会紧贴右下角 */ body.appendChild(foot); card.appendChild(head); card.appendChild(body); mask.appendChild(card); document.body.appendChild(mask); donateMask = mask; donateWrap = wrap; renderDonate(); donateEsc = function (e) { if (e.key === "Escape" || e.keyCode === 27) closeDonate(); }; document.addEventListener("keydown", donateEsc); } /* ================= 三·2、打断弹窗:评价 / 评分 / 验证 ================= */ /* 站点混用两套 UI 库:iView(.scoring-wrapper / .rate-item / .ivu-btn)和 Element (.el-dialog / .el-rate / .el-button),另外还有零散的「继续计时」提醒。 它们都表现为「视频停了 + 一个浮层」,不处理就会卡在「画面卡住 → 重试播放」的死循环里。 处理策略: 1. 只认带「评价 / 评分 / 问卷 / 满意度 / 验证」字样、**且有提交按钮或滑块**的浮层, 别的弹窗一概不碰(避免误点站点公告、广告); 2. 星级点最后一颗 = 满分;问卷单选优先「非常满意 / 满意 / 是」,没有就选第一个; 文本框填一句中性好评; 3. 验证交给下面「三·3」:滑块按人手轨迹拖、算式验证码直接算, 拼图滑块 / 未配接口的图片验证码才提示手动; 4. 同一个弹窗最多点 2 次、30 秒冷静期,防止和站点互相顶。 */ var EVAL_WORDS = /(评价|评语|评分|打分|问卷|满意度|反馈|验证|继续计时|学习情况)/; var EVAL_GOOD = /(非常满意|很满意|满意|符合|同意|已完成|学完)/; var EVAL_BAD = /(取消|关闭|暂不|跳过|以后再说|下次再说)/; var EVAL_COMMENT = "课程内容充实,讲解清晰,对教学很有帮助,收获很大。"; var EVAL_REC_KEY = "ak_eval"; var EVAL_MAX_TRIES = 2; var EVAL_QUIET_MS = 30 * 1000; /* 同一弹窗两次点击的最小间隔 */ var EVAL_HANDLED_MS = 8 * 1000; /* 刚处理过,这段时间内别重复进 */ var evalTimer = null; var evalBusy = false; var evalLastSig = ""; var evalLastTs = 0; function autoEvalOn() { return cfg("自动评价", true); } function elShown(el) { if (!el) return false; if ( el.style && (el.style.display === "none" || el.style.visibility === "hidden") ) return false; /* fixed 定位的浮层 offsetParent 也是 null,所以再用尺寸兜一道 */ if (el.offsetParent) return true; return !!(el.getClientRects && el.getClientRects().length); } /* 按优先级列浮层:先站点自己那几套,再通用的 dialog / modal, 最后是滑块 / 验证码这类可能不在弹窗里的独立浮层(仍要过关键词 + 有控件才动) */ var EVAL_LAYERS = [ ".scoring-wrapper", ".alarmClock-wrapper", ".el-dialog__wrapper", ".el-message-box__wrapper", ".ivu-modal-wrap", ".ant-modal-wrap", "[class*=modal-wrap]", "[class*=dialog-wrapper]", "[role=dialog]", "[class*=slider-verify]", "[class*=verify-wrap]", "[class*=verify-box]", "[class*=captcha]", ]; function evalLayer() { for (var i = 0; i < EVAL_LAYERS.length; i++) { var els = document.querySelectorAll(EVAL_LAYERS[i]); for (var j = 0; j < els.length; j++) { if (elShown(els[j])) return els[j]; } } return null; } function evalText(root) { return (root && root.textContent ? root.textContent : "") .replace(/\s+/g, " ") .trim(); } /* iView 的评分要先 mousemove 才有预览效果,所以这里两个事件都发 */ function evalClick(el) { if (!el) return false; try { el.dispatchEvent(new Event("mousemove", { bubbles: true })); } catch (e) {} try { el.dispatchEvent(new MouseEvent("mouseover", { bubbles: true })); } catch (e) {} try { el.click(); return true; } catch (e) { return false; } } /* 点最后一颗星 = 满星;返回星数(0 表示没找到评分控件) */ function evalStars(root) { var sels = [".rate-item", ".el-rate__item", ".ivu-rate-item"]; for (var i = 0; i < sels.length; i++) { var its = root.querySelectorAll(sels[i]); if (!its.length) continue; evalClick(its[its.length - 1]); return its.length; } var loose = root.querySelectorAll("[class*=star]"); if (loose.length) { evalClick(loose[loose.length - 1]); return loose.length; } return 0; } function elChecked(el) { var c = " " + (el.className || "") + " "; if (/ (is-checked|is-active|checked|active) /.test(c)) return true; var inp = el.querySelector && el.querySelector("input[type=radio],input[type=checkbox]"); return !!(inp && inp.checked); } /* 单选题:一组选项里挑「满意」类,没有就挑第一个;已选过的组不动。 多选按同样规则勾一个(问卷多选通常允许只选一项,全勾反而像刷)。 */ function evalChoices(root) { var sels = [ ".el-radio", ".ivu-radio-wrapper", ".ant-radio-wrapper", "[class*=radio-item]", "[class*=radio]", ]; var list = []; for (var i = 0; i < sels.length; i++) { var got = root.querySelectorAll(sels[i]); if (got.length) { list = [].slice.call(got); break; } } if (!list.length) return 0; /* 按父节点分组:同一个题目下的选项互为兄弟 */ var parents = []; var groups = []; for (var k = 0; k < list.length; k++) { var p = list[k].parentNode; var idx = parents.indexOf(p); if (idx < 0) { parents.push(p); groups.push([]); idx = groups.length - 1; } groups[idx].push(list[k]); } var picked = 0; for (var g = 0; g < groups.length; g++) { var grp = groups[g]; var done = false; for (var m = 0; m < grp.length; m++) { if (elChecked(grp[m])) { done = true; break; } } if (done) continue; var target = null; for (var n = 0; n < grp.length; n++) { if (EVAL_GOOD.test(evalText(grp[n]))) { target = grp[n]; break; } } if (!target) target = grp[0]; evalClick(target); picked++; } return picked; } function evalIsCaptchaField(el) { var ph = (el.getAttribute && el.getAttribute("placeholder")) || ""; var nm = (el.getAttribute && el.getAttribute("name")) || ""; return /验证码|验证|答案|captcha/i.test(ph + " " + nm); } /* 直接改 value 不会被 Vue / React 的双向绑定认到,必须走原型 setter 再派发 input */ function evalSetValue(el, val) { var proto = el.tagName === "TEXTAREA" ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; try { var d = Object.getOwnPropertyDescriptor(proto, "value"); if (d && d.set) d.set.call(el, val); else el.value = val; } catch (e) { el.value = val; } try { el.dispatchEvent(new Event("input", { bubbles: true })); } catch (e) {} try { el.dispatchEvent(new Event("change", { bubbles: true })); } catch (e) {} /* 有些组件只监听用户输入,补一个 keyup */ try { el.dispatchEvent(new KeyboardEvent("keyup", { bubbles: true })); } catch (e) {} } function evalFillText(root) { var areas = root.querySelectorAll( "textarea, .el-textarea__inner, input[type=text], input:not([type])", ); var filled = 0; for (var i = 0; i < areas.length; i++) { var el = areas[i]; if (!elShown(el)) continue; if (evalIsCaptchaField(el)) continue; /* 验证码不猜 */ if (el.readOnly || el.disabled) continue; if (String(el.value || "").trim()) continue; /* 填过的不覆盖 */ evalSetValue(el, EVAL_COMMENT); filled++; } return filled; } /* 找提交按钮:只认明确动作词,取消/关闭一律跳过 */ function evalSubmitBtn(root) { var btns = root.querySelectorAll( "button, .el-button, .ivu-btn, .ant-btn, [class*=btn]", ); var best = null; var bestRank = 99; var bestPrimary = false; for (var i = 0; i < btns.length; i++) { var b = btns[i]; if (!elShown(b)) continue; if (b.disabled) continue; var cls = " " + (b.className || "") + " "; if (/ (is-disabled|ivu-btn-disabled|ant-btn-disabled) /.test(cls)) continue; var t = evalText(b).slice(0, 10); if (!t || EVAL_BAD.test(t)) continue; var rank = 99; if (t.indexOf("提交") !== -1) rank = 0; else if (/^(确定|确认)/.test(t)) rank = 1; else if (/^(完成|保存)/.test(t)) rank = 2; else if (/^(下一步|继续|开始学习|我知道了|知道了|好的|OK)/i.test(t)) rank = 3; if (rank === 99) continue; var primary = /primary/.test(cls); if (rank < bestRank || (rank === bestRank && primary && !bestPrimary)) { best = b; bestRank = rank; bestPrimary = primary; } } return best; } /* 需要人来做的部分(验证码 / 滑块):先交给 capTrySolve 试,试不动再交回给人。 返回的字符串会直接拼进提示语,所以要短、要是名词。 */ function evalHumanReason(root) { if (capSlider(root)) return "滑块"; var t = evalText(root); if (/验证码|验证失败/.test(t)) return "验证码"; if (/拖动滑块|请拖动|短信验证|扫码验证|安全验证|滑块/.test(t)) return "验证"; if ( root.querySelector( "img[src*=captcha], [class*=captcha], [class*=slider-verify], [class*=verify-wrap], [class*=geetest]", ) ) return "验证"; return ""; } function evalSigOf(root) { return evalText(root).slice(0, 100); } function evalRec() { var r = GM_getValue(EVAL_REC_KEY); return r && typeof r === "object" ? r : {}; } function evalCanTry(sig) { var e = evalRec()[sig]; if (!e) return true; if (Date.now() - (e.ts || 0) > EVAL_QUIET_MS) return true; /* 过了冷静期重新计数 */ return (e.n || 0) < EVAL_MAX_TRIES; } function evalMark(sig) { var r = evalRec(); var now = Date.now(); var e = r[sig]; if (!e || now - (e.ts || 0) > EVAL_QUIET_MS) e = { n: 0, ts: now }; e.n = (e.n || 0) + 1; e.ts = now; r[sig] = e; /* 只留最近 5 条,别把本地存储塞满 */ var keys = []; for (var k in r) { if (Object.prototype.hasOwnProperty.call(r, k)) keys.push(k); } if (keys.length > 5) { keys.sort(function (a, b) { return (r[a].ts || 0) - (r[b].ts || 0); }); delete r[keys[0]]; } try { GM_setValue(EVAL_REC_KEY, r); } catch (err) {} } /* 返回 "done"(已处理)/ "human"(要人接手)/ ""(没东西可做) */ function handleEvalLayer() { if (!autoEvalOn()) return ""; if (evalBusy) return ""; if (Date.now() - evalLastTs < EVAL_HANDLED_MS) return ""; /* 「继续计时」提醒没有表单,点掉就行 —— 留着它会一直暂停视频 */ var alarm = document.querySelector(".alarmClock-wrapper"); if (alarm && elShown(alarm)) { evalClick(alarm); evalLastTs = Date.now(); logEvent("弹出了「继续计时」提醒,已点掉"); return "done"; } var root = evalLayer(); if (!root) return ""; var text = evalText(root); if (!EVAL_WORDS.test(text)) return ""; /* 没有提交按钮 → 多半不是评价表单,别乱点。 但滑块验证是「松手即校验」、根本没有提交按钮,所以有滑块也放行。 */ var submit = evalSubmitBtn(root); if (!submit && !capSlider(root)) return ""; var sig = evalSigOf(root); var human = evalHumanReason(root); if (human) { /* 先自己试试:滑块按人手轨迹拖、算式验证码直接算。 搞不定才交回给人,并把「为什么」写清楚,别让人猜。 */ if (!evalCanTry("cap:" + sig)) { if (evalLastSig !== sig) { evalLastSig = sig; setDetail( "这个" + human + "已经试了 " + EVAL_MAX_TRIES + " 次还没过,先停手了。\n" + "站点可能加了验证强度,请手动过一下再让它继续。", true, ); logEvent(human + "连试 " + EVAL_MAX_TRIES + " 次未过,转手动"); } return "human"; } var cap = capTrySolve(root); if (cap && cap.ok) { evalMark("cap:" + sig); evalLastSig = sig; evalLastTs = Date.now(); logEvent("碰到" + human + "," + cap.msg); setDetail("碰到" + human + ",脚本已自动处理:" + cap.msg + "\n视频马上接着播。", false); setTimeout(function () { try { tryPlay(); } catch (e) {} }, 1200); return "done"; } if (evalLastSig !== sig) { evalLastSig = sig; setDetail( "弹出了" + human + ",这个得你手动过一下。" + (cap && cap.msg ? "\n" + cap.msg : "") + "\n过完视频会接着播。", true, ); logEvent("弹出了" + human + ":" + ((cap && cap.msg) || "等你手动处理")); } return "human"; } if (!evalCanTry(sig)) { if (evalLastSig !== sig) { evalLastSig = sig; setDetail( "这个弹窗点了 " + EVAL_MAX_TRIES + " 次还没关掉,需要你看一眼。\n" + "多半是有必填项没填对,或者提交被站点驳回了。", true, ); logEvent("评价弹窗点 " + EVAL_MAX_TRIES + " 次仍未关闭,转为手动"); } return "human"; } evalBusy = true; try { var stars = evalStars(root); var picked = evalChoices(root); var filled = evalFillText(root); var btn = evalSubmitBtn(root); evalMark(sig); evalLastSig = sig; evalLastTs = Date.now(); if (btn) evalClick(btn); var what = []; if (stars) what.push("评分 " + stars + " 星(满)"); if (picked) what.push("问卷 " + picked + " 题"); if (filled) what.push("评语 " + filled + " 处"); logEvent( what.length ? "弹出了评价表单," + what.join(" / ") + ",已提交" : "弹出了提示框,已点掉", ); /* 视频多半就是被这个浮层顶停的,处理完主动推一把 */ setTimeout(function () { try { tryPlay(); } catch (e) {} }, 600); return "done"; } finally { evalBusy = false; } } function startEvalWatch() { if (evalTimer) clearInterval(evalTimer); evalTimer = setInterval(function () { try { if (!isRunning()) return; handleEvalLayer(); } catch (e) {} }, 2500); } /* ================= 三·3、验证:滑块自动拖动 / 验证码自动填写 ================= 站点拦人有两种: ① 拖动滑块(线性「拖到最右」,或拼图「把缺口对上」); ② 图文验证码(多半是「3 + 5 = ?」这种算式,偶尔是歪扭的字母数字图片)。 做法: · 线性滑块:按真人节奏推一条轨迹(先快后慢 + 轻微抖动 + 1px 回弹), pointer / mouse 事件都发,松手后交给站点自己的校验; · 算式验证码:直接把浮层文字里的算式算出来填进去(支持 + - × ÷ 和中文数字); · 图片验证码:只有填了「打码接口」才会去识别,否则不瞎猜; · 拼图缺口型滑块:不硬拖 —— 缺口位置得人眼找,拖错还涨验证强度,直接请你手动。 每个弹窗最多试 2 次(复用 EVAL_MAX_TRIES):拖错两次站点大概率会加难度或锁几分钟, 比手点一次慢得多,见好就收。 */ function capSliderOn() { return cfg("过滑块", true); } function capCodeOn() { return cfg("填验证码", true); } function capOcrUrl() { var v = cfgRaw("打码接口", ""); return typeof v === "string" ? v.trim() : ""; } var CAP_HANDLE_SEL = ".verify-move-block, .nc_iconfont.btn_slide, .geetest_slider_button, " + ".slider-btn, [class*=move-block], [class*=slide-btn], [class*=slider-btn], " + "[class*=sliderBtn], [class*=slider-handle], [class*=drag-handle], " + "[class*=verify-slider-block], [class*=btn-slide]"; /* 找滑块把手 + 量轨道长度;找不到返回 null */ function capSlider(root) { if (!root) return null; var sels = CAP_HANDLE_SEL.split(","); var handle = null; for (var i = 0; i < sels.length && !handle; i++) { var got = root.querySelectorAll(sels[i].trim()); for (var j = 0; j < got.length; j++) { if (elShown(got[j])) { handle = got[j]; break; } } } if (!handle) return null; /* 轨道就是把手的父节点:可用行程 = 轨道宽 - 把手宽 */ var track = handle.parentNode; var dist = 0; try { var tw = track.getBoundingClientRect().width; var hw = handle.getBoundingClientRect().width || 40; if (tw > hw) dist = tw - hw; } catch (e) {} return { handle: handle, track: track, dist: Math.round(dist) }; } /* 拼图型:两张图叠在一起让你对缺口,拖到底没用。 注意 querySelector 只查子孙,根节点自己的 class 得单独看。 */ function capIsJigsaw(root) { if (!root) return false; if (/jigsaw|puzzle|slice-img|verify-img-panel/i.test(String(root.className || ""))) return true; if ( root.querySelector( "[class*=jigsaw], [class*=puzzle], [class*=slice-img], [class*=verify-img-panel]", ) ) return true; return /拼图|缺口|拖动.*图/.test(evalText(root)); } /* 人手轨迹:ease-out + 抖动 + 收尾回弹 1px(不少前端会校验「全程匀速」) */ function capTrail(dist, steps) { var n = steps || 26; var out = []; for (var i = 1; i <= n; i++) { var t = i / n; var e = 1 - Math.pow(1 - t, 3); var x = dist * e; if (t < 0.96) x += (Math.random() - 0.5) * Math.min(3, Math.max(1, dist * 0.01)); out.push(Math.max(0, Math.min(dist, x))); } out.push(dist); out.push(dist - 1); /* 手一松一紧的回弹 */ out.push(dist); return out; } function capEv(target, type, x, y, buttons) { if (!target) return; var opt = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, screenX: x, screenY: y, button: 0, buttons: buttons, }; try { target.dispatchEvent(new MouseEvent(type, opt)); } catch (e) {} /* 有些组件只认 pointer 系列 */ try { if (typeof PointerEvent === "function") target.dispatchEvent(new PointerEvent(type, opt)); } catch (e) {} } /* 从把手按下,按轨迹一路发 move,最后松手 */ function capDrag(handle, dist, done) { if (!handle || !(dist > 0)) return false; var doc = handle.ownerDocument || document; var rect; try { rect = handle.getBoundingClientRect(); } catch (e) { return false; } var sx = rect.left + (rect.width || 40) / 2; var sy = rect.top + (rect.height || 30) / 2; var pts = capTrail(dist, 26); var i = 0; capEv(handle, "pointerdown", sx, sy, 1); capEv(handle, "mousedown", sx, sy, 1); function step() { if (i >= pts.length) { var ex = sx + dist; var ey = sy + (Math.random() - 0.5) * 2; capEv(doc, "pointermove", ex, ey, 1); capEv(doc, "mousemove", ex, ey, 1); capEv(doc, "pointerup", ex, ey, 0); capEv(doc, "mouseup", ex, ey, 0); capEv(handle, "pointerup", ex, ey, 0); capEv(handle, "mouseup", ex, ey, 0); if (typeof done === "function") { try { done(); } catch (e) {} } return; } var dx = pts[i++]; var x = sx + dx; var y = sy + (Math.random() - 0.5) * 1.5; capEv(doc, "pointermove", x, y, 1); capEv(doc, "mousemove", x, y, 1); capEv(handle, "mousemove", x, y, 1); setTimeout(step, 14 + Math.random() * 12); } setTimeout(step, 50); return true; } var CAP_CN_NUM = { 零: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10, }; function capCalc(a, op, b) { var r; if (op === "+" || op === "加") r = a + b; else if (op === "-" || op === "减") r = a - b; else if (op === "*" || op === "×" || op === "x" || op === "X" || op === "乘") r = a * b; else if (op === "/" || op === "÷" || op === "除") { if (!b) return ""; r = a / b; } else return ""; if (!isFinite(r)) return ""; r = Math.round(r * 100) / 100; return String(r); } /* 从浮层文字里读算式:3+5=?、12 - 4 = ?、五加三、8×7 */ function capArithmetic(text) { var t = String(text || "").replace(/\s+/g, ""); var m = /(-?\d+(?:\.\d+)?)([+\-×xX*÷/])(-?\d+(?:\.\d+)?)/.exec(t); if (m) return capCalc(parseFloat(m[1]), m[2], parseFloat(m[3])); var cn = /([零一二两三四五六七八九十])([加减乘除])([零一二两三四五六七八九十])/.exec(t); if (cn) { var a = CAP_CN_NUM[cn[1]]; var b = CAP_CN_NUM[cn[3]]; if (a !== undefined && b !== undefined) return capCalc(a, cn[2], b); } return ""; } function capCodeField(root) { if (!root) return null; var ins = root.querySelectorAll( "input[type=text], input:not([type]), input[type=tel], input[type=number]", ); var fallback = null; for (var i = 0; i < ins.length; i++) { var el = ins[i]; if (!elShown(el) || el.disabled || el.readOnly) continue; if (evalIsCaptchaField(el)) return el; if (!fallback && !String(el.value || "").trim()) fallback = el; } return fallback; } function capImageSrc(root) { if (!root) return ""; var im = root.querySelector( "img[src*=captcha], img[src*=code], img[src*=verify], [class*=captcha] img", ); return im && im.src ? String(im.src) : ""; } /* 打码接口(可选):POST {"image":"data:image/png;base64,..."}, 返回 {"code":"1234"} 或纯文本都认。没填地址就完全不联网。 */ function capOcr(src, cb) { var url = capOcrUrl(); if (!url || typeof fetch !== "function") return false; try { fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ image: src }), }) .then(function (r) { return r.text(); }) .then(function (t) { var code = ""; try { var j = JSON.parse(t); code = j.code || j.data || j.result || j.text || ""; } catch (e) { code = t; } code = String(code || "") .replace(/[^0-9a-zA-Z]/g, "") .slice(0, 8); cb(code); }) .catch(function () { cb(""); }); } catch (e) { return false; } return true; } /* 返回 {ok, kind, msg};ok=false 时 msg 是「为什么没自动过」的原因 */ function capTrySolve(root) { var out = { ok: false, kind: "", msg: "" }; if (!root) return out; /* ---- ① 滑块 ---- */ var s = capSlider(root); if (s) { if (!capSliderOn()) { out.msg = "「自动拖动滑块」关着,请手动拖一下。"; return out; } if (capIsJigsaw(root)) { out.msg = "这是拼图滑块,缺口位置得人眼对,脚本不硬拖(拖错会涨验证强度)。"; return out; } if (!s.dist || s.dist < 20) { out.msg = "滑块轨道量不出宽度,请手动拖一下。"; return out; } capDrag(s.handle, s.dist, function () { logEvent("滑块已拖到轨道末端," + s.dist + "px,等站点校验"); setTimeout(function () { try { tryPlay(); } catch (e) {} }, 1500); }); return { ok: true, kind: "slider", msg: "已按人手轨迹把滑块拖到底(" + s.dist + "px)", }; } /* ---- ② 验证码 ---- */ var field = capCodeField(root); if (field) { if (!capCodeOn()) { out.msg = "「自动填写验证码」关着,请手动输入。"; return out; } var ans = capArithmetic(evalText(root)); if (ans) { evalSetValue(field, ans); var sb = evalSubmitBtn(root); if (sb) evalClick(sb); return { ok: true, kind: "code", msg: "验证码是算式,已算出 " + ans + " 并提交" }; } var src = capImageSrc(root); if (src && capOcrUrl()) { var sent = capOcr(src, function (code) { if (!code) { setDetail( "打码接口没返回结果,请手动输入这张验证码。\n填好视频会接着播。", true, ); logEvent("打码接口没返回结果,转手动"); try { field.focus(); } catch (e) {} return; } evalSetValue(field, code); var b = evalSubmitBtn(root); if (b) evalClick(b); logEvent("验证码已由打码接口识别为 " + code + " 并提交"); setTimeout(function () { try { tryPlay(); } catch (e) {} }, 1500); }); if (sent) return { ok: true, kind: "code", msg: "验证码图片已发给打码接口,识别结果会自动填上", }; } try { field.focus(); } catch (e) {} out.msg = capOcrUrl() ? "这张验证码没识别出来,请手动输入(输入框已帮你点好)。" : "这是图片验证码,脚本认不出。填了「打码接口」才能自动识别;现在请手动输入一次(输入框已帮你点好)。"; return out; } out.msg = "没找到滑块也没找到输入框,请手动处理。"; return out; } var liveTimer = null; var liveReader = null; function startLiveRefresh(reader) { liveReader = reader; if (liveTimer) clearInterval(liveTimer); var tick = function () { try { setLive(reader()); } catch (e) {} }; tick(); liveTimer = setInterval(tick, 1000); } function refreshLive() { if (!liveReader) return; try { setLive(liveReader()); } catch (e) {} } /* 停掉实时区刷新 —— **站内(SPA)路由跳页时必须调它**。 每个 reader 认的是**某个页面**的结构;跳页后页面没重载,这个每秒的定时器还活着, 于是它拿新页面的 DOM 去跑旧页面的逻辑,把实时区刷成一片胡话。 实测:工作台 → myStudy 过渡页之后,实时区显示「板块 · 读不到考核要求」×4 —— 那 4 行是过渡页上 4 张 .tool-card.pointer 被当成工作台的板块卡片了。 */ function stopLiveRefresh() { if (liveTimer) clearInterval(liveTimer); liveTimer = null; liveReader = null; } /* ================= 二·2、学分(服务端算的,只能拉接口) ================= */ /* 学分接口在 ipx-api.yanxiu.com,不在页面这个域上;写成同源相对路径会被重定向回 HTML */ var CREDIT_API = "https://ipx-api.yanxiu.com/train-project-center/person/summary/statistics"; /* 网关只认 X-DT-accessToken(存在同名 cookie 里,JS 可读),缺了直接回「为空」 */ var CREDIT_CLIENT_ID = "ums-teacher-pc"; /* 这里原本还有一枚站点客户端的 Basic 凭证(CREDIT_BASIC),2026-09-24 删掉了。 依据:网关真正校验的是 X-DT-accessToken;实测 Basic 填对、填错、甚至不填, 在令牌这一关没过时响应逐字节相同。拿真登录态把 Basic 换成占位值跑过, 学分照常读得出来 —— 说明它本来就是多余的。 删它的原因:脚本要公开发布,带着第三方凭证会被 secret scanning / 爬虫盯上。 ⚠️ 以后万一学分读不出来,先查 X-DT-accessToken 这个 cookie 在不在,别再去找 Basic。 */ var creditCache = null; var creditLoading = false; var creditError = ""; var creditTimer = null; function readCookie(name) { var m = document.cookie.match( new RegExp("(?:^|;\\s*)" + name + "\\s*=\\s*([^;]*)"), ); return m ? m[1] : ""; } function distinctId() { try { for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (k && k.toLowerCase().indexOf("distinct") > -1) { return localStorage.getItem(k) || ""; } } } catch (e) {} return ""; } function creditHeaders() { return { "Content-Type": "application/json", /* 占位值。这枚 Basic 网关并不校验(原来是站点客户端的凭证,已删)。 保留这个头而不是整行删掉:实测过「换个值照样能用」, 但没测过「这个头完全不发」—— 留着最省事,也不泄露任何东西。 */ Authorization: "Basic x", "X-DT-clientId": CREDIT_CLIENT_ID, "X-DT-accessToken": readCookie("X-DT-accessToken"), "X-DT-Passport": readCookie("passport"), srxUserInfo: readCookie("srxUserInfo") || "distinctId=" + distinctId(), }; } /* 这个接口成功失败都回 HTTP 200,错误写在 body 的 status 里 */ function pickCredits(j) { if (!j) return null; if (j.toolExamineExternDTOList) return j; if (j.data && j.data.toolExamineExternDTOList) return j.data; return null; } function apiErrorText(j) { var s = j && j.status; if (s && s.desc) return String(s.desc); if (s && s.code) return "接口错误码 " + s.code; return ""; } function fmt2(v) { var n = Number(v); if (!isFinite(n)) return "-"; return (Math.round(n * 100) / 100).toFixed(2).replace(/\.?0+$/, ""); } /* 响应里带着 userId / 姓名 / 学校等,脚本一个都用不上,一律不落盘(分享 .storage.json 会漏) */ function trimScore(s) { if (!s) return s; return { score: s.score, fullScore: s.fullScore, passScore: s.passScore, }; } function trimCredits(d) { if (!d) return null; return { webScoreOpen: d.webScoreOpen, managerWebScoreOpen: d.managerWebScoreOpen, f2fScoreOpen: d.f2fScoreOpen, netPassState: d.netPassState, managerWebPassState: d.managerWebPassState, f2fPassState: d.f2fPassState, mustFinishTask: d.mustFinishTask, netScore: trimScore(d.netScore), managerWebScore: trimScore(d.managerWebScore), f2fScore: trimScore(d.f2fScore), /* 只留个标记:脚本从不读它的内容,只拿它判断「这确实是一份学分数据」 */ toolExamineExternDTOList: [], }; } function readCredits() { if (creditCache && creditCache.data) return creditCache; var c = GM_getValue("ak_credits"); if (c && c.data && c.data.toolExamineExternDTOList) { var slim = trimCredits(c.data); /* 旧版本存的是整包响应,顺手洗一遍覆盖回去,别让多余字段一直留在磁盘上 */ if (Object.keys(c.data).length !== Object.keys(slim).length) { c = { ts: c.ts, data: slim }; try { GM_setValue("ak_credits", c); } catch (e) {} } creditCache = c; return c; } return null; } /* 学分是分项的:学员看 netScore,管理/辅导看 managerWebScore,面授单列;开关是 xxxScoreOpen */ function creditSections(d) { if (!d) return []; var role = pageRole() || "MEMBER"; var isMember = role === "MEMBER"; var out = []; /* 非学员身份下,管理/辅导任务和网络学习是两个独立的学分桶 */ if (!isMember && d.webScoreOpen && d.netScore) { out.push({ name: role === "BAR_MANAGER" ? "辅导任务" : "管理任务", score: d.netScore, }); } var webOpen = isMember ? d.webScoreOpen : d.managerWebScoreOpen; var webScore = isMember ? d.netScore : d.managerWebScore; if (webOpen && webScore) out.push({ name: "网络学习", score: webScore }); if (d.f2fScoreOpen && d.f2fScore) { out.push({ name: "集中面授", score: d.f2fScore }); } return out; } function sectionText(sec) { var s = sec.score || {}; var got = Number(s.score || 0), max = Number(s.fullScore || 0), need = Number(s.passScore || 0); var text = sec.name + " " + fmt2(got) + " / " + fmt2(max); if (got >= need) return text + " · 已达标"; return text + " · 未达标(还差 " + fmt2(need - got) + ")"; } /* 单行版给日志和提示用,同时当「学分有没有变」的指纹 —— 每项都要带,只比总分漏面授 */ function creditText(d) { var secs = creditSections(d); var out = []; for (var i = 0; i < secs.length; i++) { var s = secs[i].score || {}; out.push(secs[i].name + " " + fmt2(s.score) + "/" + fmt2(s.fullScore)); } return out.join(" · "); } function creditScoreText() { var c = readCredits(); return c ? creditText(c.data) : ""; } function saveCredits(d) { if (!d || !d.toolExamineExternDTOList) return false; var slim = trimCredits(d); var changed = creditText(slim) !== creditScoreText(); creditCache = { ts: Date.now(), data: slim }; creditError = ""; try { GM_setValue("ak_credits", creditCache); } catch (e) {} return changed; } function creditLine() { var c = readCredits(); if (!c) return creditError ? "学分 读取失败:" + creditError : "学分 正在读取…"; var d = c.data; var secs = creditSections(d); if (!secs.length) { var anyOpen = d.webScoreOpen || d.f2fScoreOpen || d.managerWebScoreOpen; return anyOpen ? "学分 暂时没有数据" : "学分 这个项目不显示学分"; } var lines = []; for (var i = 0; i < secs.length; i++) { lines.push((i === 0 ? "学分 " : "  ") + sectionText(secs[i])); } return lines.join("\n"); } function fetchCredits(force) { return new Promise(function (resolve) { var pid = pageProjectId(); if (!pid) { resolve(null); return; } var c = readCredits(); if (!force && c && Date.now() - c.ts < 60000) { resolve(c.data); return; } if (creditLoading) { resolve(c ? c.data : null); return; } creditLoading = true; var W = typeof unsafeWindow !== "undefined" && unsafeWindow ? unsafeWindow : window; /* 站点是按「当前角色」查的;写死 MEMBER 会让非学员账号查到别人的视图 */ var role = pageRole() || "MEMBER"; var url = CREDIT_API + "?trainProjectId=" + encodeURIComponent(pid) + "&roleLevel=" + encodeURIComponent(role) + "&userRole=" + encodeURIComponent(role); var finish = function (d, err) { creditLoading = false; var changed = saveCredits(d); if (changed) logEvent("学分更新:" + creditScoreText()); else if (!d) creditError = err || "接口没返回学分字段"; refreshLive(); resolve(d || null); }; if (!readCookie("X-DT-accessToken")) { /* 没令牌就别白发了,省得只换回一句看不懂的接口报错 */ finish( null, "没读到登录令牌(cookie X-DT-accessToken),先刷新一次页面再试", ); return; } try { W.fetch(url, { credentials: "include", headers: creditHeaders() }) .then(function (r) { var ct = r.headers.get("content-type") || ""; if (ct.indexOf("json") === -1) { throw new Error( "接口返回的不是数据(HTTP " + r.status + "," + (ct || "没有 content-type") + ")", ); } return r.json(); }) .then(function (j) { var d = pickCredits(j); if (d) { finish(d, ""); return; } var why = apiErrorText(j); finish(null, why ? "接口说:" + why : "接口没返回学分字段"); }) .catch(function (e) { finish(null, e && e.message ? e.message : String(e)); }); } catch (e) { finish(null, e && e.message ? e.message : String(e)); } }); } /* 顺手截获页面自己发的那次学分请求;站点用 axios 走 XHR,只钩 fetch 是白钩,两个都钩 */ (function hookCreditApi() { try { var W = typeof unsafeWindow !== "undefined" && unsafeWindow ? unsafeWindow : window; function absorb(text) { if (!text || text.indexOf("toolExamineExternDTOList") === -1) return; var j; try { j = JSON.parse(text); } catch (e) { return; } var d = pickCredits(j); if (!d) return; if (saveCredits(d)) logEvent("学分更新:" + creditScoreText()); refreshLive(); } var XP = W.XMLHttpRequest && W.XMLHttpRequest.prototype; if (XP && XP.open && XP.send) { var rawOpen = XP.open, rawSend = XP.send; XP.open = function (method, url) { try { this.__akCreditUrl = String(url || "").indexOf("person/summary/statistics") > -1; } catch (e) {} return rawOpen.apply(this, arguments); }; XP.send = function () { var xhr = this; try { if (xhr.__akCreditUrl) { xhr.addEventListener("load", function () { try { var rt = xhr.responseType; if (!rt || rt === "text") absorb(xhr.responseText); } catch (e) {} }); } } catch (e) {} return rawSend.apply(this, arguments); }; } var rawFetch = W.fetch; if (typeof rawFetch === "function") { W.fetch = function (input) { var url = typeof input === "string" ? input : (input && input.url) || ""; var p = rawFetch.apply(this, arguments); if (url.indexOf("person/summary/statistics") === -1) return p; try { p.then(function (r) { try { r.clone() .text() .then(absorb) .catch(function () {}); } catch (e) {} }).catch(function () {}); } catch (e) {} return p; }; } } catch (e) {} })(); function startCreditRefresh() { fetchCredits(false); if (creditTimer) clearInterval(creditTimer); creditTimer = setInterval(function () { fetchCredits(false); }, 60000); } /* ================= 二·3、运行开关(启动 / 停止) ================= 停止不是「关掉一个变量」这么简单:工作台、课程列表、看课页各有一套循环, 每套循环在每个检查点都会问一次 halted(),发现停了就**原地退出、不点任何东西**。 这样「停止」才是真的停手,而不是等你刷新页面才生效。 ================================================================ */ var RUN_KEY = "ak_run"; /* 从没设置过就算「运行中」—— 老用户升级上来不该突然变成停止状态 */ function isRunning() { var v = GM_getValue(RUN_KEY); return v === undefined || v === null ? true : !!v; } function setRunning(on) { try { GM_setValue(RUN_KEY, !!on); } catch (e) {} } /* 所有长循环的统一检查点 */ function halted() { return !isRunning(); } /* 拆掉浮窗:关掉「显示状态浮窗」时用,buildHud 会因为它不在了而重建 */ function destroyHud() { stopLiveRefresh(); if (HUD.root && HUD.root.parentNode) { HUD.root.parentNode.removeChild(HUD.root); } HUD.root = null; HUD.dot = null; HUD.line = null; HUD.head = null; HUD.body = null; HUD.arrow = null; HUD.live = null; HUD.log = null; HUD.detail = null; HUD.toast = null; HUD.runBtn = null; } /* 静音 / 倍速改动后立刻作用在当前视频上,不用等下一次刷新 */ function applyVideoPrefs() { var v = findVideo(); if (!v) return; try { if (isMuted()) v.muted = true; if (speedEnabled()) v.playbackRate = 2; else if (v.playbackRate && v.playbackRate !== 1) v.playbackRate = 1; } catch (e) {} } function stopWork(why) { setRunning(false); stopLiveRefresh(); if (creditTimer) { clearInterval(creditTimer); creditTimer = null; } if (rescanTimer) { clearTimeout(rescanTimer); rescanTimer = null; } endWatch(); try { var v = findVideo(); if (v && !v.paused) v.pause(); } catch (e) {} setStatus("已停止 · 不会自动操作", "idle"); setDetail( "脚本已停止:不会自动开课、不会自动跳页,正在播的视频也暂停了。\n" + "设置不会丢,点「启动」接着跑。", ); logEvent("已停止" + (why ? "(" + why + ")" : "")); refreshRunUi(); } function startWork() { if (isRunning()) return; setRunning(true); setDetail(""); logEvent("已启动,接着自动学习"); refreshRunUi(); /* 「停止」后旧流程最多还要几秒才退干净(它在 sleep / 等心跳)。 这段时间里 flowBusy 还是 true,直接派活会被忽略 —— 所以隔一会儿再试几次, 不然用户会看到「点了启动却没动静」。 */ var tries = 0; var kick = function () { if (!isRunning()) return; if (flowBusy && tries < 6) { tries++; setTimeout(kick, 1200); return; } dispatchFlow().catch(reportFatal); }; kick(); } function reportFatal(e) { var msg = e && e.message ? e.message : String(e); LOG("出错:" + msg); setStatus("出了点问题,已停止", "error"); setError( msg + "\n\n点「重新开始」刷新页面再试一次;\n还不行就点「复制状态」发给开发者。", ); logEvent("出错停下:" + msg.slice(0, 40)); endWatch(); } /* 浮窗和弹窗上的运行状态一起刷 —— 两边显示的东西必须是同一份事实 */ function refreshRunUi() { var on = isRunning(); if (HUD.runBtn) { HUD.runBtn.textContent = on ? "⏸ 停止" : "▶ 启动"; HUD.runBtn.style.cssText = btnStyle( on ? "#FCEBEB" : "#E6F6EE", on ? "#A32D2D" : "#0F6E4A", ); } if (setStateDot) setStateDot.style.background = on ? "#1D9E75" : "#8A94A6"; if (setStateTxt) { setStateTxt.textContent = on ? "运行中" : "已停止"; setStateTxt.style.color = on ? "#0F6E4A" : "#5F5E5A"; } if (setRunBtn) { setRunBtn.textContent = on ? "■ 停止脚本" : "▶ 启动脚本"; setRunBtn.style.background = on ? "#E24B4A" : "#1D9E75"; } } /* ================= 二·4、设置弹窗 ================= 交互约定(一句话版): · 弹窗里改的每一项都只写在「草稿」上,点「保存并应用」才落到本地存储; · 「取消」/ 按 Esc / 点遮罩 = 什么都不写,关掉前什么样,关掉后还是什么样; · 「启动 / 停止」是即时动作,不走草稿、关掉弹窗也不会被回滚; · 时长只在「自定义上限」时生效,切回「跟随课程」时输入框变灰但保留数字, 下次切回来还是原值(不会因为切模式把用户填的数字清掉)。 ================================================== */ var setMask = null; var setEsc = null; var setRunBtn = null; var setStateDot = null; var setStateTxt = null; function draftFromStore() { return { mode: watchMode(), minutes: watchLimitMin(), mute: isMuted(), speed: speedEnabled(), hud: hudEnabled(), autoNext: autoNextAfterPass(), autoPlay: autoPlayAfterPass(), autoEval: autoEvalOn(), slider: capSliderOn(), code: capCodeOn(), ocr: capOcrUrl(), }; } function draftSummary(d) { var out = []; out.push(d.mode === "fixed" ? "每门最多 " + d.minutes + " 分钟" : "播到站点提示已完成"); out.push(d.autoNext ? "达标后自动续播" : "达标后停下"); out.push(d.autoPlay ? "全达标后继续连播" : "全达标后停下"); if (d.autoEval) out.push("自动提交评价"); if (d.slider) out.push("自动拖滑块"); if (d.code) out.push("自动填验证码"); if (d.ocr) out.push("已配打码接口"); if (d.mute) out.push("静音"); if (d.speed) out.push("2 倍速"); if (!d.hud) out.push("不显示浮窗"); return out.join(" · "); } function applyDraft(d) { GM_setValue("设置.时长模式", d.mode === "fixed" ? "fixed" : "full"); GM_setValue("设置.时长分钟", d.minutes); GM_setValue("设置.静音", !!d.mute); GM_setValue("设置.倍速", !!d.speed); GM_setValue("设置.达标续播", !!d.autoNext); GM_setValue("设置.连播", !!d.autoPlay); GM_setValue("设置.自动评价", !!d.autoEval); GM_setValue("设置.过滑块", !!d.slider); GM_setValue("设置.填验证码", !!d.code); GM_setValue("设置.打码接口", String(d.ocr || "").trim()); /* 浮窗要特殊处理:关掉得真把 DOM 拆了,打开得重建 */ GM_setValue("设置.浮窗", !!d.hud); if (d.hud) buildHud(); else destroyHud(); applyVideoPrefs(); refreshRunUi(); } function setLabel(text) { var el = document.createElement("div"); el.textContent = text; el.style.cssText = "font-weight:500;font-size:12.5px;color:#0C447C;margin:16px 0 7px"; return el; } function setNote(text) { var el = document.createElement("div"); el.textContent = text; el.style.cssText = "font-size:11.5px;color:#7A8798;line-height:1.6;margin-top:6px;white-space:pre-line"; return el; } /* 单选行:value 变了就调 onPick,选中态靠左边那个圆点表示 */ function setRadio(name, value, title, desc, current, onPick) { var row = document.createElement("label"); var active = current === value; row.style.cssText = "display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border-radius:9px;" + "border:1px solid " + (active ? "#378ADD" : "#E6F1FB") + ";background:" + (active ? "#F2F8FF" : "#fff") + ";margin-bottom:7px;cursor:pointer"; var box = document.createElement("input"); box.type = "radio"; box.name = name; box.value = value; box.checked = active; box.style.cssText = "margin:3px 0 0;flex:0 0 auto;cursor:pointer"; box.onchange = function () { if (box.checked) onPick(value); }; var wrap = document.createElement("div"); var t = document.createElement("div"); t.textContent = title; t.style.cssText = "font-size:12.5px;font-weight:500;color:#042C53"; var dd = document.createElement("div"); dd.textContent = desc; dd.style.cssText = "font-size:11.5px;color:#7A8798;line-height:1.55;margin-top:2px"; wrap.appendChild(t); wrap.appendChild(dd); row.appendChild(box); row.appendChild(wrap); return row; } /* 复选行:整行可点,描述写在标题下面 */ function setCheck(title, desc, checked, onToggle) { var row = document.createElement("label"); row.style.cssText = "display:flex;gap:9px;align-items:flex-start;padding:9px 10px;border-radius:9px;" + "border:1px solid #E6F1FB;background:#F7FAFF;margin-bottom:7px;cursor:pointer"; var box = document.createElement("input"); box.type = "checkbox"; box.checked = !!checked; box.style.cssText = "margin:3px 0 0;flex:0 0 auto;width:15px;height:15px;cursor:pointer"; box.onchange = function () { onToggle(box.checked); }; var wrap = document.createElement("div"); var t = document.createElement("div"); t.textContent = title; t.style.cssText = "font-size:12.5px;font-weight:500;color:#042C53"; var dd = document.createElement("div"); dd.textContent = desc; dd.style.cssText = "font-size:11.5px;color:#7A8798;line-height:1.55;margin-top:2px"; wrap.appendChild(t); wrap.appendChild(dd); row.appendChild(box); row.appendChild(wrap); return row; } /* 单行文本设置:用在「打码接口」这种可选地址上,留空 = 不启用 */ function setText(title, desc, value, placeholder, onInput) { var row = document.createElement("div"); row.style.cssText = "padding:9px 10px;border-radius:9px;border:1px solid #E6F1FB;" + "background:#F7FAFF;margin-bottom:7px"; var t = document.createElement("div"); t.textContent = title; t.style.cssText = "font-size:12.5px;font-weight:500;color:#042C53"; var inp = document.createElement("input"); inp.type = "text"; inp.value = value || ""; inp.placeholder = placeholder || ""; inp.style.cssText = "width:100%;box-sizing:border-box;margin-top:6px;padding:6px 8px;border-radius:7px;" + 'border:1px solid #B5D4F4;font:12px -apple-system,"PingFang SC",sans-serif;' + "color:#042C53;background:#fff;outline:none"; inp.oninput = function () { onInput(inp.value); }; var dd = document.createElement("div"); dd.textContent = desc; dd.style.cssText = "font-size:11.5px;color:#7A8798;line-height:1.55;margin-top:5px"; row.appendChild(t); row.appendChild(inp); row.appendChild(dd); return row; } function setActionBtn(text, bg, fg, size) { var b = document.createElement("button"); b.textContent = text; b.style.cssText = "padding:7px 14px;border:0;border-radius:9px;background:" + bg + ";color:" + fg + ";font:" + (size || "12.5px") + ' -apple-system,"PingFang SC",sans-serif;cursor:pointer;white-space:nowrap'; return b; } function closeSettings() { if (setEsc) { document.removeEventListener("keydown", setEsc); setEsc = null; } if (setMask && setMask.parentNode) setMask.parentNode.removeChild(setMask); setMask = null; setRunBtn = null; setStateDot = null; setStateTxt = null; } function openSettings() { closeSettings(); if (!document.body) return; var d = draftFromStore(); var minutesInput = null; var modeRowFull = null; var modeRowFixed = null; var minRow = null; var preview = null; var mask = document.createElement("div"); mask.style.cssText = "position:fixed;left:0;top:0;right:0;bottom:0;z-index:2147483647;" + "background:rgba(4,44,83,.42);display:flex;align-items:center;" + "justify-content:center;padding:14px"; mask.onclick = function (e) { if (e.target === mask) closeSettings(); }; var card = document.createElement("div"); card.style.cssText = "width:372px;max-width:100%;max-height:88vh;overflow:auto;background:#fff;" + "border-radius:14px;box-shadow:0 18px 50px rgba(4,44,83,.34);" + 'font:13px/1.65 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif;color:#042C53'; /* ---- 标题栏 ---- */ var head = document.createElement("div"); head.style.cssText = "display:flex;align-items:center;gap:7px;padding:13px 16px;background:#0C447C;color:#fff"; var hTitle = document.createElement("div"); hTitle.textContent = "自动看课 · 设置"; hTitle.style.cssText = "font-size:14.5px;font-weight:500"; var hVer = document.createElement("span"); hVer.textContent = "v" + VERSION; hVer.style.cssText = "font-size:11px;opacity:.78"; var hClose = document.createElement("button"); hClose.textContent = "×"; hClose.title = "关闭(Esc)"; hClose.style.cssText = "margin-left:auto;background:none;border:0;color:#fff;font-size:20px;" + "line-height:1;cursor:pointer;padding:0 2px"; hClose.onclick = function () { closeSettings(); }; head.appendChild(hTitle); head.appendChild(hVer); head.appendChild(hClose); var body = document.createElement("div"); body.style.cssText = "padding:14px 16px 16px"; /* ---- 运行状态 ---- */ var runBox = document.createElement("div"); runBox.style.cssText = "display:flex;align-items:center;gap:9px;padding:10px 12px;border-radius:10px;" + "background:#F2F8FF;border:1px solid #B5D4F4"; var runDot = document.createElement("span"); runDot.style.cssText = "width:9px;height:9px;border-radius:50%;background:#1D9E75;flex:0 0 auto"; var runTxt = document.createElement("div"); runTxt.textContent = "运行中"; runTxt.style.cssText = "flex:1;font-weight:500;font-size:12.5px"; var runBtn = setActionBtn("■ 停止脚本", "#E24B4A", "#ffffff"); runBtn.onclick = function () { if (isRunning()) stopWork("手动停止"); else startWork(); refreshRunUi(); }; runBox.appendChild(runDot); runBox.appendChild(runTxt); runBox.appendChild(runBtn); body.appendChild(runBox); body.appendChild( setNote( "停止后立刻停手:不自动开课、不自动跳页,正在播的视频会暂停。\n" + "启动会按当前页面继续(工作台 → 挑下一门;看课页 → 接着播)。", ), ); /* ---- 观看时长 ---- */ body.appendChild(setLabel("一、每门课看多久")); var syncMinRow = function () { var fixed = d.mode === "fixed"; if (minRow) { minRow.style.opacity = fixed ? "1" : ".5"; minRow.style.pointerEvents = fixed ? "auto" : "none"; } if (minutesInput) minutesInput.disabled = !fixed; if (preview) { preview.textContent = fixed ? "当前:每门最多看 " + d.minutes + " 分钟,到点自动换下一门" : "当前:一直播到站点提示「您已完成」"; } }; var repaintMode = function () { var fixed = d.mode === "fixed"; modeRowFull.style.borderColor = fixed ? "#E6F1FB" : "#378ADD"; modeRowFull.style.background = fixed ? "#fff" : "#F2F8FF"; modeRowFixed.style.borderColor = fixed ? "#378ADD" : "#E6F1FB"; modeRowFixed.style.background = fixed ? "#F2F8FF" : "#fff"; syncMinRow(); }; modeRowFull = setRadio( "ak_dur", "full", "跟随课程", "一直播到站点提示「您已完成」(推荐)", d.mode, function (v) { d.mode = v; repaintMode(); }, ); modeRowFixed = setRadio( "ak_dur", "fixed", "自定义上限", "每门最多看这么多分钟,到点自动换下一门", d.mode, function (v) { d.mode = v; repaintMode(); }, ); body.appendChild(modeRowFull); body.appendChild(modeRowFixed); /* 数字输入单独一行,跟两个单选分开,免得看着像单选的一部分 */ minRow = document.createElement("div"); minRow.style.cssText = "display:flex;align-items:center;gap:8px;padding:9px 10px;border-radius:9px;" + "background:#F7FAFF;border:1px solid #E6F1FB"; var minLabel = document.createElement("span"); minLabel.textContent = "上限"; minLabel.style.cssText = "font-size:12.5px;color:#042C53"; minutesInput = document.createElement("input"); minutesInput.type = "number"; minutesInput.min = String(DUR_MIN_LOW); minutesInput.max = String(DUR_MIN_HIGH); minutesInput.step = "1"; minutesInput.value = String(d.minutes); minutesInput.style.cssText = "width:72px;padding:4px 7px;border:1px solid #B5D4F4;border-radius:7px;" + "font:13px -apple-system,\"PingFang SC\",sans-serif;color:#042C53;background:#fff"; minutesInput.oninput = function () { var n = Number(minutesInput.value); if (isFinite(n) && n >= DUR_MIN_LOW) { d.minutes = Math.min(DUR_MIN_HIGH, Math.round(n)); if (preview) { preview.textContent = "当前:每门最多看 " + d.minutes + " 分钟,到点自动换下一门"; } } }; /* 失焦时把非法值拉回合法区间,别让用户带着 "0" 或 "-3" 点保存 */ minutesInput.onblur = function () { var n = Number(minutesInput.value); if (!isFinite(n) || n < DUR_MIN_LOW) n = DUR_MIN_DEFAULT; d.minutes = Math.min(DUR_MIN_HIGH, Math.max(DUR_MIN_LOW, Math.round(n))); minutesInput.value = String(d.minutes); syncMinRow(); }; var minUnit = document.createElement("span"); minUnit.textContent = "分钟 / 门(" + DUR_MIN_LOW + " – " + DUR_MIN_HIGH + ")"; minUnit.style.cssText = "font-size:11.5px;color:#7A8798"; minRow.appendChild(minLabel); minRow.appendChild(minutesInput); minRow.appendChild(minUnit); body.appendChild(minRow); preview = setNote(""); body.appendChild(preview); /* ---- 行为开关 ---- */ body.appendChild(setLabel("二、行为")); body.appendChild( setCheck( "一门课达标后自动续播下一门", "推荐开启。站点提示「您已完成」后就自动回列表挑下一门,全程不用你点。\n关掉的话,每看完一门就停下来等你点「启动」。", d.autoNext, function (v) { d.autoNext = v; }, ), ); body.appendChild( setCheck( "必修、选修都达标后继续连播", "开启后就是「自动学习」:必修和选修都达标了也不停,把没看完的课一门门接着播完。\n关掉的话,全达标就收工。", d.autoPlay, function (v) { d.autoPlay = v; }, ), ); body.appendChild( setCheck( "自动提交评价 / 评分(给满分)", "看课中途弹出课程评价、问卷、评分或「验证」浮层时,自动给满分、填好评语并提交,让视频接着播。", d.autoEval, function (v) { d.autoEval = v; }, ), ); body.appendChild( setCheck( "自动拖动滑块", "验证滑块按人手轨迹拖到底(先快后慢 + 抖动 + 回弹),只处理「拖到最右」这种线性滑块。\n拼图缺口型不硬拖 —— 缺口得人眼对,拖错还会涨验证强度。", d.slider, function (v) { d.slider = v; }, ), ); body.appendChild( setCheck( "自动填写验证码", "验证码是算式(3+5=?、八减三)时直接算出来填上并提交。\n图片验证码要靠下面的打码接口才认得出来,没配就提示你手输。", d.code, function (v) { d.code = v; }, ), ); body.appendChild( setText( "打码接口(可选,留空则永不联网)", '填 HTTP 地址后,图片验证码会 POST {"image":"data:image/png;base64,..."} 过去,接口回 {"code":"1234"} 或纯文本都认。\n不填的话脚本不会发任何外部请求。', d.ocr, "https://your-ocr.example.com/solve", function (v) { d.ocr = v; }, ), ); body.appendChild( setCheck( "自动静音", "建议保持开启。浏览器不允许带声音的页面自动播放,关掉可能播不起来。", d.mute, function (v) { d.mute = v; }, ), ); body.appendChild( setCheck( "2 倍速播放", "这个网站不允许倍速(播放器里压根没有倍速选项),保持关闭。", d.speed, function (v) { d.speed = v; }, ), ); body.appendChild( setCheck( "显示右下角状态浮窗", "显示实时进度和「最近做了什么」。关掉后想再打开,用脚本管理器菜单里的「打开设置」。", d.hud, function (v) { d.hud = v; }, ), ); /* ---- 底部按钮 ---- */ var foot = document.createElement("div"); foot.style.cssText = "display:flex;justify-content:flex-end;gap:8px;margin-top:16px"; var cancel = setActionBtn("取消", "#F1EFE8", "#5F5E5A"); cancel.onclick = function () { closeSettings(); showToast("已取消,设置没有改动"); }; var save = setActionBtn("保存并应用", "#0C447C", "#ffffff"); save.onclick = function () { if (d.mode === "fixed") { var n = Number(minutesInput.value); if (!isFinite(n) || n < DUR_MIN_LOW || n > DUR_MIN_HIGH) { showToast( "观看上限要填 " + DUR_MIN_LOW + " – " + DUR_MIN_HIGH + " 之间的整数", ); minutesInput.focus(); return; } d.minutes = Math.round(n); } applyDraft(d); closeSettings(); showToast("设置已保存:" + draftSummary(d)); logEvent("设置已更新:" + draftSummary(d)); }; foot.appendChild(cancel); foot.appendChild(save); body.appendChild(foot); card.appendChild(head); card.appendChild(body); mask.appendChild(card); document.body.appendChild(mask); setMask = mask; setRunBtn = runBtn; setStateDot = runDot; setStateTxt = runTxt; repaintMode(); refreshRunUi(); setEsc = function (e) { if (e.key === "Escape" || e.keyCode === 27) closeSettings(); }; document.addEventListener("keydown", setEsc); } /* ================= 三、小工具 ================= */ function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } function waitFor(selectors, timeoutMs, what) { return new Promise(function (resolve, reject) { var t0 = Date.now(); var timer = setInterval(function () { for (var i = 0; i < selectors.length; i++) { var els = document.querySelectorAll(selectors[i]); if (els.length) { clearInterval(timer); resolve(els); return; } } if (Date.now() - t0 > timeoutMs) { clearInterval(timer); reject(new Error("没有找到" + what)); } }, 400); }); } function pageProjectId() { var m = location.pathname.match(/\/train2\/workspace\/([^/]+)/); if (m) return m[1]; var q = location.search.match(/[?&]projectId=([^&]+)/); return q ? decodeURIComponent(q[1]) : ""; } /* 角色名只可能是路径的**最后一段**(/workspace/<项目>/member); 中间那些段是路由词(course / myStudy / list …)。 旧写法抓的是第一段,于是 /course/list/member 会得出 "COURSE", 学分接口就按一个不存在的角色去查了。 */ function pageRole() { var es = location.search.match(/[?&]examineSubstance=([^&]+)/); if (es) { var ev = decodeURIComponent(es[1]); if (/^[A-Za-z]/.test(ev)) return ev.toUpperCase(); } var seg = location.pathname.replace(/\/+$/, "").split("/").pop() || ""; if (/^[A-Za-z_]+$/.test(seg) && seg.length <= 16) return seg.toUpperCase(); var q = location.search.match(/[?&]role=([^&]+)/); var v = q ? decodeURIComponent(q[1]) : ""; return /^[A-Za-z]/.test(v) ? v.toUpperCase() : "MEMBER"; } /* ================= 四、跨标签页协调 ================= */ function beginWatch() { GM_setValue("ak_watching", true); GM_setValue("ak_beat", Date.now()); } var lastBackTouch = 0; function watchHeartbeat() { if (GM_getValue("ak_watching")) { GM_setValue("ak_beat", Date.now()); /* 看课页也算「在干活」:顺手续时间戳,否则看完被甩去项目首页时脚本不肯自己跳回来 */ if (Date.now() - lastBackTouch > 15000) { lastBackTouch = Date.now(); touchBack(); } } } function endWatch() { GM_setValue("ak_watching", false); GM_setValue("ak_beat", 0); } function isWatchTabAlive() { if (!GM_getValue("ak_watching")) return false; var b = Number(GM_getValue("ak_beat") || 0); return Date.now() - b < 30000; } /* 工作台地址:一个键塞三样 —— 回哪、什么时候记的、看课页怎么打开的。别改用 window.opener 判断,见开发笔记 */ function saveBack(url, mode) { GM_setValue("ak_back", { url: url, ts: Date.now(), mode: mode === "popup" ? "popup" : "same", }); } function backRaw() { var b = GM_getValue("ak_back"); if (!b) return null; /* 老版本存的是裸地址字符串,兼容一下;没有 mode 就当 "same" */ if (typeof b === "string") return { url: b, ts: 0, mode: "same" }; return { url: b.url || "", ts: Number(b.ts || 0), mode: b.mode === "popup" ? "popup" : "same", }; } function backUrl() { var r = backRaw(); return r ? r.url : ""; } function backMode() { var r = backRaw(); return r ? r.mode : "same"; } function touchBack() { var r = backRaw(); if (r && r.url) { GM_setValue("ak_back", { url: r.url, ts: Date.now(), mode: r.mode }); } } function markBackMode(mode) { var r = backRaw(); if (r && r.url) { GM_setValue("ak_back", { url: r.url, ts: Date.now(), mode: mode }); } } function backFresh(ms) { var r = backRaw(); return !!r && r.ts > 0 && Date.now() - r.ts < ms; } /* 自动往回跳前先看是否已跳过一次,防工作台打不开时来回跳成走马灯(放 sessionStorage,不落盘) */ var BOUNCE_KEY = "ak_bounce_ts"; /* 「刚才还在干活」的判定窗口:后台标签页的定时器会被拖慢到一分钟一次,3 分钟够用 */ var BACK_FRESH_MS = 3 * 60 * 1000; function bouncedRecently() { try { var t = Number(sessionStorage.getItem(BOUNCE_KEY) || 0); return t > 0 && Date.now() - t < 15000; } catch (e) { return false; } } function markBounced() { try { sessionStorage.setItem(BOUNCE_KEY, String(Date.now())); } catch (e) {} } /* ================= 四·2、跨页记忆:这门课刚才怎么了 ================= 「达标后自动续播」最容易翻车的地方不是「续」,而是**列表不认账**: 看课页明明提示「您已完成」,退回工作台后站点表格还是 87%(服务端有延迟), 脚本照旧把同一门课再开一遍 —— 看着像没完没了地看同一门。 所以看课页离开前把结论写进本地存储,列表页读到就直接跳过: passed = 刚达标,先别重开(60 分钟内) limited = 刚到观看上限,让它先排到后面(30 分钟内) 窗口一过记录自然失效,真没看完的课照样会被重新捡起来。 ================================================================ */ var SEEN_KEY = "ak_seen"; var CUR_KEY = "ak_cur"; var SEEN_WINDOW = { passed: 60 * 60 * 1000, limited: 30 * 60 * 1000, }; /* 连播模式下要看很多门,「站点已提示完成」的课窗口拉长到 12 小时: 否则一小时后又把它翻出来重播一遍,看着像脚本犯傻 */ var SEEN_WINDOW_PASSED_LONG = 12 * 60 * 60 * 1000; function seenMap() { var v = GM_getValue(SEEN_KEY); return v && typeof v === "object" ? v : {}; } function seenState(name) { if (!name) return ""; var s = seenMap()[name]; if (!s || !s.ts || !s.state) return ""; var win = s.state === "passed" && autoPlayAfterPass() ? SEEN_WINDOW_PASSED_LONG : SEEN_WINDOW[s.state] || 0; return Date.now() - s.ts < win ? s.state : ""; } function markSeen(name, state) { if (!name) return; var m = seenMap(); m[name] = { ts: Date.now(), state: state }; /* 只留最近 60 条,别让这个键无限长大 */ var keys = Object.keys(m); if (keys.length > 60) { keys.sort(function (a, b) { return (m[b].ts || 0) - (m[a].ts || 0); }); for (var i = 60; i < keys.length; i++) delete m[keys[i]]; } try { GM_setValue(SEEN_KEY, m); } catch (e) {} } /* 打开课程前把标题存下来:看课页上的地址、标题都不一定带课程名 */ function saveCurrent(name) { try { GM_setValue(CUR_KEY, { name: name || "", ts: Date.now() }); } catch (e) {} } function currentCourse() { var v = GM_getValue(CUR_KEY); if (v && v.name && Date.now() - (v.ts || 0) < 30 * 60 * 1000) return v.name; return document.title || ""; } /* ================= 四·3、定时复查 ================= 本页的课都「刚处理过」时不要就地空转,也别让用户自己刷新: 隔几分钟自己再看一遍,这就是「无需手动干预」的兜底。 同一时刻只允许挂一个复查定时器,免得越滚越多。 ================================================ */ var rescanTimer = null; var RESCAN_MS = 3 * 60 * 1000; function scheduleRescan(ms) { if (rescanTimer || !isRunning()) return; rescanTimer = setTimeout(function () { rescanTimer = null; if (!isRunning()) return; LOG("定时复查:重新扫一遍"); logEvent("隔了几分钟,重新核对一次"); dispatchFlow().catch(function (e) { LOG("复查出错:" + (e && e.message ? e.message : e)); }); }, ms || RESCAN_MS); } /* ================= 四·4、按页面派活 ================= 启动、停止后重启、定时复查,走的都是这一条路 —— 只有一份派活逻辑, 才不会出现「刷新之后行为变了」这种事。 ================================================== */ var flowBusy = false; function pickFlow() { var href = location.href; if (href.indexOf("https://ipx.yanxiu.com/train2/") === 0) { hookNewTab(); startCreditRefresh(); /* /train2/ 下面不止一种页面:工作台是 .tool-card / .course-cell, 选修课程列表页是另一套 .course-item-pane / .course-item, myStudy 过渡页又是一套 .tool-card.pointer —— 必须分开处理 */ if (isCourseListPage()) return courseListFlow(); if (isMyStudyPage()) return myStudyFlow(); return workspaceFlow(); } if (href.indexOf("https://ipx.yanxiu.com/grain/") === 0) { startCreditRefresh(); return courseFlow(); } if (href.indexOf("https://ipx.yanxiu.com/cms/") === 0) return strayFlow(); return null; } /* 同一时刻只跑一条流程:连点「启动」不会叠出两条互相抢课的任务 */ function dispatchFlow() { if (!isRunning()) return Promise.resolve(null); if (flowBusy) { LOG("流程正在跑,这次启动忽略"); return Promise.resolve(null); } flowBusy = true; var release = function () { flowBusy = false; }; var p; try { p = pickFlow(); } catch (e) { release(); return Promise.reject(e); } if (!p) { release(); return Promise.resolve(null); } return p.then( function (v) { release(); return v; }, function (e) { release(); throw e; }, ); } function installMenu() { try { if (typeof GM_registerMenuCommand !== "function") return; GM_registerMenuCommand("自动看课 · 打开设置", function () { openSettings(); }); GM_registerMenuCommand("自动看课 · 启动 / 停止", function () { if (isRunning()) stopWork("菜单操作"); else startWork(); }); } catch (e) {} } /* ================= 五、考核规则解析 ================= */ var UNIT_RE = /(\d+(?:\.\d+)?)\s*(分钟|小时|学时|分|个|次|篇|门|条|份|秒)/g; function parseAmountUnit(text) { var out = [], m; UNIT_RE.lastIndex = 0; while ((m = UNIT_RE.exec(text)) !== null) { out.push({ v: parseFloat(m[1]), u: m[2] }); } return out; } function normalize(text) { var raw = parseAmountUnit(text || ""); var map = {}; for (var i = 0; i < raw.length; i++) { var v = raw[i].v, u = raw[i].u; if (u === "小时" || u === "学时") { v = v * 60; u = "分钟"; } else if (u === "秒") { v = v / 60; u = "分钟"; } map[u] = (map[u] || 0) + v; } return map; } /* 取「某种单位」的那个数:分钟和分同时出现,要能分开取 */ function amountOf(text, unit) { var list = parseAmountUnit(text || ""); for (var i = 0; i < list.length; i++) { if (list[i].u === unit) return list[i].v; } return null; } function isPassed(required, achieved) { if (!required || !achieved) return null; var a = normalize(required), b = normalize(achieved); var keys = Object.keys(a); if (!keys.length || !Object.keys(b).length) return null; var allOk = true; for (var i = 0; i < keys.length; i++) { var u = keys[i]; if (!(u in b)) return null; if (b[u] < a[u]) allOk = false; } return allOk; } /* ================= 六、首页「板块」判断 ================= */ function allCards() { return [].slice.call(document.querySelectorAll(".tool-card")); } function cardName(card) { var t = card.querySelector(".tool-card__title"); return t ? t.textContent.replace(/\s+/g, " ").trim() : ""; } function cardRules(card) { var metas = card.querySelectorAll(".tool-card__meta"); var out = []; for (var i = 0; i < metas.length; i++) { var p = metas[i].querySelector(".tool-card__participate"); var a = metas[i].querySelector(".tool-card__achieved"); out.push({ required: p ? p.textContent.replace(/\s+/g, " ").trim() : "", achieved: a ? a.textContent.replace(/\s+/g, " ").trim() : "", }); } return out; } function cardRulesIncomplete(card) { /* 展开箭头:旧版叫 .tool-card__toggle,新版叫 .learn-course-list__toggle,两个都认。 但**判据不能是「规则条数 <= 1」** —— 实测每张卡片本来就只有一个 .tool-card__meta (要求 + 已达成对塞在同一个 meta 里),那样写会让所有卡片都变成「读不全」, 于是什么都不跳过、连已达标的必修也被重看一遍。 真正该判的是「卡片处于收起状态、内容还没渲染」:展开态实测 title="收起"。 */ var tg = card.querySelector( ".tool-card__toggle, .learn-course-list__toggle", ); if (!tg) return false; var title = tg.getAttribute("title") || ""; return title.indexOf("展开") > -1 && title.indexOf("收起") < 0; } function cardCourseCount(card) { var tags = card.querySelectorAll(".course-cell__tag"); var must = 0, opt = 0; for (var i = 0; i < tags.length; i++) { if (tags[i].classList.contains("is-must")) must++; else if (tags[i].classList.contains("is-optional")) opt++; } return { must: must, optional: opt }; } function cardType(card) { var name = cardName(card); var hasMust = name.indexOf("必修") > -1; var hasOptional = name.indexOf("选修") > -1; if (hasMust && !hasOptional) return "must"; if (hasOptional && !hasMust) return "optional"; var c = cardCourseCount(card); if (c.must > 0 && c.optional === 0) return "must"; if (c.optional > 0 && c.must === 0) return "optional"; if (c.must > 0 && c.optional > 0) return "both"; return "unknown"; } function cardPassed(card) { if (cardRulesIncomplete(card)) return null; var rs = cardRules(card); if (!rs.length) return null; var allRulesOk = true; for (var i = 0; i < rs.length; i++) { var ok = isPassed(rs[i].required, rs[i].achieved); if (ok === null) return null; if (!ok) allRulesOk = false; } return allRulesOk; } function cardList() { var cards = allCards(); var out = []; for (var i = 0; i < cards.length; i++) { out.push({ el: cards[i], label: cardName(cards[i]) || "板块" + (i + 1), type: cardType(cards[i]), passed: cardPassed(cards[i]), courseCount: cardCourseCount(cards[i]), }); } return out; } /* 板块(必修 / 选修)各要多少分、已得多少分,数字直接来自页面文案,脚本不自己算分 */ function cardScoreLine(card) { var name = cardName(card) || "板块"; var passed = cardPassed(card); var state = passed === true ? "已达标" : passed === false ? "未达标" : "规则没看全,先不判"; var rs = cardRules(card); if (!rs.length) return name + " · 读不到考核要求"; var need = null, got = null; for (var i = 0; i < rs.length; i++) { if (need === null) need = amountOf(rs[i].required, "分"); if (got === null) got = amountOf(rs[i].achieved, "分"); } /* 不按「分」考核的项目(学时、篇数)只报状态,不硬凑数字 */ if (need === null && got === null) return name + " · " + state; var gotN = got === null ? 0 : got; var text = name + " " + fmt2(gotN) + " / " + (need === null ? "?" : fmt2(need)) + " 分"; text += " · " + state; if (passed === false && need !== null && gotN < need) { text += "(还差 " + fmt2(need - gotN) + ")"; } return text; } function cardScoreLines() { var cards = allCards(); var out = []; for (var i = 0; i < cards.length; i++) { out.push(cardScoreLine(cards[i])); } return out; } function skipPolicy() { var bl = cardList(); var mustSkippable = false, optionalSkippable = false; var mustUnmet = false, optionalUnmet = false; var hasData = false; for (var i = 0; i < bl.length; i++) { var b = bl[i]; if (b.passed === null) continue; hasData = true; var involvesMust = b.type === "must" || b.type === "both"; var involvesOptional = b.type === "optional" || b.type === "both"; if (b.passed) { if (involvesMust) mustSkippable = true; if (involvesOptional) optionalSkippable = true; } else { if (involvesMust) mustUnmet = true; if (involvesOptional) optionalUnmet = true; } } return { must: mustSkippable && !mustUnmet, optional: optionalSkippable && !optionalUnmet, hasData: hasData, }; } function allCardsPassed() { var bl = cardList(); if (!bl.length) return false; var hasData = false; for (var i = 0; i < bl.length; i++) { if (bl[i].passed === null) continue; hasData = true; if (!bl[i].passed) return false; } return hasData; } function skipSummary() { if (playThrough()) return "连播模式:已达标,继续播没看完的课"; var f = skipPolicy(); if (!f.hasData) return "未读取到考核信息,按顺序逐门学习"; var arr = []; if (f.must) arr.push("必修(已达标)"); if (f.optional) arr.push("选修(已达标)"); return arr.length ? "已跳过:" + arr.join("、") : "必修、选修都还没达标,按顺序学习"; } /* ================= 七、首页流程 ================= */ function courseRows() { var out = []; var all = document.querySelectorAll("tr.el-table__row"); for (var i = 0; i < all.length; i++) { if (all[i].querySelector(".course-cell")) out.push(all[i]); } return out; } function rowDone(tr) { var pct = tr.querySelector(".progress-cell__percent"); if (!pct) return false; if (pct.classList.contains("is-done")) return true; return (pct.textContent || "").trim().indexOf("100%") === 0; } function rowName(tr) { var n = tr.querySelector(".course-cell__name"); return n ? n.textContent.replace(/\s+/g, " ").trim() : "(未知课程)"; } function rowType(tr) { var tag = tr.querySelector(".course-cell__tag"); if (!tag) return "unknown"; if (tag.classList.contains("is-must")) return "must"; if (tag.classList.contains("is-optional")) return "optional"; return "unknown"; } function cellText(tr, sel) { var el = tr.querySelector(sel); return el ? el.textContent : ""; } function durationSec(text) { var t = String(text == null ? "" : text); var h = /(\d+)\s*小时/.exec(t); var m = /(\d+)\s*分钟/.exec(t); var s = /(\d+)\s*秒/.exec(t); return ( (h ? parseInt(h[1], 10) * 3600 : 0) + (m ? parseInt(m[1], 10) * 60 : 0) + (s ? parseInt(s[1], 10) : 0) ); } function rowProgress(tr) { var p = tr.querySelector(".progress-cell__percent"); if (!p) return 0; if (p.classList.contains("is-done")) return 1; var m = /(\d+(?:\.\d+)?)\s*%/.exec(p.textContent || ""); return m ? Math.min(1, parseFloat(m[1]) / 100) : 0; } function humanDuration(sec) { var m = Math.round((sec || 0) / 60); if (m < 1) return "不到 1 分钟"; if (m < 60) return m + " 分钟"; var h = Math.floor(m / 60), r = m % 60; return r ? h + " 小时 " + r + " 分钟" : h + " 小时"; } function hms(sec) { var t = Math.max(0, Math.round(sec || 0)); var m = Math.floor(t / 60), s = t % 60; var p2 = function (n) { return (n < 10 ? "0" : "") + n; }; if (m < 60) return m + ":" + p2(s); return Math.floor(m / 60) + ":" + p2(m % 60) + ":" + p2(s); } function rowCard(tr) { var el = tr; while (el && el.nodeType === 1) { if (el.classList && el.classList.contains("tool-card")) return el; el = el.parentElement; } return null; } function rowSkippable(tr, bl, f) { /* 连播模式:考核已全达标,但用户要「接着看」—— 一律不按板块跳过, 只按这一门自己的进度(100% 才算完)来筛 */ if (playThrough()) return false; var card = rowCard(tr); if (card) { for (var i = 0; i < bl.length; i++) { if (bl[i].el === card) return bl[i].passed === true; } return false; } var tp = rowType(tr); if (tp === "must" && f.must) return true; if (tp === "optional" && f.optional) return true; return false; } function pageSummary() { var rows = courseRows(); var bl = cardList(); var f = skipPolicy(); var doneCount = 0, skipCount = 0, todoCount = 0, remainSec = 0, withDuration = 0; for (var i = 0; i < rows.length; i++) { if (rowDone(rows[i])) { doneCount++; continue; } if (rowSkippable(rows[i], bl, f)) { skipCount++; continue; } todoCount++; var d = durationSec(cellText(rows[i], ".duration")); if (d > 0) withDuration++; remainSec += d * (1 - rowProgress(rows[i])); } return { total: rows.length, doneCount: doneCount, skipCount: skipCount, todoCount: todoCount, remainSec: remainSec, withDuration: withDuration, }; } function workspaceLiveText() { var sum = pageSummary(); /* 学分下面跟着每个板块(必修 / 选修)各要多少分、已得多少分 */ var head = [creditLine()].concat(cardScoreLines()); if (!sum.total) return head.concat(["正在读取课程表…"]).join("\n"); var lines = head.concat([ "本页 " + sum.total + " 门课 · 已看完 " + sum.doneCount + " 门 · 还要看 " + sum.todoCount + " 门", ]); if (sum.skipCount) lines.push("跳过 " + sum.skipCount + " 门(板块已达标,不用看)"); if (sum.todoCount > 0) { if (sum.withDuration > 0) lines.push( "预计还要 约 " + humanDuration(sum.remainSec) + "(按课程时长估算)", ); else lines.push("页面没给出课程时长,估不出还要多久"); } if (playThrough()) lines.push("连播模式:已达标,继续播没看完的课"); if (isWatchTabAlive()) lines.push("看课页正在播,等它看完再继续"); return lines.join("\n"); } var lastOpenedUrl = null; var lastWindow = null; function hookNewTab() { try { var W = typeof unsafeWindow !== "undefined" && unsafeWindow ? unsafeWindow : window; var rawOpen = W.open; if (typeof rawOpen !== "function") return; W.open = function (url) { var win = rawOpen.apply(this, arguments); lastOpenedUrl = url; lastWindow = win; return win; }; } catch (e) {} } async function openCourse(lines, fallbackBtn, backTo) { /* 先按 "same" 记下「看完该回哪儿」,只有真开出新标签页才升级成 "popup" —— 宁可多跳一次也不关人标签页。 backTo 是给课程列表页用的:那边看完要回**工作台页**(好重新核对考核要求), 不传就是本页(工作台页的老行为)。 */ saveBack(backTo || location.href, "same"); var beforeUrl = location.href; lastOpenedUrl = null; lastWindow = null; lines.click(); await sleep(1500); if (!lastOpenedUrl && fallbackBtn) { fallbackBtn.click(); await sleep(1500); } if (location.href !== beforeUrl) return "跳转"; if (!lastOpenedUrl) return "没反应"; var blocked = !lastWindow || lastWindow.closed; if (blocked) { LOG("新标签页被浏览器拦截,改为当前页跳转"); location.href = lastOpenedUrl; return "跳转"; } /* 到这儿才是「确实开出了一个活着的新标签页」,看课页看完可以把自己关掉 */ markBackMode("popup"); return "新标签页"; } async function workspaceFlow() { setStatus("正在扫描课程表…", "running"); setLive("正在读取页面上的课程和考核信息…"); await waitFor([".tool-card", ".course-cell"], 25000, "课程表"); await sleep(1500); var allPassed = allCardsPassed(); /* 先落标记:后面的 skipSummary / rowSkippable / 课程列表页都靠它判断「是不是连播」 */ setAllPassed(allPassed); startLiveRefresh(workspaceLiveText); logEvent("读到本页 " + courseRows().length + " 门课;" + skipSummary()); /* 把「必修 / 选修 要求与已达」存下来:课程列表页要靠它判断还要不要继续看 */ saveCardRequirement(); if (allPassed && !autoPlayAfterPass()) { setStatus("全部完成,没有要看的课了", "done"); setDetail( "必修和选修的考核都已经达标。\n" + "想要接着把没看完的课播完,在「⚙ 设置」里打开「必修、选修都达标后继续连播」。", ); logEvent("必修、选修都已达标,不再继续看课"); return; } if (allPassed) { setStatus("已达标 · 连播模式继续学习", "running"); logEvent("必修、选修都已达标 —— 连播模式:继续播没看完的课"); } var deadRows = {}; var deadStreak = 0; /* 连播模式下「本页没课了」还要再去课程列表页看一眼,但一次流程只去一趟,防来回跳 */ var triedList = false; /* 一门课在等待期间最多等多久:看了多久由看课页自己掐,这边只做兜底 */ var waitMax = (watchLimitMs() || 45 * 60 * 1000) + 5 * 60 * 1000; /* 连播模式下要看的课更多,允许更多轮,别走两下就撞上限 */ var maxAttempt = playThrough() ? 200 : 60; for (var attempt = 0; attempt < maxAttempt; attempt++) { if (halted()) { LOG("已停止,退出工作台流程"); return; } touchBack(); var rows = courseRows(); var f = skipPolicy(); var bl = cardList(); var target = null; var seenSkip = 0; for (var i = 0; i < rows.length; i++) { if (rowDone(rows[i])) continue; var rowKey = rowName(rows[i]); if (deadRows[rowKey]) continue; if (rowSkippable(rows[i], bl, f)) continue; /* 这门课刚处理过:要么已达标(站点进度还没刷新),要么刚到观看上限。 先让别的课排前面,省得在原地反复开同一门。 */ if (seenState(rowKey)) { seenSkip++; continue; } target = rows[i]; break; } if (!target) { var nextPage = document.querySelector( ".el-pagination .btn-next:not([disabled])", ); if (nextPage) { setStatus("翻到下一页…", "running"); nextPage.click(); await sleep(2500); continue; } /* 本页没课可看了。但**还有板块没达标**的话绝不能报「全部完成」—— 选修的课压根不在这一页,它在 /course/list/member 那一页。 旧版本就是在这儿骗了人:选修没达标、页面上又没选修课,于是说「全部完成」。 */ var unmet = unmetCards(); if (unmet.length) { var went = await gotoOptionalList(); if (went === "nav") return; if (went === "inline") return courseListFlow(); setStatus("本页没有要看的课了,但有板块还没达标", "skip"); setDetail( "还没达标:\n" + unmetText() + "\n\n这些板块的课不在本页。选修的课在「我的学习 → 网络学习 → 看课」那一页,\n" + "手动打开一次,脚本会记住它,以后自动去。", ); logEvent( "本页没有可看的课,但还有 " + unmet.length + " 个板块没达标", ); return; } /* 连播模式:本页没课了 ≠ 全部课都看完了 —— 选修的课在课程列表页那一张表上, 过去接着播。每门看完都会回工作台重新核对,所以不会死循环; 一次流程只去一趟(triedList),跳不过去就老实收工。 */ if (playThrough() && !triedList) { triedList = true; var wentPt = await gotoOptionalList(); if (wentPt === "nav") return; if (wentPt === "inline") return courseListFlow(); } /* 本页的课都「刚处理过」:要么刚达标、站点还没刷新进度,要么刚到观看上限。 不用你手动刷新,过几分钟脚本自己回来再看一遍。 */ if (seenSkip > 0) { setStatus("本页的课都刚处理过,稍后自动再看", "skip"); setDetail( "有 " + seenSkip + " 门课刚看过(已达标或到观看上限),站点的进度表还没跟上。\n" + "过几分钟脚本会自己回来重新核对,不用手动刷新。", ); logEvent("本页 " + seenSkip + " 门课刚处理过,安排稍后复查"); scheduleRescan(); return; } if (playThrough()) { setStatus("连播结束 · 本页的课都看完了", "done"); setDetail( "连播模式:这一页所有课的进度都到 100% 了,没有可播的了。\n" + "站点加了新课程的话,刷新一下页面就会接着播。", ); logEvent("连播模式:本页的课都看完了"); return; } setStatus("全部完成,没有要看的课了", "done"); setDetail("本页已全部处理完毕。\n如果还想继续看,可以手动打开课程。"); logEvent("本页处理完毕,没有要看的课了"); return; } var rowTitle = rowName(target); setStatus("正在打开:" + rowTitle.slice(0, 14), "running"); logEvent("打开:" + rowTitle); /* 告诉看课页「你正在看的是哪一门」,达标 / 到上限时它才知道该记在谁头上 */ saveCurrent(rowTitle); beginWatch(); var fallback = target.querySelector(".actions .btn--primary"); var outcome = await openCourse(target, fallback); if (outcome === "跳转") return; if (outcome === "没反应") { endWatch(); deadRows[rowTitle] = true; deadStreak++; logEvent("「" + rowTitle + "」点了没反应,先跳过它"); if (deadStreak >= 5) { setStatus("多门课都打不开,已停止", "error"); setError( "连续 " + deadStreak + " 门课点击后没有反应。\n" + "可能是课程已关闭,或者页面需要刷新。\n点「重新开始」试试。", ); return; } continue; } deadStreak = 0; var t0 = Date.now(); var lastTouch = 0; while (isWatchTabAlive() && !halted()) { if (Date.now() - t0 > waitMax) { LOG("等待超时,强制继续"); logEvent("等太久了,不再等,继续下一门"); endWatch(); break; } var sec = Math.round((Date.now() - t0) / 1000); /* 隔一会儿续一次时间戳;按「距上次过了多久」判断,不看 sec 能否被 15 整除(睡 2 秒 + 四舍五入,轮不到) */ if (Date.now() - lastTouch > 15000) { lastTouch = Date.now(); touchBack(); } setStatus( "看课页正在播 " + Math.floor(sec / 60) + " 分 " + (sec % 60) + " 秒", "running", ); await sleep(2000); } endWatch(); /* 用户按了停止:别再去刷新学分、也别再挑下一门 */ if (halted()) { LOG("已停止,退出工作台流程"); return; } await sleep(2000); var summary = pageSummary(); logEvent( "「" + rowTitle + "」这一轮结束(本页还剩 " + summary.todoCount + " 门要学)", ); fetchCredits(true); } } /* ================= 七·2、选修课程列表页 ================= 选修的课**不在工作台页面上**。路径是: 我的学习 → 网络学习 → 看课 → /train2/workspace/<项目>/course/list/member?projectId=..&role=100&toolId=..&stageId=.. 那一页用的是另一套组件(.course-item-pane / .course-item / .learn-btn), 跟工作台页的 .tool-card / .course-cell 毫无关系 —— 拿工作台的选择器去扫, 一个都命中不了,等 25 秒后报「出了点问题」,用户看到的就是「选修被整个屏蔽」。 这里的做法: 1) 在课程列表页上把地址记进本地存储(不去猜 toolId / stageId: 实测这两个 ID 既不在 DOM 属性里、也不在内联 script 里); 2) 工作台页发现「还有板块没达标、但本页一门课都看不了」时,跳过去; 3) 每看完一门就回工作台页 —— 那边会重新读一次考核要求, 达标了就不会再跳过来,所以不会没完没了地看下去。 ========================================================= */ var OPT_KEY = "ak_opt"; /* 同一门课 90 分钟内不重复打开:防止「看完了但页面没更新」时来回死循环 */ var OPT_OPEN_WINDOW = 90 * 60 * 1000; /* 连播模式下要几门课轮着播,90 分钟太久会把所有课全锁死、脚本干等着; 收窄到 20 分钟,转完一圈还能接着看 */ var PT_OPEN_WINDOW = 20 * 60 * 1000; function olTxt(s) { return String(s == null ? "" : s) .replace(/\s+/g, " ") .trim(); } function olStore() { var v = GM_getValue(OPT_KEY); return v && typeof v === "object" ? v : {}; } function olEntry(pid) { var s = olStore(); return (pid && s[pid]) || {}; } function olSave(pid, patch) { if (!pid) return; var s = olStore(); var e = s[pid] || {}; for (var k in patch) { if (Object.prototype.hasOwnProperty.call(patch, k)) e[k] = patch[k]; } e.ts = Date.now(); s[pid] = e; /* 只留最近 5 个项目,别让这个键无限长大 */ var keys = Object.keys(s); if (keys.length > 5) { keys.sort(function (a, b) { return (s[b].ts || 0) - (s[a].ts || 0); }); for (var i = 5; i < keys.length; i++) delete s[keys[i]]; } try { GM_setValue(OPT_KEY, s); } catch (e2) {} } function workspaceUrl(pid) { return pid ? "https://ipx.yanxiu.com/train2/workspace/" + pid + "/member" : ""; } function isCourseListPage() { if (location.pathname.indexOf("/course/list/") > -1) return true; return document.querySelectorAll(".course-item-pane").length > 0; } /* 只在真正的 /course/list/ 地址上记 —— 万一站点是「就地渲染」出课程列表的, 记下来的会是工作台地址,下次跳过去就白跳了 */ function rememberOptionalListUrl() { if (location.pathname.indexOf("/course/list/") < 0) return; var pid = pageProjectId(); if (!pid) return; /* 一条课程都没有的页面不算数:脚本会自己拼地址去试,试错的地址绝不能被记下来 */ if (!document.querySelector(".course-item")) return; var u = location.href.split("#")[0]; if (olEntry(pid).url !== u) { olSave(pid, { url: u }); LOG("记住了课程列表页:" + u); } else { olSave(pid, {}); } /* 落地成功了,把「刚才是在试拼地址」的标记清掉 */ clearGuess(); } /* 工作台页把「必修 / 选修 的要求与已达」存下来,给课程列表页判断还要不要看 */ function saveCardRequirement() { var pid = pageProjectId(); if (!pid) return; var cards = allCards(); var rec = { ts: Date.now() }; for (var i = 0; i < cards.length; i++) { var t = cardType(cards[i]); if (t !== "must" && t !== "optional" && t !== "both") continue; var rs = cardRules(cards[i]); var r0 = rs.length ? rs[0] : { required: "", achieved: "" }; var one = { required: r0.required, achieved: r0.achieved, passed: cardPassed(cards[i]), rows: cardCourseCount(cards[i]), }; if (t === "must" || t === "both") rec.must = one; if (t === "optional" || t === "both") rec.optional = one; } olSave(pid, { req: rec }); LOG("记下考核要求:" + JSON.stringify(rec)); } /* 还没达标的板块(passed === false;null 是「读不全,不判定」) */ function unmetCards() { var bl = cardList(); var out = []; for (var i = 0; i < bl.length; i++) { if (bl[i].passed === false) out.push(bl[i]); } return out; } function unmetText() { var out = unmetCards(); var lines = []; for (var i = 0; i < out.length; i++) { var rs = cardRules(out[i].el); var r0 = rs.length ? rs[0] : { required: "?", achieved: "?" }; lines.push( "· " + out[i].label + ":" + r0.achieved + "(要求 " + r0.required + ")", ); } return lines.join("\n"); } /* 工作台左侧「阶段 → 工具」里的「看课」入口。 实测结构:ul.stage-item__tools > li.tool-line > i.tool-type-icon.learn_course + span.tool-title 注意:诊断 5 确认这个「看课」是**同页切换工具面板**(在工作台内显示必修/选修卡片), 不跳转 —— 所以第 ② 条路不再用它(改用 findNetStudyEntry)。保留以备诊断。 */ function findWatchToolEntry() { var lines = document.querySelectorAll(".stage-item__tools .tool-line"); for (var i = 0; i < lines.length; i++) { if (lines[i].querySelector(".tool-type-icon.learn_course")) return lines[i]; var t = lines[i].querySelector(".tool-title"); if (t && olTxt(t.textContent).indexOf("看课") > -1) return lines[i]; } return null; } /* ---------- 第 ② 条路(改):点右侧考核面板的「网络学习」项 ---------- 诊断 5/7 确认的正确导航路径是两步: 工作台 → 点右侧 .project-assessment 里文本含「网络学习」的项 → /myStudy/member?tab=net(过渡页) → 点 .tool-card.pointer 里文本含「选修课」的卡(或其「查看」按钮) → /course/list/member?...&toolId=..&stageId=..(课程列表页) 旧版第 ② 条点的是左侧「看课」.tool-line —— 那是同页切换工具面板,不跳转, 所以在真站上永远走不通,用户看到的就是「每次都显示上次记下来的」。 */ /* 工作台页:右侧考核面板里文本含「网络学习」的可点项。 结构(诊断 5 A-3):div.assess-item > div.info > div.name「网络学习 22.88/100分」 点击是 Vue 绑的(没有 href/onclick),点 .assess-item 整项即可跳 myStudy。 */ function findNetStudyEntry() { /* 右侧考核面板里的每一项 */ var items = document.querySelectorAll( ".project-assessment .assess-item, .assess-list .assess-item", ); for (var i = 0; i < items.length; i++) { var t = olTxt(items[i].textContent); if (t.indexOf("网络学习") > -1) return items[i]; } /* 兜底:整页找文本含「网络学习」、且像个可点考核项的元素 */ var nodes = document.querySelectorAll( ".assess-item, .piece-item, .score-name-wrapper", ); for (var j = 0; j < nodes.length; j++) { if (olTxt(nodes[j].textContent).indexOf("网络学习") > -1) return nodes[j]; } return null; } /* myStudy 过渡页:文本含「选修课」的 .tool-card.pointer(或其「查看」按钮)。 结构(诊断 7):div.tool-card.pointer > div.left > div.left-right > div.flex > p.name「选修课」 + div.right > button.el-button「查看」 */ function findOptionalCardOnMyStudy() { var cards = document.querySelectorAll(".tool-card.pointer"); for (var i = 0; i < cards.length; i++) { var name = cards[i].querySelector("p.name, .name"); if (name && olTxt(name.textContent).indexOf("选修") > -1) return cards[i]; } /* 兜底:按文本找 */ cards = document.querySelectorAll(".tool-card"); for (var j = 0; j < cards.length; j++) { if (olTxt(cards[j].textContent).indexOf("选修课") > -1) return cards[j]; } return null; } function isMyStudyPage() { return location.pathname.indexOf("/myStudy/") > -1; } /* myStudy 过渡页的流程:点「选修课」卡跳到课程列表页。 只做这一件事 —— 这页是工作台和课程列表页之间的过渡页。 */ async function myStudyFlow() { setStatus("在网络学习页找选修课…", "running"); setLive("正在找「选修课」入口…"); var card = null; try { card = await waitFor([".tool-card.pointer"], 15000, "选修课卡片"); /* waitFor 返回的是第一个匹配的,但可能不是选修课那张 —— 重新定位 */ card = findOptionalCardOnMyStudy(); } catch (e) { card = findOptionalCardOnMyStudy(); } if (!card) { setStatus("网络学习页上没找到「选修课」", "error"); setError( "这一页应该有「必修课 / 选修课」几张卡片,但没找到含「选修」字样的那张。\n" + "可能是页面没加载完,刷新一下再试。", ); logEvent("myStudy 页没找到选修课卡片"); return; } var pid = pageProjectId(); /* 看完课回工作台页(不是回这个过渡页) */ var home = workspaceUrl(pid); saveBack(home || location.href, "same"); /* 优先点「查看」按钮(最明确的可点元素),其次点整张卡 */ var btn = card.querySelector("button, .btn, .el-button"); var target = btn || card; setStatus("正在打开选修课程…", "running"); logEvent( "在 myStudy 页点「选修课」" + (btn ? "(查看按钮)" : "(整张卡)"), ); var beforeUrl = location.href; target.click(); /* 等它跳走 —— 这页点完应该到 course/list 页。 和上一跳一样是**站内路由**:判据不是「地址变没变」,而是「地址变了之后我们还在不在跑」。 还在跑 = 页面没重载 = 课程列表页的流程不会自己起来,得在这儿把它接上。 (原地渲染出课程列表的情况也在这里一起兜住。) */ for (var i = 0; i < 20; i++) { await sleep(600); if (isCourseListPage()) { logEvent( location.href !== beforeUrl ? "已到课程列表页(站内跳转,脚本自己接着跑)" : "课程列表就地渲染出来了,脚本接着跑", ); stopLiveRefresh(); await courseListFlow(); return; } } setStatus("点了选修课但没跳转", "error"); setError( "点了「选修课」卡片,但页面没有跳到课程列表。\n可能站点的点击逻辑变了。", ); logEvent("myStudy 页点选修课后没跳转"); } /* ---------- 第三条路:自己拼一条地址去试 ---------- 诊断里那串真实地址是 /course/list/member?projectId=..&role=100&toolId=..&stageId=..&examineSubstance=MEMBER 其中 toolId / stageId 拿不到(不在 DOM、也不在内联 script 里),但这一页本来就是 「该工具下的全部课程」列表 —— 不带这两个参数也许照样能打开。 所以先拼一条试;成不成由落地页面判断(courseListFlow 读不到 .course-item 就退回工作台), 失败一次就记 guessBad,不再反复试。 */ var OPT_GUESS_KEY = "ak_optguess"; function markGuess() { try { GM_setValue(OPT_GUESS_KEY, Date.now()); } catch (e) {} } function guessFresh() { var t = Number(GM_getValue(OPT_GUESS_KEY) || 0); return t > 0 && Date.now() - t < 60000; } function clearGuess() { try { GM_setValue(OPT_GUESS_KEY, 0); } catch (e) {} } function guessListUrl(pid) { if (!pid) return ""; /* role=100 是诊断里那串真实地址上的值(学员身份) */ return ( "https://ipx.yanxiu.com/train2/workspace/" + pid + "/course/list/member?projectId=" + encodeURIComponent(pid) + "&role=100&examineSubstanceRole=0&examineSubstance=" + encodeURIComponent(pageRole()) ); } /* 拼出来的地址没渲染出课程:退回工作台,并记下这条路不通 */ function guessFailed(pid) { var home = workspaceUrl(pid); if (guessFresh() && home) { clearGuess(); olSave(pid, { guessBad: true }); setStatus("自动拼的地址走不通,正在回工作台…", "skip"); logEvent("拼出来的课程列表页没有课程,记下这条路不通,退回工作台"); location.href = home; return; } setStatus("这个页面没有课程列表", "skip"); setDetail( "这一页没找到任何课程。\n如果是脚本自己跳过来的,说明这条自动拼的地址不对。", ); logEvent("页面上没有 .course-item"); } /* 从工作台跳到课程列表页。 返回 "nav" = 正在跳走;"inline" = 站点就地渲染出了课程列表;"" = 跳不过去 三条路是「前一条成了就不试后面」:① 记住的地址 → ② 点「网络学习」→ ③ 自己拼地址。 其中 ② **一旦点到就终结**(点不动也返回 "",不落到 ③)—— 见下面那段注释。 */ async function gotoOptionalList() { var pid = pageProjectId(); if (!pid) return ""; /* 1) 地址记过就直接用 —— 最可靠,不猜 */ var url = olEntry(pid).url; if (url) { setStatus("选修的课在另一个页面,正在过去…", "running"); setLive("正在跳到课程列表页…"); logEvent("跳到课程列表页(地址是上次记下来的)"); saveBack(url, "same"); await sleep(800); location.href = url; return "nav"; } /* 2) 没记过:点右侧考核面板的「网络学习」项 → 跳到 myStudy 过渡页 → 那边的 myStudyFlow 会继续点「选修课」跳到课程列表页。 (旧版点的是左侧「看课」.tool-line,但那只是同页切换工具面板,不跳转 —— 等于死路。) 这条路是**终点**:只要页面上有「网络学习」入口、也点了,就不许再往下掉到第 ③ 条。 入口明明在、点了却没反应 = 站点这边变了,如实报「跳不过去」让用户自己去看一眼, 比偷偷去拼一条地址强。 */ var entry = findNetStudyEntry(); if (entry) { var before = location.href; setStatus("正在打开网络学习…", "running"); logEvent("还不知道课程列表页地址,先点「网络学习」去过渡页"); saveBack(workspaceUrl(pid) || location.href, "same"); entry.click(); for (var i = 0; i < 12; i++) { await sleep(600); if (location.href !== before) { /* 地址变了、可这段代码还在往下跑 —— 这一定是**站内路由**跳的 (真·整页跳转的话,这个 JS 上下文早被销毁,根本执行不到这里)。 页面没重载 → boot() 不会重跑 → 过渡页的流程根本起不来。 所以必须自己接下去,否则脚本就停在这一页上不动了。 */ if (isMyStudyPage()) { logEvent("已到 myStudy 过渡页(站内跳转,脚本自己接着跑)"); stopLiveRefresh(); await myStudyFlow(); } return "nav"; } if (isMyStudyPage()) return "nav"; } /* 点了没跳 —— 到此为止,绝不掉到第 ③ 条。理由: 拼出来的地址缺 toolId / stageId(这两个拿不到),落不到课 → guessFailed() 会把本项目的 guessBad **永久**标死,等于把「这一次失败」 升级成「这条路以后也别试」。为一次没反应的点击赔上整条退路,不值。 */ setStatus("点了「网络学习」但页面没跳转", "skip"); logEvent("点「网络学习」没跳转 —— 入口在却没反应,不再往下试拼地址"); return ""; } /* 3) 最后自己拼一条地址试。成不成由 courseListFlow 落地后判断: 读不到课程就自动退回工作台,并记下 guessBad,不再反复试。 */ if (!olEntry(pid).guessBad) { var guess = guessListUrl(pid); if (guess) { setStatus("正在试一条自动拼的地址…", "running"); logEvent("试拼课程列表页地址:" + guess); markGuess(); saveBack(workspaceUrl(pid), "same"); await sleep(600); location.href = guess; return "nav"; } } return ""; } /* ---------- 课程列表页的选择器 ---------- */ function olPanes() { return [].slice.call(document.querySelectorAll(".course-item-pane")); } function olItems(pane) { return [].slice.call(pane.querySelectorAll(".course-item")); } /* pane 标题里带「来源:必修课 / 来源:选修课」;没有就退回看条目标签 */ function olPaneKind(pane) { var h = pane.querySelector(".course-pane-header"); var t = h ? olTxt(h.textContent) : ""; if (t.indexOf("来源:选修课") > -1) return "optional"; if (t.indexOf("来源:必修课") > -1) return "must"; var tags = pane.querySelectorAll(".item-info-tag"); var m = 0, o = 0; for (var i = 0; i < tags.length; i++) { var s = olTxt(tags[i].textContent); if (s.indexOf("选修") > -1) o++; else if (s.indexOf("必修") > -1) m++; } if (o > 0 && m === 0) return "optional"; if (m > 0 && o === 0) return "must"; return "unknown"; } function olItemTitle(it) { var t = it.querySelector(".item-info-title"); return t ? olTxt(t.textContent) : ""; } /* 「已观看 54%」—— 没有这个元素时返回 -1(未知) */ function olItemPercent(it) { var s = it.querySelector(".learn-status"); if (!s) return -1; var m = olTxt(s.textContent).match(/(\d+(?:\.\d+)?)\s*%/); return m ? parseFloat(m[1]) : -1; } /* 真正能点的是里面的