// ==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 = `
    \u826f\u5e08\u5728\u7ebf·\u4e50\u5b66LS365\u5b66\u4e60\u52a9\u624bv${SCRIPT_VERSION}
    \u4e00\u952e\u5b8c\u6210\u89c6\u9891\u5b66\u65f6·\u7701\u65f6\u7701\u5fc3
    \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
    \u52a0\u8f7d\u4e2d…
    \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(); } })();