// ==UserScript==
// @name 良师在线·乐学LS365学习助手
// @namespace https://card.wlxy.live/
// @version 1.0
// @author 柠檬真酸
// @icon https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png
// @description 良师在线·乐学LS365平台自动完成视频课时、作业与考试,云题库,安全稳定效率,多院校平台适配,几分钟完成所有学习任务
// @antifeature payment 免费体验1门课课时,升级Pro不限课时/作业/考试
// @antifeature membership 需云端授权
// @match https://ncpuedu.ls365.net/*
// @match https://*.ls365.net/*
// @match https://*.ls365.com/*
// @connect ncpuedu.ls365.net
// @connect *.ls365.net
// @connect *.ls365.com
// @connect cdn.ls365.net
// @connect *.cdn.ls365.net
// @connect cdn.ls365.com
// @connect *.cdn.ls365.com
// @connect oa20.ahzsksw.cn
// @connect huaweicloudobs.ahjxjy.cn
// @connect card.wlxy.top
// @connect card.wlxy.live
// @grant GM_xmlhttpRequest
// @grant GM_info
// @grant GM_openInTab
// @run-at document-start
// @license All Rights Reserved
// ==/UserScript==
(function () {
"use strict";
try {
if (window.top !== window.self) return;
} catch (_) {
return;
}
if (window.__LS365_HELPER_BOOTED__) return;
window.__LS365_HELPER_BOOTED__ = true;
const HOST = location.hostname;
if (
!/\.ls365\.net$/i.test(HOST) &&
!/\.ls365\.com$/i.test(HOST) &&
HOST !== "ls365.net" &&
HOST !== "ls365.com"
) {
return;
}
const SCRIPT_VERSION = (() => {
try {
if (typeof GM_info !== "undefined" && GM_info?.script?.version) {
return String(GM_info.script.version);
}
} catch (_) {}
return "1.0.0";
})();
const ORIGIN = location.origin;
const PANEL_LOGO_URL =
"https://huaweicloudobs.ahjxjy.cn/895789f9086469785b846d30c0ed95f9.png";
const PRO_BUY_URL = "https://card.wlxy.live/details/DB2056C0";
const PANEL_NOTICE_FALLBACK =
"\u514d\u8d39\u53ef\u4f53\u9a8c 1 \u95e8\u8bfe\u8bfe\u65f6\uff1bPro\uff08\u6fc0\u6d3b30\u5929\uff09\u4e0d\u9650\u8bfe\u65f6/\u4f5c\u4e1a/\u8003\u8bd5\u3002\u4f5c\u4e1a\u4e0e\u8003\u8bd5\u5171\u7528\u4e91\u9898\u5e93\u3002";
const DEFAULT_CLOUD_API_BASE = "https://oa20.ahzsksw.cn";
const QUEUE_KEY = "ls365_course_queue_v1";
const QUEUE_TOUCHED_KEY = "ls365_queue_touched_v1";
const STUDY_MODE_KEY = "ls365_study_mode_v1";
const AUTO_HW_KEY = "ls365_auto_homework_v1";
const AUTO_EXAM_KEY = "ls365_auto_exam_v1";
const EXAM_HARD_CACHE_KEY = "ls365_exam_hard_cache_v1";
const EXAM_STEM_CACHE_KEY = "ls365_exam_stem_cache_v1";
const EXAM_SOFT_CACHE_KEY = "ls365_exam_soft_cache_v1";
const MY_WORK_DIV_OPEN_RE = /
]*>/i;
const DEEPSEEK_ENABLE_KEY = "ls365_deepseek_enable_v1";
const DEEPSEEK_API_KEY_KEY = "ls365_deepseek_api_key_v1";
const PANEL_POS_KEY = "ls365_panel_pos_v1";
const PANEL_COLLAPSED_KEY = "ls365_panel_collapsed_v1";
const CLOUD_TOKEN_KEY = "ls365_cloud_token_v1";
const CLOUD_LEASE_CACHE_KEY = "ls365_cloud_lease_cache_v1";
const FREE_USED_KEY = "ls365_free_used_courses_v1";
const STUDENT_CACHE_KEY = "ls365_student_id_v1";
const USER_CACHE_KEY = "ls365_user_cache_v1";
const DONE_RATIO = 0.85;
const EFF_MID_WAIT_MS = 1500;
const EFF_END_WAIT_MS = 800;
const COURSE_GAP_MS = 2500;
const FREE_COURSE_LIMIT = 1;
const HW_TARGET_SCORE = 100;
const HW_MAX_ROUNDS = 8;
const EXAM_TARGET_SCORE = 100;
const COURSE_DONE_PROGRESS = 100;
const COURSE_DONE_HW_SCORE = 100;
const COURSE_DONE_EXAM_SCORE = 80;
const EXAM_ACCEPT_SCORE = 90;
const EXAM_ONE_SUBMIT_PER_START = true;
const EXAM_MAX_ROUNDS = 8;
const EXAM_RETAKE_MIN_HIT_RATIO = 0.5;
const EXAM_SUBMIT_WAIT_MIN_MS = 15000;
const EXAM_SUBMIT_WAIT_MAX_MS = 30000;
const EXAM_SOFT_CACHE_MIN_SCORE = 90;
const DEEPSEEK_MODEL = "deepseek-v4-flash";
const DEEPSEEK_API_URL = "https://api.deepseek.com/chat/completions";
const state = {
enabled: false,
stopFlag: false,
runBusy: false,
studyMode: "efficiency",
autoHomework: true,
autoExam: true,
user: null,
studentId: String(localStorage.getItem(STUDENT_CACHE_KEY) || "").trim(),
schoolId: "",
professionalId: "",
courses: [],
homeworks: [],
exams: [],
selected: new Set(loadQueue()),
chapters: [],
previewCourseId: "",
logLines: [],
queueTotal: 0,
queueDone: 0,
currentCourse: "",
currentChapter: "",
currentTask: "\u70b9\u5f00\u59cb\u540e\u81ea\u52a8\u5b66\u4e60",
panelRefreshing: false,
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,
freeUsedCourses: readFreeUsed(),
freeCourseLimit: FREE_COURSE_LIMIT,
consumedCourseKeys: new Set(),
concurrentRun: false,
proBuyUrl: PRO_BUY_URL,
remotePanelNotice: "",
hwAnswerCache: {},
examAnswerCache: readJsonMap(EXAM_HARD_CACHE_KEY),
examStemCache: readJsonMap(EXAM_STEM_CACHE_KEY),
examSoftCache: readJsonMap(EXAM_SOFT_CACHE_KEY),
deepseekEnabled: true,
deepseekApiKey: "",
courseRefreshTimer: 0,
};
function readJsonMap(key) {
try {
const raw = JSON.parse(localStorage.getItem(key) || "{}");
return raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
} catch (_) {
return {};
}
}
function writeExamCaches() {
try {
localStorage.setItem(EXAM_HARD_CACHE_KEY, JSON.stringify(state.examAnswerCache || {}));
localStorage.setItem(EXAM_STEM_CACHE_KEY, JSON.stringify(state.examStemCache || {}));
localStorage.setItem(EXAM_SOFT_CACHE_KEY, JSON.stringify(state.examSoftCache || {}));
} catch (_) {}
}
function loadQueue() {
try {
const raw = JSON.parse(localStorage.getItem(QUEUE_KEY) || "[]");
return Array.isArray(raw) ? raw.map(String) : [];
} catch (_) {
return [];
}
}
function saveQueue() {
localStorage.setItem(QUEUE_KEY, JSON.stringify([...state.selected]));
}
function readFreeUsed() {
const n = parseInt(localStorage.getItem(FREE_USED_KEY) || "0", 10);
return Number.isFinite(n) && n > 0 ? n : 0;
}
function writeFreeUsed(n) {
state.freeUsedCourses = Math.max(0, Math.round(n) || 0);
localStorage.setItem(FREE_USED_KEY, String(state.freeUsedCourses));
}
function escHtml(s) {
return String(s || "")
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
function createAsyncMutex() {
let tail = Promise.resolve();
return function withLock(fn) {
const run = tail.then(() => fn());
tail = run.catch(() => {});
return run;
};
}
const homeworkMutex = createAsyncMutex();
const examMutex = createAsyncMutex();
function isPlatformTransientError(e) {
const s = String(e?.message || e || "");
return /\u81ea\u5b9a\u4e49\u9519\u8bef|\u4e0d\u80fd\u8bc6\u522b\u6b64\u9519\u8bef|\u5e76\u53d1|session|\u4f1a\u8bdd|HTTP 5\d{2}|timeout|\u7f51\u7edc\u9519\u8bef/i.test(s);
}
function trace(_msg) {
}
function log(msg) {
const line = `[${new Date().toLocaleTimeString()}] ${msg}`;
state.logLines.unshift(line);
if (state.logLines.length > 240) state.logLines.length = 240;
const box = document.getElementById("ls365-run-log");
if (box) {
box.innerHTML = state.logLines
.map((t) => `
${escHtml(t)}
`)
.join("");
}
}
function logExam(_msg) {
trace(_msg);
}
function logExamDone(courseTitle, score) {
const n = Math.round(Number(score) || 0);
log(`\u300c${courseTitle}\u300d\u8003\u8bd5\u5b8c\u6210 ${n}\u5206`);
}
function clearRunLog() {
state.logLines = [];
const box = document.getElementById("ls365-run-log");
if (box) box.innerHTML = "";
}
function isPro() {
return state.cloudTier === "pro";
}
function learningUserId() {
const u = state.user || {};
return String(u.autoid || u.userId || state.studentId || "").trim();
}
function readCloudLeaseCache() {
try {
const parsed = JSON.parse(localStorage.getItem(CLOUD_LEASE_CACHE_KEY) || "null");
const lease = String(parsed?.lease || "").trim();
const exp = Number(parsed?.exp || 0);
if (!lease || !Number.isFinite(exp) || exp * 1000 <= Date.now() + 15000) return null;
return parsed;
} 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 cloudHttp(path, method, payload, headers) {
const url = String(state.cloudApiBase || DEFAULT_CLOUD_API_BASE).replace(/\/$/, "") + path;
const hdrs = Object.assign(
{ Accept: "application/json, text/plain, */*", "Content-Type": "application/json" },
headers || {}
);
const body = payload == null ? null : JSON.stringify(payload);
return new Promise((resolve, reject) => {
const finish = (status, text) => resolve({ status, text });
if (typeof GM_xmlhttpRequest === "function") {
GM_xmlhttpRequest({
method: method || "GET",
url,
headers: hdrs,
data: body,
timeout: 30000,
onload: (res) => finish(res.status, String(res.responseText || "")),
onerror: () => reject(new Error("\u4e91\u7aef\u7f51\u7edc\u9519\u8bef")),
ontimeout: () => reject(new Error("\u4e91\u7aef\u8d85\u65f6")),
});
return;
}
fetch(url, { method: method || "GET", headers: hdrs, body })
.then(async (res) => finish(res.status, await res.text()))
.catch(() => reject(new Error("\u4e91\u7aef\u7f51\u7edc\u9519\u8bef")));
});
}
async function cloudRequestJson(path, method, payload) {
const headers = {};
const tok = String(state.cloudToken || "").trim();
if (tok) headers.Authorization = "Bearer " + tok;
const luid = learningUserId();
if (luid) headers["X-Learning-User-Id"] = luid;
const res = await cloudHttp(path, method, payload, headers);
let data = null;
try {
data = res.text ? JSON.parse(res.text) : null;
} catch (_) {
data = null;
}
if (res.status >= 400) {
const msg = data?.message || data?.detail || `HTTP ${res.status}`;
const err = new Error(msg);
err.status = res.status;
throw err;
}
if (data && data.ok === false) throw new Error(data.message || "\u4e91\u7aef\u5931\u8d25");
return data || {};
}
async function ensureCloudLease(force) {
if (!force) {
const cached = readCloudLeaseCache();
if (cached?.lease) {
state.cloudLease = cached.lease;
state.cloudLeaseExp = Number(cached.exp) || 0;
if (cached.tier) state.cloudTier = String(cached.tier);
if (cached.free_used_courses != null) state.freeUsedCourses = Number(cached.free_used_courses) || 0;
if (cached.free_course_limit != null) state.freeCourseLimit = Number(cached.free_course_limit) || FREE_COURSE_LIMIT;
if (cached.pro_expires_at) state.cloudProExpireAt = Number(cached.pro_expires_at) || 0;
return state.cloudLease;
}
}
const luid = learningUserId();
if (!luid) throw new Error("\u8bf7\u5148\u767b\u5f55\u5e73\u53f0");
const data = await cloudRequestJson("/api/ls365/lease", "POST", {
learning_user_id: luid,
name: state.user?.RealName || "",
userName: state.user?.UserName || "",
});
const lease = String(data.lease || "");
const exp = Number(data.exp || 0);
if (!lease) throw new Error("\u6388\u6743\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u91cd\u8bd5");
state.cloudLease = lease;
state.cloudLeaseExp = exp;
state.cloudTier = String(data.tier || (state.cloudToken ? "pro" : "free"));
if (data.free_course_limit != null) state.freeCourseLimit = Number(data.free_course_limit) || FREE_COURSE_LIMIT;
if (data.free_used_courses != null) state.freeUsedCourses = Number(data.free_used_courses) || 0;
if (data.pro_expires_at) state.cloudProExpireAt = Number(data.pro_expires_at) || 0;
writeCloudLeaseCache(lease, exp, {
tier: state.cloudTier,
free_course_limit: state.freeCourseLimit,
free_used_courses: state.freeUsedCourses,
pro_expires_at: state.cloudProExpireAt,
});
updateSettingsPane();
return lease;
}
async function consumeFreeCourse(course) {
await ensureCloudLease(false);
if (isPro()) return { ok: true, unlimited: true };
const key = courseKey(course);
if (state.consumedCourseKeys.has(key)) return { ok: true, cached: true };
const data = await cloudRequestJson("/api/ls365/course/consume", "POST", {
lease: state.cloudLease,
cour_no: String(course.courseId || ""),
cour_name: String(course.courseName || ""),
});
state.consumedCourseKeys.add(key);
if (data.free_used_courses != null) {
state.freeUsedCourses = Number(data.free_used_courses) || 0;
writeFreeUsed(state.freeUsedCourses);
}
writeCloudLeaseCache(state.cloudLease, state.cloudLeaseExp, {
tier: state.cloudTier,
free_course_limit: state.freeCourseLimit,
free_used_courses: state.freeUsedCourses,
pro_expires_at: state.cloudProExpireAt,
});
updateSettingsPane();
return data;
}
async function cloudQbankLookup(stems) {
if (!stems.length || !isPro()) return [];
await ensureCloudLease(false);
const data = await cloudRequestJson("/api/ls365/qbank/lookup", "POST", {
lease: state.cloudLease,
stems,
});
return Array.isArray(data.items) ? data.items : [];
}
async function cloudQbankUpsert(items) {
if (!items.length || !isPro()) return 0;
await ensureCloudLease(false);
let saved = 0;
for (let i = 0; i < items.length; i += 40) {
const chunk = items.slice(i, i + 40).map((it) => ({
stem: it.stem,
answer_text: answerTextForUpsert(it._card || it, it.answer_letter, it.answer_text),
answer_letter: it.answer_letter || "",
paper_name: it.paper_name || "",
cour_no: it.cour_no || "",
source: it.source || "ls365_hw",
}));
const data = await cloudRequestJson("/api/ls365/qbank/upsert", "POST", {
lease: state.cloudLease,
items: chunk,
});
saved += Number(data.saved || 0);
}
return saved;
}
async function cloudExamAiAnswer(prompt, images) {
const p = String(prompt || "").trim();
if (!p || !isPro()) return null;
await ensureCloudLease(false);
const apiUrl =
String(state.cloudApiBase || DEFAULT_CLOUD_API_BASE).replace(/\/$/, "") + "/api/ls365/exam-ai/answer";
const imgList = (images || [])
.map((u) => String(u || "").trim())
.filter((u) => /^https?:\/\//i.test(u))
.slice(0, 6);
try {
const data = await cloudRequestJson("/api/ls365/exam-ai/answer", "POST", {
lease: state.cloudLease,
prompt: p,
images: imgList,
});
const txt = String(data?.answer || "").trim();
state._examAiLastErr = "";
if (!txt && !state._examAiErrLogged) {
state._examAiErrLogged = true;
logExam(`AI \u8fd4\u56de\u7a7a\u5185\u5bb9\uff08\u63a5\u53e3\u5df2\u8fde\u901a\u4f46\u65e0 answer \u5b57\u6bb5\uff0c\u8bf7\u68c0\u67e5\u540e\u53f0 DeepSeek \u914d\u7f6e\uff09`);
}
return txt || null;
} catch (e) {
const status = Number(e.status) || 0;
const msg = String(e.message || e);
state._examAiLastErr = msg.slice(0, 160);
if (!state._examAiErrLogged) {
state._examAiErrLogged = true;
if (status === 404) {
logExam(
`AI \u63a5\u53e3 HTTP 404\uff1a${apiUrl} \u672a\u90e8\u7f72\u6216\u672a\u66f4\u65b0\u670d\u52a1\u7aef\uff0c\u8003\u8bd5\u5c06\u53ea\u9760\u5b98\u65b9/\u9898\u5e93\u7b54\u6848\uff0876 \u5206\u5e38\u89c1\u539f\u56e0\uff09`
);
} else if (/ssl_verify_failed|CERTIFICATE_VERIFY_FAILED|certificate verify failed/i.test(msg)) {
logExam(
`AI \u670d\u52a1\u7aef SSL \u8bc1\u4e66\u6821\u9a8c\u5931\u8d25\uff08HTTP ${status || 502}\uff09\uff1a\u8bf7\u5728 oa20 \u6267\u884c pip install certifi\uff0c\u6216\u8bbe\u7f6e\u73af\u5883\u53d8\u91cf LS365_DEEPSEEK_SSL_VERIFY=0 \u540e\u91cd\u542f`
);
logExam(`\u8be6\u60c5\uff1a${msg.slice(0, 120)}`);
} else if (status === 401 || status === 403) {
logExam(`AI \u6388\u6743\u5931\u8d25 HTTP ${status}\uff1a${msg.slice(0, 100)}\uff08\u8bf7\u68c0\u67e5 Pro Token\uff09`);
} else if (status >= 500 || status === 502) {
logExam(`AI \u670d\u52a1\u7aef\u9519\u8bef HTTP ${status}\uff1a${msg.slice(0, 120)}`);
} else if (status) {
logExam(`AI \u8bf7\u6c42\u5931\u8d25 HTTP ${status}\uff1a${msg.slice(0, 100)}`);
} else {
logExam(`AI \u7f51\u7edc\u5931\u8d25\uff1a${msg.slice(0, 100)}`);
}
}
return null;
}
}
async function refreshPanelNotice() {
try {
const res = await cloudHttp("/api/ls365/panel-notice", "GET", null);
if (res.status === 200 && res.text) {
state.remotePanelNotice = String(res.text).trim();
const el = document.getElementById("ls365-ann-text");
if (el && state.remotePanelNotice) el.textContent = state.remotePanelNotice;
}
} catch (_) {}
}
function normalizeImgUrl(url) {
const raw = String(url || "").trim();
if (!raw) return "";
const noProto = raw.replace(/^https?:\/\//i, "");
return noProto.split("?")[0].slice(0, 200);
}
function imgStemPrimary(stem) {
const s = String(stem || "").trim();
if (!/^__IMG__/i.test(s)) return "";
const u = s
.replace(/^__IMG__/i, "")
.split("|")
.map((x) => x.trim())
.find((x) => /^https?:\/\//i.test(x));
return u ? normalizeImgUrl(u) : "";
}
function examHardBagCount(bag) {
if (!bag || typeof bag !== "object") return 0;
return Object.keys(bag).filter((k) => !String(k).startsWith("__")).length;
}
function harvestItemStem(item) {
if (isUsableStem(item?.stem)) return normalizeStem(item.stem);
const fromCard = stemFromHtmlBlock(item?._card || "");
return isUsableStem(fromCard) ? normalizeStem(fromCard) : "";
}
function examStemKey(stem, cardHtml) {
const s = harvestItemStem({ stem, _card: cardHtml });
return s || "";
}
function lookupStemAnswer(stemBag, stem) {
const bag = stemBag || {};
const sk = normalizeStem(stem);
if (!sk) return "";
if (bag[sk]) return parseAnswerLetters(bag[sk]);
const p = imgStemPrimary(sk);
if (!p) return "";
for (const [k, letter] of Object.entries(bag)) {
if (imgStemPrimary(k) === p) return parseAnswerLetters(letter);
}
return "";
}
function normalizeStem(text) {
const raw = String(text || "");
if (/^__IMG__/i.test(raw.trim())) return raw.trim().slice(0, 480);
return raw
.replace(/<[^>]+>/g, "")
.replace(/ /gi, " ")
.replace(/&[a-z]+;/gi, "")
.replace(/^\s*[\[\u3010][^\]\u3011]+[\]\u3011]\s*/g, "") // [判断题]【单选题】
.replace(/^(\u5224\u65ad\u9898|\u5355\u9009\u9898|\u591a\u9009\u9898|\u586b\u7a7a\u9898|\u7b80\u7b54\u9898)/, "")
.replace(/\s+/g, "")
.replace(/^\d+[\.\u3001\uff0e]/g, "")
.replace(/^\d+(?=[\u4e00-\u9fff])/, "") // 1对个人… → 对个人…
.replace(/\u7ea0\u9519$/g, "")
.slice(0, 240);
}
function extractImgUrlsFromHtml(html) {
const urls = [];
for (const m of String(html || "").matchAll(/
![]()
]+src=["']([^"']+)["']/gi)) {
const u = String(m[1] || "").trim();
if (u && !/^data:/i.test(u)) urls.push(u);
}
return [...new Set(urls)];
}
function stemFromHtmlBlock(block) {
const textStem = normalizeStem(extractStemFromQuestionBlock(block));
if (isUsableStem(textStem)) return textStem;
const titleM = String(block || "").match(/title-img-ctr[\s\S]*?<\/div>/i);
const stemImgs = titleM ? extractImgUrlsFromHtml(titleM[0]) : [];
const navM = String(block || "").match(/my-work-nav[\s\S]*?<\/div>\s*<\/div>/i);
const navImgs = navM ? extractImgUrlsFromHtml(navM[0]) : [];
const imgs = stemImgs.length ? stemImgs : navImgs.length ? navImgs : extractImgUrlsFromHtml(block).slice(0, 4);
if (imgs.length) return `__IMG__${imgs.join("|")}`;
return textStem;
}
function optionTextFromHtml(raw) {
const text = normalizeOptionText(raw);
if (text && text.length > 0 && !/^__IMG__/.test(text)) return text;
const imgs = extractImgUrlsFromHtml(raw);
if (imgs.length) return `__IMG__${imgs[0]}`;
return text || "";
}
function normalizeOptionText(text) {
const raw = String(text || "");
if (/^__IMG__/i.test(raw.trim())) return raw.trim().slice(0, 300);
const plain = raw
.replace(/<[^>]+>/g, "")
.replace(/ /gi, " ")
.replace(/&[a-z]+;/gi, "")
.replace(/\s+/g, "")
.slice(0, 200);
if (plain) return plain;
const imgs = extractImgUrlsFromHtml(raw);
if (imgs.length) return `__IMG__${imgs[0]}`;
return "";
}
function parseAnswerLetters(raw) {
const s = String(raw || "").trim().toUpperCase();
return [...new Set([...s].filter((c) => c >= "A" && c <= "Z"))].sort().join("");
}
function getStudyMode() {
return "efficiency";
}
function setStudyMode(_mode) {
state.studyMode = "efficiency";
localStorage.setItem(STUDY_MODE_KEY, "efficiency");
}
function isRealtime() {
return getStudyMode() === "realtime";
}
function studyModeLabel(mode) {
const m = mode || getStudyMode();
if (m === "realtime") return "1:1 \u65f6\u957f\u6162\u5237";
if (m === "efficiency") return "\u6548\u7387\u6a21\u5f0f";
return "\u672a\u9009\u62e9";
}
function parseTimeToSec(text) {
const s = String(text || "").trim();
if (!s) return 0;
const parts = s.split(":").map((x) => parseInt(x, 10));
if (parts.some((n) => !Number.isFinite(n))) return 0;
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
return parts[0] || 0;
}
function formatSec(sec) {
const n = Math.max(0, Math.round(Number(sec) || 0));
const m = Math.floor(n / 60);
const s = n % 60;
return `${m}:${String(s).padStart(2, "0")}`;
}
function formEncode(obj) {
return Object.keys(obj)
.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(obj[k] == null ? "" : obj[k])}`)
.join("&");
}
function fromHeader() {
const u = state.user || {};
const ut = u.UserType != null ? String(u.UserType) : "6";
const aid = u.autoid || u.userId || state.studentId || "";
return aid ? `${ut}-${aid}` : "";
}
function gmRequest(url, opts = {}) {
return new Promise((resolve, reject) => {
if (typeof GM_xmlhttpRequest !== "function") {
reject(new Error("GM_xmlhttpRequest \u4e0d\u53ef\u7528"));
return;
}
GM_xmlhttpRequest({
method: opts.method || "GET",
url,
headers: opts.headers || {},
data: opts.data,
timeout: opts.timeout || 30000,
onload: (res) => resolve(res),
onerror: () => reject(new Error("\u7f51\u7edc\u9519\u8bef")),
ontimeout: () => reject(new Error("\u8bf7\u6c42\u8d85\u65f6")),
});
});
}
async function fetchTextCross(url) {
try {
const res = await fetch(url, { method: "GET", credentials: "omit" });
if (res.ok) return await res.text();
} catch (_) {}
const res = await gmRequest(url, { method: "GET" });
if (res.status >= 200 && res.status < 300) return String(res.responseText || "");
throw new Error(`\u62c9\u53d6\u5931\u8d25 HTTP ${res.status}`);
}
async function platformPost(path, data, opts = {}) {
const headers = {
Accept: "application/json, text/javascript, */*; q=0.01",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
};
const fh = fromHeader();
if (fh) headers.from = fh;
const res = await fetch(ORIGIN + path, {
method: "POST",
credentials: "include",
headers,
body: formEncode(data || {}),
keepalive: !!opts.keepalive,
});
const text = await res.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch (_) {
json = null;
}
return { ok: res.ok, status: res.status, text, json };
}
async function platformGetHtml(path) {
const headers = { Accept: "text/html,application/xhtml+xml,*/*" };
const fh = fromHeader();
if (fh) headers.from = fh;
const res = await fetch(ORIGIN + path, {
method: "GET",
credentials: "include",
headers,
});
const text = await res.text();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return text;
}
async function fetchUserInfo() {
let r = await platformPost("/Data/Users.ashx", { ac: "search" });
if (!r.json?.status || !r.json?.msg || typeof r.json.msg !== "object") {
r = await platformPost("/Data/Users.ashx", { ac: "GetUserInfoById" });
}
if (!r.json?.status || !r.json?.msg || typeof r.json.msg !== "object") {
throw new Error("\u672a\u767b\u5f55\u6216\u83b7\u53d6\u7528\u6237\u5931\u8d25");
}
state.user = r.json.msg;
state.schoolId = String(state.user.RegSchoolId || state.user.ExtId || "").trim();
try {
localStorage.setItem(USER_CACHE_KEY, JSON.stringify(state.user));
} catch (_) {}
return state.user;
}
async function pingUserKeepAlive() {
try {
await platformPost("/Data/Users.ashx", { ac: "search" });
} catch (_) {}
}
async function ensureStudentId(force) {
if (!force && state.studentId) return state.studentId;
const fromDom =
document.getElementById("h_StudentId")?.value ||
document.querySelector('input[name="h_StudentId"]')?.value ||
"";
if (fromDom) {
state.studentId = String(fromDom).trim();
localStorage.setItem(STUDENT_CACHE_KEY, state.studentId);
return state.studentId;
}
if (!state.courses.length) await fetchCourseList().catch(() => {});
const c0 = state.courses[0];
if (c0) {
const html = await platformGetHtml(buildPlayPath(c0.professionalId, c0.courseId, c0.lastVideoId || ""));
const m = html.match(/id=["']h_StudentId["'][^>]*value=["']([^"']*)["']/i);
const sid = m?.[1] ? String(m[1]).trim() : "";
if (sid) {
state.studentId = sid;
localStorage.setItem(STUDENT_CACHE_KEY, sid);
const apply = html.match(/id=["']hid_ApplyProfession["'][^>]*value=["']([^"']*)["']/i);
if (apply?.[1]) state.professionalId = String(apply[1]).trim();
return sid;
}
}
throw new Error("\u65e0\u6cd5\u83b7\u53d6 StudentId\uff0c\u8bf7\u5148\u6253\u5f00\u4efb\u610f\u64ad\u653e\u9875\u518d\u5237\u65b0\u52a9\u624b");
}
function buildPlayPath(professionalId, courseId, videoId) {
const pid = professionalId || state.professionalId || "";
const vid = videoId || "0";
return `/University/course/play.aspx?ProfessionalID=${encodeURIComponent(pid)}&CurriculumID=${encodeURIComponent(courseId)}&VideoCollectionID=${encodeURIComponent(vid)}`;
}
function courseKey(c) {
return `${c.professionalId}_${c.courseId}`;
}
async function fetchCourseList() {
if (!state.user) await fetchUserInfo();
const schoolId = state.schoolId || state.user?.RegSchoolId || state.user?.ExtId || "";
if (!schoolId) throw new Error("\u7f3a\u5c11 schoolId");
const r = await platformPost("/data/mycourselist.ashx", {
ac: "search",
ac_type: "simple",
iscomplete: "false",
schoolid: schoolId,
});
if (!r.json?.status) throw new Error(r.json?.msg || "\u8bfe\u7a0b\u5217\u8868\u5931\u8d25");
const groups = Array.isArray(r.json.data) ? r.json.data : [];
const list = [];
for (const g of groups) {
const pro = g.Professional || {};
const professionalId = String(pro.Id || "").trim();
if (professionalId && !state.professionalId) state.professionalId = professionalId;
const currs = Array.isArray(g.Curriculums) ? g.Curriculums : [];
for (const cur of currs) {
list.push({
professionalId,
professionalName: String(pro.Name || "").trim(),
courseId: String(cur.Id || "").trim(),
courseName: String(cur.Name || "\u672a\u547d\u540d\u8bfe\u7a0b").trim(),
studyProgress: Number(cur.StudyProgress) || 0,
lastVideoId: String(cur.LastVideoId || "0"),
semester: cur.Semester,
endTime: g.EndTime || "",
});
}
}
state.courses = list.filter((c) => c.courseId);
const valid = new Set(state.courses.map(courseKey));
state.selected = new Set([...state.selected].filter((k) => valid.has(k)));
if (localStorage.getItem(QUEUE_TOUCHED_KEY) !== "1" && !state.selected.size) {
state.courses.forEach((c) => {
if ((c.studyProgress || 0) < 100) state.selected.add(courseKey(c));
});
saveQueue();
}
return state.courses;
}
function normalizeCourseName(s) {
return String(s || "")
.replace(/\s+/g, "")
.trim();
}
function isCourseFullyDone(c) {
if (!c) return false;
const prog = Math.round(Number(c.studyProgress) || 0) >= COURSE_DONE_PROGRESS;
const hw = !!c.hwScoreKnown && Number(c.hwScore) >= COURSE_DONE_HW_SCORE;
const ex = !!c.examScoreKnown && Number(c.examScore) >= COURSE_DONE_EXAM_SCORE;
return prog && hw && ex;
}
function pruneCompletedFromSelection() {
let changed = false;
for (const c of state.courses || []) {
const key = courseKey(c);
if (isCourseFullyDone(c) && state.selected.has(key)) {
state.selected.delete(key);
changed = true;
}
}
if (changed) saveQueue();
return changed;
}
function selectUnfinishedCourses() {
localStorage.setItem(QUEUE_TOUCHED_KEY, "1");
for (const c of state.courses || []) {
const key = courseKey(c);
if (isCourseFullyDone(c)) state.selected.delete(key);
else state.selected.add(key);
}
saveQueue();
renderCourseList();
renderRunStatus();
void refreshChapterPreview();
}
function clearCourseSelection() {
localStorage.setItem(QUEUE_TOUCHED_KEY, "1");
state.selected.clear();
saveQueue();
renderCourseList();
renderRunStatus();
void refreshChapterPreview();
}
function formatCourseScore(n, known) {
if (!known) return "—";
const v = Number(n);
if (!Number.isFinite(v)) return "—";
return String(Math.round(v));
}
async function refreshCourseDisplay() {
try {
await fetchCourseList();
await ensureStudentId().catch(() => {});
if (state.user) {
await Promise.all([fetchHomeworkList().catch(() => []), fetchExamList().catch(() => [])]);
}
const hwMap = new Map();
const hwNameMap = new Map();
for (const w of state.homeworks || []) {
const score = Number(w.results) || 0;
for (const id of [w.curriculumId, w.courseId]) {
const key = String(id || "").trim();
if (!key) continue;
hwMap.set(key, Math.max(hwMap.get(key) || 0, score));
}
const name = normalizeCourseName(w.courseName);
if (name) hwNameMap.set(name, Math.max(hwNameMap.get(name) || 0, score));
}
const exMap = new Map();
const exNameMap = new Map();
for (const ex of state.exams || []) {
const score = Math.max(Number(ex.score) || 0, Number(ex.bestScore) || 0);
const id = String(ex.courseId || "").trim();
if (id) exMap.set(id, Math.max(exMap.get(id) || 0, score));
const name = normalizeCourseName(ex.courseTitle);
if (name) exNameMap.set(name, Math.max(exNameMap.get(name) || 0, score));
}
for (const c of state.courses) {
const cid = String(c.courseId || "").trim();
const cname = normalizeCourseName(c.courseName);
let hw = hwMap.get(cid);
let hwKnown = hw != null;
if (!hwKnown && cname && hwNameMap.has(cname)) {
hw = hwNameMap.get(cname);
hwKnown = true;
}
let ex = exMap.get(cid);
let exKnown = ex != null;
if (!exKnown && cname && exNameMap.has(cname)) {
ex = exNameMap.get(cname);
exKnown = true;
}
c.hwScore = hwKnown ? Number(hw) || 0 : 0;
c.hwScoreKnown = hwKnown;
c.examScore = exKnown ? Number(ex) || 0 : 0;
c.examScoreKnown = exKnown;
}
pruneCompletedFromSelection();
renderCourseList();
} catch (e) {
trace(`refreshCourseDisplay: ${e.message || e}`);
}
}
function startCourseAutoRefresh() {
if (state.courseRefreshTimer) return;
state.courseRefreshTimer = setInterval(() => {
void refreshCourseDisplay();
}, 20000);
}
function parseChaptersFromHtml(html, course) {
const chapters = [];
const re = /
([\s\S]*?)<\/li>/gi;
let m;
while ((m = re.exec(html))) {
const block = m[1];
const hrefM = block.match(
/play\.html\?CurriculumID=(\d+)&VideoCollectionID=(\d+)&ProfessionalID=(\d+)/i
);
if (!hrefM) continue;
const titleM =
block.match(/chsTitle[\s\S]*?title=["']([^"']+)["']/i) ||
block.match(/]+id=["']\d+["'][^>]*title=["']([^"']+)["']/i);
const pctM = block.match(/percentTags[\s\S]*?title=["'](\d+)%["']/i);
const timeM = block.match(/timeLength[\s\S]*?\s*([^<]*?)\s*<\/span>/i);
const hoursM = block.match(/hoursma[^>]*>([^<]+) {
const m =
html.match(new RegExp(`id=["']${id}["'][^>]*value=["']([^"']*)["']`, "i")) ||
html.match(new RegExp(`name=["']${id}["'][^>]*value=["']([^"']*)["']`, "i"));
return m?.[1] != null ? String(m[1]) : "";
};
let videoUrl = pick("h_aliyunVideoUrl");
try {
videoUrl = videoUrl ? decodeURIComponent(videoUrl) : "";
} catch (_) {}
const sid = pick("h_StudentId");
const apply = pick("hid_ApplyProfession");
const name = pick("hid_VideoCollectionName");
const limitToken = pick("hidLimitToken");
return { videoUrl, studentId: sid, professionalId: apply, videoName: name, limitToken };
}
async function fetchPlayPageMeta(chapter) {
const html = await platformGetHtml(
buildPlayPath(chapter.professionalId, chapter.courseId, chapter.videoId)
);
const meta = extractPlayPageMeta(html);
if (meta.studentId) {
state.studentId = meta.studentId;
localStorage.setItem(STUDENT_CACHE_KEY, state.studentId);
}
if (meta.professionalId && meta.professionalId !== "0") {
state.professionalId = meta.professionalId;
}
return { html, meta };
}
function parseM3u8Duration(text, baseUrl) {
const raw = String(text || "");
if (!raw) return 0;
if (/#EXTINF:/i.test(raw)) {
const secs = [...raw.matchAll(/#EXTINF:([\d.]+)/gi)].map((x) => parseFloat(x[1]));
const sum = secs.reduce((a, b) => a + (Number.isFinite(b) ? b : 0), 0);
return sum > 0 ? Math.round(sum * 1e6) / 1e6 : 0;
}
const lines = raw
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !l.startsWith("#"));
return { _follow: lines[0] ? new URL(lines[0], baseUrl).href : "" };
}
async function durationFromM3u8(m3u8Url) {
if (!m3u8Url) return 0;
const text = await fetchTextCross(m3u8Url);
const parsed = parseM3u8Duration(text, m3u8Url);
if (typeof parsed === "number") return parsed;
if (parsed && parsed._follow) {
const text2 = await fetchTextCross(parsed._follow);
const d = parseM3u8Duration(text2, parsed._follow);
return typeof d === "number" ? d : 0;
}
return 0;
}
async function fetchCourseChapters(course) {
const seedVid = course.lastVideoId && course.lastVideoId !== "0" ? course.lastVideoId : "";
const html = await platformGetHtml(buildPlayPath(course.professionalId, course.courseId, seedVid || "0"));
const meta = extractPlayPageMeta(html);
if (meta.studentId) {
state.studentId = meta.studentId;
localStorage.setItem(STUDENT_CACHE_KEY, state.studentId);
}
if (meta.professionalId && meta.professionalId !== "0") {
state.professionalId = meta.professionalId;
}
return parseChaptersFromHtml(html, course);
}
async function loadVideoProgress(courseId, videoId) {
const studentId = await ensureStudentId();
const r = await platformPost("/data/publicdata.ashx", {
ac: "search",
ac_type: "user_video_progress",
StudentId: studentId,
CourseId: courseId,
VideoId: videoId,
});
if (!r.json?.status || !r.json?.msg || typeof r.json.msg !== "object") {
return { now: 0, count: 0, url: "" };
}
const msg = r.json.msg;
let url = String(msg.url || "");
try {
if (url) url = decodeURIComponent(url);
} catch (_) {}
return {
now: Number(msg.now) || 0,
count: Number(msg.count) || 0,
url,
};
}
async function resolveMaxDuration(chapter) {
if (chapter.durationSec > 0) {
return { duration: chapter.durationSec, source: "\u76ee\u5f55" };
}
const prog = await loadVideoProgress(chapter.courseId, chapter.videoId);
if (prog.count > 0) {
return { duration: prog.count, source: "\u8fdb\u5ea6\u63a5\u53e3", prog };
}
if (prog.url) {
try {
const d = await durationFromM3u8(prog.url);
if (d > 0) return { duration: d, source: "\u8fdb\u5ea6m3u8", prog };
} catch (e) {
trace(`m3u8\u65f6\u957f\u5931\u8d25\uff1a${e.message || e}`);
}
}
const html = await platformGetHtml(
buildPlayPath(chapter.professionalId, chapter.courseId, chapter.videoId)
);
const meta = extractPlayPageMeta(html);
if (meta.studentId) {
state.studentId = meta.studentId;
localStorage.setItem(STUDENT_CACHE_KEY, state.studentId);
}
if (meta.videoUrl) {
const d = await durationFromM3u8(meta.videoUrl);
if (d > 0) return { duration: d, source: "\u64ad\u653e\u9875m3u8", prog };
}
return { duration: 0, source: "", prog };
}
async function setLearningRecord({ videoId, currPlay, maxDuration, courseId, professionalId, rate, limitToken }) {
const body = {
ac: "edit",
InfoId: videoId,
currPlay: Number(currPlay) || 0,
MaxDuration: Number(maxDuration) || 0,
vType: 1,
CourseId: courseId,
rate: rate == null ? 0 : rate,
limitToken:
String(limitToken || "").trim() ||
document.getElementById("hidLimitToken")?.value ||
"",
obj_Professionalid: professionalId || state.professionalId || "",
};
const r = await platformPost("/data/LearningRecord.ashx", body, { keepalive: true });
if (!r.ok) throw new Error(`LearningRecord HTTP ${r.status}`);
if (r.json && r.json.status === true && r.json.msg === true) return r;
if (r.json && r.json.status === true) return r;
throw new Error(`LearningRecord \u5931\u8d25\uff1a${r.text || "\u65e0\u54cd\u5e94"}`);
}
function isVideoDone(now, count, percent) {
if (percent != null && percent >= 85) return true;
if (!(count > 0)) return false;
return now + 0.5 >= count * DONE_RATIO;
}
function finishPlaySec(maxDuration) {
const d = Number(maxDuration) || 0;
if (!(d > 0)) return 0;
return Math.round((d + 0.05) * 1e6) / 1e6;
}
async function consumeFreeVideo(_chapter) {
}
async function fetchHomeworkList() {
const r = await platformPost("/data/CurriculumWork.ashx", { ac: "mycurriculumwork" });
if (!r.json?.status) throw new Error(r.json?.msg || "\u4f5c\u4e1a\u5217\u8868\u5931\u8d25");
const list = Array.isArray(r.json.msg) ? r.json.msg : [];
state.homeworks = list.map((w) => ({
courseId: String(w.CourseId || ""),
curriculumId: String(w.CurriculumId || ""),
professionId: String(w.ProfessionId || ""),
courseName: String(w.CourseName || ""),
sectionId: String(w.SectionId || ""),
sectionName: String(w.SectionName || ""),
results: Number(w.Results) || 0,
workId: String(w.WorkId || ""),
tikuId: String(w.TikuId || ""),
ids: Array.isArray(w.Ids) ? w.Ids.map(String) : [],
lockCount: Number(w.studentpapersIsLockCount) || 0,
}));
return state.homeworks;
}
function homeworkPageUrl(w) {
return `/User/Student/myhomework.aspx?start=1&ProfessionalID=${encodeURIComponent(w.professionId)}&CurriculumID=${encodeURIComponent(w.courseId)}&VideoCollectionID=${encodeURIComponent(w.sectionId)}`;
}
function homeworkAfterUrl(w, spId) {
const sid = spId || (w.ids && w.ids[0]) || "";
return `/User/Student/myhomework_after.aspx?spId=${encodeURIComponent(sid)}&VideoCollectionID=${encodeURIComponent(w.sectionId)}&CurriculumID=${encodeURIComponent(w.courseId)}&ProfessionalID=${encodeURIComponent(w.professionId)}&StuId=${encodeURIComponent(state.studentId || "")}`;
}
function hwCacheKey(w) {
return `${w.courseId || ""}_${w.sectionId || ""}`;
}
function rememberHwAnswers(w, items) {
const key = hwCacheKey(w);
if (!state.hwAnswerCache[key]) state.hwAnswerCache[key] = {};
for (const it of items || []) {
const qid = String(it.qid || "").trim();
const letter = parseAnswerLetters(it.answer_letter || it.letter || "");
if (qid && letter) state.hwAnswerCache[key][qid] = letter;
}
}
function answersFromHwCache(w, questions) {
const cache = state.hwAnswerCache[hwCacheKey(w)] || {};
const answers = {};
let hit = 0;
for (const q of questions || []) {
const letter = cache[String(q.qid || "")];
if (!letter) continue;
answers[q.qid] = q.multi ? [...letter].join(",") : letter[0];
hit += 1;
}
return { answers, hit };
}
function extractStemFromQuestionBlock(block) {
const patterns = [
/exam_question_title[\s\S]*?\s*\[[^\]]*\]\s*<\/strong>\s*([\s\S]*?)<\/div>/i,
/exam_question_title[\s\S]*?\s*(?:
\s*\[[^\]]*\]\s*<\/strong>)?\s*([\s\S]*?)\s*<\/div>/i,
/question_number[^>]*>[\s\S]*?<\/span>\s*([\s\S]*?)<\/div>/i,
/
\s*\[[^\]]*\]\s*<\/strong>\s*([^<]{4,240})/i,
/exam_question_title[^>]*>([\s\S]*?)<\/div>\s* {
if (!qid || seen.has(qid)) return;
seen.add(qid);
const imgs = imageUrls && imageUrls.length ? imageUrls : extractImgUrlsFromStem(stem);
out.push({
qid,
stem: normalizeStem(stem || ""),
options: options.length
? options
: [
{ letter: "A", text: "\u5bf9" },
{ letter: "B", text: "\u9519" },
],
multi: !!multi,
imageUrls: imgs,
});
};
function extractImgUrlsFromStem(stem) {
const s = String(stem || "");
if (!/^__IMG__/i.test(s)) return [];
return s
.replace(/^__IMG__/i, "")
.split("|")
.map((u) => u.trim())
.filter((u) => /^https?:\/\//i.test(u));
}
const re = /
]*>([\s\S]*?)(?=
]*type=["'](radio|checkbox)["'][^>]*>/gi)].map((x) => x[0]);
if (!inputs.length) continue;
const qid = (inputs[0].match(/name=["'](\d+)["']/i) || [])[1];
const multi =
/multi_select/i.test(block) || inputs.some((tag) => /type=["']checkbox["']/i.test(tag));
const options = [];
const seenLetter = new Set();
for (const tag of inputs) {
const letter = (tag.match(/value=["']([A-Z])["']/i) || [])[1];
if (!letter || seenLetter.has(letter)) continue;
seenLetter.add(letter);
const idx = block.indexOf(tag);
const slice = block.slice(Math.max(0, idx - 60), idx + 220);
const txtM =
slice.match(/select_detail[^>]*>([\s\S]*?)<\/div>/i) ||
slice.match(/<\/em>\s*([\s\S]*?)<\/li>/i) ||
slice.match(/m[a-z-]*10[^>]*>[A-Z]<\/span>\s*([^<]+)/i);
options.push({
letter: letter.toUpperCase(),
text: optionTextFromHtml(txtM?.[1] || slice),
});
}
pushQ(qid, stemFromHtmlBlock(block), options, multi);
}
if (!out.length) {
const cards = String(html || "").split(MY_WORK_DIV_OPEN_RE);
for (let i = 1; i < cards.length; i++) {
const card = cards[i];
const radios = [
...card.matchAll(/
]*type=["'](radio|checkbox)["'][^>]*>/gi),
].map((x) => x[0]);
if (!radios.length) continue;
const qid = (radios[0].match(/name=["'](\d+)["']/i) || [])[1];
const multi = radios.some((tag) => /type=["']checkbox["']/i.test(tag));
const options = [];
for (const tag of radios) {
const letter = (tag.match(/value=["']([A-Z])["']/i) || [])[1];
if (!letter) continue;
const idx = card.indexOf(tag);
const slice = card.slice(Math.max(0, idx - 40), idx + 320);
const txtM =
slice.match(/
\s*([\s\S]*?)\s*<\/div>/i) ||
slice.match(/col-md-10[^>]*>\s*([\s\S]*?)\s*<\/div>/i);
options.push({
letter: letter.toUpperCase(),
text: optionTextFromHtml(txtM?.[1] || slice),
});
}
pushQ(qid, stemFromHtmlBlock(card), options, multi);
}
}
return out;
}
function isJunkStem(stem) {
const s = String(stem || "");
return /\u7b2c\d+\u6b21\s*[\(\uff08]?\d+[\)\uff09]?/.test(s) || /\u5386\u53f2\u8bb0\u5f55|\u91cd\u65b0\u8003\u8bd5|\u7b54\u9898\u5361/.test(s);
}
function isUsableStem(stem) {
const s = String(stem || "").trim();
if (!s || s.length < 4) return false;
if (isJunkStem(s)) return false;
if (/^qid:\d+$/i.test(s)) return false;
if (/^__IMG__https?:\/\//i.test(s)) return true;
if (/^\[\u5224\u65ad\u9898\]|^\[\u5355\u9009\u9898\]|^\[\u591a\u9009\u9898\]/.test(s)) return false;
return /[\u4e00-\u9fff]/.test(s);
}
function extractOptionTextsFromBlock(block, letter) {
const want = new Set([...parseAnswerLetters(letter)]);
if (!want.size) return "";
const out = [];
const push = (L, raw) => {
const l = String(L || "").toUpperCase();
const t = normalizeOptionText(raw);
if (!want.has(l) || !t || t === l) return;
out.push(t);
};
for (const m of String(block || "").matchAll(
/
\s*[A-Z]\s*<\/span>[\s\S]*?
\s*([\s\S]*?)\s*<\/div>/gi
)) {
push(m[1], m[2]);
}
for (const m of String(block || "").matchAll(
/
([A-Z])<\/span>[\s\S]*?([\s\S]*?)<\/div>/gi
)) {
push(m[1], m[2]);
}
for (const m of String(block || "").matchAll(
/value=["']([A-Z])["'][\s\S]*?
\s*([\s\S]*?)\s*<\/div>/gi
)) {
push(m[1], m[2]);
}
for (const m of String(block || "").matchAll(
/
([A-Z])<\/span>\s*([^<]+)/gi
)) {
push(m[1], m[2]);
}
return [...new Set(out)].join("|||");
}
function answerTextForUpsert(qOrCard, letter, answerText) {
const text = String(answerText || "").trim();
if (text && !/^__LETTER__/i.test(text)) return text;
if (qOrCard && Array.isArray(qOrCard.options)) {
const letters = parseAnswerLetters(letter);
const parts = [];
for (const L of letters) {
const opt = qOrCard.options.find((o) => o.letter === L);
if (opt?.text) parts.push(normalizeOptionText(opt.text));
}
if (parts.length) return parts.join("|||");
}
if (typeof qOrCard === "string") {
const fromBlock = extractOptionTextsFromBlock(qOrCard, letter);
if (fromBlock) return fromBlock;
}
const letters = parseAnswerLetters(letter);
return letters ? `__LETTER__${letters}` : text;
}
function parseAfterAnswers(html) {
const items = [];
const seen = new Set();
const pushItem = (stem, letter, qid, answerText, cardHtml) => {
if (!letter) return;
const usable = isUsableStem(stem);
if (!usable && !qid) return;
const key = (usable ? stem : `qid:${qid}`) + "|" + letter;
if (seen.has(key)) return;
seen.add(key);
const stemKey = harvestItemStem({ stem, _card: cardHtml });
items.push({
qid: qid || "",
stem: stemKey || (usable ? normalizeStem(stem) : ""),
answer_letter: letter,
answer_text: answerText || "",
_card: cardHtml || "",
});
};
const optionTextFromCard = (card, letter) => extractOptionTextsFromBlock(card, letter);
const cards = String(html || "").split(MY_WORK_DIV_OPEN_RE);
for (let i = 1; i < cards.length; i++) {
const card = cards[i];
const stemM =
card.match(/title-img-ctr[^>]*>[\s\S]*?<\/div>/i) ||
card.match(/col-md-10[^>]*>\s*([\s\S]*?)\s*<\/div>/i) ||
card.match(/my-work-nav[\s\S]*?]*>\s*\d+[\u3001.\uff0e]\s*([\s\S]*?)\s*<\/div>/i);
const stem = stemFromHtmlBlock(card);
const ansM =
card.match(/(?:\u53c2\u8003|\u6b63\u786e)\u7b54\u6848\s*[:\uff1a]\s*
]*>\s*([A-Z,]+)\s*<\/span>/i) ||
card.match(/(?:\u53c2\u8003|\u6b63\u786e)\u7b54\u6848\s*[:\uff1a]\s*([A-Z,]+)/i);
const letter = parseAnswerLetters(ansM?.[1] || "");
const qidM = card.match(/name=['"](\d+)['"]/i);
pushItem(stem, letter, qidM?.[1] || "", optionTextFromCard(card, letter), card);
}
if (!items.length) {
const parts = String(html || "").split(//i);
for (let i = 1; i < parts.length; i++) {
const ansPart = parts[i];
const before = parts[i - 1] || "";
const ansM = ansPart.match(/(?:\u53c2\u8003|\u6b63\u786e)\u7b54\u6848\s*[:\uff1a]\s*
]*>\s*([A-Z,]+)\s*<\/span>/i);
const letter = parseAnswerLetters(ansM?.[1] || "");
if (!letter) continue;
const stemM = before.match(/col-md-10[^>]*>\s*([\s\S]*?)\s*<\/div>/i);
let stem = normalizeStem(stemM?.[1] || "");
if (!isUsableStem(stem)) {
const texts = [...before.matchAll(/>([\u4e00-\u9fff][^<]{6,200}) normalizeStem(x[1]))
.filter((s) => isUsableStem(s) && !/^(\u5bf9|\u9519|\u6b63\u786e|\u9519\u8bef|\u662f|\u5426)$/.test(s));
stem = texts.length ? texts[texts.length - 1] : "";
}
const qidM = before.match(/name=['"](\d+)['"]/i);
const block = before.slice(-2000) + ansPart.slice(0, 500);
pushItem(stem, letter, qidM?.[1] || "", optionTextFromCard(block, letter), block);
}
}
return items;
}
async function harvestHomeworkAnswers(w) {
const spId = w.ids?.[0];
if (!spId) return 0;
const html = await platformGetHtml(homeworkAfterUrl(w, spId));
const answers = parseAfterAnswers(html).filter((a) => a.answer_letter && (isUsableStem(a.stem) || a.qid));
if (!answers.length) {
trace(`\u300c${w.courseName}/${w.sectionName}\u300d\u6279\u9605\u9875\u672a\u89e3\u6790\u5230\u7b54\u6848\uff0c\u5df2\u8df3\u8fc7\u5165\u5e93`);
return 0;
}
rememberHwAnswers(w, answers);
const enriched = answers
.filter((a) => isUsableStem(a.stem))
.map((a) => ({
stem: a.stem,
answer_letter: a.answer_letter,
answer_text: answerTextForUpsert(a._card, a.answer_letter, a.answer_text),
paper_name: `${w.courseName}-${w.sectionName}`,
cour_no: w.courseId,
source: "ls365_after",
}));
const letterOnly = enriched.filter((a) => /^__LETTER__/i.test(a.answer_text)).length;
const saved = enriched.length ? await cloudQbankUpsert(enriched) : 0;
const cached = Object.keys(state.hwAnswerCache[hwCacheKey(w)] || {}).length;
trace(`\u9898\u5e93\u5165\u5e93 ${saved}/${enriched.length}\u300c${w.sectionName}\u300d\uff08\u672c\u673a\u7f13\u5b58 ${cached} \u9898\u53f7${letterOnly ? `\uff0c${letterOnly} \u9898\u4ec5\u5b57\u6bcd\u5360\u4f4d` : ""}\uff09`);
return saved;
}
function mapAnswersForQuestions(questions, lookupItems) {
const byStem = new Map();
const byImgPrimary = new Map();
for (const it of lookupItems || []) {
if (!it?.found) continue;
const sk = normalizeStem(it.stem);
byStem.set(sk, it);
const p = imgStemPrimary(sk);
if (p) byImgPrimary.set(p, it);
}
const answers = {};
let hit = 0;
for (const q of questions) {
const qStem = normalizeStem(q.stem);
let it = byStem.get(qStem);
if (!it) {
const p = imgStemPrimary(qStem);
if (p) it = byImgPrimary.get(p);
}
let letter = it ? parseAnswerLetters(it.answer_letter) : "";
if (!letter && it?.answer_text) {
const texts = String(it.answer_text).split("|||").map(normalizeOptionText);
const found = [];
for (const opt of q.options) {
if (texts.includes(opt.text) || texts.includes(normalizeOptionText(opt.letter))) {
found.push(opt.letter);
}
}
letter = found.sort().join("");
}
if (letter) {
answers[q.qid] = q.multi ? [...letter].join(",") : letter[0];
hit += 1;
}
}
return { answers, hit };
}
async function submitHomework(w, html, answerMap) {
const identityno =
(html.match(/id=["']identityno["'][^>]*value=["']([^"']*)["']/i) || [])[1] ||
(html.match(/id=["']identityNo["'][^>]*value=["']([^"']*)["']/i) || [])[1] ||
(html.match(/name=["']identityno["'][^>]*value=["']([^"']*)["']/i) || [])[1] ||
"";
const studentPaperId =
(html.match(/id=["']StudentPaperId["'][^>]*value=["']([^"']*)["']/i) || [])[1] ||
(html.match(/name=["']StudentPaperId["'][^>]*value=["']([^"']*)["']/i) || [])[1] ||
"";
const body = {
ac: "examsave",
professionid: w.professionId,
curriculumid: w.courseId,
vcid: w.sectionId,
identityno,
StudentPaperId: studentPaperId,
};
Object.assign(body, answerMap);
const r = await platformPost("/data/CurriculumWork.ashx", body);
if (r.json?.statu || r.json?.status) return r.json;
throw new Error(r.json?.msg || r.text || "\u4f5c\u4e1a\u63d0\u4ea4\u5931\u8d25");
}
function homeworkPageHasQuestions(html) {
if (!html) return false;
if (/identityno|StudentPaperId|ac=["']?examsave/i.test(html)) return true;
if (/]*type=["'](radio|checkbox)["'][^>]*name=["']\d+["']/i.test(html)) return true;
if (/exam_question|my-work-div/i.test(html) && /type=["'](radio|checkbox)["']/i.test(html)) return true;
return false;
}
async function runOneHomework(w) {
state.currentCourse = w.courseName;
state.currentChapter = w.sectionName;
state.currentTask = "\u5904\u7406\u4f5c\u4e1a…";
renderRunStatus();
if (!isPro()) {
throw new Error("\u4f5c\u4e1a\u9700 Pro\uff08\u514d\u8d39\u4ec5\u4f53\u9a8c\u8bfe\u65f6\uff09");
}
if (Number(w.results) >= HW_TARGET_SCORE && w.ids?.length) {
await harvestHomeworkAnswers(w);
trace(`\u8df3\u8fc7\u4f5c\u4e1a\u300c${w.sectionName}\u300d\uff1a\u5df2\u6ee1\u5206 ${w.results}`);
return { skipped: true, done: true, score: w.results };
}
if (Number(w.results) > 0 && w.ids?.length) {
await harvestHomeworkAnswers(w).catch(() => 0);
trace(`\u4f5c\u4e1a\u300c${w.sectionName}\u300d\u5f53\u524d ${w.results} \u5206\uff0c\u672a\u6ee1\u5206\u5c06\u5c1d\u8bd5\u91cd\u505a`);
}
let best = Number(w.results) || 0;
for (let round = 1; round <= HW_MAX_ROUNDS; round++) {
if (state.stopFlag) break;
if (best >= HW_TARGET_SCORE) break;
state.currentTask = `\u4f5c\u4e1a\u7b2c ${round} \u8f6e…`;
renderRunStatus();
const html = await platformGetHtml(homeworkPageUrl(w)).catch(() => "");
if (!homeworkPageHasQuestions(html)) {
if (best > 0) {
trace(`\u300c${w.sectionName}\u300d\u65e0\u6cd5\u6253\u5f00\u91cd\u505a\u9875\uff08\u53ef\u80fd\u5df2\u9501\u5377\uff09\uff0c\u4fdd\u6301 ${best} \u5206`);
} else {
trace(`\u300c${w.sectionName}\u300d\u672a\u6253\u5f00\u5230\u7b54\u9898\u9875\uff0c\u8df3\u8fc7`);
}
break;
}
const questions = parseHomeworkQuestions(html);
if (!questions.length) {
trace(`\u300c${w.sectionName}\u300d\u7b2c ${round} \u8f6e\u672a\u89e3\u6790\u5230\u9898\u76ee`);
break;
}
const emptyStem = questions.filter((q) => !q.stem).length;
if (emptyStem) trace(`\u300c${w.sectionName}\u300d\u7b2c ${round} \u8f6e\u6709 ${emptyStem} \u9898\u9898\u5e72\u672a\u89e3\u6790\u5230\uff0c\u5c06\u4f18\u5148\u7528\u9898\u53f7\u7f13\u5b58`);
const stems = questions.map((q) => q.stem).filter(Boolean);
const lookup = stems.length ? await cloudQbankLookup(stems) : [];
let { answers, hit } = mapAnswersForQuestions(questions, lookup);
const cached = answersFromHwCache(w, questions);
for (const [qid, letter] of Object.entries(cached.answers)) {
if (!answers[qid]) answers[qid] = letter;
}
const mergedHit = Object.keys(answers).length;
trace(
`\u4f5c\u4e1a\u300c${w.sectionName}\u300d\u7b2c ${round} \u8f6e\u547d\u4e2d ${mergedHit}/${questions.length}\uff08\u9898\u5e93 ${hit} + \u9898\u53f7\u7f13\u5b58 ${cached.hit}\uff09`
);
if (!mergedHit && best <= 0) {
trace(`\u300c${w.sectionName}\u300d\u6682\u65e0\u7b54\u6848\uff0c\u5148\u4ea4\u4e00\u7248\u7528\u4e8e\u5165\u5e93`);
} else if (!mergedHit && best > 0) {
trace(`\u300c${w.sectionName}\u300d\u9898\u5e93\u4e0e\u9898\u53f7\u7f13\u5b58\u5747\u65e0\u547d\u4e2d\uff0c\u505c\u6b62\u91cd\u505a`);
break;
}
for (const q of questions) {
if (!answers[q.qid]) answers[q.qid] = "A";
}
await submitHomework(w, html, answers);
await sleep(800);
const list = await fetchHomeworkList();
const fresh = list.find((x) => x.courseId === w.courseId && x.sectionId === w.sectionId) || w;
Object.assign(w, fresh);
if (fresh.ids?.length) {
await harvestHomeworkAnswers(fresh);
}
best = Math.max(best, Number(fresh.results) || 0);
trace(`\u4f5c\u4e1a\u300c${w.sectionName}\u300d\u7b2c ${round} \u8f6e\u6210\u7ee9 ${fresh.results || "—"}\uff08\u76ee\u6807 ${HW_TARGET_SCORE}\uff09`);
if (best >= HW_TARGET_SCORE) {
trace(`\u300c${w.sectionName}\u300d\u5df2\u8fbe\u6ee1\u5206`);
break;
}
await sleep(1000);
}
return { skipped: false, done: best >= HW_TARGET_SCORE, score: best };
}
function homeworkItemsForCourse(course, list) {
const cid = String(course.courseId || "");
const name = String(course.courseName || "");
return (list || []).filter(
(w) =>
String(w.curriculumId) === cid ||
String(w.courseId) === cid ||
(name && String(w.courseName || "") === name)
);
}
function examItemForCourse(course, list) {
const cid = String(course.courseId || "");
const name = String(course.courseName || "");
return (list || []).find(
(ex) => String(ex.courseId) === cid || (name && String(ex.courseTitle || "") === name)
);
}
async function processOneHomework(w) {
if (state.stopFlag) return;
const courseLabel = w.courseName || w.sectionName || "\u8bfe\u7a0b";
const hwLabel = w.sectionName || w.courseName || "\u4f5c\u4e1a";
await homeworkMutex(async () => {
if (Number(w.results) >= HW_TARGET_SCORE && w.ids?.length) {
log(`\u5f00\u59cb\u5904\u7406\u300c${courseLabel}\u300d\u4f5c\u4e1a\u300c${hwLabel}\u300d`);
try {
await harvestHomeworkAnswers(w);
log(`\u300c${courseLabel}\u300d\u4f5c\u4e1a\u300c${hwLabel}\u300d\u5df2\u7ecf\u5b8c\u6210 ${w.results}\u5206`);
} catch (e) {
log(`\u300c${courseLabel}\u300d\u4f5c\u4e1a\u300c${hwLabel}\u300d\u5165\u5e93\u5931\u8d25\uff1a${e.message || e}`);
}
return;
}
log(`\u5f00\u59cb\u5904\u7406\u300c${courseLabel}\u300d\u4f5c\u4e1a\u300c${hwLabel}\u300d`);
for (let attempt = 1; attempt <= 3; attempt++) {
if (state.stopFlag) return;
try {
const res = await runOneHomework(w);
const score = Number(res?.score ?? w.results) || 0;
log(`\u300c${courseLabel}\u300d\u4f5c\u4e1a\u300c${hwLabel}\u300d\u5df2\u7ecf\u5b8c\u6210 ${score}\u5206`);
return;
} catch (e) {
if (attempt < 3 && isPlatformTransientError(e)) {
trace(`\u4f5c\u4e1a\u91cd\u8bd5 ${attempt}/3\u300c${hwLabel}\u300d\uff1a${e.message || e}`);
await sleep(2000 * attempt);
continue;
}
log(`\u300c${courseLabel}\u300d\u4f5c\u4e1a\u300c${hwLabel}\u300d\u5931\u8d25\uff1a${e.message || e}`);
}
}
});
}
async function processOneExam(ex) {
if (state.stopFlag) return;
const exBest = Math.max(Number(ex.score) || 0, Number(ex.bestScore) || 0);
await examMutex(async () => {
if (examScoreSatisfied(exBest) || exBest >= EXAM_TARGET_SCORE) {
log(`\u5f00\u59cb\u5904\u7406\u300c${ex.courseTitle}\u300d\u8003\u8bd5`);
try {
await harvestExamIfViewable(ex, "");
} catch (e) {
trace(`\u300c${ex.courseTitle}\u300d\u8003\u8bd5\u5165\u5e93\u5931\u8d25\uff1a${e.message || e}`);
}
logExamDone(ex.courseTitle, exBest);
return;
}
log(`\u5f00\u59cb\u5904\u7406\u300c${ex.courseTitle}\u300d\u8003\u8bd5`);
for (let attempt = 1; attempt <= 3; attempt++) {
if (state.stopFlag) return;
try {
const res = await runOneExam(ex);
const best = Number(res?.score ?? exBest) || 0;
const latest = Number(res?.latestScore ?? best) || 0;
const showScore = res?.submitted || res?.skipped ? latest : Math.max(latest, best);
if (res?.submitted || res?.skipped) {
logExamDone(ex.courseTitle, showScore);
} else if (res) {
trace(
`\u300c${ex.courseTitle}\u300d\u8003\u8bd5\u672a\u4ea4\u5377${res?.reason ? `\uff1a${res.reason}` : ""}\uff08\u5e73\u53f0 ${showScore} \u5206\uff09`
);
logExamDone(ex.courseTitle, showScore);
}
return;
} catch (e) {
if (attempt < 3 && isPlatformTransientError(e)) {
trace(`\u8003\u8bd5\u91cd\u8bd5 ${attempt}/3\u300c${ex.courseTitle}\u300d\uff1a${e.message || e}`);
await sleep(3000 * attempt);
continue;
}
log(`\u300c${ex.courseTitle}\u300d\u8003\u8bd5\u5931\u8d25\uff1a${e.message || e}`);
}
}
});
}
async function runVideosForCourse(course) {
if (state.stopFlag) return;
const name = course.courseName || "\u8bfe\u7a0b";
const progPct = Math.round(Number(course.studyProgress) || 0);
if (progPct >= COURSE_DONE_PROGRESS) {
log(`\u300c${name}\u300d\u8bfe\u65f6\u5df2\u7ecf\u5b8c\u6210`);
return;
}
try {
await consumeFreeCourse(course);
} catch (e) {
trace(`skip course videos: ${e.message || e}`);
if (/\u514d\u8d39|quota|limit|\u989d\u5ea6/i.test(String(e.message || ""))) {
openProHint(String(e.message || "\u514d\u8d39\u989d\u5ea6\u5df2\u7528\u5b8c"));
}
return;
}
let chs = [];
try {
chs = await fetchCourseChapters(course);
} catch (e) {
trace(`\u52a0\u8f7d\u300c${name}\u300d\u7ae0\u8282\u5931\u8d25\uff1a${e.message || e}`);
return;
}
const todo = [];
for (const ch of chs) {
if (state.stopFlag) break;
try {
const prog = await loadVideoProgress(ch.courseId, ch.videoId);
ch._now = prog.now;
ch._count = prog.count || ch.durationSec;
if (!isVideoDone(ch._now, ch._count, ch.percent)) todo.push(ch);
} catch (_) {
if ((ch.percent || 0) < 85) todo.push(ch);
}
}
if (!todo.length) {
log(`\u300c${name}\u300d\u8bfe\u65f6\u5df2\u7ecf\u5b8c\u6210`);
return;
}
log(`\u5f00\u59cb\u5904\u7406\u300c${name}\u300d\u8bfe\u65f6\uff08${todo.length} \u4e2a\u89c6\u9891\uff09`);
state.queueTotal += todo.length;
renderRunStatus();
let doneCount = 0;
const total = todo.length;
await Promise.all(
todo.map(async (ch) => {
if (state.stopFlag) return;
try {
const result = await studyOneVideo(ch);
if (result.done) {
state.queueDone += 1;
doneCount += 1;
renderRunStatus();
renderChapterPreview();
if (doneCount >= total) log(`\u300c${name}\u300d\u8bfe\u65f6\u5df2\u7ecf\u5b8c\u6210`);
}
} catch (e) {
trace(`video fail\u300c${name}\u300d\uff1a${e.message || e}`);
}
})
);
}
async function runHomeworkForCourse(course, list) {
if (!state.autoHomework || !isPro() || state.stopFlag) return;
const queue = homeworkItemsForCourse(course, list);
if (!queue.length) return;
await Promise.all(queue.map((w) => processOneHomework(w)));
}
async function runExamForCourse(course, list) {
if (!state.autoExam || !isPro() || state.stopFlag) return;
const ex = examItemForCourse(course, list);
if (!ex) return;
await processOneExam(ex);
}
async function runOneCoursePipeline(course, hwList) {
await Promise.all([runVideosForCourse(course), runHomeworkForCourse(course, hwList)]);
}
async function runHomeworkForSelectedCourses(courses) {
if (!state.autoHomework) {
trace("\u5df2\u5173\u95ed\u81ea\u52a8\u4f5c\u4e1a");
return;
}
if (!isPro()) {
trace("\u81ea\u52a8\u4f5c\u4e1a\u9700 Pro\uff0c\u5df2\u8df3\u8fc7");
return;
}
await ensureStudentId().catch(() => {});
let list = [];
try {
list = await fetchHomeworkList();
} catch (e) {
trace("\u4f5c\u4e1a\u5217\u8868\u5931\u8d25\uff1a" + (e.message || e));
return;
}
const courseIds = new Set(courses.map((c) => c.courseId));
const todo = list.filter(
(w) => courseIds.has(w.curriculumId) || courseIds.has(w.courseId) || courses.some((c) => c.courseId === w.curriculumId)
);
const matched = list.filter((w) =>
courses.some((c) => String(c.courseId) === String(w.curriculumId) || c.courseName === w.courseName)
);
const queue = matched.length ? matched : todo;
if (!queue.length) {
trace("\u6240\u9009\u8bfe\u7a0b\u6682\u65e0\u4f5c\u4e1a");
return;
}
await Promise.all(queue.map((w) => processOneHomework(w)));
}
async function fetchExamList() {
const merge = new Map();
for (const finished of [1, 2]) {
const r = await platformPost("/data/StudentPapers.ashx", {
ac: "search",
pagetype: "myexam",
page: 1,
rows: 99,
finished,
subjectname: "",
});
const rows = Array.isArray(r.json?.rows) ? r.json.rows : [];
for (const row of rows) {
const courseId = String(row.CourseId || row.course_id || "");
if (!courseId) continue;
const item = {
autoId: String(row.autoid || ""),
courseId,
courseTitle: String(row.courseTitle || ""),
paperTitle: String(row.paperTitle || ""),
examId: String(row.exam_id || ""),
examType: String(row.pType || "422"),
studentId: String(row.StudentId || state.studentId || ""),
score: Number(row.LatestExamScores ?? row.ExamScores ?? row.examination) || 0,
bestScore: Number(row.ExamScores ?? row.LatestExamScores ?? row.examination) || 0,
allowExam: Number(row.IsAllowExam) === 1,
permitCount: Number(row.PromitExamCount) || 0,
takeTimes: Number(row.takeexamtimes ?? row.examcount) || 0,
examState: Number(row.exam_state) || 0,
};
const prev = merge.get(courseId);
if (!prev) {
merge.set(courseId, item);
} else {
merge.set(courseId, {
...prev,
...item,
score: Math.max(Number(prev.score) || 0, Number(item.score) || 0),
bestScore: Math.max(
Number(prev.bestScore) || 0,
Number(item.bestScore) || 0,
Number(prev.score) || 0,
Number(item.score) || 0
),
takeTimes: Math.max(Number(prev.takeTimes) || 0, Number(item.takeTimes) || 0),
});
}
}
}
state.exams = [...merge.values()];
return state.exams;
}
function examPageUrl(ex) {
const sid = ex.studentId || state.studentId || "";
return `/Examination/student/examing.aspx?studentid=${encodeURIComponent(sid)}&CurriculumID=${encodeURIComponent(ex.courseId)}&examtype=${encodeURIComponent(ex.examType || "422")}&`;
}
function examViewUrl(ex, examToken, viewtype) {
const vt = String(viewtype || "top").trim() || "top";
const vtQ = `viewtype=${encodeURIComponent(vt)}&`;
const token = examToken || "";
if (token) {
return `/University/User/Student/ViewPaper.aspx?examtoken=${encodeURIComponent(token)}&courseid=${encodeURIComponent(ex.courseId)}&viewtype=${encodeURIComponent(vt)}&`;
}
return `/University/User/Student/ViewPaper.aspx?pid=${encodeURIComponent(ex.autoId)}&${vtQ}exam_id=${encodeURIComponent(ex.examId)}&course=${encodeURIComponent(ex.courseId)}&`;
}
async function fetchBestExamViewPaper(ex, examToken) {
const empty = {
html: "",
label: "",
stats: { cardCount: 0, refCount: 0, parsedWithAns: 0 },
answers: [],
viewPaperScore: 0,
};
const token = String(examToken || "").trim();
let url = "";
let label = "";
if (token) {
url = examViewUrl(ex, token, "top");
label = "examtoken+top";
} else if (ex.autoId && ex.examId) {
url = examViewUrl(ex, "", "top");
label = "pid+top";
}
if (!url) return empty;
try {
const html = await platformGetHtml(url);
const stats = countViewPaperStats(html);
const answers = parseAfterAnswers(html).filter(
(a) => a.answer_letter && (isUsableStem(a.stem) || a.qid)
);
trace(`ViewPaper\u300c${label}\u300d${stats.parsedWithAns}/${stats.cardCount || stats.parsedWithAns} \u53c2\u8003\u7b54\u6848`);
return { html, label, stats, answers, viewPaperScore: parseViewPaperScore(html) };
} catch (e) {
trace(`ViewPaper \u5931\u8d25\uff1a${e.message || e}`);
return empty;
}
}
function parseExamPaperInfo(html) {
const asnM =
html.match(/id=["']asn-count["'][^>]*>\s*(\d+)\s*\/\s*(\d+)/i) ||
html.match(/asn-count[^>]*>\s*(\d+)\s*\/\s*(\d+)/i);
const cardTotal = asnM ? Number(asnM[2]) || 0 : 0;
const sections = [];
for (const m of html.matchAll(
/collection_toggle[^>]*>([\s\S]*?)]*>\uff08[^\uff09]*\u5171\s*(\d+)\s*\u9898[^\uff09]*\uff09/gi
)) {
const name = String(m[1] || "")
.replace(/<[^>]+>/g, "")
.replace(/\s+/g, "")
.trim();
if (name) sections.push({ name, count: Number(m[2]) || 0 });
}
const sectionSum = sections.reduce((s, x) => s + x.count, 0);
return { cardTotal: cardTotal || sectionSum, sections, sectionSum };
}
function parseViewPaperScore(html) {
const m = String(html || "").match(/class=["']i_score["'][^>]*>\s*(-?\d+(?:\.\d+)?)/i);
if (!m) return 0;
const n = Number(m[1]);
if (!Number.isFinite(n) || n < 0) return 0;
return Math.round(n);
}
function parseExamSubmitMsgScore(msg) {
const s = String(msg || "").trim();
if (!s || s === "\u4fdd\u5b58\u6210\u529f") return 0;
const m = s.match(/(\d+(?:\.\d+)?)\s*\u5206/) || s.match(/^(\d+(?:\.\d+)?)$/);
return m ? Math.round(Number(m[1])) : 0;
}
function examScoresFromRow(row) {
const latest = Number(row?.score) || 0;
const best = Number(row?.bestScore) || 0;
const display = Math.max(latest, best);
return { latest, best, display };
}
async function waitForExamScoreAfterSubmit(ex, baselineScore, examToken) {
const base = Number(baselineScore) || 0;
let latest = base;
let best = base;
let display = base;
const token = String(examToken || "").trim();
const absorb = (row) => {
if (!row) return;
const s = examScoresFromRow(row);
latest = Math.max(latest, s.latest);
best = Math.max(best, s.best);
display = Math.max(display, s.display);
row.score = Math.max(Number(row.score) || 0, latest);
row.bestScore = Math.max(Number(row.bestScore) || 0, best, latest);
Object.assign(ex, row);
};
const pollList = async () => {
const list = await fetchExamList();
absorb(list.find((x) => String(x.courseId) === String(ex.courseId)));
};
const pollViewPaper = async () => {
if (!token) return 0;
try {
const html = await platformGetHtml(examViewUrl(ex, token, "top"));
const vp = parseViewPaperScore(html);
if (vp > 0) {
display = Math.max(display, vp);
latest = Math.max(latest, vp);
best = Math.max(best, vp);
ex.score = Math.max(Number(ex.score) || 0, vp);
ex.bestScore = Math.max(Number(ex.bestScore) || 0, vp);
}
return vp;
} catch (_) {
return 0;
}
};
await pollList();
if (token) await pollViewPaper();
const steps = [2000, 3000, 5000];
for (let i = 0; i < steps.length; i++) {
if (state.stopFlag) break;
if (display > base) break;
await sleep(steps[i]);
await pollList();
if (token) await pollViewPaper();
}
return { latest, best, display };
}
function countViewPaperStats(html) {
const cards = String(html || "").split(MY_WORK_DIV_OPEN_RE);
const cardCount = Math.max(0, cards.length - 1);
const refCount = String(html || "").split(/\u53c2\u8003\u7b54\u6848/i).length - 1;
const parsed = parseAfterAnswers(html);
return {
cardCount,
refCount,
parsedWithAns: parsed.filter((a) => a.answer_letter).length,
};
}
function parseExamPageMeta(html) {
const guid =
(html.match(/guid\s*:\s*['"]([a-f0-9]{32})['"]/i) || [])[1] ||
(html.match(/examtoken\s*[:=]\s*['"]([a-f0-9]{32})['"]/i) || [])[1] ||
"";
const ptidentity = (html.match(/ptidentity\s*:\s*['"]([a-f0-9]+)['"]/i) || [])[1] || "";
const tcode = (html.match(/tcode\s*:\s*['"]([a-z0-9]+)['"]/i) || [])[1] || "";
const course =
(html.match(/course\s*:\s*(\d+)/i) || [])[1] ||
(html.match(/CurriculumID[=:]['"]?(\d+)/i) || [])[1] ||
"";
return { guid, ptidentity, tcode, course };
}
function examCacheKey(ex) {
return String(ex.courseId || "");
}
function rememberExamAnswers(ex, items, opts) {
const key = examCacheKey(ex);
const replace = !!(opts && opts.replace);
if (replace) {
delete state.examAnswerCache[key];
delete state.examStemCache[key];
}
if (!state.examAnswerCache[key]) state.examAnswerCache[key] = {};
if (!state.examStemCache[key]) state.examStemCache[key] = {};
const bag = state.examAnswerCache[key];
const stemBag = state.examStemCache[key];
const order = [];
for (const it of items || []) {
const qid = String(it.qid || "").trim();
const letter = parseAnswerLetters(it.answer_letter || it.letter || "");
if (!letter) continue;
const sk = examStemKey(it.stem, it._card);
if (qid) bag[qid] = letter;
if (sk) stemBag[sk] = letter;
order.push({ qid, stem: sk, letter });
}
if (order.length) bag.__order = order;
writeExamCaches();
}
function rememberExamSoftAnswers(ex, answerMap, score) {
const key = examCacheKey(ex);
if (!state.examSoftCache[key]) state.examSoftCache[key] = {};
const bag = state.examSoftCache[key];
const prevScore = Number(bag.__score) || 0;
if (score < prevScore) return;
bag.__score = score;
for (const [qid, val] of Object.entries(answerMap || {})) {
const letter = parseAnswerLetters(String(val || "").replace(/,/g, ""));
if (qid && letter) bag[String(qid)] = letter;
}
writeExamCaches();
}
function answersFromExamCache(ex, questions, opts) {
const includeSoft = !!(opts && opts.includeSoft);
const softMinScore =
opts && opts.softMinScore != null ? Number(opts.softMinScore) : EXAM_SOFT_CACHE_MIN_SCORE;
const key = examCacheKey(ex);
const hard = state.examAnswerCache[key] || {};
const stemBag = state.examStemCache[key] || {};
const order = Array.isArray(hard.__order) ? hard.__order : [];
const soft = state.examSoftCache[key] || {};
const softScore = Number(soft.__score) || 0;
const answers = {};
let qidHit = 0;
let stemHit = 0;
let orderHit = 0;
let softHit = 0;
for (let i = 0; i < (questions || []).length; i++) {
const q = questions[i];
const qid = String(q.qid || "");
let letter = parseAnswerLetters(hard[qid] || "");
if (letter) qidHit += 1;
if (!letter) {
letter = lookupStemAnswer(stemBag, q.stem);
if (letter) stemHit += 1;
}
if (!letter && order[i]?.letter) {
const o = order[i];
const qStem = normalizeStem(q.stem);
const stemOk =
o.stem &&
qStem &&
(o.stem === qStem || (imgStemPrimary(o.stem) && imgStemPrimary(o.stem) === imgStemPrimary(qStem)));
if (stemOk) {
letter = parseAnswerLetters(o.letter);
if (letter) orderHit += 1;
}
}
if (!letter && includeSoft && softScore >= softMinScore) {
letter = parseAnswerLetters(soft[qid] || "");
if (letter) softHit += 1;
}
if (!letter) continue;
answers[q.qid] = q.multi ? [...letter].join(",") : letter[0];
}
const hardHit = qidHit + stemHit + orderHit;
return {
answers,
hit: hardHit + softHit,
hardHit,
qidHit,
stemHit,
orderHit,
softHit,
softScore,
};
}
function clearExamSoftCacheForHidden(ex) {
const key = examCacheKey(ex);
const hard = state.examAnswerCache[key] || {};
const bag = state.examSoftCache[key];
if (!bag) return 0;
let n = 0;
for (const k of Object.keys(bag)) {
if (k === "__score") continue;
if (!hard[k]) {
delete bag[k];
n += 1;
}
}
if (n) delete bag.__score;
writeExamCaches();
return n;
}
function shortStemText(q, maxLen) {
const n = maxLen || 30;
const s = String(q?.stem || q?.qid || "").trim();
if (!s) return `qid:${q?.qid || "?"}`;
return s.length > n ? s.slice(0, n) + "…" : s;
}
function logExamSubmitPlan(_ex, _questions, _answers, _sources) {
trace("exam submit plan");
}
async function harvestExamAnswers(ex, examToken) {
const best = await fetchBestExamViewPaper(ex, examToken);
const viewStats = best.stats || countViewPaperStats(best.html);
const viewPaperScore = Number(best.viewPaperScore) || parseViewPaperScore(best.html);
const answers = best.answers?.length
? best.answers
: parseAfterAnswers(best.html).filter((a) => a.answer_letter && (isUsableStem(a.stem) || a.qid));
if (!answers.length) {
trace(`\u8003\u8bd5\u300c${ex.courseTitle}\u300d\u67e5\u770b\u8bd5\u5377\u672a\u89e3\u6790\u5230\u7b54\u6848\uff0c\u5df2\u8df3\u8fc7\u5165\u5e93`);
return { saved: 0, viewPaperScore };
}
const replaceCache =
viewStats.parsedWithAns >= 25 &&
(viewStats.parsedWithAns >= Math.max(0, viewStats.cardCount - 3) ||
viewStats.cardCount >= 25);
rememberExamAnswers(ex, answers, { replace: replaceCache });
const enriched = answers
.map((a) => {
const stem = harvestItemStem(a);
if (!stem || !a.answer_letter) return null;
return {
stem,
answer_letter: a.answer_letter,
answer_text: answerTextForUpsert(a._card, a.answer_letter, a.answer_text),
paper_name: `${ex.courseTitle}-\u8003\u8bd5`,
cour_no: ex.courseId,
source: "ls365_exam",
_card: a._card,
};
})
.filter(Boolean);
const letterOnly = enriched.filter((a) => /^__LETTER__/i.test(a.answer_text)).length;
const saved = enriched.length ? await cloudQbankUpsert(enriched) : 0;
const cached = examHardBagCount(state.examAnswerCache[examCacheKey(ex)] || {});
const stemCached = Object.keys(state.examStemCache[examCacheKey(ex)] || {}).length;
logExam(
`\u67e5\u770b\u8bd5\u5377\u5165\u5e93\u300c${ex.courseTitle}\u300d\u4e91\u5e93 +${saved}/${enriched.length}\uff0c\u672c\u673a\u9898\u53f7 ${cached} · \u9898\u5e72 ${stemCached}\uff08\u5377\u9762\u516c\u5e03\u7ea6 ${viewStats.parsedWithAns} \u9898${letterOnly ? `\uff0c${letterOnly} \u9898\u4ec5\u5b57\u6bcd` : ""}\uff09`
);
return { saved, viewPaperScore, viewStats };
}
async function enterExam(courseId) {
const r = await platformPost("/data/CurriculumWork.ashx", {
ac: "enterexam",
courseid: String(courseId),
}).catch(() => ({ json: null }));
return r;
}
async function exitExam(courseId) {
await platformPost("/data/CurriculumWork.ashx", { ac: "exitexam", courseid: String(courseId) }).catch(() => {});
}
async function fetchExamTakePageHtml(ex) {
const basePath = examPageUrl(ex);
await exitExam(ex.courseId);
await sleep(400);
await enterExam(ex.courseId);
await sleep(700);
let html = await platformGetHtml(basePath).catch(() => "");
let meta = parseExamPageMeta(html);
if (examPageHasQuestions(html) && meta.guid && meta.ptidentity && meta.tcode) {
return html;
}
try {
const viewHtml = await platformGetHtml(examViewUrl(ex, "", "top"));
const againM =
viewHtml.match(/class=["']againOne["'][\s\S]*?href=["']([^"']+)["']/i) ||
viewHtml.match(/href=["'](\/Examination\/student\/examing\.aspx[^"']+)["']/i);
if (againM) {
let path = String(againM[1] || "").trim();
if (/^https?:\/\//i.test(path)) path = path.replace(/^https?:\/\/[^/]+/i, "");
if (path) {
await exitExam(ex.courseId);
await sleep(300);
await enterExam(ex.courseId);
await sleep(700);
html = await platformGetHtml(path);
meta = parseExamPageMeta(html);
if (examPageHasQuestions(html) && meta.guid && meta.ptidentity && meta.tcode) {
return html;
}
}
}
} catch (_) {}
return html || "";
}
async function saveExamDraft(guid, answerMap) {
if (!guid) return;
const studentAnswers = {};
for (const [qid, val] of Object.entries(answerMap || {})) {
studentAnswers[qid] = String(val || "");
}
await platformPost("/data/CurriculumWork.ashx", {
ac: "SaveStudentPapersAnswer",
StudentPaperId: guid,
StudentAnswers: JSON.stringify(studentAnswers),
}).catch(() => {});
}
async function submitExam(meta, answerMap) {
const body = {
...answerMap,
ac: "examsave",
actype: "examination",
course: meta.course || "",
guid: meta.guid || "",
ptidentity: meta.ptidentity || "",
tcode: meta.tcode || "",
};
const r = await platformPost("/data/CurriculumWork.ashx", body);
if (r.json?.statu || r.json?.status) return r.json;
throw new Error(r.json?.msg || r.text || "\u8003\u8bd5\u63d0\u4ea4\u5931\u8d25");
}
function examPageHasQuestions(html) {
if (!html) return false;
if (/guid\s*:\s*['"][a-f0-9]{32}['"]/i.test(html) && /tcode\s*:/i.test(html)) return true;
if (/exam_question/i.test(html) && /type=["'](radio|checkbox)["']/i.test(html)) return true;
return false;
}
function fillRandomMissingAnswers(questions, answers, answerSources) {
let n = 0;
for (const q of questions || []) {
if (answers[q.qid]) continue;
answers[q.qid] = randomAnswerForQuestion(q);
if (answerSources) answerSources[q.qid] = "random";
n += 1;
}
return n;
}
function randomAnswerForQuestion(q) {
const opts = q?.options || [];
if (!opts.length) return "A";
if (q.multi) {
const letters = opts.map((o) => o.letter).filter(Boolean);
const n = Math.max(1, 1 + Math.floor(Math.random() * letters.length));
const picked = letters
.map((l) => ({ l, r: Math.random() }))
.sort((a, b) => a.r - b.r)
.slice(0, n)
.map((x) => x.l)
.sort();
return picked.join(",");
}
const idx = Math.floor(Math.random() * opts.length);
return opts[idx]?.letter || "A";
}
function ls365QuestionTypeLabel(q) {
if (q?.multi) return "\u591a\u9009\u9898";
const texts = (q?.options || []).map((o) => o.text).join("");
if ((q?.options || []).length <= 2 && /\u5bf9|\u9519|\u6b63\u786e|\u9519\u8bef|√|×/.test(texts)) return "\u5224\u65ad\u9898";
return "\u5355\u9009\u9898";
}
function buildLs365AiPrompt(q) {
const typeHint = ls365QuestionTypeLabel(q);
const stem = String(q?.stem || "").trim() || "\uff08\u89c1\u9009\u9879\uff09";
const optLines = (q?.options || []).map((o) => `${o.letter}. ${o.text || o.letter}`).join("\n");
const imgLines = (q?.imageUrls || [])
.concat((q?.options || []).map((o) => o.text).filter((t) => /^__IMG__/.test(String(t || ""))))
.map((u) => String(u).replace(/^__IMG__/i, ""))
.filter((u) => /^https?:\/\//i.test(u))
.slice(0, 6)
.map((u, i) => `\u56fe\u7247${i + 1}: ${u}`)
.join("\n");
return (
`\u4f60\u662f\u8003\u8bd5\u7b54\u9898\u52a9\u624b\u3002\u6839\u636e\u9898\u76ee\u9009\u51fa\u6700\u53ef\u80fd\u6b63\u786e\u7b54\u6848\uff0c\u53ea\u8fd4\u56deJSON\uff0c\u4e0d\u8981\u89e3\u91ca\u3002\n` +
`\u9898\u578b\uff1a${typeHint}\n\u9898\u76ee\uff1a\n${stem}\n` +
(imgLines ? `\u9898\u76ee\u56fe\u7247\uff1a\n${imgLines}\n` : "") +
`\u9009\u9879\uff1a\n${optLines}\n` +
`\u8fd4\u56de\u683c\u5f0f\uff1a{"answer":"A"}\uff1b\u591a\u9009 {"answer":"A,C,D"}\uff1b\u5224\u65ad\u9898\u7528\u9009\u9879\u5b57\u6bcd\u3002\n` +
`\u65e0\u6cd5\u786e\u5b9a\u65f6\u4e5f\u5fc5\u987b\u7ed9\u51fa\u6700\u53ef\u80fd\u7b54\u6848\u3002`
);
}
function parseLs365AiAnswer(text, q) {
const raw = String(text || "").trim();
if (!raw) return "";
let ans = "";
try {
const j = JSON.parse(raw);
if (j?.answer != null) ans = String(j.answer).trim();
} catch (_) {}
if (!ans) {
const m = raw.match(/"answer"\s*:\s*"([^"]+)"/i);
if (m) ans = String(m[1] || "").trim();
}
if (!ans) ans = raw.slice(0, 80);
const valid = new Set((q?.options || []).map((o) => o.letter));
let letters = parseAnswerLetters(ans.replace(/,/g, ""));
letters = [...letters].filter((c) => valid.has(c)).sort().join("");
if (!letters && valid.size) {
for (const opt of q.options || []) {
if (opt.text && raw.includes(opt.text)) letters += opt.letter;
}
letters = parseAnswerLetters(letters);
}
return letters;
}
function deepseekHttp(body) {
return Promise.reject(new Error("use cloud exam-ai"));
}
async function deepseekAnswerQuestion(q) {
if (!state.deepseekEnabled || !isPro()) return { letter: "", raw: "" };
const prompt = buildLs365AiPrompt(q);
const images = (q?.imageUrls || [])
.concat((q?.options || []).map((o) => o.text).filter((t) => /^__IMG__/.test(String(t || ""))))
.map((u) => String(u).replace(/^__IMG__/i, ""))
.filter((u) => /^https?:\/\//i.test(u));
try {
let content = await cloudExamAiAnswer(prompt, images);
if (!content && images.length) {
await sleep(500);
content = await cloudExamAiAnswer(prompt, images);
}
if (!content) {
const err = String(state._examAiLastErr || "").trim();
return { letter: "", raw: err ? `cloud_err:${err.slice(0, 80)}` : "cloud_empty" };
}
let letter = parseLs365AiAnswer(content, q);
if (!letter && content) {
letter = parseLs365AiAnswer(content.replace(/[{}"]/g, " "), q);
}
return { letter, raw: content.slice(0, 120) };
} catch (e) {
trace(`DeepSeek \u7b54\u9898\u5931\u8d25 q${q?.qid}\uff1a${e.message || e}`);
return { letter: "", raw: String(e.message || e).slice(0, 80) };
}
}
async function fillMissingAnswersWithAi(ex, questions, answers, sources) {
if (!state.deepseekEnabled) return { filled: 0, answers, usedAi: false };
const missing = (questions || []).filter((q) => !answers[q.qid]);
if (!missing.length) return { filled: 0, answers, usedAi: false };
if (!isPro()) return { filled: 0, answers, usedAi: false };
logExam(`\u300c${ex.courseTitle}\u300d${missing.length} \u9898\u8c03\u7528\u4e91\u7aef AI\uff08Pro=${isPro()} AI\u5f00\u5173=${state.deepseekEnabled}\uff09`);
let filled = 0;
let aiFail = 0;
const upsertItems = [];
for (let i = 0; i < missing.length; i++) {
if (state.stopFlag) break;
const q = missing[i];
state.currentTask = `AI \u7b54\u9898 ${i + 1}/${missing.length}…`;
renderRunStatus();
const { letter, raw } = await deepseekAnswerQuestion(q);
if (!letter) {
aiFail += 1;
logExam(`AI \u672a\u7b54\u51fa q${q.qid}\uff08${shortStemText(q, 24)}\uff09raw=${String(raw || "").slice(0, 40)}`);
continue;
}
answers[q.qid] = q.multi ? [...letter].join(",") : letter[0];
if (sources) sources[q.qid] = "ai";
filled += 1;
logExam(`AI q${q.qid} → ${answers[q.qid]}\uff08${shortStemText(q, 20)}\uff09`);
if (isUsableStem(q.stem)) {
upsertItems.push({
stem: q.stem,
answer_letter: letter,
answer_text: answerTextForUpsert(q, letter, ""),
paper_name: `${ex.courseTitle}-\u8003\u8bd5AI`,
cour_no: ex.courseId,
source: "ls365_ai",
});
}
if (i < missing.length - 1) await sleep(400);
}
if (upsertItems.length) {
await cloudQbankUpsert(upsertItems).catch(() => 0);
}
logExam(`\u300c${ex.courseTitle}\u300dAI \u5b8c\u6210\uff1a\u6210\u529f ${filled}/${missing.length}\uff0c\u5931\u8d25 ${aiFail}`);
return { filled, answers, usedAi: filled > 0 };
}
function examSubmitWaitMs() {
return (
EXAM_SUBMIT_WAIT_MIN_MS +
Math.floor(Math.random() * (EXAM_SUBMIT_WAIT_MAX_MS - EXAM_SUBMIT_WAIT_MIN_MS + 1))
);
}
async function waitBeforeExamSubmit(meta, answers) {
const total = examSubmitWaitMs();
logExam(`\u7b49\u5f85 ${Math.round(total / 1000)} \u79d2\u540e\u4ea4\u5377\uff08\u6a21\u62df\u7528\u65f6\uff09`);
let draftAt = 0;
const ok = await waitInterruptible(total, (elapsed) => {
state.currentTask = `\u8003\u8bd5\u4e2d ${Math.round(elapsed / 1000)}/${Math.round(total / 1000)}s…`;
renderRunStatus();
if (meta?.guid && elapsed - draftAt >= 8000) {
draftAt = elapsed;
void saveExamDraft(meta.guid, answers);
}
});
if (ok && meta?.guid) await saveExamDraft(meta.guid, answers);
return ok;
}
async function harvestExamIfViewable(ex, examToken) {
const key = examCacheKey(ex);
const before = examHardBagCount(state.examAnswerCache[key] || {});
const raw = await harvestExamAnswers(ex, examToken || "").catch(() => ({ saved: 0, viewPaperScore: 0 }));
const saved = Number(raw?.saved) || 0;
const viewPaperScore = Number(raw?.viewPaperScore) || 0;
const after = examHardBagCount(state.examAnswerCache[key] || {});
return { saved, before, after, hasAnswers: after > 0 || saved > 0, viewPaperScore };
}
function examScoreSatisfied(score) {
return Number(score) >= EXAM_ACCEPT_SCORE;
}
function examTrustedFullCoverage(questions, sources) {
const trusted = new Set(["hard", "qbank", "hw", "ai", "soft"]);
return (
(questions || []).length > 0 &&
(questions || []).every((q) => {
const s = sources[q.qid];
return s && trusted.has(s) && s !== "random";
})
);
}
function examRoundFullyCovered(questions, answers) {
return (questions || []).length > 0 && (questions || []).every((q) => !!answers[q.qid]);
}
async function runOneExam(ex) {
state.currentCourse = ex.courseTitle;
state.currentChapter = "\u8003\u8bd5";
state.currentTask = "\u5904\u7406\u8003\u8bd5…";
state._examAiErrLogged = false;
renderRunStatus();
if (!isPro()) throw new Error("\u8003\u8bd5\u9700 Pro\uff08\u514d\u8d39\u4ec5\u4f53\u9a8c\u8bfe\u65f6\uff09");
const bestScore = Math.max(Number(ex.score) || 0, Number(ex.bestScore) || 0);
logExam(
`\u8fdb\u5165\u300c${ex.courseTitle}\u300d\uff1a\u5f53\u524d ${bestScore} \u5206\uff0c\u76ee\u6807 ≥${EXAM_ACCEPT_SCORE}\uff0cAI=${state.deepseekEnabled ? "\u5f00" : "\u5173"}\uff0c\u6b21\u6570 ${ex.takeTimes || 0}/${ex.permitCount || "?"}`
);
if (examScoreSatisfied(bestScore)) {
await harvestExamIfViewable(ex, "").catch(() => ({ saved: 0 }));
logExam(`\u8df3\u8fc7\uff1a\u5df2\u8fbe\u53ef\u63a5\u53d7\u5206\u6570 ${bestScore}\uff08≥${EXAM_ACCEPT_SCORE}\uff09`);
return { skipped: true, done: true, score: bestScore, submitted: false };
}
if (bestScore >= EXAM_TARGET_SCORE) {
await harvestExamIfViewable(ex, "").catch(() => ({ saved: 0 }));
logExam(`\u8df3\u8fc7\uff1a\u5df2\u6ee1\u5206 ${bestScore}`);
return { skipped: true, done: true, score: bestScore, submitted: false };
}
if (bestScore > 0 || ex.takeTimes > 0) {
const pre = await harvestExamIfViewable(ex, "").catch(() => ({ saved: 0, hasAnswers: false }));
if (pre.hasAnswers) {
trace(
`\u300c${ex.courseTitle}\u300d\u8003\u524d\u5df2\u4ece\u67e5\u770b\u8bd5\u5377\u5165\u5e93\uff08\u672c\u673a\u53c2\u8003\u7b54\u6848 ${pre.after} \u9898` +
`${pre.saved ? `\uff0c\u672c\u6b21\u4e91\u5e93 +${pre.saved}` : ""}\uff09`
);
}
if (bestScore > 0 && bestScore < EXAM_ACCEPT_SCORE) {
const sk = examCacheKey(ex);
if (state.examSoftCache[sk]) {
delete state.examSoftCache[sk];
writeExamCaches();
}
}
if (bestScore > 0) {
trace(
`\u8003\u8bd5\u300c${ex.courseTitle}\u300d\u5f53\u524d ${bestScore} \u5206\uff0c\u672a\u8fbe ${EXAM_ACCEPT_SCORE} \u5c06\u5c1d\u8bd5\u91cd\u8003\uff08\u53ef\u7528 ${ex.permitCount || "?"} \u6b21\uff0c\u4e0d\u8ffd\u6c42\u6ee1\u5206\uff09`
);
}
}
if (!ex.allowExam && bestScore <= 0) {
trace(`\u8003\u8bd5\u300c${ex.courseTitle}\u300d\u5f53\u524d\u4e0d\u53ef\u8003\uff0c\u8df3\u8fc7`);
return { skipped: true, done: false, score: bestScore, submitted: false, reason: "\u4e0d\u53ef\u8003" };
}
let best = bestScore;
let lastToken = "";
let stagnantRounds = 0;
let forceAiRetry = false;
let didSubmit = false;
let stopReason = "";
for (let round = 1; round <= EXAM_MAX_ROUNDS; round++) {
if (state.stopFlag) break;
if (examScoreSatisfied(best)) break;
if (best >= EXAM_TARGET_SCORE) break;
try {
const freshList = await fetchExamList();
const freshEx = freshList.find((x) => String(x.courseId) === String(ex.courseId));
if (freshEx) Object.assign(ex, freshEx);
} catch (_) {}
if (ex.permitCount > 0 && ex.takeTimes >= ex.permitCount && best > 0 && !ex.allowExam) {
stopReason = `\u6b21\u6570\u5df2\u7528\u5b8c ${ex.takeTimes}/${ex.permitCount}`;
trace(`\u300c${ex.courseTitle}\u300d${stopReason}`);
break;
}
state.currentTask = `\u8003\u8bd5\u7b2c ${round} \u8f6e…`;
renderRunStatus();
await ensureStudentId().catch(() => {});
const html = await fetchExamTakePageHtml(ex);
if (!examPageHasQuestions(html)) {
stopReason = "\u672a\u80fd\u6253\u5f00\u91cd\u8003\u7b54\u9898\u9875";
trace(`\u300c${ex.courseTitle}\u300d${stopReason}\uff0c\u8bf7\u624b\u52a8\u70b9\u300c\u91cd\u65b0\u8003\u8bd5\u300d`);
await exitExam(ex.courseId);
break;
}
const meta = parseExamPageMeta(html);
if (!meta.guid || !meta.ptidentity || !meta.tcode) {
stopReason = "\u91cd\u8003\u9875\u7f3a\u5c11\u4ea4\u5377\u53c2\u6570";
trace(`\u300c${ex.courseTitle}\u300d${stopReason}\uff0c\u672a\u4ea4\u5377`);
await exitExam(ex.courseId);
break;
}
if (!meta.course) meta.course = ex.courseId;
const questions = parseHomeworkQuestions(html);
if (!questions.length) {
trace(`\u300c${ex.courseTitle}\u300d\u7b2c ${round} \u8f6e\u672a\u89e3\u6790\u5230\u9898\u76ee`);
await exitExam(ex.courseId);
break;
}
const paperInfo = parseExamPaperInfo(html);
const hardCachedN = examHardBagCount(state.examAnswerCache[examCacheKey(ex)] || {});
const stemCachedN = Object.keys(state.examStemCache[examCacheKey(ex)] || {}).length;
const paperTotal = paperInfo.cardTotal || questions.length;
if (paperTotal !== questions.length) {
trace(`\u300c${ex.courseTitle}\u300d\u7b54\u9898\u5361 ${paperTotal} \u9898\uff0c\u89e3\u6790\u5230 ${questions.length} \u9898`);
}
if (paperInfo.sections.length) {
const secTxt = paperInfo.sections.map((s) => `${s.name}${s.count}`).join("·");
trace(`\u300c${ex.courseTitle}\u300d\u5377\u9762\u7ed3\u6784\uff1a${secTxt}\uff08\u5171 ${paperTotal} \u9898\uff09`);
}
if ((hardCachedN > 0 || stemCachedN > 0) && paperTotal > Math.max(hardCachedN, stemCachedN)) {
trace(
`\u300c${ex.courseTitle}\u300d\u6821\u65b9\u67e5\u770b\u8bd5\u5377\u7ea6\u516c\u5e03 ${Math.max(hardCachedN, stemCachedN)} \u53c2\u8003\u7b54\u6848\uff08\u9898\u53f7 ${hardCachedN} · \u9898\u5e72 ${stemCachedN}\uff09\uff1b\u91cd\u8003\u9875\u5168\u5377 ${paperTotal} \u9898`
);
}
const stems = questions.map((q) => q.stem).filter(Boolean);
const lookup = stems.length ? await cloudQbankLookup(stems) : [];
const answerSources = {};
const answers = {};
const cached = answersFromExamCache(ex, questions, { includeSoft: false });
for (const q of questions) {
const letter = cached.answers[q.qid];
if (!letter) continue;
answers[q.qid] = letter;
answerSources[q.qid] = "hard";
}
const qb = mapAnswersForQuestions(
questions.filter((q) => !answers[q.qid]),
lookup
);
let hit = 0;
for (const q of questions) {
if (answers[q.qid]) continue;
const letter = qb.answers[q.qid];
if (!letter) continue;
answers[q.qid] = letter;
answerSources[q.qid] = "qbank";
hit += 1;
}
for (const bag of Object.values(state.hwAnswerCache || {})) {
for (const q of questions) {
if (answers[q.qid]) continue;
const letter = bag[String(q.qid)];
if (!letter) continue;
answers[q.qid] = q.multi ? [...letter].join(",") : letter[0];
answerSources[q.qid] = "hw";
}
}
const mergedHit = Object.keys(answers).length;
let missQids = questions.filter((q) => !answers[q.qid]).map((q) => q.qid);
const miss = missQids.length;
const softBag = state.examSoftCache[examCacheKey(ex)] || {};
const softScore = Number(softBag.__score) || 0;
let roundUsedAi = false;
if (forceAiRetry) {
const cleared = clearExamSoftCacheForHidden(ex);
forceAiRetry = false;
trace(`\u300c${ex.courseTitle}\u300d\u5df2\u6e05\u9664 ${cleared} \u9053\u4f4e\u5206\u8f6f\u7f13\u5b58\uff0c\u672c\u8f6e\u56de\u6539\u7528 AI`);
}
logExam(
`\u300c${ex.courseTitle}\u300d\u7b2c ${round} \u8f6e\u547d\u4e2d ${mergedHit}/${questions.length}\uff08\u4e91\u5e93 ${hit} · \u5b98\u65b9 ${cached.hardHit}` +
`${cached.qidHit ? ` \u9898\u53f7${cached.qidHit}` : ""}${cached.stemHit ? ` \u9898\u5e72${cached.stemHit}` : ""}${cached.orderHit ? ` \u987a\u5e8f${cached.orderHit}` : ""}` +
`${softScore >= EXAM_SOFT_CACHE_MIN_SCORE ? ` · \u8f6f\u7f13\u5b58 ${softScore} \u5206` : softScore > 0 ? ` · \u8df3\u8fc7\u4f4e\u5206\u8f6f\u7f13\u5b58 ${softScore} \u5206` : ""}\uff09`
);
if (missQids.length > 0) {
if (best > 0) {
if (missQids.length > 0 && state.deepseekEnabled) {
const aiRes = await fillMissingAnswersWithAi(ex, questions, answers, answerSources);
roundUsedAi = !!aiRes.usedAi;
missQids = questions.filter((q) => !answers[q.qid]).map((q) => q.qid);
}
const softCanFill =
missQids.length > 0 &&
softScore >= EXAM_SOFT_CACHE_MIN_SCORE &&
softScore >= best &&
missQids.every((id) => parseAnswerLetters(softBag[String(id)] || ""));
if (softCanFill) {
for (const qid of missQids) {
const q = questions.find((x) => String(x.qid) === String(qid));
const letter = parseAnswerLetters(softBag[String(qid)] || "");
if (q && letter) {
answers[q.qid] = q.multi ? [...letter].join(",") : letter[0];
answerSources[q.qid] = "soft";
}
}
missQids = questions.filter((q) => !answers[q.qid]).map((q) => q.qid);
logExam(`\u7528 ${softScore} \u5206\u8f6f\u7f13\u5b58\u8865\u5168 ${miss - missQids.length} \u9053\u7f3a\u9898`);
} else if (missQids.length > 0 && softScore > 0 && softScore < EXAM_SOFT_CACHE_MIN_SCORE) {
logExam(
`\u8df3\u8fc7 ${softScore} \u5206\u8f6f\u7f13\u5b58\uff08\u9700 ≥${EXAM_SOFT_CACHE_MIN_SCORE}\uff09\uff0c${missQids.length} \u9898${state.deepseekEnabled ? "\u5f85 AI" : "\u4ecd\u7f3a\u7b54\u6848"}`
);
}
if (missQids.length > 0) {
const rnd = fillRandomMissingAnswers(
questions.filter((q) => missQids.includes(q.qid)),
answers,
answerSources
);
if (rnd > 0) {
trace(
`\u300c${ex.courseTitle}\u300d${rnd} \u9898\u968f\u673a\u586b\u7b54\uff08\u5b98\u65b9 ${cached.hardHit}/${questions.length}\uff09`
);
}
missQids = [];
}
} else {
const rnd = fillRandomMissingAnswers(questions, answers, answerSources);
logExam(`\u9996\u8003\u7f3a ${miss} \u9898\uff0c\u968f\u673a\u4f5c\u7b54 ${rnd} \u9898\u540e\u4ea4\u5377\u770b\u7b54\u6848\u5165\u5e93`);
}
} else if (
mergedHit / questions.length < EXAM_RETAKE_MIN_HIT_RATIO &&
best > 0 &&
mergedHit < Math.min(questions.length, 25)
) {
stopReason = `\u547d\u4e2d\u8fc7\u4f4e ${mergedHit}/${questions.length}\uff0c\u672a\u4ea4\u5377`;
trace(`\u300c${ex.courseTitle}\u300d${stopReason}`);
await exitExam(ex.courseId);
break;
}
logExamSubmitPlan(ex, questions, answers, answerSources);
const trustedFull = examTrustedFullCoverage(questions, answerSources);
if (trustedFull && examRoundFullyCovered(questions, answers)) {
const aiN = Object.values(answerSources).filter((s) => s === "ai").length;
const hardN = Object.values(answerSources).filter((s) => s === "hard").length;
trace(
`\u300c${ex.courseTitle}\u300d\u5168\u5377 ${questions.length} \u9898\u5df2\u8986\u76d6\uff08\u5b98\u65b9${hardN} · AI${aiN} \u7b49\uff09\uff0c` +
`\u4ea4\u5377\u540e ≥${EXAM_ACCEPT_SCORE} \u5206\u5373\u505c\uff0c\u4e0d\u8ffd\u6c42\u6ee1\u5206`
);
}
await saveExamDraft(meta.guid, answers);
const canSubmit = await waitBeforeExamSubmit(meta, answers);
if (!canSubmit) {
await exitExam(ex.courseId);
break;
}
const submitRes = await submitExam(meta, answers);
didSubmit = true;
trace(`\u300c${ex.courseTitle}\u300d\u7b54\u5377\u5df2\u63d0\u4ea4\uff0c\u7b49\u5f85\u51fa\u5206`);
lastToken = String(submitRes?.guid || meta.guid || "");
const msgScore = parseExamSubmitMsgScore(submitRes?.msg);
await sleep(1500);
let harvestViewScore = 0;
if (lastToken) {
const hr = await harvestExamIfViewable(ex, lastToken).catch(() => ({
saved: 0,
viewPaperScore: 0,
}));
harvestViewScore = Number(hr.viewPaperScore) || 0;
}
const scoreBaseline = Math.max(best, bestScore);
const polled = await waitForExamScoreAfterSubmit(ex, scoreBaseline, lastToken);
const roundLatest = Math.max(
polled.latest,
harvestViewScore,
msgScore,
Number(ex.score) || 0
);
const roundBest = Math.max(polled.best, roundLatest, Number(ex.bestScore) || 0);
const roundScore = Math.max(roundLatest, roundBest);
ex.score = roundLatest;
ex.bestScore = roundBest;
if (harvestViewScore > 0 && harvestViewScore > polled.latest) {
trace(
`\u300c${ex.courseTitle}\u300d\u67e5\u770b\u8bd5\u5377\u51fa\u5206 ${harvestViewScore} \u5206\uff08\u8003\u8bd5\u5217\u8868\u63a5\u53e3\u4ecd\u4e3a ${polled.latest || "—"}\uff0c\u4ee5\u67e5\u770b\u8bd5\u5377\u4e3a\u51c6\uff09`
);
}
logExam(
`\u7b2c ${round} \u8f6e\u6210\u7ee9 \u672c\u6b21 ${roundLatest} · \u6700\u9ad8 ${roundBest}\uff08\u76ee\u6807 ≥${EXAM_ACCEPT_SCORE} / \u6ee1\u5206 ${EXAM_TARGET_SCORE}\uff09` +
`${roundUsedAi ? "\uff0c\u672c\u8f6e\u7528\u8fc7 AI" : ""}`
);
if (EXAM_ONE_SUBMIT_PER_START && bestScore > 0) {
best = Math.max(best, roundScore);
trace(`\u300c${ex.courseTitle}\u300d\u5df2\u6709\u6210\u7ee9\uff0c\u672c\u6b21\u4ec5\u4ea4\u5377\u4e00\u8f6e`);
break;
}
if (roundScore >= best && roundScore >= EXAM_SOFT_CACHE_MIN_SCORE) {
rememberExamSoftAnswers(ex, answers, roundScore);
} else if (roundScore >= best && roundScore < EXAM_SOFT_CACHE_MIN_SCORE) {
trace(`\u672c\u8f6e ${roundScore} \u5206\u672a\u8fbe\u8f6f\u7f13\u5b58\u95e8\u69db ${EXAM_SOFT_CACHE_MIN_SCORE}\uff0c\u4e0d\u5199\u5165\u4f4e\u5206\u7b54\u5377\u7f13\u5b58`);
}
if (roundScore < best) {
trace(`\u300c${ex.courseTitle}\u300d\u672c\u8f6e ${roundScore} \u4f4e\u4e8e\u5df2\u6709\u6700\u9ad8 ${best}\uff0c\u505c\u6b62\u91cd\u8003`);
break;
}
best = Math.max(best, roundScore);
if (examScoreSatisfied(best)) {
trace(
`\u300c${ex.courseTitle}\u300d${best} \u5206\u5df2\u8fbe\u6807\uff08≥${EXAM_ACCEPT_SCORE}\uff09` +
`${trustedFull ? "\uff0c\u5b98\u65b9/\u9898\u5e93/AI \u5df2\u8986\u76d6\u5168\u5377" : ""}\uff0c\u505c\u6b62\u91cd\u8003`
);
break;
}
if (best >= EXAM_TARGET_SCORE) {
trace(`\u300c${ex.courseTitle}\u300d\u8003\u8bd5\u5df2\u8fbe\u6ee1\u5206`);
break;
}
if (roundScore <= best && round > 1 && !examScoreSatisfied(best)) {
stagnantRounds += 1;
if (
stagnantRounds >= 2 &&
state.deepseekEnabled &&
!roundUsedAi
) {
forceAiRetry = true;
stagnantRounds = 0;
trace(`\u300c${ex.courseTitle}\u300d\u5206\u6570\u5361\u5728 ${best} \u4e14\u672a\u7528 AI\uff0c\u4e0b\u8f6e\u6e05\u9664\u4f4e\u5206\u8f6f\u7f13\u5b58\u6539 AI \u4f5c\u7b54`);
} else if (stagnantRounds >= 2) {
trace(`\u300c${ex.courseTitle}\u300d\u8fde\u7eed ${stagnantRounds} \u8f6e ${best} \u5206\u672a\u63d0\u5347\uff0c\u7ee7\u7eed\u91cd\u8003\u76f4\u81f3\u6b21\u6570\u4e0a\u9650`);
stagnantRounds = 0;
}
} else {
stagnantRounds = 0;
}
await sleep(1200);
}
return {
skipped: false,
done: examScoreSatisfied(best),
score: best,
latestScore: Math.max(Number(ex.score) || 0, best),
submitted: didSubmit,
reason: stopReason,
};
}
async function runExamForSelectedCourses(courses) {
if (!state.autoExam) {
trace("\u5df2\u5173\u95ed\u81ea\u52a8\u8003\u8bd5");
return;
}
if (!isPro()) {
trace("\u81ea\u52a8\u8003\u8bd5\u9700 Pro\uff0c\u5df2\u8df3\u8fc7");
return;
}
await ensureStudentId().catch(() => {});
let list = [];
try {
list = await fetchExamList();
} catch (e) {
trace("\u8003\u8bd5\u5217\u8868\u5931\u8d25\uff1a" + (e.message || e));
return;
}
const courseIds = new Set(courses.map((c) => String(c.courseId)));
const queue = list.filter(
(ex) =>
courseIds.has(String(ex.courseId)) ||
courses.some((c) => c.courseName && c.courseName === ex.courseTitle)
);
if (!queue.length) {
trace("\u6240\u9009\u8bfe\u7a0b\u6682\u65e0\u8003\u8bd5");
return;
}
await Promise.all(queue.map((ex) => processOneExam(ex)));
}
async function waitInterruptible(totalMs, onTick) {
const step = 1000;
let left = Math.max(0, totalMs);
while (left > 0 && !state.stopFlag) {
const slice = Math.min(step, left);
await sleep(slice);
left -= slice;
if (typeof onTick === "function") onTick(Math.max(0, totalMs - left), totalMs);
}
return !state.stopFlag;
}
async function studyOneVideo(chapter) {
state.currentChapter = chapter.title;
state.currentTask = "\u8bfb\u53d6\u8fdb\u5ea6/\u65f6\u957f…";
renderRunStatus();
let resolved;
try {
resolved = await resolveMaxDuration(chapter);
} catch (e) {
throw new Error(`\u83b7\u53d6\u65f6\u957f\u5931\u8d25\uff1a${e.message || e}`);
}
let maxDuration = resolved.duration || 0;
let prog = resolved.prog || { now: 0, count: 0, url: "" };
if (!prog || prog.count == null) {
try {
prog = await loadVideoProgress(chapter.courseId, chapter.videoId);
} catch (_) {
prog = { now: 0, count: 0, url: "" };
}
}
let now = Number(prog.now) || 0;
if (!(maxDuration > 0)) {
throw new Error(`\u300c${chapter.title}\u300d\u65e0\u6cd5\u83b7\u53d6\u89c6\u9891\u65f6\u957f\uff08\u76ee\u5f55\u4e3a\u7a7a\u4e14 m3u8 \u5931\u8d25\uff09`);
}
chapter.durationSec = maxDuration;
if (resolved.source) {
trace(`\u300c${chapter.title}\u300d\u65f6\u957f ${formatSec(maxDuration)}\uff08\u6765\u6e90\uff1a${resolved.source}\uff09`);
}
if (isVideoDone(now, maxDuration, chapter.percent)) {
trace(`\u8df3\u8fc7\u300c${chapter.title}\u300d\uff1a\u5df2\u5b66\u5b8c`);
return { skipped: true, done: true };
}
const realtime = isRealtime();
const remainSec = Math.max(1, maxDuration - now);
trace(
`\u5f00\u59cb\u300c${chapter.title}\u300d ${formatSec(now)}/${formatSec(maxDuration)} · ${studyModeLabel()}\uff08\u64ad\u5b8c\u518d\u4e0a\u62a5\uff09`
);
if (realtime) {
const waitMs = Math.round(remainSec * 1000);
const midAt = Math.floor(waitMs / 2);
let pinged = false;
state.currentTask = `\u89c2\u770b\u4e2d · \u5269\u4f59\u7ea6 ${formatSec(remainSec)}`;
renderRunStatus();
const ok = await waitInterruptible(waitMs, (elapsed) => {
if (!pinged && elapsed >= midAt) {
pinged = true;
state.currentTask = "\u4e2d\u9014\u4fdd\u6d3b Users.ashx…";
renderRunStatus();
void pingUserKeepAlive().then(() => {
if (!state.stopFlag) {
state.currentTask = `\u89c2\u770b\u4e2d · \u5269\u4f59\u7ea6 ${formatSec(Math.max(0, (waitMs - elapsed) / 1000))}`;
renderRunStatus();
}
});
} else if (elapsed % 10000 < 1000) {
state.currentTask = `\u89c2\u770b\u4e2d · \u5269\u4f59\u7ea6 ${formatSec(Math.max(0, (waitMs - elapsed) / 1000))}`;
renderRunStatus();
}
});
if (!ok) {
state.currentTask = "\u5df2\u505c\u6b62";
renderRunStatus();
return { skipped: false, done: false };
}
} else {
state.currentTask = "\u6548\u7387\u6a21\u5f0f · \u4e2d\u9014\u4fdd\u6d3b…";
renderRunStatus();
await sleep(EFF_MID_WAIT_MS);
if (state.stopFlag) return { skipped: false, done: false };
await pingUserKeepAlive();
await sleep(EFF_END_WAIT_MS);
if (state.stopFlag) return { skipped: false, done: false };
}
const currPlay = finishPlaySec(maxDuration);
state.currentTask = `\u64ad\u5b8c\u4e0a\u62a5 ${formatSec(currPlay)} / ${formatSec(maxDuration)}`;
renderRunStatus();
let playMeta = { limitToken: "" };
try {
const play = await fetchPlayPageMeta(chapter);
playMeta = play.meta || playMeta;
} catch (e) {
trace(`\u64ad\u653e\u9875\u9884\u8bfb\u5931\u8d25\uff1a${e.message || e}`);
}
let reported = false;
for (let tryNo = 0; tryNo < 3 && !state.stopFlag; tryNo++) {
try {
if (tryNo > 0) {
try {
const play = await fetchPlayPageMeta(chapter);
playMeta = play.meta || playMeta;
} catch (_) {}
}
await setLearningRecord({
videoId: chapter.videoId,
currPlay,
maxDuration,
courseId: chapter.courseId,
professionalId: chapter.professionalId,
rate: 0,
limitToken: playMeta.limitToken,
});
reported = true;
now = currPlay;
chapter.percent = 100;
break;
} catch (e) {
trace(`\u4e0a\u62a5\u5931\u8d25\uff1a${e.message || e}${tryNo < 2 ? "\uff0c\u91cd\u8bd5\u4e2d" : ""}`);
await sleep(2000);
}
}
if (reported) {
try {
prog = await loadVideoProgress(chapter.courseId, chapter.videoId);
now = prog.now || now;
if (prog.count > 0) maxDuration = prog.count;
chapter.percent = Math.min(100, Math.round((now / Math.max(maxDuration, 1)) * 100));
if (!isVideoDone(now, maxDuration, chapter.percent)) {
trace(`\u300c${chapter.title}\u300d\u8fdb\u5ea6\u672a\u8fbe\u6807\uff0c\u8865\u8bbf\u64ad\u653e\u9875\u540e\u91cd\u8bd5\u4e0a\u62a5`);
try {
const play = await fetchPlayPageMeta(chapter);
playMeta = play.meta || playMeta;
await setLearningRecord({
videoId: chapter.videoId,
currPlay,
maxDuration,
courseId: chapter.courseId,
professionalId: chapter.professionalId,
rate: 0,
limitToken: playMeta.limitToken,
});
prog = await loadVideoProgress(chapter.courseId, chapter.videoId);
now = prog.now || now;
chapter.percent = Math.min(100, Math.round((now / Math.max(maxDuration, 1)) * 100));
} catch (_) {}
}
} catch (_) {}
}
const done = reported || isVideoDone(now, maxDuration, chapter.percent);
trace(done ? `\u5b8c\u6210\u300c${chapter.title}\u300d` : `\u5df2\u505c\u6b62\u300c${chapter.title}\u300d`);
state.currentTask = done ? "\u672c\u8282\u5b8c\u6210" : "\u5df2\u505c\u6b62";
renderRunStatus();
return { skipped: false, done };
}
async function runQueue() {
if (state.runBusy) return;
setStudyMode("efficiency");
try {
await fetchUserInfo();
await ensureStudentId();
await ensureCloudLease(true);
} catch (e) {
log("\u542f\u52a8\u5931\u8d25\uff0c\u8bf7\u68c0\u67e5\u767b\u5f55\u4e0e\u6388\u6743");
return;
}
const queueCourses = state.courses.filter(
(c) => state.selected.has(courseKey(c)) && !isCourseFullyDone(c)
);
if (!queueCourses.length) {
log("\u6ca1\u6709\u672a\u5b8c\u6210\u7684\u52fe\u9009\u8bfe\u7a0b");
return;
}
state.enabled = true;
state.stopFlag = false;
state.runBusy = true;
state.queueDone = 0;
state.queueTotal = 0;
state.consumedCourseKeys = new Set();
state.concurrentRun = true;
syncStartStop();
renderRunStatus();
try {
let hwList = [];
let examList = [];
if (isPro()) {
if (state.autoHomework) {
try {
hwList = await fetchHomeworkList();
} catch (e) {
trace("\u4f5c\u4e1a\u5217\u8868\u5931\u8d25\uff1a" + (e.message || e));
}
}
if (state.autoExam) {
try {
examList = await fetchExamList();
} catch (e) {
trace("\u8003\u8bd5\u5217\u8868\u5931\u8d25\uff1a" + (e.message || e));
}
}
}
state.currentTask = "\u5904\u7406\u4e2d…";
renderRunStatus();
await Promise.all(queueCourses.map((course) => runOneCoursePipeline(course, hwList)));
if (!state.stopFlag && isPro() && state.autoExam) {
const hasExam = queueCourses.some((c) => examItemForCourse(c, examList));
if (hasExam) {
state.currentTask = "\u8003\u8bd5…";
renderRunStatus();
for (const course of queueCourses) {
if (state.stopFlag) break;
await runExamForCourse(course, examList);
}
}
}
log(state.stopFlag ? "\u5df2\u505c\u6b62" : "\u5168\u90e8\u5904\u7406\u5b8c\u6210");
await refreshCourseDisplay().catch(() => {});
} finally {
state.enabled = false;
state.runBusy = false;
state.concurrentRun = false;
state.currentTask = "\u5f85\u673a";
syncStartStop();
renderRunStatus();
updateSettingsPane();
}
}
function openProBuy() {
const url = state.proBuyUrl || PRO_BUY_URL;
try {
if (typeof GM_openInTab === "function") GM_openInTab(url, { active: true });
else window.open(url, "_blank", "noopener,noreferrer");
} catch (_) {
location.href = url;
}
}
function openProHint(message) {
const old = document.getElementById("ls365-dialog-root");
if (old) old.remove();
const root = document.createElement("div");
root.id = "ls365-dialog-root";
root.innerHTML = `
\u5f00\u901a\u4e13\u4e1a\u7248
${escHtml(message || "\u5f00\u901a\u4e13\u4e1a\u7248\u540e\u53ef\u5b66\u4e60\u5168\u90e8\u89c6\u9891\u3002")}
`;
root.addEventListener("click", (e) => {
const act = e.target?.closest?.("[data-act]")?.getAttribute("data-act");
if (act === "buy") openProBuy();
if (act === "buy" || act === "close") root.remove();
});
document.documentElement.appendChild(root);
}
function readPanelPos() {
try {
return JSON.parse(localStorage.getItem(PANEL_POS_KEY) || "null");
} catch (_) {
return null;
}
}
function writePanelPos(left, top) {
localStorage.setItem(PANEL_POS_KEY, JSON.stringify({ left, top }));
}
function readPanelCollapsed() {
return localStorage.getItem(PANEL_COLLAPSED_KEY) === "1";
}
function writePanelCollapsed(v) {
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
}
function applyPanelCollapsed(panel, collapsed) {
panel.classList.toggle("ls365-collapsed", !!collapsed);
const btnMin = document.getElementById("ls365-btn-min");
const btnMax = document.getElementById("ls365-btn-max");
if (btnMin) btnMin.style.display = collapsed ? "none" : "";
if (btnMax) btnMax.style.display = collapsed ? "" : "none";
}
function enablePanelDrag(panel) {
const header = panel.querySelector("#ls365-panel-header");
if (!header) return;
let dragging = false;
let sx = 0;
let sy = 0;
let ol = 0;
let ot = 0;
header.addEventListener("mousedown", (e) => {
if (e.target.closest("button")) return;
dragging = true;
const rect = panel.getBoundingClientRect();
sx = e.clientX;
sy = e.clientY;
ol = rect.left;
ot = rect.top;
panel.style.right = "auto";
panel.style.left = `${ol}px`;
panel.style.top = `${ot}px`;
e.preventDefault();
});
window.addEventListener("mousemove", (e) => {
if (!dragging) return;
const left = Math.max(0, ol + e.clientX - sx);
const top = Math.max(0, ot + e.clientY - sy);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
});
window.addEventListener("mouseup", () => {
if (!dragging) return;
dragging = false;
const rect = panel.getBoundingClientRect();
writePanelPos(rect.left, rect.top);
});
}
function injectPanelStyles() {
if (document.getElementById("ls365-panel-style")) return;
const style = document.createElement("style");
style.id = "ls365-panel-style";
style.textContent = `
#ls365-auto-panel{position:fixed;right:16px;top:72px;z-index:2147483000;width:360px;max-width:calc(100vw - 20px);background:#fff7ed;border:1px solid #fdba74;border-radius:14px;box-shadow:0 12px 40px rgba(154,52,18,.18);font:13px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;color:#7c2d12;overflow:hidden}
#ls365-auto-panel.ls365-collapsed{width:auto;min-width:0}
#ls365-auto-panel.ls365-collapsed #ls365-panel-body,.ls365-collapsed .ls365-footer-extra,#ls365-auto-panel.ls365-collapsed #ls365-panel-footer{display:none!important}
#ls365-panel-header{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding:10px 12px 8px;background:linear-gradient(135deg,#ffedd5,#fed7aa);cursor:move;user-select:none}
#ls365-panel-brand{display:flex;gap:8px;align-items:flex-start;min-width:0}
#ls365-panel-logo{width:34px;height:34px;border-radius:8px;object-fit:cover;flex:0 0 auto;background:#fff}
#ls365-panel-title{font-size:13px;font-weight:800;color:#9a3412;line-height:1.35}
.ls365-panel-version{margin-left:6px;font-size:11px;font-weight:600;color:#c2410c;opacity:.85}
#ls365-panel-sub{margin-top:2px;font-size:11px;color:#9a3412;opacity:.9}
.ls365-sub-chip{font-weight:700}
.ls365-sub-chip-em{color:#c2410c}
.ls365-sub-dot{margin:0 4px;opacity:.5}
#ls365-panel-controls{display:flex;gap:4px}
.ls365-panel-ctl{border:0;background:rgba(255,255,255,.7);width:26px;height:26px;border-radius:8px;cursor:pointer;color:#9a3412;font-size:14px;line-height:1}
#ls365-panel-body{padding:8px 10px 10px;max-height:min(72vh,640px);overflow:auto}
.ls365-card{background:#fff;border:1px solid #fed7aa;border-radius:12px;padding:8px 9px;margin-bottom:8px}
.ls365-status-row{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:4px}
.ls365-status-label{font-size:12px;color:#a16207}
#ls365-auto-status{font-weight:800;color:#9a3412}
.ls365-metric em{font-style:normal;font-weight:800;color:#c2410c}
.ls365-progress-bar{height:7px;background:#ffedd5;border-radius:99px;overflow:hidden;margin-top:4px}
#ls365-queue-progress{display:block;height:100%;width:0;background:linear-gradient(90deg,#fb923c,#ea580c);border-radius:99px;transition:width .25s}
.ls365-tabbar{display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap}
.ls365-tab-btn{flex:1;min-width:64px;border:1px solid #fdba74;background:#fff7ed;color:#9a3412;border-radius:8px;padding:5px 6px;font-size:12px;cursor:pointer;font-weight:700}
.ls365-tab-btn.active{background:#ea580c;border-color:#ea580c;color:#fff}
.ls365-pane{display:none}
.ls365-pane.active{display:block}
.ls365-list-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.ls365-list-title{font-weight:800;font-size:12px}
.ls365-list-tag{font-size:11px;color:#a16207}
#ls365-course-list,#ls365-chapter-preview,#ls365-run-log{max-height:220px;overflow:auto}
.ls365-course-item,.ls365-chapter-item{display:flex;gap:8px;align-items:flex-start;padding:6px 4px;border-bottom:1px dashed #fed7aa}
.ls365-course-item.ls365-course-done{opacity:.62;background:#f8fafc;border-radius:8px}
.ls365-course-item.ls365-course-done .ls365-course-name{color:#94a3b8;text-decoration:line-through;text-decoration-color:#cbd5e1}
.ls365-course-item.ls365-course-done .ls365-score-chip{background:#e2e8f0;border-color:#cbd5e1;color:#64748b}
.ls365-done-tag{font-size:10px;font-weight:700;color:#16a34a;text-decoration:none}
.ls365-course-actions{display:flex;gap:6px;margin-bottom:6px;flex-wrap:wrap}
.ls365-course-actions button{font-size:11px;padding:4px 10px;border-radius:8px;border:1px solid #fdba74;background:#fff7ed;color:#9a3412;font-weight:700;cursor:pointer}
.ls365-course-actions button:hover{background:#ffedd5}
.ls365-course-item:last-child,.ls365-chapter-item:last-child{border-bottom:0}
.ls365-course-main{min-width:0;flex:1}
.ls365-course-name{font-weight:700;font-size:12px;word-break:break-all}
.ls365-course-meta,.ls365-chapter-meta{font-size:11px;color:#a16207;margin-top:2px}
.ls365-empty-state{padding:16px 8px;text-align:center;color:#a16207;font-size:12px}
.ls365-log-row{font-size:11px;padding:3px 0;border-bottom:1px dashed #ffedd5;word-break:break-all}
.ls365-meta-row{display:flex;align-items:center;gap:8px;margin:5px 0;font-size:12px}
.ls365-meta-label{flex:0 0 72px;color:#a16207}
.ls365-meta-value{flex:1;min-width:0;word-break:break-all;font-weight:700}
.ls365-auth-badge{color:#c2410c}
.ls365-plan-select, #ls365-cloud-token{flex:1;border:1px solid #fdba74;border-radius:8px;padding:5px 7px;font-size:12px;background:#fff}
.ls365-btn-row{display:flex;gap:8px;margin-top:8px}
.ls365-btn{border:0;border-radius:10px;padding:8px 10px;font-size:13px;font-weight:800;cursor:pointer}
.ls365-btn-start{flex:1;background:#ea580c;color:#fff}
.ls365-btn-stop{flex:1;background:#9a3412;color:#fff}
.ls365-btn-off,.ls365-btn:disabled{opacity:.45;cursor:not-allowed}
.ls365-btn-ghost{background:#ffedd5;color:#9a3412;border:1px solid #fdba74}
.ls365-btn-pro{background:linear-gradient(135deg,#f59e0b,#ea580c);color:#fff}
.ls365-course-scores{display:flex;flex-wrap:wrap;gap:4px;margin-top:4px}
.ls365-score-chip{font-size:10px;font-weight:700;padding:2px 6px;border-radius:6px;background:#ffedd5;color:#9a3412;border:1px solid #fed7aa}
.ls365-score-chip-ex{background:#fff7ed;color:#c2410c}
.ls365-settings-card{padding:10px 11px}
.ls365-settings-pro{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:10px 12px;border-radius:10px;background:linear-gradient(135deg,#ffedd5,#fed7aa);margin-bottom:10px}
.ls365-settings-pro-title{font-size:13px;font-weight:800;color:#9a3412}
.ls365-settings-pro-sub{font-size:11px;color:#a16207;margin-top:2px}
.ls365-token-row{display:flex;gap:6px;align-items:center}
.ls365-token-row input{flex:1}
.ls365-settings-tip{font-size:11px;color:#a16207;line-height:1.5;margin-top:8px;padding:8px;border-radius:8px;background:#fff7ed;border:1px dashed #fdba74}
.ls365-start-hint{color:#b91c1c;font-size:12px;margin:4px 0}
.ls365-footer-extra{padding:0 10px 8px}
.ls365-ann{background:#fff;border:1px solid #fed7aa;border-radius:10px;padding:7px 8px;font-size:11px;margin-bottom:6px}
#ls365-panel-footer{padding:6px 12px 10px;font-size:11px;color:#a16207;border-top:1px solid #fed7aa;background:#fff7ed}
.ls365-log-clear-btn{border:0;background:#ffedd5;color:#9a3412;border-radius:6px;padding:2px 7px;font-size:11px;cursor:pointer}
#ls365-dialog-root{position:fixed;inset:0;z-index:2147483646;display:flex;align-items:center;justify-content:center}
.ls365-dialog-mask{position:absolute;inset:0;background:rgba(0,0,0,.35)}
.ls365-dialog-card{position:relative;width:min(360px,92vw);background:#fff;border-radius:14px;padding:14px;border:1px solid #fdba74;box-shadow:0 16px 40px rgba(0,0,0,.2)}
.ls365-dialog-head{font-weight:800;color:#9a3412;margin-bottom:8px}
.ls365-dialog-body{font-size:13px;color:#7c2d12;margin-bottom:12px;line-height:1.5}
.ls365-dialog-actions{display:flex;gap:8px;justify-content:flex-end}
`;
document.documentElement.appendChild(style);
}
function switchTab(tab) {
document.querySelectorAll(".ls365-tab-btn").forEach((b) => {
b.classList.toggle("active", b.dataset.tab === tab);
});
document.querySelectorAll(".ls365-pane").forEach((p) => {
p.classList.toggle("active", p.dataset.pane === tab);
});
if (tab === "course") void refreshCourseDisplay();
}
function syncStartStop() {
const start = document.getElementById("ls365-start");
const stop = document.getElementById("ls365-stop");
if (start) {
start.disabled = !!state.runBusy;
start.classList.toggle("ls365-btn-off", !!state.runBusy);
}
if (stop) {
stop.disabled = !state.runBusy;
stop.classList.toggle("ls365-btn-off", !state.runBusy);
}
}
function renderRunStatus() {
const st = document.getElementById("ls365-auto-status");
if (st) st.textContent = state.runBusy ? "\u8fd0\u884c\u4e2d" : "\u5df2\u505c\u6b62";
const done = document.getElementById("ls365-queue-done");
const total = document.getElementById("ls365-queue-total");
const pct = document.getElementById("ls365-queue-percent");
const bar = document.getElementById("ls365-queue-progress");
const qtext = document.getElementById("ls365-queue-text");
if (done) done.textContent = String(state.queueDone);
if (total) total.textContent = String(state.queueTotal);
const p =
state.queueTotal > 0 ? Math.min(100, Math.round((state.queueDone / state.queueTotal) * 100)) : 0;
if (pct) pct.textContent = `${p}%`;
if (bar) bar.style.width = `${p}%`;
if (qtext) {
const base = state.selected.size ? `\u5df2\u9009 ${state.selected.size} \u95e8\u8bfe` : "\u672a\u9009\u62e9\u8bfe\u7a0b";
qtext.textContent = base;
}
}
function renderCourseList() {
const box = document.getElementById("ls365-course-list");
if (!box) return;
if (!state.courses.length) {
box.innerHTML = `\u767b\u5f55\u540e\u5c06\u81ea\u52a8\u52a0\u8f7d\u8bfe\u7a0b
`;
return;
}
box.innerHTML = state.courses
.map((c) => {
const key = courseKey(c);
const done = isCourseFullyDone(c);
const checked = !done && state.selected.has(key) ? "checked" : "";
const disabled = done ? "disabled" : "";
const prog = Math.round(c.studyProgress || 0);
const hw = formatCourseScore(c.hwScore, c.hwScoreKnown);
const ex = formatCourseScore(c.examScore, c.examScoreKnown);
const itemCls = done ? "ls365-course-item ls365-course-done" : "ls365-course-item";
return ``;
})
.join("");
box.querySelectorAll("input[type=checkbox]").forEach((el) => {
el.addEventListener("change", () => {
if (el.disabled) {
el.checked = false;
return;
}
localStorage.setItem(QUEUE_TOUCHED_KEY, "1");
const key = el.getAttribute("data-ckey");
if (el.checked) state.selected.add(key);
else state.selected.delete(key);
saveQueue();
renderRunStatus();
void refreshChapterPreview();
});
});
}
function renderChapterPreview() {
const box = document.getElementById("ls365-chapter-preview");
if (!box) return;
if (!state.chapters.length) {
box.innerHTML = `\u8bf7\u52fe\u9009\u8bfe\u7a0b\u540e\u67e5\u770b\u7ae0\u8282
`;
return;
}
box.innerHTML = state.chapters
.map((ch) => {
const done = (ch.percent || 0) >= 85;
const meta = [];
if (ch.hoursLabel) meta.push(escHtml(ch.hoursLabel));
meta.push(`${ch.percent || 0}%`);
if (done) meta.push("\u5df2\u5b8c\u6210");
return `
${escHtml(ch.title)}
${meta.join(" · ")}
`;
})
.join("");
}
async function refreshChapterPreview() {
const picked = state.courses.filter((c) => state.selected.has(courseKey(c)));
if (!picked.length) {
state.chapters = [];
renderChapterPreview();
return;
}
const course = picked[0];
state.previewCourseId = course.courseId;
try {
state.chapters = await fetchCourseChapters(course);
} catch (e) {
state.chapters = [];
trace("\u7ae0\u8282\u9884\u89c8\u5931\u8d25\uff1a" + (e.message || e));
}
renderChapterPreview();
}
function updateSettingsPane() {
const tier = document.getElementById("ls365-cloud-tier");
const free = document.getElementById("ls365-cloud-free");
const token = document.getElementById("ls365-cloud-token");
if (tier) {
tier.textContent = isPro()
? state.cloudProExpireAt
? `Pro · \u81f3 ${new Date(state.cloudProExpireAt * 1000).toLocaleDateString()}`
: "Pro \u4f1a\u5458"
: state.cloudTier === "unknown"
? "\u6821\u9a8c\u4e2d…"
: "\u514d\u8d39\u7528\u6237";
}
if (free) free.textContent = `${state.freeUsedCourses}/${state.freeCourseLimit} \u95e8\u514d\u8d39\u8bfe\u65f6`;
if (token && token !== document.activeElement) token.value = state.cloudToken || "";
}
function createPanel() {
const old = document.getElementById("ls365-auto-panel");
if (old) old.remove();
injectPanelStyles();
const panel = document.createElement("div");
panel.id = "ls365-auto-panel";
panel.innerHTML = `
\u8fd0\u884c\u72b6\u6001
\u5df2\u505c\u6b62
0/0 \u89c6\u9891\u5df2\u5b66\u4e60
0%
\u8bfe\u7a0b\u9009\u62e9\u8fdb\u5ea6100·\u4f5c\u4e1a100·\u8003\u8bd580+\u4e3a\u5df2\u5b8c\u6210
\u7ae0\u8282\u9884\u89c8\u9996\u4e2a\u5df2\u9009\u8bfe\u7a0b
\u8bf7\u9009\u62e9\u8bfe\u7a0b
\u8fd0\u884c\u65e5\u5fd7
\u514d\u8d39\u7528\u6237
\u514d\u8d39\u8bfe\u65f6 0/1
\u6388\u6743\u7801
\u52fe\u9009\u8bfe\u7a0b\u540e\u70b9\u300c\u5f00\u59cb\u300d\uff0c\u5c06\u81ea\u52a8\u5b8c\u6210\u8bfe\u65f6\u3001\u4f5c\u4e1a\u4e0e\u8003\u8bd5\uff08\u6548\u7387\u6a21\u5f0f\uff09\u3002
`;
document.body.appendChild(panel);
const savedPos = readPanelPos();
if (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);
document.getElementById("ls365-btn-min")?.addEventListener("click", (e) => {
e.stopPropagation();
writePanelCollapsed(true);
applyPanelCollapsed(panel, true);
});
document.getElementById("ls365-btn-max")?.addEventListener("click", (e) => {
e.stopPropagation();
writePanelCollapsed(false);
applyPanelCollapsed(panel, false);
});
panel.querySelectorAll(".ls365-tab-btn").forEach((btn) => {
btn.addEventListener("click", () => switchTab(btn.dataset.tab));
});
document.getElementById("ls365-clear-log")?.addEventListener("click", clearRunLog);
document.getElementById("ls365-select-unfinished")?.addEventListener("click", selectUnfinishedCourses);
document.getElementById("ls365-clear-selection")?.addEventListener("click", clearCourseSelection);
document.getElementById("ls365-start")?.addEventListener("click", () => {
setStudyMode("efficiency");
void runQueue();
});
document.getElementById("ls365-stop")?.addEventListener("click", () => {
state.stopFlag = true;
log("\u5df2\u505c\u6b62");
renderRunStatus();
});
document.getElementById("ls365-cloud-save")?.addEventListener("click", async () => {
const v = String(document.getElementById("ls365-cloud-token")?.value || "").trim();
state.cloudToken = v;
localStorage.setItem(CLOUD_TOKEN_KEY, v);
try {
await ensureCloudLease(true);
log(v ? "\u6388\u6743\u5df2\u4fdd\u5b58" : "\u5df2\u6e05\u9664\u6388\u6743");
} catch (e) {
state.cloudTier = v ? "unknown" : "free";
log("\u6388\u6743\u6821\u9a8c\u5931\u8d25");
}
updateSettingsPane();
});
document.getElementById("ls365-open-pro")?.addEventListener("click", openProBuy);
syncStartStop();
renderRunStatus();
updateSettingsPane();
}
async function boot() {
createPanel();
setStudyMode("efficiency");
try {
await fetchUserInfo();
await ensureStudentId().catch(() => {});
await refreshCourseDisplay();
try {
await ensureCloudLease(!!state.cloudToken);
} catch (e) {
trace("cloud: " + (e.message || e));
}
updateSettingsPane();
void refreshPanelNotice();
startCourseAutoRefresh();
log("\u5df2\u5c31\u7eea");
} catch (e) {
log("\u8bf7\u5148\u767b\u5f55\u4e50\u5b66\u5e73\u53f0");
renderCourseList();
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => void boot());
} else {
void boot();
}
})();