// ==UserScript== // @name 教师研修网 自动看课 // @namespace https://ipx.yanxiu.com/ // @version 3.11.4 // @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 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 ==/UserConfig== */ /* 教师研修网 自动看课 —— 打开「工作台」就自己开始;用法见「使用说明.html」,原因见「开发笔记.md」 */ (function () { "use strict"; if (window !== top) return; var LOG = function () { console.log.apply(console, ["[自动看课]"].concat([].slice.call(arguments))); }; /* ================= 一、设置 ================= */ function cfg(key, def) { var v = GM_getValue("设置." + key); return v === undefined || v === null ? def : !!v; } function isMuted() { return cfg("静音", true); } /* 站点 playbackRates 是空数组,明确不允许倍速;默认关闭,关着时连 playbackRate 都不碰 */ function speedEnabled() { return cfg("倍速", false); } function hudEnabled() { return cfg("浮窗", true); } /* ================= 二、右下角小浮窗 ================= */ var VERSION = "3.11.4"; /* 必须与头部 @version 一致,「复制状态」报的就是它 */ var HUD = { root: null, dot: null, line: null, head: null, body: null, arrow: null, live: null, log: null, detail: null, toast: null, events: [], toastTimer: null, }; var COLORS = { running: "#378ADD", skip: "#1D9E75", done: "#1D9E75", error: "#E24B4A", }; 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 || "未知原因"), ); }); }; /* 「回到工作台」只在被站点甩到「项目首页」时才出现,平时不占地方 */ 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(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; 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、打赏 ================= */ /* 微信赞赏码:base64 在文件末尾「十、赞赏码图片」节;内嵌被 CSP 拦就自动换外链,换图见「开发笔记.md」 */ var DONATE_QR = ""; var DONATE_QR_FALLBACK = "https://s41.ax1x.com/2026/09/21/pnQEOAA.jpg"; /* 图片直链,不是图床的页面地址 */ var DONATE_QR_LABEL = "微信赞赏码"; /* 内嵌图被 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) {} /* 赞赏码截图上百 KB,原样存进本地存储迟早撑爆配额,存前必须先压 */ var DONATE_MAX_PX = 720; var DONATE_KEY = "ak_qr"; var donateMask = null; var donateWrap = null; var donateEsc = null; function donateStored() { try { var v = GM_getValue(DONATE_KEY); return v ? String(v) : ""; } catch (e) { return ""; } } /* 本地存的优先,其次脚本常量;都没有返回空串,调用方画占位框 */ function donateSrc() { return donateStored() || DONATE_QR || ""; } function donateKb(dataUrl) { return Math.max(1, Math.round((dataUrl.length * 0.75) / 1024)); } /* JPEG 进 JPEG 出、PNG 进 PNG 出:照片转 PNG 反而能涨到好几 MB */ function donateShrink(file, done) { var reader = new FileReader(); reader.onload = function () { var img = new Image(); img.onload = function () { try { var scale = Math.min( 1, DONATE_MAX_PX / Math.max(img.width, img.height), ); var w = Math.max(1, Math.round(img.width * scale)); var h = Math.max(1, Math.round(img.height * scale)); var cv = document.createElement("canvas"); cv.width = w; cv.height = h; cv.getContext("2d").drawImage(img, 0, 0, w, h); done( /jpe?g/i.test(file.type || "") ? cv.toDataURL("image/jpeg", 0.92) : cv.toDataURL("image/png"), "", ); } catch (e) { /* 画布用不了(跨域或被限制)就退回原图,总比没有强 */ done(String(reader.result), ""); } }; img.onerror = function () { done("", "这个文件不是图片,或者已经损坏"); }; img.src = String(reader.result); }; reader.onerror = function () { done("", "读不出这个文件"); }; reader.readAsDataURL(file); } function donateUseFile(file) { if (!file) return; donateShrink(file, function (dataUrl, err) { if (!dataUrl) { showToast(err || "这张图用不了"); return; } try { GM_setValue(DONATE_KEY, dataUrl); } catch (e) { showToast("存不下了(浏览器本地存储满了),先清掉再试"); return; } showToast( "赞赏码已保存(" + donateKb(dataUrl) + " KB,只存在你自己电脑上)", ); renderDonate(); }); } function donatePick() { var input = document.createElement("input"); input.type = "file"; input.accept = "image/*"; input.style.cssText = "position:fixed;left:-9999px;top:0"; input.onchange = function () { donateUseFile(input.files && input.files[0]); if (input.parentNode) input.parentNode.removeChild(input); }; document.body.appendChild(input); input.click(); } function donateClear() { try { GM_deleteValue(DONATE_KEY); } catch (e) {} renderDonate(); showToast("已清除赞赏码"); } function donateCopyBase64(dataUrl) { var ok = function (good) { showToast( good ? "base64 已复制,粘到脚本顶部的 DONATE_QR_ 常量里" : "复制失败,请手动选中并复制", ); }; try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(dataUrl).then( function () { ok(true); }, function () { ok(false); }, ); return; } } catch (e) {} ok(false); } 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 src = donateSrc(); var fb = DONATE_QR_FALLBACK || ""; var box = document.createElement("div"); box.style.cssText = "width:100%;border:1px solid #B5D4F4;border-radius:11px;" + "padding:11px;text-align:center;background:#fff"; if (!src && !fb) { /* 内嵌和外链都空 —— 画个占位框,作者可以拖一张进来 */ var ph = document.createElement("div"); ph.textContent = "点这里选图片\n或把图片拖进来"; ph.style.cssText = "padding:44px 8px;border:1px dashed #B5D4F4;border-radius:8px;" + "background:#F7FAFF;color:#7A8798;font-size:11.5px;line-height:1.7;" + "white-space:pre-line;cursor:pointer"; ph.onclick = function () { donatePick(); }; box.appendChild(ph); box.ondragover = function (e) { e.preventDefault(); ph.style.borderColor = "#378ADD"; ph.style.color = "#0C447C"; }; box.ondragleave = function () { ph.style.borderColor = "#B5D4F4"; ph.style.color = "#7A8798"; }; box.ondrop = function (e) { e.preventDefault(); var files = e.dataTransfer && e.dataTransfer.files; donateUseFile(files && files[0]); }; } else { 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"; /* step: 0 = 内嵌图,1 = 已换外链,2 = 两条路都不行 */ var useInline = !!src && !(donateCspBlocked && !!fb); var step = useInline ? 0 : 1; var degrade = function () { if (step === 0 && fb) { step = 1; img.src = fb; return; } if (step === 2) return; step = 2; img.style.display = "none"; note.style.display = "block"; }; img.onerror = degrade; /* 万一某个浏览器拦掉图时不派发 error,只把 naturalWidth 留成 0 */ img.onload = function () { if (!img.naturalWidth) degrade(); }; img.src = useInline ? src : fb; /* 再兜一道:2 秒后还是 0 像素就认为没出来 */ setTimeout(function () { if (step < 2 && img.complete && img.naturalWidth === 0) degrade(); }, 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); var row = document.createElement("div"); row.style.cssText = "display:flex;gap:6px;justify-content:center;margin-top:8px"; if (src || fb) { /* 弹窗不放「换一张」:图写死在脚本里,给使用者换图没有意义 */ if (donateStored()) { var cp = donateBtn("复制 base64", "#E6F1FB", "#0C447C"); cp.onclick = function () { donateCopyBase64(src); }; row.appendChild(cp); var cl = donateBtn("清除", "#F1EFE8", "#5F5E5A"); cl.onclick = function () { donateClear(); }; row.appendChild(cl); } } else { var pick = donateBtn("选择图片", "#E6F1FB", "#0C447C"); pick.onclick = function () { donatePick(); }; row.appendChild(pick); } /* 一个按钮都没有时别把这个空行容器塞进去,否则标签下会多出一截空当 */ if (row.childNodes.length) box.appendChild(row); 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); } 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); } /* ================= 三、小工具 ================= */ 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) {} } /* ================= 五、考核规则解析 ================= */ 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() { 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) { 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 (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); startLiveRefresh(workspaceLiveText); logEvent("读到本页 " + courseRows().length + " 门课;" + skipSummary()); /* 把「必修 / 选修 要求与已达」存下来:课程列表页要靠它判断还要不要继续看 */ saveCardRequirement(); if (allCardsPassed()) { setStatus("全部完成,没有要看的课了", "done"); setDetail( "必修和选修的考核都已经达标。\n如果还想继续看,可以手动打开课程。", ); logEvent("必修、选修都已达标,不再继续看课"); return; } var deadRows = {}; var deadStreak = 0; for (var attempt = 0; attempt < 60; attempt++) { touchBack(); var rows = courseRows(); var f = skipPolicy(); var bl = cardList(); var target = null; 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; 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; } setStatus("全部完成,没有要看的课了", "done"); setDetail("本页已全部处理完毕。\n如果还想继续看,可以手动打开课程。"); logEvent("本页处理完毕,没有要看的课了"); return; } var rowTitle = rowName(target); setStatus("正在打开:" + rowTitle.slice(0, 14), "running"); logEvent("打开:" + 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()) { if (Date.now() - t0 > 45 * 60 * 1000) { 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(); 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; 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; } /* 真正能点的是里面的