// ==UserScript==
// @name 好医生继续医学教育自动学习助手
// @namespace https://card.wlxy.live/details/9F72A908
// @author 柠檬真酸
// @version 2.0
// @icon https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png
// @description 好医生继续医学教育,自动完成未完成课程视频和考试,一键秒过收藏课程列表内所有课程学时和考试,安全稳定,支持人脸识别考试
// @antifeature payment Pro Token 解锁完整能力
// @connect huaweicloudobs.ahjxjy.cn
// @connect card.wlxy.live
// @connect bdp.haoyisheng.com
// @connect oa3.ahzsksw.cn
// @connect www.cmechina.net
// @connect *.cmechina.net
// @connect *.haoyisheng.com
// @match https://www.cmechina.net/*
// @grant GM_xmlhttpRequest
// @grant GM_openInTab
// @run-at document-start
// @license All Rights Reserved
// ==/UserScript==
(function () {
"use strict";
const STORAGE_KEY = "cme_auto_study_enabled";
const MULTIPLIER_KEY = "cme_study_time_multiplier";
const ADVANCE_KEY = "cme_advance_multiplier";
const SAVE_INTERVAL_KEY = "cme_save_interval_sec";
const HEARTBEAT_BASE = 300;
const MODE_KEY = "cme_run_mode";
const STUDY_MODE_KEY = "cme_study_mode";
const BG_STATE_KEY = "cme_bg_learn_state";
const QUEUE_KEY = "cme_course_queue";
const QUEUE_TOUCHED_KEY = "cme_queue_touched";
const PLAN_TP_KEY = "cme_selected_tp_id";
const AUTO_EXAM_KEY = "cme_auto_exam";
const API_ONLY_STUDY_KEY = "cme_api_only_study";
const SCRIPT_VERSION = (() => {
try {
if (typeof GM_info !== "undefined" && GM_info?.script?.version) {
return String(GM_info.script.version);
}
} catch (_) {}
return "unknown";
})();
const PRO_BUY_URL = "https://card.wlxy.live/details/9F72A908";
const PRO_BUY_OPEN_MS = new Date("2026-06-10T19:00:00+08:00").getTime();
const CLOUD_API_BASE_KEY = "cme_cloud_api_base";
const CLOUD_TOKEN_KEY = "cme_cloud_token_v1";
const CLOUD_LEASE_CACHE_KEY = "cme_cloud_lease_cache_v1";
const CLOUD_PRO_EXPIRE_CACHE_KEY = "cme_cloud_pro_expire_cache_v1";
const CLOUD_LAST_STATE_KEY = "cme_cloud_last_state_v1";
const PANEL_POS_KEY = "cme_panel_pos_v1";
const PANEL_COLLAPSED_KEY = "cme_auto_panel_collapsed_v1";
const PANEL_LOGO_URL = "https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png";
const CME_USER_PROFILE_KEY = "cme_user_profile_v1";
const QQ_GROUP_NUMBER = "903117129";
const QQ_GROUP_LINK =
"https://qun.qq.com/universal-share/share?ac=1&authKey=rxdL6YIJ0%2FxOEemjLqTGULvl5aAfJIVQcIvkvnwvmL%2FAmpFZnSafajYHgSXMUXvx&busi_data=eyJncm91cENvZGUiOiI5MDMxMTcxMjkiLCJ0b2tlbiI6IlB2dkFGSm5XRXBrSEhtQVFTUGQzdVNZakhNWDNMbW1kODA2enpoMi9obDh4SWp0YzBDODNFaGtwRU44Z0hyU0siLCJ1aW4iOiIxMjU0MzE1MTQifQ%3D%3D&data=9oyJixSPcigCQW-saV5eXlcMwV9C6J36XySx-rDHwVwNlofvRmd2ze5sLwFtHTbYbG4nAWIUrI0qftC6aTX9xg&svctype=4&tempid=h5_group_info";
const DEFAULT_CLOUD_API_BASE = "https://oa3.ahzsksw.cn";
const PANEL_NOTICE_FALLBACK = "\u597d\u533b\u751f CME \u52a9\u624b";
const NAV_GUARD_KEY = "cme_auto_nav_guard";
const TICK_MS = 3000;
const NAV_COOLDOWN_MS = 60000;
const CHAPTER_CLICK_COOLDOWN_MS = 20000;
const VIDEO_WAIT_MS = 45000;
const SITE_ID = "cmechina";
const API_BASE = "https://www.cmechina.net";
const MAX_ADVANCE_SEC = 3600;
const PROGRESS_STEP_SEC = 300;
const PROGRESS_GAP_MS = 4000;
const API_PROGRESS_GAP_MS = 5500;
const API_PROGRESS_LEAD_MS = 200;
const API_FULL_KEY_PREFIX = "cme_api_reported_full_";
const API_PROGRESS_KEY_PREFIX = "cme_api_progress_";
const PROGRESS_LEAD_MS = 1500;
const ISEXAM_POLL_TIMES = 24;
const ISEXAM_POLL_INTERVAL_MS = 1500;
const ISEXAM_POLL_AFTER_FULL_TIMES = 6;
const ISEXAM_WAIT_MAX_TICKS = 30;
const STUDY_SESSION_PREFIX = "cme_study_start_";
const PAGE_STUDY_PENDING_KEY = "cme_page_study_pending";
const RETURN_URL_AFTER_STUDY_KEY = "cme_return_url_after_study";
const CHAPTER_PROGRESS_PREFIX = "cme_chapter_reported_";
function trace(msg) {
console.log(`[\u597d\u533b\u751f\u770b\u8bfe] ${String(msg || "")}`);
}
function isUserFacingLog(text) {
const t = String(text || "").trim();
if (!t) return false;
return [
/^(\u81ea\u52a8\u770b\u8bfe\u5df2(\u5f00\u542f|\u6682\u505c))/,
/^\u8bf7\u5148/,
/^\u6b63\u5728\u89c2\u770b\uff1a\u7b2c\d+\u8282/,
/^\u6b63\u5728\u8003\u8bd5\uff1a\u7b2c\d+\u8282/,
/^\u89c6\u9891\u5df2\u5b66\u5b8c\uff1a\u7b2c\d+\u8282/,
/^\u8003\u8bd5\u901a\u8fc7\uff1a/,
/^\u8003\u8bd5\u5931\u8d25\uff1a/,
/^\u4e0b\u4e00\u4efb\u52a1\uff1a/,
/^\u5f00\u59cb\u5b66\u4e60\uff1a/,
/^\u8bfe\u7a0b.+\u5df2\u5168\u90e8\u5b8c\u6210/,
/^\u7b2c\d+\u8282.+\u8fdb\u5165\u672c\u8282\u8003\u8bd5/,
/^\u7b2c\d+\u8282.+\u5df2\u8003\u8bd5\u901a\u8fc7/,
/^\u6240\u6709\u5df2\u9009\u8bfe\u7a0b/,
/^\u5237\u65b0\u8bfe\u7a0b\u5931\u8d25/,
/^\u8fd0\u884c\u5f02\u5e38/,
/^\u5f53\u524d\u8fdb\u884c\u4e2d\u7684\u8bfe\u7a0b\u5df2\u53d6\u6d88\u52fe\u9009/,
/^\u68c0\u6d4b\u5230\u4eba\u8138\u8bc6\u522b/,
/^\u9700\u8981\u4eba\u8138\u8bc6\u522b/,
/^\u4eba\u8138\u8bc6\u522b\u5df2\u901a\u8fc7/,
/^\u5df2\u9009\u62e9\u6682\u4e0d\u8003\u8bd5/,
/^\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5/,
/^\u8003\u8bd5\u5f02\u5e38\uff1a/,
].some((re) => re.test(t));
}
function normalizeWareId(wareId) {
const n = String(wareId || "").replace(/^0+/, "") || String(wareId || "0");
return n.padStart(2, "0");
}
function injectPageKeepAlive() {
const code = function () {
if (window.__cmeKeepAlivePatched) return;
window.__cmeKeepAlivePatched = true;
window.requestKeepAlive = function () {
const xhr = new XMLHttpRequest();
xhr.open("GET", "/cme/sessionKeeper.jsp", true);
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (xhr.readyState === 4) setTimeout(window.requestKeepAlive, 30000);
};
xhr.send();
};
};
const el = document.createElement("script");
el.textContent = "(" + code.toString() + ")();";
(document.documentElement || document.head).appendChild(el);
el.remove();
}
injectPageKeepAlive();
injectPageExamHooks();
injectProgressCapture();
function injectProgressCapture() {
const code = function () {
if (window.__cmeProgressCapture) return;
window.__cmeProgressCapture = true;
function store(url) {
try {
const u = new URL(url, location.origin);
if (!u.pathname.includes("updatePlayStatus.jsp")) return;
const cid = u.searchParams.get("course_id");
const wid = u.searchParams.get("ware_id");
if (!cid || !wid) return;
localStorage.setItem(
"cme_progress_capture_" + cid + "_" + wid,
JSON.stringify({
loginName: u.searchParams.get("loginName"),
batchId: u.searchParams.get("batch_id"),
totalTime: Number(u.searchParams.get("total_time") || 0),
playingTime: Number(u.searchParams.get("playing_time") || 0),
ip: u.searchParams.get("ip") || "",
t: Date.now(),
})
);
} catch (_) {}
}
const oo = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
if (typeof url === "string") store(url);
return oo.call(this, method, url, ...rest);
};
const of = window.fetch;
if (of) {
window.fetch = function (input, init) {
const url = typeof input === "string" ? input : input?.url;
if (url) store(url);
return of.apply(this, arguments);
};
}
};
const el = document.createElement("script");
el.textContent = "(" + code.toString() + ")();";
(document.documentElement || document.head).appendChild(el);
el.remove();
}
function injectPageStudyComplete(courseId, wareId) {
const code = function (courseId, wareId) {
if (window.__cmePageStudyRunning) return;
window.__cmePageStudyRunning = true;
const sigKey = "cme_study_done_" + courseId + "_" + wareId;
const sessKey = "cme_study_start_" + courseId + "_" + wareId;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const done = (ok, msg) => {
localStorage.setItem(sigKey, JSON.stringify({ ok, msg: msg || "", t: Date.now() }));
window.__cmePageStudyRunning = false;
const ret = localStorage.getItem("cme_return_url_after_study");
if (ok && ret) {
localStorage.removeItem("cme_return_url_after_study");
localStorage.removeItem("cme_page_study_pending");
setTimeout(function () {
location.href = ret;
}, 1500);
}
};
const progressState = { lastUrl: null, captured: false, lastPlayingTime: 0, totalTime: 0 };
function parseProgressUrl(url) {
try {
const u = new URL(url, location.origin);
if (!u.pathname.includes("updatePlayStatus.jsp")) return;
progressState.lastUrl = u;
progressState.lastPlayingTime = parseInt(u.searchParams.get("playing_time") || "0", 10);
progressState.totalTime = parseInt(u.searchParams.get("total_time") || "0", 10);
progressState.captured = true;
} catch (_) {}
}
(function hookProgress() {
const oo = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (m, url, ...r) {
if (typeof url === "string") parseProgressUrl(url);
return oo.call(this, m, url, ...r);
};
const of = window.fetch;
if (of) {
window.fetch = function (input, init) {
const url = typeof input === "string" ? input : input?.url;
if (url) parseProgressUrl(url);
return of.apply(this, arguments);
};
}
})();
async function sendProgress(playingTime, totalTime, status) {
const cap = localStorage.getItem("cme_progress_capture_" + courseId + "_" + wareId);
let url;
if (progressState.captured && progressState.lastUrl) {
const u = new URL(progressState.lastUrl.href);
u.searchParams.set("playing_time", String(Math.floor(playingTime)));
u.searchParams.set("total_time", String(Math.floor(totalTime)));
u.searchParams.set("status", String(status == null ? 1 : status));
u.searchParams.set("t", String(Date.now()));
url = u.pathname + "?" + u.searchParams.toString();
} else if (cap) {
const c = JSON.parse(cap);
const u = new URL("/webcam/updatePlayStatus.jsp", location.origin);
u.searchParams.set("loginName", c.loginName);
u.searchParams.set("course_id", courseId);
u.searchParams.set("ware_id", wareId);
u.searchParams.set("batch_id", c.batchId);
u.searchParams.set("playing_time", String(Math.floor(playingTime)));
u.searchParams.set("total_time", String(Math.floor(totalTime || c.totalTime)));
u.searchParams.set("logType", "0");
u.searchParams.set("status", String(status == null ? 1 : status));
u.searchParams.set("ip", "127.0.0.1");
u.searchParams.set("t", String(Date.now()));
url = u.pathname + "?" + u.searchParams.toString();
} else return false;
await fetch(url, { credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest" } });
progressState.lastPlayingTime = playingTime;
progressState.captured = true;
return true;
}
async function checkIsExamLocal() {
const wid =
new URLSearchParams(location.search).get("courseware_id") || wareId || "01";
try {
const r = await fetch(
"/isexam.jsp?course_id=" + courseId + "&paper_id=" + wid + "&type=7&_=" + Date.now(),
{ credentials: "include", headers: { "X-Requested-With": "XMLHttpRequest" } }
);
const t = await r.text();
try {
const j = JSON.parse(t.trim());
return Number(j.state) === 1;
} catch (_) {
return /"state"\s*:\s*1/.test(t);
}
} catch (_) {
return false;
}
}
async function run() {
try {
if (!localStorage.getItem(sessKey)) localStorage.setItem(sessKey, String(Date.now()));
await sleep(1500);
let player = null;
for (let i = 0; i < 50; i++) {
if (window.cc_js_Player?.getDuration) {
player = window.cc_js_Player;
break;
}
if (window.player?.getDuration) {
player = window.player;
break;
}
await sleep(500);
}
const video = document.querySelector(".pv-video") || document.querySelector("video");
if (!player && !video) return done(false, "\u672a\u627e\u5230\u64ad\u653e\u5668\uff08\u987b\u5728 study2/polyv \u64ad\u653e\u9875\uff09");
try {
if (player?.params) player.params.rate_allow_change = true;
if (window.cc_js_Player?.params) window.cc_js_Player.params.rate_allow_change = true;
} catch (_) {}
if (player) {
try {
player.setVolume?.(0);
player.play?.();
} catch (_) {}
} else {
try {
video.muted = true;
video.play?.();
} catch (_) {}
}
for (let i = 0; i < 30 && !progressState.captured; i++) await sleep(500);
let total =
progressState.totalTime ||
(player?.getDuration ? Math.floor(player.getDuration()) : 0) ||
(video?.duration ? Math.floor(video.duration) : 0);
if (!total || !Number.isFinite(total)) return done(false, "\u65e0\u6cd5\u83b7\u53d6\u89c6\u9891\u603b\u65f6\u957f");
const endPos = Math.max(0, total - 1);
if (player?.jumpToTime) {
try {
player.setVolume?.(0);
player.play?.();
player.jumpToTime(endPos);
} catch (_) {}
} else if (video) {
try {
video.muted = true;
video.playbackRate = 8;
video.currentTime = endPos;
video.play?.();
} catch (_) {}
}
if (window.see) localStorage.setItem("see", "1");
if (window.see2) localStorage.setItem("see2", String(endPos));
let okProgress = false;
for (let i = 0; i < 45; i++) {
await sleep(1000);
const pos = player?.getPosition?.() ?? video?.currentTime ?? progressState.lastPlayingTime;
if (progressState.lastPlayingTime >= total * 0.9 || pos >= total - 5) okProgress = true;
if (await checkIsExamLocal()) {
okProgress = true;
break;
}
}
if (!okProgress) {
await sendProgress(total, total, 1);
await sleep(800);
}
if (typeof window.updatePlayStatus === "function") {
try {
window.updatePlayStatus(1);
} catch (_) {}
}
await sleep(800);
if (typeof window.playEnd === "function") {
try {
window.playEnd();
} catch (_) {}
}
await sleep(1500);
for (let i = 0; i < 20; i++) {
if (await checkIsExamLocal()) return done(true, "\u64ad\u653e\u9875\u5237\u8bfe\u5b8c\u6210(isexam=1)");
await sleep(1500);
}
if (progressState.lastPlayingTime >= total * 0.85) {
return done(true, "\u64ad\u653e\u9875\u8fdb\u5ea6\u5df2\u62a5\u6ee1\uff0c\u7b49\u5f85\u4fa7\u680f\u540c\u6b65");
}
return done(false, "\u64ad\u653e\u9875\u5237\u8bfe\u672a\u5b8c\u6210\uff0c\u8bf7\u624b\u52a8\u70b9\u300c\u770b\u89c6\u9891\u300d\u6216\u8c03\u9ad8\u500d\u901f");
} catch (e) {
done(false, e.message || String(e));
}
}
run();
};
const el = document.createElement("script");
el.textContent =
"(" + code.toString() + ")(" + JSON.stringify(String(courseId)) + "," + JSON.stringify(String(wareId)) + ");";
(document.documentElement || document.head).appendChild(el);
el.remove();
}
function injectPageExamHooks() {
const code = function () {
if (window.__cmeExamHookPatched) return;
if (!/\/cme\/exam\.jsp/i.test(location.pathname)) return;
window.__cmeExamHookPatched = true;
const shouldBlock = (msg) => msg != null && String(msg).indexOf("\u5b66\u4e60\u65f6\u957f\u4e0d\u8db3") !== -1;
const shouldBlockNav = (url) => String(url || "").indexOf("course.jsp") !== -1;
const rawAlert = window.alert.bind(window);
window.alert = function (msg) {
if (shouldBlock(msg)) {
console.log("[CME] \u5df2\u62e6\u622a\u65f6\u957f\u4e0d\u8db3\u5f39\u7a97");
return;
}
return rawAlert(msg);
};
const rawAssign = Location.prototype.assign;
Location.prototype.assign = function (url) {
if (shouldBlockNav(url)) {
console.log("[CME] \u5df2\u62e6\u622a assign \u8df3\u8f6c");
return;
}
return rawAssign.call(this, url);
};
const rawReplace = Location.prototype.replace;
Location.prototype.replace = function (url) {
if (shouldBlockNav(url)) {
console.log("[CME] \u5df2\u62e6\u622a replace \u8df3\u8f6c");
return;
}
return rawReplace.call(this, url);
};
try {
const hrefDesc = Object.getOwnPropertyDescriptor(Location.prototype, "href");
if (hrefDesc && hrefDesc.set) {
const rawHrefSet = hrefDesc.set;
Object.defineProperty(Location.prototype, "href", {
configurable: true,
enumerable: hrefDesc.enumerable,
get: hrefDesc.get,
set(v) {
if (shouldBlockNav(v)) {
console.log("[CME] \u5df2\u62e6\u622a href \u8df3\u8f6c");
return;
}
return rawHrefSet.call(this, v);
},
});
}
} catch (e) {
console.warn("[CME] href hook failed", e);
}
};
const el = document.createElement("script");
el.textContent = "(" + code.toString() + ")();";
(document.documentElement || document.head).appendChild(el);
el.remove();
}
let keepAliveTimer = null;
function syncKeepAlive() {
if (keepAliveTimer) {
clearInterval(keepAliveTimer);
keepAliveTimer = null;
}
if (!state.enabled || getMode() !== "background") return;
keepAliveTimer = setInterval(() => {
try {
const xhr = new XMLHttpRequest();
xhr.open("GET", location.origin + "/cme/sessionKeeper.jsp", true);
xhr.withCredentials = true;
xhr.send();
} catch (_) {}
}, 30000);
}
function pickHtml(html, patterns) {
for (const re of patterns) {
const m = html.match(re);
if (m) return m[1];
}
return "";
}
async function platformFetch(path, options) {
options = options || {};
const p = String(path || "");
if (/saveStudy[36]\.jsp/i.test(p) && !/course_id=\d+/i.test(p)) {
throw new Error("saveStudy \u8bf7\u6c42 course_id \u4e3a\u7a7a: " + p.slice(0, 120));
}
const url = p.startsWith("http") ? p : API_BASE + p;
const res = await fetch(url, {
credentials: "include",
method: options.method || "GET",
headers: Object.assign({ "X-Requested-With": "XMLHttpRequest" }, options.headers || {}),
body: options.body || null,
});
return { status: res.status, text: await res.text(), url: res.url };
}
async function documentFetch(path, options) {
return cmePageFetch(path, options);
}
function isWafRedirectUrl(url) {
const u = String(url || "");
return /qc\d+\.haoyisheng\.com/i.test(u) && !/cmechina\.net/i.test(u);
}
async function cmePageFetch(path, options) {
options = options || {};
const url = String(path || "").startsWith("http") ? path : API_BASE + path;
const headers = Object.assign({ Accept: "text/html,application/xhtml+xml,*/*" }, options.headers || {});
const method = options.method || "GET";
if (typeof GM_xmlhttpRequest === "function") {
const res = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url,
headers,
timeout: 30000,
onload: (r) =>
resolve({
status: r.status,
text: r.responseText || "",
url: r.finalUrl || r.responseURL || url,
}),
onerror: () => reject(new Error("\u7f51\u7edc\u9519\u8bef " + url)),
ontimeout: () => reject(new Error("\u8bf7\u6c42\u8d85\u65f6 " + url)),
});
});
if (isWafRedirectUrl(res.url)) {
throw new Error("\u5e73\u53f0\u5b89\u5168\u9a8c\u8bc1\u8df3\u8f6c\uff0c\u8bf7\u5148\u5728\u6d4f\u89c8\u5668\u6253\u5f00\u8be5\u8bfe\u7a0b\u9875\u5b8c\u6210\u9a8c\u8bc1");
}
return res;
}
const res = await fetch(url, {
credentials: "include",
method,
headers,
body: options.body || null,
redirect: "follow",
});
const out = { status: res.status, text: await res.text(), url: res.url };
if (isWafRedirectUrl(out.url)) {
throw new Error("\u5e73\u53f0\u5b89\u5168\u9a8c\u8bc1\u8df3\u8f6c\uff0c\u8bf7\u5148\u5728\u6d4f\u89c8\u5668\u6253\u5f00\u8be5\u8bfe\u7a0b\u9875\u5b8c\u6210\u9a8c\u8bc1");
}
return out;
}
async function fetchFavoriteHtml() {
if (pageType() === "favorite") return document.documentElement.outerHTML;
const r = await cmePageFetch("/cme/myFavorite.jsp?_=" + Date.now());
return r.text;
}
function parseCoursesFromHtml(html) {
const doc = new DOMParser().parseFromString(html, "text/html");
const map = new Map();
const roots = [
doc.querySelector("table.home_table"),
doc.querySelector(".home_right_text, .home_warp"),
doc.querySelector(".main, .content, #content"),
doc.body,
].filter(Boolean);
for (const root of roots) {
root.querySelectorAll('a[href*="course.jsp"]').forEach((a) => {
if (a.closest("header, .header, .nav, .top, #header, .footer, .menu, .leftnav")) return;
const m = (a.getAttribute("href") || "").match(/course_id=(\d+)/);
if (!m) return;
const id = m[1];
if (map.has(id)) return;
let title = cleanChapterTitle(a.textContent);
if (!title || title.length < 2) {
const row = a.closest("tr, li, .item, td");
title = cleanChapterTitle(row?.textContent || "").slice(0, 80) || "\u8bfe\u7a0b" + id;
}
map.set(id, { courseId: id, title });
});
if (map.size) break;
}
return [...map.values()];
}
function cleanChapterTitle(text) {
return String(text || "")
.replace(/\s+/g, " ")
.replace(/^(\u53bb\u5b66\u4e60|\u8fdb\u5165\u5b66\u4e60|\u5f00\u59cb\u5b66\u4e60|\u64ad\u653e|\u89c2\u770b|\u5f85\u8003\u8bd5|\u5df2\u901a\u8fc7|\u5df2\u5b8c\u6210|\u672a\u5b66\u4e60)\s*/g, "")
.trim();
}
function inferChapterStatus(text) {
const txt = String(text || "");
if (/\bxxz\b|\u5f85\u8003\u8bd5|\u5f85\u8003/.test(txt) && !/\u5df2\u901a\u8fc7|\u5df2\u901a|kstg|\u8003\u8bd5\u901a\u8fc7|\u5b8c\u6210/.test(txt)) return "exam";
if (/\bkstg\b|\u5df2\u901a\u8fc7|\u5df2\u901a|\u8003\u8bd5\u901a\u8fc7|\u5b8c\u6210|100%/.test(txt)) return "done";
if (/\bwxx\b|\u672a\u5b66\u4e60/.test(txt)) return "study";
return "study";
}
function inferStatusFromNode(node) {
if (!node) return "study";
const html = node.innerHTML || "";
if (/\bkstg\b/.test(html) || /\u8003\u8bd5\u901a\u8fc7/.test(html)) return "done";
if (/\bxxz\b/.test(html) || /\u5f85\u8003\u8bd5/.test(html)) return "exam";
if (/\bwxx\b/.test(html) || /\u672a\u5b66\u4e60/.test(html)) return "study";
return inferChapterStatus(node.textContent || "");
}
function parseSidebarItemStatus(li) {
if (!li) return "study";
const iEl = li.querySelector("i");
const tagText = (iEl?.textContent || "").replace(/\s+/g, "").trim();
if (/\u8003\u8bd5\u901a\u8fc7|\u5df2\u901a\u8fc7|\u5df2\u901a/.test(tagText)) return "done";
if (/\u5f85\u8003\u8bd5|\u5f85\u8003/.test(tagText)) return "exam";
if (/\u672a\u5b66\u4e60/.test(tagText)) return "study";
return inferStatusFromNode({
innerHTML: (iEl?.outerHTML || "") + " " + li.innerHTML,
textContent: iEl?.textContent || li.textContent || "",
});
}
function parseSidebarStatusMap(html) {
const map = {};
if (!html) return map;
const doc = new DOMParser().parseFromString(html, "text/html");
doc.querySelectorAll("#s_r_ml li, ul.s_r_ml li").forEach((li) => {
let wareId = "";
const idm = (li.id || "").match(/^li0*(\d+)$/i);
if (idm) wareId = idm[1].padStart(2, "0");
if (!wareId) {
const m = (li.innerHTML || "").match(/courseware_id=0*(\d+)/i);
if (m) wareId = m[1].padStart(2, "0");
}
if (!wareId) return;
const status = parseSidebarItemStatus(li);
for (const k of wareIdVariants(wareId)) map[k] = status;
});
return map;
}
const sidebarStatusCache = {};
function invalidateSidebarStatusMap(csId) {
delete sidebarStatusCache[String(csId || "")];
}
async function getSidebarStatusMap(csId, force) {
const key = String(csId || "");
if (force) invalidateSidebarStatusMap(csId);
const hit = sidebarStatusCache[key];
if (!force && hit && Date.now() - hit.t < 15000) return hit.map;
let map = {};
for (const probe of ["01", "05", "06", "09", "10"]) {
try {
const r = await cmePageFetch(
`/cme/study2.jsp?course_id=${csId}&courseware_id=${probe}&_=${Date.now()}`
);
map = parseSidebarStatusMap(r.text);
if (Object.keys(map).length >= 2) break;
} catch (_) {}
}
sidebarStatusCache[key] = { map, t: Date.now() };
return map;
}
async function querySidebarStatus(csId, wareId) {
invalidateSidebarStatusMap(csId);
const wid = String(wareId || "");
for (const probe of wareIdVariants(wid)) {
try {
const r = await cmePageFetch(
`/cme/study2.jsp?course_id=${csId}&courseware_id=${probe}&_=${Date.now()}`
);
const map = parseSidebarStatusMap(r.text);
const st = resolveChapterStatus(probe, "", map);
if (st) return st;
} catch (_) {}
}
const map = await getSidebarStatusMap(csId, true);
return resolveChapterStatus(wid, "", map);
}
function resolveChapterStatus(wareId, courseStatus, sidebarMap) {
for (const k of wareIdVariants(wareId)) {
if (sidebarMap[k]) return sidebarMap[k];
}
return courseStatus || "study";
}
function parseKjJumpToChapters(html, courseId) {
const cid = String(courseId || "");
const chapters = [];
const seen = new Set();
const productMap = {};
for (const m of String(html || "").matchAll(
/exam\.jsp\?course_id=\d+&paper_id=([^&"']+)[^"']*product_id=(\d+)/gi
)) {
productMap[m[1]] = m[2];
}
const re = /kjJumpTo\s*\(\s*['"]([^'"]+)['"]\s*,\s*['"]([^'"]+)['"](?:\s*,\s*['"]([^'"]+)['"])?/gi;
for (const m of html.matchAll(re)) {
const url = m[1];
const arg2 = m[2];
const arg3 = m[3];
if (!/study2\.jsp|polyv\.jsp/i.test(url)) continue;
const cidMatch = url.match(/course_id=(\d+)/i);
if (cidMatch && cidMatch[1] !== cid) continue;
const wm = url.match(/courseware_id=([^&'"]+)/i);
if (!wm) continue;
const wareId = wm[1];
if (seen.has(wareId)) continue;
seen.add(wareId);
let title = arg2;
if (arg2 === wareId || /^\d{1,3}$/.test(arg2)) title = arg3 || arg2;
title = cleanChapterTitle(title);
if (/^\u7b2c\d+\u8282/.test(title)) {
title = title.replace(/^\u7b2c\d+\u8282\s*/, "").trim() || title;
}
chapters.push({
wareId,
productId: productMap[wareId] || "",
title: title || "\u8bfe\u4ef6 " + wareId,
status: "study",
});
}
return chapters;
}
function parseWareTitleFromHtml(html, wareId) {
const wid = String(wareId || "");
const m1 = html.match(
new RegExp("\u8bfe\u4ef6id['\":\\s]+['\"]?" + wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "['\"]?[^'\"]{0,40}\u8bfe\u4ef6\u540d\u79f0['\":\\s]+['\"]([^'\"]+)['\"]", "i")
);
if (m1) return cleanChapterTitle(m1[1]);
const m2 = html.match(
new RegExp("courseware_id[=:]['\"]?" + wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "['\"]?[^'\"]{0,80}['\"]([^'\"]{2,60})['\"]", "i")
);
if (m2) return cleanChapterTitle(m2[1]);
return "";
}
function parseChaptersFromCourseHtml(html, courseId) {
const cid = String(courseId || "");
const doc = new DOMParser().parseFromString(html, "text/html");
const chapters = parseKjJumpToChapters(html, courseId);
const byWare = new Map(chapters.map((c) => [c.wareId, c]));
doc.querySelectorAll("li.course_list, #s_r_ml li, .course_box li").forEach((li) => {
const onclick = li.getAttribute("onclick") || li.innerHTML || "";
const m = onclick.match(/kjJumpTo\s*\(\s*['"]([^'"]+)['"]/i) || li.innerHTML.match(/kjJumpTo\s*\(\s*['"]([^'"]+)['"]/i);
if (!m) return;
const url = m[1];
if (!/study2|polyv/i.test(url)) return;
const cidMatch = url.match(/course_id=(\d+)/i);
if (cidMatch && cidMatch[1] !== cid) return;
const wm = url.match(/courseware_id=([^&'"]+)/i);
if (!wm) return;
const wareId = wm[1];
const status = inferStatusFromNode(li);
const a = li.querySelector("a");
let title = cleanChapterTitle(a?.textContent || "");
title = title.replace(/^\u7b2c\d+\u8282\s*/, "").trim();
const existing = byWare.get(wareId);
if (existing) {
if (title && title.length > 1 && title.length < 80) existing.title = title;
existing.status = status;
} else {
byWare.set(wareId, {
wareId,
productId: "",
title: title || "\u8bfe\u4ef6 " + wareId,
status,
});
}
});
const out = [...byWare.values()].sort((a, b) =>
String(a.wareId).localeCompare(String(b.wareId), undefined, { numeric: true })
);
for (const ch of out) {
if (!ch.title || ch.title.startsWith("\u8bfe\u4ef6 ")) {
const t = parseWareTitleFromHtml(html, ch.wareId);
if (t) ch.title = t;
}
}
return out;
}
async function fetchCourseHtml(courseId) {
if (pageType() === "course" && getCourseParams().csId === String(courseId)) {
return document.documentElement.outerHTML;
}
const r = await cmePageFetch(`/cme/course.jsp?course_id=${courseId}&_=${Date.now()}`);
return r.text;
}
async function probeWareFromStudy2(courseId, wareId) {
const r = await cmePageFetch(
`/cme/study2.jsp?course_id=${courseId}&courseware_id=${wareId}&_=${Date.now()}`
);
const html = r.text;
if (/\u8bfe\u7a0b\u4e0d\u5b58\u5728|\u672a\u627e\u5230|\u9519\u8bef\u9875\u9762|\u8bf7\u5148\u767b\u5f55/i.test(html)) return null;
const sidebarMap = parseSidebarStatusMap(html);
const sidebarStatus = sidebarMap[wareId] || sidebarMap[String(parseInt(wareId, 10))];
if (!/total_time\s*[=:]\s*\d+/i.test(html) && !/updatePlayStatus/i.test(html)) return null;
const productId = pickHtml(html, [/product_id=(\d+)/i]) || "";
const title = parseWareTitleFromHtml(html, wareId) || "\u8bfe\u4ef6 " + wareId;
let status = sidebarStatus || "study";
if (!sidebarStatus) {
if (/\u5f85\u8003\u8bd5/.test(html) && !/\u5df2\u901a\u8fc7|\u5df2\u901a/.test(html)) status = "exam";
else if (/\u5df2\u901a\u8fc7|\u5df2\u901a|kstg|play100/.test(html)) status = "done";
}
return { html, wareId: String(wareId), productId, title, status };
}
async function fetchCourseChapters(courseId, depth) {
depth = depth || 0;
if (!courseId || depth > 2) return [];
const html = await fetchCourseHtml(courseId);
let chapters = parseChaptersFromCourseHtml(html, courseId);
if (!chapters.length) {
for (const wid of ["01", "02", "03", "04", "05", "06", "07", "08", "09", "10"]) {
try {
const probe = await probeWareFromStudy2(courseId, wid);
if (!probe) continue;
const fromStudy2 = parseChaptersFromCourseHtml(probe.html, courseId);
if (fromStudy2.length) {
chapters = fromStudy2;
break;
}
if (!chapters.some((c) => c.wareId === wid)) {
chapters.push({
wareId: wid,
productId: probe.productId,
title: probe.title,
status: probe.status,
});
}
} catch (_) {}
}
chapters.sort((a, b) => String(a.wareId).localeCompare(String(b.wareId), undefined, { numeric: true }));
}
return chapters;
}
function parseStudyMetaFromHtml(html, courseId, wareId) {
const cid = String(courseId || "");
const wid = String(wareId || "").padStart(2, "0");
const loginName = pickHtml(html, [
/var\s+loginName\s*=\s*['"]([^'"]+)['"]/i,
/loginName\s*=\s*['"]([^'"]+)['"]/i,
/loginName=([^&"'\s]+)/i,
/login_name=([^&"'\s]+)/i,
/updatePlayStatus\.jsp\?loginName=([^&]+)/i,
]);
const batchId = pickHtml(html, [
/batchid\s*:\s*['"]([a-f0-9-]{36})['"]/i,
/batch_id=([a-f0-9-]{36})/i,
/batch_id\s*[=:]\s*['"]([a-f0-9-]{36})['"]/i,
/batchid\s*[=:]\s*['"]([a-f0-9-]{36})['"]/i,
]);
const pageWareId = pickHtml(html, [/var\s+courseware_id\s*=\s*['"](\d+)['"]/i, /courseware_id\s*=\s*['"](\d+)['"]/i]);
const resolvedWare = pageWareId ? String(pageWareId).padStart(2, "0") : wid;
const totalTime = parseInt(
pickHtml(html, [
/var\s+total_time\s*=\s*['"](\d+)['"]/i,
/total_time\s*=\s*['"](\d+)['"]/i,
/totalTime\s*:\s*['"](\d+)['"]/i,
/total_time\s*[=:]\s*['"]?(\d+)/i,
/totalTime\s*[=:]\s*['"]?(\d+)/i,
]) || "3600",
10
);
const userid = pickHtml(html, [
/var\s+user_id\s*=\s*['"]([a-f0-9]+)['"]/i,
/var\s+user_id\s*=\s*['"]([a-f0-9]+)/i,
/userid\s*[=:]\s*['"]([a-f0-9]+)['"]/i,
/userid=([a-f0-9]+)/i,
]);
const timeStamp = pickHtml(html, [
/var\s+timeStamp\s*=\s*['"](\d+)['"]/i,
/timeStamp\s*[=:]\s*['"]?(\d+)/i,
]);
const sign = pickHtml(html, [
/var\s+sign\s*=\s*['"]([a-f0-9]{32})['"]/i,
/sign\s*[=:]\s*['"]([a-f0-9]{32})['"]/i,
]);
const clientIp = pickHtml(html, [/updatePlayStatus\.jsp[^"']*?ip=([\d.]+)/i, /[?&]ip=([\d.]+)&/i]);
let saveStartUrl =
(html.match(/(\/qypx\/bj\/saveStudy6\.jsp\?course_id=\d+[^"'\s<>]*)/i) || [])[1] ||
(html.match(/(https?:\/\/[^"'\s<>]*saveStudy6\.jsp\?course_id=\d+[^"'\s<>]*)/i) || [])[1] ||
"";
let saveEndUrl =
(html.match(/(\/qypx\/bj\/saveStudy3\.jsp\?course_id=\d+[^"'\s<>]*)/i) || [])[1] ||
(html.match(/(https?:\/\/[^"'\s<>]*saveStudy3\.jsp\?course_id=\d+[^"'\s<>]*)/i) || [])[1] ||
"";
if (!saveStartUrl && userid && sign && timeStamp && cid && resolvedWare) {
saveStartUrl = `/qypx/bj/saveStudy6.jsp?course_id=${cid}&cware_id=${resolvedWare}&userid=${userid}&timeStamp=${timeStamp}&sign=${sign}`;
saveEndUrl = `/qypx/bj/saveStudy3.jsp?course_id=${cid}&cware_id=${resolvedWare}&userid=${userid}&timeStamp=${timeStamp}&sign=${sign}`;
}
const productId = pickHtml(html, [
new RegExp(
"exam\\.jsp\\?course_id=" +
cid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
"&paper_id=" +
resolvedWare.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +
"[^\"']*product_id=(\\d+)",
"i"
),
/product_id=(\d+)/i,
]);
return {
html,
loginName,
batchId,
totalTime: Number.isFinite(totalTime) && totalTime > 0 ? totalTime : 3600,
productId,
userid,
timeStamp,
sign,
clientIp,
saveStartUrl,
saveEndUrl,
courseId: cid,
wareId: resolvedWare,
};
}
function syncMetaToCapture(meta, opts) {
opts = opts || {};
if (!meta?.loginName || !meta?.batchId) return;
const existing = readProgressCapture(meta.courseId, meta.wareId);
const apiSt = loadApiProgressState(meta.courseId, meta.wareId);
const lockedBatch =
apiSt?.batchId ||
(existing?.sign && existing?.batchId && Number(existing.playingTime) >= 0 ? existing.batchId : null);
if (lockedBatch && lockedBatch !== meta.batchId && !opts.force) return;
const playingTime =
existing?.batchId === meta.batchId && Number(existing.playingTime) > 0
? Number(existing.playingTime)
: lockedBatch === meta.batchId && Number(existing?.playingTime) > 0
? Number(existing.playingTime)
: 0;
const patch = {
courseId: String(meta.courseId || existing?.courseId || ""),
loginName: meta.loginName,
batchId: meta.batchId,
totalTime: meta.totalTime,
playingTime,
ip: meta.clientIp || existing?.ip || "",
};
if (existing?.sign && existing.batchId === meta.batchId) {
patch.userid = existing.userid;
patch.timeStamp = existing.timeStamp;
patch.sign = existing.sign;
} else if (meta.userid && meta.sign && meta.timeStamp) {
patch.userid = meta.userid;
patch.timeStamp = meta.timeStamp;
patch.sign = meta.sign;
}
saveProgressCapture(meta.courseId, meta.wareId, patch);
}
async function fetchStudyMeta(courseId, wareId, opts) {
opts = opts || {};
const wid = String(wareId || "");
const tryIds = [...new Set([wid, wid.padStart(2, "0"), wid.replace(/^0+/, "") || wid])];
const referer = API_BASE + `/cme/course.jsp?course_id=${courseId}`;
let last = null;
for (const id of tryIds) {
const r = await documentFetch(`/cme/study2.jsp?course_id=${courseId}&courseware_id=${id}&_=${Date.now()}`, {
headers: { Referer: referer },
});
const html = r.text;
if (/\u8bf7\u5148\u767b\u5f55|\u767b\u5f55\u8d85\u65f6|\u672a\u767b\u5f55/i.test(html)) throw new Error("\u672a\u767b\u5f55\u6216\u4f1a\u8bdd\u5931\u6548");
const meta = parseStudyMetaFromHtml(html, courseId, id);
last = meta;
if (meta.loginName && meta.batchId) {
if (!opts.skipCaptureSync) syncMetaToCapture(meta);
return meta;
}
}
if (last?.loginName && !last.batchId) {
log(`study2 \u5df2\u89e3\u6790 loginName=${last.loginName}\uff0c\u4f46\u672a\u627e\u5230 batch_id`);
}
return last || parseStudyMetaFromHtml("", courseId, wid);
}
function study2Referer(meta) {
const cid = meta.courseId || "";
const wid = normalizeWareId(meta.wareId || "01");
return API_BASE + `/cme/study2.jsp?course_id=${cid}&courseware_id=${wid}`;
}
async function reportProgress(meta, playingTime, status, opts) {
opts = opts || {};
if (!meta.loginName || !meta.batchId) throw new Error("\u7f3a\u5c11 loginName/batch_id");
const u = new URL("/webcam/updatePlayStatus.jsp", location.origin);
u.searchParams.set("loginName", meta.loginName);
u.searchParams.set("course_id", meta.courseId);
u.searchParams.set("ware_id", normalizeWareId(meta.wareId));
u.searchParams.set("batch_id", meta.batchId);
u.searchParams.set("playing_time", String(Math.floor(playingTime)));
u.searchParams.set("total_time", String(Math.floor(meta.totalTime)));
u.searchParams.set("logType", "0");
u.searchParams.set("status", String(status == null ? 1 : status));
u.searchParams.set("ip", opts.ip || meta.clientIp || "127.0.0.1");
u.searchParams.set("t", String(Date.now()));
const r = await platformFetch(u.pathname + "?" + u.searchParams.toString(), {
headers: { Referer: study2Referer(meta) },
});
return r.text;
}
function parseApiState(text) {
const raw = String(text || "").trim();
try {
const j = JSON.parse(raw);
if (j.state != null) return Number(j.state);
if (j.status != null) return Number(j.status);
} catch (_) {}
const m = raw.match(/"state"\s*:\s*(\d+)/);
return m ? Number(m[1]) : NaN;
}
async function buildLockedStudyMeta(csId, wareId, cap0, apiSt0) {
const wid = normalizeWareId(wareId);
if (apiSt0?.batchId && cap0?.sign && cap0.batchId === apiSt0.batchId) {
return studyMetaFromCapture(cap0, csId, wid);
}
if (cap0?.sign && cap0?.batchId && Number(cap0.playingTime) > 0) {
return studyMetaFromCapture(cap0, csId, wid);
}
const fetched = await fetchStudyMeta(csId, wareId, { skipCaptureSync: !!(apiSt0?.batchId || cap0?.sign) });
let locked = mergeStudyCredentials(fetched, cap0);
if (apiSt0?.batchId) locked.batchId = apiSt0.batchId;
else if (cap0?.batchId && cap0?.sign) locked.batchId = cap0.batchId;
return locked;
}
const apiStudyRunGates = new Map();
async function withApiStudyRunLock(csId, wareId, fn) {
const key = String(csId) + "_" + normalizeWareId(wareId);
while (apiStudyRunGates.has(key)) {
await apiStudyRunGates.get(key);
}
let release;
const gate = new Promise((resolve) => {
release = resolve;
});
apiStudyRunGates.set(key, gate);
try {
return await fn();
} finally {
apiStudyRunGates.delete(key);
release();
}
}
async function pollIsExamReady(csId, wareId, opts) {
opts = opts || {};
const times = opts.times || ISEXAM_POLL_TIMES;
const interval = opts.intervalMs || ISEXAM_POLL_INTERVAL_MS;
clearIsExamCache(csId, wareId);
for (let i = 0; i < times; i++) {
if (await checkIsExam(csId, wareId)) return true;
if (i < times - 1) await sleep(interval);
}
return false;
}
function isValidSaveStudyUrl(url, jspName) {
if (!url || typeof url !== "string") return false;
if (/\{|\}/.test(url)) return false;
if (!url.includes(jspName)) return false;
if (!/course_id=\d+/i.test(url)) return false;
if (!/sign=[a-f0-9]{32}/i.test(url)) return false;
if (!/userid=[a-f0-9]+/i.test(url)) return false;
return true;
}
function normalizeSaveStudyPath(url) {
let p = String(url || "").replace(/^https?:\/\/[^/]+/i, "");
if (!p.startsWith("/")) p = "/" + p;
return p.split("&_=")[0];
}
function buildSaveStudyPath(meta, jsp) {
const cid = String(meta.courseId || "").trim();
const wid = normalizeWareId(meta.wareId || "01");
const uid = String(meta.userid || "").trim();
const ts = String(meta.timeStamp || "").trim();
const sign = String(meta.sign || "").trim();
if (!/^\d+$/.test(cid)) return "";
if (!uid || !ts || !/^[a-f0-9]{32}$/i.test(sign)) return "";
return `/qypx/bj/${jsp}?course_id=${cid}&cware_id=${wid}&userid=${uid}&timeStamp=${ts}&sign=${sign}&_=${Date.now()}`;
}
function ensureStudyMeta(meta, csId, wareId) {
const m = Object.assign({}, meta || {});
const cid = String(csId || m.courseId || m.csId || "").trim();
if (/^\d+$/.test(cid)) m.courseId = cid;
if (wareId != null && wareId !== "") m.wareId = normalizeWareId(wareId);
delete m.saveEndUrl;
delete m.saveStartUrl;
return m;
}
function resolveSaveStudyPath(meta, jsp) {
return buildSaveStudyPath(meta, jsp);
}
function mergeStudyCredentials(meta, capture) {
if (!capture) return meta;
const m = Object.assign({}, meta);
if (capture.batchId) m.batchId = capture.batchId;
if (capture.loginName) m.loginName = capture.loginName;
if (capture.userid) m.userid = capture.userid;
if (capture.timeStamp) m.timeStamp = capture.timeStamp;
if (capture.sign && /^[a-f0-9]{32}$/i.test(String(capture.sign))) m.sign = capture.sign;
if (capture.totalTime) m.totalTime = capture.totalTime;
if (capture.ip) m.clientIp = capture.ip;
if (!m.courseId && capture.courseId && /^\d+$/.test(String(capture.courseId))) {
m.courseId = String(capture.courseId);
}
delete m.saveEndUrl;
delete m.saveStartUrl;
return m;
}
function studyMetaFromCapture(cap, csId, wareId) {
return mergeStudyCredentials(
{
courseId: String(csId),
wareId: normalizeWareId(wareId),
totalTime: Number(cap?.totalTime) || 3600,
},
cap
);
}
async function resolveStudyMetaForStep(csId, wareId, opts) {
const wid = String(wareId || "");
const st = loadApiProgressState(csId, wid);
const capture = readProgressCapture(csId, wid);
if (opts?.lockedMeta) {
const m = Object.assign({}, opts.lockedMeta);
if (st?.batchId) m.batchId = st.batchId;
else if (capture?.batchId) m.batchId = capture.batchId;
m.wareId = st?.wareId || m.wareId || wid;
m.courseId = String(csId);
return mergeStudyCredentials(m, capture);
}
const meta = await fetchStudyMeta(csId, wareId, {
skipCaptureSync: !!(st?.batchId || capture?.sign),
});
let merged = meta;
if (st?.batchId) merged = Object.assign({}, meta, { batchId: st.batchId });
else if (capture?.batchId) merged = Object.assign({}, meta, { batchId: capture.batchId });
return mergeStudyCredentials(merged, capture);
}
function progressCaptureKey(csId, wareId) {
return "cme_progress_capture_" + csId + "_" + normalizeWareId(wareId);
}
function readProgressCapture(csId, wareId) {
for (const w of [...new Set(wareIdVariants(wareId).map(normalizeWareId))]) {
const raw = localStorage.getItem("cme_progress_capture_" + csId + "_" + w);
if (!raw) continue;
try {
const c = JSON.parse(raw);
if (c && c.batchId) return c;
} catch (_) {}
}
return null;
}
function saveProgressCapture(csId, wareId, data) {
const prev = readProgressCapture(csId, wareId) || {};
localStorage.setItem(
progressCaptureKey(csId, wareId),
JSON.stringify(Object.assign({}, prev, data, { t: Date.now() }))
);
}
function markApiReportedFull(csId, wareId, cap) {
localStorage.setItem(
API_FULL_KEY_PREFIX + csId + "_" + normalizeWareId(wareId),
JSON.stringify({
batchId: cap?.batchId,
totalTime: cap?.totalTime,
loginName: cap?.loginName,
t: Date.now(),
})
);
}
function isApiReportedFull(csId, wareId) {
return !!localStorage.getItem(API_FULL_KEY_PREFIX + csId + "_" + normalizeWareId(wareId));
}
function clearApiReportedFull(csId, wareId) {
for (const w of [...new Set(wareIdVariants(wareId).map(normalizeWareId))]) {
localStorage.removeItem(API_FULL_KEY_PREFIX + csId + "_" + w);
}
}
function shouldOnlyWaitIsExam(csId, wareId) {
return isApiReportedFull(csId, wareId) || isChapterProgressFull(csId, wareId);
}
function isChapterProgressFull(csId, wareId) {
const cap = readProgressCapture(csId, wareId);
if (!cap?.batchId || !Number(cap.totalTime)) return false;
return Number(cap.playingTime) >= Number(cap.totalTime) * 0.99;
}
async function softBoostIsExamAfterFull(csId, wareId) {
const cap = readProgressCapture(csId, wareId);
if (!cap?.batchId || !cap?.sign) return false;
const meta = studyMetaFromCapture(cap, csId, wareId);
if (!meta.loginName) return false;
const ip = cap.ip || meta.clientIp || "127.0.0.1";
meta.clientIp = ip;
await reportProgress(meta, cap.totalTime, 5, { ip });
await sleep(600);
await pingCcState(meta, 2);
await callSaveStudyEnd(meta, csId, wareId);
clearIsExamCache(csId, wareId);
invalidateSidebarStatusMap(csId);
return pollIsExamReady(csId, wareId, { times: 12, intervalMs: 1500 });
}
function loadApiProgressState(csId, wareId) {
for (const w of wareIdVariants(wareId)) {
const raw = localStorage.getItem(API_PROGRESS_KEY_PREFIX + csId + "_" + w);
if (!raw) continue;
try {
return JSON.parse(raw);
} catch (_) {}
}
return null;
}
function saveApiProgressState(st) {
localStorage.setItem(
API_PROGRESS_KEY_PREFIX + st.csId + "_" + normalizeWareId(st.wareId),
JSON.stringify({ ...st, wareId: normalizeWareId(st.wareId) })
);
}
function clearApiProgressState(csId, wareId) {
for (const w of wareIdVariants(wareId)) {
localStorage.removeItem(API_PROGRESS_KEY_PREFIX + csId + "_" + w);
}
}
function studyDoneSignalKey(courseId, wareId) {
return `cme_study_done_${courseId}_${wareId}`;
}
function chapterProgressKey(courseId, wareId) {
return `${CHAPTER_PROGRESS_PREFIX}${courseId}_${wareId}`;
}
function markChapterProgressReported(courseId, wareId) {
for (const w of wareIdVariants(wareId)) {
localStorage.setItem(chapterProgressKey(courseId, w), String(Date.now()));
}
}
function isChapterProgressReported(courseId, wareId) {
for (const w of wareIdVariants(wareId)) {
if (localStorage.getItem(chapterProgressKey(courseId, w))) return true;
}
return false;
}
function clearChapterProgressReported(courseId, wareId) {
for (const w of wareIdVariants(wareId)) {
localStorage.removeItem(chapterProgressKey(courseId, w));
}
}
function wareIdsMatch(a, b) {
if (!a || !b) return false;
const av = wareIdVariants(a);
const bv = wareIdVariants(b);
return av.some((x) => bv.includes(x));
}
async function waitStudyDoneSignal(courseId, wareId, timeoutMs) {
const deadline = Date.now() + (timeoutMs || 900000);
while (Date.now() < deadline) {
for (const w of wareIdVariants(wareId)) {
const raw = localStorage.getItem(studyDoneSignalKey(courseId, w));
if (!raw) continue;
try {
const j = JSON.parse(raw);
if (j && j.t && Date.now() - j.t < 900000) return j;
} catch (_) {}
}
await sleep(2000);
}
return null;
}
function shouldRunPageStudyOnThisTab() {
const { csId, wareId } = getCourseParams();
if (!csId) return null;
const wid = wareId || "01";
const pending = (() => {
try {
return JSON.parse(localStorage.getItem(PAGE_STUDY_PENDING_KEY) || "null");
} catch (_) {
return null;
}
})();
if (pending && pending.csId === csId && wareIdsMatch(pending.wareId, wid)) {
return { csId, wareId: wid, reason: "pending" };
}
if (localStorage.getItem(STORAGE_KEY) !== "1") return null;
const bg = loadBgState();
if (bg.phase !== "study" || bg.csId !== csId) return null;
const cur = bg.videos?.[bg.videoIndex || 0];
if (cur?.resId && wareIdsMatch(cur.resId, wid)) {
return { csId, wareId: wid, reason: "bg" };
}
return null;
}
function initStudyPageWorker() {
if (pageType() !== "courseplay") return;
const hit = shouldRunPageStudyOnThisTab();
if (!hit) return;
for (const w of wareIdVariants(hit.wareId)) {
const sig = localStorage.getItem(studyDoneSignalKey(hit.csId, w));
if (sig) {
try {
const j = JSON.parse(sig);
if (j?.ok && j.t && Date.now() - j.t < 120000) return;
} catch (_) {}
}
}
const start = () => {
log(`\u7b2c${formatWareNo(hit.wareId)}\u8282 \u64ad\u653e\u9875\u5f15\u64ce\u542f\u52a8\uff08OpenScript \u8df3\u81f3\u7247\u5c3e\uff09…`);
injectPageStudyComplete(hit.csId, hit.wareId);
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => setTimeout(start, 1500));
} else {
setTimeout(start, 1500);
}
}
function readRecentStudyDone(csId, wareId) {
for (const w of wareIdVariants(wareId)) {
const raw = localStorage.getItem(studyDoneSignalKey(csId, w));
if (!raw) continue;
try {
const j = JSON.parse(raw);
if (j?.ok && j.t && Date.now() - j.t < 600000) return j;
} catch (_) {}
}
return null;
}
async function navigateToPageStudy(csId, video) {
const wid = String(video.cptId || video.resId);
localStorage.setItem(PAGE_STUDY_PENDING_KEY, JSON.stringify({ csId, wareId: wid, t: Date.now() }));
localStorage.setItem(RETURN_URL_AFTER_STUDY_KEY, location.href);
for (const w of wareIdVariants(wid)) localStorage.removeItem(studyDoneSignalKey(csId, w));
markStudySessionStart(csId, wid);
const url = API_BASE + courseplayPath(csId, "", wid, video.productId);
log(`\u7b2c${formatWareNo(wid)}\u8282 \u8df3\u8f6c\u64ad\u653e\u9875\u5237\u8bfe\uff08\u5e73\u53f0\u987b\u5728 study2 \u64ad\u653e\u9875\u4e0a\u62a5\uff0c\u5b8c\u6210\u540e\u56de\u6536\u85cf\u9875\uff09…`);
location.href = url;
await new Promise(() => {});
}
async function tryDelegatedPageStudy(csId, video) {
const wid = String(video.cptId || video.resId);
if (pageType() === "courseplay" && getCourseParams().csId === csId && wareIdsMatch(getCourseParams().wareId, wid)) {
localStorage.removeItem(studyDoneSignalKey(csId, wid));
injectPageStudyComplete(csId, wid);
log(`\u7b2c${formatWareNo(wid)}\u8282 \u5f53\u524d\u9875\u6267\u884c v1.4.1 \u5237\u8bfe…`);
const sig = await waitStudyDoneSignal(csId, wid, 900000);
if (sig?.ok) {
markChapterProgressReported(csId, wid);
return true;
}
if (sig) log(`\u7b2c${formatWareNo(wid)}\u8282 \u9875\u9762\u5237\u8bfe\u5931\u8d25\uff1a${sig.msg || "\u672a\u77e5"}`);
return false;
}
const url = API_BASE + courseplayPath(csId, "", wid, video.productId);
localStorage.setItem(PAGE_STUDY_PENDING_KEY, JSON.stringify({ csId, wareId: wid, t: Date.now() }));
for (const w of wareIdVariants(wid)) localStorage.removeItem(studyDoneSignalKey(csId, w));
try {
GM_openInTab(url, { active: true, insert: true, setParent: true });
} catch (_) {
window.open(url, "_blank");
}
log(`\u7b2c${formatWareNo(wid)}\u8282 \u5df2\u6253\u5f00\u64ad\u653e\u9875\uff08\u8bf7\u4fdd\u6301\u8be5\u6807\u7b7e\u9875\u5728\u524d\u53f0\u76f4\u81f3\u5237\u5b8c\uff09…`);
const sig = await waitStudyDoneSignal(csId, wid, 900000);
localStorage.removeItem(PAGE_STUDY_PENDING_KEY);
if (sig?.ok) {
markChapterProgressReported(csId, wid);
return true;
}
if (sig) log(`\u7b2c${formatWareNo(wid)}\u8282 \u9875\u9762\u5237\u8bfe\u5931\u8d25\uff1a${sig.msg || "\u672a\u77e5"}\uff0c\u6539 API \u5237\u8bfe`);
return false;
}
function studySessionKey(courseId, wareId) {
return `${STUDY_SESSION_PREFIX}${courseId}_${wareId}`;
}
function markStudySessionStart(courseId, wareId) {
const key = studySessionKey(courseId, wareId);
if (!localStorage.getItem(key)) localStorage.setItem(key, String(Date.now()));
return parseInt(localStorage.getItem(key) || String(Date.now()), 10);
}
function getStudyWaitInfo(courseId, wareId, totalSec) {
const key = studySessionKey(courseId, wareId);
const start = parseInt(localStorage.getItem(key) || String(Date.now()), 10);
const elapsed = Math.floor((Date.now() - start) / 1000);
const need = Number(totalSec) || 0;
const remain = Math.max(0, need - elapsed);
return { start, elapsed, need, remain, timeReady: remain <= 0 };
}
async function checkChapterExamReady(courseId, wareId, totalSec) {
let videoDone = false;
for (const pid of wareIdVariants(wareId)) {
if (await checkIsExam(courseId, pid)) {
videoDone = true;
break;
}
}
const wait = getStudyWaitInfo(courseId, wareId, totalSec);
return {
videoDone,
timeReady: wait.timeReady,
canEnterExam: videoDone,
remain: wait.remain,
need: wait.need,
elapsed: wait.elapsed,
};
}
function isWrongExamH3(el) {
if (!el) return false;
return el.classList.contains("cuo") || /\bcuo\b/.test(el.className);
}
function isCorrectExamH3(el) {
if (!el) return false;
return el.classList.contains("dui") || /\bdui\b/.test(el.className);
}
function extractQuesIdFromRadioName(name) {
const m = String(name || "").match(/^ques_(.+)$/i);
return m ? m[1] : "";
}
function multiAnswerCombos(options) {
const opts = (options && options.length ? options : ["A", "B", "C", "D", "E"]).map(String);
const combos = [];
for (let mask = 1; mask < 1 << opts.length; mask++) {
const picked = [];
for (let i = 0; i < opts.length; i++) {
if (mask & (1 << i)) picked.push(opts[i]);
}
combos.push(picked.join(","));
}
return combos;
}
function splitMultiAnswer(value) {
return String(value || "")
.split(/[,\uff0c]/)
.map((s) => s.trim())
.filter(Boolean);
}
function joinMultiAnswer(values) {
return splitMultiAnswer(Array.isArray(values) ? values.join(",") : values).join(",");
}
function nextAnswerOption(current, options, multi) {
if (multi) {
const combos = multiAnswerCombos(options);
const cur = joinMultiAnswer(current);
const idx = combos.indexOf(cur);
if (idx >= 0 && idx + 1 < combos.length) return combos[idx + 1];
for (const c of combos) {
if (c !== cur) return c;
}
return combos[0] || "A";
}
const opts =
options && options.length ? options.map(String) : ["A", "B", "C", "D", "E"];
const cur = String(current || opts[0] || "A");
const idx = opts.indexOf(cur);
if (idx >= 0) return opts[(idx + 1) % opts.length];
return nextExamChoice(cur);
}
function pickAnswerForQuestion(state, quesId, options, multi) {
const opts =
options && options.length ? options.map(String) : ["A", "B", "C", "D", "E"];
if (multi) {
const combos = multiAnswerCombos(opts);
const locked = state.byQuesId[quesId];
if (locked && combos.includes(joinMultiAnswer(locked))) return joinMultiAnswer(locked);
const tried = ensureTriedSet(state, quesId);
for (const c of combos) {
if (!tried.has(c)) return c;
}
return combos[state.rotateIdx++ % combos.length] || combos[0] || "A";
}
const locked = state.byQuesId[quesId];
if (locked && opts.includes(String(locked))) return String(locked);
const tried = ensureTriedSet(state, quesId);
for (const o of opts) {
if (!tried.has(o)) return o;
}
return opts[state.rotateIdx++ % opts.length] || "A";
}
function readExamQuesList(form) {
const inp = form?.querySelector('input[name="ques_list"]');
if (!inp?.value) return [];
return String(inp.value)
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
function readExamAnswNumList(form) {
const inp = form?.querySelector('input[name="answ_num_list"]');
if (!inp?.value) return [];
return String(inp.value)
.split(",")
.map((s) => parseInt(String(s).trim(), 10) || 0)
.filter((n) => n > 0);
}
function normalizeExamHtml(html) {
let text = String(html || "");
text = text.replace(/^[\s\S]*?(?=
/examDo|exam_list|ques_list|form1/.test(head) ? head : ""
);
const chunks = [];
const re = /]*>[\s\S]*?<\/html\s*>|][\s\S]*?<\/html\s*>/gi;
let m;
while ((m = re.exec(text)) !== null) chunks.push(m[0]);
if (chunks.length <= 1) return text.trim() || html;
let best = chunks[chunks.length - 1];
let bestScore = -1;
for (const chunk of chunks) {
if (!/examDo\.jsp|name=["']form1["']/i.test(chunk)) continue;
const ids = (chunk.match(/name=["']ques_list["'][^>]*value=([^>\s"']+)/i) || [])[1];
const idN = ids ? ids.split(",").filter(Boolean).length : 0;
const liN = (chunk.match(/
]*class=["'][^"']*exam_list[^"']*["'][^>]*>[\s\S]*?<\/ul>/gi) || [""])[0]
.split(/- ]/i).length - 1;
const radioN = new Set(
[
...chunk.matchAll(/type=["'](?:radio|checkbox)["'][^>]*name=["']ques_([^"']+)["']/gi),
].map((x) => x[1])
).size;
const score = Math.max(idN, liN, radioN) * 10 + (chunk.match(/exam_list/gi) || []).length;
if (score > bestScore) {
bestScore = score;
best = chunk;
}
}
trace(`exam HTML \u542b ${chunks.length} \u6bb5\uff0c\u9009\u7528\u7ea6 ${Math.floor(bestScore / 10)} \u9898\u5377`);
return best;
}
function createExamAnswerState() {
return {
byQuesId: Object.create(null),
tried: Object.create(null),
rotateQues: [],
rotateIdx: 0,
};
}
function ensureTriedSet(state, quesId) {
if (!state.tried[quesId]) state.tried[quesId] = new Set();
return state.tried[quesId];
}
function isMultiExamLi(li) {
if (!li) return false;
const h3 = li.querySelector("h3, .name");
return /\u591a\u9009/.test(h3?.textContent || li.textContent || "");
}
function collectExamQuesGroups(form, doc) {
const groups = new Map();
const roots = [];
if (form) roots.push(form);
if (doc?.body) roots.push(doc.body);
const seen = new Set();
for (const root of roots) {
root.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach((inp) => {
if (seen.has(inp)) return;
seen.add(inp);
const quesId = extractQuesIdFromRadioName(inp.name);
if (!quesId) return;
if (!groups.has(quesId)) groups.set(quesId, []);
groups.get(quesId).push(inp);
});
}
return groups;
}
function findExamInputsForQues(form, doc, quesId, liFallback) {
const name = `ques_${quesId}`;
let inputs = form
? [...form.querySelectorAll(`input[type="radio"][name="${name}"], input[type="checkbox"][name="${name}"]`)]
: [];
if (!inputs.length && doc) {
inputs = [
...doc.querySelectorAll(`input[type="radio"][name="${name}"], input[type="checkbox"][name="${name}"]`),
];
}
if (!inputs.length && form) {
inputs = [...form.querySelectorAll('input[type="radio"], input[type="checkbox"]')].filter(
(inp) => extractQuesIdFromRadioName(inp.name) === quesId
);
}
if (!inputs.length && liFallback) {
inputs = [...liFallback.querySelectorAll('input[type="radio"], input[type="checkbox"]')].filter(
(inp) => extractQuesIdFromRadioName(inp.name) === quesId
);
}
if (!inputs.length && doc) {
for (const li of getExamListItems({ doc, items: null })) {
const rs = [...li.querySelectorAll('input[type="radio"], input[type="checkbox"]')].filter(
(inp) => extractQuesIdFromRadioName(inp.name) === quesId
);
if (rs.length) {
inputs = rs;
break;
}
}
}
return inputs;
}
function getExamListItems(parsed) {
const doc = parsed?.doc;
if (!doc) return [];
if (parsed.items && parsed.items.length) return [...parsed.items];
return [
...doc.querySelectorAll(
".exam_list li, .exam-list li, .exam_list .exam_item, ul.exam_list > li"
),
];
}
function parseExamQuestionEntries(parsed) {
const form = parsed?.form;
if (!form) return [];
const doc = parsed.doc;
const ids = readExamQuesList(form);
const answNums = readExamAnswNumList(form);
const groups = collectExamQuesGroups(form, doc);
const items = getExamListItems(parsed);
const liByQues = new Map();
for (const li of items) {
const inp = li.querySelector('input[type="radio"], input[type="checkbox"]');
const qid = extractQuesIdFromRadioName(inp?.name);
if (qid) liByQues.set(qid, li);
}
const buildEntry = (quesId, idx) => {
const li = liByQues.get(quesId) || items[idx];
let inputs = groups.get(quesId) || findExamInputsForQues(form, doc, quesId, li);
if (!inputs.length) return null;
const multi = inputs.some((inp) => inp.type === "checkbox") || isMultiExamLi(li);
let options = [...new Set(inputs.map((inp) => String(inp.value || "").trim()).filter(Boolean))];
const expect = answNums[idx];
if (expect > 0 && options.length > expect) options = options.slice(0, expect);
if (!options.length) options = expect === 4 ? ["A", "B", "C", "D"] : ["A", "B", "C", "D", "E"];
return { quesId, inputs, radios: inputs, options, multi };
};
if (ids.length) {
const entries = [];
for (let i = 0; i < ids.length; i++) {
const entry = buildEntry(ids[i], i);
if (!entry) {
trace(`\u8003\u8bd5\u5377\u89e3\u6790\uff1aques_list \u7b2c${i + 1}\u9898 ${ids[i].slice(0, 8)}… \u672a\u627e\u5230\u9009\u9879`);
continue;
}
entries.push(entry);
}
if (entries.length === ids.length) return entries;
if (entries.length && groups.size === ids.length) {
trace(`\u8003\u8bd5\u5377\u89e3\u6790\uff1aques_list \u4e0e\u9009\u9879\u7ec4\u6570\u4e00\u81f4\uff0c\u6309 DOM \u7ec4\u8865\u5168`);
const rebuilt = ids.map((id, i) => buildEntry(id, i)).filter(Boolean);
if (rebuilt.length === ids.length) return rebuilt;
}
}
const entries = [];
const groupIds = ids.length ? ids.filter((id) => groups.has(id)) : [...groups.keys()];
const order = groupIds.length ? groupIds : [...groups.keys()];
for (let i = 0; i < order.length; i++) {
const entry = buildEntry(order[i], i);
if (entry) entries.push(entry);
}
if (!entries.length) {
for (let i = 0; i < items.length; i++) {
const li = items[i];
const inputs = [...li.querySelectorAll('input[type="radio"], input[type="checkbox"]')];
if (!inputs.length) continue;
let quesId = extractQuesIdFromRadioName(inputs[0].name);
if (!quesId && ids[i]) quesId = ids[i];
const multi = inputs.some((inp) => inp.type === "checkbox") || isMultiExamLi(li);
const options = [...new Set(inputs.map((inp) => String(inp.value || "").trim()).filter(Boolean))];
entries.push({
quesId,
inputs,
radios: inputs,
multi,
options: options.length ? options : ["A", "B", "C", "D", "E"],
});
}
if (ids.length === entries.length) {
entries.forEach((e, i) => {
if (!e.quesId) e.quesId = ids[i];
});
}
}
return entries.filter((e) => e.quesId);
}
function fillExamEntries(state, entries) {
return entries.map((e) => {
const value = pickAnswerForQuestion(state, e.quesId, e.options, e.multi);
return Object.assign({}, e, { value });
});
}
function appendExamAnswerToBody(body, entry) {
const inputs = entry.inputs || entry.radios || [];
if (!inputs.length) return false;
const values = entry.multi ? splitMultiAnswer(entry.value) : [String(entry.value || inputs[0].value || "A")];
let appended = 0;
for (const v of values) {
const pick = inputs.find((inp) => String(inp.value) === String(v));
if (pick?.name) {
body.append(pick.name, pick.value);
appended++;
}
}
if (!appended) {
body.append(inputs[0].name, inputs[0].value);
appended = 1;
}
return appended > 0;
}
async function loadExamFailDocument(text, url) {
let failText = String(text || "");
let failUrl = String(url || "");
if (failUrl.includes("examQuizFail") && !failText.includes("answer_list")) {
const r = await documentFetch(failUrl);
failText = r.text;
failUrl = r.url;
}
if (!failText.includes("answer_list") && !/dui|cuo/.test(failText)) {
const linkMatch = failText.match(/examQuizFail\.jsp[^"'\s<>]*/);
if (linkMatch) {
const failPath = linkMatch[0].startsWith("/") ? linkMatch[0] : `/cme/${linkMatch[0]}`;
const r = await documentFetch(failPath);
failText = r.text;
failUrl = r.url;
}
}
return { failText, failUrl };
}
function parseExamFailUrlParams(failUrl) {
const out = { ansList: [], errorQues: new Set(), errorOrder: [], examEId: "" };
try {
const u = failUrl.startsWith("http") ? new URL(failUrl) : new URL(failUrl, API_BASE);
const ans = u.searchParams.get("ansList");
if (ans) out.ansList = ans.split(",").map((s) => s.trim()).filter(Boolean);
const err = u.searchParams.get("error_ques");
if (err) err.split(",").forEach((id) => id && out.errorQues.add(id.trim()));
const ord = u.searchParams.get("error_order");
if (ord) out.errorOrder = ord.split(",").map((s) => Number(s)).filter((n) => n > 0);
out.examEId = String(u.searchParams.get("eId") || "").trim();
} catch (_) {
const ansM = failUrl.match(/ansList=([^&]+)/);
if (ansM) out.ansList = ansM[1].split(",").map((s) => s.trim()).filter(Boolean);
const errM = failUrl.match(/error_ques=([^&]+)/);
if (errM) errM[1].split(",").forEach((id) => id && out.errorQues.add(id.trim()));
const eM = failUrl.match(/[?&]eId=([^&]+)/);
if (eM) out.examEId = decodeURIComponent(eM[1]);
}
return out;
}
function updateExamAnswerStateFromFail(state, failText, failUrl, submittedEntries) {
const params = parseExamFailUrlParams(failUrl);
let examEId = params.examEId;
if (!examEId) {
const eM = failText.match(/exam\.jsp[^"']*[?&]eId=([^"'&]+)/i);
if (eM) examEId = decodeURIComponent(eM[1]);
}
const ansList = params.ansList;
const errorQues = params.errorQues;
let locked = 0;
let adjusted = 0;
submittedEntries.forEach((entry, i) => {
const quesId = entry.quesId;
const submitted = ansList[i] || entry.value;
if (!quesId || !submitted) return;
ensureTriedSet(state, quesId).add(entry.multi ? joinMultiAnswer(submitted) : String(submitted));
if (errorQues.size && !errorQues.has(quesId)) {
state.byQuesId[quesId] = entry.multi ? joinMultiAnswer(submitted) : String(submitted);
locked++;
} else if (!errorQues.size) {
delete state.byQuesId[quesId];
}
});
const doc = new DOMParser().parseFromString(failText, "text/html");
doc.querySelectorAll(".answer_list").forEach((list, i) => {
const h3 = list.querySelector("h3");
const entry = submittedEntries[i];
if (!entry?.quesId || !h3) return;
const ansM = String(h3.textContent || "").match(/\u60a8\u7684\u7b54\u6848[\uff1a:]\s*([A-Za-z0-9.+\-<>,\uff0c%]+)/);
const submitted = ansM ? joinMultiAnswer(ansM[1].trim()) : joinMultiAnswer(ansList[i] || entry.value);
if (!submitted) return;
ensureTriedSet(state, entry.quesId).add(submitted);
if (isCorrectExamH3(h3)) {
state.byQuesId[entry.quesId] = submitted;
locked++;
} else if (isWrongExamH3(h3)) {
delete state.byQuesId[entry.quesId];
const nextVal = nextAnswerOption(submitted, entry.options, entry.multi);
if (nextVal !== submitted) adjusted++;
}
});
if (errorQues.size) {
for (const quesId of errorQues) {
delete state.byQuesId[quesId];
const idx = submittedEntries.findIndex((e) => e.quesId === quesId);
const entry = idx >= 0 ? submittedEntries[idx] : null;
const cur = idx >= 0 ? joinMultiAnswer(ansList[idx] || entry?.value) : joinMultiAnswer(entry?.value);
if (entry && cur) {
const nextVal = nextAnswerOption(cur, entry.options, entry.multi);
if (nextVal !== cur) adjusted++;
}
}
}
if (!state.rotateQues.length) {
state.rotateQues = submittedEntries.map((e) => e.quesId);
}
return { locked, adjusted, examEId, hasFeedback: locked > 0 || adjusted > 0 || errorQues.size > 0 };
}
function isExamPassResponse(text, url) {
if (!text) return false;
if (text.includes("\u5b66\u4e60\u65f6\u957f\u4e0d\u8db3")) return false;
if (String(url || "").includes("examQuizFail")) return false;
if (text.includes("answer_list") && text.includes("cuo")) return false;
if (/\u91cd\u65b0\u7b54\u9898|cxdt/.test(text) && text.includes("answer_list")) return false;
if (text.includes("show_exam_btns") || text.includes("\u8003\u8bd5\u901a\u8fc7") || text.includes("\u606d\u559c")) return true;
if (text.includes("examSuccess") || text.includes("exam_pass")) return true;
return false;
}
async function fixAnswersFromFail(text, url, answers) {
const state = createExamAnswerState();
const submitted = answers.map((value, i) => ({
quesId: String(i),
value,
options: ["A", "B", "C", "D", "E"],
}));
const { failText, failUrl } = await loadExamFailDocument(text, url);
const r = updateExamAnswerStateFromFail(state, failText, failUrl, submitted);
const next = submitted.map((e, i) => state.byQuesId[e.quesId] || answers[i] || "A");
return { answers: next, fixed: r.hasFeedback, wrongIdx: [], failUrl };
}
async function pingCcState(meta, times) {
if (!meta?.loginName) return;
const n = times || 1;
const path =
`/webcam/ccstate.jsp?loginName=${encodeURIComponent(meta.loginName)}` +
`&course_id=${meta.courseId}&ware_id=${normalizeWareId(meta.wareId)}&expiresTime=10&t=${Date.now()}`;
for (let i = 0; i < n; i++) {
try {
await platformFetch(path);
} catch (_) {}
if (i < n - 1) await sleep(300);
}
}
async function apiStudyProgressStep(csId, wareId, opts) {
opts = opts || {};
const meta = await resolveStudyMetaForStep(csId, wareId, opts);
if (!meta.loginName || !meta.batchId) {
throw new Error(`study2.jsp \u672a\u89e3\u6790\u5230 loginName/batch_id\uff08${meta.loginName || "\u65e0loginName"}\uff09`);
}
const wid = String(meta.wareId || wareId);
const capture = readProgressCapture(csId, wid);
const clientIp = capture?.ip || meta.clientIp || "127.0.0.1";
const st0 = loadApiProgressState(csId, wid);
if (!opts.quiet && !st0) {
trace(
`study2 API\u53c2\u6570\uff1aloginName=${meta.loginName} ware=${wid} batch=${meta.batchId.slice(0, 8)}… total=${meta.totalTime}s ip=${clientIp}`
);
}
meta.clientIp = clientIp;
const total = meta.totalTime;
const step = opts.step || PROGRESS_STEP_SEC;
const gapMs = opts.gapMs != null ? opts.gapMs : API_PROGRESS_GAP_MS;
let st = loadApiProgressState(csId, wid);
const apiStResume = st;
if (!st || st.batchId !== meta.batchId) {
let startCurrent = 0;
const sameBatch = capture && capture.batchId === meta.batchId;
if (sameBatch) startCurrent = Number(capture.playingTime) || 0;
const resumeProgress = apiStResume && apiStResume.batchId === meta.batchId ? Number(apiStResume.current) : 0;
if (resumeProgress > startCurrent) startCurrent = resumeProgress;
const reuseBatch =
sameBatch && (startCurrent > 0 || capture.needSlowRetry || capture.slowWalkStarted || resumeProgress > 0);
if (reuseBatch) {
if (capture.needSlowRetry && !capture.slowWalkStarted) {
trace(
`\u7b2c${formatWareNo(wid)}\u8282 \u91cd\u8bd5\u7eed\u62a5\uff085.5s/\u6863\uff0cbatch ${meta.batchId.slice(0, 8)}…\uff09`
);
saveProgressCapture(csId, wid, { slowWalkStarted: true });
} else {
trace(
`\u7b2c${formatWareNo(wid)}\u8282 \u7eed\u62a5 batch=${meta.batchId.slice(0, 8)}… \u4ece ${startCurrent}/${total} \u79d2`
);
}
} else {
markStudySessionStart(meta.courseId, wid);
await callSaveStudyStart(meta, csId, wid);
await reportProgress(meta, 0, 1, { ip: clientIp });
await pingCcState(meta, 2);
saveProgressCapture(csId, wid, {
courseId: String(csId),
loginName: meta.loginName,
batchId: meta.batchId,
totalTime: total,
playingTime: 0,
ip: clientIp,
userid: meta.userid,
timeStamp: meta.timeStamp,
sign: meta.sign,
});
trace(`\u7b2c${formatWareNo(wid)}\u8282 API\u5237\u8bfe\u5f00\u59cb loginName=${meta.loginName} batch=${meta.batchId.slice(0, 8)}…`);
}
st = {
csId: String(csId),
wareId: wid,
batchId: meta.batchId,
loginName: meta.loginName,
current: startCurrent,
total,
nextAt: 0,
clientIp,
};
}
if (Date.now() < st.nextAt) {
const remain = Math.ceil((st.nextAt - Date.now()) / 1000);
return {
waiting: true,
done: false,
remain,
current: st.current,
total: st.total,
isReady: false,
wid,
};
}
const next = Math.min(st.current + step, st.total);
await sleep(opts.leadMs != null ? opts.leadMs : API_PROGRESS_LEAD_MS);
await reportProgress(meta, next, next >= st.total ? 5 : 1, { ip: st.clientIp });
await pingCcState(meta, 2);
st.current = next;
saveProgressCapture(csId, wid, {
courseId: String(csId),
loginName: meta.loginName,
batchId: meta.batchId,
totalTime: st.total,
playingTime: next,
ip: st.clientIp,
userid: meta.userid || capture?.userid,
timeStamp: meta.timeStamp || capture?.timeStamp,
sign: meta.sign || capture?.sign,
});
if (opts.logEach) {
trace(
`\u7b2c${formatWareNo(wid)}\u8282 API\u4e0a\u62a5 ${next}/${st.total} \u79d2\uff08${Math.round((next / st.total) * 100)}%\uff09`
);
}
if (next >= st.total) {
await sleep(500);
await pingCcState(meta, 3);
const metaEnd = ensureStudyMeta(
mergeStudyCredentials(meta, readProgressCapture(csId, wid)),
csId,
wid
);
let saved = await callSaveStudyEnd(metaEnd, csId, wid);
if (!saved) {
await sleep(1000);
saved = await callSaveStudyEnd(metaEnd, csId, wid);
}
trace(`\u7b2c${formatWareNo(wid)}\u8282 saveStudy3 \u672a\u8fd4\u56de state=1\uff08sign=${String(metaEnd.sign || "").slice(0, 8)}…\uff09`);
clearApiProgressState(csId, wid);
clearIsExamCache(csId, wid);
invalidateSidebarStatusMap(csId);
const capDone = {
courseId: String(csId),
loginName: meta.loginName,
batchId: meta.batchId,
totalTime: st.total,
playingTime: st.total,
ip: st.clientIp,
userid: metaEnd.userid,
timeStamp: metaEnd.timeStamp,
sign: metaEnd.sign,
completed: true,
};
saveProgressCapture(csId, wid, capDone);
markApiReportedFull(csId, wid, capDone);
const isReady = await pollIsExamReady(csId, wid, {
times: ISEXAM_POLL_AFTER_FULL_TIMES,
intervalMs: ISEXAM_POLL_INTERVAL_MS,
});
const sidebarSt = await querySidebarStatus(csId, wid);
if (!isReady) {
if (!saved) {
saveProgressCapture(csId, wid, {
needSaveStudy3Retry: true,
needSlowRetry: false,
slowWalkStarted: false,
playingTime: st.total,
courseId: String(csId),
userid: metaEnd.userid,
timeStamp: metaEnd.timeStamp,
sign: metaEnd.sign,
});
clearApiProgressState(csId, wid);
trace(`\u7b2c${formatWareNo(wid)}\u8282 saveStudy3 \u5931\u8d25\uff0c\u5c06\u7528\u9501\u5b9a sign \u91cd\u8bd5\uff08\u4e0d\u91cd\u62a5\u8fdb\u5ea6\uff09`);
} else {
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u5df2\u62a5\u6ee1 ${st.total}s\uff0cisexam \u672a\u540c\u6b65\uff0c\u7a0d\u540e\u8f7b\u63a8\uff08batch \u9501\u5b9a ${meta.batchId.slice(0, 8)}…\uff09`);
}
} else {
clearApiReportedFull(csId, wid);
saveProgressCapture(csId, wid, { needSlowRetry: false });
}
return {
waiting: false,
done: true,
current: next,
total: st.total,
isReady,
sidebarSt,
wid,
};
}
if (opts.burst) {
st.nextAt = 0;
} else {
st.nextAt = Date.now() + gapMs;
}
saveApiProgressState(st);
const remain = opts.burst ? Math.ceil(gapMs / 1000) : Math.ceil((st.nextAt - Date.now()) / 1000);
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u5df2\u62a5 ${next}/${st.total}\uff0c${remain}s \u540e\u62a5\u4e0b\u4e00\u6863`);
return {
waiting: true,
done: false,
remain,
current: next,
total: st.total,
isReady: false,
wid,
};
}
function clearApiProgressWait(csId, wareId) {
const st = loadApiProgressState(csId, wareId);
if (st && st.nextAt > Date.now()) {
st.nextAt = 0;
saveApiProgressState(st);
}
}
async function waitIsExamOnlyTick(csId, wareId) {
const wid = normalizeWareId(wareId);
const cap = readProgressCapture(csId, wid);
if (await checkIsExam(csId, wid)) {
return {
waiting: false,
done: true,
isReady: true,
wid,
current: Number(cap?.playingTime) || cap?.totalTime,
total: Number(cap?.totalTime) || 3600,
};
}
const lastBoost = Number(cap?.lastBoostAt) || 0;
let ready = false;
if (Date.now() - lastBoost > 12000) {
ready = await softBoostIsExamAfterFull(csId, wid);
saveProgressCapture(csId, wid, Object.assign({}, cap || {}, { lastBoostAt: Date.now() }));
} else {
ready = await pollIsExamReady(csId, wid, { times: 3, intervalMs: 1500 });
}
const sidebarSt = await querySidebarStatus(csId, wid);
return {
waiting: false,
done: true,
isReady: ready,
wid,
current: Number(cap?.playingTime) || cap?.totalTime,
total: Number(cap?.totalTime) || 3600,
sidebarSt,
};
}
async function tryOneShotStudyReport(csId, wareId, opts) {
opts = opts || {};
const wid = normalizeWareId(wareId);
if (opts.skipOneShot || shouldOnlyWaitIsExam(csId, wid)) return null;
if (isChapterProgressFull(csId, wid)) return null;
const capture = readProgressCapture(csId, wid);
const apiSt = loadApiProgressState(csId, wid);
if (Number(apiSt?.current) > 0) return null;
if (Number(capture?.playingTime) > 0) return null;
const meta = ensureStudyMeta(
Object.assign({}, opts.lockedMeta || (await fetchStudyMeta(csId, wareId)), {
courseId: String(csId),
wareId: wid,
}),
csId,
wid
);
if (!meta.loginName || !meta.batchId) return null;
const clientIp = capture?.ip || meta.clientIp || "127.0.0.1";
meta.clientIp = clientIp;
const total = meta.totalTime;
markStudySessionStart(meta.courseId, wid);
await callSaveStudyStart(meta, csId, wid);
await reportProgress(meta, 0, 1, { ip: clientIp });
await pingCcState(meta, 2);
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u5c1d\u8bd5\u4e00\u6b21\u6027\u4e0a\u62a5 ${total}s…`);
await sleep(API_PROGRESS_LEAD_MS);
await reportProgress(meta, total, 5, { ip: clientIp });
await sleep(500);
await pingCcState(meta, 3);
const capPatch = {
courseId: String(csId),
loginName: meta.loginName,
batchId: meta.batchId,
totalTime: total,
playingTime: total,
ip: clientIp,
userid: meta.userid,
timeStamp: meta.timeStamp,
sign: meta.sign,
};
saveProgressCapture(csId, wid, capPatch);
clearIsExamCache(csId, wid);
invalidateSidebarStatusMap(csId);
const saved = await callSaveStudyEnd(meta, csId, wid);
if (!saved) {
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u4e00\u6b21\u6027 saveStudy3 \u672a\u6210\u529f\uff0c\u6539\u5206\u6863\u8fde\u62a5…`);
saveApiProgressState({
csId: String(csId),
wareId: wid,
batchId: meta.batchId,
loginName: meta.loginName,
current: 0,
total,
nextAt: 0,
clientIp,
});
saveProgressCapture(csId, wid, Object.assign({}, capPatch, { playingTime: 0 }));
return null;
}
markApiReportedFull(csId, wid, capPatch);
const isReady = await pollIsExamReady(csId, wid, {
times: ISEXAM_POLL_AFTER_FULL_TIMES,
intervalMs: ISEXAM_POLL_INTERVAL_MS,
});
const sidebarSt = await querySidebarStatus(csId, wid);
clearApiProgressState(csId, wid);
if (isReady) {
clearApiReportedFull(csId, wid);
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u4e00\u6b21\u6027\u4e0a\u62a5\u6210\u529f\uff0cisexam=1`);
} else {
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u4e00\u6b21\u6027\u5df2\u62a5\u6ee1\uff0cisexam \u672a\u540c\u6b65\uff0c\u7a0d\u540e\u8f7b\u63a8…`);
}
return {
waiting: false,
done: true,
current: total,
total,
isReady,
sidebarSt,
wid,
};
}
async function retrySaveStudy3Only(csId, wareId) {
const wid = normalizeWareId(wareId);
const cap = readProgressCapture(csId, wid);
if (!cap?.sign || !cap?.batchId) return null;
const meta = studyMetaFromCapture(cap, csId, wid);
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u91cd\u8bd5 saveStudy3\uff08\u9501\u5b9a sign=${String(meta.sign).slice(0, 8)}…\uff09`);
let saved = await callSaveStudyEnd(meta, csId, wid);
if (!saved) {
await sleep(800);
await reportProgress(meta, cap.totalTime, 5, { ip: cap.ip || meta.clientIp });
await sleep(500);
await pingCcState(meta, 2);
saved = await callSaveStudyEnd(meta, csId, wid);
}
clearIsExamCache(csId, wid);
invalidateSidebarStatusMap(csId);
if (saved) {
saveProgressCapture(csId, wid, { needSaveStudy3Retry: false, needSlowRetry: false });
const isReady = await pollIsExamReady(csId, wid, {
times: ISEXAM_POLL_AFTER_FULL_TIMES,
intervalMs: ISEXAM_POLL_INTERVAL_MS,
});
return {
waiting: false,
done: true,
isReady,
wid,
current: Number(cap.totalTime),
total: Number(cap.totalTime),
};
}
return null;
}
async function runApiStudyProgress(_csId, _wareId, _opts) {
throw new Error("\u672c\u5730\u5f15\u64ce\u5df2\u5173\u95ed\uff0c\u8bf7\u4f7f\u7528\u4e91\u7aef\u6388\u6743");
}
function isApiOnlyStudy() {
if (isRealtimeStudyMode()) return false;
const v = localStorage.getItem(API_ONLY_STUDY_KEY);
if (v === "0") return false;
return v === "1" || v === null || v === "";
}
async function callSaveStudyStart(meta, csId, wareId) {
meta = ensureStudyMeta(meta, csId, wareId);
const hdr = {
Referer: study2Referer(meta),
Accept: "application/json, text/javascript, */*; q=0.01",
};
const path = resolveSaveStudyPath(meta, "saveStudy6.jsp");
if (!path) return;
const r = await platformFetch(path, { headers: hdr });
parseApiState(r.text);
}
async function callSaveStudyEnd(meta, csId, wareId) {
meta = ensureStudyMeta(meta, csId, wareId);
const hdr = {
Referer: study2Referer(meta),
Accept: "application/json, text/javascript, */*; q=0.01",
};
const path = resolveSaveStudyPath(meta, "saveStudy3.jsp");
if (!path) return false;
const r = await platformFetch(path, { headers: hdr });
const st = parseApiState(r.text);
return Number(st) === 1;
}
const nextExamChoice = (s) => {
const c = s.charCodeAt(0) + 1;
return c > 69 ? "A" : String.fromCharCode(c);
};
const isExamCache = new Map();
function buildTestFromVideo(v) {
const pid = v?.productId || "";
const wid = String(v?.resId || v?.cptId || "");
return {
tsId: wid,
tsSpcId: pid,
spcId: pid,
productId: pid,
resName: v?.resName || "",
};
}
function clearIsExamCache(courseId, wareId) {
const cid = String(courseId || "");
for (const pid of wareIdVariants(wareId)) {
for (const k of [...isExamCache.keys()]) {
if (k.startsWith(`${cid}:${pid}:`)) isExamCache.delete(k);
}
}
}
async function fetchIsExamState(courseId, paperId, type) {
type = type || 7;
const cid = String(courseId || "");
const pid = String(paperId || "");
const r = await platformFetch(
`/isexam.jsp?course_id=${cid}&paper_id=${pid}&type=${type}&_=${Date.now()}`
);
const text = String(r.text || "").trim();
let state = NaN;
try {
state = Number(JSON.parse(text).state);
} catch (_) {
const m = text.match(/"state"\s*:\s*(\d+)/);
if (m) state = Number(m[1]);
}
return { ok: Number(state) === 1, state, text, paperId: pid };
}
async function checkIsExam(courseId, paperId, type) {
type = type || 7;
const cid = String(courseId || "");
for (const pid of wareIdVariants(paperId)) {
const key = `${cid}:${pid}:${type}`;
const hit = isExamCache.get(key);
if (hit && Date.now() - hit.t < 30000) {
if (hit.v) return true;
continue;
}
try {
const res = await fetchIsExamState(cid, pid, type);
isExamCache.set(key, { v: res.ok, t: Date.now() });
if (res.ok) return true;
} catch (_) {}
}
return false;
}
function isChapterNeedStudy(v) {
return !!(v && v.chapterStatus === "study");
}
function isChapterNeedExam(v) {
return !!(v && v.chapterStatus === "exam");
}
function isChapterDone(v) {
return !!(v && v.chapterStatus === "done");
}
function compareWareId(a, b) {
return String(a || "").localeCompare(String(b || ""), undefined, { numeric: true });
}
function formatWareNo(wareId) {
const n = parseInt(String(wareId || "").replace(/^0+/, ""), 10);
return Number.isFinite(n) ? String(n) : String(wareId || "");
}
function sortVideosByWare(videos) {
return [...(videos || [])].sort((a, b) => compareWareId(a.resId, b.resId));
}
async function canTakeChapterExam(csId, wareId) {
for (const pid of wareIdVariants(wareId)) {
if (await checkIsExam(csId, pid)) return pid;
}
return "";
}
function isHoldingSameChapterExam(bg, wareId) {
if (!bg) return false;
if (bg.phase !== "exam") return false;
const hold =
bg.pendingExamWareId || bg.videos?.[bg.videoIndex || 0]?.resId || bg.test?.tsId || "";
if (!hold) return !!bg.sameChapterExam;
if (!wareId) return true;
const a = wareIdVariants(hold);
const b = wareIdVariants(wareId);
return a.some((x) => b.includes(x));
}
async function tryBeginSameChapterExam(bg, video) {
if (!shouldRunExam() || !video) return false;
clearIsExamCache(bg.csId, video.resId);
let readyPaper = await canTakeChapterExam(bg.csId, video.resId);
if (!readyPaper) {
for (let i = 0; i < 4; i++) {
await sleep(2000);
readyPaper = await canTakeChapterExam(bg.csId, video.resId);
if (readyPaper) break;
}
}
if (!readyPaper) {
if (isChapterProgressFull(bg.csId, video.resId)) {
trace(`\u7b2c${formatWareNo(video.resId)}\u8282 \u5df2\u62a5\u6ee1\uff0c\u7b49\u5f85\u8003\u8bd5\u5c31\u7eea`);
}
bg.lastSaveAt = 0;
saveBgState(bg);
return false;
}
const chapter = { ...video, resId: readyPaper };
bg.phase = "exam";
bg.videos = [chapter];
bg.videoIndex = 0;
bg.test = buildTestFromVideo(chapter);
bg.lastSaveAt = 0;
bg.sameChapterExam = true;
bg.pendingExamWareId = chapter.resId;
bg.apiBoostCount = 0;
saveBgState(bg);
const label = chapter.resName || `\u8bfe\u4ef6${formatWareNo(chapter.resId)}`;
log(`\u7b2c${formatWareNo(chapter.resId)}\u8282 \u8fdb\u5165\u672c\u8282\u8003\u8bd5\uff1a${label}`);
return true;
}
async function partitionCourseWork(csId, opts) {
const holdExamWare = opts?.holdExamWare ? String(opts.holdExamWare) : "";
const all = sortVideosByWare(await loadCourseVideos(csId, true));
const study = [];
const exam = [];
for (const v of all) {
if (isChapterDone(v)) continue;
if (holdExamWare && wareIdVariants(v.resId).some((k) => wareIdVariants(holdExamWare).includes(k))) {
exam.push(v);
continue;
}
if (isChapterNeedExam(v)) {
exam.push(v);
continue;
}
if (isChapterNeedStudy(v)) {
if (await checkIsExam(csId, v.resId)) exam.push(v);
else if (isChapterProgressReported(csId, v.resId) && (await querySidebarStatus(csId, v.resId)) === "exam") {
exam.push(v);
} else study.push(v);
continue;
}
study.push(v);
}
return { all, study, exam };
}
function wareIdVariants(wareId) {
const w = String(wareId || "");
const out = [w];
const n = w.replace(/^0+/, "") || w;
const p2 = n.padStart(2, "0");
if (!out.includes(n)) out.push(n);
if (!out.includes(p2)) out.push(p2);
return out;
}
async function resolveExamContext(courseId, wareId, productId) {
const cid = String(courseId || "");
let wid = String(wareId || "");
let pid = String(productId || "");
let resName = "";
try {
const meta = await fetchStudyMeta(cid, wid);
if (meta.productId) pid = meta.productId;
const t = parseWareTitleFromHtml(meta.html, wid);
if (t) resName = t;
if (!pid) {
const m = meta.html.match(
new RegExp(
"exam\\.jsp\\?course_id=" + cid + "&paper_id=" + wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[^\"']*product_id=(\\d+)",
"i"
)
);
if (m) pid = m[1];
}
} catch (_) {}
if (!pid) {
try {
const html = await fetchCourseHtml(cid);
const m = html.match(
new RegExp("paper_id=" + wid.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "[^\"']*product_id=(\\d+)", "i")
);
if (m) pid = m[1];
} catch (_) {}
}
return { courseId: cid, wareId: wid, productId: pid, resName };
}
function isExamAlreadyPassedHtml(html, parsed) {
const text = String(html || "");
if (parsed?.form && parsed.qCount > 0) return false;
if (/answer_list/.test(text) && /cuo/.test(text)) return false;
if (/\u91cd\u65b0\u7b54\u9898|examQuizFail|cxdt/.test(text)) return false;
if (/\u5df2\u901a\u8fc7|\u5df2\u8003\u8bd5|\u4e0d\u8981\u91cd\u590d|\u5df2\u5b8c\u6210\u8003\u8bd5/.test(text)) return true;
if (/\u8003\u8bd5\u901a\u8fc7/.test(text) && !/\u91cd\u65b0\u7b54\u9898/.test(text)) return true;
return false;
}
function parseExamDocument(html) {
const normalized = normalizeExamHtml(html);
const doc = new DOMParser().parseFromString(normalized, "text/html");
const form =
doc.querySelector('form[name="form1"]') ||
doc.querySelector("form#form1") ||
doc.querySelector('form[action*="examDo"]');
const items = doc.querySelectorAll(".exam_list li, .exam-list li, .exam_list .exam_item, ul.exam_list > li");
let qCount = items.length;
if (form) {
const ql = readExamQuesList(form);
if (ql.length) qCount = ql.length;
const groups = collectExamQuesGroups(form, doc);
if (groups.size > qCount) qCount = groups.size;
}
if (!qCount && form) {
const names = new Set();
form.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach((inp) => {
if (inp.name && /^ques_/i.test(inp.name)) names.add(inp.name);
});
qCount = names.size;
}
return { doc, form, qCount, items, normalized };
}
function tryParseExamPageHtml(html, ctx, url, referer, examEId) {
const parsed = parseExamDocument(html);
if (isExamAlreadyPassedHtml(parsed.normalized || html, parsed)) {
return { ok: false, msg: "ALREADY_PASSED", ctx };
}
const entries = parseExamQuestionEntries(parsed);
const ids = readExamQuesList(parsed.form);
const groups = collectExamQuesGroups(parsed.form, parsed.doc);
const durationBlocked =
/\u770b\u5b8c\u8bfe\u4ef6\u624d\u80fd|\u5b66\u4e60\u65f6\u957f\u4e0d\u8db3|\u65f6\u95f4\u672a\u5230|\u4e0d\u80fd\u53c2\u52a0|\u4e0d\u80fd\u7b54\u5377|\u8bf7\u5148\u5b66\u4e60|alert\s*\(\s*['"]\u770b\u5b8c/.test(
String(html || "")
);
if (durationBlocked && (!parsed.form || entries.length === 0)) {
return { ok: false, msg: "\u5b66\u65f6\u672a\u6ee1\uff0c\u4e0d\u80fd\u7b54\u5377", ctx };
}
const expect = ids.length || groups.size || entries.length;
const ready =
entries.length > 0 &&
expect > 0 &&
entries.length === expect &&
(groups.size === 0 || groups.size === entries.length);
if (ready) {
return {
ok: true,
html: parsed.normalized || html,
parsed,
entries,
examUrl: url,
referer,
ctx,
examEId: examEId || "",
forceExam: durationBlocked,
};
}
if (parsed.form && entries.length > 0) {
trace(
`exam.jsp \u9898\u76ee ${entries.length}/${expect || "?"} \u9009\u9879\u7ec4=${groups.size}\uff0c\u672c URL \u8df3\u8fc7`
);
}
return null;
}
function parseWebcamJson(text) {
const raw = String(text || "").trim();
try {
return JSON.parse(raw);
} catch (_) {
const m = raw.match(/\{[\s\S]*\}/);
if (m) {
try {
return JSON.parse(m[0]);
} catch (_2) {}
}
}
return null;
}
function isExamFaceVerifyPage(html, url) {
const text = String(html || "");
const u = String(url || "");
if (/ewmface/i.test(u)) return true;
if (/ewmface|ewm1\.jsp|ewmstate\.jsp|wx_pay_ewm|\u4eba\u8138\u8bc6\u522b/.test(text)) return true;
if (/\u597d\u533b\u751fAPP.*\u626b\u4e00\u626b|\u626b\u4e00\u626b.*\u597d\u533b\u751fAPP/.test(text)) return true;
return false;
}
async function fetchExamFaceEwm(courseId, wareId) {
const cid = String(courseId || "");
const wid = normalizeWareId(wareId);
try {
const r = await platformFetch(
`/webcam/ewm1.jsp?course_id=${cid}&courseware_id=${wid}&t=${Date.now()}`
);
const data = parseWebcamJson(r.text);
if (data && Number(data.state) === 1 && data.content && data.eId) {
return { ok: true, content: String(data.content), eId: String(data.eId) };
}
return { ok: false, msg: "\u5e73\u53f0\u672a\u8fd4\u56de\u6709\u6548\u4e8c\u7ef4\u7801" };
} catch (err) {
return { ok: false, msg: err.message || "\u83b7\u53d6\u4e8c\u7ef4\u7801\u5931\u8d25" };
}
}
async function fetchExamFaceState(courseId, wareId, eId) {
const cid = String(courseId || "");
const wid = normalizeWareId(wareId);
const r = await platformFetch(
`/webcam/ewmstate.jsp?course_id=${cid}&courseware_id=${wid}&eId=${encodeURIComponent(eId)}&t=${Date.now()}`
);
const data = parseWebcamJson(r.text);
return { verified: !!(data && Number(data.status) === 0), data };
}
function ensureExamFaceModal() {
if (document.getElementById("cme-face-modal")) return;
const modal = document.createElement("div");
modal.id = "cme-face-modal";
modal.innerHTML = `
\u8003\u8bd5\u524d\u4eba\u8138\u8bc6\u522b
\u8bf7\u4f7f\u7528\u597d\u533b\u751f APP \u626b\u7801\u5b8c\u6210\u8ba4\u8bc1
\u8bf7\u4f7f\u7528\u597d\u533b\u751f APP\uff086.0.17 \u53ca\u4ee5\u4e0a\uff09\u300c\u626b\u4e00\u626b\u300d\u626b\u63cf\u4e0a\u65b9\u4e8c\u7ef4\u7801\uff0c\u5b8c\u6210\u8ba4\u8bc1\u540e\u53ef\u7ee7\u7eed\u8003\u8bd5\u3002
\u300c\u53d6\u6d88\u300d\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5\uff1b\u300c\u6682\u4e0d\u8003\u8bd5\u300d\u672c\u6b21\u8fd0\u884c\u8df3\u8fc7\u6240\u6709\u8003\u8bd5\u3001\u4ec5\u5b66\u89c6\u9891\uff08\u91cd\u65b0\u70b9\u5f00\u59cb\u540e\u6062\u590d\u8003\u8bd5\uff09\u3002
\u7b49\u5f85\u626b\u7801…
`;
document.body.appendChild(modal);
modal.querySelector("#cme-face-cancel")?.addEventListener("click", () => {
state.examFaceCancel = true;
updateExamFaceModalStatus("\u5df2\u53d6\u6d88\uff0c\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5");
});
modal.querySelector("#cme-face-skip")?.addEventListener("click", () => {
state.examFaceSkipAll = true;
state.examFaceCancel = true;
updateExamFaceModalStatus("\u5df2\u9009\u62e9\u6682\u4e0d\u8003\u8bd5\uff0c\u6b63\u5728\u5207\u6362\u5230\u89c6\u9891\u5b66\u4e60…");
});
}
function showExamFaceModal(opts) {
ensureExamFaceModal();
state.examFaceCancel = false;
state.examFaceSkipAll = false;
const modal = document.getElementById("cme-face-modal");
const qr = document.getElementById("cme-face-qr");
const label = document.getElementById("cme-face-label");
const status = document.getElementById("cme-face-status");
if (!modal || !qr) return;
if (label && opts?.label) {
label.textContent = `${opts.label} · \u8bf7\u4f7f\u7528\u597d\u533b\u751f APP \u626b\u7801\u5b8c\u6210\u8ba4\u8bc1`;
}
const content = String(opts?.qrContent || "");
qr.src =
"https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=" + encodeURIComponent(content);
if (status) status.textContent = "\u7b49\u5f85\u626b\u7801…\uff08\u7ea6 90 \u79d2\u5185\u6709\u6548\uff09";
modal.style.display = "flex";
}
function updateExamFaceModalStatus(text) {
const el = document.getElementById("cme-face-status");
if (el) el.textContent = String(text || "");
}
function updateExamFaceModalCountdown(sec) {
if (state.examFaceCancel) return;
updateExamFaceModalStatus(`\u7b49\u5f85\u626b\u7801…\u5269\u4f59 ${Math.max(0, sec)} \u79d2`);
}
function closeExamFaceModal() {
const modal = document.getElementById("cme-face-modal");
if (modal) modal.style.display = "none";
state.examFaceCancel = false;
state.examFaceSkipAll = false;
}
function facePollAbortResult() {
if (!state.examFaceCancel && !state.examFaceSkipAll) return null;
return { ok: false, cancelled: true, skipAll: !!state.examFaceSkipAll };
}
async function pollExamFaceUntilVerified(courseId, wareId, eId, timeoutMs) {
const deadline = Date.now() + (timeoutMs || 90000);
while (Date.now() < deadline) {
const abort = facePollAbortResult();
if (abort) return abort;
try {
const st = await fetchExamFaceState(courseId, wareId, eId);
if (st.verified) return { ok: true };
} catch (err) {
trace("ewmstate\uff1a" + (err.message || err));
}
updateExamFaceModalCountdown(Math.ceil((deadline - Date.now()) / 1000));
await sleep(1000);
}
return { ok: false, timeout: true };
}
async function waitExamFaceVerify(courseId, wareId, label) {
ensureExamFaceModal();
const cid = String(courseId || "");
const wid = normalizeWareId(wareId);
const maxCycles = 24;
for (let cycle = 0; cycle < maxCycles; cycle++) {
const abort = facePollAbortResult();
if (abort) {
closeExamFaceModal();
if (abort.skipAll) return { ok: false, skipAll: true, msg: "\u5df2\u8df3\u8fc7\u8003\u8bd5\uff0c\u4ec5\u5b66\u4e60\u89c6\u9891" };
return { ok: false, msg: "\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5" };
}
const ewm = await fetchExamFaceEwm(cid, wid);
if (!ewm.ok) {
closeExamFaceModal();
return { ok: false, msg: ewm.msg || "\u83b7\u53d6\u4eba\u8138\u8bc6\u522b\u4e8c\u7ef4\u7801\u5931\u8d25" };
}
showExamFaceModal({ label, qrContent: ewm.content, eId: ewm.eId });
if (cycle === 0) log("\u9700\u8981\u4eba\u8138\u8bc6\u522b\uff0c\u8bf7\u7528\u597d\u533b\u751fAPP\u626b\u7801\u9a8c\u8bc1");
const verified = await pollExamFaceUntilVerified(cid, wid, ewm.eId, 90000);
if (verified.ok) {
closeExamFaceModal();
log("\u4eba\u8138\u8bc6\u522b\u5df2\u901a\u8fc7\uff0c\u7ee7\u7eed\u8003\u8bd5");
return { ok: true, eId: ewm.eId };
}
if (verified.cancelled) {
closeExamFaceModal();
if (verified.skipAll) return { ok: false, skipAll: true, msg: "\u5df2\u8df3\u8fc7\u8003\u8bd5\uff0c\u4ec5\u5b66\u4e60\u89c6\u9891" };
return { ok: false, msg: "\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5" };
}
updateExamFaceModalStatus("\u4e8c\u7ef4\u7801\u5df2\u8fc7\u671f\uff0c\u6b63\u5728\u5237\u65b0…");
await sleep(600);
}
closeExamFaceModal();
return { ok: false, msg: "\u4eba\u8138\u8bc6\u522b\u8d85\u65f6\uff0c\u8bf7\u91cd\u8bd5" };
}
async function fetchExamPage(courseId, wareId, productId, examEId) {
const ctx = await resolveExamContext(courseId, wareId, productId);
const referer =
API_BASE + `/cme/study2.jsp?course_id=${ctx.courseId}&courseware_id=${ctx.wareId}`;
const paperIds = wareIdVariants(ctx.wareId);
const productAttempts = ctx.productId ? [ctx.productId, ""] : [""];
let lastHtml = "";
let lastUrl = "";
let lastHint = ctx.productId ? `paper=${ctx.wareId}` : "\u7f3a\u5c11 product_id";
for (const pid of paperIds) {
for (const prod of productAttempts) {
let url = `/cme/exam.jsp?course_id=${ctx.courseId}&paper_id=${pid}&type=7`;
if (prod) url += `&product_id=${prod}`;
if (examEId) url += `&eId=${encodeURIComponent(examEId)}`;
url += `&_=${Date.now()}`;
const exam = await documentFetch(url, {
headers: { Referer: referer },
});
lastHtml = exam.text;
lastUrl = exam.url || url;
const hit = tryParseExamPageHtml(exam.text, { ...ctx, wareId: pid, productId: prod || ctx.productId }, url, referer, examEId);
if (hit) {
if (!hit.ok) return hit;
if (prod !== ctx.productId) {
trace(`exam.jsp \u65e0 product_id \u62c9\u5377\u6210\u529f paper=${pid}`);
}
return hit;
}
}
}
if (isExamFaceVerifyPage(lastHtml, lastUrl)) {
trace(`\u8003\u8bd5\u9700\u4eba\u8138\u8bc6\u522b course=${ctx.courseId} paper=${ctx.wareId}`);
return { ok: false, msg: "FACE_VERIFY", needFace: true, html: lastHtml, ctx };
}
const snippet = String(lastHtml || "")
.replace(/\s+/g, " ")
.slice(0, 120);
trace(`\u672a\u83b7\u53d6\u8bd5\u5377 course=${ctx.courseId} paper=${ctx.wareId} product=${ctx.productId || "\u65e0"} html=${snippet}`);
return { ok: false, msg: `\u672a\u83b7\u53d6\u8bd5\u5377\uff08${lastHint}\uff09`, html: lastHtml, ctx };
}
function buildExamBodyFromEntries(parsed, filledEntries) {
const form = parsed?.form;
if (!form || !filledEntries?.length) return null;
const ids = readExamQuesList(form);
const filledMap = Object.create(null);
filledEntries.forEach((e) => {
if (e?.quesId) filledMap[e.quesId] = e;
});
const body = new URLSearchParams();
form.querySelectorAll('input[type="hidden"]').forEach((inp) => {
if (inp.name) body.append(inp.name, inp.value);
});
const order = ids.length ? ids : filledEntries.map((e) => e.quesId);
for (const quesId of order) {
let entry = filledMap[quesId];
if (!entry) {
const inputs = findExamInputsForQues(form, parsed.doc, quesId);
if (!inputs.length) {
trace(`\u7ec4\u5377\u5931\u8d25\uff1a\u7f3a\u5c11\u7b2c ${quesId.slice(0, 8)}… \u9898\uff08${order.indexOf(quesId) + 1}/${order.length}\uff09`);
return null;
}
const options = [...new Set(inputs.map((inp) => String(inp.value || "").trim()).filter(Boolean))];
const multi = inputs.some((inp) => inp.type === "checkbox");
entry = {
quesId,
inputs,
radios: inputs,
multi,
options: options.length ? options : ["A"],
value: options[0] || "A",
};
}
if (!appendExamAnswerToBody(body, entry)) {
trace(`\u7ec4\u5377\u5931\u8d25\uff1a\u9898 ${quesId.slice(0, 8)}… \u65e0\u6709\u6548\u9009\u9879`);
return null;
}
}
const action = form.getAttribute("action") || "examDo.jsp";
const path = action.startsWith("/") ? action : "/cme/" + action.replace(/^\//, "");
return { path, body: body.toString(), quesCount: order.length };
}
async function documentFetchPost(path, options) {
options = options || {};
const url = String(path || "").startsWith("http") ? path : API_BASE + path;
const headers = Object.assign(
{ Accept: "text/html,application/xhtml+xml,*/*" },
options.headers || {}
);
const body = options.body || null;
if (typeof GM_xmlhttpRequest !== "undefined") {
const res = await _gm(url, options.method || "POST", headers, body);
return { status: res.status, text: res.text, url };
}
const res = await fetch(url, {
credentials: "include",
method: options.method || "POST",
headers,
body,
redirect: "follow",
});
return { status: res.status, text: await res.text(), url: res.url };
}
function buildExamBodyFromParsed(parsed, answers) {
const entries = parseExamQuestionEntries(parsed);
if (!entries.length) return null;
const filled = entries.map((e, i) =>
Object.assign({}, e, { value: answers[i] || pickAnswerForQuestion(createExamAnswerState(), e.quesId, e.options) })
);
return buildExamBodyFromEntries(parsed, filled);
}
async function postExamFromPage(page, answersOrEntries) {
let built;
if (Array.isArray(answersOrEntries) && answersOrEntries[0]?.quesId) {
built = buildExamBodyFromEntries(page.parsed, answersOrEntries);
} else {
built = buildExamBodyFromParsed(page.parsed, answersOrEntries || []);
}
if (!built) throw new Error("\u672a\u83b7\u53d6\u8bd5\u5377");
const referer =
page.referer ||
API_BASE + `/cme/study2.jsp?course_id=${page.ctx.courseId}&courseware_id=${page.ctx.wareId}`;
const postPath = built.path;
return documentFetchPost(postPath, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Referer: referer,
},
body: built.body,
});
}
async function runClientCourseExam(courseId, wareId, productId) {
const answerState = createExamAnswerState();
let examEId = "";
let qCount = 5;
const maxTries = 120;
for (let t = 0; t < maxTries; t++) {
const fresh = await fetchExamPage(courseId, wareId, productId, examEId);
if (!fresh.ok) {
if (fresh.msg === "ALREADY_PASSED") {
return { ok: true, skipped: true, msg: "\u9875\u9762\u663e\u793a\u5df2\u8003\u8bd5\uff08\u9700\u4fa7\u680f\u786e\u8ba4\uff09" };
}
let needFace = !!(fresh.needFace || fresh.msg === "FACE_VERIFY");
if (!needFace && t === 0 && /\u672a\u83b7\u53d6\u8bd5\u5377/.test(fresh.msg || "")) {
const probe = await fetchExamFaceEwm(courseId, wareId);
needFace = probe.ok;
}
if (needFace) {
if (t > 0) return { ok: false, msg: "\u4eba\u8138\u8bc6\u522b\u540e\u4ecd\u672a\u83b7\u53d6\u8bd5\u5377\uff0c\u8bf7\u91cd\u8bd5" };
const ctx = fresh.ctx || (await resolveExamContext(courseId, wareId, productId));
const label =
ctx.resName ? `\u7b2c${formatWareNo(wareId)}\u8282 ${ctx.resName}` : `\u7b2c${formatWareNo(wareId)}\u8282`;
const face = await waitExamFaceVerify(courseId, wareId, label);
if (!face.ok) {
if (face.skipAll) return { ok: false, skipAll: true, msg: face.msg || "\u5df2\u8df3\u8fc7\u8003\u8bd5\uff0c\u4ec5\u5b66\u4e60\u89c6\u9891" };
return { ok: false, msg: face.msg || "\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5" };
}
examEId = face.eId;
await sleep(500);
continue;
}
if (t === 0) return { ok: false, msg: fresh.msg || "\u672a\u83b7\u53d6\u8bd5\u5377" };
return { ok: false, msg: fresh.msg || "\u8bd5\u5377\u8fc7\u671f" };
}
const entries = fresh.entries || parseExamQuestionEntries(fresh.parsed);
const expectCount = readExamQuesList(fresh.parsed?.form).length || entries.length;
if (!entries.length) return { ok: false, msg: fresh.msg || "\u672a\u83b7\u53d6\u8bd5\u5377" };
if (expectCount && entries.length < expectCount) {
return { ok: false, msg: `\u8bd5\u5377\u9898\u76ee\u4e0d\u5b8c\u6574\uff08${entries.length}/${expectCount}\uff09` };
}
qCount = entries.length;
const filled = fillExamEntries(answerState, entries);
if (filled.length < entries.length) {
return { ok: false, msg: "\u8bd5\u5377\u9898\u76ee\u89e3\u6790\u4e0d\u5b8c\u6574" };
}
const post = await postExamFromPage(fresh, filled);
if (post.status >= 500) {
trace(`examDo \u8fd4\u56de ${post.status}\uff08\u53ef\u80fd\u6f0f\u9898\u6216 sign \u5931\u6548\uff09\uff0c\u91cd\u65b0\u62c9\u5377`);
examEId = "";
await sleep(800);
continue;
}
if (isExamPassResponse(post.text, post.url)) {
const scoreM = post.text.match(/(\d+)\s*\u5206/);
return { ok: true, score: scoreM ? scoreM[1] : "", msg: "\u8003\u8bd5\u901a\u8fc7" };
}
if (/\u5b66\u4e60\u65f6\u957f\u4e0d\u8db3|\u4e0d\u80fd\u7b54\u5377|\u8bf7\u5148\u5b66\u4e60/.test(post.text)) {
return { ok: false, msg: "\u5b66\u65f6\u672a\u6ee1\uff0c\u4e0d\u80fd\u7b54\u5377" };
}
const { failText, failUrl } = await loadExamFailDocument(post.text, post.url);
const feedback = updateExamAnswerStateFromFail(answerState, failText, failUrl, filled);
if (feedback.examEId) examEId = feedback.examEId;
const locked = Object.keys(answerState.byQuesId).length;
if (feedback.hasFeedback) {
trace(`\u8003\u8bd5\u7ea0\u9519\uff1a\u5df2\u9501\u5b9a ${locked}/${qCount} \u9898\uff0c\u672c\u8f6e\u8c03\u6574 ${feedback.adjusted} \u9898`);
} else if (answerState.rotateQues.length) {
const qid = answerState.rotateQues[answerState.rotateIdx % answerState.rotateQues.length];
answerState.rotateIdx++;
delete answerState.byQuesId[qid];
const entry = filled.find((e) => e.quesId === qid);
const cur = entry?.value || "A";
ensureTriedSet(answerState, qid).add(cur);
}
if (locked >= qCount) {
trace(`\u8003\u8bd5\uff1a${qCount} \u9898\u7b54\u6848\u5df2\u9f50\uff0c\u7ee7\u7eed\u63d0\u4ea4`);
}
await sleep(600);
}
return { ok: false, msg: "\u66b4\u529b\u7b54\u9898\u5931\u8d25" };
}
async function abandonExamsAndResumeVideos(bg, course) {
state.skipExamsSession = true;
invalidateSidebarStatusMap(bg.csId);
const { all, study, exam } = await partitionCourseWork(bg.csId);
bg.sameChapterExam = false;
bg.pendingExamWareId = "";
bg.apiBoostCount = 0;
if (study.length) {
const nb = buildBgStateFromWork({
course: course || { csId: bg.csId, tpId: bg.tpId, csName: bg.csName, lcsProcess: bg.lcsProcess },
phase: "study",
videos: [study[0]],
videoTotal: all.length,
videoDone: all.length - study.length - exam.length,
});
saveBgState(nb);
log(`\u4e0b\u4e00\u4efb\u52a1\uff1a\u89c6\u9891\u7b2c${formatWareNo(study[0].resId)}\u8282 ${study[0].resName || ""}`);
renderCourseList();
void refreshChapterPreview();
return true;
}
saveBgState({});
state.coursesDoneSession += 1;
const again = await initBackgroundTarget();
if (again) {
saveBgState(again);
return true;
}
if (state.enabled) stopAutoStudy("\u6240\u6709\u5df2\u9009\u8bfe\u7a0b\u5747\u5df2\u5b8c\u6210\uff0c\u7a0b\u5e8f\u81ea\u52a8\u505c\u6b62");
return false;
}
async function promoteToNextTask(bg, course, opts) {
const excludeWare = opts?.excludeWareId ? String(opts.excludeWareId) : "";
invalidateSidebarStatusMap(bg.csId);
await refreshPanelCourses();
let rest = await partitionCourseWork(bg.csId);
const dropWare = (v) => excludeWare && wareIdsMatch(v.resId, excludeWare);
rest.study = rest.study.filter((v) => !dropWare(v));
rest.exam = rest.exam.filter((v) => !dropWare(v));
const prunedExam = [];
for (const v of rest.exam) {
if ((await querySidebarStatus(bg.csId, v.resId)) === "done") continue;
prunedExam.push(v);
}
rest.exam = prunedExam;
if (!rest.study.length && !rest.exam.length) {
saveBgState({});
state.coursesDoneSession += 1;
return false;
}
const pendingHold =
bg?.pendingExamWareId &&
!(excludeWare && wareIdsMatch(bg.pendingExamWareId, excludeWare)) &&
rest.exam.some((v) => wareIdsMatch(v.resId, bg.pendingExamWareId));
const preferExam =
shouldRunExam() &&
rest.exam.length > 0 &&
(rest.study.length === 0 ||
pendingHold ||
(rest.study[0] &&
rest.exam[0] &&
(wareIdsMatch(rest.study[0].resId, rest.exam[0].resId) ||
isChapterProgressReported(bg.csId, rest.study[0].resId))));
const nb = buildBgStateFromWork({
course,
phase: preferExam ? "exam" : "study",
videos: preferExam ? [rest.exam[0]] : [rest.study[0]],
test: preferExam ? buildTestFromVideo(rest.exam[0]) : undefined,
videoTotal: rest.all.length,
videoDone: rest.all.length - rest.study.length - rest.exam.length,
});
saveBgState(nb);
if (preferExam) {
log(`\u4e0b\u4e00\u4efb\u52a1\uff1a\u8003\u8bd5\u7b2c${formatWareNo(rest.exam[0].resId)}\u8282 ${rest.exam[0].resName || ""}`);
} else if (rest.study.length) {
log(`\u4e0b\u4e00\u4efb\u52a1\uff1a\u89c6\u9891\u7b2c${formatWareNo(rest.study[0].resId)}\u8282 ${rest.study[0].resName || ""}`);
} else {
log(`\u4e0b\u4e00\u4efb\u52a1\uff1a\u8003\u8bd5\u7b2c${formatWareNo(rest.exam[0].resId)}\u8282 ${rest.exam[0].resName || ""}`);
}
return true;
}
async function skipChapterIfAlreadyPassed(bg, course, wareId, label) {
const st = await querySidebarStatus(bg.csId, wareId);
if (st !== "done") return false;
trace(`\u7b2c${formatWareNo(wareId)}\u8282 ${label || ""} \u5df2\u901a\u8fc7\uff0c\u8df3\u8fc7`);
clearIsExamCache(bg.csId, wareId);
bg.sameChapterExam = false;
bg.pendingExamWareId = "";
await promoteToNextTask(bg, course, { excludeWareId: wareId });
return true;
}
async function localExamSubmit(courseId, wareId, productId) {
return runClientCourseExam(courseId, wareId, productId);
}
async function localLearnChapter(_csId, _video, _mult) {
throw new Error("\u672c\u5730\u5f15\u64ce\u5df2\u5173\u95ed\uff0c\u8bf7\u4f7f\u7528\u4e91\u7aef\u6388\u6743");
}
async function apiBoostStudyForExam(csId, wareId, label) {
const elabel = label || "";
try {
let meta = null;
try {
meta = await fetchStudyMeta(csId, wareId);
} catch (_) {}
const video = {
resId: wareId,
cptId: wareId,
fileTimeLen: meta?.totalTime || 3600,
resName: elabel,
productId: meta?.productId || "",
};
const res = await learnSaveFullVideo(csId, video, getMultiplier());
if (!(res.skipped || res.success === "1")) {
trace(`\u7b2c${formatWareNo(wareId)}\u8282 ${elabel} \u4e91\u7aef\u7eed\u62a5\u672a\u5b8c\u6210`);
return false;
}
const totalSec = meta?.totalTime || 3600;
const examReady = await checkChapterExamReady(csId, wareId, totalSec);
if (examReady.videoDone) {
markChapterProgressReported(csId, wareId);
trace(`\u7b2c${formatWareNo(wareId)}\u8282 ${elabel} \u4e91\u7aef\u7eed\u62a5\u5b8c\u6210\uff0c\u53ef\u8003\u8bd5`);
return true;
}
trace(`\u7b2c${formatWareNo(wareId)}\u8282 ${elabel} \u4e91\u7aef\u7eed\u62a5\u540e\u7b49\u5f85 isexam \u540c\u6b65`);
return false;
} catch (e) {
trace(`\u7b2c${formatWareNo(wareId)}\u8282 \u7eed\u62a5\u5931\u8d25\uff1a${e.message || e}`);
return false;
}
}
async function listTeachPlans() {
return [{ tpId: "favorite", tpName: "\u6211\u7684\u6536\u85cf", year: "" }];
}
async function listAllCoursesForPlan() {
const html = await fetchFavoriteHtml();
return parseCoursesFromHtml(html).map((c) => ({
csId: c.courseId,
tpId: "favorite",
csName: c.title,
courseName: c.title,
lcsProcess: 0,
tpName: "\u6211\u7684\u6536\u85cf",
lcsStudyFinished: "0",
lcsExameFinished: "0",
lcsFinished: "0",
courseTestList: [],
}));
}
const state = {
enabled: false,
studyMode: "",
lastAction: "",
lastTick: 0,
bgTickBusy: false,
faceWaiting: false,
examFaceCancel: false,
examFaceSkipAll: false,
skipExamsSession: false,
courseplayWaitSince: 0,
trainApiTriedAt: 0,
panelCourses: [],
teachPlans: [],
selectedTpId: "",
chapterPreview: [],
queueVideoTotal: 0,
queueVideoDone: 0,
queueExamTotal: 0,
queueExamDone: 0,
coursesDoneSession: 0,
panelRefreshing: false,
previewRefreshTimer: null,
cloudApiBase: DEFAULT_CLOUD_API_BASE,
cloudToken: String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim(),
cloudTier: String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim() ? "unknown" : "free",
cloudLease: "",
cloudLeaseExp: 0,
cloudProExpireAt: 0,
cloudRevoked: false,
freeVideoLimit: 1,
freeUsedVideos: 0,
panelNoticePath: "/api/cme/panel-notice",
remotePanelNotice: "",
proBuyUrl: PRO_BUY_URL,
logLines: [],
panelHint: "",
platformHeartbeatSec: 0,
studyModeHintTimer: null,
cmeUserProfile: null,
};
function loadQueue() {
try {
const raw = JSON.parse(localStorage.getItem(QUEUE_KEY) || "[]");
return Array.isArray(raw) ? raw : [];
} catch (_) {
return [];
}
}
function saveQueue(csIds) {
localStorage.setItem(QUEUE_KEY, JSON.stringify(csIds));
}
function loadSelectedTpId() {
return String(localStorage.getItem(PLAN_TP_KEY) || "").trim();
}
function saveSelectedTpId(tpId) {
const id = String(tpId || "").trim();
localStorage.setItem(PLAN_TP_KEY, id);
state.selectedTpId = id;
}
function getAutoExam() {
return localStorage.getItem(AUTO_EXAM_KEY) !== "0";
}
function shouldRunExam() {
return getAutoExam() && !state.skipExamsSession;
}
function setAutoExam(v) {
localStorage.setItem(AUTO_EXAM_KEY, v ? "1" : "0");
return v;
}
function isTruthyFlag(v) {
return v === true || v === 1 || v === "1";
}
function isFalsyFlag(v) {
return v === false || v === 0 || v === "0";
}
function escHtml(s) {
return String(s || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function getMultiplier() {
const v = parseFloat(localStorage.getItem(MULTIPLIER_KEY) || "2");
if (!Number.isFinite(v)) return 2;
return Math.min(5, Math.max(1, v));
}
function setMultiplier(v) {
const n = Math.min(5, Math.max(1, parseFloat(v) || 1));
localStorage.setItem(MULTIPLIER_KEY, String(n));
return n;
}
function getAdvanceMult() {
const v = parseFloat(localStorage.getItem(ADVANCE_KEY) || "6");
if (!Number.isFinite(v)) return 6;
return Math.min(18, Math.max(1, v));
}
function setAdvanceMult(v) {
const n = Math.min(18, Math.max(1, parseFloat(v) || 1));
localStorage.setItem(ADVANCE_KEY, String(n));
return n;
}
function getSaveInterval() {
if (isRealtimeStudyMode()) return getRealtimeChunkSec();
const v = parseInt(localStorage.getItem(SAVE_INTERVAL_KEY) || "5", 10);
if (!Number.isFinite(v)) return 300;
return Math.min(300, Math.max(3, v));
}
function setSaveInterval(v) {
const n = Math.min(300, Math.max(3, parseInt(v, 10) || 300));
localStorage.setItem(SAVE_INTERVAL_KEY, String(n));
return n;
}
function getAdvanceChunkSec() {
return Math.min(Math.round(HEARTBEAT_BASE * getAdvanceMult()), MAX_ADVANCE_SEC);
}
function readStudyModeSelect() {
return "efficiency";
}
function hasStudyModeSelected() {
return true;
}
function isRealtimeStudyMode() {
return false;
}
function getStudyMode() {
return "efficiency";
}
function setStudyMode(mode) {
state.studyMode = "efficiency";
localStorage.setItem(STUDY_MODE_KEY, "efficiency");
return "efficiency";
}
function ensureEfficiencyMode() {
setStudyMode("efficiency");
}
function clearStudyModeSelection() {
ensureEfficiencyMode();
}
function resetSessionOnPageLoad() {
state.enabled = false;
ensureEfficiencyMode();
localStorage.setItem(STORAGE_KEY, "0");
}
function formatIntervalCn(sec) {
const s = Math.max(1, Math.round(Number(sec) || HEARTBEAT_BASE));
if (s >= 60 && s % 60 === 0) return `${s / 60}\u5206\u949f`;
return `${s}\u79d2`;
}
function formatDurationMinutesCn(sec) {
const s = Math.max(0, Math.round(Number(sec) || 0));
if (s >= 60 && s % 60 === 0) return `${s / 60}\u5206\u949f`;
if (s >= 60) {
const m = (s / 60).toFixed(1).replace(/\.0$/, "");
return `${m}\u5206\u949f`;
}
return `${s}\u79d2`;
}
async function ensurePlatformHeartbeatSec(force) {
if (!force && state.platformHeartbeatSec > 0) return state.platformHeartbeatSec;
const sec = await fetchHeartbeatSec();
state.platformHeartbeatSec = Math.min(300, Math.max(30, Math.round(sec) || HEARTBEAT_BASE));
return state.platformHeartbeatSec;
}
function getRealtimeChunkSec() {
return state.platformHeartbeatSec > 0 ? state.platformHeartbeatSec : HEARTBEAT_BASE;
}
function getMode() {
const m = localStorage.getItem(MODE_KEY) || "background";
return ["background", "autoplay", "manual"].includes(m) ? m : "background";
}
function setMode(m) {
localStorage.setItem(MODE_KEY, m);
return m;
}
function applyLockedPanelDefaults() {
setMode("background");
if (getStudyMode() === "efficiency") {
setSaveInterval(10);
setMultiplier(2);
localStorage.setItem(API_ONLY_STUDY_KEY, "1");
}
setAutoExam(true);
}
function shouldPatchPageLearnSave() {
return getMode() === "manual" || getMode() === "autoplay";
}
function loadBgState() {
try {
return JSON.parse(localStorage.getItem(BG_STATE_KEY) || "null") || {};
} catch (_) {
return {};
}
}
function saveBgState(bg) {
localStorage.setItem(BG_STATE_KEY, JSON.stringify(bg));
}
function flattenChapters(nodes, out) {
out = out || [];
for (const n of nodes || []) {
if (n.cptId) out.push(n);
if (n.childs?.length) flattenChapters(n.childs, out);
}
return out;
}
function isProgressUrl(url) {
return url && String(url).includes("updatePlayStatus.jsp");
}
function patchProgressUrl(url) {
if (!shouldPatchPageLearnSave()) return url;
const mult = getMultiplier();
if (mult <= 1) return url;
try {
const u = new URL(url, location.origin);
const playing = Number(u.searchParams.get("playing_time") || 0);
const total = Number(u.searchParams.get("total_time") || 0);
if (playing > 0) {
const boosted = Math.min(Math.round(playing * mult), total || playing * mult);
if (boosted > playing) {
u.searchParams.set("playing_time", String(boosted));
console.log(`[\u597d\u533b\u751f\u770b\u8bfe] \u8fdb\u5ea6\u4e0a\u62a5 ${playing}s → ${boosted}s\uff08${mult}x\uff09`);
return u.pathname + "?" + u.searchParams.toString();
}
}
} catch (_) {}
return url;
}
const xhrOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
let u = url;
if (isProgressUrl(u)) u = patchProgressUrl(u);
this._cmeUrl = u;
const args = Array.from(arguments);
args[1] = u;
return xhrOpen.apply(this, args);
};
const xhrSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.send = function (body) {
return xhrSend.call(this, body);
};
const nativeFetch = window.fetch;
if (nativeFetch) {
window.fetch = function (input, init) {
let url = typeof input === "string" ? input : input?.url || "";
if (isProgressUrl(url)) {
const patched = patchProgressUrl(url);
if (patched !== url) {
if (typeof input === "string") input = patched;
else input = new Request(patched, input);
}
}
return nativeFetch.call(this, input, init);
};
}
function log(msg) {
const text = String(msg || "").trim();
if (!text) return;
if (!isUserFacingLog(text)) {
trace(text);
return;
}
state.lastUserAction = text;
const ts = new Date().toLocaleTimeString("zh-CN", { hour12: false });
state.logLines.push(`${ts} ${text}`);
if (state.logLines.length > 50) state.logLines.shift();
appendLogLine(`${ts} ${text}`);
updatePanel();
}
function courseDisplayName(nameOrCourse) {
const raw =
typeof nameOrCourse === "string"
? nameOrCourse
: nameOrCourse?.csName || nameOrCourse?.csId || "\u8bfe\u7a0b";
return String(raw).trim() || "\u8bfe\u7a0b";
}
function setPanelHint(msg) {
state.panelHint = String(msg || "").trim();
updatePanel();
}
function clearPanelHint() {
if (!state.panelHint) return;
state.panelHint = "";
updatePanel();
}
let panelToastTimer = null;
function showPanelToast(msg, type) {
const text = String(msg || "").trim();
if (!text) return;
const panel = document.getElementById("cme-auto-panel");
if (!panel) return;
let el = document.getElementById("cme-panel-toast");
if (!el) {
el = document.createElement("div");
el.id = "cme-panel-toast";
panel.appendChild(el);
}
el.className = "cme-panel-toast cme-panel-toast-" + (type || "info");
el.textContent = text;
el.style.display = "block";
clearTimeout(panelToastTimer);
panelToastTimer = setTimeout(() => {
if (el) el.style.display = "none";
}, 3500);
}
function formatCloudTokenError(err) {
const em = String(err?.message || err || "").trim();
if (/invalid token/i.test(em)) return "Token \u65e0\u6548\uff0c\u8bf7\u68c0\u67e5\u662f\u5426\u590d\u5236\u5b8c\u6574";
if (/revoked/i.test(em)) return "Token \u5df2\u88ab\u7981\u7528";
if (/expired/i.test(em)) return "Token \u5df2\u8fc7\u671f";
if (em) return "\u4e91\u7aef\u6821\u9a8c\u5931\u8d25\uff1a" + em;
return "\u4e91\u7aef\u6821\u9a8c\u5931\u8d25";
}
function appendLogLine(text) {
const box = document.getElementById("cme-run-log");
if (!box) return;
const div = document.createElement("div");
div.className = "cme-log-row";
div.textContent = text;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
}
function clearRunLog() {
state.logLines = [];
const box = document.getElementById("cme-run-log");
if (box) box.innerHTML = "";
}
function getCloudApiBase() {
return DEFAULT_CLOUD_API_BASE;
}
function setCloudApiBase() {
state.cloudApiBase = DEFAULT_CLOUD_API_BASE;
}
function formatLeaseExpireText(expSec) {
const ex = Number(expSec || 0);
if (!Number.isFinite(ex) || ex <= 0) return "—";
const d = new Date(ex * 1000);
if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("zh-CN", { hour12: false });
}
function resolveLeaseExpireSec(data) {
const raw = data?.exp ?? data?.expire_at ?? data?.expires_at ?? data?.lease_exp ?? 0;
const n = Number(raw || 0);
if (Number.isFinite(n) && n > 1e12) return Math.floor(n / 1000);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
function resolveProExpireSec(data) {
const raw = data?.pro_expires_at ?? data?.proExpiresAt ?? data?.pro_expire_at ?? 0;
const n = Number(raw || 0);
if (Number.isFinite(n) && n > 1e12) return Math.floor(n / 1000);
return Number.isFinite(n) ? Math.floor(n) : 0;
}
function readCloudLeaseCache() {
try {
const raw = localStorage.getItem(CLOUD_LEASE_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object") return null;
const lease = String(parsed.lease || "").trim();
const exp = Number(parsed.exp || 0);
if (!lease || !Number.isFinite(exp) || exp <= 0) return null;
return {
lease,
exp,
tier: String(parsed.tier || "").trim(),
freeVideoLimit: Number(parsed.freeVideoLimit ?? parsed.free_video_limit ?? parsed.freeChapterLimit ?? 1),
freeUsedVideos: Number(parsed.freeUsedVideos ?? parsed.free_used_videos ?? parsed.freeUsedChapters ?? 0),
proExpireAt: Number(parsed.proExpireAt ?? parsed.pro_expire_at ?? 0),
};
} catch (_) {
return null;
}
}
function writeCloudLeaseCache(lease, exp, extra) {
if (!lease || !exp) {
localStorage.removeItem(CLOUD_LEASE_CACHE_KEY);
return;
}
localStorage.setItem(CLOUD_LEASE_CACHE_KEY, JSON.stringify(Object.assign({ lease, exp }, extra || {})));
}
function readCloudLastState() {
try {
const raw = localStorage.getItem(CLOUD_LAST_STATE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (!p || typeof p !== "object") return null;
return {
tier: String(p.tier || "").trim(),
freeVideoLimit: Number(p.freeVideoLimit ?? p.free_video_limit ?? 1),
freeUsedVideos: Number(p.freeUsedVideos ?? p.free_used_videos ?? 0),
};
} catch (_) {
return null;
}
}
function writeCloudLastState(partial) {
try {
const prev = readCloudLastState() || {};
localStorage.setItem(CLOUD_LAST_STATE_KEY, JSON.stringify(Object.assign({}, prev, partial || {}, { ts: Date.now() })));
} catch (_) {}
}
function writeCloudProExpireCache(token, exp) {
try {
const tk = String(token || "").trim();
if (!tk || !exp) {
localStorage.removeItem(CLOUD_PRO_EXPIRE_CACHE_KEY);
return;
}
localStorage.setItem(CLOUD_PRO_EXPIRE_CACHE_KEY, JSON.stringify({ token: tk, exp: Math.floor(exp) }));
} catch (_) {}
}
function readCloudProExpireCache() {
try {
const raw = localStorage.getItem(CLOUD_PRO_EXPIRE_CACHE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (!p?.token || !p?.exp) return null;
return { token: String(p.token), exp: Number(p.exp) };
} catch (_) {
return null;
}
}
function _gm(url, method, headers, data) {
return new Promise((resolve, reject) => {
const req = {
method: method || "GET",
url,
headers: headers || {},
timeout: 30000,
withCredentials: false,
responseType: "text",
onload: (res) => resolve({ status: res.status, text: res.responseText || "" }),
onerror: () => reject(new Error("\u7f51\u7edc\u9519\u8bef " + url)),
ontimeout: () => reject(new Error("\u8bf7\u6c42\u8d85\u65f6 " + url)),
};
if (data != null) req.data = data;
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest(req);
return;
}
const xhr = new XMLHttpRequest();
xhr.open(req.method, url, true);
xhr.timeout = req.timeout;
Object.keys(req.headers).forEach((k) => xhr.setRequestHeader(k, req.headers[k]));
xhr.onload = () => resolve({ status: xhr.status, text: xhr.responseText || "" });
xhr.onerror = () => reject(new Error("\u7f51\u7edc\u9519\u8bef " + url));
xhr.ontimeout = () => reject(new Error("\u8bf7\u6c42\u8d85\u65f6 " + url));
xhr.send(data);
});
}
async function _rq(path, method, payload) {
const base = getCloudApiBase();
const url = `${base}${path.startsWith("/") ? path : `/${path}`}`;
const headers = { Accept: "application/json", "Content-Type": "application/json" };
const luid = getLearningUserId();
if (luid) headers["x-learning-user-id"] = luid;
if (state.cloudToken) headers.Authorization = `Bearer ${state.cloudToken}`;
let reqPayload = payload;
if (reqPayload && typeof reqPayload === "object") {
reqPayload = Object.assign({}, reqPayload);
if (path !== "/api/cme/lease" && state.cloudLease && reqPayload.lease == null) {
reqPayload.lease = state.cloudLease;
}
}
const body = reqPayload == null ? null : JSON.stringify(reqPayload);
const res = await _gm(url, method || "POST", headers, body);
const text = String(res.text || "").trim();
let data = {};
if (text) data = JSON.parse(text);
if (res.status < 200 || res.status >= 300) {
const errCode = String(data.detail || data.message || data.code || `http_${res.status}`);
throw new Error(errCode);
}
return data;
}
function readCachedCmeProfile() {
try {
const raw = sessionStorage.getItem(CME_USER_PROFILE_KEY);
if (!raw) return null;
const p = JSON.parse(raw);
if (!p || typeof p !== "object") return null;
if (!isProfileCacheValid(p)) return null;
if (!p.userAccount && !p.bdpUserId) return null;
return p;
} catch (_) {
return null;
}
}
function writeCachedCmeProfile(prof) {
try {
if (!prof) {
sessionStorage.removeItem(CME_USER_PROFILE_KEY);
return;
}
sessionStorage.setItem(CME_USER_PROFILE_KEY, JSON.stringify(prof));
} catch (_) {}
}
function clearCmeUserProfileCache() {
state.cmeUserProfile = null;
writeCachedCmeProfile(null);
}
function mergeCmeProfiles() {
const out = {};
for (let i = 0; i < arguments.length; i++) {
const p = arguments[i];
if (!p || typeof p !== "object") continue;
for (const k of Object.keys(p)) {
const v = p[k];
if (v != null && String(v).trim() !== "") out[k] = v;
}
}
if (!out.userAccount && !out.bdpUserId) return null;
return out;
}
function discoverBdpProfileUrl(html) {
const text = String(html || "");
const m =
text.match(/https?:\/\/bdp\.haoyisheng\.com\/bdp\/register\/toUserInfo\?[^"'\s<>]+/i) ||
text.match(/\/\/bdp\.haoyisheng\.com\/bdp\/register\/toUserInfo\?[^"'\s<>]+/i) ||
text.match(/bdp\.haoyisheng\.com\/bdp\/register\/toUserInfo\?[^"'\s<>]+/i);
if (!m) return "";
let u = m[0];
if (!/^https?:/i.test(u)) u = "https://" + u.replace(/^\/\//, "");
return u;
}
function discoverHysSignAuthorization(html) {
const text = String(html || "");
const m =
text.match(/hysSign\/\?authorization=([^"'\s<>]+)/i) ||
text.match(/authorization=(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)/);
return m ? decodeURIComponent(m[1]) : "";
}
function parseBdpProfileHtml(html) {
const text = String(html || "");
if (!text || !/userInfoForm|userAccount|realName/i.test(text)) return null;
const userAccount = pickHtml(text, [
/name=["']userAccount["'][^>]*value=["']([^"']+)["']/i,
/name=["']userAccount["'][^>]*value=([^>\s"']+)/i,
]);
const realName = pickHtml(text, [
/id=["']realName["'][^>]*value=["']([^"']+)["']/i,
/name=["']realName["'][^>]*value=["']([^"']+)["']/i,
]);
const bdpUserId = pickHtml(text, [/name=["']userId["'][^>]*value=["'](\d+)["']/i]);
const certificateNo = pickHtml(text, [
/id=["']certificateNo["'][^>]*value=["']([^"']+)["']/i,
/name=["']certificateNo["'][^>]*value=["']([^"']+)["']/i,
]);
const mobileNumber = pickHtml(text, [
/id=["']mobileNumber["'][^>]*value=["']([^"']+)["']/i,
/name=["']mobileNumber["'][^>]*value=["']([^"']+)["']/i,
]);
const hospital = pickHtml(text, [
/id=["']hospital["'][^>]*value=["']([^"']+)["']/i,
/name=["']hospital["'][^>]*value=["']([^"']+)["']/i,
]);
const department = pickHtml(text, [/name=["']deptName["'][^>]*value=["']([^"']+)["']/i]);
if (!userAccount && !bdpUserId) return null;
return {
userAccount: String(userAccount || "").trim(),
bdpUserId: String(bdpUserId || "").trim(),
realName: String(realName || "").trim(),
certificateNo: String(certificateNo || "").trim(),
mobileNumber: String(mobileNumber || "").trim(),
hospital: String(hospital || "").trim(),
department: String(department || "").trim(),
};
}
async function fetchBdpProfileByUrl(url) {
if (!url) return null;
try {
const res = await _gm(
url,
"GET",
{ Accept: "text/html,application/xhtml+xml", Referer: API_BASE + "/cme/index.jsp" },
null
);
if (res.status < 200 || res.status >= 300) return null;
return parseBdpProfileHtml(res.text);
} catch (_) {
return null;
}
}
async function bootstrapCmeSessionCookies(htmlSources) {
for (const html of htmlSources || []) {
const auth = discoverHysSignAuthorization(html);
if (!auth) continue;
try {
await platformFetch("/sso/hysSign/?authorization=" + encodeURIComponent(auth));
} catch (_) {}
}
}
async function resolveUserAccountFromStudy2(favHtml) {
const html = String(favHtml || "");
if (!html || /\u8bf7\u5148\u767b\u5f55|\u767b\u5f55\u8d85\u65f6|\u672a\u767b\u5f55/i.test(html)) return null;
const courses = parseCoursesFromHtml(html);
const csId = courses[0]?.courseId;
if (!csId) return null;
try {
const r = await cmePageFetch(
`/cme/study2.jsp?course_id=${csId}&courseware_id=01&_=${Date.now()}`
);
if (/\u8bf7\u5148\u767b\u5f55|\u767b\u5f55\u8d85\u65f6|\u672a\u767b\u5f55/i.test(r.text)) return null;
const meta = parseStudyMetaFromHtml(r.text, csId, "01");
const loginName = meta?.loginName || "";
if (!loginName) return null;
trace("\u4ece study2 \u89e3\u6790 userAccount\uff1a" + loginName);
return { userAccount: loginName };
} catch (_) {
return null;
}
}
function parseCmeIndexProfile(html) {
const text = String(html || "");
if (!text) return null;
if (/\u8bf7\u5148\u767b\u5f55|\u767b\u5f55\u8d85\u65f6|\u672a\u767b\u5f55/i.test(text) && !/\u6b22\u8fce\u60a8/.test(text)) return null;
if (!/\u6b22\u8fce\u60a8|IC\s*\u5361|user_id\s*=/.test(text)) return null;
const bdpUserId = pickHtml(text, [
/user_id\s*=\s*['"](\d+)['"]/i,
/var\s+user_id\s*=\s*['"](\d+)['"]/i,
]);
const realName = pickHtml(text, [
/\s*\u6b22\u8fce\u60a8[\uff0c,]\s*([^<]+?)\s*<\/h4>/i,
/\u6b22\u8fce\u60a8[\uff0c,]\s*([^\s<\uff0c,]+)/i,
]);
const userAccount = pickHtml(text, [
/IC\s*\u5361\s*[:\uff1a]\s*([A-Za-z0-9]+)/i,
/linkJumpTo\s*\(\s*['"]([A-Za-z0-9]+)['"]/i,
/name=["']userAccount["'][^>]*value=["']([^"']+)["']/i,
/userAccount\s*[=:]\s*['"]([A-Za-z0-9]+)['"]/i,
/var\s+loginName\s*=\s*['"]([^'"]+)['"]/i,
/loginName\s*[=:]\s*['"]([A-Za-z0-9]+)['"]/i,
]);
const department = pickHtml(text, [/\u79d1\u5ba4[\uff1a:]\s*([^<\n]+)/i]);
const organization = pickHtml(text, [/\u673a\u6784[\uff1a:]\s*([^<\n]+)/i]);
const hospital = pickHtml(text, [/\u5355\u4f4d[\uff1a:]\s*([^<\n]+)/i]);
const clean = (s) =>
String(s || "")
.replace(/<[^>]+>/g, "")
.replace(/\s+/g, " ")
.trim();
const ic = String(userAccount || "").trim();
const uid = String(bdpUserId || "").trim();
if (!ic && !uid) return null;
return {
userAccount: ic,
bdpUserId: uid,
realName: clean(realName),
department: clean(department),
organization: clean(organization),
hospital: clean(hospital),
};
}
async function _fip(forceRefresh) {
if (!hasCmeSessionSync()) {
const online = await probeCmeSessionOnline();
if (!online) {
clearCmeUserProfileCache();
return null;
}
}
if (!forceRefresh) {
const cached = state.cmeUserProfile || readCachedCmeProfile();
if (cached && isProfileCacheValid(cached)) {
state.cmeUserProfile = cached;
return cached;
}
}
const htmlSources = [];
const loadPage = async (path, useDom) => {
if (useDom) return document.documentElement.outerHTML;
try {
const r = await cmePageFetch(path + (path.includes("?") ? "&" : "?") + "_=" + Date.now());
return r.text;
} catch (_) {
return "";
}
};
const loginScriptHtml = await loadPage("/loginScript.jsp", false);
if (loginScriptHtml) htmlSources.push(loginScriptHtml);
const indexHtml = await loadPage("/cme/index.jsp", pageType() === "home");
if (indexHtml) htmlSources.push(indexHtml);
const favHtml = await loadPage("/cme/myFavorite.jsp", pageType() === "favorite");
if (favHtml) htmlSources.push(favHtml);
await bootstrapCmeSessionCookies(htmlSources);
let merged = null;
for (const html of htmlSources) {
merged = mergeCmeProfiles(merged, parseCmeIndexProfile(html));
merged = mergeCmeProfiles(merged, parseBdpProfileHtml(html));
}
let bdpUrl = "";
for (const html of htmlSources) {
bdpUrl = discoverBdpProfileUrl(html);
if (bdpUrl) break;
}
if (bdpUrl) {
const bdp = await fetchBdpProfileByUrl(bdpUrl);
merged = mergeCmeProfiles(merged, bdp);
}
if (!merged?.userAccount && favHtml) {
const fromStudy = await resolveUserAccountFromStudy2(favHtml);
merged = mergeCmeProfiles(merged, fromStudy);
}
if (!merged?.userAccount && !merged?.bdpUserId) return null;
const prof = Object.assign({ sessionFp: getSessionFingerprint() || "online", ts: Date.now() }, merged);
state.cmeUserProfile = prof;
writeCachedCmeProfile(prof);
if (prof.userAccount) trace("\u8d26\u53f7\u7ed1\u5b9a ID\uff1a" + prof.userAccount);
return prof;
}
async function _fup() {
const luid = getLearningUserId();
if (!luid) return null;
return { learning_user_id: luid };
}
async function _el(forceRefresh) {
const now = Math.floor(Date.now() / 1000);
if (!forceRefresh && state.cloudLease && state.cloudLeaseExp - now > 60) return true;
if (!forceRefresh) {
const cached = readCloudLeaseCache();
if (cached && cached.exp - now > 60) {
state.cloudLease = cached.lease;
state.cloudLeaseExp = cached.exp;
state.cloudProExpireAt = Number(cached.proExpireAt || 0);
if (cached.tier) state.cloudTier = cached.tier;
if (cached.freeVideoLimit > 0) state.freeVideoLimit = cached.freeVideoLimit;
if (cached.freeUsedVideos >= 0) state.freeUsedVideos = cached.freeUsedVideos;
updateCloudPanelUI();
return true;
}
}
const luid = getLearningUserId();
if (!luid) throw new Error("\u672a\u767b\u5f55\uff0c\u65e0\u6cd5\u83b7\u53d6\u4e91\u7aef\u6388\u6743");
let data;
try {
data = await _rq("/api/cme/lease", "POST", { learning_user_id: luid });
state.cloudRevoked = false;
} catch (e) {
const em = String(e?.message || e);
if (/(revoked|invalid token|expired)/i.test(em)) {
state.cloudRevoked = true;
state.cloudTier = "revoked";
state.cloudLease = "";
state.cloudLeaseExp = 0;
writeCloudLeaseCache("", 0);
updateCloudPanelUI();
}
throw e;
}
const lease = String(data.lease || "");
const exp = resolveLeaseExpireSec(data);
let tier = String(data.tier || "").trim() || (state.cloudToken ? "pro" : "free");
const proExpireAt = resolveProExpireSec(data);
state.cloudLease = lease;
state.cloudLeaseExp = exp;
state.cloudProExpireAt = proExpireAt > 0 ? proExpireAt : 0;
state.cloudTier = tier;
state.freeVideoLimit = Number(
data.free_video_limit ?? data.free_chapter_limit ?? data.freeVideoLimit ?? state.freeVideoLimit ?? 1
);
state.freeUsedVideos = Number(
data.free_used_videos ?? data.free_used_chapters ?? data.freeUsedVideos ?? 0
);
writeCloudLeaseCache(lease, exp, {
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
proExpireAt: state.cloudProExpireAt,
});
writeCloudLastState({
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
});
if (tier === "pro" && state.cloudProExpireAt > 0) {
writeCloudProExpireCache(state.cloudToken, state.cloudProExpireAt);
}
updateCloudPanelUI();
return !!lease;
}
async function _vt() {
if (!state.cloudToken) return true;
const base = getCloudApiBase();
const url = `${base}${"/api/license/verify"}`;
const headers = { Accept: "application/json", Authorization: `Bearer ${state.cloudToken}` };
const luid = getLearningUserId();
if (luid) headers["x-learning-user-id"] = luid;
const res = await _gm(url, "GET", headers, null);
const text = String(res.text || "").trim();
let data = {};
if (text) data = JSON.parse(text);
if (res.status < 200 || res.status >= 300 || !data.ok) {
throw new Error(String(data.message || data.detail || "Token \u6821\u9a8c\u5931\u8d25"));
}
return true;
}
function _sq(data) {
if (!data || typeof data !== "object") return;
if (data.tier) state.cloudTier = String(data.tier);
const lim = data.free_video_limit ?? data.free_chapter_limit ?? data.limit;
const used = data.free_used_videos ?? data.free_used_chapters ?? data.used;
if (lim != null) state.freeVideoLimit = Number(lim);
if (used != null) state.freeUsedVideos = Number(used);
writeCloudLastState({
tier: state.cloudTier,
freeVideoLimit: state.freeVideoLimit,
freeUsedVideos: state.freeUsedVideos,
});
updateCloudPanelUI();
}
async function _es(kind, context, cfg) {
await _el(false);
const data = await _rq("/api/cme/study/engine/start", "POST", {
kind: String(kind || ""),
context: context || {},
config: cfg || {},
});
_sq(data);
return data;
}
async function _ep(sessionId, event, lastResult, contextPatch) {
await _el(false);
const data = await _rq("/api/cme/study/engine/step", "POST", {
session_id: String(sessionId || ""),
event: String(event || "tick"),
last_result: lastResult == null ? null : lastResult,
context_patch: contextPatch == null ? null : contextPatch,
});
_sq(data);
return data;
}
function assertCmeAllowedEnginePath(pathOrUrl) {
const raw = String(pathOrUrl || "").trim();
if (!raw) throw new Error("engine_path_empty");
if (raw.startsWith("/") && !raw.startsWith("//")) return raw;
let host = "";
try {
host = new URL(raw, location.href).hostname || "";
} catch (_) {
throw new Error("engine_path_invalid");
}
if (!/(^|\.)(cmechina\.net|haoyisheng\.com)$/i.test(host)) {
throw new Error("engine_host_blocked");
}
return raw;
}
async function _xc(cmd) {
const c = cmd || {};
const t = String(c.type || "");
if (t === "document_fetch") {
const fetchPath = assertCmeAllowedEnginePath(c.path || c.url || "");
const r = await documentFetch(fetchPath, {
method: "GET",
headers: c.headers || {},
});
return {
event: c.event || "submit_result",
lastResult: { text: r.text, status: r.status, url: r.url },
};
}
if (t === "platform_fetch" || t === "platform_get") {
const getPath = assertCmeAllowedEnginePath(c.path || c.url || "");
const r = await platformFetch(getPath, {
method: "GET",
headers: c.headers || {},
});
return {
event: c.event || "submit_result",
lastResult: { text: r.text, status: r.status, url: r.url },
};
}
if (t === "platform_post") {
const postPath = assertCmeAllowedEnginePath(c.path || "");
const headers = Object.assign({}, c.headers || {});
if (!headers["Content-Type"] && !headers["content-type"]) {
headers["Content-Type"] = "application/x-www-form-urlencoded";
}
const body =
c.body != null
? c.body
: typeof c.payload === "string"
? c.payload
: new URLSearchParams(c.payload || {}).toString();
const r = await platformFetch(postPath, { method: "POST", headers, body });
let data;
try {
data = JSON.parse(r.text);
} catch {
data = { text: r.text };
}
return {
event: "submit_result",
lastResult: { data, http_status: r.status, text: r.text, url: r.url },
};
}
if (t === "wait") {
await sleep(Math.max(500, Number(c.ms || 1000)));
return { event: "tick", lastResult: null };
}
if (t === "done") {
return {
terminal: true,
ok: !!c.success,
skipped: !!c.skipped,
score: c.score,
msg: c.message || "\u5b8c\u6210",
};
}
if (t === "failed") {
return { terminal: true, ok: false, msg: c.message || "failed" };
}
return { event: "tick", lastResult: null };
}
function shouldLogEngineLine(logText, options) {
const s = String(logText || "");
if (!s) return false;
if (options && options.realtime) return /\[1:1\]|learnSave \u672a\u6210\u529f|\u9996\u6b21\u8fdb\u5165\u7b49\u5f85/.test(s);
return true;
}
async function _ve(startRes, options) {
const quiet = !!(options && options.quiet);
const realtime = !!(options && options.realtime);
let sessionId = String(startRes.session_id || "");
let cmd = startRes.command;
if (!quiet && shouldLogEngineLine(startRes.log, options)) {
trace(startRes.log);
}
const maxSteps = Number(options && options.maxSteps) || (realtime ? 320 : 96);
for (let i = 0; i < maxSteps && cmd; i++) {
const out = await _xc(cmd);
if (out.terminal !== undefined) return out;
const next = await _ep(sessionId, out.event, out.lastResult, null);
sessionId = String(next.session_id || sessionId);
if (!quiet && shouldLogEngineLine(next.log, options)) {
trace(next.log);
}
cmd = next.command;
}
return { terminal: true, ok: false, msg: "\u4e91\u7aef\u5f15\u64ce\u6b65\u6570\u8d85\u9650" };
}
async function _xe(startRes, courseName) {
let sessionId = String(startRes.session_id || "");
let cmd = startRes.command;
let questionCount = 0;
let submitAnnounced = false;
const maxSteps = 96;
for (let i = 0; i < maxSteps && cmd; i++) {
const out = await _xc(cmd);
if (out.terminal !== undefined) {
if (out.ok) {
const score = out.score != null && out.score !== "" ? out.score : "—";
log(`\u8bfe\u7a0b${courseName}\u8003\u8bd5\u5b8c\u6210\uff0c\u5f97\u5206\uff1a${score}\u5206`);
}
return out;
}
const next = await _ep(sessionId, out.event, out.lastResult, null);
sessionId = String(next.session_id || sessionId);
const slog = String(next.log || "");
const batchM = slog.match(/\u6279\u91cf\u4fdd\u5b58\s*(\d+)/);
if (batchM) {
questionCount = Number(batchM[1]) || questionCount;
if (!submitAnnounced && questionCount > 0) {
submitAnnounced = true;
log(`\u8bfe\u7a0b${courseName}\u8003\u8bd5\u5f00\u59cb\uff0c\u5171${questionCount}\u9053\u9898\uff0c\u5df2\u63d0\u4ea4\u7b54\u6848`);
}
}
if (/\u63d0\u4ea4\u7b54\u5377/.test(slog) && !submitAnnounced) {
const qm = slog.match(/(\d+)\s*\u9898/);
if (qm) {
submitAnnounced = true;
log(`\u8bfe\u7a0b${courseName}\u8003\u8bd5\u5f00\u59cb\uff0c\u5171${qm[1]}\u9053\u9898\uff0c\u5df2\u63d0\u4ea4\u7b54\u6848`);
}
}
if (/\u8003\u8bd5\u4f5c\u7b54\u4e2d/.test(slog)) {
log(`\u8bfe\u7a0b${courseName}\uff1a${slog}`);
}
if (/^\u4ea4\u5377/.test(slog)) {
log(`\u8bfe\u7a0b${courseName}\u5f00\u59cb\u4ea4\u5377`);
}
cmd = next.command;
}
if (!cmd) {
trace("\u4e91\u7aef\u8003\u8bd5\uff1a\u5f15\u64ce\u672a\u8fd4\u56de\u4e0b\u4e00\u6b65\u6307\u4ee4");
return { terminal: true, ok: false, msg: "\u4e91\u7aef\u5f15\u64ce\u65e0\u4e0b\u4e00\u6b65\u6307\u4ee4" };
}
trace(`\u4e91\u7aef\u8003\u8bd5\uff1a\u6b65\u6570\u8fbe\u5230\u4e0a\u9650 ${maxSteps}`);
return { terminal: true, ok: false, msg: "\u4e91\u7aef\u5f15\u64ce\u6b65\u6570\u8d85\u9650" };
}
async function _cr() {
if (!(await ensureCmeSession())) {
clearCmeUserProfileCache();
log("\u8bf7\u5148\u767b\u5f55\u597d\u533b\u751f\u5e73\u53f0");
return false;
}
const prof = await _fip(true);
if (!prof?.userAccount && !prof?.bdpUserId) {
log("\u65e0\u6cd5\u8bfb\u53d6\u8d26\u53f7\u8d44\u6599\uff0c\u8bf7\u5237\u65b0\u6536\u85cf\u9875\u6216\u6253\u5f00\u9996\u9875\u540e\u91cd\u8bd5");
return false;
}
if (!prof?.userAccount) {
trace("\u672a\u89e3\u6790\u5230 userAccount\uff0c\u6682\u7528 BDP \u7528\u6237 ID\uff1a" + prof.bdpUserId);
}
try {
if (state.cloudToken) await _vt();
await _el(false);
} catch (err) {
log("\u4e91\u7aef\u6388\u6743\u5931\u8d25\uff1a" + (err.message || err));
return false;
}
const tier = String(state.cloudTier || "").toLowerCase();
if (tier === "pro") return true;
if (tier === "free") {
if (Number(state.freeUsedVideos) >= Number(state.freeVideoLimit)) {
log(`\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c\uff08${state.freeUsedVideos}/${state.freeVideoLimit}\uff09\uff0c\u8bf7\u5347\u7ea7 Pro`);
switchPanelTab("settings");
openProModal();
return false;
}
return true;
}
log("\u4e91\u7aef\u6388\u6743\u72b6\u6001\u5f02\u5e38\uff0c\u8bf7\u68c0\u67e5 Token \u6216\u4e91\u7aef\u5730\u5740");
return false;
}
function _so() {
try {
return new URL(getCloudApiBase()).origin;
} catch (_) {
return "";
}
}
async function _pn() {
const origin = _so();
if (!origin) return;
const path = String(state.panelNoticePath || "/api/cme/panel-notice");
const url = `${origin}${path.startsWith("/") ? path : `/${path}`}`;
try {
const res = await _gm(url, "GET", {}, null);
if (res.status >= 200 && res.status < 300) {
state.remotePanelNotice = String(res.text || "").trim() || PANEL_NOTICE_FALLBACK;
const el = document.querySelector("#cme-ann-text");
if (el) el.textContent = state.remotePanelNotice;
}
} catch (_) {}
}
async function _cf() {
try {
const data = await _gm(
`${getCloudApiBase()}${"/api/cme/client-config"}`,
"GET",
{ Accept: "application/json" },
null
);
if (data.status >= 200 && data.status < 300 && data.text) {
const j = JSON.parse(data.text);
if (j?.panelNoticePath) state.panelNoticePath = String(j.panelNoticePath);
if (j?.freeVideoLimit != null) state.freeVideoLimit = Number(j.freeVideoLimit) || 1;
if (j?.proBuyUrl) state.proBuyUrl = String(j.proBuyUrl).trim() || PRO_BUY_URL;
}
} catch (_) {}
await _pn();
}
function getProBuyUrl() {
return String(state.proBuyUrl || PRO_BUY_URL).trim() || PRO_BUY_URL;
}
function isProBuyOpen() {
return Date.now() >= PRO_BUY_OPEN_MS;
}
function syncProBuyButtonUi() {
const btn = document.getElementById("cme-pro-buy-link");
if (!btn) return;
const open = isProBuyOpen();
btn.disabled = !open;
btn.textContent = open ? "\u524d\u5f80\u8d2d\u4e70\u9875\u5f00\u901a Pro" : "6\u670810\u65e519\u70b9\u5f00\u653e\u8d2d\u4e70";
btn.title = open ? "\u6253\u5f00\u8d2d\u4e70\u9875\u83b7\u53d6 Token" : "6\u670810\u65e5\u516c\u6d4b\u8bf7\u4eceQQ\u7fa4\u83b7\u53d6Token";
}
function openProBuyPage() {
if (!isProBuyOpen()) {
log("\u8d2d\u4e70\u9875\u5c06\u4e8e6\u670810\u65e519\u70b9\u5f00\u653e\uff0c\u516c\u6d4b\u8bf7\u52a0\u5165QQ\u7fa4\u83b7\u53d6Token");
return;
}
const url = getProBuyUrl();
try {
GM_openInTab(url, { active: true, insert: true, setParent: true });
} catch (_) {
window.open(url, "_blank", "noopener,noreferrer");
}
}
function openProModal() {
syncProBuyButtonUi();
const vm = document.getElementById("cme-pro-modal");
if (vm) vm.style.display = "flex";
}
function closeProModal() {
const vm = document.getElementById("cme-pro-modal");
if (vm) vm.style.display = "none";
}
function createProModal() {
const old = document.getElementById("cme-pro-modal");
if (old) old.remove();
const modal = document.createElement("div");
modal.id = "cme-pro-modal";
modal.innerHTML = `
\u5f00\u901a Pro
Pro \u7528\u6237\u53ef\u65e0\u9650\u5236\u89c6\u9891\u5b66\u4e60\uff0c\u5e76\u81ea\u52a8\u5b8c\u6210\u8bfe\u7a0b\u8003\u8bd5
Pro \u6743\u76ca
- \u89e3\u9664\u514d\u8d39\u4f53\u9a8c\u89c6\u9891\u6b21\u6570\u9650\u5236\uff0c\u5df2\u9009\u8bfe\u7a0b\u53ef\u6301\u7eed\u5b66\u4e60
- \u652f\u6301\u5b66\u5b8c\u540e\u81ea\u52a8\u8003\u8bd5\u4ea4\u5377
- Pro \u6709\u6548\u671f 10 \u5929\uff08\u7ed1\u5b9a\u597d\u533b\u751f\u8d26\u53f7\uff0c\u5207\u6362\u8d26\u53f7\u5931\u6548\uff09
\u63d0\u793a\uff1aPro \u6743\u9650\u7ed1\u5b9a\u5f53\u524d\u767b\u5f55\u7684\u597d\u533b\u751f\u8d26\u53f7\uff0c\u8bf7\u52ff\u4e0e\u4ed6\u4eba\u5171\u7528 Token\u3002
\u5f00\u901a\u65b9\u5f0f
1) \u52a0\u5165 QQ \u7fa4 ${QQ_GROUP_NUMBER}\uff0c\u8054\u7cfb\u7ba1\u7406\u5458\u83b7\u53d6 Token\uff08\u516c\u6d4b\u671f\u95f4\u622a\u6b626\u670810\u65e5\uff0c\u53cd\u9988Bug\u53ef\u4f18\u5148\u4f53\u9a8c\uff09
2) \u524d\u5f80\u8d2d\u4e70\u9875\u83b7\u53d6 Token\uff0c24 \u5c0f\u65f6\u81ea\u52a9\u53d1\u8d27
\u8d2d\u5f97 Token \u540e\uff1a\u5728\u8bbe\u7f6e\u9875 Token \u8f93\u5165\u6846\u7c98\u8d34\uff0c\u70b9\u51fb\u300c\u4fdd\u5b58\u5e76\u6821\u9a8c\u300d\u3002
\u91cd\u8981\uff1a\u8bf7\u52ff\u968f\u610f\u6cc4\u9732 Token\uff0c\u907f\u514d\u8d26\u53f7\u88ab\u591a\u4eba\u5171\u7528\u5bfc\u81f4\u5931\u6548\u3002
`;
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === modal) closeProModal();
});
modal.querySelector("#cme-pro-close")?.addEventListener("click", closeProModal);
modal.querySelector("#cme-pro-buy-link")?.addEventListener("click", openProBuyPage);
syncProBuyButtonUi();
}
function switchPanelTab(name) {
document.querySelectorAll(".cme-tab-btn").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === name);
});
document.querySelectorAll(".cme-pane").forEach((el) => {
el.classList.toggle("active", el.dataset.pane === name);
});
}
function formatCloudTierText(tier) {
if (state.cloudRevoked) return "\u5df2\u7981\u7528";
const t = String(tier || "").trim().toLowerCase();
if (t === "pro") return "Pro \u4f1a\u5458";
if (t === "free") return "\u514d\u8d39\u4f53\u9a8c";
if (t === "debug") return "\u672c\u5730\u8c03\u8bd5";
if (t === "revoked") return "\u5df2\u7981\u7528";
if (t === "unknown") return "\u672a\u6821\u9a8c";
return tier ? String(tier) : "\u672a\u6821\u9a8c";
}
function updateCloudPanelUI() {
const tierEl = document.querySelector("#cme-cloud-tier");
const freeEl = document.querySelector("#cme-cloud-free");
const freeLabelEl = document.querySelector("#cme-cloud-free-label");
const tokenInput = document.querySelector("#cme-cloud-token");
if (tierEl) {
tierEl.textContent = formatCloudTierText(state.cloudTier);
tierEl.style.color = state.cloudRevoked || String(state.cloudTier || "").toLowerCase() === "revoked" ? "#dc2626" : "#0f172a";
}
if (state.cloudRevoked) {
if (freeLabelEl) freeLabelEl.textContent = "\u6388\u6743\u72b6\u6001";
if (freeEl) freeEl.textContent = "Token \u5df2\u7981\u7528";
} else if (String(state.cloudTier || "").toLowerCase() === "debug") {
if (freeLabelEl) freeLabelEl.textContent = "\u8fd0\u884c\u6a21\u5f0f";
if (freeEl) freeEl.textContent = "\u672c\u5730\u5f15\u64ce\uff08\u65e0\u4e91\u7aef\uff09";
} else if (String(state.cloudTier || "").toLowerCase() === "pro") {
if (freeLabelEl) freeLabelEl.textContent = "Pro \u5230\u671f";
if (freeEl) freeEl.textContent = formatLeaseExpireText(state.cloudProExpireAt || state.cloudLeaseExp);
} else {
if (freeLabelEl) freeLabelEl.textContent = "\u514d\u8d39\u4f53\u9a8c\u89c6\u9891";
if (freeEl) freeEl.textContent = `${state.freeUsedVideos}/${state.freeVideoLimit}`;
}
if (tokenInput && tokenInput !== document.activeElement) tokenInput.value = state.cloudToken || "";
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function getCookie(name) {
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return m ? decodeURIComponent(m[1]) : "";
}
function getSessionFingerprint() {
return String(getCookie("client_id") || "").trim();
}
function isProfileCacheValid(prof) {
if (!prof) return false;
const fp = getSessionFingerprint();
const cachedFp = String(prof.sessionFp || prof.jsid || "");
if (fp && cachedFp) return fp === cachedFp;
return state.cmeUserProfile === prof;
}
function isLoggedInByDom() {
if (pageType() !== "favorite" && pageType() !== "home") return false;
const html = document.documentElement.outerHTML;
if (/\u8bf7\u5148\u767b\u5f55|\u767b\u5f55\u8d85\u65f6|\u672a\u767b\u5f55|cmsLogin/i.test(html)) return false;
return /course\.jsp\?course_id=|\u6b22\u8fce\u60a8|IC\s*\u5361|home_table/i.test(html);
}
function hasCmeSessionSync() {
if (getSessionFingerprint()) return true;
if (getLearningUserId()) return true;
return isLoggedInByDom();
}
async function probeCmeSessionOnline() {
try {
const r = await cmePageFetch("/cme/myFavorite.jsp?_=" + Date.now());
if (/\u8bf7\u5148\u767b\u5f55|\u767b\u5f55\u8d85\u65f6|\u672a\u767b\u5f55|cmsLogin/i.test(r.text)) return false;
return /course\.jsp\?course_id=|home_table|myFavorite/i.test(r.text);
} catch (_) {
return false;
}
}
async function ensureCmeSession() {
if (hasCmeSessionSync()) return true;
return probeCmeSessionOnline();
}
function getUserData() {
const prof = state.cmeUserProfile || readCachedCmeProfile();
const uid = getLearningUserId();
if (!uid) return null;
return {
custId: uid,
loginName: prof?.userAccount || uid,
realName: prof?.realName || "",
};
}
function syncCmeProfileFromDom() {
if (pageType() !== "home") return null;
const parsed = parseCmeIndexProfile(document.documentElement.outerHTML);
if (!parsed?.userAccount && !parsed?.bdpUserId) return null;
if (!hasCmeSessionSync()) return null;
const prof = Object.assign({ sessionFp: getSessionFingerprint() || "dom", ts: Date.now() }, parsed);
state.cmeUserProfile = prof;
writeCachedCmeProfile(prof);
return prof;
}
function isLoggedIn() {
if (hasCmeSessionSync()) return true;
return isLoggedInByDom();
}
function getLearningUserId() {
const prof = state.cmeUserProfile || readCachedCmeProfile();
if (prof?.userAccount) return String(prof.userAccount).trim();
if (prof?.bdpUserId) return String(prof.bdpUserId).trim();
return "";
}
function pageType() {
const path = location.pathname;
if (path.includes("study2") || path.includes("polyv")) return "courseplay";
if (path.includes("myFavorite")) return "favorite";
if (path.includes("course.jsp")) return "course";
if (path.includes("/cme/index.jsp") || path.endsWith("index.jsp")) return "home";
return "other";
}
function getCourseParams() {
const qs = new URLSearchParams(location.search);
return {
csId: qs.get("course_id") || qs.get("id") || "",
wareId: qs.get("courseware_id") || "",
};
}
function courseplayPath(csId, tpId, wareId, productId) {
if (wareId) {
let p = `/cme/study2.jsp?course_id=${csId}&courseware_id=${wareId}`;
if (productId) p += `&product_id=${productId}`;
return p;
}
return `/cme/course.jsp?course_id=${csId}`;
}
function readNavGuard() {
try {
return JSON.parse(sessionStorage.getItem(NAV_GUARD_KEY) || "{}");
} catch (_) {
return {};
}
}
function writeNavGuard(url) {
sessionStorage.setItem(
NAV_GUARD_KEY,
JSON.stringify({ url, time: Date.now() })
);
}
function isSameCoursePage(csId) {
return (pageType() === "courseplay" || pageType() === "course") && getCourseParams().csId === csId;
}
function canNavigate(url) {
const target = new URL(url, location.origin).pathname + new URL(url, location.origin).search;
const current = location.pathname + location.search;
if (target === current) return false;
const guard = readNavGuard();
if (guard.url === target && Date.now() - (guard.time || 0) < NAV_COOLDOWN_MS) {
return false;
}
return true;
}
function safeNavigate(url) {
if (!canNavigate(url)) return false;
writeNavGuard(url);
log("\u8df3\u8f6c\uff1a" + url);
location.href = url;
return true;
}
function canClickChapter(chapterId) {
if (!chapterId) return false;
const guard = readNavGuard();
if (guard.chapterId === chapterId && Date.now() - (guard.chapterClickAt || 0) < CHAPTER_CLICK_COOLDOWN_MS) {
return false;
}
return true;
}
function markChapterClick(chapterId) {
const guard = readNavGuard();
guard.chapterId = chapterId;
guard.chapterClickAt = Date.now();
sessionStorage.setItem(NAV_GUARD_KEY, JSON.stringify(guard));
}
function visible(el) {
if (!el) return false;
const st = getComputedStyle(el);
if (st.display === "none" || st.visibility === "hidden" || st.opacity === "0") return false;
return el.offsetWidth > 0 || el.offsetHeight > 0;
}
function clickEl(el) {
if (!el) return false;
el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window }));
return true;
}
function findVisibleButton(root, pattern) {
return Array.from(root.querySelectorAll("button, .el-button, a")).find((el) =>
pattern.test((el.textContent || "").replace(/\s+/g, ""))
);
}
function dismissDialogs() {
const onCoursePlay = pageType() === "courseplay";
document.querySelectorAll(".el-message-box__wrapper, .el-dialog__wrapper").forEach((wrapper) => {
if (!visible(wrapper)) return;
const btn = onCoursePlay
? findVisibleButton(wrapper, /^(\u786e\u5b9a|\u77e5\u9053\u4e86|\u7ee7\u7eed|\u786e\u8ba4)$/) ||
findVisibleButton(wrapper, /(\u786e\u5b9a|\u77e5\u9053\u4e86)/)
: findVisibleButton(wrapper, /^(\u53bb\u5b66\u4e60|\u786e\u5b9a|\u77e5\u9053\u4e86|\u7ee7\u7eed|\u786e\u8ba4)$/) ||
findVisibleButton(wrapper, /(\u53bb\u5b66\u4e60|\u786e\u5b9a)/);
if (btn) clickEl(btn);
});
}
function isFaceDialogVisible() {
const qr = document.getElementById("qrCode");
if (qr && visible(qr.closest(".el-dialog__wrapper") || qr)) return true;
return Array.from(document.querySelectorAll(".el-dialog__wrapper")).some((w) => {
if (!visible(w)) return false;
const t = w.textContent || "";
return /\u4eba\u8138|\u8bc6\u522b|\u626b\u7801|\u4e0a\u4f20\u5934\u50cf/.test(t);
});
}
function getVideoEl() {
const box = document.getElementById("player_video");
if (box) {
const v = box.querySelector("video");
if (v) return v;
}
return document.querySelector("#player_video video, .playboxin video, video");
}
function getPlayButton() {
return (
document.querySelector(".e3cplay-play, .vjs-play-control, .xgplayer-play, .play-btn") ||
document.querySelector("#player_video .vjs-big-play-button")
);
}
async function ensurePlaying() {
if (isFaceDialogVisible()) {
if (!state.faceWaiting) {
state.faceWaiting = true;
log("\u68c0\u6d4b\u5230\u4eba\u8138\u8bc6\u522b\uff0c\u8bf7\u624b\u673a\u626b\u7801\u540e\u7ee7\u7eed");
}
return;
}
state.faceWaiting = false;
const video = getVideoEl();
if (!video) return;
if (video.ended) return;
if (video.paused) {
try {
video.muted = true;
await video.play();
log("\u5df2\u81ea\u52a8\u64ad\u653e\u89c6\u9891");
return;
} catch (_) {
const btn = getPlayButton();
if (btn) {
clickEl(btn);
log("\u5df2\u70b9\u51fb\u64ad\u653e\u6309\u94ae");
}
}
}
}
function chapterDone(link) {
const img = link.querySelector("img.course_play_percent");
if (!img || !img.src) return false;
return /play100|100/.test(img.src) || /play100/.test(img.getAttribute("src") || "");
}
function getChapterLinks() {
return Array.from(document.querySelectorAll("a.cptresult_a"));
}
function getCurrentChapterId() {
const active = document.querySelector("li.active a.cptresult_a");
return active?.dataset?.name || active?.id || "";
}
function findNextIncompleteChapter(skipCurrent) {
const links = getChapterLinks();
if (!links.length) return null;
const currentId = getCurrentChapterId();
let passedCurrent = !currentId || !skipCurrent;
for (const link of links) {
const id = link.dataset.name || link.id;
if (!passedCurrent) {
if (id === currentId) passedCurrent = true;
continue;
}
if (!chapterDone(link)) return link;
}
if (skipCurrent) return null;
return links.find((link) => !chapterDone(link)) || null;
}
function ensureInStudyTab() {
const inStudyTab = Array.from(document.querySelectorAll(".tab-ul li, .zaixue-course-tit li")).find((li) =>
/\u5728\u5b66/.test(li.textContent || "")
);
if (inStudyTab && !inStudyTab.classList.contains("on") && !inStudyTab.classList.contains("active")) {
clickEl(inStudyTab.querySelector("a") || inStudyTab);
return true;
}
return false;
}
function apiPost(path, payload) {
const body =
payload == null
? null
: typeof payload === "string"
? payload
: new URLSearchParams(payload).toString();
return platformFetch(path, {
method: "POST",
headers: body ? { "Content-Type": "application/x-www-form-urlencoded" } : {},
body,
}).then((r) => {
try {
return JSON.parse(r.text);
} catch {
return { success: r.status < 400 ? "1" : "0", text: r.text, status: r.status };
}
});
}
async function fetchHeartbeatSec() {
return HEARTBEAT_BASE;
}
function isCourseUnfinished(c) {
if (!c) return false;
if (isFalsyFlag(c.lcsFinished)) return true;
if (isFalsyFlag(c.lcsStudyFinished)) return true;
if (isFalsyFlag(c.lcsExameFinished) && (c.courseTestList || []).length) return true;
const p = parseFloat(c.lcsProcess);
return !Number.isNaN(p) && p < 100;
}
function isCourseFullyFinished(c) {
if (!c) return false;
if (isTruthyFlag(c.lcsFinished)) return true;
const hasExam = (c.courseTestList || []).length > 0;
if (hasExam) return isStudyFinished(c) && isTruthyFlag(c.lcsExameFinished);
return isStudyFinished(c);
}
function isStudyFinished(course) {
if (!course) return false;
if (isTruthyFlag(course.lcsStudyFinished)) return true;
return parseFloat(course.lcsProcess || 0) >= 100;
}
function courseNeedsExam(course) {
if (!shouldRunExam() || !course) return false;
if (isTruthyFlag(course.lcsExameFinished)) return false;
const test = (course.courseTestList || [])[0];
if (!test?.tsId) return false;
return isStudyFinished(course);
}
async function enrichPanelCourse(course) {
if (!course?.csId || course.chaptersLoaded) return course;
try {
const built = await buildCourseFromChapters(course.csId, course.csName);
Object.assign(course, built, { chaptersLoaded: true, progressError: "" });
} catch (err) {
const msg = String(err.message || err);
course.progressError = /\u5b89\u5168\u9a8c\u8bc1/.test(msg)
? "\u9700\u6d4f\u89c8\u5668\u6253\u5f00\u8bfe\u7a0b\u9a8c\u8bc1"
: msg.slice(0, 36);
if (/\u5b89\u5168\u9a8c\u8bc1|\u7f51\u7edc\u9519\u8bef|\u8bf7\u6c42\u8d85\u65f6/.test(msg)) course.chaptersLoaded = true;
trace(`\u8bfe\u7a0b ${course.csId} \u7ae0\u8282\u52a0\u8f7d\u5931\u8d25\uff1a${msg}`);
}
return course;
}
async function enrichPanelCoursesProgress() {
const courses = state.panelCourses || [];
if (!courses.length) return;
for (const course of courses) {
if (course.chaptersLoaded) continue;
await enrichPanelCourse(course);
renderCourseList();
}
state.panelCourses.sort((a, b) => {
const au = isCourseUnfinished(a) ? 1 : 0;
const bu = isCourseUnfinished(b) ? 1 : 0;
if (au !== bu) return bu - au;
return b.lcsProcess - a.lcsProcess;
});
renderCourseList();
}
async function buildCourseFromChapters(csId, title) {
const chapters = await fetchCourseChapters(csId);
const total = chapters.length || 1;
const done = chapters.filter((x) => x.status === "done").length;
const examPending = chapters.filter((x) => x.status === "exam").length;
const process = Math.round(((done + examPending) / total) * 100);
const studyDone = chapters.filter((x) => x.status === "exam" || x.status === "done").length;
return {
csId,
tpId: "favorite",
csName: title || "\u8bfe\u7a0b" + csId,
lcsProcess: process,
chapterTotal: total,
studyDone,
examDone: done,
examPending,
tpName: "\u6211\u7684\u6536\u85cf",
planYear: "",
lcsStudyFinished: examPending > 0 || studyDone >= total ? "1" : "0",
lcsExameFinished: done >= total && examPending === 0 ? "1" : "0",
lcsFinished: done >= total && examPending === 0 ? "1" : "0",
courseTestList: chapters
.filter((ch) => ch.status === "exam")
.map((ch) => ({ tsId: ch.wareId, tsSpcId: ch.productId, spcId: ch.productId })),
};
}
async function fetchCourseFromPlans(csId) {
const cached = state.panelCourses.find((c) => c.csId === csId);
if (cached) {
await enrichPanelCourse(cached);
return cached;
}
try {
const html = await fetchFavoriteHtml();
const found = parseCoursesFromHtml(html).find((c) => c.courseId === csId);
return await buildCourseFromChapters(csId, found?.title);
} catch (_) {
return null;
}
}
async function listAllUnfinishedCourses() {
if (!state.panelCourses.length) await fetchPanelCoursesWithProgress();
return state.panelCourses.filter(isCourseUnfinished);
}
async function findUnfinishedCourse() {
const list = await listAllUnfinishedCourses();
if (!list.length) {
log("\u5df2\u68c0\u67e5\u6536\u85cf\u8bfe\u7a0b\uff0c\u5747\u663e\u793a\u5df2\u5b8c\u6210");
return null;
}
return list[0];
}
async function findNextWorkInQueue(options) {
const silent = !!(options && options.silent);
const queue = loadQueue();
if (!queue.length) {
if (!silent) log("\u8bf7\u52fe\u9009\u8981\u5b66\u4e60\u7684\u8bfe\u7a0b\u540e\u518d\u70b9\u5f00\u59cb");
return null;
}
const allCourses = await listAllUnfinishedCourses();
const courseMap = new Map(allCourses.map((c) => [c.csId, c]));
for (const csId of queue) {
let course = courseMap.get(csId);
if (!course) course = await fetchCourseFromPlans(csId);
if (!course) continue;
const { all, study, exam } = await partitionCourseWork(csId);
if (study.length) {
const nextStudy = study[0];
return {
phase: "study",
course,
videos: [nextStudy],
videoTotal: all.length,
videoDone: all.length - study.length - exam.length,
};
}
if (exam.length && shouldRunExam()) {
const nextExam = exam[0];
return {
phase: "exam",
course,
videos: [nextExam],
test: buildTestFromVideo(nextExam),
};
}
}
return null;
}
function collectCorrectAnswers(paper) {
const answers = [];
for (const cat of paper.questionCats || []) {
const itemType = cat.itemType;
for (const q of cat.questions || []) {
const correct = (q.options || []).filter((o) => isTruthyFlag(o.answer));
if (!correct.length) continue;
let answerIdValue;
if (itemType === "ms") {
answerIdValue = correct.map((o) => o.itemId).join(",");
} else {
answerIdValue = correct[0].itemId;
}
answers.push({
questionId: q.questionId,
answerIdValue,
answerContentValue: "",
});
}
}
return answers;
}
async function submitCourseExam(course, testInfo) {
const user = getUserData();
if (!user?.custId) return { ok: false, msg: "\u672a\u767b\u5f55" };
const tsId = testInfo.tsId;
const spcId = testInfo.tsSpcId || testInfo.spcId || testInfo.productId;
if (!tsId) return { ok: false, msg: "\u7f3a\u5c11 paper_id" };
try {
await _el(false);
} catch (err) {
return { ok: false, msg: "\u4e91\u7aef\u6388\u6743\u5931\u8d25\uff1a" + (err.message || err) };
}
try {
return await runClientCourseExam(course.csId, tsId, spcId);
} catch (err) {
trace("\u8003\u8bd5\uff1a" + (err.message || err));
return { ok: false, msg: err.message || "\u8003\u8bd5\u5931\u8d25" };
}
}
function applyBgProgressEstimate(bg) {
const total = Number(bg.videoTotal) || 0;
if (!total) return;
const completed = Number(bg.videoDone || 0) + Number(bg.videoIndex || 0);
const est = (completed / total) * 100;
bg.lcsProcess = Math.max(Number(bg.lcsProcess || 0), est);
}
async function afterVideoLearnSuccess(bg, idx, waitSec) {
const video = bg.videos[idx];
bg.videoIndex = idx + 1;
bg.lastSaveAt = Date.now();
bg.saveCount = (bg.saveCount || 0) + 1;
const prog = await fetchCourseProgress(bg.csId);
if (prog) {
bg.lcsProcess = prog.lcsProcess;
bg.csName = prog.csName || bg.csName;
}
applyBgProgressEstimate(bg);
updateCourseProgressInPanel(bg.csId, bg.lcsProcess);
updatePanel();
void refreshChapterPreview();
if (video) {
const vlabel = video.resName || `\u8bfe\u4ef6${formatWareNo(video.resId || idx + 1)}`;
log(`\u89c6\u9891\u5df2\u5b66\u5b8c\uff1a\u7b2c${formatWareNo(video.resId || idx + 1)}\u8282 ${vlabel}`);
}
if (video && (await tryBeginSameChapterExam(bg, video))) {
return;
}
saveBgState(bg);
const allVideosDone = bg.videoIndex >= bg.videos.length;
if (allVideosDone) {
await finishCourseAndAdvance(bg);
}
}
function stopAutoStudy(reason) {
if (!state.enabled) return;
state.enabled = false;
localStorage.setItem(STORAGE_KEY, "0");
clearStudyModeSelection();
syncStartStopButtons();
syncKeepAlive();
stopPreviewAutoRefresh();
log(reason || "\u6240\u6709\u5df2\u9009\u8bfe\u7a0b\u5747\u5df2\u5b8c\u6210\uff0c\u7a0b\u5e8f\u81ea\u52a8\u505c\u6b62");
updatePanel();
}
function buildBgStateFromWork(hit) {
const { course, phase } = hit;
const base = {
csId: course.csId,
tpId: course.tpId,
csName: course.csName,
lcsProcess: parseFloat(course.lcsProcess || 0),
phase,
lastSaveAt: 0,
saveCount: 0,
};
if (phase === "study") {
return {
...base,
videos: hit.videos,
videoIndex: 0,
videoTotal: hit.videoTotal ?? hit.videos.length,
videoDone: hit.videoDone ?? 0,
};
}
return { ...base, videos: hit.videos || [], videoIndex: 0, test: hit.test };
}
function isVideoUnfinished(v) {
if (!v) return false;
return isChapterNeedStudy(v);
}
async function loadCourseVideos(csId, forceSidebar) {
const chapters = await fetchCourseChapters(csId);
const sidebarMap = await getSidebarStatusMap(csId, !!forceSidebar);
const out = [];
for (const ch of chapters) {
if (/^\d{10,}$/.test(String(ch.wareId || ""))) continue;
const status = resolveChapterStatus(ch.wareId, ch.status, sidebarMap);
let total = 3600;
let resName = ch.title;
let productId = ch.productId || "";
try {
const meta = await fetchStudyMeta(csId, ch.wareId);
total = meta.totalTime || 3600;
if (meta.productId) productId = meta.productId;
const metaTitle = parseWareTitleFromHtml(meta.html, ch.wareId);
if (metaTitle && (!resName || resName.startsWith("\u8bfe\u4ef6 ") || resName.length > 50)) {
resName = metaTitle;
}
} catch (_) {}
out.push({
resId: ch.wareId,
cptId: ch.wareId,
fileTimeLen: total,
studyTime: status === "done" ? total : 0,
process: status === "done" ? 100 : status === "exam" ? 100 : 0,
studyFinished: status === "done" || status === "exam" ? "1" : "0",
resName: resName || "\u8bfe\u4ef6 " + ch.wareId,
productId,
chapterStatus: status,
});
}
return out;
}
async function fetchVideoBreakpoint(csId, resId) {
return 0;
}
async function learnSaveFullVideo(csId, video, mult) {
const wid = String(video.cptId || video.resId);
const recent = readRecentStudyDone(csId, wid);
if (recent?.ok && (await checkIsExam(csId, wid))) {
markChapterProgressReported(csId, wid);
return { success: "1", skipped: true };
}
const user = getUserData();
if (!user?.custId) throw new Error("\u672a\u767b\u5f55");
const cfg = {
study_mode: "efficiency",
multiplier: mult,
free_video_limit: state.freeVideoLimit,
};
await _el(false);
const startRes = await _es(
"video",
{
cs_id: csId,
course_id: csId,
study_user_id: user.custId,
video: {
resId: video.resId,
cptId: video.cptId,
ware_id: video.cptId,
fileTimeLen: video.fileTimeLen,
resName: video.resName || "",
product_id: video.productId || "",
},
multiplier: cfg.multiplier,
breakpoint: 0,
},
cfg
);
const loop = await _ve(startRes, { quiet: true, maxSteps: 96 });
if (loop.skipped) return { success: "1", skipped: true };
if (loop.ok) return { success: "1" };
throw new Error(loop.msg || "\u4e91\u7aef\u5f15\u64ce\u5931\u8d25");
}
async function initBackgroundTarget() {
const queue = loadQueue();
const saved = loadBgState();
if (queue.length && saved.csId && queue.includes(saved.csId)) {
if (saved.phase === "exam" && (saved.videos?.length || saved.test?.tsId)) {
if (!shouldRunExam()) {
const { all, study } = await partitionCourseWork(saved.csId);
if (study.length) {
const prog = await fetchCourseProgress(saved.csId);
return {
...saved,
phase: "study",
videos: [study[0]],
videoIndex: 0,
lastSaveAt: 0,
videoTotal: all.length,
videoDone: all.length - study.length,
lcsProcess: prog?.lcsProcess ?? saved.lcsProcess,
csName: prog?.csName || saved.csName,
};
}
saveBgState({});
} else {
const wid = saved.videos?.[saved.videoIndex || 0]?.resId || saved.test?.tsId;
const st = wid ? await querySidebarStatus(saved.csId, wid) : "";
if (st === "done") {
trace(`\u7b2c${formatWareNo(wid)}\u8282\u5df2\u901a\u8fc7\uff0c\u91cd\u65b0\u626b\u63cf\u4efb\u52a1`);
saveBgState({});
} else {
clearIsExamCache(saved.csId, wid);
const meta = await fetchStudyMeta(saved.csId, wid).catch(() => null);
const ready = meta
? await checkChapterExamReady(saved.csId, wid, meta.totalTime || 3600)
: { videoDone: false };
if (!ready.videoDone) {
trace(`\u7b2c${formatWareNo(wid)}\u8282 \u6062\u590d\u540e\u8865\u62a5\u5b66\u4e60\u8fdb\u5ea6`);
return { ...saved, lastSaveAt: 0, apiBoostCount: 0 };
}
return { ...saved, lastSaveAt: 0 };
}
}
}
if (saved.phase === "study" && saved.videos?.length) {
const idx = saved.videoIndex || 0;
if (idx < saved.videos.length) {
const { all, study } = await partitionCourseWork(saved.csId);
if (study.length) {
let videoIndex = idx;
const cur = saved.videos[idx];
if (cur?.resId) {
const at = study.findIndex((v) => v.resId === cur.resId);
if (at >= 0) videoIndex = 0;
else videoIndex = 0;
}
const prog = await fetchCourseProgress(saved.csId);
return {
...saved,
phase: "study",
videos: [study[0]],
videoIndex: 0,
lastSaveAt: 0,
videoTotal: all.length,
videoDone: all.length - study.length,
lcsProcess: prog?.lcsProcess ?? saved.lcsProcess,
csName: prog?.csName || saved.csName,
};
}
}
}
} else if (saved.csId && queue.length && !queue.includes(saved.csId)) {
saveBgState({});
}
const hit = await findNextWorkInQueue({ silent: true });
if (!hit) return null;
const bg = buildBgStateFromWork(hit);
if (hit.phase === "study") {
const prog = await fetchCourseProgress(hit.course.csId);
if (prog) {
bg.lcsProcess = prog.lcsProcess;
bg.csName = prog.csName || bg.csName;
}
}
return bg;
}
async function finishCourseAndAdvance(bg) {
bg.sameChapterExam = false;
const { all, study, exam } = await partitionCourseWork(bg.csId);
if (study.length) {
bg.phase = "study";
bg.videos = [study[0]];
bg.videoIndex = 0;
bg.videoTotal = all.length;
bg.videoDone = all.length - study.length - exam.length;
bg.lastSaveAt = 0;
saveBgState(bg);
log(`\u4e0b\u4e00\u4efb\u52a1\uff1a\u89c6\u9891\u7b2c${formatWareNo(study[0].resId)}\u8282 ${study[0].resName || ""}`);
renderCourseList();
void refreshChapterPreview();
return;
}
if (exam.length && shouldRunExam()) {
const next = exam[0];
bg.phase = "exam";
bg.videos = [next];
bg.videoIndex = 0;
bg.test = buildTestFromVideo(next);
bg.lastSaveAt = 0;
saveBgState(bg);
log(`\u4e0b\u4e00\u4efb\u52a1\uff1a\u8003\u8bd5\u7b2c${formatWareNo(next.resId)}\u8282 ${next.resName || ""}`);
renderCourseList();
void refreshChapterPreview();
return;
}
saveBgState({});
state.coursesDoneSession += 1;
log(`\u8bfe\u7a0b${courseDisplayName(bg)}\u5df2\u5168\u90e8\u5b8c\u6210`);
void refreshChapterPreview();
refreshPanelCourses();
}
async function fetchCourseProgress(csId) {
if (!csId) return null;
try {
const c = state.panelCourses.find((x) => x.csId === csId);
if (c) return { lcsProcess: c.lcsProcess, csName: c.csName };
const row = await buildCourseFromChapters(csId);
return { lcsProcess: row.lcsProcess, csName: row.csName };
} catch (_) {
return null;
}
}
function formatBgPanelExtra(bg) {
if (!bg?.csId) return "";
const name = (bg.csName || "\u8bfe\u7a0b").slice(0, 12);
const idx = (bg.videoIndex || 0) + 1;
const total = bg.videos?.length || 0;
if (bg.phase === "exam") {
const cur = bg.videos?.[bg.videoIndex || 0];
const label = cur?.resName || bg.test?.resName || bg.test?.tsId || "";
return `\u3010${name} · \u8003\u8bd5 ${idx}/${total}${label ? " · " + label.slice(0, 8) : ""}\u3011`;
}
const cur = bg.videos?.[bg.videoIndex || 0];
const proc = bg.lcsProcess != null ? ` · ${bg.lcsProcess}%` : "";
const vid = cur ? ` · ${cur.fileTimeLen}s` : "";
return `\u3010${name} \u89c6\u9891${idx}/${total}${vid}${proc}\u3011`;
}
function hasActiveBgTask(bg) {
if (!bg?.csId) return false;
if (bg.phase === "exam") return !!(bg.videos?.length || bg.test?.tsId);
return bg.phase === "study" && !!(bg.videos && bg.videos.length);
}
async function backgroundLearnTick() {
try {
if (pageType() === "courseplay") {
trace("\u540e\u53f0\u6a21\u5f0f\uff1a\u8bf7\u5173\u95ed study2 \u64ad\u653e\u9875");
return;
}
let bg = loadBgState();
if (!hasActiveBgTask(bg)) {
bg = await initBackgroundTarget();
if (!bg) {
if (state.enabled) {
stopAutoStudy("\u6240\u6709\u5df2\u9009\u8bfe\u7a0b\u5747\u5df2\u5b8c\u6210\uff0c\u7a0b\u5e8f\u81ea\u52a8\u505c\u6b62");
}
return;
}
saveBgState(bg);
renderCourseList();
const cname = courseDisplayName(bg);
const proc = Number(bg.lcsProcess || 0).toFixed(1);
if (bg.phase === "exam") {
log(`\u5f00\u59cb\u5b66\u4e60\uff1a${cname}\uff08\u8fdb\u5165\u8003\u8bd5\u9636\u6bb5\uff09`);
} else {
log(`\u5f00\u59cb\u5b66\u4e60\uff1a${cname}`);
}
}
const waitSec = getSaveInterval();
if (bg.phase === "study" && isApiOnlyStudy()) {
const sw = bg.videos?.[bg.videoIndex || 0]?.resId;
const apiSt = sw ? loadApiProgressState(bg.csId, sw) : null;
if (apiSt && apiSt.current < apiSt.total && Date.now() < apiSt.nextAt) {
updatePanel();
return;
}
}
const elapsed = Date.now() - (bg.lastSaveAt || 0);
let skipWait = false;
if (bg.phase === "exam") {
const ew = bg.videos?.[bg.videoIndex || 0]?.resId || bg.test?.tsId;
if (ew) {
clearIsExamCache(bg.csId, ew);
skipWait = !(await checkIsExam(bg.csId, ew));
}
} else if (bg.phase === "study" && isApiOnlyStudy()) {
const sw = bg.videos?.[bg.videoIndex || 0]?.resId;
if (sw && isChapterProgressFull(bg.csId, sw)) skipWait = true;
const apiSt = sw ? loadApiProgressState(bg.csId, sw) : null;
if (apiSt && apiSt.current < apiSt.total && Date.now() >= apiSt.nextAt) {
skipWait = true;
}
}
if (bg.lastSaveAt && elapsed < waitSec * 1000 && !skipWait) {
updatePanel();
return;
}
if (bg.phase === "exam") {
if (!shouldRunExam()) {
const course = (await fetchCourseFromPlans(bg.csId)) || {
csId: bg.csId,
csName: bg.csName,
tpId: bg.tpId,
courseTestList: [bg.test],
};
await abandonExamsAndResumeVideos(bg, course);
return;
}
const course = (await fetchCourseFromPlans(bg.csId)) || {
csId: bg.csId,
csName: bg.csName,
tpId: bg.tpId,
courseTestList: [bg.test],
};
const eidx = bg.videoIndex || 0;
const ev = bg.videos?.[eidx];
const testInfo = ev ? buildTestFromVideo(ev) : bg.test;
if (!testInfo?.tsId) {
trace("\u8003\u8bd5\u961f\u5217\u5f02\u5e38\uff0c\u91cd\u65b0\u626b\u63cf\u8bfe\u7a0b");
saveBgState({});
return;
}
const examCtx = await resolveExamContext(bg.csId, testInfo.tsId, testInfo.productId);
const elabel = ev?.resName || examCtx.resName || testInfo.resName || `\u8bfe\u4ef6${testInfo.tsId}`;
try {
if (await skipChapterIfAlreadyPassed(bg, course, testInfo.tsId, elabel)) return;
const sidebarSt = await querySidebarStatus(bg.csId, testInfo.tsId);
const holdingSame = isHoldingSameChapterExam(bg, testInfo.tsId);
let meta = null;
try {
meta = await fetchStudyMeta(bg.csId, testInfo.tsId);
} catch (_) {}
const totalSec = meta?.totalTime || 3600;
const examReady = await checkChapterExamReady(bg.csId, testInfo.tsId, totalSec);
if (!examReady.videoDone) {
const progressFull = isChapterProgressFull(bg.csId, testInfo.tsId);
bg.apiBoostCount = (bg.apiBoostCount || 0) + 1;
const maxBoost = progressFull ? ISEXAM_WAIT_MAX_TICKS : 8;
if (bg.apiBoostCount <= maxBoost) {
const boosted = await apiBoostStudyForExam(bg.csId, testInfo.tsId, elabel);
clearIsExamCache(bg.csId, testInfo.tsId);
if (boosted) {
bg.apiBoostCount = 0;
bg.lastSaveAt = 0;
saveBgState(bg);
return;
}
} else if (!progressFull && bg.apiBoostCount === maxBoost + 1) {
trace(`\u7b2c${formatWareNo(testInfo.tsId)}\u8282 \u8003\u8bd5\u672a\u5c31\u7eea\uff0c\u9000\u56de\u5b66\u4e60\u961f\u5217`);
clearChapterProgressReported(bg.csId, testInfo.tsId);
bg.phase = "study";
bg.apiBoostCount = 0;
bg.sameChapterExam = false;
bg.pendingExamWareId = "";
bg.lastSaveAt = 0;
saveBgState(bg);
return;
}
bg.lastSaveAt = progressFull ? 0 : Date.now();
saveBgState(bg);
trace(
progressFull
? `\u7b2c${formatWareNo(testInfo.tsId)}\u8282 \u5df2\u62a5\u6ee1\uff0c\u7b49\u5f85 isexam\uff08${bg.apiBoostCount}/${maxBoost}\uff09`
: `\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${elabel} isexam≠1\uff0c${waitSec}s \u540e API \u8865\u62a5\uff08${bg.apiBoostCount}/${maxBoost}\uff09`
);
return;
}
bg.apiBoostCount = 0;
if (sidebarSt === "study") {
trace(`\u7b2c${formatWareNo(testInfo.tsId)}\u8282 \u4fa7\u680f\u672a\u540c\u6b65\uff0cisexam\u5df2\u5c31\u7eea\uff0c\u5f00\u59cb\u8003\u8bd5`);
}
if (examCtx.productId && !testInfo.productId) testInfo.productId = examCtx.productId;
log(`\u6b63\u5728\u8003\u8bd5\uff1a\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${elabel}`);
const examRes = await submitCourseExam(course, testInfo);
bg.lastSaveAt = Date.now();
if (examRes.ok) {
if (examRes.skipped) {
const st = await querySidebarStatus(bg.csId, testInfo.tsId);
if (st !== "done") {
trace(
`\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${elabel} \u5e73\u53f0\u4ecd\u663e\u793a\u672a\u901a\u8fc7\uff0c\u53d6\u6d88 ALREADY_PASSED \u8df3\u8fc7`
);
if (examRes.msg && examRes.msg !== "\u5df2\u901a\u8fc7") {
log(`\u8003\u8bd5\u5931\u8d25\uff1a\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${examRes.msg}`);
} else {
log(`\u8003\u8bd5\u5931\u8d25\uff1a\u7b2c${formatWareNo(testInfo.tsId)}\u8282 \u65e0\u6cd5\u83b7\u53d6\u6709\u6548\u8bd5\u5377\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5\u6216\u624b\u52a8\u8003\u8bd5`);
}
saveBgState(bg);
return;
}
trace(`\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${elabel} \u5df2\u901a\u8fc7\uff0c\u8df3\u8fc7`);
} else {
log(`\u8003\u8bd5\u901a\u8fc7\uff1a\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${elabel}`);
}
clearIsExamCache(bg.csId, testInfo.tsId);
invalidateSidebarStatusMap(bg.csId);
bg.sameChapterExam = false;
bg.pendingExamWareId = "";
const advanced = await promoteToNextTask(bg, course, { excludeWareId: testInfo.tsId });
if (!advanced) {
const again = await initBackgroundTarget();
if (again) {
saveBgState(again);
} else if (state.enabled) {
stopAutoStudy("\u6240\u6709\u5df2\u9009\u8bfe\u7a0b\u5747\u5df2\u5b8c\u6210\uff0c\u7a0b\u5e8f\u81ea\u52a8\u505c\u6b62");
}
}
} else {
if (examRes.skipAll) {
log("\u5df2\u9009\u62e9\u6682\u4e0d\u8003\u8bd5\uff0c\u540e\u7eed\u4ec5\u5b66\u4e60\u89c6\u9891");
await abandonExamsAndResumeVideos(bg, course);
return;
}
if (/\u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5/.test(examRes.msg || "")) {
saveBgState(bg);
log(`\u7b2c${formatWareNo(testInfo.tsId)}\u8282 \u7a0d\u540e\u91cd\u8bd5\u672c\u8282\u8003\u8bd5`);
return;
}
if (/\u672a\u83b7\u53d6\u8bd5\u5377|\u4eba\u8138\u8bc6\u522b|FACE_VERIFY|ALREADY_PASSED/.test(examRes.msg || "")) {
const st = await querySidebarStatus(bg.csId, testInfo.tsId);
if (st === "done") {
trace(`\u7b2c${formatWareNo(testInfo.tsId)}\u8282 \u5df2\u901a\u8fc7\uff0c\u8df3\u8fc7\u91cd\u590d\u7b54\u5377`);
bg.pendingExamWareId = "";
await promoteToNextTask(bg, course, { excludeWareId: testInfo.tsId });
return;
}
}
saveBgState(bg);
log(`\u8003\u8bd5\u5931\u8d25\uff1a\u7b2c${formatWareNo(testInfo.tsId)}\u8282 ${examRes.msg}`);
}
} catch (err) {
bg.lastSaveAt = Date.now();
saveBgState(bg);
log(`\u8003\u8bd5\u5f02\u5e38\uff1a${err.message || err}`);
}
return;
}
const idx = bg.videoIndex || 0;
const video = bg.videos[idx];
if (!video) {
await finishCourseAndAdvance(bg);
return;
}
const mult = getMultiplier();
const vlabel = video.resName || `\u8bfe\u4ef6${formatWareNo(video.resId || idx + 1)}`;
const recentDone = readRecentStudyDone(bg.csId, video.resId);
if (recentDone?.ok) {
trace(`\u7b2c${formatWareNo(video.resId)}\u8282 \u64ad\u653e\u9875\u5df2\u5237\u5b8c\uff0c\u7ee7\u7eed\u6d41\u7a0b`);
for (const w of wareIdVariants(video.resId)) localStorage.removeItem(studyDoneSignalKey(bg.csId, w));
markChapterProgressReported(bg.csId, video.resId);
await afterVideoLearnSuccess(bg, idx, waitSec);
return;
}
let res;
try {
log(`\u6b63\u5728\u89c2\u770b\uff1a\u7b2c${formatWareNo(video.resId || idx + 1)}\u8282 ${vlabel}`);
res = await learnSaveFullVideo(bg.csId, video, mult);
} catch (err) {
const em = String(err.message || err);
trace("\u4e91\u7aef\u89c6\u9891\u5f15\u64ce\uff1a" + em);
if (/free_quota|\u914d\u989d/.test(em)) {
state.enabled = false;
localStorage.setItem(STORAGE_KEY, "0");
clearStudyModeSelection();
syncStartStopButtons();
syncKeepAlive();
updatePanel();
}
return;
}
if (res.skipped || res.success === "1") {
await afterVideoLearnSuccess(bg, idx, waitSec);
return;
}
if (String(res.success).includes("drag")) {
saveBgState({});
log(`\u4e0a\u62a5\u88ab\u62d2\uff08${video.resName}\uff09\uff0c\u5df2\u91cd\u7f6e`);
return;
}
log("\u4e0a\u62a5\u5931\u8d25\uff1a" + (res.message || res.success) + " · " + (video.resName || ""));
} catch (err) {
log("\u540e\u53f0\u5237\u8bfe\u5f02\u5e38\uff1a" + (err.message || err));
}
}
async function openFirstUnfinishedCourseByApi() {
const course = await findUnfinishedCourse();
if (!course) return false;
if (isSameCoursePage(course.csId)) {
log("\u5df2\u5728\u5f53\u524d\u8bfe\u7a0b\u9875\uff0c\u7b49\u5f85\u64ad\u653e");
return false;
}
const videos = await loadCourseVideos(course.csId);
const pending = videos.filter(isVideoUnfinished);
const ware = pending[0];
const target = ware
? courseplayPath(course.csId, course.tpId, ware.resId, ware.productId)
: courseplayPath(course.csId, course.tpId);
if (!canNavigate(target)) {
log("\u521a\u8fdb\u5165\u8bfe\u7a0b\uff0c\u7b49\u5f85\u9875\u9762\u7a33\u5b9a");
return false;
}
log(`\u8fdb\u5165\u8bfe\u7a0b\uff1a${course.csName || course.csId}\uff08${course.lcsProcess || 0}%\uff09`);
safeNavigate(target);
return true;
}
async function handleTrainPage() {
dismissDialogs();
const qs = new URLSearchParams(location.search);
const csId = qs.get("course_id");
if (!csId) {
if (pageType() !== "favorite") safeNavigate("/cme/myFavorite.jsp");
return;
}
const now = Date.now();
if (now - state.trainApiTriedAt < NAV_COOLDOWN_MS) return;
state.trainApiTriedAt = now;
const videos = (await loadCourseVideos(csId)).filter(isVideoUnfinished);
if (!videos.length) {
log("\u5f53\u524d\u8bfe\u7a0b\u89c6\u9891\u5747\u5df2\u5b66\u5b8c");
safeNavigate("/cme/myFavorite.jsp");
return;
}
const v = videos[0];
const target = courseplayPath(csId, "favorite", v.resId, v.productId);
if (location.href.includes(`courseware_id=${v.resId}`)) return;
if (!canNavigate(target)) return;
log(`\u8fdb\u5165\u8bfe\u4ef6\uff1a${v.resName || v.resId}`);
safeNavigate(target);
}
async function handleCoursePlayPage() {
dismissDialogs();
const video = getVideoEl();
const links = getChapterLinks();
const activeId = getCurrentChapterId();
if (!video) {
if (activeId) {
if (!state.courseplayWaitSince) state.courseplayWaitSince = Date.now();
if (Date.now() - state.courseplayWaitSince < VIDEO_WAIT_MS) {
log("\u7b49\u5f85\u64ad\u653e\u5668\u52a0\u8f7d...");
return;
}
state.courseplayWaitSince = 0;
}
const next = findNextIncompleteChapter(true);
if (next) {
const chapterId = next.dataset.name || next.id;
if (!canClickChapter(chapterId)) {
log("\u7ae0\u8282\u5207\u6362\u51b7\u5374\u4e2d");
return;
}
markChapterClick(chapterId);
clickEl(next);
log("\u5df2\u5207\u6362\u7ae0\u8282\uff1a" + (next.title || next.textContent || "").trim());
state.courseplayWaitSince = Date.now();
return;
}
if (links.length && !links.some((link) => !chapterDone(link))) {
log("\u5f53\u524d\u8bfe\u7a0b\u7ae0\u8282\u5df2\u5168\u90e8\u5b8c\u6210\uff0c\u8fd4\u56de\u6536\u85cf\u9875");
safeNavigate("/cme/myFavorite.jsp");
}
return;
}
state.courseplayWaitSince = 0;
if (video.ended) {
const next = findNextIncompleteChapter(true);
if (!next) {
log("\u5f53\u524d\u8bfe\u7a0b\u5df2\u5168\u90e8\u5b8c\u6210\uff0c\u8fd4\u56de\u6536\u85cf\u9875");
safeNavigate("/cme/myFavorite.jsp");
}
return;
}
await ensurePlaying();
}
async function handleHomePage() {
if (!isLoggedIn()) {
log("\u8bf7\u5148\u767b\u5f55");
return;
}
if (pageType() === "favorite") return;
safeNavigate("/cme/myFavorite.jsp");
}
async function tick() {
if (!state.enabled) return;
if (state.bgTickBusy) return;
const now = Date.now();
if (now - state.lastTick < TICK_MS) return;
state.lastTick = now;
if (!isLoggedIn()) {
log("\u672a\u767b\u5f55\uff0c\u8bf7\u5148\u767b\u5f55\u8d26\u53f7");
return;
}
if (!(await _cr())) {
state.enabled = false;
localStorage.setItem(STORAGE_KEY, "0");
clearStudyModeSelection();
syncStartStopButtons();
syncKeepAlive();
updatePanel();
return;
}
dismissDialogs();
const mode = getMode();
const type = pageType();
try {
if (mode === "background") {
if (state.bgTickBusy) return;
state.bgTickBusy = true;
try {
await backgroundLearnTick();
} finally {
state.bgTickBusy = false;
}
return;
}
if (mode === "manual") {
if (type === "courseplay") {
dismissDialogs();
return;
}
log("\u624b\u52a8\u6a21\u5f0f\uff1a\u8bf7\u81ea\u884c\u6253\u5f00\u64ad\u653e\u9875\u770b\u8bfe");
return;
}
if (type === "home" || type === "favorite") {
await handleHomePage();
} else if (type === "course") {
await handleTrainPage();
} else if (type === "courseplay") {
await handleCoursePlayPage();
}
} catch (err) {
log("\u8fd0\u884c\u5f02\u5e38\uff1a" + (err.message || err));
}
}
async function fetchPanelCourses() {
if (!(await ensureCmeSession())) {
clearCmeUserProfileCache();
state.panelCourses = [];
state.teachPlans = [];
return [];
}
await _fip(false);
const plans = await listTeachPlans();
state.teachPlans = plans;
let tpId = loadSelectedTpId();
if (!tpId || !plans.some((p) => p.tpId === tpId)) {
tpId = plans[0]?.tpId || "favorite";
saveSelectedTpId(tpId);
} else {
state.selectedTpId = tpId;
}
const raw = await listAllCoursesForPlan({ tpId });
const list = raw.map((c) => ({
csId: c.csId,
tpId: c.tpId,
csName: c.csName || c.courseName || "\u672a\u547d\u540d\u8bfe\u7a0b",
lcsProcess: 0,
chapterTotal: 0,
studyDone: 0,
examDone: 0,
examPending: 0,
tpName: c.tpName || "\u6211\u7684\u6536\u85cf",
planYear: "",
lcsStudyFinished: "0",
lcsExameFinished: "0",
lcsFinished: "0",
courseTestList: [],
chaptersLoaded: false,
}));
state.panelCourses = list;
state.panelCourses.sort((a, b) => {
const au = isCourseUnfinished(a) ? 1 : 0;
const bu = isCourseUnfinished(b) ? 1 : 0;
if (au !== bu) return bu - au;
return b.lcsProcess - a.lcsProcess;
});
let q = loadQueue();
const touched = localStorage.getItem(QUEUE_TOUCHED_KEY) === "1";
if (!touched && !q.length && state.panelCourses.length) {
q = state.panelCourses.filter(isCourseUnfinished).map((c) => c.csId);
}
saveQueue(q);
return state.panelCourses;
}
async function fetchPanelCoursesWithProgress() {
const list = await fetchPanelCourses();
await enrichPanelCoursesProgress();
return list;
}
async function refreshPanelCourses() {
if (!(await ensureCmeSession())) return;
state.panelRefreshing = true;
updatePanel();
try {
await fetchPanelCourses();
renderPlanSelect();
renderCourseList();
await enrichPanelCoursesProgress();
await refreshChapterPreview();
} catch (err) {
log("\u5237\u65b0\u8bfe\u7a0b\u5931\u8d25\uff1a" + (err.message || err));
} finally {
state.panelRefreshing = false;
updatePanel();
}
}
function updateCourseProgressInPanel(csId, lcsProcess) {
const c = state.panelCourses.find((x) => x.csId === csId);
if (c && lcsProcess != null) c.lcsProcess = lcsProcess;
renderCourseList();
void refreshChapterPreview();
}
function startPreviewAutoRefresh() {
stopPreviewAutoRefresh();
state.previewRefreshTimer = setInterval(() => {
if (!state.enabled) return;
refreshChapterPreview().catch(() => {});
}, 12000);
}
function stopPreviewAutoRefresh() {
if (state.previewRefreshTimer) {
clearInterval(state.previewRefreshTimer);
state.previewRefreshTimer = null;
}
}
function courseProgressLabel(c) {
const total = Math.max(1, Number(c.chapterTotal) || 1);
const study = Number(c.studyDone) || 0;
const exam = Number(c.examDone) || 0;
return `\u89c6\u9891 ${study}/${total} · \u8003\u8bd5 ${exam}/${total}`;
}
function formatChapterExamTag(status) {
if (status === "done") return "\u5df2\u901a\u8fc7";
if (status === "exam") return "\u5f85\u8003\u8bd5";
return "\u672a\u8003\u8bd5";
}
function renderPlanSelect() {
const sel = document.getElementById("cme-plan-select");
if (!sel) return;
const plans = state.teachPlans || [];
if (!plans.length) {
sel.innerHTML = "";
sel.disabled = true;
return;
}
sel.disabled = false;
sel.innerHTML = plans
.map((p) => {
const label = p.tpName || (p.year ? `${p.year}\u5e74\u5ea6` : p.tpId);
const selected = p.tpId === state.selectedTpId ? " selected" : "";
return ``;
})
.join("");
}
function renderCourseList() {
const box = document.getElementById("cme-course-list");
if (!box) return;
const bg = loadBgState();
const activeCsId = bg.csId || "";
const selectedSet = new Set(loadQueue());
if (!state.panelCourses.length) {
box.innerHTML =
"
\u6536\u85cf\u5939\u6682\u65e0\u8bfe\u7a0b
\u8bf7\u5148\u5728\u7f51\u7ad9\u6536\u85cf\u8981\u5b66\u4e60\u7684\u8bfe\u7a0b
";
return;
}
box.innerHTML = state.panelCourses
.map((c) => {
const total = Math.max(1, Number(c.chapterTotal) || 1);
const studyDone = Number(c.studyDone) || 0;
const examDone = Number(c.examDone) || 0;
const studyPct = Math.round((studyDone / total) * 100);
const examPct = Math.round((examDone / total) * 100);
const isActive = c.csId === activeCsId;
const finished = isCourseFullyFinished(c);
const checked = selectedSet.has(c.csId) ? "checked" : "";
const border = isActive ? "#16a34a" : finished ? "#e2e8f0" : "#e2e8f0";
const bgColor = isActive ? "#f0fdf4" : finished ? "#f1f5f9" : "#f8fafc";
const titleColor = finished ? "#64748b" : "#0f172a";
const progressLine = c.progressError
? c.progressError
: c.chaptersLoaded === false
? "\u8fdb\u5ea6\u52a0\u8f7d\u4e2d…"
: `\u89c6\u9891 ${studyDone}/${total} · \u8003\u8bd5 ${examDone}/${total}`;
const studyPctDisplay = c.chaptersLoaded === false ? 0 : studyPct;
const examPctDisplay = c.chaptersLoaded === false ? 0 : examPct;
return `
`;
})
.join("");
box.querySelectorAll("input[type='checkbox']").forEach((el) => {
el.addEventListener("change", () => {
const selected = [];
box.querySelectorAll("label").forEach((label) => {
const cb = label.querySelector("input[type='checkbox']");
if (cb?.checked && cb.dataset.csid) selected.push(cb.dataset.csid);
});
localStorage.setItem(QUEUE_TOUCHED_KEY, "1");
saveQueue(selected);
if (selected.length) clearPanelHint();
void refreshChapterPreview();
const bg = loadBgState();
if (bg.csId && selected.length && !selected.includes(bg.csId)) {
saveBgState({});
log("\u5f53\u524d\u8fdb\u884c\u4e2d\u7684\u8bfe\u7a0b\u5df2\u53d6\u6d88\u52fe\u9009\uff0c\u5df2\u91cd\u7f6e\u540e\u53f0\u4efb\u52a1");
}
});
});
}
async function resolveQueueCourse(csId) {
let course = state.panelCourses.find((c) => c.csId === csId);
if (course) {
await enrichPanelCourse(course);
return course;
}
try {
course = await fetchCourseFromPlans(csId);
} catch (_) {}
return course || null;
}
async function refreshChapterPreview() {
const selected = loadQueue();
if (!selected.length) {
state.chapterPreview = [];
state.queueVideoTotal = 0;
state.queueVideoDone = 0;
state.queueExamTotal = 0;
state.queueExamDone = 0;
renderChapterPreview();
updateQueueSummary();
return;
}
const list = [];
let examTotal = 0;
let examDone = 0;
for (const csId of selected) {
const course = await resolveQueueCourse(csId);
if (!course) continue;
try {
const videos = await loadCourseVideos(csId);
examTotal += videos.length;
examDone += videos.filter((v) => v.chapterStatus === "done").length;
list.push({
csId,
courseTitle: course.csName || "\u672a\u547d\u540d\u8bfe\u7a0b",
videos: videos.map((v) => ({
resId: v.resId,
title: v.resName || v.resId,
process: Number(v.process) || 0,
studyFinished: v.studyFinished,
chapterStatus: v.chapterStatus || "study",
active: false,
})),
});
} catch (_) {}
}
const bg = loadBgState();
const activeResId =
bg.csId && bg.videos?.length ? String(bg.videos[bg.videoIndex || 0]?.resId || "") : "";
list.forEach((c) => {
if (c.csId === bg.csId) {
c.videos.forEach((v) => {
if (String(v.resId) === activeResId) v.active = true;
});
}
});
state.chapterPreview = list;
state.queueVideoTotal = list.reduce((s, c) => s + (c.videos?.length || 0), 0);
state.queueVideoDone = list.reduce((s, c) => {
const arr = c.videos || [];
return s + arr.filter((v) => v.studyFinished === "1" || Number(v.process) >= 100).length;
}, 0);
state.queueExamTotal = examTotal;
state.queueExamDone = examDone;
renderChapterPreview();
updateQueueSummary();
}
function renderChapterPreview() {
const box = document.getElementById("cme-chapter-preview");
if (!box) return;
if (!state.chapterPreview.length) {
box.innerHTML = "\u8bf7\u9009\u62e9\u8bfe\u7a0b
";
return;
}
box.innerHTML = state.chapterPreview
.map((course) => {
const items = (course.videos || [])
.map((v) => {
const done = v.studyFinished === "1" || Number(v.process) >= 100;
const color = v.active ? "#16a34a" : done ? "#64748b" : "#334155";
const mark = v.active ? "▶" : done ? "✓" : "•";
const pct = Math.round(Number(v.process) || 0);
const videoTag = done ? "\u89c6\u9891\u5df2\u5b66\u5b8c" : `\u89c6\u9891 ${pct}%`;
const examTag = formatChapterExamTag(v.chapterStatus || (done ? "exam" : "study"));
return `${mark} ${escHtml(v.title)}${escHtml(videoTag)} · ${escHtml(examTag)}
`;
})
.join("");
return `${escHtml(course.courseTitle)}
${items || "
\u672a\u89e3\u6790\u5230\u8bfe\u4ef6\uff0c\u8bf7\u70b9\u5237\u65b0\u6216\u6253\u5f00\u8be5\u8bfe\u7a0b\u9875\u540e\u518d\u8bd5
"}
`;
})
.join("");
}
function updateQueueSummary() {
const doneEl = document.getElementById("cme-queue-done");
const totalEl = document.getElementById("cme-queue-total");
const pctEl = document.getElementById("cme-queue-percent");
const barEl = document.getElementById("cme-queue-progress");
const examEl = document.getElementById("cme-queue-exam");
const textEl = document.getElementById("cme-queue-text");
const q = loadQueue();
const total = Math.max(0, Number(state.queueVideoTotal || 0));
const done = Math.max(0, Math.min(total, Number(state.queueVideoDone || 0)));
const examTotal = Math.max(0, Number(state.queueExamTotal || 0));
const examDone = Math.max(0, Math.min(examTotal, Number(state.queueExamDone || 0)));
if (doneEl) doneEl.textContent = String(done);
if (totalEl) totalEl.textContent = String(total);
const pct = total > 0 ? Math.max(0, Math.min(100, Math.round((done / total) * 100))) : 0;
if (pctEl) pctEl.textContent = `${pct}%`;
if (barEl) barEl.style.width = `${pct}%`;
if (examEl) {
examEl.textContent = examTotal > 0 ? `${examDone} / ${examTotal}` : "—";
}
if (textEl) {
if (!q.length) {
textEl.textContent = "\u672a\u9009\u62e9";
} else if (total > 0) {
const examPart = examTotal > 0 ? ` · \u8003\u8bd5 ${examDone}/${examTotal}` : "";
textEl.textContent = `\u89c6\u9891 ${done}/${total}${examPart}\uff08\u5df2\u9009 ${q.length} \u95e8\uff09`;
} else {
textEl.textContent = `\u5df2\u9009 ${q.length} \u95e8\uff0c\u6b63\u5728\u7edf\u8ba1…`;
}
}
}
function formatCurrentChapterLabel(bg) {
if (!bg?.videos?.length) return "\u65e0";
const cur = bg.videos[bg.videoIndex || 0];
if (!cur) return "\u65e0";
const no = formatWareNo(cur.resId || "");
const name = String(cur.resName || "").trim();
return name ? `\u7b2c${no}\u8282 ${name}` : `\u7b2c${no}\u8282`;
}
function deriveCurrentTaskText(bg) {
if (!state.enabled) return state.panelHint || "\u70b9\u300c\u5f00\u59cb\u300d\u540e\u81ea\u52a8\u5b66\u4e60";
if (bg?.csId && bg.videos?.length) {
const cur = bg.videos[bg.videoIndex || 0];
const label = String(cur?.resName || "").trim();
const no = formatWareNo(cur?.resId);
if (bg.phase === "exam") return `\u6b63\u5728\u8003\u8bd5\uff1a\u7b2c${no}\u8282 ${label}`.trim();
return `\u6b63\u5728\u89c2\u770b\uff1a\u7b2c${no}\u8282 ${label}`.trim();
}
if (state.lastUserAction) return state.lastUserAction;
return "\u6b63\u5728\u5206\u914d\u4efb\u52a1…";
}
function updateCurrentMetaPanel() {
const courseEl = document.getElementById("cme-current-course");
const chapterEl = document.getElementById("cme-current-chapter");
const taskEl = document.getElementById("cme-current-task");
const bg = loadBgState();
let courseText = "\u65e0";
let chapterText = "\u65e0";
const taskText = deriveCurrentTaskText(bg);
if (bg?.csId) {
courseText =
bg.csName || state.panelCourses.find((c) => c.csId === bg.csId)?.csName || bg.csId;
if (bg.videos?.length) chapterText = formatCurrentChapterLabel(bg);
} else if (state.enabled) {
const q = loadQueue();
const picked = q[0] && state.panelCourses.find((c) => c.csId === q[0]);
if (picked) courseText = picked.csName || picked.csId;
}
if (courseEl) {
courseEl.textContent = courseText;
courseEl.title = courseText;
}
if (chapterEl) {
chapterEl.textContent = chapterText;
chapterEl.title = chapterText;
}
if (taskEl) {
taskEl.textContent = taskText;
taskEl.title = taskText;
}
}
function readPanelCollapsed() {
const v = localStorage.getItem(PANEL_COLLAPSED_KEY);
return v == null ? true : v === "1";
}
function writePanelCollapsed(collapsed) {
localStorage.setItem(PANEL_COLLAPSED_KEY, collapsed ? "1" : "0");
}
function readPanelPos() {
try {
return JSON.parse(localStorage.getItem(PANEL_POS_KEY) || "{}");
} catch (_) {
return null;
}
}
function writePanelPos(left, top) {
localStorage.setItem(PANEL_POS_KEY, JSON.stringify({ left, top }));
}
function clampPanelInViewport(panel) {
if (!panel) return;
const rect = panel.getBoundingClientRect();
const maxLeft = Math.max(0, window.innerWidth - panel.offsetWidth);
const maxTop = Math.max(0, window.innerHeight - panel.offsetHeight);
const left = Math.max(0, Math.min(maxLeft, rect.left));
const top = Math.max(0, Math.min(maxTop, rect.top));
panel.style.right = "auto";
panel.style.bottom = "auto";
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
writePanelPos(left, top);
}
function applyPanelCollapsed(panel, collapsed) {
const btnMin = panel.querySelector("#cme-btn-min");
const btnMax = panel.querySelector("#cme-btn-max");
panel.classList.toggle("cme-panel-min", !!collapsed);
panel.classList.toggle("cme-panel-max", !collapsed);
if (btnMin) btnMin.style.display = collapsed ? "none" : "";
if (btnMax) btnMax.style.display = collapsed ? "" : "none";
const footerExtra = panel.querySelector(".cme-footer-extra");
if (footerExtra) footerExtra.style.display = collapsed ? "none" : "";
clampPanelInViewport(panel);
}
function enablePanelDrag(panel) {
const header = panel.querySelector("#cme-panel-header");
if (!header) return;
let dragging = false;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
header.addEventListener("mousedown", (e) => {
if (e.target?.closest("#cme-panel-controls, .cme-panel-ctl")) return;
dragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
panel.style.right = "auto";
panel.style.bottom = "auto";
e.preventDefault();
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
const left = Math.max(0, Math.min(window.innerWidth - panel.offsetWidth, startLeft + (e.clientX - startX)));
const top = Math.max(0, Math.min(window.innerHeight - panel.offsetHeight, startTop + (e.clientY - startY)));
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
});
document.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
const rect = panel.getBoundingClientRect();
writePanelPos(rect.left, rect.top);
});
}
function injectPanelStyles() {
const id = "cme-panel-style-v4";
document.getElementById("cme-panel-style-v2")?.remove();
document.getElementById("cme-panel-style-v3")?.remove();
if (document.getElementById(id) || !document.head) return;
const st = document.createElement("style");
st.id = id;
st.textContent = `
#cme-auto-panel{position:fixed;right:20px;top:80px;z-index:999999;width:412px;display:flex;flex-direction:column;background:#f4f7fb;border:1px solid #dbe4f0;border-radius:16px;box-shadow:0 16px 40px rgba(15,23,42,.17);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;font-size:12px;color:#0f172a;overflow:hidden;transition:max-height .22s ease,box-shadow .22s ease;}
.cme-panel-toast{display:none;position:absolute;left:50%;top:10px;transform:translateX(-50%);z-index:1000002;padding:8px 14px;border-radius:10px;font-size:12px;font-weight:700;box-shadow:0 6px 20px rgba(15,23,42,.18);max-width:90%;text-align:center;pointer-events:none;line-height:1.4;}
.cme-panel-toast-ok{background:#ecfdf5;color:#047857;border:1px solid #6ee7b7;}
.cme-panel-toast-err{background:#fef2f2;color:#b91c1c;border:1px solid #fca5a5;}
.cme-panel-toast-info{background:#eff6ff;color:#1d4ed8;border:1px solid #93c5fd;}
#cme-auto-panel.cme-panel-hidden{display:none !important;}
#cme-auto-panel.cme-panel-max{max-height:min(92vh,780px);}
#cme-auto-panel.cme-panel-min{max-height:none;box-shadow:0 10px 28px rgba(15,23,42,.14);}
#cme-auto-panel.cme-panel-min #cme-panel-header{border-bottom:none;}
#cme-auto-panel.cme-panel-min #cme-panel-body,#cme-auto-panel.cme-panel-min #cme-panel-footer,#cme-auto-panel.cme-panel-min .cme-footer-extra{display:none !important;}
#cme-panel-header{flex:0 0 auto;}
#cme-panel-body{flex:1 1 auto;min-height:0;overflow-y:auto;padding:8px;}
.cme-footer-extra{flex:0 0 auto;}
#cme-panel-footer{flex:0 0 auto;}
#cme-panel-header{padding:8px 11px;background:linear-gradient(180deg,#f8ecd5,#f4e8cf);border-bottom:1px solid #e5dbc6;display:flex;justify-content:space-between;align-items:center;cursor:move;user-select:none;}
#cme-panel-brand{display:flex;align-items:center;gap:9px;min-width:0;flex:1;}
#cme-panel-logo{width:30px;height:30px;border-radius:9px;object-fit:cover;box-shadow:0 2px 9px rgba(15,23,42,.11);border:1px solid rgba(148,163,184,.45);background:#fff;flex:0 0 auto;}
#cme-panel-title{font-size:13px;font-weight:900;color:#9a3412;line-height:1.26;display:flex;align-items:flex-start;gap:5px;flex-wrap:wrap;}
.cme-panel-title-text{flex:1 1 12em;min-width:0;letter-spacing:-0.01em;}
#cme-panel-sub{display:flex;flex-wrap:wrap;align-items:center;gap:3px 5px;margin-top:3px;line-height:1.32;}
.cme-sub-chip{font-size:11px;color:#7c2d12;background:rgba(255,255,255,.62);padding:2px 7px;border-radius:999px;border:1px solid rgba(180,83,9,.18);font-weight:700;}
.cme-sub-chip-em{color:#0f766e;background:rgba(236,253,245,.9);border-color:rgba(15,118,110,.28);}
.cme-sub-dot{color:#d6d3d1;font-size:10px;font-weight:400;}
.cme-panel-ctl .cme-ctl-ico{display:block;box-sizing:border-box;margin:0 auto;}
.cme-ctl-min{width:10px;height:2px;background:#64748b;border-radius:1px;}
.cme-ctl-plus{font-size:16px;line-height:1;font-weight:700;color:#64748b;}
.cme-panel-version{font-size:11px;font-weight:900;color:#64748b;padding:2px 7px;border-radius:999px;background:#f1f5f9;border:1px solid #e2e8f0;}
#cme-panel-controls{display:flex;align-items:center;gap:5px;flex:0 0 auto;}
.cme-panel-ctl{border:none;background:#fff;color:#64748b;width:28px;height:28px;border-radius:999px;cursor:pointer;box-shadow:0 1px 2px rgba(15,23,42,.1);font-size:15px;line-height:1;font-weight:900;padding:0;}
.cme-panel-ctl:hover{filter:brightness(1.03);}
.cme-card{background:#fff;border:1px solid #d9e2ee;border-radius:12px;padding:7px 9px;margin-bottom:7px;}
.cme-card-status{padding:6px 8px;margin-bottom:6px;}
.cme-status-row{display:flex;justify-content:space-between;align-items:center;gap:7px;line-height:1.28;}
.cme-status-label{font-size:11px;color:#64748b;font-weight:700;}
.cme-status-badge{padding:2px 7px;border-radius:999px;font-weight:800;font-size:11px;background:#fff;border:1px solid #cbd5e1;color:#334155;}
.cme-status-main{margin-top:3px;}
.cme-status-metrics{display:flex;align-items:center;gap:5px;font-size:12px;color:#475569;min-width:0;flex:1;}
.cme-status-metrics em{font-style:normal;font-weight:900;color:#0f172a;}
.cme-metric-div{color:#cbd5e1;}
.cme-progress-top{display:flex;justify-content:space-between;align-items:center;margin-bottom:3px;font-size:11px;color:#334155;}
.cme-progress-num{font-size:22px;font-weight:900;color:#0f172a;}
.cme-progress-num small{font-size:13px;color:#64748b;}
.cme-card-status .cme-progress-pct{font-size:14px;font-weight:900;color:#0369a1;flex:0 0 auto;}
.cme-progress-pct{font-size:20px;font-weight:900;color:#0369a1;}
.cme-card-status .cme-progress-bar{height:5px;margin-top:4px;margin-bottom:0;}
.cme-progress-bar{height:9px;border-radius:999px;background:#e2e8f0;overflow:hidden;margin-top:5px;}
.cme-progress-bar>span{display:block;height:100%;width:0;background:linear-gradient(90deg,#22d3ee,#2563eb);transition:width .2s ease;}
.cme-list-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;}
.cme-list-head-actions{display:flex;align-items:center;gap:5px;}
.cme-log-clear-btn{border:1px solid #cbd5e1;background:#fff;color:#64748b;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;cursor:pointer;line-height:1.4;}
.cme-log-clear-btn:hover{background:#f8fafc;color:#0f172a;}
.cme-plan-row{padding:0 2px 5px;}
.cme-plan-row .cme-plan-select{padding:4px 8px;font-size:11px;line-height:1.25;border-radius:7px;}
.cme-plan-select{width:100%;border:1px solid #cbd5e1;border-radius:8px;padding:6px 9px;font-size:12px;background:#fff;color:#0f172a;}
.cme-list-title{font-size:12px;color:#64748b;font-weight:700;}
.cme-list-tag{font-size:11px;color:#92400e;background:#ffedd5;border:1px solid #fdba74;border-radius:999px;padding:2px 7px;}
.cme-settings-group{background:#f8fafc;border:1px solid #dbe4f0;border-radius:12px;padding:8px;margin-bottom:8px;}
.cme-settings-title{font-size:12px;color:#64748b;font-weight:900;margin-bottom:7px;}
.cme-hidden{display:none !important;}
.cme-btn-row-nowrap{flex-wrap:nowrap;}
.cme-btn-action{min-width:0 !important;flex:1 1 0 !important;}
.cme-btn-ico{font-size:13px;line-height:1;display:inline-flex;align-items:center;margin-right:5px;}
.cme-btn-label{white-space:nowrap;}
.cme-toggle-on{background:linear-gradient(135deg,#1d4ed8,#0ea5e9) !important;border-color:transparent !important;color:#fff !important;}
.cme-card-actions .cme-btn-row{margin-bottom:0;}
.cme-card-actions{padding:6px 8px;}
.cme-empty-state{padding:12px 7px;text-align:center;color:#94a3b8;font-size:12px;font-weight:900;}
#cme-course-list,#cme-chapter-preview,#cme-run-log{max-height:188px;overflow-y:auto;background:#f8fafc;border:1px solid #dbe4f0;border-radius:11px;padding:5px;}
#cme-settings-pane{padding:2px 0;}
.cme-chapter-course{margin-bottom:7px;border:1px solid #dbe4f0;border-radius:9px;background:#fff;}
.cme-chapter-title{padding:6px 9px;background:#eaf1ff;border-bottom:1px solid #dbe4f0;color:#1d4ed8;font-size:12px;font-weight:700;}
.cme-chapter-item{padding:5px 9px;font-size:12px;display:flex;justify-content:space-between;gap:7px;}
.cme-dot{font-weight:700;}
.cme-footer-extra{padding:6px 9px;background:#f8fafc;border-top:1px solid #e2e8f0;opacity:.95;}
.cme-ann{position:relative;font-size:12px;color:#334155;line-height:1.42;background:linear-gradient(180deg,#fffdf5,#fff7e6);border:1px solid #fcd34d;border-radius:11px;padding:8px 9px 8px 11px;margin-bottom:0;}
.cme-ann-title{display:inline-flex;align-items:center;gap:4px;font-size:12px;font-weight:900;color:#9a3412;margin-bottom:3px;}
.cme-ann-text{display:block;word-break:break-word;}
.cme-ann::before{content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:linear-gradient(180deg,#f59e0b,#ef4444);border-top-left-radius:11px;border-bottom-left-radius:11px;}
.cme-qq-row{display:flex;align-items:center;justify-content:space-between;gap:9px;margin-top:5px;}
.cme-qq-text{font-size:12px;color:#475569;}
.cme-qq-title{font-size:13px;font-weight:800;color:#0f172a;}
.cme-qq-btn{border:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:5px 12px;border-radius:999px;font-size:12px;font-weight:800;cursor:pointer;}
.cme-exam-hint{font-size:11px;color:#64748b;margin-top:5px;line-height:1.42;}
.cme-start-hint{font-size:11px;color:#b45309;background:#fffbeb;border:1px solid #fcd34d;border-radius:7px;padding:5px 7px;margin-bottom:5px;line-height:1.32;text-align:center;font-weight:600;}
.cme-btn-pro{flex:0 0 auto;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;border:none;box-shadow:0 3px 10px rgba(239,68,68,.22);padding:4px 8px;font-size:12px;border-radius:10px;cursor:pointer;font-weight:800;}
.cme-btn-pro:hover{filter:brightness(1.06);}
#cme-pro-modal{position:fixed;inset:0;background:rgba(15,23,42,.45);z-index:1000001;display:none;align-items:center;justify-content:center;padding:20px;}
.cme-pro-card{width:min(560px,92vw);max-height:88vh;overflow:auto;background:#fff;border-radius:18px;border:1px solid #dbe4f0;box-shadow:0 18px 46px rgba(15,23,42,.25);padding:16px;}
.cme-pro-title{font-size:24px;font-weight:900;color:#0f172a;}
.cme-pro-sub{font-size:13px;color:#64748b;margin-top:4px;line-height:1.45;}
.cme-pro-sec{margin-top:12px;border:1px solid #dbe4f0;border-radius:12px;padding:12px;background:#f8fafc;}
.cme-pro-sec h4{margin:0 0 8px;font-size:15px;color:#0f172a;}
.cme-pro-sec p{margin:4px 0;font-size:13px;color:#334155;line-height:1.5;}
.cme-pro-sec ul{margin:6px 0 0 18px;padding:0;}
.cme-pro-sec li{margin:4px 0;font-size:13px;color:#334155;line-height:1.45;}
.cme-pro-buy{display:flex;flex-direction:column;gap:10px;margin-top:8px;}
.cme-pro-muted{color:#64748b;font-size:12px;line-height:1.5;}
.cme-pro-buy-btn{display:inline-block;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:10px 16px;border-radius:12px;font-size:14px;font-weight:800;border:none;cursor:pointer;align-self:flex-start;}
.cme-pro-buy-btn:hover{filter:brightness(1.05);}
.cme-pro-buy-btn:disabled{background:#94a3b8;cursor:not-allowed;box-shadow:none;opacity:.92;}
.cme-pro-buy-btn:disabled:hover{filter:none;}
.cme-pro-tip{margin-top:8px;background:#fef3c7;border:1px solid #fcd34d;border-radius:10px;padding:8px 10px;font-size:13px;color:#92400e;font-weight:700;line-height:1.45;}
.cme-pro-tip-info{background:#eff6ff;border-color:#93c5fd;color:#1d4ed8;font-weight:600;}
.cme-pro-actions{margin-top:14px;display:flex;justify-content:flex-end;}
.cme-pro-close{border:none;background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;padding:10px 18px;border-radius:12px;font-size:14px;font-weight:800;cursor:pointer;}
#cme-face-modal{position:fixed;inset:0;background:rgba(15,23,42,.52);z-index:1000002;display:none;align-items:center;justify-content:center;padding:20px;}
.cme-face-card{width:min(420px,92vw);background:#fff;border-radius:18px;border:1px solid #dbe4f0;box-shadow:0 18px 46px rgba(15,23,42,.28);padding:18px 16px 14px;text-align:center;}
.cme-face-title{font-size:20px;font-weight:900;color:#0f172a;}
.cme-face-sub{font-size:13px;color:#64748b;margin-top:6px;line-height:1.45;}
.cme-face-qr-wrap{width:220px;height:220px;margin:14px auto 10px;border:1px solid #dbdbdb;border-radius:8px;overflow:hidden;background:#fff;}
.cme-face-qr-wrap img{display:block;width:220px;height:220px;}
.cme-face-tip{font-size:12px;color:#475569;line-height:1.55;text-align:left;background:#f8fafc;border:1px solid #e2e8f0;border-radius:10px;padding:8px 10px;}
.cme-face-tip strong{color:#1d4ed8;}
.cme-face-tip-actions{margin-top:8px;font-size:11px;color:#64748b;background:#fffbeb;border-color:#fde68a;}
.cme-face-status{margin-top:10px;font-size:13px;font-weight:700;color:#0369a1;}
.cme-face-actions{margin-top:12px;display:flex;justify-content:center;gap:10px;flex-wrap:wrap;}
.cme-face-cancel{border:1px solid #cbd5e1;background:#fff;color:#64748b;padding:8px 18px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;}
.cme-face-cancel:hover{background:#f8fafc;color:#0f172a;}
.cme-face-skip{border:none;background:linear-gradient(135deg,#f59e0b,#ef4444);color:#fff;padding:8px 18px;border-radius:10px;font-size:13px;font-weight:800;cursor:pointer;}
.cme-face-skip:hover{filter:brightness(1.05);}
.cme-tabbar{display:flex;gap:6px;margin-bottom:7px;}
.cme-tab-btn{flex:1;border:1px solid #cbd5e1;background:#f8fafc;color:#475569;padding:4px 4px;border-radius:9px;cursor:pointer;font-weight:700;font-size:11px;}
.cme-tab-btn.active{background:linear-gradient(135deg,#1d4ed8,#0ea5e9);color:#fff;border-color:transparent;}
.cme-pane{display:none;}.cme-pane.active{display:block;}
.cme-btn-row{display:flex;gap:7px;margin-bottom:6px;flex-wrap:wrap;}
.cme-btn{flex:1;border:none;color:#fff;padding:7px 9px;border-radius:10px;cursor:pointer;font-weight:800;font-size:12px;min-width:68px;}
.cme-btn-start{background:#16a34a;}.cme-btn-stop{background:#ef4444;}.cme-btn-refresh{background:#64748b;}
.cme-btn-start.cme-btn-off,.cme-btn-start:disabled{background:#cbd5e1 !important;color:#64748b !important;cursor:not-allowed;box-shadow:none;pointer-events:none;}
.cme-btn-stop.cme-btn-off,.cme-btn-stop:disabled{background:#e2e8f0 !important;color:#94a3b8 !important;cursor:not-allowed;box-shadow:none;pointer-events:none;}
.cme-btn-ghost{border:1px solid #cbd5e1;background:#fff;color:#0f172a;flex:0 0 auto;}
.cme-meta-row{display:flex;justify-content:space-between;gap:7px;font-size:12px;margin-bottom:5px;}
.cme-meta-label{color:#64748b;}.cme-meta-value{font-weight:700;text-align:right;}
.cme-card-current{padding:5px 8px;margin-bottom:6px;}
.cme-card-current .cme-meta-row{font-size:11px;margin-bottom:2px;gap:5px;line-height:1.28;align-items:flex-start;}
.cme-card-current .cme-meta-row:last-child{margin-bottom:0;}
.cme-card-current .cme-meta-label{flex:0 0 auto;font-size:10px;white-space:nowrap;}
.cme-card-current .cme-meta-value{flex:1 1 auto;min-width:0;font-size:11px;font-weight:600;text-align:right;word-break:break-all;line-height:1.28;}
.cme-auth-badge{padding:2px 7px;border-radius:999px;border:1px solid #cbd5e1;font-size:11px;font-weight:900;}
#cme-auto-status{padding:3px 9px;border-radius:999px;font-weight:900;font-size:12px;background:#fff;border:1px solid #cbd5e1;}
.cme-card-status #cme-auto-status{padding:2px 7px;font-size:11px;}
#cme-panel-footer{padding:7px 11px;background:#eef2f7;border-top:1px solid #dbe4f0;font-size:12px;}
.cme-log-row{padding:4px 6px;border-bottom:1px dashed #d4deea;font-size:12px;line-height:1.42;}
`;
document.head.appendChild(st);
}
function createPanel() {
const old = document.getElementById("cme-auto-panel") || document.getElementById("cme-auto-study-panel");
if (old) old.remove();
injectPanelStyles();
const panel = document.createElement("div");
panel.id = "cme-auto-panel";
panel.innerHTML = `
\u8fd0\u884c\u72b6\u6001
\u5df2\u505c\u6b62
\u89c6\u9891 0/0
·
\u8003\u8bd5 —
0%
\u8bfe\u7a0b\u9009\u62e9\u89c6\u9891·\u8003\u8bd5
\u767b\u5f55\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d\u8bfe\u7a0b
\u7ae0\u8282\u9884\u89c8\u89c6\u9891·\u8003\u8bd5
\u8bf7\u9009\u62e9\u8bfe\u7a0b
\u8fd0\u884c\u65e5\u5fd7
\u8fdb\u5ea6
\u6388\u6743\u4e0e\u9009\u9879\u8bbe\u7f6e
\u7528\u6237\u7c7b\u578b\u672a\u6821\u9a8c
\u514d\u8d39\u4f53\u9a8c\uff081\u4e2a\u89c6\u9891\uff090/1
Token
\u4e91\u7aef\u670d\u52a1${DEFAULT_CLOUD_API_BASE}
\u8fd0\u884c\u6a21\u5f0f
\u4e0a\u62a5\u95f4\u9694 \u79d2
\u5b66\u65f6\u500d\u7387 \u500d
\u5b66\u5b8c\u540e\u81ea\u52a8\u8003\u8bd5\uff08\u5df2\u9ed8\u8ba4\u5f00\u542f\uff09
\u5f53\u524d\u8bfe\u7a0b\u65e0
\u5f53\u524d\u7ae0\u8282\u65e0
\u5f53\u524d\u4efb\u52a1\u70b9\u300c\u5f00\u59cb\u300d\u540e\u81ea\u52a8\u5b66\u4e60
`;
document.body.appendChild(panel);
createProModal();
const savedPos = readPanelPos();
if (savedPos && savedPos.left != null && savedPos.top != null) {
panel.style.right = "auto";
panel.style.left = `${savedPos.left}px`;
panel.style.top = `${savedPos.top}px`;
}
applyPanelCollapsed(panel, readPanelCollapsed());
enablePanelDrag(panel);
const btnMin = document.getElementById("cme-btn-min");
const btnMax = document.getElementById("cme-btn-max");
if (btnMin) {
btnMin.addEventListener("click", (e) => {
e.stopPropagation();
writePanelCollapsed(true);
applyPanelCollapsed(panel, true);
});
}
if (btnMax) {
btnMax.addEventListener("click", (e) => {
e.stopPropagation();
writePanelCollapsed(false);
applyPanelCollapsed(panel, false);
});
}
panel.querySelectorAll(".cme-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => switchPanelTab(btn.dataset.tab));
});
document.getElementById("cme-clear-log")?.addEventListener("click", () => {
clearRunLog();
});
const planSel = document.getElementById("cme-plan-select");
if (planSel) {
planSel.addEventListener("change", async () => {
saveSelectedTpId(planSel.value);
state.panelRefreshing = true;
updatePanel();
try {
await fetchPanelCourses();
renderPlanSelect();
renderCourseList();
await enrichPanelCoursesProgress();
await refreshChapterPreview();
} catch (err) {
log("\u5207\u6362\u8bfe\u7a0b\u6765\u6e90\u5931\u8d25\uff1a" + (err.message || err));
} finally {
state.panelRefreshing = false;
updatePanel();
}
});
}
document.getElementById("cme-start").addEventListener("click", async () => {
if (state.enabled) return;
ensureEfficiencyMode();
applyLockedPanelDefaults();
if (!(await _cr())) {
updatePanel();
return;
}
const q = loadQueue();
if (!q.length) {
log("\u8bf7\u5148\u52fe\u9009\u81f3\u5c11\u4e00\u95e8\u8bfe\u7a0b");
setPanelHint("\u8bf7\u5148\u52fe\u9009\u81f3\u5c11\u4e00\u95e8\u8bfe\u7a0b\u518d\u70b9\u5f00\u59cb");
updatePanel();
return;
}
clearPanelHint();
const bg = loadBgState();
if (bg.csId && !q.includes(bg.csId)) saveBgState({});
else if (bg?.csId) {
bg.lastSaveAt = 0;
saveBgState(bg);
}
state.enabled = true;
state.skipExamsSession = false;
localStorage.setItem(STORAGE_KEY, "1");
state.lastTick = 0;
syncStartStopButtons();
syncKeepAlive();
log("\u81ea\u52a8\u770b\u8bfe\u5df2\u5f00\u542f");
startPreviewAutoRefresh();
updatePanel();
tick();
});
document.getElementById("cme-stop").addEventListener("click", () => {
if (!state.enabled) return;
state.enabled = false;
localStorage.setItem(STORAGE_KEY, "0");
clearStudyModeSelection();
syncStartStopButtons();
syncKeepAlive();
stopPreviewAutoRefresh();
log("\u81ea\u52a8\u770b\u8bfe\u5df2\u6682\u505c");
updatePanel();
});
document.getElementById("cme-join-qq").addEventListener("click", () => {
try {
GM_openInTab(QQ_GROUP_LINK, { active: true, insert: true, setParent: true });
} catch (_) {
window.open(QQ_GROUP_LINK, "_blank");
}
});
document.getElementById("cme-open-pro")?.addEventListener("click", () => {
openProModal();
});
document.getElementById("cme-cloud-save").addEventListener("click", async () => {
const input = document.getElementById("cme-cloud-token");
const btn = document.getElementById("cme-cloud-save");
state.cloudToken = String((input && input.value) || "").trim();
state.cloudRevoked = false;
localStorage.setItem(CLOUD_TOKEN_KEY, state.cloudToken);
writeCloudLeaseCache("", 0);
state.cloudLease = "";
state.cloudLeaseExp = 0;
if (btn) {
btn.disabled = true;
btn.textContent = "\u6821\u9a8c\u4e2d…";
}
try {
if (!state.cloudToken) {
await _el(true);
showPanelToast("Token \u5df2\u6e05\u7a7a\uff0c\u5f53\u524d\u4e3a\u514d\u8d39\u4f53\u9a8c", "info");
} else {
await _vt();
await _el(true);
const tier = String(state.cloudTier || "").toLowerCase();
if (tier === "pro") {
showPanelToast("Pro Token \u6821\u9a8c\u6210\u529f", "ok");
} else {
showPanelToast("\u4e91\u7aef\u6388\u6743\u5df2\u66f4\u65b0", "ok");
}
}
} catch (err) {
showPanelToast(formatCloudTokenError(err), "err");
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = "\u4fdd\u5b58\u5e76\u6821\u9a8c";
}
}
updateCloudPanelUI();
});
applyLockedPanelDefaults();
setCloudApiBase();
state.cloudToken = String(localStorage.getItem(CLOUD_TOKEN_KEY) || "").trim();
const tokenInput = document.getElementById("cme-cloud-token");
if (tokenInput) tokenInput.value = state.cloudToken;
updateCloudPanelUI();
updatePanel();
renderCourseList();
updateQueueSummary();
void _cf();
void _pn();
void _fip(false).then(() => updatePanel());
}
function shouldHidePanelOnPage() {
return pageType() === "courseplay";
}
function syncPanelVisibility() {
const hide = shouldHidePanelOnPage();
const panel = document.getElementById("cme-auto-panel");
if (panel) panel.classList.toggle("cme-panel-hidden", hide);
if (hide) closeProModal();
}
function syncStartStopButtons() {
const running = !!state.enabled;
const startBtn = document.getElementById("cme-start");
const stopBtn = document.getElementById("cme-stop");
if (startBtn) {
startBtn.disabled = running;
startBtn.classList.toggle("cme-btn-off", running);
startBtn.setAttribute("aria-disabled", running ? "true" : "false");
}
if (stopBtn) {
stopBtn.disabled = !running;
stopBtn.classList.toggle("cme-btn-off", !running);
stopBtn.setAttribute("aria-disabled", !running ? "true" : "false");
}
}
function updatePanel() {
syncPanelVisibility();
syncProBuyButtonUi();
syncStartStopButtons();
const statusEl = document.getElementById("cme-auto-status");
if (!statusEl) return;
if (shouldHidePanelOnPage()) return;
if (state.panelRefreshing) {
statusEl.textContent = "\u5237\u65b0\u4e2d…";
} else if (!state.enabled) {
statusEl.textContent = "\u5df2\u505c\u6b62";
} else {
const bg = loadBgState();
if (bg.phase === "exam") statusEl.textContent = "\u8003\u8bd5\u4e2d";
else if (bg.csId && bg.videos?.length) {
statusEl.textContent = `\u8fd0\u884c ${(bg.videoIndex || 0) + 1}/${bg.videos.length}`;
} else statusEl.textContent = "\u8fd0\u884c\u4e2d";
}
updateCloudPanelUI();
updateCurrentMetaPanel();
updateQueueSummary();
const hintEl = document.getElementById("cme-start-hint");
if (hintEl && !state.enabled) {
if (state.panelHint) {
hintEl.textContent = state.panelHint;
hintEl.style.display = "block";
} else {
hintEl.textContent = "";
hintEl.style.display = "none";
}
} else if (hintEl) {
hintEl.textContent = "";
hintEl.style.display = "none";
}
}
async function initPanelData() {
if (!(await ensureCmeSession())) return;
ensureEfficiencyMode();
try {
await ensurePlatformHeartbeatSec(true);
} catch (_) {}
await _fip(false);
await refreshPanelCourses();
try {
await _el(false);
} catch (_) {}
}
function init() {
resetSessionOnPageLoad();
if (pageType() === "courseplay") initStudyPageWorker();
if (pageType() === "favorite" || pageType() === "course" || pageType() === "home") createPanel();
syncPanelVisibility();
syncKeepAlive();
initPanelData();
setInterval(tick, TICK_MS);
setInterval(updatePanel, 1000);
setInterval(() => {
if (isLoggedIn()) _el(false).catch(() => {});
}, 10 * 60 * 1000);
window.addEventListener("hashchange", () => {
state.lastTick = 0;
syncPanelVisibility();
});
window.addEventListener("popstate", () => {
syncPanelVisibility();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();